From a497aa432fbbd5510c6d8d12f87b8cd4696d624d Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Mon, 20 Jul 2026 18:16:38 +0200 Subject: [PATCH] fix: bound Athena resource use and freeze paths --- .github/PULL_REQUEST_TEMPLATE.md | 14 + .github/workflows/pr-checks.yml | 58 ++ README.md | 48 +- backend/adapters/grok.py | 5 +- backend/agent_sessions.py | 883 ++++++++++++++---- backend/app.py | 176 ++-- backend/executor.py | 233 ++++- backend/memory_admission.py | 255 +++++ client/electron/agent-sessions.ts | 384 +++----- client/electron/backend.ts | 79 +- client/electron/control-server.ts | 209 ++++- client/electron/embedded-terminal.ts | 518 +++++++--- client/electron/graphics-state.ts | 231 +++++ client/electron/hermes-session-index.ts | 635 +++++++++++++ client/electron/ipc-handlers.ts | 187 +++- client/electron/launch-admission.ts | 310 ++++++ client/electron/main.ts | 121 ++- client/electron/memory-guard.ts | 13 +- client/electron/platform.ts | 25 +- client/electron/preload.ts | 135 ++- client/electron/pty-host-client.ts | 46 +- client/electron/session-index-client.ts | 209 +++++ client/electron/session-index-host.ts | 38 + client/electron/session-index-protocol.ts | 41 + client/electron/terminal-attention.ts | 42 + client/electron/terminal-buffer.ts | 298 +++++- client/electron/terminal-output-ack.ts | 99 +- client/electron/terminal-output-cleanup.ts | 20 + client/electron/terminal-output-stream.ts | 465 +++++++++ client/electron/terminal-restore-policy.ts | 14 + client/package.json | 5 +- client/src/App.tsx | 46 +- client/src/app-state.ts | 12 + .../src/components/EmbeddedChatTerminal.tsx | 112 ++- client/src/components/EmbeddedTerminal.tsx | 270 ++++-- client/src/electron.ts | 115 ++- client/src/pane-layout.ts | 35 + client/src/rooms/CommandRoom.tsx | 120 ++- client/src/rooms/ReviewRoom.tsx | 80 +- client/src/rooms/SettingsRoom.tsx | 36 +- client/src/styles.css | 25 + client/src/terminal-lifecycle.ts | 38 + client/src/themes/press.css | 14 +- client/src/workspace-utils.ts | 20 +- client/tests/agent-sessions.test.mjs | 57 +- client/tests/graphics-state.test.mjs | 69 ++ client/tests/hermes-session-index.test.mjs | 244 +++++ client/tests/memory-guard.test.mjs | 122 +++ client/tests/pane-layout.test.mjs | 34 + client/tests/platform.test.mjs | 16 + client/tests/regressions/README.md | 3 + .../pr-169-172-terminal-stream.test.mjs | 6 + .../pr-181-launch-admission.test.mjs | 4 + .../pr-26-100-184-graphics.test.mjs | 4 + .../pr-57-146-189-pane-layout.test.mjs | 22 + .../pr-95-154-169-170-session-index.test.mjs | 5 + client/tests/run-regressions.mjs | 45 + client/tests/session-index-client.test.mjs | 202 ++++ client/tests/terminal-attention.test.mjs | 18 + client/tests/terminal-buffer.test.mjs | 81 +- client/tests/terminal-output-ack.test.mjs | 406 +++++++- client/tests/terminal-restore.test.mjs | 25 + client/tests/workspace-utils.test.mjs | 21 + scripts/run_regression_checks.py | 47 + tests/regressions/README.md | 3 + .../test_pr_104_136_executor_bounds.py | 16 + tests/test_agent_sessions.py | 444 +++++++++ tests/test_app.py | 85 ++ tests/test_executor.py | 187 ++++ tests/test_memory_admission.py | 111 +++ 70 files changed, 7919 insertions(+), 1077 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/pr-checks.yml create mode 100644 backend/memory_admission.py create mode 100644 client/electron/graphics-state.ts create mode 100644 client/electron/hermes-session-index.ts create mode 100644 client/electron/launch-admission.ts create mode 100644 client/electron/session-index-client.ts create mode 100644 client/electron/session-index-host.ts create mode 100644 client/electron/session-index-protocol.ts create mode 100644 client/electron/terminal-attention.ts create mode 100644 client/electron/terminal-output-cleanup.ts create mode 100644 client/electron/terminal-output-stream.ts create mode 100644 client/src/pane-layout.ts create mode 100644 client/src/terminal-lifecycle.ts create mode 100644 client/tests/graphics-state.test.mjs create mode 100644 client/tests/hermes-session-index.test.mjs create mode 100644 client/tests/pane-layout.test.mjs create mode 100644 client/tests/regressions/README.md create mode 100644 client/tests/regressions/pr-169-172-terminal-stream.test.mjs create mode 100644 client/tests/regressions/pr-181-launch-admission.test.mjs create mode 100644 client/tests/regressions/pr-26-100-184-graphics.test.mjs create mode 100644 client/tests/regressions/pr-57-146-189-pane-layout.test.mjs create mode 100644 client/tests/regressions/pr-95-154-169-170-session-index.test.mjs create mode 100644 client/tests/run-regressions.mjs create mode 100644 client/tests/session-index-client.test.mjs create mode 100644 client/tests/terminal-attention.test.mjs create mode 100644 client/tests/workspace-utils.test.mjs create mode 100644 scripts/run_regression_checks.py create mode 100644 tests/regressions/README.md create mode 100644 tests/regressions/test_pr_104_136_executor_bounds.py create mode 100644 tests/test_memory_admission.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..671a8f3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +- + +## Testing + +- + +## Regression protection + +- [ ] Every fixed bug has a regression test that fails on the old behavior. +- [ ] Resource-sensitive changes include bounded-queue, failure, and cleanup coverage. +- [ ] UI/terminal changes were exercised in Electron, or the missing hardware test is explicit. +- [ ] Rollback and compatibility behavior is documented for protocol or persistence changes. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..dd99094 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,58 @@ +name: PR checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Tests and regression protection + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r backend/requirements.txt -r mcp_server/requirements.txt pytest httpx + + - name: Run backend tests + run: python -m pytest + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Run client tests + working-directory: client + run: | + npm run test:chat + npm run test:electron + + - name: Build client + working-directory: client + run: npm run build + + - name: Run permanent regression suite + run: python scripts/run_regression_checks.py diff --git a/README.md b/README.md index 87fad45..810fad8 100644 --- a/README.md +++ b/README.md @@ -255,20 +255,33 @@ GET /agents/runs/{run_id}/artifacts/{artifact_name} ## Testing -Run the backend test suite from the repository root: +Run the complete backend suite and the permanent historical-regression gate +from the repository root: ```bash -pytest +python -m pytest +python scripts/run_regression_checks.py ``` -Run the client build checks: +Run the client unit suites and build checks: ```bash cd client +npm run test:chat +npm run test:electron +npm run test:regression npm run build ``` -The tests use fake CLI agent fixtures so execution flow can be verified without hosted models or external agent tools. +The regression gate fails when either the backend or client regression corpus is +missing. It permanently covers the resource/freeze incidents behind terminal +remount and layout, output ACK recovery, bounded execution logs, and session +scan amplification. Tests use fake CLI agent fixtures, so these checks do not +launch hosted models or external agent tools. + +Pull requests run the same suites in `.github/workflows/pr-checks.yml`. Changes +to terminal streaming, restore, graphics, process launch, session discovery, or +pane layout must add a regression case for the historical behavior they touch. For the first public release gate, see [`docs/release-0.1.0-checklist.md`](docs/release-0.1.0-checklist.md). @@ -312,6 +325,14 @@ That backend-run flow is maintained for compatibility and tests. Athena's curren The Electron main process manages embedded terminals through `node-pty`. The React UI renders them with `xterm.js`. +Mounted terminal views use a bounded, sequence-aware stream. Output is sent +only to subscribed visible views, retained until xterm's write callback +acknowledges it, and replayed from an atomic snapshot after a remount. If a +consumer falls behind its bounded budget, Athena sends an explicit reset and +truncation marker instead of silently joining incompatible VT fragments. +Collapsed, maximized-away, and off-workspace panes keep their PTY alive without +receiving raw renderer IPC. + The `New` menu can launch: - Shell @@ -560,6 +581,25 @@ which hermes Quit all running Athena/AppImage instances before testing a newly built AppImage. Linux AppImages mount into `/tmp/.mount_ATHENA...`, so an older running instance can make it look like a rebuild did not change the UI. +### Athena is hot or slow on Linux + +Open **Settings โ†’ Graphics** to inspect the active graphics mode. Athena keeps +hardware acceleration enabled on healthy Linux systems so terminal painting +does not overload the software compositor. A GPU-process crash or interrupted +accelerated launch quarantines the next launch into safe mode, preventing a +crash loop. The setting always requires a restart. + +Environment overrides remain available for diagnosis: + +```bash +CONTEXT_WORKSPACE_ENABLE_GPU=1 npm start +CONTEXT_WORKSPACE_SAFE_GRAPHICS=1 npm start +``` + +The explicit GPU override wins over quarantine and should only be used when you +can recover from a native graphics crash. Settings also shows terminal stream +subscribers, retries, resets, dropped/truncated characters, and event-loop lag. + ### Embedded shell prints an `nvm` warning If the app is launched through `npm run dev`, the embedded shell may inherit npm environment variables. With `nvm`, this can produce: diff --git a/backend/adapters/grok.py b/backend/adapters/grok.py index 02a3b04..3802316 100644 --- a/backend/adapters/grok.py +++ b/backend/adapters/grok.py @@ -32,7 +32,10 @@ def build_command(self, run: Run, artifacts: RunArtifacts) -> AdapterCommand: def summarize_result(self, run: Run, artifacts: RunArtifacts) -> str: if artifacts.stdout.exists(): - result = artifacts.stdout.read_text(encoding="utf-8").strip() + # Run logs are bounded as raw process bytes. A byte-budget cutoff + # can land between UTF-8 code units, and third-party CLIs may emit + # non-UTF-8 diagnostics. Keep result summarization recoverable. + result = artifacts.stdout.read_text(encoding="utf-8", errors="replace").strip() if result: return result return f"{run.agent_id} completed without a captured final message." diff --git a/backend/agent_sessions.py b/backend/agent_sessions.py index 698833a..8c2331d 100644 --- a/backend/agent_sessions.py +++ b/backend/agent_sessions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import heapq import json import logging import os @@ -9,16 +10,27 @@ import shutil import sqlite3 import subprocess +import threading +from collections import OrderedDict from dataclasses import asdict, dataclass, field from datetime import datetime, timezone +from itertools import chain, islice from pathlib import Path -from typing import Any, Literal -from urllib.parse import unquote +from typing import Any, Generator, Literal +from urllib.parse import quote, unquote AgentSessionProvider = Literal["codex", "opencode", "athena", "claude", "hermes", "grok"] AgentSessionStatus = Literal["historical"] MAX_PROVIDER_ROWS = 1000 +MAX_FILE_SCAN_DIRS = 160 +MAX_FILE_SCAN_ENTRIES = 1200 +CLAUDE_METADATA_MAX_LINES = 200 +GROK_METADATA_MAX_LINES = 200 +HERMES_WORKSPACE_HINT_MAX_BYTES = 8192 +HERMES_WORKSPACE_HINT_MAX_MESSAGES = 8 +MAX_HERMES_ROWS_INSPECTED = MAX_FILE_SCAN_ENTRIES +HERMES_METADATA_CACHE_MAX_ENTRIES = 2048 logger = logging.getLogger(__name__) @@ -28,6 +40,61 @@ # escape the provider's session directory. This guards the function directly # rather than relying on the HTTP router to reject "/" in the path segment. _SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,200}$") +_PATH_HINT_RE = re.compile(r"(?:[A-Za-z]:[\\/]|/)[^\s\"'`]{2,1024}") +_HermesFileSignature = tuple[int, int, int] +_HermesMetadataCacheEntry = tuple[_HermesFileSignature, dict[str, str | None]] +_hermes_metadata_cache: OrderedDict[str, _HermesMetadataCacheEntry] = OrderedDict() +_hermes_metadata_cache_lock = threading.Lock() + + +class _BoundedTextJoiner: + """Build an exact UTF-8 head or tail without retaining the whole result.""" + + def __init__(self, separator: str, *, max_bytes: int, tail: bool) -> None: + self._separator = separator + self._max_bytes = max(1, max_bytes) + self._tail = tail + self._data = bytearray() + self._has_item = False + + @property + def full(self) -> bool: + return not self._tail and len(self._data) >= self._max_bytes + + def add(self, text: str) -> None: + if self._has_item: + self._append_text(self._separator) + self._has_item = True + self._append_text(text) + + def _append_text(self, text: str) -> None: + # Encode in modest pieces so appending a large tool response does not + # create another full-size byte copy alongside the decoded JSON value. + for offset in range(0, len(text), 16_384): + if self.full: + return + self._append_bytes(text[offset : offset + 16_384].encode("utf-8")) + + def _append_bytes(self, data: bytes) -> None: + if not data: + return + if self._tail: + if len(data) >= self._max_bytes: + self._data = bytearray(data[-self._max_bytes :]) + return + self._data.extend(data) + # Trim in batches to avoid shifting a max-sized bytearray for every + # small transcript fragment. + if len(self._data) > self._max_bytes * 2: + self._data = self._data[-self._max_bytes :] + return + remaining = self._max_bytes - len(self._data) + if remaining > 0: + self._data.extend(data[:remaining]) + + def text(self) -> str: + data = self._data[-self._max_bytes :] if self._tail else self._data + return data.decode("utf-8", errors="replace") def _validate_session_id(session_id: str) -> str: @@ -126,22 +193,22 @@ def read_agent_session_transcript( normalized_id = _validate_session_id(session_id) home = Path(home_dir).expanduser().resolve() if home_dir is not None else Path.home() if provider == "opencode": - markdown = _read_opencode_transcript(normalized_id, home) + markdown = _read_opencode_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) elif provider == "athena": - markdown = _read_athena_transcript(normalized_id, home) + markdown = _read_athena_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) elif provider == "claude": - markdown = _read_claude_transcript(normalized_id, home) + markdown = _read_claude_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) elif provider == "hermes": - markdown = _read_hermes_transcript(normalized_id, home) + markdown = _read_hermes_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) elif provider == "codex": - markdown = _read_codex_transcript(normalized_id, home) + markdown = _read_codex_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) elif provider == "grok": - markdown = _read_grok_transcript(normalized_id, home) + markdown = _read_grok_transcript(normalized_id, home, max_bytes=max_bytes, tail=tail) else: raise ValueError(f"Unsupported session provider: {provider}") if not markdown: raise FileNotFoundError(f"{provider} session not found: {normalized_id}") - return _bounded_text(markdown, max_bytes=max_bytes, tail=tail) + return markdown def _read_codex_sessions(workspace: Path | None, home: Path) -> list[AgentSession]: @@ -155,16 +222,23 @@ def _read_codex_sessions(workspace: Path | None, home: Path) -> list[AgentSessio seen_ids: set[str] = set() if db_path.exists(): + workspace_sql = "" + params: tuple[Any, ...] = () + if workspace is not None: + filter_sql, filter_params = _workspace_sql_filter("cwd", workspace) + workspace_sql = f"where {filter_sql}" + params = filter_params rows = _query_sqlite( db_path, - """ + f""" select id, cwd, title, created_at_ms, updated_at_ms, git_branch, cli_version, first_user_message, model, agent_role from threads + {workspace_sql} order by updated_at_ms desc limit ? """, - (MAX_PROVIDER_ROWS,), + (*params, MAX_PROVIDER_ROWS), ) for row in rows: session_workspace = _string_value(row[1]) or (str(workspace) if workspace else "") @@ -241,11 +315,68 @@ def _read_codex_jsonl_metadata(workspace: Path, home: Path) -> dict[str, dict[st def _recent_jsonl_files(root: Path, *, limit: int) -> list[Path]: - try: - files = [path for path in root.rglob("*.jsonl") if path.is_file()] - except OSError: + return _recent_files(root, limit=limit, suffix=".jsonl") + + +def _recent_files(root: Path, *, limit: int, suffix: str, prefix: str = "") -> list[Path]: + """Return recent matching files while bounding traversal and retained state. + + Provider directories are user-controlled and can contain years of history. + A fixed directory/entry budget prevents a discovery refresh from turning + into an unbounded recursive walk. Within that budget, a heap retains only + the requested newest paths. + """ + if limit <= 0 or not root.is_dir(): return [] - return sorted(files, key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)[:limit] + + heap: list[tuple[int, int, Path]] = [] + directories = [root] + visited_dirs = 0 + inspected_entries = 0 + sequence = 0 + + while directories and visited_dirs < MAX_FILE_SCAN_DIRS and inspected_entries < MAX_FILE_SCAN_ENTRIES: + directory = directories.pop() + visited_dirs += 1 + try: + with os.scandir(directory) as iterator: + entries = [] + for entry in iterator: + if inspected_entries >= MAX_FILE_SCAN_ENTRIES: + break + inspected_entries += 1 + entries.append(entry) + except OSError: + continue + + # Date-based Codex directories sort newest-first. Push in ascending + # order because the stack pops from the end. + entries.sort(key=lambda entry: entry.name, reverse=True) + child_dirs: list[Path] = [] + for entry in entries: + try: + if entry.is_dir(follow_symlinks=False): + child_dirs.append(Path(entry.path)) + continue + if not entry.is_file(follow_symlinks=False): + continue + except OSError: + continue + if not entry.name.endswith(suffix) or (prefix and not entry.name.startswith(prefix)): + continue + try: + modified_ns = entry.stat(follow_symlinks=False).st_mtime_ns + except OSError: + continue + sequence += 1 + candidate = (modified_ns, sequence, Path(entry.path)) + if len(heap) < limit: + heapq.heappush(heap, candidate) + elif candidate[:2] > heap[0][:2]: + heapq.heapreplace(heap, candidate) + directories.extend(reversed(child_dirs)) + + return [path for _mtime, _sequence, path in sorted(heap, reverse=True)] def _read_codex_jsonl_file_metadata(file_path: Path) -> dict[str, Any]: @@ -337,17 +468,29 @@ def _copy_string_fields(metadata: dict[str, Any], source: dict[str, Any] | None, metadata[target_key] = value -def _read_codex_transcript(session_id: str, home: Path) -> str: - metadata_by_id = _read_codex_jsonl_metadata_for_all(home) - metadata = metadata_by_id.get(session_id) - file_path_text = _metadata_string(metadata or {}, "jsonl_path") - if not file_path_text: +def _read_codex_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: + sessions_dir = home / ".codex" / "sessions" + if not sessions_dir.exists(): return "" - file_path = Path(file_path_text) - if not file_path.exists(): + + recent_files = _recent_jsonl_files(sessions_dir, limit=800) + file_path = next((path for path in recent_files if session_id in path.stem), None) + metadata: dict[str, Any] | None = None + if file_path is None: + # Older/variant Codex filenames may omit the id. Preserve compatibility + # while staying inside the bounded recent-file candidate set. + for candidate in recent_files: + candidate_metadata = _read_codex_jsonl_file_metadata(candidate) + if _metadata_string(candidate_metadata, "session_id") == session_id: + file_path = candidate + metadata = candidate_metadata + break + if file_path is None: return "" + metadata = metadata or _read_codex_jsonl_file_metadata(file_path) - lines = [f"# Codex Session Transcript\n\n- session: {session_id}"] + output = _BoundedTextJoiner("\n\n", max_bytes=max_bytes, tail=tail) + output.add(f"# Codex Session Transcript\n\n- session: {session_id}") for label, key in ( ("workspace", "cwd"), ("model", "model"), @@ -361,25 +504,29 @@ def _read_codex_transcript(session_id: str, home: Path) -> str: ("git commit", "git_commit_hash"), ("jsonl", "jsonl_path"), ): - value = _metadata_string(metadata or {}, key) + value = _metadata_string(metadata, key) if value: - lines.append(f"- {label}: {value}") - system_prompt = _metadata_string(metadata or {}, "system_prompt_excerpt") + output.add(f"- {label}: {value}") + system_prompt = _metadata_string(metadata, "system_prompt_excerpt") if system_prompt: - lines.extend(["", "## System Prompt Excerpt", "", system_prompt]) - lines.extend(["", "## Events", ""]) + for item in ("", "## System Prompt Excerpt", "", system_prompt): + output.add(item) + for item in ("", "## Events", ""): + output.add(item) try: with file_path.open("r", encoding="utf-8", errors="replace") as handle: for line in handle: + if output.full: + break entry = _parse_json_object(line) if not entry: continue rendered = _render_codex_event(entry) if rendered: - lines.append(rendered) + output.add(rendered) except OSError: return "" - return "\n\n".join(lines) + return output.text() def _read_codex_jsonl_metadata_for_all(home: Path) -> dict[str, dict[str, Any]]: @@ -428,17 +575,24 @@ def _read_opencode_sessions(workspace: Path | None, home: Path) -> list[AgentSes db_path = home / ".local" / "share" / "opencode" / "opencode.db" if not db_path.exists(): return [] + workspace_sql = "" + params: tuple[Any, ...] = () + if workspace is not None: + filter_sql, filter_params = _workspace_sql_filter("coalesce(s.directory, p.worktree)", workspace) + workspace_sql = f"where {filter_sql}" + params = filter_params rows = _query_sqlite( db_path, - """ + f""" select s.id, coalesce(s.directory, p.worktree), s.title, s.time_created, s.time_updated, s.agent, s.model, p.worktree from session s left join project p on s.project_id = p.id + {workspace_sql} order by s.time_updated desc limit ? """, - (MAX_PROVIDER_ROWS,), + (*params, MAX_PROVIDER_ROWS), ) sessions: list[AgentSession] = [] for row in rows: @@ -468,7 +622,7 @@ def _read_opencode_sessions(workspace: Path | None, home: Path) -> list[AgentSes return sessions -def _read_opencode_transcript(session_id: str, home: Path) -> str: +def _read_opencode_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: db_path = home / ".local" / "share" / "opencode" / "opencode.db" if not db_path.exists(): return "" @@ -485,7 +639,7 @@ def _read_opencode_transcript(session_id: str, home: Path) -> str: if not session_rows: return "" - rows = _query_sqlite( + rows = _iter_sqlite( db_path, """ select m.id, m.data, p.id, p.data, coalesce(p.time_created, m.time_created) @@ -497,23 +651,30 @@ def _read_opencode_transcript(session_id: str, home: Path) -> str: (session_id,), ) header = _opencode_transcript_header(session_rows[0]) - lines = [header, ""] + output = _BoundedTextJoiner("\n", max_bytes=max_bytes, tail=tail) + output.add(header) + output.add("") current_message = "" - for message_id, message_data, _part_id, part_data, _time in rows: - msg_id = _string_value(message_id) - if msg_id and msg_id != current_message: - current_message = msg_id - role = _json_string(message_data, "role") or "message" - agent = _json_string(message_data, "agent") - model = _json_string(message_data, "modelID") or _json_nested_string(message_data, "model", "modelID") - details = " / ".join(value for value in (agent, model) if value) - lines.append(f"## {role.title()}{f' ({details})' if details else ''}") - lines.append("") - rendered = _render_opencode_part(part_data) - if rendered: - lines.append(rendered) - lines.append("") - return "\n".join(lines).strip() + "\n" + try: + for message_id, message_data, _part_id, part_data, _time in rows: + if output.full: + break + msg_id = _string_value(message_id) + if msg_id and msg_id != current_message: + current_message = msg_id + role = _json_string(message_data, "role") or "message" + agent = _json_string(message_data, "agent") + model = _json_string(message_data, "modelID") or _json_nested_string(message_data, "model", "modelID") + details = " / ".join(value for value in (agent, model) if value) + output.add(f"## {role.title()}{f' ({details})' if details else ''}") + output.add("") + rendered = _render_opencode_part(part_data) + if rendered: + output.add(rendered) + output.add("") + finally: + rows.close() + return output.text() def _athena_index_path(home: Path) -> Path: @@ -530,9 +691,15 @@ def _read_athena_sessions(workspace: Path | None, home: Path) -> list[AgentSessi db_path = _athena_index_path(home) if not db_path.exists() or not _sqlite_user_version(db_path, minimum=2): return [] + workspace_sql = "" + params: tuple[Any, ...] = () + if workspace is not None: + filter_sql, filter_params = _workspace_sql_filter("m.workspace", workspace) + workspace_sql = f"and {filter_sql}" + params = filter_params rows = _query_sqlite( db_path, - """ + f""" select m.session_id, m.workspace, (select text from messages first_user where first_user.agent = 'athena' @@ -545,14 +712,14 @@ def _read_athena_sessions(workspace: Path | None, home: Path) -> list[AgentSessi max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end), count(*) from messages m - where m.agent = 'athena' + where m.agent = 'athena' {workspace_sql} group by m.session_id, m.workspace order by (max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end) is null), max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end) desc, max(id) desc limit ? """, - (MAX_PROVIDER_ROWS,), + (*params, MAX_PROVIDER_ROWS), ) sessions: list[AgentSession] = [] for row in rows: @@ -583,11 +750,11 @@ def _read_athena_sessions(workspace: Path | None, home: Path) -> list[AgentSessi return sessions -def _read_athena_transcript(session_id: str, home: Path) -> str: +def _read_athena_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: db_path = _athena_index_path(home) if not db_path.exists() or not _sqlite_user_version(db_path, minimum=2): return "" - rows = _query_sqlite( + rows = iter(_iter_sqlite( db_path, """ select workspace, role, ts, text @@ -597,22 +764,33 @@ def _read_athena_transcript(session_id: str, home: Path) -> str: limit ? """, (session_id, MAX_PROVIDER_ROWS), - ) - if not rows: + )) + first_row = next(rows, None) + if first_row is None: + rows.close() return "" - workspace = _string_value(rows[0][0]) - lines = ["# Athena Code Session Transcript", "", f"- session: {session_id}"] + workspace = _string_value(first_row[0]) + output = _BoundedTextJoiner("\n", max_bytes=max_bytes, tail=tail) + for item in ("# Athena Code Session Transcript", "", f"- session: {session_id}"): + output.add(item) if workspace: - lines.append(f"- workspace: {workspace}") - lines.extend(["", "## Indexed Turns", ""]) - for _workspace, role, ts, text in rows: - label = _string_value(role).title() or "Message" - timestamp = _string_value(ts) - heading = f"### {label}{f' ({timestamp})' if timestamp else ''}" - body = _string_value(text).strip() - if body: - lines.extend([heading, "", body, ""]) - return "\n".join(lines).strip() + "\n" + output.add(f"- workspace: {workspace}") + for item in ("", "## Indexed Turns", ""): + output.add(item) + try: + for _workspace, role, ts, text in chain((first_row,), rows): + if output.full: + break + label = _string_value(role).title() or "Message" + timestamp = _string_value(ts) + heading = f"### {label}{f' ({timestamp})' if timestamp else ''}" + body = _string_value(text).strip() + if body: + for item in (heading, "", body, ""): + output.add(item) + finally: + rows.close() + return output.text() def _grok_sessions_root(home: Path) -> Path: @@ -624,23 +802,89 @@ def _read_grok_sessions(workspace: Path | None, home: Path) -> list[AgentSession if not sessions_root.is_dir(): return [] sessions: list[AgentSession] = [] - for cwd_dir in sorted(sessions_root.iterdir()): - if not cwd_dir.is_dir(): - continue - decoded_cwd = unquote(cwd_dir.name) - if workspace is not None and not _same_or_descendant_path(decoded_cwd, workspace): - continue - for session_dir in cwd_dir.iterdir(): - if not session_dir.is_dir(): - continue - session = _read_grok_session(session_dir, decoded_cwd) - if session is not None: - sessions.append(session) - if len(sessions) >= MAX_PROVIDER_ROWS: - return sessions + for session_dir, decoded_cwd in _recent_grok_session_dirs( + sessions_root, + workspace, + limit=MAX_PROVIDER_ROWS, + ): + session = _read_grok_session(session_dir, decoded_cwd) + if session is not None: + sessions.append(session) return sessions +def _recent_grok_session_dirs( + sessions_root: Path, + workspace: Path | None, + *, + limit: int, +) -> list[tuple[Path, str]]: + """Select recent Grok session dirs under fixed cwd/session scan budgets.""" + if limit <= 0: + return [] + + cwd_dirs: list[tuple[Path, str]] = [] + seen_cwd_dirs: set[Path] = set() + if workspace is not None: + direct = sessions_root / quote(str(workspace), safe="") + if direct.is_dir(): + cwd_dirs.append((direct, str(workspace))) + seen_cwd_dirs.add(direct) + + inspected_cwds = 0 + try: + with os.scandir(sessions_root) as iterator: + for entry in iterator: + if inspected_cwds >= MAX_FILE_SCAN_ENTRIES: + break + inspected_cwds += 1 + try: + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + continue + path = Path(entry.path) + if path in seen_cwd_dirs: + continue + decoded_cwd = unquote(entry.name) + if workspace is not None and not _same_or_descendant_path(decoded_cwd, workspace): + continue + cwd_dirs.append((path, decoded_cwd)) + seen_cwd_dirs.add(path) + if len(cwd_dirs) >= MAX_FILE_SCAN_DIRS: + break + except OSError: + pass + + heap: list[tuple[int, int, Path, str]] = [] + sequence = 0 + inspected_sessions = 0 + for cwd_dir, decoded_cwd in cwd_dirs: + if inspected_sessions >= MAX_FILE_SCAN_ENTRIES: + break + try: + with os.scandir(cwd_dir) as iterator: + for entry in iterator: + if inspected_sessions >= MAX_FILE_SCAN_ENTRIES: + break + inspected_sessions += 1 + try: + if not entry.is_dir(follow_symlinks=False): + continue + modified_ns = entry.stat(follow_symlinks=False).st_mtime_ns + except OSError: + continue + sequence += 1 + candidate = (modified_ns, sequence, Path(entry.path), decoded_cwd) + if len(heap) < limit: + heapq.heappush(heap, candidate) + elif candidate[:2] > heap[0][:2]: + heapq.heapreplace(heap, candidate) + except OSError: + continue + return [(path, cwd) for _mtime, _sequence, path, cwd in sorted(heap, reverse=True)] + + def _read_grok_session(session_dir: Path, fallback_workspace: str) -> AgentSession | None: summary = _json_object(_read_text_file(session_dir / "summary.json")) or {} info = summary.get("info") @@ -675,12 +919,17 @@ def _read_grok_session(session_dir: Path, fallback_workspace: str) -> AgentSessi ) -def _read_grok_transcript(session_id: str, home: Path) -> str: +def _read_grok_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: sessions_root = _grok_sessions_root(home) if not sessions_root.is_dir(): return "" session_dir = next( - (path.parent for path in sessions_root.glob(f"*/{session_id}/chat_history.jsonl")), + ( + candidate + for cwd_dir in _bounded_child_directories(sessions_root) + if (candidate := cwd_dir / session_id).is_dir() + and (candidate / "chat_history.jsonl").is_file() + ), None, ) if session_dir is None: @@ -688,11 +937,16 @@ def _read_grok_transcript(session_id: str, home: Path) -> str: summary = _json_object(_read_text_file(session_dir / "summary.json")) or {} info = summary.get("info") workspace = _string_value(info.get("cwd")) if isinstance(info, dict) else unquote(session_dir.parent.name) - lines = ["# Grok Session Transcript", "", f"- session: {session_id}"] + output = _BoundedTextJoiner("\n", max_bytes=max_bytes, tail=tail) + for item in ("# Grok Session Transcript", "", f"- session: {session_id}"): + output.add(item) if workspace: - lines.append(f"- workspace: {workspace}") - lines.extend(["", "## Turns", ""]) - for entry in _read_jsonl(session_dir / "chat_history.jsonl"): + output.add(f"- workspace: {workspace}") + for item in ("", "## Turns", ""): + output.add(item) + for entry in _iter_jsonl(session_dir / "chat_history.jsonl"): + if output.full: + break if "synthetic_reason" in entry: continue role = _string_value(entry.get("type")) @@ -700,12 +954,13 @@ def _read_grok_transcript(session_id: str, home: Path) -> str: continue body = _grok_message_text(entry.get("content")).strip() if body: - lines.extend([f"### {role.title()}", "", body, ""]) - return "\n".join(lines).strip() + "\n" + for item in (f"### {role.title()}", "", body, ""): + output.add(item) + return output.text() def _first_grok_user_message(history_path: Path) -> str | None: - for entry in _read_jsonl(history_path): + for entry in _iter_jsonl(history_path, max_lines=GROK_METADATA_MAX_LINES): # Skip injected system-reminders, which Grok records as synthetic user turns. if entry.get("type") != "user" or "synthetic_reason" in entry: continue @@ -725,18 +980,18 @@ def _grok_message_text(content: Any) -> str: return "" -def _read_jsonl(path: Path) -> list[dict[str, Any]]: - raw = _read_text_file(path) - if not raw: - return [] - entries: list[dict[str, Any]] = [] - for line in raw.splitlines(): - if not line.strip(): - continue - data = _json_object(line) - if data: - entries.append(data) - return entries +def _iter_jsonl(path: Path, *, max_lines: int | None = None) -> Generator[dict[str, Any], None, None]: + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + lines = islice(handle, max_lines) if max_lines is not None else handle + for line in lines: + if not line.strip(): + continue + data = _json_object(line) + if data: + yield data + except OSError: + return def _read_text_file(path: Path) -> str: @@ -784,27 +1039,60 @@ def _render_opencode_part(value: Any) -> str: return "" -def _read_claude_transcript(session_id: str, home: Path) -> str: +def _read_claude_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: projects_dir = home / ".claude" / "projects" if not projects_dir.exists(): return "" - matches = list(projects_dir.glob(f"*/{session_id}.jsonl")) - if not matches: + file_path = next( + ( + candidate + for project_dir in _bounded_child_directories(projects_dir) + if (candidate := project_dir / f"{session_id}.jsonl").is_file() + ), + None, + ) + if file_path is None: return "" - lines = [f"# Claude Code Session Transcript\n\n- session: {session_id}", ""] - for raw in matches[0].read_text(encoding="utf-8", errors="replace").splitlines(): - data = _json_object(raw) - message = data.get("message") if isinstance(data, dict) else None - if not isinstance(message, dict): - continue - role = _string_property(message, "role") or "message" - text = _message_content(message) - if text: - lines.extend([f"## {role.title()}", "", text, ""]) - return "\n".join(lines).strip() + "\n" + output = _BoundedTextJoiner("\n", max_bytes=max_bytes, tail=tail) + output.add(f"# Claude Code Session Transcript\n\n- session: {session_id}") + output.add("") + try: + with file_path.open("r", encoding="utf-8", errors="replace") as handle: + for raw in handle: + if output.full: + break + data = _json_object(raw) + message = data.get("message") if isinstance(data, dict) else None + if not isinstance(message, dict): + continue + role = _string_property(message, "role") or "message" + text = _message_content(message) + if text: + for item in (f"## {role.title()}", "", text, ""): + output.add(item) + except OSError: + return "" + return output.text() + +def _bounded_child_directories(root: Path) -> Generator[Path, None, None]: + inspected = 0 + try: + with os.scandir(root) as iterator: + for entry in iterator: + if inspected >= MAX_FILE_SCAN_ENTRIES: + break + inspected += 1 + try: + if entry.is_dir(follow_symlinks=False): + yield Path(entry.path) + except OSError: + continue + except OSError: + return -def _read_hermes_transcript(session_id: str, home: Path) -> str: + +def _read_hermes_transcript(session_id: str, home: Path, *, max_bytes: int, tail: bool) -> str: hermes_dir = _resolve_hermes_dir(home) if hermes_dir is None: return "" @@ -812,17 +1100,24 @@ def _read_hermes_transcript(session_id: str, home: Path) -> str: if not file_path.exists(): return "" data = _json_object(file_path.read_text(encoding="utf-8", errors="replace")) - lines = [f"# Hermes Session Transcript\n\n- session: {session_id}", ""] + if not data: + return "" + output = _BoundedTextJoiner("\n", max_bytes=max_bytes, tail=tail) + output.add(f"# Hermes Session Transcript\n\n- session: {session_id}") + output.add("") messages = data.get("messages") if isinstance(messages, list): for item in messages: + if output.full: + break if not isinstance(item, dict): continue role = _string_property(item, "role") or _string_property(item, "type") or "message" text = _message_content(item) if text: - lines.extend([f"## {role.title()}", "", text, ""]) - return "\n".join(lines).strip() + "\n" + for part in (f"## {role.title()}", "", text, ""): + output.add(part) + return output.text() def _read_claude_sessions(workspace: Path | None, home: Path) -> list[AgentSession]: @@ -839,7 +1134,7 @@ def _read_claude_sessions(workspace: Path | None, home: Path) -> list[AgentSessi if directory in seen_dirs or not directory.exists(): continue seen_dirs.add(directory) - for file_path in sorted(directory.glob("*.jsonl")): + for file_path in _recent_jsonl_files(directory, limit=MAX_PROVIDER_ROWS): seen_files.add(file_path) session = _read_claude_session_file(file_path, workspace, allow_missing_cwd=True) if session: @@ -857,7 +1152,7 @@ def _read_claude_sessions(workspace: Path | None, home: Path) -> list[AgentSessi def _read_claude_session_file(file_path: Path, workspace: Path | None, *, allow_missing_cwd: bool) -> AgentSession | None: try: stat = file_path.stat() - lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()[:200] + handle = file_path.open("r", encoding="utf-8", errors="replace") except OSError: return None @@ -869,21 +1164,22 @@ def _read_claude_session_file(file_path: Path, workspace: Path | None, *, allow_ model: str | None = None title: str | None = None - for line in lines: - entry = _parse_json_object(line) - if not entry: - continue - session_id = _string_property(entry, "sessionId") or session_id - cwd = _string_property(entry, "cwd") or cwd - branch = _string_property(entry, "gitBranch") or branch - timestamp = _string_property(entry, "timestamp") - if timestamp: - created_at = created_at or timestamp - updated_at = timestamp - message = _object_property(entry, "message") - model = _string_property(message, "model") or model - if not title and _string_property(message, "role") == "user": - title = _clean_session_title(_message_content(message)) + with handle: + for line in islice(handle, CLAUDE_METADATA_MAX_LINES): + entry = _parse_json_object(line) + if not entry: + continue + session_id = _string_property(entry, "sessionId") or session_id + cwd = _string_property(entry, "cwd") or cwd + branch = _string_property(entry, "gitBranch") or branch + timestamp = _string_property(entry, "timestamp") + if timestamp: + created_at = created_at or timestamp + updated_at = timestamp + message = _object_property(entry, "message") + model = _string_property(message, "model") or model + if not title and _string_property(message, "role") == "user": + title = _clean_session_title(_message_content(message)) if cwd: if workspace is not None and not _same_or_descendant_path(cwd, workspace): @@ -917,44 +1213,56 @@ def _read_hermes_sessions(workspace: Path | None, home: Path) -> list[AgentSessi sessions_by_id: dict[str, AgentSession] = {} if db_path.exists(): - rows = _query_sqlite( - db_path, - """ + base_query = """ select id, source, model, started_at, ended_at, message_count, title from sessions order by coalesce(ended_at, started_at) desc - limit ? - """, - (MAX_PROVIDER_ROWS,), - ) - for row in rows: - session_id = _string_value(row[0]) - if not session_id: - continue - file_metadata = _read_hermes_session_file(sessions_dir / f"session_{session_id}.json") - manifest_entry = manifest.get(session_id, {}) - if workspace is not None and not _hermes_session_matches_workspace(file_metadata, manifest_entry, workspace): - continue - created_at = _from_epoch(row[3]) - updated_at = _from_epoch(row[4]) if row[4] else file_metadata.get("updated_at") or manifest_entry.get("updated_at") or created_at - sessions_by_id[session_id] = AgentSession( - id=session_id, - provider="hermes", - title=_clean_session_title(_nullable_string(row[6]) or file_metadata.get("title") or manifest_entry.get("title")) or "Hermes session", - workspace=file_metadata.get("workspace") or (str(workspace) if workspace else ""), - branch=None, - model=_nullable_string(row[2]) or file_metadata.get("model"), - agent=_hermes_agent_label(_nullable_string(row[1]), manifest_entry, file_metadata), - created_at=file_metadata.get("created_at") or manifest_entry.get("created_at") or created_at, - updated_at=updated_at, - status="historical", - terminal_id=None, - pid=None, - resume_command=f"hermes --resume {_quote_shell_arg(session_id)}", - ) - - if not sessions_by_id and sessions_dir.exists(): - for file_path in sessions_dir.glob("session_*.json"): + """ + # Hermes has no indexed workspace column. For project queries, stream + # newest rows and apply workspace membership before the result limit; + # the old global LIMIT could make an older active workspace disappear. + row_limit = MAX_PROVIDER_ROWS if workspace is None else MAX_HERMES_ROWS_INSPECTED + rows = _iter_sqlite(db_path, f"{base_query} limit ?", (row_limit,)) + try: + for row in rows: + session_id = _string_value(row[0]) + if not session_id: + continue + file_metadata = _read_hermes_session_file(sessions_dir / f"session_{session_id}.json") + manifest_entry = manifest.get(session_id, {}) + if workspace is not None and not _hermes_session_matches_workspace(file_metadata, manifest_entry, workspace): + continue + created_at = _from_epoch(row[3]) + updated_at = _from_epoch(row[4]) if row[4] else file_metadata.get("updated_at") or manifest_entry.get("updated_at") or created_at + sessions_by_id[session_id] = AgentSession( + id=session_id, + provider="hermes", + title=_clean_session_title(_nullable_string(row[6]) or file_metadata.get("title") or manifest_entry.get("title")) or "Hermes session", + workspace=file_metadata.get("workspace") or (str(workspace) if workspace else ""), + branch=None, + model=_nullable_string(row[2]) or file_metadata.get("model"), + agent=_hermes_agent_label(_nullable_string(row[1]), manifest_entry, file_metadata), + created_at=file_metadata.get("created_at") or manifest_entry.get("created_at") or created_at, + updated_at=updated_at, + status="historical", + terminal_id=None, + pid=None, + resume_command=f"hermes --resume {_quote_shell_arg(session_id)}", + ) + if workspace is not None and len(sessions_by_id) >= 100: + break + finally: + rows.close() + + # Always merge file-only sessions. Previously any database row globally + # suppressed this fallback, hiding valid JSON-only sessions. + if len(sessions_by_id) < 100 and sessions_dir.exists(): + for file_path in _recent_files( + sessions_dir, + limit=MAX_PROVIDER_ROWS, + suffix=".json", + prefix="session_", + ): match = re.match(r"^session_(.+)\.json$", file_path.name) if not match: continue @@ -1040,18 +1348,57 @@ def _read_hermes_manifest(file_path: Path) -> dict[str, dict[str, str | None]]: def _read_hermes_session_file(file_path: Path) -> dict[str, str | None]: - parsed = _read_json_object(file_path) - if not parsed: - return {} - return { - "title": _first_hermes_user_message(parsed), - "model": _string_property(parsed, "model"), - "platform": _string_property(parsed, "platform"), - "created_at": _string_property(parsed, "session_start"), - "updated_at": _string_property(parsed, "last_updated"), - "workspace": _hermes_workspace(parsed), - "search_text": json.dumps(parsed, ensure_ascii=False)[:300_000], - } + cache_key = str(file_path.absolute()) + try: + stat = file_path.stat() + signature: _HermesFileSignature = (stat.st_mtime_ns, stat.st_size, getattr(stat, "st_ino", 0)) + except OSError: + return _cached_hermes_metadata(cache_key) or {} + + with _hermes_metadata_cache_lock: + cached = _hermes_metadata_cache.get(cache_key) + if cached and cached[0] == signature: + _hermes_metadata_cache.move_to_end(cache_key) + return dict(cached[1]) + # Parse under the cache lock. Discovery is already sequential, and this + # coalesces concurrent API refreshes instead of parsing the same large + # Hermes file in multiple worker threads. + parsed = _read_json_object(file_path) + if not parsed: + if cached: + _hermes_metadata_cache[cache_key] = (signature, cached[1]) + _hermes_metadata_cache.move_to_end(cache_key) + return dict(cached[1]) + _hermes_metadata_cache[cache_key] = (signature, {}) + _hermes_metadata_cache.move_to_end(cache_key) + while len(_hermes_metadata_cache) > HERMES_METADATA_CACHE_MAX_ENTRIES: + _hermes_metadata_cache.popitem(last=False) + return {} + metadata = { + "title": _first_hermes_user_message(parsed), + "model": _string_property(parsed, "model"), + "platform": _string_property(parsed, "platform"), + "created_at": _string_property(parsed, "session_start"), + "updated_at": _string_property(parsed, "last_updated"), + "workspace": _hermes_workspace(parsed), + "workspace_hint": _hermes_workspace_hint(parsed), + } + # Cache the signature observed before the read. If the file changed + # during parsing, the next refresh will detect and parse it again. + _hermes_metadata_cache[cache_key] = (signature, dict(metadata)) + _hermes_metadata_cache.move_to_end(cache_key) + while len(_hermes_metadata_cache) > HERMES_METADATA_CACHE_MAX_ENTRIES: + _hermes_metadata_cache.popitem(last=False) + return metadata + + +def _cached_hermes_metadata(cache_key: str) -> dict[str, str | None] | None: + with _hermes_metadata_cache_lock: + cached = _hermes_metadata_cache.get(cache_key) + if not cached: + return None + _hermes_metadata_cache.move_to_end(cache_key) + return dict(cached[1]) def _read_json_object(file_path: Path) -> dict[str, Any] | None: @@ -1068,7 +1415,10 @@ def _first_hermes_user_message(session: dict[str, Any]) -> str | None: for item in messages: if not isinstance(item, dict) or _string_property(item, "role") != "user": continue - return _clean_session_title(_message_content(item)) + for fragment in _message_content_fragments(item): + title = _clean_session_title(fragment[:4096]) + if title: + return title return None @@ -1081,15 +1431,67 @@ def _hermes_workspace(session: dict[str, Any]) -> str | None: return _string_property(context, "project_dir") or _string_property(context, "workspace") +def _hermes_workspace_hint(session: dict[str, Any]) -> str | None: + """Extract a small matching hint instead of retaining serialized history.""" + output = _BoundedTextJoiner("\n", max_bytes=HERMES_WORKSPACE_HINT_MAX_BYTES, tail=False) + workspace = _hermes_workspace(session) + if workspace: + output.add(workspace) + for key in ("title", "name", "session_name", "sessionName"): + value = _string_property(session, key) + if value: + output.add(value) + + messages = session.get("messages") + if isinstance(messages, list): + added_messages = 0 + for item in messages: + if output.full or added_messages >= HERMES_WORKSPACE_HINT_MAX_MESSAGES: + break + if not isinstance(item, dict): + continue + role = _string_property(item, "role") or _string_property(item, "type") + if role not in {None, "user", "system"}: + continue + # Prefer explicit path-like tokens so a long prompt cannot crowd + # the workspace identifier out of the bounded hint. Keep a short + # title prefix as a fallback for basename matching. + paths_added = 0 + has_content = False + for fragment in _message_content_fragments(item): + has_content = has_content or bool(fragment) + for match in _PATH_HINT_RE.finditer(fragment): + output.add(match.group(0)) + paths_added += 1 + if output.full or paths_added >= 8: + break + if output.full or paths_added >= 8: + break + if not has_content: + continue + if not output.full: + for fragment in _message_content_fragments(item): + if fragment: + output.add(fragment[:1024]) + if output.full: + break + added_messages += 1 + + return output.text() or None + + def _hermes_session_matches_workspace(file_metadata: dict[str, str | None], manifest_entry: dict[str, str | None], workspace: Path) -> bool: metadata_workspace = file_metadata.get("workspace") - if metadata_workspace and _same_or_descendant_path(metadata_workspace, workspace): - return True + # Explicit provider metadata is authoritative. Falling through to a + # lower-cased transcript/title hint after an explicit mismatch can merge + # case-distinct POSIX workspaces or resurrect stale path mentions. + if metadata_workspace: + return _same_or_descendant_path(metadata_workspace, workspace) haystack = _normalize_session_search_text("\n".join( value for value in ( file_metadata.get("title"), - file_metadata.get("search_text"), + file_metadata.get("workspace_hint"), manifest_entry.get("title"), ) if value @@ -1105,14 +1507,18 @@ def _hermes_agent_label(source: str | None, manifest_entry: dict[str, str | None def _query_sqlite(db_path: Path, sql: str, params: tuple[Any, ...] = ()) -> list[tuple[Any, ...]]: + return list(_iter_sqlite(db_path, sql, params)) + + +def _iter_sqlite(db_path: Path, sql: str, params: tuple[Any, ...] = ()) -> Generator[tuple[Any, ...], None, None]: try: connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=0.25) try: - return list(connection.execute(sql, params)) + yield from connection.execute(sql, params) finally: connection.close() except sqlite3.Error: - return [] + return def _sqlite_user_version(db_path: Path, *, minimum: int) -> bool: @@ -1161,21 +1567,82 @@ def _same_or_descendant_path(candidate: str | Path, workspace: str | Path) -> bo parent = _normalize_path(workspace) if not child or not parent: return False - return child == parent or (child.startswith("/") and parent == "/") or child.startswith(f"{parent}/") + if child == parent: + return True + return child.startswith(parent if parent.endswith("/") else f"{parent}/") + + +def _workspace_sql_filter(column_expression: str, workspace: str | Path) -> tuple[str, tuple[str, ...]]: + """Build a literal-safe workspace/descendant predicate for native indexes.""" + slashed_expression = f"replace(trim(coalesce({column_expression}, '')), char(92), '/')" + sensitive_expression = f"rtrim({slashed_expression}, '/')" + insensitive_expression = f"lower({sensitive_expression})" + clauses: list[str] = [] + params: list[str] = [] + for candidate, case_insensitive in _sql_workspace_candidates(workspace): + expression = insensitive_expression if case_insensitive else sensitive_expression + if candidate == "/": + # rtrim('/') is empty, so root must inspect the untrimmed spelling. + clauses.append(f"substr({slashed_expression}, 1, 1) = '/'") + continue + clauses.append(f"({expression} = ? or substr({expression}, 1, length(?) + 1) = ? || '/')") + params.extend((candidate, candidate, candidate)) + return (f"({' or '.join(clauses)})" if clauses else "0", tuple(params)) + + +def _sql_workspace_candidates(workspace: str | Path) -> list[tuple[str, bool]]: + direct = str(workspace).strip().replace("\\", "/").rstrip("/") or "/" + comparable = _normalize_path(workspace) or "/" + values: dict[tuple[str, bool], tuple[str, bool]] = {} + + def add(value: str, case_insensitive: bool) -> None: + normalized = value.lower() if case_insensitive else value + values[(normalized, case_insensitive)] = (normalized, case_insensitive) + + add(direct, _is_case_insensitive_sql_path(direct)) + add(comparable, _is_case_insensitive_sql_path(comparable)) + drive = re.match(r"^([a-z]):(?:/(.*))?$", comparable, re.IGNORECASE) + if drive: + add(f"/mnt/{drive.group(1).lower()}{f'/{drive.group(2)}' if drive.group(2) else ''}", True) + return sorted(values.values(), key=lambda item: (item[1], item[0])) + + +def _is_case_insensitive_sql_path(value: str) -> bool: + return bool( + re.match(r"^[a-z]:(?:/|$)", value, re.IGNORECASE) + or re.match(r"^/mnt/[a-z](?:/|$)", value, re.IGNORECASE) + or re.match(r"^//[^/]+/[^/]+", value) + ) def _normalize_path(value: str | Path) -> str: - text = str(value).replace("\\", "/").rstrip("/").lower() - match = re.match(r"^/mnt/([a-z])/(.+)$", text) - if match: - text = f"{match.group(1)}:/{match.group(2)}" - if os.name == "nt" and text.startswith("/") and re.match(r"^/[a-z]:/", text): - text = text[1:] - return text + slashed = str(value).strip().replace("\\", "/") + if not slashed: + return "" + + # Windows drives and their WSL mount spellings share case-insensitive + # identity. Preserve a root slash so C:/ never degrades into drive-relative + # C:. POSIX paths remain case-sensitive on every host platform. + wsl_drive = re.match(r"^/mnt/([a-z])(?:/(.*))?$", slashed, re.IGNORECASE) + if wsl_drive: + rest = re.sub(r"/+$", "", wsl_drive.group(2) or "") + return f"{wsl_drive.group(1)}:/{rest}".lower() + windows_drive = re.match(r"^/?([a-z]):/(.*)$", slashed, re.IGNORECASE) + if windows_drive: + rest = re.sub(r"/+$", "", windows_drive.group(2)) + return f"{windows_drive.group(1)}:/{rest}".lower() + + without_trailing_slashes = re.sub(r"/+$", "", slashed) + normalized = without_trailing_slashes or ("/" if slashed.startswith("/") else "") + if re.match(r"^//[^/]+/[^/]+", normalized): + return normalized.lower() + return normalized def _workspace_needles(workspace: str | Path) -> list[str]: - normalized = _normalize_path(workspace) + # Hints are deliberately case-folded free text; explicit metadata above is + # compared with filesystem semantics before this fallback is considered. + normalized = _normalize_path(workspace).lower() basename = Path(workspace).name.lower() needles = {normalized} if len(basename) >= 6 and re.search(r"[-_]", basename): @@ -1258,16 +1725,18 @@ def _metadata_string(metadata: dict[str, Any], key: str) -> str | None: def _message_content(message: dict[str, Any] | None) -> str | None: + parts = list(_message_content_fragments(message)) + return "\n".join(parts) if parts else None + + +def _message_content_fragments(message: dict[str, Any] | None) -> Generator[str, None, None]: content = message.get("content") if message else None if isinstance(content, str): - return content - if isinstance(content, list): - parts = [] + yield content + elif isinstance(content, list): for item in content: if isinstance(item, dict) and isinstance(item.get("text"), str): - parts.append(item["text"]) - return "\n".join(parts) - return None + yield item["text"] def _bounded_text(text: str, *, max_bytes: int, tail: bool) -> str: diff --git a/backend/app.py b/backend/app.py index eacaa3a..db6ab83 100644 --- a/backend/app.py +++ b/backend/app.py @@ -7,7 +7,9 @@ import re import subprocess import tempfile +import threading import time +from contextlib import asynccontextmanager from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict from datetime import datetime, timezone @@ -28,6 +30,7 @@ from .executor import ExecutionResult, RunExecutor from .hermes import HermesManager from .memory import HermesMemoryStore +from .memory_admission import LaunchMemoryAdmission from .runs import Run, RunRegistry, RunStatus from .runtime import RuntimeLimits, adapter_statuses, check_runtime_limits from .safety import resolve_project_dir @@ -45,9 +48,9 @@ class MemoryDeleteRequest(BaseModel): class SpawnAgentRequest(BaseModel): agent_type: str = "codex" project_dir: str - task: str = Field(min_length=1) - memory_query: str | None = None - timeout_seconds: float | None = Field(default=None, gt=0) + task: str = Field(min_length=1, max_length=200000) + memory_query: str | None = Field(default=None, max_length=20000) + timeout_seconds: float | None = Field(default=None, gt=0, le=3600) class HermesInstallRequest(BaseModel): @@ -121,9 +124,25 @@ def create_app( executor: RunExecutor | None = None, adapters: dict[str, AgentAdapter] | None = None, limits: RuntimeLimits | None = None, + memory_admission: LaunchMemoryAdmission | None = None, execute_inline: bool = False, ) -> FastAPI: - app = FastAPI(title="Context Workspace Backend") + @asynccontextmanager + async def lifespan(application: FastAPI): + try: + yield + finally: + application.state.executor.begin_shutdown() + # Prevent queued work from entering the executor after its process + # snapshot. Running work either appears in that snapshot or sees + # the executor's post-Popen shutdown gate and reaps itself. + application.state.pool.shutdown(wait=False, cancel_futures=True) + application.state.executor.shutdown() + # Waiting last drains bookkeeping for the workers whose process + # groups were just terminated, without admitting queued launches. + application.state.pool.shutdown(wait=True, cancel_futures=True) + + app = FastAPI(title="Context Workspace Backend", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(127\.0\.0\.1|localhost)(:\d+)?$", @@ -138,9 +157,17 @@ def create_app( app.state.context_bundles = ContextBundleStore() app.state.adapters = adapters or {"codex": CodexAdapter(), "grok": GrokAdapter()} app.state.limits = limits or RuntimeLimits() + app.state.memory_admission = ( + memory_admission if memory_admission is not None else LaunchMemoryAdmission() + ) app.state.pool = ThreadPoolExecutor(max_workers=4) app.state.execute_inline = execute_inline app.state.all_sessions_cache = {} + app.state.project_sessions_cache = {} + # Both session endpoints traverse the same provider corpora. A shared lock + # coalesces cold misses across project and all-workspace callers instead of + # allowing each surface to duplicate the scan concurrently. + app.state.session_scan_lock = threading.Lock() @app.get("/health") def health() -> dict[str, str]: @@ -386,18 +413,40 @@ def list_agent_sessions( provider: str | None = Query(default=None), q: str = Query(default=""), limit: int = Query(default=100, ge=1, le=500), + refresh: bool = Query(default=False), ) -> dict[str, Any]: try: project = _resolve_read_project_dir(project_dir) session_provider = _session_provider(provider) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - sessions = list_native_agent_sessions(project, provider=session_provider, query=q, limit=limit) - return { - "project_dir": str(project), - "sessions": [session.payload() for session in sessions], - "summary": format_agent_sessions_summary(sessions), - } + cache_key = (str(project), session_provider or "", q, limit) + # Project-scoped MCP callers previously bypassed the only sessions + # cache and could launch the same provider corpus scan concurrently. + # Serialize cache misses so a burst coalesces into one bounded scan. + with app.state.session_scan_lock: + now = time.monotonic() + cached = app.state.project_sessions_cache.get(cache_key) + if ( + not refresh + and cached is not None + and now - cached["created_monotonic"] < ALL_SESSIONS_CACHE_TTL_SECONDS + ): + return dict(cached["payload"]) + sessions = list_native_agent_sessions(project, provider=session_provider, query=q, limit=limit) + payload = { + "project_dir": str(project), + "sessions": [session.payload() for session in sessions], + "summary": format_agent_sessions_summary(sessions), + } + app.state.project_sessions_cache[cache_key] = {"created_monotonic": now, "payload": payload} + if len(app.state.project_sessions_cache) > ALL_SESSIONS_CACHE_MAX_ENTRIES: + oldest_key = min( + app.state.project_sessions_cache, + key=lambda key: app.state.project_sessions_cache[key]["created_monotonic"], + ) + del app.state.project_sessions_cache[oldest_key] + return payload @app.get("/agents/sessions/all") def list_all_agent_sessions( @@ -412,38 +461,41 @@ def list_all_agent_sessions( except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc cache_key = (session_provider or "", q, limit) - now = time.monotonic() - cached = app.state.all_sessions_cache.get(cache_key) - if ( - not refresh - and cached is not None - and now - cached["created_monotonic"] < ALL_SESSIONS_CACHE_TTL_SECONDS - ): - payload = dict(cached["payload"]) - payload["cache"] = { - "hit": True, - "ttl_seconds": ALL_SESSIONS_CACHE_TTL_SECONDS, - "age_seconds": now - cached["created_monotonic"], + with app.state.session_scan_lock: + # Recheck after acquiring the lock: another endpoint/request may + # have populated the cache while this request was waiting. + now = time.monotonic() + cached = app.state.all_sessions_cache.get(cache_key) + if ( + not refresh + and cached is not None + and now - cached["created_monotonic"] < ALL_SESSIONS_CACHE_TTL_SECONDS + ): + payload = dict(cached["payload"]) + payload["cache"] = { + "hit": True, + "ttl_seconds": ALL_SESSIONS_CACHE_TTL_SECONDS, + "age_seconds": now - cached["created_monotonic"], + } + return payload + + sessions = list_native_agent_sessions(None, provider=session_provider, query=q, limit=limit) + payload = { + "project_dir": None, + "sessions": [session.payload() for session in sessions], + "summary": format_agent_sessions_summary(sessions), + } + app.state.all_sessions_cache[cache_key] = {"created_monotonic": now, "payload": payload} + if len(app.state.all_sessions_cache) > ALL_SESSIONS_CACHE_MAX_ENTRIES: + oldest_key = min( + app.state.all_sessions_cache, + key=lambda key: app.state.all_sessions_cache[key]["created_monotonic"], + ) + del app.state.all_sessions_cache[oldest_key] + return { + **payload, + "cache": {"hit": False, "ttl_seconds": ALL_SESSIONS_CACHE_TTL_SECONDS, "age_seconds": 0.0}, } - return payload - - sessions = list_native_agent_sessions(None, provider=session_provider, query=q, limit=limit) - payload = { - "project_dir": None, - "sessions": [session.payload() for session in sessions], - "summary": format_agent_sessions_summary(sessions), - } - app.state.all_sessions_cache[cache_key] = {"created_monotonic": now, "payload": payload} - if len(app.state.all_sessions_cache) > ALL_SESSIONS_CACHE_MAX_ENTRIES: - oldest_key = min( - app.state.all_sessions_cache, - key=lambda key: app.state.all_sessions_cache[key]["created_monotonic"], - ) - del app.state.all_sessions_cache[oldest_key] - return { - **payload, - "cache": {"hit": False, "ttl_seconds": ALL_SESSIONS_CACHE_TTL_SECONDS, "age_seconds": 0.0}, - } @app.get("/agents/sessions/{provider}/{session_id}/transcript", response_class=PlainTextResponse) def get_agent_session_transcript( @@ -481,6 +533,12 @@ def spawn_agent(request: SpawnAgentRequest, background_tasks: BackgroundTasks) - if not limit_decision.allowed: raise HTTPException(status_code=429, detail=limit_decision.reason) + memory_decision = app.state.memory_admission.reserve() + if not memory_decision.allowed: + # This must happen before create_run: a rejected launch may not + # consume an agent id, occupy a registry slot, or write artifacts. + raise HTTPException(status_code=429, detail=memory_decision.reason) + try: run = app.state.registry.create_run( agent_type=agent_type, @@ -488,6 +546,7 @@ def spawn_agent(request: SpawnAgentRequest, background_tasks: BackgroundTasks) - task=request.task, ) except ValueError as exc: + app.state.memory_admission.release(memory_decision.reservation_id) raise HTTPException(status_code=400, detail=str(exc)) from exc memory_excerpt = "" @@ -496,19 +555,28 @@ def spawn_agent(request: SpawnAgentRequest, background_tasks: BackgroundTasks) - if timeout_seconds is None: timeout_seconds = app.state.limits.default_timeout_seconds - if app.state.execute_inline: - _execute_and_record(app.state.executor, app.state.memory, run, adapter, memory_excerpt, timeout_seconds) - else: - background_tasks.add_task( - app.state.pool.submit, - _execute_and_record, - app.state.executor, - app.state.memory, - run, - adapter, - memory_excerpt, - timeout_seconds, - ) + try: + if app.state.execute_inline: + _execute_and_record(app.state.executor, app.state.memory, run, adapter, memory_excerpt, timeout_seconds) + # Inline execution has already reached a terminal state, so + # no delayed descendant allocation remains to reserve for. + app.state.memory_admission.release(memory_decision.reservation_id) + else: + background_tasks.add_task( + app.state.pool.submit, + _execute_and_record, + app.state.executor, + app.state.memory, + run, + adapter, + memory_excerpt, + timeout_seconds, + ) + # Async launches retain the short TTL reservation while the + # child and its MCP descendants grow toward steady-state RSS. + except Exception: + app.state.memory_admission.release(memory_decision.reservation_id) + raise return {"run": _run_payload(run)} diff --git a/backend/executor.py b/backend/executor.py index 6443982..400d638 100644 --- a/backend/executor.py +++ b/backend/executor.py @@ -3,15 +3,22 @@ from __future__ import annotations import os +import signal import subprocess import threading +import time from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO from .adapters.base import AgentAdapter from .context_artifacts import ContextArtifactWriter, RunArtifacts from .runs import Run, RunRegistry, RunStatus +LATE_SHUTDOWN_PROCESS_GRACE_SECONDS = 0.5 + + @dataclass(frozen=True) class ExecutionResult: run: Run @@ -26,11 +33,17 @@ def __init__( *, registry: RunRegistry, artifacts: ContextArtifactWriter | None = None, + max_log_bytes: int | None = None, ) -> None: self.registry = registry self.artifacts = artifacts or ContextArtifactWriter() - self._processes: dict[str, subprocess.Popen[str]] = {} + configured_max = max_log_bytes if max_log_bytes is not None else int( + os.environ.get("CONTEXT_WORKSPACE_RUN_LOG_MAX_BYTES", str(4 * 1024 * 1024)) + ) + self.max_log_bytes = max(64 * 1024, configured_max) + self._processes: dict[str, subprocess.Popen[bytes]] = {} self._process_lock = threading.Lock() + self._shutting_down = False def execute( self, @@ -43,7 +56,7 @@ def execute( artifacts = self.artifacts.write_context(run, memory_excerpt=memory_excerpt) self.artifacts.initialize_logs(run) command = adapter.build_command(run, artifacts) - if self.registry.cancel_requested(run.run_id): + if self.registry.cancel_requested(run.run_id) or self._shutdown_requested(): cancelled = self.registry.update_status(run.run_id, RunStatus.CANCELLED) return ExecutionResult( run=cancelled, @@ -53,6 +66,19 @@ def execute( ) self.registry.update_status(run.run_id, RunStatus.RUNNING) + # ``build_command`` may do non-trivial work. Close the gap between the + # first shutdown check and Popen without holding a lock across process + # creation: if shutdown begins immediately after this check, the + # post-Popen gate below owns termination and reaping. + if self._shutdown_requested(): + cancelled = self.registry.update_status(run.run_id, RunStatus.CANCELLED) + return ExecutionResult( + run=cancelled, + artifacts=artifacts, + returncode=-2, + summary=f"{run.agent_id} was cancelled during shutdown.", + ) + try: try: process = subprocess.Popen( @@ -62,7 +88,8 @@ def execute( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + start_new_session=os.name == "posix", + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, ) except OSError as exc: # The agent binary is missing or not executable. Without this @@ -80,22 +107,38 @@ def execute( ) with self._process_lock: self._processes[run.run_id] = process - if self.registry.cancel_requested(run.run_id): - process.terminate() - stdout, stderr = process.communicate( - input=command.stdin, - timeout=timeout_seconds, - ) + shutdown_after_start = self._shutting_down + drain_threads = [ + self._start_stream_drain(process.stdout, artifacts.stdout), + self._start_stream_drain(process.stderr, artifacts.stderr), + ] + stdin_thread = self._start_stdin_write(process.stdin, command.stdin) + if shutdown_after_start: + # shutdown() may already have taken an empty process snapshot + # while this Popen was in progress. This worker therefore + # owns bounded termination and reaping; it must not fall into + # the run's potentially hour-long timeout if SIGTERM is + # ignored. + self._terminate_and_reap( + process, + grace_seconds=LATE_SHUTDOWN_PROCESS_GRACE_SECONDS, + ) + elif self.registry.cancel_requested(run.run_id): + self._terminate_process_tree(process) + process.wait(timeout=timeout_seconds) returncode = process.returncode - except subprocess.TimeoutExpired as exc: - process.terminate() + cancelled_during_shutdown = shutdown_after_start or self._shutdown_requested() + except subprocess.TimeoutExpired: + self._terminate_process_tree(process) try: - stdout, stderr = process.communicate(timeout=5) + process.wait(timeout=5) except subprocess.TimeoutExpired: - process.kill() - stdout, stderr = process.communicate() - artifacts.stdout.write_text(stdout or exc.stdout or "", encoding="utf-8") - artifacts.stderr.write_text(stderr or exc.stderr or "Process timed out.", encoding="utf-8") + self._terminate_process_tree(process, force=True) + process.wait() + for thread in drain_threads: + thread.join(timeout=5) + if artifacts.stderr.stat().st_size == 0: + artifacts.stderr.write_text("Process timed out.\n", encoding="utf-8") failed = self.registry.update_status(run.run_id, RunStatus.FAILED) return ExecutionResult( run=failed, @@ -104,13 +147,15 @@ def execute( summary=f"{run.agent_id} timed out.", ) finally: + stdin_writer = locals().get("stdin_thread") + if stdin_writer is not None: + stdin_writer.join(timeout=5) + for thread in locals().get("drain_threads", []): + thread.join(timeout=5) with self._process_lock: self._processes.pop(run.run_id, None) - artifacts.stdout.write_text(stdout, encoding="utf-8") - artifacts.stderr.write_text(stderr, encoding="utf-8") - - if self.registry.cancel_requested(run.run_id): + if self.registry.cancel_requested(run.run_id) or cancelled_during_shutdown: updated = self.registry.update_status(run.run_id, RunStatus.CANCELLED) return ExecutionResult( run=updated, @@ -133,6 +178,152 @@ def cancel(self, run_id: str) -> bool: process = self._processes.get(run_id) if process is None or process.poll() is not None: return False - process.terminate() + self._terminate_process_tree(process) return True + def begin_shutdown(self) -> None: + """Permanently close the executor's process-admission gate.""" + + with self._process_lock: + self._shutting_down = True + + def shutdown(self, *, grace_seconds: float = 3.0) -> None: + self.begin_shutdown() + with self._process_lock: + processes = list(self._processes.values()) + for process in processes: + if process.poll() is None: + self._terminate_process_tree(process) + deadline = time.monotonic() + max(0.0, grace_seconds) + for process in processes: + if process.poll() is not None: + continue + try: + process.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + self._terminate_process_tree(process, force=True) + for process in processes: + if process.poll() is not None: + continue + try: + process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + # The process group has received a hard kill. Do not let an + # uncooperative platform-specific wait block backend shutdown. + pass + + def _shutdown_requested(self) -> bool: + with self._process_lock: + return self._shutting_down + + @classmethod + def _terminate_and_reap( + cls, + process: subprocess.Popen[bytes], + *, + grace_seconds: float, + ) -> None: + if process.poll() is not None: + process.wait() + return + cls._terminate_process_tree(process) + try: + process.wait(timeout=max(0.0, grace_seconds)) + return + except subprocess.TimeoutExpired: + cls._terminate_process_tree(process, force=True) + # A hard-killed child must be collected here because the global + # shutdown snapshot never saw it. Waiting after SIGKILL is reaping, + # not an additional graceful-shutdown window. + process.wait() + + def _start_stream_drain(self, stream: BinaryIO | None, destination: Path) -> threading.Thread: + if stream is None: + raise RuntimeError("Agent process was started without a captured output stream.") + thread = threading.Thread( + target=_drain_stream_bounded, + args=(stream, destination, self.max_log_bytes), + name=f"athena-run-log-{destination.name}", + daemon=True, + ) + thread.start() + return thread + + @staticmethod + def _start_stdin_write(stream: BinaryIO | None, value: str) -> threading.Thread: + if stream is None: + raise RuntimeError("Agent process was started without an input stream.") + thread = threading.Thread( + target=_write_stdin, + args=(stream, value), + name="athena-run-stdin", + daemon=True, + ) + thread.start() + return thread + + @staticmethod + def _terminate_process_tree(process: subprocess.Popen[bytes], *, force: bool = False) -> None: + if process.poll() is not None: + return + try: + if os.name == "posix": + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + elif os.name == "nt": + command = ["taskkill", "/PID", str(process.pid), "/T"] + if force: + command.append("/F") + subprocess.run(command, check=False, capture_output=True) + elif force: + process.kill() + else: + process.terminate() + except (OSError, subprocess.SubprocessError): + try: + process.kill() if force else process.terminate() + except OSError: + pass + + +def _drain_stream_bounded(stream: BinaryIO, destination: Path, max_bytes: int) -> None: + written = 0 + dropped = 0 + try: + with destination.open("wb") as target: + while True: + chunk = stream.read(64 * 1024) + if not chunk: + break + remaining = max(0, max_bytes - written) + if remaining: + kept = chunk[:remaining] + target.write(kept) + written += len(kept) + dropped += max(0, len(chunk) - remaining) + if dropped: + target.write(f"\n[Athena truncated {dropped} output bytes]\n".encode("utf-8")) + finally: + stream.close() + + +def _write_stdin(stream: BinaryIO, value: str) -> None: + try: + # Keep the executor thread free to enforce timeout/cancellation even if + # a child never drains its pipe. Modest chunks also avoid a second full + # encoded copy of a large prompt. + for offset in range(0, len(value), 64 * 1024): + data = value[offset : offset + 64 * 1024].encode("utf-8") + view = memoryview(data) + while view: + written = stream.write(view) + if written is None: + break + view = view[written:] + stream.flush() + except (BrokenPipeError, OSError, ValueError): + pass + finally: + try: + stream.close() + except OSError: + pass diff --git a/backend/memory_admission.py b/backend/memory_admission.py new file mode 100644 index 0000000..fd3c81b --- /dev/null +++ b/backend/memory_admission.py @@ -0,0 +1,255 @@ +"""Physical-memory admission for heavyweight legacy agent launches. + +The Electron terminal path has its own launch admission service. The backend +``POST /agents/spawn`` route is a separate, non-visible execution surface, so it +must reserve physical headroom independently instead of assuming the desktop +gate ran first. +""" + +from __future__ import annotations + +import ctypes +import os +import sys +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + + +MIB = 1024 * 1024 +GIB = 1024 * MIB + +# The measured Codex process group was about 621 MiB including helper and MCP +# descendants. Match the Electron-side reservation so either launch surface +# makes the same conservative capacity decision. +DEFAULT_LAUNCH_RESERVATION_BYTES = 640 * MIB +DEFAULT_MINIMUM_HEADROOM_BYTES = 1 * GIB +DEFAULT_RESERVATION_TTL_SECONDS = 15.0 + + +@dataclass(frozen=True) +class MemoryAdmissionDecision: + allowed: bool + available_bytes: int | None + requested_bytes: int + reserved_bytes: int + projected_available_bytes: int | None + reservation_id: str | None + reason: str + + +@dataclass(frozen=True) +class _Reservation: + bytes: int + expires_at: float + + +class _WindowsMemoryStatusEx(ctypes.Structure): + # Windows DWORD is always 32-bit even on 64-bit Python. Using c_ulong + # would be incorrect on non-Windows CI hosts where it may be 64-bit. + _fields_ = [ + ("dwLength", ctypes.c_uint32), + ("dwMemoryLoad", ctypes.c_uint32), + ("ullTotalPhys", ctypes.c_uint64), + ("ullAvailPhys", ctypes.c_uint64), + ("ullTotalPageFile", ctypes.c_uint64), + ("ullAvailPageFile", ctypes.c_uint64), + ("ullTotalVirtual", ctypes.c_uint64), + ("ullAvailVirtual", ctypes.c_uint64), + ("ullAvailExtendedVirtual", ctypes.c_uint64), + ] + + +class LaunchMemoryAdmission: + """Atomically checks and briefly reserves physical launch headroom. + + ``MemAvailable`` already includes reclaimable page cache. Swap is + deliberately absent from this model: admitting a heavyweight process only + because it fits in swap is the freeze mode this guard prevents. + """ + + def __init__( + self, + *, + probe: Callable[[], int | None] | None = None, + reservation_bytes: int = DEFAULT_LAUNCH_RESERVATION_BYTES, + minimum_headroom_bytes: int = DEFAULT_MINIMUM_HEADROOM_BYTES, + reservation_ttl_seconds: float = DEFAULT_RESERVATION_TTL_SECONDS, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._probe = probe if probe is not None else read_physical_available_bytes + self._reservation_bytes = max(1, int(reservation_bytes)) + self._minimum_headroom_bytes = max(0, int(minimum_headroom_bytes)) + self._reservation_ttl_seconds = max(0.001, float(reservation_ttl_seconds)) + self._clock = clock + self._reservations: dict[str, _Reservation] = {} + self._lock = threading.Lock() + + def reserve(self) -> MemoryAdmissionDecision: + """Check current physical headroom and reserve capacity as one action.""" + + with self._lock: + now = self._clock() + self._expire_locked(now) + available_bytes = self._safe_probe() + reserved_bytes = sum(reservation.bytes for reservation in self._reservations.values()) + projected = ( + None + if available_bytes is None + else available_bytes - reserved_bytes - self._reservation_bytes + ) + + # A failed platform probe must not make the backend permanently + # unusable. Runtime concurrency limits still apply, and a later + # healthy probe will restore physical-memory enforcement. + if available_bytes is not None and projected < self._minimum_headroom_bytes: + return MemoryAdmissionDecision( + allowed=False, + available_bytes=available_bytes, + requested_bytes=self._reservation_bytes, + reserved_bytes=reserved_bytes, + projected_available_bytes=max(0, projected), + reservation_id=None, + reason=( + "Agent launch blocked by physical-memory admission: " + f"{_format_gib(available_bytes)} MemAvailable, " + f"{_format_gib(reserved_bytes)} already reserved, and " + f"{_format_gib(self._minimum_headroom_bytes)} minimum headroom required. " + "Swap is not counted as launch capacity." + ), + ) + + reservation_id = f"backend-launch-{uuid4().hex}" + self._reservations[reservation_id] = _Reservation( + bytes=self._reservation_bytes, + expires_at=now + self._reservation_ttl_seconds, + ) + return MemoryAdmissionDecision( + allowed=True, + available_bytes=available_bytes, + requested_bytes=self._reservation_bytes, + reserved_bytes=reserved_bytes, + projected_available_bytes=None if projected is None else max(0, projected), + reservation_id=reservation_id, + reason=( + "Physical-memory probe unavailable; launch admitted with runtime limits." + if available_bytes is None + else "Physical-memory capacity reserved for agent launch." + ), + ) + + def release(self, reservation_id: str | None) -> bool: + if not reservation_id: + return False + with self._lock: + return self._reservations.pop(reservation_id, None) is not None + + def reserved_bytes(self) -> int: + with self._lock: + self._expire_locked(self._clock()) + return sum(reservation.bytes for reservation in self._reservations.values()) + + def _safe_probe(self) -> int | None: + try: + value = self._probe() + if value is None: + return None + value = int(value) + except (OSError, RuntimeError, TypeError, ValueError, OverflowError): + return None + return value if value >= 0 else None + + def _expire_locked(self, now: float) -> None: + expired = [ + reservation_id + for reservation_id, reservation in self._reservations.items() + if reservation.expires_at <= now + ] + for reservation_id in expired: + del self._reservations[reservation_id] + + +def parse_linux_mem_available(text: str) -> int | None: + """Return ``MemAvailable`` bytes from ``/proc/meminfo``. + + The parser intentionally ignores ``SwapFree`` and every other swap field. + """ + + for line in text.splitlines(): + key, separator, value = line.partition(":") + if not separator or key != "MemAvailable": + continue + fields = value.split() + if not fields: + return None + try: + amount = int(fields[0]) + except ValueError: + return None + if amount < 0: + return None + unit = fields[1].lower() if len(fields) > 1 else "kb" + if unit != "kb": + return None + return amount * 1024 + return None + + +def read_physical_available_bytes() -> int | None: + """Best-effort physical available memory, never including swap.""" + + if sys.platform == "win32": + return read_windows_physical_available_bytes() + + if sys.platform.startswith("linux"): + try: + return parse_linux_mem_available(Path("/proc/meminfo").read_text(encoding="ascii")) + except OSError: + return None + + # POSIX exposes the currently available physical pages without swap. + if hasattr(os, "sysconf"): + try: + pages = int(os.sysconf("SC_AVPHYS_PAGES")) + page_size = int(os.sysconf("SC_PAGE_SIZE")) + if pages >= 0 and page_size > 0: + return pages * page_size + except (OSError, TypeError, ValueError, OverflowError): + pass + + # Keep the guard fail-open on platforms without a standard-library + # physical-memory probe. Concurrency limits remain enforced. + return None + + +def read_windows_physical_available_bytes(kernel32: object | None = None) -> int | None: + """Return Windows physical availability from ``GlobalMemoryStatusEx``. + + ``ullAvailPageFile`` is intentionally ignored: page-file capacity is the + Windows analogue of swap and cannot safely justify another agent launch. + ``kernel32`` is injectable so the ABI mapping and failure path can be + validated on non-Windows CI. + """ + + if kernel32 is None: + try: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + except (AttributeError, OSError): + return None + + status = _WindowsMemoryStatusEx() + status.dwLength = ctypes.sizeof(_WindowsMemoryStatusEx) + try: + succeeded = getattr(kernel32, "GlobalMemoryStatusEx")(ctypes.byref(status)) + except (AttributeError, OSError, TypeError, ValueError, ctypes.ArgumentError): + return None + if not succeeded: + return None + return int(status.ullAvailPhys) + + +def _format_gib(value: int) -> str: + return f"{value / GIB:.1f} GiB" diff --git a/client/electron/agent-sessions.ts b/client/electron/agent-sessions.ts index 9117592..b6c1cc9 100644 --- a/client/electron/agent-sessions.ts +++ b/client/electron/agent-sessions.ts @@ -1,14 +1,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import type { EmbeddedTerminalSession } from "./embedded-terminal.js"; import { normalizeComparablePath } from "./platform.js"; import { querySqlite, type SqliteValue } from "./sqlite.js"; import { mapWithConcurrency, readFilePrefix } from "./file-prefix.js"; import { memoizeAsyncWithTtl } from "./ttl-cache.js"; import { claudeProjectPathCandidates } from "./terminal-restore-policy.js"; +import { sessionIndexClient } from "./session-index-client.js"; export type AgentSessionProvider = "codex" | "opencode" | "athena" | "claude" | "hermes" | "grok"; @@ -29,24 +28,75 @@ export type AgentSession = { metadata: Record; }; -const execFileAsync = promisify(execFile); const CACHE_TTL_MS = 30_000; const MAX_PROVIDER_ROWS = 1000; const MAX_JSONL_SCAN_DIRS = 160; const MAX_JSONL_SCAN_FILES = 1200; const SESSION_FILE_PREFIX_MAX_BYTES = 512_000; const SESSION_FILE_SCAN_CONCURRENCY = 8; -const sessionCache = new Map }>(); +export const AGENT_SESSION_CACHE_MAX_ENTRIES = 32; + +export class BoundedTtlPromiseCache { + readonly #entries = new Map }>(); + + constructor( + readonly maxEntries: number, + readonly ttlMs: number, + ) { + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new Error("maxEntries must be a positive integer."); + if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new Error("ttlMs must be non-negative."); + } + + getOrCreate(key: string, factory: () => Promise, now = Date.now()): Promise { + this.pruneExpired(now); + const cached = this.#entries.get(key); + if (cached) { + // Map insertion order is the LRU order; refresh it on every hit. + this.#entries.delete(key); + this.#entries.set(key, cached); + return cached.promise; + } + + const promise = factory(); + const entry = { expiresAt: now + this.ttlMs, promise }; + this.#entries.set(key, entry); + while (this.#entries.size > this.maxEntries) { + const oldest = this.#entries.keys().next().value as string | undefined; + if (oldest == null) break; + this.#entries.delete(oldest); + } + void promise.catch(() => { + if (this.#entries.get(key) === entry) this.#entries.delete(key); + }); + return promise; + } + + get size(): number { + return this.#entries.size; + } + + private pruneExpired(now: number): void { + for (const [key, entry] of this.#entries) { + if (entry.expiresAt <= now) this.#entries.delete(key); + } + } +} + +const sessionCache = new BoundedTtlPromiseCache(AGENT_SESSION_CACHE_MAX_ENTRIES, CACHE_TTL_MS); export function listAgentSessionsCached(workspace: string, liveTerminals: EmbeddedTerminalSession[] = []): Promise { const resolvedWorkspace = path.resolve(workspace); - const cached = sessionCache.get(resolvedWorkspace); - if (cached && cached.expiresAt > Date.now()) return cached.promise.then((sessions) => mergeLiveSessions(sessions, liveTerminals, resolvedWorkspace)); - const promise = listHistoricalAgentSessions(resolvedWorkspace); - sessionCache.set(resolvedWorkspace, { expiresAt: Date.now() + CACHE_TTL_MS, promise }); + const promise = sessionCache.getOrCreate( + resolvedWorkspace, + () => listHistoricalAgentSessions(resolvedWorkspace), + ); return promise.then((sessions) => mergeLiveSessions(sessions, liveTerminals, resolvedWorkspace)); } +export function getAgentSessionScanDiagnostics(): ReturnType { + return sessionIndexClient.getDiagnostics(); +} + async function listHistoricalAgentSessions(workspace: string): Promise { const [codex, opencode, athena, claude, hermes, grok] = await Promise.all([ readCodexSessions(workspace), @@ -95,12 +145,14 @@ async function readCodexSessions(workspace: string): Promise { const seenIds = new Set(); if (fs.existsSync(dbPath)) { + const workspaceFilter = workspaceSqlFilter("cwd", workspace); const rows = await querySqlite(dbPath, [ "select id, cwd, title, created_at_ms, updated_at_ms, git_branch, cli_version, first_user_message, model, agent_role", "from threads", + `where ${workspaceFilter.sql}`, "order by updated_at_ms desc", `limit ${MAX_PROVIDER_ROWS}`, - ].join(" "), []); + ].join(" "), workspaceFilter.params); for (const row of rows) { const id = stringValue(row[0]); if (!id) continue; @@ -289,13 +341,15 @@ function boundedUtf8(value: string, maxBytes: number): string { async function readOpenCodeSessions(workspace: string): Promise { const dbPath = path.join(os.homedir(), ".local", "share", "opencode", "opencode.db"); if (!fs.existsSync(dbPath)) return []; + const workspaceFilter = workspaceSqlFilter("coalesce(s.directory, p.worktree)", workspace); const rows = await querySqlite(dbPath, [ "select s.id, coalesce(s.directory, p.worktree), s.title, s.time_created, s.time_updated, s.agent, s.model, p.worktree", "from session s", "left join project p on s.project_id = p.id", + `where ${workspaceFilter.sql}`, "order by s.time_updated desc", `limit ${MAX_PROVIDER_ROWS}`, - ].join(" "), []); + ].join(" "), workspaceFilter.params); return rows.filter((row) => sameOrDescendantPath(stringValue(row[1]) || stringValue(row[7]) || workspace, workspace)).map((row): AgentSession => { const id = stringValue(row[0]); const model = parseOpenCodeModel(nullableString(row[6])); @@ -322,6 +376,7 @@ async function readOpenCodeSessions(workspace: string): Promise async function readAthenaSessions(workspace: string): Promise { const dbPath = path.join(process.env.ATHENA_CODE_HOME || path.join(os.homedir(), ".athena-code"), "context", "sessions.db"); if (!fs.existsSync(dbPath) || !await sqliteUserVersion(dbPath, 2)) return []; + const workspaceFilter = workspaceSqlFilter("m.workspace", workspace); const rows = await querySqlite(dbPath, [ "select m.session_id, m.workspace,", "(select text from messages first_user where first_user.agent = 'athena'", @@ -330,12 +385,12 @@ async function readAthenaSessions(workspace: string): Promise { "min(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end),", "max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end), count(*)", "from messages m", - "where m.agent = 'athena'", + `where m.agent = 'athena' and ${workspaceFilter.sql}`, "group by m.session_id, m.workspace", "order by (max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end) is null),", "max(case when ts glob '[12][0-9][0-9][0-9]-*' then ts end) desc, max(id) desc", `limit ${MAX_PROVIDER_ROWS}`, - ].join(" "), []); + ].join(" "), workspaceFilter.params); return rows.filter((row) => sameOrDescendantPath(stringValue(row[1]) || workspace, workspace)).map((row): AgentSession => { const id = stringValue(row[0]); const sessionWorkspace = stringValue(row[1]) || workspace; @@ -562,242 +617,19 @@ async function readClaudeSessionFile(filePath: string, workspace: string, option }; } -// The Hermes session corpus lives in a single, workspace-independent directory -// (~/.hermes plus its state.db) and is only narrowed to a workspace by the final -// match. Each cached entry therefore carries a workspace-agnostic session -// template alongside the metadata needed to test workspace membership; the only -// per-workspace field is `workspace`, applied when the entry is selected. -type HermesScanEntry = { - session: AgentSession; - metadata: HermesSessionFileMetadata | null; - manifestEntry: HermesManifestEntry | undefined; -}; - -// Scan the global corpus once per CACHE_TTL_MS and share it across workspaces. -// Without this, a multi-workspace review reran the entire scan โ€” re-reading and -// re-decoding every session file โ€” once per open workspace, multiplying peak -// heap by the workspace count. -const cachedHermesScan = memoizeAsyncWithTtl(CACHE_TTL_MS, scanHermesSessions); - async function readHermesSessions(workspace: string): Promise { - const entries = await cachedHermesScan(); - const matched: AgentSession[] = []; - for (const entry of entries) { - if (!hermesSessionMatchesWorkspace(entry.metadata, entry.manifestEntry, workspace)) continue; - matched.push({ ...entry.session, workspace }); - } - return matched - .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) - .slice(0, 100); -} - -async function scanHermesSessions(): Promise { - const hermesDir = await resolveHermesDir(); - if (!hermesDir) return []; - const dbPath = path.join(hermesDir, "state.db"); - const sessionsDir = path.join(hermesDir, "sessions"); - const manifest = await readHermesManifest(path.join(sessionsDir, "sessions.json")); - const entries = new Map(); - - if (fs.existsSync(dbPath)) { - const rows = await querySqlite(dbPath, [ - "select id, source, model, started_at, ended_at, message_count, title", - "from sessions", - "order by coalesce(ended_at, started_at) desc", - `limit ${MAX_PROVIDER_ROWS}`, - ].join(" "), []); - for (const row of rows) { - const id = stringValue(row[0]); - if (!id) continue; - const metadata = await readHermesSessionFile(path.join(sessionsDir, `session_${id}.json`)); - const manifestEntry = manifest.get(id); - const agent = hermesAgentLabel(nullableString(row[1]), manifestEntry, metadata); - const createdAt = fromEpoch(row[3]); - const updatedAt = row[4] ? fromEpoch(row[4]) : metadata?.updatedAt ?? manifestEntry?.updatedAt ?? createdAt; - entries.set(id, { - metadata, - manifestEntry, - session: { - id, - provider: "hermes", - title: cleanSessionTitle(nullableString(row[6]) || metadata?.title || manifestEntry?.title || null) || "Hermes session", - workspace: "", - branch: null, - model: nullableString(row[2]) || metadata?.model || null, - agent, - createdAt: metadata?.createdAt || manifestEntry?.createdAt || createdAt, - updatedAt, - status: "historical", - terminalId: null, - pid: null, - resumeCommand: `hermes --resume ${quoteShellArg(id)}`, - metadata: {}, - }, - }); - } - } - - // Fallback for installs without a session database: enumerate the on-disk - // session files directly. Mirrors the previous per-workspace fallback, hoisted - // to the shared scan (so it triggers when the database yields nothing at all). - if (entries.size === 0 && fs.existsSync(sessionsDir)) { - for (const name of await safeReadDir(sessionsDir)) { - const match = name.match(/^session_(.+)\.json$/); - if (!match || entries.has(match[1])) continue; - const filePath = path.join(sessionsDir, name); - const metadata = await readHermesSessionFile(filePath); - if (!metadata) continue; - const stat = await fs.promises.stat(filePath); - const manifestEntry = manifest.get(match[1]); - entries.set(match[1], { - metadata, - manifestEntry, - session: { - id: match[1], - provider: "hermes", - title: cleanSessionTitle(metadata.title || manifestEntry?.title || null) || "Hermes session", - workspace: "", - branch: null, - model: metadata.model, - agent: hermesAgentLabel(metadata.platform, manifestEntry, metadata), - createdAt: metadata.createdAt || manifestEntry?.createdAt || stat.birthtime.toISOString(), - updatedAt: metadata.updatedAt || manifestEntry?.updatedAt || stat.mtime.toISOString(), - status: "historical", - terminalId: null, - pid: null, - resumeCommand: `hermes --resume ${quoteShellArg(match[1])}`, - metadata: {}, - }, - }); - } - } - - return Array.from(entries.values()); -} - -async function resolveHermesDir(): Promise { - const native = path.join(os.homedir(), ".hermes"); - if (fs.existsSync(native)) return native; - if (process.platform !== "win32") return null; - try { - const { stdout } = await execFileAsync("wsl.exe", ["-e", "sh", "-lc", 'wslpath -w "$HOME/.hermes"'], { - encoding: "utf8", - timeout: 3000, - windowsHide: true, - }); - const candidate = stdout.trim().split(/\r?\n/)[0]; - return candidate && fs.existsSync(candidate) ? candidate : null; - } catch { - return null; - } -} - -type HermesManifestEntry = { - title: string | null; - createdAt: string | null; - updatedAt: string | null; - platform: string | null; - chatType: string | null; -}; - -type HermesSessionFileMetadata = { - title: string | null; - model: string | null; - platform: string | null; - createdAt: string | null; - updatedAt: string | null; - workspace: string | null; - searchText: string; -}; - -async function readHermesManifest(filePath: string): Promise> { - const manifest = new Map(); - const parsed = await readJsonObject(filePath); - if (!parsed) return manifest; - for (const value of Object.values(parsed)) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const entry = value as Record; - const sessionId = stringProperty(entry, "session_id"); - if (!sessionId) continue; - const origin = objectProperty(entry, "origin"); - manifest.set(sessionId, { - title: stringProperty(entry, "display_name") || stringProperty(origin, "chat_name") || stringProperty(entry, "session_key"), - createdAt: stringProperty(entry, "created_at"), - updatedAt: stringProperty(entry, "updated_at"), - platform: stringProperty(entry, "platform") || stringProperty(origin, "platform"), - chatType: stringProperty(entry, "chat_type") || stringProperty(origin, "chat_type"), - }); - } - return manifest; -} - -async function readHermesSessionFile(filePath: string): Promise { - const parsed = await readJsonObject(filePath); - if (!parsed) return null; - return { - title: firstHermesUserMessage(parsed), - model: stringProperty(parsed, "model"), - platform: stringProperty(parsed, "platform"), - createdAt: stringProperty(parsed, "session_start"), - updatedAt: stringProperty(parsed, "last_updated"), - workspace: hermesWorkspace(parsed), - searchText: JSON.stringify(parsed).slice(0, 300_000), - }; -} - -async function readJsonObject(filePath: string): Promise | null> { - try { - return parseJsonObject(await fs.promises.readFile(filePath, "utf8")); - } catch { - return null; - } -} - -function firstHermesUserMessage(session: Record): string | null { - const messages = session.messages; - if (!Array.isArray(messages)) return null; - for (const item of messages) { - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const message = item as Record; - if (stringProperty(message, "role") !== "user") continue; - return cleanSessionTitle(messageText(message)); - } - return null; -} - -function messageText(message: Record): string | null { - const content = message.content; - if (typeof content === "string") return content; - if (!Array.isArray(content)) return null; - return content - .map((item) => item && typeof item === "object" && !Array.isArray(item) ? stringProperty(item as Record, "text") : null) - .filter(Boolean) - .join("\n"); -} - -function hermesAgentLabel(source: string | null, manifestEntry?: HermesManifestEntry, metadata?: HermesSessionFileMetadata | null): string | null { - const platform = manifestEntry?.platform || metadata?.platform || source; - const chatType = manifestEntry?.chatType; - return [platform, chatType].filter(Boolean).join(" / ") || null; -} - -function hermesWorkspace(session: Record): string | null { - for (const key of ["workspace", "cwd", "project_dir", "projectDir", "project_path", "projectPath", "working_directory"]) { - const value = stringProperty(session, key); - if (value) return value; - } - const context = objectProperty(session, "context_workspace") || objectProperty(session, "contextWorkspace"); - return stringProperty(context, "project_dir") || stringProperty(context, "workspace"); -} - -function hermesSessionMatchesWorkspace(metadata: HermesSessionFileMetadata | null, manifestEntry: HermesManifestEntry | undefined, workspace: string): boolean { - if (metadata?.workspace && sameOrDescendantPath(metadata.workspace, workspace)) return true; - const text = normalizeSessionSearchText([ - metadata?.title, - metadata?.searchText, - manifestEntry?.title, - ].filter(Boolean).join("\n")); - return workspaceNeedles(workspace).some((needle) => text.includes(needle)); + const indexed = await sessionIndexClient.listHermes(workspace); + return indexed.map((session): AgentSession => ({ + ...session, + provider: "hermes", + workspace, + branch: null, + status: "historical", + terminalId: null, + pid: null, + resumeCommand: `hermes --resume ${quoteShellArg(session.id)}`, + metadata: {}, + })); } const warnedSessionIndexes = new Set(); @@ -855,27 +687,59 @@ function samePath(left: string, right: string): boolean { return normalizeComparablePath(left) === normalizeComparablePath(right); } -function sameOrDescendantPath(candidate: string, workspace: string): boolean { +export function sameOrDescendantPath(candidate: string, workspace: string): boolean { const child = normalizeComparablePath(candidate); const parent = normalizeComparablePath(workspace); if (!child || !parent) return false; if (child === parent) return true; - return parent === "/" ? child.startsWith("/") : child.startsWith(`${parent}/`); + return child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); +} + +/** + * Apply workspace narrowing before a provider's ORDER/LIMIT. The final + * sameOrDescendantPath check remains in JS as a correctness guard, while this + * SQL predicate prevents a busy unrelated workspace from consuming the global + * row budget first. Native Windows and WSL spellings are both included. + */ +export function workspaceSqlFilter(columnExpression: string, workspace: string): { sql: string; params: string[] } { + const slashedExpression = `replace(trim(coalesce(${columnExpression}, '')), char(92), '/')`; + const sensitiveExpression = `rtrim(${slashedExpression}, '/')`; + const insensitiveExpression = `lower(${sensitiveExpression})`; + const candidates = sqlWorkspaceCandidates(workspace); + const clauses: string[] = []; + const params: string[] = []; + for (const candidate of candidates) { + const expression = candidate.caseInsensitive ? insensitiveExpression : sensitiveExpression; + if (candidate.value === "/") { + // rtrim('/') is empty, so root must inspect the untrimmed spelling. + clauses.push(`substr(${slashedExpression}, 1, 1) = '/'`); + continue; + } + clauses.push(`(${expression} = ? or substr(${expression}, 1, length(?) + 1) = ? || '/')`); + params.push(candidate.value, candidate.value, candidate.value); + } + return { sql: clauses.length > 0 ? `(${clauses.join(" or ")})` : "0", params }; } -function workspaceNeedles(workspace: string): string[] { - const normalized = normalizeComparablePath(workspace).toLowerCase(); - const baseName = path.basename(workspace).toLowerCase(); - const needles = new Set([normalized]); - if (baseName.length >= 6 && /[-_]/.test(baseName)) { - needles.add(baseName); - needles.add(baseName.replace(/[-_]+/g, " ")); - } - return Array.from(needles).filter(Boolean); +function sqlWorkspaceCandidates(workspace: string): Array<{ value: string; caseInsensitive: boolean }> { + const direct = workspace.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/"; + const comparable = normalizeComparablePath(workspace) || "/"; + const candidates = new Map(); + const add = (value: string, caseInsensitive: boolean): void => { + const normalized = caseInsensitive ? value.toLowerCase() : value; + candidates.set(`${caseInsensitive ? "i" : "s"}:${normalized}`, { value: normalized, caseInsensitive }); + }; + add(direct, isCaseInsensitiveSqlPath(direct)); + add(comparable, isCaseInsensitiveSqlPath(comparable)); + const drive = /^([a-z]):(?:\/(.*))?$/i.exec(comparable); + if (drive) add(`/mnt/${drive[1].toLowerCase()}${drive[2] ? `/${drive[2]}` : ""}`, true); + return Array.from(candidates.values()); } -function normalizeSessionSearchText(value: string): string { - return value.toLowerCase().replace(/\\/g, "/").replace(/\/+/g, "/"); +function isCaseInsensitiveSqlPath(value: string): boolean { + return /^[a-z]:(?:\/|$)/i.test(value) + || /^\/mnt\/[a-z](?:\/|$)/i.test(value) + || /^\/\/[^/]+\/[^/]+/.test(value); } function fromEpoch(value: SqliteValue): string { diff --git a/client/electron/backend.ts b/client/electron/backend.ts index c34c777..ef926d5 100644 --- a/client/electron/backend.ts +++ b/client/electron/backend.ts @@ -45,6 +45,9 @@ export async function startBackend(appRoot: string): Promise { ["-m", "uvicorn", "backend.app:app", "--host", "127.0.0.1", "--port", String(port), "--no-access-log"], { cwd: backendParent, + // POSIX group ownership lets shutdown signal uvicorn and every child it + // may have spawned. Windows uses taskkill /T for the forced fallback. + detached: process.platform !== "win32", env: { ...process.env, CONTEXT_WORKSPACE_BACKEND_PORT: String(port), @@ -78,6 +81,7 @@ export async function startBackend(appRoot: string): Promise { } }); + const launchedProcess = backendProcess; backendProcess.on("error", (error) => { state = { ...state, @@ -86,7 +90,7 @@ export async function startBackend(appRoot: string): Promise { lastError: `Backend failed to start with ${python}: ${error.message}`, }; writeBackendDiscovery(); - backendProcess = null; + if (backendProcess === launchedProcess) backendProcess = null; }); backendProcess.on("exit", (code, signal) => { @@ -97,7 +101,7 @@ export async function startBackend(appRoot: string): Promise { lastError: `Backend exited: ${code ?? signal ?? "unknown"}`, }; writeBackendDiscovery(); - backendProcess = null; + if (backendProcess === launchedProcess) backendProcess = null; }); return waitForHealth(baseUrl); @@ -108,29 +112,74 @@ export async function restartBackend(appRoot: string): Promise { return startBackend(appRoot); } -export async function stopBackend(): Promise { +export async function stopBackend(): Promise { const processToStop = backendProcess; backendProcess = null; if (!processToStop) { state = { ...state, healthy: false, running: false }; writeBackendDiscovery(); - return; + return true; } - await new Promise((resolve) => { - const timeout = setTimeout(() => { - processToStop.kill(); - resolve(); - }, 3000); - processToStop.once("exit", () => { - clearTimeout(timeout); - resolve(); - }); - processToStop.kill(); - }); + signalBackendTree(processToStop, "SIGTERM"); + let exited = await waitForChildExit(processToStop, 3_000); + if (!exited) { + if (process.platform === "win32" && processToStop.pid) { + await forceKillWindowsTree(processToStop.pid); + } else { + signalBackendTree(processToStop, "SIGKILL"); + } + exited = await waitForChildExit(processToStop, 1_000); + } state = { ...state, healthy: false, running: false }; writeBackendDiscovery(); + return exited; +} + +function signalBackendTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean { + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, signal); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; + } + } + try { + return child.kill(signal); + } catch { + return false; + } +} + +function waitForChildExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + if (child.exitCode != null || child.signalCode != null) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const finish = (exited: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.removeListener("exit", onExit); + resolve(exited); + }; + const onExit = () => finish(true); + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + child.once("exit", onExit); + }); +} + +function forceKillWindowsTree(pid: number): Promise { + return new Promise((resolve) => { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); + killer.once("error", () => resolve()); + killer.once("exit", () => resolve()); + }); } export async function checkBackendHealth(): Promise { diff --git a/client/electron/control-server.ts b/client/electron/control-server.ts index c4f443a..85f122c 100644 --- a/client/electron/control-server.ts +++ b/client/electron/control-server.ts @@ -6,6 +6,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { + attachEmbeddedTerminalControlStream, findEmbeddedTerminal, getEmbeddedTerminalBuffer, killEmbeddedTerminal, @@ -15,11 +16,16 @@ import { spawnEmbeddedTerminal, submitEmbeddedTerminalInput, writeEmbeddedTerminalInputRaw, - subscribeEmbeddedTerminalData, type EmbeddedTerminalKind, type EmbeddedTerminalSession, } from "./embedded-terminal.js"; import { recordControlFailure } from "./control-events.js"; +import { + launchStaggerDelayMs, + publicLaunchAdmission, + reserveLaunchAdmission, + settleLaunchAdmission, +} from "./launch-admission.js"; import { evaluateControlAccess, sameControlPath, @@ -28,7 +34,6 @@ import { import { boundedTerminalBufferMaxChars, formatTerminalBuffer, - terminalBufferTail, } from "./terminal-buffer.js"; import { parseRawTerminalInputRequest, rawInputPreview } from "./terminal-input.js"; import { toWorkspacePath, type WorkspacePath } from "./platform.js"; @@ -59,6 +64,9 @@ type SpawnTerminalRequest = { model?: string; cols?: number; rows?: number; + /** Authenticated, explicit opt-in to launch despite critical memory pressure. */ + memory_override?: boolean; + memoryOverride?: boolean; }; type WriteTerminalRequest = { @@ -114,6 +122,7 @@ const CONTROL_HEALTH_FAILURE_THRESHOLD = 3; let controlToken: string | null = null; let server: http.Server | null = null; +const controlSockets = new Set(); let watchdog: NodeJS.Timeout | null = null; let watchdogRestartInFlight = false; let healthFailureCount = 0; @@ -166,6 +175,10 @@ export async function startControlServer(): Promise { const nextServer = http.createServer((request, response) => { void handleRequest(request, response); }); + nextServer.on("connection", (socket) => { + controlSockets.add(socket); + socket.once("close", () => controlSockets.delete(socket)); + }); await new Promise((resolve, reject) => { nextServer.once("error", reject); @@ -199,7 +212,7 @@ export async function restartControlServer(reason = "manual restart"): Promise((resolve) => serverToStop.close(() => resolve())); + await closeControlServer(serverToStop); } state = { baseUrl: null, @@ -212,18 +225,39 @@ export async function restartControlServer(reason = "manual restart"): Promise { +export async function stopControlServer(): Promise { stopControlWatchdog(); const serverToStop = server; server = null; if (!serverToStop) { state = { ...state, running: false }; writeControlDiscovery(); - return; + return true; } - await new Promise((resolve) => serverToStop.close(() => resolve())); + const closed = await closeControlServer(serverToStop); state = { ...state, running: false }; writeControlDiscovery(); + return closed; +} + +function closeControlServer(serverToStop: http.Server, timeoutMs = 1_000): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | null = null; + const finish = (closed: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(closed); + }; + serverToStop.close((error) => finish(!error)); + // SSE and keep-alive connections otherwise keep `close()` pending forever. + serverToStop.closeIdleConnections?.(); + serverToStop.closeAllConnections?.(); + for (const socket of controlSockets) socket.destroy(); + timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + }); } async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { @@ -333,35 +367,84 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) } if (request.method === "POST" && url.pathname === "/terminals/spawn") { const payload = parseSpawnTerminalRequest(await readJsonBody(request)); - if (payload.openWorkspace) { - payload.workspace = openWorkspaceInRenderer(payload.workspace, payload.selectWorkspace).nativePath; + const admission = reserveLaunchAdmission({ + source: "control", + kind: payload.kind, + count: payload.count, + // Reaching this route already requires the per-launch control token, so + // this flag is both explicit and authorized by the existing auth layer. + overrideCritical: payload.memoryOverride, + }); + if (!admission.granted) { + recordControlFailure({ + kind: "spawn.failed", + detail: admission.message, + preview: payload.task, + }); + sendJson(response, 429, { + error: admission.message, + admission: publicLaunchAdmission(admission), + retryable: true, + override: "Resubmit the authenticated request with memory_override: true to launch anyway.", + }); + return; } const sessions: EmbeddedTerminalSession[] = []; - for (let index = 0; index < payload.count; index += 1) { - const session = await spawnEmbeddedTerminal(payload.workspace, { - kind: payload.kind, - title: payload.count > 1 ? terminalGridTitle(payload.kind, index) : payload.title, - task: payload.task, - cols: payload.cols, - rows: payload.rows, - resumeSessionId: payload.resumeSessionId, - sessionLabel: payload.sessionLabel, - contextMode: payload.contextMode, - contextText: payload.contextText, - model: payload.model, - controlSource: "electron-control", - }).catch((error) => { - recordControlFailure({ - kind: "spawn.failed", - detail: String(error), - preview: payload.task, + let failedSession: EmbeddedTerminalSession | null = null; + try { + if (payload.openWorkspace) { + payload.workspace = openWorkspaceInRenderer(payload.workspace, payload.selectWorkspace).nativePath; + } + for (let index = 0; index < payload.count; index += 1) { + const staggerMs = launchStaggerDelayMs(payload.kind, index); + if (staggerMs > 0) await delay(staggerMs); + const session = await spawnEmbeddedTerminal(payload.workspace, { + kind: payload.kind, + title: payload.count > 1 ? terminalGridTitle(payload.kind, index) : payload.title, + task: payload.task, + cols: payload.cols, + rows: payload.rows, + resumeSessionId: payload.resumeSessionId, + sessionLabel: payload.sessionLabel, + contextMode: payload.contextMode, + contextText: payload.contextText, + model: payload.model, + controlSource: "electron-control", + }).catch((error) => { + recordControlFailure({ + kind: "spawn.failed", + detail: String(error), + preview: payload.task, + }); + throw error; }); - throw error; + if (session.status !== "running") { + failedSession = session; + recordControlFailure({ + kind: "spawn.failed", + detail: session.error ?? `Failed to launch ${session.title}.`, + preview: payload.task, + }); + break; + } + sessions.push(session); + } + } finally { + // Drop failed/unstarted capacity immediately. Successful capacity stays + // leased briefly because agent/MCP descendants allocate after node-pty's + // spawn promise resolves and are not yet visible in /proc at this point. + settleLaunchAdmission(admission, sessions.length); + } + if (failedSession) { + sendJson(response, 500, { + error: failedSession.error ?? `Failed to launch ${failedSession.title}.`, + failed: failedSession, + sessions, + admission: publicLaunchAdmission(admission), }); - sessions.push(session); - if ((payload.kind === "opencode" || payload.kind === "athena" || payload.kind === "grok") && index < payload.count - 1) await delay(650); + return; } - sendJson(response, 200, { sessions }); + sendJson(response, 200, { sessions, admission: publicLaunchAdmission(admission) }); return; } sendJson(response, 404, { error: `Unknown control endpoint: ${request.method} ${url.pathname}` }); @@ -431,6 +514,7 @@ function parseSpawnTerminalRequest(body: unknown): { model?: string; cols?: number; rows?: number; + memoryOverride: boolean; } { if (!body || typeof body !== "object") throw new Error("Request body must be an object."); const request = body as SpawnTerminalRequest; @@ -454,6 +538,7 @@ function parseSpawnTerminalRequest(body: unknown): { model: modelValue(request.model), cols: numberValue(request.cols), rows: numberValue(request.rows), + memoryOverride: booleanValue(request.memory_override ?? request.memoryOverride), }; } @@ -616,13 +701,13 @@ const SSE_HEARTBEAT_INTERVAL_MS = 15_000; const SSE_MAX_BACKLOG_BYTES = 1_000_000; /** - * Stream a terminal's live output as Server-Sent Events. The current rolling - * buffer is replayed first as a `snapshot` event (so a remote viewer matches the - * on-screen state, and an EventSource reconnect re-syncs instead of duplicating), - * then every subsequent PTY chunk is pushed as a `data` event. Chunks are base64 - * encoded because raw terminal output contains newlines and control bytes that - * would otherwise break SSE's line-based framing. Keystrokes continue to flow - * back over POST /terminals/write; this channel is output-only. + * Stream a terminal's sequenced output as Server-Sent Events. Atomic attach + * pauses a distinct hub consumer while its `snapshot` is queued, so output + * produced in that window is retained and follows at the next sequence. A slow + * consumer that exceeds its bounded hub queue receives another self-declaring + * `snapshot` reset instead of a silent gap. SSE ids carry epoch:sequence while + * payloads stay base64-compatible with existing clients. Keystrokes continue + * to flow back over POST /terminals/write; this channel is output-only. */ function streamEmbeddedTerminal( request: IncomingMessage, @@ -640,40 +725,62 @@ function streamEmbeddedTerminal( // An initial comment flushes headers so EventSource fires `open` right away. response.write(": athena-control stream\n\n"); - const send = (event: string, payloadBase64: string): void => { - if (closed) return; + const send = (event: string, payloadBase64: string, eventId?: string): boolean => { + if (closed) return false; if (response.writableLength > SSE_MAX_BACKLOG_BYTES) { cleanup(); response.destroy(new Error("Terminal stream backpressure exceeded.")); - return; + return false; + } + try { + response.write(`${eventId ? `id: ${eventId}\n` : ""}event: ${event}\ndata: ${payloadBase64}\n\n`); + return true; + } catch { + cleanup(); + return false; } - response.write(`event: ${event}\ndata: ${payloadBase64}\n\n`); }; let closed = false; - let unsubscribe: (() => void) | null = null; + let stream: ReturnType | null = null; let heartbeat: NodeJS.Timeout | null = null; const cleanup = (): void => { if (closed) return; closed = true; if (heartbeat) clearInterval(heartbeat); - unsubscribe?.(); + stream?.close(); }; - const snapshot = terminalBufferTail(getEmbeddedTerminalBuffer(terminalId), maxChars); - send("snapshot", Buffer.from(snapshot, "utf8").toString("base64")); - - unsubscribe = subscribeEmbeddedTerminalData( + const consumerId = `control-sse:${crypto.randomUUID()}`; + stream = attachEmbeddedTerminalControlStream( terminalId, - (chunk) => { - if (chunk) send("data", Buffer.from(chunk, "utf8").toString("base64")); + consumerId, + maxChars, + (delivery) => { + if (!delivery.data && !delivery.reset) return true; + return send( + delivery.reset ? "snapshot" : "data", + Buffer.from(delivery.data, "utf8").toString("base64"), + `${delivery.epoch}:${delivery.sequence}`, + ); }, - (exitCode) => { - send("exit", Buffer.from(JSON.stringify({ exitCode }), "utf8").toString("base64")); + ({ exitCode, epoch, throughSequence }) => { + send( + "exit", + Buffer.from(JSON.stringify({ exitCode }), "utf8").toString("base64"), + `${epoch}:${throughSequence}`, + ); cleanup(); response.end(); }, ); + const { snapshot } = stream; + if (!send( + "snapshot", + Buffer.from(snapshot.buffer, "utf8").toString("base64"), + `${snapshot.epoch}:${snapshot.throughSequence}`, + )) return; + stream.start(); heartbeat = setInterval(() => response.write(": keep-alive\n\n"), SSE_HEARTBEAT_INTERVAL_MS); heartbeat.unref?.(); diff --git a/client/electron/embedded-terminal.ts b/client/electron/embedded-terminal.ts index 2c12b4a..8ae7f7d 100644 --- a/client/electron/embedded-terminal.ts +++ b/client/electron/embedded-terminal.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { promisify } from "node:util"; -import { BrowserWindow } from "electron"; +import { BrowserWindow, type WebContents } from "electron"; import { buildAgentContextPrompt, resolveAgentContextMode, type AgentContextMode } from "./agent-context.js"; import { buildClaudeMcpConfig, @@ -42,11 +42,17 @@ import { getBackendState } from "./backend.js"; import { getControlState } from "./control-server.js"; import { terminalInputWritesForKind } from "./input-sequencing.js"; import { isTerminalRestorePaused, readAthenaLaunchState } from "./launch-state.js"; +import { + launchStaggerDelayMs, + reserveLaunchAdmission, + settleLaunchAdmission, +} from "./launch-admission.js"; import { ptyHost } from "./pty-host-client.js"; import { claudeProjectPathCandidates, codexSessionIdForWorkspace, effectiveCreationMs, + hasMissingSavedRestoreIdentity, openCodeDatabaseCandidates, openCodeSessionExists, openCodeSessionIdForWorkspace, @@ -66,11 +72,17 @@ import { recordTerminalInputActivity, recordTerminalOutputActivity, } from "./terminal-activity.js"; +import { TerminalAttentionTracker } from "./terminal-attention.js"; +import { + TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS, + TERMINAL_OUTPUT_MAX_GRACE_MS, + terminalOutputCleanupDecision, +} from "./terminal-output-cleanup.js"; import { - DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS, - appendBoundedTerminalOutput, -} from "./terminal-buffer.js"; -import { OutputAckGate } from "./terminal-output-ack.js"; + TerminalOutputStreamHub, + type TerminalStreamAttachSnapshot, + type TerminalStreamDelivery, +} from "./terminal-output-stream.js"; import { agentConfig, terminalLaunch } from "./terminal-launch.js"; import { resolveOpenCodeBaselineBinary, @@ -157,10 +169,14 @@ export function initEmbeddedTerminals(appRoot: string): void { startEventLoopMonitor(); } -export function prepareEmbeddedTerminalRestoreForQuit(): void { +export function prepareEmbeddedTerminalRestoreForQuit(): Promise { appQuitting = true; + return ptyHost.shutdown(); +} + +/** Clear restore crash-loop evidence only after main confirms PTY shutdown. */ +export function confirmEmbeddedTerminalRestoreShutdown(): void { clearRestoreAttempts(); - ptyHost.shutdown(); } /** @@ -173,9 +189,8 @@ export function hasPendingEmbeddedTerminalRestoreAttempts(): boolean { } const terminals = new Map(); -const outputBuffers = new Map(); const MAX_BUFFER_CHARS = 200_000; -const MAX_PENDING_OUTPUT_CHARS = DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS; +const RENDERER_REPLAY_MAX_CHARS = 64 * 1024; const PTY_FLUSH_INTERVAL_MS = 16; const EVENT_LOOP_SAMPLE_INTERVAL_MS = 1000; const CLAUDE_SESSION_DISCOVERY_ATTEMPTS = 20; @@ -187,8 +202,22 @@ const OPENCODE_SESSION_DISCOVERY_INTERVAL_MS = 750; // Grok stamps the session dir at creation; allow a little slack so a dir written // just before our spawn timestamp (clock skew / startup latency) still matches. const GROK_SESSION_DISCOVERY_SLACK_MS = 5_000; -const pendingOutput = new Map(); -const outputAckGate = new OutputAckGate(); +const outputStream = new TerminalOutputStreamHub({ maxSnapshotChars: MAX_BUFFER_CHARS }); +const terminalAttention = new TerminalAttentionTracker(); +type RendererOutputSubscription = { + sender: WebContents; + allTerminals: boolean; + terminalIds: Set; +}; +const rendererOutputSubscriptions = new Map(); +type ControlOutputConsumer = { + terminalId: string; + onDelivery: (delivery: TerminalStreamDelivery) => boolean; + onExit: (payload: { exitCode: number | null; epoch: string; throughSequence: number }) => void; +}; +const controlOutputConsumers = new Map(); +const outputCleanupTimers = new Map(); +const outputCleanupDeadlines = new Map(); let outputFlushTimer: NodeJS.Timeout | null = null; let outputFlushTimerDueAt = 0; let eventLoopMonitorTimer: NodeJS.Timeout | null = null; @@ -199,6 +228,7 @@ const perfCounters = { ptyBytes: 0, ipcBatches: 0, ipcBytes: 0, + hiddenRawIpcBytes: 0, lastBatchAt: null as string | null, sampleStartedAt: Date.now(), rates: { @@ -221,6 +251,17 @@ export type PerformanceDiagnostics = { eventLoopLagMs: number; maxEventLoopLagMs: number; lastOutputBatchAt: string | null; + rendererTerminalSubscribers: number; + hiddenRawIpcBytes: number; + terminalOutputRetries: number; + terminalOutputResets: number; + terminalOutputDroppedChars: number; + terminalOutputDeliveredChars: number; + terminalOutputAcknowledgedChars: number; + terminalReplayCount: number; + terminalReplayBytes: number; + terminalReplayDurationMs: number; + terminalReplayMaxDurationMs: number; controlEvents: ControlEvent[]; terminalControl: TerminalControlState[]; agentProcesses: AgentProcessDiagnostic[]; @@ -247,91 +288,115 @@ export function listEmbeddedAgentMessages(workspace?: string | null, limit?: num } export function getEmbeddedTerminalBuffer(id: string): string { - return outputBuffers.get(id) ?? ""; + return outputStream.getBuffer(id); } export function attachEmbeddedTerminalBuffer(id: string): string { - const buffer = getEmbeddedTerminalBuffer(id); - pendingOutput.delete(id); - outputAckGate.clear(id); - if (pendingOutput.size === 0) clearOutputFlushTimer(); - return buffer; + return getEmbeddedTerminalBuffer(id); } -type TerminalDataListener = (chunk: string) => void; -type TerminalExitListener = (exitCode: number | null) => void; +export function attachEmbeddedTerminalStream(id: string, sender: WebContents): TerminalStreamAttachSnapshot { + const subscription = ensureRendererOutputSubscription(sender); + subscription.terminalIds.add(id); + return outputStream.attach(id, rendererConsumerId(sender.id), { + replayMaxChars: RENDERER_REPLAY_MAX_CHARS, + }); +} -const dataListeners = new Map>(); -const exitListeners = new Map>(); +export type EmbeddedTerminalControlStream = { + snapshot: TerminalStreamAttachSnapshot; + start: () => void; + close: () => void; +}; /** - * Subscribe to live PTY output for a terminal from inside the main process. This - * powers the control server's SSE stream endpoint (remote viewers such as the - * mobile app) alongside the existing renderer IPC fan-out. The listener receives - * raw terminal chunks exactly as they are appended to the rolling buffer. - * Returns an unsubscribe function; `onExit` (if provided) fires once when the - * terminal exits or crashes. + * Atomically attach a distinct non-renderer consumer. The consumer starts + * paused so its snapshot can be queued first; output produced in that window is + * retained by sequence and released only after `start()`. */ -export function subscribeEmbeddedTerminalData( +export function attachEmbeddedTerminalControlStream( id: string, - onData: TerminalDataListener, - onExit?: TerminalExitListener, -): () => void { - let dataSet = dataListeners.get(id); - if (!dataSet) { - dataSet = new Set(); - dataListeners.set(id, dataSet); + consumerId: string, + replayMaxChars: number, + onDelivery: ControlOutputConsumer["onDelivery"], + onExit: ControlOutputConsumer["onExit"], +): EmbeddedTerminalControlStream { + if (controlOutputConsumers.has(consumerId)) { + throw new Error(`Terminal output consumer already exists: ${consumerId}`); } - dataSet.add(onData); + controlOutputConsumers.set(consumerId, { terminalId: id, onDelivery, onExit }); + const snapshot = outputStream.attach(id, consumerId, { replayMaxChars, paused: true }); + let closed = false; + return { + snapshot, + start: () => { + if (closed || !outputStream.resumeConsumer(id, consumerId)) return; + flushOutput(id); + }, + close: () => { + if (closed) return; + closed = true; + controlOutputConsumers.delete(consumerId); + outputStream.detach(id, consumerId); + if (!outputStream.hasPendingDeliveries()) clearOutputFlushTimer(); + }, + }; +} - if (onExit) { - let exitSet = exitListeners.get(id); - if (!exitSet) { - exitSet = new Set(); - exitListeners.set(id, exitSet); - } - exitSet.add(onExit); - } +export function subscribeEmbeddedTerminalOutput(id: string, sender: WebContents): void { + const subscription = ensureRendererOutputSubscription(sender); + subscription.terminalIds.add(id); + outputStream.subscribe(id, rendererConsumerId(sender.id)); +} - return () => { - const currentData = dataListeners.get(id); - if (currentData) { - currentData.delete(onData); - if (currentData.size === 0) dataListeners.delete(id); - } - if (onExit) { - const currentExit = exitListeners.get(id); - if (currentExit) { - currentExit.delete(onExit); - if (currentExit.size === 0) exitListeners.delete(id); - } - } - }; +export function unsubscribeEmbeddedTerminalOutput(id: string, senderId: number): void { + const subscription = rendererOutputSubscriptions.get(senderId); + if (!subscription) return; + subscription.terminalIds.delete(id); + if (!subscription.allTerminals) outputStream.detach(id, rendererConsumerId(senderId)); } -function notifyTerminalData(id: string, chunk: string): void { - const listeners = dataListeners.get(id); - if (!listeners) return; - for (const listener of listeners) { - try { - listener(chunk); - } catch { - // A failing stream subscriber must not break PTY fan-out to other clients. - } +export function subscribeAllEmbeddedTerminalOutput(sender: WebContents): void { + const subscription = ensureRendererOutputSubscription(sender); + subscription.allTerminals = true; + for (const id of outputStream.terminalIds()) { + outputStream.subscribe(id, rendererConsumerId(sender.id)); } } -function notifyTerminalExit(id: string, exitCode: number | null): void { - const listeners = exitListeners.get(id); - if (!listeners) return; - for (const listener of Array.from(listeners)) { - try { - listener(exitCode); - } catch { - // Ignore subscriber errors raised during stream teardown. +export function unsubscribeAllEmbeddedTerminalOutput(senderId: number): void { + const subscription = rendererOutputSubscriptions.get(senderId); + if (!subscription) return; + subscription.allTerminals = false; + for (const id of outputStream.terminalIds()) { + if (!subscription.terminalIds.has(id)) outputStream.detach(id, rendererConsumerId(senderId)); + } +} + +function ensureRendererOutputSubscription(sender: WebContents): RendererOutputSubscription { + let subscription = rendererOutputSubscriptions.get(sender.id); + if (subscription) return subscription; + subscription = { sender, allTerminals: false, terminalIds: new Set() }; + rendererOutputSubscriptions.set(sender.id, subscription); + sender.once("destroyed", () => { + rendererOutputSubscriptions.delete(sender.id); + outputStream.detachConsumer(rendererConsumerId(sender.id)); + }); + return subscription; +} + +function rendererConsumerId(senderId: number): string { + return `renderer:${senderId}`; +} + +function rendererTerminalSubscriberCount(): number { + let count = 0; + for (const terminalId of outputStream.terminalIds()) { + for (const consumerId of outputStream.terminalConsumerIds(terminalId)) { + if (consumerId.startsWith("renderer:")) count += 1; } } - exitListeners.delete(id); + return count; } export function findEmbeddedTerminal(target: string): EmbeddedTerminalSession | null { @@ -354,36 +419,89 @@ export async function restoreEmbeddedTerminals(allowedWorkspaces?: string[]): Pr const entries = readRestoreEntries(); const plan = selectEmbeddedTerminalRestoreEntries(entries, allowedWorkspaces, terminals.keys()); writeRestoreEntries([...plan.retained, ...plan.live]); - recordRestoreAttempts(plan.restore); for (const entry of plan.live) { const liveSession = terminals.get(entry.id)?.session; if (liveSession) restored.push({ ...liveSession }); } - if (plan.restore.length > 0) scheduleRestoreAttemptClear(); - for (const entry of plan.restore) { + let attemptedRestore = false; + for (let index = 0; index < plan.restore.length; index += 1) { + const entry = plan.restore[index]; if (!fs.existsSync(entry.workspace)) continue; - const resumeSessionId = await resolveRestoreResumeSessionId(entry); - const session = await spawnEmbeddedTerminal(entry.workspace, { - kind: entry.kind, - title: entry.title, - cols: 96, - rows: 28, - resumeSessionId: resumeSessionId ?? undefined, - sessionLabel: entry.sessionLabel ?? undefined, - // Without a resumable session the spawn starts a fresh provider - // session, so a saved provider id would be a stale, wrong binding. - providerSessionId: resumeSessionId ? entry.providerSessionId ?? resumeSessionId : undefined, - contextMode: "none", - controlSource: "restore", - }); - if (session.status === "running") restored.push(session); + const admission = reserveLaunchAdmission({ source: "restore", kind: entry.kind, count: 1 }); + if (!admission.granted) { + preserveDeferredRestoreEntries(plan.restore.slice(index)); + console.warn(`[Athena] ${admission.message} Saved restore entries were preserved for a later retry.`); + break; + } + + let launched = 0; + let stopAfterAttempt = false; + try { + const resumeSessionId = await resolveRestoreResumeSessionId(entry); + if (hasMissingSavedRestoreIdentity(entry, resumeSessionId)) { + // A known conversation disappearing from the provider store is not + // permission to silently replace it with a fresh conversation. Keep + // this entry for explicit user recovery while restoring independent + // entries after it. + preserveDeferredRestoreEntries([entry]); + console.warn( + `[Athena] Restore deferred for ${entry.title}: saved provider session identity is unavailable. ` + + "No fresh session was launched.", + ); + } else { + attemptedRestore = true; + recordRestoreAttempts([entry]); + const session = await spawnEmbeddedTerminal(entry.workspace, { + kind: entry.kind, + title: entry.title, + cols: 96, + rows: 28, + resumeSessionId: resumeSessionId ?? undefined, + sessionLabel: entry.sessionLabel ?? undefined, + // Without a resumable session the spawn starts a fresh provider + // session, so a saved provider id would be a stale, wrong binding. + providerSessionId: resumeSessionId ? entry.providerSessionId ?? resumeSessionId : undefined, + contextMode: "none", + controlSource: "restore", + }); + if (session.status === "running") { + launched = 1; + restored.push(session); + const settleMs = launchStaggerDelayMs(entry.kind, index + 1); + if (settleMs > 0 && index < plan.restore.length - 1) await delay(settleMs); + } else { + preserveDeferredRestoreEntries(plan.restore.slice(index)); + stopAfterAttempt = true; + } + } + } catch (error) { + // A provider/session-store failure must not consume the current entry or + // every entry after it. Preserve the unstarted plan before surfacing the + // error; the existing idempotency filter prevents a later retry from + // duplicating any terminal that did start. + preserveDeferredRestoreEntries(plan.restore.slice(index)); + throw error; + } finally { + settleLaunchAdmission(admission, launched); + } + if (stopAfterAttempt) break; } + if (attemptedRestore) scheduleRestoreAttemptClear(); return restored; } finally { restoreInFlight = false; } } +function preserveDeferredRestoreEntries(entries: RestorableTerminal[]): void { + const byId = new Map(); + for (const entry of entries) byId.set(entry.id, entry); + for (const entry of readRestoreEntries()) { + if (!byId.has(entry.id)) byId.set(entry.id, entry); + } + writeRestoreEntries(Array.from(byId.values()).slice(0, 40)); +} + async function resolveRestoreResumeSessionId(entry: RestorableTerminal): Promise { const savedSessionId = savedResumeSessionId(entry); if (savedSessionId && entry.kind === "claude") { @@ -433,8 +551,9 @@ export function clearSavedEmbeddedTerminalRestores(): number { export async function getPerformanceDiagnostics(): Promise { updatePerformanceRates(); - const pendingOutputBytes = Array.from(pendingOutput.values()).reduce((total, value) => total + Buffer.byteLength(value), 0); - const bufferedTerminalChars = Array.from(outputBuffers.values()).reduce((total, value) => total + value.length, 0); + const pendingOutputBytes = outputStream.pendingChars(); + const bufferedTerminalChars = outputStream.bufferedChars(); + const streamDiagnostics = outputStream.diagnostics(); const agentProcesses = await detectAgentProcesses(); return { activeTerminals: terminals.size, @@ -448,6 +567,17 @@ export async function getPerformanceDiagnostics(): Promise { flushOutput(id); - notifyTerminalExit(id, exitCode); const entry = terminals.get(id); if (!entry) return; entry.session = { ...entry.session, status: "exited", exitCode }; recordTerminalExited(id, exitCode); failQueuedAgentMessages(id, "Target terminal exited before delivery."); clearQueueDrain(id); - emit("embedded-terminal:exit", { id, exitCode }); + emitTerminalExit(id, exitCode); terminals.delete(id); clearTerminalActivity(id); if (!appQuitting) removeRestoreEntry(id); - clearTerminalOutputState(id); + scheduleTerminalOutputStateCleanup(id); }); ptyHost.on("error", ({ id, error }) => { if (!id) { @@ -981,7 +1109,6 @@ function installPtyHostListeners(): void { console.error("[Athena] PTY host crashed:", error); for (const id of ids) { flushOutput(id); - notifyTerminalExit(id, null); const entry = terminals.get(id); if (!entry) continue; entry.session = { ...entry.session, status: "failed", error }; @@ -996,22 +1123,93 @@ function installPtyHostListeners(): void { failQueuedAgentMessages(id, "Target terminal crashed before delivery."); clearQueueDrain(id); emit("embedded-terminal:session", entry.session); - emit("embedded-terminal:exit", { id, exitCode: null }); + emitTerminalExit(id, null); terminals.delete(id); clearTerminalActivity(id); if (!appQuitting) removeRestoreEntry(id); - clearTerminalOutputState(id); + scheduleTerminalOutputStateCleanup(id); } }); } function clearTerminalOutputState(id: string): void { - outputBuffers.delete(id); - pendingOutput.delete(id); - outputAckGate.clear(id); - dataListeners.delete(id); - exitListeners.delete(id); - if (pendingOutput.size === 0) clearOutputFlushTimer(); + const cleanupTimer = outputCleanupTimers.get(id); + if (cleanupTimer) clearTimeout(cleanupTimer); + outputCleanupTimers.delete(id); + outputCleanupDeadlines.delete(id); + outputStream.clearTerminal(id); + terminalAttention.clear(id); + for (const subscription of rendererOutputSubscriptions.values()) { + subscription.terminalIds.delete(id); + } + for (const [consumerId, consumer] of controlOutputConsumers) { + if (consumer.terminalId === id) controlOutputConsumers.delete(consumerId); + } + if (!outputStream.hasPendingDeliveries()) clearOutputFlushTimer(); +} + +function scheduleTerminalOutputStateCleanup(id: string): void { + const current = outputCleanupTimers.get(id); + if (current) clearTimeout(current); + const now = Date.now(); + const existingDeadline = outputCleanupDeadlines.get(id); + const deadline = existingDeadline ?? now + TERMINAL_OUTPUT_MAX_GRACE_MS; + outputCleanupDeadlines.set(id, deadline); + // Always retain a newly exited terminal for one ordinary grace interval so + // a renderer mounting just after the exit can still obtain its final screen. + const decision = existingDeadline == null + ? { clear: false, delayMs: Math.min(TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS, deadline - now) } + : terminalOutputCleanupDecision( + now, + deadline, + outputStream.hasPendingDeliveriesForTerminal(id), + ); + if (decision.clear) { + clearTerminalOutputState(id); + return; + } + // Give a renderer that is parsing a large final batch enough time for its + // drain ACK (including several lost-ACK retries) before releasing replay + // state. The previous immediate cleanup could discard the process's final + // output whenever it exited while another batch was in flight. + const timer = setTimeout(() => { + outputCleanupTimers.delete(id); + // Pending final sequences extend retention only to the hard tombstone + // deadline. A dead renderer can therefore neither lose ordinary slow-drain + // recovery nor retain buffers/retry timers forever. + scheduleTerminalOutputStateCleanup(id); + }, decision.delayMs); + timer.unref?.(); + outputCleanupTimers.set(id, timer); +} + +function emitTerminalExit(id: string, exitCode: number | null): void { + // Publish any incomplete final UTF-16 code unit as a sequenced replacement + // character, then flush it before taking the exit cursor. The renderer can + // therefore hold the exit marker until every final output sequence drains. + outputStream.finalize(id); + flushOutput(id); + const cursor = outputStream.cursor(id); + const payload = { + id, + exitCode, + epoch: cursor.epoch, + throughSequence: cursor.sequence, + }; + emit("embedded-terminal:exit", payload); + for (const consumer of Array.from(controlOutputConsumers.values())) { + if (consumer.terminalId !== id) continue; + try { + consumer.onExit({ + exitCode, + epoch: cursor.epoch, + throughSequence: cursor.sequence, + }); + } catch { + // Stream cleanup is owned by the consumer; one failed remote client must + // not interfere with renderer exit delivery or other terminals. + } + } } function restoreFilePath(): string { @@ -1158,17 +1356,15 @@ function isRestorableTerminal(value: unknown): value is RestorableTerminal { && (item.resumeSessionId == null || typeof item.resumeSessionId === "string"); } -function appendBuffer(id: string, data: string): void { - const next = `${outputBuffers.get(id) ?? ""}${data}`; - outputBuffers.set(id, next.length > MAX_BUFFER_CHARS ? next.slice(-MAX_BUFFER_CHARS) : next); -} - function queueOutput(id: string, data: string): void { updatePerformanceRates(); perfCounters.ptyChunks += 1; perfCounters.ptyBytes += Buffer.byteLength(data); - pendingOutput.set(id, appendBoundedTerminalOutput(pendingOutput.get(id) ?? "", data, MAX_PENDING_OUTPUT_CHARS)); - scheduleOutputFlush(PTY_FLUSH_INTERVAL_MS); + for (const [senderId, subscription] of rendererOutputSubscriptions) { + if (subscription.allTerminals) outputStream.subscribe(id, rendererConsumerId(senderId)); + } + outputStream.append(id, data); + if (outputStream.terminalConsumerIds(id).length > 0) scheduleOutputFlush(PTY_FLUSH_INTERVAL_MS); } function clearOutputFlushTimer(): void { @@ -1191,32 +1387,76 @@ function flushOutput(id?: string): void { clearOutputFlushTimer(); const now = Date.now(); - let retryDelayMs: number | null = null; - const entries = id ? [[id, pendingOutput.get(id) ?? ""] as const] : Array.from(pendingOutput.entries()); - for (const [terminalId, data] of entries) { - if (!data) continue; - if (!outputAckGate.canSend(terminalId, now)) { - const delay = outputAckGate.retryDelayMs(terminalId, now); - retryDelayMs = Math.min(retryDelayMs ?? Infinity, delay ?? PTY_FLUSH_INTERVAL_MS); - continue; - } - pendingOutput.delete(terminalId); - const sequence = outputAckGate.markSent(terminalId, now); + const deliveries = id ? outputStream.pollTerminal(id, now) : outputStream.poll(now); + for (const delivery of deliveries) { + if (!sendTerminalDelivery(delivery)) continue; perfCounters.ipcBatches += 1; - perfCounters.ipcBytes += Buffer.byteLength(data); + perfCounters.ipcBytes += Buffer.byteLength(delivery.data); perfCounters.lastBatchAt = new Date().toISOString(); - emit("embedded-terminal:data", { id: terminalId, data, sequence }); } - if (pendingOutput.size > 0) { - const hasUnprocessedPending = Boolean(id && Array.from(pendingOutput.keys()).some((terminalId) => terminalId !== id)); - scheduleOutputFlush(hasUnprocessedPending ? PTY_FLUSH_INTERVAL_MS : retryDelayMs ?? PTY_FLUSH_INTERVAL_MS); + if (outputStream.hasPendingDeliveries()) { + scheduleOutputFlush(outputStream.nextRetryDelayMs(now) ?? PTY_FLUSH_INTERVAL_MS); } } -export function acknowledgeEmbeddedTerminalOutput(id: string, sequence: number): void { - if (!outputAckGate.acknowledge(id, sequence)) return; - if (pendingOutput.has(id)) flushOutput(id); +function sendTerminalDelivery(delivery: TerminalStreamDelivery): boolean { + const controlConsumer = controlOutputConsumers.get(delivery.consumerId); + if (controlConsumer) { + let accepted = false; + try { + accepted = controlConsumer.onDelivery(delivery); + } catch { + accepted = false; + } + if (accepted) { + outputStream.acknowledge( + delivery.id, + delivery.consumerId, + delivery.epoch, + delivery.sequence, + ); + return true; + } + controlOutputConsumers.delete(delivery.consumerId); + outputStream.detach(delivery.id, delivery.consumerId); + return false; + } + const senderId = Number(delivery.consumerId.slice("renderer:".length)); + const subscription = rendererOutputSubscriptions.get(senderId); + if (!subscription || subscription.sender.isDestroyed()) { + rendererOutputSubscriptions.delete(senderId); + outputStream.detachConsumer(delivery.consumerId); + return false; + } + try { + if (subscription.allTerminals && !subscription.terminalIds.has(delivery.id)) { + perfCounters.hiddenRawIpcBytes += Buffer.byteLength(delivery.data); + } + subscription.sender.send("embedded-terminal:data", { + id: delivery.id, + epoch: delivery.epoch, + fromSequence: delivery.fromSequence, + sequence: delivery.sequence, + data: delivery.data, + reset: delivery.reset, + }); + return true; + } catch { + rendererOutputSubscriptions.delete(senderId); + outputStream.detachConsumer(delivery.consumerId); + return false; + } +} + +export function acknowledgeEmbeddedTerminalOutput( + id: string, + senderId: number, + epoch: string, + sequence: number, +): void { + if (!outputStream.acknowledge(id, rendererConsumerId(senderId), epoch, sequence)) return; + flushOutput(id); } function roundRate(value: number): number { diff --git a/client/electron/graphics-state.ts b/client/electron/graphics-state.ts new file mode 100644 index 0000000..5a10a2b --- /dev/null +++ b/client/electron/graphics-state.ts @@ -0,0 +1,231 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export const GRAPHICS_PREFERENCE_KEY = "athena.graphicsMode"; + +export type GraphicsPreference = "auto" | "safe" | "accelerated"; +export type GraphicsMode = "safe" | "accelerated"; + +export type GraphicsDecision = { + mode: GraphicsMode; + reason: string; + quarantined: boolean; +}; + +export type PersistedGraphicsState = { + version: 1; + quarantined: boolean; + acceleratedClean: boolean; + /** Set before an accelerated launch and cleared only after orderly shutdown. */ + acceleratedPending: boolean; + lastMode: GraphicsMode | null; + lastGpuCrashAt: string | null; + lastGpuCrashReason: string | null; + lastCleanAt: string | null; +}; + +export type GraphicsRuntimeStatus = GraphicsDecision & { + preference: GraphicsPreference; + recommendedMode: GraphicsMode; + restartRequired: boolean; + lastGpuCrashAt: string | null; + lastGpuCrashReason: string | null; +}; + +let runtimeDecision: GraphicsDecision | null = null; +let runtimeGpuCrashed = false; +const GPU_FAILURE_REASONS = new Set(["abnormal-exit", "crashed", "oom", "launch-failed", "integrity-failure"]); + +export function graphicsStateFilePath(): string { + return path.join(os.homedir(), ".context-workspace", "athena-graphics.json"); +} + +export function parseGraphicsPreference(value: string | null | undefined): GraphicsPreference { + return value === "safe" || value === "accelerated" ? value : "auto"; +} + +export function isGpuFailureReason(reason: string): boolean { + return GPU_FAILURE_REASONS.has(reason); +} + +/** + * Run launch-state setup only in the process that owns Electron's packaged + * single-instance lock. The losing process still evaluates the main module + * while `app.quit()` is being delivered, so relying on that call alone can + * corrupt the primary instance's pending/clean graphics marker. + */ +export function initializeOwnedGraphicsLaunch( + ownsApplicationInstance: boolean, + initialize: () => void, +): boolean { + if (!ownsApplicationInstance) return false; + initialize(); + return true; +} + +export function chooseGraphicsMode(args: { + platform: NodeJS.Platform; + preference: GraphicsPreference; + forceGpu?: boolean; + forceSafe?: boolean; + headless?: boolean; + state?: PersistedGraphicsState | null; +}): GraphicsDecision { + const state = args.state ?? readGraphicsState(); + if (args.forceGpu) return { mode: "accelerated", reason: "forced by environment", quarantined: false }; + if (args.forceSafe || args.headless) return { mode: "safe", reason: "headless or safe mode forced", quarantined: false }; + if (args.preference === "safe") return { mode: "safe", reason: "safe mode selected", quarantined: false }; + if (state.acceleratedPending) { + return { mode: "safe", reason: "previous accelerated launch did not exit cleanly", quarantined: true }; + } + if (state.quarantined) { + return { mode: "safe", reason: "previous GPU-process crash quarantined acceleration", quarantined: true }; + } + if (args.platform !== "linux") return { mode: "accelerated", reason: "platform hardware acceleration default", quarantined: false }; + if (args.preference === "accelerated") { + return { mode: "accelerated", reason: "acceleration selected", quarantined: false }; + } + // Auto is an accelerated canary on healthy Linux machines. We persist an + // acceleratedPending marker before Chromium starts, so either a GPU-process + // crash or an unclean native exit quarantines the next launch into safe mode. + // A safe-first default has no promotion path and permanently leaves healthy + // systems on the measured CPU-heavy software compositor. + return { + mode: "accelerated", + reason: state.acceleratedClean ? "previous accelerated launch was clean" : "adaptive Linux acceleration canary", + quarantined: false, + }; +} + +export function beginGraphicsLaunch(decision: GraphicsDecision): void { + runtimeDecision = decision; + runtimeGpuCrashed = false; + const state = readGraphicsState(); + const recoveredUncleanAcceleration = state.acceleratedPending; + writeGraphicsState({ + ...state, + quarantined: recoveredUncleanAcceleration ? true : state.quarantined, + acceleratedClean: recoveredUncleanAcceleration ? false : state.acceleratedClean, + acceleratedPending: decision.mode === "accelerated", + lastMode: decision.mode, + lastGpuCrashAt: recoveredUncleanAcceleration ? new Date().toISOString() : state.lastGpuCrashAt, + lastGpuCrashReason: recoveredUncleanAcceleration + ? "Previous accelerated Athena launch did not exit cleanly." + : state.lastGpuCrashReason, + }); +} + +export function markGraphicsLaunchClean(): void { + if (!runtimeDecision || runtimeGpuCrashed) return; + const state = readGraphicsState(); + writeGraphicsState({ + ...state, + lastMode: runtimeDecision.mode, + acceleratedPending: false, + acceleratedClean: runtimeDecision.mode === "accelerated" ? true : state.acceleratedClean, + quarantined: runtimeDecision.mode === "accelerated" ? false : state.quarantined, + lastCleanAt: new Date().toISOString(), + }); +} + +export function quarantineGraphicsAcceleration(reason: string): void { + runtimeGpuCrashed = true; + const state = readGraphicsState(); + writeGraphicsState({ + ...state, + quarantined: true, + acceleratedClean: false, + acceleratedPending: false, + lastGpuCrashAt: new Date().toISOString(), + lastGpuCrashReason: reason.slice(0, 500), + }); +} + +export function clearGraphicsQuarantine(): void { + const state = readGraphicsState(); + writeGraphicsState({ + ...state, + quarantined: false, + acceleratedPending: false, + lastGpuCrashAt: null, + lastGpuCrashReason: null, + }); +} + +export function getGraphicsRuntimeStatus(preference: GraphicsPreference): GraphicsRuntimeStatus { + const state = readGraphicsState(); + const current = runtimeDecision ?? chooseGraphicsMode({ platform: process.platform, preference, state }); + // While this process is alive, its own pending marker is not evidence of a + // prior crash. It becomes evidence only if the process dies before clearing + // it during orderly shutdown. + const recommendationState = runtimeDecision ? { ...state, acceleratedPending: false } : state; + const recommended = chooseGraphicsMode({ platform: process.platform, preference, state: recommendationState }); + return { + ...current, + reason: state.quarantined + ? "GPU-process crash detected; crash-safe mode will be used after restart" + : current.reason, + quarantined: state.quarantined, + preference, + recommendedMode: recommended.mode, + restartRequired: recommended.mode !== current.mode, + lastGpuCrashAt: state.lastGpuCrashAt, + lastGpuCrashReason: state.lastGpuCrashReason, + }; +} + +function defaultGraphicsState(): PersistedGraphicsState { + return { + version: 1, + quarantined: false, + acceleratedClean: false, + acceleratedPending: false, + lastMode: null, + lastGpuCrashAt: null, + lastGpuCrashReason: null, + lastCleanAt: null, + }; +} + +function readGraphicsState(): PersistedGraphicsState { + try { + const value = JSON.parse(fs.readFileSync(graphicsStateFilePath(), "utf8")) as Partial; + if (!value || value.version !== 1) return defaultGraphicsState(); + return { + version: 1, + quarantined: Boolean(value.quarantined), + acceleratedClean: Boolean(value.acceleratedClean), + acceleratedPending: Boolean(value.acceleratedPending), + lastMode: value.lastMode === "safe" || value.lastMode === "accelerated" ? value.lastMode : null, + lastGpuCrashAt: typeof value.lastGpuCrashAt === "string" ? value.lastGpuCrashAt : null, + lastGpuCrashReason: typeof value.lastGpuCrashReason === "string" ? value.lastGpuCrashReason : null, + lastCleanAt: typeof value.lastCleanAt === "string" ? value.lastCleanAt : null, + }; + } catch { + return defaultGraphicsState(); + } +} + +function writeGraphicsState(state: PersistedGraphicsState): void { + try { + const filePath = graphicsStateFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 }); + try { + fs.renameSync(temporary, filePath); + } catch { + // Some Windows filesystems do not replace an existing destination with + // rename. The state is reconstructible, so use a narrow fallback. + try { + fs.unlinkSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + fs.renameSync(temporary, filePath); + } + } catch { + // Graphics fallback must never block startup or shutdown. + } +} diff --git a/client/electron/hermes-session-index.ts b/client/electron/hermes-session-index.ts new file mode 100644 index 0000000..7daacad --- /dev/null +++ b/client/electron/hermes-session-index.ts @@ -0,0 +1,635 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { normalizeComparablePath } from "./platform.js"; +import { querySqlite, type SqliteValue } from "./sqlite.js"; +import type { HermesIndexDiagnostics, HermesIndexedSession } from "./session-index-protocol.js"; + +const execFileAsync = promisify(execFile); +const INDEX_VERSION = 2; +const MAX_RESULTS_PER_WORKSPACE = 100; +const SEARCH_CHARACTER_BUDGET = 300_000; +const MAX_WORKSPACE_HINT_BYTES = 16_384; +const MAX_WORKSPACE_HINT_LENGTH = 512; + +type HermesManifestEntry = { + title: string | null; + createdAt: string | null; + updatedAt: string | null; + platform: string | null; + chatType: string | null; +}; + +type HermesSessionFileMetadata = { + title: string | null; + model: string | null; + platform: string | null; + createdAt: string | null; + updatedAt: string | null; + workspace: string | null; +}; + +type HermesDatabaseRow = { + id: string; + source: string | null; + model: string | null; + startedAt: SqliteValue; + endedAt: SqliteValue; + title: string | null; +}; + +type CachedHermesFile = { + filePath: string; + mtimeMs: number; + size: number; + birthtime: string; + metadata: HermesSessionFileMetadata; + workspaceHints: string[]; +}; + +type PersistedIndex = { + version: number; + hermesDir: string; + entries: CachedHermesFile[]; +}; + +type WorkspaceQuery = { + workspace: string; + key: string; + needles: string[]; +}; + +export type HermesSessionIndexOptions = { + cachePath?: string; + queryDatabase?: typeof querySqlite; + readSessionFile?: (filePath: string) => Promise; + readCacheFile?: (filePath: string) => Promise; +}; + +/** + * Incremental Hermes metadata index. + * + * The large session JSON documents are parsed only in the session-index child, + * only when their (mtime,size) signature changes or a previously unseen + * workspace needs to be classified. The cache retains metadata and exact + * workspace match decisions, never transcript/search text or parsed messages. + */ +export class HermesSessionIndex { + private readonly cachePath: string; + private readonly queryDatabase: typeof querySqlite; + private readonly readSessionFile: (filePath: string) => Promise; + private readonly readCacheFile: (filePath: string) => Promise; + private entries = new Map(); + private loadedForDir: string | null = null; + private loadPromise: Promise | null = null; + private lastDatabaseRows = new Map(); + private refreshPromise: Promise | null = null; + private diagnostics: HermesIndexDiagnostics = emptyDiagnostics(); + + constructor(options: HermesSessionIndexOptions = {}) { + this.cachePath = options.cachePath ?? path.join(os.homedir(), ".context-workspace", "hermes-session-index-v2.json"); + this.queryDatabase = options.queryDatabase ?? querySqlite; + this.readSessionFile = options.readSessionFile ?? ((filePath) => fs.promises.readFile(filePath, "utf8")); + this.readCacheFile = options.readCacheFile ?? ((filePath) => fs.promises.readFile(filePath, "utf8")); + } + + async list(workspaces: string[], hermesDirOverride?: string | null): Promise> { + const uniqueQueries = uniqueWorkspaceQueries(workspaces); + const result: Record = Object.fromEntries(uniqueQueries.map((query) => [query.workspace, []])); + if (uniqueQueries.length === 0) return result; + + const hermesDir = hermesDirOverride === undefined ? await resolveHermesDir() : hermesDirOverride; + if (!hermesDir) return result; + await this.loadPersistedIndex(hermesDir); + await this.refresh(hermesDir); + + const manifest = await readHermesManifest(path.join(hermesDir, "sessions", "sessions.json")); + for (const query of uniqueQueries) { + const sessions: HermesIndexedSession[] = []; + const ids = new Set([...this.lastDatabaseRows.keys(), ...this.entries.keys()]); + for (const id of ids) { + const file = this.entries.get(id); + const row = this.lastDatabaseRows.get(id); + const manifestEntry = manifest.get(id); + if (!matchesWorkspace(file, manifestEntry, query)) continue; + const session = indexedSession(id, file, row, manifestEntry); + if (session) sessions.push(session); + } + result[query.workspace] = sessions + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) + .slice(0, MAX_RESULTS_PER_WORKSPACE); + } + return result; + } + + getDiagnostics(): HermesIndexDiagnostics { + return { ...this.diagnostics }; + } + + private async refresh(hermesDir: string): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + return; + } + const promise = this.refreshNow(hermesDir); + this.refreshPromise = promise; + try { + await promise; + } catch (error) { + this.diagnostics = { + ...emptyDiagnostics(), + lastError: `Hermes session index refresh failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 240), + }; + throw error; + } finally { + if (this.refreshPromise === promise) this.refreshPromise = null; + } + } + + private async refreshNow(hermesDir: string): Promise { + const startedAt = Date.now(); + const diagnostics = emptyDiagnostics(); + const sessionsDir = path.join(hermesDir, "sessions"); + let dirty = false; + const databaseRows = await this.readDatabaseRows(path.join(hermesDir, "state.db")); + if (databaseRows.size > 0 || this.lastDatabaseRows.size === 0) this.lastDatabaseRows = databaseRows; + + const names = await safeReadDir(sessionsDir); + const fileIds = new Map(); + for (const name of names) { + const match = /^session_(.+)\.json$/.exec(name); + if (match) fileIds.set(match[1], path.join(sessionsDir, name)); + } + diagnostics.filesSeen = fileIds.size; + // Database-only rows are kept for display metadata, but only session files + // can prove workspace membership. Conversely, file-only sessions remain + // discoverable even when state.db is absent, locked, or behind the files. + const currentIds = new Set(fileIds.keys()); + for (const id of this.entries.keys()) { + if (!currentIds.has(id)) { + this.entries.delete(id); + dirty = true; + } + } + + for (const [id, filePath] of fileIds) { + diagnostics.filesStatted += 1; + const stat = await safeStat(filePath); + if (!stat) continue; + const cached = this.entries.get(id); + const signatureChanged = !cached || cached.filePath !== filePath || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size; + if (!signatureChanged) { + diagnostics.cacheHits += 1; + continue; + } + + diagnostics.filesParsed += 1; + const parsed = await parseHermesSessionFile(filePath, this.readSessionFile, (bytes) => { + diagnostics.bytesParsed += bytes; + }); + if (!parsed) { + diagnostics.lastError = "One or more changed Hermes session files could not be parsed; retained last-known-good metadata."; + continue; + } + this.entries.set(id, { + filePath, + mtimeMs: stat.mtimeMs, + size: stat.size, + birthtime: stat.birthtime.toISOString(), + metadata: parsed.metadata, + workspaceHints: parsed.workspaceHints, + }); + dirty = true; + } + if (dirty && !(await this.persist(hermesDir))) { + diagnostics.lastError = diagnostics.lastError + ?? "Hermes session metadata was indexed, but the persistent cache could not be updated."; + } + diagnostics.durationMs = Date.now() - startedAt; + this.diagnostics = diagnostics; + } + + private async readDatabaseRows(dbPath: string): Promise> { + if (!fs.existsSync(dbPath)) return new Map(); + const rows = await this.queryDatabase(dbPath, [ + "select id, source, model, started_at, ended_at, message_count, title", + "from sessions", + "order by coalesce(ended_at, started_at) desc", + ].join(" "), []); + const result = new Map(); + for (const row of rows) { + const id = stringValue(row[0]); + if (!id) continue; + result.set(id, { + id, + source: nullableString(row[1]), + model: nullableString(row[2]), + startedAt: row[3] ?? null, + endedAt: row[4] ?? null, + title: nullableString(row[6]), + }); + } + return result; + } + + private async loadPersistedIndex(hermesDir: string): Promise { + if (this.loadedForDir === hermesDir) return; + while (this.loadPromise) { + await this.loadPromise; + if (this.loadedForDir === hermesDir) return; + } + const promise = this.loadPersistedIndexNow(hermesDir); + this.loadPromise = promise; + try { + await promise; + } finally { + if (this.loadPromise === promise) this.loadPromise = null; + } + } + + private async loadPersistedIndexNow(hermesDir: string): Promise { + const nextEntries = new Map(); + const parsed = await readJsonObject(this.cachePath, this.readCacheFile); + if (parsed?.version === INDEX_VERSION && parsed.hermesDir === hermesDir && Array.isArray(parsed.entries)) { + for (const value of parsed.entries) { + if (!isCachedHermesFile(value)) continue; + const id = sessionIdFromFile(value.filePath); + if (id) nextEntries.set(id, value); + } + } + this.entries = nextEntries; + this.lastDatabaseRows = new Map(); + // Publish the directory only after its complete cache snapshot is ready. + // Concurrent list() calls then either join this load or observe all of it; + // none can refresh an empty map that is later overwritten by stale data. + this.loadedForDir = hermesDir; + } + + private async persist(hermesDir: string): Promise { + const payload: PersistedIndex = { + version: INDEX_VERSION, + hermesDir, + entries: Array.from(this.entries.values()), + }; + const directory = path.dirname(this.cachePath); + const temporary = `${this.cachePath}.${process.pid}.tmp`; + try { + await fs.promises.mkdir(directory, { recursive: true }); + await fs.promises.writeFile(temporary, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 }); + try { + await fs.promises.rename(temporary, this.cachePath); + } catch { + // Windows does not consistently replace an existing destination with + // rename(). The cache is reconstructible, so a brief remove/rename + // fallback is safer than retaining a permanently stale index. + await fs.promises.unlink(this.cachePath).catch(() => undefined); + await fs.promises.rename(temporary, this.cachePath); + } + return true; + } catch { + await fs.promises.unlink(temporary).catch(() => undefined); + return false; + } + } +} + +async function parseHermesSessionFile( + filePath: string, + readSessionFile: (filePath: string) => Promise, + onRead: (bytes: number) => void, +): Promise<{ + metadata: HermesSessionFileMetadata; + workspaceHints: string[]; +} | null> { + let parsed: Record | null; + try { + const raw = await readSessionFile(filePath); + onRead(Buffer.byteLength(raw, "utf8")); + parsed = parseJsonObject(raw); + } catch { + return null; + } + if (!parsed) return null; + const metadata: HermesSessionFileMetadata = { + title: firstHermesUserMessage(parsed), + model: stringProperty(parsed, "model"), + platform: stringProperty(parsed, "platform"), + createdAt: stringProperty(parsed, "session_start"), + updatedAt: stringProperty(parsed, "last_updated"), + workspace: hermesWorkspace(parsed), + }; + return { + metadata, + workspaceHints: collectWorkspaceHints(parsed), + }; +} + +function emptyDiagnostics(): HermesIndexDiagnostics { + return { + filesSeen: 0, + filesStatted: 0, + filesParsed: 0, + bytesParsed: 0, + cacheHits: 0, + durationMs: 0, + lastError: null, + }; +} + +function collectWorkspaceHints(session: Record): string[] { + const hints = new Set(); + let hintBytes = 0; + let consumed = 0; + const stack: unknown[] = [session]; + const addHint = (value: string): void => { + if (hintBytes >= MAX_WORKSPACE_HINT_BYTES) return; + const normalized = normalizeSearchText(value).trim().slice(0, MAX_WORKSPACE_HINT_LENGTH); + if (!normalized || hints.has(normalized)) return; + const bytes = Buffer.byteLength(normalized, "utf8"); + if (hintBytes + bytes > MAX_WORKSPACE_HINT_BYTES) return; + hints.add(normalized); + hintBytes += bytes; + }; + + while (stack.length > 0 && consumed < SEARCH_CHARACTER_BUDGET && hintBytes < MAX_WORKSPACE_HINT_BYTES) { + const value = stack.pop(); + if (typeof value === "string") { + consumed += value.length; + const normalized = normalizeSearchText(value); + for (const line of normalized.split(/\r?\n/)) { + if (looksPathBearing(line)) addPathBearingLine(line, addHint); + if (/\b(?:workspace|project|cwd|repository|repo|working directory)\b/.test(line)) { + for (const identifier of line.matchAll(/\b[a-z0-9][a-z0-9._-]{5,}\b/g)) { + if (/[-_]/.test(identifier[0])) addHint(identifier[0]); + } + } + } + continue; + } + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) stack.push(value[index]); + continue; + } + if (value && typeof value === "object") { + const values = Object.values(value as Record); + for (let index = values.length - 1; index >= 0; index -= 1) stack.push(values[index]); + } + } + return Array.from(hints); +} + +function looksPathBearing(value: string): boolean { + return /(?:^|[\s"'`(])(?:[a-z]:\/|\/(?:[a-z0-9._~-]+\/)?[a-z0-9._~-]+)/i.test(value); +} + +function addPathBearingLine(value: string, addHint: (value: string) => void): void { + const trimmed = value.trim(); + // Preserve unquoted filesystem paths containing spaces without retaining + // arbitrary transcript lines. Restrict this to common filesystem roots; URL + // and API route lines continue through the token extractor below. + if (trimmed.length <= MAX_WORKSPACE_HINT_LENGTH + && /(?:[a-z]:\/|\/(?:home|users|mnt|workspaces?|projects?|tmp|var|opt|srv|root)\/)/i.test(trimmed)) { + addHint(trimmed); + } + for (const quoted of trimmed.matchAll(/(["'`])((?:[a-z]:\/|\/)[^"'`\r\n]{1,512})\1/gi)) addHint(quoted[2].replace(/[).!?]+$/, "")); + for (const match of trimmed.matchAll(/(?:[a-z]:\/|\/)[^\s"'`<>|{}\[\],;]+/gi)) addHint(match[0].replace(/[).!?]+$/, "")); +} + +function indexedSession( + id: string, + file: CachedHermesFile | undefined, + row: HermesDatabaseRow | undefined, + manifest: HermesManifestEntry | undefined, +): HermesIndexedSession | null { + if (!file && !row) return null; + const metadata = file?.metadata; + const createdAtFromDb = row ? fromEpoch(row.startedAt) : null; + const createdAt = metadata?.createdAt || manifest?.createdAt || createdAtFromDb || file?.birthtime || new Date(0).toISOString(); + const updatedAtFromDb = row?.endedAt ? fromEpoch(row.endedAt) : null; + return { + id, + title: cleanSessionTitle(row?.title || metadata?.title || manifest?.title || null) || "Hermes session", + model: row?.model || metadata?.model || null, + agent: hermesAgentLabel(row?.source ?? null, manifest, metadata), + createdAt, + updatedAt: updatedAtFromDb || metadata?.updatedAt || manifest?.updatedAt || (file ? new Date(file.mtimeMs).toISOString() : null) || createdAt, + }; +} + +function matchesWorkspace(file: CachedHermesFile | undefined, manifest: HermesManifestEntry | undefined, query: WorkspaceQuery): boolean { + // An explicit workspace is authoritative. Falling through to lower-cased + // transcript hints after a mismatch would merge case-distinct POSIX paths. + if (file?.metadata.workspace) return sameOrDescendantPath(file.metadata.workspace, query.workspace); + if (file?.workspaceHints.some((hint) => query.needles.some((needle) => hint.includes(needle)))) return true; + const titleText = normalizeSearchText(file?.metadata.title ?? ""); + if (query.needles.some((needle) => titleText.includes(needle))) return true; + const manifestText = normalizeSearchText(manifest?.title ?? ""); + return query.needles.some((needle) => manifestText.includes(needle)); +} + +async function readHermesManifest(filePath: string): Promise> { + const manifest = new Map(); + const parsed = await readJsonObject(filePath); + if (!parsed) return manifest; + for (const value of Object.values(parsed)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const entry = value as Record; + const sessionId = stringProperty(entry, "session_id"); + if (!sessionId) continue; + const origin = objectProperty(entry, "origin"); + manifest.set(sessionId, { + title: stringProperty(entry, "display_name") || stringProperty(origin, "chat_name") || stringProperty(entry, "session_key"), + createdAt: stringProperty(entry, "created_at"), + updatedAt: stringProperty(entry, "updated_at"), + platform: stringProperty(entry, "platform") || stringProperty(origin, "platform"), + chatType: stringProperty(entry, "chat_type") || stringProperty(origin, "chat_type"), + }); + } + return manifest; +} + +export async function resolveHermesDir(): Promise { + const native = path.join(os.homedir(), ".hermes"); + if (fs.existsSync(native)) return native; + if (process.platform !== "win32") return null; + try { + const { stdout } = await execFileAsync("wsl.exe", ["-e", "sh", "-lc", 'wslpath -w "$HOME/.hermes"'], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }); + const candidate = stdout.trim().split(/\r?\n/)[0]; + return candidate && fs.existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} + +function uniqueWorkspaceQueries(workspaces: string[]): WorkspaceQuery[] { + const queries = new Map(); + for (const workspace of workspaces) { + const resolved = path.resolve(workspace); + // normalizeComparablePath already folds Windows/WSL/UNC identity while + // preserving the case-sensitive identity of POSIX filesystems. + const key = normalizeComparablePath(resolved); + if (!key || queries.has(key)) continue; + queries.set(key, { workspace: resolved, key, needles: workspaceNeedles(resolved) }); + } + return Array.from(queries.values()); +} + +function workspaceNeedles(workspace: string): string[] { + const normalized = normalizeComparablePath(workspace).toLowerCase(); + const direct = normalizeSearchText(workspace).replace(/\/+$/, ""); + const baseName = path.basename(workspace).toLowerCase(); + const needles = new Set([normalized, direct]); + if (baseName.length >= 6 && /[-_]/.test(baseName)) { + needles.add(baseName); + needles.add(baseName.replace(/[-_]+/g, " ")); + } + return Array.from(needles).filter(Boolean); +} + +function sameOrDescendantPath(candidate: string, workspace: string): boolean { + const child = normalizeComparablePath(candidate); + const parent = normalizeComparablePath(workspace); + if (!child || !parent) return false; + if (child === parent) return true; + return child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); +} + +function normalizeSearchText(value: string): string { + return value.toLowerCase().replace(/\\/g, "/").replace(/\/+/g, "/"); +} + +function sessionIdFromFile(filePath: string): string | null { + return /^session_(.+)\.json$/.exec(path.basename(filePath))?.[1] ?? null; +} + +function isCachedHermesFile(value: unknown): value is CachedHermesFile { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Partial; + const metadata = item.metadata as Partial | undefined; + const nullableText = (entry: unknown): boolean => entry === null || typeof entry === "string"; + return typeof item.filePath === "string" + && typeof item.mtimeMs === "number" + && typeof item.size === "number" + && typeof item.birthtime === "string" + && Boolean(metadata && typeof metadata === "object") + && nullableText(metadata?.title) + && nullableText(metadata?.model) + && nullableText(metadata?.platform) + && nullableText(metadata?.createdAt) + && nullableText(metadata?.updatedAt) + && nullableText(metadata?.workspace) + && Array.isArray(item.workspaceHints) + && item.workspaceHints.every((entry) => typeof entry === "string" && entry.length <= MAX_WORKSPACE_HINT_LENGTH) + && Buffer.byteLength(item.workspaceHints.join(""), "utf8") <= MAX_WORKSPACE_HINT_BYTES; +} + +async function readJsonObject( + filePath: string, + readFile: (filePath: string) => Promise = (target) => fs.promises.readFile(target, "utf8"), +): Promise | null> { + try { + return parseJsonObject(await readFile(filePath)); + } catch { + return null; + } +} + +function parseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } +} + +function firstHermesUserMessage(session: Record): string | null { + const messages = session.messages; + if (!Array.isArray(messages)) return null; + for (const item of messages) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const message = item as Record; + if (stringProperty(message, "role") !== "user") continue; + return cleanSessionTitle(messageText(message)); + } + return null; +} + +function messageText(message: Record): string | null { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + return content + .map((item) => item && typeof item === "object" && !Array.isArray(item) ? stringProperty(item as Record, "text") : null) + .filter(Boolean) + .join("\n"); +} + +function hermesWorkspace(session: Record): string | null { + for (const key of ["workspace", "cwd", "project_dir", "projectDir", "project_path", "projectPath", "working_directory"]) { + const value = stringProperty(session, key); + if (value) return value; + } + const context = objectProperty(session, "context_workspace") || objectProperty(session, "contextWorkspace"); + return stringProperty(context, "project_dir") || stringProperty(context, "workspace"); +} + +function hermesAgentLabel(source: string | null, manifest?: HermesManifestEntry, metadata?: HermesSessionFileMetadata): string | null { + const platform = manifest?.platform || metadata?.platform || source; + return [platform, manifest?.chatType].filter(Boolean).join(" / ") || null; +} + +function cleanSessionTitle(value: string | null): string { + const text = value ?? ""; + const pane = text.match(/^Pane:\s*(.+)$/m)?.[1]?.trim(); + if (pane) return pane.slice(0, 120); + const agent = text.match(/^Agent:\s*(.+)$/m)?.[1]?.trim(); + if (agent) return `${agent} session`.slice(0, 120); + return text.split(/\r?\n/).find((line) => line.trim())?.trim().slice(0, 120) ?? ""; +} + +function objectProperty(value: Record | null, key: string): Record | null { + const item = value?.[key]; + return item && typeof item === "object" && !Array.isArray(item) ? item as Record : null; +} + +function stringProperty(value: Record | null, key: string): string | null { + const item = value?.[key]; + return typeof item === "string" && item.trim() ? item : null; +} + +function nullableString(value: SqliteValue): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function stringValue(value: SqliteValue): string { + return typeof value === "string" ? value : value == null ? "" : String(value); +} + +function fromEpoch(value: SqliteValue): string { + const number = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(number) || number <= 0) return new Date(0).toISOString(); + return new Date(number < 10_000_000_000 ? number * 1000 : number).toISOString(); +} + +async function safeReadDir(directory: string): Promise { + try { + return await fs.promises.readdir(directory); + } catch { + return []; + } +} + +async function safeStat(filePath: string): Promise { + try { + return await fs.promises.stat(filePath); + } catch { + return null; + } +} diff --git a/client/electron/ipc-handlers.ts b/client/electron/ipc-handlers.ts index 9242e1b..b70481c 100644 --- a/client/electron/ipc-handlers.ts +++ b/client/electron/ipc-handlers.ts @@ -2,14 +2,34 @@ import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { listAgentSessionsCached, type AgentSession } from "./agent-sessions.js"; +import { + getAgentSessionScanDiagnostics, + listAgentSessionsCached, + type AgentSession, +} from "./agent-sessions.js"; import type { BackendState } from "./backend.js"; import { checkBackendHealth, getBackendState, restartBackend } from "./backend.js"; import { checkControlHealth, getControlState, restartControlServer, type ControlState } from "./control-server.js"; import type { CodexTerminalState } from "./codex-terminal.js"; import { normalizeExternalUrl } from "./external-links.js"; +import { + clearGraphicsQuarantine, + getGraphicsRuntimeStatus, + GRAPHICS_PREFERENCE_KEY, + parseGraphicsPreference, + type GraphicsRuntimeStatus, +} from "./graphics-state.js"; import { clearTerminalRestorePause, readAthenaLaunchState, type AthenaLaunchState } from "./launch-state.js"; -import { checkMemoryPressure, formatBytes } from "./memory-guard.js"; +import { + launchStaggerDelayMs, + OneShotLaunchOverride, + publicLaunchAdmission, + releaseLaunchAdmission, + reserveLaunchAdmission, + settleLaunchAdmission, + type LaunchAdmissionResult, +} from "./launch-admission.js"; +import { formatBytes } from "./memory-guard.js"; import { getDefaultWorkspace, toWorkspacePath, type WorkspacePath } from "./platform.js"; import { getPreferences, removePreference, setPreference } from "./preferences.js"; import { @@ -26,6 +46,7 @@ import { import { acknowledgeEmbeddedTerminalOutput, attachEmbeddedTerminalBuffer, + attachEmbeddedTerminalStream, clearSavedEmbeddedTerminalRestores, getEmbeddedTerminalBuffer, getPerformanceDiagnostics, @@ -38,6 +59,10 @@ import { sendAgentMessage, restoreEmbeddedTerminals, spawnEmbeddedTerminal, + subscribeAllEmbeddedTerminalOutput, + subscribeEmbeddedTerminalOutput, + unsubscribeAllEmbeddedTerminalOutput, + unsubscribeEmbeddedTerminalOutput, writeEmbeddedTerminal, type EmbeddedTerminalKind, type EmbeddedTerminalSession, @@ -52,50 +77,100 @@ import type { AgentMessage } from "./agent-messages.js"; // of MiB plus their own MCP server; launching one onto a memory-starved machine // freezes the whole desktop via swap thrashing. Warn before the user adds the // pane that tips the box over. Plain shells are cheap, so they are never guarded. -// This runs only on user-initiated spawns, not on session restore (which reuses -// spawnEmbeddedTerminal directly and must not pop a dialog per restored pane). -async function guardAgentLaunchMemory(options?: EmbeddedTerminalSpawnOptions): Promise { +// A grid reaches IPC as one atomically reserved batch. A low-memory approval is +// therefore a one-shot token for that single request; it must never silently +// authorize later panes or unrelated concurrent requests. Concurrent callers +// share the visible prompt, but only one can consume its approval. +const MAX_UI_TERMINAL_SPAWN_COUNT = 8; +const uiMemoryOverride = new OneShotLaunchOverride(); +let uiMemoryPromptInFlight: Promise | null = null; + +async function guardAgentLaunchMemory( + options?: EmbeddedTerminalSpawnOptions, + count = 1, +): Promise { const kind: EmbeddedTerminalKind = options?.kind ?? "shell"; - if (kind === "shell") return; - const status = checkMemoryPressure(); - if (status.level === "ok") return; + let admission = reserveLaunchAdmission({ + source: "ui", + kind, + count, + }); + if (admission.granted) { + if (admission.decision === "warn") { + console.warn(admission.message, publicLaunchAdmission(admission)); + } + return admission; + } - const available = formatBytes(status.availableBytes); - if (status.level === "warn") { - console.warn(`Launching ${kind} with low free memory (${available} available).`); - return; + if (uiMemoryOverride.consume()) { + admission = reserveLaunchAdmission({ source: "ui", kind, count, overrideCritical: true }); + console.warn(admission.message, publicLaunchAdmission(admission)); + return admission; } + console.error(admission.message, publicLaunchAdmission(admission)); + const approved = await requestUiMemoryOverride(admission); + if (!approved) { + throw new Error("Launch cancelled: not enough free memory. Close some agents and try again."); + } + if (!uiMemoryOverride.consume()) { + throw new Error("Launch cancelled: the low-memory override expired. Try again after closing some agents."); + } + admission = reserveLaunchAdmission({ source: "ui", kind, count, overrideCritical: true }); + if (!admission.granted) { + throw new Error(admission.message); + } + console.warn(admission.message, publicLaunchAdmission(admission)); + return admission; +} - console.error(`Launching ${kind} with critically low memory (${available} available).`); +function requestUiMemoryOverride(admission: LaunchAdmissionResult): Promise { + if (uiMemoryPromptInFlight) return uiMemoryPromptInFlight; + const available = formatBytes(admission.projectedAvailableBytes); const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null; const messageBox = { type: "warning" as const, title: "Low memory", message: "Your machine is almost out of memory.", detail: - `Only ${available} of memory is free. Launching another agent now is likely to freeze Athena ` + `After reserving memory for this agent, only ${available} remains. Launching now is likely to freeze Athena ` + "and your whole desktop while the system swaps.\n\n" - + "Close some running agents or apps first, or launch anyway at your own risk.", + + "Close some running agents or apps first, or launch anyway at your own risk. One confirmation covers this grid launch.", buttons: ["Cancel", "Launch anyway"], defaultId: 0, cancelId: 0, noLink: true, }; - const { response } = window - ? await dialog.showMessageBox(window, messageBox) - : await dialog.showMessageBox(messageBox); - if (response === 0) { - throw new Error("Launch cancelled: not enough free memory. Close some agents and try again."); - } + uiMemoryPromptInFlight = (window + ? dialog.showMessageBox(window, messageBox) + : dialog.showMessageBox(messageBox)) + .then(({ response }) => { + const approved = response !== 0; + if (approved) { + uiMemoryOverride.grant(); + } + return approved; + }) + .finally(() => { + uiMemoryPromptInFlight = null; + }); + return uiMemoryPromptInFlight; } export function registerIpcHandlers(appRoot: string): void { initEmbeddedTerminals(appRoot); - ipcMain.on("embeddedTerminal:dataAck", (_event, id: string, sequence: number) => { - if (typeof id === "string" && Number.isSafeInteger(sequence)) { - acknowledgeEmbeddedTerminalOutput(id, sequence); + ipcMain.on("embeddedTerminal:dataAck", (event, id: string, epoch: string, sequence: number) => { + if (typeof id === "string" && typeof epoch === "string" && Number.isSafeInteger(sequence)) { + acknowledgeEmbeddedTerminalOutput(id, event.sender.id, epoch, sequence); } }); + ipcMain.on("embeddedTerminal:subscribe", (event, id: string) => { + if (typeof id === "string" && id) subscribeEmbeddedTerminalOutput(id, event.sender); + }); + ipcMain.on("embeddedTerminal:unsubscribe", (event, id: string) => { + if (typeof id === "string" && id) unsubscribeEmbeddedTerminalOutput(id, event.sender.id); + }); + ipcMain.on("embeddedTerminal:subscribeAll", (event) => subscribeAllEmbeddedTerminalOutput(event.sender)); + ipcMain.on("embeddedTerminal:unsubscribeAll", (event) => unsubscribeAllEmbeddedTerminalOutput(event.sender.id)); const handle = (channel: string, listener: Parameters[1]): void => { ipcMain.handle(channel, async (event, ...args) => { recordIpcBreadcrumb(channel, "start", args); @@ -164,6 +239,16 @@ export function registerIpcHandlers(appRoot: string): void { handle("preferences:get", (): Record => getPreferences()); handle("preferences:set", (_event, key: string, value: string): Record => setPreference(key, value)); handle("preferences:remove", (_event, key: string): Record => removePreference(key)); + handle("graphics:getStatus", (): GraphicsRuntimeStatus => { + const preference = parseGraphicsPreference(getPreferences()[GRAPHICS_PREFERENCE_KEY]); + return getGraphicsRuntimeStatus(preference); + }); + handle("graphics:setPreference", (_event, value: string): GraphicsRuntimeStatus => { + const preference = parseGraphicsPreference(value); + setPreference(GRAPHICS_PREFERENCE_KEY, preference); + if (preference === "accelerated") clearGraphicsQuarantine(); + return getGraphicsRuntimeStatus(preference); + }); handle("codexTerminal:getState", (): CodexTerminalState => getCodexTerminalState()); handle("codexTerminal:start", (event, workspace: string): Promise => { const window = BrowserWindow.fromWebContents(event.sender); @@ -189,15 +274,63 @@ export function registerIpcHandlers(appRoot: string): void { restoreEmbeddedTerminals(allowedWorkspaces), ); handle("embeddedTerminal:attachBuffer", (_event, id: string): string => attachEmbeddedTerminalBuffer(id)); + handle("embeddedTerminal:attachStream", (event, id: string) => attachEmbeddedTerminalStream(id, event.sender)); handle("embeddedTerminal:buffer", (_event, id: string): string => getEmbeddedTerminalBuffer(id)); handle("agentMessages:list", (_event, workspace?: string, limit?: number): AgentMessage[] => listEmbeddedAgentMessages(workspace, limit)); handle("agentMessages:send", (_event, request: SendAgentMessageRequest): Promise => sendAgentMessage({ ...request, source: "ui" })); - handle("performance:diagnostics", (): Promise => getPerformanceDiagnostics()); + handle("performance:diagnostics", async () => ({ + ...await getPerformanceDiagnostics(), + sessionIndex: getAgentSessionScanDiagnostics(), + })); handle( "embeddedTerminal:spawn", async (_event, workspace: string, options?: EmbeddedTerminalSpawnOptions): Promise => { - await guardAgentLaunchMemory(options); - return spawnEmbeddedTerminal(workspace, options); + const admission = await guardAgentLaunchMemory(options); + let launched = false; + try { + const session = await spawnEmbeddedTerminal(workspace, options); + launched = session.status === "running"; + if (!launched) throw new Error(session.error ?? `Failed to launch ${session.title}.`); + return session; + } finally { + if (launched) settleLaunchAdmission(admission, 1); + else releaseLaunchAdmission(admission); + } + }, + ); + handle( + "embeddedTerminal:spawnBatch", + async ( + _event, + workspace: string, + optionList: EmbeddedTerminalSpawnOptions[], + ): Promise => { + if (!Array.isArray(optionList) || optionList.length < 1 || optionList.length > MAX_UI_TERMINAL_SPAWN_COUNT) { + throw new Error(`Terminal batch must contain 1-${MAX_UI_TERMINAL_SPAWN_COUNT} entries.`); + } + const kind = optionList[0]?.kind ?? "shell"; + if (optionList.some((options) => (options.kind ?? "shell") !== kind)) { + throw new Error("A terminal batch must use one agent kind so memory can be reserved atomically."); + } + const admission = await guardAgentLaunchMemory(optionList[0], optionList.length); + const sessions: EmbeddedTerminalSession[] = []; + let runningCount = 0; + try { + for (const [index, options] of optionList.entries()) { + const staggerMs = launchStaggerDelayMs(kind, index); + if (staggerMs > 0) await new Promise((resolve) => setTimeout(resolve, staggerMs)); + const session = await spawnEmbeddedTerminal(workspace, options); + sessions.push(session); + if (session.status !== "running") { + throw new Error(session.error ?? `Failed to launch ${session.title}.`); + } + runningCount += 1; + } + return sessions; + } finally { + if (runningCount > 0) settleLaunchAdmission(admission, runningCount); + else releaseLaunchAdmission(admission); + } }, ); handle("embeddedTerminal:write", (_event, id: string, data: string): Promise => writeEmbeddedTerminal(id, data)); diff --git a/client/electron/launch-admission.ts b/client/electron/launch-admission.ts new file mode 100644 index 0000000..0da490a --- /dev/null +++ b/client/electron/launch-admission.ts @@ -0,0 +1,310 @@ +import { + checkMemoryPressure, + MEMORY_CRITICAL_BYTES, + MEMORY_WARN_BYTES, + type MemoryLevel, + type MemoryStatus, +} from "./memory-guard.js"; + +export type LaunchAdmissionSource = "ui" | "control" | "restore" | "internal"; +export type LaunchAdmissionDecision = "allow" | "warn" | "reject" | "defer"; +export type LaunchAdmissionKind = "shell" | "hermes" | "codex" | "opencode" | "claude" | "athena" | "grok"; + +export type LaunchAdmissionRequest = { + source: LaunchAdmissionSource; + kind: LaunchAdmissionKind; + count?: number; + /** + * A deliberate caller-specific override. UI callers obtain this only after + * the user confirms the warning dialog; authenticated control callers must + * explicitly put it in the request body. Restore must never set it silently. + */ + overrideCritical?: boolean; +}; + +export type LaunchAdmissionResult = { + decision: LaunchAdmissionDecision; + granted: boolean; + source: LaunchAdmissionSource; + kind: LaunchAdmissionKind; + count: number; + memoryLevel: MemoryLevel; + overrideUsed: boolean; + requestedBytes: number; + alreadyReservedBytes: number; + projectedAvailableBytes: number | null; + message: string; + /** Opaque process-local lease. Settle or release it after the spawn attempt. */ + reservationId: string | null; +}; + +export type PublicLaunchAdmissionResult = Omit; + +type LaunchReservation = { + bytes: number; + count: number; + kind: LaunchAdmissionKind; + source: LaunchAdmissionSource; + expiry: NodeJS.Timeout | null; +}; + +// This is an admission reservation, not an assertion about exact steady-state +// RSS. The heaviest measured Codex process group was about 621 MiB, including +// its helper/MCP processes, so 640 MiB leaves a small safety margin while a +// multi-pane request reserves its whole burst before the first spawn. +export const HEAVY_LAUNCH_RESERVATION_BYTES = 640 * 1024 * 1024; +// Agent wrappers return from spawn before the CLI and its MCP descendants reach +// steady-state RSS. Keep successful capacity leased briefly so a second request +// cannot race that delayed allocation and observe deceptively high headroom. +export const LAUNCH_RESERVATION_SETTLE_MS = 15_000; +// Spawning several CLI/MCP trees in the same tick causes a short CPU/disk storm +// even when enough memory exists. Admission reserves the whole request first; +// this cadence lets each heavyweight launch establish itself before the next. +export const HEAVY_LAUNCH_STAGGER_MS = 750; + +/** A short-lived approval that authorizes exactly one atomic UI request. */ +export class OneShotLaunchOverride { + #expiresAt = 0; + + grant(now = Date.now(), ttlMs = 5_000): void { + this.#expiresAt = now + Math.max(0, ttlMs); + } + + consume(now = Date.now()): boolean { + const granted = this.#expiresAt > now; + this.#expiresAt = 0; + return granted; + } +} + +export function estimatedLaunchBytes(kind: LaunchAdmissionKind, count = 1): number { + const normalizedCount = validLaunchCount(count); + return kind === "shell" ? 0 : HEAVY_LAUNCH_RESERVATION_BYTES * normalizedCount; +} + +export function launchStaggerDelayMs(kind: LaunchAdmissionKind, index: number): number { + return kind === "shell" || index <= 0 ? 0 : HEAVY_LAUNCH_STAGGER_MS; +} + +/** + * Process-local, synchronous admission gate. JavaScript's run-to-completion + * semantics make check-and-reserve atomic across concurrent IPC/HTTP requests. + * Failed capacity is released after spawning; successful capacity stays leased + * briefly while the OS memory probe catches up with descendant initialization. + */ +export class LaunchAdmissionService { + readonly #readMemoryStatus: () => MemoryStatus; + readonly #reservations = new Map(); + #nextReservationId = 1; + + constructor(readMemoryStatus: () => MemoryStatus = checkMemoryPressure) { + this.#readMemoryStatus = readMemoryStatus; + } + + reserve(request: LaunchAdmissionRequest): LaunchAdmissionResult { + const count = validLaunchCount(request.count ?? 1); + const requestedBytes = estimatedLaunchBytes(request.kind, count); + const status = this.#readMemoryStatus(); + const alreadyReservedBytes = this.reservedBytes(); + const projectedAvailableBytes = status.availableBytes == null + ? null + : Math.max(0, status.availableBytes - alreadyReservedBytes - requestedBytes); + + // Shells preserve the previous fail-open behavior. They are small enough + // that blocking them can prevent the user from recovering a pressured app. + if (requestedBytes === 0) { + return resultFor({ + request, + count, + status, + requestedBytes, + alreadyReservedBytes, + projectedAvailableBytes, + decision: "allow", + message: "Shell launch allowed; heavyweight-agent memory admission does not apply.", + }); + } + + const projectedLevel = worstMemoryLevel(status.level, levelForProjectedAvailable(projectedAvailableBytes)); + const critical = projectedLevel === "critical"; + if (critical && !request.overrideCritical) { + const decision: LaunchAdmissionDecision = request.source === "ui" ? "reject" : "defer"; + return resultFor({ + request, + count, + status: { ...status, level: projectedLevel }, + requestedBytes, + alreadyReservedBytes, + projectedAvailableBytes, + decision, + message: criticalMemoryMessage(requestedBytes, count, projectedAvailableBytes, decision), + }); + } + + const reservationId = `launch-${process.pid}-${this.#nextReservationId}`; + this.#nextReservationId += 1; + this.#reservations.set(reservationId, { + bytes: requestedBytes, + count, + kind: request.kind, + source: request.source, + expiry: null, + }); + const overrideUsed = critical && Boolean(request.overrideCritical); + const decision: LaunchAdmissionDecision = projectedLevel === "ok" && !overrideUsed ? "allow" : "warn"; + return resultFor({ + request, + count, + status: { ...status, level: projectedLevel }, + requestedBytes, + alreadyReservedBytes, + projectedAvailableBytes, + decision, + overrideUsed, + reservationId, + message: overrideUsed + ? `Critical-memory launch override accepted for ${count} ${agentLabel(count)}.` + : decision === "warn" + ? `Low-memory launch admitted for ${count} ${agentLabel(count)}.` + : `Memory admission reserved capacity for ${count} ${agentLabel(count)}.`, + }); + } + + release(reservation: string | LaunchAdmissionResult | null | undefined): boolean { + const reservationId = typeof reservation === "string" ? reservation : reservation?.reservationId; + if (!reservationId) return false; + const active = this.#reservations.get(reservationId); + if (!active) return false; + if (active.expiry) clearTimeout(active.expiry); + return this.#reservations.delete(reservationId); + } + + /** + * Finish a spawn attempt without racing the agent's delayed initialization. + * Failed/unstarted capacity is released immediately; successfully started + * capacity remains reserved for a short settling window. + */ + settle( + reservation: string | LaunchAdmissionResult | null | undefined, + launchedCount: number, + settleMs = LAUNCH_RESERVATION_SETTLE_MS, + ): boolean { + const reservationId = typeof reservation === "string" ? reservation : reservation?.reservationId; + if (!reservationId) return false; + const active = this.#reservations.get(reservationId); + if (!active) return false; + const completed = Number.isFinite(launchedCount) + ? Math.max(0, Math.min(active.count, Math.floor(launchedCount))) + : 0; + if (completed === 0 || active.bytes === 0 || settleMs <= 0) return this.release(reservationId); + active.count = completed; + active.bytes = estimatedLaunchBytes(active.kind, completed); + if (active.expiry) clearTimeout(active.expiry); + active.expiry = setTimeout(() => { + this.#reservations.delete(reservationId); + }, settleMs); + active.expiry.unref?.(); + return true; + } + + reservedBytes(): number { + let total = 0; + for (const reservation of this.#reservations.values()) total += reservation.bytes; + return total; + } + + reservationCount(): number { + return this.#reservations.size; + } +} + +const sharedLaunchAdmission = new LaunchAdmissionService(); + +export function reserveLaunchAdmission(request: LaunchAdmissionRequest): LaunchAdmissionResult { + return sharedLaunchAdmission.reserve(request); +} + +export function releaseLaunchAdmission(reservation: string | LaunchAdmissionResult | null | undefined): boolean { + return sharedLaunchAdmission.release(reservation); +} + +export function settleLaunchAdmission( + reservation: string | LaunchAdmissionResult | null | undefined, + launchedCount: number, + settleMs = LAUNCH_RESERVATION_SETTLE_MS, +): boolean { + return sharedLaunchAdmission.settle(reservation, launchedCount, settleMs); +} + +export function publicLaunchAdmission(result: LaunchAdmissionResult): PublicLaunchAdmissionResult { + const { reservationId: _reservationId, ...publicResult } = result; + return publicResult; +} + +function validLaunchCount(count: number): number { + if (!Number.isSafeInteger(count) || count < 1) { + throw new Error(`Launch admission count must be a positive integer; received ${count}.`); + } + return count; +} + +function levelForProjectedAvailable(availableBytes: number | null): MemoryLevel { + if (availableBytes == null) return "ok"; + if (availableBytes < MEMORY_CRITICAL_BYTES) return "critical"; + if (availableBytes < MEMORY_WARN_BYTES) return "warn"; + return "ok"; +} + +function worstMemoryLevel(left: MemoryLevel, right: MemoryLevel): MemoryLevel { + const rank: Record = { ok: 0, warn: 1, critical: 2 }; + return rank[left] >= rank[right] ? left : right; +} + +function criticalMemoryMessage( + requestedBytes: number, + count: number, + projectedAvailableBytes: number | null, + decision: LaunchAdmissionDecision, +): string { + const projected = projectedAvailableBytes == null + ? "unknown projected headroom" + : `${formatGiB(projectedAvailableBytes)} projected headroom`; + const action = decision === "defer" ? "deferred" : "blocked"; + return `Launch ${action}: reserving ${formatGiB(requestedBytes)} for ${count} ${agentLabel(count)} leaves ${projected}.`; +} + +function resultFor(args: { + request: LaunchAdmissionRequest; + count: number; + status: MemoryStatus; + requestedBytes: number; + alreadyReservedBytes: number; + projectedAvailableBytes: number | null; + decision: LaunchAdmissionDecision; + message: string; + overrideUsed?: boolean; + reservationId?: string; +}): LaunchAdmissionResult { + return { + decision: args.decision, + granted: args.decision === "allow" || args.decision === "warn", + source: args.request.source, + kind: args.request.kind, + count: args.count, + memoryLevel: args.status.level, + overrideUsed: args.overrideUsed ?? false, + requestedBytes: args.requestedBytes, + alreadyReservedBytes: args.alreadyReservedBytes, + projectedAvailableBytes: args.projectedAvailableBytes, + message: args.message, + reservationId: args.reservationId ?? null, + }; +} + +function agentLabel(count: number): string { + return count === 1 ? "agent" : "agents"; +} + +function formatGiB(bytes: number): string { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`; +} diff --git a/client/electron/main.ts b/client/electron/main.ts index 82be47b..e5150a2 100644 --- a/client/electron/main.ts +++ b/client/electron/main.ts @@ -5,7 +5,11 @@ import { fileURLToPath } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; import os from "node:os"; import { registerIpcHandlers } from "./ipc-handlers.js"; -import { hasPendingEmbeddedTerminalRestoreAttempts, prepareEmbeddedTerminalRestoreForQuit } from "./embedded-terminal.js"; +import { + confirmEmbeddedTerminalRestoreShutdown, + hasPendingEmbeddedTerminalRestoreAttempts, + prepareEmbeddedTerminalRestoreForQuit, +} from "./embedded-terminal.js"; import { startBackend, stopBackend } from "./backend.js"; import { startControlServer, stopControlServer } from "./control-server.js"; import { normalizeExternalUrl } from "./external-links.js"; @@ -14,6 +18,18 @@ import { checkDiskSpace, formatBytes, DISK_WARN_BYTES } from "./disk-guard.js"; import { installManagedAgentSkills } from "./agent-skills.js"; import { installAthenaCli } from "./athena-cli.js"; import { startAutoUpdates } from "./auto-update.js"; +import { + beginGraphicsLaunch, + chooseGraphicsMode, + GRAPHICS_PREFERENCE_KEY, + initializeOwnedGraphicsLaunch, + isGpuFailureReason, + markGraphicsLaunchClean, + parseGraphicsPreference, + quarantineGraphicsAcceleration, +} from "./graphics-state.js"; +import { getPreferences } from "./preferences.js"; +import { shouldConfirmEmbeddedTerminalRestoreShutdown } from "./terminal-restore-policy.js"; import type { IncomingMessage } from "node:http"; const __filename = fileURLToPath(import.meta.url); @@ -22,6 +38,8 @@ const appRoot = path.resolve(__dirname, ".."); let mainWindow: BrowserWindow | null = null; let viteProc: ChildProcess | null = null; +let shutdownStarted = false; +let shutdownComplete = false; // Resolve the ATHENA app icon for the live window/taskbar/dock. The // electron-builder `icon` config only brands the *packaged* bundle, so without @@ -203,31 +221,51 @@ function installExternalLinkHandler(window: BrowserWindow): void { }); } -// GPU-accelerated compositing in the browser process is the source of the -// recurring `segfault at 0 ip 0` crashes on Linux (flaky Mesa/Intel drivers -// call a null GL entry point). Disable hardware acceleration by default on -// Linux and in headless mode; CPU paint is slightly less smooth but stable. -// Set CONTEXT_WORKSPACE_ENABLE_GPU=1 to opt back in on machines with healthy -// drivers. These switches must be set before app.whenReady() / window creation. +// Healthy Linux machines start accelerated so xterm/Chromium painting does not +// permanently saturate the software compositor. A marker written before GPU +// startup and GPU-process crash events quarantine the next launch into safe +// mode, preventing the native crash loops that motivated the old global flip. app.commandLine.appendSwitch("no-sandbox"); const forceGpu = process.env.CONTEXT_WORKSPACE_ENABLE_GPU === "1"; -if (!forceGpu && (process.platform === "linux" || shouldUseHeadlessGraphicsMode())) { - app.commandLine.appendSwitch("disable-gpu"); - app.commandLine.appendSwitch("disable-software-rasterizer"); - app.commandLine.appendSwitch("disable-gpu-compositing"); - app.commandLine.appendSwitch("disable-gpu-rasterization"); - app.disableHardwareAcceleration(); -} +const graphicsPreference = parseGraphicsPreference(getPreferences()[GRAPHICS_PREFERENCE_KEY]); +const graphicsDecision = chooseGraphicsMode({ + platform: process.platform, + preference: graphicsPreference, + forceGpu, + forceSafe: process.env.CONTEXT_WORKSPACE_SAFE_GRAPHICS === "1", + headless: shouldUseHeadlessGraphicsMode(), +}); +initializeOwnedGraphicsLaunch(singleInstanceLock, () => { + beginGraphicsLaunch(graphicsDecision); + if (graphicsDecision.mode === "safe") { + app.commandLine.appendSwitch("disable-gpu"); + app.commandLine.appendSwitch("disable-software-rasterizer"); + app.commandLine.appendSwitch("disable-gpu-compositing"); + app.commandLine.appendSwitch("disable-gpu-rasterization"); + app.disableHardwareAcceleration(); + } + + app.on("child-process-gone", (_event, details) => { + if ( + details.type !== "GPU" + || graphicsDecision.mode !== "accelerated" + || shutdownStarted + || !isGpuFailureReason(details.reason) + ) return; + quarantineGraphicsAcceleration(`${details.reason}${details.exitCode == null ? "" : ` (exit ${details.exitCode})`}`); + }); -// Capture Chromium/GPU/V8 diagnostics to a file so the next crash leaves a -// real stack instead of a bare null-pointer core dump. -enableChromiumLogging(); + // Capture Chromium/GPU/V8 diagnostics to a file so the next crash leaves a + // real stack instead of a bare null-pointer core dump. Only the instance + // owner may hold or replace this log. + enableChromiumLogging(); -app.on("second-instance", () => { - if (!mainWindow || mainWindow.isDestroyed()) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); + app.on("second-instance", () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + }); }); if (singleInstanceLock) { @@ -327,13 +365,42 @@ app.on("window-all-closed", () => { } }); -app.on("before-quit", () => { - markAthenaCleanExit(); - prepareEmbeddedTerminalRestoreForQuit(); - void stopBackend(); - void stopControlServer(); +app.on("before-quit", (event) => { + // A packaged second instance calls app.quit() after losing the lock. Let it + // exit immediately: it does not own graphics/restore/process state and must + // never clear the primary instance's launch marker. + if (!singleInstanceLock) return; + if (shutdownComplete) return; + event.preventDefault(); + if (shutdownStarted) return; + shutdownStarted = true; if (viteProc) { viteProc.kill(); viteProc = null; } + const shutdown = Promise.all([ + Promise.resolve(prepareEmbeddedTerminalRestoreForQuit()).then(() => true, () => false), + stopBackend().catch(() => false), + stopControlServer().catch(() => false), + ]); + void Promise.race<{ completedBeforeDeadline: boolean; results: boolean[] | null }>([ + shutdown.then((results) => ({ completedBeforeDeadline: true, results })), + new Promise((resolve) => setTimeout( + () => resolve({ completedBeforeDeadline: false, results: null }), + 5_000, + )), + ]).then(({ completedBeforeDeadline, results }) => { + const ptyHostShutdownConfirmed = results?.[0] === true; + if (shouldConfirmEmbeddedTerminalRestoreShutdown(completedBeforeDeadline, ptyHostShutdownConfirmed)) { + confirmEmbeddedTerminalRestoreShutdown(); + } + const cleanShutdown = completedBeforeDeadline && Boolean(results?.every(Boolean)); + // Reaching this point proves the Electron/graphics process itself exited + // normally, even if an owned helper failed its cleanup deadline. + markGraphicsLaunchClean(); + if (cleanShutdown) markAthenaCleanExit(); + else console.error("Athena shutdown did not confirm cleanup of every owned process; leaving launch state unclean."); + shutdownComplete = true; + app.quit(); + }); }); diff --git a/client/electron/memory-guard.ts b/client/electron/memory-guard.ts index c0e0190..7ca3b9e 100644 --- a/client/electron/memory-guard.ts +++ b/client/electron/memory-guard.ts @@ -18,7 +18,7 @@ export type MemoryLevel = "ok" | "warn" | "critical"; // A point-in-time read of the host's memory situation. Kept as plain numbers so // classification can be unit tested without a real /proc/meminfo. export type MemorySnapshot = { - /** MemAvailable + free swap: what can be allocated before the kernel swaps. Null if unreadable. */ + /** Physical MemAvailable headroom. Swap is never launch capacity. Null if unreadable. */ availableBytes: number | null; /** Genuinely free RAM (MemFree), ignoring reclaimable cache. */ memFreeBytes: number | null; @@ -28,7 +28,7 @@ export type MemorySnapshot = { export type MemoryStatus = { level: MemoryLevel; - /** MemAvailable + free swap, or null if the probe failed. */ + /** Physical MemAvailable headroom, or null if the probe failed. */ availableBytes: number | null; /** Total physical RAM, for context in user-facing messages. */ totalBytes: number; @@ -67,7 +67,10 @@ function readLinuxMemorySnapshot(): MemorySnapshot | null { const swapTotalKib = parseMeminfoKib(text, "SwapTotal") ?? 0; const swapFreeKib = parseMeminfoKib(text, "SwapFree") ?? 0; return { - availableBytes: (memAvailableKib + swapFreeKib) * 1024, + // Counting unused swap here admitted exactly the kind of burst that makes + // the desktop freeze: the agents fit only by pushing the app into swap. + // Keep swap as a pressure signal below, never as launch capacity. + availableBytes: memAvailableKib * 1024, memFreeBytes: memFreeKib * 1024, swapTotalBytes: swapTotalKib * 1024, swapFreeBytes: swapFreeKib * 1024, @@ -76,8 +79,8 @@ function readLinuxMemorySnapshot(): MemorySnapshot | null { /** * Best estimate of the host's memory headroom. On Linux we read /proc/meminfo so - * we account for reclaimable cache and swap; elsewhere os.freemem() is the only - * portable signal, so swap pressure is treated as unknown (zero). + * we account for reclaimable cache and observe swap pressure separately; + * elsewhere os.freemem() is the only portable signal, so swap is unknown. */ export function readMemorySnapshot(): MemorySnapshot { const linux = readLinuxMemorySnapshot(); diff --git a/client/electron/platform.ts b/client/electron/platform.ts index a7eb83c..5ce7b30 100644 --- a/client/electron/platform.ts +++ b/client/electron/platform.ts @@ -123,11 +123,26 @@ export function wslPathToWindowsPath(value: string): string | null { } export function normalizeComparablePath(value: string): string { - const normalized = value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); - const wslDrive = /^\/mnt\/([a-zA-Z])\/(.+)$/.exec(normalized); - if (wslDrive) return `${wslDrive[1]}:/${wslDrive[2]}`.toLowerCase(); - const windowsDrive = /^\/?([a-zA-Z]):\/(.+)$/.exec(normalized); - if (windowsDrive) return `${windowsDrive[1]}:/${windowsDrive[2]}`.toLowerCase(); + const slashed = value.trim().replace(/\\/g, "/"); + if (!slashed) return ""; + + // Keep drive roots canonical. Removing their trailing slash turns `C:/` + // into the drive-relative `C:` and breaks native/WSL descendant matching. + const wslDrive = /^\/mnt\/([a-zA-Z])(?:\/(.*))?$/.exec(slashed); + if (wslDrive) { + const rest = (wslDrive[2] ?? "").replace(/\/+$/, ""); + return `${wslDrive[1]}:/${rest}`.toLowerCase(); + } + const windowsDrive = /^\/?([a-zA-Z]):\/(.*)$/.exec(slashed); + if (windowsDrive) { + const rest = windowsDrive[2].replace(/\/+$/, ""); + return `${windowsDrive[1]}:/${rest}`.toLowerCase(); + } + + const withoutTrailingSlashes = slashed.replace(/\/+$/, ""); + const normalized = withoutTrailingSlashes || (slashed.startsWith("/") ? "/" : ""); + // UNC server/share names follow Windows' case-insensitive path semantics. + if (/^\/\/[^/]+\/[^/]+/.test(normalized)) return normalized.toLowerCase(); return normalized; } diff --git a/client/electron/preload.ts b/client/electron/preload.ts index 726474c..8ac9aee 100644 --- a/client/electron/preload.ts +++ b/client/electron/preload.ts @@ -55,6 +55,26 @@ export type PerformanceDiagnostics = { eventLoopLagMs: number; maxEventLoopLagMs: number; lastOutputBatchAt: string | null; + rendererTerminalSubscribers: number; + hiddenRawIpcBytes: number; + terminalOutputRetries: number; + terminalOutputResets: number; + terminalOutputDroppedChars: number; + terminalOutputDeliveredChars: number; + terminalOutputAcknowledgedChars: number; + terminalReplayCount: number; + terminalReplayBytes: number; + terminalReplayDurationMs: number; + terminalReplayMaxDurationMs: number; + sessionIndex: { + filesSeen: number; + filesStatted: number; + filesParsed: number; + bytesParsed: number; + cacheHits: number; + durationMs: number; + lastError: string | null; + } | null; controlEvents: Array<{ id: string; at: string; @@ -95,6 +115,45 @@ export type PerformanceDiagnostics = { }>; }; +export type EmbeddedTerminalStreamSnapshot = { + id: string; + epoch: string; + buffer: string; + throughSequence: number; +}; + +export type EmbeddedTerminalDataPayload = { + id: string; + epoch: string; + fromSequence: number; + sequence: number; + data: string; + reset: boolean; +}; + +export type EmbeddedTerminalExitPayload = { + id: string; + exitCode: number | null; + epoch?: string; + throughSequence?: number; +}; + +export type EmbeddedTerminalDataSubscriptionOptions = { + ackMode?: "after-dispatch" | "manual"; +}; + +export type GraphicsPreference = "auto" | "safe" | "accelerated"; +export type GraphicsRuntimeStatus = { + mode: "safe" | "accelerated"; + reason: string; + quarantined: boolean; + preference: GraphicsPreference; + recommendedMode: "safe" | "accelerated"; + restartRequired: boolean; + lastGpuCrashAt: string | null; + lastGpuCrashReason: string | null; +}; + export type WorkspaceApi = { getBackendState: () => Promise; checkBackendHealth: () => Promise; @@ -107,6 +166,8 @@ export type WorkspaceApi = { getPreferences: () => Promise>; setPreference: (key: string, value: string) => Promise>; removePreference: (key: string) => Promise>; + getGraphicsStatus: () => Promise; + setGraphicsPreference: (value: GraphicsPreference) => Promise; getDefaultWorkspace: () => Promise; toWorkspacePath: (workspace: string) => Promise; getCodexTerminalState: () => Promise; @@ -119,10 +180,13 @@ export type WorkspaceApi = { listEmbeddedTerminals: () => Promise; restoreEmbeddedTerminals: (allowedWorkspaces?: string[]) => Promise; spawnEmbeddedTerminal: (workspace: string, options?: EmbeddedTerminalSpawnOptions) => Promise; + spawnEmbeddedTerminals: (workspace: string, options: EmbeddedTerminalSpawnOptions[]) => Promise; writeEmbeddedTerminal: (id: string, data: string) => Promise; renameEmbeddedTerminal: (id: string, title: string) => Promise; resizeEmbeddedTerminal: (id: string, cols: number, rows: number) => Promise; attachEmbeddedTerminalBuffer: (id: string) => Promise; + attachEmbeddedTerminalStream: (id: string) => Promise; + ackEmbeddedTerminalData: (id: string, epoch: string, sequence: number) => void; getEmbeddedTerminalBuffer: (id: string) => Promise; listAgentMessages: (workspace?: string, limit?: number) => Promise; sendAgentMessage: (request: SendAgentMessageRequest) => Promise; @@ -133,9 +197,14 @@ export type WorkspaceApi = { openExternalUrl: (url: string) => Promise; openPath: (path: string) => Promise; playAttentionSound: () => Promise; - onEmbeddedTerminalData: (callback: (payload: { id: string; data: string }) => void) => () => void; - onEmbeddedTerminalDataFor: (id: string, callback: (payload: { id: string; data: string }) => void) => () => void; - onEmbeddedTerminalExit: (callback: (payload: { id: string; exitCode: number | null }) => void) => () => void; + onEmbeddedTerminalData: (callback: (payload: EmbeddedTerminalDataPayload) => void) => () => void; + onEmbeddedTerminalDataFor: ( + id: string, + callback: (payload: EmbeddedTerminalDataPayload) => void, + options?: EmbeddedTerminalDataSubscriptionOptions, + ) => () => void; + onEmbeddedTerminalAttention: (callback: (payload: { id: string; kind: "action" | "update" }) => void) => () => void; + onEmbeddedTerminalExit: (callback: (payload: EmbeddedTerminalExitPayload) => void) => () => void; onEmbeddedTerminalSession: (callback: (session: EmbeddedTerminalSession) => void) => () => void; onCodexTerminalData: (callback: (data: string) => void) => () => void; onCodexTerminalState: (callback: (state: CodexTerminalState) => void) => () => void; @@ -165,19 +234,26 @@ function createIpcSubscription(channel: string, afterDispatch?: (payload: T) }; } -type EmbeddedTerminalDataPayload = { id: string; data: string; sequence: number }; - const embeddedTerminalDataCallbacks = new Set<(payload: EmbeddedTerminalDataPayload) => void>(); const embeddedTerminalDataCallbacksById = new Map void>>(); +const manualAckCallbacksById = new Map void>>(); +const acknowledgedTerminalSequences = new Map(); let embeddedTerminalDataListenerInstalled = false; const embeddedTerminalDataListener = (_event: Electron.IpcRendererEvent, payload: EmbeddedTerminalDataPayload) => { + const acknowledged = acknowledgedTerminalSequences.get(payload.id); + if (acknowledged?.epoch === payload.epoch && payload.sequence <= acknowledged.sequence) { + ipcRenderer.send("embeddedTerminal:dataAck", payload.id, payload.epoch, payload.sequence); + return; + } for (const callback of embeddedTerminalDataCallbacks) callback(payload); const idCallbacks = embeddedTerminalDataCallbacksById.get(payload.id); if (idCallbacks) { for (const callback of idCallbacks) callback(payload); } - ipcRenderer.send("embeddedTerminal:dataAck", payload.id, payload.sequence); + if ((manualAckCallbacksById.get(payload.id)?.size ?? 0) === 0) { + acknowledgeEmbeddedTerminalData(payload.id, payload.epoch, payload.sequence); + } }; function embeddedTerminalDataSubscriberCount(): number { @@ -198,33 +274,68 @@ function updateEmbeddedTerminalDataListener(): void { } function onEmbeddedTerminalData(callback: (payload: EmbeddedTerminalDataPayload) => void) { + const wasEmpty = embeddedTerminalDataCallbacks.size === 0; embeddedTerminalDataCallbacks.add(callback); updateEmbeddedTerminalDataListener(); + if (wasEmpty) ipcRenderer.send("embeddedTerminal:subscribeAll"); return () => { embeddedTerminalDataCallbacks.delete(callback); + if (embeddedTerminalDataCallbacks.size === 0) ipcRenderer.send("embeddedTerminal:unsubscribeAll"); updateEmbeddedTerminalDataListener(); }; } -function onEmbeddedTerminalDataFor(id: string, callback: (payload: EmbeddedTerminalDataPayload) => void) { +function onEmbeddedTerminalDataFor( + id: string, + callback: (payload: EmbeddedTerminalDataPayload) => void, + options: EmbeddedTerminalDataSubscriptionOptions = {}, +) { let callbacks = embeddedTerminalDataCallbacksById.get(id); + const wasEmpty = !callbacks || callbacks.size === 0; if (!callbacks) { callbacks = new Set(); embeddedTerminalDataCallbacksById.set(id, callbacks); } callbacks.add(callback); + if (options.ackMode === "manual") { + let manualCallbacks = manualAckCallbacksById.get(id); + if (!manualCallbacks) { + manualCallbacks = new Set(); + manualAckCallbacksById.set(id, manualCallbacks); + } + manualCallbacks.add(callback); + } updateEmbeddedTerminalDataListener(); + if (wasEmpty) ipcRenderer.send("embeddedTerminal:subscribe", id); return () => { const current = embeddedTerminalDataCallbacksById.get(id); if (current) { current.delete(callback); - if (current.size === 0) embeddedTerminalDataCallbacksById.delete(id); + if (current.size === 0) { + embeddedTerminalDataCallbacksById.delete(id); + acknowledgedTerminalSequences.delete(id); + ipcRenderer.send("embeddedTerminal:unsubscribe", id); + } + } + const manualCallbacks = manualAckCallbacksById.get(id); + if (manualCallbacks) { + manualCallbacks.delete(callback); + if (manualCallbacks.size === 0) manualAckCallbacksById.delete(id); } updateEmbeddedTerminalDataListener(); }; } -const onEmbeddedTerminalExit = createIpcSubscription<{ id: string; exitCode: number | null }>("embedded-terminal:exit"); + +function acknowledgeEmbeddedTerminalData(id: string, epoch: string, sequence: number): void { + const current = acknowledgedTerminalSequences.get(id); + if (!current || current.epoch !== epoch || sequence > current.sequence) { + acknowledgedTerminalSequences.set(id, { epoch, sequence }); + } + ipcRenderer.send("embeddedTerminal:dataAck", id, epoch, sequence); +} +const onEmbeddedTerminalExit = createIpcSubscription("embedded-terminal:exit"); const onEmbeddedTerminalSession = createIpcSubscription("embedded-terminal:session"); +const onEmbeddedTerminalAttention = createIpcSubscription<{ id: string; kind: "action" | "update" }>("embedded-terminal:attention"); const onCodexTerminalData = createIpcSubscription("codex-terminal:data"); const onCodexTerminalState = createIpcSubscription("codex-terminal:state"); const onWorkspaceOpen = createIpcSubscription<{ workspace: WorkspacePath; select: boolean }>("workspace:open"); @@ -242,6 +353,8 @@ const api: WorkspaceApi = { getPreferences: () => ipcRenderer.invoke("preferences:get"), setPreference: (key, value) => ipcRenderer.invoke("preferences:set", key, value), removePreference: (key) => ipcRenderer.invoke("preferences:remove", key), + getGraphicsStatus: () => ipcRenderer.invoke("graphics:getStatus"), + setGraphicsPreference: (value) => ipcRenderer.invoke("graphics:setPreference", value), getDefaultWorkspace: () => ipcRenderer.invoke("workspace:getDefault"), toWorkspacePath: (workspace: string) => ipcRenderer.invoke("workspace:toPath", workspace), getCodexTerminalState: () => ipcRenderer.invoke("codexTerminal:getState"), @@ -254,10 +367,13 @@ const api: WorkspaceApi = { listEmbeddedTerminals: () => ipcRenderer.invoke("embeddedTerminal:list"), restoreEmbeddedTerminals: (allowedWorkspaces) => ipcRenderer.invoke("embeddedTerminal:restore", allowedWorkspaces), spawnEmbeddedTerminal: (workspace, options) => ipcRenderer.invoke("embeddedTerminal:spawn", workspace, options), + spawnEmbeddedTerminals: (workspace, options) => ipcRenderer.invoke("embeddedTerminal:spawnBatch", workspace, options), writeEmbeddedTerminal: (id, data) => ipcRenderer.invoke("embeddedTerminal:write", id, data), renameEmbeddedTerminal: (id, title) => ipcRenderer.invoke("embeddedTerminal:rename", id, title), resizeEmbeddedTerminal: (id, cols, rows) => ipcRenderer.invoke("embeddedTerminal:resize", id, cols, rows), attachEmbeddedTerminalBuffer: (id) => ipcRenderer.invoke("embeddedTerminal:attachBuffer", id), + attachEmbeddedTerminalStream: (id) => ipcRenderer.invoke("embeddedTerminal:attachStream", id), + ackEmbeddedTerminalData: acknowledgeEmbeddedTerminalData, getEmbeddedTerminalBuffer: (id) => ipcRenderer.invoke("embeddedTerminal:buffer", id), listAgentMessages: (workspace, limit) => ipcRenderer.invoke("agentMessages:list", workspace, limit), sendAgentMessage: (request) => ipcRenderer.invoke("agentMessages:send", request), @@ -270,6 +386,7 @@ const api: WorkspaceApi = { playAttentionSound: () => ipcRenderer.invoke("shell:beep"), onEmbeddedTerminalData, onEmbeddedTerminalDataFor, + onEmbeddedTerminalAttention, onEmbeddedTerminalExit, onEmbeddedTerminalSession, onCodexTerminalData, diff --git a/client/electron/pty-host-client.ts b/client/electron/pty-host-client.ts index 939e3e1..a240cc6 100644 --- a/client/electron/pty-host-client.ts +++ b/client/electron/pty-host-client.ts @@ -35,6 +35,7 @@ class TypedEventEmitter extends EventEmitter { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REQUEST_TIMEOUT_MS = 10_000; +const SHUTDOWN_FORCE_KILL_MS = 1_500; export class PtyHostClient extends TypedEventEmitter { private child: ChildProcess | null = null; @@ -42,6 +43,7 @@ export class PtyHostClient extends TypedEventEmitter { private terminalIds = new Set(); private nextRequestId = 1; private stopping = false; + private shutdownPromise: Promise | null = null; async spawn(payload: PtyHostSpawnRequest): Promise { const pid = await this.request({ type: "spawn", payload }); @@ -65,13 +67,48 @@ export class PtyHostClient extends TypedEventEmitter { this.terminalIds.delete(id); } - shutdown(): void { + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise; this.stopping = true; - if (!this.child || this.child.killed) return; + if (!this.child || this.child.killed) return Promise.resolve(); const child = this.child; - void this.request({ type: "shutdown" }).finally(() => { - child.kill(); + this.shutdownPromise = new Promise((resolve, reject) => { + let settled = false; + let fallbackTimer: NodeJS.Timeout | null = null; + let forceTimer: NodeJS.Timeout | null = null; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + if (forceTimer) clearTimeout(forceTimer); + if (fallbackTimer) clearTimeout(fallbackTimer); + child.removeListener("exit", onExit); + child.removeListener("error", onError); + if (error) reject(error); + else resolve(); + }; + const onExit = () => finish(); + const onError = (error: Error) => finish(error); + forceTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + // ChildProcess normally emits exit after kill. Do not hang application + // shutdown forever if the platform fails to deliver that event, but do + // reject so launch state is not falsely marked clean. + fallbackTimer = setTimeout( + () => finish(new Error("PTY host did not confirm exit after forced shutdown.")), + 250, + ); + fallbackTimer.unref?.(); + }, SHUTDOWN_FORCE_KILL_MS); + forceTimer.unref?.(); + child.once("exit", onExit); + child.once("error", onError); + // The host flushes its pending output, kills every owned PTY and exits. + // A request rejection merely accelerates the forced-kill fallback. + void this.request({ type: "shutdown" }).catch(() => { + if (!child.killed) child.kill("SIGKILL"); + }); }); + return this.shutdownPromise; } private request(request: PtyHostClientRequest): Promise { @@ -105,6 +142,7 @@ export class PtyHostClient extends TypedEventEmitter { private ensureChild(): ChildProcess { if (this.child && !this.child.killed) return this.child; + if (this.stopping) throw new Error("PTY host is shutting down."); const hostPath = path.join(__dirname, "pty-host.js"); const child = fork(hostPath, [], { execPath: process.execPath, diff --git a/client/electron/session-index-client.ts b/client/electron/session-index-client.ts new file mode 100644 index 0000000..2807c49 --- /dev/null +++ b/client/electron/session-index-client.ts @@ -0,0 +1,209 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { HermesIndexDiagnostics, HermesIndexedSession, SessionIndexRequest, SessionIndexResponse } from "./session-index-protocol.js"; + +type WaitingCall = { + workspace: string; + resolve: (sessions: HermesIndexedSession[]) => void; +}; + +type PendingRequest = { + calls: WaitingCall[]; + child: ChildProcess; + timer: TimerHandle; +}; + +type TimerHandle = { + unref?: () => void; +}; + +export type SessionIndexClientOptions = { + spawnChild?: () => ChildProcess; + now?: () => number; + schedule?: (callback: () => void, delayMs: number) => TimerHandle; + cancel?: (timer: TimerHandle) => void; + requestTimeoutMs?: number; + restartBackoffMs?: number; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REQUEST_TIMEOUT_MS = 45_000; +const RESTART_BACKOFF_MS = 5_000; +const MAX_RESTART_BACKOFF_MS = 30_000; + +export class SessionIndexClient { + private readonly spawnChild: () => ChildProcess; + private readonly now: () => number; + private readonly schedule: (callback: () => void, delayMs: number) => TimerHandle; + private readonly cancel: (timer: TimerHandle) => void; + private readonly requestTimeoutMs: number; + private readonly restartBackoffMs: number; + private child: ChildProcess | null = null; + private queued: WaitingCall[] = []; + private flushTimer: TimerHandle | null = null; + private pending = new Map(); + private lastKnown = new Map(); + private nextRequestId = 1; + private restartAfter = 0; + private diagnostics: HermesIndexDiagnostics | null = null; + + constructor(options: SessionIndexClientOptions = {}) { + this.spawnChild = options.spawnChild ?? (() => fork(path.join(__dirname, "session-index-host.js"), [], { + execPath: process.execPath, + execArgv: [], + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + })); + this.now = options.now ?? Date.now; + this.schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + this.cancel = options.cancel ?? ((timer) => clearTimeout(timer as NodeJS.Timeout)); + this.requestTimeoutMs = positiveDuration(options.requestTimeoutMs, REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS); + this.restartBackoffMs = positiveDuration(options.restartBackoffMs, RESTART_BACKOFF_MS, MAX_RESTART_BACKOFF_MS); + } + + listHermes(workspace: string): Promise { + return new Promise((resolve) => { + this.queued.push({ workspace, resolve }); + if (this.flushTimer) return; + this.flushTimer = this.schedule(() => this.flush(), 0); + this.flushTimer.unref?.(); + }); + } + + getDiagnostics(): HermesIndexDiagnostics | null { + return this.diagnostics ? { ...this.diagnostics } : null; + } + + private flush(): void { + this.flushTimer = null; + const calls = this.queued.splice(0); + if (calls.length === 0) return; + if (this.now() < this.restartAfter) { + this.resolveFromLastKnown(calls); + return; + } + let child: ChildProcess; + try { + child = this.ensureChild(); + } catch { + this.startRestartBackoff(); + this.resolveFromLastKnown(calls); + return; + } + const requestId = String(this.nextRequestId++); + const workspaces = Array.from(new Set(calls.map((call) => call.workspace))); + const request: SessionIndexRequest = { type: "list-hermes", requestId, workspaces }; + const timer = this.schedule(() => { + const pending = this.pending.get(requestId); + if (!pending || pending.child !== child) return; + this.retireChild(child); + }, this.requestTimeoutMs); + timer.unref?.(); + this.pending.set(requestId, { calls, child, timer }); + try { + if (!child.send) throw new Error("Session index child has no IPC channel"); + child.send(request, (error) => { + if (!error) return; + const pending = this.pending.get(requestId); + if (!pending || pending.child !== child) return; + this.retireChild(child); + }); + } catch { + this.retireChild(child); + } + } + + private ensureChild(): ChildProcess { + if (this.child && this.child.connected && !this.child.killed) return this.child; + const child = this.spawnChild(); + this.child = child; + child.on("message", (message) => this.handleMessage(child, message as SessionIndexResponse)); + child.on("exit", (code, signal) => this.handleExit(child, code === 0 && signal === null)); + child.on("error", () => this.retireChild(child)); + if (child.pid) { + try { + os.setPriority(child.pid, os.constants.priority.PRIORITY_BELOW_NORMAL); + } catch { + // Priority adjustment is best-effort (some platforms require privileges). + } + } + child.unref(); + child.channel?.unref(); + return child; + } + + private handleMessage(child: ChildProcess, message: SessionIndexResponse): void { + if (!message || message.type !== "response") return; + const pending = this.pending.get(message.requestId); + if (!pending || pending.child !== child) return; + this.cancel(pending.timer); + this.pending.delete(message.requestId); + if (!message.ok) { + this.resolveFromLastKnown(pending.calls); + return; + } + this.diagnostics = { ...message.diagnostics }; + for (const call of pending.calls) { + const sessions = message.sessions[call.workspace] ?? []; + this.lastKnown.set(call.workspace, sessions); + call.resolve(sessions); + } + } + + private handleExit(child: ChildProcess, clean: boolean): void { + const wasCurrent = this.child === child; + const hadPending = Array.from(this.pending.values()).some((pending) => pending.child === child); + if (wasCurrent) { + this.child = null; + if (clean && !hadPending) this.restartAfter = 0; + else this.startRestartBackoff(); + } + this.resolvePendingForChild(child); + } + + private retireChild(child: ChildProcess): void { + if (this.child === child) { + this.child = null; + this.startRestartBackoff(); + } + this.resolvePendingForChild(child); + try { + if (!child.killed) child.kill(); + } catch { + // The exact worker may already have exited between the failure and kill. + } + try { + if (child.connected) child.disconnect(); + } catch { + // Disconnect is best-effort after the worker has been retired. + } + } + + private resolvePendingForChild(child: ChildProcess): void { + for (const [requestId, pending] of this.pending) { + if (pending.child !== child) continue; + this.cancel(pending.timer); + this.pending.delete(requestId); + this.resolveFromLastKnown(pending.calls); + } + } + + private startRestartBackoff(): void { + this.restartAfter = this.now() + this.restartBackoffMs; + } + + private resolveFromLastKnown(calls: WaitingCall[]): void { + for (const call of calls) call.resolve(this.lastKnown.get(call.workspace) ?? []); + } +} + +function positiveDuration(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0) return fallback; + return Math.min(value, maximum); +} + +export const sessionIndexClient = new SessionIndexClient(); diff --git a/client/electron/session-index-host.ts b/client/electron/session-index-host.ts new file mode 100644 index 0000000..53091b4 --- /dev/null +++ b/client/electron/session-index-host.ts @@ -0,0 +1,38 @@ +import { HermesSessionIndex } from "./hermes-session-index.js"; +import type { SessionIndexRequest, SessionIndexResponse } from "./session-index-protocol.js"; + +const index = new HermesSessionIndex(); +const IDLE_EXIT_MS = 500; +let activeRequests = 0; +let idleTimer: NodeJS.Timeout | null = null; + +function send(message: SessionIndexResponse): void { + if (process.send && process.connected) process.send(message); +} + +process.on("message", (message: SessionIndexRequest) => { + if (!message || message.type !== "list-hermes" || !message.requestId || !Array.isArray(message.workspaces)) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = null; + activeRequests += 1; + void index.list(message.workspaces) + .then( + (sessions) => send({ type: "response", requestId: message.requestId, ok: true, sessions, diagnostics: index.getDiagnostics() }), + (error) => send({ type: "response", requestId: message.requestId, ok: false, error: String(error) }), + ) + .finally(() => { + activeRequests -= 1; + scheduleIdleExit(); + }); +}); + +process.on("disconnect", () => process.exit(0)); + +function scheduleIdleExit(): void { + if (activeRequests > 0 || idleTimer) return; + idleTimer = setTimeout(() => { + idleTimer = null; + if (activeRequests > 0) return; + process.disconnect?.(); + }, IDLE_EXIT_MS); +} diff --git a/client/electron/session-index-protocol.ts b/client/electron/session-index-protocol.ts new file mode 100644 index 0000000..e6f7b0a --- /dev/null +++ b/client/electron/session-index-protocol.ts @@ -0,0 +1,41 @@ +export type HermesIndexedSession = { + id: string; + title: string; + model: string | null; + agent: string | null; + createdAt: string; + updatedAt: string; +}; + +export type HermesIndexDiagnostics = { + filesSeen: number; + filesStatted: number; + filesParsed: number; + bytesParsed: number; + cacheHits: number; + durationMs: number; + lastError: string | null; +}; + +export type SessionIndexRequest = { + type: "list-hermes"; + requestId: string; + workspaces: string[]; +}; + +export type SessionIndexSuccess = { + type: "response"; + requestId: string; + ok: true; + sessions: Record; + diagnostics: HermesIndexDiagnostics; +}; + +export type SessionIndexFailure = { + type: "response"; + requestId: string; + ok: false; + error: string; +}; + +export type SessionIndexResponse = SessionIndexSuccess | SessionIndexFailure; diff --git a/client/electron/terminal-attention.ts b/client/electron/terminal-attention.ts new file mode 100644 index 0000000..4b1b25a --- /dev/null +++ b/client/electron/terminal-attention.ts @@ -0,0 +1,42 @@ +export type TerminalAttentionKind = "action" | "update"; + +export const TERMINAL_ATTENTION_SCAN_MAX_CHARS = 4_000; +const TERMINAL_ATTENTION_CARRY_CHARS = 96; + +const attentionCuePattern = /\b(approve|approval|permission|allow|confirm|confirmation|required|requires|proceed|continue|waiting|needs?|requesting|press|select|task complete|completed|finished|done|implemented|fixed|passed|succeeded|opened pr|ready for review)\b/i; + +/** + * Classify a bounded tail of a PTY chunk in main so hidden terminals do not + * need raw renderer IPC merely to update workspace attention badges. + */ +export function classifyTerminalAttention(data: string): TerminalAttentionKind | null { + const bounded = data.length > TERMINAL_ATTENTION_SCAN_MAX_CHARS + ? data.slice(-TERMINAL_ATTENTION_SCAN_MAX_CHARS) + : data; + if (!attentionCuePattern.test(bounded)) return null; + const text = bounded.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, " "); + if (/\b(approve|approval|permission|allow|confirm|confirmation|required|requires|proceed|continue)\b/i.test(text)) { + if (/\b(waiting|needs?|requires?|requesting|press|select|confirm|approve|allow|permission)\b/i.test(text)) { + return "action"; + } + } + if (/\b(task complete|completed|finished|done|implemented|fixed|passed|succeeded|opened pr|ready for review)\b/i.test(text)) { + return "update"; + } + return null; +} + +/** Keeps only a tiny suffix so attention words split across PTY batches match. */ +export class TerminalAttentionTracker { + private readonly tails = new Map(); + + classify(id: string, data: string): TerminalAttentionKind | null { + const combined = `${this.tails.get(id) ?? ""}${data.slice(-TERMINAL_ATTENTION_SCAN_MAX_CHARS)}`; + this.tails.set(id, combined.slice(-TERMINAL_ATTENTION_CARRY_CHARS)); + return classifyTerminalAttention(combined); + } + + clear(id: string): void { + this.tails.delete(id); + } +} diff --git a/client/electron/terminal-buffer.ts b/client/electron/terminal-buffer.ts index 164958f..cfd7204 100644 --- a/client/electron/terminal-buffer.ts +++ b/client/electron/terminal-buffer.ts @@ -5,7 +5,10 @@ export const DEFAULT_TERMINAL_BUFFER_MAX_CHARS = 40_000; export const MIN_TERMINAL_BUFFER_MAX_CHARS = 1_000; export const MAX_TERMINAL_BUFFER_MAX_CHARS = 200_000; export const DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS = 64_000; -export const TERMINAL_OUTPUT_TRUNCATED_NOTICE = "\r\n\x1b[33m[Athena truncated terminal output backlog]\x1b[0m\r\n"; +// A replay that starts part-way through a VT stream cannot safely inherit the +// parser/cursor/style state that preceded it. Reset first and make the gap +// visible instead of silently presenting a corrupt tail as complete output. +export const TERMINAL_OUTPUT_TRUNCATED_NOTICE = "\x1bc\r\n\x1b[33m[Athena truncated terminal output backlog]\x1b[0m\r\n"; export type TerminalBufferResult = { buffer: string; @@ -13,6 +16,185 @@ export type TerminalBufferResult = { max_chars: number; }; +type TerminalReplayChunk = { + data: string; + safeRanges: Array<[start: number, end: number]>; +}; + +const TERMINAL_REPLAY_CHUNK_TARGET_CHARS = 4_096; + +/** + * Chunked rolling replay storage. Normal output appends are O(1); strings are + * joined only when a renderer/control client explicitly requests a snapshot. + * When the budget rolls over, the first retained code unit is moved to a VT + * parser-safe boundary and the returned replay declares the gap/reset. + */ +export class BoundedTerminalReplayBuffer { + private readonly chunks: TerminalReplayChunk[] = []; + private chars = 0; + private truncated = false; + private parserState: AnsiParserState = "text"; + + constructor(private readonly maxChars: number) {} + + append(data: string): number { + if (!data) return 0; + const safeRanges: Array<[start: number, end: number]> = []; + let state = this.parserState; + for (let index = 0; index <= data.length; index += 1) { + if ( + state === "text" + && isCodePointBoundary(data, index) + ) { + const lastRange = safeRanges.at(-1); + if (lastRange && lastRange[1] === index - 1) lastRange[1] = index; + else safeRanges.push([index, index]); + } + if (index < data.length) state = advanceAnsiParserState(state, data[index], data.charCodeAt(index)); + } + this.parserState = state; + const lastChunk = this.chunks.at(-1); + if (lastChunk && lastChunk.data.length + data.length <= TERMINAL_REPLAY_CHUNK_TARGET_CHARS) { + const offset = lastChunk.data.length; + lastChunk.data += data; + mergeSafeRanges(lastChunk.safeRanges, safeRanges, offset); + } else { + this.chunks.push({ data, safeRanges }); + } + this.chars += data.length; + + const payloadBudget = Math.max(0, Math.floor(this.maxChars) - TERMINAL_OUTPUT_TRUNCATED_NOTICE.length); + if (this.chars <= Math.floor(this.maxChars) && !this.truncated) return 0; + this.truncated = true; + const before = this.chars; + this.trimToBudget(payloadBudget); + return Math.max(0, before - this.chars); + } + + value(): string { + const value = this.chunks.map((chunk) => chunk.data).join(""); + if (!this.truncated) return value; + const boundedMax = Math.max(0, Math.floor(this.maxChars)); + if (boundedMax < TERMINAL_OUTPUT_TRUNCATED_NOTICE.length) { + return "[truncated]".slice(0, boundedMax); + } + return `${TERMINAL_OUTPUT_TRUNCATED_NOTICE}${value}`; + } + + /** + * Materialize a bounded replay using the VT/code-point boundaries indexed + * during append. Unlike terminalReplayTail(raw), this does not rescan or join + * the discarded prefix, so mounting a 64 KiB view stays proportional to the + * replay it will actually parse rather than the full 200k retention budget. + */ + replay(maxChars: number): string { + const boundedMax = Math.max(0, Math.floor(maxChars)); + if (this.length <= boundedMax) return this.value(); + if (boundedMax < TERMINAL_OUTPUT_TRUNCATED_NOTICE.length) { + return "[truncated]".slice(0, boundedMax); + } + + const payloadBudget = boundedMax - TERMINAL_OUTPUT_TRUNCATED_NOTICE.length; + const minimumStart = Math.max(0, this.chars - payloadBudget); + let consumed = 0; + for (let chunkIndex = 0; chunkIndex < this.chunks.length; chunkIndex += 1) { + const chunk = this.chunks[chunkIndex]; + const chunkEnd = consumed + chunk.data.length; + if (chunkEnd < minimumStart) { + consumed = chunkEnd; + continue; + } + const localMinimum = Math.max(0, minimumStart - consumed); + const safeOffset = firstOffsetInRanges(chunk.safeRanges, localMinimum); + if (safeOffset == null) { + consumed = chunkEnd; + continue; + } + const parts = [chunk.data.slice(safeOffset)]; + for (let tailIndex = chunkIndex + 1; tailIndex < this.chunks.length; tailIndex += 1) { + parts.push(this.chunks[tailIndex].data); + } + return `${TERMINAL_OUTPUT_TRUNCATED_NOTICE}${parts.join("")}`; + } + return TERMINAL_OUTPUT_TRUNCATED_NOTICE; + } + + get length(): number { + if (!this.truncated) return this.chars; + return this.chars + Math.min( + TERMINAL_OUTPUT_TRUNCATED_NOTICE.length, + Math.max(0, Math.floor(this.maxChars)), + ); + } + + private trimToBudget(payloadBudget: number): void { + let toDrop = Math.max(0, this.chars - payloadBudget); + while (this.chunks.length > 0 && toDrop > 0) { + const chunk = this.chunks[0]; + if (toDrop >= chunk.data.length) { + this.chunks.shift(); + this.chars -= chunk.data.length; + toDrop -= chunk.data.length; + continue; + } + const safeOffset = firstOffsetInRanges(chunk.safeRanges, Math.max(1, toDrop)); + if (safeOffset == null) { + this.chunks.shift(); + this.chars -= chunk.data.length; + toDrop = 0; + continue; + } + chunk.data = chunk.data.slice(safeOffset); + chunk.safeRanges = shiftedSafeRanges(chunk.safeRanges, safeOffset); + this.chars -= safeOffset; + toDrop = 0; + } + + // If the desired cut consumed whole chunks, the next chunk can still have + // begun inside a control sequence from its predecessor. Move to its first + // known-safe boundary (or discard it) before exposing a replay. + while (this.chunks.length > 0 && firstOffsetInRanges(this.chunks[0].safeRanges, 0) !== 0) { + const chunk = this.chunks[0]; + const safeOffset = firstOffsetInRanges(chunk.safeRanges, 1); + if (safeOffset == null) { + this.chunks.shift(); + this.chars -= chunk.data.length; + continue; + } + chunk.data = chunk.data.slice(safeOffset); + chunk.safeRanges = shiftedSafeRanges(chunk.safeRanges, safeOffset); + this.chars -= safeOffset; + } + } +} + +function mergeSafeRanges( + target: Array<[number, number]>, + source: Array<[number, number]>, + offset: number, +): void { + for (const [sourceStart, sourceEnd] of source) { + const start = sourceStart + offset; + const end = sourceEnd + offset; + const last = target.at(-1); + if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end); + else target.push([start, end]); + } +} + +function firstOffsetInRanges(ranges: Array<[number, number]>, minimum: number): number | null { + for (const [start, end] of ranges) { + if (end >= minimum) return Math.max(start, minimum); + } + return null; +} + +function shiftedSafeRanges(ranges: Array<[number, number]>, offset: number): Array<[number, number]> { + return ranges + .filter(([, end]) => end >= offset) + .map(([start, end]) => [Math.max(start, offset) - offset, end - offset]); +} + export function boundedTerminalBufferMaxChars(value: string | null): number { const parsed = Number(value ?? DEFAULT_TERMINAL_BUFFER_MAX_CHARS); if (!Number.isFinite(parsed)) return DEFAULT_TERMINAL_BUFFER_MAX_CHARS; @@ -23,7 +205,18 @@ export function boundedTerminalBufferMaxChars(value: string | null): number { } export function terminalBufferTail(value: string, maxChars: number): string { - return value.length > maxChars ? value.slice(-maxChars) : value; + const boundedMax = Math.max(0, Math.floor(maxChars)); + if (value.length <= boundedMax) return value; + if (boundedMax === 0) return ""; + + // Public control-buffer callers sometimes request tiny test/debug tails for + // which the notice itself cannot fit. Keep those code-point safe. Production + // terminal replay limits are >= 1,000 chars and always take the explicit-gap + // branch below. + if (boundedMax < TERMINAL_OUTPUT_TRUNCATED_NOTICE.length) { + return codePointSafeTail(value, boundedMax); + } + return terminalReplayTail(value, boundedMax); } export function formatTerminalBuffer(value: string, maxChars: number): TerminalBufferResult { @@ -44,7 +237,102 @@ export function appendBoundedTerminalOutput( if (combined.length <= maxChars) return combined; const boundedMax = Math.max(0, Math.floor(maxChars)); - const notice = TERMINAL_OUTPUT_TRUNCATED_NOTICE.slice(0, boundedMax); - const tailChars = Math.max(0, boundedMax - notice.length); - return `${notice}${tailChars > 0 ? combined.slice(-tailChars) : ""}`; + if (boundedMax === 0) return ""; + if (boundedMax < TERMINAL_OUTPUT_TRUNCATED_NOTICE.length) { + // This only applies to deliberately tiny callers/tests. Avoid returning a + // partial ANSI control sequence even when the full colored notice cannot + // fit. + return "[truncated]".slice(0, boundedMax); + } + return terminalReplayTail(combined, boundedMax); +} + +/** + * Return a bounded, self-declaring terminal replay tail. + * + * The cut is moved forward until the ANSI parser is back in ordinary text, so + * replay never begins inside CSI/OSC/DCS/APC/PM/SOS. A terminal reset precedes + * the tail because styles, cursor position and modes before the cut are not + * reconstructable from text alone. The first UTF-16 code unit is also never a + * dangling low surrogate. + */ +export function terminalReplayTail(value: string, maxChars: number): string { + const boundedMax = Math.max(0, Math.floor(maxChars)); + if (value.length <= boundedMax) return value; + if (boundedMax < TERMINAL_OUTPUT_TRUNCATED_NOTICE.length) { + return codePointSafeTail(value, boundedMax); + } + + const availableChars = boundedMax - TERMINAL_OUTPUT_TRUNCATED_NOTICE.length; + const minimumStart = Math.max(0, value.length - availableChars); + const safeStart = firstSafeTerminalBoundaryAtOrAfter(value, minimumStart); + if (safeStart == null) return TERMINAL_OUTPUT_TRUNCATED_NOTICE; + return `${TERMINAL_OUTPUT_TRUNCATED_NOTICE}${value.slice(safeStart)}`; +} + +function codePointSafeTail(value: string, maxChars: number): string { + if (maxChars <= 0) return ""; + let start = Math.max(0, value.length - maxChars); + if (start < value.length && isLowSurrogate(value.charCodeAt(start))) start += 1; + return value.slice(start); +} + +type AnsiParserState = "text" | "escape" | "csi" | "osc" | "oscEscape" | "string" | "stringEscape"; + +function firstSafeTerminalBoundaryAtOrAfter(value: string, minimumStart: number): number | null { + let state: AnsiParserState = "text"; + for (let index = 0; index <= value.length; index += 1) { + if ( + index >= minimumStart + && state === "text" + && isCodePointBoundary(value, index) + ) { + return index; + } + if (index === value.length) break; + + state = advanceAnsiParserState(state, value[index], value.charCodeAt(index)); + } + return null; +} + +function advanceAnsiParserState(state: AnsiParserState, char: string, code: number): AnsiParserState { + if (state === "text") { + if (code === 0x1b) return "escape"; + if (code === 0x9b) return "csi"; + if (code === 0x9d) return "osc"; + if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) return "string"; + return "text"; + } + if (state === "escape") { + if (char === "[") return "csi"; + if (char === "]") return "osc"; + if (char === "P" || char === "X" || char === "^" || char === "_") return "string"; + return "text"; + } + if (state === "csi") return code >= 0x40 && code <= 0x7e ? "text" : "csi"; + if (state === "osc") { + if (code === 0x07 || code === 0x9c) return "text"; + return code === 0x1b ? "oscEscape" : "osc"; + } + if (state === "oscEscape") return char === "\\" ? "text" : (code === 0x1b ? "oscEscape" : "osc"); + if (state === "string") { + if (code === 0x9c) return "text"; + return code === 0x1b ? "stringEscape" : "string"; + } + return char === "\\" ? "text" : (code === 0x1b ? "stringEscape" : "string"); +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff; +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +function isCodePointBoundary(value: string, index: number): boolean { + if (index < value.length && isLowSurrogate(value.charCodeAt(index))) return false; + if (index === value.length && index > 0 && isHighSurrogate(value.charCodeAt(index - 1))) return false; + return true; } diff --git a/client/electron/terminal-output-ack.ts b/client/electron/terminal-output-ack.ts index a0c47ef..decc027 100644 --- a/client/electron/terminal-output-ack.ts +++ b/client/electron/terminal-output-ack.ts @@ -1,60 +1,81 @@ -// Default window after which an unacknowledged terminal output batch is treated -// as lost. Comfortably above a normal IPC round-trip (sub-millisecond) so it -// only fires when an ack genuinely never arrives. +// Default window after which an unacknowledged terminal output batch is +// retried. The exact same retained batch is sent again; the renderer discards +// an already-applied sequence and ACKs it without writing twice. export const DEFAULT_OUTPUT_ACK_TIMEOUT_MS = 2_000; -type InFlightBatch = { sequence: number; sentAt: number }; +export type SequencedOutputBatch = { + epoch: string; + fromSequence: number; + sequence: number; + data: string; + reset: boolean; +}; + +type InFlightBatch = { + batch: T; + sentAt: number; +}; /** - * Single-batch-in-flight backpressure gate for embedded terminal output. - * - * `flushOutput` sends at most one unacknowledged batch per terminal so the - * renderer can't be flooded faster than it drains; the renderer acknowledges - * each batch by sequence, and until then the gate holds further sends. + * Single-batch-in-flight backpressure with payload retention. * - * Without a timeout this wedges: if the renderer reloads or crashes after a - * batch is emitted but before it acks, the in-flight entry is never cleared and - * every later flush skips that terminal forever โ€” its live output silently - * freezes until the process exits. A send left unacknowledged for longer than - * `ackTimeoutMs` is therefore treated as lost, so the next flush re-sends and - * the terminal recovers on its own. The dropped batch's bytes are still in the - * rolling buffer, so a remounting renderer re-renders them from the snapshot. + * The old gate remembered only a sequence number. Once main emitted a batch it + * deleted the bytes, so its timeout could send later output but could not + * actually recover the missing batch. This gate owns the in-flight payload + * until a matching epoch/sequence ACK arrives and only ever retries that same + * payload. Gates are keyed by consumer+terminal, keeping slow panes isolated. */ -export class OutputAckGate { - private readonly inFlight = new Map(); - private nextSequence = 1; +export class OutputAckGate { + private readonly inFlight = new Map>(); constructor(private readonly ackTimeoutMs: number = DEFAULT_OUTPUT_ACK_TIMEOUT_MS) {} - /** Whether a fresh batch may be sent for this terminal right now. */ - canSend(id: string, now: number = Date.now()): boolean { - const inFlight = this.inFlight.get(id); - if (!inFlight) return true; - return now - inFlight.sentAt >= this.ackTimeoutMs; + canSend(key: string): boolean { + return !this.inFlight.has(key); + } + + markSent(key: string, batch: T, now: number = Date.now()): void { + if (this.inFlight.has(key)) throw new Error(`Output already in flight for ${key}`); + this.inFlight.set(key, { batch, sentAt: now }); + } + + current(key: string): T | null { + return this.inFlight.get(key)?.batch ?? null; + } + + /** + * Return the retained payload once its ACK deadline expires and restart the + * deadline. Callers may safely emit it again because the renderer deduplicates + * the epoch/sequence before writing. + */ + retry(key: string, now: number = Date.now()): T | null { + const inFlight = this.inFlight.get(key); + if (!inFlight || now - inFlight.sentAt < this.ackTimeoutMs) return null; + inFlight.sentAt = now; + return inFlight.batch; } - /** Milliseconds until a blocked terminal should be retried, or null if unblocked. */ - retryDelayMs(id: string, now: number = Date.now()): number | null { - const inFlight = this.inFlight.get(id); + retryDelayMs(key: string, now: number = Date.now()): number | null { + const inFlight = this.inFlight.get(key); if (!inFlight) return null; return Math.max(0, this.ackTimeoutMs - (now - inFlight.sentAt)); } - /** Record that a batch was just sent; returns its sequence number. */ - markSent(id: string, now: number = Date.now()): number { - const sequence = this.nextSequence++; - this.inFlight.set(id, { sequence, sentAt: now }); - return sequence; + acknowledge(key: string, epoch: string, sequence: number): boolean { + const inFlight = this.inFlight.get(key); + if (!inFlight) return false; + if (inFlight.batch.epoch !== epoch || inFlight.batch.sequence !== sequence) return false; + this.inFlight.delete(key); + return true; } - /** Clear the gate when the ack matches the outstanding batch. Returns whether it cleared. */ - acknowledge(id: string, sequence: number): boolean { - if (this.inFlight.get(id)?.sequence !== sequence) return false; - this.inFlight.delete(id); - return true; + clear(key: string): void { + this.inFlight.delete(key); } - clear(id: string): void { - this.inFlight.delete(id); + clearMatching(predicate: (key: string) => boolean): void { + for (const key of this.inFlight.keys()) { + if (predicate(key)) this.inFlight.delete(key); + } } } diff --git a/client/electron/terminal-output-cleanup.ts b/client/electron/terminal-output-cleanup.ts new file mode 100644 index 0000000..df45380 --- /dev/null +++ b/client/electron/terminal-output-cleanup.ts @@ -0,0 +1,20 @@ +export const TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS = 30_000; +export const TERMINAL_OUTPUT_MAX_GRACE_MS = 120_000; + +export type TerminalOutputCleanupDecision = { + clear: boolean; + delayMs: number; +}; + +/** Pure deadline policy for deterministic tests and bounded exit tombstones. */ +export function terminalOutputCleanupDecision( + now: number, + deadline: number, + hasPendingDelivery: boolean, +): TerminalOutputCleanupDecision { + if (!hasPendingDelivery || now >= deadline) return { clear: true, delayMs: 0 }; + return { + clear: false, + delayMs: Math.max(1, Math.min(TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS, deadline - now)), + }; +} diff --git a/client/electron/terminal-output-stream.ts b/client/electron/terminal-output-stream.ts new file mode 100644 index 0000000..a45f20e --- /dev/null +++ b/client/electron/terminal-output-stream.ts @@ -0,0 +1,465 @@ +import { randomUUID } from "node:crypto"; +import { + BoundedTerminalReplayBuffer, + DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS, +} from "./terminal-buffer.js"; +import { + DEFAULT_OUTPUT_ACK_TIMEOUT_MS, + OutputAckGate, + type SequencedOutputBatch, +} from "./terminal-output-ack.js"; + +export type TerminalStreamAttachSnapshot = { + id: string; + epoch: string; + buffer: string; + throughSequence: number; +}; + +export type TerminalStreamDelivery = SequencedOutputBatch & { + id: string; + consumerId: string; +}; + +type OutputChunk = { + sequence: number; + data: string; +}; + +type TerminalState = { + epoch: string; + sequence: number; + buffer: BoundedTerminalReplayBuffer; + pendingHighSurrogate: string; + consumers: Set; +}; + +type ConsumerState = { + terminalId: string; + consumerId: string; + pending: OutputChunk[]; + pendingChars: number; + needsReset: boolean; + paused: boolean; + replayMaxChars: number | null; +}; + +export type TerminalStreamAttachOptions = { + replayMaxChars?: number; + paused?: boolean; +}; + +export type TerminalOutputStreamOptions = { + maxSnapshotChars?: number; + maxPendingChars?: number; + ackTimeoutMs?: number; + epochFactory?: () => string; +}; + +export type TerminalOutputStreamDiagnostics = { + subscribers: number; + retries: number; + resets: number; + droppedOrTruncatedChars: number; + deliveredChars: number; + acknowledgedChars: number; + replayCount: number; + replayBytes: number; + replayDurationMs: number; + maxReplayDurationMs: number; +}; + +/** + * A bounded, consumer-aware terminal stream protocol. + * + * PTY output is retained once in a rolling replay snapshot. Only explicitly + * subscribed consumers receive live chunks, and each consumer has independent + * pending/in-flight state. Overflow converts into an explicit reset snapshot; + * it never splices together an undeclared arbitrary tail. Attach synchronously + * rebases a consumer at the snapshot cursor, making snapshot + later live + * sequences atomic from the main-process point of view. + */ +export class TerminalOutputStreamHub { + private readonly terminals = new Map(); + private readonly consumers = new Map(); + private readonly gate: OutputAckGate; + private readonly maxSnapshotChars: number; + private readonly maxPendingChars: number; + private readonly epochFactory: () => string; + private readonly counters = { + retries: 0, + resets: 0, + droppedOrTruncatedChars: 0, + deliveredChars: 0, + acknowledgedChars: 0, + replayCount: 0, + replayBytes: 0, + replayDurationMs: 0, + maxReplayDurationMs: 0, + }; + + constructor(options: TerminalOutputStreamOptions = {}) { + this.maxSnapshotChars = Math.max(1, Math.floor(options.maxSnapshotChars ?? 200_000)); + this.maxPendingChars = Math.max( + 1, + Math.floor(options.maxPendingChars ?? DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS), + ); + this.epochFactory = options.epochFactory ?? randomUUID; + this.gate = new OutputAckGate( + options.ackTimeoutMs ?? DEFAULT_OUTPUT_ACK_TIMEOUT_MS, + ); + } + + append(terminalId: string, data: string): number { + const terminal = this.terminal(terminalId); + if (!data) return terminal.sequence; + let normalized = data; + if (terminal.pendingHighSurrogate) { + normalized = data && isLowSurrogate(data.charCodeAt(0)) + ? `${terminal.pendingHighSurrogate}${data}` + : `\ufffd${data}`; + } + terminal.pendingHighSurrogate = ""; + if (normalized && isHighSurrogate(normalized.charCodeAt(normalized.length - 1))) { + terminal.pendingHighSurrogate = normalized.at(-1) ?? ""; + normalized = normalized.slice(0, -1); + } + if (!normalized) return terminal.sequence; + this.counters.droppedOrTruncatedChars += terminal.buffer.append(normalized); + terminal.sequence += 1; + const sequence = terminal.sequence; + + for (const consumerId of terminal.consumers) { + const consumer = this.consumers.get(this.consumerKey(consumerId, terminalId)); + if (!consumer || consumer.needsReset) continue; + if (consumer.pendingChars + normalized.length > this.maxPendingChars) { + consumer.pending = []; + consumer.pendingChars = 0; + consumer.needsReset = true; + continue; + } + consumer.pending.push({ sequence, data: normalized }); + consumer.pendingChars += normalized.length; + } + return sequence; + } + + /** + * Finish a producer stream before publishing its exit cursor. + * + * A PTY chunk can end between the two UTF-16 code units of a supplementary + * character. While the process is alive we retain that leading surrogate so + * the next chunk can complete it. At EOF there is no next chunk, so publish a + * replacement character as ordinary sequenced output instead of silently + * dropping the final code unit or exposing malformed UTF-16 to xterm. + */ + finalize(terminalId: string): number { + const terminal = this.terminals.get(terminalId); + if (!terminal?.pendingHighSurrogate) return terminal?.sequence ?? 0; + terminal.pendingHighSurrogate = ""; + return this.append(terminalId, "\ufffd"); + } + + getBuffer(terminalId: string): string { + return this.terminals.get(terminalId)?.buffer.value() ?? ""; + } + + /** Subscribe for future output without replaying history. */ + subscribe(terminalId: string, consumerId: string): void { + if (this.consumers.has(this.consumerKey(consumerId, terminalId))) return; + this.rebaseConsumer(terminalId, consumerId); + } + + /** Atomically subscribe/rebase and return the replay cursor. */ + attach( + terminalId: string, + consumerId: string, + options: TerminalStreamAttachOptions = {}, + ): TerminalStreamAttachSnapshot { + const startedAt = process.hrtime.bigint(); + const terminal = this.terminal(terminalId); + const replayMaxChars = options.replayMaxChars == null + ? null + : Math.max(0, Math.floor(options.replayMaxChars)); + this.rebaseConsumer(terminalId, consumerId, Boolean(options.paused), replayMaxChars); + const buffer = replayMaxChars == null + ? terminal.buffer.value() + : terminal.buffer.replay(replayMaxChars); + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + this.counters.replayCount += 1; + this.counters.replayBytes += Buffer.byteLength(buffer); + this.counters.replayDurationMs += durationMs; + this.counters.maxReplayDurationMs = Math.max(this.counters.maxReplayDurationMs, durationMs); + return { + id: terminalId, + epoch: terminal.epoch, + buffer, + throughSequence: terminal.sequence, + }; + } + + pauseConsumer(terminalId: string, consumerId: string): boolean { + const consumer = this.consumers.get(this.consumerKey(consumerId, terminalId)); + if (!consumer) return false; + consumer.paused = true; + return true; + } + + resumeConsumer(terminalId: string, consumerId: string): boolean { + const consumer = this.consumers.get(this.consumerKey(consumerId, terminalId)); + if (!consumer) return false; + consumer.paused = false; + return true; + } + + detach(terminalId: string, consumerId: string): void { + const key = this.consumerKey(consumerId, terminalId); + this.gate.clear(key); + this.consumers.delete(key); + const terminal = this.terminals.get(terminalId); + terminal?.consumers.delete(consumerId); + // A late attach after an exited terminal's tombstone was cleared creates a + // temporary empty epoch so the renderer can resolve its stale exit. Do not + // retain that phantom indefinitely after its final view goes away. The same + // rule is safe for a live PTY before first output: its next chunk simply + // creates the authoritative epoch then. + if ( + terminal + && terminal.consumers.size === 0 + && terminal.sequence === 0 + && terminal.buffer.length === 0 + && !terminal.pendingHighSurrogate + ) { + this.terminals.delete(terminalId); + } + } + + detachConsumer(consumerId: string): void { + for (const consumer of Array.from(this.consumers.values())) { + if (consumer.consumerId === consumerId) this.detach(consumer.terminalId, consumerId); + } + } + + clearTerminal(terminalId: string): void { + const terminal = this.terminals.get(terminalId); + if (terminal) { + for (const consumerId of terminal.consumers) { + const key = this.consumerKey(consumerId, terminalId); + this.gate.clear(key); + this.consumers.delete(key); + } + } + this.terminals.delete(terminalId); + } + + poll( + now: number = Date.now(), + onlyConsumerKey?: string, + onlyTerminalId?: string, + ): TerminalStreamDelivery[] { + const deliveries: TerminalStreamDelivery[] = []; + const consumers = onlyConsumerKey + ? [this.consumers.get(onlyConsumerKey)].filter((item): item is ConsumerState => Boolean(item)) + : onlyTerminalId + ? Array.from(this.terminals.get(onlyTerminalId)?.consumers ?? []) + .map((consumerId) => this.consumers.get(this.consumerKey(consumerId, onlyTerminalId))) + .filter((item): item is ConsumerState => Boolean(item)) + : Array.from(this.consumers.values()); + + for (const consumer of consumers) { + if (consumer.paused) continue; + const key = this.consumerKey(consumer.consumerId, consumer.terminalId); + const retry = this.gate.retry(key, now); + if (retry) { + this.counters.retries += 1; + this.counters.deliveredChars += retry.data.length; + deliveries.push(retry); + continue; + } + if (!this.gate.canSend(key)) continue; + + const terminal = this.terminals.get(consumer.terminalId); + if (!terminal) continue; + let batch: TerminalStreamDelivery | null = null; + if (consumer.needsReset) { + this.counters.resets += 1; + consumer.needsReset = false; + consumer.pending = []; + consumer.pendingChars = 0; + batch = { + id: consumer.terminalId, + consumerId: consumer.consumerId, + epoch: terminal.epoch, + fromSequence: 0, + sequence: terminal.sequence, + data: this.replaySnapshot(terminal, consumer.replayMaxChars), + reset: true, + }; + } else if (consumer.pending.length > 0) { + const chunks = consumer.pending; + consumer.pending = []; + consumer.pendingChars = 0; + batch = { + id: consumer.terminalId, + consumerId: consumer.consumerId, + epoch: terminal.epoch, + fromSequence: chunks[0].sequence, + sequence: chunks[chunks.length - 1].sequence, + data: chunks.map((chunk) => chunk.data).join(""), + reset: false, + }; + } + + if (batch) { + this.gate.markSent(key, batch, now); + this.counters.deliveredChars += batch.data.length; + deliveries.push(batch); + } + } + return deliveries; + } + + pollTerminal(terminalId: string, now: number = Date.now()): TerminalStreamDelivery[] { + return this.poll(now, undefined, terminalId); + } + + acknowledge( + terminalId: string, + consumerId: string, + epoch: string, + sequence: number, + ): boolean { + const key = this.consumerKey(consumerId, terminalId); + const current = this.gate.current(key); + const acknowledged = this.gate.acknowledge(key, epoch, sequence); + if (acknowledged && current) this.counters.acknowledgedChars += current.data.length; + return acknowledged; + } + + consumerKey(consumerId: string, terminalId: string): string { + return `${consumerId}\u0000${terminalId}`; + } + + nextRetryDelayMs(now: number = Date.now()): number | null { + let delay: number | null = null; + for (const consumer of this.consumers.values()) { + const candidate = this.gate.retryDelayMs( + this.consumerKey(consumer.consumerId, consumer.terminalId), + now, + ); + if (candidate != null) delay = Math.min(delay ?? Number.POSITIVE_INFINITY, candidate); + } + return delay; + } + + hasPendingDeliveries(): boolean { + for (const consumer of this.consumers.values()) { + if (consumer.needsReset || consumer.pending.length > 0) return true; + if (this.gate.current(this.consumerKey(consumer.consumerId, consumer.terminalId))) return true; + } + return false; + } + + hasPendingDeliveriesForTerminal(terminalId: string): boolean { + const terminal = this.terminals.get(terminalId); + if (!terminal) return false; + for (const consumerId of terminal.consumers) { + const key = this.consumerKey(consumerId, terminalId); + const consumer = this.consumers.get(key); + if (consumer?.needsReset || (consumer?.pending.length ?? 0) > 0) return true; + if (this.gate.current(key)) return true; + } + return false; + } + + pendingChars(): number { + let total = 0; + for (const consumer of this.consumers.values()) { + total += consumer.pendingChars; + total += this.gate.current(this.consumerKey(consumer.consumerId, consumer.terminalId))?.data.length ?? 0; + } + return total; + } + + bufferedChars(): number { + let total = 0; + for (const terminal of this.terminals.values()) total += terminal.buffer.length; + return total; + } + + terminalConsumerIds(terminalId: string): string[] { + return Array.from(this.terminals.get(terminalId)?.consumers ?? []); + } + + terminalIds(): string[] { + return Array.from(this.terminals.keys()); + } + + cursor(terminalId: string): { epoch: string; sequence: number } { + const terminal = this.terminal(terminalId); + return { epoch: terminal.epoch, sequence: terminal.sequence }; + } + + diagnostics(): TerminalOutputStreamDiagnostics { + return { + subscribers: this.consumers.size, + retries: this.counters.retries, + resets: this.counters.resets, + droppedOrTruncatedChars: this.counters.droppedOrTruncatedChars, + deliveredChars: this.counters.deliveredChars, + acknowledgedChars: this.counters.acknowledgedChars, + replayCount: this.counters.replayCount, + replayBytes: this.counters.replayBytes, + replayDurationMs: Math.round(this.counters.replayDurationMs * 100) / 100, + maxReplayDurationMs: Math.round(this.counters.maxReplayDurationMs * 100) / 100, + }; + } + + private rebaseConsumer( + terminalId: string, + consumerId: string, + paused = false, + replayMaxChars: number | null = null, + ): void { + const terminal = this.terminal(terminalId); + const key = this.consumerKey(consumerId, terminalId); + this.gate.clear(key); + this.consumers.set(key, { + terminalId, + consumerId, + pending: [], + pendingChars: 0, + needsReset: false, + paused, + replayMaxChars, + }); + terminal.consumers.add(consumerId); + } + + private terminal(terminalId: string): TerminalState { + let terminal = this.terminals.get(terminalId); + if (!terminal) { + terminal = { + epoch: this.epochFactory(), + sequence: 0, + buffer: new BoundedTerminalReplayBuffer(this.maxSnapshotChars), + pendingHighSurrogate: "", + consumers: new Set(), + }; + this.terminals.set(terminalId, terminal); + } + return terminal; + } + + private replaySnapshot(terminal: TerminalState, replayMaxChars: number | null): string { + return replayMaxChars == null ? terminal.buffer.value() : terminal.buffer.replay(replayMaxChars); + } +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff; +} diff --git a/client/electron/terminal-restore-policy.ts b/client/electron/terminal-restore-policy.ts index 738e557..45a5c19 100644 --- a/client/electron/terminal-restore-policy.ts +++ b/client/electron/terminal-restore-policy.ts @@ -46,6 +46,20 @@ export function savedResumeSessionId(entry: RestorableTerminal): string | null { return entry.resumeSessionId ?? entry.providerSessionId; } +export function hasMissingSavedRestoreIdentity( + entry: RestorableTerminal, + resolvedResumeSessionId: string | null, +): boolean { + return Boolean(savedResumeSessionId(entry) && !resolvedResumeSessionId); +} + +export function shouldConfirmEmbeddedTerminalRestoreShutdown( + completedBeforeDeadline: boolean, + ptyHostShutdownConfirmed: boolean, +): boolean { + return completedBeforeDeadline && ptyHostShutdownConfirmed; +} + export function claudeProjectPathCandidates(projectsDir: string, workspace: string): string[] { return Array.from(new Set([ path.join(projectsDir, encodeClaudeProjectPath(workspace)), diff --git a/client/package.json b/client/package.json index 358f13a..376a81c 100644 --- a/client/package.json +++ b/client/package.json @@ -14,8 +14,9 @@ "dev": "npm run build:electron && concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", "build": "tsc -p tsconfig.json && vite build && npm run build:electron", "build:electron": "tsc -p tsconfig.electron.json", - "test:chat": "node --test tests/app-state.test.mjs tests/chat-mode.test.mjs tests/embedded-scroll.test.mjs tests/workspace-attention.test.mjs tests/session-utils.test.mjs", - "test:electron": "npm run build:electron && node --test tests/platform.test.mjs tests/input-sequencing.test.mjs tests/pty-write.test.mjs tests/agent-context.test.mjs tests/agent-routing.test.mjs tests/agent-skills.test.mjs tests/agent-mcp.test.mjs tests/athena-cli.test.mjs tests/agent-sessions.test.mjs tests/terminal-activity.test.mjs tests/terminal-env.test.mjs tests/external-links.test.mjs tests/terminal-restore.test.mjs tests/launch-state.test.mjs tests/terminal-launch.test.mjs tests/terminal-buffer.test.mjs tests/terminal-output-ack.test.mjs tests/file-prefix.test.mjs tests/ttl-cache.test.mjs tests/control-access.test.mjs tests/terminal-input.test.mjs tests/disk-guard.test.mjs tests/memory-guard.test.mjs tests/update-mode.test.mjs", + "test:chat": "node --test tests/app-state.test.mjs tests/chat-mode.test.mjs tests/embedded-scroll.test.mjs tests/workspace-attention.test.mjs tests/session-utils.test.mjs tests/pane-layout.test.mjs tests/workspace-utils.test.mjs", + "test:electron": "npm run build:electron && node --test tests/platform.test.mjs tests/input-sequencing.test.mjs tests/pty-write.test.mjs tests/agent-context.test.mjs tests/agent-routing.test.mjs tests/agent-skills.test.mjs tests/agent-mcp.test.mjs tests/athena-cli.test.mjs tests/agent-sessions.test.mjs tests/hermes-session-index.test.mjs tests/session-index-client.test.mjs tests/terminal-activity.test.mjs tests/terminal-attention.test.mjs tests/terminal-env.test.mjs tests/external-links.test.mjs tests/terminal-restore.test.mjs tests/launch-state.test.mjs tests/graphics-state.test.mjs tests/terminal-launch.test.mjs tests/terminal-buffer.test.mjs tests/terminal-output-ack.test.mjs tests/file-prefix.test.mjs tests/ttl-cache.test.mjs tests/control-access.test.mjs tests/terminal-input.test.mjs tests/disk-guard.test.mjs tests/memory-guard.test.mjs tests/update-mode.test.mjs", + "test:regression": "npm run build:electron && node tests/run-regressions.mjs", "start": "electron .", "package": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder" diff --git a/client/src/App.tsx b/client/src/App.tsx index e539ad8..7ba69b0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,7 +9,7 @@ import { X, } from "lucide-react"; import { BackendClient, type BackendStatus, type ElectronControlStatus } from "./api"; -import { desktop, type AgentMessage, type AgentSession, type AthenaLaunchState, type EmbeddedTerminalKind, type EmbeddedTerminalSession, type PerformanceDiagnostics, type WorkspacePath } from "./electron"; +import { desktop, type AgentMessage, type AgentSession, type AthenaLaunchState, type EmbeddedTerminalKind, type EmbeddedTerminalSession, type GraphicsPreference, type GraphicsRuntimeStatus, type PerformanceDiagnostics, type WorkspacePath } from "./electron"; import { AppSidebar, AthenaMark } from "./components/AppSidebar"; import { ContextGlance, LiveWorkflow, SharedMemorySnapshot } from "./components/DashboardPanels"; import { WorkspaceTabs } from "./components/WorkspaceTabs"; @@ -31,7 +31,7 @@ import { type LoadState, } from "./app-state"; import { recordChatPromptForSession, writePromptSequence } from "./chat-mode"; -import { classifyTerminalAttention, mergeWorkspaceAttention, type WorkspaceAttention, type WorkspaceAttentionKind } from "./workspace-attention"; +import { mergeWorkspaceAttention, type WorkspaceAttention, type WorkspaceAttentionKind } from "./workspace-attention"; import { handoffLaunchOptions, type HandoffAgentKind } from "./handoff-launch"; import { applyAgentSessionRenames, @@ -59,6 +59,11 @@ const terminalFocusStorageKey = "context-workspace:terminalFocus"; const appRefreshIntervalMs = 15_000; const nativeSessionRefreshIntervalMs = 60_000; const uiThemeStyleElementId = "athena-selected-ui-theme"; + +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + const loadUiThemeCss: Record, () => Promise<{ default: string }>> = { monolith: () => import("./themes/monolith.css?raw"), press: () => import("./themes/press.css?raw"), @@ -244,6 +249,7 @@ export function App() { const [performanceDiagnostics, setPerformanceDiagnostics] = useState(null); const [agentMessages, setAgentMessages] = useState([]); const [launchState, setLaunchState] = useState(null); + const [graphicsStatus, setGraphicsStatus] = useState(null); const [restoreRequest, setRestoreRequest] = useState<{ workspaceKey: string; nonce: number } | null>(null); const backendRefreshInFlight = useRef(false); const dataRefreshInFlight = useRef(false); @@ -303,6 +309,12 @@ export function App() { if (focused) setActiveRoom("command"); } + function updateGraphicsPreference(preference: GraphicsPreference) { + void desktop.setGraphicsPreference(preference) + .then(setGraphicsStatus) + .catch((err) => setError(String(err))); + } + const client = useMemo(() => { return backend?.healthy && backend.baseUrl ? new BackendClient(backend.baseUrl) : null; }, [backend?.baseUrl, backend?.healthy]); @@ -542,6 +554,7 @@ export function App() { } }) .catch(() => undefined); + desktop.getGraphicsStatus().then(setGraphicsStatus).catch(() => undefined); }, [refreshData, refreshElectronControl, refreshSessions, sessionRenames]); useEffect(() => { @@ -609,10 +622,8 @@ export function App() { const removeWorkspaceClose = desktop.onWorkspaceClose(({ workspace: closedWorkspace }) => { closeWorkspaceTab(closedWorkspace); }); - const removeData = desktop.onEmbeddedTerminalData((payload) => { - if (!workspaceAttentionKeyForSession(payload.id)) return; - const kind = classifyTerminalAttention(payload.data); - if (kind) markWorkspaceAttention(payload.id, kind); + const removeAttention = desktop.onEmbeddedTerminalAttention((payload) => { + markWorkspaceAttention(payload.id, payload.kind); }); const removeExit = desktop.onEmbeddedTerminalExit((payload) => { markWorkspaceAttention(payload.id, "update"); @@ -624,7 +635,7 @@ export function App() { removeSession(); removeWorkspaceOpen(); removeWorkspaceClose(); - removeData(); + removeAttention(); removeExit(); }; }, []); @@ -826,20 +837,7 @@ export function App() { rows: 28, sessionLabel: kind === "shell" || kind === "hermes" ? undefined : "New", })); - const created: EmbeddedTerminalSession[] = []; - - for (const [index, options] of launchOptions.entries()) { - if ((kind === "opencode" || kind === "athena" || kind === "grok") && index > 0) await delay(650); - created.push( - await desktop.spawnEmbeddedTerminal(workspace, { - kind, - title: options.title, - cols: options.cols, - rows: options.rows, - sessionLabel: options.sessionLabel, - }), - ); - } + const created = await desktop.spawnEmbeddedTerminals(workspace, launchOptions); setEmbeddedSessions((current) => count > 1 ? [...created.reverse(), ...current.filter((item) => !created.some((createdItem) => createdItem.id === item.id))] : appendEmbeddedSessions(current, created)); @@ -1285,6 +1283,7 @@ export function App() { terminalFocus={terminalFocus} performance={performanceDiagnostics} launchState={launchState} + graphics={graphicsStatus} onSelectWorkspace={selectWorkspace} onRestartBackend={restartBackend} onRestartControl={restartElectronControl} @@ -1293,6 +1292,7 @@ export function App() { onInterfaceModeChange={setInterfaceMode} onThemeChange={setUiTheme} onTerminalFocusChange={setTerminalFocus} + onGraphicsPreferenceChange={updateGraphicsPreference} /> )} @@ -1369,7 +1369,3 @@ function AppTitleBar({ ); } - -function delay(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} diff --git a/client/src/app-state.ts b/client/src/app-state.ts index fcc262b..8f989c7 100644 --- a/client/src/app-state.ts +++ b/client/src/app-state.ts @@ -69,6 +69,18 @@ export function samePerformanceDiagnostics(a: PerformanceDiagnostics | null, b: && a.eventLoopLagMs === b.eventLoopLagMs && a.maxEventLoopLagMs === b.maxEventLoopLagMs && a.lastOutputBatchAt === b.lastOutputBatchAt + && a.rendererTerminalSubscribers === b.rendererTerminalSubscribers + && a.hiddenRawIpcBytes === b.hiddenRawIpcBytes + && a.terminalOutputRetries === b.terminalOutputRetries + && a.terminalOutputResets === b.terminalOutputResets + && a.terminalOutputDroppedChars === b.terminalOutputDroppedChars + && a.terminalOutputDeliveredChars === b.terminalOutputDeliveredChars + && a.terminalOutputAcknowledgedChars === b.terminalOutputAcknowledgedChars + && a.terminalReplayCount === b.terminalReplayCount + && a.terminalReplayBytes === b.terminalReplayBytes + && a.terminalReplayDurationMs === b.terminalReplayDurationMs + && a.terminalReplayMaxDurationMs === b.terminalReplayMaxDurationMs + && JSON.stringify(a.sessionIndex) === JSON.stringify(b.sessionIndex) && sameControlEvents(a.controlEvents, b.controlEvents) && sameTerminalControlStates(a.terminalControl, b.terminalControl) && sameAgentProcessDiagnostics(a.agentProcesses, b.agentProcesses); diff --git a/client/src/components/EmbeddedChatTerminal.tsx b/client/src/components/EmbeddedChatTerminal.tsx index 6519e7a..0cee44b 100644 --- a/client/src/components/EmbeddedChatTerminal.tsx +++ b/client/src/components/EmbeddedChatTerminal.tsx @@ -1,6 +1,11 @@ import { DragEvent, FormEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, ImagePlus, Send, TerminalSquare } from "lucide-react"; -import { desktop, type EmbeddedTerminalSession } from "../electron"; +import { + desktop, + type EmbeddedTerminalDataPayload, + type EmbeddedTerminalExitPayload, + type EmbeddedTerminalSession, +} from "../electron"; import { promptHistoryForSession, recordChatPromptForSession, @@ -18,6 +23,7 @@ const MAX_CHAT_BUFFER_CHARS = 80_000; const MAX_OUTPUT_CHARS = 14_000; const MAX_OUTPUT_BLOCKS = 8; const CHAT_OUTPUT_FLUSH_MS = 32; +const EXIT_STREAM_RECOVERY_MS = 2_500; type ChatBlock = { id: string; @@ -37,6 +43,15 @@ export function EmbeddedChatTerminal({ session }: Props) { useEffect(() => { let mounted = true; + let attached = false; + let streamEpoch: string | null = null; + let throughSequence = 0; + let attachGeneration = 0; + let pendingExit: EmbeddedTerminalExitPayload | null = null; + let exitRendered = false; + let exitReattachAttempted = false; + let exitRecoveryTimer = 0; + const beforeAttach: EmbeddedTerminalDataPayload[] = []; let pendingData = ""; let flushTimer = 0; const flushPendingData = () => { @@ -54,26 +69,99 @@ export function EmbeddedChatTerminal({ session }: Props) { pendingData = capChatBuffer(`${pendingData}${data}`); scheduleFlush(); }; - void desktop.attachEmbeddedTerminalBuffer(session.id) - .then((nextBuffer) => { - if (mounted) setBuffer(capChatBuffer(nextBuffer)); - }) - .catch(() => undefined); - - const removeData = desktop.onEmbeddedTerminalDataFor(session.id, (payload) => { - enqueueOutput(payload.data); - }); + const renderPendingExit = () => { + if (!pendingExit || exitRendered || !attached) return; + if (pendingExit.epoch && streamEpoch && pendingExit.epoch !== streamEpoch) { + if (!exitReattachAttempted) { + exitReattachAttempted = true; + void attachStream(); + return; + } + const exit = pendingExit; + pendingExit = null; + exitRendered = true; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); + exitRecoveryTimer = 0; + flushPendingData(); + setBuffer((current) => capChatBuffer( + `${current}\n[Athena: final terminal history expired before this view attached]` + + `\n[process exited: ${exit.exitCode ?? "unknown"}]\n`, + )); + return; + } + if ((pendingExit.throughSequence ?? throughSequence) > throughSequence) { + if (!exitRecoveryTimer) { + exitRecoveryTimer = window.setTimeout(() => { + exitRecoveryTimer = 0; + if (pendingExit && !exitRendered) void attachStream(); + }, EXIT_STREAM_RECOVERY_MS); + } + return; + } + const exit = pendingExit; + pendingExit = null; + exitRendered = true; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); + exitRecoveryTimer = 0; + flushPendingData(); + setBuffer((current) => capChatBuffer(`${current}\n[process exited: ${exit.exitCode ?? "unknown"}]\n`)); + }; + const applyPayload = (payload: EmbeddedTerminalDataPayload) => { + if (!attached) { + beforeAttach.push(payload); + return; + } + if (payload.epoch !== streamEpoch || (!payload.reset && payload.fromSequence > throughSequence + 1)) { + void attachStream(); + return; + } + if (payload.sequence <= throughSequence) return; + throughSequence = payload.sequence; + if (payload.reset) { + if (flushTimer) window.clearTimeout(flushTimer); + flushTimer = 0; + pendingData = ""; + setBuffer(capChatBuffer(payload.data)); + } else { + enqueueOutput(payload.data); + } + renderPendingExit(); + }; + const attachStream = async () => { + const generation = ++attachGeneration; + attached = false; + const snapshot = await desktop.attachEmbeddedTerminalStream(session.id).catch(() => null); + if (!snapshot || !mounted || generation !== attachGeneration) return; + streamEpoch = snapshot.epoch; + throughSequence = snapshot.throughSequence; + pendingData = ""; + setBuffer(capChatBuffer(snapshot.buffer)); + attached = true; + const deferred = beforeAttach.splice(0); + for (const payload of deferred) { + if (payload.epoch === snapshot.epoch && payload.sequence <= snapshot.throughSequence) continue; + applyPayload(payload); + } + renderPendingExit(); + }; + + const removeData = desktop.onEmbeddedTerminalDataFor(session.id, applyPayload); + void attachStream(); const removeExit = desktop.onEmbeddedTerminalExit((payload) => { if (payload.id === session.id) { - flushPendingData(); - setBuffer((current) => capChatBuffer(`${current}\n[process exited: ${payload.exitCode ?? "unknown"}]\n`)); + pendingExit = payload; + exitReattachAttempted = false; + renderPendingExit(); } }); return () => { mounted = false; + attachGeneration += 1; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); if (flushTimer) window.clearTimeout(flushTimer); pendingData = ""; + beforeAttach.length = 0; removeData(); removeExit(); }; diff --git a/client/src/components/EmbeddedTerminal.tsx b/client/src/components/EmbeddedTerminal.tsx index f6062a2..e2d0f84 100644 --- a/client/src/components/EmbeddedTerminal.tsx +++ b/client/src/components/EmbeddedTerminal.tsx @@ -1,8 +1,14 @@ import { DragEvent, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal, type ILink, type ITheme } from "@xterm/xterm"; -import { desktop, type EmbeddedTerminalSession } from "../electron"; +import { + desktop, + type EmbeddedTerminalDataPayload, + type EmbeddedTerminalExitPayload, + type EmbeddedTerminalSession, +} from "../electron"; import { terminalUsesMouseWheelProtocol } from "../embedded-scroll"; +import { subscribeTerminalWindowReturn } from "../terminal-lifecycle"; import "@xterm/xterm/css/xterm.css"; type Props = { @@ -10,7 +16,8 @@ type Props = { active?: boolean; }; -const MAX_PENDING_XTERM_OUTPUT_CHARS = 64_000; +type FitRequest = { refresh?: boolean; focus?: boolean }; +const EXIT_STREAM_RECOVERY_MS = 2_500; export function EmbeddedTerminal({ session, active = true }: Props) { const containerRef = useRef(null); @@ -18,6 +25,7 @@ export function EmbeddedTerminal({ session, active = true }: Props) { const fitRef = useRef(null); const activeRef = useRef(active); const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null); + const scheduleFitRef = useRef<((request?: FitRequest) => void) | null>(null); const dragDepthRef = useRef(0); const [imageDropActive, setImageDropActive] = useState(false); @@ -30,9 +38,9 @@ export function EmbeddedTerminal({ session, active = true }: Props) { if (!container) return; const terminal = new Terminal({ - // Blinking cursors keep xterm repainting even when output is idle. Athena - // disables GPU compositing on Linux by default, so avoid that continuous - // software-render cost across every visible terminal pane. + // Blinking cursors keep xterm repainting even when output is idle. Keep + // this disabled in both accelerated and crash-safe graphics modes so a + // workspace with several visible panes remains genuinely idle. cursorBlink: false, cursorStyle: "block", fontFamily: "'Cascadia Mono', 'SFMono-Regular', Consolas, monospace", @@ -52,62 +60,216 @@ export function EmbeddedTerminal({ session, active = true }: Props) { terminal.open(container); terminalRef.current = terminal; fitRef.current = fit; - fitVisibleTerminal(container, terminal, fit, session.id, activeRef.current, lastResizeRef); - if (activeRef.current) terminal.focus(); - let disposed = false; let writeInFlight = false; - let pendingWrite = ""; + let attachResolved = false; + let streamEpoch: string | null = null; + let appliedSequence = 0; + let queuedThroughSequence = 0; + let attachGeneration = 0; + let pendingExit: EmbeddedTerminalExitPayload | null = null; + let exitQueued = false; + let exitReattachAttempted = false; + let exitRecoveryTimer = 0; + const pendingWrites: Array<{ + data: string; + epoch: string; + sequence: number; + acknowledge: boolean; + reset: boolean; + refresh: boolean; + }> = []; + const beforeAttach: EmbeddedTerminalDataPayload[] = []; let refreshAfterWrite = false; + const enqueuePendingExit = () => { + if (!pendingExit || exitQueued || !attachResolved) return; + if (pendingExit.epoch && streamEpoch && pendingExit.epoch !== streamEpoch) { + if (!exitReattachAttempted) { + exitReattachAttempted = true; + void attachStream(); + return; + } + // Main retains final output briefly after exit. If that state expired + // before this view attached, the old exit cursor can never belong to + // the replacement epoch. Render one explicit gap plus the exit once; + // repeatedly reattaching here otherwise creates an infinite loop. + const exit = pendingExit; + pendingExit = null; + exitQueued = true; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); + exitRecoveryTimer = 0; + pendingWrites.push({ + data: `\r\n\x1b[33m[Athena: final terminal history expired before this view attached]\x1b[0m\r\n` + + `\x1b[33m[process exited: ${exit.exitCode ?? "unknown"}]\x1b[0m\r\n`, + epoch: streamEpoch, + sequence: queuedThroughSequence, + acknowledge: false, + reset: false, + refresh: true, + }); + drainWrites(); + return; + } + const throughSequence = pendingExit.throughSequence ?? queuedThroughSequence; + if (throughSequence > queuedThroughSequence) { + if (!exitRecoveryTimer) { + exitRecoveryTimer = window.setTimeout(() => { + exitRecoveryTimer = 0; + if (pendingExit && !exitQueued) void attachStream(); + }, EXIT_STREAM_RECOVERY_MS); + } + return; + } + const exit = pendingExit; + pendingExit = null; + exitQueued = true; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); + exitRecoveryTimer = 0; + pendingWrites.push({ + data: `\r\n\x1b[33m[process exited: ${exit.exitCode ?? "unknown"}]\x1b[0m\r\n`, + epoch: streamEpoch ?? "", + sequence: throughSequence, + acknowledge: false, + reset: false, + refresh: true, + }); + drainWrites(); + }; const drainWrites = () => { - if (disposed || writeInFlight || !pendingWrite) return; - const data = pendingWrite; - pendingWrite = ""; + if (disposed || writeInFlight || pendingWrites.length === 0) return; + const write = pendingWrites.shift(); + if (!write) return; writeInFlight = true; - terminal.write(data, () => { + if (write.reset) terminal.reset(); + const complete = () => { + // A reattach can replace the stream epoch while xterm is still + // draining one old write. Its completion/ACK is harmless, but its old + // sequence must not advance the cursor for the replacement epoch or + // fresh payloads could be mistaken for duplicates. + if (write.epoch === streamEpoch) { + appliedSequence = Math.max(appliedSequence, write.sequence); + } + if (write.acknowledge) { + desktop.ackEmbeddedTerminalData(session.id, write.epoch, write.sequence); + } writeInFlight = false; - if (refreshAfterWrite && !pendingWrite) { + refreshAfterWrite ||= write.refresh; + if (refreshAfterWrite && pendingWrites.length === 0) { refreshAfterWrite = false; refreshTerminal(terminal); } drainWrites(); - }); + }; + if (write.data) terminal.write(write.data, complete); + else complete(); }; - const enqueueWrite = (data: string, refresh = false) => { - const combined = `${pendingWrite}${data}`; - pendingWrite = combined.length > MAX_PENDING_XTERM_OUTPUT_CHARS - ? combined.slice(-MAX_PENDING_XTERM_OUTPUT_CHARS) - : combined; - refreshAfterWrite ||= refresh; + + const enqueuePayload = (payload: EmbeddedTerminalDataPayload) => { + if (!attachResolved) { + beforeAttach.push(payload); + return; + } + if (payload.epoch !== streamEpoch) { + void attachStream(); + return; + } + if (payload.sequence <= appliedSequence) { + desktop.ackEmbeddedTerminalData(session.id, payload.epoch, payload.sequence); + return; + } + if (payload.sequence <= queuedThroughSequence) { + // A retry can arrive while its first copy is still being parsed. The + // first copy's completion callback will ACK it; never enqueue it twice. + return; + } + if (!payload.reset && payload.fromSequence > queuedThroughSequence + 1) { + // A sequence gap means this view cannot safely continue from its local + // parser state. Rebase atomically from the rolling snapshot. + void attachStream(); + return; + } + if (payload.reset) { + pendingWrites.length = 0; + } + queuedThroughSequence = payload.sequence; + pendingWrites.push({ + data: payload.data, + epoch: payload.epoch, + sequence: payload.sequence, + acknowledge: true, + reset: payload.reset, + refresh: payload.reset, + }); + enqueuePendingExit(); drainWrites(); }; - void desktop.attachEmbeddedTerminalBuffer(session.id) - .then((buffer) => { - if (!buffer || terminalRef.current !== terminal) return; - enqueueWrite(buffer, true); - }) - .catch(() => undefined); + const attachStream = async () => { + const generation = ++attachGeneration; + attachResolved = false; + const snapshot = await desktop.attachEmbeddedTerminalStream(session.id).catch(() => null); + if (!snapshot || disposed || generation !== attachGeneration || terminalRef.current !== terminal) return; + streamEpoch = snapshot.epoch; + appliedSequence = 0; + queuedThroughSequence = snapshot.throughSequence; + pendingWrites.length = 0; + pendingWrites.push({ + data: snapshot.buffer, + epoch: snapshot.epoch, + sequence: snapshot.throughSequence, + acknowledge: false, + reset: true, + refresh: true, + }); + attachResolved = true; + const deferred = beforeAttach.splice(0); + for (const payload of deferred) { + if (payload.epoch === snapshot.epoch && payload.sequence <= snapshot.throughSequence) continue; + enqueuePayload(payload); + } + enqueuePendingExit(); + drainWrites(); + }; const dataDisposable = terminal.onData((data) => { void desktop.writeEmbeddedTerminal(session.id, data).catch(() => undefined); }); - const removeData = desktop.onEmbeddedTerminalDataFor(session.id, (payload) => { - enqueueWrite(payload.data); - }); + const removeData = desktop.onEmbeddedTerminalDataFor(session.id, enqueuePayload, { ackMode: "manual" }); + void attachStream(); const removeExit = desktop.onEmbeddedTerminalExit((payload) => { - if (payload.id === session.id) terminal.writeln(`\r\n\x1b[33m[process exited: ${payload.exitCode ?? "unknown"}]\x1b[0m`); + if (payload.id !== session.id || exitQueued) return; + pendingExit = payload; + exitReattachAttempted = false; + enqueuePendingExit(); }); - const resize = () => { - fitVisibleTerminal(container, terminal, fit, session.id, activeRef.current, lastResizeRef); + let fitFrame = 0; + let pendingFitRequest: FitRequest = {}; + const scheduleFit = (request: FitRequest = {}) => { + pendingFitRequest = { + refresh: pendingFitRequest.refresh || request.refresh, + focus: pendingFitRequest.focus || request.focus, + }; + if (fitFrame) return; + fitFrame = window.requestAnimationFrame(() => { + fitFrame = 0; + const next = pendingFitRequest; + pendingFitRequest = {}; + fitVisibleTerminal(container, terminal, fit, session.id, lastResizeRef); + if (next.refresh) refreshTerminal(terminal); + if (next.focus && activeRef.current) terminal.focus(); + }); }; - const observer = new ResizeObserver(resize); + scheduleFitRef.current = scheduleFit; + const observer = new ResizeObserver(() => scheduleFit()); observer.observe(container); - window.setTimeout(resize, 50); + scheduleFit({ refresh: true, focus: activeRef.current }); + const handleWindowReturn = () => scheduleFit({ refresh: true, focus: activeRef.current }); + const removeWindowReturn = subscribeTerminalWindowReturn(handleWindowReturn); const themeObserver = new MutationObserver(() => { terminal.options.theme = readTerminalTheme(); + scheduleFit({ refresh: true }); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-theme-loaded"] }); @@ -119,8 +281,13 @@ export function EmbeddedTerminal({ session, active = true }: Props) { return () => { disposed = true; - pendingWrite = ""; + attachGeneration += 1; + if (exitRecoveryTimer) window.clearTimeout(exitRecoveryTimer); + pendingWrites.length = 0; + beforeAttach.length = 0; container.removeEventListener("wheel", stopWheelPropagation); + removeWindowReturn(); + if (fitFrame) window.cancelAnimationFrame(fitFrame); observer.disconnect(); themeObserver.disconnect(); removeData(); @@ -130,36 +297,14 @@ export function EmbeddedTerminal({ session, active = true }: Props) { terminal.dispose(); terminalRef.current = null; fitRef.current = null; + scheduleFitRef.current = null; }; }, [session.id]); useEffect(() => { activeRef.current = active; - if (!active) return; - // Force PTY resize on next fit so the backend re-emits SIGWINCH and TUI apps redraw - // after tab/workspace switches that left the pane visually stale. - lastResizeRef.current = null; - const refit = () => { - const container = containerRef.current; - const terminal = terminalRef.current; - const fit = fitRef.current; - if (!container || !terminal || !fit) return; - fitVisibleTerminal(container, terminal, fit, session.id, true, lastResizeRef); - refreshTerminal(terminal); - terminal.focus(); - }; - let raf1 = 0; - let raf2 = 0; - raf1 = window.requestAnimationFrame(() => { - raf2 = window.requestAnimationFrame(refit); - }); - const timers = [80, 240].map((delay) => window.setTimeout(refit, delay)); - return () => { - timers.forEach((timer) => window.clearTimeout(timer)); - window.cancelAnimationFrame(raf1); - window.cancelAnimationFrame(raf2); - }; - }, [active, session.id]); + scheduleFitRef.current?.({ refresh: true, focus: active }); + }, [active]); function handleDragEnter(event: DragEvent) { if (!hasImageFiles(event.dataTransfer)) return; @@ -265,10 +410,9 @@ function fitVisibleTerminal( terminal: Terminal, fit: FitAddon, sessionId: string, - active: boolean, lastResizeRef: { current: { cols: number; rows: number } | null }, ) { - if (!active || !hasUsableTerminalSize(container)) return; + if (!hasUsableTerminalSize(container)) return; try { fit.fit(); const nextSize = { cols: terminal.cols, rows: terminal.rows }; diff --git a/client/src/electron.ts b/client/src/electron.ts index 6f53581..7daba16 100644 --- a/client/src/electron.ts +++ b/client/src/electron.ts @@ -54,6 +54,45 @@ export type EmbeddedTerminalSession = { error: string | null; }; +export type EmbeddedTerminalStreamSnapshot = { + id: string; + epoch: string; + buffer: string; + throughSequence: number; +}; + +export type EmbeddedTerminalDataPayload = { + id: string; + epoch: string; + fromSequence: number; + sequence: number; + data: string; + reset: boolean; +}; + +export type EmbeddedTerminalExitPayload = { + id: string; + exitCode: number | null; + epoch?: string; + throughSequence?: number; +}; + +export type EmbeddedTerminalDataSubscriptionOptions = { + ackMode?: "after-dispatch" | "manual"; +}; + +export type GraphicsPreference = "auto" | "safe" | "accelerated"; +export type GraphicsRuntimeStatus = { + mode: "safe" | "accelerated"; + reason: string; + quarantined: boolean; + preference: GraphicsPreference; + recommendedMode: "safe" | "accelerated"; + restartRequired: boolean; + lastGpuCrashAt: string | null; + lastGpuCrashReason: string | null; +}; + export type EmbeddedTerminalSpawnOptions = { kind?: EmbeddedTerminalKind; title?: string; @@ -96,6 +135,26 @@ export type PerformanceDiagnostics = { eventLoopLagMs: number; maxEventLoopLagMs: number; lastOutputBatchAt: string | null; + rendererTerminalSubscribers: number; + hiddenRawIpcBytes: number; + terminalOutputRetries: number; + terminalOutputResets: number; + terminalOutputDroppedChars: number; + terminalOutputDeliveredChars: number; + terminalOutputAcknowledgedChars: number; + terminalReplayCount: number; + terminalReplayBytes: number; + terminalReplayDurationMs: number; + terminalReplayMaxDurationMs: number; + sessionIndex: { + filesSeen: number; + filesStatted: number; + filesParsed: number; + bytesParsed: number; + cacheHits: number; + durationMs: number; + lastError: string | null; + } | null; controlEvents: ControlEvent[]; terminalControl: TerminalControlState[]; agentProcesses: AgentProcessDiagnostic[]; @@ -198,6 +257,8 @@ type WorkspaceApi = { getPreferences: () => Promise>; setPreference: (key: string, value: string) => Promise>; removePreference: (key: string) => Promise>; + getGraphicsStatus: () => Promise; + setGraphicsPreference: (value: GraphicsPreference) => Promise; getDefaultWorkspace: () => Promise; toWorkspacePath: (workspace: string) => Promise; getCodexTerminalState: () => Promise; @@ -210,10 +271,13 @@ type WorkspaceApi = { listEmbeddedTerminals: () => Promise; restoreEmbeddedTerminals: (allowedWorkspaces?: string[]) => Promise; spawnEmbeddedTerminal: (workspace: string, options?: EmbeddedTerminalSpawnOptions) => Promise; + spawnEmbeddedTerminals: (workspace: string, options: EmbeddedTerminalSpawnOptions[]) => Promise; writeEmbeddedTerminal: (id: string, data: string) => Promise; renameEmbeddedTerminal: (id: string, title: string) => Promise; resizeEmbeddedTerminal: (id: string, cols: number, rows: number) => Promise; attachEmbeddedTerminalBuffer: (id: string) => Promise; + attachEmbeddedTerminalStream: (id: string) => Promise; + ackEmbeddedTerminalData: (id: string, epoch: string, sequence: number) => void; getEmbeddedTerminalBuffer: (id: string) => Promise; listAgentMessages: (workspace?: string, limit?: number) => Promise; sendAgentMessage: (request: SendAgentMessageRequest) => Promise; @@ -226,9 +290,14 @@ type WorkspaceApi = { playAttentionSound: () => Promise; onWorkspaceOpen: (callback: (payload: { workspace: WorkspacePath; select: boolean }) => void) => () => void; onWorkspaceClose: (callback: (payload: { workspace: WorkspacePath }) => void) => () => void; - onEmbeddedTerminalData: (callback: (payload: { id: string; data: string }) => void) => () => void; - onEmbeddedTerminalDataFor: (id: string, callback: (payload: { id: string; data: string }) => void) => () => void; - onEmbeddedTerminalExit: (callback: (payload: { id: string; exitCode: number | null }) => void) => () => void; + onEmbeddedTerminalData: (callback: (payload: EmbeddedTerminalDataPayload) => void) => () => void; + onEmbeddedTerminalDataFor: ( + id: string, + callback: (payload: EmbeddedTerminalDataPayload) => void, + options?: EmbeddedTerminalDataSubscriptionOptions, + ) => () => void; + onEmbeddedTerminalAttention: (callback: (payload: { id: string; kind: "action" | "update" }) => void) => () => void; + onEmbeddedTerminalExit: (callback: (payload: EmbeddedTerminalExitPayload) => void) => () => void; onEmbeddedTerminalSession: (callback: (session: EmbeddedTerminalSession) => void) => () => void; onCodexTerminalData: (callback: (data: string) => void) => () => void; onCodexTerminalState: (callback: (state: CodexTerminalStatus) => void) => () => void; @@ -265,6 +334,19 @@ const browserFallback: WorkspaceApi = { async getPreferences() { return {}; }, async setPreference() { return {}; }, async removePreference() { return {}; }, + async getGraphicsStatus() { + return { + mode: "safe" as const, + reason: "Browser preview uses safe graphics mode.", + quarantined: false, + preference: "auto" as const, + recommendedMode: "safe" as const, + restartRequired: false, + lastGpuCrashAt: null, + lastGpuCrashReason: null, + }; + }, + async setGraphicsPreference() { return this.getGraphicsStatus(); }, async getDefaultWorkspace() { return fallbackWorkspacePath(); }, async toWorkspacePath(workspace: string) { return toFallbackWorkspacePath(workspace); }, async getCodexTerminalState() { return { running: false, workspace: null, pid: null, lastError: null }; }, @@ -293,12 +375,26 @@ const browserFallback: WorkspaceApi = { error: null, }; }, + async spawnEmbeddedTerminals(workspace: string, options: EmbeddedTerminalSpawnOptions[]) { + const sessions: EmbeddedTerminalSession[] = []; + for (const item of options) sessions.push(await this.spawnEmbeddedTerminal(workspace, item)); + return sessions; + }, async writeEmbeddedTerminal() { return this.spawnEmbeddedTerminal("/preview"); }, async renameEmbeddedTerminal(id: string, title: string) { return { ...(await this.spawnEmbeddedTerminal("/preview")), id, title }; }, async resizeEmbeddedTerminal() { return this.spawnEmbeddedTerminal("/preview"); }, async attachEmbeddedTerminalBuffer() { return "[preview terminal buffer]\\r\\n$ "; }, + async attachEmbeddedTerminalStream(id: string) { + return { + id, + epoch: "preview", + buffer: "[preview terminal buffer]\\r\\n$ ", + throughSequence: 0, + }; + }, + ackEmbeddedTerminalData() {}, async getEmbeddedTerminalBuffer() { return "[preview terminal buffer]\\r\\n$ "; }, async listAgentMessages() { return []; }, async sendAgentMessage(request: SendAgentMessageRequest) { @@ -336,6 +432,18 @@ const browserFallback: WorkspaceApi = { eventLoopLagMs: 0, maxEventLoopLagMs: 0, lastOutputBatchAt: null, + rendererTerminalSubscribers: 0, + hiddenRawIpcBytes: 0, + terminalOutputRetries: 0, + terminalOutputResets: 0, + terminalOutputDroppedChars: 0, + terminalOutputDeliveredChars: 0, + terminalOutputAcknowledgedChars: 0, + terminalReplayCount: 0, + terminalReplayBytes: 0, + terminalReplayDurationMs: 0, + terminalReplayMaxDurationMs: 0, + sessionIndex: null, controlEvents: [], terminalControl: [], agentProcesses: [], @@ -374,6 +482,7 @@ const browserFallback: WorkspaceApi = { onWorkspaceClose() { return () => undefined; }, onEmbeddedTerminalData() { return () => undefined; }, onEmbeddedTerminalDataFor() { return () => undefined; }, + onEmbeddedTerminalAttention() { return () => undefined; }, onEmbeddedTerminalExit() { return () => undefined; }, onEmbeddedTerminalSession() { return () => undefined; }, onCodexTerminalData() { return () => undefined; }, diff --git a/client/src/pane-layout.ts b/client/src/pane-layout.ts new file mode 100644 index 0000000..3cb1e8e --- /dev/null +++ b/client/src/pane-layout.ts @@ -0,0 +1,35 @@ +export const MIN_TERMINAL_PANE_HEIGHT = 205; + +export function clampTerminalPaneHeight(value: number, availableHeight: number): number { + const finiteValue = Number.isFinite(value) ? value : MIN_TERMINAL_PANE_HEIGHT; + const finiteAvailable = Number.isFinite(availableHeight) ? availableHeight : finiteValue; + const maximum = Math.max(MIN_TERMINAL_PANE_HEIGHT, Math.floor(finiteAvailable)); + return Math.min(maximum, Math.max(MIN_TERMINAL_PANE_HEIGHT, Math.round(finiteValue))); +} + +export function reconcileTerminalPaneHeights( + current: Record, + sessionIds: readonly string[], + resetForPaneSetChange: boolean, +): Record { + if (resetForPaneSetChange) return {}; + const sessionIdSet = new Set(sessionIds); + const next = Object.fromEntries(Object.entries(current).filter(([id]) => sessionIdSet.has(id))); + if (Object.keys(next).length === Object.keys(current).length) return current; + return next; +} + +export function terminalFocusAfterCollapse( + collapsingId: string, + activeId: string | null, + orderedVisibleIds: readonly string[], + alreadyCollapsedIds: ReadonlySet, +): string | null { + if (activeId !== collapsingId) return activeId; + const index = orderedVisibleIds.indexOf(collapsingId); + const candidates = [ + ...orderedVisibleIds.slice(Math.max(0, index + 1)), + ...orderedVisibleIds.slice(0, Math.max(0, index)), + ]; + return candidates.find((id) => id !== collapsingId && !alreadyCollapsedIds.has(id)) ?? null; +} diff --git a/client/src/rooms/CommandRoom.tsx b/client/src/rooms/CommandRoom.tsx index 31f745a..bcdb592 100644 --- a/client/src/rooms/CommandRoom.tsx +++ b/client/src/rooms/CommandRoom.tsx @@ -1,4 +1,4 @@ -import { type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, Code2, @@ -28,6 +28,11 @@ import { writeDeletedAgentSessions, } from "../session-utils"; import { normalizeWorkspaceKey, sameWorkspacePath } from "../workspace-utils"; +import { + clampTerminalPaneHeight, + reconcileTerminalPaneHeights, + terminalFocusAfterCollapse, +} from "../pane-layout"; type PaneDragState = { id: string; @@ -85,15 +90,18 @@ export function CommandRoom({ const [collapsedPaneIds, setCollapsedPaneIds] = useState>(new Set()); const [maximizedPaneId, setMaximizedPaneId] = useState(null); const [activeTerminalPaneByWorkspace, setActiveTerminalPaneByWorkspace] = useState>({}); + const [paneHeightsByWorkspace, setPaneHeightsByWorkspace] = useState>>({}); const [newMenuOpen, setNewMenuOpen] = useState(false); const dragStartRef = useRef<{ id: string; x: number; y: number } | null>(null); const dragTargetRef = useRef(null); + const paneSetSignatureByWorkspaceRef = useRef(new Map()); const newMenuRef = useRef(null); const workspaceOrderKey = normalizeWorkspaceKey(workspace || "none"); const sessionIds = sessions.map((session) => session.id); const sessionSignature = sessionIds.join("|"); const paneOrder = paneOrderByWorkspace[workspaceOrderKey] ?? sessionIds; const activeTerminalPaneId = activeTerminalPaneByWorkspace[workspaceOrderKey] ?? null; + const paneHeights = paneHeightsByWorkspace[workspaceOrderKey] ?? {}; useEffect(() => { setPaneOrderByWorkspace((current) => { @@ -153,6 +161,27 @@ export function CommandRoom({ const canBroadcast = promptTargets.length > 0 && broadcastPrompt.trim().length > 0 && !broadcasting; const runningAgentSessions = visibleAgentSessions.filter((session) => session.status === "running").length; + useEffect(() => { + const previousSignature = paneSetSignatureByWorkspaceRef.current.get(workspaceOrderKey); + paneSetSignatureByWorkspaceRef.current.set(workspaceOrderKey, visibleSessionKey); + setPaneHeightsByWorkspace((current) => { + const existing = current[workspaceOrderKey] ?? {}; + const next = reconcileTerminalPaneHeights( + existing, + visibleSessions.map((session) => session.id), + previousSignature !== undefined && previousSignature !== visibleSessionKey, + ); + if (next === existing) return current; + if (Object.keys(next).length === 0) { + if (!current[workspaceOrderKey]) return current; + const withoutWorkspace = { ...current }; + delete withoutWorkspace[workspaceOrderKey]; + return withoutWorkspace; + } + return { ...current, [workspaceOrderKey]: next }; + }); + }, [visibleSessionKey, workspaceOrderKey]); + useEffect(() => { setDeletedSessionKeys(readDeletedAgentSessions(workspace)); }, [workspace]); @@ -191,6 +220,24 @@ export function CommandRoom({ }, [newMenuOpen]); function togglePaneCollapsed(sessionId: string) { + const collapsing = !collapsedPaneIds.has(sessionId); + if (collapsing) { + const nextActive = terminalFocusAfterCollapse( + sessionId, + activeTerminalPaneId, + visibleSessions.map((session) => session.id), + collapsedPaneIds, + ); + setActiveTerminalPaneByWorkspace((current) => { + if (nextActive) return current[workspaceOrderKey] === nextActive + ? current + : { ...current, [workspaceOrderKey]: nextActive }; + if (!current[workspaceOrderKey]) return current; + const next = { ...current }; + delete next[workspaceOrderKey]; + return next; + }); + } setCollapsedPaneIds((current) => { const next = new Set(current); if (next.has(sessionId)) next.delete(sessionId); @@ -281,6 +328,49 @@ export function CommandRoom({ chrome.addEventListener("pointercancel", end); } + function startPaneResize(event: ReactPointerEvent, sessionId: string) { + event.preventDefault(); + event.stopPropagation(); + const handle = event.currentTarget; + const pane = handle.closest("[data-pane-id]"); + const stage = pane?.parentElement; + if (!pane || !stage) return; + handle.setPointerCapture(event.pointerId); + const startY = event.clientY; + const startHeight = pane.getBoundingClientRect().height; + let pendingHeight = startHeight; + let resizeFrame: number | null = null; + + const commitHeight = () => { + resizeFrame = null; + const nextHeight = clampTerminalPaneHeight(pendingHeight, stage.clientHeight); + setPaneHeightsByWorkspace((current) => ({ + ...current, + [workspaceOrderKey]: { + ...(current[workspaceOrderKey] ?? {}), + [sessionId]: nextHeight, + }, + })); + }; + const move = (moveEvent: PointerEvent) => { + pendingHeight = startHeight + moveEvent.clientY - startY; + if (resizeFrame == null) { + resizeFrame = window.requestAnimationFrame(commitHeight); + } + }; + const end = () => { + if (resizeFrame != null) window.cancelAnimationFrame(resizeFrame); + commitHeight(); + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", end); + handle.removeEventListener("pointercancel", end); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", end); + handle.addEventListener("pointercancel", end); + } + async function submitBroadcastPrompt() { const trimmed = broadcastPrompt.trim(); if (!trimmed || promptTargets.length === 0 || broadcasting) return; @@ -363,6 +453,17 @@ export function CommandRoom({ > {visibleSessions.map((session) => { const displayed = displayedTerminalSessions.some((item) => item.id === session.id); + const customHeight = !collapsedPaneIds.has(session.id) && activeMaximizedPaneId !== session.id + ? paneHeights[session.id] + : undefined; + const paneStyle: CSSProperties = { + position: "relative", + resize: "none", + ...(customHeight ? { height: `${customHeight}px` } : {}), + ...(dragState?.id === session.id + ? { transform: `translate(${dragState.deltaX}px, ${dragState.deltaY}px)` } + : {}), + }; return (
setActiveTerminalPaneForWorkspace(workspaceOrderKey, session.id)} - style={ - dragState?.id === session.id - ? { transform: `translate(${dragState.deltaX}px, ${dragState.deltaY}px)` } - : undefined - } + style={paneStyle} >
- : + : + )} + {displayed && !collapsedPaneIds.has(session.id) && activeMaximizedPaneId !== session.id && ( +
startPaneResize(event, session.id)} + /> )}
); diff --git a/client/src/rooms/ReviewRoom.tsx b/client/src/rooms/ReviewRoom.tsx index 9b1af1f..3646b63 100644 --- a/client/src/rooms/ReviewRoom.tsx +++ b/client/src/rooms/ReviewRoom.tsx @@ -1,7 +1,13 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Activity, Bot, CheckCircle2, Code2, FileText, Play, ScrollText, Sparkles, TerminalSquare, XCircle } from "lucide-react"; import type { RecallSourceSession, WorkspaceSnapshot } from "../api"; -import { desktop, type AgentSession, type EmbeddedTerminalKind, type EmbeddedTerminalSession } from "../electron"; +import { + desktop, + type AgentSession, + type EmbeddedTerminalDataPayload, + type EmbeddedTerminalKind, + type EmbeddedTerminalSession, +} from "../electron"; import { agentSessionDotStatus, embeddedSessionDotStatus, inspectorStatusView, StatusDot, StatusPill } from "../components/status"; import { byteLength, @@ -576,25 +582,11 @@ function SessionInspector({ } let cancelled = false; - desktop - .getEmbeddedTerminalBuffer(terminalId) - .then((content) => { - if (!cancelled) setBuffer(tailBuffer(content, inspectorBufferTailChars)); - }) - .catch((error) => { - if (!cancelled) setBuffer(String(error)); - }); - return () => { - cancelled = true; - if (flushTimerRef.current !== null) { - window.clearTimeout(flushTimerRef.current); - flushTimerRef.current = null; - } - }; - }, [terminalId]); - - useEffect(() => { - if (!terminalId) return undefined; + let attached = false; + let streamEpoch: string | null = null; + let throughSequence = 0; + let attachGeneration = 0; + const beforeAttach: EmbeddedTerminalDataPayload[] = []; const flushPending = () => { flushTimerRef.current = null; const pending = pendingBufferRef.current; @@ -602,19 +594,57 @@ function SessionInspector({ if (!pending) return; setBuffer((current) => tailBuffer(`${current}${pending}`, inspectorBufferTailChars)); }; - const removeData = desktop.onEmbeddedTerminalDataFor(terminalId, (payload) => { - pendingBufferRef.current += payload.data; - if (flushTimerRef.current === null) { - flushTimerRef.current = window.setTimeout(flushPending, inspectorBufferFlushMs); + const applyPayload = (payload: EmbeddedTerminalDataPayload) => { + if (!attached) { + beforeAttach.push(payload); + return; } - }); + if (payload.epoch !== streamEpoch || (!payload.reset && payload.fromSequence > throughSequence + 1)) { + void attachStream(); + return; + } + if (payload.sequence <= throughSequence) return; + throughSequence = payload.sequence; + if (payload.reset) { + if (flushTimerRef.current !== null) window.clearTimeout(flushTimerRef.current); + flushTimerRef.current = null; + pendingBufferRef.current = ""; + setBuffer(tailBuffer(payload.data, inspectorBufferTailChars)); + } else { + pendingBufferRef.current += payload.data; + if (flushTimerRef.current === null) { + flushTimerRef.current = window.setTimeout(flushPending, inspectorBufferFlushMs); + } + } + }; + const attachStream = async () => { + const generation = ++attachGeneration; + attached = false; + const snapshot = await desktop.attachEmbeddedTerminalStream(terminalId).catch(() => null); + if (!snapshot || cancelled || generation !== attachGeneration) return; + streamEpoch = snapshot.epoch; + throughSequence = snapshot.throughSequence; + pendingBufferRef.current = ""; + setBuffer(tailBuffer(snapshot.buffer, inspectorBufferTailChars)); + attached = true; + const deferred = beforeAttach.splice(0); + for (const payload of deferred) { + if (payload.epoch === snapshot.epoch && payload.sequence <= snapshot.throughSequence) continue; + applyPayload(payload); + } + }; + const removeData = desktop.onEmbeddedTerminalDataFor(terminalId, applyPayload); + void attachStream(); return () => { + cancelled = true; + attachGeneration += 1; removeData(); if (flushTimerRef.current !== null) { window.clearTimeout(flushTimerRef.current); flushTimerRef.current = null; } pendingBufferRef.current = ""; + beforeAttach.length = 0; }; }, [terminalId]); diff --git a/client/src/rooms/SettingsRoom.tsx b/client/src/rooms/SettingsRoom.tsx index 6e458ad..96c6235 100644 --- a/client/src/rooms/SettingsRoom.tsx +++ b/client/src/rooms/SettingsRoom.tsx @@ -1,7 +1,7 @@ import { Copy, Download, Maximize2, MessageSquare, FolderOpen, RefreshCw, TerminalSquare } from "lucide-react"; import type { AdapterStatus, BackendStatus, ElectronControlStatus, HermesStatus, RecallStatus } from "../api"; import { adapterInstallStatusView, backendStatusView, electronControlStatusView, hermesStatusView, recallStatusView, StatusPill } from "../components/status"; -import type { AthenaLaunchState, PerformanceDiagnostics } from "../electron"; +import type { AthenaLaunchState, GraphicsPreference, GraphicsRuntimeStatus, PerformanceDiagnostics } from "../electron"; import { formatAge, recallAuditLines } from "../session-utils"; type UiTheme = "classic" | "monolith" | "press" | "mono-light" | "mono-dark"; @@ -35,6 +35,7 @@ export function SettingsRoom({ terminalFocus, performance, launchState, + graphics, onSelectWorkspace, onRestartBackend, onRestartControl, @@ -44,6 +45,7 @@ export function SettingsRoom({ onInterfaceModeChange, onThemeChange, onTerminalFocusChange, + onGraphicsPreferenceChange, }: { workspace: string; backend: BackendStatus | null; @@ -59,6 +61,7 @@ export function SettingsRoom({ terminalFocus: boolean; performance: PerformanceDiagnostics | null; launchState: AthenaLaunchState | null; + graphics: GraphicsRuntimeStatus | null; onSelectWorkspace: () => Promise; onRestartBackend: () => Promise; onRestartControl: () => Promise; @@ -68,6 +71,7 @@ export function SettingsRoom({ onInterfaceModeChange: (mode: "terminal" | "chat") => void; onThemeChange: (theme: UiTheme) => void; onTerminalFocusChange: (focused: boolean) => void; + onGraphicsPreferenceChange: (preference: GraphicsPreference) => void; }) { const backendStatus = backendStatusView(backend); const electronControlStatus = electronControlStatusView(electronControl); @@ -97,6 +101,27 @@ export function SettingsRoom({ Change +
+
+ Graphics + {graphics + ? `${graphics.mode === "accelerated" ? "Hardware acceleration active" : "Crash-safe software mode active"}. ${graphics.reason}${graphics.restartRequired ? " Restart Athena to apply the selected mode." : ""}` + : "Graphics status unavailable."} +
+
+ {(["auto", "safe", "accelerated"] as GraphicsPreference[]).map((preference) => ( + + ))} +
+
Backend @@ -334,6 +359,15 @@ function performanceSummary(performance: PerformanceDiagnostics): string { `Buffered: ${formatBytes(performance.bufferedTerminalChars)} chars across terminals`, `Pending renderer output: ${formatBytes(performance.pendingOutputBytes)}`, `Per-terminal cap: ${formatBytes(performance.maxBufferChars)} chars`, + `Visible renderer consumers: ${performance.rendererTerminalSubscribers}`, + `Hidden raw renderer IPC: ${formatBytes(performance.hiddenRawIpcBytes)}`, + `Output recovery: ${performance.terminalOutputRetries} retries, ${performance.terminalOutputResets} resets`, + `Explicitly truncated: ${formatBytes(performance.terminalOutputDroppedChars)} chars`, + `Delivered / acknowledged: ${formatBytes(performance.terminalOutputDeliveredChars)} / ${formatBytes(performance.terminalOutputAcknowledgedChars)} chars`, + `Attach replay: ${performance.terminalReplayCount} snapshots, ${formatBytes(performance.terminalReplayBytes)}, ${performance.terminalReplayDurationMs.toFixed(2)} ms total (${performance.terminalReplayMaxDurationMs.toFixed(2)} ms max)`, + performance.sessionIndex + ? `Session index: ${performance.sessionIndex.filesParsed} parsed / ${performance.sessionIndex.cacheHits} cached, ${formatBytes(performance.sessionIndex.bytesParsed)}, ${Math.round(performance.sessionIndex.durationMs)} ms${performance.sessionIndex.lastError ? ` ยท ${performance.sessionIndex.lastError}` : ""}` + : "Session index: not run yet", `Last batch: ${performance.lastOutputBatchAt ?? "none"}`, ].join("\n"); } diff --git a/client/src/styles.css b/client/src/styles.css index 3b53755..677d008 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -1166,6 +1166,31 @@ button:disabled { cursor: not-allowed; opacity: .48; } .slotPane.dropTarget { border-color: rgba(216, 194, 143, .5); } +.terminalPaneResizeHandle { + position: absolute; + z-index: 4; + right: 0; + bottom: 0; + left: 0; + height: 7px; + cursor: ns-resize; + touch-action: none; +} +.terminalPaneResizeHandle::after { + content: ""; + position: absolute; + right: 8px; + bottom: 2px; + width: 20px; + height: 1px; + background: var(--line-strong); + opacity: 0; + transition: opacity 120ms ease; +} +.terminalPane:hover .terminalPaneResizeHandle::after, +.terminalPaneResizeHandle:focus-visible::after { + opacity: 1; +} .terminalChrome { min-height: 34px; padding: 0 12px; diff --git a/client/src/terminal-lifecycle.ts b/client/src/terminal-lifecycle.ts new file mode 100644 index 0000000..4cecf9d --- /dev/null +++ b/client/src/terminal-lifecycle.ts @@ -0,0 +1,38 @@ +type TerminalWindowReturnListener = () => void; + +const listeners = new Set(); +let installed = false; + +function notifyWindowReturn(): void { + for (const listener of Array.from(listeners)) listener(); +} + +function handleVisibilityChange(): void { + if (document.visibilityState === "visible") notifyWindowReturn(); +} + +function install(): void { + if (installed) return; + installed = true; + window.addEventListener("focus", notifyWindowReturn); + window.addEventListener("pageshow", notifyWindowReturn); + document.addEventListener("visibilitychange", handleVisibilityChange); +} + +function uninstall(): void { + if (!installed) return; + installed = false; + window.removeEventListener("focus", notifyWindowReturn); + window.removeEventListener("pageshow", notifyWindowReturn); + document.removeEventListener("visibilitychange", handleVisibilityChange); +} + +/** One shared browser lifecycle listener fan-outs to mounted terminal views. */ +export function subscribeTerminalWindowReturn(listener: TerminalWindowReturnListener): () => void { + listeners.add(listener); + install(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) uninstall(); + }; +} diff --git a/client/src/themes/press.css b/client/src/themes/press.css index 1a5b5c4..3d329c2 100644 --- a/client/src/themes/press.css +++ b/client/src/themes/press.css @@ -76,17 +76,9 @@ background: var(--bg); } -/* โ”€โ”€ Paper grain texture overlay โ”€โ”€ */ -body::before { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 9999; - opacity: 0.035; - mix-blend-mode: overlay; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); -} +/* Keep the press surface flat. A former fixed, full-window feTurbulence layer + used mix-blend-mode and forced Chromium to continuously composite the entire + application in Linux software-rendering mode. */ * { box-sizing: border-box; } html, body, #root { min-height: 100%; } diff --git a/client/src/workspace-utils.ts b/client/src/workspace-utils.ts index 433d5d6..3af70bd 100644 --- a/client/src/workspace-utils.ts +++ b/client/src/workspace-utils.ts @@ -1,11 +1,21 @@ import type { WorkspacePath } from "./electron"; export function normalizeWorkspaceKey(value: string): string { - const normalized = value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); - const wslDrive = /^\/mnt\/([a-zA-Z])\/(.+)$/.exec(normalized); - if (wslDrive) return `${wslDrive[1]}:/${wslDrive[2]}`.toLowerCase(); - const windowsDrive = /^\/?([a-zA-Z]):\/(.+)$/.exec(normalized); - if (windowsDrive) return `${windowsDrive[1]}:/${windowsDrive[2]}`.toLowerCase(); + const slashed = value.trim().replace(/\\/g, "/"); + if (!slashed) return ""; + const wslDrive = /^\/mnt\/([a-zA-Z])(?:\/(.*))?$/.exec(slashed); + if (wslDrive) { + const rest = (wslDrive[2] ?? "").replace(/\/+$/, ""); + return `${wslDrive[1]}:/${rest}`.toLowerCase(); + } + const windowsDrive = /^\/?([a-zA-Z]):\/(.*)$/.exec(slashed); + if (windowsDrive) { + const rest = windowsDrive[2].replace(/\/+$/, ""); + return `${windowsDrive[1]}:/${rest}`.toLowerCase(); + } + const withoutTrailingSlashes = slashed.replace(/\/+$/, ""); + const normalized = withoutTrailingSlashes || (slashed.startsWith("/") ? "/" : ""); + if (/^\/\/[^/]+\/[^/]+/.test(normalized)) return normalized.toLowerCase(); return normalized; } diff --git a/client/tests/agent-sessions.test.mjs b/client/tests/agent-sessions.test.mjs index 25ec12e..bc4e85f 100644 --- a/client/tests/agent-sessions.test.mjs +++ b/client/tests/agent-sessions.test.mjs @@ -1,7 +1,30 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { liveTerminalAgentSession } from "../dist-electron/agent-sessions.js"; +import { + BoundedTtlPromiseCache, + liveTerminalAgentSession, + sameOrDescendantPath, + workspaceSqlFilter, +} from "../dist-electron/agent-sessions.js"; + +test("agent-session cache evicts expired and least-recently-used workspaces", async () => { + const cache = new BoundedTtlPromiseCache(2, 10); + let loads = 0; + const load = (value) => cache.getOrCreate(value, async () => { loads += 1; return value; }, 100); + + assert.equal(await load("first"), "first"); + assert.equal(await load("second"), "second"); + assert.equal(await load("first"), "first"); + assert.equal(await load("third"), "third"); + assert.equal(cache.size, 2); + assert.equal(loads, 3); + assert.equal(await load("second"), "second"); + assert.equal(loads, 4); + + assert.equal(await cache.getOrCreate("expired", async () => "fresh", 111), "fresh"); + assert.ok(cache.size <= 2); +}); function terminalSession(overrides = {}) { return { @@ -39,3 +62,35 @@ test("live terminal agent sessions prefer discovered provider session ids", () = assert.equal(session.terminalId, "terminal-1"); assert.equal(session.status, "running"); }); + +test("provider SQL workspace filters preserve POSIX case and include Windows/WSL equivalents", () => { + const posix = workspaceSqlFilter("cwd", "/Work/Case-Sensitive"); + assert.ok(posix.params.includes("/Work/Case-Sensitive")); + assert.ok(!posix.params.includes("/work/case-sensitive")); + + const wsl = workspaceSqlFilter("cwd", "/mnt/C/Users/Alan/Project"); + assert.ok(wsl.params.includes("/mnt/c/users/alan/project")); + assert.ok(wsl.params.includes("c:/users/alan/project")); + assert.match(wsl.sql, /substr/); + + const unc = workspaceSqlFilter("cwd", "\\\\Server\\Share\\Project"); + assert.ok(unc.params.includes("//server/share/project")); + assert.match(unc.sql, /lower/); +}); + +test("provider workspace guards include root descendants without folding POSIX case", () => { + assert.equal(sameOrDescendantPath("/", "/"), true); + assert.equal(sameOrDescendantPath("/work/project", "/"), true); + assert.equal(sameOrDescendantPath("/Work/Project/child", "/Work/Project"), true); + assert.equal(sameOrDescendantPath("/work/project", "/Work/Project"), false); + assert.equal(sameOrDescendantPath("C:\\Users\\Alan", "C:\\"), true); + assert.equal(sameOrDescendantPath("/mnt/c/Users/Alan", "C:\\"), true); + + const posixRoot = workspaceSqlFilter("cwd", "/"); + assert.equal(posixRoot.params.length, 0); + assert.match(posixRoot.sql, /substr/); + + const driveRoot = workspaceSqlFilter("cwd", "C:\\"); + assert.ok(driveRoot.params.includes("c:")); + assert.ok(driveRoot.params.includes("/mnt/c")); +}); diff --git a/client/tests/graphics-state.test.mjs b/client/tests/graphics-state.test.mjs new file mode 100644 index 0000000..e7bedb7 --- /dev/null +++ b/client/tests/graphics-state.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + chooseGraphicsMode, + initializeOwnedGraphicsLaunch, + isGpuFailureReason, + parseGraphicsPreference, +} from "../dist-electron/graphics-state.js"; + +const cleanState = { + version: 1, + quarantined: false, + acceleratedClean: true, + acceleratedPending: false, + lastMode: "accelerated", + lastGpuCrashAt: null, + lastGpuCrashReason: null, + lastCleanAt: "2026-07-20T00:00:00.000Z", +}; + +test("Linux auto mode canaries acceleration and retains a clean record", () => { + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "auto", state: { ...cleanState, acceleratedClean: false } }).mode, "accelerated"); + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "auto", state: cleanState }).mode, "accelerated"); +}); + +test("a GPU crash quarantines preference-based acceleration but not an environment override", () => { + const crashed = { ...cleanState, quarantined: true, acceleratedClean: false }; + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "accelerated", state: crashed }).mode, "safe"); + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "accelerated", state: crashed, forceGpu: true }).mode, "accelerated"); +}); + +test("an interrupted accelerated launch is crash-loop quarantined", () => { + const interrupted = { ...cleanState, acceleratedPending: true }; + const decision = chooseGraphicsMode({ platform: "linux", preference: "accelerated", state: interrupted }); + assert.equal(decision.mode, "safe"); + assert.equal(decision.quarantined, true); + assert.match(decision.reason, /did not exit cleanly/); +}); + +test("headless safety wins unless GPU is explicitly forced", () => { + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "accelerated", state: cleanState, headless: true }).mode, "safe"); + assert.equal(chooseGraphicsMode({ platform: "linux", preference: "safe", state: cleanState, headless: true, forceGpu: true }).mode, "accelerated"); + assert.equal(parseGraphicsPreference("unknown"), "auto"); +}); + +test("explicit safe mode and crash quarantine work on Windows and macOS", () => { + assert.equal(chooseGraphicsMode({ platform: "win32", preference: "safe", state: cleanState }).mode, "safe"); + assert.equal(chooseGraphicsMode({ platform: "darwin", preference: "safe", state: cleanState }).mode, "safe"); + assert.equal( + chooseGraphicsMode({ platform: "win32", preference: "auto", state: { ...cleanState, quarantined: true } }).mode, + "safe", + ); +}); + +test("normal GPU teardown is not mistaken for a graphics crash", () => { + assert.equal(isGpuFailureReason("clean-exit"), false); + assert.equal(isGpuFailureReason("killed"), false); + assert.equal(isGpuFailureReason("crashed"), true); + assert.equal(isGpuFailureReason("oom"), true); +}); + +test("a packaged second instance cannot mutate the primary graphics launch state", () => { + let initialized = 0; + assert.equal(initializeOwnedGraphicsLaunch(false, () => { initialized += 1; }), false); + assert.equal(initialized, 0); + assert.equal(initializeOwnedGraphicsLaunch(true, () => { initialized += 1; }), true); + assert.equal(initialized, 1); +}); diff --git a/client/tests/hermes-session-index.test.mjs b/client/tests/hermes-session-index.test.mjs new file mode 100644 index 0000000..ec56cbc --- /dev/null +++ b/client/tests/hermes-session-index.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; + +import { HermesSessionIndex } from "../dist-electron/hermes-session-index.js"; + +const testsDir = path.dirname(fileURLToPath(import.meta.url)); + +async function fixture(t) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "athena-hermes-index-")); + const sessions = path.join(root, "sessions"); + const cachePath = path.join(root, "cache", "index.json"); + await fs.mkdir(sessions, { recursive: true }); + await fs.writeFile(path.join(root, "state.db"), "fixture"); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return { root, sessions, cachePath }; +} + +function session(overrides = {}) { + return { + session_id: "session-1", + model: "model-from-file", + platform: "cli", + session_start: "2026-01-01T00:00:00.000Z", + last_updated: "2026-01-02T00:00:00.000Z", + messages: [{ role: "user", content: "Indexed title" }], + ...overrides, + }; +} + +async function writeSession(sessionsDir, id, value) { + await fs.writeFile(path.join(sessionsDir, `session_${id}.json`), JSON.stringify(value)); +} + +test("Hermes index filters before its result limit and preserves descendant workspace matching", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + const rows = []; + for (let index = 0; index < 105; index += 1) { + const id = `unrelated-${index}`; + rows.push([id, "cli", "model", 2_000 + index, 3_000 + index, 1, `Unrelated ${index}`]); + await writeSession(sessions, id, session({ session_id: id, workspace: `/other/${index}` })); + } + rows.push(["older-match", "cli", "db-model", 1_000, 1_001, 1, "Older matching session"]); + await writeSession(sessions, "older-match", session({ session_id: "older-match", workspace: "/work/project/child" })); + + const index = new HermesSessionIndex({ cachePath, queryDatabase: async () => rows }); + const result = await index.list(["/work/project"], root); + + assert.deepEqual(result["/work/project"].map((entry) => entry.id), ["older-match"]); + assert.equal(result["/work/project"][0].model, "db-model"); +}); + +test("Hermes index includes root descendants and isolates case-distinct POSIX workspaces", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + await writeSession(sessions, "upper", session({ session_id: "upper", workspace: "/Work/Project" })); + await writeSession(sessions, "lower", session({ session_id: "lower", workspace: "/work/project" })); + const rows = [ + ["upper", "cli", "model", 2_000, 2_000, 1, "Upper"], + ["lower", "cli", "model", 1_000, 1_000, 1, "Lower"], + ]; + const index = new HermesSessionIndex({ cachePath, queryDatabase: async () => rows }); + + const result = await index.list(["/", "/Work/Project", "/work/project"], root); + + assert.deepEqual(new Set(result["/"].map((entry) => entry.id)), new Set(["upper", "lower"])); + assert.deepEqual(result["/Work/Project"].map((entry) => entry.id), ["upper"]); + assert.deepEqual(result["/work/project"].map((entry) => entry.id), ["lower"]); +}); + +test("Hermes index preserves embedded-path fallback without persisting transcript search text", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + const secretTranscript = "DO_NOT_RETAIN_THIS_TRANSCRIPT_PAYLOAD"; + await writeSession(sessions, "embedded", session({ + session_id: "embedded", + system_prompt: "The active project is /work/context-workspace and its descendants.", + messages: [ + { role: "user", content: "Embedded path title" }, + { role: "assistant", content: secretTranscript }, + ], + })); + const rows = [["embedded", "cli", "model", 1_000, 2_000, 1, null]]; + const index = new HermesSessionIndex({ cachePath, queryDatabase: async () => rows }); + + const result = await index.list(["/work/context-workspace"], root); + assert.deepEqual(result["/work/context-workspace"].map((entry) => entry.id), ["embedded"]); + + const persisted = await fs.readFile(cachePath, "utf8"); + assert.doesNotMatch(persisted, new RegExp(secretTranscript)); + assert.doesNotMatch(persisted, /searchText/); + const persistedEntry = JSON.parse(persisted).entries[0]; + assert.ok(Buffer.byteLength(persistedEntry.workspaceHints.join(""), "utf8") <= 16_384); +}); + +test("Hermes index retains last-known-good metadata when a changed file is temporarily corrupt", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + const filePath = path.join(sessions, "session-stable.json"); + await writeSession(sessions, "stable", session({ session_id: "stable", workspace: "/work/stable" })); + const rows = [["stable", "cli", null, 1_000, null, 1, null]]; + const index = new HermesSessionIndex({ cachePath, queryDatabase: async () => rows }); + + const first = await index.list(["/work/stable"], root); + assert.equal(first["/work/stable"][0].title, "Indexed title"); + + await fs.writeFile(filePath, "{ corrupt and incomplete json payload"); + const future = new Date(Date.now() + 2_000); + await fs.utimes(filePath, future, future); + const second = await index.list(["/work/stable"], root); + + assert.equal(second["/work/stable"][0].title, "Indexed title"); + assert.equal(second["/work/stable"][0].model, "model-from-file"); +}); + +test("Hermes index classifies a newly requested workspace without losing prior matches", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + await writeSession(sessions, "shared", session({ + session_id: "shared", + system_prompt: "Handoff between /work/first-project and /work/second-project. Active path: /home/alan/My Project with spaces", + })); + const rows = [["shared", "cli", "model", 1_000, 2_000, 1, null]]; + let sessionFileReads = 0; + const index = new HermesSessionIndex({ + cachePath, + queryDatabase: async () => rows, + readSessionFile: async (filePath) => { + sessionFileReads += 1; + return fs.readFile(filePath, "utf8"); + }, + }); + + const first = await index.list(["/work/first-project"], root); + assert.equal(sessionFileReads, 1); + assert.equal(index.getDiagnostics().filesParsed, 1); + assert.ok(index.getDiagnostics().bytesParsed > 0); + sessionFileReads = 0; + const second = await index.list(["/work/second-project"], root); + const warmDiagnostics = index.getDiagnostics(); + const both = await index.list(["/work/first-project", "/work/second-project", "/home/alan/My Project"], root); + + assert.equal(first["/work/first-project"][0].id, "shared"); + assert.equal(second["/work/second-project"][0].id, "shared"); + assert.equal(both["/work/first-project"][0].id, "shared"); + assert.equal(both["/work/second-project"][0].id, "shared"); + assert.equal(both["/home/alan/My Project"][0].id, "shared"); + assert.equal(sessionFileReads, 0, "unchanged files must be classified from persisted path hints"); + assert.equal(warmDiagnostics.filesParsed, 0); + assert.equal(warmDiagnostics.bytesParsed, 0); + assert.equal(warmDiagnostics.cacheHits, 1); +}); + +test("Hermes index keeps database metadata across a transient empty query", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + await writeSession(sessions, "db-stable", session({ session_id: "db-stable", workspace: "/work/db-stable" })); + let calls = 0; + const index = new HermesSessionIndex({ + cachePath, + queryDatabase: async () => { + calls += 1; + return calls === 1 ? [["db-stable", "cli", "db-model", 1_000, 2_000, 1, "Database title"]] : []; + }, + }); + + const first = await index.list(["/work/db-stable"], root); + const second = await index.list(["/work/db-stable"], root); + + assert.equal(first["/work/db-stable"][0].title, "Database title"); + assert.equal(second["/work/db-stable"][0].title, "Database title"); + assert.equal(second["/work/db-stable"][0].model, "db-model"); +}); + +test("overlapping list calls wait for one complete persisted-index load before refreshing", async (t) => { + const { root, sessions, cachePath } = await fixture(t); + await writeSession(sessions, "overlap", session({ session_id: "overlap", workspace: "/work/overlap" })); + let startCacheRead; + const cacheReadStarted = new Promise((resolve) => { + startCacheRead = resolve; + }); + let releaseCacheRead; + const cacheReadGate = new Promise((resolve) => { + releaseCacheRead = resolve; + }); + let databaseQueries = 0; + const index = new HermesSessionIndex({ + cachePath, + readCacheFile: async () => { + startCacheRead(); + await cacheReadGate; + return JSON.stringify({ version: 2, hermesDir: root, entries: [] }); + }, + queryDatabase: async () => { + databaseQueries += 1; + return [["overlap", "cli", "model", 1_000, 2_000, 1, "Overlapping session"]]; + }, + }); + + const first = index.list(["/work/overlap"], root); + await cacheReadStarted; + const second = index.list(["/work/overlap"], root); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(databaseQueries, 0, "refresh must not race an incomplete cache load"); + + releaseCacheRead(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(databaseQueries, 1, "overlapping lists must share the refresh after loading"); + assert.deepEqual(firstResult["/work/overlap"].map((entry) => entry.id), ["overlap"]); + assert.deepEqual(secondResult["/work/overlap"].map((entry) => entry.id), ["overlap"]); +}); + +test("session-index host returns compact results over IPC and exits when idle", async (t) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "athena-session-host-")); + const sessions = path.join(home, ".hermes", "sessions"); + await fs.mkdir(sessions, { recursive: true }); + await writeSession(sessions, "ipc", session({ session_id: "ipc", workspace: "/work/ipc-project" })); + const child = fork(path.resolve(testsDir, "../dist-electron/session-index-host.js"), [], { + execPath: process.execPath, + execArgv: [], + env: { ...process.env, HOME: home, USERPROFILE: home, ELECTRON_RUN_AS_NODE: "1" }, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + t.after(async () => { + if (child.exitCode === null) child.kill(); + await fs.rm(home, { recursive: true, force: true }); + }); + + const responsePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("session-index host response timed out")), 5_000); + child.once("message", (message) => { + clearTimeout(timer); + resolve(message); + }); + }); + child.send({ type: "list-hermes", requestId: "request-1", workspaces: ["/work/ipc-project"] }); + const response = await responsePromise; + + assert.equal(response.ok, true); + assert.deepEqual(response.sessions["/work/ipc-project"].map((entry) => entry.id), ["ipc"]); + assert.equal(response.diagnostics.filesParsed, 1); + await once(child, "exit"); + assert.equal(child.exitCode, 0); +}); diff --git a/client/tests/memory-guard.test.mjs b/client/tests/memory-guard.test.mjs index ac3583d..2fb7f0f 100644 --- a/client/tests/memory-guard.test.mjs +++ b/client/tests/memory-guard.test.mjs @@ -7,6 +7,14 @@ import { MEMORY_CRITICAL_BYTES, MEMORY_WARN_BYTES, } from "../dist-electron/memory-guard.js"; +import { + HEAVY_LAUNCH_RESERVATION_BYTES, + HEAVY_LAUNCH_STAGGER_MS, + LaunchAdmissionService, + launchStaggerDelayMs, + OneShotLaunchOverride, + publicLaunchAdmission, +} from "../dist-electron/launch-admission.js"; const GiB = 1024 * 1024 * 1024; @@ -67,6 +75,22 @@ test("classifyMemorySnapshot reports ok with ample memory and idle swap", () => ); }); +test("unused swap is not treated as physical launch capacity", () => { + assert.equal( + classifyMemorySnapshot({ + availableBytes: 1.5 * GiB, + memFreeBytes: 256 * 1024 * 1024, + swapTotalBytes: 8 * GiB, + swapFreeBytes: 8 * GiB, + }), + "warn", + ); + const service = new LaunchAdmissionService(() => memoryStatus("warn", 1.5 * GiB)); + const burst = service.reserve({ source: "control", kind: "codex", count: 2 }); + assert.equal(burst.granted, false); + assert.equal(burst.decision, "defer"); +}); + test("classifyMemorySnapshot never blocks a launch when the probe failed", () => { assert.equal( classifyMemorySnapshot({ availableBytes: null, memFreeBytes: null, swapTotalBytes: 0, swapFreeBytes: 0 }), @@ -78,3 +102,101 @@ test("formatBytes renders human-readable sizes", () => { assert.equal(formatBytes(null), "unknown"); assert.equal(formatBytes(2 * GiB), "2.0 GiB"); }); + +function memoryStatus(level, availableBytes) { + return { level, availableBytes, totalBytes: 16 * GiB }; +} + +test("launch admission atomically reserves a whole multi-agent request", () => { + const service = new LaunchAdmissionService(() => memoryStatus("ok", 4 * GiB)); + const batch = service.reserve({ source: "control", kind: "codex", count: 4 }); + + assert.equal(batch.decision, "warn"); + assert.equal(batch.granted, true); + assert.equal(batch.requestedBytes, 4 * HEAVY_LAUNCH_RESERVATION_BYTES); + assert.equal(batch.projectedAvailableBytes, 4 * GiB - 4 * HEAVY_LAUNCH_RESERVATION_BYTES); + assert.equal(service.reservedBytes(), 4 * HEAVY_LAUNCH_RESERVATION_BYTES); + + const competing = service.reserve({ source: "control", kind: "claude", count: 3 }); + assert.equal(competing.decision, "defer"); + assert.equal(competing.granted, false); + assert.equal(competing.alreadyReservedBytes, 4 * HEAVY_LAUNCH_RESERVATION_BYTES); + assert.equal(service.reservationCount(), 1); +}); + +test("launch admission returns source-appropriate critical decisions", () => { + const service = new LaunchAdmissionService(() => memoryStatus("critical", 900 * 1024 * 1024)); + const ui = service.reserve({ source: "ui", kind: "codex" }); + const control = service.reserve({ source: "control", kind: "codex" }); + const restore = service.reserve({ source: "restore", kind: "codex" }); + + assert.equal(ui.decision, "reject"); + assert.equal(control.decision, "defer"); + assert.equal(restore.decision, "defer"); + assert.equal(ui.reservationId, null); + assert.equal(service.reservationCount(), 0); +}); + +test("critical launch requires an explicit override and returns only a warning lease", () => { + const service = new LaunchAdmissionService(() => memoryStatus("critical", 900 * 1024 * 1024)); + const admission = service.reserve({ + source: "control", + kind: "athena", + count: 2, + overrideCritical: true, + }); + + assert.equal(admission.decision, "warn"); + assert.equal(admission.granted, true); + assert.equal(admission.overrideUsed, true); + assert.ok(admission.reservationId); + assert.equal(publicLaunchAdmission(admission).reservationId, undefined); +}); + +test("release is idempotent and removes failed-spawn capacity reservations", () => { + const service = new LaunchAdmissionService(() => memoryStatus("ok", 8 * GiB)); + const admission = service.reserve({ source: "control", kind: "grok", count: 2 }); + assert.equal(service.reservedBytes(), 2 * HEAVY_LAUNCH_RESERVATION_BYTES); + assert.equal(service.release(admission), true); + assert.equal(service.release(admission), false); + assert.equal(service.reservedBytes(), 0); +}); + +test("settling a partial batch releases failed capacity and then expires", async () => { + const service = new LaunchAdmissionService(() => memoryStatus("ok", 8 * GiB)); + const admission = service.reserve({ source: "control", kind: "codex", count: 4 }); + assert.equal(service.reservedBytes(), 4 * HEAVY_LAUNCH_RESERVATION_BYTES); + + assert.equal(service.settle(admission, 2, 10), true); + assert.equal(service.reservedBytes(), 2 * HEAVY_LAUNCH_RESERVATION_BYTES); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(service.reservedBytes(), 0); + assert.equal(service.reservationCount(), 0); +}); + +test("shell launches stay fail-open without consuming reservations", () => { + const service = new LaunchAdmissionService(() => memoryStatus("critical", 0)); + const admission = service.reserve({ source: "control", kind: "shell", count: 8 }); + assert.equal(admission.decision, "allow"); + assert.equal(admission.granted, true); + assert.equal(admission.requestedBytes, 0); + assert.equal(admission.reservationId, null); + assert.equal(service.reservedBytes(), 0); +}); + +test("heavy launch batches are staggered while recovery shells remain immediate", () => { + assert.equal(launchStaggerDelayMs("codex", 0), 0); + assert.equal(launchStaggerDelayMs("codex", 1), HEAVY_LAUNCH_STAGGER_MS); + assert.equal(launchStaggerDelayMs("claude", 7), HEAVY_LAUNCH_STAGGER_MS); + assert.equal(launchStaggerDelayMs("shell", 7), 0); +}); + +test("critical-memory UI approval authorizes one atomic request only", () => { + const override = new OneShotLaunchOverride(); + override.grant(1_000, 5_000); + assert.equal(override.consume(1_001), true); + assert.equal(override.consume(1_002), false); + + override.grant(2_000, 5); + assert.equal(override.consume(2_005), false); +}); diff --git a/client/tests/pane-layout.test.mjs b/client/tests/pane-layout.test.mjs new file mode 100644 index 0000000..687f32a --- /dev/null +++ b/client/tests/pane-layout.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MIN_TERMINAL_PANE_HEIGHT, + clampTerminalPaneHeight, + reconcileTerminalPaneHeights, + terminalFocusAfterCollapse, +} from "../src/pane-layout.ts"; + +test("pane height clamps to the visible stage", () => { + assert.equal(clampTerminalPaneHeight(50, 900), MIN_TERMINAL_PANE_HEIGHT); + assert.equal(clampTerminalPaneHeight(480, 900), 480); + assert.equal(clampTerminalPaneHeight(1_200, 900), 900); +}); + +test("pane-set changes reset React-owned manual heights", () => { + const current = { first: 380, second: 410 }; + assert.deepEqual(reconcileTerminalPaneHeights(current, ["first", "second", "third"], true), {}); +}); + +test("ordinary rerenders preserve known pane heights and prune closed panes", () => { + const current = { first: 380, closed: 410 }; + assert.deepEqual(reconcileTerminalPaneHeights(current, ["first"], false), { first: 380 }); + const stable = { first: 380 }; + assert.equal(reconcileTerminalPaneHeights(stable, ["first"], false), stable); +}); + +test("collapsing the focused pane hands focus to a visible sibling", () => { + assert.equal(terminalFocusAfterCollapse("second", "second", ["first", "second", "third"], new Set()), "third"); + assert.equal(terminalFocusAfterCollapse("third", "third", ["first", "second", "third"], new Set(["first"])), "second"); + assert.equal(terminalFocusAfterCollapse("only", "only", ["only"], new Set()), null); + assert.equal(terminalFocusAfterCollapse("second", "first", ["first", "second"], new Set()), "first"); +}); diff --git a/client/tests/platform.test.mjs b/client/tests/platform.test.mjs index 4493b34..2d8314f 100644 --- a/client/tests/platform.test.mjs +++ b/client/tests/platform.test.mjs @@ -32,11 +32,27 @@ test("normalizes equivalent Windows and WSL workspace keys", () => { assert.equal(normalizeComparablePath("/C:/Users/dev/repo"), "c:/users/dev/repo"); }); +test("preserves filesystem roots in comparable paths", () => { + assert.equal(normalizeComparablePath("/"), "/"); + assert.equal(normalizeComparablePath("///"), "/"); + assert.equal(normalizeComparablePath("C:\\"), "c:/"); + assert.equal(normalizeComparablePath("/mnt/C/"), "c:/"); + assert.equal(normalizeComparablePath("\\\\Server\\Share\\"), "//server/share"); +}); + test("preserves POSIX path case when normalizing comparable paths", () => { assert.equal(normalizeComparablePath("/home/dev/Repo"), "/home/dev/Repo"); assert.notEqual(normalizeComparablePath("/home/dev/Repo"), normalizeComparablePath("/home/dev/repo")); }); +test("normalizes UNC paths with Windows case semantics", () => { + assert.equal(normalizeComparablePath("\\\\Server\\Share\\Repo"), "//server/share/repo"); + assert.equal( + normalizeComparablePath("\\\\SERVER\\SHARE\\REPO"), + normalizeComparablePath("\\\\server\\share\\repo"), + ); +}); + test("keeps Windows workspace paths first-class in the workspace model", () => { const workspace = toWorkspacePath("C:\\Users\\dev\\repo"); assert.equal(workspace.nativePath, "C:\\Users\\dev\\repo"); diff --git a/client/tests/regressions/README.md b/client/tests/regressions/README.md new file mode 100644 index 0000000..adff109 --- /dev/null +++ b/client/tests/regressions/README.md @@ -0,0 +1,3 @@ +# Client regression tests + +Permanent tests in this directory encode production incidents and their behavioral contracts. Each test must name the PR/incident chain it protects and fail against the pre-fix implementation. diff --git a/client/tests/regressions/pr-169-172-terminal-stream.test.mjs b/client/tests/regressions/pr-169-172-terminal-stream.test.mjs new file mode 100644 index 0000000..e7e3230 --- /dev/null +++ b/client/tests/regressions/pr-169-172-terminal-stream.test.mjs @@ -0,0 +1,6 @@ +// Permanent incident coverage: bounded output/backpressure (#169) and the +// lost-ACK terminal wedge (#172). These suites exercise retained retries, +// duplicate/stale ACKs, independent consumers, explicit overflow resets, and +// VT/Unicode-safe bounded replay. +import "../terminal-buffer.test.mjs"; +import "../terminal-output-ack.test.mjs"; diff --git a/client/tests/regressions/pr-181-launch-admission.test.mjs b/client/tests/regressions/pr-181-launch-admission.test.mjs new file mode 100644 index 0000000..538f332 --- /dev/null +++ b/client/tests/regressions/pr-181-launch-admission.test.mjs @@ -0,0 +1,4 @@ +// Permanent incident coverage: swap-thrash desktop freezes (#181). The shared +// admission suite verifies atomic reservations and parity for UI, control, and +// restore decisions under projected memory pressure. +import "../memory-guard.test.mjs"; diff --git a/client/tests/regressions/pr-26-100-184-graphics.test.mjs b/client/tests/regressions/pr-26-100-184-graphics.test.mjs new file mode 100644 index 0000000..ced86d7 --- /dev/null +++ b/client/tests/regressions/pr-26-100-184-graphics.test.mjs @@ -0,0 +1,4 @@ +// Permanent incident coverage: accelerated rendering fixed software-paint CPU +// (#26), global Linux acceleration later exposed native crashes (#100), and +// renderer mitigations could not resolve that policy conflict (#184). +import "../graphics-state.test.mjs"; diff --git a/client/tests/regressions/pr-57-146-189-pane-layout.test.mjs b/client/tests/regressions/pr-57-146-189-pane-layout.test.mjs new file mode 100644 index 0000000..c78ed77 --- /dev/null +++ b/client/tests/regressions/pr-57-146-189-pane-layout.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MIN_TERMINAL_PANE_HEIGHT, + clampTerminalPaneHeight, + reconcileTerminalPaneHeights, + terminalFocusAfterCollapse, +} from "../../src/pane-layout.ts"; + +test("PR #57/#146/#189: pane-set reflow resets browser-independent manual sizing", () => { + assert.deepEqual(reconcileTerminalPaneHeights({ first: 360 }, ["first", "second"], true), {}); +}); + +test("PR #189: manual pane sizing is bounded by the visible stage", () => { + assert.equal(clampTerminalPaneHeight(1, 800), MIN_TERMINAL_PANE_HEIGHT); + assert.equal(clampTerminalPaneHeight(2_000, 800), 800); +}); + +test("PR #146/#179: collapsing the focus owner promotes a mounted sibling", () => { + assert.equal(terminalFocusAfterCollapse("a", "a", ["a", "b"], new Set()), "b"); +}); diff --git a/client/tests/regressions/pr-95-154-169-170-session-index.test.mjs b/client/tests/regressions/pr-95-154-169-170-session-index.test.mjs new file mode 100644 index 0000000..4985fec --- /dev/null +++ b/client/tests/regressions/pr-95-154-169-170-session-index.test.mjs @@ -0,0 +1,5 @@ +// Permanent incident coverage: async-but-main-thread scans (#95), +// multi-workspace amplification (#154), OOM bounds (#169), and global Hermes +// deduplication (#170). The imported suite asserts incremental parsing, +// metadata-only persistence, workspace-before-limit, and last-known-good data. +import "../hermes-session-index.test.mjs"; diff --git a/client/tests/run-regressions.mjs b/client/tests/run-regressions.mjs new file mode 100644 index 0000000..558ee14 --- /dev/null +++ b/client/tests/run-regressions.mjs @@ -0,0 +1,45 @@ +import { readdir } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const root = path.resolve(import.meta.dirname, "regressions"); + +async function collectTests(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const tests = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) tests.push(...await collectTests(entryPath)); + else if (/\.test\.m?js$/.test(entry.name)) tests.push(entryPath); + } + return tests.sort(); +} + +const tests = await collectTests(root); +if (tests.length === 0) { + console.error(`No permanent client regression tests found under ${root}.`); + process.exit(1); +} + +const child = spawn(process.execPath, ["--test", ...tests], { + cwd: path.resolve(import.meta.dirname, ".."), + stdio: "inherit", +}); +child.on("exit", (code, signal) => { + if (signal) { + console.error(`Client regression tests stopped by ${signal}.`); + process.exit(1); + } + process.exit(code ?? 1); +}); +child.on("error", (error) => { + console.error(`Failed to start client regression tests: ${error.message}`); + process.exit(1); +}); diff --git a/client/tests/session-index-client.test.mjs b/client/tests/session-index-client.test.mjs new file mode 100644 index 0000000..65e7351 --- /dev/null +++ b/client/tests/session-index-client.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { SessionIndexClient } from "../dist-electron/session-index-client.js"; + +const diagnostics = { + filesSeen: 1, + filesStatted: 1, + filesParsed: 1, + bytesParsed: 100, + cacheHits: 0, + durationMs: 1, + lastError: null, +}; + +function indexedSession(id) { + return { + id, + title: id, + model: null, + agent: "Hermes", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +class FakeClock { + now = 0; + nextId = 1; + timers = new Map(); + + schedule = (callback, delayMs) => { + const timer = { + id: this.nextId++, + due: this.now + delayMs, + callback, + unref() {}, + }; + this.timers.set(timer.id, timer); + return timer; + }; + + cancel = (timer) => { + this.timers.delete(timer.id); + }; + + advance(delayMs) { + const target = this.now + delayMs; + while (true) { + const due = Array.from(this.timers.values()) + .filter((timer) => timer.due <= target) + .sort((left, right) => left.due - right.due || left.id - right.id)[0]; + if (!due) break; + this.now = due.due; + this.timers.delete(due.id); + due.callback(); + } + this.now = target; + } +} + +class FakeChild extends EventEmitter { + connected = true; + killed = false; + channel = { unref() {} }; + sent = []; + callbacks = []; + killCalls = 0; + disconnectCalls = 0; + + unref() {} + + send(message, callback) { + this.sent.push(message); + this.callbacks.push(callback); + } + + kill() { + this.killCalls += 1; + this.killed = true; + return true; + } + + disconnect() { + this.disconnectCalls += 1; + this.connected = false; + } + + respond(requestIndex, sessions) { + const request = this.sent[requestIndex]; + this.emit("message", { + type: "response", + requestId: request.requestId, + ok: true, + sessions, + diagnostics, + }); + } + + exit(code = 0, signal = null) { + this.connected = false; + this.emit("exit", code, signal); + } +} + +function fixture({ requestTimeoutMs = 100, restartBackoffMs = 100 } = {}) { + const clock = new FakeClock(); + const children = []; + const client = new SessionIndexClient({ + spawnChild: () => { + const child = new FakeChild(); + children.push(child); + return child; + }, + now: () => clock.now, + schedule: clock.schedule, + cancel: clock.cancel, + requestTimeoutMs, + restartBackoffMs, + }); + return { client, clock, children }; +} + +test("a timeout retires its exact worker, resolves all of that worker's requests, and restarts after bounded backoff", async () => { + const { client, clock, children } = fixture(); + const sessionA = indexedSession("known-a"); + const sessionB = indexedSession("known-b"); + + const seedA = client.listHermes("/work/a"); + const seedB = client.listHermes("/work/b"); + clock.advance(0); + children[0].respond(0, { "/work/a": [sessionA], "/work/b": [sessionB] }); + assert.deepEqual(await seedA, [sessionA]); + assert.deepEqual(await seedB, [sessionB]); + + const pendingA = client.listHermes("/work/a"); + clock.advance(0); + const pendingB = client.listHermes("/work/b"); + clock.advance(0); + assert.equal(children[0].sent.length, 3); + + clock.advance(100); + assert.deepEqual(await pendingA, [sessionA]); + assert.deepEqual(await pendingB, [sessionB]); + assert.equal(children[0].killCalls, 1); + assert.equal(children[0].disconnectCalls, 1); + + const duringBackoff = client.listHermes("/work/a"); + clock.advance(0); + assert.deepEqual(await duringBackoff, [sessionA]); + assert.equal(children.length, 1, "backoff must not create a restart loop"); + + clock.advance(100); + const recovered = client.listHermes("/work/a"); + clock.advance(0); + assert.equal(children.length, 2); + assert.notEqual(children[1], children[0]); + + // Late events from the retired generation cannot retire or resolve the new one. + children[0].exit(0, null); + const fresh = indexedSession("fresh-a"); + children[1].respond(0, { "/work/a": [fresh] }); + assert.deepEqual(await recovered, [fresh]); +}); + +test("an IPC send error retires the worker and resolves every request assigned to it", async () => { + const { client, clock, children } = fixture(); + const first = client.listHermes("/work/first"); + clock.advance(0); + const second = client.listHermes("/work/second"); + clock.advance(0); + + children[0].callbacks[1](new Error("IPC channel closed")); + assert.deepEqual(await first, []); + assert.deepEqual(await second, []); + assert.equal(children[0].killCalls, 1); + assert.equal(children.length, 1); + + clock.advance(100); + const recovered = client.listHermes("/work/first"); + clock.advance(0); + assert.equal(children.length, 2); + const fresh = indexedSession("fresh"); + children[1].respond(0, { "/work/first": [fresh] }); + assert.deepEqual(await recovered, [fresh]); +}); + +test("a clean idle worker exit allows the next request to restart immediately", async () => { + const { client, clock, children } = fixture({ restartBackoffMs: 10_000 }); + const first = client.listHermes("/work/idle"); + clock.advance(0); + children[0].respond(0, { "/work/idle": [] }); + assert.deepEqual(await first, []); + + children[0].exit(0, null); + const next = client.listHermes("/work/idle"); + clock.advance(0); + assert.equal(children.length, 2, "clean idle exit must not inherit failure backoff"); + children[1].respond(0, { "/work/idle": [] }); + assert.deepEqual(await next, []); +}); diff --git a/client/tests/terminal-attention.test.mjs b/client/tests/terminal-attention.test.mjs new file mode 100644 index 0000000..ce77a37 --- /dev/null +++ b/client/tests/terminal-attention.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { TerminalAttentionTracker, classifyTerminalAttention } from "../dist-electron/terminal-attention.js"; + +test("main-side attention classification replaces global raw renderer output", () => { + assert.equal(classifyTerminalAttention("Waiting for approval to run command"), "action"); + assert.equal(classifyTerminalAttention("Task complete. Ready for review."), "update"); + assert.equal(classifyTerminalAttention("transforming modules...".repeat(4_000)), null); +}); + +test("attention classification carries only a bounded suffix across split PTY batches", () => { + const tracker = new TerminalAttentionTracker(); + assert.equal(tracker.classify("one", "Waiting for appr"), null); + assert.equal(tracker.classify("one", "oval to continue"), "action"); + tracker.clear("one"); + assert.equal(tracker.classify("one", "ordinary output"), null); +}); diff --git a/client/tests/terminal-buffer.test.mjs b/client/tests/terminal-buffer.test.mjs index c0330ce..0dfa62c 100644 --- a/client/tests/terminal-buffer.test.mjs +++ b/client/tests/terminal-buffer.test.mjs @@ -2,13 +2,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + BoundedTerminalReplayBuffer, DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS, TERMINAL_OUTPUT_TRUNCATED_NOTICE, appendBoundedTerminalOutput, boundedTerminalBufferMaxChars, formatTerminalBuffer, terminalBufferTail, + terminalReplayTail, } from "../dist-electron/terminal-buffer.js"; +import { + TERMINAL_ATTENTION_SCAN_MAX_CHARS, + classifyTerminalAttention, +} from "../dist-electron/terminal-attention.js"; test("terminal buffer max chars uses default and clamps bounds", () => { assert.equal(boundedTerminalBufferMaxChars(null), 40_000); @@ -44,7 +50,7 @@ test("pending terminal output handles caps smaller than the truncation notice", const capped = appendBoundedTerminalOutput("abc", "defghi", 8); assert.equal(capped.length, 8); - assert.equal(capped, TERMINAL_OUTPUT_TRUNCATED_NOTICE.slice(0, 8)); + assert.equal(capped, "[truncat"); }); test("pending terminal output uses the default cap", () => { @@ -53,3 +59,76 @@ test("pending terminal output uses the default cap", () => { assert.equal(capped.length, DEFAULT_PENDING_TERMINAL_OUTPUT_MAX_CHARS); assert.equal(capped.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); }); + +test("replay truncation never starts on a dangling UTF-16 low surrogate", () => { + const maxChars = TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 2; + const capped = terminalReplayTail(`${"x".repeat(100)}๐Ÿ˜€Z`, maxChars); + const tail = capped.slice(TERMINAL_OUTPUT_TRUNCATED_NOTICE.length); + assert.equal(tail, "Z"); + assert.equal(tail.charCodeAt(0) >= 0xdc00 && tail.charCodeAt(0) <= 0xdfff, false); +}); + +test("replay truncation advances past an OSC control string boundary", () => { + const value = `${"x".repeat(100)}\x1b]0;unsafe-title\x07SAFE`; + const capped = terminalReplayTail(value, TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 8); + assert.equal(capped.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + assert.equal(capped.endsWith("SAFE"), true); + assert.equal(capped.includes("unsafe-title"), false); +}); + +test("an unterminated control string is dropped instead of replayed mid-sequence", () => { + const value = `${"x".repeat(100)}\x1b]0;${"y".repeat(100)}`; + assert.equal( + terminalReplayTail(value, TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 20), + TERMINAL_OUTPUT_TRUNCATED_NOTICE, + ); +}); + +test("chunked replay storage stays bounded across fragmented ANSI and Unicode output", () => { + const maxChars = TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 64; + const replay = new BoundedTerminalReplayBuffer(maxChars); + replay.append(`${"old\r\n".repeat(40)}\x1b]0;frag`); + replay.append("mented-title\x07๐Ÿ˜€new\r\n"); + for (let index = 0; index < 100; index += 1) replay.append(`line-${index}\r\n`); + const value = replay.value(); + assert.ok(value.length <= maxChars); + assert.equal(value.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + const tail = value.slice(TERMINAL_OUTPUT_TRUNCATED_NOTICE.length); + assert.equal(tail.charCodeAt(0) >= 0xdc00 && tail.charCodeAt(0) <= 0xdfff, false); + assert.equal(tail.includes("mented-title"), false); + assert.equal(tail.endsWith("line-99\r\n"), true); +}); + +test("indexed replay matches VT-safe tail semantics without materializing the discarded prefix", () => { + const replay = new BoundedTerminalReplayBuffer(200_000); + const chunks = [ + "old\r\n".repeat(5_000), + "\x1b]0;split-", + "title\x07๐Ÿ˜€\x1b[31mRED\x1b[0m\r\n", + "new\r\n".repeat(2_000), + ]; + for (const chunk of chunks) replay.append(chunk); + const raw = chunks.join(""); + for (const limit of [TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 20, 1_000, 64 * 1024]) { + assert.equal(replay.replay(limit), terminalReplayTail(raw, limit)); + } +}); + +test("indexed replay preserves safe-tail semantics after authoritative retention truncates", () => { + const replay = new BoundedTerminalReplayBuffer(2_000); + for (let index = 0; index < 1_000; index += 1) replay.append(`row-${index}\r\n`); + const retained = replay.value(); + assert.equal(retained.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + for (const limit of [500, 1_000]) { + assert.equal(replay.replay(limit), terminalReplayTail(retained, limit)); + } +}); + +test("main-side attention classification remains bounded to the newest 4k chars", () => { + assert.equal(classifyTerminalAttention("Waiting for approval to continue"), "action"); + assert.equal(classifyTerminalAttention("Task completed and ready for review"), "update"); + assert.equal( + classifyTerminalAttention(`approval ${"x".repeat(TERMINAL_ATTENTION_SCAN_MAX_CHARS + 10)}`), + null, + ); +}); diff --git a/client/tests/terminal-output-ack.test.mjs b/client/tests/terminal-output-ack.test.mjs index 9741fcb..21ddf4d 100644 --- a/client/tests/terminal-output-ack.test.mjs +++ b/client/tests/terminal-output-ack.test.mjs @@ -1,72 +1,382 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { DEFAULT_OUTPUT_ACK_TIMEOUT_MS, OutputAckGate } from "../dist-electron/terminal-output-ack.js"; +import { + DEFAULT_OUTPUT_ACK_TIMEOUT_MS, + OutputAckGate, +} from "../dist-electron/terminal-output-ack.js"; +import { TerminalOutputStreamHub } from "../dist-electron/terminal-output-stream.js"; +import { TERMINAL_OUTPUT_TRUNCATED_NOTICE } from "../dist-electron/terminal-buffer.js"; +import { + TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS, + TERMINAL_OUTPUT_MAX_GRACE_MS, + terminalOutputCleanupDecision, +} from "../dist-electron/terminal-output-cleanup.js"; -test("allows a send when nothing is in flight", () => { +function batch(overrides = {}) { + return { + epoch: "epoch-1", + fromSequence: 1, + sequence: 1, + data: "one", + reset: false, + ...overrides, + }; +} + +test("retains one batch until a matching epoch/sequence is acknowledged", () => { const gate = new OutputAckGate(1000); - assert.equal(gate.canSend("t", 0), true); + const output = batch(); + assert.equal(gate.canSend("consumer"), true); + gate.markSent("consumer", output, 0); + assert.equal(gate.canSend("consumer"), false); + assert.equal(gate.current("consumer"), output); + assert.equal(gate.acknowledge("consumer", output.epoch, output.sequence), true); + assert.equal(gate.canSend("consumer"), true); }); -test("holds further sends until the batch is acknowledged", () => { +test("stale, wrong-epoch and duplicate ACKs cannot clear a fresh batch", () => { const gate = new OutputAckGate(1000); - const sequence = gate.markSent("t", 0); - assert.equal(gate.canSend("t", 500), false); - assert.equal(gate.acknowledge("t", sequence), true); - assert.equal(gate.canSend("t", 500), true); + const output = batch({ epoch: "fresh", sequence: 8 }); + gate.markSent("consumer", output, 0); + assert.equal(gate.acknowledge("consumer", "old", 8), false); + assert.equal(gate.acknowledge("consumer", "fresh", 7), false); + assert.equal(gate.canSend("consumer"), false); + assert.equal(gate.acknowledge("consumer", "fresh", 8), true); + assert.equal(gate.acknowledge("consumer", "fresh", 8), false); +}); + +test("timeout retries the exact retained payload and restarts its deadline", () => { + const gate = new OutputAckGate(2000); + const output = batch({ data: "must-not-be-lost" }); + gate.markSent("consumer", output, 1000); + assert.equal(gate.retry("consumer", 2999), null); + assert.equal(gate.retryDelayMs("consumer", 2999), 1); + assert.equal(gate.retry("consumer", 3000), output); + assert.equal(gate.retry("consumer", 3001), null, "retry deadline was restarted"); + assert.equal(gate.retryDelayMs("consumer", 3001), 1999); }); -test("ignores acks that do not match the outstanding batch", () => { +test("clearing/rebasing a consumer makes a late ACK harmless", () => { const gate = new OutputAckGate(1000); - gate.markSent("t", 0); - assert.equal(gate.acknowledge("t", 999), false); - assert.equal(gate.canSend("t", 500), false, "stays gated on a stale ack"); + const old = batch({ epoch: "old", sequence: 2 }); + gate.markSent("consumer", old, 0); + gate.clear("consumer"); + const fresh = batch({ epoch: "fresh", sequence: 9 }); + gate.markSent("consumer", fresh, 1); + assert.equal(gate.acknowledge("consumer", old.epoch, old.sequence), false); + assert.equal(gate.current("consumer"), fresh); }); -test("treats an unacknowledged batch as lost after the timeout so output recovers", () => { - // The wedge guard: a renderer that reloaded mid-batch never acks, so the gate - // must release on its own once the ack window elapses instead of freezing the - // terminal's live output forever. - const gate = new OutputAckGate(2000); - gate.markSent("t", 0); - assert.equal(gate.canSend("t", 1999), false, "still waiting within the timeout"); - assert.equal(gate.canSend("t", 2000), true, "allows another send once the ack window elapses"); +test("exposes a positive default ack timeout", () => { + assert.equal(typeof DEFAULT_OUTPUT_ACK_TIMEOUT_MS, "number"); + assert.ok(DEFAULT_OUTPUT_ACK_TIMEOUT_MS > 0); }); -test("reports the remaining retry delay for a blocked batch", () => { - const gate = new OutputAckGate(2000); - gate.markSent("t", 1000); - assert.equal(gate.retryDelayMs("t", 1500), 1500); - assert.equal(gate.retryDelayMs("t", 2999), 1); - assert.equal(gate.retryDelayMs("t", 3000), 0); +test("exit tombstones extend for pending drains but clear at a hard deadline", () => { + const startedAt = 1_000; + const deadline = startedAt + TERMINAL_OUTPUT_MAX_GRACE_MS; + assert.deepEqual( + terminalOutputCleanupDecision(startedAt, deadline, false), + { clear: true, delayMs: 0 }, + ); + assert.deepEqual( + terminalOutputCleanupDecision(startedAt, deadline, true), + { clear: false, delayMs: TERMINAL_OUTPUT_CLEANUP_INTERVAL_MS }, + ); + assert.deepEqual( + terminalOutputCleanupDecision(deadline - 7, deadline, true), + { clear: false, delayMs: 7 }, + ); + assert.deepEqual( + terminalOutputCleanupDecision(deadline, deadline, true), + { clear: true, delayMs: 0 }, + ); }); -test("reports no retry delay after the batch is cleared", () => { - const gate = new OutputAckGate(2000); - const sequence = gate.markSent("t", 0); - assert.equal(gate.acknowledge("t", sequence), true); - assert.equal(gate.retryDelayMs("t", 100), null); +test("versioned attach snapshots output atomically before later live sequences", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", ackTimeoutMs: 1000 }); + hub.append("terminal", "before attach\r\n"); + hub.subscribe("terminal", "renderer"); + hub.append("terminal", "between subscribe and snapshot\r\n"); + const snapshot = hub.attach("terminal", "renderer"); + assert.deepEqual(snapshot, { + id: "terminal", + epoch: "epoch", + buffer: "before attach\r\nbetween subscribe and snapshot\r\n", + throughSequence: 2, + }); + + hub.append("terminal", "after attach\r\n"); + const [delivery] = hub.poll(0); + assert.equal(delivery.fromSequence, 3); + assert.equal(delivery.sequence, 3); + assert.equal(delivery.data, "after attach\r\n"); }); -test("a late ack for a re-sent batch does not clear the fresh batch", () => { - const gate = new OutputAckGate(2000); - const first = gate.markSent("t", 0); - assert.equal(gate.canSend("t", 2000), true); - const second = gate.markSent("t", 2000); - assert.notEqual(first, second); - assert.equal(gate.acknowledge("t", first), false, "the stale ack is ignored"); - assert.equal(gate.canSend("t", 2100), false, "the resent batch is still in flight"); - assert.equal(gate.acknowledge("t", second), true); +test("a paused control attach cannot lose output produced between snapshot and subscribe", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", maxPendingChars: 1000 }); + hub.append("terminal", "snapshot"); + const snapshot = hub.attach("terminal", "control-sse:one", { paused: true }); + hub.append("terminal", "after-snapshot"); + + assert.deepEqual(hub.pollTerminal("terminal", 0), [], "paused consumer does not overtake its snapshot"); + assert.equal(hub.resumeConsumer("terminal", "control-sse:one"), true); + const [delivery] = hub.pollTerminal("terminal", 0); + assert.equal(delivery.consumerId, "control-sse:one"); + assert.equal(delivery.fromSequence, snapshot.throughSequence + 1); + assert.equal(delivery.data, "after-snapshot"); }); -test("clear() drops the in-flight batch", () => { - const gate = new OutputAckGate(1000); - gate.markSent("t", 0); - gate.clear("t"); - assert.equal(gate.canSend("t", 0), true); +test("a paused control consumer fault recovers with an explicit sequenced reset", () => { + const hub = new TerminalOutputStreamHub({ + epochFactory: () => "epoch", + maxPendingChars: 5, + maxSnapshotChars: 1000, + }); + const snapshot = hub.attach("terminal", "control-sse:fault", { paused: true }); + hub.append("terminal", "1234"); + hub.append("terminal", "5678"); + hub.resumeConsumer("terminal", "control-sse:fault"); + const [delivery] = hub.pollTerminal("terminal", 0); + assert.equal(delivery.reset, true); + assert.equal(delivery.fromSequence, 0); + assert.equal(delivery.sequence, snapshot.throughSequence + 2); + assert.equal(delivery.data, "12345678"); }); -test("exposes a positive default ack timeout", () => { - assert.equal(typeof DEFAULT_OUTPUT_ACK_TIMEOUT_MS, "number"); - assert.ok(DEFAULT_OUTPUT_ACK_TIMEOUT_MS > 0); +test("renderer replay is capped independently from the retained control buffer", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", maxSnapshotChars: 200_000 }); + const retained = "x".repeat(150_000); + hub.append("terminal", retained); + const renderer = hub.attach("terminal", "renderer", { replayMaxChars: 64 * 1024 }); + const control = hub.attach("terminal", "control", { replayMaxChars: 200_000 }); + + assert.ok(renderer.buffer.length <= 64 * 1024); + assert.equal(renderer.buffer.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + assert.equal(control.buffer, retained); + assert.equal(hub.getBuffer("terminal"), retained, "view replay limits never shrink authoritative retention"); + const diagnostics = hub.diagnostics(); + assert.equal(diagnostics.replayCount, 2); + assert.ok(diagnostics.replayBytes >= Buffer.byteLength(control.buffer)); + assert.ok(diagnostics.replayDurationMs >= 0); + assert.ok(diagnostics.maxReplayDurationMs >= 0); +}); + +test("per-consumer replay limits persist across overflow reset snapshots", () => { + const rendererLimit = 64 * 1024; + const controlLimit = 96 * 1024; + const hub = new TerminalOutputStreamHub({ + epochFactory: () => "epoch", + maxSnapshotChars: 200_000, + maxPendingChars: 32, + }); + hub.attach("terminal", "renderer", { replayMaxChars: rendererLimit }); + hub.attach("terminal", "control-sse", { replayMaxChars: controlLimit }); + hub.append("terminal", "q".repeat(150_000)); + + const deliveries = hub.pollTerminal("terminal", 0); + const rendererReset = deliveries.find((delivery) => delivery.consumerId === "renderer"); + const controlReset = deliveries.find((delivery) => delivery.consumerId === "control-sse"); + assert.ok(rendererReset && controlReset); + assert.equal(rendererReset.reset, true); + assert.equal(controlReset.reset, true); + assert.ok(rendererReset.data.length <= rendererLimit); + assert.ok(controlReset.data.length <= controlLimit); + assert.equal(rendererReset.data.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + assert.equal(controlReset.data.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + assert.equal(hub.getBuffer("terminal").length, 150_000); +}); + +test("repeated multi-pane navigation keeps each replay bounded and releases consumers", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", maxSnapshotChars: 200_000 }); + hub.append("terminal", "z".repeat(180_000)); + for (let index = 0; index < 32; index += 1) { + const consumer = `renderer:${index}`; + const snapshot = hub.attach("terminal", consumer, { replayMaxChars: 64 * 1024 }); + assert.ok(snapshot.buffer.length <= 64 * 1024); + hub.detach("terminal", consumer); + } + assert.equal(hub.diagnostics().subscribers, 0); + assert.equal(hub.getBuffer("terminal").length, 180_000); +}); + +test("empty PTY chunks do not consume sequence numbers or create false gaps", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch" }); + const snapshot = hub.attach("terminal", "renderer"); + assert.equal(hub.append("terminal", ""), snapshot.throughSequence); + hub.append("terminal", "visible"); + const [delivery] = hub.poll(0); + assert.equal(delivery.fromSequence, snapshot.throughSequence + 1); + assert.equal(delivery.sequence, snapshot.throughSequence + 1); +}); + +test("a UTF-16 surrogate pair split across PTY chunks is delivered as one code point", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch" }); + const snapshot = hub.attach("terminal", "renderer"); + assert.equal(hub.append("terminal", "\ud83d"), snapshot.throughSequence); + assert.equal(hub.append("terminal", ""), snapshot.throughSequence); + hub.append("terminal", "\ude00!"); + const [delivery] = hub.poll(0); + assert.equal(delivery.data, "๐Ÿ˜€!"); + assert.equal(delivery.fromSequence, snapshot.throughSequence + 1); +}); + +test("an unmatched carried high surrogate is replaced before later ordinary text", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch" }); + hub.attach("terminal", "renderer"); + hub.append("terminal", "\ud83d"); + hub.append("terminal", "plain"); + const [delivery] = hub.poll(0); + assert.equal(delivery.data, "\ufffdplain"); +}); + +test("a delayed drain ACK holds only that consumer and preserves later bytes", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", ackTimeoutMs: 1000 }); + hub.attach("terminal", "slow"); + hub.append("terminal", "first"); + const [first] = hub.poll(0); + hub.append("terminal", "second"); + assert.deepEqual(hub.poll(500), [], "second batch remains behind the drain ACK"); + assert.equal(hub.acknowledge("terminal", "slow", first.epoch, first.sequence), true); + const [second] = hub.poll(500); + assert.equal(second.data, "second"); + assert.equal(second.fromSequence, first.sequence + 1); +}); + +test("an exit cursor stays behind final output queued after an in-flight batch", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", ackTimeoutMs: 1000 }); + hub.attach("terminal", "renderer"); + hub.append("terminal", "first"); + const [first] = hub.poll(0); + hub.append("terminal", "final-before-exit"); + const exitCursor = hub.cursor("terminal"); + + assert.equal(exitCursor.sequence, first.sequence + 1); + assert.equal(hub.hasPendingDeliveriesForTerminal("terminal"), true); + assert.deepEqual(hub.poll(100), [], "final output remains ordered behind the first drain ACK"); + assert.equal(hub.acknowledge("terminal", "renderer", first.epoch, first.sequence), true); + const [final] = hub.poll(100); + assert.equal(final.data, "final-before-exit"); + assert.equal(final.sequence, exitCursor.sequence, "the exit cursor names the final delivered batch"); + assert.equal(hub.acknowledge("terminal", "renderer", final.epoch, final.sequence), true); + assert.equal(hub.hasPendingDeliveriesForTerminal("terminal"), false); +}); + +test("exit ordering survives pending overflow by delivering a reset through the exit cursor", () => { + const hub = new TerminalOutputStreamHub({ + epochFactory: () => "epoch", + maxPendingChars: 5, + maxSnapshotChars: 1000, + }); + hub.attach("terminal", "renderer"); + hub.append("terminal", "live"); + const [inFlight] = hub.poll(0); + hub.append("terminal", "after"); + hub.append("terminal", "-overflow"); + const exitCursor = hub.cursor("terminal"); + + assert.deepEqual(hub.poll(100), []); + assert.equal(hub.acknowledge("terminal", "renderer", inFlight.epoch, inFlight.sequence), true); + const [reset] = hub.poll(100); + assert.equal(reset.reset, true); + assert.equal(reset.sequence, exitCursor.sequence); + assert.equal(reset.data, "liveafter-overflow"); +}); + +test("lost ACK retry does not invent a new sequence or discard the payload", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch", ackTimeoutMs: 1000 }); + hub.attach("terminal", "renderer"); + hub.append("terminal", "payload"); + const [first] = hub.poll(0); + assert.deepEqual(hub.poll(999), []); + const [retry] = hub.poll(1000); + assert.deepEqual(retry, first); + assert.equal(hub.acknowledge("terminal", "renderer", retry.epoch, retry.sequence), true); + assert.equal(hub.acknowledge("terminal", "renderer", retry.epoch, retry.sequence), false); +}); + +test("pending overflow becomes an explicit reset snapshot", () => { + const hub = new TerminalOutputStreamHub({ + epochFactory: () => "epoch", + maxPendingChars: 5, + maxSnapshotChars: 1000, + }); + hub.attach("terminal", "renderer"); + hub.append("terminal", "1234"); + hub.append("terminal", "5678"); + const [delivery] = hub.poll(0); + assert.equal(delivery.reset, true); + assert.equal(delivery.data, "12345678"); + assert.equal(delivery.sequence, 2); + assert.equal(hub.diagnostics().resets, 1); + assert.equal(hub.diagnostics().droppedOrTruncatedChars, 0, "reset snapshot recovered the pending bytes"); +}); + +test("finalize replaces an unmatched high surrogate as sequenced output before exit", () => { + const hub = new TerminalOutputStreamHub({ epochFactory: () => "epoch" }); + const snapshot = hub.attach("terminal", "renderer"); + assert.equal(hub.append("terminal", "\ud83d"), snapshot.throughSequence); + assert.equal(hub.finalize("terminal"), snapshot.throughSequence + 1); + const exitCursor = hub.cursor("terminal"); + const [delivery] = hub.poll(0); + assert.equal(delivery.data, "\ufffd"); + assert.equal(delivery.sequence, exitCursor.sequence); + assert.equal(hub.getBuffer("terminal"), "\ufffd"); + assert.equal(hub.finalize("terminal"), exitCursor.sequence, "finalization is idempotent"); +}); + +test("clearing expired output state creates a new epoch for stale-exit detection", () => { + let epoch = 0; + const hub = new TerminalOutputStreamHub({ epochFactory: () => `epoch-${++epoch}` }); + hub.append("terminal", "final"); + const exited = hub.cursor("terminal"); + hub.clearTerminal("terminal"); + const replacement = hub.attach("terminal", "renderer"); + assert.notEqual(replacement.epoch, exited.epoch); + assert.equal(replacement.throughSequence, 0); + assert.equal(replacement.buffer, ""); +}); + +test("late stale-exit attach churn releases empty phantom terminal epochs", () => { + let epoch = 0; + const hub = new TerminalOutputStreamHub({ epochFactory: () => `epoch-${++epoch}` }); + for (let index = 0; index < 100; index += 1) { + hub.attach("expired", `renderer:${index}`); + assert.deepEqual(hub.terminalIds(), ["expired"]); + hub.detach("expired", `renderer:${index}`); + assert.deepEqual(hub.terminalIds(), []); + } + assert.equal(hub.diagnostics().subscribers, 0); +}); + +test("rolling snapshot truncation is explicit and counted separately from reset recovery", () => { + const hub = new TerminalOutputStreamHub({ + epochFactory: () => "epoch", + maxSnapshotChars: TERMINAL_OUTPUT_TRUNCATED_NOTICE.length + 8, + }); + hub.append("terminal", "x".repeat(200)); + const snapshot = hub.attach("terminal", "renderer"); + assert.equal(snapshot.buffer.startsWith(TERMINAL_OUTPUT_TRUNCATED_NOTICE), true); + assert.ok(hub.diagnostics().droppedOrTruncatedChars > 0); + assert.equal(hub.diagnostics().resets, 0); +}); + +test("slow consumers and terminals do not block each other", () => { + let epoch = 0; + const hub = new TerminalOutputStreamHub({ epochFactory: () => `epoch-${++epoch}`, ackTimeoutMs: 1000 }); + hub.attach("one", "renderer"); + hub.attach("two", "renderer"); + hub.append("one", "one-a"); + hub.append("two", "two-a"); + const initial = hub.poll(0); + const one = initial.find((item) => item.id === "one"); + const two = initial.find((item) => item.id === "two"); + assert.ok(one && two); + assert.equal(hub.acknowledge("two", "renderer", two.epoch, two.sequence), true); + hub.append("one", "one-b"); + hub.append("two", "two-b"); + const [next] = hub.poll(100); + assert.equal(next.id, "two"); + assert.equal(next.data, "two-b"); }); diff --git a/client/tests/terminal-restore.test.mjs b/client/tests/terminal-restore.test.mjs index 0cab20d..f7dbf5a 100644 --- a/client/tests/terminal-restore.test.mjs +++ b/client/tests/terminal-restore.test.mjs @@ -11,6 +11,7 @@ import { codexSessionIdForWorkspace, encodeResolvedClaudeProjectPath, effectiveCreationMs, + hasMissingSavedRestoreIdentity, openCodeDatabaseCandidates, openCodeSessionCandidates, openCodeSessionExists, @@ -18,6 +19,7 @@ import { savedResumeSessionId, selectDiscoveredSessionId, selectEmbeddedTerminalRestoreEntries, + shouldConfirmEmbeddedTerminalRestoreShutdown, } from "../dist-electron/terminal-restore-policy.js"; const execFileAsync = promisify(execFile); @@ -86,6 +88,29 @@ test("restore uses provider session id when resume session id is missing", () => ); }); +test("one missing saved identity is deferred without blocking independent restore entries", () => { + const missing = entry("missing", "claude", "/home/dev/project", { providerSessionId: "deleted-session" }); + const valid = entry("valid", "codex", "/home/dev/project", { resumeSessionId: "valid-session" }); + const legacy = entry("legacy", "codex"); + + const decisions = [ + hasMissingSavedRestoreIdentity(missing, null), + hasMissingSavedRestoreIdentity(valid, "valid-session"), + hasMissingSavedRestoreIdentity(legacy, null), + ]; + assert.deepEqual(decisions, [true, false, false]); + assert.deepEqual( + [missing, valid, legacy].filter((_item, index) => !decisions[index]).map((item) => item.id), + ["valid", "legacy"], + ); +}); + +test("restore crash-loop evidence clears only after PTY shutdown beats the quit deadline", () => { + assert.equal(shouldConfirmEmbeddedTerminalRestoreShutdown(true, true), true); + assert.equal(shouldConfirmEmbeddedTerminalRestoreShutdown(true, false), false); + assert.equal(shouldConfirmEmbeddedTerminalRestoreShutdown(false, true), false); +}); + test("claude project path candidates include current and legacy encodings", () => { const projectsDir = path.join(path.sep, "home", "dev", ".claude", "projects"); const project = path.join(path.sep, "home", "dev", "My Project"); diff --git a/client/tests/workspace-utils.test.mjs b/client/tests/workspace-utils.test.mjs new file mode 100644 index 0000000..ca79f5a --- /dev/null +++ b/client/tests/workspace-utils.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeWorkspaceKey, sameWorkspacePath } from "../src/workspace-utils.ts"; + +test("workspace keys preserve POSIX roots and case", () => { + assert.equal(normalizeWorkspaceKey("/"), "/"); + assert.equal(normalizeWorkspaceKey("/Work/Project/"), "/Work/Project"); + assert.notEqual(normalizeWorkspaceKey("/Work/Project"), normalizeWorkspaceKey("/work/project")); + assert.equal(sameWorkspacePath("/", "/"), true); +}); + +test("workspace keys unify Windows, WSL, drive roots, and UNC case", () => { + assert.equal(normalizeWorkspaceKey("C:\\"), "c:/"); + assert.equal(normalizeWorkspaceKey("/mnt/C/"), "c:/"); + assert.equal(sameWorkspacePath("C:\\Users\\Alan", "/mnt/c/users/alan"), true); + assert.equal( + normalizeWorkspaceKey("\\\\Server\\Share\\Project"), + normalizeWorkspaceKey("//server/share/project"), + ); +}); diff --git a/scripts/run_regression_checks.py b/scripts/run_regression_checks.py new file mode 100644 index 0000000..9163f17 --- /dev/null +++ b/scripts/run_regression_checks.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BACKEND_REGRESSION_DIR = ROOT / "tests" / "regressions" +CLIENT_DIR = ROOT / "client" + + +def run_step(label: str, command: list[str], cwd: Path) -> int: + print(f"\n==> {label}", flush=True) + print(f"$ {' '.join(command)}", flush=True) + return subprocess.run(command, cwd=cwd, check=False).returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run permanent Athena regression checks.") + parser.add_argument("--python-only", action="store_true") + parser.add_argument("--client-only", action="store_true") + args = parser.parse_args() + if args.python_only and args.client_only: + parser.error("--python-only and --client-only cannot be combined") + + failures = 0 + if not args.client_only: + python_tests = sorted(BACKEND_REGRESSION_DIR.glob("test_*.py")) + if not python_tests: + print(f"Missing permanent backend regression tests under {BACKEND_REGRESSION_DIR}.") + failures += 1 + else: + failures += run_step( + "Backend regression tests", + [sys.executable, "-m", "pytest", str(BACKEND_REGRESSION_DIR)], + ROOT, + ) + if not args.python_only: + failures += run_step("Client regression tests", ["npm", "run", "test:regression"], CLIENT_DIR) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/regressions/README.md b/tests/regressions/README.md new file mode 100644 index 0000000..7bde16b --- /dev/null +++ b/tests/regressions/README.md @@ -0,0 +1,3 @@ +# Backend regression tests + +Permanent tests in this directory protect backend resource bounds, cancellation, cleanup, and session-discovery behavior. Each fixed production failure must have an executable test here. diff --git a/tests/regressions/test_pr_104_136_executor_bounds.py b/tests/regressions/test_pr_104_136_executor_bounds.py new file mode 100644 index 0000000..3a0143e --- /dev/null +++ b/tests/regressions/test_pr_104_136_executor_bounds.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import io +from pathlib import Path + +from backend.executor import _drain_stream_bounded + + +def test_pr_104_136_large_run_logs_are_streamed_to_a_hard_bound(tmp_path: Path) -> None: + destination = tmp_path / "stdout.log" + _drain_stream_bounded(io.BytesIO(b"x" * 200_000), destination, 64 * 1024) + + payload = destination.read_bytes() + assert len(payload) < 66 * 1024 + assert payload.startswith(b"x" * 1024) + assert b"Athena truncated 134464 output bytes" in payload diff --git a/tests/test_agent_sessions.py b/tests/test_agent_sessions.py index c0a5612..4ce9cb3 100644 --- a/tests/test_agent_sessions.py +++ b/tests/test_agent_sessions.py @@ -1,7 +1,10 @@ from __future__ import annotations import json +import os import sqlite3 +import threading +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -87,6 +90,38 @@ def test_lists_codex_sessions_from_workspace_descendants(tmp_path: Path) -> None assert [session.id for session in sessions] == ["child-codex"] +def test_codex_filters_workspace_before_provider_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + workspace = tmp_path / "project" + workspace.mkdir() + db_path = home / ".codex" / "state_5.sqlite" + db_path.parent.mkdir(parents=True) + monkeypatch.setattr(agent_sessions, "MAX_PROVIDER_ROWS", 2) + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + create table threads ( + id text, cwd text, title text, created_at_ms integer, + updated_at_ms integer, git_branch text, cli_version text, + first_user_message text, model text, agent_role text + ) + """ + ) + connection.execute( + "insert into threads values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("wanted-old", str(workspace), "Wanted", 1, 1, None, None, None, None, None), + ) + for index in range(3): + connection.execute( + "insert into threads values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (f"other-{index}", str(tmp_path / "other"), "Other", 1, 100 + index, None, None, None, None, None), + ) + + sessions = list_native_agent_sessions(workspace, home_dir=home, provider="codex") + + assert [session.id for session in sessions] == ["wanted-old"] + + def test_lists_codex_sessions_across_all_workspaces(tmp_path: Path) -> None: home = tmp_path / "home" workspace = tmp_path / "project" @@ -245,6 +280,34 @@ def test_lists_opencode_sessions_from_workspace_descendants(tmp_path: Path) -> N assert [session.id for session in sessions] == ["opencode-child"] +def test_opencode_filters_workspace_before_provider_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + workspace = tmp_path / "project" + workspace.mkdir() + db_path = home / ".local" / "share" / "opencode" / "opencode.db" + db_path.parent.mkdir(parents=True) + monkeypatch.setattr(agent_sessions, "MAX_PROVIDER_ROWS", 2) + with sqlite3.connect(db_path) as connection: + connection.execute("create table project (id text, worktree text)") + connection.execute( + "create table session (id text, project_id text, directory text, title text, " + "time_created integer, time_updated integer, agent text, model text)" + ) + connection.execute( + "insert into session values (?, ?, ?, ?, ?, ?, ?, ?)", + ("wanted-old", None, str(workspace), "Wanted", 1, 1, "build", None), + ) + for index in range(3): + connection.execute( + "insert into session values (?, ?, ?, ?, ?, ?, ?, ?)", + (f"other-{index}", None, str(tmp_path / "other"), "Other", 1, 100 + index, "build", None), + ) + + sessions = list_native_agent_sessions(workspace, home_dir=home, provider="opencode") + + assert [session.id for session in sessions] == ["wanted-old"] + + def test_reads_opencode_transcript_from_message_parts(tmp_path: Path) -> None: home = tmp_path / "home" db_path = home / ".local" / "share" / "opencode" / "opencode.db" @@ -336,6 +399,43 @@ def test_lists_athena_code_sessions_from_native_index(tmp_path: Path) -> None: assert "Implemented the session provider." in transcript +def test_athena_filters_workspace_before_provider_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + workspace = tmp_path / "project" + workspace.mkdir() + db_path = home / ".athena-code" / "context" / "sessions.db" + db_path.parent.mkdir(parents=True) + monkeypatch.setattr(agent_sessions, "MAX_PROVIDER_ROWS", 2) + with sqlite3.connect(db_path) as connection: + connection.execute("pragma user_version = 2") + connection.execute( + """ + create table messages ( + id integer primary key autoincrement, + agent text not null, + session_id text not null, + workspace text not null, + role text not null, + ts text not null, + text text not null + ) + """ + ) + connection.execute( + "insert into messages (agent, session_id, workspace, role, ts, text) values (?, ?, ?, ?, ?, ?)", + ("athena", "wanted-old", str(workspace), "user", "2026-01-01T00:00:00Z", "Wanted"), + ) + for index in range(3): + connection.execute( + "insert into messages (agent, session_id, workspace, role, ts, text) values (?, ?, ?, ?, ?, ?)", + ("athena", f"other-{index}", str(tmp_path / "other"), "user", f"2026-06-0{index + 1}T00:00:00Z", "Other"), + ) + + sessions = list_native_agent_sessions(workspace, home_dir=home, provider="athena") + + assert [session.id for session in sessions] == ["wanted-old"] + + def _seed_athena_index(db_path: Path, workspace: Path, *, user_version: int) -> None: db_path.parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(db_path) as connection: @@ -432,6 +532,147 @@ def test_lists_claude_jsonl_sessions(tmp_path: Path) -> None: assert sessions[0].updated_at == "2026-05-09T12:05:00Z" +def test_recent_jsonl_selection_avoids_rglob_and_keeps_newest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path / "sessions" + root.mkdir() + expected: list[Path] = [] + for index in range(5): + file_path = root / f"session-{index}.jsonl" + file_path.write_text("{}", encoding="utf-8") + os.utime(file_path, ns=(index + 1, index + 1)) + if index >= 3: + expected.append(file_path) + + monkeypatch.setattr(Path, "rglob", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unbounded rglob"))) + + selected = agent_sessions._recent_jsonl_files(root, limit=2) + + assert selected == list(reversed(expected)) + + +def test_claude_metadata_streams_only_first_200_lines(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + workspace = tmp_path / "project" + workspace.mkdir() + file_path = tmp_path / "claude-session.jsonl" + first = json.dumps( + { + "sessionId": "claude-session", + "cwd": str(workspace), + "timestamp": "2026-05-09T12:00:00Z", + "message": {"role": "user", "content": "Bound the metadata reader"}, + } + ) + filler = json.dumps({"timestamp": "2026-05-09T12:00:01Z", "message": {"role": "assistant"}}) + ignored = json.dumps( + { + "cwd": str(tmp_path / "wrong-workspace"), + "timestamp": "2026-05-09T13:00:00Z", + "message": {"role": "assistant", "model": "must-not-be-read"}, + } + ) + file_path.write_text("\n".join([first, *([filler] * 199), ignored, *([ignored] * 20)]), encoding="utf-8") + + real_open = Path.open + lines_read = 0 + + class CountingReader: + def __init__(self, handle): + self.handle = handle + + def __enter__(self): + return self + + def __exit__(self, *args): + return self.handle.__exit__(*args) + + def __iter__(self): + return self + + def __next__(self): + nonlocal lines_read + value = next(self.handle) + lines_read += 1 + return value + + def tracking_open(path: Path, *args, **kwargs): + handle = real_open(path, *args, **kwargs) + return CountingReader(handle) if path == file_path else handle + + monkeypatch.setattr(Path, "open", tracking_open) + monkeypatch.setattr(Path, "read_text", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("read_text"))) + + session = agent_sessions._read_claude_session_file(file_path, workspace, allow_missing_cwd=False) + + assert session is not None + assert session.title == "Bound the metadata reader" + assert session.model is None + assert lines_read == agent_sessions.CLAUDE_METADATA_MAX_LINES + + +def test_claude_transcript_head_stops_at_requested_budget(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + session_id = "bounded-claude" + file_path = home / ".claude" / "projects" / "project" / f"{session_id}.jsonl" + file_path.parent.mkdir(parents=True) + file_path.write_text( + "\n".join( + json.dumps({"message": {"role": "user", "content": f"message-{index}-" + ("x" * 200)}}) + for index in range(100) + ), + encoding="utf-8", + ) + + real_open = Path.open + lines_read = 0 + + class CountingReader: + def __init__(self, handle): + self.handle = handle + + def __enter__(self): + return self + + def __exit__(self, *args): + return self.handle.__exit__(*args) + + def __iter__(self): + return self + + def __next__(self): + nonlocal lines_read + value = next(self.handle) + lines_read += 1 + return value + + def tracking_open(path: Path, *args, **kwargs): + handle = real_open(path, *args, **kwargs) + return CountingReader(handle) if path == file_path else handle + + monkeypatch.setattr(Path, "open", tracking_open) + + transcript = read_agent_session_transcript( + "claude", + session_id, + home_dir=home, + max_bytes=128, + tail=False, + ) + + assert len(transcript.encode("utf-8")) <= 128 + assert transcript.startswith("# Claude Code Session Transcript") + assert lines_read < 100 + + +@pytest.mark.parametrize("tail", [False, True]) +def test_bounded_transcript_joiner_preserves_utf8_boundary_semantics(tail: bool) -> None: + parts = ["header๐Ÿ™‚", "middle-รฉ", "tail๐Ÿ™‚"] + output = agent_sessions._BoundedTextJoiner("\n", max_bytes=13, tail=tail) + for part in parts: + output.add(part) + + assert output.text() == agent_sessions._bounded_text("\n".join(parts), max_bytes=13, tail=tail) + + def test_claude_sessions_do_not_leak_across_workspaces(tmp_path: Path) -> None: home = tmp_path / "home" workspace = tmp_path / "project" @@ -550,6 +791,152 @@ def test_lists_hermes_sessions_from_wsl_fallback(tmp_path: Path, monkeypatch: py assert "Review the Athena sessions" in transcript +def test_hermes_workspace_hint_is_bounded_without_serialized_search_text(tmp_path: Path) -> None: + workspace = tmp_path / "project-with-hyphen" + workspace.mkdir() + file_path = tmp_path / "session_h1.json" + file_path.write_text( + json.dumps( + { + "model": "hermes-model", + "messages": [ + {"role": "user", "content": f"Continue work in {workspace} " + ("x" * 50_000)}, + {"role": "assistant", "content": "y" * 50_000}, + ], + } + ), + encoding="utf-8", + ) + + metadata = agent_sessions._read_hermes_session_file(file_path) + + assert "search_text" not in metadata + assert metadata["workspace_hint"] is not None + assert len((metadata["workspace_hint"] or "").encode("utf-8")) <= agent_sessions.HERMES_WORKSPACE_HINT_MAX_BYTES + assert agent_sessions._hermes_session_matches_workspace(metadata, {}, workspace) + + +def test_provider_path_identity_preserves_posix_case_and_roots() -> None: + assert agent_sessions._normalize_path("/") == "/" + assert agent_sessions._same_or_descendant_path("/Work/Project/child", "/Work/Project") + assert not agent_sessions._same_or_descendant_path("/work/project", "/Work/Project") + assert agent_sessions._same_or_descendant_path("/work/project", "/") + assert agent_sessions._same_or_descendant_path(r"C:\Users\Alan", "C:/") + assert agent_sessions._same_or_descendant_path("/mnt/c/Users/Alan", "C:\\") + + +def test_provider_sql_workspace_filter_uses_filesystem_case_semantics() -> None: + connection = sqlite3.connect(":memory:") + try: + connection.execute("create table sessions (cwd text)") + connection.executemany( + "insert into sessions (cwd) values (?)", + [("/Work/Project",), ("/Work/Project/child",), ("/work/project",), ("relative",)], + ) + sql, params = agent_sessions._workspace_sql_filter("cwd", "/Work/Project") + assert connection.execute(f"select cwd from sessions where {sql} order by cwd", params).fetchall() == [ + ("/Work/Project",), + ("/Work/Project/child",), + ] + root_sql, root_params = agent_sessions._workspace_sql_filter("cwd", "/") + assert connection.execute(f"select cwd from sessions where {root_sql} order by cwd", root_params).fetchall() == [ + ("/Work/Project",), + ("/Work/Project/child",), + ("/work/project",), + ] + finally: + connection.close() + + +def test_explicit_hermes_workspace_cannot_fall_through_to_stale_hints() -> None: + metadata = { + "workspace": "/Work/Other", + "workspace_hint": "Earlier work mentioned /Work/Target", + "title": "Continue /Work/Target", + } + assert not agent_sessions._hermes_session_matches_workspace(metadata, {}, Path("/Work/Target")) + + +def test_hermes_metadata_cache_reuses_unchanged_file_and_keeps_last_good( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_path = tmp_path / "session_cached.json" + file_path.write_text( + json.dumps( + { + "model": "hermes-model", + "last_updated": "2026-05-12T10:05:00Z", + "messages": [{"role": "user", "content": "Cache this metadata"}], + } + ), + encoding="utf-8", + ) + real_read_text = Path.read_text + reads = 0 + + def counting_read_text(path: Path, *args, **kwargs): + nonlocal reads + if path == file_path: + reads += 1 + return real_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", counting_read_text) + + first = agent_sessions._read_hermes_session_file(file_path) + second = agent_sessions._read_hermes_session_file(file_path) + file_path.write_text("{truncated", encoding="utf-8") + corrupt = agent_sessions._read_hermes_session_file(file_path) + still_corrupt = agent_sessions._read_hermes_session_file(file_path) + + assert first == second == corrupt == still_corrupt + assert first["title"] == "Cache this metadata" + assert reads == 2 + + +def test_hermes_metadata_cache_coalesces_concurrent_cold_reads( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_path = tmp_path / "session_concurrent.json" + file_path.write_text( + json.dumps({"messages": [{"role": "user", "content": "One cold parse"}]}), + encoding="utf-8", + ) + real_read_text = Path.read_text + read_started = threading.Event() + release_read = threading.Event() + second_started = threading.Event() + reads = 0 + reads_lock = threading.Lock() + + def blocking_read_text(path: Path, *args, **kwargs): + nonlocal reads + if path == file_path: + with reads_lock: + reads += 1 + read_started.set() + assert release_read.wait(timeout=2) + return real_read_text(path, *args, **kwargs) + + def second_read(): + second_started.set() + return agent_sessions._read_hermes_session_file(file_path) + + monkeypatch.setattr(Path, "read_text", blocking_read_text) + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(agent_sessions._read_hermes_session_file, file_path) + assert read_started.wait(timeout=2) + second_future = executor.submit(second_read) + assert second_started.wait(timeout=2) + release_read.set() + first = first_future.result(timeout=2) + second = second_future.result(timeout=2) + + assert first == second + assert reads == 1 + + def test_hermes_sessions_do_not_leak_across_workspaces(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: home = tmp_path / "home" workspace = tmp_path / "project" @@ -617,6 +1004,41 @@ def test_hermes_sessions_match_windows_style_workspace_mentions(tmp_path: Path, assert [session.id for session in sessions] == ["windows_path"] +def test_hermes_workspace_filter_is_applied_before_result_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = tmp_path / "home" + workspace = tmp_path / "older-workspace" + workspace.mkdir() + hermes_home = home / ".hermes" + (hermes_home / "sessions").mkdir(parents=True) + (hermes_home / "state.db").touch() + + def fake_rows(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + for index in range(agent_sessions.MAX_PROVIDER_ROWS): + yield (f"new-{index}", "cli", "model", index + 1, index + 1, 1, f"New {index}") + yield ("older-target", "cli", "model", 1, 1, 1, "Older target") + + def fake_metadata(file_path: Path) -> dict[str, str | None]: + return { + "title": file_path.stem, + "model": "model", + "platform": "cli", + "created_at": None, + "updated_at": None, + "workspace": str(workspace) if "older-target" in file_path.name else str(tmp_path / "other"), + "workspace_hint": None, + } + + monkeypatch.setattr(agent_sessions, "_iter_sqlite", fake_rows) + monkeypatch.setattr(agent_sessions, "_read_hermes_session_file", fake_metadata) + + sessions = list_native_agent_sessions(workspace, home_dir=home, provider="hermes") + + assert [session.id for session in sessions] == ["older-target"] + + def test_lists_grok_sessions_from_session_dirs(tmp_path: Path) -> None: from urllib.parse import quote @@ -672,6 +1094,28 @@ def _write_grok_session(cwd: Path, session_id: str, *, summary: str, first_user: assert "ignored reminder" not in transcript +def test_grok_session_directory_discovery_is_bounded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from urllib.parse import quote + + workspace = tmp_path / "project" + workspace.mkdir() + sessions_root = tmp_path / "sessions" + cwd_dir = sessions_root / quote(str(workspace), safe="") + cwd_dir.mkdir(parents=True) + for index in range(10): + session_dir = cwd_dir / f"session-{index}" + session_dir.mkdir() + os.utime(session_dir, ns=(index + 1, index + 1)) + + monkeypatch.setattr(agent_sessions, "MAX_FILE_SCAN_ENTRIES", 3) + monkeypatch.setattr(Path, "iterdir", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unbounded iterdir"))) + + selected = agent_sessions._recent_grok_session_dirs(sessions_root, workspace, limit=10) + + assert len(selected) == 3 + assert all(decoded == str(workspace) for _path, decoded in selected) + + def test_formats_empty_and_populated_summary(tmp_path: Path) -> None: home = tmp_path / "home" workspace = tmp_path / "project" diff --git a/tests/test_app.py b/tests/test_app.py index ba2de8d..d5b2ce5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,6 +2,8 @@ import json import sys +import threading +from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime from pathlib import Path @@ -14,6 +16,11 @@ from backend.context_artifacts import RunArtifacts from backend.hermes import HermesAskResult, HermesInstallResult, HermesStatus from backend.memory import HermesMemoryStore +from backend.memory_admission import ( + DEFAULT_LAUNCH_RESERVATION_BYTES, + DEFAULT_MINIMUM_HEADROOM_BYTES, + LaunchMemoryAdmission, +) from backend.runs import Run, RunRegistry, RunStatus from backend.runtime import RuntimeLimits @@ -499,6 +506,25 @@ def test_agent_sessions_endpoint_returns_native_session_summary(tmp_path: Path, assert body["summary"] == "No native agent sessions were found for this workspace." +def test_project_agent_sessions_endpoint_coalesces_repeated_scans(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + + def fake_list(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + nonlocal calls + calls += 1 + return [] + + monkeypatch.setattr(app_module, "list_native_agent_sessions", fake_list) + client = _client(tmp_path) + + first = client.get("/agents/sessions", params={"project_dir": str(tmp_path)}) + second = client.get("/agents/sessions", params={"project_dir": str(tmp_path)}) + refreshed = client.get("/agents/sessions", params={"project_dir": str(tmp_path), "refresh": "true"}) + + assert first.status_code == second.status_code == refreshed.status_code == 200 + assert calls == 2 + + def test_agent_sessions_endpoint_rejects_unknown_provider(tmp_path: Path) -> None: client = _client(tmp_path) @@ -539,6 +565,37 @@ def fake_list(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 assert second.json()["cache"]["hit"] is True +def test_all_agent_sessions_concurrent_cold_misses_are_coalesced( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + scan_started = threading.Event() + release_scan = threading.Event() + + def fake_list(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + nonlocal calls + calls += 1 + scan_started.set() + assert release_scan.wait(timeout=2) + return [] + + monkeypatch.setattr(app_module, "list_native_agent_sessions", fake_list) + monkeypatch.setattr(app_module, "format_agent_sessions_summary", lambda sessions: "0 sessions") + client = _client(tmp_path) + + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(client.get, "/agents/sessions/all") + assert scan_started.wait(timeout=2) + second = pool.submit(client.get, "/agents/sessions/all") + release_scan.set() + responses = [first.result(timeout=2), second.result(timeout=2)] + + assert calls == 1 + assert all(response.status_code == 200 for response in responses) + assert sorted(response.json()["cache"]["hit"] for response in responses) == [False, True] + + def test_all_agent_sessions_endpoint_refresh_bypasses_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: calls = 0 @@ -872,6 +929,32 @@ def test_spawn_rejects_when_global_limit_is_reached(tmp_path: Path) -> None: assert "Global concurrency limit" in response.json()["detail"] +def test_spawn_rejects_low_physical_memory_before_creating_run(tmp_path: Path) -> None: + registry = RunRegistry() + memory_admission = LaunchMemoryAdmission( + probe=lambda: DEFAULT_MINIMUM_HEADROOM_BYTES + DEFAULT_LAUNCH_RESERVATION_BYTES - 1, + ) + client = _client( + tmp_path, + registry=registry, + memory_admission=memory_admission, + ) + + response = client.post( + "/agents/spawn", + json={ + "agent_type": "codex", + "project_dir": str(tmp_path), + "task": "Must not allocate a run.", + }, + ) + + assert response.status_code == 429 + assert "physical-memory admission" in response.json()["detail"] + assert "Swap is not counted" in response.json()["detail"] + assert registry.list_runs() == [] + + def test_spawn_uses_default_timeout_from_runtime_limits(tmp_path: Path) -> None: client = _client( tmp_path, @@ -920,6 +1003,7 @@ def _client( registry: RunRegistry | None = None, limits: RuntimeLimits | None = None, memory: object | None = None, + memory_admission: LaunchMemoryAdmission | None = None, execute_inline: bool = True, ) -> TestClient: memory = memory or HermesMemoryStore(memory_path=tmp_path / "MEMORY.md") @@ -931,6 +1015,7 @@ def _client( registry=registry, adapters={"codex": adapter or FakeAdapter(fixture)}, limits=limits, + memory_admission=memory_admission, execute_inline=execute_inline, ) return TestClient(app) diff --git a/tests/test_executor.py b/tests/test_executor.py index 74ce3d8..c59018a 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -1,8 +1,16 @@ from __future__ import annotations +import os +import signal import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import pytest + +import backend.executor as executor_module from backend.adapters.base import AdapterCommand from backend.context_artifacts import RunArtifacts from backend.executor import RunExecutor @@ -108,3 +116,182 @@ def summarize_result(self, run: Run, artifacts: RunArtifacts) -> str: assert "Failed to start" in (result.run.error or "") assert "Failed to start" in result.artifacts.stderr.read_text(encoding="utf-8") assert registry.get(run.run_id).status == RunStatus.FAILED + + +def test_executor_streams_and_bounds_large_process_output(tmp_path: Path) -> None: + registry = RunRegistry() + run = registry.create_run( + agent_type="codex", + project_dir=tmp_path, + task="Produce bounded output.", + run_id="run_largeout1", + ) + + class LargeOutputAdapter: + agent_type = "codex" + + def build_command(self, run: Run, artifacts: RunArtifacts) -> AdapterCommand: + return AdapterCommand( + argv=[ + sys.executable, + "-c", + ( + "import pathlib,sys; " + "pathlib.Path(sys.argv[1]).write_text('done'); " + "sys.stdout.write('x' * 200000); " + "sys.stderr.write('y' * 200000)" + ), + str(artifacts.result), + ], + cwd=run.project_dir, + stdin="", + ) + + def summarize_result(self, run: Run, artifacts: RunArtifacts) -> str: + return artifacts.result.read_text(encoding="utf-8") + + result = RunExecutor(registry=registry, max_log_bytes=64 * 1024).execute(run, LargeOutputAdapter()) + + assert result.run.status == RunStatus.SUCCEEDED + assert result.summary == "done" + for log in (result.artifacts.stdout, result.artifacts.stderr): + payload = log.read_bytes() + assert len(payload) < 66 * 1024 + assert b"Athena truncated" in payload + + +def test_executor_timeout_is_not_blocked_by_child_that_never_reads_stdin(tmp_path: Path) -> None: + registry = RunRegistry() + run = registry.create_run( + agent_type="codex", + project_dir=tmp_path, + task="Do not block on stdin.", + run_id="run_stdinblk1", + ) + + class BlockingStdinAdapter: + agent_type = "codex" + + def build_command(self, run: Run, artifacts: RunArtifacts) -> AdapterCommand: + return AdapterCommand( + argv=[sys.executable, "-c", "import time; time.sleep(10)"], + cwd=run.project_dir, + stdin="x" * (2 * 1024 * 1024), + ) + + def summarize_result(self, run: Run, artifacts: RunArtifacts) -> str: + return "unexpected" + + started = time.monotonic() + result = RunExecutor(registry=registry).execute( + run, + BlockingStdinAdapter(), + timeout_seconds=0.1, + ) + + assert result.run.status == RunStatus.FAILED + assert result.returncode == -1 + assert time.monotonic() - started < 3 + + +def test_executor_rejects_new_process_after_shutdown_begins(tmp_path: Path) -> None: + registry = RunRegistry() + run = registry.create_run( + agent_type="codex", + project_dir=tmp_path, + task="Do not start after shutdown.", + run_id="run_shutdown1", + ) + fixture = Path(__file__).parent / "fixtures" / "fake_agent.py" + executor = RunExecutor(registry=registry) + executor.begin_shutdown() + + result = executor.execute(run, FakeAdapter(fixture)) + + assert result.run.status == RunStatus.CANCELLED + assert result.returncode == -2 + assert registry.get(run.run_id).status == RunStatus.CANCELLED + assert executor._processes == {} # noqa: SLF001 - asserts no process was admitted + + +def test_executor_reaps_process_that_popen_starts_during_shutdown( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = RunRegistry() + run = registry.create_run( + agent_type="codex", + project_dir=tmp_path, + task="Race Popen with shutdown.", + run_id="run_shutdown2", + ) + + class SleepingAdapter: + agent_type = "codex" + + def build_command(self, run: Run, artifacts: RunArtifacts) -> AdapterCommand: + return AdapterCommand( + argv=[ + sys.executable, + "-c", + ( + "import pathlib,signal,sys,time; " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "pathlib.Path(sys.argv[1]).write_text('ready'); " + "time.sleep(30)" + ), + str(tmp_path / "shutdown-child-ready"), + ], + cwd=run.project_dir, + stdin="", + ) + + def summarize_result(self, run: Run, artifacts: RunArtifacts) -> str: + return "unexpected" + + real_popen = executor_module.subprocess.Popen + popen_entered = threading.Event() + release_popen = threading.Event() + spawned: list[object] = [] + first_call_lock = threading.Lock() + first_call = True + child_ready = tmp_path / "shutdown-child-ready" + + def blocked_popen(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 + nonlocal first_call + with first_call_lock: + should_block = first_call + first_call = False + if should_block: + popen_entered.set() + assert release_popen.wait(timeout=3) + process = real_popen(*args, **kwargs) + if should_block: + deadline = time.monotonic() + 3 + while not child_ready.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(0.01) + assert child_ready.exists(), "shutdown-race child did not install its SIGTERM handler" + spawned.append(process) + return process + + monkeypatch.setattr(executor_module.subprocess, "Popen", blocked_popen) + executor = RunExecutor(registry=registry) + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(executor.execute, run, SleepingAdapter()) + assert popen_entered.wait(timeout=3) + # The shutdown snapshot is intentionally taken while Popen is not yet + # registered, reproducing the historical orphan window. + executor.begin_shutdown() + executor.shutdown(grace_seconds=0.1) + released_at = time.monotonic() + release_popen.set() + result = future.result(timeout=5) + + assert result.run.status == RunStatus.CANCELLED + assert result.returncode == -2 + assert len(spawned) == 1 + assert spawned[0].poll() is not None + if os.name == "posix": + assert spawned[0].returncode == -signal.SIGKILL + assert time.monotonic() - released_at < 3 + assert executor._processes == {} # noqa: SLF001 - no owned child remains diff --git a/tests/test_memory_admission.py b/tests/test_memory_admission.py new file mode 100644 index 0000000..f5c1966 --- /dev/null +++ b/tests/test_memory_admission.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import backend.memory_admission as memory_admission_module +from backend.memory_admission import ( + DEFAULT_LAUNCH_RESERVATION_BYTES, + DEFAULT_MINIMUM_HEADROOM_BYTES, + GIB, + LaunchMemoryAdmission, + parse_linux_mem_available, + read_windows_physical_available_bytes, +) + + +def test_linux_probe_uses_memavailable_and_never_swapfree() -> None: + text = """\ +MemTotal: 16384000 kB +MemFree: 100000 kB +MemAvailable: 700000 kB +SwapTotal: 67108864 kB +SwapFree: 67108864 kB +""" + + assert parse_linux_mem_available(text) == 700000 * 1024 + + +def test_windows_probe_uses_available_physical_memory_not_pagefile() -> None: + class FakeKernel32: + @staticmethod + def GlobalMemoryStatusEx(status_pointer): # noqa: ANN001, ANN205 + status = status_pointer._obj # noqa: SLF001 - ctypes byref test double + status.ullAvailPhys = 750 * 1024 * 1024 + status.ullAvailPageFile = 64 * GIB + return 1 + + assert read_windows_physical_available_bytes(FakeKernel32()) == 750 * 1024 * 1024 + + +def test_windows_probe_fails_open_only_when_api_probe_fails() -> None: + class FailingKernel32: + @staticmethod + def GlobalMemoryStatusEx(status_pointer): # noqa: ARG004, ANN001, ANN205 + return 0 + + assert read_windows_physical_available_bytes(FailingKernel32()) is None + + +def test_platform_probe_dispatches_win32_to_global_memory_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(memory_admission_module.sys, "platform", "win32") + monkeypatch.setattr( + memory_admission_module, + "read_windows_physical_available_bytes", + lambda: 123456789, + ) + + assert memory_admission_module.read_physical_available_bytes() == 123456789 + + +def test_admission_reservations_are_atomic_across_concurrent_requests() -> None: + # Exactly one reservation fits while preserving the minimum headroom. + available = ( + DEFAULT_MINIMUM_HEADROOM_BYTES + + 2 * DEFAULT_LAUNCH_RESERVATION_BYTES + - 1 + ) + admission = LaunchMemoryAdmission(probe=lambda: available) + ready = threading.Barrier(8) + + def reserve_once(): # noqa: ANN202 + ready.wait(timeout=2) + return admission.reserve() + + with ThreadPoolExecutor(max_workers=8) as pool: + decisions = list(pool.map(lambda _index: reserve_once(), range(8))) + + allowed = [decision for decision in decisions if decision.allowed] + rejected = [decision for decision in decisions if not decision.allowed] + assert len(allowed) == 1 + assert len(rejected) == 7 + assert admission.reserved_bytes() == DEFAULT_LAUNCH_RESERVATION_BYTES + + +def test_admission_reservation_expires_without_counting_swap() -> None: + now = [100.0] + admission = LaunchMemoryAdmission( + probe=lambda: 4 * GIB, + reservation_ttl_seconds=5, + clock=lambda: now[0], + ) + + assert admission.reserve().allowed is True + assert admission.reserved_bytes() == DEFAULT_LAUNCH_RESERVATION_BYTES + now[0] += 5 + assert admission.reserved_bytes() == 0 + + +def test_failed_memory_probe_is_fail_open_but_still_reserved() -> None: + admission = LaunchMemoryAdmission(probe=lambda: None) + + decision = admission.reserve() + + assert decision.allowed is True + assert decision.available_bytes is None + assert decision.reservation_id is not None + assert admission.reserved_bytes() == DEFAULT_LAUNCH_RESERVATION_BYTES