diff --git a/AGENTS.md b/AGENTS.md index bb90719..79899ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ local `claude` or `codex` session through a WebSocket relay. Two independent lin transport, never the caller's Origin. Uvicorn trusts forwarded transport metadata only from loopback Caddy. Never put tokens in URLs or protocol message bodies; logging redacts token/password fields. -- **Protocol version gate**: current wire protocol v34 is declared by +- **Protocol version gate**: current wire protocol v35 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all @@ -113,7 +113,12 @@ local `claude` or `codex` session through a WebSocket relay. Two independent lin hello sends lightweight resident `Snapshot`s; reconnect cursors replay only the bounded missing live tail. Source fingerprints invalidate appended pages, and rollback explicitly invalidates both server and browser projections. These - reads never spawn/resume an engine or create a model turn. + reads never spawn/resume an engine or create a model turn. Codex + `History.terminal_fences` is a separate bounded lifecycle projection: only a + real app-server terminal or a source-validated rollout marker may enter it; + local synthetic failures may not. The browser applies a fence only to its + exact native turn identity and never changes completion receipts or guesses + from the last open row. - **Token-aware residency**: resuming an evicted Claude SDK session may rebuild a cold prompt cache, so it only happens on first spawn / re-focus after eviction; raising the cap trades RAM for fewer cold re-sends. Codex context is @@ -132,8 +137,9 @@ local `claude` or `codex` session through a WebSocket relay. Two independent lin - `cc_remote/log.py` — JSON logging with token redaction; use `logger("...")`. - `cc_remote/wrapper/` — `sdk.py` / `stream.py` and `claude_*` implement Claude; `codex_handle.py` / `codex_stream.py` / `codex_daemon.py` / `codex_external.py` - implement the official Codex app-server paths; `history_store.py` owns the - rebuildable SQLite projection; `machine.py`, `command_router.py`, + implement the official Codex app-server paths; `codex_lifecycle.py` owns the + source-bound exact-terminal ledger; `history_store.py` owns the rebuildable + SQLite projection; `machine.py`, `command_router.py`, `session_ctx.py`, `ringbuffer.py`, `transport.py`, and `session.py` provide the shared session pool, command dispatch, live replay, relay transport, and persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e672ac..c0d8e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## Unreleased +- Upgrade the coordinated Wrapper/Relay/Web gate to protocol v35. Exact Codex + app-server and source-validated rollout terminals now travel independently of + the narrative History projection, so a multi-hundred-MiB rollout cannot keep + a completed turn spinning while its content index catches up. Terminal facts + remain profile-, revision-, and source-bound; they never guess the newest open + row or create a second completion receipt. - Upgrade the coordinated Wrapper/Relay/Web gate to protocol v34. Main-session completion acknowledgements and exact Goal-generation dismissals now live in bounded wrapper-owned state, so reading or hiding them in one browser updates diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index cca5fdc..d362541 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -4,6 +4,11 @@ ## 未发布 +- Wrapper、Relay 与 Web 的协同 gate 升级到 protocol v35。Codex app-server 的 + 精确终态与通过源文件校验的 rollout 终态现在独立于 History 正文投影下发;数百 + MiB 的 rollout 即使仍在补建内容索引,也不会让已经完成的回合继续转圈。终态事实 + 始终绑定账号、revision 与源文件,不会猜测“最后一个未完成回合”,也不会重复生成 + 完成回执。 - Wrapper、Relay 与 Web 的协同 gate 升级到 protocol v34。主会话完成回执和精确 Goal generation 的隐藏回执改由 wrapper 有界持久化;任一浏览器已读或隐藏后会 同步到所有已连接浏览器,重连后仍保持一致,同时不会误隐藏后来替换的新 Goal。 diff --git a/CLAUDE.md b/CLAUDE.md index b496cda..477722e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ local `claude` or `codex` session through a WebSocket relay. Two independent lin `useLayoutEffect` is deliberately dependency-free — late virtualizer/image measurements settle without a React render, and constraining it to its read set reintroduces a full-viewport jump on touch release. -- **Protocol version gate**: current wire protocol v34 is declared by +- **Protocol version gate**: current wire protocol v35 is declared by `PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`. `deserialize` hard-rejects a version mismatch, and `_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all @@ -125,7 +125,12 @@ local `claude` or `codex` session through a WebSocket relay. Two independent lin hello sends lightweight resident `Snapshot`s; reconnect cursors replay only the bounded missing live tail. Source fingerprints invalidate appended pages, and rollback explicitly invalidates both server and browser projections. These - reads never spawn/resume an engine or create a model turn. + reads never spawn/resume an engine or create a model turn. Codex + `History.terminal_fences` is a separate bounded lifecycle projection: only a + real app-server terminal or a source-validated rollout marker may enter it; + local synthetic failures may not. The browser applies a fence only to its + exact native turn identity and never changes completion receipts or guesses + from the last open row. - **Token-aware residency**: resuming an evicted Claude SDK session may rebuild a cold prompt cache, so it only happens on first spawn / re-focus after eviction; raising the cap trades RAM for fewer cold re-sends. Codex context is diff --git a/README.md b/README.md index 6ebbb66..7cb095e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 自托管 · 双引擎 · 多会话 · 实时过程 · 响应式 Web -**当前版本:v3.0.0** · Wire protocol v34 +**当前版本:v3.0.0** · Wire protocol v35 [English](README_en.md) · [5 分钟上手](#本地快速开始一台机器5-分钟) · @@ -466,15 +466,15 @@ npm --prefix web run build # 产出 web/dist/ > 现在网页**不再把 token 烤进 JS**:登录改为向中继 POST 口令换取短期会话 token。所以构建不需要任何 `VITE_*` 变量。 -> **升级到协议 v34**:线协议会严格拒绝版本不一致。请在同一次维护窗口部署 +> **升级到协议 v35**:线协议会严格拒绝版本不一致。请在同一次维护窗口部署 > `cc_remote/` 和新的 `web/dist/`,然后依次重启 relay、wrapper;不要新旧版本滚动混跑。 > 升级期间已有 WebSocket 会短暂重连,relay 重启也会要求浏览器重新登录。已打开的 > 旧版页面必须做一次**硬刷新**(重新加载新的带 hash 静态资源),仅重新登录不够。 -> 手工发布时先停本机 wrapper,再停服更新 relay + web,最后启动 v34 relay 和 -> v34 wrapper;这样旧 wrapper 不会占住同一 `machine_id` 的连接槽。v34 会迁移本机 -> Work SQLite;手工发布还必须在启动 v34 前用 -> `deploy/work_registry_snapshot.py snapshot` 保存两个注册表。回滚时先停 v34、恢复该 -> 快照,再切回旧代码;不要在 wrapper 运行时只复制主 `.sqlite3` 文件而漏掉 WAL。 +> 手工发布时先停本机 wrapper,再停服更新 relay + web,最后启动 v35 relay 和 +> v35 wrapper;这样旧 wrapper 不会占住同一 `machine_id` 的连接槽。若从 v34 以前的 +> 版本跨级升级,仍须执行 v34 引入的 Work SQLite 迁移保护:启动新 wrapper 前用 +> `deploy/work_registry_snapshot.py snapshot` 保存两个注册表。回滚时先停新版本、恢复 +> 该快照,再切回旧代码;不要在 wrapper 运行时只复制主 `.sqlite3` 文件而漏掉 WAL。 ### 3)上传 staging,由原子 release 安装器发布 @@ -528,7 +528,7 @@ sudo bash ~/cc-remote-upload/deploy/setup-vps.sh \ 脚本会:装 `python3-venv` + Caddy、建 `ccremote` 系统用户、创建不可变 release 和 release-local venv、合并 Caddy 配置、原子切换 `current`,再重启 relay。若新 relay 重启或健康检查失败,`current`、Caddyfile、systemd unit 会作为一个事务全部 -恢复,并验证旧 release 的 `/healthz`。成功后再启动 v34 wrapper。 +恢复,并验证旧 release 的 `/healthz`。成功后再启动 v35 wrapper。 验证: diff --git a/README_en.md b/README_en.md index a1b3bfe..51373ad 100644 --- a/README_en.md +++ b/README_en.md @@ -4,7 +4,7 @@ Self-hosted · Dual-engine · Multi-session · Live process · Responsive Web -**Current release: v3.0.0** · Wire protocol v34 +**Current release: v3.0.0** · Wire protocol v35 [中文](README.md) · [5-minute quick start](#quick-start-local-one-machine-5-min) · @@ -548,19 +548,20 @@ npm --prefix web run build # produces web/dist/ > The web client no longer bakes any token into the JS: login POSTs the password to the relay for a short-lived session token. So the build needs no `VITE_*` variables. -> **Upgrading to protocol v34:** the wire gate rejects mixed versions. Deploy +> **Upgrading to protocol v35:** the wire gate rejects mixed versions. Deploy > `cc_remote/` and the new `web/dist/` in one maintenance window, then restart the > relay and wrapper; do not run a rolling mixture. Existing sockets reconnect > briefly, and a relay restart intentionally requires browsers to log in again. > Any already-open older page also needs one **hard refresh** to load the new hashed > assets; logging in again inside the old JavaScript bundle isn't sufficient. > For a manual release, stop the local wrapper first, stop and update relay + web, -> then start the v34 relay and v34 wrapper so the old wrapper cannot occupy the -> slot for the same `machine_id`. v34 migrates provider-local Work SQLite data; -> a manual release must also run `deploy/work_registry_snapshot.py snapshot` -> before v34 starts. To roll back, stop v34, restore that snapshot, then switch -> to the old code. Do not copy only the main `.sqlite3` file while the wrapper is -> live because committed pages may still be in WAL. +> then start the v35 relay and v35 wrapper so the old wrapper cannot occupy the +> slot for the same `machine_id`. When upgrading from a pre-v34 release, retain +> the Work SQLite migration protection introduced by v34: a manual release must +> run `deploy/work_registry_snapshot.py snapshot` before the new wrapper starts. +> To roll back, stop the new version, restore that snapshot, then switch to the +> old code. Do not copy only the main `.sqlite3` file while the wrapper is live +> because committed pages may still be in WAL. ### 3) Upload staging, then publish it as an atomic release @@ -617,7 +618,7 @@ The script installs `python3-venv` + Caddy, creates the `ccremote` service user, builds an immutable release and its venv, merges Caddy configuration, atomically switches `current`, and restarts the relay. If restart/readiness fails, `current`, the Caddyfile, and the systemd unit roll back as one transaction and the previous -release's `/healthz` is verified. Start the v34 wrapper after success. +release's `/healthz` is verified. Start the v35 wrapper after success. Verify: diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py index c5bc1b5..7e8ccad 100644 --- a/cc_remote/protocol.py +++ b/cc_remote/protocol.py @@ -19,7 +19,7 @@ from pydantic import ( AfterValidator, BaseModel, ConfigDict, Field, StringConstraints, ValidationError, - model_validator, + PrivateAttr, model_validator, ) from cc_remote.attachments import ( @@ -28,13 +28,14 @@ MAX_SINGLE_ATTACHMENT_BYTES, ) -PROTOCOL_VERSION = 34 +PROTOCOL_VERSION = 35 # Codex Desktop renders a 53-week daily token-activity calendar. Keep the wire # payload to that same bounded window so an account response can never turn a # one-shot status frame into an unbounded relay/browser allocation. MAX_STATUS_USAGE_BUCKETS = 53 * 7 MAX_SAFE_WIRE_INTEGER = 9_007_199_254_740_991 +MAX_SAFE_WIRE_TIMESTAMP_SECONDS = MAX_SAFE_WIRE_INTEGER // 1000 State = Literal["idle", "running", "interrupting", "draining"] Engine = Literal["claude", "codex"] @@ -843,6 +844,11 @@ class TurnEnd(_Base): # not the assistant UUID above or the browser's optimistic message id. checkpoint_id: Optional[WireId] = None notification_context: Optional[TurnNotificationContext] = None + # Wrapper-internal provenance. This is deliberately a Pydantic private + # attribute so it never crosses the wire or changes protocol validation. + # Only a real Codex app-server ``turn/completed`` may set it; locally + # synthesized TurnEnd frames must not become durable lifecycle facts. + _codex_authoritative_terminal: bool = PrivateAttr(default=False) class Error(_Base): @@ -1972,6 +1978,23 @@ class ConversationTurn(BaseModel): detailLoaded: bool = False +class CodexTerminalFence(BaseModel): + """Source-bound Codex lifecycle fact independent of History content. + + App-server's terminal notification is authoritative, but a large rollout's + materialized History page can briefly lag behind it. A newest-page History + carries these small exact-turn fences so a reconnect can close the already + painted row without rescanning or guessing from the last open turn. + """ + model_config = ConfigDict(extra="forbid") + turn_id: WireId + status: Literal["completed", "interrupted", "failed"] + duration_ms: Optional[int] = Field( + default=None, ge=0, le=MAX_SAFE_WIRE_INTEGER) + completed_at: Optional[float] = Field( + default=None, ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS) + + class History(_Base): """wrapper -> client: one summary or compatibility event page. @@ -2032,6 +2055,11 @@ class History(_Base): # real user interrupt or crash must stay terminal on a cold browser. compaction_continuation_turn_ids: list[WireId] = Field( default_factory=list, max_length=4) + # Exact native terminal facts are a separate lifecycle projection. They + # may close a stale/incomplete narrative page, but never identify a target + # by array position and never replace live TurnEnd notifications. + terminal_fences: list[CodexTerminalFence] = Field( + default_factory=list, max_length=16) # Authoritative replacement after a destructive history mutation such as # Codex rollback. Ordinary loads merge with a live tail; reset loads must # discard turns that the engine has just removed. diff --git a/cc_remote/wrapper/claude_forks.py b/cc_remote/wrapper/claude_forks.py index 3cf4f7a..b217461 100644 --- a/cc_remote/wrapper/claude_forks.py +++ b/cc_remote/wrapper/claude_forks.py @@ -42,7 +42,9 @@ _MAX_ERROR_CHARS = 512 _STATUSES = { "intent", "alias", "submitted", "uncertain", "complete", "rejected", + "delete_pending", "deleted", } +_CHILD_STATUSES = {"complete", "delete_pending", "deleted"} _IDENTITY_FIELDS = ("parent_session_id", "cutoff_message_id", "cwd") _ALLOWED_ENTRY_FIELDS = { *_IDENTITY_FIELDS, @@ -157,9 +159,11 @@ def _validate_entry(request_id: Any, entry: Any) -> None: if status_value != "alias" and canonical_id is not None: # Resolved aliases keep canonical_request_id, so only unresolved # non-alias roots are forbidden here. - if status_value not in {"complete", "rejected"}: + if status_value not in { + "complete", "delete_pending", "deleted", "rejected", + }: raise ValueError("invalid Claude fork alias status") - if status_value == "complete": + if status_value in _CHILD_STATUSES: _safe_id(entry.get("session_id"), "forked session id") elif entry.get("session_id") is not None: raise ValueError("unresolved Claude fork has a child session id") @@ -216,11 +220,13 @@ def _validate_aliases( compatible = { "alias": {"intent", "submitted", "uncertain"}, "complete": {"complete"}, + "delete_pending": {"delete_pending"}, + "deleted": {"deleted"}, "rejected": {"rejected"}, } if canonical.get("status") not in compatible.get(entry.get("status"), set()): raise ValueError("Claude fork alias and root states differ") - if (entry.get("status") == "complete" + if (entry.get("status") in _CHILD_STATUSES and entry.get("session_id") != canonical.get("session_id")): raise ValueError("Claude fork aliases have different children") if (entry.get("status") == "rejected" @@ -316,6 +322,9 @@ def _terminal_group_for_compaction( if candidate.get("marker") == marker ] statuses = {candidate.get("status") for _, candidate in group} + # Deleted children are durable replay tombstones. Compacting one + # could resurrect a cached SessionForked event after restart, so a + # journal full of tombstones must fail closed. if statuses == {"complete"}: children = {candidate.get("session_id") for _, candidate in group} if len(children) == 1: @@ -346,6 +355,93 @@ def get_canonical(self, request_id: str) -> Optional[dict[str, Any]]: "canonical fork intent is missing") return dict(canonical) + def child_entry(self, session_id: str) -> Optional[dict[str, Any]]: + """Return the strongest durable lifecycle record for one fork child.""" + session_id = _safe_id(session_id, "forked session id") + rank = {"complete": 1, "delete_pending": 2, "deleted": 3} + with self._lock: + candidates = [ + value for value in self.entries.values() + if value.get("session_id") == session_id + and value.get("status") in rank + ] + if not candidates: + return None + return dict(max(candidates, key=lambda value: rank[value["status"]])) + + def begin_delete(self, session_id: str) -> Optional[str]: + """Persist deletion intent before the native child is touched.""" + session_id = _safe_id(session_id, "forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") in _CHILD_STATUSES + ] + if not matches: + return None + target = "deleted" if any( + value.get("status") == "deleted" for _, value in matches + ) else "delete_pending" + updated = OrderedDict(self.entries) + changed = False + for key, value in matches: + if value.get("status") == target: + continue + pending = dict(value) + pending["status"] = target + updated[key] = pending + changed = True + if changed: + self._persist(updated) + self.entries = updated + return target + + def finish_delete(self, session_id: str) -> bool: + """Turn every pending reference to a child into a replay tombstone.""" + session_id = _safe_id(session_id, "forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") in {"delete_pending", "deleted"} + ] + if not matches: + return False + updated = OrderedDict(self.entries) + changed = False + for key, value in matches: + if value.get("status") == "deleted": + continue + deleted = dict(value) + deleted["status"] = "deleted" + updated[key] = deleted + changed = True + if changed: + self._persist(updated) + self.entries = updated + return True + + def abort_delete(self, session_id: str) -> bool: + """Restore a child after a proven native deletion failure.""" + session_id = _safe_id(session_id, "forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") == "delete_pending" + ] + if not matches: + return False + updated = OrderedDict(self.entries) + for key, value in matches: + restored = dict(value) + restored["status"] = "complete" + updated[key] = restored + self._persist(updated) + self.entries = updated + return True + def claim_submission(self, request_id: str) -> bool: """Persist the at-most-once boundary; only one alias may return true.""" request_id = _safe_id(request_id, "fork request id") @@ -402,6 +498,11 @@ def complete(self, request_id: str, session_id: str) -> dict[str, Any]: raise ClaudeForkJournalError("rejected fork request cannot complete") if status_value == "intent": raise ClaudeForkJournalError("fork submission was not claimed") + if status_value in {"delete_pending", "deleted"}: + if canonical.get("session_id") != session_id: + raise ClaudeForkJournalError( + "deleted fork request resolved to another child session") + return dict(self.entries[request_id]) if (status_value == "complete" and canonical.get("session_id") != session_id): raise ClaudeForkJournalError( @@ -426,7 +527,7 @@ def reject(self, request_id: str, message: str) -> dict[str, Any]: bounded = str(message or "Claude SDK rejected the fork")[:_MAX_ERROR_CHARS] with self._lock: _, canonical = self._canonical(request_id) - if canonical.get("status") == "complete": + if canonical.get("status") in _CHILD_STATUSES: raise ClaudeForkJournalError("completed fork request cannot reject") updated = OrderedDict(self.entries) marker = canonical["marker"] diff --git a/cc_remote/wrapper/codex_daemon.py b/cc_remote/wrapper/codex_daemon.py index 66ec630..8d67463 100644 --- a/cc_remote/wrapper/codex_daemon.py +++ b/cc_remote/wrapper/codex_daemon.py @@ -35,6 +35,8 @@ _OUTPUT_MAX = 64 * 1024 _PID_RECORD_MAX = 4096 _STALE_UPDATER_EXIT_TIMEOUT = 3.0 +_DAEMON_UPGRADE_SETTLE_TIMEOUT = 5.0 +_DAEMON_UPGRADE_POLL_INTERVAL = 0.1 def codex_daemon_mode(value: Optional[str] = None) -> str: @@ -155,7 +157,76 @@ def _prepare_profile_standalone( existing = (destination / "codex").resolve(strict=True) except OSError: return False - return existing == binary and os.access(existing, os.X_OK) + if existing == binary: + return os.access(existing, os.X_OK) + # A configured account may already own an older official standalone + # ``current`` symlink. Merely restarting that daemon cannot upgrade it: + # the official lifecycle command launches the stale path again. Only + # replace the pointer when every part of the old target is the same + # user's canonical standalone release layout. Directories, ordinary + # files, broken links, and third-party layouts remain user-owned. + profile_standalone = profile_home / "packages" / "standalone" + try: + destination_stat = destination.lstat() + existing_stat = existing.stat() + existing_release = existing.parent.parent + safely_replaceable = bool( + stat.S_ISLNK(destination_stat.st_mode) + and destination_stat.st_uid == os.getuid() + and stat.S_ISREG(existing_stat.st_mode) + and existing_stat.st_uid == os.getuid() + and os.access(existing, os.X_OK) + and existing.parent.name == "bin" + and existing_release.parent.name == "releases" + and existing_release.parent.parent == profile_standalone + ) + except OSError: + return False + if not safely_replaceable: + return False + replacement = destination.with_name( + f".{destination.name}.cc-remote-{os.getpid()}-" + f"{time.monotonic_ns()}" + ) + try: + os.symlink(source_current, replacement, target_is_directory=True) + # The official updater may advance ``current`` concurrently. A + # symlink's target is immutable, so the same inode proves the path + # still names the exact pointer validated above. If it changed, + # preserve the updater's result instead of overwriting it. + if not os.path.samestat(destination_stat, destination.lstat()): + replacement.unlink() + try: + concurrent = (destination / "codex").resolve(strict=True) + except OSError: + return False + return concurrent == binary and os.access(concurrent, os.X_OK) + os.replace(replacement, destination) + except OSError: + try: + replacement.unlink() + except OSError: + pass + return False + try: + directory_fd = os.open(destination.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError as exc: + # The atomic replacement is already visible. A directory-fsync + # failure weakens crash durability but must not make callers retry + # or report that the old pointer is still active. + log.warning( + "Codex profile standalone directory fsync failed", + error_type=type(exc).__name__, + ) + try: + prepared = (destination / "codex").resolve(strict=True) + except OSError: + return False + return prepared == binary and os.access(prepared, os.X_OK) # ``Path.exists`` is false for a broken symlink. Never replace one. if os.path.lexists(destination): return False @@ -556,19 +627,37 @@ async def _align_managed_daemon( app_server_version=_text( lifecycle.get("appServerVersion"), 128), ) + prepared = await asyncio.to_thread( + _prepare_profile_standalone, codex_bin, env, + ) + if prepared is False: + log.warning( + "Codex profile managed standalone could not be aligned" + ) if not await self.restart(codex_bin, env): self.invalidate() raise CodexDaemonUpgradeRequired( "Codex shared daemon is older than the selected CLI and " "could not be restarted" ) - verified = await self.version(codex_bin, env) - if verified is None or _managed_daemon_lags_cli(verified): - self.invalidate() - raise CodexDaemonUpgradeRequired( - "Codex shared daemon did not upgrade to the selected CLI" - ) - return verified + deadline = ( + asyncio.get_running_loop().time() + + _DAEMON_UPGRADE_SETTLE_TIMEOUT + ) + while True: + verified = await self.version(codex_bin, env) + if verified is not None and not _managed_daemon_lags_cli(verified): + return verified + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + self.invalidate() + raise CodexDaemonUpgradeRequired( + "Codex shared daemon did not upgrade to the selected CLI" + ) + await asyncio.sleep(min( + _DAEMON_UPGRADE_POLL_INTERVAL, + remaining, + )) async def enable_remote_control( self, codex_bin: str, env: Mapping[str, str], diff --git a/cc_remote/wrapper/codex_external.py b/cc_remote/wrapper/codex_external.py index c9c1981..97b33a3 100644 --- a/cc_remote/wrapper/codex_external.py +++ b/cc_remote/wrapper/codex_external.py @@ -8,17 +8,22 @@ """ from __future__ import annotations +import glob import json +import math import os import re import sqlite3 import subprocess import sys -import glob from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Mapping +from cc_remote.protocol import ( + MAX_SAFE_WIRE_INTEGER, + MAX_SAFE_WIRE_TIMESTAMP_SECONDS, +) from cc_remote.wrapper.process_scan import ( DarwinProcessInfo, MAX_PROC_SCAN, @@ -51,8 +56,10 @@ r"([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\b" ) _TERMINAL_EVENTS = frozenset({ - "task_complete", "turn_aborted", "task_failed", "task_cancelled", + "task_complete", "turn_aborted", "task_failed", "turn_failed", + "task_error", "task_cancelled", }) +_SAFE_WIRE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$") _RESUME_OPTIONS_WITH_VALUE = frozenset({ b"-c", b"--config", b"--enable", b"--disable", b"--remote", b"--remote-auth-token-env", b"-m", b"--model", b"--local-provider", @@ -61,6 +68,14 @@ }) +@dataclass(frozen=True) +class RolloutTerminalMarker: + turn_id: str + status: str + duration_ms: int | None = None + completed_at: float | None = None + + @dataclass(frozen=True) class TurnMarkers: started: frozenset[str] @@ -68,6 +83,7 @@ class TurnMarkers: partial: bytes ordered: tuple[tuple[str, str], ...] = () has_visible_user_message: bool = False + terminals: tuple[RolloutTerminalMarker, ...] = () @dataclass(frozen=True) @@ -800,11 +816,12 @@ def parse_turn_markers(data: bytes, partial: bytes = b"") -> TurnMarkers: started: set[str] = set() finished: set[str] = set() ordered: list[tuple[str, str]] = [] + terminals: list[RolloutTerminalMarker] = [] has_visible_user_message = False for line in lines: try: record = json.loads(line) - except (json.JSONDecodeError, UnicodeDecodeError): + except (UnicodeDecodeError, ValueError, RecursionError): continue if not isinstance(record, dict) or record.get("type") != "event_msg": continue @@ -816,7 +833,7 @@ def parse_turn_markers(data: bytes, partial: bytes = b"") -> TurnMarkers: if user is not None and user.prompt: has_visible_user_message = True turn_id = payload.get("turn_id") - if not isinstance(turn_id, str) or not turn_id or len(turn_id) > 128: + if not isinstance(turn_id, str) or not _SAFE_WIRE_ID.fullmatch(turn_id): continue if kind == "task_started": started.add(turn_id) @@ -824,9 +841,46 @@ def parse_turn_markers(data: bytes, partial: bytes = b"") -> TurnMarkers: elif kind in _TERMINAL_EVENTS: finished.add(turn_id) ordered.append((kind, turn_id)) + reason = str(payload.get("reason") or "").lower() + status = ( + "completed" + if kind == "task_complete" + else "interrupted" + if kind == "task_cancelled" or ( + kind == "turn_aborted" + and reason not in {"error", "failed", "crash"} + ) + else "failed" + ) + duration = payload.get("duration_ms") + duration_ms = ( + int(duration) + if isinstance(duration, (int, float)) + and not isinstance(duration, bool) + and (not isinstance(duration, float) or math.isfinite(duration)) + and duration > 0 + and duration <= MAX_SAFE_WIRE_INTEGER + else None + ) + completed = payload.get("completed_at") + completed_at = ( + float(completed) + if isinstance(completed, (int, float)) + and not isinstance(completed, bool) + and (not isinstance(completed, float) or math.isfinite(completed)) + and completed >= 0 + and completed <= MAX_SAFE_WIRE_TIMESTAMP_SECONDS + else None + ) + terminals.append(RolloutTerminalMarker( + turn_id=turn_id, + status=status, + duration_ms=duration_ms, + completed_at=completed_at, + )) return TurnMarkers( frozenset(started), frozenset(finished), carry, tuple(ordered), - has_visible_user_message, + has_visible_user_message, tuple(terminals), ) diff --git a/cc_remote/wrapper/codex_forks.py b/cc_remote/wrapper/codex_forks.py index b8443b1..3f7ee61 100644 --- a/cc_remote/wrapper/codex_forks.py +++ b/cc_remote/wrapper/codex_forks.py @@ -26,6 +26,7 @@ _MAX_META_RECORD_BYTES = 1024 * 1024 _SOURCE_PREFIX = "cc-remote-fork:" _PROFILE_META_KEY = "__cc_remote_profile__" +_CHILD_STATUSES = {"complete", "delete_pending", "deleted"} class ForkJournalError(RuntimeError): @@ -153,13 +154,15 @@ def _validate_aliases(entries: OrderedDict[str, dict[str, Any]]) -> None: compatible = { "alias": {"intent", "submitted", "uncertain"}, "complete": {"complete"}, + "delete_pending": {"delete_pending"}, + "deleted": {"deleted"}, "rejected": {"rejected"}, } allowed_canonical = compatible.get(entry.get("status")) if (allowed_canonical is None or canonical.get("status") not in allowed_canonical): raise ValueError("fork alias and canonical states are inconsistent") - if (entry.get("status") == "complete" + if (entry.get("status") in _CHILD_STATUSES and entry.get("session_id") != canonical.get("session_id")): raise ValueError("fork alias and canonical child ids differ") if (entry.get("status") == "rejected" @@ -193,12 +196,13 @@ def _validate_entry(request_id: Any, entry: Any) -> None: raise ValueError("invalid fork cwd") if entry.get("status") not in { "intent", "alias", "submitted", "uncertain", "rejected", "complete", + "delete_pending", "deleted", }: raise ValueError("invalid fork status") if entry.get("status") == "alias" and canonical_request_id is None: raise ValueError("fork alias is missing its canonical request") session_id = entry.get("session_id") - if entry.get("status") == "complete" and ( + if entry.get("status") in _CHILD_STATUSES and ( not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id) ): raise ValueError("invalid child session id") @@ -354,6 +358,10 @@ def _terminal_group_for_compaction( if candidate.get("thread_source") == source ] statuses = {candidate.get("status") for _, candidate in group} + # Deleted children are durable replay tombstones. Compacting one + # would let a sufficiently old reliable fork command publish its + # cached SessionForked event again after a wrapper restart. If + # tombstones fill the bounded journal, fail closed instead. if statuses == {"complete"}: children = {candidate.get("session_id") for _, candidate in group} if len(children) == 1: @@ -377,8 +385,14 @@ def mark_name_finalized(self, request_id: str) -> dict[str, Any]: existing = self.entries.get(request_id) if existing is None: raise ForkJournalError("fork intent is missing") - if (existing.get("target") != "worktree" - or existing.get("status") != "complete"): + if existing.get("target") != "worktree": + raise ForkJournalError( + "only a completed worktree fork can finalize its name") + if existing.get("status") in {"delete_pending", "deleted"}: + # Deletion owns the child. A reconciler racing that deletion + # must terminate quietly instead of retrying title work forever. + return dict(existing) + if existing.get("status") != "complete": raise ForkJournalError( "only a completed worktree fork can finalize its name") source = existing.get("thread_source") @@ -401,6 +415,11 @@ def _complete(self, request_id: str, session_id: str) -> dict[str, Any]: raise ForkJournalError("rejected fork request cannot complete") if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id): raise ForkJournalError("invalid forked session id") + if existing.get("status") in {"delete_pending", "deleted"}: + if existing.get("session_id") != session_id: + raise ForkJournalError( + "deleted fork request resolved to another child session") + return dict(existing) if (existing.get("status") == "complete" and existing.get("session_id") != session_id): raise ForkJournalError("fork request resolved to two child sessions") @@ -519,6 +538,139 @@ def set_title(self, session_id: str, title: str) -> bool: self.entries = updated return True + def child_entry(self, session_id: str) -> Optional[dict[str, Any]]: + """Return the strongest durable lifecycle record for one fork child.""" + if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id): + raise ForkJournalError("invalid forked session id") + rank = {"complete": 1, "delete_pending": 2, "deleted": 3} + with self._lock: + candidates = [ + value for value in self.entries.values() + if value.get("session_id") == session_id + and value.get("status") in rank + ] + if not candidates: + return None + return dict(max(candidates, key=lambda value: rank[value["status"]])) + + def completed_children( + self, limit: int = _MAX_ENTRIES, + ) -> list[dict[str, Any]]: + """Return newest unique, live fork children for catalog repair. + + Aliased reliable commands can point at the same native child. Expose + each child once, and never expose deletion-owned lifecycle states. The + caller still verifies the exact child in its owning Codex state DB + before placing it in a public session list. + """ + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise ForkJournalError("invalid completed child limit") + bounded = min(limit, _MAX_ENTRIES) + rank = {"complete": 1, "delete_pending": 2, "deleted": 3} + with self._lock: + strongest: dict[str, str] = {} + for value in self.entries.values(): + child = value.get("session_id") + status = value.get("status") + if not isinstance(child, str) or status not in rank: + continue + previous = strongest.get(child) + if previous is None or rank[status] > rank[previous]: + strongest[child] = status + + children: list[dict[str, Any]] = [] + seen: set[str] = set() + for value in reversed(self.entries.values()): + child = value.get("session_id") + if not isinstance(child, str) or child in seen: + continue + status = value.get("status") + if status not in rank: + continue + seen.add(child) + if strongest.get(child) != "complete": + continue + children.append(dict(value)) + if len(children) >= bounded: + break + return children + + def begin_delete(self, session_id: str) -> Optional[str]: + """Persist deletion intent before the native child is touched.""" + if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id): + raise ForkJournalError("invalid forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") in _CHILD_STATUSES + ] + if not matches: + return None + target = "deleted" if any( + value.get("status") == "deleted" for _, value in matches + ) else "delete_pending" + updated = OrderedDict(self.entries) + changed = False + for key, value in matches: + if value.get("status") == target: + continue + pending = dict(value) + pending["status"] = target + updated[key] = pending + changed = True + if changed: + self._persist(updated) + self.entries = updated + return target + + def finish_delete(self, session_id: str) -> bool: + """Turn every pending reference to a child into a replay tombstone.""" + if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id): + raise ForkJournalError("invalid forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") in {"delete_pending", "deleted"} + ] + if not matches: + return False + updated = OrderedDict(self.entries) + changed = False + for key, value in matches: + if value.get("status") == "deleted": + continue + deleted = dict(value) + deleted["status"] = "deleted" + updated[key] = deleted + changed = True + if changed: + self._persist(updated) + self.entries = updated + return True + + def abort_delete(self, session_id: str) -> bool: + """Restore a child after a proven native deletion failure.""" + if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id): + raise ForkJournalError("invalid forked session id") + with self._lock: + matches = [ + (key, value) for key, value in self.entries.items() + if value.get("session_id") == session_id + and value.get("status") == "delete_pending" + ] + if not matches: + return False + updated = OrderedDict(self.entries) + for key, value in matches: + restored = dict(value) + restored["status"] = "complete" + updated[key] = restored + self._persist(updated) + self.entries = updated + return True + def _set_status( self, request_id: str, status: str, **fields: Any, ) -> dict[str, Any]: @@ -529,7 +681,7 @@ def _set_status( canonical = self.entries.get(canonical_id) if canonical is None: raise ForkJournalError("canonical fork intent is missing") - if canonical.get("status") == "complete": + if canonical.get("status") in _CHILD_STATUSES: return dict(existing) if canonical.get("status") == "rejected" and status != "rejected": raise ForkJournalError("rejected fork request cannot be resubmitted") diff --git a/cc_remote/wrapper/codex_handle.py b/cc_remote/wrapper/codex_handle.py index 6b5c058..8ccb5c7 100644 --- a/cc_remote/wrapper/codex_handle.py +++ b/cc_remote/wrapper/codex_handle.py @@ -72,6 +72,7 @@ WORK_BASE_INSTRUCTIONS, WORK_DEVELOPER_INSTRUCTIONS, ) +from cc_remote.wrapper.work_context import recover_codex_context_usage log = logger("cc_remote.wrapper.codex_handle") @@ -79,6 +80,7 @@ _APPROVAL_TIMEOUT = 5 * 60.0 _MAX_SERVER_REQUEST_TASKS = 32 _THREAD_SETTINGS_NOTIFY_TIMEOUT = 1.0 +_CONFIGURED_DEFAULT_EFFORT_CACHE_SECONDS = 30.0 _OWNED_TURN_IDS_MAX = 512 _STATUS_RATE_LIMIT_MAX = 16 _STATUS_USAGE_BUCKET_SCAN_MAX = 4096 @@ -106,6 +108,7 @@ _WORK_PATH_MAX = 4096 _WORK_NAME_MAX = 256 _COMPACTION_CONTINUATION_GRACE_SECONDS = 5.0 +_COMPACTION_CONTINUATION_PROBE_TIMEOUT_SECONDS = 1.0 _GOAL_PROMPT_CANDIDATE_TTL_SECONDS = 30.0 _PROXY_HANDSHAKE_MAX = 16 * 1024 _PROXY_HANDSHAKE_TIMEOUT = 5.0 @@ -479,6 +482,7 @@ class _CodexCompactionContinuation: "candidate_turn_id", "candidate_started", "candidate_size", + "probing", "settled", "expiry_task", ) @@ -506,6 +510,7 @@ def __init__( self.candidate_turn_id: Optional[str] = None self.candidate_started: Optional[dict] = None self.candidate_size: Optional[int] = None + self.probing = False self.settled = asyncio.Event() self.settled.set() self.expiry_task: Optional[asyncio.Task] = None @@ -1114,6 +1119,39 @@ def _append_openai_http_resume_provider(argv: list[str]) -> None: ]) +def _openai_http_resume_thread_config() -> dict[str, Any]: + """Register the HTTP compatibility alias in one thread configuration. + + ``thread/resume.config`` is evaluated by the app-server which owns the + thread. Supplying the alias there lets a shared daemon select official + Responses HTTP without writing the user's config.toml or starting a second + independently writable app-server. + """ + return { + "model_providers": { + _OPENAI_HTTP_RESUME_PROVIDER_ID: { + "name": "cc-remote OpenAI HTTP", + "base_url": _OPENAI_HTTP_RESUME_BASE_URL, + "wire_api": "responses", + "requires_openai_auth": True, + "supports_websockets": False, + }, + }, + } + + +def _code_thread_config( + *, http_only_resume: bool, web_search: Optional[str], +) -> Optional[dict[str, Any]]: + """Compose independent Code-time overrides without losing either one.""" + config: dict[str, Any] = {} + if http_only_resume: + config.update(_openai_http_resume_thread_config()) + if web_search: + config["web_search"] = web_search + return config or None + + def _semantic_version(value: Optional[str]) -> tuple[int, ...]: """Return the numeric release prefix used for app-server feature gates.""" if not isinstance(value, str): @@ -1352,6 +1390,7 @@ def __init__(self, cfg, cwd: Optional[str] = None, self._thread_deleted_ids: Optional[list[str]] = None self.thread_delete_notifications_overflowed = False self._thread_delete_done = asyncio.Event() + self._thread_settings_revision = 0 # Human approval can take minutes. It must not block the sole stdout # reader, which still has to consume turn/interrupt and other RPC replies. # Keep detached request handlers generation-owned and cancel them on @@ -1389,6 +1428,7 @@ def __init__(self, cfg, cwd: Optional[str] = None, self._http_provider_repair_stop = asyncio.Event() self.last_token_usage: Optional[dict] = None self.context_window: Optional[int] = None + self._rollout_context_recovery_attempted = False self.app_server_version: Optional[str] = None self.last_thread_status: Optional[dict] = None self.last_rate_limits: Optional[dict] = None @@ -1423,15 +1463,43 @@ def __init__(self, cfg, cwd: Optional[str] = None, # turn. Config.toml is read-only here and supplies fresh-thread defaults. # Codex equivalents of cc's model / effort / permission-mode. Defaults come # from ~/.codex/config.toml; the client overrides them via set_* . - self.model: Optional[str] = ( + configured_model = ( codex_model() if self.codex_home is None else codex_model(codex_home=self.codex_home) ) - self.effort: Optional[str] = ( + self.model: Optional[str] = configured_model or None + configured_effort = ( codex_effort() if self.codex_home is None else codex_effort(codex_home=self.codex_home) - ) # low | medium | high | xhigh + ) + self.effort: Optional[str] = configured_effort or None self.applied_effort = self.effort # keep machine's spawn-time check a no-op + # UI projection may be ``model-default`` when app-server reports a null + # thread override and its effective config has no explicit fallback. + # Query must continue to use only ``effort``; never send that display + # sentinel to turn/start. + self.display_effort: Optional[str] = self.effort + self.display_effort_model: Optional[str] = ( + self.model if self.display_effort else None + ) + self.display_effort_cwd: Optional[str] = ( + os.path.realpath(self._cwd) + if self.display_effort and isinstance(self._cwd, str) and self._cwd + else None + ) + self.display_effort_generation: Optional[int] = ( + self._generation if self.display_effort else None + ) + self._display_effort_retry_at: Optional[float] = None + # A null thread override falls through to app-server's effective config + # before the selected model's catalog default. Cache that read per cwd + # and app-server generation; it is presentation state only and must not + # become a turn/start override. + self._configured_default_effort: Optional[str] = None + self._configured_default_effort_cwd: Optional[str] = None + self._configured_default_effort_generation: Optional[int] = None + self._configured_default_effort_read_at: Optional[float] = None + self._configured_default_effort_known = False # Work is governed by its per-process named permission profile. It must # never fall back to interactive escalation outside that profile, even # when a resumed native thread persisted a Code-time approval policy. @@ -1682,6 +1750,26 @@ async def _open_process( self._discard_managed_compaction_continuation() self.last_token_usage = None self.context_window = None + self._rollout_context_recovery_attempted = False + self._configured_default_effort = None + self._configured_default_effort_cwd = None + self._configured_default_effort_generation = None + self._configured_default_effort_read_at = None + self._configured_default_effort_known = False + if self.effort: + self.display_effort = self.effort + self.display_effort_model = self.model + self.display_effort_cwd = ( + os.path.realpath(self._cwd) + if isinstance(self._cwd, str) and self._cwd else None + ) + self.display_effort_generation = self._generation + else: + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None self._stderr_task = asyncio.create_task( self._drain_stderr(proc, generation)) try: @@ -1720,11 +1808,7 @@ async def connect( codex_bin = await asyncio.to_thread(_resolve_codex_bin) private_core = None http_only_resume = False - if ( - not self.work_mode - and not self._daemon_proxy_established - and not control_only - ): + if not self.work_mode: http_only_resume = await asyncio.to_thread( _oversized_desktop_openai_resume_requires_http, resume_id, @@ -1732,6 +1816,7 @@ async def connect( _oversized_desktop_openai_resume_requires_http, resume_id, self.codex_home, ) + if not self.work_mode and not self._daemon_proxy_established: private_core = await asyncio.to_thread( _newer_private_core_for_oversized_resume, codex_bin, @@ -1741,13 +1826,16 @@ async def connect( codex_bin, resume_id, self.codex_home, ) - if private_core is not None: - codex_bin = private_core + # The managed binary owns the shared daemon. A newer desktop core is + # only a private-stdio fallback if no shared control plane is available; + # choosing it before probing the daemon would split terminal and Web + # into two independently writable app-servers for every large thread. + stdio_codex_bin = private_core or codex_bin self._http_provider_root_id = resume_id if http_only_resume else None if http_only_resume: self._http_provider_repair_stop.clear() child_env = _profile_codex_env(codex_bin, self.codex_home) - stdio_argv = [codex_bin, "app-server", "--stdio"] + stdio_argv = [stdio_codex_bin, "app-server", "--stdio"] if http_only_resume: _append_openai_http_resume_provider(stdio_argv) log.info( @@ -1774,13 +1862,18 @@ async def connect( "-c", "permissions.cc_remote_work.network.enabled=false", ]) proxy_argv: Optional[list[str]] = None - strict_shared = False - if (private_core is None and not http_only_resume and not self.work_mode - and self.daemon_mode == "auto"): + strict_shared = bool( + getattr( + self.daemon_manager, + "strict_shared_affinity", + False, + ) + ) + if not self.work_mode and self.daemon_mode == "auto": try: proxy_argv = await self.daemon_manager.proxy_args( codex_bin, child_env) - strict_shared = bool( + strict_shared = strict_shared or bool( getattr( self.daemon_manager, "strict_shared_affinity", @@ -1792,16 +1885,27 @@ async def connect( # silently severing the terminal CLI <-> Remote live channel. raise except Exception as exc: + strict_shared = strict_shared or bool( + getattr( + self.daemon_manager, + "strict_shared_affinity", + False, + ) + ) + if strict_shared: + raise log.warning( "Codex daemon preparation failed; using stdio", error_type=type(exc).__name__, ) attempts = ( - [(proxy_argv, True), (stdio_argv, False)] - if proxy_argv is not None else [(stdio_argv, False)] + [(proxy_argv, True, codex_bin), + (stdio_argv, False, stdio_codex_bin)] + if proxy_argv is not None + else [(stdio_argv, False, stdio_codex_bin)] ) if strict_shared and proxy_argv is not None: - attempts = [(proxy_argv, True)] + attempts = [(proxy_argv, True, codex_bin)] if self._daemon_proxy_established: if proxy_argv is None: raise RuntimeError( @@ -1809,15 +1913,15 @@ async def connect( # A previously shared thread must never reconnect through private # stdio. Leave the handle disconnected and let Machine retry the # shared proxy instead of manufacturing a false external-CLI lock. - attempts = [(proxy_argv, True)] + attempts = [(proxy_argv, True, codex_bin)] if control_only: if proxy_argv is None: raise RuntimeError( "shared Codex app-server proxy is unavailable" ) - attempts = [(proxy_argv, True)] + attempts = [(proxy_argv, True, codex_bin)] initialized: Any = None - for argv, daemon_proxy in attempts: + for argv, daemon_proxy, attempt_codex_bin in attempts: self._shared_resume_binding_thread_id = ( resume_id if daemon_proxy and resume_id and not fork @@ -1825,7 +1929,7 @@ async def connect( ) try: await self._open_process( - argv, codex_bin, daemon_proxy=daemon_proxy) + argv, attempt_codex_bin, daemon_proxy=daemon_proxy) initialized = await self._request( "initialize", _initialize_params()) self.app_server_version = _app_server_version(initialized) @@ -1880,6 +1984,7 @@ async def connect( ) self._work_config = _work_thread_config( skills_response, config_response) + self._remember_configured_default_effort(config_response) if fork and resume_id: # ephemeral /btw fork: inherits resume_id's context into a throwaway @@ -1892,10 +1997,14 @@ async def connect( } if self.permission_profile: fork_params["permissions"] = self.permission_profile - if not self.work_mode and self.web_search_override: - fork_params["config"] = { - "web_search": self.web_search_override, - } + code_config = _code_thread_config( + http_only_resume=http_only_resume, + web_search=( + None if self.work_mode else self.web_search_override + ), + ) + if code_config is not None: + fork_params["config"] = code_config if http_only_resume: fork_params["modelProvider"] = ( _OPENAI_HTTP_RESUME_PROVIDER_ID) @@ -1944,10 +2053,13 @@ async def connect( "config": self._work_config, "permissions": "cc_remote_work", }) - elif self.web_search_override: - resume_params["config"] = { - "web_search": self.web_search_override, - } + else: + code_config = _code_thread_config( + http_only_resume=http_only_resume, + web_search=self.web_search_override, + ) + if code_config is not None: + resume_params["config"] = code_config if _supports_lightweight_resume(self.app_server_version): # Since Codex 0.144.6, excludeTurns is the official way for # clients with a paged history UI to resume a live thread. @@ -2652,6 +2764,18 @@ def _notification_is_user_message(message: dict) -> bool: item = params.get("item") if isinstance(params, dict) else None return isinstance(item, dict) and item.get("type") == "userMessage" + @staticmethod + def _notification_is_context_compaction(message: dict) -> bool: + """Recognize the current authoritative app-server compact item.""" + if message.get("method") != "item/completed": + return False + params = message.get("params") + item = params.get("item") if isinstance(params, dict) else None + return ( + isinstance(item, dict) + and item.get("type") == "contextCompaction" + ) + def _managed_compaction_fence_current( self, fence: Optional[_CodexCompactionContinuation] = None, @@ -2674,6 +2798,7 @@ def _managed_compaction_fence_current( if ( check_deadline and fence.awaiting_replacement + and not fence.probing and fence.deadline > 0 and asyncio.get_running_loop().time() > fence.deadline ): @@ -2746,7 +2871,11 @@ async def _wait_managed_compaction_settled(self) -> None: try: await asyncio.wait_for( fence.settled.wait(), - timeout=remaining + 0.25, + timeout=( + remaining + + _COMPACTION_CONTINUATION_PROBE_TIMEOUT_SECONDS + + 0.25 + ), ) except asyncio.TimeoutError: if self._managed_compaction_continuation is fence: @@ -2761,7 +2890,58 @@ async def _expire_managed_compaction_continuation( fence.deadline - asyncio.get_running_loop().time(), ) await asyncio.sleep(delay) - if self._managed_compaction_continuation is fence: + if ( + self._managed_compaction_continuation is not fence + or not fence.awaiting_replacement + or not self._managed_compaction_fence_current( + fence, check_deadline=False) + ): + return + fence.probing = True + still_running = False + try: + still_running = await asyncio.wait_for( + self._probe_managed_compaction_continuation(fence), + timeout=( + _COMPACTION_CONTINUATION_PROBE_TIMEOUT_SECONDS + ), + ) + except asyncio.TimeoutError: + pass + except Exception as exc: + log.warning( + "Codex compaction continuation probe failed", + error_type=type(exc).__name__, + ) + finally: + fence.probing = False + + if self._managed_compaction_continuation is not fence: + return + if ( + still_running + and fence.awaiting_replacement + and self._managed_compaction_fence_current( + fence, check_deadline=False) + ): + # The official status endpoints prove that the exact native + # turn is still in progress. Drop only the compact-interrupt + # shell; the eventual real terminal remains authoritative. + fence.awaiting_replacement = False + fence.suppressed_terminal = None + fence.suppressed_size = None + fence.settled.set() + fence.expiry_task = None + self._compaction_continuation_turn_id = None + return + # ``_request`` replies are dispatched by the sole stdout reader. + # A continuation item may therefore have confirmed this fence + # while the final probe reply was being delivered. Never replay + # the previously suppressed terminal after that confirmation. + if ( + fence.awaiting_replacement + and not fence.settled.is_set() + ): await self._release_managed_compaction_continuation() except asyncio.CancelledError: return @@ -2771,6 +2951,50 @@ async def _expire_managed_compaction_continuation( error_type=type(exc).__name__, ) + async def _probe_managed_compaction_continuation( + self, fence: _CodexCompactionContinuation, + ) -> bool: + """Prove once that a compact-interrupted native turn is still active. + + This runs only in the detached expiry task. Never await these requests + from ``_dispatch``: their replies are consumed by the same stdout reader. + """ + if not self._managed_compaction_fence_current( + fence, check_deadline=False, + ): + return False + response = await self._request("thread/read", { + "threadId": fence.thread_id, + "includeTurns": False, + }) + thread = response.get("thread") if isinstance(response, dict) else None + status = thread.get("status") if isinstance(thread, dict) else None + if ( + not isinstance(thread, dict) + or thread.get("id") != fence.thread_id + or not isinstance(status, dict) + or status.get("type") != "active" + or not self._managed_compaction_fence_current( + fence, check_deadline=False) + ): + return False + page = await self._request("thread/turns/list", { + "threadId": fence.thread_id, + "cursor": None, + "limit": 1, + "sortDirection": "desc", + "itemsView": "notLoaded", + }) + turns = page.get("data") if isinstance(page, dict) else None + latest = turns[0] if isinstance(turns, list) and len(turns) == 1 else None + return bool( + isinstance(latest, dict) + and latest.get("id") == fence.native_turn_id + and latest.get("status") == "inProgress" + and self._managed_compaction_fence_current( + fence, check_deadline=False) + ) + def _arm_managed_compaction_continuation( self, native_turn_id: str, ) -> None: @@ -2820,6 +3044,7 @@ def _confirm_same_id_compaction_continuation( or not fence.awaiting_replacement or not self._managed_compaction_fence_current(fence) or _notification_turn_id(message) != fence.native_turn_id + or self._notification_is_context_compaction(message) or message.get("method") in { "turn/completed", "thread/compacted", "error", } @@ -3725,6 +3950,7 @@ def _apply_thread_settings(self, settings: dict[str, Any]) -> None: Granular approval objects are preserved in ``approval_policy`` while the current UI receives their lossless-compatible ``on-request`` projection. """ + self._thread_settings_revision += 1 cwd = settings.get("cwd") if (isinstance(cwd, str) and os.path.isabs(cwd) and 0 < len(cwd) <= 4096): @@ -3740,9 +3966,22 @@ def _apply_thread_settings(self, settings: dict[str, Any]) -> None: if effort is None: self.effort = None self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None elif isinstance(effort, str) and effort: self.effort = effort[:64] self.applied_effort = self.effort + self.display_effort = self.effort + self.display_effort_model = self.model + self.display_effort_cwd = ( + os.path.realpath(self._cwd) + if isinstance(self._cwd, str) and self._cwd else None + ) + self.display_effort_generation = self._generation + self._display_effort_retry_at = None approval = settings.get("approvalPolicy") if self.work_mode: @@ -3791,6 +4030,60 @@ def _apply_thread_settings(self, settings: dict[str, Any]) -> None: elif active_key in settings: self.permission_profile = None + def _remember_configured_default_effort( + self, + response: object, + *, + cwd: Optional[str] = None, + generation: Optional[int] = None, + ) -> Optional[str]: + if not isinstance(response, dict): + raise RuntimeError("codex config/read returned an invalid response") + config = response.get("config") + if not isinstance(config, dict): + raise RuntimeError("codex config/read returned an invalid response") + raw = config.get("model_reasoning_effort") + if raw is not None and ( + not isinstance(raw, str) or not raw or len(raw) > 64 + ): + raise RuntimeError( + "codex config/read returned an invalid reasoning effort") + resolved_cwd = os.path.realpath(cwd or self._cwd) + resolved_generation = ( + self._generation if generation is None else generation) + # config/read is asynchronous. A cwd migration or reconnect may finish + # while the old request is in flight; never label that old response as + # belonging to the new scope. The caller separately revalidates before + # using the returned value as a display projection. + if (os.path.realpath(self._cwd) == resolved_cwd + and self._generation == resolved_generation): + self._configured_default_effort = raw + self._configured_default_effort_cwd = resolved_cwd + self._configured_default_effort_generation = resolved_generation + self._configured_default_effort_read_at = time.monotonic() + self._configured_default_effort_known = True + return raw + + async def configured_default_effort(self) -> Optional[str]: + """Read the effective config fallback for a null thread override.""" + cwd = os.path.realpath(self._cwd) + generation = self._generation + if ( + self._configured_default_effort_known + and self._configured_default_effort_cwd == cwd + and self._configured_default_effort_generation == generation + and self._configured_default_effort_read_at is not None + and time.monotonic() - self._configured_default_effort_read_at + < _CONFIGURED_DEFAULT_EFFORT_CACHE_SECONDS + ): + return self._configured_default_effort + response = await self._request("config/read", { + "cwd": cwd, + "includeLayers": False, + }) + return self._remember_configured_default_effort( + response, cwd=cwd, generation=generation) + async def set_model(self, model: str) -> None: if not isinstance(model, str) or not model: raise ValueError("Codex model must be non-empty") @@ -3832,7 +4125,7 @@ async def set_cwd( ) return effective - async def set_effort(self, effort: str) -> None: + async def set_effort(self, effort: str) -> bool: if not isinstance(effort, str) or not effort: raise ValueError("Codex effort must be non-empty") authoritative = await self._update_thread_settings( @@ -3840,8 +4133,17 @@ async def set_effort(self, effort: str) -> None: if not authoritative: self.effort = effort self.applied_effort = effort + self.display_effort = effort + self.display_effort_model = self.model + self.display_effort_cwd = ( + os.path.realpath(self._cwd) + if isinstance(self._cwd, str) and self._cwd else None + ) + self.display_effort_generation = self._generation + self._display_effort_retry_at = None log.info("codex thread effort set", requested=effort, applied=self.effort) + return authoritative async def set_service_tier(self, tier: Optional[str]) -> None: normalized = tier if tier and tier != "default" else None @@ -4470,14 +4772,48 @@ async def get_context_usage(self) -> dict: # recent turn's full token count ≈ current context depth (what the codex TUI # gauges); `total` is the cumulative session sum (over-counts context). Use # `last` for the "context full?" reading, falling back to `total`. + if (self.thread_id and self.last_token_usage is None + and not self._rollout_context_recovery_attempted): + recovery_thread_id = self.thread_id + recovery_generation = self._generation + self._rollout_context_recovery_attempted = True + recovered = await asyncio.to_thread( + recover_codex_context_usage, + recovery_thread_id, + codex_home=self.codex_home, + ) + # The stdout reader may have installed a live notification while + # the bounded file read was in flight. A reconnect/resume can also + # replace the handle's thread; never install that old file sample + # into a new app-server generation or native session. + if (self.last_token_usage is None + and self.thread_id == recovery_thread_id + and self._generation == recovery_generation + and isinstance(recovered, dict)): + self.last_token_usage = recovered + window = recovered.get("modelContextWindow") + if isinstance(window, int) and not isinstance(window, bool): + self.context_window = window + elif (self.last_token_usage is None + and self.thread_id == recovery_thread_id + and self._generation == recovery_generation): + # Missing/replaced/truncated rollouts are transient while Codex + # is flushing or rotating the file. Let a later explicit + # context read retry; a successful recovery or live + # tokenUsage notification still permanently ends cold reads + # for this process generation. + self._rollout_context_recovery_attempted = False u = self.last_token_usage if isinstance(self.last_token_usage, dict) else {} last = u.get("last") if isinstance(u.get("last"), dict) else {} total = u.get("total") if isinstance(u.get("total"), dict) else {} - used = last.get("totalTokens") + used = _nonnegative_int(last.get("totalTokens")) if used is None: - used = total.get("totalTokens") + used = _nonnegative_int(total.get("totalTokens")) # server value (captured in _dispatch) wins; else the config-declared window. - win = self.context_window or u.get("modelContextWindow") or ( + win = _nonnegative_int(self.context_window) + if not win: + win = _nonnegative_int(u.get("modelContextWindow")) + win = win or ( codex_context_window() if self.codex_home is None else codex_context_window(codex_home=self.codex_home) ) @@ -5775,7 +6111,10 @@ async def _dispatch(self, m: dict, raw_size: Optional[int] = None) -> None: if method == "turn/completed" and review_execution_frame: self._review_execution_turn_id = None return - if method == "thread/compacted": + if ( + method == "thread/compacted" + or self._notification_is_context_compaction(m) + ): # This notification has already passed exact thread/turn routing. # Freeze its native owner now; a later different turn cannot inherit # the continuation right. @@ -6372,7 +6711,8 @@ def _runtime_event_key(event: RuntimeEvent) -> str: def _nonnegative_int(value: Any) -> Optional[int]: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: + if (isinstance(value, bool) or not isinstance(value, int) or value < 0 + or value > MAX_SAFE_WIRE_INTEGER): return None return value diff --git a/cc_remote/wrapper/codex_lifecycle.py b/cc_remote/wrapper/codex_lifecycle.py new file mode 100644 index 0000000..0ea0b2d --- /dev/null +++ b/cc_remote/wrapper/codex_lifecycle.py @@ -0,0 +1,640 @@ +"""Small source-bound terminal ledger for Codex History recovery. + +The official app-server owns lifecycle. Rollout History is only a content +projection and can lag a terminal notification while a large source is being +indexed. This store remembers a bounded set of exact native turn terminals so +newest-page History can carry them independently from narrative parsing. + +Persistent records are valid only while the current rollout is a strict +append-only continuation of the captured source boundary. Rotation, +truncation, rollback, inode reuse, corruption, or an unreadable witness all +degrade to an empty snapshot; none can manufacture a successful terminal. +""" +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +import hashlib +import json +import math +import os +from pathlib import Path +import re +import tempfile +import threading +import time +from typing import Callable, Iterable + +from cc_remote.protocol import CodexTerminalFence + + +_SCHEMA_VERSION = 1 +_FILENAME = "codex-terminal-ledger.json" +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$") +_MAX_FILE_BYTES = 2 * 1024 * 1024 +_MAX_SESSIONS = 512 +_MAX_FENCES_PER_SESSION = 16 +_WITNESS_BYTES = 64 * 1024 + + +class CodexTerminalLedgerError(RuntimeError): + """A lifecycle record could not be validated or persisted.""" + + +@dataclass(frozen=True) +class _SourceWitness: + path: str + device: int + inode: int + size: int + window_start: int + window_sha256: str + + +@dataclass(frozen=True) +class _PersistentSession: + source: _SourceWitness + fences: tuple[CodexTerminalFence, ...] + updated_at: float + + +@dataclass +class _VolatileSession: + revision: str + source_identity: tuple[str, int, int, int] | None + fences: OrderedDict[str, CodexTerminalFence] + + +def _safe_id(value: object) -> str: + if not isinstance(value, str) or not _SAFE_ID.fullmatch(value): + raise CodexTerminalLedgerError("invalid Codex lifecycle identity") + return value + + +def _clean_fence(value: object) -> CodexTerminalFence: + try: + fence = ( + value + if isinstance(value, CodexTerminalFence) + else CodexTerminalFence.model_validate(value) + ) + except Exception as exc: + raise CodexTerminalLedgerError( + "invalid Codex terminal fence") from exc + _safe_id(fence.turn_id) + return CodexTerminalFence.model_validate( + fence.model_dump(mode="python")) + + +def _real_path(path: str | os.PathLike[str]) -> str: + value = os.path.realpath(os.fspath(path)) + if not value or "\x00" in value: + raise CodexTerminalLedgerError("invalid Codex rollout path") + return value + + +def _capture_witness(path: str | os.PathLike[str]) -> _SourceWitness: + real = _real_path(path) + before = os.stat(real) + with open(real, "rb") as stream: + opened = os.fstat(stream.fileno()) + if ( + opened.st_dev != before.st_dev + or opened.st_ino != before.st_ino + ): + raise CodexTerminalLedgerError( + "Codex rollout rotated while capturing terminal witness") + size = int(opened.st_size) + start = max(0, size - _WITNESS_BYTES) + stream.seek(start) + boundary = stream.read(size - start) + finished = os.fstat(stream.fileno()) + after = os.stat(real) + if ( + finished.st_dev != opened.st_dev + or finished.st_ino != opened.st_ino + or finished.st_size < size + or after.st_dev != before.st_dev + or after.st_ino != before.st_ino + or after.st_size < size + or len(boundary) != size - start + ): + raise CodexTerminalLedgerError( + "Codex rollout changed while capturing terminal witness") + return _SourceWitness( + path=real, + device=int(before.st_dev), + inode=int(before.st_ino), + size=size, + window_start=start, + window_sha256=hashlib.sha256(boundary).hexdigest(), + ) + + +def _witness_matches( + witness: _SourceWitness, + path: str | os.PathLike[str], +) -> bool: + try: + real = _real_path(path) + before = os.stat(real) + if ( + real != witness.path + or int(before.st_dev) != witness.device + or int(before.st_ino) != witness.inode + or int(before.st_size) < witness.size + or witness.window_start < 0 + or witness.window_start > witness.size + or witness.size - witness.window_start > _WITNESS_BYTES + ): + return False + with open(real, "rb") as stream: + opened = os.fstat(stream.fileno()) + if ( + int(opened.st_dev) != witness.device + or int(opened.st_ino) != witness.inode + or int(opened.st_size) < witness.size + ): + return False + stream.seek(witness.window_start) + boundary = stream.read(witness.size - witness.window_start) + finished = os.fstat(stream.fileno()) + after = os.stat(real) + return ( + int(finished.st_dev) == witness.device + and int(finished.st_ino) == witness.inode + and int(finished.st_size) >= witness.size + and int(after.st_dev) == witness.device + and int(after.st_ino) == witness.inode + and int(after.st_size) >= witness.size + and len(boundary) == witness.size - witness.window_start + and hashlib.sha256(boundary).hexdigest() + == witness.window_sha256 + ) + except (OSError, CodexTerminalLedgerError): + return False + + +def _source_identity_matches( + identity: tuple[str, int, int, int], + path: str, +) -> bool: + try: + current = os.stat(path) + except OSError: + return False + identity_path, device, inode, boundary_size = identity + return ( + identity_path == path + and device == int(current.st_dev) + and inode == int(current.st_ino) + and int(current.st_size) >= boundary_size + ) + + +class CodexTerminalLedger: + """Bounded volatile + durable exact-turn terminal projection.""" + + def __init__(self, state_dir: str | Path): + self.path = Path(state_dir).expanduser() / _FILENAME + # Durable writes intentionally serialize behind their own lock because + # every update atomically replaces one bounded JSON file. Volatile + # terminal publication has a separate lock: it runs on the wrapper's + # event loop and must never wait behind a background fsync. + self._lock = threading.RLock() + self._volatile_lock = threading.RLock() + self._profile_revision = 0 + self._sessions = self._load() + self._volatile: OrderedDict[str, _VolatileSession] = OrderedDict() + + def _load(self) -> OrderedDict[str, _PersistentSession]: + sessions: OrderedDict[str, _PersistentSession] = OrderedDict() + try: + if self.path.stat().st_size > _MAX_FILE_BYTES: + raise ValueError("terminal ledger exceeds size limit") + raw_text = self.path.read_text(encoding="utf-8") + if len(raw_text.encode("utf-8", "surrogatepass")) > _MAX_FILE_BYTES: + raise ValueError("terminal ledger exceeds size limit") + raw = json.loads(raw_text) + if ( + not isinstance(raw, dict) + or raw.get("version") != _SCHEMA_VERSION + or not isinstance(raw.get("sessions"), dict) + or len(raw["sessions"]) > _MAX_SESSIONS + ): + raise ValueError("terminal ledger has an invalid shape") + profile_revision = raw.get("profile_revision", 0) + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 0 + ): + raise ValueError("terminal ledger profile revision is invalid") + self._profile_revision = profile_revision + for session_id, value in raw["sessions"].items(): + session_id = _safe_id(session_id) + sessions[session_id] = self._decode_session(value) + except FileNotFoundError: + pass + except Exception: + # This is a rebuildable projection. A malformed file is never + # partially trusted and must not keep the wrapper from starting. + sessions.clear() + self._profile_revision = 0 + return sessions + + @staticmethod + def _decode_session(value: object) -> _PersistentSession: + if not isinstance(value, dict) or set(value) != { + "source", "fences", "updated_at", + }: + raise ValueError("invalid terminal session") + source = value["source"] + if not isinstance(source, dict) or set(source) != { + "path", "device", "inode", "size", "window_start", + "window_sha256", + }: + raise ValueError("invalid terminal source witness") + path = source.get("path") + digest = source.get("window_sha256") + integers = [ + source.get("device"), source.get("inode"), source.get("size"), + source.get("window_start"), + ] + if ( + not isinstance(path, str) + or not os.path.isabs(path) + or "\x00" in path + or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", digest) + or any(isinstance(item, bool) or not isinstance(item, int) + or item < 0 for item in integers) + or source["window_start"] > source["size"] + or source["size"] - source["window_start"] > _WITNESS_BYTES + ): + raise ValueError("invalid terminal source witness") + raw_fences = value.get("fences") + if ( + not isinstance(raw_fences, list) + or len(raw_fences) > _MAX_FENCES_PER_SESSION + ): + raise ValueError("invalid terminal fence list") + fences: OrderedDict[str, CodexTerminalFence] = OrderedDict() + for raw_fence in raw_fences: + fence = _clean_fence(raw_fence) + fences.pop(fence.turn_id, None) + fences[fence.turn_id] = fence + updated_at = value.get("updated_at") + if ( + isinstance(updated_at, bool) + or not isinstance(updated_at, (int, float)) + or not math.isfinite(updated_at) + or updated_at < 0 + ): + raise ValueError("invalid terminal timestamp") + return _PersistentSession( + source=_SourceWitness( + path=path, + device=source["device"], + inode=source["inode"], + size=source["size"], + window_start=source["window_start"], + window_sha256=digest, + ), + fences=tuple(fences.values()), + updated_at=float(updated_at), + ) + + @staticmethod + def _encode_session(value: _PersistentSession) -> dict[str, object]: + return { + "source": { + "path": value.source.path, + "device": value.source.device, + "inode": value.source.inode, + "size": value.source.size, + "window_start": value.source.window_start, + "window_sha256": value.source.window_sha256, + }, + "fences": [ + fence.model_dump(mode="json", exclude_none=True) + for fence in value.fences + ], + "updated_at": value.updated_at, + } + + def _persist( + self, + sessions: OrderedDict[str, _PersistentSession], + *, + profile_revision: int | None = None, + ) -> None: + revision = ( + self._profile_revision + if profile_revision is None else profile_revision + ) + payload = json.dumps({ + "version": _SCHEMA_VERSION, + "profile_revision": revision, + "sessions": { + session_id: self._encode_session(value) + for session_id, value in sessions.items() + }, + }, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(payload) > _MAX_FILE_BYTES: + raise CodexTerminalLedgerError( + "Codex terminal ledger exceeds size limit") + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp( + prefix=f".{self.path.name}.", dir=self.path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + try: + directory_fd = os.open(self.path.parent, os.O_RDONLY) + except OSError: + directory_fd = None + if directory_fd is not None: + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(temporary) + except OSError: + pass + raise + + @staticmethod + def _bounded_fences( + values: Iterable[CodexTerminalFence], + ) -> tuple[CodexTerminalFence, ...]: + bounded: OrderedDict[str, CodexTerminalFence] = OrderedDict() + for raw in values: + fence = _clean_fence(raw) + bounded.pop(fence.turn_id, None) + bounded[fence.turn_id] = fence + while len(bounded) > _MAX_FENCES_PER_SESSION: + bounded.popitem(last=False) + return tuple(bounded.values()) + + def remember( + self, + session_id: str, + fence: CodexTerminalFence, + *, + revision: str, + source_identity: tuple[str, int, int, int] | None = None, + ) -> None: + """Publish a zero-I/O process-local fence before History can race it.""" + session_id = _safe_id(session_id) + revision = _safe_id(revision) + fence = _clean_fence(fence) + identity: tuple[str, int, int, int] | None = None + if source_identity is not None: + raw_path, device, inode, size = source_identity + if ( + isinstance(device, bool) + or not isinstance(device, int) + or device < 0 + or isinstance(inode, bool) + or not isinstance(inode, int) + or inode < 0 + or isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + ): + raise CodexTerminalLedgerError( + "invalid volatile source identity") + identity = (_real_path(raw_path), device, inode, size) + with self._volatile_lock: + current = self._volatile.get(session_id) + source_changed = False + if current is not None: + previous_identity = current.source_identity + if (previous_identity is None) != (identity is None): + source_changed = True + elif previous_identity is not None and identity is not None: + source_changed = ( + previous_identity[:3] != identity[:3] + or identity[3] < previous_identity[3] + ) + if ( + current is None + or current.revision != revision + or source_changed + ): + current = _VolatileSession( + revision=revision, + source_identity=identity, + fences=OrderedDict(), + ) + self._volatile[session_id] = current + elif identity is not None: + # An append-only continuation advances the minimum source + # boundary without discarding earlier exact-turn fences. + current.source_identity = identity + current.fences.pop(fence.turn_id, None) + current.fences[fence.turn_id] = fence + while len(current.fences) > _MAX_FENCES_PER_SESSION: + current.fences.popitem(last=False) + self._volatile.move_to_end(session_id) + while len(self._volatile) > _MAX_SESSIONS: + self._volatile.popitem(last=False) + + def rebase_revision( + self, + session_id: str, + *, + previous_revision: str, + revision: str, + ) -> bool: + """Carry a source-bound volatile fence across a read-side revision. + + Switching History projection families or learning an exact display + alias advances the browser revision without mutating the rollout. A + cold/live fence which is already bound to that rollout must remain + immediately visible. Unbound fences deliberately stay revision-local; + destructive invalidation never calls this method. + """ + session_id = _safe_id(session_id) + previous_revision = _safe_id(previous_revision) + revision = _safe_id(revision) + with self._volatile_lock: + current = self._volatile.get(session_id) + if ( + current is None + or current.revision != previous_revision + or current.source_identity is None + ): + return False + current.revision = revision + self._volatile.move_to_end(session_id) + return True + + def persist( + self, + session_id: str, + fence: CodexTerminalFence, + source_path: str | os.PathLike[str], + *, + expected_source_identity: tuple[str, int, int, int] | None = None, + ) -> None: + """Capture one bounded append witness and atomically persist the fence.""" + session_id = _safe_id(session_id) + fence = _clean_fence(fence) + witness = _capture_witness(source_path) + if expected_source_identity is not None: + raw_path, device, inode, size = expected_source_identity + if ( + isinstance(device, bool) + or not isinstance(device, int) + or device < 0 + or isinstance(inode, bool) + or not isinstance(inode, int) + or inode < 0 + or isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + or (_real_path(raw_path), device, inode) != ( + witness.path, witness.device, witness.inode) + or witness.size < size + ): + raise CodexTerminalLedgerError( + "Codex rollout source changed before terminal persistence") + with self._lock: + if not _witness_matches(witness, source_path): + raise CodexTerminalLedgerError( + "Codex rollout source changed before terminal persistence") + existing = self._sessions.get(session_id) + existing_matches = bool( + existing is not None + and existing.source.path == witness.path + and existing.source.device == witness.device + and existing.source.inode == witness.inode + and _witness_matches(existing.source, witness.path) + ) + previous = ( + existing.fences + if existing is not None and existing_matches + else () + ) + stored_witness = ( + existing.source + if existing is not None + and existing_matches + and existing.source.size >= witness.size + else witness + ) + incoming_is_older = bool( + existing is not None + and existing_matches + and witness.size < existing.source.size + ) + fences = self._bounded_fences( + (fence, *previous) + if incoming_is_older else (*previous, fence) + ) + updated = OrderedDict(self._sessions) + updated.pop(session_id, None) + updated[session_id] = _PersistentSession( + source=stored_witness, + fences=fences, + updated_at=time.time(), + ) + while len(updated) > _MAX_SESSIONS: + updated.popitem(last=False) + self._persist(updated) + self._sessions = updated + + def snapshot( + self, + session_id: str, + source_path: str | os.PathLike[str] | None, + *, + revision: str, + ) -> tuple[CodexTerminalFence, ...]: + """Return only exact fences still bound to this revision/source.""" + session_id = _safe_id(session_id) + revision = _safe_id(revision) + real: str | None = None + if source_path is not None: + try: + real = _real_path(source_path) + except (OSError, CodexTerminalLedgerError): + real = None + merged: OrderedDict[str, CodexTerminalFence] = OrderedDict() + with self._lock: + persistent = self._sessions.get(session_id) + if ( + persistent is not None + and real is not None + and _witness_matches(persistent.source, real) + ): + for fence in persistent.fences: + merged[fence.turn_id] = fence + with self._volatile_lock: + volatile = self._volatile.get(session_id) + volatile_matches_source = bool( + volatile is not None + and ( + volatile.source_identity is None + or ( + real is not None + and _source_identity_matches( + volatile.source_identity, real) + ) + ) + ) + if ( + volatile is not None + and volatile.revision == revision + and volatile_matches_source + ): + for fence in volatile.fences.values(): + merged.pop(fence.turn_id, None) + merged[fence.turn_id] = fence + while len(merged) > _MAX_FENCES_PER_SESSION: + merged.popitem(last=False) + return tuple(merged.values()) + + def migrate_profile_sessions( + self, + transform: Callable[[str], str], + *, + profile_revision: int, + ) -> int: + """Replay-safely migrate routed session keys across profile topology.""" + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 1 + ): + raise CodexTerminalLedgerError( + "invalid Codex profile revision") + with self._lock: + if self._profile_revision >= profile_revision: + return 0 + updated: OrderedDict[str, _PersistentSession] = OrderedDict() + migrated = 0 + for session_id, value in self._sessions.items(): + target = _safe_id(transform(session_id)) + previous = updated.get(target) + if previous is not None and previous != value: + raise CodexTerminalLedgerError( + "Codex terminal profile migration collides") + updated[target] = value + migrated += target != session_id + self._persist(updated, profile_revision=profile_revision) + self._sessions = updated + self._profile_revision = profile_revision + return migrated diff --git a/cc_remote/wrapper/codex_models.py b/cc_remote/wrapper/codex_models.py index 88f609c..9a8e2f0 100644 --- a/cc_remote/wrapper/codex_models.py +++ b/cc_remote/wrapper/codex_models.py @@ -46,6 +46,10 @@ # Cost/latency order, low -> high. Used only to clamp an unsupported request DOWN # to something the model accepts; unknown levels sort last so they never win. EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] +# Wire/display sentinel used only when app-server explicitly selected the +# model's default but model/list is temporarily unavailable. It is never sent +# back to turn/start as a reasoning effort. +MODEL_DEFAULT_EFFORT = "model-default" def _rank(effort: str) -> int: @@ -192,6 +196,20 @@ async def efforts_for( return [] +async def default_effort_for( + model: Optional[str], + *, + codex_home: str | None = None, +) -> Optional[str]: + if not model: + return None + for candidate in await codex_catalog(codex_home=codex_home): + if candidate["id"] == model: + value = candidate.get("default_effort") + return value if isinstance(value, str) and value else None + return None + + async def clamp_effort( model: Optional[str], effort: Optional[str], *, diff --git a/cc_remote/wrapper/codex_sessions.py b/cc_remote/wrapper/codex_sessions.py index c23dd28..3946c18 100644 --- a/cc_remote/wrapper/codex_sessions.py +++ b/cc_remote/wrapper/codex_sessions.py @@ -11,8 +11,10 @@ import json import math import os +from pathlib import Path import re -from typing import Any, Optional +import sqlite3 +from typing import Any, Iterable, Optional from cc_remote.log import logger from cc_remote.wrapper.codex_rpc import codex_rpc @@ -41,6 +43,34 @@ def _load_tomllib(): _LIST_MAX_PER_ARCHIVE_STATE = 200 _LIST_MAX_PAGES = 20 _THREAD_STATUSES = frozenset({"notLoaded", "idle", "systemError", "active"}) +_STATE_DB = re.compile(r"^state_(\d+)\.sqlite$") +CODEX_EXACT_CATALOG_MAX_IDS = 512 +_EXACT_CATALOG_COLUMNS = ( + "id", + "cwd", + "name", + "preview", + "first_user_message", + "title", + "recency_at", + "recency_at_ms", + "updated_at", + "updated_at_ms", + "created_at", + "created_at_ms", + "git_branch", + "archived", + "model_provider", +) +_EXACT_CATALOG_TEXT_LIMITS = { + "cwd": 4096, + "name": 500, + "preview": 2000, + "first_user_message": 2000, + "title": 2000, + "git_branch": 500, + "model_provider": 256, +} def _codex_home(codex_home: str | os.PathLike[str] | None = None) -> str: @@ -169,6 +199,17 @@ def _normalize_thread(thread: Any, *, archived: bool) -> Optional[dict[str, Any] } +def codex_thread_catalog_row(thread: Any) -> Optional[dict[str, Any]]: + """Normalize one profile-scoped ``thread/read`` result for the sidebar. + + The caller is responsible for selecting the matching ``CODEX_HOME`` before + obtaining ``thread``. This helper deliberately performs no cross-account + lookup and accepts only the same bounded fields as ``thread/list``. + """ + archived = isinstance(thread, dict) and thread.get("archived") is True + return _normalize_thread(thread, archived=archived) + + def _bounded_text(value: Any, limit: int) -> Optional[str]: if not isinstance(value, str): return None @@ -213,8 +254,211 @@ def codex_rollout_path( ) +def _state_db_path( + codex_home: str | os.PathLike[str] | None = None, +) -> Optional[str]: + """Resolve the newest app-server state DB without opening it writable.""" + home = _codex_home(codex_home) + config_path = os.path.join(home, "config.toml") + try: + if os.path.getsize(config_path) > _CONFIG_MAX_BYTES: + return None + with open(config_path, "rb") as stream: + config = tomllib.load(stream) + except FileNotFoundError: + config = {} + except Exception: + return None + sqlite_home = config.get("sqlite_home") + if sqlite_home is None: + sqlite_root = home + elif isinstance(sqlite_home, str) and sqlite_home.strip(): + sqlite_root = os.path.expanduser(sqlite_home) + if not os.path.isabs(sqlite_root): + sqlite_root = os.path.join(home, sqlite_root) + sqlite_root = os.path.realpath(sqlite_root) + else: + return None + try: + candidates = [ + (int(match.group(1)), os.path.join(sqlite_root, entry.name)) + for entry in os.scandir(sqlite_root) + if entry.is_file(follow_symlinks=False) + and (match := _STATE_DB.fullmatch(entry.name)) is not None + ] + except OSError: + return None + if not candidates: + return None + return max(candidates, key=lambda item: item[0])[1] + + +def codex_session_presence( + session_id: str, + *, + codex_home: str | os.PathLike[str] | None = None, +) -> bool | None: + """Read one exact native thread id without collapsing I/O failure. + + The app-server SQLite catalog is authoritative for active and archived + threads. ``None`` means ownership is unknown and callers must not infer a + different engine from absence. + """ + if not isinstance(session_id, str) or not _SAFE_SESSION_ID.fullmatch( + session_id + ): + return None + db_path = _state_db_path(codex_home) + if db_path is None: + return None + try: + uri = f"{Path(db_path).resolve().as_uri()}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=1.0) as connection: + row = connection.execute( + "SELECT 1 FROM threads WHERE id=? LIMIT 1", (session_id,) + ).fetchone() + except (OSError, sqlite3.Error): + return None + return row is not None + + +def codex_exact_catalog_rows( + session_ids: Iterable[str], + *, + codex_home: str | os.PathLike[str] | None = None, +) -> Optional[list[dict[str, Any]]]: + """Read bounded, exact sidebar rows which ``thread/list`` omitted. + + Recent app-server builds can keep a real thread addressable through + ``thread/read`` while hiding it from ``thread/list`` until its preview is + materialized. New cc-remote sessions and persistent forks must not vanish + during that window. This helper does not scan or merge accounts: callers + provide native ids for one already-selected ``CODEX_HOME``, and the query + is additionally filtered to that home's configured model provider. + + ``None`` preserves read uncertainty; an empty list proves that none of the + exact ids belongs to the selected catalog. + """ + unique_ids: list[str] = [] + seen: set[str] = set() + for value in session_ids: + if ( + not isinstance(value, str) + or not _SAFE_SESSION_ID.fullmatch(value) + or value in seen + ): + continue + seen.add(value) + unique_ids.append(value) + if len(unique_ids) >= CODEX_EXACT_CATALOG_MAX_IDS: + break + if not unique_ids: + return [] + + db_path = _state_db_path(codex_home) + if db_path is None: + return None + provider = codex_current_provider(codex_home=codex_home).strip() + try: + uri = f"{Path(db_path).resolve().as_uri()}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=1.0) as connection: + connection.row_factory = sqlite3.Row + schema = { + row["name"] + for row in connection.execute('PRAGMA table_info("threads")') + if isinstance(row["name"], str) + } + if "id" not in schema: + return None + if provider and "model_provider" not in schema: + # Older Codex schemas predate provider ownership metadata. The + # id may exist, but this SQLite snapshot cannot prove it belongs + # to the configured provider, so absence is not authoritative. + return None + projections = [] + for name in _EXACT_CATALOG_COLUMNS: + if name not in schema: + continue + limit = _EXACT_CATALOG_TEXT_LIMITS.get(name) + projections.append( + f'substr("{name}", 1, {limit}) AS "{name}"' + if limit is not None else f'"{name}"' + ) + placeholders = ",".join("?" for _ in unique_ids) + predicates = [f'"id" IN ({placeholders})'] + parameters = list(unique_ids) + if provider: + predicates.append('"model_provider" = ?') + parameters.append(provider) + records = connection.execute( + f"SELECT {','.join(projections)} FROM \"threads\" " + f"WHERE {' AND '.join(predicates)}", + parameters, + ).fetchall() + except (OSError, sqlite3.Error): + return None + + rows: list[dict[str, Any]] = [] + for raw in records: + record = dict(raw) + session_id = record.get("id") + if ( + not isinstance(session_id, str) + or not _SAFE_SESSION_ID.fullmatch(session_id) + ): + continue + timestamps: list[float] = [] + for name, scale in ( + ("recency_at", 1), + ("updated_at", 1), + ("created_at", 1), + ("recency_at_ms", 1000), + ("updated_at_ms", 1000), + ("created_at_ms", 1000), + ): + value = record.get(name) + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and value >= 0 + ): + timestamps.append(value / scale) + modified = max(timestamps, default=None) + modified_text = ( + str(int(modified)) + if modified is not None and modified.is_integer() + else str(modified) if modified is not None else None + ) + preview = next(( + value for value in ( + record.get("preview"), + record.get("first_user_message"), + record.get("title"), + ) + if isinstance(value, str) and value + ), None) + archived = bool(record.get("archived")) + rows.append({ + "session_id": session_id, + "summary": _bounded_text(record.get("name"), 500), + "first_prompt": _bounded_text(preview, 2000), + "cwd": _bounded_text(record.get("cwd"), 4096), + "last_modified": modified_text, + "git_branch": _bounded_text(record.get("git_branch"), 500), + "forked_from_id": None, + "status": None, + "tag": "archived" if archived else None, + }) + rows.sort( + key=lambda item: _updated_sort_key(item.get("last_modified")), + reverse=True, + ) + return rows + + def codex_model( - default: str = "gpt-5-codex", + default: str = "", *, codex_home: str | os.PathLike[str] | None = None, ) -> str: @@ -224,7 +468,7 @@ def codex_model( def codex_effort( - default: str = "high", + default: str = "", *, codex_home: str | os.PathLike[str] | None = None, ) -> str: diff --git a/cc_remote/wrapper/codex_stream.py b/cc_remote/wrapper/codex_stream.py index 21920f0..605dd1a 100644 --- a/cc_remote/wrapper/codex_stream.py +++ b/cc_remote/wrapper/codex_stream.py @@ -1203,13 +1203,17 @@ def codex_history_turn_user( turn_id: str, cursor: str, user_index: int = 0, + *, + max_reverse_scan_bytes: int | None = None, ) -> UserMsg | None: """Recover one visible user row for one native Codex turn. Official summary items retain expired ``localImage`` paths rather than the inline image bytes persisted in the rollout. Locate only the requested native turn boundary, then reuse the bounded forward reader above so image - thumbnails remain available without translating the whole rollout. + thumbnails remain available without translating the whole rollout. Live + recovery may bound the reverse search to the recent tail; history detail + reads retain the existing unbounded exact-turn lookup by default. """ if ( not isinstance(turn_id, str) @@ -1222,7 +1226,10 @@ def codex_history_turn_user( ): return None try: - for offset, line in _reverse_jsonl_records(path): + for offset, line in _reverse_jsonl_records( + path, + max_scan_bytes=max_reverse_scan_bytes, + ): if _history_turn_cursor(line) == turn_id: return codex_history_boundary_user( path, offset, cursor, user_index=user_index) @@ -1513,7 +1520,19 @@ def __init__(self, tool_result_max: int): self._live_items_truncated = False self._turn_closed = False - def feed(self, msg: dict) -> list: + def feed( + self, + msg: dict, + *, + authoritative_terminal: bool = True, + ) -> list: + """Translate one app-server notification. + + ``authoritative_terminal=False`` is reserved for the few callers which + feed a locally synthesized ``turn/completed`` only to close translator + blocks. Its TurnEnd remains useful on the live wire, but cannot be + persisted as an engine-owned lifecycle fact. + """ method = msg.get("method") p = msg.get("params") if isinstance(msg.get("params"), dict) else {} out: list = [] @@ -2032,12 +2051,14 @@ def feed(self, msg: dict) -> list: else "error_during_execution" if st == "interrupted" else "error") completed_turn_id = turn.get("id") - out.append(TurnEnd(result=TurnResult( + terminal = TurnEnd(result=TurnResult( subtype=subtype, duration_ms=int(turn.get("durationMs") or 0), is_error=(st != "completed"), ), turn_id=(completed_turn_id - if isinstance(completed_turn_id, str) else None))) + if isinstance(completed_turn_id, str) else None)) + terminal._codex_authoritative_terminal = authoritative_terminal + out.append(terminal) self._clear_all_delta_budgets() self._completed_plan = None self._turn_closed = True @@ -3027,6 +3048,7 @@ def codex_translate_history( end_offset: int | None = None, source_continuation: str | None = None, snapshot_in_progress: bool = False, + active_task_ids: set[str] | frozenset[str] | tuple[str, ...] = (), client_message_ids: dict[str, str] | None = None, segment_client_message_ids: dict[tuple[str, int], str] | None = None, ) -> tuple[list, str | None]: @@ -3074,6 +3096,10 @@ def codex_translate_history( # native task/start marker. A window which begins mid-turn must not mistake # its first visible steer for segment zero. task_segment_index: int | None = None + known_active_task_ids = { + value for value in active_task_ids + if isinstance(value, str) and _SAFE_WIRE_ID.fullmatch(value) + } native_client_aliases = client_message_ids or {} segment_client_aliases = segment_client_message_ids or {} seen_tool_uses: set[str] = set() @@ -3657,6 +3683,13 @@ def close_turn( # the Web projection stores one user prompt per turn, # but it must be a neutral non-error boundary. steered_same_task = task_has_user and turn_has_user + active_mid_task_steer = bool( + snapshot_in_progress + and turn_open + and not turn_has_user + and isinstance(next_turn_id, str) + and next_turn_id in known_active_task_ids + ) # No terminal record proved where the previous visible # reply ended. In particular, pending_turn_id now often # belongs to this NEW user turn; never attach it to the @@ -3671,7 +3704,7 @@ def close_turn( "authoritative_page", } ) - if steered_same_task: + if steered_same_task or active_mid_task_steer: close_turn( "steered", 0, False, authoritative_boundary=False) diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py index 7f4e311..a9c3736 100644 --- a/cc_remote/wrapper/history_store.py +++ b/cc_remote/wrapper/history_store.py @@ -30,8 +30,10 @@ # v17 also discards Codex pages whose legacy rollout user rows were materialized -# without the adjacent native app-server item id used by the live stream. -_SCHEMA_VERSION = 17 +# without the adjacent native app-server item id used by the live stream. v18 +# rebuilds page projections once: older summary pages could be truncated against +# source-complete event size before lightweight turns were materialized. +_SCHEMA_VERSION = 18 _FINGERPRINT_SAMPLE_BYTES = 64 * 1024 _DEFAULT_MAX_ENTRIES = 128 _DEFAULT_MAX_BYTES = 64 * 1024 * 1024 @@ -206,6 +208,10 @@ class MaterializedHistoryPage: # fingerprint cached during a turn cannot close it (or remain open) after # ResultMessage changes state without adding another JSONL row. in_progress: bool | None = None + # Codex can resume one persisted rollout task under a different control + # user-item id without appending another source record first. Bind a newest- + # page projection to the complete source-proven active task set. + active_task_ids: tuple[str, ...] = () def as_payload(self) -> dict[str, Any]: return { @@ -215,6 +221,7 @@ def as_payload(self) -> dict[str, Any]: "newest_id": self.newest_id, "turns": list(self.turns), "in_progress": self.in_progress, + "active_task_ids": list(self.active_task_ids), } def semantic_token(self) -> str: @@ -237,6 +244,7 @@ def semantic_token(self) -> str: "newest_id": self.newest_id, "turns": list(self.turns), "in_progress": self.in_progress, + "active_task_ids": list(self.active_task_ids), } encoded = json.dumps( payload, ensure_ascii=False, sort_keys=True, @@ -262,6 +270,11 @@ def from_payload(cls, payload: dict[str, Any]) -> "MaterializedHistoryPage": raw_in_progress = payload.get("in_progress") if raw_in_progress is not None and not isinstance(raw_in_progress, bool): raise ValueError("invalid materialized history lifecycle") + raw_active_task_ids = payload.get("active_task_ids", []) + if not isinstance(raw_active_task_ids, (list, tuple)) or not all( + isinstance(value, str) and value + for value in raw_active_task_ids): + raise ValueError("invalid materialized history active tasks") # The SQLite projection is rebuildable, but can outlive a wire-schema # change. Validate its cached summary at this single boundary so an # obsolete field is never served to the client; callers invalidate the @@ -283,6 +296,7 @@ def from_payload(cls, payload: dict[str, Any]) -> "MaterializedHistoryPage": if isinstance(payload.get("newest_id"), str) else None), turns=tuple(normalized_turns), in_progress=raw_in_progress, + active_task_ids=tuple(sorted(set(raw_active_task_ids))), ) @@ -455,6 +469,7 @@ def materialize_history_turns( fork_point = None checkpoint_id = None done = False + steered = False interrupted = False error = None channels: dict[str, str] = {} @@ -552,6 +567,7 @@ def add_live_text(message_id: str, channel: str) -> dict[str, Any] | None: result = event.get("result") if isinstance(result, dict): subtype = str(result.get("subtype") or "") + steered = subtype == "steered" if ( subtype != "steered" and isinstance(result.get("duration_ms"), int) @@ -750,6 +766,12 @@ def add_live_text(message_id: str, channel: str) -> dict[str, Any] | None: for block in candidates: if block.get("done"): continue + if ( + steered + and block.get("kind") == "process" + and block.get("processKind") == "plan" + ): + continue block["done"] = True if block.get("kind") == "process": block["phase"] = "end" @@ -904,6 +926,11 @@ def _ensure_schema(self) -> None: ): connection.execute( f"DELETE FROM {table} WHERE engine='codex'") + elif current == 17: + # Page payloads are rebuildable and may contain the old + # pre-summary size truncation. Source-complete turn details and + # image assets are independently fingerprinted and remain valid. + connection.execute("DELETE FROM history_pages") elif current not in (0, _SCHEMA_VERSION): # v9 changes the invariant of history_turn_details: those rows # must contain the source-complete translated turn, never the diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index ff7834a..0baa8b4 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -58,7 +58,7 @@ from dataclasses import dataclass from pathlib import Path from uuid import uuid4 -from typing import Optional +from typing import Literal, Optional from claude_agent_sdk import ( PermissionResultAllow, PermissionResultDeny, delete_session, @@ -106,7 +106,7 @@ BtwOpened, ContextReport, StatusReport, Notice, RateLimitUpdate, DiffReport, FilePreview, FileSaveResult, PreviewAsset, PreviewAuthorizationRequired, PreviewAuthorizationResult, - ConversationTurn, History, TurnDetail, HistoryImage, + ConversationTurn, CodexTerminalFence, History, TurnDetail, HistoryImage, HistoryInvalidated, ArtifactInvalidated, AskUser, AskUserClosed, GoalState, CompletionState, ReplayStart, ReplayEnd, Snapshot, StateEvent, State, TakeoverState, SessionControl, @@ -205,7 +205,7 @@ transcript_compact_history_page, recover_claude_delayed_retry_tail, transcript_internal_user_events, - transcript_timestamps, transcript_path, + transcript_timestamps, transcript_path, transcript_presence, translate_subagent_history, merge_subagent_history, ) from cc_remote.wrapper.codex_handle import ( @@ -216,6 +216,7 @@ CodexSteerUserIdentityProof, ) from cc_remote.wrapper.codex_turn_leases import CodexTurnLeaseStore +from cc_remote.wrapper.codex_lifecycle import CodexTerminalLedger from cc_remote.wrapper.codex_client_messages import ( CodexClientMessageAliases, CodexClientMessageStore, @@ -223,7 +224,8 @@ ) from cc_remote.wrapper.codex_permissions import codex_permission_profiles from cc_remote.wrapper.codex_stream import ( - CodexHistoryImageView, CodexHistoryNativeWitness, CodexStreamTranslator, + CodexHistoryImageView, CodexHistoryNativeWitness, CodexLiveUserMessage, + CodexStreamTranslator, coalesce_codex_live_notifications, codex_live_user_message, codex_rollout_task_bindings, @@ -242,12 +244,20 @@ CodexOfficialHistory, ) from cc_remote.wrapper.codex_sessions import ( - list_codex_sessions, codex_session_cwd, codex_rollout_path, codex_model, - codex_session_settings, + CODEX_EXACT_CATALOG_MAX_IDS, + list_codex_sessions, codex_exact_catalog_rows, codex_session_cwd, + codex_rollout_path, codex_model, codex_effort, codex_session_settings, + codex_session_presence, codex_current_provider, + codex_thread_catalog_row, +) +from cc_remote.wrapper.codex_models import ( + MODEL_DEFAULT_EFFORT, + clamp_effort, + codex_catalog, + default_effort_for, ) -from cc_remote.wrapper.codex_models import codex_catalog, clamp_effort from cc_remote.wrapper.codex_rpc import ( - CodexRpcOutcomeUnknown, CodexRpcRejected, codex_rpc, + CodexRpcOutcomeUnknown, CodexRpcRejected, codex_rpc, codex_rpc_batch, ) from cc_remote.wrapper.engine_capabilities import ( engine_capabilities, manage_engine_plugin, manage_engine_skill, @@ -283,7 +293,8 @@ classify_claude_growth, ) from cc_remote.wrapper.codex_external import ( - CodexTuiLogTracker, HolderScan, codex_app_server_client_socket, + CodexTuiLogTracker, HolderScan, RolloutTerminalMarker, + codex_app_server_client_socket, parse_turn_markers, writable_rollout_holders, ) @@ -309,6 +320,8 @@ CODEX_PERMISSION_MODES = frozenset({"never", "on-request", "untrusted"}) CODEX_COLLABORATION_MODES = frozenset({"default", "plan"}) CODEX_FAST_SERVICE_TIERS = frozenset({"fast", "priority"}) +CODEX_EFFORT_RESOLVE_TIMEOUT_SECONDS = 1.0 +CODEX_EFFORT_RESOLVE_RETRY_SECONDS = 30.0 _CLAUDE_OPUS_5_1M_ALIASES = frozenset({ "opus", "opus[1m]", @@ -317,6 +330,48 @@ }) +def _quarantine_rebuildable_projection( + path: Path, + *, + projection: str, +) -> bool: + """Move one unsafe display cache aside so it can rebuild from native state.""" + quarantine = path.with_name(f"{path.name}.corrupt-{uuid4().hex}") + try: + os.replace(path, quarantine) + except FileNotFoundError: + # A concurrent cleanup already removed the rejected path. Retrying the + # constructor is equivalent to rebuilding an empty projection. + return True + except OSError as exc: + log.warning( + "rebuildable projection quarantine failed", + projection=projection, + error_type=type(exc).__name__, + ) + return False + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError as exc: + # The rename already made the unsafe projection unreachable. A failed + # directory fsync affects crash durability, not this process's safety. + log.warning( + "rebuildable projection quarantine fsync failed", + projection=projection, + error_type=type(exc).__name__, + ) + log.warning( + "rebuildable projection quarantined", + projection=projection, + quarantine_path=str(quarantine), + ) + return True + + def _normalize_claude_new_session_model(model: Optional[str]) -> Optional[str]: """Pin only fresh-session Opus 5 aliases to the explicit 1M model.""" if model is None: @@ -361,11 +416,13 @@ def _codex_success_terminal(message: dict, fallback_turn_id: str) -> TurnEnd: duration_ms = max(0, int(duration or 0)) except (TypeError, ValueError): duration_ms = 0 - return TurnEnd( + terminal = TurnEnd( result=TurnResult( subtype="success", duration_ms=duration_ms, is_error=False), turn_id=turn_id, ) + terminal._codex_authoritative_terminal = True + return terminal def _codex_user_message_identity( @@ -966,24 +1023,133 @@ def _session_model(ctx: SessionContext) -> Optional[str]: def _session_effort(ctx: SessionContext) -> Optional[str]: - """Return the live engine's desired reasoning strength, if known.""" - value = getattr(ctx.sdk, "effort", None) or ctx.announced_effort + """Return the live engine's effective/display reasoning strength.""" + explicit = getattr(ctx.sdk, "effort", None) + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip() + if ctx.engine == "codex" and hasattr(ctx.sdk, "effort"): + display = getattr(ctx.sdk, "display_effort", None) + model = _session_model(ctx) + raw_cwd = getattr(ctx.sdk, "_cwd", None) or ctx.cwd + cwd = ( + os.path.realpath(raw_cwd) + if isinstance(raw_cwd, str) and raw_cwd else None + ) + if ( + isinstance(display, str) + and display.strip() + and getattr(ctx.sdk, "display_effort_model", None) == model + and getattr(ctx.sdk, "display_effort_cwd", None) == cwd + and getattr(ctx.sdk, "display_effort_generation", None) + == getattr(ctx.sdk, "_generation", None) + ): + return display.strip() + # A normal Codex handle with an invalidated display projection must not + # fall back to the previously announced value from another cwd/process. + return None + value = getattr(ctx.sdk, "display_effort", None) or ctx.announced_effort return value.strip() if isinstance(value, str) and value.strip() else None -def _codex_compaction_continuation_ids( +def _history_control_rows( + sid: str, ctx: Optional[SessionContext], -) -> list[str]: - """Return only handle-proven compact continuation ids for History.""" + *, + fallback_model: Optional[str] = None, + allow_fallback_model: bool = False, +) -> list[dict]: + """Return the one authoritative model/effort pair for a newest page. + + Transcript control rows describe an earlier point in the conversation. A + resident engine (including an ephemeral BTW fork) instead owns the current + settings readout. Build that pair once at the History envelope boundary so + cached and freshly translated pages cannot order an old row after it and + silently roll the browser back on refresh. + """ + model = _session_model(ctx) if ctx is not None else None + if ( + model is None + and allow_fallback_model + and isinstance(fallback_model, str) + and fallback_model.strip() + ): + model = fallback_model.strip() + effort = _session_effort(ctx) if ctx is not None else None + if ctx is not None and ctx.engine == "codex" and not effort: + # A null app-server thread override is a real model-default state. It + # must replace any stale rollout effort instead of leaving the browser + # on its previously painted concrete value. + effort = MODEL_DEFAULT_EFFORT + + rows: list[dict] = [] + if model: + rows.append(Model(model=model, sid=sid).model_dump(mode="json")) + if effort: + rows.append(Effort(effort=effort, sid=sid).model_dump(mode="json")) + return rows + + +def _replace_history_control_rows( + rows: list[dict] | tuple[dict, ...], + controls: list[dict], +) -> list[dict]: + """Install one control row per kind before the narrative projection. + + A resident value replaces every transcript/cache copy of the same kind. + When one side is unavailable (notably a cold, non-resident cache hit), keep + only the newest source value instead of erasing the best known readout. + """ + selected = { + row.get("type"): dict(row) + for row in rows + if row.get("type") in {"model", "effort"} + } + selected.update({row.get("type"): dict(row) for row in controls}) + return [ + *(selected[kind] for kind in ("model", "effort") + if kind in selected), + *(dict(row) for row in rows + if row.get("type") not in {"model", "effort"}), + ] + + +def _codex_active_continuation_ids( + ctx: Optional[SessionContext], +) -> set[str]: + """Return every handle-proven active continuation id for translation.""" if ctx is None or ctx.engine != "codex": - return [] + return set() values = getattr(ctx.sdk, "compaction_continuation_turn_ids", frozenset()) if not isinstance(values, (set, frozenset, tuple, list)): - return [] - return sorted({ + return set() + return { value for value in values if isinstance(value, str) and value - })[:4] + } + + +def _codex_compaction_continuation_ids( + ctx: Optional[SessionContext], +) -> list[str]: + """Return the four most relevant handle-proven ids allowed on the wire.""" + values = _codex_active_continuation_ids(ctx) + if not values or ctx is None: + return [] + if len(values) <= 4: + return sorted(values) + # The wire hint is bounded to four ids. Never let lexical truncation drop + # the resident control owners; translation receives the complete set. + priority: list[str] = [] + for candidate in ( + getattr(ctx.sdk, "turn_id", None), + ctx.codex_owned_turn_id, + ctx.codex_spontaneous_turn_id, + ): + if isinstance(candidate, str) and candidate in values \ + and candidate not in priority: + priority.append(candidate) + priority.extend(sorted(values.difference(priority))) + return priority[:4] def _codex_list_state(status: Optional[str]) -> Optional[State]: @@ -1002,6 +1168,22 @@ def _codex_catalog_sort_key(value: object) -> float: return parsed if math.isfinite(parsed) else -1.0 +def _codex_catalog_default_model(models: list[dict]) -> Optional[str]: + """Return one normalized catalog default without crossing profiles.""" + available = [ + item for item in models + if isinstance(item.get("id"), str) and item["id"] + ] + if not available: + return None + return next(( + item["id"] for item in available if item.get("is_default") + ), available[0]["id"]) + + +_CODEX_CATALOG_PRIORITY = "_cc_remote_catalog_priority" + + class _BtwSpawnFailure(Exception): """Expected /btw rejection that must be correlated and ACKed.""" @@ -1020,6 +1202,10 @@ def __init__(self, code: str, message: str): self.message = message +class _UnsupportedCodexModel(ValueError): + """An explicit model is absent from the selected account's catalog.""" + + class _ForkOutcomeUncertain(RuntimeError): """A persistent fork may have committed and must not be ACKed/replayed.""" @@ -1070,6 +1256,12 @@ class _CodexGoalRecoveryMiss: stable: bool +@dataclass(frozen=True) +class _CodexDeleteRejection: + error: Error + outcome: Literal["failed", "unknown"] + + class _CodexOfficialProjectionIncomplete(RuntimeError): """A stable rollout proves that the official newest page omitted turns.""" @@ -1255,10 +1447,13 @@ class WrapperMachine: CODEX_HISTORY_IMAGE_VIEW_CACHE_ENTRIES = 256 CODEX_GOAL_RECOVERY_MISS_CACHE_ENTRIES = 2048 CODEX_GOAL_RECOVERY_MAX_SOURCE_SCANS = 3 + CODEX_LIVE_USER_RECOVERY_SCAN_BYTES = 64 * 1024 * 1024 CODEX_LIVE_USER_ITEM_IDS = 512 CODEX_PUBLISHED_STEER_IDS = 512 CODEX_THREAD_STARTED_HINTS = 1024 CODEX_THREAD_STARTED_HINT_TTL_SECONDS = 30.0 + CODEX_EXACT_CATALOG_RPC_TIMEOUT_SECONDS = 10.0 + CODEX_EXACT_CATALOG_RPC_BATCH_IDS = 128 NOTIFICATION_TITLE_CAP = 512 NOTIFICATION_TITLE_LENGTH = 120 PREVIEW_WRITE_TOOLS = frozenset({ @@ -1412,6 +1607,15 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): self._codex_profiles.default.id] ) self._codex_turn_leases = CodexTurnLeaseStore(cfg.state_dir) + try: + self._codex_terminal_ledger: CodexTerminalLedger | None = ( + CodexTerminalLedger(cfg.state_dir) + ) + except Exception: + # This store is a rebuildable lifecycle projection. A damaged + # private cache must never prevent access to the native engine. + self._codex_terminal_ledger = None + log.exception("Codex terminal ledger unavailable") try: self._codex_client_messages: CodexClientMessageStore | None = ( CodexClientMessageStore(cfg.state_dir) @@ -1539,6 +1743,12 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): self._codex_catalog_hint_tasks: set[asyncio.Task] = set() self._codex_catalog_hint_dirty = False self._codex_catalog_hint_last: tuple[str, str] | None = None + # A last-moment app-server reconnect can happen inside turn/start after + # the normal preflight resolved nullable effort. Refresh that display + # projection off the managed stream path so config/model catalog reads + # never delay draining an already-started turn. + self._codex_effort_publish_tasks: set[asyncio.Task] = set() + self._codex_terminal_persist_tasks: set[asyncio.Task] = set() # Catalog reads must never hold the serial command lane: a cold Codex # app-server startup can take tens of seconds on a very large store. self._session_list_command_tasks: set[asyncio.Task] = set() @@ -1624,6 +1834,15 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): # private cache must not prevent users from reaching their engine. self._session_plans = None log.exception("session plan store unavailable") + plan_path = Path(self.cfg.state_dir) / "session-plans.json" + if _quarantine_rebuildable_projection( + plan_path, + projection="session plans", + ): + try: + self._session_plans = SessionPlanStore(self.cfg.state_dir) + except SessionPlanStoreError: + log.exception("session plan store recovery failed") try: self._session_presentation: SessionPresentationStore | None = ( SessionPresentationStore(self.cfg.state_dir) @@ -1634,6 +1853,21 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): # cache is damaged. self._session_presentation = None log.exception("session presentation store unavailable") + presentation_path = ( + Path(self.cfg.state_dir) / "session-presentation.json" + ) + if _quarantine_rebuildable_projection( + presentation_path, + projection="session presentation", + ): + try: + self._session_presentation = SessionPresentationStore( + self.cfg.state_dir + ) + except SessionPresentationStoreError: + log.exception( + "session presentation store recovery failed" + ) try: self._claude_controls: ClaudeControlStore | None = ( ClaudeControlStore(self.cfg.state_dir) @@ -1703,7 +1937,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): transition = self._codex_profile_transition if transition is not None and self._codex_profile_migration_ok: try: - self._migrate_codex_profile_state(transition) + self._migrate_codex_core_profile_state(transition) except Exception as exc: self._codex_profile_migration_ok = False self._codex_work_profile_migration_ok = False @@ -1711,11 +1945,37 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): "Codex profile state migration is incomplete", error_type=type(exc).__name__, ) + if transition is not None and self._codex_profile_migration_ok: + try: + self._migrate_codex_work_profile_state(transition) + except Exception as exc: + self._codex_work_profile_migration_ok = False + log.warning( + "Codex Work profile ownership migration is incomplete", + error_type=type(exc).__name__, + ) + presentation_migration_ok = self._codex_profile_migration_ok if self._codex_profile_migration_ok: + # Plan and presentation files are rebuildable display caches. Keep + # their replay-safe revision migration outside both authorization + # gates: one malformed optional file must not disable Code or Work. + presentation_migration_ok = ( + self._migrate_codex_presentation_profile_state(transition) + ) + self._codex_presentation_profile_migration_ok = ( + presentation_migration_ok + ) + if ( + self._codex_profile_migration_ok + and not self._codex_presentation_profile_migration_ok + ): + log.warning( + "Codex optional presentation state migration is incomplete" + ) + if self._codex_work_profile_migration_ok: try: - # Schedule ownership was introduced after the account topology - # journal. Repair it independently so an already-applied - # topology revision cannot leave legacy tasks unowned. + # Schedule ownership was introduced after the topology journal. + # Catch it up even when there is no pending topology transition. self._work.for_engine("codex").assign_legacy_codex_profile( self._codex_profiles.default.id ) @@ -1725,7 +1985,12 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): "Codex Work profile ownership migration is incomplete", error_type=type(exc).__name__, ) - if self._codex_profile_migration_ok and transition is not None: + if ( + self._codex_profile_migration_ok + and self._codex_work_profile_migration_ok + and self._codex_presentation_profile_migration_ok + and transition is not None + ): try: self._codex_profile_topology.complete( self._codex_profiles, @@ -1739,10 +2004,10 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport): error_type=type(exc).__name__, ) - def _migrate_codex_profile_state( + def _migrate_codex_core_profile_state( self, transition: CodexProfileTopologyTransition, ) -> None: - """Apply one replay-safe topology revision across private stores.""" + """Apply one replay-safe revision to authorization-critical stores.""" revision = transition.revision transform = transition.wire_session_id self._codex_turn_leases.migrate_profile_sessions( @@ -1764,11 +2029,69 @@ def _migrate_codex_profile_state( transform, profile_revision=revision, ) + + def _migrate_codex_work_profile_state( + self, transition: CodexProfileTopologyTransition, + ) -> None: + """Move only Work ownership for one profile topology revision.""" self._work.for_engine("codex").migrate_codex_profiles( transition.remaps, legacy_profile_id=transition.legacy_profile_id, - profile_revision=revision, + profile_revision=transition.revision, + ) + + def _migrate_codex_presentation_profile_state( + self, + transition: CodexProfileTopologyTransition | None, + ) -> bool: + """Best-effort migration for optional Codex display projections.""" + revision = self._codex_profile_revision + transform = ( + transition.wire_session_id + if transition is not None else self._codex_plan_catch_up_id ) + complete = True + if self._session_plans is not None: + try: + self._session_plans.migrate_profile_sessions( + transform, profile_revision=revision) + except Exception: + self._session_plans = None + complete = False + log.exception("session plan profile migration failed") + elif transition is not None: + complete = False + if self._session_presentation is not None: + try: + self._session_presentation.migrate_codex_profile_sessions( + transform, profile_revision=revision) + except Exception: + self._session_presentation = None + complete = False + log.exception("session presentation profile migration failed") + elif transition is not None: + complete = False + if self._codex_terminal_ledger is not None: + try: + self._codex_terminal_ledger.migrate_profile_sessions( + transform, profile_revision=revision) + except Exception: + self._codex_terminal_ledger = None + complete = False + log.exception("Codex terminal profile migration failed") + elif transition is not None: + complete = False + return complete + + def _codex_plan_catch_up_id(self, session_id: str) -> str: + """Namespace old Codex-only Plan ids after topology was already saved.""" + if "@" in session_id or not self._codex_profiles.is_multi_profile: + return session_id + owner = self._codex_legacy_restart_profile_id + if owner is None: + raise SessionPlanStoreError( + "legacy Codex Plan owner is unavailable") + return self._codex_profiles.wire_session_id(owner, session_id) # ---- pool helpers ---- @@ -1796,6 +2119,52 @@ def _codex_home(self, profile: CodexProfile) -> Optional[str]: return None return str(profile.home) + async def _resolve_codex_profile_model( + self, + profile: CodexProfile, + candidate: Optional[str], + *, + explicit: bool = False, + catalog: Optional[list[dict]] = None, + ) -> tuple[Optional[str], bool]: + """Resolve a model inside exactly one Codex account namespace. + + A non-empty catalog proves availability. Explicit unavailable choices + fail closed; implicit/retired values follow that account's advertised + default. An empty catalog is read uncertainty, so preserve a supplied + config/session value and let app-server apply its native default when + no value exists. The boolean reports whether a supplied value changed. + """ + if catalog is None: + home = self._codex_home(profile) + catalog = ( + await codex_catalog() + if home is None + else await codex_catalog(codex_home=home) + ) + catalog_ids = { + item["id"] for item in catalog + if isinstance(item.get("id"), str) and item["id"] + } + if candidate and (not catalog_ids or candidate in catalog_ids): + return candidate, False + if candidate and explicit and catalog_ids: + raise _UnsupportedCodexModel(candidate) + if candidate and catalog_ids: + home = self._codex_home(profile) + provider = await asyncio.to_thread( + codex_current_provider, + **({} if home is None else {"codex_home": home}), + ) + if provider and provider != "openai": + # Custom providers can intentionally use ids absent from + # OpenAI's entitlement-filtered model/list. Config and rollout + # remain authoritative inside that already-selected provider; + # only official/implicit OpenAI ids are treated as retired. + return candidate, False + fallback = _codex_catalog_default_model(catalog) + return fallback, fallback != candidate + def _codex_spawn_profile_kwargs( self, profile: Optional[CodexProfile], ) -> dict[str, str]: @@ -2057,13 +2426,14 @@ def _completion_state( def _session_presentation_fields( self, + engine: str, session_id: str, ) -> dict[str, object]: """Project a durable completion receipt into one cold catalog row.""" if self._session_presentation is None: return {} try: - snapshot = self._session_presentation.get(session_id) + snapshot = self._session_presentation.get(engine, session_id) except SessionPresentationStoreError: log.warning( "session completion receipt could not be listed", @@ -2078,6 +2448,145 @@ def _session_presentation_fields( "completion_revision": snapshot.completion_revision, } + async def _claim_legacy_presentation_ids( + self, + claude_session_ids: set[str], + codex_session_ids: dict[str, str], + ) -> None: + """Claim v1 engine-less receipts only from a complete native witness. + + The two catalogs are independent and may contain the same UUID. A + receipt moves only when exactly one engine proves ownership; ambiguous + or failed discovery remains quarantined for a later listing. + """ + store = self._session_presentation + if store is None: + return + try: + legacy_ids = await asyncio.to_thread(store.legacy_ids) + for session_id in legacy_ids: + in_claude = session_id in claude_session_ids + codex_target = codex_session_ids.get(session_id) + in_codex = codex_target is not None + if in_claude == in_codex: + continue + await asyncio.to_thread( + store.claim_legacy, + "claude" if in_claude else "codex", + session_id, + session_id if in_claude else codex_target, + ) + except SessionPresentationStoreError: + log.warning("legacy session presentation ownership claim failed") + + async def _claim_legacy_presentation_from_claude_catalog( + self, + claude_session_ids: set[str], + ) -> None: + store = self._session_presentation + if store is None: + return + try: + legacy_ids = await asyncio.to_thread(store.legacy_ids) + if not legacy_ids: + return + codex_ids: dict[str, str] = {} + # Only rows the Claude catalog can render need a collision probe. + # This caps exact SQLite lookups to the bounded native page rather + # than probing once for every quarantined receipt. + for session_id in legacy_ids & claude_session_ids: + matches: list[str] = [] + uncertain = False + for profile in self._codex_profiles: + home = self._codex_home(profile) + presence = await asyncio.to_thread( + codex_session_presence, + session_id, + **({} if home is None else {"codex_home": home}), + ) + if presence is True: + matches.append(self._codex_wire_sid( + profile, session_id)) + elif presence is None: + uncertain = True + # More than one account owning the same native UUID is also + # ambiguous, even though the engine family is the same. + if uncertain: + claude_session_ids.discard(session_id) + elif len(matches) == 1: + codex_ids[session_id] = matches[0] + elif len(matches) > 1: + claude_session_ids.discard(session_id) + await self._claim_legacy_presentation_ids( + claude_session_ids, codex_ids) + except Exception as exc: + log.warning( + "legacy presentation Codex ownership probe failed", + error_type=type(exc).__name__, + ) + + async def _claim_legacy_presentation_from_codex_catalog( + self, + raw: list[dict], + ) -> None: + store = self._session_presentation + if store is None: + return + try: + legacy_ids = await asyncio.to_thread(store.legacy_ids) + if not legacy_ids: + return + listed_native_ids: set[str] = set() + for row in raw: + native_id = row.get("native_session_id") + if isinstance(native_id, str) and native_id in legacy_ids: + listed_native_ids.add(native_id) + codex_ids: dict[str, str] = {} + unknown_codex_ids: set[str] = set() + duplicate_codex_ids: set[str] = set() + for session_id in listed_native_ids: + matches: list[str] = [] + for profile in self._codex_profiles: + home = self._codex_home(profile) + presence = await asyncio.to_thread( + codex_session_presence, + session_id, + **({} if home is None else {"codex_home": home}), + ) + if presence is True: + matches.append(self._codex_wire_sid( + profile, session_id)) + elif presence is None: + unknown_codex_ids.add(session_id) + if session_id in unknown_codex_ids: + continue + if len(matches) == 1: + codex_ids[session_id] = matches[0] + elif len(matches) > 1: + duplicate_codex_ids.add(session_id) + claude_ids: set[str] = set() + unknown_claude_ids: set[str] = set() + for session_id in listed_native_ids: + presence = await asyncio.to_thread( + transcript_presence, session_id) + if presence is True: + claude_ids.add(session_id) + elif presence is None: + unknown_claude_ids.add(session_id) + for session_id in unknown_claude_ids: + codex_ids.pop(session_id, None) + # Duplicate native UUIDs across Codex profiles cannot map one v1 + # bare receipt to a unique wire id; keep those quarantined. + claude_ids.difference_update( + duplicate_codex_ids | unknown_codex_ids) + await self._claim_legacy_presentation_ids( + claude_ids, codex_ids) + except Exception as exc: + log.warning( + "legacy presentation Claude ownership probe failed", + error_type=type(exc).__name__, + ) + def _history_revision(self, sid: str) -> str: return f"{self.instance_id}-{self._history_revision_epochs.get(sid, 0)}" @@ -2097,6 +2606,34 @@ def _bump_history_revision(self, sid: str) -> str: ) return self._history_revision(sid) + def _bump_codex_projection_revision(self, sid: str) -> str: + """Advance a read-side Codex revision without losing source facts. + + This is only for projection-family or exact-alias changes which leave + the native rollout untouched. Rollback and other destructive history + invalidations continue to call ``_bump_history_revision`` directly so + their revision-scoped volatile terminals fail closed. + """ + previous_revision = self._history_revision(sid) + revision = self._bump_history_revision(sid) + ledger = getattr(self, "_codex_terminal_ledger", None) + if ledger is not None: + try: + ledger.rebase_revision( + sid, + previous_revision=previous_revision, + revision=revision, + ) + except Exception as exc: + # The lifecycle ledger is optional; a display-cache revision + # must never fail the underlying turn or History request. + log.warning( + "Codex terminal revision could not be rebased", + session_id=sid, + error_type=type(exc).__name__, + ) + return revision + def _invalidate_codex_history(self, sid: str) -> None: """Invalidate only Codex-native supplements for one routed thread id.""" self._codex_rollout_history_revisions.pop(sid, None) @@ -2127,9 +2664,21 @@ def _codex_rollout_history_active(self, sid: str) -> bool: return self._codex_rollout_history_revisions.get( sid) == self._history_revision(sid) - def _activate_codex_rollout_history(self, sid: str) -> str: - """Start one clean rollout generation after a proven official omission.""" - self._bump_history_revision(sid) + def _activate_codex_rollout_history( + self, + sid: str, + *, + advance_revision: bool = True, + ) -> str: + """Pin summary pagination to rollout for one History revision. + + A proven omission changes an already-visible source family and needs a + fresh browser/index revision. A capability rejection happens before an + official page exists, so it can retain the current rollout cache while + still pinning all subsequent cursors to the same reader. + """ + if advance_revision: + self._bump_codex_projection_revision(sid) # Drop official page cursors, locators and detail rows before any # rollout page can be requested under the new revision. self._invalidate_codex_history(sid) @@ -2253,7 +2802,7 @@ async def _remember_codex_client_message_id( # Source fingerprints do not include metadata learned from the live # app-server. Discard an alias-free materialized page and advance # the browser revision before it can race this identity update. - self._bump_history_revision(session_id) + self._bump_codex_projection_revision(session_id) return inserted async def _remember_codex_initial_turn_alias( @@ -2353,7 +2902,7 @@ async def _backfill_official_codex_client_message_ids( ) continue if inserted: - self._bump_history_revision(sid) + self._bump_codex_projection_revision(sid) async def _apply_codex_steer_user_identity( self, @@ -2440,7 +2989,7 @@ async def _apply_codex_steer_user_identity( self._schedule_history_refresh( sid, before=None, - limit=None, + limit=self.MIRROR_LIMIT, cwd=ctx.cwd, detail="summary", ) @@ -2969,7 +3518,6 @@ async def _reconnect_codex_shared( can_takeover=False, ) return False - await self._sync_external_control(ctx, watch) return True async def _codex_restart_state( @@ -3191,9 +3739,9 @@ async def _recover_codex_owned_turn( except OSError: source = None size = source.size if source is not None else 0 - active, _partial, last_marker = ( + active, _partial, last_marker, _last_terminal = ( await asyncio.to_thread(self._codex_tail_snapshot, path, size) - if path and source is not None else (set(), b"", None) + if path and source is not None else (set(), b"", None, None) ) persisted_stream_ids = ( lease.stream_task_ids( @@ -3378,52 +3926,77 @@ async def _recover_codex_owned_turn( ) ) ), None) - if witness is None: - log.warning( - "Codex active rollout lacks exact control-turn witness", - session_id=session_id, - turn_id=lease.turn_id, - ) - return False - native_message_id, active_stream_id = witness - try: - bound = await asyncio.to_thread( - self._codex_turn_leases.bind_stream, - session_id, - lease.turn_id, - active_stream_id, - native_message_id, - source_device=source.device, - source_inode=source.inode, - expected_msg_id=lease.msg_id, - daemon_epoch=lease.daemon_epoch, - ) - except Exception as exc: - log.warning( - "Codex recovered stream binding could not be persisted", - session_id=session_id, - turn_id=lease.turn_id, - error_type=type(exc).__name__, - ) - return False - if not bound: - return False - rollout_matches = True - # Only the newest official user item owns the lease's latest - # browser message. An older item may prove task lineage but - # must never be relabelled as the newest Remote steer. + persisted_task_id = ( + next(iter(persisted_stream_ids)) + if len(persisted_stream_ids) == 1 + else None + ) if ( - probe.native_user_message_ids - and native_message_id - == probe.native_user_message_ids[0] + witness is None + and persisted_task_id is not None + and not active + and last_marker is None ): - await self._remember_codex_client_message_id( - ctx, - active_stream_id, - lease.msg_id, - native_message_id=native_message_id, - source_path=path, + # Codex may expose a different official user-item id + # after a steer than the msg id written to the rollout. + # A prior live binding remains exact for this lease and + # rollout inode, so retain the already-proven task id. + active_stream_id = persisted_task_id + rollout_matches = True + log.info( + "recovering Codex turn from durable stream witness", + session_id=session_id, + turn_id=lease.turn_id, + stream_turn_id=active_stream_id, ) + if witness is None: + if not rollout_matches: + log.warning( + "Codex active rollout lacks exact control-turn witness", + session_id=session_id, + turn_id=lease.turn_id, + ) + return False + else: + native_message_id, active_stream_id = witness + try: + bound = await asyncio.to_thread( + self._codex_turn_leases.bind_stream, + session_id, + lease.turn_id, + active_stream_id, + native_message_id, + source_device=source.device, + source_inode=source.inode, + expected_msg_id=lease.msg_id, + daemon_epoch=lease.daemon_epoch, + ) + except Exception as exc: + log.warning( + "Codex recovered stream binding could not be persisted", + session_id=session_id, + turn_id=lease.turn_id, + error_type=type(exc).__name__, + ) + return False + if not bound: + return False + rollout_matches = True + # Only the newest official user item owns the lease's + # latest browser message. Older items may prove lineage + # but must not be relabelled as the newest Remote steer. + if ( + probe.native_user_message_ids + and native_message_id + == probe.native_user_message_ids[0] + ): + await self._remember_codex_client_message_id( + ctx, + active_stream_id, + lease.msg_id, + native_message_id=native_message_id, + source_path=path, + ) else: # The ordered official reads authoritatively show that the # leased control turn is no longer the active turn. @@ -3578,6 +4151,36 @@ async def _ensure_codex_daemon_generation( error_type=type(exc).__name__, ) return False + if connected: + # thread/resume adopted the replacement daemon's authoritative + # settings, but a status/background reconnect may not launch a + # turn afterwards. Publish a changed nullable effort here so the + # browser cannot retain the previous daemon/account's chip until + # some unrelated command happens to refresh it. + if not self._is_resident_context(ctx): + try: + await ctx.sdk.disconnect() + except Exception as exc: + log.warning( + "failed to disconnect evicted Codex shared proxy", + session_id=ctx.session_id, + error_type=type(exc).__name__, + ) + return False + await self._sync_external_control( + ctx, self._watch.get(self._ctx_wire_sid(ctx) or "")) + if not await self._publish_codex_model_effort( + ctx, require_resident=True, + ): + try: + await ctx.sdk.disconnect() + except Exception as exc: + log.warning( + "failed to disconnect evicted Codex shared proxy", + session_id=ctx.session_id, + error_type=type(exc).__name__, + ) + return False if state is None: return connected if connected: @@ -4978,6 +5581,12 @@ async def run(self) -> None: self._codex_catalog_hint_tasks.clear() self._codex_catalog_hint_dirty = False self._codex_catalog_hint_last = None + effort_tasks = list(self._codex_effort_publish_tasks) + for task in effort_tasks: + task.cancel() + if effort_tasks: + await asyncio.gather(*effort_tasks, return_exceptions=True) + self._codex_effort_publish_tasks.clear() history_tasks = list(self._history_command_tasks.values()) for task in history_tasks: task.cancel() @@ -5063,6 +5672,23 @@ async def run(self) -> None: if c.btw and c.engine != "codex" and c.btw_real_id: await self._delete_private_btw( c.btw_real_id, c.cwd, forget=disconnected) + terminal_tasks = list(self._codex_terminal_persist_tasks) + # Stop every producer first, then give the remaining small fsyncs a + # chance to finish. Draining earlier could miss a terminal emitted + # by a turn/watch task while shutdown was still unwinding it. + if terminal_tasks: + try: + await asyncio.wait_for( + asyncio.gather( + *terminal_tasks, return_exceptions=True), + timeout=2.0, + ) + except asyncio.TimeoutError: + for task in terminal_tasks: + task.cancel() + await asyncio.gather( + *terminal_tasks, return_exceptions=True) + self._codex_terminal_persist_tasks.clear() async def _work_schedule_loop(self) -> None: """Claim and launch due Work tasks without stealing the UI focus.""" @@ -5785,25 +6411,207 @@ def _observe_active_turn_binding( ): ctx.active_turn_binding = None - async def _emit_locked(self, ctx: SessionContext, msg) -> None: - # Stamp routing before buffering so byte accounting includes the final - # wire shape. Every live and replayable /btw frame is owner-only; relay - # treats an absent ``to`` as broadcast, so fail closed for an impossible - # ownerless fork rather than leaking its contents. - msg.sid = self._ctx_wire_sid(ctx) or ctx.key - if ctx.btw: - if not ctx.owner_client_id: - log.error("dropping frame for ownerless btw", sid=ctx.key, - type=getattr(msg, "type", None)) - return - msg.to = ctx.owner_client_id - if isinstance(msg, GoalState) and msg.sid: - goal_id = self._goal_identity(msg.goal) - dismissed = False - if self._session_presentation is not None: - try: - dismissed = await asyncio.to_thread( - self._session_presentation.reconcile_goal, + @staticmethod + def _codex_terminal_fence_from_event( + msg: TurnEnd, + ) -> CodexTerminalFence | None: + """Normalize one exact live Codex terminal without inventing timing.""" + if not msg.turn_id or msg.result.subtype == "steered": + return None + status = ( + "completed" + if not msg.result.is_error + else "interrupted" + if msg.result.subtype == "error_during_execution" + else "failed" + ) + duration_ms = ( + msg.result.duration_ms + if msg.result.duration_ms > 0 else None + ) + try: + return CodexTerminalFence( + turn_id=msg.turn_id, + status=status, + duration_ms=duration_ms, + completed_at=msg.ts if msg.ts >= 0 else None, + ) + except (TypeError, ValueError): + # Recovery metadata is optional. A malformed/unrepresentable + # provider duration must never prevent the authoritative TurnEnd + # itself from reaching the browser. + return None + + async def _persist_codex_terminal_fence( + self, + ledger: CodexTerminalLedger, + sid: str, + fence: CodexTerminalFence, + source_identity: tuple[str, int, int, int], + ) -> None: + try: + source_path = await asyncio.to_thread( + self._codex_rollout_for_wire, sid) + if not source_path: + return + await asyncio.to_thread( + ledger.persist, + sid, + fence, + source_path, + expected_source_identity=source_identity, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + # The live TurnEnd remains authoritative. Persistence is only a + # reconnect hint and must never hold up or fail the terminal send. + log.warning( + "Codex terminal fence could not be persisted", + session_id=sid, + turn_id=fence.turn_id, + error_type=type(exc).__name__, + ) + + def _remember_codex_terminal_fence( + self, + sid: str, + fence: CodexTerminalFence, + *, + source_identity: tuple[str, int, int, int] | None = None, + persist: bool = True, + ) -> None: + """Publish a fence before an in-flight stale History can win.""" + ledger = self._codex_terminal_ledger + if ledger is None: + return + try: + ledger.remember( + sid, + fence, + revision=self._history_revision(sid), + source_identity=source_identity, + ) + except Exception as exc: + log.warning( + "Codex terminal fence could not be remembered", + session_id=sid, + turn_id=fence.turn_id, + error_type=type(exc).__name__, + ) + return + # Persistence must be bound to the exact source observed when the + # terminal arrived. Without that identity, keep only the revision- + # scoped process-local fence; resolving a path later could attach an old + # terminal to a rollout which rotated in the meantime. + if source_identity is not None and persist: + task = asyncio.create_task(self._persist_codex_terminal_fence( + ledger, sid, fence, source_identity)) + self._codex_terminal_persist_tasks.add(task) + task.add_done_callback(self._codex_terminal_persist_tasks.discard) + + @staticmethod + def _codex_watch_source_identity( + watch: object, + ) -> tuple[str, int, int, int] | None: + """Return one validated watched rollout identity or fail unknown.""" + if not isinstance(watch, dict): + return None + path = watch.get("path") + file_id = watch.get("file_id") + size = watch.get("size") + if ( + not isinstance(path, str) + or not path + or "\x00" in path + or not isinstance(file_id, tuple) + or len(file_id) != 2 + or isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + or any( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + for value in file_id + ) + ): + return None + return path, file_id[0], file_id[1], size + + def _remember_codex_terminal_event( + self, + ctx: SessionContext, + msg: TurnEnd, + ) -> None: + """Normalize and retain an authoritative live app-server terminal.""" + if ( + ctx.engine != "codex" + or ctx.btw + or not msg._codex_authoritative_terminal + ): + return + fence = self._codex_terminal_fence_from_event(msg) + if fence is None: + return + if ( + fence.status == "interrupted" + and fence.turn_id in _codex_compaction_continuation_ids(ctx) + ): + # The app-server uses an interrupted native boundary while compact + # immediately continues the same visible logical turn. Its final + # replacement terminal will be recorded normally. + return + sid = self._ctx_wire_sid(ctx) or ctx.key + watch = self._watch.get(sid) + source_identity = self._codex_watch_source_identity(watch) + self._remember_codex_terminal_fence( + sid, fence, source_identity=source_identity) + + async def _codex_terminal_snapshot( + self, + sid: str, + revision: str, + *, + source_path: str | None = None, + ) -> list[CodexTerminalFence]: + ledger = self._codex_terminal_ledger + if ledger is None: + return [] + try: + path = source_path or await asyncio.to_thread( + self._codex_rollout_for_wire, sid) + snapshot = await asyncio.to_thread( + ledger.snapshot, sid, path, revision=revision) + except Exception as exc: + log.warning( + "Codex terminal snapshot unavailable", + session_id=sid, + error_type=type(exc).__name__, + ) + return [] + return list(snapshot) + + async def _emit_locked(self, ctx: SessionContext, msg) -> None: + # Stamp routing before buffering so byte accounting includes the final + # wire shape. Every live and replayable /btw frame is owner-only; relay + # treats an absent ``to`` as broadcast, so fail closed for an impossible + # ownerless fork rather than leaking its contents. + msg.sid = self._ctx_wire_sid(ctx) or ctx.key + if ctx.btw: + if not ctx.owner_client_id: + log.error("dropping frame for ownerless btw", sid=ctx.key, + type=getattr(msg, "type", None)) + return + msg.to = ctx.owner_client_id + if isinstance(msg, GoalState) and msg.sid: + goal_id = self._goal_identity(msg.goal) + dismissed = False + if self._session_presentation is not None: + try: + dismissed = await asyncio.to_thread( + self._session_presentation.reconcile_goal, + ctx.engine, msg.sid, goal_id, ) @@ -5838,17 +6646,17 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None: else: current_turn_ids = frozenset() try: - # Completed Plan state belongs to the task which just ended. + # Settled Plan state belongs to the task which just ended. # Retire it before broadcasting the next user boundary so a - # reconnect cannot recover the old green monitor. + # reconnect cannot recover the old task monitor as active. await asyncio.to_thread( - self._session_plans.retire_completed, + self._session_plans.retire_settled, msg.sid, current_turn_ids=current_turn_ids, ) except SessionPlanStoreError: log.warning( - "Codex completed plan snapshot could not be retired", + "Codex settled plan snapshot could not be retired", session_id=msg.sid, ) if ( @@ -5869,10 +6677,53 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None: "Codex session plan snapshot could not be persisted", session_id=msg.sid, ) + if ( + isinstance(msg, TurnEnd) + and ctx.engine == "codex" + and not ctx.btw + and self._session_plans is not None + and msg.sid + and msg.turn_id + and msg._codex_authoritative_terminal + and msg.result.subtype != "steered" + ): + terminal_status = ( + "succeeded" + if not msg.result.is_error + else "interrupted" + if msg.result.subtype == "error_during_execution" + else "failed" + ) + try: + # Persist the exact terminal separately from step progress. + # A successful turn does not prove that omitted Plan updates + # ran, but it does prove the old inProgress marker is no longer + # active and must not be rebound to a later user turn. + await asyncio.to_thread( + self._session_plans.mark_terminal, + msg.sid, + turn_id=msg.turn_id, + status=terminal_status, + ) + except SessionPlanStoreError: + log.warning( + "Codex plan terminal could not be persisted", + session_id=msg.sid, + ) if isinstance(msg, TurnEnd): # The replayable object is deliberately notification-free. Only a # copy sent on this live call receives presentation metadata. msg.notification_context = None + try: + self._remember_codex_terminal_event(ctx, msg) + except Exception as exc: + # Lifecycle recovery is optional. No cache/ledger failure may + # suppress the engine's authoritative live terminal. + log.error( + "Codex terminal recovery hook failed", + session_id=msg.sid, + error_type=type(exc).__name__, + ) if is_downstream(msg): msg.seq = ctx.next_seq() ctx.buffer.append(msg) @@ -5902,6 +6753,7 @@ async def _emit(self, ctx: SessionContext, msg) -> None: try: snapshot = await asyncio.to_thread( self._session_presentation.mark_completion, + ctx.engine, sid, msg.turn_id, ) @@ -6879,6 +7731,79 @@ def _refresh_cached_response(self, response): replay.generation = self.instance_id return replay + async def _fork_entry_for_cached_command( + self, cmd, cached_responses: tuple[object, ...], + ) -> Optional[dict]: + """Resolve the one journal entry owned by a cached fork command. + + Request ids are browser-generated and therefore cannot be treated as a + cross-engine namespace. Match the command's parent/target and its + cached child before consulting a deletion tombstone; an unrelated + journal collision must never suppress or resurrect this response. + """ + request_id = getattr(cmd, "request_id", None) + requested_parent = getattr(cmd, "session_id", None) + if not isinstance(request_id, str) or not isinstance( + requested_parent, str + ): + return None + resolved_parent = ( + self._resolve_session_alias(requested_parent) or requested_parent) + parents = {requested_parent, resolved_parent} + target = ( + "worktree" if cmd.type == "fork_session_worktree" else "same_cwd") + cached = next(( + response for response in cached_responses + if isinstance(response, SessionForked) + and response.request_id == request_id + and response.parent_session_id in parents + and response.target == target + ), None) + child = getattr(cached, "session_id", None) + journals = ( + (self._codex_forks,) + if target == "worktree" + else (self._codex_forks, self._claude_forks) + ) + candidates: list[tuple[str, dict]] = [] + for engine, journal in zip( + (("codex",) if target == "worktree" else ("codex", "claude")), + journals, + ): + try: + entry = await asyncio.to_thread(journal.get, request_id) + except (ForkJournalError, ClaudeForkJournalError): + continue + if not entry: + continue + if entry.get("parent_session_id") not in parents: + continue + if entry.get("target", "same_cwd") != target: + continue + if isinstance(child, str) and entry.get("session_id") != child: + continue + candidates.append((engine, entry)) + if len(candidates) == 1: + return candidates[0][1] + if len(candidates) > 1: + ctx = self._ctx_for(resolved_parent) + if ctx is not None: + matched = next(( + entry for engine, entry in candidates + if engine == ctx.engine + ), None) + if matched is not None: + return matched + statuses = {entry.get("status") for _, entry in candidates} + if len(statuses) == 1: + return candidates[0][1] + log.warning( + "ambiguous cross-engine cached fork journal collision", + request_id=request_id, + parent_session_id=resolved_parent, + ) + return None + async def _process_command(self, cmd) -> None: """Deduplicate reliable client commands and ACK completed handlers. @@ -6892,6 +7817,17 @@ async def _process_command(self, cmd) -> None: seen, cached_responses = ( self._command_seen(client_id, cmd_id) if reliable else (False, ())) if seen: + if cmd.type in {"fork_session", "fork_session_worktree"}: + entry = await self._fork_entry_for_cached_command( + cmd, cached_responses) + if entry and entry.get("status") in { + "delete_pending", "deleted", + }: + # Cached command responses predate the durable child + # tombstone. ACK the reliable retry but do not replay its + # now-deleted SessionForked navigation frame. + await self._send_command_ack(client_id, cmd_id) + return if cmd.type in self.SAFE_RETRY_COMMANDS: # The original one-shot response may have died on the same link as # its ACK. Safe reads and idempotent reconciliations are re-run @@ -7180,7 +8116,7 @@ async def _handle_client_hello(self, cmd) -> None: if not ctx.btw and self._session_presentation is not None: try: presentation = await asyncio.to_thread( - self._session_presentation.get, sid + self._session_presentation.get, ctx.engine, sid ) except SessionPresentationStoreError: log.warning( @@ -7227,24 +8163,40 @@ async def _handle_client_hello(self, cmd) -> None: to=cmd.client_id, route_id=getattr(cmd, "route_id", None), )) - model = _session_model(ctx) - if model: - ctx.announced_model = model - await self.transport.send(Model( - model=model, - sid=sid, - to=cmd.client_id, - route_id=getattr(cmd, "route_id", None), - )) - effort = _session_effort(ctx) - if effort: - ctx.announced_effort = effort - await self.transport.send(Effort( - effort=effort, - sid=sid, - to=cmd.client_id, - route_id=getattr(cmd, "route_id", None), - )) + # Hello must remain a no-probe fast path, but model and effort + # still form one settings snapshot. Capture both before the first + # transport await, finish that pair even if native settings move + # while it is sent, then send one complete replacement pair. + settings_authority = None + for _attempt in range(3): + settings_authority = ( + self._codex_model_effort_authority(ctx) + if ctx.engine == "codex" else None + ) + model = _session_model(ctx) + effort = _session_effort(ctx) + if ctx.engine == "codex" and not effort: + effort = MODEL_DEFAULT_EFFORT + if model: + ctx.announced_model = model + await self.transport.send(Model( + model=model, + sid=sid, + to=cmd.client_id, + route_id=getattr(cmd, "route_id", None), + )) + if effort: + ctx.announced_effort = effort + await self.transport.send(Effort( + effort=effort, + sid=sid, + to=cmd.client_id, + route_id=getattr(cmd, "route_id", None), + )) + if (ctx.engine != "codex" + or self._codex_model_effort_authority(ctx) + == settings_authority): + break if ctx.engine == "codex": await self.transport.send(CollaborationMode( mode=getattr(ctx.sdk, "collaboration_mode", "default"), @@ -7262,6 +8214,7 @@ async def _handle_client_hello(self, cmd) -> None: EXTERNAL_TTL = 60.0 CODEX_TURN_TRACK_MAX = 512 CODEX_TURN_ATTRIBUTION_GRACE = 3.0 + CODEX_TERMINAL_CANDIDATE_GRACE = 3.0 CLAUDE_OWNED_MESSAGE_MAX = 512 CLAUDE_SILENCE_NOTICE_SECONDS = 3 * 60.0 CLAUDE_SILENCE_WARNING_SECONDS = 10 * 60.0 @@ -7463,6 +8416,7 @@ def _watch_session(self, sid: str, *, sidebar: bool = False) -> None: return self._watch.pop(victim, None) self._codex_sidebar_watches.pop(victim, None) + tail_terminal: RolloutTerminalMarker | None = None watch = { "path": path, "size": st.st_size, "file_id": (st.st_dev, st.st_ino), "engine": engine, "external_ts": 0.0, @@ -7474,7 +8428,12 @@ def _watch_session(self, sid: str, *, sidebar: bool = False) -> None: return own_turn_ids = set( getattr(ctx.sdk, "owned_turn_ids", ())) if ctx is not None else set() - tail_active, tail_partial = self._codex_tail_state(path, st.st_size) + ( + tail_active, + tail_partial, + _tail_marker, + tail_terminal, + ) = self._codex_tail_snapshot(path, st.st_size) active_turns = { turn_id: time.time() for turn_id in tail_active @@ -7502,6 +8461,7 @@ def _watch_session(self, sid: str, *, sidebar: bool = False) -> None: # of being erased by the first empty holder scan. "preserve_seeded_without_holder": bool(sidebar and active_turns), "pending_wrapper_turns": {}, + "terminal_candidates": OrderedDict(), "takeover_holders": set(), "takeover_interactive_holders": set(), "takeover_pending": None, @@ -7535,6 +8495,31 @@ def _watch_session(self, sid: str, *, sidebar: bool = False) -> None: "owned_message_ids": OrderedDict(), }) self._watch[sid] = watch + if engine == "codex" and tail_terminal is not None: + fence = CodexTerminalFence( + turn_id=tail_terminal.turn_id, + status=tail_terminal.status, + duration_ms=tail_terminal.duration_ms, + completed_at=tail_terminal.completed_at, + ) + source_identity = ( + path, int(st.st_dev), int(st.st_ino), int(st.st_size)) + if fence.status == "interrupted": + observed_at = time.time() + watch["terminal_candidates"][fence.turn_id] = ( + fence, observed_at, source_identity) + watch["last_growth_at"] = observed_at + else: + # Cold startup has no live notification to repopulate a new + # ledger. The bounded tail is enough to repair this process's + # first History immediately; defer durable persistence until a + # later incrementally observed terminal. + self._remember_codex_terminal_fence( + sid, + fence, + source_identity=source_identity, + persist=False, + ) if sidebar and engine == "codex": self._codex_sidebar_watches[sid] = None self._codex_sidebar_watches.move_to_end(sid) @@ -7995,7 +8980,12 @@ async def _prime_codex_ownership( @classmethod def _codex_tail_snapshot( cls, path: str, size: int, - ) -> tuple[set[str], bytes, Optional[tuple[str, str]]]: + ) -> tuple[ + set[str], + bytes, + Optional[tuple[str, str]], + RolloutTerminalMarker | None, + ]: """Return the latest bounded lifecycle state and exact last marker.""" try: start = max(0, size - cls.CODEX_TAIL_READ_MAX) @@ -8003,11 +8993,11 @@ def _codex_tail_snapshot( stream.seek(start) data = stream.read(cls.CODEX_TAIL_READ_MAX) except OSError: - return set(), b"", None + return set(), b"", None, None if start: _, separator, data = data.partition(b"\n") if not separator: - return set(), b"", None + return set(), b"", None, None markers = parse_turn_markers(data) # A Codex thread has one current turn. Historical crash/orphan starts can # lack a matching terminal record, so set subtraction would resurrect an @@ -8016,12 +9006,21 @@ def _codex_tail_snapshot( for kind, turn_id in markers.ordered: active = {turn_id} if kind == "task_started" else set() last_marker = markers.ordered[-1] if markers.ordered else None - return active, markers.partial, last_marker + last_terminal = ( + markers.terminals[-1] + if markers.terminals + and last_marker is not None + and last_marker[0] != "task_started" + and markers.terminals[-1].turn_id == last_marker[1] + else None + ) + return active, markers.partial, last_marker, last_terminal @classmethod def _codex_tail_state(cls, path: str, size: int) -> tuple[set[str], bytes]: """Best-effort seed when a watch begins during an external Codex turn.""" - active, partial, _last_marker = cls._codex_tail_snapshot(path, size) + active, partial, _last_marker, _terminal = cls._codex_tail_snapshot( + path, size) return active, partial @classmethod @@ -8202,6 +9201,7 @@ async def _poll_codex_watch( w["active_external_turns"].clear() w.setdefault("observed_external_turns", set()).clear() w["pending_wrapper_turns"].clear() + w.setdefault("terminal_candidates", OrderedDict()).clear() if w.get("takeover_pending"): takeover_cleared = True takeover_clear_message = "会话文件已变化,本次自动接管已取消,请重新点击接管" @@ -8265,9 +9265,59 @@ async def _poll_codex_watch( external_growth = True if data: + w["last_growth_at"] = now markers = parse_turn_markers(data, w.get("partial", b"")) w["partial"] = markers.partial visible_user_growth = markers.has_visible_user_message + source_identity = self._codex_watch_source_identity(w) + candidates: OrderedDict[ + str, + tuple[ + CodexTerminalFence, + float, + tuple[str, int, int, int], + ], + ] = w.setdefault("terminal_candidates", OrderedDict()) + # Only the newest bounded lifecycle window can repair the moving + # History head. Avoid one fsync task per historical marker if a + # watcher catches up after an unusually large append burst. + for marker in markers.terminals[-16:]: + if source_identity is None: + continue + fence = CodexTerminalFence( + turn_id=marker.turn_id, + status=marker.status, + duration_ms=marker.duration_ms, + completed_at=marker.completed_at, + ) + if fence.status == "interrupted": + # A rollout interrupt can be an internal compact/account + # handoff boundary. Keep it provisional through a bounded + # quiet window so an idle TUI/daemon FD cannot suppress a + # genuine interrupt forever; app-server TurnEnd above is + # the immediate authoritative path for managed turns. + candidates.pop(fence.turn_id, None) + candidates[fence.turn_id] = ( + fence, now, source_identity) + while len(candidates) > self.CODEX_TURN_TRACK_MAX: + candidates.popitem(last=False) + else: + candidates.pop(fence.turn_id, None) + self._remember_codex_terminal_fence( + sid, fence, source_identity=source_identity) + if markers.ordered: + last_kind, last_turn_id = markers.ordered[-1] + for candidate_turn_id in list(candidates): + if ( + last_kind == "task_started" + or candidate_turn_id != last_turn_id + ): + # A provisional interrupt is only relevant while it is + # the newest lifecycle boundary. A later task/terminal + # makes it either an internal handoff or no longer part + # of the moving History head; failing stale is safer + # than rebinding it past that newer source boundary. + candidates.pop(candidate_turn_id, None) for turn_id in markers.started: if turn_id in own_turn_ids: continue @@ -8315,6 +9365,40 @@ async def _poll_codex_watch( for turn_id in active: active[turn_id] = now + candidates = w.setdefault("terminal_candidates", OrderedDict()) + continuation_ids = set(_codex_compaction_continuation_ids(ctx)) + current_source_identity = self._codex_watch_source_identity(w) + for turn_id, ( + fence, seen_at, source_identity, + ) in list(candidates.items()): + if turn_id in continuation_ids: + candidates.pop(turn_id, None) + continue + if ( + current_source_identity is None + or current_source_identity[:3] != source_identity[:3] + or current_source_identity[3] < source_identity[3] + ): + # Rotation/truncation invalidates a provisional interrupted + # marker. Never rebind it to whatever path exists now. + candidates.pop(turn_id, None) + continue + last_growth_at = float(w.get("last_growth_at", seen_at)) + if ( + now - max(seen_at, last_growth_at) + < self.CODEX_TERMINAL_CANDIDATE_GRACE + or turn_id in active + or turn_id in pending + ): + continue + self._remember_codex_terminal_fence( + sid, + fence, + source_identity=source_identity, + ) + candidates.pop(turn_id, None) + external_growth = True + if holders or (active and writers): w["external_ts"] = now elif external_growth: @@ -8745,6 +9829,16 @@ async def _build_history( _codex_compaction_continuation_ids(ctx) if is_codex_hist and before is None else [] ) + active_codex_history_task_ids = _codex_active_continuation_ids(ctx) + if ( + is_codex_hist + and before is None + and isinstance(active_external_turns, dict) + ): + active_codex_history_task_ids.update( + turn_id for turn_id in active_external_turns + if isinstance(turn_id, str) and turn_id + ) source_path = None source_fingerprint = None source_snapshot_stable: bool | None = None @@ -8848,6 +9942,30 @@ async def _build_history( # of the real lifecycle boundary. Old cache rows have no marker and # are rebuilt once rather than trusted in either state. indexed_page = None + if ( + indexed_page is not None + and is_codex_hist + and before is None + and ( + ( + in_progress + and ( + indexed_page.in_progress is not True + or set(indexed_page.active_task_ids) + != active_codex_history_task_ids + ) + ) + or ( + not in_progress + and indexed_page.in_progress is True + ) + ) + ): + # Codex lifecycle can change without changing rollout bytes. Rebuild + # with the exact active task set rather than replaying a cached + # terminal boundary for a different continuation identity. + indexed_page = None + stale_indexed_page = False cached_full_events: list[dict] | None = None if (indexed_page is not None and detail == "full" and source_fingerprint is not None @@ -8856,10 +9974,7 @@ async def _build_history( # full-history callers hydrate each bounded turn from the # source-complete detail table instead of reparsing the rollout or # receiving a silently lossy summary page. - cached_full_events = [ - dict(row) for row in indexed_page.events - if row.get("type") in {"model", "effort"} - ] + cached_full_events = [] for turn in indexed_page.turns: turn_id = turn.get("id") if not isinstance(turn_id, str): @@ -8890,27 +10005,19 @@ async def _build_history( codex_client_aliases, turns=False, ) - if before is None and ctx is not None: - live_model = _session_model(ctx) - live_effort = _session_effort(ctx) - if live_model: - cached_events = [ - row for row in cached_events - if row.get("type") != "model" - ] - cached_events.insert( - 0, Model(model=live_model, sid=sid).model_dump(mode="json")) - if live_effort: - cached_events = [ - row for row in cached_events - if row.get("type") != "effort" - ] - model_rows = 1 if cached_events and ( - cached_events[0].get("type") == "model") else 0 - cached_events.insert( - model_rows, - Effort(effort=live_effort, sid=sid).model_dump(mode="json"), - ) + if before is None: + cached_events = _replace_history_control_rows( + cached_events, + _history_control_rows(sid, ctx), + ) + else: + # Older cache schemas could persist transcript control rows on + # pagination pages. Deep history is narrative-only and must + # match the fresh-build path, which never mutates live controls. + cached_events = [ + row for row in cached_events + if row.get("type") not in {"model", "effort"} + ] log.info( "history index hit", session_id=sid, events=len(cached_events), before=bool(before), limit=limit, @@ -9091,6 +10198,10 @@ async def _build_history( snapshot_in_progress=( in_progress and before is None ), + active_task_ids=( + tuple(sorted(active_codex_history_task_ids)) + if before is None else () + ), client_message_ids=( codex_client_aliases.native_messages), segment_client_message_ids=( @@ -9333,23 +10444,36 @@ def _tid(grp): # Prepend live control readouts only on the newest page (initial load). # Claude's SDK model is the selected alias; its transcript may instead # contain a proxy's raw upstream model and must not replace that alias. - authoritative_model = _session_model(ctx) if ctx is not None else None - history_model = authoritative_model or mdl - control_rows: list[dict] = [] - if (before is None and history_model - and (authoritative_model or is_codex_hist - or history_model.startswith("claude-"))): - model_event = Model(model=history_model, sid=sid) - control_rows.append(model_event.model_dump(mode="json")) - history_effort = _session_effort(ctx) if ctx is not None else None - if before is None and history_effort: - effort_event = Effort(effort=history_effort, sid=sid) - control_rows.append(effort_event.model_dump(mode="json")) + history_model = ( + _session_model(ctx) if ctx is not None else None + ) or mdl + control_rows = ( + _history_control_rows( + sid, + ctx, + fallback_model=history_model, + allow_fallback_model=bool( + is_codex_hist + or ( + isinstance(history_model, str) + and history_model.startswith("claude-") + ) + ), + ) + if before is None else [] + ) def make_history(selected: list[list], effective_start: int) -> History: - payload: list[dict] = [row.copy() for row in control_rows] + narrative: list[dict] = [] for group in selected: - payload.extend(ev.model_dump(mode="json") for ev in group) + narrative.extend(ev.model_dump(mode="json") for ev in group) + payload = ( + _replace_history_control_rows(narrative, control_rows) + if before is None else [ + row for row in narrative + if row.get("type") not in {"model", "effort"} + ] + ) oldest_id = _tid(selected[0]) if selected else None if (source_window_oldest_cursor is not None and selected @@ -9385,96 +10509,182 @@ def make_history(selected: list[list], effective_start: int) -> History: history = make_history(selected, effective_start) margin = min(64 * 1024, max(1024, self.cfg.ws_max_size_bytes // 16)) frame_budget = max(1024, self.cfg.ws_max_size_bytes - margin) - frame_size = len(history.model_dump_json().encode()) - if frame_size > frame_budget and len(selected) > 1: - # Find the smallest number of oldest turns to drop. Re-serializing - # after every single removal is quadratic for a legal transcript - # containing many small turns; binary search bounds this to O(log n) - # complete serializations while retaining the largest fitting page. - low, high = 1, len(selected) - 1 - best_drop = len(selected) - 1 - best_history = make_history(selected[-1:], start + best_drop) - while low <= high: - drop = (low + high) // 2 - candidate = make_history(selected[drop:], start + drop) - candidate_size = len(candidate.model_dump_json().encode()) - if candidate_size <= frame_budget: - best_drop = drop - best_history = candidate - high = drop - 1 - else: - low = drop + 1 - selected = selected[best_drop:] - effective_start = start + best_drop - history = best_history + include_live_summary = bool( + is_codex_hist and in_progress and before is None) + + def make_summary_history( + groups: list[list], + group_start: int, + ) -> tuple[History, tuple[dict, ...], tuple[dict, ...]]: + # Size the payload the browser actually receives. Measuring source- + # complete tool detail first can discard a turn even when its final + # summary page is only a few KiB. + projected = make_history(groups, group_start) + source_events = tuple(dict(row) for row in projected.events) + summary_turns = materialize_history_turns( + source_events, + include_live_detail=include_live_summary, + ) + projected.turns = [ + ConversationTurn.model_validate(turn) + for turn in summary_turns + ] + projected.detail = "summary" + projected.events = [ + row for row in projected.events + if row.get("type") in {"model", "effort"} + ] + return projected, source_events, summary_turns + + if detail == "summary": + history, detail_source_events, detail_source_turns = ( + make_summary_history(selected, effective_start) + ) frame_size = len(history.model_dump_json().encode()) + if frame_size > frame_budget and len(selected) > 1: + low, high = 1, len(selected) - 1 + best_drop = len(selected) - 1 + best_projection = make_summary_history( + selected[-1:], start + best_drop) + while low <= high: + drop = (low + high) // 2 + candidate = make_summary_history( + selected[drop:], start + drop) + candidate_size = len( + candidate[0].model_dump_json().encode()) + if candidate_size <= frame_budget: + best_drop = drop + best_projection = candidate + high = drop - 1 + else: + low = drop + 1 + selected = selected[best_drop:] + effective_start = start + best_drop + history, detail_source_events, detail_source_turns = ( + best_projection + ) + frame_size = len(history.model_dump_json().encode()) + if frame_size > frame_budget and detail_source_turns: + # Preserve identity/lifecycle and leave the source-complete + # detail expandable; only the large inline preview is omitted. + compact_turn = dict(detail_source_turns[-1]) + compact_turn["prompt"] = str( + compact_turn.get("prompt") or "")[:32 * 1024] + compact_turn["blocks"] = [] + compact_turn["detailEventCount"] = max( + 1, int(compact_turn.get("detailEventCount") or 0)) + compact_turn["detailLoaded"] = False + history.turns = [ + ConversationTurn.model_validate(compact_turn) + ] + history.oldest_id = compact_turn["id"] + history.newest_id = compact_turn["id"] + frame_size = len(history.model_dump_json().encode()) + log.warning( + "oversized history summary compacted", + session_id=sid, + frame_budget=frame_budget, + ) + if frame_size > frame_budget: + history.turns = [] + history.events = [] + history.oldest_id = None + history.newest_id = None + history.has_more = False + history.authoritative = False + history.error = "该历史摘要超过传输上限,请缩小历史页后重试" + detail_source_turns = () + else: + frame_size = len(history.model_dump_json().encode()) + if frame_size > frame_budget and len(selected) > 1: + # Retain the largest newest suffix with O(log n) serializations. + low, high = 1, len(selected) - 1 + best_drop = len(selected) - 1 + best_history = make_history(selected[-1:], start + best_drop) + while low <= high: + drop = (low + high) // 2 + candidate = make_history(selected[drop:], start + drop) + candidate_size = len(candidate.model_dump_json().encode()) + if candidate_size <= frame_budget: + best_drop = drop + best_history = candidate + high = drop - 1 + else: + low = drop + 1 + selected = selected[best_drop:] + effective_start = start + best_drop + history = best_history + frame_size = len(history.model_dump_json().encode()) - # Keep the coherent source-complete projection before applying - # transport/cache-only image compaction. GetTurnDetail/GetHistoryImage - # read this independent row; the lightweight page stores only opaque - # image metadata and therefore never reparses base64 on every switch. - detail_source_events = tuple( - dict(row) - for row in history.events - ) - detail_source_turns = materialize_history_turns( - detail_source_events, - include_live_detail=bool( - is_codex_hist and in_progress and before is None), - ) + # Keep the coherent source-complete projection before applying + # transport/cache-only image compaction. + detail_source_events = tuple( + dict(row) for row in history.events + ) + detail_source_turns = materialize_history_turns( + detail_source_events, + include_live_detail=include_live_summary, + ) - if frame_size > frame_budget: - # A single legacy turn may predate today's attachment limits. First - # omit historical image bodies. If it is still too large, preserve a - # bounded prompt + terminal marker and surface an explicit error event - # instead of silently dropping the connection or pretending completeness. - for row in history.events: - if row.get("type") == "user_msg" and row.get("images"): - row["images"] = None - frame_size = len(history.model_dump_json().encode()) if frame_size > frame_budget: - compact: list[dict] = [row.copy() for row in control_rows] for row in history.events: - if row.get("type") == "user_msg": - kept = row.copy() - prompt_text = str(kept.get("prompt", "")) - kept["prompt"] = prompt_text[:32 * 1024] - kept["images"] = None - compact.append(kept) - break + if row.get("type") == "user_msg" and row.get("images"): + row["images"] = None + frame_size = len(history.model_dump_json().encode()) + if frame_size > frame_budget: + compact: list[dict] = [row.copy() for row in control_rows] + for row in history.events: + if row.get("type") == "user_msg": + kept = row.copy() + prompt_text = str(kept.get("prompt", "")) + kept["prompt"] = prompt_text[:32 * 1024] + kept["images"] = None + compact.append(kept) + break + notice = Error( + code=ERR_INTERNAL, + message="该历史回合超过传输上限,已省略过大的回复或附件", + ) + notice.sid = sid + compact.append(notice.model_dump(mode="json")) + terminal = next( + (row for row in reversed(history.events) + if row.get("type") == "turn_end"), + None, + ) + if terminal: + compact.append(terminal) + history.events = compact + log.warning( + "oversized history turn compacted", + session_id=sid, + frame_budget=frame_budget, + ) + frame_size = len(history.model_dump_json().encode()) + if frame_size > frame_budget: notice = Error( code=ERR_INTERNAL, - message="该历史回合超过传输上限,已省略过大的回复或附件", + message="该历史回合超过传输上限,无法在当前帧限制内显示", ) notice.sid = sid - compact.append(notice.model_dump(mode="json")) - terminal = next( - (row for row in reversed(history.events) - if row.get("type") == "turn_end"), - None, - ) - if terminal: - compact.append(terminal) - history.events = compact - log.warning("oversized history turn compacted", session_id=sid, - frame_budget=frame_budget) - frame_size = len(history.model_dump_json().encode()) - if frame_size > frame_budget: - notice = Error( - code=ERR_INTERNAL, - message="该历史回合超过传输上限,无法在当前帧限制内显示", - ) - notice.sid = sid - history.events = [notice.model_dump(mode="json")] - history.oldest_id = None - history.newest_id = None + history.events = [notice.model_dump(mode="json")] + history.oldest_id = None + history.newest_id = None + + # Retain source-complete narrative in the private page cache even when + # the wire response is a summary; TurnDetail has an independent LRU. + cached_page_rows = ( + detail_source_events + if detail == "summary" + else tuple(history.events) + ) page_events = tuple( { **row, **({"images": None} if row.get("type") == "user_msg" and row.get("images") else {}), } - for row in history.events + for row in cached_page_rows ) materialized = MaterializedHistoryPage( events=page_events, @@ -9485,7 +10695,13 @@ def make_history(selected: list[list], effective_start: int) -> History: in_progress=( claude_snapshot_in_progress if not is_codex_hist and before is None - else None + else in_progress + if is_codex_hist and before is None + else None + ), + active_task_ids=( + tuple(sorted(active_codex_history_task_ids)) + if is_codex_hist and before is None else () ), ) if source_fingerprint is not None: @@ -9556,16 +10772,6 @@ def make_history(selected: list[list], effective_start: int) -> History: cwd=cwd_hint, detail=detail, ) - if detail == "summary": - history.turns = [ - ConversationTurn.model_validate(turn) - for turn in materialized.turns - ] - history.detail = "summary" - history.events = [ - row for row in history.events - if row.get("type") in {"model", "effort"} - ] if self._history_revision(sid) != revision: if allow_stale: return await self._build_history( @@ -9597,6 +10803,15 @@ def _schedule_history_refresh( detail: str, ) -> None: """Refresh one provisional moving-source page off the first-paint path.""" + # Newest-page refreshes are broadcast. Never let an omitted limit turn + # one into a full-transcript replacement that moves every reader's + # viewport; use the same lightweight moving head as normal mirroring. + refresh_limit = ( + limit + if before is not None + or (isinstance(limit, int) and limit > 0) + else self.MIRROR_LIMIT + ) ctx = self._ctx_by_sid(sid) watch = self._watch.get(sid) or {} is_codex = bool( @@ -9610,7 +10825,7 @@ def _schedule_history_refresh( key = ( sid, before or "", - limit or 0, + refresh_limit or 0, f"{refresh_cwd or ''}\0{detail}", ) current = self._history_refresh_tasks.get(key) @@ -9627,7 +10842,7 @@ async def refresh() -> None: history = await self._build_history( sid, before=before, - limit=limit, + limit=refresh_limit, cwd_hint=refresh_cwd, detail=detail, allow_stale=False, @@ -9715,8 +10930,10 @@ async def _recover_official_codex_user( native_turn_id: str, visible_turn_id: str, user_index: int, + *, + max_reverse_scan_bytes: int | None = None, ) -> UserMsg | None: - """Recover inline image bytes hidden behind expired localImage paths.""" + """Recover one exact persisted user row, including inline image bytes.""" path = await asyncio.to_thread(self._codex_rollout_for_wire, sid) if not path: return None @@ -9726,6 +10943,7 @@ async def _recover_official_codex_user( native_turn_id, visible_turn_id, user_index, + max_reverse_scan_bytes=max_reverse_scan_bytes, ) async def _recover_official_codex_users( @@ -9821,6 +11039,11 @@ async def _build_official_codex_history( else: build_seq = self._history_build_sequences.get(sid, 0) ctx = self._ctx_by_sid(sid) + # Bind this page to the live-stream position observed before any + # rollout/app-server I/O. A cold newest-page read can overlap the first + # Query, so sampling after the await could let an old idle snapshot + # masquerade as newer than the intervening running StateEvent. + live_seq = ctx.seq if ctx is not None else None watch = self._watch.get(sid) or {} active_external_turns = watch.get("active_external_turns") active_turn_ids = ( @@ -10001,7 +11224,7 @@ async def _build_official_codex_history( revision=revision, generation=self.instance_id, build_seq=build_seq, - live_seq=ctx.seq if ctx is not None else None, + live_seq=live_seq, authoritative=projection_outcome != "inconclusive", events=control_rows, turns=[ @@ -10052,9 +11275,29 @@ async def _build_requested_history( (ctx is not None and ctx.engine == "codex") or watch.get("engine") == "codex" ) + + async def with_terminal_snapshot(history: History) -> History: + # Lifecycle is provider-neutral. Whether content came from the + # experimental official pagination API or the bounded rollout + # adapter must not change how an exact terminal repairs a stale UI. + if not is_codex or before is not None: + return history + fences = await self._codex_terminal_snapshot( + sid, history.revision) + continuation_ids = set( + history.compaction_continuation_turn_ids) + history.terminal_fences = [ + fence for fence in fences + if not ( + fence.status == "interrupted" + and fence.turn_id in continuation_ids + ) + ] + return history + if is_codex and detail == "summary": if self._codex_rollout_history_active(sid): - return await self._build_history( + history = await self._build_history( sid, before=before, limit=limit, @@ -10062,6 +11305,7 @@ async def _build_requested_history( detail=detail, allow_stale=True, ) + return await with_terminal_snapshot(history) try: history = await self._build_official_codex_history( sid, before=before, limit=limit) @@ -10072,7 +11316,7 @@ async def _build_requested_history( ): self._schedule_official_codex_history_refresh( sid, limit=limit, cwd=cwd) - return history + return await with_terminal_snapshot(history) except _CodexOfficialProjectionIncomplete: revision = self._activate_codex_rollout_history(sid) log.warning( @@ -10081,7 +11325,7 @@ async def _build_requested_history( session_id=sid, revision=revision, ) - return await self._build_history( + history = await self._build_history( sid, before=before, limit=limit, @@ -10089,14 +11333,22 @@ async def _build_requested_history( detail=detail, allow_stale=True, ) + return await with_terminal_snapshot(history) except CodexHistoryUnsupported: # Older app-server builds retain the source-window rollout # parser. This is a capability fallback, never a response/error # fallback: auth, timeout and malformed official data must stay # visible instead of being mistaken for empty history. + # Pin the *summary page family* to rollout for this revision. + # Otherwise the newest rollout page advertises a stable turn-id + # cursor which the next request incorrectly hands back to the + # generation-local official cursor table. + revision = self._activate_codex_rollout_history( + sid, advance_revision=False) log.info( "official Codex history unsupported; using rollout", session_id=sid, + revision=revision, ) except ( CodexHistoryCursorError, @@ -10116,7 +11368,7 @@ async def _build_requested_history( # sequence before issuing I/O. Preserve that exact watermark # for its correlated error instead of creating a phantom build. build_seq = self._history_build_sequences.get(sid, 0) - return History( + history = History( session_id=sid, revision=revision, generation=self.instance_id, @@ -10144,9 +11396,11 @@ async def _build_requested_history( _codex_compaction_continuation_ids(ctx) if before is None else []), ) - return await self._build_history( + return await with_terminal_snapshot(history) + history = await self._build_history( sid, before=before, limit=limit, cwd_hint=cwd, detail=detail, allow_stale=True) + return await with_terminal_snapshot(history) def _schedule_official_codex_history_refresh( self, @@ -10249,22 +11503,50 @@ async def _handle_get_history(self, cmd) -> None: if snapshot is not None: turns = list(hist.turns) target_index = None - if snapshot.turn_id: + owner_turn_ids = snapshot.owner_turn_ids + if owner_turn_ids: for index in range(len(turns) - 1, -1, -1): turn = turns[index] - if snapshot.turn_id in { + if owner_turn_ids.intersection({ turn.id, turn.clientMsgId, turn.forkPointId, - }: + }): target_index = index break - # An unfinished Plan may legitimately span several steer turns - # after its owner fell off the newest page. A completed Plan - # must never be rebound to an unrelated newest turn: that would - # resurrect it after the user already started another task. - if target_index is None and not snapshot.complete: - target_index = len(turns) - 1 + terminal_fence = next(( + fence + for fence in hist.terminal_fences + if fence.turn_id in owner_turn_ids + ), None) + if ( + terminal_fence is not None + and snapshot.terminal_status is None + ): + try: + repaired = await asyncio.to_thread( + self._session_plans.mark_terminal, + sid, + turn_id=terminal_fence.turn_id, + status=( + "interrupted" + if terminal_fence.status == "interrupted" + else "failed" + if terminal_fence.status == "failed" + else "succeeded" + ), + ) + if repaired is not None: + snapshot = repaired + except SessionPlanStoreError: + log.warning( + "Codex historical plan terminal could not be repaired", + session_id=sid, + ) + # A durable Plan is only safe to project onto its exact native + # owner. If that turn is outside the newest summary page, keep + # the snapshot private instead of attaching it to unrelated + # work that happened later. if target_index is not None: target = turns[target_index] block = snapshot.as_process_block() @@ -12030,18 +13312,45 @@ async def _handle_get_models(self, cmd) -> None: default_effort = None defaults_cwd = None if engine == "codex": - # config.toml's `model` = what a NEW session (and the terminal codex) - # starts on. Only offer it if the catalog actually has it, so a stale - # config can't preselect a model that isn't there. - cfg_model = await asyncio.to_thread( - codex_model, - "", - **({} if codex_home is None else { - "codex_home": codex_home, - }), - ) - if cfg_model and any(m["id"] == cfg_model for m in models): - default_model = cfg_model + # Report the same profile-scoped defaults _spawn will apply. A stale + # configured model follows this account's catalog default; an empty + # catalog preserves config because availability is then unknown. + cfg_model, cfg_effort = await asyncio.gather( + asyncio.to_thread( + codex_model, + "", + **({} if codex_home is None else { + "codex_home": codex_home, + }), + ), + asyncio.to_thread( + codex_effort, + "", + **({} if codex_home is None else { + "codex_home": codex_home, + }), + ), + ) + default_model, _ = await self._resolve_codex_profile_model( + codex_profile, + cfg_model or None, + catalog=models, + ) + selected = next(( + item for item in models + if item.get("id") == default_model + ), None) + supported_efforts = ( + selected.get("efforts") if isinstance(selected, dict) else [] + ) + if cfg_effort and ( + not supported_efforts or cfg_effort in supported_efforts + ): + default_effort = cfg_effort + elif isinstance(selected, dict): + value = selected.get("default_effort") + if isinstance(value, str) and value: + default_effort = value elif engine in {"cc", "claude"}: defaults_cwd = getattr(cmd, "cwd", None) or self.cfg.cc_cwd default_model, default_effort = ( @@ -12447,22 +13756,21 @@ async def _handle_set_model(self, cmd): await ctx.sdk.set_model(cmd.model) await self._refresh_pending_claude_work_baseline(ctx) await self._persist_claude_session_controls(ctx) + if ctx.engine == "codex": + # Model and nullable effort form one authoritative app-server + # settings snapshot. Resolution may await config/read or the + # model catalog, so publish them together only after rechecking + # that authority; emitting Model first can pair an old model + # with a newer thread/settings effort during that await. + responses: list[object] = [] + await self._publish_codex_model_effort( + ctx, force=True, published=responses) + return tuple(responses) applied_model = getattr(ctx.sdk, "model", None) or cmd.model ctx.announced_model = applied_model model_event = Model(model=applied_model) await self._emit(ctx, model_event) - responses = [model_event] - if ctx.engine == "codex": - # thread/settings/updated is authoritative. app-server may adjust - # effort when the selected model cannot use the old level; never - # overwrite that decision with a Web-side guess or stale chip. - applied = getattr(ctx.sdk, "effort", None) - if applied and applied != ctx.announced_effort: - ctx.announced_effort = applied - effort_event = Effort(effort=applied) - await self._emit(ctx, effort_event) - responses.append(effort_event) - return tuple(responses) + return (model_event,) except Exception as e: log.exception("set_model failed", error=str(e)) error = ( @@ -12470,8 +13778,178 @@ async def _handle_set_model(self, cmd): if getattr(ctx.sdk, "is_claude_broker", False) else Error(code=ERR_INTERNAL, message="模型切换未完成,请重试。") ) - await self._emit(ctx, error) - return error + await self._emit(ctx, error) + return error + + async def _resolve_codex_session_effort( + self, + ctx: SessionContext, + *, + preferred: Optional[str] = None, + ) -> Optional[str]: + if ctx.engine != "codex": + return _session_effort(ctx) + async with ctx.codex_effort_resolve_lock: + return await self._resolve_codex_session_effort_locked( + ctx, preferred=preferred) + + async def _resolve_codex_session_effort_locked( + self, + ctx: SessionContext, + *, + preferred: Optional[str] = None, + ) -> Optional[str]: + """Resolve app-server's nullable thread effort into a stable readout. + + ``reasoningEffort: null`` means "no thread override", not "the control + is still loading". Prefer a wrapper-owned explicit choice (notably BTW's + low setting), then app-server's effective configured fallback. If no + configured level exists, publish a truthful model-default sentinel while + leaving ``sdk.effort`` unset so turn/start keeps following app-server. + """ + # Some replay-only contexts intentionally carry no live Codex control + # adapter. They can still stream their ring, but there is no app-server + # state to resolve or mutable handle on which to cache presentation. + if not hasattr(ctx.sdk, "effort"): + return ctx.announced_effort + sdk = ctx.sdk + model = _session_model(ctx) + raw_cwd = getattr(sdk, "_cwd", None) or ctx.cwd + display_cwd = ( + os.path.realpath(raw_cwd) + if isinstance(raw_cwd, str) and raw_cwd else None + ) + display_generation = getattr(sdk, "_generation", None) + settings_revision = getattr(sdk, "_thread_settings_revision", None) + requested = preferred.strip() if isinstance(preferred, str) else "" + explicit = bool(requested) + current = getattr(sdk, "effort", None) + if not explicit and isinstance(current, str) and current.strip(): + current = current.strip() + setattr(sdk, "display_effort", current) + setattr(sdk, "display_effort_model", model) + setattr(sdk, "display_effort_cwd", display_cwd) + setattr(sdk, "display_effort_generation", display_generation) + setattr(sdk, "_display_effort_retry_at", None) + return current + + cached = getattr(sdk, "display_effort", None) + if (not explicit and isinstance(cached, str) and cached.strip() + and getattr(sdk, "display_effort_model", None) == model + and getattr(sdk, "display_effort_cwd", None) == display_cwd + and getattr(sdk, "display_effort_generation", None) + == display_generation): + cached = cached.strip() + retry_at = getattr(sdk, "_display_effort_retry_at", None) + if retry_at is None or (isinstance(retry_at, (int, float)) + and asyncio.get_running_loop().time() < retry_at): + return cached + + profile = self._codex_profile_for_ctx(ctx) + home = self._codex_home(profile) + kwargs = {} if home is None else {"codex_home": home} + resolved: Optional[str] = None + if explicit: + try: + resolved = await asyncio.wait_for( + clamp_effort( + model, requested, **kwargs), + timeout=CODEX_EFFORT_RESOLVE_TIMEOUT_SECONDS, + ) + except Exception as exc: + # The wrapper will send this exact explicit choice on the next + # turn. Catalog availability must not erase a user's setting. + log.warning( + "Codex explicit effort could not be catalog-clamped", + model=model, + requested=requested, + error_type=type(exc).__name__, + ) + resolved = requested + else: + configured_default = getattr( + sdk, "configured_default_effort", None) + if callable(configured_default): + try: + configured = await asyncio.wait_for( + configured_default(), + timeout=CODEX_EFFORT_RESOLVE_TIMEOUT_SECONDS, + ) + if isinstance(configured, str) and configured.strip(): + resolved = configured + except Exception as exc: + log.warning( + "Codex configured effort could not be resolved", + model=model, + error_type=type(exc).__name__, + ) + if not resolved: + try: + resolved = await asyncio.wait_for( + default_effort_for(model, **kwargs), + timeout=CODEX_EFFORT_RESOLVE_TIMEOUT_SECONDS, + ) + except Exception as exc: + log.warning( + "Codex model-default effort could not be resolved", + model=model, + error_type=type(exc).__name__, + ) + + current_raw_cwd = getattr(sdk, "_cwd", None) or ctx.cwd + current_cwd = ( + os.path.realpath(current_raw_cwd) + if isinstance(current_raw_cwd, str) and current_raw_cwd else None + ) + authority_changed = ( + ctx.sdk is not sdk + or _session_model(ctx) != model + or current_cwd != display_cwd + or getattr(sdk, "_generation", None) != display_generation + or getattr(sdk, "_thread_settings_revision", None) + != settings_revision + ) + if authority_changed: + # A newer thread/settings snapshot or a new model/cwd/process owns + # the readout. Never install the completed old probe, and never + # reapply ``preferred`` over that newer authority. A concrete live + # value wins immediately; nullable state truthfully falls back to + # model-default until the next scoped probe resolves it. + return _session_effort(ctx) or MODEL_DEFAULT_EFFORT + + if isinstance(resolved, str) and resolved.strip(): + resolved = resolved.strip() + # A concrete configured default is presentation state, not a thread + # override. Keep sdk.effort null unless the caller supplied an + # explicit choice; otherwise turn/start would silently pin the + # current config and stop following later app-server defaults. + if explicit: + sdk.effort = resolved + sdk.applied_effort = resolved + setattr(sdk, "display_effort", resolved) + setattr(sdk, "display_effort_model", model) + setattr(sdk, "display_effort_cwd", display_cwd) + setattr(sdk, "display_effort_generation", display_generation) + setattr( + sdk, + "_display_effort_retry_at", + None if explicit else ( + asyncio.get_running_loop().time() + + CODEX_EFFORT_RESOLVE_RETRY_SECONDS + ), + ) + return resolved + setattr(sdk, "display_effort", MODEL_DEFAULT_EFFORT) + setattr(sdk, "display_effort_model", model) + setattr(sdk, "display_effort_cwd", display_cwd) + setattr(sdk, "display_effort_generation", display_generation) + setattr( + sdk, + "_display_effort_retry_at", + asyncio.get_running_loop().time() + + CODEX_EFFORT_RESOLVE_RETRY_SECONDS, + ) + return MODEL_DEFAULT_EFFORT async def _apply_codex_effort(self, ctx, effort: Optional[str]) -> Optional[str]: """Clamp `effort` to what ctx's codex model supports and apply it to the live @@ -12494,8 +13972,16 @@ async def _apply_codex_effort(self, ctx, effort: Optional[str]) -> Optional[str] return applied # Persist through app-server's official thread setting. turn/start repeats # it defensively, but a restart/eviction no longer loses the selection. - await ctx.sdk.set_effort(applied) - return getattr(ctx.sdk, "effort", None) or applied + authoritative_update = await ctx.sdk.set_effort(applied) + # A real thread/settings/updated snapshot may clamp or otherwise adjust + # the requested level. Honor that authoritative value; only isolated + # fakes/older servers which left no value need the requested fallback. + authoritative = getattr(ctx.sdk, "effort", None) + if authoritative_update is True: + return await self._resolve_codex_session_effort(ctx) + if isinstance(authoritative, str) and authoritative.strip(): + return await self._resolve_codex_session_effort(ctx) + return await self._resolve_codex_session_effort(ctx, preferred=applied) async def _handle_set_effort(self, cmd): # cc SDK: effort is a spawn-time flag (--effort), so record it and let @@ -12624,16 +14110,12 @@ async def _refresh_codex_collaboration_mode( # corresponding turn_context until the next turn starts. Reading the # rollout here would therefore put an old model/effort back into the # handle (and Web) just after a successful Remote or TUI switch. - model = getattr(ctx.sdk, "model", None) - if (isinstance(model, str) and model - and ctx.announced_model != model): - ctx.announced_model = model - await self._emit(ctx, Model(model=model)) - effort = getattr(ctx.sdk, "effort", None) - if (isinstance(effort, str) and effort - and ctx.announced_effort != effort): - ctx.announced_effort = effort - await self._emit(ctx, Effort(effort=effort)) + # Model and effort are one app-server settings snapshot. Resolving a + # nullable effort can await config/read, during which a newer native + # settings notification may replace both fields. Publish the pair + # through the authority-checked path instead of mixing the model + # captured before that await with the effort captured afterwards. + await self._publish_codex_model_effort(ctx) approval = getattr(ctx.sdk, "approval", None) if (approval in CODEX_PERMISSION_MODES and ctx.announced_perm != approval): @@ -12666,14 +14148,12 @@ async def _refresh_codex_collaboration_mode( model = settings.get("model") if isinstance(model, str) and model and ctx.sdk.model != model: ctx.sdk.model = model - ctx.announced_model = model - await self._emit(ctx, Model(model=model)) effort = settings.get("effort") if isinstance(effort, str) and effort and ctx.sdk.effort != effort: ctx.sdk.effort = effort ctx.sdk.applied_effort = effort - ctx.announced_effort = effort - await self._emit(ctx, Effort(effort=effort)) + setattr(ctx.sdk, "display_effort", effort) + await self._publish_codex_model_effort(ctx) approval = settings.get("approval_policy") if (ctx.space != "work" and approval in CODEX_PERMISSION_MODES and ctx.sdk.approval != approval): @@ -13012,10 +14492,171 @@ async def _handle_set_permission_profile(self, cmd): await self._emit(ctx, error) return error + @staticmethod + def _codex_model_effort_authority( + ctx: SessionContext, + ) -> tuple[object, ...]: + """Identity of one live Codex model/effort settings snapshot.""" + sdk = ctx.sdk + raw_cwd = getattr(sdk, "_cwd", None) or ctx.cwd + cwd = ( + os.path.realpath(raw_cwd) + if isinstance(raw_cwd, str) and raw_cwd else None + ) + # Include both the native settings revision and the actual display + # fields. Real Codex handles advance the revision, while lightweight + # adapters/tests may only replace their public values. + return ( + id(sdk), + _session_model(ctx), + cwd, + getattr(sdk, "_generation", None), + getattr(sdk, "_thread_settings_revision", None), + getattr(sdk, "effort", None), + getattr(sdk, "display_effort", None), + getattr(sdk, "display_effort_model", None), + getattr(sdk, "display_effort_cwd", None), + getattr(sdk, "display_effort_generation", None), + ) + + async def _publish_codex_model_effort( + self, + ctx: SessionContext, + *, + require_resident: bool = False, + resolve_effort: bool = True, + force: bool = False, + published: Optional[list[object]] = None, + ) -> bool: + """Publish model/effort adopted by the current app-server generation.""" + def authority() -> tuple[object, ...]: + return self._codex_model_effort_authority(ctx) + + async def publish_snapshot( + model: Optional[str], + effort: Optional[str], + ) -> None: + # Model and effort are one display snapshot. Holding emit_lock keeps + # these frames adjacent to every other wrapper event, but app-server + # notifications may still mutate the handle while transport.send() + # yields. Finish the captured pair before observing that mutation; + # the caller will then publish a complete replacement pair. + publish_pair = bool(model and effort and ( + force + or ctx.announced_model != model + or ctx.announced_effort != effort + )) + if publish_pair: + ctx.announced_model = model + model_event = Model(model=model) + await self._emit_locked(ctx, model_event) + if published is not None: + published.append(model_event) + + ctx.announced_effort = effort + effort_event = Effort(effort=effort) + await self._emit_locked(ctx, effort_event) + if published is not None: + published.append(effort_event) + return + + # Replay-only/lightweight adapters can lack one side of the pair. + # Preserve the useful control without inventing a model id. + if model and (force or ctx.announced_model != model): + ctx.announced_model = model + model_event = Model(model=model) + await self._emit_locked(ctx, model_event) + if published is not None: + published.append(model_event) + if effort and (force or ctx.announced_effort != effort): + ctx.announced_effort = effort + effort_event = Effort(effort=effort) + await self._emit_locked(ctx, effort_event) + if published is not None: + published.append(effort_event) + + # Resolving a nullable effort can await config/read and model/list. Do + # not publish the model before that await: thread/settings/updated may + # replace both values in the meantime, which would otherwise put an old + # Model and a new Effort next to each other on the wire. Retry a moving + # authority a few times. Once a stable snapshot is selected, always + # finish its complete pair before checking for a newer authority. + for _attempt in range(3): + if require_resident and not self._is_resident_context(ctx): + return False + starting_authority = authority() + effort = ( + (await self._resolve_codex_session_effort(ctx)) + if resolve_effort else _session_effort(ctx) + ) or MODEL_DEFAULT_EFFORT + if require_resident and not self._is_resident_context(ctx): + return False + model = _session_model(ctx) + resolved_authority = authority() + if resolved_authority != starting_authority: + continue + async with ctx.emit_lock: + if require_resident and not self._is_resident_context(ctx): + return False + if authority() != resolved_authority: + continue + await publish_snapshot(model, effort) + # Eviction cannot cancel an in-flight transport write. Finish the + # selected complete pair, then tell the reconnect caller that the + # context no longer owns a resident route. + if require_resident and not self._is_resident_context(ctx): + return False + if authority() == resolved_authority: + return True + + # Sustained churn can invalidate every asynchronous probe before it is + # publishable. A reliable SetModel must not be ACKed with an empty + # response cache in that case. Take one non-awaiting, truthful snapshot: + # a null override is represented as model-default, and any subsequent + # refresh can replace this complete pair normally. + if require_resident and not self._is_resident_context(ctx): + return False + async with ctx.emit_lock: + if require_resident and not self._is_resident_context(ctx): + return False + await publish_snapshot( + _session_model(ctx), + _session_effort(ctx) or MODEL_DEFAULT_EFFORT, + ) + if require_resident and not self._is_resident_context(ctx): + return False + log.warning( + "Codex model/effort published a bounded fallback during settings churn", + session_id=ctx.session_id, + ) + return True + + def _schedule_codex_model_effort_publish( + self, ctx: SessionContext, + ) -> None: + """Resolve a reconnected turn's nullable effort without blocking it.""" + async def publish() -> None: + try: + await self._publish_codex_model_effort( + ctx, require_resident=True) + except asyncio.CancelledError: + raise + except Exception as exc: + log.warning( + "Codex model/effort background refresh failed", + session_id=ctx.session_id, + error_type=type(exc).__name__, + ) + + task = asyncio.create_task(publish()) + self._codex_effort_publish_tasks.add(task) + task.add_done_callback(self._codex_effort_publish_tasks.discard) + async def _republish_codex_execution_controls( self, ctx: SessionContext, ) -> None: """Reassert the controls proven by a recovered Codex connection.""" + await self._publish_codex_model_effort(ctx) permission_mode = _session_permission_mode(ctx) if permission_mode in CODEX_PERMISSION_MODES: ctx.announced_perm = permission_mode @@ -13056,6 +14697,11 @@ async def _handle_set_web_search(self, cmd): await ctx.sdk.set_web_search(cmd.mode) await self._stamp_codex_daemon_epoch(ctx) await self._persist_codex_session_controls(ctx) + # set_web_search resumes the app-server and may adopt a different + # authoritative model/null effort from that generation. Publish + # those coupled controls before the requested search result so the + # Web UI never keeps the pre-reconnect chip indefinitely. + await self._publish_codex_model_effort(ctx) applied = _session_web_search(ctx) if applied not in CODEX_WEB_SEARCH_MODES: raise RuntimeError( @@ -13548,6 +15194,7 @@ async def _run_codex_spontaneous_turn( stream_closed = False repair_history = False seen_user_item_ids: set[str] = set() + anchor_recovery_attempted = False def start_restart_watch() -> None: nonlocal restart_watch_task @@ -13571,9 +15218,60 @@ async def cancel_restart_watch() -> None: restart_watch_task = None async def ensure_automatic_anchor() -> None: - """Anchor only after output proves this turn has no earlier user item.""" + """Recover a missed CLI user row or anchor true automatic output.""" + nonlocal logical_msg_id, anchor_recovery_attempted if ctx.codex_spontaneous_anchor_id is not None: return + anchor_recovery_attempted = True + sid = self._ctx_wire_sid(ctx) + recovered = None + if sid: + try: + recovered = await self._recover_official_codex_user( + sid, + current_turn_id, + logical_msg_id, + 0, + max_reverse_scan_bytes=( + self.CODEX_LIVE_USER_RECOVERY_SCAN_BYTES + ), + ) + except Exception as exc: + # The live stream remains authoritative. A bounded local + # recovery failure may hide only the prompt until the final + # newest-page refresh converges from persisted history. + log.warning( + "Codex live user recovery failed", + session_id=sid, + turn_id=current_turn_id, + error_type=type(exc).__name__, + ) + if recovered is not None and recovered.prompt: + if recovered.client_msg_id is not None: + await self._remember_codex_live_user_alias( + ctx, + CodexLiveUserMessage( + message_id=recovered.msg_id, + turn_id=current_turn_id, + prompt=recovered.prompt, + client_id=recovered.client_msg_id, + ), + ) + seen_user_item_ids.add(recovered.msg_id) + ctx.codex_spontaneous_anchor_id = recovered.msg_id + logical_msg_id = recovered.msg_id + ctx.active_msg_id = recovered.msg_id + self._rebind_codex_turn( + ctx, + current_turn_id, + recovered.msg_id, + ) + await self._emit(ctx, recovered) + await self._emit(ctx, TurnBinding( + msg_id=recovered.msg_id, + turn_id=current_turn_id, + )) + return ctx.codex_spontaneous_anchor_id = logical_msg_id ctx.active_msg_id = logical_msg_id await self._emit(ctx, UserMsg( @@ -13749,7 +15447,9 @@ async def handoff_account_switch( "status": "interrupted", }}, } - for event in translator.feed(synthetic_old_terminal): + for event in translator.feed( + synthetic_old_terminal, authoritative_terminal=False, + ): if isinstance(event, (Error, TurnEnd)): continue if isinstance(event, StateEvent) and event.detail: @@ -13953,8 +15653,8 @@ async def interrupted_handoff_result() -> Optional[str]: ) # A newly-set or modified Goal objective is the user's durable - # request. Ordinary automatic turns remain unanchored until the first - # output frame proves no official CLI user item preceded it. + # request. Other spontaneous turns remain unanchored until their + # first output lets us recover any missed official CLI user row. if recovered_msg_id is None and goal_prompt: ctx.codex_spontaneous_anchor_id = turn_id logical_msg_id = turn_id @@ -14076,7 +15776,9 @@ async def interrupted_handoff_result() -> Optional[str]: "error": {"message": "Codex app-server connection closed"}, }}, } - for event in translator.feed(synthetic): + for event in translator.feed( + synthetic, authoritative_terminal=False, + ): if isinstance(event, Error) and event.msg_id is None: event.msg_id = logical_msg_id await self._emit(ctx, event) @@ -14133,6 +15835,20 @@ async def interrupted_handoff_result() -> Optional[str]: if repair_history: await self._repair_codex_projection_after_overflow( ctx, current_turn_id) + elif terminal_seen and anchor_recovery_attempted: + sid = self._ctx_wire_sid(ctx) + if sid: + # The early bounded lookup can race rollout persistence. + # Once the real terminal has unlocked the session, force a + # small authoritative moving-head rebuild instead of waiting + # for a later focus/history request to reveal the prompt. + self._schedule_history_refresh( + sid, + before=None, + limit=self.MIRROR_LIMIT, + cwd=None, + detail="summary", + ) async def _run_codex_review_turn( self, ctx: SessionContext, turn_id: str, @@ -14256,6 +15972,7 @@ async def emit_failure(code: str, message: str, *, interrupted: bool) -> None: ) try: await ctx.sdk.force_reconnect(ctx.session_id, ctx.cwd) + await self._publish_codex_model_effort(ctx) except Exception as exc: log.exception( "codex review reconnect failed", error=str(exc)) @@ -14832,10 +16549,14 @@ async def _handle_dismiss_goal(self, cmd) -> None: if goal_id is not None and goal_id == cmd.goal_id: await asyncio.to_thread( self._session_presentation.dismiss_goal, + ctx.engine, self._ctx_wire_sid(ctx) or ctx.key, goal_id, ) - event = GoalState(goal=goal) + event = GoalState( + goal=goal, + request_id=getattr(cmd, "cmd_id", None), + ) await self._emit(ctx, event) return event except SessionPresentationStoreError: @@ -14880,8 +16601,25 @@ async def _handle_acknowledge_completion(self, cmd) -> None: if ctx is not None else sid ) + engine = ctx.engine if ctx is not None else ( + await asyncio.to_thread( + self._session_presentation.completion_engine, + session_id, + cmd.completion_id, + ) + ) + if engine is None: + error = Error( + code=ERR_PROTOCOL, + message="无法确认该任务完成状态所属的引擎,请打开会话后重试", + request_id=getattr(cmd, "cmd_id", None), + to=getattr(cmd, "client_id", None), + ) + await self._emit_to_sid(sid, error) + return error snapshot = await asyncio.to_thread( self._session_presentation.acknowledge_completion, + engine, session_id, cmd.completion_id, ) @@ -16795,6 +18533,14 @@ async def _handle_list_sessions(self, cmd) -> None: self._work.for_engine("claude").records_by_session) pinned_ids = (self._session_pins.ids("claude") if self._session_pins is not None else frozenset()) + await self._claim_legacy_presentation_from_claude_catalog({ + info.session_id + for info in infos + if ( + info.session_id not in blocked + and info.session_id not in private_btw_ids + ) + }) sessions = [] for info in infos: record = work_records.get(info.session_id) @@ -16821,7 +18567,7 @@ async def _handle_list_sessions(self, cmd) -> None: state=resident_state.get(info.session_id), engine="claude", space=space, work_id=record.work_id if record else None, - **self._session_presentation_fields(info.session_id), + **self._session_presentation_fields("claude", info.session_id), )) if space == "code" and self._claude_broker_enabled: # `claude-remote new` reserves the native session UUID before @@ -16859,7 +18605,7 @@ async def _handle_list_sessions(self, cmd) -> None: pinned=broker_sid in pinned_ids, engine="claude", space="code", - **self._session_presentation_fields(broker_sid), + **self._session_presentation_fields("claude", broker_sid), )) known.add(broker_sid) for session in sessions: @@ -16915,12 +18661,6 @@ def _on_codex_thread_started_hint( return now = time.monotonic() - cutoff = now - self.CODEX_THREAD_STARTED_HINT_TTL_SECONDS - while self._codex_thread_started_hints: - first_key = next(iter(self._codex_thread_started_hints)) - if self._codex_thread_started_hints[first_key] > cutoff: - break - self._codex_thread_started_hints.popitem(last=False) key = (profile.id, native_thread_id) if key in self._codex_thread_started_hints: self._codex_thread_started_hints.move_to_end(key) @@ -16994,7 +18734,33 @@ async def _refresh_codex_session_catalog(self) -> list[dict]: row for row in cached[1] if row.get("codex_profile_id") in failed_profile_ids ] - raw = self._bounded_codex_profile_catalog([*raw, *stale_rows]) + # _read_codex_profile_catalog returns public rows with its internal + # retention marker stripped. Restore that marker only for exact + # candidates before this second cache merge; otherwise 400 newer + # stale rows can evict an old but currently resident session while + # thread/list is unavailable. + exact_candidates = self._codex_exact_catalog_candidates() + prioritized_raw: list[dict] = [] + for row in raw: + item = dict(row) + profile_id = item.get("codex_profile_id") + native_sid = item.get("native_session_id") + if ( + isinstance(profile_id, str) + and profile_id in failed_profile_ids + and isinstance(native_sid, str) + ): + info = exact_candidates.get(profile_id, {}).get(native_sid) + priority = int((info or {}).get("priority") or 0) + if priority: + item[_CODEX_CATALOG_PRIORITY] = priority + prioritized_raw.append(item) + raw = self._bounded_codex_profile_catalog([ + *prioritized_raw, *stale_rows, + ]) + # Exact state-DB repair above is authoritative. Keep the rollout-backed + # overlay as a bounded compatibility fallback for app-server versions + # whose state DB has not materialized a just-forked child yet. raw = await asyncio.to_thread( self._overlay_completed_codex_forks, raw) self._codex_session_profile_errors = profile_errors @@ -17172,6 +18938,202 @@ def _find_completed_codex_fork_rollouts( break return found + def _codex_exact_catalog_candidates( + self, + ) -> dict[str, dict[str, dict[str, object]]]: + """Return exact native ids whose owning profile may need list repair. + + Resident sessions cover the create/list race. The durable fork journal + covers reconnects where app-server still addresses a child through + ``thread/read`` but omits its empty-preview row from ``thread/list``. + Profile-scoped native CLI start hints cover the same preview race before + a foreign TUI thread enters the ordinary catalog. Parent and child must + resolve through the same profile namespace; a malformed or stale + cross-profile record is never repaired into either account's public + catalog. + """ + candidates: dict[str, dict[str, dict[str, object]]] = { + profile.id: {} for profile in self._codex_profiles + } + + def candidate(profile_id: str, native_sid: str) -> dict[str, object]: + return candidates[profile_id].setdefault(native_sid, { + "forked_from_id": None, + "priority": 0, + "created_at": 0.0, + "hinted": False, + "hint_fresh": False, + "resident": False, + }) + + now_wall = time.time() + try: + # Collect through the journal's own hard bound, then let exact DB + # lookup and the final account-fair catalog bound select rows. A + # global 400-entry cut here would let one busy account starve every + # hidden fork belonging to a quieter sibling profile. + completed = self._codex_forks.completed_children() + except ForkJournalError as exc: + log.warning( + "Codex fork catalog repair journal unavailable", + error_type=type(exc).__name__, + ) + completed = [] + for entry in completed: + child_wire = entry.get("session_id") + parent_wire = entry.get("parent_session_id") + if not isinstance(child_wire, str) or not isinstance(parent_wire, str): + continue + try: + child_profile, child_native = self._codex_target(child_wire) + parent_profile, _parent_native = self._codex_target(parent_wire) + except ValueError: + continue + if child_profile.id != parent_profile.id: + log.warning( + "cross-profile Codex fork catalog record ignored", + child_profile_id=child_profile.id, + parent_profile_id=parent_profile.id, + ) + continue + info = candidate(child_profile.id, child_native) + if info.get("forked_from_id") is None: + info["forked_from_id"] = parent_wire + created_at = entry.get("created_at") + if ( + isinstance(created_at, (int, float)) + and not isinstance(created_at, bool) + and math.isfinite(created_at) + and created_at >= 0 + ): + info["created_at"] = max( + float(info.get("created_at") or 0.0), + float(created_at), + ) + if now_wall - created_at <= self.CODEX_THREAD_STARTED_HINT_TTL_SECONDS: + info["priority"] = max( + int(info.get("priority") or 0), 1) + + now_monotonic = time.monotonic() + for (profile_id, native_sid), hinted_at in tuple( + self._codex_thread_started_hints.items() + ): + if profile_id not in candidates: + continue + try: + self._codex_wire_sid(profile_id, native_sid) + except ValueError: + continue + info = candidate(profile_id, native_sid) + info["hinted"] = True + info["hint_fresh"] = ( + isinstance(hinted_at, (int, float)) + and not isinstance(hinted_at, bool) + and math.isfinite(hinted_at) + and now_monotonic - hinted_at + <= self.CODEX_THREAD_STARTED_HINT_TTL_SECONDS + ) + if ( + isinstance(hinted_at, (int, float)) + and not isinstance(hinted_at, bool) + and math.isfinite(hinted_at) + ): + # Priority already separates hints from journal/resident rows. + # Preserve monotonic hint order within this class so the exact + # SQLite read's 512-id bound selects the newest starts first. + info["created_at"] = float(hinted_at) + info["priority"] = max( + int(info.get("priority") or 0), 2) + + for ctx in self.sessions.values(): + if ctx.engine != "codex" or ctx.btw or not ctx.session_id: + continue + try: + profile = self._codex_profile_for_ctx(ctx) + wire_sid = self._codex_wire_sid(profile, ctx.session_id) + routed_profile, native_sid = self._codex_target(wire_sid) + except (RuntimeError, ValueError): + continue + if routed_profile.id != profile.id: + continue + info = candidate(profile.id, native_sid) + info["resident"] = True + info["priority"] = 3 + info["created_at"] = max( + float(info.get("created_at") or 0.0), + now_wall, + ) + return candidates + + async def _codex_exact_catalog_rpc_rows( + self, + profile: CodexProfile, + native_session_ids: list[str], + ) -> list[dict]: + """Resolve uncertain exact rows through one account-scoped app-server. + + This is a compatibility fallback for state DB schemas which cannot + prove provider ownership. It is intentionally exact-id-only and bounded + by both the SQLite candidate cap and a small per-process request batch. + The batches share one deadline so a broken app-server cannot multiply + the sidebar delay. + """ + bounded_ids = native_session_ids[:CODEX_EXACT_CATALOG_MAX_IDS] + if not bounded_ids: + return [] + home = self._codex_home(profile) + deadline = ( + asyncio.get_running_loop().time() + + self.CODEX_EXACT_CATALOG_RPC_TIMEOUT_SECONDS + ) + results: list[object] = [] + for offset in range( + 0, len(bounded_ids), self.CODEX_EXACT_CATALOG_RPC_BATCH_IDS, + ): + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + break + chunk_ids = bounded_ids[ + offset:offset + self.CODEX_EXACT_CATALOG_RPC_BATCH_IDS] + requests = [( + "thread/read", + {"threadId": native_sid, "includeTurns": False}, + ) for native_sid in chunk_ids] + try: + chunk = ( + await codex_rpc_batch(requests, timeout=remaining) + if home is None + else await codex_rpc_batch( + requests, timeout=remaining, codex_home=home) + ) + except Exception as exc: + log.warning( + "Codex exact catalog RPC repair unavailable", + profile_id=profile.id, + error_type=type(exc).__name__, + ) + break + results.extend(chunk) + + provider = await asyncio.to_thread( + codex_current_provider, + **({} if home is None else {"codex_home": home}), + ) + provider = provider.strip() + rows: list[dict] = [] + for native_sid, result in zip(bounded_ids, results): + if isinstance(result, Exception) or not isinstance(result, dict): + continue + thread = result.get("thread") + if not isinstance(thread, dict) or thread.get("id") != native_sid: + continue + if provider and thread.get("modelProvider") != provider: + continue + row = codex_thread_catalog_row(thread) + if row is not None: + rows.append(row) + return rows + def _bounded_codex_profile_catalog( self, candidates: list[dict], ) -> list[dict]: @@ -17189,12 +19151,14 @@ def _bounded_codex_profile_catalog( ): continue seen.add((profile_id, session_id)) - buckets[profile_id].append(item) + buckets[profile_id].append(dict(item)) rows_by_profile = list(buckets.values()) for profile_rows in rows_by_profile: profile_rows.sort( - key=lambda row: _codex_catalog_sort_key( - row.get("last_modified")), + key=lambda row: ( + int(row.get(_CODEX_CATALOG_PRIORITY) or 0), + _codex_catalog_sort_key(row.get("last_modified")), + ), reverse=True, ) @@ -17210,14 +19174,18 @@ def _bounded_codex_profile_catalog( remaining = True item = profile_rows[index] indices[position] += 1 + public_item = { + key: value for key, value in item.items() + if key != _CODEX_CATALOG_PRIORITY + } encoded_size = len(json.dumps( - item, + public_item, ensure_ascii=False, separators=(",", ":"), ).encode("utf-8")) + 64 if row_bytes + encoded_size > self.CODEX_SESSION_LIST_MAX_BYTES: continue - rows.append(item) + rows.append(public_item) row_bytes += encoded_size if len(rows) >= self.CODEX_SESSION_LIST_MAX_ROWS: break @@ -17246,9 +19214,12 @@ async def _read_codex_profile_catalog( 200, math.ceil(self.CODEX_SESSION_LIST_MAX_ROWS / (2 * profile_count)), )) + exact_candidates = self._codex_exact_catalog_candidates() async def read(profile: CodexProfile): home = self._codex_home(profile) + profile_candidates = exact_candidates.get(profile.id, {}) + list_error: Optional[Exception] = None try: rows = ( await list_codex_sessions(per_state_limit) @@ -17257,7 +19228,8 @@ async def read(profile: CodexProfile): per_state_limit, codex_home=home) ) except Exception as exc: - return profile, None, exc + rows = [] + list_error = exc normalized: list[dict] = [] for row in rows: if not isinstance(row, dict): @@ -17283,8 +19255,203 @@ async def read(profile: CodexProfile): profile, parent) except ValueError: item["forked_from_id"] = None + candidate_info = profile_candidates.get(native_sid) + if candidate_info is not None: + priority = int(candidate_info.get("priority") or 0) + if priority: + item[_CODEX_CATALOG_PRIORITY] = priority normalized.append(item) - return profile, normalized, None + + # Once the official list itself exposes a foreign CLI thread, its + # bounded hint has completed its job. Exact-DB repairs deliberately + # retain the hint so a still-hidden row survives later refreshes. + if list_error is None: + for native_sid in ( + row.get("native_session_id") for row in normalized + ): + if isinstance(native_sid, str): + self._codex_thread_started_hints.pop( + (profile.id, native_sid), None) + + # ``thread/list`` can temporarily (and for an empty-preview fork, + # indefinitely) omit a thread which remains present in this exact + # account's authoritative state DB. Repair only wrapper-owned + # resident/fork ids. Never scan sibling homes or merge by a bare + # UUID, even when two profiles contain the same native id. + listed_native_ids = { + row.get("native_session_id") + for row in normalized + if isinstance(row.get("native_session_id"), str) + } + missing_native_ids = sorted( + ( + native_sid for native_sid in profile_candidates + if native_sid not in listed_native_ids + ), + key=lambda native_sid: ( + int(profile_candidates[native_sid].get("priority") or 0), + float( + profile_candidates[native_sid].get("created_at") or 0.0 + ), + ), + reverse=True, + ) + if missing_native_ids: + exact_rows = await asyncio.to_thread( + codex_exact_catalog_rows, + missing_native_ids, + **({} if home is None else {"codex_home": home}), + ) + if exact_rows is None: + log.warning( + "Codex exact SQLite catalog repair uncertain", + profile_id=profile.id, + ) + exact_rows = await self._codex_exact_catalog_rpc_rows( + profile, missing_native_ids) + for row in exact_rows: + native_sid = row.get("session_id") + if not isinstance(native_sid, str): + continue + try: + wire_sid = self._codex_wire_sid( + profile, native_sid) + except ValueError: + continue + item = dict(row) + item.update({ + "session_id": wire_sid, + "native_session_id": native_sid, + "codex_profile_id": profile.id, + "codex_profile_label": profile.label, + }) + candidate_info = profile_candidates.get(native_sid, {}) + priority = int(candidate_info.get("priority") or 0) + if priority: + item[_CODEX_CATALOG_PRIORITY] = priority + parent_wire = candidate_info.get("forked_from_id") + if isinstance(parent_wire, str): + item["forked_from_id"] = parent_wire + if not item.get("summary") and not item.get( + "first_prompt" + ): + item["summary"] = "派生会话" + normalized.append(item) + + materialized_native_ids = { + row.get("native_session_id") + for row in normalized + if isinstance(row.get("native_session_id"), str) + } + # ``thread/started`` is an account-scoped, source-filtered native + # event. During the very small state-DB commit gap it is sufficient + # to keep a fresh CLI row discoverable; older hints remain gated by + # an exact DB hit so an externally deleted thread cannot live forever. + for native_sid, candidate_info in profile_candidates.items(): + if ( + native_sid in materialized_native_ids + or not candidate_info.get("hint_fresh") + ): + continue + try: + wire_sid = self._codex_wire_sid(profile, native_sid) + except ValueError: + continue + normalized.append({ + "session_id": wire_sid, + "native_session_id": native_sid, + "summary": "新会话", + "first_prompt": None, + "cwd": None, + "last_modified": str(time.time()), + "git_branch": None, + "forked_from_id": candidate_info.get("forked_from_id"), + "status": None, + "tag": None, + "codex_profile_id": profile.id, + "codex_profile_label": profile.label, + _CODEX_CATALOG_PRIORITY: 2, + }) + materialized_native_ids.add(native_sid) + + # A just-started resident is stronger evidence than a lagging DB + # projection: thread/start already returned its durable id and this + # wrapper owns the live handle. Keep that exact profile-scoped row + # visible even if the state DB is briefly locked or has not exposed + # its metadata transaction yet. Ephemeral BTW contexts are excluded + # from candidates above and can never reach this public catalog. + materialized_by_native = { + row["native_session_id"]: row + for row in normalized + if isinstance(row.get("native_session_id"), str) + } + for ctx in self.sessions.values(): + if ( + ctx.engine != "codex" + or ctx.btw + or not ctx.session_id + ): + continue + try: + resident_profile = self._codex_profile_for_ctx(ctx) + wire_sid = self._codex_wire_sid( + resident_profile, ctx.session_id) + except (RuntimeError, ValueError): + continue + if resident_profile.id != profile.id: + continue + title = self._notification_titles.get(wire_sid) + existing = materialized_by_native.get(ctx.session_id) + if existing is not None: + existing[_CODEX_CATALOG_PRIORITY] = 3 + existing["cwd"] = ctx.cwd + if title and not existing.get("summary"): + existing["summary"] = title + if title and not existing.get("first_prompt"): + existing["first_prompt"] = title + continue + try: + rollout_path = self._codex_rollout_for_wire(wire_sid) + modified = ( + os.path.getmtime(rollout_path) + if rollout_path else time.time() + ) + except OSError: + modified = time.time() + normalized.append({ + "session_id": wire_sid, + "native_session_id": ctx.session_id, + "summary": title, + "first_prompt": title, + "cwd": ctx.cwd, + "last_modified": str(modified), + "git_branch": None, + "forked_from_id": profile_candidates.get( + ctx.session_id, {} + ).get("forked_from_id"), + "status": None, + "tag": None, + "codex_profile_id": profile.id, + "codex_profile_label": profile.label, + _CODEX_CATALOG_PRIORITY: 3, + }) + materialized_by_native[ctx.session_id] = normalized[-1] + + # Current app-server state DB versions can also omit forkedFromId + # on an otherwise listed child. The same durable, same-profile + # journal record repairs only that relationship metadata. + for item in normalized: + if item.get("forked_from_id"): + continue + native_sid = item.get("native_session_id") + candidate_info = ( + profile_candidates.get(native_sid, {}) + if isinstance(native_sid, str) else {} + ) + parent_wire = candidate_info.get("forked_from_id") + if isinstance(parent_wire, str): + item["forked_from_id"] = parent_wire + return profile, normalized, list_error results = await asyncio.gather(*( read(profile) for profile in self._codex_profiles @@ -17292,15 +19459,14 @@ async def read(profile: CodexProfile): rows_by_profile: list[list[dict]] = [] errors: list[tuple[str, str]] = [] for profile, profile_rows, error in results: + rows_by_profile.append(profile_rows or []) if error is not None: errors.append((profile.id, "会话列表暂不可用")) log.warning( "Codex profile catalog unavailable", profile_id=profile.id, - error_type=type(error).__name__, + error_type=type(error).__name__, ) - continue - rows_by_profile.append(profile_rows or []) return self._bounded_codex_profile_catalog([ item for profile_rows in rows_by_profile for item in profile_rows ]), tuple(errors) @@ -17368,6 +19534,7 @@ async def _send_codex_session_list( ) -> None: """Filter and route one already-read native Codex catalog.""" try: + await self._claim_legacy_presentation_from_codex_catalog(raw) self._prime_codex_sidebar_watches(raw) resident_state = { c.key: c.state for c in self.sessions.values() @@ -17506,7 +19673,7 @@ async def _send_codex_session_list( native_session_id=native_sid, codex_profile_id=row.get("codex_profile_id"), codex_profile_label=row.get("codex_profile_label"), - **self._session_presentation_fields(wire_sid), + **self._session_presentation_fields("codex", wire_sid), )) for session in sessions: self._remember_notification_title( @@ -17635,6 +19802,8 @@ async def _handle_switch_session(self, cmd) -> None: ) await self._emit_to_sid(sid, error) return error + if ctx.engine == "codex": + await self._resolve_codex_session_effort(ctx) self.focused_sid = ctx.key # A newly-spawned session isn't tracked by the client yet — send its # snapshot + full replay so the client builds a runtime for it (else the @@ -17661,6 +19830,7 @@ async def _handle_switch_session(self, cmd) -> None: try: presentation = await asyncio.to_thread( self._session_presentation.get, + ctx.engine, self._ctx_wire_sid(ctx) or ctx.key, ) except SessionPresentationStoreError: @@ -17721,9 +19891,10 @@ async def _handle_switch_session(self, cmd) -> None: model_event = Model(model=ctx.sdk.model) await self._emit(ctx, model_event) cached_responses.append(model_event) - if ctx.sdk.effort: - ctx.announced_effort = ctx.sdk.effort - effort_event = Effort(effort=ctx.sdk.effort) + effort = _session_effort(ctx) + if effort: + ctx.announced_effort = effort + effort_event = Effort(effort=effort) await self._emit(ctx, effort_event) cached_responses.append(effort_event) # Snapshot/SessionFocus has now created the browser runtime. Release @@ -17850,7 +20021,8 @@ async def _capture_session_id(self, ctx: SessionContext, sid: str) -> None: if self._session_presentation is not None: try: await asyncio.to_thread( - self._session_presentation.move, old_key, route_sid + self._session_presentation.move, + ctx.engine, old_key, route_sid, ) except SessionPresentationStoreError: log.warning( @@ -17869,6 +20041,18 @@ async def _capture_session_id(self, ctx: SessionContext, sid: str) -> None: old_key=old_key, session_id=route_sid, cwd=ctx.cwd)) self._rekey_cached_create_responses( old_key, route_sid, ctx.cwd) + if ctx.engine == "codex": + try: + # SessionRekey repairs clients which saw the temporary runtime. + # Other connected clients need a catalog hint so the new real + # id enters their sidebar during the same in-flight first turn. + await self._invalidate_session_list("codex", ctx.space) + except Exception as exc: + log.warning( + "new Codex session list invalidation failed", + session_id=route_sid, + error_type=type(exc).__name__, + ) if ctx.engine == "claude": # The real id becomes visible before the first turn necessarily ends. # Start ownership monitoring at capture so a terminal resume during @@ -18365,8 +20549,8 @@ async def _handle_delete_work_session(self, cmd): ctx, rollout_path=codex_alias_path, ) - if isinstance(delete_result, Error): - return delete_result + if isinstance(delete_result, _CodexDeleteRejection): + return delete_result.error codex_deleted = True else: await ctx.sdk.disconnect() @@ -18410,10 +20594,20 @@ async def _handle_delete_work_session(self, cmd): else: await self._delete_codex_client_message_ids(codex_alias_path) await self._drop_preview_session(engine, sid) + if engine == "codex" and self._session_plans is not None: + try: + await asyncio.to_thread(self._session_plans.delete, sid) + except SessionPlanStoreError: + # The native thread and Work registry row are already gone. + # This cache is optional presentation state, so cleanup is + # best-effort and must not turn a successful delete into a + # misleading product failure. + log.warning( + "stale Codex Work plan cleanup failed", session_id=sid) if self._session_presentation is not None: try: await asyncio.to_thread( - self._session_presentation.delete, sid + self._session_presentation.delete, engine, sid ) except SessionPresentationStoreError: log.warning( @@ -19003,13 +21197,27 @@ async def _delete_loaded_codex_thread( *, rollout_path: str | None, transient: bool = False, - ) -> Error | tuple[str, ...]: + ) -> _CodexDeleteRejection | tuple[str, ...]: """Delete a loaded Codex thread before releasing its writer.""" - async with ctx.query_lock: - if self._session_delete_busy(ctx): - return await self._send_code_delete_error( + async def reject( + code: str, + message: str, + *, + outcome: Literal["failed", "unknown"] = "failed", + ) -> _CodexDeleteRejection: + return _CodexDeleteRejection( + error=await self._send_code_delete_error( cmd, sid, + code, + message, + ), + outcome=outcome, + ) + + async with ctx.query_lock: + if self._session_delete_busy(ctx): + return await reject( ERR_BUSY, "会话仍在运行或有排队消息,请先停止并取消排队后再删除", ) @@ -19019,9 +21227,7 @@ async def _delete_loaded_codex_thread( ctx.sdk.daemon_mode == "auto" and not self._codex_shared_live(ctx) ): - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_NOT_RUNNING, "Codex 共享删除通道不可用,请重试", ) @@ -19033,7 +21239,10 @@ async def _delete_loaded_codex_thread( client_id=getattr(cmd, "client_id", None), ) if control_error is not None: - return control_error + return _CodexDeleteRejection( + error=control_error, + outcome="failed", + ) if transient: external_owner = await self._cold_codex_delete_blocked( sid, @@ -19045,16 +21254,12 @@ async def _delete_loaded_codex_thread( await self._codex_delete_external_owner(sid) ) if external_owner: - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_BUSY, "会话正由 Codex App 使用,无法删除", ) if self._session_delete_busy(ctx): - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_BUSY, "会话状态已变化,请等待当前回合结束后再删除", ) @@ -19087,9 +21292,7 @@ async def _delete_loaded_codex_thread( if self._is_resident_context(candidate) ] if self._session_delete_busy(ctx): - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_BUSY, "会话状态已变化,请等待当前回合结束后再删除", ) @@ -19108,16 +21311,12 @@ async def _delete_loaded_codex_thread( session_id=sid, error_type=type(exc).__name__, ) - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_INTERNAL, "无法确认派生会话状态,未执行删除", ) if busy_descendant is not None: - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_BUSY, "派生会话仍在运行、排队或由其他 Codex 客户端使用", ) @@ -19132,9 +21331,7 @@ async def _delete_loaded_codex_thread( session_id=sid, error_code=exc.code, ) - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_INTERNAL, "会话删除失败,请刷新后重试", ) @@ -19156,11 +21353,10 @@ async def _delete_loaded_codex_thread( session_id=sid, error_type=type(reconcile_error).__name__, ) - return await self._send_code_delete_error( - cmd, - sid, + return await reject( ERR_INTERNAL, "会话删除结果暂时无法确认,请刷新后重试", + outcome="unknown", ) rollout_gone = bool( rollout_path @@ -19169,13 +21365,17 @@ async def _delete_loaded_codex_thread( rollout_path, ) ) - if still_exists or not rollout_gone: - return await self._send_code_delete_error( - cmd, - sid, + if still_exists: + return await reject( ERR_INTERNAL, "会话删除失败,请刷新后重试", ) + if not rollout_gone: + return await reject( + ERR_INTERNAL, + "会话删除结果暂时无法确认,请刷新后重试", + outcome="unknown", + ) deleted_native_ids = (native_sid,) if not isinstance(deleted_native_ids, (tuple, list)): deleted_native_ids = (native_sid,) @@ -19275,6 +21475,7 @@ async def _handle_delete_session(self, cmd): return error ctx = self._ctx_for(sid) transient_codex_ctx = False + transient_codex_ctx_closed = False if engine == "codex" and ctx is None: ctx_or_error = await self._cold_codex_delete_context( cmd, @@ -19285,7 +21486,27 @@ async def _handle_delete_session(self, cmd): return ctx_or_error ctx = ctx_or_error transient_codex_ctx = True + + async def cleanup_transient_codex_ctx() -> None: + nonlocal transient_codex_ctx_closed + if ( + not transient_codex_ctx + or transient_codex_ctx_closed + or ctx is None + ): + return + transient_codex_ctx_closed = True + try: + await ctx.sdk.disconnect() + except Exception as exc: + log.warning( + "cold Codex delete control cleanup failed", + session_id=sid, + error_type=type(exc).__name__, + ) + if ctx is not None and self._session_delete_busy(ctx): + await cleanup_transient_codex_ctx() return await self._send_code_delete_error( cmd, sid, @@ -19323,6 +21544,44 @@ async def _handle_delete_session(self, cmd): ) await self.transport.send(error) return error + fork_journal = ( + self._codex_forks if engine == "codex" else self._claude_forks) + fork_delete_state = None + try: + fork_delete_state = await asyncio.to_thread( + fork_journal.begin_delete, sid) + except (ForkJournalError, ClaudeForkJournalError): + # A completed fork may still have reliable command retries in a + # browser outbox. Deleting its native child without first recording + # a replay tombstone could make that old command publish + # SessionForked again after restart, so fail closed before mutation. + log.exception( + "fork deletion intent journal failed", + engine=engine, + session_id=sid, + ) + error = Error( + code=ERR_INTERNAL, + message="无法安全记录派生会话删除状态,未执行删除", + sid=sid, + to=getattr(cmd, "client_id", None), + ) + await cleanup_transient_codex_ctx() + await self.transport.send(error) + return error + + async def abort_fork_delete() -> None: + if fork_delete_state != "delete_pending": + return + try: + await asyncio.to_thread(fork_journal.abort_delete, sid) + except (ForkJournalError, ClaudeForkJournalError): + log.exception( + "fork deletion intent rollback failed", + engine=engine, + session_id=sid, + ) + if engine == "codex": assert ctx is not None try: @@ -19335,17 +21594,11 @@ async def _handle_delete_session(self, cmd): transient=transient_codex_ctx, ) finally: - if transient_codex_ctx: - try: - await ctx.sdk.disconnect() - except Exception as exc: - log.warning( - "cold Codex delete control cleanup failed", - session_id=sid, - error_type=type(exc).__name__, - ) - if isinstance(delete_result, Error): - return delete_result + await cleanup_transient_codex_ctx() + if isinstance(delete_result, _CodexDeleteRejection): + if delete_result.outcome == "failed": + await abort_fork_delete() + return delete_result.error deleted_sids = delete_result if transient_codex_ctx: await self._cleanup_cold_deleted_codex_checkpoint(sid) @@ -19359,6 +21612,7 @@ async def _handle_delete_session(self, cmd): engine=engine, session_id=sid, ) + await abort_fork_delete() return await self._send_code_delete_error( cmd, sid, @@ -19377,6 +21631,7 @@ async def _handle_delete_session(self, cmd): engine=engine, session_id=sid, ) + await abort_fork_delete() return await self._send_code_delete_error( cmd, sid, @@ -19384,6 +21639,19 @@ async def _handle_delete_session(self, cmd): "会话删除失败,请刷新后重试", ) deleted_sids = (sid,) + + if fork_delete_state is not None: + try: + await asyncio.to_thread(fork_journal.finish_delete, sid) + except (ForkJournalError, ClaudeForkJournalError): + # Native deletion already succeeded. Keep delete_pending as a + # fail-closed tombstone: replay suppression treats it exactly + # like deleted and the next explicit delete can finalize it. + log.exception( + "fork deletion tombstone finalization failed", + engine=engine, + session_id=sid, + ) if engine == "claude": await self._delete_claude_client_message_ids(sid) else: @@ -19417,6 +21685,7 @@ async def _handle_delete_session(self, cmd): try: await asyncio.to_thread( self._session_presentation.delete, + engine, deleted_sid, ) except SessionPresentationStoreError: @@ -19536,6 +21805,7 @@ async def _codex_code_context(self, cmd, action: str) -> SessionContext | Error: cwd=ctx.cwd, reason=f"external transcript change before {action}", ) + await self._publish_codex_model_effort(ctx) ctx.needs_reload = False except Exception as exc: log.warning( @@ -19770,7 +22040,8 @@ async def _publish_rollback_outcome( if self._session_presentation is not None: try: presentation = await asyncio.to_thread( - self._session_presentation.clear_completion, sid + self._session_presentation.clear_completion, + ctx.engine, sid, ) if presentation.completion_revision > 0: await self._emit( @@ -20692,7 +22963,9 @@ def _release_terminal_codex_fork_lock( # failed local preflight/persist attempt has nothing to reconcile. # Submitted/uncertain requests keep their lock for the background # reconciler and reliable retry path. - or effective_status in {"intent", "complete", "rejected"} + or effective_status in { + "intent", "complete", "delete_pending", "deleted", "rejected", + } ) if (terminal_or_unrecorded and (task is None or task.done()) @@ -20779,7 +23052,8 @@ async def _reconcile_codex_fork_command( if client_id and cmd_id: self._remember_command( client_id, cmd_id, - (event.model_copy(deep=True),), + ((event.model_copy(deep=True),) + if event is not None else ()), ) await self._send_command_ack(client_id, cmd_id) return @@ -20864,6 +23138,8 @@ async def _codex_fork_control_snapshot( model = ( _session_model(ctx) if ctx is not None else None ) or settings.get("model") + model, _ = await self._resolve_codex_profile_model( + codex_profile, model) approval: Optional[str] = None granular_approval = bool(settings.get("approval_policy_granular")) if ctx is not None: @@ -20964,7 +23240,7 @@ async def _inherit_codex_fork_controls( async def _finish_same_cwd_fork( self, cmd, sid: str, cwd: str, child_session_id: str, - ) -> SessionForked | Error: + ) -> SessionForked | Error | None: try: await asyncio.to_thread( self._codex_forks.complete, cmd.request_id, child_session_id) @@ -20988,6 +23264,11 @@ async def _finish_same_cwd_fork( try: entry = await asyncio.to_thread( self._codex_forks.get, cmd.request_id) + if entry and entry.get("status") in { + "delete_pending", "deleted", + }: + self._uncertain_codex_forks.pop(cmd.request_id, None) + return None await self._inherit_codex_fork_controls( child_session_id, (entry or {}).get("controls")) except Exception as exc: @@ -21003,6 +23284,13 @@ async def _finish_same_cwd_fork( raise _ForkOutcomeUncertain( "fork controls are not durably inherited") from exc self._uncertain_codex_forks.pop(cmd.request_id, None) + entry = await asyncio.to_thread( + self._codex_forks.get, cmd.request_id) + if entry and entry.get("status") in {"delete_pending", "deleted"}: + # The native deletion owns this child now. Reliable retries and a + # background reconciler still complete/ACK the original command, + # but must never resurrect its SessionForked navigation event. + return None event = SessionForked( parent_session_id=sid, session_id=child_session_id, @@ -21017,8 +23305,21 @@ async def _finish_same_cwd_fork( # immediately erase the newly focused child from the browser and make a # later fork target whichever session the sidebar selects instead. self._invalidate_codex_session_catalog() + try: + # Publish the read hint before the correlated result. The creating + # client may therefore materialize the child before installing its + # focus lease, while reliable-command replay still keeps + # SessionForked adjacent to its eventual ACK. + await self._invalidate_session_list("codex", "code") + except Exception as exc: + log.warning( + "forked session list invalidation failed", + session_id=child_session_id, + error_type=type(exc).__name__, + ) await self.transport.send(event) try: + self._invalidate_codex_session_catalog() await self._list_codex_sessions(cmd) except Exception as exc: # The correlated fork result is already durable and delivered. A @@ -21096,7 +23397,9 @@ def _release_terminal_claude_fork_lock( lock = self._claude_fork_locks.get(request_id) terminal_or_unrecorded = ( entry is None - or entry.get("status") in {"complete", "rejected"} + or entry.get("status") in { + "complete", "delete_pending", "deleted", "rejected", + } ) if (terminal_or_unrecorded and (task is None or task.done()) @@ -21174,7 +23477,9 @@ async def _reconcile_claude_fork_command( continue if client_id and cmd_id: self._remember_command( - client_id, cmd_id, (event.model_copy(deep=True),)) + client_id, cmd_id, + ((event.model_copy(deep=True),) + if event is not None else ())) await self._send_command_ack(client_id, cmd_id) return await self._send_session_fork_error( @@ -21196,7 +23501,7 @@ async def _reconcile_claude_fork_command( async def _finish_claude_fork( self, cmd, sid: str, cwd: str, child_session_id: str, title: Optional[str], - ) -> SessionForked: + ) -> Optional[SessionForked]: try: await asyncio.to_thread( self._claude_forks.complete, cmd.request_id, child_session_id) @@ -21217,6 +23522,11 @@ async def _finish_claude_fork( try: fork_entry = await asyncio.to_thread( self._claude_forks.get, cmd.request_id) + if fork_entry and fork_entry.get("status") in { + "delete_pending", "deleted", + }: + self._uncertain_claude_forks.pop(cmd.request_id, None) + return None await self._inherit_claude_fork_controls( child_session_id, (fork_entry or {}).get("controls")) except Exception as exc: @@ -21233,6 +23543,12 @@ async def _finish_claude_fork( "Claude fork controls are not durably inherited") from exc self._uncertain_claude_forks.pop(cmd.request_id, None) + fork_entry = await asyncio.to_thread( + self._claude_forks.get, cmd.request_id) + if fork_entry and fork_entry.get("status") in { + "delete_pending", "deleted", + }: + return None # The marker must remain list-visible until the child id is durable. # Replace only that exact marker: after SessionForked is delivered, an # ACK-loss retry must never overwrite a title the user chose meanwhile. @@ -21250,6 +23566,13 @@ async def _finish_claude_fork( log.warning("Claude fork title finalization failed", session_id=child_session_id, error=str(exc)) + fork_entry = await asyncio.to_thread( + self._claude_forks.get, cmd.request_id) + if fork_entry and fork_entry.get("status") in { + "delete_pending", "deleted", + }: + return None + event = SessionForked( parent_session_id=sid, session_id=child_session_id, @@ -21360,6 +23683,8 @@ async def _handle_claude_fork_session_locked(self, cmd, sid: str): assert entry is not None + if canonical_status in {"delete_pending", "deleted"}: + return None if canonical_status == "complete": return await self._finish_claude_fork( cmd, sid, source_cwd, @@ -21569,6 +23894,8 @@ async def _handle_fork_session_locked(self, cmd): return await self._send_session_fork_error( cmd, ERR_INTERNAL, f"无法记录派生请求: {exc}") + if entry.get("status") in {"delete_pending", "deleted"}: + return None if entry.get("status") == "complete": child = entry.get("session_id") return await self._finish_same_cwd_fork( @@ -21651,7 +23978,10 @@ async def recover(attempts: int = 1) -> Optional[str]: } parent_model = (entry.get("controls") or {}).get("model") if isinstance(parent_model, str) and parent_model: - params["model"] = parent_model + parent_model, _ = await self._resolve_codex_profile_model( + codex_profile, parent_model) + if parent_model: + params["model"] = parent_model parent_approval = (entry.get("controls") or {}).get( "approval_policy") if parent_approval in CODEX_PERMISSION_MODES: @@ -21812,7 +24142,7 @@ async def _finish_worktree_fork( marker: str, *, freshly_confirmed: bool = False, - ) -> SessionForked: + ) -> Optional[SessionForked]: """Durably publish one worktree fork without replaying its mutation.""" try: await asyncio.to_thread( @@ -21839,6 +24169,11 @@ async def _finish_worktree_fork( try: entry = await asyncio.to_thread( self._codex_forks.get, cmd.request_id) + if entry and entry.get("status") in { + "delete_pending", "deleted", + }: + self._uncertain_codex_forks.pop(cmd.request_id, None) + return None await self._inherit_codex_fork_controls( child_session_id, (entry or {}).get("controls")) except Exception as exc: @@ -21854,6 +24189,12 @@ async def _finish_worktree_fork( raise _ForkOutcomeUncertain( "worktree fork controls are not durably inherited") from exc + entry = await asyncio.to_thread( + self._codex_forks.get, cmd.request_id) + if entry and entry.get("status") in {"delete_pending", "deleted"}: + self._uncertain_codex_forks.pop(cmd.request_id, None) + return None + try: await self._finalize_codex_worktree_fork_name( cmd, @@ -21870,6 +24211,10 @@ async def _finish_worktree_fork( "worktree fork name state is not durable") from exc self._uncertain_codex_forks.pop(cmd.request_id, None) + entry = await asyncio.to_thread( + self._codex_forks.get, cmd.request_id) + if entry and entry.get("status") in {"delete_pending", "deleted"}: + return None event = SessionForked( parent_session_id=sid, session_id=child_session_id, @@ -21883,8 +24228,21 @@ async def _finish_worktree_fork( # Worktree forks enter the same native catalog as same-cwd forks. Keep # both publication paths on the same cache-generation boundary. self._invalidate_codex_session_catalog() + try: + # Match same-cwd forks: every connected client needs the read hint + # before the creating client receives its correlated navigation + # event. The child is already durable in the profile-scoped fork + # journal, so an immediate catalog read can repair thread/list. + await self._invalidate_session_list("codex", "code") + except Exception as exc: + log.warning( + "worktree fork session list invalidation failed", + session_id=child_session_id, + error_type=type(exc).__name__, + ) await self.transport.send(event) try: + self._invalidate_codex_session_catalog() await self._list_codex_sessions(cmd) except Exception as exc: log.warning( @@ -22106,6 +24464,8 @@ async def _handle_fork_session_worktree_locked(self, cmd): cmd, ERR_INTERNAL, f"无法记录派生请求: {exc}") controls = dict(fork_entry.get("controls") or {}) marker = fork_entry["thread_source"] + if fork_entry.get("status") in {"delete_pending", "deleted"}: + return None if fork_entry.get("status") == "rejected": if spec.created: await asyncio.to_thread(rollback_worktree, spec) @@ -22210,7 +24570,10 @@ async def recover(attempts: int = 1) -> Optional[str]: params["lastTurnId"] = cmd.last_turn_id parent_model = controls.get("model") if parent_model: - params["model"] = parent_model + parent_model, _ = await self._resolve_codex_profile_model( + codex_profile, parent_model) + if parent_model: + params["model"] = parent_model parent_approval = controls.get("approval_policy") if parent_approval in CODEX_PERMISSION_MODES: params["approvalPolicy"] = parent_approval @@ -22779,6 +25142,8 @@ async def _spawn(self, resume_id: Optional[str], cwd: Optional[str] = None, resolves from current settings, then falls back to the curated default; omitted Codex controls retain native defaults.""" explicit_claude_model = engine == "claude" and model is not None + explicit_codex_model = engine == "codex" and model is not None + explicit_codex_effort = engine == "codex" and effort is not None codex_profile = ( self._codex_profile(codex_profile_id) if engine == "codex" else None @@ -23098,7 +25463,33 @@ async def codex_profile_allowed(profile_id: str) -> bool: model, _default_effort = await self._claude_new_session_defaults( target_cwd) + codex_resume_model_reconcile: Optional[str] = None if engine == "codex": + if not resume_id: + configured_model = await asyncio.to_thread( + codex_model, + "", + **({} if self._codex_home(codex_profile) is None else { + "codex_home": self._codex_home(codex_profile), + }), + ) + try: + resolved_model, _ = ( + await self._resolve_codex_profile_model( + codex_profile, + model or configured_model or None, + explicit=explicit_codex_model, + ) + ) + except _UnsupportedCodexModel: + await reject( + ERR_BAD_PROMPT, + "所选模型不适用于当前 Codex 账号,请重新选择。", + route="sid", + sid=wire_resume_id, + ) + return None + model = resolved_model codex_handle_kwargs = { "cwd": target_cwd, "daemon_mode": ( @@ -23249,6 +25640,25 @@ async def codex_profile_allowed(profile_id: str) -> bool: mode = prev.get("collaboration_mode") if mode in CODEX_COLLABORATION_MODES: sdk.collaboration_mode = mode + try: + resolved_model, model_replaced = ( + await self._resolve_codex_profile_model( + codex_profile, + model, + explicit=explicit_codex_model, + ) + ) + except _UnsupportedCodexModel: + await reject( + ERR_BAD_PROMPT, + "所选模型不适用于当前 Codex 账号,请重新选择。", + route="sid", + sid=wire_resume_id, + ) + return None + model = resolved_model + if model and (model_replaced or explicit_codex_model): + codex_resume_model_reconcile = model if model: sdk.model = model # A stale client can ask for a level this model doesn't have (it used @@ -23347,6 +25757,24 @@ async def codex_profile_allowed(profile_id: str) -> bool: await ctx.sdk.connect( **codex_connect_options, ) + if ( + resume_id + and codex_resume_model_reconcile + and getattr(ctx.sdk, "model", None) + != codex_resume_model_reconcile + ): + # thread/resume can echo a retired model from the native + # thread and overwrite the pre-connect fallback. Reconcile + # only an explicit choice or a catalog-proven replacement, + # then persist it through the official settings API before + # the first resumed turn can start. + await ctx.sdk.set_model(codex_resume_model_reconcile) + if getattr(ctx.sdk, "model", None) != ( + codex_resume_model_reconcile + ): + raise RuntimeError( + "Codex app-server did not confirm the replacement model" + ) if ( resume_id and space == "code" @@ -23368,10 +25796,41 @@ async def codex_profile_allowed(profile_id: str) -> bool: ) ctx.cwd = effective_cwd target_cwd = effective_cwd + if (resume_id and effort + and not getattr(ctx.sdk, "effort", None)): + # Some app-server versions return reasoningEffort=null on + # thread/resume even though the rollout's last turn_context + # records the session's explicit selection. Null means the + # resume response omitted an override; it must not demote a + # max session to the model/config default after every page + # refresh. Restore only this session's already-clamped + # bounded rollout value, after connect has installed the + # authoritative model/cwd generation. + ctx.sdk.effort = effort + ctx.sdk.applied_effort = effort + setattr(ctx.sdk, "display_effort", effort) + setattr(ctx.sdk, "display_effort_model", ctx.sdk.model) + setattr( + ctx.sdk, "display_effort_cwd", + os.path.realpath( + getattr(ctx.sdk, "_cwd", None) or ctx.cwd), + ) + setattr( + ctx.sdk, "display_effort_generation", + getattr(ctx.sdk, "_generation", None), + ) + setattr(ctx.sdk, "_display_effort_retry_at", None) else: await ctx.sdk.connect( resume_id=resume_id, cwd=target_cwd) except CodexProfileDaemonUnavailable as e: + try: + await ctx.sdk.disconnect() + except Exception: + log.warning( + "failed Codex profile spawn cleanup failed", + profile_id=codex_profile.id if codex_profile else None, + ) log.warning( "required Codex profile daemon unavailable", profile_id=codex_profile.id if codex_profile else None, @@ -23389,17 +25848,58 @@ async def codex_profile_allowed(profile_id: str) -> bool: if bootstrap and resume_id: log.warning("resume failed, starting a fresh session", error=str(e)) ctx.session_id = None + try: + # SdkHandle.connect() may fail after assigning a partially + # connected client. Tear that generation down before the + # documented bootstrap resume→fresh retry reuses the handle. + await ctx.sdk.disconnect() + except Exception: + log.warning("bootstrap resume cleanup failed") try: await ctx.sdk.connect(resume_id=None, cwd=target_cwd) except Exception as e2: log.exception("fresh connect also failed", error=str(e2)) + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("failed fresh spawn cleanup failed") await reject(ERR_CC_CRASH, "会话连接未完成,请稍后重试。") return None else: log.exception("connect failed", error=str(e)) + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("failed spawn cleanup failed") await reject(ERR_CC_CRASH, "会话连接未完成,请稍后重试。") return None - await self._stamp_codex_daemon_epoch(ctx) + try: + if engine == "codex": + await self._resolve_codex_session_effort( + ctx, + # A rollout value only bridges older/incomplete resume + # replies. If app-server authoritatively clears the thread + # override, do not promote the rollout value back into an + # explicit next-turn setting. + preferred=effort if explicit_codex_effort else None, + ) + await self._stamp_codex_daemon_epoch(ctx) + except asyncio.CancelledError: + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("cancelled spawn cleanup failed") + raise + except Exception as e: + # The handle is connected but not resident yet. A failed effort or + # daemon-epoch probe would otherwise orphan its private child/proxy. + log.exception("post-connect spawn initialization failed", error=str(e)) + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("post-connect spawn cleanup failed") + await reject(ERR_CC_CRASH, "会话连接未完成,请稍后重试。") + return None if (ctx.space == "work" and ctx.work_context_baseline_pending and ctx.work_context_baseline_tokens is None): @@ -23519,8 +26019,11 @@ async def codex_profile_allowed(profile_id: str) -> bool: # them (the client already reflects its own pick optimistically). if model: ctx.announced_model = model - if effort: - ctx.announced_effort = effort + initial_effort = ( + _session_effort(ctx) if engine == "codex" else effort + ) + if initial_effort: + ctx.announced_effort = initial_effort # Codex knows its real id at connect time. Claude still uses a temporary # key until its first init/result message exposes the SDK session id. key = ( @@ -23590,10 +26093,47 @@ async def _spawn_btw( raise _BtwSpawnFailure( ERR_INTERNAL, "这个会话还没有上下文,先发一条消息再开 btw") engine = parent.engine + parent_space = parent.space + work_record = None + if parent_space == "work": + if not parent.work_id: + raise _BtwSpawnFailure( + ERR_AUTH, "Work 会话注册信息不存在,无法打开 btw") + try: + work_record = await asyncio.to_thread( + self._work.for_engine(engine).get_by_work_id, + parent.work_id, + ) + except Exception as exc: + log.warning( + "Work btw registry lookup failed", + engine=engine, + work_id=parent.work_id, + error_type=type(exc).__name__, + ) + raise _BtwSpawnFailure( + ERR_INTERNAL, "Work 会话状态无法确认,请稍后重试。") from exc + if ( + work_record is None + or work_record.session_id != parent_id + or os.path.realpath(work_record.cwd) + != os.path.realpath(parent.cwd) + or not self._work.for_engine(engine).contains_cwd( + work_record.cwd) + ): + raise _BtwSpawnFailure( + ERR_AUTH, "Work 会话目录或账号归属不一致,已拒绝打开 btw") codex_profile = ( self._codex_profile_for_ctx(parent) if engine == "codex" else None ) + if ( + work_record is not None + and engine == "codex" + and work_record.codex_profile_id != codex_profile.id + ): + raise _BtwSpawnFailure( + ERR_AUTH, "Codex Work 会话不属于当前账号,已拒绝打开 btw") if engine != "codex": try: SdkHandle.preflight(self.cfg.claude_bin) @@ -23619,18 +26159,39 @@ async def _spawn_btw( if engine == "codex": codex_handle_kwargs = { "cwd": parent.cwd, - "daemon_mode": getattr( - self.cfg, "codex_daemon_mode", "auto"), + "daemon_mode": ( + "off" if parent_space == "work" else + getattr(self.cfg, "codex_daemon_mode", "auto") + ), "daemon_manager": self._codex_daemon_for_profile( codex_profile), } + if parent_space == "work": + codex_handle_kwargs["work_mode"] = True codex_home = self._codex_home(codex_profile) if codex_home is not None: codex_handle_kwargs["codex_home"] = codex_home sdk = CodexHandle(self.cfg, **codex_handle_kwargs) else: sdk = SdkHandle(self.cfg) - if engine != "codex": + if engine != "codex" and parent_space == "work": + assert work_record is not None + sdk.work_mode = True + try: + sdk.work_settings_path = await asyncio.to_thread( + self._work.for_engine("claude").ensure_claude_policy, + work_record, + ) + except Exception as exc: + log.warning( + "Claude Work btw policy preparation failed", + work_id=parent.work_id, + error_type=type(exc).__name__, + ) + raise _BtwSpawnFailure( + ERR_INTERNAL, "Work 隔离策略无法建立,请稍后重试。") from exc + sdk.permission_mode = "acceptEdits" + elif engine != "codex": sdk.permission_mode = getattr( parent.sdk, "permission_mode", "bypassPermissions") # /btw is a quick side question — run the fork at LOW effort so the first @@ -23642,17 +26203,25 @@ async def _spawn_btw( buffer=RingBuffer(self.cfg.ring_max_events, self.cfg.ring_max_bytes), cwd=parent.cwd, engine=engine, codex_profile_id=(codex_profile.id if codex_profile else None), + space=parent_space, + work_id=parent.work_id if parent_space == "work" else None, btw=True, parent_sid=(parent.key or parent_id), owner_client_id=owner_client_id) if engine != "codex": self._configure_claude_sdk_callbacks(ctx, ctx.sdk) else: - ctx.sdk.approval = parent.sdk.approval - ctx.sdk.approval_policy = parent.sdk.approval_policy - ctx.sdk.permission_profile = parent.sdk.permission_profile - ctx.sdk.web_search_override = ( - parent.sdk.web_search_override) - ctx.sdk.web_search = parent.sdk.web_search + if parent_space == "work": + ctx.sdk.approval = "never" + ctx.sdk.permission_profile = "cc_remote_work" + ctx.sdk.web_search_override = None + ctx.sdk.web_search = "cached" + else: + ctx.sdk.approval = parent.sdk.approval + ctx.sdk.approval_policy = parent.sdk.approval_policy + ctx.sdk.permission_profile = parent.sdk.permission_profile + ctx.sdk.web_search_override = ( + parent.sdk.web_search_override) + ctx.sdk.web_search = parent.sdk.web_search ctx.sdk.approval_callback = ( lambda method, params: self._on_codex_approval( ctx, method, params)) @@ -23670,13 +26239,37 @@ async def _spawn_btw( ctx.sdk.runtime_event_callback = ( lambda event: self._on_codex_runtime_event(ctx, event)) try: - await ctx.sdk.connect(resume_id=parent_id, cwd=parent.cwd, fork=True) + await ctx.sdk.connect( + resume_id=parent_id, cwd=parent.cwd, fork=True) + if engine == "codex": + if parent_space == "work": + # A fork response may echo settings persisted by a former + # Code incarnation. Work's private process remains + # authoritative. + ctx.sdk.approval = "never" + ctx.sdk.permission_profile = "cc_remote_work" + await self._resolve_codex_session_effort( + ctx, preferred="low") + await self._stamp_codex_daemon_epoch(ctx) + except asyncio.CancelledError: + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("btw fork cancellation cleanup failed") + raise except Exception as e: - log.exception("btw fork connect failed", error=str(e)) + # connect() can fail after starting a private app-server/SDK child, + # and the post-connect effort probe can fail too. The context is not + # resident yet, so no later pool cleanup can reach that partial + # handle; close it here before returning the correlated rejection. + try: + await ctx.sdk.disconnect() + except Exception: + log.warning("btw fork failure cleanup failed") + log.exception("btw fork initialization failed", error=str(e)) raise _BtwSpawnFailure( ERR_CC_CRASH, "临时侧边会话暂时无法打开,请稍后重试。" ) from e - await self._stamp_codex_daemon_epoch(ctx) key = f"btw-{uuid4().hex}" self.sessions[key] = ctx ctx.key = key @@ -24337,6 +26930,7 @@ async def _run_turn(self, ctx: SessionContext, prompt: str, codex_overflow_repair_turn_id: Optional[str] = None codex_restart_watch_task: Optional[asyncio.Task] = None codex_handoff_to_spontaneous = False + codex_query_reconnected = False native_turn_id: Optional[str] = None if not is_codex: ctx.claude_last_activity_at = asyncio.get_running_loop().time() @@ -24602,7 +27196,9 @@ async def handoff_codex_account_switch( "status": "interrupted", }}, } - for event in ctx.translator.feed(synthetic_old_terminal): + for event in ctx.translator.feed( + synthetic_old_terminal, authoritative_terminal=False, + ): if isinstance(event, (Error, TurnEnd)): continue await emit_codex_event(event) @@ -24795,6 +27391,7 @@ async def reconnect_claude(reason: str) -> None: await ctx.sdk.force_reconnect( resume_id=ctx.session_id, cwd=ctx.cwd, reason="external transcript change") + await self._publish_codex_model_effort(ctx) ctx.needs_reload = False else: # Clear first so a watcher that observes a new external write @@ -25009,6 +27606,7 @@ async def reconnect_claude(reason: str) -> None: resume_id=ctx.session_id, cwd=ctx.cwd, reason="external transcript change at final preflight", ) + await self._publish_codex_model_effort(ctx) ctx.needs_reload = False if (ctx.interrupt_event.is_set() or ctx.state == "interrupting"): @@ -25039,6 +27637,7 @@ async def reconnect_claude(reason: str) -> None: )) await self._set_idle_after_managed_turn(ctx) return + await self._resolve_codex_session_effort(ctx) await self._begin_codex_checkpoint(ctx) if ctx.interrupt_event.is_set() or ctx.state == "interrupting": await self._abort_codex_checkpoint(ctx) @@ -25049,11 +27648,19 @@ async def reconnect_claude(reason: str) -> None: ))) await self._set_idle_after_managed_turn(ctx) return + query_generation = getattr(ctx.sdk, "_generation", None) native_turn_id = await ctx.sdk.query( prompt, images=img_paths, client_user_message_id=ctx.active_msg_id, ) + current_query_generation = getattr( + ctx.sdk, "_generation", None) + codex_query_reconnected = bool( + isinstance(query_generation, int) + and isinstance(current_query_generation, int) + and current_query_generation != query_generation + ) # CodexHandle marks turn/start failure by raising with # turn_active=False. Reaching here is the authoritative # acceptance boundary, including an ultra-fast turn that @@ -25108,17 +27715,13 @@ async def msg_stream(): if is_codex: collaboration_mode = getattr( ctx.sdk, "collaboration_mode", "default") - if ctx.announced_model != ctx.sdk.model: - ctx.announced_model = ctx.sdk.model - await self._emit(ctx, Model(model=ctx.announced_model)) - effort = getattr(ctx.sdk, "effort", None) - if isinstance(effort, str) and effort: - if ctx.announced_effort != effort: - ctx.announced_effort = effort - await self._emit(ctx, Effort(effort=effort)) - elif ctx.announced_effort is not None: - ctx.announced_effort = None - await self._emit(ctx, Effort(effort="")) + # query() can repair a dead app-server and adopt a new complete + # settings snapshot. Do not derive effort after awaiting the + # Model send: a settings notification in that gap would mix two + # authorities. This fast publication never probes config/catalog; + # a nullable effort is represented truthfully as model-default. + await self._publish_codex_model_effort( + ctx, resolve_effort=False) if ctx.announced_collaboration_mode != collaboration_mode: ctx.announced_collaboration_mode = collaboration_mode await self._emit(ctx, CollaborationMode( @@ -25126,6 +27729,8 @@ async def msg_stream(): await self._emit(ctx, Fast( on=_codex_fast_on(ctx.sdk.service_tier))) reader_task = asyncio.create_task(reader(queue, reader_exc)) + if codex_query_reconnected and _session_effort(ctx) is None: + self._schedule_codex_model_effort_publish(ctx) while True: msg = await next_turn_message() if isinstance(msg, CodexSteerFence): @@ -25295,6 +27900,8 @@ async def msg_stream(): timed_out_spontaneous_turn = ctx.codex_spontaneous_turn_id try: await ctx.sdk.force_reconnect(ctx.session_id, ctx.cwd) + if is_codex: + await self._publish_codex_model_effort(ctx) except Exception as e: log.exception("force reconnect failed", error=str(e)) await self._emit(ctx, Error( diff --git a/cc_remote/wrapper/session_ctx.py b/cc_remote/wrapper/session_ctx.py index 7d25bfd..6ce1a30 100644 --- a/cc_remote/wrapper/session_ctx.py +++ b/cc_remote/wrapper/session_ctx.py @@ -148,6 +148,12 @@ class SessionContext: btw_real_id: Optional[str] = None announced_model: Optional[str] = None announced_effort: Optional[str] = None + # Model/cwd/process changes and thread/settings notifications can arrive + # while config/read or model/list is resolving a nullable Codex effort. + # Serialize those presentation-only probes per resident session; the + # resolver still revalidates authoritative state after every await. + codex_effort_resolve_lock: asyncio.Lock = field( + default_factory=asyncio.Lock) announced_perm: Optional[str] = None announced_permission_profile: Optional[str] = None announced_web_search: Optional[str] = None diff --git a/cc_remote/wrapper/session_plans.py b/cc_remote/wrapper/session_plans.py index 53ddfb6..4f9d6a2 100644 --- a/cc_remote/wrapper/session_plans.py +++ b/cc_remote/wrapper/session_plans.py @@ -8,7 +8,7 @@ from __future__ import annotations from collections import OrderedDict -from dataclasses import dataclass +from dataclasses import dataclass, replace import json import os from pathlib import Path @@ -16,7 +16,7 @@ import stat import threading import time -from typing import Any +from typing import Any, Callable from uuid import uuid4 from cc_remote.protocol import TurnPlan @@ -25,6 +25,7 @@ _SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$") _MAX_ENTRIES = 4096 _MAX_FILE_BYTES = 16 * 1024 * 1024 +_TERMINAL_STATUSES = frozenset({"succeeded", "failed", "interrupted"}) class SessionPlanStoreError(RuntimeError): @@ -38,29 +39,52 @@ class SessionPlanSnapshot: explanation: str | None plan: tuple[dict[str, str], ...] updated_at: float + terminal_status: str | None = None @property def complete(self) -> bool: return bool(self.plan) and all( entry["status"] == "completed" for entry in self.plan) + @property + def settled(self) -> bool: + return self.complete or self.terminal_status is not None + + @property + def owner_turn_ids(self) -> frozenset[str]: + if self.turn_id is not None: + return frozenset({self.turn_id}) + owners: set[str] = set() + if self.item_id.startswith("plan:"): + derived = self.item_id.removeprefix("plan:") + if derived and derived != "current": + owners.add(derived) + return frozenset(owners) + @classmethod def from_event( cls, event: TurnPlan, *, updated_at: float | None = None, + terminal_status: str | None = None, ) -> "SessionPlanSnapshot": # TurnPlan is the public bounded schema. Re-validating a copied payload # keeps this private store aligned if a caller supplies a subclass or a # test double instead of the exact model instance. clean = TurnPlan.model_validate(event.model_dump(mode="python")) + if ( + terminal_status is not None + and terminal_status not in _TERMINAL_STATUSES + ): + raise SessionPlanStoreError("Codex plan terminal status is invalid") return cls( item_id=clean.item_id, turn_id=clean.turn_id, explanation=clean.explanation, plan=tuple(dict(entry) for entry in clean.plan), updated_at=time.time() if updated_at is None else updated_at, + terminal_status=terminal_status, ) def as_event(self) -> TurnPlan: @@ -72,16 +96,19 @@ def as_event(self) -> TurnPlan: ) def as_process_block(self) -> dict[str, Any]: + status = self.terminal_status or ( + "succeeded" if self.complete else "running" + ) return { "kind": "process", "item_id": self.item_id, "processKind": "plan", "phase": "snapshot", - "status": "succeeded" if self.complete else "running", + "status": status, "turn_id": self.turn_id, "parent_id": None, "title": "计划", - "done": self.complete, + "done": self.settled, "explanation": self.explanation, "plan": [dict(entry) for entry in self.plan], } @@ -93,6 +120,7 @@ def as_dict(self) -> dict[str, Any]: "explanation": self.explanation, "plan": [dict(entry) for entry in self.plan], "updated_at": self.updated_at, + "terminal_status": self.terminal_status, } @@ -102,10 +130,13 @@ def _session_id(value: object) -> str: return value -def _snapshot(value: object) -> SessionPlanSnapshot: - if not isinstance(value, dict) or set(value) != { +def _snapshot(value: object, *, version: int) -> SessionPlanSnapshot: + expected = { "item_id", "turn_id", "explanation", "plan", "updated_at", - }: + } + if version >= 3: + expected.add("terminal_status") + if not isinstance(value, dict) or set(value) != expected: raise SessionPlanStoreError("Codex plan snapshot has an invalid shape") updated_at = value.get("updated_at") if ( @@ -124,7 +155,13 @@ def _snapshot(value: object) -> SessionPlanSnapshot: except Exception as exc: raise SessionPlanStoreError( "Codex plan snapshot payload is invalid") from exc - return SessionPlanSnapshot.from_event(event, updated_at=float(updated_at)) + return SessionPlanSnapshot.from_event( + event, + updated_at=float(updated_at), + terminal_status=( + value.get("terminal_status") if version >= 3 else None + ), + ) class SessionPlanStore: @@ -133,7 +170,39 @@ class SessionPlanStore: def __init__(self, state_dir: Path): self.path = Path(state_dir) / "session-plans.json" self._lock = threading.RLock() - self._plans = self._load() + self._plans, self._profile_revision = self._load() + + def migrate_profile_sessions( + self, + transform: Callable[[str], str], + *, + profile_revision: int, + ) -> int: + """Atomically translate Codex Plan keys once per topology revision.""" + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 1 + ): + raise SessionPlanStoreError("invalid Codex profile revision") + with self._lock: + if self._profile_revision >= profile_revision: + return 0 + updated: OrderedDict[str, SessionPlanSnapshot] = OrderedDict() + migrated = 0 + for session_id, snapshot in self._plans.items(): + target = _session_id(transform(session_id)) + existing = updated.get(target) + if existing is not None and existing != snapshot: + raise SessionPlanStoreError( + "Codex Plan profile migration collides") + updated[target] = snapshot + migrated += target != session_id + self._persist_bounded( + updated, profile_revision=profile_revision) + self._plans = updated + self._profile_revision = profile_revision + return migrated def get(self, session_id: str) -> SessionPlanSnapshot | None: session_id = _session_id(session_id) @@ -156,6 +225,34 @@ def put(self, session_id: str, event: TurnPlan) -> SessionPlanSnapshot: self._plans = updated return snapshot + def mark_terminal( + self, + session_id: str, + *, + turn_id: str, + status: str, + ) -> SessionPlanSnapshot | None: + """Attach an exact engine terminal without inventing step progress.""" + session_id = _session_id(session_id) + turn_id = _session_id(turn_id) + if status not in _TERMINAL_STATUSES: + raise SessionPlanStoreError("Codex plan terminal status is invalid") + with self._lock: + snapshot = self._plans.get(session_id) + if snapshot is None: + return None + if turn_id not in snapshot.owner_turn_ids: + return None + if snapshot.terminal_status is not None: + return snapshot + terminal = replace(snapshot, terminal_status=status) + updated = OrderedDict(self._plans) + updated.pop(session_id, None) + updated[session_id] = terminal + self._persist_bounded(updated) + self._plans = updated + return terminal + def move(self, old_session_id: str, session_id: str) -> None: old_session_id = _session_id(old_session_id) session_id = _session_id(session_id) @@ -182,13 +279,13 @@ def delete(self, session_id: str) -> None: self._persist_bounded(updated) self._plans = updated - def retire_completed( + def retire_settled( self, session_id: str, *, current_turn_ids: frozenset[str] = frozenset(), ) -> bool: - """Delete a completed Plan when a later user message begins. + """Delete a completed or terminal Plan at the next user boundary. A replay of the Plan's own ``UserMsg`` is not a later message, so its known native/client identity may protect the snapshot. A steer is @@ -199,11 +296,8 @@ def retire_completed( snapshot = self._plans.get(session_id) if ( snapshot is None - or not snapshot.complete - or ( - snapshot.turn_id is not None - and snapshot.turn_id in current_turn_ids - ) + or not snapshot.settled + or not snapshot.owner_turn_ids.isdisjoint(current_turn_ids) ): return False updated = OrderedDict(self._plans) @@ -212,7 +306,19 @@ def retire_completed( self._plans = updated return True - def _load(self) -> OrderedDict[str, SessionPlanSnapshot]: + def retire_completed( + self, + session_id: str, + *, + current_turn_ids: frozenset[str] = frozenset(), + ) -> bool: + """Compatibility alias for callers predating terminal snapshots.""" + return self.retire_settled( + session_id, current_turn_ids=current_turn_ids) + + def _load( + self, + ) -> tuple[OrderedDict[str, SessionPlanSnapshot], int]: try: info = self.path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_FILE_BYTES: @@ -221,18 +327,33 @@ def _load(self) -> OrderedDict[str, SessionPlanSnapshot]: if len(raw_bytes) > _MAX_FILE_BYTES: raise ValueError("session plan store exceeds size limit") raw = json.loads(raw_bytes.decode("utf-8")) - if not isinstance(raw, dict) or set(raw) != {"version", "plans"}: + if not isinstance(raw, dict) or set(raw) not in ( + {"version", "plans"}, + {"version", "profile_revision", "plans"}, + ): raise ValueError("session plan store has an invalid envelope") - if raw.get("version") != 1 or not isinstance(raw.get("plans"), dict): + if raw.get("version") not in {1, 2, 3} or not isinstance( + raw.get("plans"), dict + ): raise ValueError("session plan store version is unsupported") + profile_revision = raw.get("profile_revision", 0) + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 0 + or (raw.get("version") == 1 and profile_revision != 0) + ): + raise ValueError("session plan profile revision is invalid") loaded: OrderedDict[str, SessionPlanSnapshot] = OrderedDict() + version = raw["version"] for session_id, value in raw["plans"].items(): - loaded[_session_id(session_id)] = _snapshot(value) + loaded[_session_id(session_id)] = _snapshot( + value, version=version) if len(loaded) > _MAX_ENTRIES: raise ValueError("session plan store has too many entries") - return loaded + return loaded, profile_revision except FileNotFoundError: - return OrderedDict() + return OrderedDict(), 0 except Exception as exc: raise SessionPlanStoreError( "session plan store is unreadable") from exc @@ -240,9 +361,12 @@ def _load(self) -> OrderedDict[str, SessionPlanSnapshot]: @staticmethod def _payload( plans: OrderedDict[str, SessionPlanSnapshot], + *, + profile_revision: int, ) -> bytes: return json.dumps({ - "version": 1, + "version": 3, + "profile_revision": profile_revision, "plans": { session_id: snapshot.as_dict() for session_id, snapshot in plans.items() @@ -252,12 +376,19 @@ def _payload( def _persist_bounded( self, plans: OrderedDict[str, SessionPlanSnapshot], + *, + profile_revision: int | None = None, ) -> None: bounded = OrderedDict(plans) - payload = self._payload(bounded) + revision = ( + self._profile_revision + if profile_revision is None else profile_revision + ) + payload = self._payload(bounded, profile_revision=revision) while len(payload) > _MAX_FILE_BYTES and len(bounded) > 1: bounded.popitem(last=False) - payload = self._payload(bounded) + payload = self._payload( + bounded, profile_revision=revision) if len(payload) > _MAX_FILE_BYTES: raise SessionPlanStoreError("session plan store exceeds size limit") # Propagate LRU evictions back to the caller's replacement map. diff --git a/cc_remote/wrapper/session_presentation.py b/cc_remote/wrapper/session_presentation.py index 48222c7..ce786ca 100644 --- a/cc_remote/wrapper/session_presentation.py +++ b/cc_remote/wrapper/session_presentation.py @@ -16,12 +16,48 @@ import stat import threading import time +from typing import Callable, Literal from uuid import uuid4 -_WIRE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$") +_WIRE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$") _MAX_ENTRIES = 4096 _MAX_FILE_BYTES = 16 * 1024 * 1024 +_ENGINES = frozenset({"claude", "codex"}) +_LEGACY_ENGINE = "legacy" + + +def _engine(value: object) -> Literal["claude", "codex"]: + if value not in _ENGINES: + raise SessionPresentationStoreError( + "session presentation engine is invalid") + return value # type: ignore[return-value] + + +def _scope_key(engine: str, session_id: str) -> str: + clean_engine = _engine(engine) + clean_id = _wire_id(session_id) + assert clean_id is not None + return f"{clean_engine}\0{clean_id}" + + +def _legacy_scope_key(session_id: str) -> str: + clean_id = _wire_id(session_id) + assert clean_id is not None + return f"{_LEGACY_ENGINE}\0{clean_id}" + + +def _split_persisted_scope_key(key: str) -> tuple[str, str]: + try: + engine, session_id = key.split("\0", 1) + except ValueError as exc: + raise SessionPresentationStoreError( + "session presentation scope is invalid") from exc + clean_id = _wire_id(session_id) + assert clean_id is not None + if engine == _LEGACY_ENGINE: + return engine, clean_id + return _engine(engine), clean_id class SessionPresentationStoreError(RuntimeError): @@ -111,31 +147,103 @@ class SessionPresentationStore: def __init__(self, state_dir: Path): self.path = Path(state_dir) / "session-presentation.json" self._lock = threading.RLock() - self._sessions = self._load() + self._sessions, self._profile_revision = self._load() - def get(self, session_id: str) -> SessionPresentationSnapshot: - session_id = _wire_id(session_id) # type: ignore[assignment] - assert session_id is not None + def get( + self, engine: str, session_id: str, + ) -> SessionPresentationSnapshot: + key = _scope_key(engine, session_id) with self._lock: - snapshot = self._sessions.get(session_id) + snapshot = self._sessions.get(key) if snapshot is None: return SessionPresentationSnapshot() - self._sessions.move_to_end(session_id) + self._sessions.move_to_end(key) return snapshot + def completion_engine( + self, session_id: str, completion_id: str, + ) -> Literal["claude", "codex"] | None: + """Resolve an engine-less legacy acknowledgement without guessing. + + Protocol v34 predates engine-scoped completion commands. A cold + acknowledgement can still be routed safely because it echoes the exact + completion identity. If both engine scopes somehow contain that same + identity, fail closed and let a later resident acknowledgement resolve + it instead of clearing the wrong receipt. + """ + completion_id = _wire_id(completion_id) # type: ignore[assignment] + assert completion_id is not None + matches: list[Literal["claude", "codex"]] = [] + with self._lock: + for engine in ("claude", "codex"): + snapshot = self._sessions.get(_scope_key(engine, session_id)) + if ( + snapshot is not None + and snapshot.completion_id == completion_id + ): + matches.append(engine) + return matches[0] if len(matches) == 1 else None + + def legacy_ids(self) -> frozenset[str]: + """Return ambiguous v1 ids still waiting for native ownership proof.""" + with self._lock: + return frozenset( + session_id + for key in self._sessions + for engine, session_id in [_split_persisted_scope_key(key)] + if engine == _LEGACY_ENGINE + ) + + def claim_legacy( + self, + engine: str, + session_id: str, + target_session_id: str | None = None, + ) -> SessionPresentationSnapshot | None: + """Move one quarantined v1 receipt after its engine is proven. + + Discovery is deliberately external to this store: callers must first + establish that exactly one native engine owns the id. If a newer + engine-scoped receipt already exists, keep that generation and retire + the older ambiguous projection instead of overwriting it. + """ + # Validate the source independently; otherwise a caller-supplied target + # could bypass the persisted legacy-key identity check. + session_id = str(_wire_id(session_id)) + target_id = session_id if target_session_id is None else target_session_id + target = _scope_key(engine, target_id) + legacy = _legacy_scope_key(session_id) + with self._lock: + snapshot = self._sessions.get(legacy) + if snapshot is None: + return self._sessions.get(target) + updated = OrderedDict(self._sessions) + updated.pop(legacy, None) + current = updated.get(target) + if current is None or snapshot.updated_at > current.updated_at: + updated.pop(target, None) + updated[target] = snapshot + claimed = snapshot + else: + claimed = current + self._persist_bounded(updated) + self._sessions = updated + return claimed + def mark_completion( self, + engine: str, session_id: str, completion_id: str | None = None, ) -> SessionPresentationSnapshot: - session_id = _wire_id(session_id) # type: ignore[assignment] + key = _scope_key(engine, session_id) completion_id = _wire_id( completion_id or f"completion-{uuid4().hex}" ) assert session_id is not None and completion_id is not None with self._lock: current = self._sessions.get( - session_id, SessionPresentationSnapshot() + key, SessionPresentationSnapshot(), ) if ( current.completion_id == completion_id @@ -143,7 +251,7 @@ def mark_completion( ): return current return self._replace_locked( - session_id, + key, replace( current, completion_id=completion_id, @@ -155,15 +263,16 @@ def mark_completion( def acknowledge_completion( self, + engine: str, session_id: str, completion_id: str, ) -> SessionPresentationSnapshot: - session_id = _wire_id(session_id) # type: ignore[assignment] + key = _scope_key(engine, session_id) completion_id = _wire_id(completion_id) # type: ignore[assignment] - assert session_id is not None and completion_id is not None + assert completion_id is not None with self._lock: current = self._sessions.get( - session_id, SessionPresentationSnapshot() + key, SessionPresentationSnapshot(), ) # A delayed acknowledgement for turn N must never clear the unread # receipt for a newer turn N+1. @@ -173,7 +282,7 @@ def acknowledge_completion( ): return current return self._replace_locked( - session_id, + key, replace( current, completion_unread=False, @@ -184,18 +293,18 @@ def acknowledge_completion( def clear_completion( self, + engine: str, session_id: str, ) -> SessionPresentationSnapshot: - session_id = _wire_id(session_id) # type: ignore[assignment] - assert session_id is not None + key = _scope_key(engine, session_id) with self._lock: current = self._sessions.get( - session_id, SessionPresentationSnapshot() + key, SessionPresentationSnapshot(), ) if current.completion_id is None and not current.completion_unread: return current return self._replace_locked( - session_id, + key, replace( current, completion_id=None, @@ -207,20 +316,21 @@ def clear_completion( def dismiss_goal( self, + engine: str, session_id: str, goal_id: str, ) -> SessionPresentationSnapshot: - session_id = _wire_id(session_id) # type: ignore[assignment] + key = _scope_key(engine, session_id) goal_id = _wire_id(goal_id) # type: ignore[assignment] - assert session_id is not None and goal_id is not None + assert goal_id is not None with self._lock: current = self._sessions.get( - session_id, SessionPresentationSnapshot() + key, SessionPresentationSnapshot(), ) if current.dismissed_goal_id == goal_id: return current return self._replace_locked( - session_id, + key, replace( current, dismissed_goal_id=goal_id, @@ -228,19 +338,20 @@ def dismiss_goal( ), ) - def reconcile_goal(self, session_id: str, goal_id: str | None) -> bool: + def reconcile_goal( + self, engine: str, session_id: str, goal_id: str | None, + ) -> bool: """Return whether *goal_id* is hidden, clearing stale generations.""" - session_id = _wire_id(session_id) # type: ignore[assignment] + key = _scope_key(engine, session_id) goal_id = _wire_id(goal_id, optional=True) # type: ignore[assignment] - assert session_id is not None with self._lock: current = self._sessions.get( - session_id, SessionPresentationSnapshot() + key, SessionPresentationSnapshot(), ) dismissed = current.dismissed_goal_id if dismissed is not None and dismissed != goal_id: current = self._replace_locked( - session_id, + key, replace( current, dismissed_goal_id=None, @@ -249,51 +360,101 @@ def reconcile_goal(self, session_id: str, goal_id: str | None) -> bool: ) return goal_id is not None and current.dismissed_goal_id == goal_id - def move(self, old_session_id: str, session_id: str) -> None: + def move( + self, engine: str, old_session_id: str, session_id: str, + ) -> None: old_session_id = _wire_id(old_session_id) # type: ignore[assignment] session_id = _wire_id(session_id) # type: ignore[assignment] assert old_session_id is not None and session_id is not None if old_session_id == session_id: return + old_key = _scope_key(engine, old_session_id) + key = _scope_key(engine, session_id) with self._lock: - snapshot = self._sessions.get(old_session_id) + snapshot = self._sessions.get(old_key) if snapshot is None: return updated = OrderedDict(self._sessions) - updated.pop(old_session_id, None) - target = updated.get(session_id) + updated.pop(old_key, None) + target = updated.get(key) if target is None or snapshot.updated_at >= target.updated_at: - updated.pop(session_id, None) - updated[session_id] = snapshot + updated.pop(key, None) + updated[key] = snapshot self._persist_bounded(updated) self._sessions = updated - def delete(self, session_id: str) -> None: - session_id = _wire_id(session_id) # type: ignore[assignment] - assert session_id is not None + def delete(self, engine: str, session_id: str) -> None: + key = _scope_key(engine, session_id) with self._lock: - if session_id not in self._sessions: + if key not in self._sessions: return updated = OrderedDict(self._sessions) - updated.pop(session_id, None) + updated.pop(key, None) self._persist_bounded(updated) self._sessions = updated def _replace_locked( self, - session_id: str, + key: str, snapshot: SessionPresentationSnapshot, ) -> SessionPresentationSnapshot: updated = OrderedDict(self._sessions) - updated.pop(session_id, None) - updated[session_id] = snapshot + updated.pop(key, None) + updated[key] = snapshot while len(updated) > _MAX_ENTRIES: updated.popitem(last=False) self._persist_bounded(updated) self._sessions = updated return snapshot - def _load(self) -> OrderedDict[str, SessionPresentationSnapshot]: + def migrate_codex_profile_sessions( + self, + transform: Callable[[str], str], + *, + profile_revision: int, + ) -> int: + """Translate only explicitly Codex-owned presentation scopes.""" + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 1 + ): + raise SessionPresentationStoreError( + "invalid Codex profile revision") + with self._lock: + if self._profile_revision >= profile_revision: + return 0 + updated: OrderedDict[str, SessionPresentationSnapshot] = ( + OrderedDict() + ) + migrated = 0 + for key, snapshot in self._sessions.items(): + engine, session_id = _split_persisted_scope_key(key) + target_id = ( + str(_wire_id(transform(session_id))) + if engine == "codex" else session_id + ) + target = ( + _legacy_scope_key(target_id) + if engine == _LEGACY_ENGINE + else _scope_key(engine, target_id) + ) + existing = updated.get(target) + if existing is not None and existing != snapshot: + raise SessionPresentationStoreError( + "presentation profile migration collides") + updated[target] = snapshot + migrated += target != key + self._persist_bounded( + updated, profile_revision=profile_revision) + self._sessions = updated + self._profile_revision = profile_revision + return migrated + + def _load(self) -> tuple[ + OrderedDict[str, SessionPresentationSnapshot], + int, + ]: try: info = self.path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_FILE_BYTES: @@ -304,28 +465,56 @@ def _load(self) -> OrderedDict[str, SessionPresentationSnapshot]: if len(raw_bytes) > _MAX_FILE_BYTES: raise ValueError("session presentation store exceeds size limit") raw = json.loads(raw_bytes.decode("utf-8")) - if not isinstance(raw, dict) or set(raw) != {"version", "sessions"}: + if not isinstance(raw, dict) or set(raw) not in ( + {"version", "sessions"}, + {"version", "profile_revision", "sessions"}, + ): raise ValueError( "session presentation store has an invalid envelope" ) - if raw.get("version") != 1 or not isinstance( + if raw.get("version") not in {1, 2, 3} or not isinstance( raw.get("sessions"), dict ): raise ValueError( "session presentation store version is unsupported" ) + profile_revision = raw.get("profile_revision", 0) + if ( + isinstance(profile_revision, bool) + or not isinstance(profile_revision, int) + or profile_revision < 0 + or (raw.get("version") == 1 and profile_revision != 0) + ): + raise ValueError( + "session presentation profile revision is invalid") loaded: OrderedDict[ str, SessionPresentationSnapshot ] = OrderedDict() for session_id, value in raw["sessions"].items(): - clean_id = _wire_id(session_id) - assert clean_id is not None - loaded[clean_id] = _snapshot(value) + snapshot = _snapshot(value) + if raw.get("version") == 1: + clean_id = _wire_id(session_id) + assert clean_id is not None + # v1 did not record the engine. The old multi-account wire + # form has a provable Codex owner; retain every ambiguous + # bare id in quarantine until the native stores prove a + # unique owner. Dropping it here permanently loses unread + # completion and dismissed Goal state. + if "@" in clean_id: + loaded[_scope_key("codex", clean_id)] = snapshot + else: + loaded[_legacy_scope_key(clean_id)] = snapshot + else: + engine, _clean_id = _split_persisted_scope_key(session_id) + if raw.get("version") == 2 and engine == _LEGACY_ENGINE: + raise ValueError( + "v2 session presentation scope cannot be legacy") + loaded[session_id] = snapshot if len(loaded) > _MAX_ENTRIES: raise ValueError("session presentation store has too many entries") - return loaded + return loaded, profile_revision except FileNotFoundError: - return OrderedDict() + return OrderedDict(), 0 except Exception as exc: raise SessionPresentationStoreError( "session presentation store is unreadable" @@ -334,10 +523,13 @@ def _load(self) -> OrderedDict[str, SessionPresentationSnapshot]: @staticmethod def _payload( sessions: OrderedDict[str, SessionPresentationSnapshot], + *, + profile_revision: int, ) -> bytes: return json.dumps( { - "version": 1, + "version": 3, + "profile_revision": profile_revision, "sessions": { session_id: snapshot.as_dict() for session_id, snapshot in sessions.items() @@ -350,12 +542,19 @@ def _payload( def _persist_bounded( self, sessions: OrderedDict[str, SessionPresentationSnapshot], + *, + profile_revision: int | None = None, ) -> None: bounded = OrderedDict(sessions) - payload = self._payload(bounded) + revision = ( + self._profile_revision + if profile_revision is None else profile_revision + ) + payload = self._payload(bounded, profile_revision=revision) while len(payload) > _MAX_FILE_BYTES and len(bounded) > 1: bounded.popitem(last=False) - payload = self._payload(bounded) + payload = self._payload( + bounded, profile_revision=revision) if len(payload) > _MAX_FILE_BYTES: raise SessionPresentationStoreError( "session presentation store exceeds size limit" diff --git a/cc_remote/wrapper/stream.py b/cc_remote/wrapper/stream.py index 410c5a0..89fd0a9 100644 --- a/cc_remote/wrapper/stream.py +++ b/cc_remote/wrapper/stream.py @@ -14,6 +14,7 @@ import json import os import re +import stat import time import uuid from dataclasses import dataclass @@ -1075,6 +1076,49 @@ def transcript_path(session_id: str) -> str | None: return None +def transcript_presence(session_id: str) -> bool | None: + """Return exact Claude transcript presence, preserving lookup uncertainty. + + ``transcript_path`` intentionally collapses every filesystem failure into + ``None`` for ordinary history fallbacks. Engine ownership migration cannot: + an unreadable catalog is not proof that the same UUID belongs to Codex. + """ + if not _SAFE_SESSION_ID.fullmatch(session_id): + return None + try: + root = claude_projects_dir().resolve() + entries = os.scandir(root) + except FileNotFoundError: + return False + except OSError: + return None + scanned = 0 + try: + with entries: + for entry in entries: + try: + if entry.is_symlink(): + return None + if not entry.is_dir(follow_symlinks=False): + continue + except OSError: + return None + scanned += 1 + if scanned > _MAX_TRANSCRIPT_MATCHES: + return None + candidate = os.path.join(entry.path, f"{session_id}.jsonl") + try: + info = os.lstat(candidate) + except FileNotFoundError: + continue + except OSError: + return None + return True if stat.S_ISREG(info.st_mode) else None + except OSError: + return None + return False + + def _bounded_jsonl_lines(file): """Yield complete records while skipping a single pathological long line.""" while True: diff --git a/cc_remote/wrapper/work_context.py b/cc_remote/wrapper/work_context.py index f7c00bb..837b1d2 100644 --- a/cc_remote/wrapper/work_context.py +++ b/cc_remote/wrapper/work_context.py @@ -2,21 +2,131 @@ from __future__ import annotations import json +import os from typing import Any +from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER from cc_remote.wrapper.codex_sessions import codex_rollout_path from cc_remote.wrapper.stream import _bounded_jsonl_lines, transcript_path _BASELINE_HISTORY_RECORD_LIMIT = 256 +_CONTEXT_TAIL_SCAN_BYTES = 4 * 1024 * 1024 +_CONTEXT_RECORD_MAX_BYTES = 1024 * 1024 def _nonnegative_int(value: object) -> int | None: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: + if (isinstance(value, bool) or not isinstance(value, int) or value < 0 + or value > MAX_SAFE_WIRE_INTEGER): return None return value +def recover_codex_context_usage( + session_id: str, + *, + codex_home: str | None = None, +) -> dict[str, Any] | None: + """Recover the newest persisted Codex context sample from a bounded tail. + + Lightweight ``thread/resume`` does not replay historical tokenUsage + notifications. Resolve the rollout inside the selected account namespace + and inspect only its tail, so even multi-gigabyte sessions remain cheap. + """ + path = ( + codex_rollout_path(session_id) + if codex_home is None + else codex_rollout_path(session_id, codex_home=codex_home) + ) + if not path: + return None + try: + with open(path, "rb") as history: + before = os.fstat(history.fileno()) + size = before.st_size + start = max(0, size - _CONTEXT_TAIL_SCAN_BYTES) + # Inspect the byte immediately before the bounded window. Without + # it, a window which happens to start exactly after ``\n`` is + # indistinguishable from one starting halfway through a record and + # we would discard a complete first line. + read_start = max(0, start - 1) + history.seek(read_start) + data = history.read(size - read_start) + after = os.fstat(history.fileno()) + current = os.stat(path) + except OSError: + return None + + # A rollout can append while this bounded read is in flight. That makes the + # captured sample merely older, not corrupt, because we read exactly the + # pre-open snapshot length. Replacement or truncation is different: bytes + # may now belong to another source/offset, so fail closed and retry after + # the next process generation instead of painting a fabricated context. + if (before.st_dev != after.st_dev or before.st_ino != after.st_ino + or after.st_size < size + or current.st_dev != before.st_dev + or current.st_ino != before.st_ino + or current.st_size < size): + return None + + # A partial first record is never trustworthy. The final record may be + # complete without a trailing newline, which is normal for a closed file. + starts_at_record_boundary = start == 0 + if start > 0: + starts_at_record_boundary = data[:1] == b"\n" + data = data[1:] + lines = data.splitlines() + if not starts_at_record_boundary and lines: + lines = lines[1:] + for raw in reversed(lines): + if not raw or len(raw) > _CONTEXT_RECORD_MAX_BYTES: + continue + try: + record = json.loads(raw) + except (UnicodeError, ValueError): + continue + if not isinstance(record, dict): + continue + payload = record.get("payload") + if (record.get("type") != "event_msg" + or not isinstance(payload, dict) + or payload.get("type") != "token_count"): + continue + info = payload.get("info") + if not isinstance(info, dict): + continue + source = info.get("last_token_usage") + if not isinstance(source, dict): + source = info.get("last") + if not isinstance(source, dict): + continue + total = _nonnegative_int(source.get("total_tokens")) + if total is None: + total = _nonnegative_int(source.get("totalTokens")) + window = _nonnegative_int(info.get("model_context_window")) + if window is None: + window = _nonnegative_int(info.get("modelContextWindow")) + if total is None or window is None or window <= 0: + continue + last: dict[str, int] = {"totalTokens": total} + for snake, camel in ( + ("input_tokens", "inputTokens"), + ("cached_input_tokens", "cachedInputTokens"), + ("output_tokens", "outputTokens"), + ("reasoning_output_tokens", "reasoningOutputTokens"), + ): + value = _nonnegative_int(source.get(snake)) + if value is None: + value = _nonnegative_int(source.get(camel)) + if value is not None: + last[camel] = value + return { + "last": last, + "modelContextWindow": window, + } + return None + + def initial_work_context_baseline(engine: str, usage: dict[str, Any]) -> int: """Return the fresh Work session's startup zero point. diff --git a/deploy/README.md b/deploy/README.md index 25d79a5..59f5153 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -63,11 +63,11 @@ machine). The **full step-by-step guide is in the main [README](../README.md#生 through SQLite's backup API, restores the matching pre-release images before an older wrapper is restarted, and verifies the v34 Codex ownership backfill. -Protocol v34 is a coordinated upgrade: publish freshly built Relay/Web and +Protocol v35 is a coordinated upgrade: publish freshly built Relay/Web and Wrapper artifacts from the same tagged commit. The strict protocol gate is intentional and mixed protocol versions will not communicate. `setup-vps.sh` rejects a missing or mismatched web build manifest. Stop the wrapper first; -activate the v34 relay/web release; then start the v34 wrapper. +activate the v35 relay/web release; then start the v35 wrapper. The wrapper installer treats local Work data as part of the release transaction. It stops the existing service, writes a private snapshot below @@ -78,8 +78,8 @@ restores and starts the previous code. If data restoration fails, it leaves the wrapper stopped instead of running old code against a new schema. A manual or legacy-layout deployment must use the same order: stop the wrapper, run `work_registry_snapshot.py snapshot` from the new staging tree, activate and -verify v34, and retain that snapshot with the previous release. To roll back, -stop v34, run `work_registry_snapshot.py restore`, then switch and start the old +verify v35, and retain that snapshot with the previous release. To roll back, +stop v35, run `work_registry_snapshot.py restore`, then switch and start the old release. Never copy only `registry.sqlite3` while the wrapper is live because committed state may still be in its WAL file. Restoring a pre-release snapshot also restores pre-release Work metadata: sessions, projects, or schedule state diff --git a/tests/test_atomic_new_session.py b/tests/test_atomic_new_session.py index 2cc60ee..df0cd78 100644 --- a/tests/test_atomic_new_session.py +++ b/tests/test_atomic_new_session.py @@ -300,6 +300,61 @@ async def run(): asyncio.run(run()) +def test_spawn_bootstrap_disconnects_failed_resume_before_fresh_retry( + monkeypatch, tmp_path): + class RetryClaude: + permission_mode = "bypassPermissions" + effort = None + applied_effort = None + model = None + + def __init__(self, *_args, **_kwargs): + self.connect_args = [] + self.disconnects = 0 + + @staticmethod + def preflight(_binary): + return None + + async def connect(self, **kwargs): + self.connect_args.append(kwargs) + if kwargs["resume_id"] is not None: + raise RuntimeError("partial resume failed") + + async def disconnect(self): + self.disconnects += 1 + + async def run(): + machine, transport = _mk_machine() + monkeypatch.setattr(machine_module, "SdkHandle", RetryClaude) + monkeypatch.setattr( + machine_module, + "get_session_info", + lambda _sid: type("Info", (), {"cwd": str(tmp_path)})(), + ) + monkeypatch.setattr(machine_module, "save_session_id", lambda *_args: None) + machine._watch_session = lambda _sid: None + machine._prime_claude_ownership = lambda _sid: asyncio.sleep(0) + machine._load_history = lambda *_args: asyncio.sleep(0) + + ctx = await machine._spawn( + resume_id="resume-bootstrap", + engine="claude", + bootstrap=True, + ) + + assert ctx is not None + assert ctx.session_id is None + assert [call["resume_id"] for call in ctx.sdk.connect_args] == [ + "resume-bootstrap", None, + ] + assert ctx.sdk.disconnects == 1 + assert not [message for message in transport.sent + if message.type == "error"] + + asyncio.run(run()) + + def test_blank_new_session_does_not_start_a_turn(): async def run(): machine, transport = _mk_machine() diff --git a/tests/test_claude_permission_state.py b/tests/test_claude_permission_state.py index f2f345d..cdfe7b8 100644 --- a/tests/test_claude_permission_state.py +++ b/tests/test_claude_permission_state.py @@ -784,6 +784,53 @@ async def go(): asyncio.run(go()) +def test_claude_work_btw_reuses_registered_policy_and_work_identity(monkeypatch): + class FakeHandle: + @staticmethod + def preflight(_path): + return None + + def __init__(self, _cfg): + self.permission_mode = "bypassPermissions" + self.effort = "max" + self.work_mode = False + self.work_settings_path = None + self.connected = None + + async def connect(self, **kwargs): + self.connected = kwargs + + async def disconnect(self): + return None + + async def go(): + monkeypatch.setattr(machine_module, "SdkHandle", FakeHandle) + machine, _ = _mk_machine() + store = machine._work.for_engine("claude") + record = store.create_session() + store.bind_session(record.work_id, "parent-work") + parent = _mk_ctx("parent-work", "parent-work") + parent.cwd = record.cwd + parent.space = "work" + parent.work_id = record.work_id + parent.sdk = SimpleNamespace(permission_mode="bypassPermissions") + machine.sessions[parent.key] = parent + + fork = await machine._spawn_btw( + parent, owner_client_id="client-1") + + assert fork.space == "work" + assert fork.work_id == record.work_id + assert fork.sdk.work_mode is True + assert fork.sdk.permission_mode == "acceptEdits" + assert fork.sdk.work_settings_path.endswith(f"{record.work_id}.json") + assert fork.sdk.connected == { + "resume_id": "parent-work", "cwd": record.cwd, "fork": True, + } + + asyncio.run(go()) + + def test_open_btw_emits_its_permission_frame(): async def go(): machine, transport = _mk_machine() diff --git a/tests/test_claude_session_fork.py b/tests/test_claude_session_fork.py index 18d493b..82ca7b7 100644 --- a/tests/test_claude_session_fork.py +++ b/tests/test_claude_session_fork.py @@ -8,6 +8,7 @@ from cc_remote.protocol import ForkSession, SessionForked from cc_remote.wrapper import machine as machine_module +from cc_remote.wrapper import claude_forks as claude_forks_module from cc_remote.wrapper.claude_forks import ( ClaudeForkJournalError, claude_fork_marker, @@ -541,6 +542,48 @@ def get_info(session_id, directory=None): asyncio.run(run()) +def test_claude_fork_child_delete_lifecycle_survives_restart(tmp_path): + from cc_remote.wrapper.claude_forks import ClaudeForkJournal + + journal = ClaudeForkJournal(tmp_path) + journal.begin("request-1", PARENT, CUTOFF, CWD) + journal.claim_submission("request-1") + journal.complete("request-1", CHILD) + + assert journal.begin_delete(CHILD) == "delete_pending" + assert ClaudeForkJournal(tmp_path).child_entry(CHILD)["status"] == ( + "delete_pending") + assert journal.abort_delete(CHILD) is True + assert journal.begin_delete(CHILD) == "delete_pending" + assert journal.finish_delete(CHILD) is True + reloaded = ClaudeForkJournal(tmp_path) + assert reloaded.child_entry(CHILD)["status"] == "deleted" + assert reloaded.complete("request-1", CHILD)["status"] == "deleted" + + +def test_claude_fork_journal_never_compacts_deleted_replay_tombstone( + tmp_path, monkeypatch, +): + from cc_remote.wrapper.claude_forks import ClaudeForkJournal + + monkeypatch.setattr(claude_forks_module, "_MAX_ENTRIES", 1) + journal = ClaudeForkJournal(tmp_path) + journal.begin("request-deleted", PARENT, CUTOFF, CWD) + journal.claim_submission("request-deleted") + journal.complete("request-deleted", CHILD) + journal.begin_delete(CHILD) + journal.finish_delete(CHILD) + + with pytest.raises(ClaudeForkJournalError, match="capacity exhausted"): + journal.begin( + "request-new", PARENT, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", CWD, + ) + + assert ClaudeForkJournal(tmp_path).get( + "request-deleted")["status"] == "deleted" + + def test_cold_claude_source_uses_session_info_cwd_without_spawning(monkeypatch): async def run(): machine, _ = _mk_machine() diff --git a/tests/test_claude_storage_root.py b/tests/test_claude_storage_root.py index ea0ddef..0121743 100644 --- a/tests/test_claude_storage_root.py +++ b/tests/test_claude_storage_root.py @@ -12,7 +12,7 @@ ) from cc_remote.claude_paths import claude_config_dir, claude_projects_dir -from cc_remote.wrapper.stream import transcript_path +from cc_remote.wrapper.stream import transcript_path, transcript_presence SESSION_ID = "11111111-1111-4111-8111-111111111111" @@ -98,8 +98,10 @@ def test_settings_only_provider_switch_keeps_one_claude_catalog( SESSION_ID, )] == ["user", "assistant"] assert transcript_path(SESSION_ID) == str(source.resolve()) + assert transcript_presence(SESSION_ID) is True assert transcript_path(SESSION_ID) != str(decoy.resolve()) + assert transcript_presence("22222222-2222-4222-8222-222222222222") is False def test_default_claude_root_remains_home_scoped(monkeypatch, tmp_path): @@ -131,3 +133,17 @@ def test_relative_claude_root_keeps_transcript_watcher_aligned( sessions = list_sessions(limit=20) assert [item.session_id for item in sessions] == [SESSION_ID] assert transcript_path(SESSION_ID) == str(source.resolve()) + assert transcript_presence(SESSION_ID) is True + + +def test_transcript_presence_preserves_unreadable_catalog_uncertainty( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) + + def unavailable(_path): + raise PermissionError("catalog unavailable") + + monkeypatch.setattr("cc_remote.wrapper.stream.os.scandir", unavailable) + assert transcript_presence(SESSION_ID) is None diff --git a/tests/test_codex_archived_rollout.py b/tests/test_codex_archived_rollout.py index bf1170c..2026dfb 100644 --- a/tests/test_codex_archived_rollout.py +++ b/tests/test_codex_archived_rollout.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sqlite3 from cc_remote.wrapper import codex_sessions @@ -42,3 +43,121 @@ def test_active_rollout_wins_if_both_stores_contain_same_id(tmp_path, monkeypatc assert codex_sessions.codex_rollout_path(session_id) == str(active_rollout) assert codex_sessions.codex_session_cwd(session_id) == "/repo/active" + + +def test_codex_session_presence_uses_exact_state_db_and_preserves_uncertainty( + tmp_path, +): + home = tmp_path / ".codex" + home.mkdir() + db = home / "state_5.sqlite" + with sqlite3.connect(db) as connection: + connection.execute("CREATE TABLE threads (id TEXT PRIMARY KEY)") + connection.execute("INSERT INTO threads(id) VALUES (?)", ("native-id",)) + + assert codex_sessions.codex_session_presence( + "native-id", codex_home=home) is True + assert codex_sessions.codex_session_presence( + "missing-id", codex_home=home) is False + + db.write_bytes(b"not sqlite") + assert codex_sessions.codex_session_presence( + "native-id", codex_home=home) is None + + +def test_exact_catalog_rows_restore_empty_preview_without_crossing_provider( + tmp_path, +): + home = tmp_path / ".codex" + home.mkdir() + (home / "config.toml").write_text( + 'model_provider = "openai"\n', encoding="utf-8") + db = home / "state_5.sqlite" + with sqlite3.connect(db) as connection: + connection.execute( + """CREATE TABLE threads ( + id TEXT PRIMARY KEY, + cwd TEXT, + name TEXT, + preview TEXT, + first_user_message TEXT, + title TEXT, + recency_at INTEGER, + updated_at INTEGER, + created_at INTEGER, + git_branch TEXT, + archived INTEGER, + model_provider TEXT + )""" + ) + connection.executemany( + """INSERT INTO threads VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + )""", + [ + ( + "empty-preview-child", "/repo/stack", None, "", "", "", + 20, 21, 19, "fork-fix", 0, "openai", + ), + ( + "other-provider", "/repo/other", None, "secret", "secret", + "secret", 30, 30, 30, None, 0, "different-provider", + ), + ], + ) + + rows = codex_sessions.codex_exact_catalog_rows( + ["empty-preview-child", "other-provider"], codex_home=home) + + assert rows == [{ + "session_id": "empty-preview-child", + "summary": None, + "first_prompt": None, + "cwd": "/repo/stack", + "last_modified": "21", + "git_branch": "fork-fix", + "forked_from_id": None, + "status": None, + "tag": None, + }] + + +def test_exact_catalog_rows_bound_optional_text_on_minimal_schema(tmp_path): + home = tmp_path / ".codex" + home.mkdir() + db = home / "state_5.sqlite" + with sqlite3.connect(db) as connection: + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, preview TEXT)" + ) + connection.execute( + "INSERT INTO threads(id, preview) VALUES (?, ?)", + ("bounded-preview", "x" * 10_000), + ) + + rows = codex_sessions.codex_exact_catalog_rows( + ["bounded-preview"], codex_home=home) + + assert rows is not None and len(rows) == 1 + assert rows[0]["session_id"] == "bounded-preview" + assert rows[0]["first_prompt"] == "x" * 2000 + assert rows[0]["cwd"] is None + + +def test_exact_catalog_old_schema_with_provider_preserves_uncertainty(tmp_path): + home = tmp_path / ".codex" + home.mkdir() + (home / "config.toml").write_text( + 'model_provider = "openai"\n', encoding="utf-8") + db = home / "state_5.sqlite" + with sqlite3.connect(db) as connection: + connection.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, preview TEXT)" + ) + connection.execute( + "INSERT INTO threads(id, preview) VALUES (?, ?)", + ("old-schema-child", "hidden fork"), + ) + + assert codex_sessions.codex_exact_catalog_rows( + ["old-schema-child"], codex_home=home) is None diff --git a/tests/test_codex_context_interrupt.py b/tests/test_codex_context_interrupt.py index b46c907..2f98577 100644 --- a/tests/test_codex_context_interrupt.py +++ b/tests/test_codex_context_interrupt.py @@ -6,7 +6,9 @@ import tempfile import cc_remote.wrapper.codex_sessions as codex_sessions +import cc_remote.wrapper.codex_handle as codex_handle_module +from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER from cc_remote.wrapper.codex_handle import CodexHandle from cc_remote.wrapper.codex_stream import CodexStreamTranslator @@ -53,6 +55,145 @@ def test_context_uses_last_not_cumulative_total(): assert u["used_tokens"] == 40000, u # last, NOT 120000 +def test_context_rejects_invalid_live_token_usage_values(): + h = CodexHandle(_Cfg()) + h.last_token_usage = { + "last": {"totalTokens": True}, + "total": {"totalTokens": -5}, + "modelContextWindow": "not-a-number", + } + h.context_window = True + + usage = asyncio.run(h.get_context_usage()) + + assert usage["used_tokens"] is None + assert isinstance(usage["context_window"], int) + assert not isinstance(usage["context_window"], bool) + + h.last_token_usage = { + "last": {"totalTokens": MAX_SAFE_WIRE_INTEGER + 1}, + "total": {"totalTokens": MAX_SAFE_WIRE_INTEGER + 1}, + "modelContextWindow": MAX_SAFE_WIRE_INTEGER + 1, + } + h.context_window = MAX_SAFE_WIRE_INTEGER + 1 + usage = asyncio.run(h.get_context_usage()) + assert usage["used_tokens"] is None + assert 0 < usage["context_window"] <= MAX_SAFE_WIRE_INTEGER + + +def test_work_cold_resume_recovers_context_until_live_notification(monkeypatch): + recovered_calls = [] + + def recover(session_id, *, codex_home=None): + recovered_calls.append((session_id, codex_home)) + return { + "last": {"totalTokens": 103658}, + "modelContextWindow": 258400, + } + + monkeypatch.setattr(codex_handle_module, "recover_codex_context_usage", recover) + h = CodexHandle(_Cfg(), work_mode=True, codex_home="/tmp/profile") + h.thread_id = "native-session" + cold = asyncio.run(h.get_context_usage()) + assert cold["used_tokens"] == 103658 + assert cold["context_window"] == 258400 + assert recovered_calls == [( + "native-session", os.path.realpath("/tmp/profile"))] + + asyncio.run(h._dispatch({ + "method": "thread/tokenUsage/updated", + "params": {"tokenUsage": { + "last": {"totalTokens": 104321}, + "modelContextWindow": 300000, + }}, + })) + live = asyncio.run(h.get_context_usage()) + assert live["used_tokens"] == 104321 + assert live["context_window"] == 300000 + assert len(recovered_calls) == 1 + + +def test_code_cold_resume_recovers_profile_scoped_context(monkeypatch): + recovered_calls = [] + + def recover(session_id, *, codex_home=None): + recovered_calls.append((session_id, codex_home)) + return { + "last": {"totalTokens": 88_765}, + "modelContextWindow": 258_400, + } + + monkeypatch.setattr( + codex_handle_module, "recover_codex_context_usage", recover) + handle = CodexHandle(_Cfg(), codex_home="/tmp/code-profile") + handle.thread_id = "code-native-session" + + usage = asyncio.run(handle.get_context_usage()) + + assert usage["used_tokens"] == 88_765 + assert usage["context_window"] == 258_400 + assert recovered_calls == [( + "code-native-session", os.path.realpath("/tmp/code-profile"))] + + +def test_work_context_recovery_discards_old_thread_race(monkeypatch): + async def run(): + started = asyncio.Event() + release = asyncio.Event() + loop = asyncio.get_running_loop() + + def recover(_session_id, *, codex_home=None): + del codex_home + loop.call_soon_threadsafe(started.set) + asyncio.run_coroutine_threadsafe(release.wait(), loop).result() + return { + "last": {"totalTokens": 999}, + "modelContextWindow": 1000, + } + + monkeypatch.setattr( + codex_handle_module, "recover_codex_context_usage", recover) + handle = CodexHandle(_Cfg(), work_mode=True) + handle.thread_id = "old-thread" + reading = asyncio.create_task(handle.get_context_usage()) + await started.wait() + handle.thread_id = "new-thread" + handle._generation += 1 + release.set() + usage = await reading + assert usage["used_tokens"] is None + assert handle.last_token_usage is None + + asyncio.run(run()) + + +def test_work_context_recovery_retries_after_transient_miss(monkeypatch): + calls = 0 + + def recover(_session_id, *, codex_home=None): + nonlocal calls + del codex_home + calls += 1 + if calls == 1: + return None + return { + "last": {"totalTokens": 456}, + "modelContextWindow": 1000, + } + + monkeypatch.setattr( + codex_handle_module, "recover_codex_context_usage", recover) + handle = CodexHandle(_Cfg(), work_mode=True) + handle.thread_id = "native-session" + + first = asyncio.run(handle.get_context_usage()) + second = asyncio.run(handle.get_context_usage()) + + assert first["used_tokens"] is None + assert second["used_tokens"] == 456 + assert calls == 2 + + def test_interrupt_status_maps_to_cc_vocab(): tr = CodexStreamTranslator(8000) evs = tr.feed({"method": "turn/completed", "params": {"turn": { diff --git a/tests/test_codex_controls.py b/tests/test_codex_controls.py index d010d6a..3a67431 100644 --- a/tests/test_codex_controls.py +++ b/tests/test_codex_controls.py @@ -36,6 +36,7 @@ WORK_BASE_INSTRUCTIONS, WORK_DEVELOPER_INSTRUCTIONS, ) +from cc_remote.workspaces import WorkStores from tests.test_multisession import _mk_ctx, _mk_machine @@ -44,6 +45,308 @@ class _Cfg: tool_result_max = 8000 +def test_codex_work_btw_uses_private_profile_bound_runtime( + monkeypatch, tmp_path, +): + created = [] + + class FakeCodexHandle: + def __init__(self, _cfg, **kwargs): + self.init = kwargs + self.thread_id = None + self.model = "gpt-work" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = kwargs["cwd"] + self._generation = 1 + self._thread_settings_revision = 0 + self._approval = "on-request" + self.approval_policy = "on-request" + self.permission_profile = ":workspace" + self.web_search_override = "live" + self.web_search = "live" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + self.connect_call = None + self.disconnected = False + created.append(self) + + @property + def approval(self): + return self._approval + + @approval.setter + def approval(self, value): + self._approval = value + self.approval_policy = value + + async def connect(self, **kwargs): + self.connect_call = kwargs + self.thread_id = "forked-work" + # Model the app-server echoing controls from the parent rollout. + self.approval = "on-request" + self.approval_policy = "on-request" + self.permission_profile = ":workspace" + + async def disconnect(self): + self.disconnected = True + + async def run(): + profiles = { + "primary": { + "label": "Primary", + "home": str(tmp_path / "primary-home"), + "default": True, + }, + "secondary": { + "label": "Secondary", + "home": str(tmp_path / "secondary-home"), + }, + } + machine, _ = _mk_machine() + machine.cfg.codex_profiles_json = json.dumps(profiles) + machine.cfg.state_dir = tmp_path / "state" + machine.cfg.codex_work_root = tmp_path / "work" / "codex" + machine.cfg.claude_work_root = tmp_path / "work" / "claude" + # Rebuild so profile daemons and Work roots match the explicit config. + machine = machine_module.WrapperMachine(machine.cfg, machine.transport) + machine._work = WorkStores( + machine.cfg.claude_work_root, machine.cfg.codex_work_root) + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + + async def keep_effort(_model, effort, **_kwargs): + return effort + + monkeypatch.setattr(machine_module, "clamp_effort", keep_effort) + + store = machine._work.for_engine("codex") + record = store.create_session(codex_profile_id="secondary") + store.bind_session( + record.work_id, "parent-work", codex_profile_id="secondary") + parent = _mk_ctx("secondary@parent-work", "parent-work") + parent.engine = "codex" + parent.cwd = record.cwd + parent.space = "work" + parent.work_id = record.work_id + parent.codex_profile_id = "secondary" + machine.sessions[parent.key] = parent + + fork = await machine._spawn_btw( + parent, owner_client_id="client-1") + + handle = created[-1] + assert handle.init["cwd"] == record.cwd + assert handle.init["daemon_mode"] == "off" + assert handle.init["work_mode"] is True + assert handle.init["codex_home"] == str(tmp_path / "secondary-home") + assert handle.connect_call == { + "resume_id": "parent-work", "cwd": record.cwd, "fork": True, + } + assert fork.space == "work" + assert fork.work_id == record.work_id + assert fork.codex_profile_id == "secondary" + assert handle.approval == handle.approval_policy == "never" + assert handle.permission_profile == "cc_remote_work" + assert handle.web_search_override is None + assert handle.web_search == "cached" + + asyncio.run(run()) + + +def test_codex_code_btw_keeps_parent_controls_and_shared_mode(monkeypatch): + created = [] + + class FakeCodexHandle: + def __init__(self, _cfg, **kwargs): + self.init = kwargs + self.thread_id = None + self.model = "gpt-code" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = kwargs["cwd"] + self._generation = 1 + self._thread_settings_revision = 0 + self.approval = "never" + self.approval_policy = "never" + self.permission_profile = None + self.web_search_override = None + self.web_search = "cached" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + created.append(self) + + async def connect(self, **_kwargs): + self.thread_id = "forked-code" + + async def disconnect(self): + return None + + async def run(): + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + machine, _ = _mk_machine() + parent = _mk_ctx("parent-code", "parent-code") + parent.engine = "codex" + parent.space = "code" + parent.sdk = SimpleNamespace( + approval="on-request", + approval_policy="on-request", + permission_profile=":read-only", + web_search_override="live", + web_search="live", + ) + machine.sessions[parent.key] = parent + + fork = await machine._spawn_btw( + parent, owner_client_id="client-1") + + handle = created[-1] + assert handle.init["daemon_mode"] == machine.cfg.codex_daemon_mode + assert "work_mode" not in handle.init + assert "codex_home" not in handle.init + assert fork.space == "code" and fork.work_id is None + assert handle.approval == handle.approval_policy == "on-request" + assert handle.permission_profile == ":read-only" + assert handle.web_search_override == handle.web_search == "live" + + asyncio.run(run()) + + +def test_btw_post_connect_failure_disconnects_partial_handle(monkeypatch): + created = [] + + class FakeCodexHandle: + def __init__(self, _cfg, **kwargs): + self.init = kwargs + self.thread_id = None + self.model = "gpt-code" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = kwargs["cwd"] + self._generation = 1 + self._thread_settings_revision = 0 + self.approval = "never" + self.approval_policy = "never" + self.permission_profile = None + self.web_search_override = None + self.web_search = "cached" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + self.disconnected = False + created.append(self) + + async def connect(self, **_kwargs): + self.thread_id = "partial-fork" + + async def disconnect(self): + self.disconnected = True + + async def run(): + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + machine, _ = _mk_machine() + parent = _mk_ctx("parent-code", "parent-code") + parent.engine = "codex" + parent.sdk = SimpleNamespace( + approval="never", approval_policy="never", + permission_profile=None, web_search_override=None, + web_search="cached", + ) + machine.sessions[parent.key] = parent + + async def fail_effort(*_args, **_kwargs): + raise RuntimeError("effort probe failed") + + machine._resolve_codex_session_effort = fail_effort + with pytest.raises( + machine_module._BtwSpawnFailure, + match="临时侧边会话暂时无法打开", + ): + await machine._spawn_btw(parent, owner_client_id="client-1") + + assert created[-1].disconnected is True + assert list(machine.sessions) == ["parent-code"] + + asyncio.run(run()) + + +def test_spawn_post_connect_failure_disconnects_partial_handle( + monkeypatch, tmp_path, +): + created = [] + + class FakeCodexHandle: + def __init__(self, _cfg, **kwargs): + self.thread_id = None + self.model = "gpt-code" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = kwargs["cwd"] + self._generation = 1 + self._thread_settings_revision = 0 + self.approval = "never" + self.approval_policy = "never" + self.permission_profile = None + self.web_search_override = None + self.web_search = "cached" + self.collaboration_mode = "default" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + self.disconnected = False + created.append(self) + + async def connect(self, **_kwargs): + self.thread_id = "partial-session" + + async def disconnect(self): + self.disconnected = True + + async def run(): + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + machine, transport = _mk_machine() + machine.cfg.cc_cwd = str(tmp_path) + + async def fail_effort(*_args, **_kwargs): + raise RuntimeError("effort probe failed") + + machine._resolve_codex_session_effort = fail_effort + ctx = await machine._spawn( + resume_id=None, + cwd=str(tmp_path), + engine="codex", + space="code", + ) + + assert ctx is None + assert created[-1].disconnected is True + assert machine.sessions == {} + assert any(message.type == "error" for message in transport.sent) + + asyncio.run(run()) + + def test_provider_error_diagnostic_keeps_only_safe_classification(): diagnostic = _provider_error_diagnostic({ "willRetry": True, @@ -2558,6 +2861,635 @@ def test_codex_catalog_normalization_is_structurally_bounded(monkeypatch): assert normalized[0]["efforts"] == ["low", "high"] +def test_machine_resolves_nullable_codex_effort_without_loading_forever( + monkeypatch): + async def run(): + machine, _transport = _mk_machine() + ctx = _mk_ctx("effort-session", "effort-session") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-default", + effort=None, + applied_effort=None, + display_effort=None, + display_effort_model=None, + _cwd="/tmp/effort-one", + _generation=1, + ) + + async def configured_default(): + return "medium" + + async def unexpected_model_default(*_args, **_kwargs): + raise AssertionError("configured default must win") + + monkeypatch.setattr( + machine_module, "default_effort_for", unexpected_model_default) + ctx.sdk.configured_default_effort = configured_default + assert await machine._resolve_codex_session_effort(ctx) == "medium" + assert ctx.sdk.effort is None + assert ctx.sdk.applied_effort is None + assert ctx.sdk.display_effort == "medium" + assert ctx.sdk.display_effort_model == "gpt-default" + + ctx.sdk.effort = None + ctx.sdk.applied_effort = None + ctx.sdk.display_effort = None + ctx.sdk.display_effort_model = None + + async def unavailable_default(): + return None + + ctx.sdk.configured_default_effort = unavailable_default + + async def model_default(model, *, codex_home=None): + assert model == "gpt-default" + assert codex_home is None + return "low" + + monkeypatch.setattr( + machine_module, "default_effort_for", model_default) + assert await machine._resolve_codex_session_effort(ctx) == "low" + assert ctx.sdk.effort is None + assert ctx.sdk.display_effort == "low" + + ctx.sdk.display_effort = None + ctx.sdk.display_effort_model = None + + unknown_default_calls = 0 + + async def unknown_model_default(_model, *, codex_home=None): + nonlocal unknown_default_calls + unknown_default_calls += 1 + return None + + monkeypatch.setattr( + machine_module, "default_effort_for", unknown_model_default) + assert await machine._resolve_codex_session_effort(ctx) == ( + machine_module.MODEL_DEFAULT_EFFORT) + assert ctx.sdk.effort is None + assert ctx.sdk.display_effort == machine_module.MODEL_DEFAULT_EFFORT + assert ctx.sdk.display_effort_model == "gpt-default" + assert unknown_default_calls == 1 + assert await machine._resolve_codex_session_effort(ctx) == ( + machine_module.MODEL_DEFAULT_EFFORT) + assert unknown_default_calls == 1 + + # A temporary catalog miss is throttled, not cached forever. The next + # eligible control refresh can replace the truthful sentinel with the + # concrete suggested default without pinning turn/start. + ctx.sdk._display_effort_retry_at = 0 + + async def recovered_model_default(_model, *, codex_home=None): + return "high" + + monkeypatch.setattr( + machine_module, "default_effort_for", recovered_model_default) + assert await machine._resolve_codex_session_effort(ctx) == "high" + assert ctx.sdk.effort is None + assert ctx.sdk.display_effort == "high" + + # A failed effective-config read must still leave time for the catalog + # fallback instead of immediately degrading to the sentinel. + ctx.sdk.display_effort = None + ctx.sdk.display_effort_model = None + + async def failed_configured_default(): + raise RuntimeError("config temporarily unavailable") + + ctx.sdk.configured_default_effort = failed_configured_default + assert await machine._resolve_codex_session_effort(ctx) == "high" + assert ctx.sdk.effort is None + assert ctx.sdk.display_effort == "high" + assert ctx.sdk._display_effort_retry_at is not None + + # A concrete catalog fallback is provisional when config/read failed. + # Once the retry window opens, the recovered effective config replaces + # it instead of leaving the UI pinned to the wrong model default. + configured_recovery_calls = 0 + + async def recovered_configured_default(): + nonlocal configured_recovery_calls + configured_recovery_calls += 1 + return "medium" + + ctx.sdk.configured_default_effort = recovered_configured_default + assert await machine._resolve_codex_session_effort(ctx) == "high" + assert configured_recovery_calls == 0 + ctx.sdk._display_effort_retry_at = 0 + assert await machine._resolve_codex_session_effort(ctx) == "medium" + assert configured_recovery_calls == 1 + assert ctx.sdk._display_effort_retry_at is not None + + # A concrete display cache is authoritative only for the cwd and + # app-server generation that produced it. + scoped_config_calls = 0 + + async def scoped_configured_default(): + nonlocal scoped_config_calls + scoped_config_calls += 1 + return "xhigh" if ctx.sdk._generation == 1 else "low" + + ctx.sdk.configured_default_effort = scoped_configured_default + ctx.sdk._cwd = "/tmp/effort-two" + assert await machine._resolve_codex_session_effort(ctx) == "xhigh" + ctx.sdk._generation = 2 + assert await machine._resolve_codex_session_effort(ctx) == "low" + assert scoped_config_calls == 2 + + ctx.sdk.display_effort = None + ctx.sdk.display_effort_model = None + + async def clamp(_model, effort, *, codex_home=None): + assert effort == "low" + return effort + + monkeypatch.setattr(machine_module, "clamp_effort", clamp) + assert await machine._resolve_codex_session_effort( + ctx, preferred="low") == "low" + assert ctx.sdk.effort == ctx.sdk.applied_effort == "low" + assert ctx.sdk.display_effort == "low" + assert ctx.sdk.display_effort_model == "gpt-default" + + # thread/fork may echo the parent's explicit setting. BTW's wrapper- + # owned low choice must still win before its first query. + ctx.sdk.effort = "high" + ctx.sdk.applied_effort = "high" + assert await machine._resolve_codex_session_effort( + ctx, preferred="low") == "low" + assert ctx.sdk.effort == ctx.sdk.applied_effort == "low" + + asyncio.run(run()) + + +def test_machine_effort_resolution_discards_stale_async_authority(monkeypatch): + async def run(): + machine, _transport = _mk_machine() + ctx = _mk_ctx("effort-race", "effort-race") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-before", + effort=None, + applied_effort=None, + display_effort=None, + display_effort_model=None, + display_effort_cwd=None, + display_effort_generation=None, + _display_effort_retry_at=None, + _cwd="/tmp/effort-before", + _generation=1, + _thread_settings_revision=0, + ) + started = asyncio.Event() + release = asyncio.Event() + + async def delayed_configured_default(): + started.set() + await release.wait() + return "xhigh" + + async def unexpected_catalog_default(*_args, **_kwargs): + raise AssertionError("stale config result must not fall through") + + ctx.sdk.configured_default_effort = delayed_configured_default + monkeypatch.setattr( + machine_module, "default_effort_for", unexpected_catalog_default) + resolving = asyncio.create_task( + machine._resolve_codex_session_effort(ctx)) + await started.wait() + + # Simulate the authoritative thread/settings/updated snapshot which can + # arrive while config/read is in flight. Its concrete clamp must win. + ctx.sdk.model = "gpt-after" + ctx.sdk.effort = "medium" + ctx.sdk.applied_effort = "medium" + ctx.sdk.display_effort = "medium" + ctx.sdk.display_effort_model = "gpt-after" + ctx.sdk.display_effort_cwd = os.path.realpath("/tmp/effort-after") + ctx.sdk.display_effort_generation = 2 + ctx.sdk._cwd = "/tmp/effort-after" + ctx.sdk._generation = 2 + ctx.sdk._thread_settings_revision = 1 + release.set() + + assert await resolving == "medium" + assert ctx.sdk.effort == "medium" + assert ctx.sdk.display_effort == "medium" + assert ctx.sdk.display_effort_model == "gpt-after" + assert ctx.sdk.display_effort_generation == 2 + + asyncio.run(run()) + + +def test_machine_model_effort_publish_never_mixes_async_authorities(monkeypatch): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("effort-publish-race", "effort-publish-race") + ctx.engine = "codex" + ctx.announced_model = "gpt-ui-before" + ctx.announced_effort = "high" + ctx.sdk = SimpleNamespace( + model="gpt-before", + effort=None, + applied_effort=None, + display_effort=None, + display_effort_model=None, + display_effort_cwd=None, + display_effort_generation=None, + _display_effort_retry_at=None, + _cwd="/tmp/effort-publish-before", + _generation=1, + _thread_settings_revision=0, + ) + started = asyncio.Event() + release = asyncio.Event() + + async def delayed_configured_default(): + started.set() + await release.wait() + return "xhigh" + + async def unexpected_catalog_default(*_args, **_kwargs): + raise AssertionError("stale config result must not fall through") + + ctx.sdk.configured_default_effort = delayed_configured_default + monkeypatch.setattr( + machine_module, "default_effort_for", unexpected_catalog_default) + publishing = asyncio.create_task( + machine._publish_codex_model_effort(ctx)) + await started.wait() + + # No half-snapshot may escape while the nullable effort probe is still + # tied to the old model. The authoritative notification replaces the + # complete pair before the probe completes. + assert not [ + event for event in transport.sent + if isinstance(event, (Model, Effort)) + ] + ctx.sdk.model = "gpt-after" + ctx.sdk.effort = "medium" + ctx.sdk.applied_effort = "medium" + ctx.sdk.display_effort = "medium" + ctx.sdk.display_effort_model = "gpt-after" + ctx.sdk.display_effort_cwd = os.path.realpath( + "/tmp/effort-publish-after") + ctx.sdk.display_effort_generation = 2 + ctx.sdk._cwd = "/tmp/effort-publish-after" + ctx.sdk._generation = 2 + ctx.sdk._thread_settings_revision = 1 + release.set() + + assert await publishing is True + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in transport.sent + if isinstance(event, (Model, Effort)) + ] == [ + ("model", "gpt-after", None), + ("effort", None, "medium"), + ] + assert ctx.announced_model == "gpt-after" + assert ctx.announced_effort == "medium" + + asyncio.run(run()) + + +def test_machine_model_effort_publish_completes_old_pair_before_replacement(): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("effort-send-race", "effort-send-race") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-before", + effort="high", + applied_effort="high", + display_effort="high", + display_effort_model="gpt-before", + display_effort_cwd=os.path.realpath("/tmp/effort-send-before"), + display_effort_generation=1, + _display_effort_retry_at=None, + _cwd="/tmp/effort-send-before", + _generation=1, + _thread_settings_revision=0, + ) + original_send = transport.send + replaced = False + + async def send(event): + nonlocal replaced + await original_send(event) + if isinstance(event, Model) and not replaced: + replaced = True + # A native settings notification lands while sending the first + # frame. The old effort must still follow the old model before + # a complete new pair replaces it. + ctx.sdk.model = "gpt-after" + ctx.sdk.effort = "medium" + ctx.sdk.applied_effort = "medium" + ctx.sdk.display_effort = "medium" + ctx.sdk.display_effort_model = "gpt-after" + ctx.sdk.display_effort_cwd = os.path.realpath( + "/tmp/effort-send-after") + ctx.sdk.display_effort_generation = 2 + ctx.sdk._cwd = "/tmp/effort-send-after" + ctx.sdk._generation = 2 + ctx.sdk._thread_settings_revision = 1 + + transport.send = send + published: list[object] = [] + assert await machine._publish_codex_model_effort( + ctx, force=True, published=published, + ) is True + + expected = [ + ("model", "gpt-before", None), + ("effort", None, "high"), + ("model", "gpt-after", None), + ("effort", None, "medium"), + ] + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in transport.sent + ] == expected + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in published + ] == expected + assert ctx.announced_model == "gpt-after" + assert ctx.announced_effort == "medium" + + asyncio.run(run()) + + +def test_machine_forced_model_effort_publish_falls_back_after_probe_churn(): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("effort-probe-churn", "effort-probe-churn") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-0", + effort="high", + applied_effort="high", + display_effort="high", + display_effort_model="gpt-0", + display_effort_cwd=os.path.realpath("/tmp/effort-probe-churn"), + display_effort_generation=1, + _display_effort_retry_at=None, + _cwd="/tmp/effort-probe-churn", + _generation=1, + _thread_settings_revision=0, + ) + probes = 0 + + async def churning_effort(_ctx): + nonlocal probes + probes += 1 + ctx.sdk.model = f"gpt-{probes}" + ctx.sdk.display_effort_model = ctx.sdk.model + ctx.sdk._thread_settings_revision = probes + return "high" + + machine._resolve_codex_session_effort = churning_effort + published: list[object] = [] + assert await machine._publish_codex_model_effort( + ctx, force=True, published=published, + ) is True + + assert probes == 3 + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in transport.sent + ] == [ + ("model", "gpt-3", None), + ("effort", None, "high"), + ] + assert published == transport.sent + assert ctx.announced_model == "gpt-3" + assert ctx.announced_effort == "high" + + asyncio.run(run()) + + +def test_codex_resume_restores_rollout_effort_after_nullable_resume( + monkeypatch, tmp_path): + class FakeCodexHandle: + def __init__(self, _cfg, cwd=None, daemon_mode=None, + daemon_manager=None): + self.cwd = cwd + self._cwd = cwd + self._generation = 1 + self.daemon_mode = daemon_mode + self.daemon_manager = daemon_manager + self.thread_id = None + self.proc = SimpleNamespace(returncode=None) + self.model = "gpt-test" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._approval = "never" + self.approval_policy = "never" + self.permission_profile = None + self.web_search = "cached" + self.web_search_override = None + self.collaboration_mode = "default" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + self.preconnect_effort = None + self.preconnect_model = None + self.set_model_calls = [] + + @property + def approval(self): + return self._approval + + @approval.setter + def approval(self, value): + self._approval = value + self.approval_policy = value + + async def connect(self, **kwargs): + self.thread_id = kwargs["resume_id"] + self.preconnect_effort = self.effort + self.preconnect_model = self.model + # Model the resume response echoing the retired native value over + # the wrapper's pre-connect catalog replacement. + self.model = "retired-model" + # Some app-server versions omit the effective override on resume. + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + + async def set_model(self, model): + self.set_model_calls.append(model) + self.model = model + + async def configured_default_effort(self): + return "medium" + + async def disconnect(self): + self.proc = None + + async def run(): + thread_id = "nullable-effort-resume" + machine, _transport = _mk_machine() + machine.cfg.cc_cwd = str(tmp_path) + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + monkeypatch.setattr( + machine_module, + "codex_session_cwd", + lambda _thread_id: str(tmp_path), + ) + monkeypatch.setattr( + machine_module, + "codex_session_settings", + lambda *_args, **_kwargs: { + "model": "retired-model", + "effort": "high", + }, + ) + + async def catalog(*, codex_home=None): + return [{ + "id": "current-model", + "efforts": ["high"], + "default_effort": "high", + "is_default": True, + }] + + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr( + machine_module, "codex_current_provider", lambda **_kwargs: "") + + async def unchanged_effort(_model, effort, **_kwargs): + return effort + + monkeypatch.setattr(machine_module, "clamp_effort", unchanged_effort) + machine._watch_session = lambda _sid: None + machine._prime_codex_ownership = ( + lambda _sid: asyncio.sleep(0, result=False)) + machine._load_history = lambda *_args: asyncio.sleep(0) + + ctx = await machine._spawn( + resume_id=thread_id, + engine="codex", + space="code", + ) + + assert ctx is not None + assert ctx.sdk.preconnect_model == "current-model" + assert ctx.sdk.set_model_calls == ["current-model"] + assert ctx.sdk.model == "current-model" + assert ctx.sdk.preconnect_effort == "high" + assert ctx.sdk.effort == "high" + assert ctx.sdk.applied_effort == "high" + assert ctx.sdk.display_effort == "high" + assert ctx.announced_effort == "high" + + asyncio.run(run()) + + +def test_machine_effort_apply_preserves_authoritative_app_server_clamp( + monkeypatch): + async def run(): + machine, _transport = _mk_machine() + ctx = _mk_ctx("effort-clamp", "effort-clamp") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-clamped", + effort="high", + applied_effort="high", + display_effort="high", + display_effort_model="gpt-clamped", + display_effort_cwd=os.path.realpath("/tmp"), + display_effort_generation=1, + _display_effort_retry_at=None, + _cwd="/tmp", + _generation=1, + ) + + async def set_effort(_requested): + # The authoritative notification adjusted the requested high level. + ctx.sdk.effort = "medium" + ctx.sdk.applied_effort = "medium" + ctx.sdk.display_effort = "medium" + return True + + ctx.sdk.set_effort = set_effort + + async def clamp(_model, effort, *, codex_home=None): + assert effort == "high" + return "high" + + monkeypatch.setattr(machine_module, "clamp_effort", clamp) + assert await machine._apply_codex_effort(ctx, "high") == "medium" + assert ctx.sdk.effort == ctx.sdk.applied_effort == "medium" + assert ctx.sdk.display_effort == "medium" + + asyncio.run(run()) + + +def test_machine_effort_apply_keeps_authoritative_null(monkeypatch): + async def run(): + machine, _transport = _mk_machine() + ctx = _mk_ctx("effort-null", "effort-null") + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + model="gpt-null", + effort="high", + applied_effort="high", + display_effort="high", + display_effort_model="gpt-null", + display_effort_cwd=os.path.realpath("/tmp"), + display_effort_generation=1, + _display_effort_retry_at=None, + _cwd="/tmp", + _generation=1, + ) + + async def set_effort(_requested): + # An authoritative notification can reject/clear the override. + ctx.sdk.effort = None + ctx.sdk.applied_effort = None + ctx.sdk.display_effort = None + ctx.sdk.display_effort_model = None + ctx.sdk.display_effort_cwd = None + ctx.sdk.display_effort_generation = None + return True + + async def configured_default(): + return None + + async def model_default(_model, *, codex_home=None): + return "low" + + async def clamp(_model, effort, *, codex_home=None): + return effort + + ctx.sdk.set_effort = set_effort + ctx.sdk.configured_default_effort = configured_default + monkeypatch.setattr(machine_module, "default_effort_for", model_default) + monkeypatch.setattr(machine_module, "clamp_effort", clamp) + + assert await machine._apply_codex_effort(ctx, "high") == "low" + assert ctx.sdk.effort is None + assert ctx.sdk.applied_effort is None + assert ctx.sdk.display_effort == "low" + + asyncio.run(run()) + + def test_codex_binary_resolution_probes_bounded_candidates_and_picks_newest( monkeypatch): monkeypatch.setattr(codex_runtime_module, "_BIN_CACHE", None) @@ -2718,6 +3650,18 @@ def test_codex_config_defaults_use_only_top_level_toml_keys( assert codex_sessions_module.codex_web_search() == "live" +def test_codex_handle_has_no_retired_model_or_effort_fallback(tmp_path): + handle = CodexHandle(_Cfg(), codex_home=str(tmp_path)) + + assert codex_sessions_module.codex_model( + codex_home=str(tmp_path)) == "" + assert codex_sessions_module.codex_effort( + codex_home=str(tmp_path)) == "" + assert handle.model is None + assert handle.effort is None + assert handle.applied_effort is None + + def test_codex_thread_settings_update_uses_official_01441_shapes(): async def run(): handle = CodexHandle(_Cfg()) @@ -2838,6 +3782,42 @@ async def run(): asyncio.run(run()) +def test_codex_configured_default_effort_is_bounded_and_scoped_per_cwd( + monkeypatch): + async def run(): + handle = CodexHandle(_Cfg(), cwd="/tmp/one") + calls = [] + now = 0.0 + one = os.path.realpath("/tmp/one") + two = os.path.realpath("/tmp/two") + + monkeypatch.setattr( + codex_handle_module.time, "monotonic", lambda: now) + + async def request(method, params=None): + calls.append((method, params)) + effort = "xhigh" if params["cwd"] == one else None + return {"config": {"model_reasoning_effort": effort}} + + handle._request = request + assert await handle.configured_default_effort() == "xhigh" + assert await handle.configured_default_effort() == "xhigh" + now = codex_handle_module._CONFIGURED_DEFAULT_EFFORT_CACHE_SECONDS + 1 + assert await handle.configured_default_effort() == "xhigh" + handle._cwd = "/tmp/two" + assert await handle.configured_default_effort() is None + handle._generation += 1 + assert await handle.configured_default_effort() is None + assert calls == [ + ("config/read", {"cwd": one, "includeLayers": False}), + ("config/read", {"cwd": one, "includeLayers": False}), + ("config/read", {"cwd": two, "includeLayers": False}), + ("config/read", {"cwd": two, "includeLayers": False}), + ] + + asyncio.run(run()) + + def test_codex_granular_approval_survives_resume_and_turn_start(): async def run(): handle = CodexHandle(_Cfg()) @@ -3112,6 +4092,7 @@ async def run(): handle.thread_id = "work-thread" handle.model = "gpt-work" handle.effort = None + handle.display_effort = codex_models_module.MODEL_DEFAULT_EFFORT requests = [] async def request(method, params=None): @@ -3126,6 +4107,7 @@ async def request(method, params=None): assert params["cwd"] == cwd assert params["approvalPolicy"] == "never" assert params["permissions"] == "cc_remote_work" + assert "effort" not in params assert "sandboxPolicy" not in params asyncio.run(run()) @@ -3135,6 +4117,7 @@ async def request(method, params=None): "work_mode,http_only,web_override,preserve_controls,preserve_profile", [ (False, False, None, False, True), (False, True, None, False, True), + (False, True, "live", False, True), (False, False, "live", False, True), (False, False, "live", True, True), (False, False, None, True, False), @@ -3256,14 +4239,19 @@ async def request(method, params=None): "config": _expected_work_config(), "permissions": "cc_remote_work", }) - elif web_override: - expected_resume["config"] = {"web_search": web_override} + else: + code_config = codex_handle_module._code_thread_config( + http_only_resume=http_only, + web_search=web_override, + ) + if code_config is not None: + expected_resume["config"] = code_config resume_call = next(call for call in calls if call[0] == "thread/resume") assert resume_call == ("thread/resume", expected_resume) assert not ({ "sandbox", "sandboxPolicy", "approvalsReviewer", } & resume_call[1].keys()) - if not work_mode and not web_override: + if not work_mode and not web_override and not http_only: assert not ({"config", "personality"} & resume_call[1].keys()) assert (handle.model, handle.effort, handle.approval, handle.permission_profile, handle.service_tier) == ( @@ -5732,7 +6720,7 @@ async def receive_response(self): asyncio.run(run()) -def test_managed_codex_turn_clears_stale_effort_when_sdk_has_none(): +def test_managed_codex_turn_replaces_stale_effort_with_model_default(): async def run(): machine, transport = _mk_machine() ctx = _mk_ctx("no-effort-session", "no-effort-session") @@ -5778,11 +6766,11 @@ async def receive_response(self): machine._accept_codex_checkpoint = lambda _ctx: asyncio.sleep(0) await machine._run_turn(ctx, "hello") - assert ctx.announced_effort is None + assert ctx.announced_effort == machine_module.MODEL_DEFAULT_EFFORT assert [ event.effort for event in transport.sent if isinstance(event, Effort) - ] == [""] + ] == [machine_module.MODEL_DEFAULT_EFFORT] assert not [event for event in transport.sent if isinstance(event, Error)] terminal = [event for event in transport.sent @@ -5793,6 +6781,103 @@ async def receive_response(self): asyncio.run(run()) +def test_managed_query_reconnect_refreshes_effort_without_blocking_stream(): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("query-reconnect-effort", "query-reconnect-effort") + ctx.engine = "codex" + ctx.state = "running" + ctx.active_msg_id = "browser-message" + ctx.announced_effort = "high" + ctx.turn_task = asyncio.current_task() + resolution_started = asyncio.Event() + release_resolution = asyncio.Event() + + class ReconnectingSdk: + tier_dirty = False + model = "gpt-after-reconnect" + effort = None + applied_effort = None + display_effort = "high" + display_effort_model = model + display_effort_cwd = os.path.realpath("/tmp") + display_effort_generation = 1 + _display_effort_retry_at = None + _cwd = "/tmp" + _generation = 1 + _thread_settings_revision = 0 + collaboration_mode = "default" + service_tier = None + + async def query( + self, _prompt, images=None, *, client_user_message_id=None, + ): + # Model CodexHandle.query() repairing a dead app-server after + # Machine's normal effort preflight has already completed. + self._generation += 1 + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + return "native-turn" + + async def configured_default_effort(self): + resolution_started.set() + await release_resolution.wait() + return "medium" + + async def receive_response(self): + yield { + "method": "item/completed", + "params": { + "turnId": "native-turn", + "item": { + "id": "answer", + "type": "agentMessage", + "text": "done", + }, + }, + } + yield { + "method": "turn/completed", + "params": {"turn": { + "id": "native-turn", "status": "completed", + }}, + } + + ctx.sdk = ReconnectingSdk() + machine.sessions[ctx.key] = ctx + machine._begin_codex_checkpoint = lambda _ctx: asyncio.sleep(0) + machine._accept_codex_checkpoint = lambda _ctx: asyncio.sleep(0) + + await asyncio.wait_for(machine._run_turn(ctx, "hello"), timeout=0.5) + await asyncio.wait_for(resolution_started.wait(), timeout=0.5) + + # The immediate sentinel removes the stale chip, while the config read + # remains entirely outside the already-accepted turn's stream drain. + assert ctx.state == "idle" + assert [ + event.effort for event in transport.sent + if isinstance(event, Effort) + ] == [machine_module.MODEL_DEFAULT_EFFORT] + + release_resolution.set() + for _ in range(20): + if any( + isinstance(event, Effort) and event.effort == "medium" + for event in transport.sent + ): + break + await asyncio.sleep(0) + assert [ + event.effort for event in transport.sent + if isinstance(event, Effort) + ] == [machine_module.MODEL_DEFAULT_EFFORT, "medium"] + assert ctx.announced_effort == "medium" + + asyncio.run(run()) + + def test_machine_goal_errors_are_routed_without_raw_exception_text(): async def run(): machine, transport = _mk_machine() @@ -6161,6 +7246,51 @@ async def run(): asyncio.run(run()) +def test_web_search_reconnect_republishes_changed_nullable_effort(): + async def run(): + machine, transport = _mk_machine() + sdk = _ControlSdk() + sdk.model = "gpt-after-search-reconnect" + sdk.effort = None + sdk.applied_effort = None + sdk.display_effort = None + sdk._cwd = ctx_cwd = "/tmp/cc-remote-test-cwd" + sdk._generation = 2 + + async def set_web_search(mode): + sdk.web_search_calls.append(mode) + sdk.web_search = mode + sdk.web_search_override = mode + # Model a successful thread/resume whose authoritative thread + # setting is null and whose effective config resolves concretely. + sdk.display_effort = "medium" + sdk.display_effort_model = sdk.model + sdk.display_effort_cwd = os.path.realpath(ctx_cwd) + sdk.display_effort_generation = sdk._generation + + sdk.set_web_search = set_web_search + ctx = _control_ctx("codex", "codex", sdk) + ctx.announced_model = "gpt-before-search-reconnect" + ctx.announced_effort = "high" + machine.sessions = {"codex": ctx} + machine._stamp_codex_daemon_epoch = lambda _ctx: asyncio.sleep(0) + machine._persist_codex_session_controls = ( + lambda _ctx: asyncio.sleep(0)) + + result = await machine._handle_set_web_search( + SetWebSearch(sid="codex", mode="live")) + + assert isinstance(result, WebSearch) + assert [event.type for event in transport.sent] == [ + "model", "effort", "web_search", + ] + assert transport.sent[0].model == "gpt-after-search-reconnect" + assert transport.sent[1].effort == "medium" + assert ctx.announced_effort == "medium" + + asyncio.run(run()) + + def test_failed_web_search_republishes_restored_execution_controls(): async def run(): machine, transport = _mk_machine() @@ -6235,25 +7365,99 @@ async def run(): asyncio.run(run()) -def test_client_hello_always_seeds_resident_codex_collaboration_mode(): +def test_client_hello_seeds_codex_defaults_without_control_plane_probes(): async def run(): machine, transport = _mk_machine() ctx = _control_ctx("codex", "codex") ctx.sdk.collaboration_mode = "plan" - machine.sessions = {"codex": ctx} + ctx.sdk.effort = None + ctx.sdk.display_effort = machine_module.MODEL_DEFAULT_EFFORT + second = _control_ctx("codex-2", "codex") + second.sdk.effort = None + second.sdk.display_effort = None + machine.sessions = {"codex": ctx, "codex-2": second} - await machine._handle_client_hello(SimpleNamespace( - cursors={"codex": 0}, generations={"codex": machine.instance_id}, - last_seq=None, client_id="client-1", route_id="route-1", - )) + async def unexpected_effort_probe(*_args, **_kwargs): + raise AssertionError("client hello must remain a no-probe fast path") + + machine._resolve_codex_session_effort = unexpected_effort_probe + + await asyncio.wait_for( + machine._handle_client_hello(SimpleNamespace( + cursors={"codex": 0}, + generations={"codex": machine.instance_id}, + last_seq=None, client_id="client-1", route_id="route-1", + )), + timeout=0.1, + ) modes = [message for message in transport.sent - if message.type == "collaboration_mode"] + if message.type == "collaboration_mode" + and message.sid == "codex"] assert len(modes) == 1 assert modes[0].mode == "plan" assert modes[0].sid == "codex" assert modes[0].to == "client-1" assert modes[0].route_id == "route-1" + efforts = [message for message in transport.sent + if message.type == "effort"] + assert {(event.sid, event.effort) for event in efforts} == { + ("codex", machine_module.MODEL_DEFAULT_EFFORT), + ("codex-2", machine_module.MODEL_DEFAULT_EFFORT), + } + assert all(event.to == "client-1" for event in efforts) + assert all(event.route_id == "route-1" for event in efforts) + + asyncio.run(run()) + + +def test_client_hello_never_mixes_model_effort_during_settings_update(): + async def run(): + machine, transport = _mk_machine() + ctx = _control_ctx("codex", "codex") + ctx.sdk.model = "gpt-before" + ctx.sdk.effort = "high" + ctx.sdk.display_effort = "high" + ctx.sdk.display_effort_model = "gpt-before" + ctx.sdk.display_effort_cwd = os.path.realpath(ctx.cwd) + ctx.sdk.display_effort_generation = 1 + ctx.sdk._cwd = ctx.cwd + ctx.sdk._generation = 1 + ctx.sdk._thread_settings_revision = 0 + machine.sessions = {"codex": ctx} + original_send = transport.send + replaced = False + + async def send(event): + nonlocal replaced + await original_send(event) + if isinstance(event, Model) and not replaced: + replaced = True + ctx.sdk.model = "gpt-after" + ctx.sdk.effort = "medium" + ctx.sdk.display_effort = "medium" + ctx.sdk.display_effort_model = "gpt-after" + ctx.sdk.display_effort_generation = 2 + ctx.sdk._generation = 2 + ctx.sdk._thread_settings_revision = 1 + + transport.send = send + await machine._handle_client_hello(SimpleNamespace( + cursors={}, generations={}, last_seq=None, + client_id="client-1", route_id="route-1", + )) + + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in transport.sent + if isinstance(event, (Model, Effort)) + ] == [ + ("model", "gpt-before", None), + ("effort", None, "high"), + ("model", "gpt-after", None), + ("effort", None, "medium"), + ] asyncio.run(run()) diff --git a/tests/test_codex_daemon.py b/tests/test_codex_daemon.py index 61f11ec..89d5132 100644 --- a/tests/test_codex_daemon.py +++ b/tests/test_codex_daemon.py @@ -410,6 +410,76 @@ def test_profile_bootstrap_never_replaces_existing_managed_path(tmp_path): assert not existing.is_symlink() +def test_profile_bootstrap_atomically_advances_owned_official_current( + tmp_path, +): + source_home = tmp_path / "source" + source_standalone = source_home / "packages" / "standalone" + source_release = source_standalone / "releases" / "0.148.0" + source_binary = source_release / "bin" / "codex" + source_binary.parent.mkdir(parents=True) + source_binary.write_bytes(b"#!/bin/sh\n") + source_binary.chmod(0o755) + (source_release / "codex").symlink_to("bin/codex") + (source_standalone / "current").symlink_to( + source_release, + target_is_directory=True, + ) + + profile_home = tmp_path / "profile" + profile_standalone = profile_home / "packages" / "standalone" + old_release = profile_standalone / "releases" / "0.147.0" + old_binary = old_release / "bin" / "codex" + old_binary.parent.mkdir(parents=True) + old_binary.write_bytes(b"#!/bin/sh\n") + old_binary.chmod(0o755) + (old_release / "codex").symlink_to("bin/codex") + current = profile_standalone / "current" + current.symlink_to(old_release, target_is_directory=True) + + assert daemon_module._prepare_profile_standalone( + str(source_binary), + {"CODEX_HOME": str(profile_home)}, + ) is True + assert current.is_symlink() + assert current.readlink() == source_standalone / "current" + assert (current / "codex").resolve() == source_binary + assert old_binary.exists() + + +def test_profile_bootstrap_never_replaces_external_current_symlink(tmp_path): + source_home = tmp_path / "source" + source_standalone = source_home / "packages" / "standalone" + source_release = source_standalone / "releases" / "0.148.0" + source_binary = source_release / "bin" / "codex" + source_binary.parent.mkdir(parents=True) + source_binary.write_bytes(b"#!/bin/sh\n") + source_binary.chmod(0o755) + (source_release / "codex").symlink_to("bin/codex") + (source_standalone / "current").symlink_to( + source_release, + target_is_directory=True, + ) + + profile_home = tmp_path / "profile" + current = profile_home / "packages" / "standalone" / "current" + current.parent.mkdir(parents=True) + external_release = tmp_path / "external" / "releases" / "0.147.0" + external_binary = external_release / "bin" / "codex" + external_binary.parent.mkdir(parents=True) + external_binary.write_bytes(b"#!/bin/sh\n") + external_binary.chmod(0o755) + (external_release / "codex").symlink_to("bin/codex") + current.symlink_to(external_release, target_is_directory=True) + + assert daemon_module._prepare_profile_standalone( + str(source_binary), + {"CODEX_HOME": str(profile_home)}, + ) is False + assert current.readlink() == external_release + assert (current / "codex").resolve() == external_binary + + def test_required_profile_never_silently_falls_back_to_stdio(monkeypatch): async def run(): monkeypatch.setattr( @@ -638,6 +708,60 @@ async def command(_bin, _env, *args): asyncio.run(run()) +def test_lagging_managed_daemon_waits_for_async_replacement(monkeypatch): + async def run(): + monkeypatch.setattr( + daemon_module, "_binary_identity", lambda _path: ("codex-v2",)) + monkeypatch.setattr( + daemon_module, + "_prepare_profile_standalone", + lambda _bin, _env: True, + ) + monkeypatch.setattr( + daemon_module, + "_DAEMON_UPGRADE_POLL_INTERVAL", + 0.0, + ) + manager = CodexDaemonManager("auto") + version_probes = 0 + restarted = False + + async def command(_bin, _env, *args): + nonlocal version_probes, restarted + if args[-1] == "--help": + return _result(0) + if args[-1] == "version": + version_probes += 1 + upgraded = restarted and version_probes >= 5 + version = "0.148.0" if upgraded else "0.147.0" + return _result(0, { + "status": "running", + "managedCodexPath": "/opt/codex/current/codex", + "managedCodexVersion": version, + "socketPath": "/tmp/codex.sock", + "cliVersion": "0.148.0", + "appServerVersion": version, + }) + if args[-1] == "restart": + restarted = True + return _result(0, {"status": "restarted"}) + assert args[-1] == "enable-remote-control" + return _result(0, { + "status": "enabled", + "remoteControlEnabled": True, + "socketPath": "/tmp/codex.sock", + }) + + manager._run = command # type: ignore[method-assign] + assert await manager.proxy_args("/bin/codex", {}) == [ + "/bin/codex", "app-server", "proxy", + "--sock", "/tmp/codex.sock", + ] + assert version_probes == 5 + + asyncio.run(run()) + + def test_daemon_enable_failure_is_not_reported_ready(monkeypatch): async def run(): monkeypatch.setattr( @@ -923,7 +1047,8 @@ async def spawn(*argv, **_kwargs): asyncio.run(run(True)) -def test_oversized_resume_newer_core_bypasses_shared_daemon(monkeypatch): +def test_oversized_resume_prefers_shared_daemon_before_newer_private_core( + monkeypatch): async def run(): manager = _Manager(["/managed/codex", "app-server", "proxy"]) spawned = [] @@ -947,15 +1072,18 @@ async def spawn(*argv, **_kwargs): ).connect(resume_id="oversized-thread", cwd="/tmp") assert spawned[0][:3] == [ + "/managed/codex", "app-server", "proxy", + ] + assert spawned[1][:3] == [ "/Applications/Codex.app/Resources/codex", "app-server", "--stdio", ] - assert manager.proxy_calls == 0 + assert manager.proxy_calls == 1 asyncio.run(run()) -def test_oversized_desktop_openai_resume_uses_private_http_provider( +def test_oversized_desktop_openai_resume_prefers_shared_daemon_then_http_stdio( monkeypatch): async def run(): manager = _Manager(["/managed/codex", "app-server", "proxy"]) @@ -983,13 +1111,16 @@ async def spawn(*argv, **_kwargs): _Cfg(), daemon_mode="auto", daemon_manager=manager, ).connect(resume_id="oversized-thread", cwd="/tmp") - argv = spawned[0] + assert spawned[0][:3] == [ + "/managed/codex", "app-server", "proxy", + ] + argv = spawned[1] assert argv[:3] == [ "/managed/codex", "app-server", "--stdio", ] assert any( item.endswith("supports_websockets=false") for item in argv) - assert manager.proxy_calls == 0 + assert manager.proxy_calls == 1 asyncio.run(run()) @@ -1132,6 +1263,39 @@ async def spawn(*argv, **_kwargs): asyncio.run(run()) +def test_strict_daemon_preparation_failure_never_starts_private_stdio( + monkeypatch): + async def run(): + class _StrictFailingManager(_Manager): + async def proxy_args(self, _bin, _env): + self.proxy_calls += 1 + raise RuntimeError("strict daemon preparation failed") + + manager = _StrictFailingManager(strict_shared=True) + spawned = [] + + async def spawn(*argv, **_kwargs): + spawned.append(list(argv)) + raise AssertionError("private stdio must not be started") + + monkeypatch.setattr( + handle_module, "_resolve_codex_bin", lambda: "/usr/bin/codex") + monkeypatch.setattr( + handle_module.asyncio, "create_subprocess_exec", spawn) + + with pytest.raises( + RuntimeError, + match="strict daemon preparation failed", + ): + await CodexHandle( + _Cfg(), daemon_manager=manager).connect(cwd="/tmp") + + assert manager.proxy_calls == 1 + assert spawned == [] + + asyncio.run(run()) + + def test_proxy_connect_exposes_shared_state_and_disconnect_keeps_manager( monkeypatch): async def run(): @@ -1183,6 +1347,84 @@ async def request(method, _params=None): asyncio.run(run()) +def test_oversized_http_resume_keeps_shared_affinity_and_thread_local_provider( + monkeypatch): + async def run(): + nonce = b"0123456789abcdef" + monkeypatch.setattr(handle_module.os, "urandom", lambda _size: nonce) + monkeypatch.setattr( + handle_module, + "_oversized_desktop_openai_resume_requires_http", + lambda _sid: True, + ) + monkeypatch.setattr( + handle_module, + "_newer_private_core_for_oversized_resume", + lambda _bin, _sid: "/Applications/Codex.app/Resources/codex", + ) + manager = _Manager(["/managed/codex", "app-server", "proxy"]) + spawned = [] + + async def spawn(*argv, **_kwargs): + spawned.append(list(argv)) + return _Process( + _Reader(_handshake_response(nonce)), 50004 + len(spawned)) + + monkeypatch.setattr( + handle_module, "_resolve_codex_bin", lambda: "/managed/codex") + monkeypatch.setattr( + handle_module.asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(handle_module.os, "killpg", lambda *_args: None) + handle = CodexHandle(_Cfg(), daemon_manager=manager) + resume_params = [] + + async def idle(*_args): + await asyncio.Event().wait() + + async def request(method, params=None): + if method == "initialize": + return {"userAgent": "codex_cli_rs/0.147.0 (test)"} + if method == "thread/resume": + resume_params.append(params) + return {"thread": {"id": "oversized-thread"}} + raise AssertionError(method) + + handle._read_loop = idle # type: ignore[method-assign] + handle._request = request # type: ignore[method-assign] + handle._notify = lambda *_args: asyncio.sleep(0) # type: ignore[method-assign] + handle._restore_http_provider_state = ( # type: ignore[method-assign] + lambda **_kwargs: asyncio.sleep(0) + ) + await handle.connect(resume_id="oversized-thread", cwd="/tmp") + + assert spawned == [["/managed/codex", "app-server", "proxy"]] + assert handle.using_daemon_proxy is True + assert handle.shared_daemon_affinity is True + expected_resume = { + "threadId": "oversized-thread", + "cwd": "/tmp", + "modelProvider": handle_module._OPENAI_HTTP_RESUME_PROVIDER_ID, + "config": handle_module._openai_http_resume_thread_config(), + "excludeTurns": True, + } + assert resume_params == [expected_resume] + await handle.disconnect() + + # Reconnecting an already-shared thread must retain its HTTP transport + # override while remaining on the shared daemon. Shared affinity only + # disables the private-core fallback, not HTTP-provider detection. + await handle.connect(resume_id="oversized-thread", cwd="/tmp") + assert spawned == [ + ["/managed/codex", "app-server", "proxy"], + ["/managed/codex", "app-server", "proxy"], + ] + assert handle.using_daemon_proxy is True + assert resume_params == [expected_resume, expected_resume] + await handle.disconnect() + + asyncio.run(run()) + + def test_shared_approval_without_callback_waits_for_resolved(): async def run(): handle = CodexHandle(_Cfg(), daemon_mode="off") diff --git a/tests/test_codex_external.py b/tests/test_codex_external.py index fbe266a..d79396b 100644 --- a/tests/test_codex_external.py +++ b/tests/test_codex_external.py @@ -13,7 +13,13 @@ import pytest -from cc_remote.protocol import History, Query, SessionActivity, Takeover +from cc_remote.protocol import ( + MAX_SAFE_WIRE_INTEGER, + History, + Query, + SessionActivity, + Takeover, +) from cc_remote.wrapper import machine as machine_module from cc_remote.wrapper.codex_external import ( CodexTuiLogTracker, HolderScan, ProcessIdentity, @@ -922,6 +928,55 @@ def test_turn_marker_parser_preserves_partial_and_skips_malformed(): ) +def test_turn_marker_parser_bounds_terminal_wire_metadata(): + records = [ + { + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "valid-terminal", + "duration_ms": float("inf"), + "completed_at": float("nan"), + }, + }, + { + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "oversized-terminal", + "duration_ms": MAX_SAFE_WIRE_INTEGER + 1, + "completed_at": MAX_SAFE_WIRE_INTEGER + 1, + }, + }, + { + "type": "event_msg", + "payload": { + "type": "task_complete", + "turn_id": "not/a/wire/id", + "duration_ms": 1, + }, + }, + ] + parsed = parse_turn_markers( + ( + b'{"type":"event_msg","payload":{"type":"task_complete",' + b'"turn_id":"huge-number","duration_ms":' + + b"9" * 5000 + + b"}}\n" + + b"".join( + (json.dumps(record) + "\n").encode() for record in records + ) + ) + ) + + assert {"valid-terminal", "oversized-terminal"} <= parsed.finished + assert "not/a/wire/id" not in parsed.finished + by_id = {marker.turn_id: marker for marker in parsed.terminals} + for turn_id in ("valid-terminal", "oversized-terminal"): + assert by_id[turn_id].duration_ms is None + assert by_id[turn_id].completed_at is None + + def test_turn_marker_parser_reports_visible_user_message_without_turn_id(): visible = (json.dumps({ "type": "event_msg", @@ -2056,6 +2111,45 @@ def test_cold_codex_watcher_seeds_unfinished_external_turn(tmp_path, monkeypatch assert watch["external"] is True +def test_cold_codex_watcher_seeds_latest_completed_terminal_fence( + tmp_path, monkeypatch, +): + path = tmp_path / "rollout.jsonl" + path.write_bytes( + _event("task_started", "cold-completed") + + _event("task_complete", "cold-completed") + ) + machine, _ = _mk_machine() + monkeypatch.setattr( + machine_module, "codex_rollout_path", + lambda sid: str(path) if sid == "cold-completed-session" else None, + ) + monkeypatch.setattr(machine_module, "transcript_path", lambda _sid: None) + + machine._watch_session("cold-completed-session") + + fences = machine._codex_terminal_ledger.snapshot( + "cold-completed-session", + path, + revision=machine._history_revision("cold-completed-session"), + ) + assert [(fence.turn_id, fence.status) for fence in fences] == [ + ("cold-completed", "completed")] + assert not machine._codex_terminal_persist_tasks + + # Falling back from an incomplete official projection advances only the + # read-side History revision. The source-bound cold terminal must remain + # available to the rollout page assembled under that new revision. + machine._activate_codex_rollout_history("cold-completed-session") + rebased = machine._codex_terminal_ledger.snapshot( + "cold-completed-session", + path, + revision=machine._history_revision("cold-completed-session"), + ) + assert [(fence.turn_id, fence.status) for fence in rebased] == [ + ("cold-completed", "completed")] + + def test_sidebar_watch_preserves_private_app_seed_without_stable_writer( tmp_path, monkeypatch): async def go(): @@ -2115,6 +2209,156 @@ async def go(): asyncio.run(go()) +def test_interrupted_rollout_terminal_commits_after_quiet_grace_even_with_writer( + tmp_path, +): + async def go(): + path = tmp_path / "interrupted.jsonl" + path.write_bytes(b"") + machine, _ = _mk_machine() + sid = "interrupted-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = _CodexSdk() + machine.sessions[sid] = ctx + watch = _watch(path) + machine._watch[sid] = watch + machine._codex_rollout_for_wire = lambda _sid: str(path) + machine._push_mirrored_history = lambda sid: _record_async([], sid) + passive_writer = ProcessIdentity(901, 9001) + + path.write_bytes( + _event("task_started", "interrupted-turn") + + _event("turn_aborted", "interrupted-turn") + ) + await machine._poll_codex_watch( + sid, + watch, + set(), + 1000.0, + writers={passive_writer}, + ) + assert machine._codex_terminal_ledger.snapshot( + sid, path, revision=machine._history_revision(sid), + ) == () + + with path.open("ab") as stream: + stream.write( + b'{"type":"event_msg","payload":{"type":"agent_message",' + b'"message":"trailing flush"}}\n' + ) + await machine._poll_codex_watch( + sid, + watch, + set(), + 1002.0, + writers={passive_writer}, + ) + await machine._poll_codex_watch( + sid, + watch, + set(), + 1004.0, + writers={passive_writer}, + ) + assert machine._codex_terminal_ledger.snapshot( + sid, path, revision=machine._history_revision(sid), + ) == () + + await machine._poll_codex_watch( + sid, + watch, + set(), + 1006.0, + writers={passive_writer}, + ) + fences = machine._codex_terminal_ledger.snapshot( + sid, path, revision=machine._history_revision(sid), + ) + assert [(fence.turn_id, fence.status) for fence in fences] == [ + ("interrupted-turn", "interrupted")] + tasks = list(machine._codex_terminal_persist_tasks) + if tasks: + await asyncio.gather(*tasks) + + asyncio.run(go()) + + +def test_interrupted_rollout_terminal_drops_proven_compaction_continuation( + tmp_path, +): + async def go(): + path = tmp_path / "compact-continuation.jsonl" + path.write_bytes(b"") + machine, _ = _mk_machine() + sid = "compact-continuation-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = _CodexSdk() + ctx.sdk.compaction_continuation_turn_ids = frozenset({"compact-turn"}) + machine.sessions[sid] = ctx + watch = _watch(path) + machine._watch[sid] = watch + machine._codex_rollout_for_wire = lambda _sid: str(path) + machine._push_mirrored_history = lambda sid: _record_async([], sid) + + path.write_bytes( + _event("task_started", "compact-turn") + + _event("turn_aborted", "compact-turn") + ) + await machine._poll_codex_watch(sid, watch, set(), 1000.0) + await machine._poll_codex_watch(sid, watch, set(), 1004.0) + + assert watch["terminal_candidates"] == {} + assert machine._codex_terminal_ledger.snapshot( + sid, path, revision=machine._history_revision(sid), + ) == () + + asyncio.run(go()) + + +def test_newer_rollout_lifecycle_retires_provisional_interrupt(tmp_path): + async def go(): + path = tmp_path / "newer-lifecycle.jsonl" + path.write_bytes(b"") + machine, _ = _mk_machine() + sid = "newer-lifecycle-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = _CodexSdk() + machine.sessions[sid] = ctx + watch = _watch(path) + machine._watch[sid] = watch + machine._codex_rollout_for_wire = lambda _sid: str(path) + machine._push_mirrored_history = lambda sid: _record_async([], sid) + + path.write_bytes( + _event("task_started", "old-interrupt") + + _event("turn_aborted", "old-interrupt") + ) + await machine._poll_codex_watch(sid, watch, set(), 1000.0) + assert set(watch["terminal_candidates"]) == {"old-interrupt"} + + with path.open("ab") as stream: + stream.write( + _event("task_started", "new-completion") + + _event("task_complete", "new-completion") + ) + await machine._poll_codex_watch(sid, watch, set(), 1001.0) + + assert watch["terminal_candidates"] == {} + fences = machine._codex_terminal_ledger.snapshot( + sid, path, revision=machine._history_revision(sid), + ) + assert [(fence.turn_id, fence.status) for fence in fences] == [ + ("new-completion", "completed")] + tasks = list(machine._codex_terminal_persist_tasks) + if tasks: + await asyncio.gather(*tasks) + + asyncio.run(go()) + + def test_cold_orphan_seed_unlocks_on_first_complete_empty_holder_scan( tmp_path, monkeypatch): async def go(): diff --git a/tests/test_codex_forks.py b/tests/test_codex_forks.py index b228678..eef8fd7 100644 --- a/tests/test_codex_forks.py +++ b/tests/test_codex_forks.py @@ -70,6 +70,46 @@ def test_worktree_fork_control_snapshot_survives_journal_reload(tmp_path): assert finalized["name_finalized"] is True +def test_fork_child_delete_lifecycle_survives_restart_and_can_abort(tmp_path): + journal = CodexForkJournal(tmp_path) + journal.begin("request-1", "parent", "turn-1", "/repo") + journal.claim_submission("request-1") + journal.complete("request-1", "child") + assert journal.completed_children() == [journal.get("request-1")] + + assert journal.begin_delete("child") == "delete_pending" + assert journal.completed_children() == [] + assert CodexForkJournal(tmp_path).child_entry("child")["status"] == ( + "delete_pending") + assert journal.abort_delete("child") is True + assert journal.child_entry("child")["status"] == "complete" + assert journal.completed_children() == [journal.get("request-1")] + + assert journal.begin_delete("child") == "delete_pending" + assert journal.finish_delete("child") is True + reloaded = CodexForkJournal(tmp_path) + assert reloaded.child_entry("child")["status"] == "deleted" + assert reloaded.complete("request-1", "child")["status"] == "deleted" + assert reloaded.begin_delete("child") == "deleted" + + +def test_completed_children_never_overrides_a_stronger_child_tombstone(tmp_path): + journal = CodexForkJournal(tmp_path) + journal.begin("request-old", "parent-old", "turn-old", "/repo") + journal.complete("request-old", "same-child") + journal.begin_delete("same-child") + journal.finish_delete("same-child") + + # Distinct source groups are individually valid journal records. Even if a + # malformed upstream reconciliation later associates another completed + # request with the same native child, deletion remains the strongest owner. + journal.begin("request-new", "parent-new", "turn-new", "/repo") + journal.complete("request-new", "same-child") + + assert journal.child_entry("same-child")["status"] == "deleted" + assert journal.completed_children() == [] + + def test_rollout_marker_recovery_scans_active_and_archived_with_bounds(tmp_path): active = tmp_path / "sessions" archived = tmp_path / "archived_sessions" @@ -221,3 +261,21 @@ def test_fork_journal_compacts_complete_alias_group_atomically( assert set(journal.entries) == {"request-keep", "request-new"} reloaded = CodexForkJournal(tmp_path) assert set(reloaded.entries) == {"request-keep", "request-new"} + + +def test_fork_journal_never_compacts_deleted_replay_tombstone( + tmp_path, monkeypatch, +): + monkeypatch.setattr(codex_forks_module, "_MAX_ENTRIES", 1) + journal = CodexForkJournal(tmp_path) + journal.begin("request-deleted", "parent", "turn-old", "/repo") + journal.claim_submission("request-deleted") + journal.complete("request-deleted", "deleted-child") + journal.begin_delete("deleted-child") + journal.finish_delete("deleted-child") + + with pytest.raises(ForkJournalError, match="capacity exhausted"): + journal.begin("request-new", "parent", "turn-new", "/repo") + + reloaded = CodexForkJournal(tmp_path) + assert reloaded.entries["request-deleted"]["status"] == "deleted" diff --git a/tests/test_codex_history.py b/tests/test_codex_history.py index 7185d8f..35491e7 100644 --- a/tests/test_codex_history.py +++ b/tests/test_codex_history.py @@ -1272,6 +1272,57 @@ def test_rollout_user_recovery_is_bound_to_the_native_turn(tmp_path): str(rollout), "missing-turn", "user-image") is None +def test_live_rollout_user_recovery_bounds_the_reverse_search(tmp_path): + rollout = tmp_path / "rollout-bounded-live-user.jsonl" + rows = [ + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "native-old"}, + }, + { + "type": "event_msg", + "payload": {"type": "user_message", "message": "old prompt"}, + }, + { + "type": "event_msg", + "payload": { + "type": "agent_message", + "message": "x" * 4096, + }, + }, + { + "type": "event_msg", + "payload": {"type": "task_started", "turn_id": "native-current"}, + }, + { + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "current prompt", + }, + }, + ] + rollout.write_text( + "".join(json.dumps(row) + "\n" for row in rows), + encoding="utf-8", + ) + + current = codex_history_turn_user( + str(rollout), + "native-current", + "native-current", + max_reverse_scan_bytes=512, + ) + assert current is not None + assert current.prompt == "current prompt" + assert codex_history_turn_user( + str(rollout), + "native-old", + "native-old", + max_reverse_scan_bytes=512, + ) is None + + def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path): rollout = tmp_path / "rollout-modern-user.jsonl" rollout.write_text("".join(json.dumps(row) + "\n" for row in [ diff --git a/tests/test_codex_lifecycle.py b/tests/test_codex_lifecycle.py new file mode 100644 index 0000000..5fc4921 --- /dev/null +++ b/tests/test_codex_lifecycle.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import json +import os +import threading + +import pytest + +from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER, CodexTerminalFence +from cc_remote.wrapper import codex_lifecycle as lifecycle_module +from cc_remote.wrapper.codex_lifecycle import ( + CodexTerminalLedger, + CodexTerminalLedgerError, +) + + +def _fence( + turn_id: str, + status: str = "completed", + *, + duration_ms: int | None = None, +) -> CodexTerminalFence: + return CodexTerminalFence( + turn_id=turn_id, + status=status, + duration_ms=duration_ms, + completed_at=123.5, + ) + + +def test_persistent_terminal_survives_restart_and_append(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b'{"type":"task_complete"}\n') + ledger = CodexTerminalLedger(tmp_path) + ledger.persist("default@session-1", _fence("turn-1", duration_ms=42), rollout) + + rollout.write_bytes(rollout.read_bytes() + b'{"type":"world_state"}\n') + restarted = CodexTerminalLedger(tmp_path) + + assert restarted.snapshot( + "default@session-1", rollout, revision="a" * 32 + "-0", + ) == (_fence("turn-1", duration_ms=42),) + + +def test_terminal_witness_rejects_truncate_rotate_and_rollback(tmp_path): + rollout = tmp_path / "rollout.jsonl" + original = b"prefix\nterminal-boundary\n" + rollout.write_bytes(original) + ledger = CodexTerminalLedger(tmp_path) + ledger.persist("session-1", _fence("turn-1"), rollout) + + rollout.write_bytes(b"short\n") + assert ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + ) == () + + rollout.write_bytes(original) + ledger.persist("session-1", _fence("turn-2"), rollout) + replacement = tmp_path / "replacement.jsonl" + replacement.write_bytes(original + b"replacement\n") + os.replace(replacement, rollout) + assert ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + ) == () + + ledger.persist("session-1", _fence("turn-3"), rollout) + changed = bytearray(rollout.read_bytes()) + changed[-2] = ord("X") + rollout.write_bytes(changed) + assert ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + ) == () + + +def test_terminal_persistence_cannot_rebind_after_source_rotation(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"old source\n") + observed = rollout.stat() + replacement = tmp_path / "replacement.jsonl" + replacement.write_bytes(b"new source\n") + os.replace(replacement, rollout) + + ledger = CodexTerminalLedger(tmp_path) + with pytest.raises( + CodexTerminalLedgerError, + match="source changed before terminal persistence", + ): + ledger.persist( + "session-1", + _fence("turn-1"), + rollout, + expected_source_identity=( + str(rollout), observed.st_dev, observed.st_ino, + observed.st_size), + ) + + assert ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + ) == () + + +def test_out_of_order_persist_tasks_never_regress_source_witness( + tmp_path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"older boundary\n") + older_witness = lifecycle_module._capture_witness(rollout) + with rollout.open("ab") as stream: + stream.write(b"newer boundary\n") + + ledger = CodexTerminalLedger(tmp_path) + ledger.persist("session-1", _fence("newer-turn"), rollout) + newer_size = rollout.stat().st_size + monkeypatch.setattr( + lifecycle_module, "_capture_witness", lambda _path: older_witness) + ledger.persist("session-1", _fence("older-turn"), rollout) + + stored = json.loads( + (tmp_path / "codex-terminal-ledger.json").read_text()) + assert stored["sessions"]["session-1"]["source"]["size"] == newer_size + assert [fence.turn_id for fence in ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + )] == ["older-turn", "newer-turn"] + + +def test_late_persist_from_rotated_inode_cannot_overwrite_new_source( + tmp_path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"old inode\n") + old_witness = lifecycle_module._capture_witness(rollout) + replacement = tmp_path / "replacement.jsonl" + replacement.write_bytes(b"new inode\n") + os.replace(replacement, rollout) + + ledger = CodexTerminalLedger(tmp_path) + ledger.persist("session-1", _fence("new-source-turn"), rollout) + monkeypatch.setattr( + lifecycle_module, "_capture_witness", lambda _path: old_witness) + with pytest.raises( + CodexTerminalLedgerError, + match="source changed before terminal persistence", + ): + ledger.persist("session-1", _fence("old-source-turn"), rollout) + + assert [fence.turn_id for fence in ledger.snapshot( + "session-1", rollout, revision="a" * 32 + "-0", + )] == ["new-source-turn"] + + +def test_volatile_terminal_is_immediate_revision_and_profile_scoped(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"source\n") + st = rollout.stat() + ledger = CodexTerminalLedger(tmp_path) + revision = "b" * 32 + "-0" + ledger.remember( + "iris@same-native-id", + _fence("turn-1", "interrupted"), + revision=revision, + source_identity=( + str(rollout), st.st_dev, st.st_ino, st.st_size), + ) + + assert ledger.snapshot( + "iris@same-native-id", rollout, revision=revision, + ) == (_fence("turn-1", "interrupted"),) + assert ledger.snapshot( + "default@same-native-id", rollout, revision=revision, + ) == () + assert ledger.snapshot( + "iris@same-native-id", rollout, revision="b" * 32 + "-1", + ) == () + + +def test_volatile_terminal_never_rebinds_across_source_knowledge(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"source\n") + st = rollout.stat() + ledger = CodexTerminalLedger(tmp_path) + revision = "f" * 32 + "-0" + ledger.remember( + "session-1", _fence("unbound-turn"), revision=revision) + ledger.remember( + "session-1", + _fence("bound-turn"), + revision=revision, + source_identity=( + str(rollout), st.st_dev, st.st_ino, st.st_size), + ) + + assert ledger.snapshot( + "session-1", rollout, revision=revision, + ) == (_fence("bound-turn"),) + + with rollout.open("ab") as stream: + stream.write(b"append\n") + appended = rollout.stat() + ledger.remember( + "session-1", + _fence("later-bound-turn"), + revision=revision, + source_identity=( + str(rollout), appended.st_dev, appended.st_ino, + appended.st_size), + ) + assert ledger.snapshot( + "session-1", rollout, revision=revision, + ) == (_fence("bound-turn"), _fence("later-bound-turn")) + + # Codex rollback/truncation commonly retains the inode. The observed size + # boundary keeps the process-local fast path as fail-closed as the durable + # SHA-256 witness. + rollout.write_bytes(b"") + assert ledger.snapshot( + "session-1", rollout, revision=revision, + ) == () + + +def test_source_bound_terminal_rebases_across_read_side_revision(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"source\n") + st = rollout.stat() + ledger = CodexTerminalLedger(tmp_path) + first_revision = "1" * 32 + "-0" + second_revision = "1" * 32 + "-1" + ledger.remember( + "session-1", + _fence("turn-1"), + revision=first_revision, + source_identity=( + str(rollout), st.st_dev, st.st_ino, st.st_size), + ) + + assert ledger.rebase_revision( + "session-1", + previous_revision=first_revision, + revision=second_revision, + ) is True + assert ledger.snapshot( + "session-1", rollout, revision=second_revision, + ) == (_fence("turn-1"),) + + # An unbound live fact cannot be carried into another History epoch: there + # is no exact rollout identity with which to validate that transition. + ledger.remember( + "unbound-session", _fence("unbound-turn"), + revision=first_revision, + ) + assert ledger.rebase_revision( + "unbound-session", + previous_revision=first_revision, + revision=second_revision, + ) is False + assert ledger.snapshot( + "unbound-session", rollout, revision=second_revision, + ) == () + + +def test_volatile_remember_does_not_wait_for_durable_store_lock(tmp_path): + ledger = CodexTerminalLedger(tmp_path) + completed = threading.Event() + + def remember() -> None: + ledger.remember( + "session-1", _fence("turn-1"), revision="2" * 32 + "-0") + completed.set() + + # Durable persistence holds this lock across JSON replacement and fsync. + # Process-local publication must use an independent lock so the wrapper's + # event loop can still emit the authoritative TurnEnd immediately. + with ledger._lock: + worker = threading.Thread(target=remember) + worker.start() + assert completed.wait(timeout=1.0) + worker.join(timeout=1.0) + assert not worker.is_alive() + + +def test_corrupt_store_degrades_to_unknown_and_never_fabricates_success(tmp_path): + path = tmp_path / "codex-terminal-ledger.json" + path.write_text('{"version":1,"sessions":{"session-1":') + + ledger = CodexTerminalLedger(tmp_path) + + assert ledger.snapshot( + "session-1", tmp_path / "missing-rollout", + revision="c" * 32 + "-0", + ) == () + + +@pytest.mark.parametrize("field", ["duration_ms", "completed_at"]) +def test_non_finite_or_unsafe_terminal_metadata_is_rejected(field): + for value in (float("inf"), float("nan"), MAX_SAFE_WIRE_INTEGER + 1): + with pytest.raises(ValueError): + CodexTerminalFence( + turn_id="turn-1", + status="completed", + **{field: value}, + ) + + +def test_terminal_ledger_keeps_only_newest_sixteen_exact_turns(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"source\n") + ledger = CodexTerminalLedger(tmp_path) + revision = "d" * 32 + "-0" + for index in range(20): + ledger.remember( + "session-1", _fence(f"turn-{index}"), revision=revision) + + assert [fence.turn_id for fence in ledger.snapshot( + "session-1", rollout, revision=revision, + )] == [f"turn-{index}" for index in range(4, 20)] + + +def test_profile_migration_keeps_terminal_namespace_isolated(tmp_path): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes(b"source\n") + ledger = CodexTerminalLedger(tmp_path) + ledger.persist("legacy-session", _fence("turn-1"), rollout) + + assert ledger.migrate_profile_sessions( + lambda session_id: f"default@{session_id}", + profile_revision=1, + ) == 1 + stored = json.loads( + (tmp_path / "codex-terminal-ledger.json").read_text()) + assert set(stored["sessions"]) == {"default@legacy-session"} + assert ledger.snapshot( + "legacy-session", rollout, revision="e" * 32 + "-0", + ) == () + assert ledger.snapshot( + "default@legacy-session", rollout, + revision="e" * 32 + "-0", + ) == (_fence("turn-1"),) diff --git a/tests/test_codex_profiles.py b/tests/test_codex_profiles.py index 0e34722..07184a0 100644 --- a/tests/test_codex_profiles.py +++ b/tests/test_codex_profiles.py @@ -31,9 +31,12 @@ from cc_remote.wrapper.codex_turn_leases import CodexTurnLeaseStore from cc_remote.wrapper.process_scan import ProcessIdentity from cc_remote.wrapper.session_pins import SessionPinStore +from cc_remote.wrapper.session_plans import SessionPlanStore +from cc_remote.wrapper.session_presentation import SessionPresentationStore from cc_remote.wrapper.machine import WrapperMachine, _CodexHistoryProfiles from cc_remote.wrapper.ringbuffer import RingBuffer from cc_remote.wrapper.session_ctx import SessionContext +from cc_remote.workspaces import WorkRegistry def _profiles(primary: Path, stack: Path) -> str: @@ -108,6 +111,116 @@ async def run() -> None: event for event in transport.sent if isinstance(event, SessionListInvalidated) ]) == 1 + candidates = machine._codex_exact_catalog_candidates() + assert candidates["primary"]["same-native-new"]["hinted"] is True + assert candidates["stack"]["same-native-new"]["hinted"] is True + + asyncio.run(run()) + + +def test_cli_thread_catalog_hint_survives_placeholder_ttl( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + async def run() -> None: + machine, _transport = _machine(tmp_path) + primary = _context( + "primary@current-primary", "current-primary", "primary") + machine.sessions[primary.key] = primary + machine._codex_thread_started_hints[ + ("primary", "cli-long-running") + ] = 1.0 + monkeypatch.setattr(machine_module.time, "monotonic", lambda: 60.0) + + machine._on_codex_thread_started_hint(primary, "cli-newer") + await asyncio.gather(*tuple(machine._codex_catalog_hint_tasks)) + + assert set(machine._codex_thread_started_hints) == { + ("primary", "cli-long-running"), + ("primary", "cli-newer"), + } + candidates = machine._codex_exact_catalog_candidates() + assert candidates["primary"]["cli-long-running"]["hinted"] is True + assert candidates["primary"]["cli-long-running"]["hint_fresh"] is False + assert candidates["primary"]["cli-newer"]["hint_fresh"] is True + assert ( + candidates["primary"]["cli-long-running"]["created_at"] + < candidates["primary"]["cli-newer"]["created_at"] + ) + + asyncio.run(run()) + + +def test_hidden_cli_thread_hint_materializes_in_its_own_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + async def run() -> None: + machine, _transport = _machine(tmp_path) + primary = _context( + "primary@current-primary", "current-primary", "primary") + machine.sessions = {primary.key: primary} + + async def empty_list(_limit, *, codex_home=None): + return [] + + def exact_rows(session_ids, *, codex_home=None): + assert Path(codex_home).name == "primary" + return [{ + "session_id": "cli-hidden", + "summary": None, + "first_prompt": "CLI prompt", + "cwd": "/repo/cli", + "last_modified": "30", + "git_branch": None, + "forked_from_id": None, + "status": None, + "tag": None, + }] if "cli-hidden" in session_ids else [] + + monkeypatch.setattr(machine_module, "list_codex_sessions", empty_list) + monkeypatch.setattr( + machine_module, "codex_exact_catalog_rows", exact_rows) + machine._on_codex_thread_started_hint(primary, "cli-hidden") + await asyncio.gather(*tuple(machine._codex_catalog_hint_tasks)) + + rows = await machine._read_all_codex_profile_sessions() + + hidden = next( + row for row in rows + if row["session_id"] == "primary@cli-hidden" + ) + assert hidden["codex_profile_id"] == "primary" + assert hidden["cwd"] == "/repo/cli" + assert all(row["session_id"] != "stack@cli-hidden" for row in rows) + + asyncio.run(run()) + + +def test_codex_id_capture_broadcasts_catalog_invalidation( + tmp_path: Path, +) -> None: + async def run() -> None: + machine, transport = _machine(tmp_path) + ctx = SessionContext( + session_id=None, + sdk=object(), + buffer=RingBuffer(100, 1024 * 1024), + cwd="/repo/new", + key="tmp-new", + engine="codex", + codex_profile_id="primary", + ) + machine.sessions = {ctx.key: ctx} + + await machine._capture_session_id(ctx, "captured-native") + + assert ctx.key == "primary@captured-native" + assert [event.type for event in transport.sent][-2:] == [ + "session_rekey", + "session_list_invalidated", + ] + invalidated = transport.sent[-1] + assert invalidated.engine == "codex" + assert invalidated.space == "code" asyncio.run(run()) @@ -1227,6 +1340,333 @@ def find_fork(thread_source, parent_sid, cwd, *, roots): } +def test_hidden_resident_and_fork_catalog_rows_stay_profile_scoped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + primary = _context( + "primary@same-native-id", "same-native-id", "primary") + machine.sessions = {primary.key: primary} + machine._codex_forks.begin( + "fork-request", "stack@parent-native", "turn-1", "/repo/stack") + machine._codex_forks.complete( + "fork-request", "stack@same-native-id") + # A corrupt/stale cross-profile journal record must not be repaired into + # either account even if its child happens to exist in one state DB. + machine._codex_forks.begin( + "cross-request", "stack@other-parent", "turn-2", "/repo/stack") + machine._codex_forks.complete( + "cross-request", "primary@cross-child") + + calls: list[tuple[str, tuple[str, ...]]] = [] + + async def empty_list(_limit, *, codex_home=None): + assert codex_home is not None + return [] + + def exact_rows(session_ids, *, codex_home=None): + home = str(Path(codex_home).resolve()) + native_ids = tuple(session_ids) + calls.append((home, native_ids)) + if Path(home).name == "primary": + # thread/start returned the durable id, but the exact DB metadata + # transaction is still lagging. The live resident itself keeps the + # row visible without borrowing anything from Stack. + return [] + return [{ + "session_id": native_sid, + "summary": None, + "first_prompt": None, + "cwd": f"/repo/{Path(home).name}", + "last_modified": "20", + "git_branch": None, + "forked_from_id": None, + "status": None, + "tag": None, + } for native_sid in native_ids] + + monkeypatch.setattr(machine_module, "list_codex_sessions", empty_list) + monkeypatch.setattr( + machine_module, "codex_exact_catalog_rows", exact_rows) + + rows = asyncio.run(machine._read_all_codex_profile_sessions()) + + by_wire = {row["session_id"]: row for row in rows} + assert set(by_wire) == { + "primary@same-native-id", + "stack@same-native-id", + } + assert by_wire["primary@same-native-id"]["codex_profile_id"] == "primary" + assert by_wire["primary@same-native-id"]["forked_from_id"] is None + assert by_wire["primary@same-native-id"]["cwd"] == ( + "/tmp/cc-remote-profile-test") + assert by_wire["stack@same-native-id"]["codex_profile_id"] == "stack" + assert by_wire["stack@same-native-id"]["forked_from_id"] == ( + "stack@parent-native") + assert by_wire["stack@same-native-id"]["summary"] == "派生会话" + assert all("cross-child" not in native_ids for _home, native_ids in calls) + assert set(calls) == { + (str((tmp_path / "primary").resolve()), ("same-native-id",)), + (str((tmp_path / "stack").resolve()), ("same-native-id",)), + } + + +def test_old_schema_catalog_fallback_is_batched_and_profile_scoped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + machine.sessions = {} + for profile_id in ("primary", "stack"): + profile_home = tmp_path / profile_id + profile_home.mkdir(parents=True, exist_ok=True) + (profile_home / "config.toml").write_text( + 'model_provider = "openai"\n', encoding="utf-8") + request_id = f"fork-{profile_id}" + machine._codex_forks.begin( + request_id, + f"{profile_id}@parent-native", + f"turn-{profile_id}", + f"/repo/{profile_id}", + ) + machine._codex_forks.complete( + request_id, f"{profile_id}@same-native-id") + + async def empty_list(_limit, *, codex_home=None): + assert codex_home is not None + return [] + + def uncertain_exact_rows(session_ids, *, codex_home=None): + assert tuple(session_ids) == ("same-native-id",) + assert codex_home is not None + return None + + calls: list[tuple[str, list[tuple[str, object]], float]] = [] + + async def batch(requests, cwd=None, *, timeout, codex_home=None): + assert cwd is None + assert codex_home is not None + profile_id = Path(codex_home).name + calls.append((profile_id, requests, timeout)) + return [{"thread": { + "id": request[1]["threadId"], + "cwd": f"/repo/{profile_id}", + "preview": f"{profile_id} hidden fork", + "updatedAt": 42, + "modelProvider": "openai", + }} for request in requests] + + monkeypatch.setattr(machine_module, "list_codex_sessions", empty_list) + monkeypatch.setattr( + machine_module, "codex_exact_catalog_rows", uncertain_exact_rows) + monkeypatch.setattr(machine_module, "codex_rpc_batch", batch) + + rows = asyncio.run(machine._read_all_codex_profile_sessions()) + + by_wire = {row["session_id"]: row for row in rows} + assert set(by_wire) == { + "primary@same-native-id", "stack@same-native-id", + } + assert by_wire["primary@same-native-id"]["cwd"] == "/repo/primary" + assert by_wire["stack@same-native-id"]["cwd"] == "/repo/stack" + assert by_wire["primary@same-native-id"]["forked_from_id"] == ( + "primary@parent-native") + assert by_wire["stack@same-native-id"]["forked_from_id"] == ( + "stack@parent-native") + assert {profile_id for profile_id, _requests, _timeout in calls} == { + "primary", "stack", + } + assert all(requests == [( + "thread/read", + {"threadId": "same-native-id", "includeTurns": False}, + )] for _profile_id, requests, _timeout in calls) + assert all(0 < timeout <= machine.CODEX_EXACT_CATALOG_RPC_TIMEOUT_SECONDS + for _profile_id, _requests, timeout in calls) + + +def test_exact_catalog_rpc_fallback_is_bounded_and_provider_filtered( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + primary_home = tmp_path / "primary" + primary_home.mkdir(parents=True, exist_ok=True) + (primary_home / "config.toml").write_text( + 'model_provider = "openai"\n', encoding="utf-8") + captured: list[tuple[str, object]] = [] + batch_sizes: list[int] = [] + timeouts: list[float] = [] + + async def batch(requests, cwd=None, *, timeout, codex_home=None): + del cwd + assert Path(codex_home) == primary_home + batch_sizes.append(len(requests)) + timeouts.append(timeout) + captured.extend(requests) + return [{"thread": { + "id": params["threadId"], + "modelProvider": "different-provider", + }} for _method, params in requests] + + monkeypatch.setattr(machine_module, "codex_rpc_batch", batch) + rows = asyncio.run(machine._codex_exact_catalog_rpc_rows( + machine._codex_profile("primary"), + [f"thread-{index}" for index in range(600)], + )) + + assert rows == [] + assert len(captured) == machine_module.CODEX_EXACT_CATALOG_MAX_IDS + assert batch_sizes == [ + machine.CODEX_EXACT_CATALOG_RPC_BATCH_IDS, + ] * ( + machine_module.CODEX_EXACT_CATALOG_MAX_IDS + // machine.CODEX_EXACT_CATALOG_RPC_BATCH_IDS + ) + assert all(0 < timeout <= machine.CODEX_EXACT_CATALOG_RPC_TIMEOUT_SECONDS + for timeout in timeouts) + + +def test_resident_catalog_repair_survives_the_global_row_bound( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + resident = _context( + "primary@resident-old", "resident-old", "primary") + machine.sessions = {resident.key: resident} + + async def full_list(_limit, *, codex_home=None): + profile_id = Path(codex_home).name + return [{ + "session_id": f"listed-{profile_id}-{index}", + "summary": f"row {index}", + "last_modified": str(10_000 - index), + } for index in range(200)] + + def exact_rows(session_ids, *, codex_home=None): + if Path(codex_home).name != "primary": + return [] + return [{ + "session_id": "resident-old", + "summary": "old resident", + "first_prompt": None, + "cwd": "/repo/old", + "last_modified": "1", + "git_branch": None, + "forked_from_id": None, + "status": None, + "tag": None, + }] if "resident-old" in session_ids else [] + + monkeypatch.setattr(machine_module, "list_codex_sessions", full_list) + monkeypatch.setattr( + machine_module, "codex_exact_catalog_rows", exact_rows) + + rows = asyncio.run(machine._read_all_codex_profile_sessions()) + + assert len(rows) == machine.CODEX_SESSION_LIST_MAX_ROWS + assert any( + row["session_id"] == "primary@resident-old" for row in rows + ) + assert all( + machine_module._CODEX_CATALOG_PRIORITY not in row for row in rows + ) + + +def test_profile_list_failure_still_returns_resident_exact_rows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + resident = _context( + "primary@resident-during-error", "resident-during-error", "primary") + machine.sessions = {resident.key: resident} + + async def list_sessions(_limit, *, codex_home=None): + if Path(codex_home).name == "primary": + raise RuntimeError("app-server list unavailable") + return [] + + monkeypatch.setattr(machine_module, "list_codex_sessions", list_sessions) + monkeypatch.setattr( + machine_module, + "codex_exact_catalog_rows", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + machine_module, + "codex_rpc_batch", + lambda *_args, **_kwargs: asyncio.sleep(0, result=[]), + ) + + rows, errors = asyncio.run(machine._read_codex_profile_catalog()) + + assert [row["session_id"] for row in rows] == [ + "primary@resident-during-error" + ] + assert errors == (("primary", "会话列表暂不可用"),) + + +def test_profile_list_failure_cache_merge_keeps_old_resident( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + resident = _context( + "primary@resident-during-error", "resident-during-error", "primary") + machine.sessions = {resident.key: resident} + cached_rows = [ + { + "session_id": f"primary@cached-primary-{index}", + "native_session_id": f"cached-primary-{index}", + "codex_profile_id": "primary", + "codex_profile_label": "primary", + "summary": "cached", + "last_modified": str(10_000 - index), + } + for index in range(machine.CODEX_SESSION_LIST_MAX_ROWS) + ] + machine._codex_session_list_cache = ( + time.monotonic(), cached_rows, (), + ) + + async def list_sessions(_limit, *, codex_home=None): + if Path(codex_home).name == "primary": + raise RuntimeError("app-server list unavailable") + return [] + + def exact_rows(session_ids, *, codex_home=None): + if ( + Path(codex_home).name != "primary" + or "resident-during-error" not in session_ids + ): + return [] + return [{ + "session_id": "resident-during-error", + "summary": "old resident", + "first_prompt": None, + "cwd": "/repo/old", + "last_modified": "1", + "git_branch": None, + "forked_from_id": None, + "status": None, + "tag": None, + }] + + monkeypatch.setattr(machine_module, "list_codex_sessions", list_sessions) + monkeypatch.setattr( + machine_module, "codex_exact_catalog_rows", exact_rows) + + rows = asyncio.run(machine._refresh_codex_session_catalog()) + + assert len(rows) == machine.CODEX_SESSION_LIST_MAX_ROWS + assert any( + row["session_id"] == "primary@resident-during-error" + for row in rows + ) + assert machine._codex_session_profile_errors == ( + ("primary", "会话列表暂不可用"), + ) + assert all( + machine_module._CODEX_CATALOG_PRIORITY not in row for row in rows + ) + + def test_profile_history_rpc_uses_native_uuid_and_matching_home( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1727,6 +2167,157 @@ def fail_controls(self, transform, *, profile_revision): tmp_path / "state" / "codex-profile-transition.json").exists() +@pytest.mark.parametrize( + ("store_type", "method_name", "attribute"), + [ + (SessionPlanStore, "migrate_profile_sessions", "_session_plans"), + ( + SessionPresentationStore, + "migrate_codex_profile_sessions", + "_session_presentation", + ), + ], +) +def test_optional_presentation_migration_does_not_disable_codex_or_work( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + store_type, + method_name: str, + attribute: str, +) -> None: + machine, _transport = _machine(tmp_path) + state = tmp_path / "state" + previous = json.loads( + (state / "codex-profile-topology.json").read_text(encoding="utf-8") + ) + renamed_profiles = {} + for profile in previous["profiles"]: + target = "renamed" if profile["id"] == "primary" else profile["id"] + renamed_profiles[target] = { + "label": target, + "home": profile["home"], + "default": profile["id"] == previous["default_id"], + } + + def fail_optional(self, transform, *, profile_revision): + raise RuntimeError("simulated optional cache failure") + + monkeypatch.setattr(store_type, method_name, fail_optional) + cfg = WrapperConfig() + cfg.state_dir = state + cfg.claude_work_root = tmp_path / "work" / "claude" + cfg.codex_work_root = tmp_path / "work" / "codex" + cfg.codex_profiles_json = json.dumps(renamed_profiles) + + migrated = WrapperMachine(cfg, _StubTransport()) + + assert migrated._codex_profile_migration_ok + assert migrated._codex_work_profile_migration_ok + assert not migrated._codex_presentation_profile_migration_ok + assert getattr(migrated, attribute) is None + assert migrated._codex_profile().id == "renamed" + assert (state / "codex-profile-transition.json").exists() + # The pending transition is retained so the optional projection can catch + # up after restart without revoking access to the already-migrated engines. + assert machine._codex_profiles.default.id == "primary" + + +@pytest.mark.parametrize( + ("filename", "attribute"), + [ + ("session-plans.json", "_session_plans"), + ("session-presentation.json", "_session_presentation"), + ], +) +def test_corrupt_rebuildable_projection_does_not_pin_profile_transition( + tmp_path: Path, + filename: str, + attribute: str, +) -> None: + _machine(tmp_path) + state = tmp_path / "state" + previous = json.loads( + (state / "codex-profile-topology.json").read_text(encoding="utf-8") + ) + renamed_profiles = {} + for profile in previous["profiles"]: + target = "renamed" if profile["id"] == "primary" else profile["id"] + renamed_profiles[target] = { + "label": target, + "home": profile["home"], + "default": profile["id"] == previous["default_id"], + } + + projection = state / filename + projection.write_text("{not-json", encoding="utf-8") + cfg = WrapperConfig() + cfg.state_dir = state + cfg.claude_work_root = tmp_path / "work" / "claude" + cfg.codex_work_root = tmp_path / "work" / "codex" + cfg.codex_profiles_json = json.dumps(renamed_profiles) + + migrated = WrapperMachine(cfg, _StubTransport()) + + assert migrated._codex_profile_migration_ok + assert migrated._codex_work_profile_migration_ok + assert migrated._codex_presentation_profile_migration_ok + assert getattr(migrated, attribute) is not None + assert not (state / "codex-profile-transition.json").exists() + quarantined = list(state.glob(f"{filename}.corrupt-*")) + assert len(quarantined) == 1 + assert quarantined[0].read_text(encoding="utf-8") == "{not-json" + + next_profiles = dict(renamed_profiles) + next_profiles["third"] = { + "label": "third", + "home": str(tmp_path / "third"), + } + cfg.codex_profiles_json = json.dumps(next_profiles) + advanced = WrapperMachine(cfg, _StubTransport()) + + assert advanced._codex_profile_migration_ok + assert advanced._codex_work_profile_migration_ok + assert advanced._codex_presentation_profile_migration_ok + assert not (state / "codex-profile-transition.json").exists() + + +def test_work_profile_migration_failure_does_not_disable_codex_code( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _machine(tmp_path) + state = tmp_path / "state" + previous = json.loads( + (state / "codex-profile-topology.json").read_text(encoding="utf-8") + ) + renamed_profiles = {} + for profile in previous["profiles"]: + target = "renamed" if profile["id"] == "primary" else profile["id"] + renamed_profiles[target] = { + "label": target, + "home": profile["home"], + "default": profile["id"] == previous["default_id"], + } + + def fail_work(*_args, **_kwargs): + raise RuntimeError("simulated Work ownership failure") + + monkeypatch.setattr( + WorkRegistry, "migrate_codex_profiles", fail_work) + cfg = WrapperConfig() + cfg.state_dir = state + cfg.claude_work_root = tmp_path / "work" / "claude" + cfg.codex_work_root = tmp_path / "work" / "codex" + cfg.codex_profiles_json = json.dumps(renamed_profiles) + + migrated = WrapperMachine(cfg, _StubTransport()) + + assert migrated._codex_profile_migration_ok + assert not migrated._codex_work_profile_migration_ok + assert migrated._codex_profile().id == "renamed" + assert (state / "codex-profile-transition.json").exists() + + def test_v1_topology_is_upgraded_before_old_checkpoint_is_opened( tmp_path: Path, ) -> None: @@ -2432,7 +3023,7 @@ async def forbidden_spawn(**_kwargs): assert transport.sent == [result] -def test_profile_model_permission_and_capability_reads_use_matching_home( +def test_profile_model_reads_use_catalog_default_from_matching_home( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: machine, _transport = _machine(tmp_path) @@ -2452,7 +3043,14 @@ async def catalog(*, codex_home=None): def configured_model(_default="", *, codex_home=None): calls.append(("default", codex_home)) - return "stack-model" + # A newly added account commonly has no model in config.toml. Its own + # app-server catalog default must win; never borrow the primary account + # or CodexHandle's historical fallback. + return "" + + def configured_effort(_default="", *, codex_home=None): + calls.append(("effort", codex_home)) + return "" async def permission_profiles(_cwd, *, codex_home=None): calls.append(("permissions", codex_home)) @@ -2467,6 +3065,7 @@ async def capabilities( monkeypatch.setattr(machine_module, "codex_catalog", catalog) monkeypatch.setattr(machine_module, "codex_model", configured_model) + monkeypatch.setattr(machine_module, "codex_effort", configured_effort) monkeypatch.setattr( machine_module, "codex_permission_profiles", permission_profiles) monkeypatch.setattr(machine_module, "engine_capabilities", capabilities) @@ -2478,6 +3077,7 @@ async def run() -> None: )) assert models.codex_profile_id == "stack" assert models.default_model == "stack-model" + assert models.default_effort == "high" permissions = await machine._handle_get_permission_profiles( SimpleNamespace( sid=None, @@ -2500,14 +3100,200 @@ async def run() -> None: assert report.codex_profile_id == "stack" asyncio.run(run()) - assert calls == [ - ("models", stack_home), + assert calls[0] == ("models", stack_home) + assert set(calls[1:3]) == { ("default", stack_home), + ("effort", stack_home), + } + assert calls[3:] == [ ("permissions", stack_home), ("capabilities", stack_home), ] +def test_profile_model_resolution_isolated_and_fails_closed_for_explicit_choice( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + calls: list[str] = [] + + async def catalog(*, codex_home=None): + profile_id = Path(codex_home).name + calls.append(profile_id) + return [{ + "id": f"{profile_id}-default", + "efforts": ["low"], + "default_effort": "low", + "is_default": True, + }] + + def provider(*, codex_home=None): + return "cubence" if Path(codex_home).name == "primary" else "" + + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr(machine_module, "codex_current_provider", provider) + + async def run() -> None: + primary = machine._codex_profile("primary") + stack = machine._codex_profile("stack") + assert await machine._resolve_codex_profile_model( + primary, None, + ) == ("primary-default", True) + assert await machine._resolve_codex_profile_model( + primary, "custom-provider-model", + ) == ("custom-provider-model", False) + assert await machine._resolve_codex_profile_model( + stack, "retired-model", + ) == ("stack-default", True) + with pytest.raises(machine_module._UnsupportedCodexModel): + await machine._resolve_codex_profile_model( + stack, "primary-default", explicit=True) + + # An empty result is availability uncertainty. Do not resurrect an old + # hardcoded model when no configured/session value exists. + assert await machine._resolve_codex_profile_model( + stack, None, catalog=[], + ) == (None, False) + + asyncio.run(run()) + assert calls == ["primary", "primary", "stack", "stack"] + + +def test_new_stack_session_applies_its_catalog_default_before_connect( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + created = [] + + class FakeCodexHandle: + def __init__(self, _cfg, **kwargs): + self.init = kwargs + self.model = "gpt-5-codex" + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = kwargs["cwd"] + self.cwd = kwargs["cwd"] + self._generation = 1 + self._approval = "never" + self.approval_policy = "never" + self.permission_profile = None + self.web_search = "cached" + self.web_search_override = None + self.collaboration_mode = "default" + self.service_tier = None + self.shared_daemon_affinity = False + self.using_daemon_proxy = False + self.thread_id = None + self.connect_model = None + self.connect_effort = None + created.append(self) + + @property + def approval(self): + return self._approval + + @approval.setter + def approval(self, value): + self._approval = value + self.approval_policy = value + + async def connect(self, **_kwargs): + self.connect_model = self.model + self.connect_effort = self.effort + self.thread_id = "stack-native" + + async def disconnect(self): + return None + + stack_home = str((tmp_path / "stack").resolve()) + + async def catalog(*, codex_home=None): + assert codex_home == stack_home + return [{ + "id": "stack-default", + "efforts": ["low"], + "default_effort": "low", + "is_default": True, + }] + + def configured_model(_default="", *, codex_home=None): + assert codex_home == stack_home + return "" + + async def clamp(_model, effort, *, codex_home=None): + assert _model == "stack-default" + assert codex_home == stack_home + return effort + + async def no_op(*_args, **_kwargs): + return None + + monkeypatch.setattr(machine_module, "CodexHandle", FakeCodexHandle) + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr(machine_module, "codex_model", configured_model) + monkeypatch.setattr(machine_module, "clamp_effort", clamp) + monkeypatch.setattr(machine, "_resolve_codex_session_effort", no_op) + monkeypatch.setattr(machine, "_stamp_codex_daemon_epoch", no_op) + monkeypatch.setattr(machine, "_persist_codex_session_controls", no_op) + monkeypatch.setattr(machine, "_load_history", no_op) + + ctx = asyncio.run(machine._spawn( + resume_id=None, + cwd=str(tmp_path), + engine="codex", + codex_profile_id="stack", + raise_on_failure=True, + )) + + assert ctx is not None + assert ctx.key == "stack@stack-native" + assert ctx.codex_profile_id == "stack" + assert created[0].init["codex_home"] == stack_home + assert created[0].connect_model == "stack-default" + assert created[0].connect_effort is None + + +def test_explicit_stack_model_is_rejected_before_handle_creation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + machine, _transport = _machine(tmp_path) + stack_home = str((tmp_path / "stack").resolve()) + + async def catalog(*, codex_home=None): + assert codex_home == stack_home + return [{ + "id": "stack-default", + "efforts": ["low"], + "default_effort": "low", + "is_default": True, + }] + + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr( + machine_module, + "CodexHandle", + lambda *_args, **_kwargs: pytest.fail( + "unsupported model must fail before handle creation"), + ) + + with pytest.raises(machine_module._SpawnFailure) as caught: + asyncio.run(machine._spawn( + resume_id=None, + cwd=str(tmp_path), + engine="codex", + codex_profile_id="stack", + model="primary-only-model", + raise_on_failure=True, + )) + + assert caught.value.code == machine_module.ERR_BAD_PROMPT + + def test_new_codex_work_session_freezes_selected_profile_before_spawn( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_codex_session_delete.py b/tests/test_codex_session_delete.py index 2e31bfb..01b46b5 100644 --- a/tests/test_codex_session_delete.py +++ b/tests/test_codex_session_delete.py @@ -21,6 +21,7 @@ Query, ) from cc_remote.wrapper import machine as machine_module +from cc_remote.wrapper.codex_forks import ForkJournalError from cc_remote.wrapper.codex_handle import CodexAppServerError from cc_remote.wrapper.codex_external import HolderScan from cc_remote.wrapper.codex_rpc import CodexRpcRejected @@ -47,6 +48,12 @@ def delete(self, sid: str) -> None: self.calls.append((self.kind, sid)) +class _PresentationDeleteRecorder(_DeleteRecorder): + def delete(self, engine: str, sid: str) -> None: + assert engine == "codex" + self.calls.append((self.kind, sid)) + + class _PinRecorder: def __init__(self, calls: list[tuple]) -> None: self.calls = calls @@ -288,6 +295,9 @@ async def run(): handle = _DeleteHandle(ConnectionError("proxy closed")) ctx = _resident(machine, handle) _prepare(machine, monkeypatch) + machine._codex_forks.begin( + "fork-request", "parent-thread", "parent-turn", "/repo") + machine._codex_forks.complete("fork-request", "codex-thread") async def session_exists(_sid): return True @@ -304,6 +314,8 @@ async def session_exists(_sid): assert result.code == ERR_INTERNAL assert handle.calls == [("delete", "codex-thread")] assert machine.sessions[ctx.key] is ctx + assert machine._codex_forks.child_entry( + "codex-thread")["status"] == "complete" asyncio.run(run()) @@ -357,6 +369,9 @@ async def run(): handle = _DeleteHandle(ConnectionError("proxy closed")) ctx = _resident(machine, handle) _prepare(machine, monkeypatch) + machine._codex_forks.begin( + "fork-request", "parent-thread", "parent-turn", "/repo") + machine._codex_forks.complete("fork-request", "codex-thread") rollout = tmp_path / "rollout.jsonl" rollout.write_text("session\n", encoding="utf-8") @@ -376,6 +391,8 @@ async def session_exists(_sid): assert result.code == ERR_INTERNAL assert machine.sessions[ctx.key] is ctx assert rollout.exists() + assert machine._codex_forks.child_entry( + "codex-thread")["status"] == "delete_pending" asyncio.run(run()) @@ -456,7 +473,7 @@ async def run(): cleanup_calls, ) machine._session_plans = _DeleteRecorder("plan", cleanup_calls) - machine._session_presentation = _DeleteRecorder( + machine._session_presentation = _PresentationDeleteRecorder( "presentation", cleanup_calls, ) @@ -643,7 +660,7 @@ async def run(): cleanup_calls, ) machine._session_plans = _DeleteRecorder("plan", cleanup_calls) - machine._session_presentation = _DeleteRecorder( + machine._session_presentation = _PresentationDeleteRecorder( "presentation", cleanup_calls, ) @@ -717,7 +734,7 @@ async def run(): cleanup_calls, ) machine._session_plans = _DeleteRecorder("plan", cleanup_calls) - machine._session_presentation = _DeleteRecorder( + machine._session_presentation = _PresentationDeleteRecorder( "presentation", cleanup_calls, ) @@ -1241,6 +1258,101 @@ async def forbidden_spawn(*_args, **_kwargs): asyncio.run(run()) +def test_cold_codex_delete_disconnects_when_fork_journal_fails( + monkeypatch, + tmp_path: Path, +): + async def run(): + machine, _ = _mk_machine() + machine.cfg.cc_cwd = str(tmp_path) + _prepare(machine, monkeypatch) + _ColdDeleteHandle.created = [] + monkeypatch.setattr( + machine_module, + "CodexHandle", + _ColdDeleteHandle, + ) + monkeypatch.setattr( + machine, + "_codex_cwd_for_wire", + lambda _sid: str(tmp_path), + ) + + def fail_begin_delete(_sid): + raise ForkJournalError("disk full") + + monkeypatch.setattr( + machine._codex_forks, + "begin_delete", + fail_begin_delete, + ) + + result = await machine._handle_delete_session(_delete_command()) + + assert isinstance(result, Error) + assert result.code == ERR_INTERNAL + assert len(_ColdDeleteHandle.created) == 1 + assert _ColdDeleteHandle.created[0].calls == [ + ( + "connect", + { + "cwd": str(tmp_path), + "control_only": True, + }, + ), + ("disconnect", None), + ] + + asyncio.run(run()) + + +def test_cold_codex_delete_disconnects_when_transient_context_is_busy( + monkeypatch, + tmp_path: Path, +): + async def run(): + machine, _ = _mk_machine() + machine.cfg.cc_cwd = str(tmp_path) + _prepare(machine, monkeypatch) + _ColdDeleteHandle.created = [] + monkeypatch.setattr( + machine_module, + "CodexHandle", + _ColdDeleteHandle, + ) + monkeypatch.setattr( + machine, + "_codex_cwd_for_wire", + lambda _sid: str(tmp_path), + ) + + original_connect = _ColdDeleteHandle.connect + + async def connect_busy(handle, **kwargs): + await original_connect(handle, **kwargs) + handle.turn_active = True + + monkeypatch.setattr(_ColdDeleteHandle, "connect", connect_busy) + + result = await machine._handle_delete_session(_delete_command()) + + assert isinstance(result, Error) + assert result.code == ERR_BUSY + assert len(_ColdDeleteHandle.created) == 1 + assert _ColdDeleteHandle.created[0].calls == [ + ( + "connect", + { + "cwd": str(tmp_path), + "control_only": True, + }, + ), + ("disconnect", None), + ] + + asyncio.run(run()) + + def test_cold_codex_delete_rejects_active_catalog_root_without_rollout( monkeypatch, tmp_path: Path, diff --git a/tests/test_codex_session_migration.py b/tests/test_codex_session_migration.py index 34f227b..d33a88f 100644 --- a/tests/test_codex_session_migration.py +++ b/tests/test_codex_session_migration.py @@ -79,7 +79,7 @@ async def list_sessions(_cmd): def test_session_migration_protocol_roundtrips_as_control_frames(): - assert PROTOCOL_VERSION == 34 + assert PROTOCOL_VERSION == 35 command = deserialize(serialize(_command("/tmp/new-cwd"))) assert command.type == "migrate_session" assert command.session_id == "thread-1" diff --git a/tests/test_codex_shared_machine.py b/tests/test_codex_shared_machine.py index e124122..6b794ce 100644 --- a/tests/test_codex_shared_machine.py +++ b/tests/test_codex_shared_machine.py @@ -3,6 +3,7 @@ import asyncio import json +import os from types import SimpleNamespace from cc_remote.codex_daemon_restart import ( @@ -127,6 +128,33 @@ async def disconnect(self) -> None: self.live = False +class _EvictedDuringEffortPublishSdk(_InterruptedSharedSdk): + def __init__(self) -> None: + super().__init__() + self.effort = None + self.applied_effort = None + self.display_effort = None + self.display_effort_model = None + self.display_effort_cwd = None + self.display_effort_generation = None + self._display_effort_retry_at = None + self._cwd = "/tmp" + self._generation = 2 + self._thread_settings_revision = 0 + self.effort_resolution_started = asyncio.Event() + self.release_effort_resolution = asyncio.Event() + self.disconnects = 0 + + async def configured_default_effort(self): + self.effort_resolution_started.set() + await self.release_effort_resolution.wait() + return "medium" + + async def disconnect(self) -> None: + self.disconnects += 1 + self.live = False + + class _AccountSwitchSharedSdk(_SharedSdk): shared_daemon_affinity = True @@ -606,6 +634,74 @@ async def go() -> None: asyncio.run(go()) +def test_restarted_wrapper_recovers_large_steered_turn_from_durable_binding( + tmp_path, monkeypatch, +): + """A new official steer id must not orphan its already-bound task.""" + async def go() -> None: + machine, transport = _mk_machine() + rollout = tmp_path / "oversized-steered-tail.jsonl" + rollout.write_bytes( + _response_user_item("rollout-msg-id", "rollout-task") + + _event("task_started", "rollout-task") + ) + monkeypatch.setattr( + "cc_remote.wrapper.machine.codex_rollout_path", + lambda _sid: str(rollout), + ) + monkeypatch.setattr(type(machine), "CODEX_TAIL_READ_MAX", 256) + machine._codex_turn_leases.claim( + "sid", "control-turn", "latest-browser-message") + source = rollout.stat() + assert machine._codex_turn_leases.bind_stream( + "sid", + "control-turn", + "rollout-task", + "previous-official-user-item", + source_device=source.st_dev, + source_inode=source.st_ino, + expected_msg_id="latest-browser-message", + ) is True + with rollout.open("ab") as stream: + stream.write(b'{}\n' * 256) + + class ShiftedOfficialIdentitySdk(_RecoveredSplitTurnSdk): + async def probe_owned_turn(self, turn_id: str): + assert turn_id == "control-turn" + return SimpleNamespace( + turn_id=turn_id, + native_user_message_ids=("new-official-user-item",), + ) + + ctx = _mk_ctx("sid", "sid") + ctx.engine = "codex" + ctx.space = "code" + ctx.sdk = ShiftedOfficialIdentitySdk() + ctx.sdk.machine = machine + ctx.sdk.ctx = ctx + machine.sessions[ctx.key] = ctx + + assert await machine._recover_codex_owned_turn(ctx, "sid") is True + assert ctx.sdk.recovered_stream_turn_ids == ("rollout-task",) + lease = machine._codex_turn_leases.get("sid") + assert lease is not None + assert [ + (binding.task_id, binding.native_message_id) + for binding in lease.stream_bindings + ] == [("rollout-task", "previous-official-user-item")] + assert not [ + event for event in transport.sent + if isinstance(event, (Error, TurnEnd)) + ] + + await machine._handle_interrupt(SimpleNamespace(sid="sid")) + task = ctx.codex_spontaneous_task + assert task is not None + await asyncio.wait_for(task, timeout=1.0) + + asyncio.run(go()) + + def test_restarted_wrapper_keeps_lease_when_stream_witness_is_incomplete( tmp_path, monkeypatch, ): @@ -1766,6 +1862,8 @@ async def go() -> None: ctx.engine = "codex" ctx.space = "code" ctx.sdk = _SharedSdk() + ctx.sdk.effort = "medium" + ctx.announced_effort = "high" ctx.state = "idle" ctx.codex_daemon_epoch = "a" * 32 machine.sessions[ctx.key] = ctx @@ -1800,6 +1898,11 @@ async def no_external(_sid): assert isinstance(result, StatusReport) assert ctx.sdk.reconnects == 1 assert ctx.codex_daemon_epoch == "b" * 32 + effort_events = [ + event for event in transport.sent if isinstance(event, Effort) + ] + assert [event.effort for event in effort_events] == ["medium"] + assert ctx.announced_effort == "medium" assert transport.sent[-1].to == "browser" assert transport.sent[-1].request_id == "usage-refresh" @@ -1847,6 +1950,135 @@ async def restart_state(*, wait, interrupt_event): asyncio.run(go()) +def test_generation_reconnect_drops_effort_resolved_after_eviction(monkeypatch): + async def go() -> None: + machine, transport = _mk_machine() + ctx = _mk_ctx("sid", "sid") + ctx.engine = "codex" + ctx.space = "code" + sdk = _EvictedDuringEffortPublishSdk() + ctx.sdk = sdk + ctx.announced_effort = "high" + ctx.codex_daemon_epoch = "a" * 32 + machine.sessions[ctx.key] = ctx + ready = CodexDaemonRestartState( + epoch="b" * 32, + phase="ready", + updated_at=1.0, + deadline_at=2.0, + ) + + async def restart_state(*, wait, interrupt_event): + assert wait is True + assert interrupt_event is ctx.interrupt_event + return ready + + monkeypatch.setattr(machine, "_codex_restart_state", restart_state) + reconnect = asyncio.create_task(machine._ensure_codex_daemon_generation( + ctx, reason="background status refresh")) + await asyncio.wait_for( + sdk.effort_resolution_started.wait(), timeout=1.0) + + # The proxy connected, then the status command lost its resident route + # while its nullable effort was still resolving from config/read. + assert machine.sessions.pop(ctx.key) is ctx + sdk.release_effort_resolution.set() + + assert await asyncio.wait_for(reconnect, timeout=1.0) is False + assert sdk.disconnects == 1 + assert sdk.live is False + assert ctx.codex_daemon_epoch == "a" * 32 + assert not any(isinstance(event, Effort) for event in transport.sent) + assert ctx.announced_effort == "high" + + asyncio.run(go()) + + +def test_generation_reconnect_drops_effort_when_evicted_at_emit_lock(monkeypatch): + async def go() -> None: + machine, transport = _mk_machine() + ctx = _mk_ctx("sid", "sid") + ctx.engine = "codex" + ctx.space = "code" + sdk = _EvictedDuringEffortPublishSdk() + sdk.live = True + sdk.display_effort = "medium" + sdk.display_effort_model = sdk.model + sdk.display_effort_cwd = os.path.realpath(sdk._cwd) + sdk.display_effort_generation = sdk._generation + ctx.sdk = sdk + ctx.announced_model = sdk.model + ctx.announced_effort = "high" + machine.sessions[ctx.key] = ctx + + # Hold the serialization boundary until publish has completed its first + # residency check. Eviction at this exact point must not append an old + # Effort frame to the detached replay ring. + await ctx.emit_lock.acquire() + publishing = asyncio.create_task( + machine._publish_codex_model_effort( + ctx, require_resident=True)) + for _ in range(20): + if getattr(ctx.emit_lock, "_waiters", None): + break + await asyncio.sleep(0) + assert getattr(ctx.emit_lock, "_waiters", None) + assert machine.sessions.pop(ctx.key) is ctx + ctx.emit_lock.release() + + assert await asyncio.wait_for(publishing, timeout=1.0) is False + assert not any(isinstance(event, Effort) for event in transport.sent) + assert ctx.announced_effort == "high" + + asyncio.run(go()) + + +def test_generation_reconnect_finishes_pair_then_fails_if_evicted_during_send( + monkeypatch, +): + async def go() -> None: + machine, transport = _mk_machine() + ctx = _mk_ctx("sid", "sid") + ctx.engine = "codex" + ctx.space = "code" + sdk = _EvictedDuringEffortPublishSdk() + sdk.live = True + sdk.display_effort = "medium" + sdk.display_effort_model = sdk.model + sdk.display_effort_cwd = os.path.realpath(sdk._cwd) + sdk.display_effort_generation = sdk._generation + ctx.sdk = sdk + ctx.announced_model = "gpt-old" + ctx.announced_effort = "high" + machine.sessions[ctx.key] = ctx + original_send = transport.send + evicted = False + + async def send(event): + nonlocal evicted + await original_send(event) + if isinstance(event, Model) and not evicted: + evicted = True + assert machine.sessions.pop(ctx.key) is ctx + + transport.send = send + published = await machine._publish_codex_model_effort( + ctx, require_resident=True) + + assert published is False + assert [ + (event.type, getattr(event, "model", None), + getattr(event, "effort", None)) + for event in transport.sent + if isinstance(event, (Model, Effort)) + ] == [ + ("model", sdk.model, None), + ("effort", None, "medium"), + ] + + asyncio.run(go()) + + def test_status_read_does_not_block_serial_commands(): async def go() -> None: machine, _transport = _mk_machine() diff --git a/tests/test_codex_spontaneous_stream.py b/tests/test_codex_spontaneous_stream.py index 9238c54..c3bcb12 100644 --- a/tests/test_codex_spontaneous_stream.py +++ b/tests/test_codex_spontaneous_stream.py @@ -38,6 +38,14 @@ def _notification(method: str, turn_id: str, **params): } +def _context_compaction(turn_id: str, item_id: str = "context-compaction"): + return _notification( + "item/completed", + turn_id, + item={"id": item_id, "type": "contextCompaction"}, + ) + + def _goal_notification( objective: str, *, @@ -325,6 +333,49 @@ async def run(): asyncio.run(run()) +def test_official_context_compaction_item_arms_managed_continuation(): + async def run(): + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = "managed-official-context-compaction" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + + compacted = _context_compaction(turn_id) + await handle._dispatch(compacted) + await handle._dispatch(_notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + )) + + fence = handle._managed_compaction_continuation + assert fence is not None + assert fence.awaiting_replacement is True + assert handle.turn_active is True + + continuation = _notification( + "item/completed", turn_id, + item={ + "id": "official-after-compaction", + "type": "agentMessage", + "text": "continued", + }, + ) + await handle._dispatch(continuation) + final = _notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "completed"}, + ) + await handle._dispatch(final) + + assert [item async for item in handle.receive_response()] == [ + compacted, continuation, final, + ] + + asyncio.run(run()) + + def test_same_native_turn_steer_item_confirms_compaction_continuation(): async def run(): handle = CodexHandle(_Cfg()) @@ -740,6 +791,9 @@ async def run(): }) assert machine._history_revision(ctx.session_id) != revision assert len(refreshes) == 1 + assert refreshes[0][1]["before"] is None + assert refreshes[0][1]["limit"] == machine.MIRROR_LIMIT + assert refreshes[0][1]["detail"] == "summary" assert ctx.codex_published_steers == { "remote-client-message": "remote-expected-turn", } @@ -1143,6 +1197,316 @@ async def run(): asyncio.run(run()) +def test_compaction_deadline_probe_keeps_exact_active_turn_open(monkeypatch): + async def run(): + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_GRACE_SECONDS", + 0.01, + ) + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = "managed-compact-active-probe" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + requests = [] + + async def request(method, params=None): + requests.append((method, params)) + if method == "thread/read": + return { + "thread": { + "id": "thread-spontaneous", + "status": {"type": "active", "activeFlags": []}, + }, + } + assert method == "thread/turns/list" + return { + "data": [{"id": turn_id, "status": "inProgress"}], + "nextCursor": None, + } + + handle._request = request + compacted = _context_compaction(turn_id) + await handle._dispatch(compacted) + await handle._dispatch(_notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + )) + fence = handle._managed_compaction_continuation + assert fence is not None + await asyncio.wait_for(fence.settled.wait(), timeout=0.2) + assert fence.awaiting_replacement is False + assert fence.suppressed_terminal is None + assert handle.turn_active is True + assert requests == [ + ("thread/read", { + "threadId": "thread-spontaneous", + "includeTurns": False, + }), + ("thread/turns/list", { + "threadId": "thread-spontaneous", + "cursor": None, + "limit": 1, + "sortDirection": "desc", + "itemsView": "notLoaded", + }), + ] + + continuation = _notification( + "item/completed", turn_id, + item={ + "id": "after-active-probe", + "type": "agentMessage", + "text": "continued after the five second boundary", + }, + ) + final = _notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "completed"}, + ) + await handle._dispatch(continuation) + await handle._dispatch(final) + assert [item async for item in handle.receive_response()] == [ + compacted, continuation, final, + ] + + asyncio.run(run()) + + +@pytest.mark.parametrize("probe_mode", [ + "idle", "different-turn", "timeout", "error", +]) +def test_compaction_deadline_probe_fails_closed(monkeypatch, probe_mode): + async def run(): + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_GRACE_SECONDS", + 0.01, + ) + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_PROBE_TIMEOUT_SECONDS", + 0.02, + ) + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = f"managed-compact-{probe_mode}" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + + async def request(method, params=None): + if probe_mode == "timeout": + await asyncio.Event().wait() + if probe_mode == "error": + raise RuntimeError("probe failed") + if method == "thread/read": + return { + "thread": { + "id": "thread-spontaneous", + "status": {"type": ( + "idle" if probe_mode == "idle" else "active" + )}, + }, + } + return { + "data": [{ + "id": "another-turn", + "status": "inProgress", + }], + } + + handle._request = request + compacted = _context_compaction(turn_id) + terminal = _notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + ) + await handle._dispatch(compacted) + await handle._dispatch(terminal) + assert await asyncio.wait_for( + _collect_managed_response(handle), timeout=0.3, + ) == [compacted, terminal] + assert handle.turn_active is False + assert handle.turn_id is None + + asyncio.run(run()) + + +def test_late_continuation_wins_while_compaction_probe_is_in_flight(monkeypatch): + async def run(): + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_GRACE_SECONDS", + 0.01, + ) + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = "managed-compact-late-during-probe" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + probe_started = asyncio.Event() + release_probe = asyncio.Event() + + async def request(method, params=None): + probe_started.set() + await release_probe.wait() + return { + "thread": { + "id": "thread-spontaneous", + "status": {"type": "idle"}, + }, + } + + handle._request = request + compacted = _context_compaction(turn_id) + await handle._dispatch(compacted) + await handle._dispatch(_notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + )) + await asyncio.wait_for(probe_started.wait(), timeout=0.2) + fence = handle._managed_compaction_continuation + assert fence is not None and fence.probing is True + + continuation = _notification( + "item/completed", turn_id, + item={ + "id": "late-continuation-during-probe", + "type": "agentMessage", + "text": "late continuation", + }, + ) + await handle._dispatch(continuation) + assert fence.awaiting_replacement is False + release_probe.set() + await asyncio.sleep(0) + final = _notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "completed"}, + ) + await handle._dispatch(final) + assert [item async for item in handle.receive_response()] == [ + compacted, continuation, final, + ] + + asyncio.run(run()) + + +def test_continuation_wins_after_final_compaction_probe_reply(monkeypatch): + async def run(): + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_GRACE_SECONDS", + 0.01, + ) + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = "managed-compact-final-probe-race" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + continuation = _notification( + "item/completed", turn_id, + item={ + "id": "continuation-at-final-probe-reply", + "type": "agentMessage", + "text": "continued", + }, + ) + + async def request(method, params=None): + if method == "thread/read": + return { + "thread": { + "id": "thread-spontaneous", + "status": {"type": "active"}, + }, + } + # Model the reader delivering a continuation immediately before it + # resolves the final RPC response to the detached probe task. + await handle._dispatch(continuation) + return {"data": [{"id": turn_id, "status": "completed"}]} + + handle._request = request + compacted = _context_compaction(turn_id) + await handle._dispatch(compacted) + await handle._dispatch(_notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + )) + fence = handle._managed_compaction_continuation + assert fence is not None + await asyncio.wait_for(fence.settled.wait(), timeout=0.2) + await asyncio.sleep(0) + + final = _notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "completed"}, + ) + await handle._dispatch(final) + assert [item async for item in handle.receive_response()] == [ + compacted, continuation, final, + ] + + asyncio.run(run()) + + +def test_compaction_probe_cannot_cross_generation_or_queue(monkeypatch): + async def run(stale_kind): + monkeypatch.setattr( + codex_handle_module, + "_COMPACTION_CONTINUATION_GRACE_SECONDS", + 0.01, + ) + handle = CodexHandle(_Cfg()) + handle.thread_id = "thread-spontaneous" + turn_id = f"managed-compact-stale-{stale_kind}" + handle.turn_id = turn_id + handle.turn_active = True + handle._open_managed_stream() + old_queue = handle._turn_q + probe_started = asyncio.Event() + release_probe = asyncio.Event() + + async def request(method, params=None): + probe_started.set() + await release_probe.wait() + if method == "thread/read": + return { + "thread": { + "id": "thread-spontaneous", + "status": {"type": "active"}, + }, + } + return {"data": [{"id": turn_id, "status": "inProgress"}]} + + handle._request = request + await handle._dispatch(_context_compaction(turn_id)) + await handle._dispatch(_notification( + "turn/completed", turn_id, + turn={"id": turn_id, "status": "interrupted"}, + )) + await asyncio.wait_for(probe_started.wait(), timeout=0.2) + if stale_kind == "generation": + handle._generation += 1 + else: + handle._open_managed_stream() + release_probe.set() + await asyncio.sleep(0.02) + assert handle._managed_compaction_continuation is None + # No stale terminal may be injected into a replacement queue. + assert handle._turn_q is not old_queue or stale_kind == "generation" + if handle._turn_q is not old_queue: + assert handle._turn_q.qsize() == 0 + + asyncio.run(run("generation")) + asyncio.run(run("queue")) + + def test_unconfirmed_replacement_start_is_replayed_after_compaction_timeout( monkeypatch, ): @@ -1672,6 +2036,152 @@ async def run(): asyncio.run(run()) +def test_machine_recovers_missed_cli_user_before_first_output(monkeypatch): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("thread-spontaneous", "thread-spontaneous") + ctx.engine = "codex" + handle = CodexHandle(machine.cfg) + handle.thread_id = ctx.session_id + handle.proc = SimpleNamespace(returncode=None) + ctx.sdk = handle + machine.sessions[ctx.key] = ctx + handle.turn_lifecycle_callback = ( + lambda phase, turn_id: machine._on_codex_turn_lifecycle( + ctx, phase, turn_id)) + recoveries = [] + refreshes = [] + + async def recover(sid, native_turn_id, visible_turn_id, user_index, + **kwargs): + recoveries.append(( + sid, + native_turn_id, + visible_turn_id, + user_index, + kwargs, + )) + return UserMsg( + msg_id="persisted-cli-user", + prompt="persisted CLI prompt", + ) + + monkeypatch.setattr( + machine, "_recover_official_codex_user", recover) + monkeypatch.setattr( + machine, + "_schedule_history_refresh", + lambda sid, **kwargs: refreshes.append((sid, kwargs)), + ) + + turn_id = "cli-user-notification-missed" + for message in [ + _notification("turn/started", turn_id, turn={"id": turn_id}), + # The wrapper attached after app-server had already emitted the + # userMessage item. Persisted rollout state still has the exact row. + _notification("item/completed", turn_id, item={ + "id": "cli-answer", "type": "agentMessage", + "text": "answer", "phase": "final_answer", + }), + _notification("turn/completed", turn_id, turn={ + "id": turn_id, "status": "completed", + }), + ]: + await handle._dispatch(message) + + task = ctx.codex_spontaneous_task + assert task is not None + await asyncio.wait_for(task, timeout=1) + + users = [event for event in transport.sent + if isinstance(event, UserMsg)] + assert [(event.msg_id, event.prompt) for event in users] == [ + ("persisted-cli-user", "persisted CLI prompt"), + ] + assert not any(event.prompt == "" for event in users) + assert [ + (event.msg_id, event.turn_id) + for event in transport.sent + if isinstance(event, TurnBinding) + ] == [("persisted-cli-user", turn_id)] + assert recoveries == [( + ctx.session_id, + turn_id, + turn_id, + 0, + { + "max_reverse_scan_bytes": ( + machine.CODEX_LIVE_USER_RECOVERY_SCAN_BYTES + ), + }, + )] + assert refreshes == [( + ctx.session_id, + { + "before": None, + "limit": machine.MIRROR_LIMIT, + "cwd": None, + "detail": "summary", + }, + )] + + asyncio.run(run()) + + +def test_machine_refreshes_history_after_missed_cli_user_recovery(monkeypatch): + async def run(): + machine, transport = _mk_machine() + ctx = _mk_ctx("thread-spontaneous", "thread-spontaneous") + ctx.engine = "codex" + turn_id = "cli-user-persisted-late" + ctx.codex_spontaneous_turn_id = turn_id + refreshes = [] + + class Sdk: + async def receive_spontaneous_response(self, requested_turn_id): + assert requested_turn_id == turn_id + yield _notification("item/completed", turn_id, item={ + "id": "cli-answer", "type": "agentMessage", + "text": "answer", "phase": "final_answer", + }) + yield _notification("turn/completed", turn_id, turn={ + "id": turn_id, "status": "completed", + }) + + async def recover(*_args, **_kwargs): + return None + + ctx.sdk = Sdk() + machine.sessions[ctx.key] = ctx + monkeypatch.setattr( + machine, "_recover_official_codex_user", recover) + monkeypatch.setattr( + machine, + "_schedule_history_refresh", + lambda sid, **kwargs: refreshes.append((sid, kwargs)), + ) + + await machine._run_codex_spontaneous_turn( + ctx, turn_id, announce_running=False) + + users = [event for event in transport.sent + if isinstance(event, UserMsg)] + assert [(event.msg_id, event.prompt) for event in users] == [ + (turn_id, ""), + ] + assert refreshes == [( + ctx.session_id, + { + "before": None, + "limit": machine.MIRROR_LIMIT, + "cwd": None, + "detail": "summary", + }, + )] + + asyncio.run(run()) + + def test_machine_projects_later_cli_user_item_as_turn_steer(): async def run(): machine, transport = _mk_machine() diff --git a/tests/test_codex_worktree_fork.py b/tests/test_codex_worktree_fork.py index 1b0d917..47c1a11 100644 --- a/tests/test_codex_worktree_fork.py +++ b/tests/test_codex_worktree_fork.py @@ -24,6 +24,30 @@ from tests.test_multisession import _mk_ctx, _mk_machine +@pytest.fixture(autouse=True) +def _profile_model_catalog(monkeypatch): + """Keep fork tests independent of the developer machine's live catalog.""" + async def catalog(*, codex_home=None): + return [ + { + "id": "gpt-test", + "efforts": ["high"], + "default_effort": "high", + "is_default": True, + }, + { + "id": "gpt-changed", + "efforts": ["high"], + "default_effort": "high", + "is_default": False, + }, + ] + + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr( + machine_module, "codex_current_provider", lambda **_kwargs: "") + + def _ctx(state: str = "idle"): ctx = _mk_ctx("parent", "parent") ctx.engine = "codex" @@ -118,6 +142,7 @@ async def list_sessions(_cmd): assert result.session_id == "forked-thread" assert [message.type for message in transport.sent] == [ + "session_list_invalidated", "session_forked", ] @@ -165,6 +190,7 @@ async def list_sessions(_cmd): assert result.session_id == "forked-thread" assert [message.type for message in transport.sent] == [ + "session_list_invalidated", "session_forked", ] @@ -496,6 +522,46 @@ async def list_sessions(_cmd): return None asyncio.run(run()) +def test_codex_fork_replaces_a_retired_parent_model(monkeypatch): + async def run(): + machine, _ = _mk_machine() + parent = _ctx() + parent.sdk.model = "retired-model" + machine.sessions = {"parent": parent} + calls = [] + + async def catalog(*, codex_home=None): + return [{ + "id": "current-default", + "efforts": ["low"], + "default_effort": "low", + "is_default": True, + }] + + async def rpc(method, params, cwd=None): + calls.append((method, params, cwd)) + return {"thread": {"id": "forked-thread"}} + + async def is_codex(_sid): return True + async def list_sessions(_cmd): return None + monkeypatch.setattr(machine, "_is_codex_session", is_codex) + monkeypatch.setattr(machine, "_list_codex_sessions", list_sessions) + monkeypatch.setattr(machine_module, "codex_catalog", catalog) + monkeypatch.setattr(machine_module, "codex_rpc", rpc) + monkeypatch.setattr( + machine_module, "find_rollout_fork", lambda *_args: None) + + result = await machine._handle_fork_session( + _command(last_turn_id="turn-2")) + + assert result.session_id == "forked-thread" + assert calls[0][1]["model"] == "current-default" + assert machine._codex_forks.entries[ + "request-1"]["controls"]["model"] == "current-default" + + asyncio.run(run()) + + def test_codex_same_cwd_fork_does_not_flatten_granular_approval(monkeypatch): async def run(): machine, _ = _mk_machine() @@ -953,6 +1019,10 @@ async def list_sessions(_cmd): return None assert result.session_id == "forked-thread" assert result.request_id == "request-1" assert result.to == "client-1" + assert any( + message.type == "session_list_invalidated" + for message in transport.sent + ) assert transport.sent[-1] is result duplicate = await machine._handle_fork_session_worktree(_command()) assert duplicate.session_id == "forked-thread" diff --git a/tests/test_command_reliability.py b/tests/test_command_reliability.py index 89692b5..4975a92 100644 --- a/tests/test_command_reliability.py +++ b/tests/test_command_reliability.py @@ -37,6 +37,7 @@ SessionList, StateEvent, SwitchSession, + ForkSession, Takeover, TakeoverState, UserMsg, @@ -1048,6 +1049,87 @@ async def listed(_cmd): asyncio.run(run()) +def test_deleted_claude_fork_retry_only_acks_without_resurrecting(monkeypatch): + async def run(): + machine, transport = _mk_machine() + parent = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + cutoff = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + child = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + cwd = "/repo/component" + machine._claude_forks.begin( + "fork-request", parent, cutoff, cwd) + machine._claude_forks.claim_submission("fork-request") + machine._claude_forks.complete("fork-request", child) + forked = machine_module.SessionForked( + parent_session_id=parent, + session_id=child, + cwd=cwd, + target="same_cwd", + last_turn_id=cutoff, + request_id="fork-request", + to="client-1", + ) + machine._remember_command("client-1", "fork-cmd", (forked,)) + machine._claude_forks.begin_delete(child) + machine._claude_forks.finish_delete(child) + + async def not_codex(_sid): + return False + + machine._is_codex_session = not_codex + await machine._process_command(ForkSession( + session_id=parent, + request_id="fork-request", + last_turn_id=cutoff, + cmd_id="fork-cmd", + client_id="client-1", + )) + + assert [message.type for message in transport.sent] == ["command_ack"] + + asyncio.run(run()) + + +def test_failed_claude_fork_delete_restores_complete_journal(monkeypatch): + async def run(): + machine, transport = _mk_machine() + child = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + parent = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + cutoff = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + machine._claude_forks.begin( + "fork-request", parent, cutoff, "/repo/component") + machine._claude_forks.claim_submission("fork-request") + machine._claude_forks.complete("fork-request", child) + + async def not_codex(_sid): + return False + + machine._is_codex_session = not_codex + monkeypatch.setattr( + machine_module, "get_session_info", + lambda _sid: SimpleNamespace(cwd="/repo/component"), + ) + monkeypatch.setattr( + machine_module, "delete_session", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("delete failed")), + ) + + result = await machine._handle_delete_session(DeleteSession( + session_id=child, + engine="claude", + space="code", + cmd_id="delete-child", + client_id="client-1", + )) + + assert isinstance(result, Error) + assert machine._claude_forks.child_entry(child)["status"] == "complete" + assert transport.sent[-1] is result + + asyncio.run(run()) + + def test_cwdless_claude_delete_still_rejects_unknown_transcript(monkeypatch): async def run(): machine, transport = _mk_machine() diff --git a/tests/test_deploy.py b/tests/test_deploy.py index cc3ac87..a30e35b 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -661,7 +661,7 @@ def test_setup_protocol_gate_has_no_release_specific_literal(): assert not re.search(r'"protocol"[^\n]*[0-9]+', source) -def test_release_docs_and_examples_describe_one_atomic_v34_layout(): +def test_release_docs_and_examples_describe_one_atomic_v35_layout(): deploy_readme = (ROOT / "deploy" / "README.md").read_text() readme = (ROOT / "README.md").read_text() readme_en = (ROOT / "README_en.md").read_text() @@ -674,10 +674,11 @@ def test_release_docs_and_examples_describe_one_atomic_v34_layout(): relay_env = (ROOT / "deploy" / "env.relay.example").read_text() unit = (ROOT / "deploy" / "cc-remote-relay.service").read_text() - assert "Protocol v34" in deploy_readme + assert "Protocol v35" in deploy_readme + assert "v34 Codex ownership backfill" in deploy_readme assert "v14" not in deploy_readme for document in (deploy_readme, readme, readme_en): - assert "v34" in document + assert "v35" in document assert "v16" not in document assert "v18" not in document assert "sudo rsync -a --delete" not in document @@ -714,7 +715,7 @@ def test_release_docs_and_examples_describe_one_atomic_v34_layout(): assert "WorkingDirectory=/opt/cc-remote/current" in unit assert "ExecStart=/opt/cc-remote/current/.venv/bin/python" in unit assert "claude-agent-sdk==0.2.128" in claude - assert "protocol v34" in claude + assert "protocol v35" in claude assert "0.2.110" not in claude assert "protocol v10" not in claude diff --git a/tests/test_history.py b/tests/test_history.py index 25e3bf5..9b6aaf9 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -27,11 +27,15 @@ ) from cc_remote.protocol import ( + MAX_SAFE_WIRE_INTEGER, serialize, deserialize, - GetHistory, GetHistoryImage, GetTurnDetail, History, HistoryImage, + CodexTerminalFence, GetHistory, GetHistoryImage, GetTurnDetail, History, + HistoryImage, TurnDetail, HistoryInvalidated, UserMsg, TurnSteered, AssistantMsgStart, AssistantMsgEnd, Delta, + ToolUse, ToolResult, ProcessEvent, TurnPlan, TurnBinding, TurnEnd, TurnResult, Error, + Model, Effort, is_downstream, ) from cc_remote.wrapper import machine as mm @@ -66,6 +70,220 @@ from tests.test_multisession import _mk_machine, _mk_ctx +def test_live_codex_terminal_is_available_to_stale_history_immediately( + tmp_path, +): + rollout = tmp_path / "terminal-rollout.jsonl" + rollout.write_text( + '{"type":"session_meta","payload":{"id":"terminal-session"}}\n') + + async def run(): + machine, transport = _mk_machine() + sid = "terminal-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + compaction_continuation_turn_ids=frozenset()) + machine.sessions[sid] = ctx + source_stat = rollout.stat() + machine._watch[sid] = { + "path": str(rollout), + "file_id": (source_stat.st_dev, source_stat.st_ino), + "size": source_stat.st_size, + "engine": "codex", + } + machine._codex_rollout_for_wire = lambda _sid: str(rollout) + + terminal = TurnEnd( + result=TurnResult( + subtype="success", duration_ms=1200, is_error=False), + turn_id="native-terminal-turn", + ) + terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, terminal) + immediate = machine._codex_terminal_ledger.snapshot( + sid, + rollout, + revision=machine._history_revision(sid), + ) + assert immediate == (CodexTerminalFence( + turn_id="native-terminal-turn", + status="completed", + duration_ms=1200, + completed_at=transport.sent[-1].ts, + ),) + + async def stale_official_history(_sid, *, before, limit): + assert before is None and limit == 4 + return History( + session_id=sid, + revision=machine._history_revision(sid), + generation=machine.instance_id, + build_seq=1, + live_seq=0, + authoritative=False, + error="stale projection", + events=[], + turns=[], + detail="summary", + has_more=False, + in_progress=True, + ) + + machine._build_official_codex_history = stale_official_history + history = await machine._build_requested_history( + sid, + before=None, + limit=4, + cwd=ctx.cwd, + detail="summary", + ) + assert history.authoritative is False + assert history.terminal_fences == list(immediate) + + # A local transport/drain failure still closes the live UI, but is not + # an engine-owned fact and must not poison a later History response. + await machine._emit_locked(ctx, TurnEnd( + result=TurnResult( + subtype="error", duration_ms=0, is_error=True), + turn_id="synthetic-local-failure", + )) + assert machine._codex_terminal_ledger.snapshot( + sid, + rollout, + revision=machine._history_revision(sid), + ) == immediate + + unsafe_terminal = TurnEnd( + result=TurnResult( + subtype="success", + duration_ms=MAX_SAFE_WIRE_INTEGER + 1, + is_error=False, + ), + turn_id="unsafe-duration-terminal", + ) + unsafe_terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, unsafe_terminal) + assert transport.sent[-1].turn_id == "unsafe-duration-terminal" + assert machine._codex_terminal_ledger.snapshot( + sid, + rollout, + revision=machine._history_revision(sid), + ) == immediate + + def fail_recovery(*_args): + raise RuntimeError("optional ledger failure") + + machine._remember_codex_terminal_event = fail_recovery + fallback_terminal = TurnEnd( + result=TurnResult( + subtype="success", duration_ms=1, is_error=False), + turn_id="live-terminal-survives-ledger-failure", + ) + fallback_terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, fallback_terminal) + assert transport.sent[-1].turn_id == fallback_terminal.turn_id + + tasks = list(machine._codex_terminal_persist_tasks) + if tasks: + await asyncio.gather(*tasks) + + asyncio.run(run()) + + +def test_codex_terminal_provenance_never_crosses_the_wire(): + terminal = TurnEnd( + result=TurnResult( + subtype="success", duration_ms=1, is_error=False), + turn_id="native-turn", + ) + terminal._codex_authoritative_terminal = True + + encoded = serialize(terminal) + restored = deserialize(encoded) + + assert "_codex_authoritative_terminal" not in encoded + assert isinstance(restored, TurnEnd) + assert restored._codex_authoritative_terminal is False + + +def test_unbound_live_codex_terminal_is_visible_in_same_revision(): + async def run(): + machine, _transport = _mk_machine() + sid = "unbound-terminal-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + compaction_continuation_turn_ids=frozenset()) + machine.sessions[sid] = ctx + machine._codex_rollout_for_wire = lambda _sid: None + + terminal = TurnEnd( + result=TurnResult( + subtype="success", duration_ms=15, is_error=False), + turn_id="unbound-native-turn", + ) + terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, terminal) + + assert await machine._codex_terminal_snapshot( + sid, machine._history_revision(sid), + ) == [CodexTerminalFence( + turn_id="unbound-native-turn", + status="completed", + duration_ms=15, + completed_at=terminal.ts, + )] + assert not machine._codex_terminal_persist_tasks + + asyncio.run(run()) + + +def test_compaction_interrupted_terminal_is_not_persisted_as_completion( + tmp_path, +): + rollout = tmp_path / "compact-rollout.jsonl" + rollout.write_text( + '{"type":"session_meta","payload":{"id":"compact-session"}}\n') + + async def run(): + machine, _transport = _mk_machine() + sid = "compact-session" + ctx = _mk_ctx(sid, sid) + ctx.engine = "codex" + ctx.sdk = SimpleNamespace( + compaction_continuation_turn_ids=frozenset({"compact-turn"})) + machine.sessions[sid] = ctx + source_stat = rollout.stat() + machine._watch[sid] = { + "path": str(rollout), + "file_id": (source_stat.st_dev, source_stat.st_ino), + "size": source_stat.st_size, + "engine": "codex", + } + machine._codex_rollout_for_wire = lambda _sid: str(rollout) + + terminal = TurnEnd( + result=TurnResult( + subtype="error_during_execution", + duration_ms=0, + is_error=True, + ), + turn_id="compact-turn", + ) + terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, terminal) + + assert machine._codex_terminal_ledger.snapshot( + sid, + rollout, + revision=machine._history_revision(sid), + ) == () + assert not machine._codex_terminal_persist_tasks + + asyncio.run(run()) + + def _write_projection_rollout(path, native_turn_ids: list[str]) -> None: rows: list[dict] = [{ "type": "session_meta", @@ -1224,8 +1442,8 @@ async def run(): asyncio.run(run()) -def test_codex_plan_snapshot_survives_native_summary_omission(): - """A fresh browser recovers a live plan absent from app-server history.""" +def test_codex_plan_snapshot_recovers_as_settled_after_native_terminal(): + """A stale Plan remains auditable without impersonating active work.""" events = ( UserMsg( sid="plan-summary", msg_id="user-1", prompt="implement", @@ -1255,6 +1473,15 @@ async def run(): ctx.engine = "codex" machine.sessions[ctx.key] = ctx + async def terminal_snapshot(_sid, _revision): + return (CodexTerminalFence( + turn_id="native-1", + status="completed", + duration_ms=1000, + ),) + + machine._codex_terminal_snapshot = terminal_snapshot + await machine._emit_locked(ctx, TurnPlan( item_id="plan:native-1", turn_id="native-1", @@ -1288,13 +1515,18 @@ async def run(): assert plans[0]["item_id"] == "plan:native-1" assert plans[0]["plan"][1] == { "step": "implement", "status": "inProgress"} + assert plans[0]["done"] is True + assert plans[0]["status"] == "succeeded" + repaired = machine._session_plans.get("plan-summary") + assert repaired is not None + assert repaired.terminal_status == "succeeded" assert history.to == "client-1" assert transport.sent[-1] is history asyncio.run(run()) -def test_codex_next_user_boundary_retires_only_completed_plan_snapshot(): +def test_codex_next_user_boundary_retires_only_settled_plan_snapshot(): async def run(): machine, _transport = _mk_machine() ctx = _mk_ctx("plan-lifecycle", "plan-lifecycle") @@ -1333,10 +1565,33 @@ async def run(): {"step": "fix", "status": "inProgress"}, ], )) - await machine._emit_locked(ctx, UserMsg( - msg_id="turn-4", prompt="clarification")) + await machine._emit_locked(ctx, TurnEnd( + turn_id="turn-3", + result=TurnResult( + subtype="steered", duration_ms=0, is_error=False), + )) + steered = machine._session_plans.get("plan-lifecycle") + assert steered is not None + assert steered.terminal_status is None + await machine._emit_locked(ctx, TurnSteered( + msg_id="turn-4", turn_id="turn-3", prompt="clarification")) assert machine._session_plans.get("plan-lifecycle") is not None + authoritative_terminal = TurnEnd( + turn_id="turn-3", + result=TurnResult( + subtype="success", duration_ms=1000, is_error=False), + ) + authoritative_terminal._codex_authoritative_terminal = True + await machine._emit_locked(ctx, authoritative_terminal) + terminal = machine._session_plans.get("plan-lifecycle") + assert terminal is not None + assert terminal.terminal_status == "succeeded" + assert terminal.complete is False + await machine._emit_locked(ctx, UserMsg( + msg_id="turn-4b", prompt="new task after terminal")) + assert machine._session_plans.get("plan-lifecycle") is None + await machine._emit_locked(ctx, TurnPlan( item_id="plan:turn-5", turn_id="turn-5", @@ -1350,6 +1605,92 @@ async def run(): asyncio.run(run()) +def test_codex_synthetic_terminal_cannot_poison_durable_plan_status(): + async def run(): + machine, _transport = _mk_machine() + ctx = _mk_ctx("plan-terminal-authority", "plan-terminal-authority") + ctx.engine = "codex" + machine.sessions[ctx.key] = ctx + + await machine._emit_locked(ctx, TurnPlan( + item_id="plan:native-turn", + turn_id="native-turn", + explanation="still working", + plan=[ + {"step": "inspect", "status": "completed"}, + {"step": "finish", "status": "inProgress"}, + ], + )) + + synthetic = TurnEnd( + turn_id="native-turn", + result=TurnResult( + subtype="error", duration_ms=0, is_error=True), + ) + assert synthetic._codex_authoritative_terminal is False + await machine._emit_locked(ctx, synthetic) + after_synthetic = machine._session_plans.get( + "plan-terminal-authority") + assert after_synthetic is not None + assert after_synthetic.terminal_status is None + + authoritative = TurnEnd( + turn_id="native-turn", + result=TurnResult( + subtype="success", duration_ms=1000, is_error=False), + ) + authoritative._codex_authoritative_terminal = True + await machine._emit_locked(ctx, authoritative) + after_authoritative = machine._session_plans.get( + "plan-terminal-authority") + assert after_authoritative is not None + assert after_authoritative.terminal_status == "succeeded" + + asyncio.run(run()) + + +def test_materialized_steer_keeps_plan_open_until_exact_terminal(): + def projection(subtype: str): + return materialize_history_turns([ + {"type": "user_msg", "msg_id": "user-1", "prompt": "work"}, + { + "type": "turn_plan", + "item_id": "plan:turn-1", + "turn_id": "turn-1", + "plan": [ + {"step": "inspect", "status": "completed"}, + {"step": "fix", "status": "inProgress"}, + ], + }, + { + "type": "turn_end", + "turn_id": "turn-1", + "result": { + "subtype": subtype, + "duration_ms": 0, + "is_error": False, + }, + }, + ], include_live_detail=True) + + steered = projection("steered")[0] + steered_plan = next( + block for block in steered["blocks"] + if block.get("processKind") == "plan" + ) + assert steered["done"] is True + assert steered_plan["done"] is False + assert steered_plan["status"] == "running" + + completed = projection("success")[0] + completed_plan = next( + block for block in completed["blocks"] + if block.get("processKind") == "plan" + ) + assert completed_plan["done"] is True + assert completed_plan["status"] == "succeeded" + + @pytest.mark.parametrize("plan_turn_id", ["native-1", None]) def test_completed_codex_plan_is_not_rebound_without_matching_owner( plan_turn_id, @@ -1406,6 +1747,60 @@ async def run(): asyncio.run(run()) +def test_incomplete_codex_plan_is_not_rebound_without_matching_owner(): + events = ( + UserMsg( + sid="stale-active-plan", msg_id="user-2", prompt="next task", + ).model_dump(mode="json"), + TurnEnd( + sid="stale-active-plan", + turn_id="native-2", + result=TurnResult( + subtype="success", duration_ms=1000, is_error=False), + ).model_dump(mode="json"), + ) + + class Official: + async def summary_page(self, _sid, **_kwargs): + return CodexHistoryPage( + events=events, + turns=materialize_history_turns(events), + has_more=True, + oldest_id="user-2", + newest_id="user-2", + ) + + async def run(): + machine, _transport = _mk_machine() + machine._codex_history = Official() + ctx = _mk_ctx("stale-active-plan", "stale-active-plan") + ctx.engine = "codex" + machine.sessions[ctx.key] = ctx + machine._session_plans.put("stale-active-plan", TurnPlan( + item_id="plan:native-1", + turn_id="native-1", + explanation="old unfinished task", + plan=[{"step": "old", "status": "inProgress"}], + )) + + history = await machine._handle_get_history(SimpleNamespace( + session_id="stale-active-plan", + client_id="client-1", + before=None, + limit=4, + cwd=ctx.cwd, + detail="summary", + )) + assert all( + block.get("processKind") != "plan" + for turn in history.turns + for block in turn.blocks + ) + assert machine._session_plans.get("stale-active-plan") is not None + + asyncio.run(run()) + + def test_requested_codex_summary_binds_exact_active_native_turn_ids( monkeypatch, tmp_path, ): @@ -1556,12 +1951,20 @@ def test_requested_codex_summary_falls_back_only_for_unsupported_capability( mm, "codex_rollout_path", lambda _sid: str(rollout)) class Unsupported: + def __init__(self): + self.calls = 0 + async def summary_page(self, *_args, **_kwargs): + self.calls += 1 raise CodexHistoryUnsupported("old app-server") + def invalidate_thread(self, _sid): + return None + async def run(): machine, _transport = _mk_machine() - machine._codex_history = Unsupported() + official = Unsupported() + machine._codex_history = official ctx = _mk_ctx("unsupported-summary", "unsupported-summary") ctx.engine = "codex" machine.sessions[ctx.key] = ctx @@ -1585,6 +1988,23 @@ async def fallback(*args, **kwargs): ) assert history.error is None assert len(fallback_calls) == 1 + assert official.calls == 1 + assert machine._codex_rollout_history_active( + "unsupported-summary") is True + + # The first rollout page owns its stable turn-id cursor. Every older + # summary page in the same revision must remain on rollout instead of + # handing that id to the official reader's opaque cursor table. + await machine._build_requested_history( + "unsupported-summary", + before="rollout-turn-id", + limit=12, + cwd=None, + detail="summary", + ) + assert len(fallback_calls) == 2 + assert fallback_calls[-1][1]["before"] == "rollout-turn-id" + assert official.calls == 1 asyncio.run(run()) @@ -3199,6 +3619,7 @@ async def go(): oldest_id="old", newest_id="old", turns=materialize_history_turns(events), + in_progress=True, ) machine._history_index.put_page( "codex-fast", "codex", old_source, @@ -3233,6 +3654,70 @@ async def summary_page(self, *_args, **_kwargs): asyncio.run(go()) +def test_active_codex_cache_is_bound_to_exact_continuation_tasks( + monkeypatch, tmp_path): + rollout = tmp_path / "active-cache.jsonl" + rollout.write_text( + '{"type":"session_meta","payload":{"id":"active-cache"}}\n') + monkeypatch.setattr(mm, "codex_rollout_path", lambda _sid: str(rollout)) + translated = [] + + def translate(*_args, **kwargs): + translated.append(kwargs) + return [], None + + monkeypatch.setattr(mm, "codex_translate_history", translate) + + async def go(): + machine, _ = _mk_machine() + machine._history_index = HistoryIndexStore( + tmp_path / "state-active-cache") + ctx = _mk_ctx("active-cache", "active-cache") + ctx.engine = "codex" + ctx.state = "running" + ctx.sdk = SimpleNamespace( + turn_id="new-active-task", + compaction_continuation_turn_ids=frozenset({"new-active-task"}), + ) + machine.sessions[ctx.key] = ctx + source = HistorySourceFingerprint.capture(rollout) + machine._history_index.put_page( + "active-cache", + "codex", + source, + before=None, + limit=4, + page=MaterializedHistoryPage( + events=(), + has_more=False, + oldest_id=None, + newest_id=None, + turns=(), + in_progress=True, + active_task_ids=("old-active-task",), + ), + ) + + history = await machine._build_history( + "active-cache", + before=None, + limit=4, + detail="summary", + allow_stale=True, + ) + + assert history.in_progress is True + assert translated + assert translated[0]["active_task_ids"] == ("new-active-task",) + rebuilt = machine._history_index.get_page( + "active-cache", "codex", source, before=None, limit=4) + assert rebuilt is not None + assert rebuilt.in_progress is True + assert rebuilt.active_task_ids == ("new-active-task",) + + asyncio.run(go()) + + def test_exact_history_cache_hit_that_grows_before_send_is_provisional( monkeypatch, tmp_path): rollout = tmp_path / "cache-race-rollout.jsonl" @@ -4287,6 +4772,41 @@ async def build(sid, **_kwargs): asyncio.run(go()) +def test_newest_background_history_refresh_defaults_to_moving_head(monkeypatch): + async def go(): + machine, transport = _mk_machine() + builds = [] + + async def build(sid, **kwargs): + builds.append((sid, kwargs)) + return History( + session_id=sid, + revision="bounded-refresh", + events=[], + turns=[], + detail="summary", + has_more=True, + ) + + monkeypatch.setattr(machine, "_build_history", build) + machine._schedule_history_refresh( + "bounded-refresh", + before=None, + limit=None, + cwd=None, + detail="summary", + ) + await asyncio.gather(*list(machine._history_refresh_tasks.values())) + + assert len(builds) == 1 + assert builds[0][1]["limit"] == machine.MIRROR_LIMIT + assert len([ + row for row in transport.sent if isinstance(row, History) + ]) == 1 + + asyncio.run(go()) + + def test_codex_history_refresh_coalesces_cwd_hints_and_rate_limits_rescan( monkeypatch): async def go(): @@ -4681,6 +5201,13 @@ async def go(): ctx = _mk_ctx("external-codex", "external-codex") ctx.engine = "codex" ctx.state = "idle" + ctx.sdk = SimpleNamespace( + turn_id="continuation-6", + compaction_continuation_turn_ids=frozenset({ + "continuation-1", "continuation-2", "continuation-3", + "continuation-4", "continuation-5", "continuation-6", + }), + ) machine.sessions[ctx.key] = ctx machine._watch["external-codex"] = { "engine": "codex", @@ -4689,10 +5216,19 @@ async def go(): "takeover_pending": None, } - await machine._build_history("external-codex", limit=20) + history = await machine._build_history("external-codex", limit=20) assert translate_kwargs assert translate_kwargs[0]["snapshot_in_progress"] is True + assert translate_kwargs[0]["active_task_ids"] == ( + "continuation-1", "continuation-2", "continuation-3", + "continuation-4", "continuation-5", "continuation-6", + "native-turn", + ) + assert history.compaction_continuation_turn_ids == [ + "continuation-6", "continuation-1", + "continuation-2", "continuation-3", + ] asyncio.run(go()) @@ -4770,6 +5306,61 @@ async def go(): asyncio.run(go()) +def test_official_codex_history_captures_live_seq_before_async_read(): + """An old idle page must not outrank a first-turn running event.""" + entered = asyncio.Event() + release = asyncio.Event() + calls = 0 + + class Official: + async def summary_page(self, _sid, **_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + entered.set() + await release.wait() + return CodexHistoryPage( + events=(), + turns=(), + has_more=False, + oldest_id=None, + newest_id=None, + ) + + async def go(): + machine, _ = _mk_machine() + machine._codex_history = Official() + machine._codex_rollout_for_wire = lambda _sid: None + ctx = _mk_ctx("official-seq-race", "official-seq-race") + ctx.engine = "codex" + ctx.state = "idle" + ctx.seq = 7 + machine.sessions[ctx.key] = ctx + + older_task = asyncio.create_task( + machine._build_official_codex_history( + "official-seq-race", before=None, limit=4, + ) + ) + await asyncio.wait_for(entered.wait(), timeout=2) + + ctx.state = "running" + ctx.seq = 8 + newer = await machine._build_official_codex_history( + "official-seq-race", before=None, limit=4, + ) + release.set() + older = await older_task + + assert older.build_seq < newer.build_seq + assert older.in_progress is False + assert older.live_seq == 7 + assert newer.in_progress is True + assert newer.live_seq == 8 + + asyncio.run(go()) + + def test_hello_sends_snapshots_and_control_state_without_replay_flood(): """Hello sends one snapshot plus authoritative control state per resident session, but no buffered narrative replay.""" @@ -4848,6 +5439,8 @@ async def go(): def test_get_history_returns_one_bulk_frame(monkeypatch): canned = [ UserMsg(msg_id="u1", prompt="hi"), + Model(model="claude-old-model"), + Effort(effort="low"), AssistantMsgStart(message_id="a1"), Delta(message_id="a1", text="hello"), TurnEnd(result=TurnResult(subtype="success", duration_ms=0, is_error=False)), @@ -4872,9 +5465,14 @@ async def go(): assert hist.has_more is False assert hist.in_progress is True assert hist.oldest_id == "u1" and hist.newest_id == "u1" - # Current model + effort precede the translated transcript narrative. - assert [(event["type"], event.get("model") or event.get("effort")) - for event in hist.events[:2]] == [ + # The newest page exposes one authoritative control pair. Older + # transcript control rows cannot override the current resident values. + controls = [ + (event["type"], event.get("model") or event.get("effort")) + for event in hist.events + if event["type"] in {"model", "effort"} + ] + assert controls == [ ("model", "claude-opus-4-8"), ("effort", "max")] assert [e["type"] for e in hist.events[2:]] == [ "user_msg", "assistant_msg_start", "delta", "turn_end"] @@ -4883,6 +5481,167 @@ async def go(): asyncio.run(go()) +def test_history_current_controls_match_cache_and_non_cache_paths( + monkeypatch, tmp_path): + transcript = tmp_path / "current-controls.jsonl" + transcript.write_text("{}\n", encoding="utf-8") + canned = [ + UserMsg(msg_id="u-current", prompt="hi"), + Model(model="claude-old-model"), + Effort(effort="low"), + Delta(message_id="a-current", text="hello"), + TurnEnd(result=TurnResult( + subtype="success", duration_ms=0, is_error=False)), + ] + monkeypatch.setattr(mm, "transcript_path", lambda _sid: str(transcript)) + monkeypatch.setattr( + mm, "get_session_messages", lambda *_args, **_kwargs: ["message"]) + monkeypatch.setattr( + mm, "translate_history", + lambda *_args, **_kwargs: [event.model_copy() for event in canned], + ) + monkeypatch.setattr(mm, "last_assistant_model", lambda _msgs: None) + + async def go(): + machine, _ = _mk_machine() + machine._history_index = HistoryIndexStore(tmp_path / "state") + ctx = _mk_ctx("controls", "controls") + ctx.engine = "claude" + ctx.sdk = SimpleNamespace(model="claude-current", effort="max") + machine.sessions[ctx.key] = ctx + + first = await machine._build_history( + "controls", limit=4, detail="full") + cached = await machine._build_history( + "controls", limit=4, detail="full") + + def controls(history): + return [ + (row["type"], row.get("model") or row.get("effort")) + for row in history.events + if row["type"] in {"model", "effort"} + ] + + assert controls(first) == [ + ("model", "claude-current"), ("effort", "max")] + assert controls(cached) == controls(first) + assert [row["type"] for row in cached.events[2:]] == [ + "user_msg", "delta", "turn_end"] + + asyncio.run(go()) + + +def test_codex_nullable_and_btw_history_controls_are_session_scoped(): + def controls(history): + return [ + (row["type"], row.get("model") or row.get("effort")) + for row in history.events + if row["type"] in {"model", "effort"} + ] + + async def go(): + machine, _ = _mk_machine() + main = _mk_ctx("main", "main") + main.engine = "codex" + main.sdk = SimpleNamespace( + model="gpt-main", + effort=None, + display_effort=None, + display_effort_model=None, + display_effort_cwd=None, + display_effort_generation=None, + _cwd=main.cwd, + _generation=1, + ) + machine.sessions[main.key] = main + main_history = History( + session_id="main", + revision="main-r1", + events=mm._history_control_rows("main", main), + ) + assert controls(main_history) == [ + ("model", "gpt-main"), + ("effort", mm.MODEL_DEFAULT_EFFORT), + ] + + btw = _mk_ctx("btw-main", "btw-main") + btw.engine = "codex" + btw.btw = True + btw.parent_sid = "main" + btw.sdk = SimpleNamespace(model="gpt-main", effort="low") + machine.sessions[btw.key] = btw + btw_history = History( + session_id="btw-main", + revision="btw-r1", + events=mm._history_control_rows("btw-main", btw), + ) + assert controls(btw_history) == [ + ("model", "gpt-main"), ("effort", "low")] + + machine.sessions.pop(btw.key) + main_after_close = History( + session_id="main", + revision="main-r1", + events=mm._history_control_rows("main", main), + ) + assert controls(main_after_close) == controls(main_history) + + asyncio.run(go()) + + +def test_history_control_overlay_keeps_only_newest_missing_source_kind(): + rows = [ + {"type": "model", "model": "gpt-old", "sid": "cold"}, + {"type": "effort", "effort": "low", "sid": "cold"}, + {"type": "model", "model": "gpt-new", "sid": "cold"}, + {"type": "effort", "effort": "high", "sid": "cold"}, + {"type": "user_msg", "msg_id": "u1", "prompt": "hi"}, + ] + normalized = mm._replace_history_control_rows(rows, []) + assert [ + (row["type"], row.get("model") or row.get("effort")) + for row in normalized[:2] + ] == [("model", "gpt-new"), ("effort", "high")] + assert [row["type"] for row in normalized[2:]] == ["user_msg"] + + +def test_cached_pagination_strips_legacy_control_rows(monkeypatch, tmp_path): + transcript = tmp_path / "legacy-controls.jsonl" + transcript.write_text("{}\n", encoding="utf-8") + monkeypatch.setattr(mm, "transcript_path", lambda _sid: str(transcript)) + + async def go(): + machine, _ = _mk_machine() + machine._history_index = HistoryIndexStore(tmp_path / "state") + source = HistorySourceFingerprint.capture(transcript) + rows = ( + {"type": "model", "model": "claude-old", "sid": "legacy"}, + {"type": "effort", "effort": "low", "sid": "legacy"}, + {"type": "user_msg", "msg_id": "u-old", "prompt": "old"}, + {"type": "turn_end", "result": { + "subtype": "success", "duration_ms": 0, "is_error": False, + }}, + ) + machine._history_index.put_page( + "legacy", "claude", source, + before="u-new", limit=4, + page=MaterializedHistoryPage( + events=rows, + has_more=False, + oldest_id="u-old", + newest_id="u-old", + turns=materialize_history_turns(rows), + ), + ) + + history = await machine._build_history( + "legacy", before="u-new", limit=4, detail="full") + assert [row["type"] for row in history.events] == [ + "user_msg", "turn_end"] + + asyncio.run(go()) + + def test_oversized_single_turn_wire_compaction_keeps_source_complete_detail( monkeypatch, tmp_path): canned = [ @@ -4922,6 +5681,121 @@ async def go(): asyncio.run(go()) +def test_codex_summary_sizes_final_projection_before_dropping_old_turns( + monkeypatch, tmp_path): + """Large deferred detail must not hide an older turn's terminal state.""" + rollout = tmp_path / "oversized-summary-rollout.jsonl" + rollout.write_text( + '{"type":"session_meta","payload":{"id":"summary-session"}}\n', + encoding="utf-8", + ) + monkeypatch.setattr( + mm, + "codex_history_window", + lambda path, **_kwargs: ( + 0, os.path.getsize(path), False, None, None, + ), + ) + + def completed_turn(index: int) -> list: + message_id = f"assistant-{index}" + tool_id = f"tool-{index}" + return [ + UserMsg(msg_id=f"user-{index}", prompt=f"question-{index}"), + AssistantMsgStart(message_id=message_id, channel="commentary"), + ToolUse( + message_id=message_id, + tool_use_id=tool_id, + tool="exec_command", + input={"payload": str(index) * 160_000}, + ), + ToolResult( + tool_use_id=tool_id, + content=str(index) * 160_000, + is_error=False, + status="succeeded", + ), + AssistantMsgEnd(message_id=message_id, channel="commentary"), + TurnEnd( + turn_id=f"native-{index}", + result=TurnResult( + subtype="success", duration_ms=index, is_error=False, + ), + ), + ] + + canned = [ + *completed_turn(1), + *completed_turn(2), + UserMsg(msg_id="user-3", prompt="question-3"), + AssistantMsgStart(message_id="assistant-3", channel="commentary"), + Delta( + message_id="assistant-3", + text="still working", + channel="commentary", + ), + ] + monkeypatch.setattr( + mm, + "codex_translate_history", + lambda *_args, **_kwargs: ( + [event.model_copy(deep=True) for event in canned], None, + ), + ) + + async def go(): + machine, _ = _mk_machine() + machine.cfg.ws_max_size_bytes = 64 * 1024 + machine._history_index = HistoryIndexStore(tmp_path / "state-summary") + machine._codex_rollout_for_wire = lambda _sid: str(rollout) + ctx = _mk_ctx("summary-session", "summary-session") + ctx.engine = "codex" + ctx.state = "running" + machine.sessions[ctx.key] = ctx + + history = await machine._build_history( + "summary-session", limit=4, detail="summary") + + assert [turn.prompt for turn in history.turns] == [ + "question-1", "question-2", "question-3", + ] + assert [turn.done for turn in history.turns] == [True, True, False] + assert len(history.model_dump_json().encode()) < 64 * 1024 + + source = HistorySourceFingerprint.capture(rollout) + cached = machine._history_index.get_page( + "summary-session", "codex", source, before=None, limit=4, + ) + assert cached is not None + assert any( + row.get("type") == "tool_result" for row in cached.events) + detail = machine._history_index.get_turn_detail( + "summary-session", "codex", source, "user-1", + ) + assert detail is not None + tool_result = next( + row for row in detail if row.get("type") == "tool_result") + assert tool_result["content"] == "1" * 160_000 + + # A retained source-complete page remains a fallback after the + # standalone detail row is evicted from its tighter LRU. + with machine._history_index._connect() as connection: + connection.execute( + "DELETE FROM history_turn_details " + "WHERE session_id=? AND turn_id=?", + ("summary-session", "user-1"), + ) + recovered = machine._history_index.get_turn_detail( + "summary-session", "codex", source, "user-1", + ) + assert recovered is not None + recovered_result = next( + row for row in recovered if row.get("type") == "tool_result") + assert recovered_result["content"] == "1" * 160_000 + + asyncio.run(go()) + + def test_many_turn_history_shrinks_with_logarithmic_serializations(monkeypatch): canned = [] for index in range(256): diff --git a/tests/test_history_store.py b/tests/test_history_store.py index 7a595a7..2168bf1 100644 --- a/tests/test_history_store.py +++ b/tests/test_history_store.py @@ -276,7 +276,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 17 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 for table in ( "history_pages", "history_turn_details", @@ -323,7 +323,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path): migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 17 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -374,7 +374,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index( "claude-session", "claude", source, before=None, limit=4, ) is None with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 17 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" @@ -420,7 +420,7 @@ def test_recent_migration_invalidates_changed_projection_rows( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 17 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 for table in ( "history_pages", "history_turn_details", "history_image_assets", ): @@ -462,7 +462,7 @@ def test_owner_migration_invalidates_only_codex_projections( migrated = HistoryIndexStore(state_dir) with sqlite3.connect(migrated.path) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 17 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 for table in ("history_pages", "history_turn_details"): assert connection.execute( f"SELECT COUNT(*) FROM {table} WHERE engine='claude'" @@ -488,6 +488,70 @@ def test_owner_migration_invalidates_only_codex_projections( ) == ("image/png", 1, 1, b"codex") +def test_v17_migration_rebuilds_pages_but_preserves_source_assets(tmp_path): + source_path = tmp_path / "transcript.jsonl" + source_path.write_text("{}\n") + source = HistorySourceFingerprint.capture(source_path) + state_dir = tmp_path / "state" + store = HistoryIndexStore(state_dir) + + for engine in ("claude", "codex"): + session_id = f"{engine}-session" + assert store.put_page( + session_id, + engine, + source, + before=None, + limit=4, + page=_page(session_id), + ) + store.put_image_asset( + session_id, + engine, + source, + session_id, + f"{engine}-image", + "thumbnail", + "image/png", + 1, + 1, + engine.encode(), + ) + + with sqlite3.connect(store.path) as connection: + connection.execute("PRAGMA user_version=17") + + migrated = HistoryIndexStore(state_dir) + with sqlite3.connect(migrated.path) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 18 + assert connection.execute( + "SELECT COUNT(*) FROM history_pages" + ).fetchone()[0] == 0 + assert connection.execute( + "SELECT COUNT(*) FROM history_turn_details" + ).fetchone()[0] == 2 + assert connection.execute( + "SELECT COUNT(*) FROM history_image_assets" + ).fetchone()[0] == 2 + + for engine in ("claude", "codex"): + session_id = f"{engine}-session" + assert migrated.get_page( + session_id, engine, source, before=None, limit=4, + ) is None + assert migrated.get_turn_detail( + session_id, engine, source, session_id, + ) == _page(session_id).events + assert migrated.get_image_asset( + session_id, + engine, + source, + session_id, + f"{engine}-image", + "thumbnail", + ) == ("image/png", 1, 1, engine.encode()) + + def test_history_index_rejects_one_page_larger_than_total_budget(tmp_path): source_path = tmp_path / "transcript.jsonl" source_path.write_text("{}\n") diff --git a/tests/test_presentation_sync.py b/tests/test_presentation_sync.py index 1322cad..1ed6e82 100644 --- a/tests/test_presentation_sync.py +++ b/tests/test_presentation_sync.py @@ -2,8 +2,10 @@ from __future__ import annotations import asyncio +import json from types import SimpleNamespace +from cc_remote.codex_profiles import CodexProfileRegistry from cc_remote.protocol import ( AcknowledgeCompletion, CommandAck, @@ -29,6 +31,22 @@ def _success(turn_id: str) -> TurnEnd: ) +def _install_two_codex_profiles(machine) -> None: + state = machine.cfg.state_dir + machine._codex_profiles = CodexProfileRegistry.from_json(json.dumps({ + "primary": { + "label": "Primary", + "home": str(state / "primary-home"), + "default": True, + }, + "secondary": { + "label": "Secondary", + "home": str(state / "secondary-home"), + }, + })) + machine._codex_profiles_explicit = True + + def test_presentation_protocol_round_trips_exact_receipt_ids(): dismiss = deserialize(serialize(DismissGoal( sid="session-1", @@ -180,7 +198,7 @@ def test_cold_session_completion_can_be_acknowledged_without_resume(): async def run(): machine, transport = _mk_machine() machine._session_presentation.mark_completion( - "cold-session", "turn-1" + "claude", "cold-session", "turn-1" ) await machine._process_command(AcknowledgeCompletion( @@ -206,11 +224,13 @@ async def run(): def test_cold_session_catalog_carries_durable_completion_receipts(monkeypatch): async def run(): machine, _ = _mk_machine() - machine._session_presentation.mark_completion("cold-seen", "turn-1") + machine._session_presentation.mark_completion( + "codex", "cold-seen", "turn-1") machine._session_presentation.acknowledge_completion( - "cold-seen", "turn-1" + "codex", "cold-seen", "turn-1" ) - machine._session_presentation.mark_completion("cold-unread", "turn-2") + machine._session_presentation.mark_completion( + "codex", "cold-unread", "turn-2") monkeypatch.setattr( machine, "_prime_codex_sidebar_watches", lambda _raw: None ) @@ -246,6 +266,176 @@ async def run(): asyncio.run(run()) +def test_codex_catalog_claims_only_unambiguous_v1_receipt( + monkeypatch, +): + async def run(): + machine, _ = _mk_machine() + path = machine._session_presentation.path + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "legacy-native": { + "completion_id": "legacy-turn", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + }, + }), encoding="utf-8") + machine._session_presentation = type( + machine._session_presentation)(path.parent) + _install_two_codex_profiles(machine) + monkeypatch.setattr( + "cc_remote.wrapper.machine.transcript_presence", + lambda _sid: False, + ) + monkeypatch.setattr( + "cc_remote.wrapper.machine.codex_session_presence", + lambda _sid, **kwargs: str( + kwargs.get("codex_home", "") + ).endswith("secondary-home"), + ) + monkeypatch.setattr( + machine, "_prime_codex_sidebar_watches", lambda _raw: None) + + event = await machine._send_codex_session_list( + SimpleNamespace( + space="code", client_id="phone", cmd_id="legacy-list" + ), + [{ + "session_id": "secondary@legacy-native", + "native_session_id": "legacy-native", + "codex_profile_id": "secondary", + "codex_profile_label": "Secondary", + "summary": "legacy", + "cwd": "/repo", + "status": "idle", + }], + ) + + assert event.sessions[0].completion_id == "legacy-turn" + assert machine._session_presentation.legacy_ids() == frozenset() + assert machine._session_presentation.get( + "codex", "secondary@legacy-native" + ).completion_id == "legacy-turn" + + asyncio.run(run()) + + +def test_codex_catalog_keeps_v1_receipt_quarantined_when_claude_is_unknown( + monkeypatch, +): + async def run(): + machine, _ = _mk_machine() + path = machine._session_presentation.path + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "legacy-native": { + "completion_id": "legacy-turn", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + }, + }), encoding="utf-8") + machine._session_presentation = type( + machine._session_presentation)(path.parent) + _install_two_codex_profiles(machine) + monkeypatch.setattr( + "cc_remote.wrapper.machine.transcript_presence", + lambda _sid: None, + ) + monkeypatch.setattr( + "cc_remote.wrapper.machine.codex_session_presence", + lambda _sid, **kwargs: str( + kwargs.get("codex_home", "") + ).endswith("secondary-home"), + ) + monkeypatch.setattr( + machine, "_prime_codex_sidebar_watches", lambda _raw: None) + + event = await machine._send_codex_session_list( + SimpleNamespace( + space="code", client_id="phone", cmd_id="unknown-list" + ), + [{ + "session_id": "secondary@legacy-native", + "native_session_id": "legacy-native", + "codex_profile_id": "secondary", + "codex_profile_label": "Secondary", + "summary": "legacy", + "cwd": "/repo", + "status": "idle", + }], + ) + + assert event.sessions[0].completion_id is None + assert machine._session_presentation.legacy_ids() == frozenset({ + "legacy-native", + }) + + asyncio.run(run()) + + +def test_codex_catalog_keeps_v1_receipt_quarantined_for_duplicate_profiles( + monkeypatch, +): + async def run(): + machine, _ = _mk_machine() + path = machine._session_presentation.path + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "legacy-native": { + "completion_id": "legacy-turn", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + }, + }), encoding="utf-8") + machine._session_presentation = type( + machine._session_presentation)(path.parent) + _install_two_codex_profiles(machine) + monkeypatch.setattr( + "cc_remote.wrapper.machine.transcript_presence", + lambda _sid: False, + ) + monkeypatch.setattr( + "cc_remote.wrapper.machine.codex_session_presence", + lambda _sid, **_kwargs: True, + ) + profiles = list(machine._codex_profiles) + monkeypatch.setattr( + machine, "_prime_codex_sidebar_watches", lambda _raw: None) + + await machine._send_codex_session_list( + SimpleNamespace( + space="code", client_id="phone", cmd_id="duplicate-list" + ), + [{ + "session_id": "legacy-native", + "native_session_id": "legacy-native", + "codex_profile_id": profiles[0].id, + "codex_profile_label": profiles[0].label, + "summary": "legacy", + "cwd": "/repo", + "status": "idle", + }], + ) + + assert machine._session_presentation.legacy_ids() == frozenset({ + "legacy-native", + }) + + asyncio.run(run()) + + def test_failed_and_private_btw_turns_do_not_create_shared_receipts(): async def run(): machine, transport = _mk_machine() @@ -350,13 +540,13 @@ async def run(): machine.sessions[ctx.key] = ctx await machine._emit(ctx, _success("turn-profiled")) - assert machine._session_presentation_fields(wire_sid) == { + assert machine._session_presentation_fields("codex", wire_sid) == { "completion_id": "turn-profiled", "completion_unread": True, "completion_revision": 1, } assert machine._session_presentation.get( - "native-session" + "codex", "native-session" ).completion_revision == 0 await machine._handle_acknowledge_completion( @@ -368,7 +558,7 @@ async def run(): ) ) assert machine._session_presentation.get( - wire_sid + "codex", wire_sid ).completion_unread is False initial = await machine._handle_get_goal(GetGoal( @@ -384,10 +574,10 @@ async def run(): )) assert dismissed.dismissed is True assert machine._session_presentation.get( - wire_sid + "codex", wire_sid ).dismissed_goal_id == initial.goal_id assert machine._session_presentation.get( - "native-session" + "codex", "native-session" ).dismissed_goal_id is None assert all( message.sid == wire_sid diff --git a/tests/test_product_version.py b/tests/test_product_version.py index a5370ca..ab18cbf 100644 --- a/tests/test_product_version.py +++ b/tests/test_product_version.py @@ -45,7 +45,7 @@ def test_release_docs_distinguish_product_and_wire_protocol_versions(): assert "## What changed in v3" in readme_en for document in (readme, readme_en, changelog): assert "v3.0.0" in document - assert "protocol v34" in document.lower() + assert "protocol v35" in document.lower() def test_readmes_use_safe_markdown_for_navigation_and_images(): diff --git a/tests/test_session_plans.py b/tests/test_session_plans.py index 0a387e6..0d72d2c 100644 --- a/tests/test_session_plans.py +++ b/tests/test_session_plans.py @@ -28,18 +28,37 @@ def test_session_plan_store_round_trips_rekeys_and_deletes(tmp_path): store = SessionPlanStore(tmp_path) stored = store.put("tmp-0123456789abcdef", _plan()) assert stored.as_event().plan[1]["status"] == "inProgress" + assert stored.owner_turn_ids == frozenset({"turn-1"}) + mismatched = SessionPlanStore(tmp_path).put("mismatched-owner", TurnPlan( + item_id="plan:wrong-turn", + turn_id="right-turn", + explanation=None, + plan=[{"step": "fix", "status": "inProgress"}], + )) + assert mismatched.owner_turn_ids == frozenset({"right-turn"}) restored = SessionPlanStore(tmp_path) assert restored.get("tmp-0123456789abcdef") == stored + terminal = restored.mark_terminal( + "tmp-0123456789abcdef", + turn_id="turn-1", + status="succeeded", + ) + assert terminal is not None + assert terminal.terminal_status == "succeeded" + assert terminal.as_process_block()["done"] is True + assert terminal.as_process_block()["plan"][1]["status"] == "inProgress" + assert SessionPlanStore(tmp_path).get( + "tmp-0123456789abcdef") == terminal restored.move("tmp-0123456789abcdef", "session-1") assert restored.get("tmp-0123456789abcdef") is None - assert restored.get("session-1") == stored + assert restored.get("session-1") == terminal restored.delete("session-1") assert SessionPlanStore(tmp_path).get("session-1") is None -def test_session_plan_store_retires_only_completed_previous_turns(tmp_path): +def test_session_plan_store_retires_only_settled_previous_turns(tmp_path): store = SessionPlanStore(tmp_path) store.put("session-1", _plan()) assert store.retire_completed("session-1") is False @@ -60,6 +79,77 @@ def test_session_plan_store_retires_only_completed_previous_turns(tmp_path): "session-1", current_turn_ids=frozenset({"turn-2"})) is True assert SessionPlanStore(tmp_path).get("session-1") is None + store.put("session-1", _plan()) + assert store.mark_terminal( + "session-1", turn_id="another-turn", status="succeeded", + ) is None + store.put("session-raw-item", TurnPlan( + item_id="turn-1", + turn_id=None, + explanation="unbound item", + plan=[{"step": "fix", "status": "inProgress"}], + )) + assert store.mark_terminal( + "session-raw-item", turn_id="turn-1", status="succeeded", + ) is None + store.put("session-current-item", TurnPlan( + item_id="plan:current", + turn_id=None, + explanation="unbound current plan", + plan=[{"step": "fix", "status": "inProgress"}], + )) + assert store.mark_terminal( + "session-current-item", turn_id="current", status="succeeded", + ) is None + store.put("session-derived-owner", TurnPlan( + item_id="plan:derived-turn", + turn_id=None, + explanation=None, + plan=[{"step": "done", "status": "completed"}], + )) + assert store.retire_settled( + "session-derived-owner", + current_turn_ids=frozenset({"derived-turn"}), + ) is False + assert store.retire_settled("session-1") is False + terminal = store.mark_terminal( + "session-1", turn_id="turn-1", status="succeeded", + ) + assert terminal is not None + assert terminal.complete is False + assert terminal.settled is True + assert store.retire_settled( + "session-1", current_turn_ids=frozenset({"turn-1"})) is False + assert store.retire_settled( + "session-1", current_turn_ids=frozenset({"turn-2"})) is True + + +def test_session_plan_store_reads_v2_as_unsettled_and_upgrades_on_write( + tmp_path, +): + path = tmp_path / "session-plans.json" + path.write_text(json.dumps({ + "version": 2, + "profile_revision": 2, + "plans": { + "session-1": { + "item_id": "plan:turn-1", + "turn_id": "turn-1", + "explanation": "legacy", + "plan": [{"step": "x", "status": "inProgress"}], + "updated_at": 1, + }, + }, + }), encoding="utf-8") + + store = SessionPlanStore(tmp_path) + assert store.get("session-1").terminal_status is None + store.mark_terminal( + "session-1", turn_id="turn-1", status="succeeded") + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["version"] == 3 + assert payload["plans"]["session-1"]["terminal_status"] == "succeeded" + def test_session_plan_store_rejects_unbounded_or_unknown_payloads(tmp_path): path = tmp_path / "session-plans.json" @@ -88,3 +178,20 @@ def test_session_plan_store_rejects_symlinks(tmp_path): with pytest.raises(SessionPlanStoreError): SessionPlanStore(tmp_path) + + +def test_session_plan_profile_migration_is_replay_safe(tmp_path): + store = SessionPlanStore(tmp_path) + store.put("old@session-1", _plan()) + + assert store.migrate_profile_sessions( + lambda sid: sid.replace("old@", "new@", 1), + profile_revision=4, + ) == 1 + assert store.migrate_profile_sessions( + lambda _sid: "must-not-run", + profile_revision=4, + ) == 0 + restored = SessionPlanStore(tmp_path) + assert restored.get("old@session-1") is None + assert restored.get("new@session-1") is not None diff --git a/tests/test_session_presentation.py b/tests/test_session_presentation.py index 5b3a95c..c92d9c8 100644 --- a/tests/test_session_presentation.py +++ b/tests/test_session_presentation.py @@ -15,52 +15,55 @@ def test_completion_receipts_round_trip_and_reject_stale_acknowledgements( tmp_path, ): store = SessionPresentationStore(tmp_path) - first = store.mark_completion("session-1", "turn-1") + first = store.mark_completion("claude", "session-1", "turn-1") assert first.completion_unread is True assert first.completion_revision == 1 # Re-emitting the same native terminal boundary is idempotent. - assert store.mark_completion("session-1", "turn-1") == first - second = store.mark_completion("session-1", "turn-2") + assert store.mark_completion("claude", "session-1", "turn-1") == first + second = store.mark_completion("claude", "session-1", "turn-2") assert second.completion_revision == 2 assert second.completion_id == "turn-2" - stale = store.acknowledge_completion("session-1", "turn-1") + stale = store.acknowledge_completion("claude", "session-1", "turn-1") assert stale == second - acknowledged = store.acknowledge_completion("session-1", "turn-2") + acknowledged = store.acknowledge_completion( + "claude", "session-1", "turn-2") assert acknowledged.completion_unread is False assert acknowledged.completion_revision == 3 - assert SessionPresentationStore(tmp_path).get("session-1") == acknowledged + assert SessionPresentationStore(tmp_path).get( + "claude", "session-1") == acknowledged def test_goal_dismissal_is_scoped_to_one_exact_generation(tmp_path): store = SessionPresentationStore(tmp_path) - store.dismiss_goal("session-1", "goal-first") - assert store.reconcile_goal("session-1", "goal-first") is True + store.dismiss_goal("codex", "session-1", "goal-first") + assert store.reconcile_goal("codex", "session-1", "goal-first") is True - assert store.reconcile_goal("session-1", "goal-replacement") is False - assert store.get("session-1").dismissed_goal_id is None - store.dismiss_goal("session-1", "goal-replacement") - assert store.reconcile_goal("session-1", None) is False - assert store.get("session-1").dismissed_goal_id is None + assert store.reconcile_goal( + "codex", "session-1", "goal-replacement") is False + assert store.get("codex", "session-1").dismissed_goal_id is None + store.dismiss_goal("codex", "session-1", "goal-replacement") + assert store.reconcile_goal("codex", "session-1", None) is False + assert store.get("codex", "session-1").dismissed_goal_id is None def test_session_presentation_rekeys_clears_and_deletes(tmp_path): store = SessionPresentationStore(tmp_path) - store.mark_completion("tmp-session", "turn-1") - store.dismiss_goal("tmp-session", "goal-1") - store.move("tmp-session", "real-session") - assert store.get("tmp-session").completion_id is None - assert store.get("real-session").completion_id == "turn-1" - assert store.get("real-session").dismissed_goal_id == "goal-1" - - cleared = store.clear_completion("real-session") + store.mark_completion("codex", "tmp-session", "turn-1") + store.dismiss_goal("codex", "tmp-session", "goal-1") + store.move("codex", "tmp-session", "real-session") + assert store.get("codex", "tmp-session").completion_id is None + assert store.get("codex", "real-session").completion_id == "turn-1" + assert store.get("codex", "real-session").dismissed_goal_id == "goal-1" + + cleared = store.clear_completion("codex", "real-session") assert cleared.completion_id is None assert cleared.completion_unread is False assert cleared.completion_revision == 2 - store.delete("real-session") + store.delete("codex", "real-session") assert SessionPresentationStore(tmp_path).get( - "real-session" + "codex", "real-session" ).completion_revision == 0 @@ -91,3 +94,133 @@ def test_session_presentation_rejects_symlinks(tmp_path): (tmp_path / "session-presentation.json").symlink_to(target) with pytest.raises(SessionPresentationStoreError): SessionPresentationStore(tmp_path) + + +def test_presentation_is_engine_scoped_and_v1_bare_ids_are_quarantined( + tmp_path, +): + path = tmp_path / "session-presentation.json" + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "ambiguous-native": { + "completion_id": "turn-a", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + "old-profile@native": { + "completion_id": "turn-c", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 2, + }, + }, + }), encoding="utf-8") + + store = SessionPresentationStore(tmp_path) + assert store.get("claude", "ambiguous-native").completion_id is None + assert store.get("codex", "ambiguous-native").completion_id is None + assert store.legacy_ids() == frozenset({"ambiguous-native"}) + assert store.get("codex", "old-profile@native").completion_id == "turn-c" + assert store.get("claude", "old-profile@native").completion_id is None + + claimed = store.claim_legacy("claude", "ambiguous-native") + assert claimed is not None and claimed.completion_id == "turn-a" + assert store.legacy_ids() == frozenset() + restored = SessionPresentationStore(tmp_path) + assert restored.get("claude", "ambiguous-native").completion_id == "turn-a" + assert restored.get("codex", "ambiguous-native").completion_id is None + assert json.loads(path.read_text(encoding="utf-8"))["version"] == 3 + + +def test_claiming_legacy_does_not_replace_newer_scoped_receipt(tmp_path): + path = tmp_path / "session-presentation.json" + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "same-id": { + "completion_id": "old-turn", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": "old-goal", + "updated_at": 1, + }, + }, + }), encoding="utf-8") + store = SessionPresentationStore(tmp_path) + store.mark_completion("codex", "same-id", "new-turn") + + claimed = store.claim_legacy("codex", "same-id") + + assert claimed is not None and claimed.completion_id == "new-turn" + assert store.get("codex", "same-id").completion_id == "new-turn" + assert store.legacy_ids() == frozenset() + + +def test_claiming_legacy_can_target_a_profile_scoped_codex_wire_id(tmp_path): + path = tmp_path / "session-presentation.json" + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "native-id": { + "completion_id": "old-turn", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + }, + }), encoding="utf-8") + store = SessionPresentationStore(tmp_path) + + store.claim_legacy("codex", "native-id", "primary@native-id") + + assert store.get("codex", "primary@native-id").completion_id == "old-turn" + assert store.get("codex", "native-id").completion_id is None + assert store.legacy_ids() == frozenset() + + +def test_v3_quarantine_survives_codex_profile_migration(tmp_path): + path = tmp_path / "session-presentation.json" + path.write_text(json.dumps({ + "version": 1, + "sessions": { + "ambiguous-id": { + "completion_id": "turn-a", + "completion_unread": True, + "completion_revision": 1, + "dismissed_goal_id": None, + "updated_at": 1, + }, + }, + }), encoding="utf-8") + store = SessionPresentationStore(tmp_path) + + assert store.migrate_codex_profile_sessions( + lambda sid: f"primary@{sid}", profile_revision=3, + ) == 0 + + restored = SessionPresentationStore(tmp_path) + assert restored.legacy_ids() == frozenset({"ambiguous-id"}) + assert restored.get("codex", "primary@ambiguous-id").completion_id is None + + +def test_presentation_profile_migration_is_replay_safe(tmp_path): + store = SessionPresentationStore(tmp_path) + store.mark_completion("codex", "old@native", "turn-c") + store.mark_completion("claude", "old@native", "turn-h") + + assert store.migrate_codex_profile_sessions( + lambda sid: sid.replace("old@", "new@", 1), + profile_revision=7, + ) == 1 + assert store.migrate_codex_profile_sessions( + lambda _sid: "must-not-run", + profile_revision=7, + ) == 0 + restored = SessionPresentationStore(tmp_path) + assert restored.get("codex", "new@native").completion_id == "turn-c" + assert restored.get("claude", "old@native").completion_id == "turn-h" diff --git a/tests/test_web_protocol_mirror.py b/tests/test_web_protocol_mirror.py index 165024b..39a9d0f 100644 --- a/tests/test_web_protocol_mirror.py +++ b/tests/test_web_protocol_mirror.py @@ -123,7 +123,7 @@ def test_every_python_wire_type_has_a_typescript_interface(): for wire_type, model in _TYPE_MAP.items(): body_name = mirrored[wire_type] for field in model.__annotations__: - if field == "type": + if field == "type" or field in model.__private_attributes__: continue assert _field_optional(body_name, field) is not None, ( f"{body_name} is missing Python field {field!r}" diff --git a/tests/test_work_context.py b/tests/test_work_context.py index 2767d67..5fff453 100644 --- a/tests/test_work_context.py +++ b/tests/test_work_context.py @@ -13,6 +13,7 @@ from cc_remote.wrapper.ringbuffer import RingBuffer from cc_remote.wrapper.work_context import ( initial_work_context_baseline, + recover_codex_context_usage, recover_work_context_baseline, work_context_metrics, ) @@ -125,6 +126,187 @@ def rollout(session_id, *, codex_home=None): assert homes == [str(tmp_path / "profile-home")] +def test_codex_context_usage_recovers_newest_bounded_profile_tail( + tmp_path: Path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + rollout.write_bytes( + b"x" * (4 * 1024 * 1024) + b"\n" + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":103658,"input_tokens":103000},' + b'"model_context_window":258400}}}\n' + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":104321,"input_tokens":103500},' + b'"model_context_window":258400}}}\n' + ) + calls = [] + + def path(session_id, *, codex_home=None): + calls.append((session_id, codex_home)) + return str(rollout) + + monkeypatch.setattr(work_context_module, "codex_rollout_path", path) + usage = recover_codex_context_usage( + "native-session", codex_home=str(tmp_path / "profile")) + assert usage == { + "last": {"totalTokens": 104321, "inputTokens": 103500}, + "modelContextWindow": 258400, + } + assert calls == [("native-session", str(tmp_path / "profile"))] + + +def test_codex_context_usage_keeps_complete_record_at_tail_boundary( + tmp_path: Path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + record = ( + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":777},' + b'"model_context_window":1000}}}\n' + ) + rollout.write_bytes( + b"x" * (work_context_module._CONTEXT_TAIL_SCAN_BYTES - len(record) - 1) + + b"\n" + record + ) + monkeypatch.setattr( + work_context_module, "codex_rollout_path", + lambda *_args, **_kwargs: str(rollout)) + + assert recover_codex_context_usage("native-session") == { + "last": {"totalTokens": 777}, + "modelContextWindow": 1000, + } + + +def test_codex_context_usage_recovery_fails_closed(tmp_path: Path, monkeypatch): + rollout = tmp_path / "rollout.jsonl" + monkeypatch.setattr( + work_context_module, "codex_rollout_path", lambda *_args, **_kwargs: str(rollout)) + + for record in ( + b'["valid json, but not a rollout object"]\n', + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":true},' + b'"model_context_window":258400}}}\n', + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":123},' + b'"model_context_window":-1}}}\n', + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":123},' + b'"model_context_window":9007199254740992}}}\n', + b'{broken json}\n', + ): + rollout.write_bytes(record) + assert recover_codex_context_usage("native-session") is None + + +def test_codex_context_usage_ignores_concurrent_append( + tmp_path: Path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + original = ( + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":321},' + b'"model_context_window":1000}}}\n' + ) + appended = ( + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":654},' + b'"model_context_window":1000}}}\n' + ) + rollout.write_bytes(original) + real_open = open + + class AppendingReader: + def __init__(self, stream): + self._stream = stream + self._appended = False + + def __enter__(self): + self._stream.__enter__() + return self + + def __exit__(self, *args): + return self._stream.__exit__(*args) + + def __getattr__(self, name): + return getattr(self._stream, name) + + def read(self, size=-1): + data = self._stream.read(size) + if not self._appended: + self._appended = True + with real_open(rollout, "ab") as writer: + writer.write(appended) + return data + + def growing_open(path, mode="r", *args, **kwargs): + stream = real_open(path, mode, *args, **kwargs) + return AppendingReader(stream) if mode == "rb" else stream + + monkeypatch.setattr(work_context_module, "open", growing_open, raising=False) + monkeypatch.setattr( + work_context_module, "codex_rollout_path", + lambda *_args, **_kwargs: str(rollout)) + + assert recover_codex_context_usage("native-session") == { + "last": {"totalTokens": 321}, + "modelContextWindow": 1000, + } + + +def test_codex_context_usage_rejects_concurrent_path_replacement( + tmp_path: Path, monkeypatch, +): + rollout = tmp_path / "rollout.jsonl" + replacement = tmp_path / "replacement.jsonl" + original = ( + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":321},' + b'"model_context_window":1000}}}\n' + ) + replacement.write_bytes( + b'{"type":"event_msg","payload":{"type":"token_count","info":{' + b'"last_token_usage":{"total_tokens":654},' + b'"model_context_window":1000}}}\n' + ) + rollout.write_bytes(original) + real_open = open + + class ReplacingReader: + def __init__(self, stream): + self._stream = stream + self._replaced = False + + def __enter__(self): + self._stream.__enter__() + return self + + def __exit__(self, *args): + return self._stream.__exit__(*args) + + def __getattr__(self, name): + return getattr(self._stream, name) + + def read(self, size=-1): + data = self._stream.read(size) + if not self._replaced: + self._replaced = True + replacement.replace(rollout) + return data + + def replacing_open(path, mode="r", *args, **kwargs): + stream = real_open(path, mode, *args, **kwargs) + return ReplacingReader(stream) if mode == "rb" else stream + + monkeypatch.setattr(work_context_module, "open", replacing_open, raising=False) + monkeypatch.setattr( + work_context_module, "codex_rollout_path", + lambda *_args, **_kwargs: str(rollout)) + + assert recover_codex_context_usage("native-session") is None + + def test_work_registry_persists_context_baseline_once(tmp_path: Path): store = WorkRegistry(tmp_path / "work", "codex") record = store.create_session() diff --git a/web/package.json b/web/package.json index 60c158a..52f1959 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,7 @@ "test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit", "test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js", "test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", - "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", + "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 4e4776a..a36c172 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,6 +6,7 @@ const WEBKIT_LIVE_INTERACTION_TESTS = /live append follows|returning to a background-grown live turn|iOS pointercancel releases process interactions|multi-line IME growth|multi-line composer growth|composer action growth|Codex controls stay on one row|queued messages expand|migration picker/; const WEBKIT_RENDERING_TESTS = /mounted message image|two visible images|HTML preview|artifact-(?:svg|markdown-svg)|mobile Markdown source editor|dark desktop code block|Codex settings|Claude settings|history page cache|instant session cache|session cache rejects|canonical image reference|fallback image preview|streaming rerenders|expanded tool batches|Mermaid|chat formulas|real wide Robot|pending composer image|profile keycaps|profile session card edges/; +const WEBKIT_GOAL_PLAN_TESTS = /[Pp]lan|[Gg]oal/; export default defineConfig({ testDir: "./tests", @@ -37,6 +38,7 @@ export default defineConfig({ NEW_CHAT_CONTROL_TESTS, WEBKIT_LIVE_INTERACTION_TESTS, WEBKIT_RENDERING_TESTS, + WEBKIT_GOAL_PLAN_TESTS, ], use: { ...devices["iPhone 15"], @@ -60,6 +62,13 @@ export default defineConfig({ ...devices["iPhone 15"], }, }, + { + name: "webkit-progress", + grep: WEBKIT_GOAL_PLAN_TESTS, + use: { + ...devices["iPhone 15"], + }, + }, { name: "webkit-controls", grep: NEW_CHAT_CONTROL_TESTS, diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json index c873cad..b5ce358 100644 --- a/web/public/cc-remote-build.json +++ b/web/public/cc-remote-build.json @@ -1 +1 @@ -{"version":"3.0.0","protocol":34} +{"version":"3.0.0","protocol":35} diff --git a/web/src/App.css b/web/src/App.css index 3a12667..004d89a 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -219,6 +219,7 @@ .plan-chip { max-width:100%; } .plan-chip-ring.complete { color:var(--ok); } .plan-chip-ring.failed { color:var(--danger); } +.plan-chip-ring.stale { color:var(--faint); } .goal-status { display:inline-flex; align-items:center; width:max-content; border-radius:999px; padding:2px 7px; font-size:10px; line-height:1.4; font-weight:700; color:var(--accent-ink); background:var(--accent-weak); } .goal-status-paused,.goal-status-blocked { color:var(--warn); background:var(--warn-weak); } .goal-status-complete { color:var(--ok); background:var(--ok-weak); } @@ -311,15 +312,12 @@ .goal-cancel { color:var(--dim); background:var(--raised); } .goal-primary { min-width:96px; color:#fff; background:var(--accent); } .goal-primary:disabled { opacity:.42; } -@media (min-width:720px) { - .goal-sheet.sheet { left:50%; right:auto; top:calc(var(--app-offset-top,0px) + 16px); - bottom:calc(var(--keyboard-inset,0px) + 16px); width:min(560px,calc(100vw - 32px)); - height:fit-content; max-height:min(740px,calc(var(--app-height,100dvh) - 32px)); - margin-block:auto; border:1px solid var(--border-strong); border-radius:18px; - opacity:0; transform:translateX(-50%) translateY(12px) scale(.985); } - .goal-sheet.sheet.show { opacity:1; transform:translateX(-50%) scale(1); } - .goal-sheet .sheet-grip { display:none; } -} +.goal-sheet.sheet { right:auto; bottom:auto; height:fit-content; + border:1px solid var(--border-strong); border-radius:18px; opacity:0; + transform:translate(-50%,calc(-50% + 12px)) scale(.985); + transition:transform .22s var(--ease),opacity .18s var(--ease); } +.goal-sheet.sheet.show { opacity:1; transform:translate(-50%,-50%) scale(1); } +.goal-sheet .sheet-grip { display:none; } @media (max-width:700px) { .goal-chip-wrap { max-width:calc(100% - 20px); margin-bottom:6px; } .goal-chip-objective { max-width:42vw; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 14a725d..c0fd4d2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -51,7 +51,10 @@ import { CapabilitiesSheet, type HookDraft, type SkillDraft } from "./components import { TerminalControl } from "./components/TerminalControl"; import { DeviceSheet, type PairingState, type RemoteDevice } from "./components/DeviceSheet"; import { HeaderMenu } from "./components/HeaderMenu"; -import { codexProfilePresentation } from "./codex-profile-presentation"; +import { + codexProfileIdForSession, + codexProfilePresentation, +} from "./codex-profile-presentation"; import { parseGoalCommand } from "./goal-command"; import { dismissGoalUi, @@ -62,6 +65,7 @@ import { reconcileGoalUiPreference, rekeyGoalUiPreference, rememberGoalUi, + resetGoalDismissMigrationTracking, writeGoalUiPreferences, type GoalUiPreferences, } from "./scoped-goal-ui"; @@ -81,6 +85,8 @@ import { import type { SendMode } from "./composer-submit"; import { MAX_RUNTIME_SESSIONS } from "./runtime-bounds"; import { + FORK_FOCUS_REFRESH_MS, + forkFocusLeaseSession, isTerminalSessionMigrationError, isTerminalWorktreeForkError, matchesSessionForkRequest, @@ -88,8 +94,10 @@ import { matchesWorktreeForkRequest, reconcileOpenMigrationSession, type PendingSessionFork, + type ForkFocusLease, type PendingSessionMigration, type PendingWorktreeFork, + withoutForkFocusPlaceholder, } from "./session-worktree"; import { classifyBtwOpened, consumeDiscardedBtwSnapshot, matchesBtwRequest, normalizeDiffTheme, normalizeEngine, type Snapshot, type QueryImg, @@ -145,6 +153,7 @@ import { HistoryRequestCoordinator, HistoryDetailRequestCoordinator, resolveHistoryCwdHint, + type CancelledHistoryBrowseRequest, type HistoryBrowseRequestContext, type HistoryDetailRequestContext, } from "./history-requests"; @@ -209,10 +218,16 @@ import { planFollowsCompletedGoal, SessionPlanProgressCache, } from "./plan-progress"; +import { + ENGINE_SPACES_KEY, + LEGACY_SPACE_KEY, + readEngineSpaces, + rememberEngineSpace, +} from "./surface-preferences"; +import { exactActiveTurnId } from "./process-blocks"; const THEME_KEY = "cc_remote_theme"; const ENGINE_KEY = "cc_remote_engine"; // which backend the NEXT new session uses -const SPACE_KEY = "cc_remote_space"; const MACHINE_KEY = "cc_remote_machine"; const GoalPanel = lazy(() => import("./components/GoalPanel").then( ({ GoalPanel: Panel }) => ({ default: Panel }), @@ -254,10 +269,11 @@ function catalogForEngineProfile( export default function App() { const [theme, setTheme] = useState( () => normalizeDiffTheme(localStorage.getItem(THEME_KEY))); - const [engine, setEngine] = useState( - () => normalizeEngine(localStorage.getItem(ENGINE_KEY))); - const [space, setSpace] = useState( - () => localStorage.getItem(SPACE_KEY) === "work" ? "work" : "code"); + const initialEngineRef = useRef(normalizeEngine(localStorage.getItem(ENGINE_KEY))); + const initialSpacesRef = useRef(readEngineSpaces(localStorage, initialEngineRef.current)); + const [engine, setEngine] = useState(initialEngineRef.current); + const [space, setSpace] = useState(initialSpacesRef.current[initialEngineRef.current]); + const spacesByEngineRef = useRef>(initialSpacesRef.current); const [authed, setAuthed] = useState(false); const [authReady, setAuthReady] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -375,6 +391,8 @@ export default function App() { readGoalUiPreferences(localStorage)); const goalRecoveryRequestsRef = useRef>(new Set()); const goalDismissMigrationsRef = useRef>(new Set()); + const goalDismissMigrationByRequestRef = useRef>( + new Map()); const planProgressCacheRef = useRef(new SessionPlanProgressCache()); const goalRequestScopeByIdRef = useRef { + for (const request of cancelled) { + const browse = request.browse; + dispatch({ + type: "history_browse_page_failed", + sid: request.sid, + scopeKey: browse.scopeKey, + revision: request.revision, + generation: request.generation, + viewId: browse.viewId, + windowEpoch: browse.windowEpoch, + before: browse.pendingBefore, + }); + } + }, []); const historyPageCacheRef = useRef(new HistoryPageCache()); const historyPageScopesRef = useRef(new Map()); @@ -433,6 +468,8 @@ export default function App() { }>>(new Map()); const pendingBtwByParentRef = useRef>(new Map()); const pendingSessionForkRef = useRef(null); + const forkFocusLeaseRef = useRef(null); + const forkFocusLeaseTimerRef = useRef(null); const pendingWorktreeForkRef = useRef(null); const pendingSessionMigrationRef = useRef(null); @@ -491,6 +528,52 @@ export default function App() { return next; }); }, []); + const clearForkFocusLease = useCallback(( + refresh = false, + dropPlaceholder = true, + ) => { + const lease = forkFocusLeaseRef.current; + if (forkFocusLeaseTimerRef.current !== null) { + window.clearTimeout(forkFocusLeaseTimerRef.current); + forkFocusLeaseTimerRef.current = null; + } + if (!lease) return; + forkFocusLeaseRef.current = null; + if (!dropPlaceholder) return; + const surfaceKey = `${lease.space}:${lease.engine}`; + const current = sessionListsBySurfaceRef.current[surfaceKey] ?? []; + const sessions = withoutForkFocusPlaceholder(current, lease); + sessionListsBySurfaceRef.current[surfaceKey] = sessions; + historySessionListsRef.current[surfaceKey] = sessions; + dispatch({ + type: "drop_fork_placeholder", + sid: lease.childSessionId, + parentSid: lease.parentSessionId, + }); + if (refresh) wsRef.current?.sendListSessions(lease.engine, lease.space); + }, []); + const startForkFocusLease = useCallback((lease: ForkFocusLease) => { + // Replacing one successful fork with another is explicit navigation. The + // old synthetic row no longer has a lease and must not linger undeletable. + clearForkFocusLease(false, true); + forkFocusLeaseRef.current = lease; + const refresh = () => { + const current = forkFocusLeaseRef.current; + if (current?.requestId !== lease.requestId + || current.childSessionId !== lease.childSessionId) return; + wsRef.current?.sendListSessions(current.engine, current.space); + current.refreshAt = Date.now() + FORK_FOCUS_REFRESH_MS; + forkFocusLeaseTimerRef.current = window.setTimeout( + refresh, FORK_FOCUS_REFRESH_MS); + }; + forkFocusLeaseTimerRef.current = window.setTimeout( + refresh, Math.max(0, lease.refreshAt - Date.now())); + }, [clearForkFocusLease]); + useEffect(() => () => { + if (forkFocusLeaseTimerRef.current !== null) { + window.clearTimeout(forkFocusLeaseTimerRef.current); + } + }, []); const requestHistory = useCallback(( sid: string, before: string | null | undefined, @@ -511,8 +594,8 @@ export default function App() { before, limit, resolveHistoryCwdHint(historySessionListsRef.current, sid), - )); - }, []); + ), settleCancelledHistoryBrowse); + }, [settleCancelledHistoryBrowse]); const cancelPendingNotificationTarget = useCallback(() => { setPendingNotificationTarget(null); notificationListRequestRef.current = null; @@ -551,6 +634,7 @@ export default function App() { pendingCreateRef.current = null; createRequestsRef.current.clear(); pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -563,7 +647,11 @@ export default function App() { setQueuedQueryEditor(null); btwDraftsRef.current.clear(); setCompletionReceipts({}); - goalDismissMigrationsRef.current.clear(); + resetGoalDismissMigrationTracking( + goalDismissMigrationsRef.current, + goalDismissMigrationByRequestRef.current, + ); + planProgressCacheRef.current.reset(); sessionListsBySurfaceRef.current = {}; historySessionListsRef.current = {}; preferredSurfaceFocusRef.current = null; @@ -609,7 +697,7 @@ export default function App() { } void import("./cache").then((module) => module.clearCache()); void historyPageCacheRef.current.clear(); - }, [clearHistoryDetailRequests, machineId]); + }, [clearForkFocusLease, clearHistoryDetailRequests, machineId]); // The focused session's runtime (turns/state/model/perm/queue/...). Falls back // to an empty runtime before any session is focused. @@ -701,7 +789,8 @@ export default function App() { (session) => session.session_id === focusedSid); const focusedEngine = (focusedSession?.engine ?? engine) as "claude" | "codex"; const focusedCodexProfileId = focusedEngine === "codex" - ? focusedSession?.codex_profile_id ?? state.defaultCodexProfileId + ? focusedSession?.codex_profile_id + ?? codexProfileIdForSession(focusedSid, state.defaultCodexProfileId) : null; const focusedWorkProfile = space === "work" && !state.newChat && focusedEngine === "codex" && focusedSession?.codex_profile_id @@ -727,7 +816,10 @@ export default function App() { const historyPlanProgress = historyView.browsing || historyView.recovering ? latestPlanProgress(historyView.turns) : null; let planProgress = focusedSid - ? planProgressCacheRef.current.resolve({ + ? planProgressCacheRef.current.resolve({ + machineId, + engine: focusedEngine, + space, sid: focusedSid, runtime: runtimePlanProgress, history: historyPlanProgress, @@ -742,7 +834,9 @@ export default function App() { // turn starts after the authoritative Goal completion boundary. if (completedGoalRetired && planProgress && !planFollowsCompletedGoal(rt.goal, planProgress)) { - planProgressCacheRef.current.clear(focusedSid!); + planProgressCacheRef.current.clear({ + machineId, engine: focusedEngine, space, sid: focusedSid!, + }); planProgress = null; } const planProgressSource = planProgress?.source ?? null; @@ -1315,8 +1409,11 @@ export default function App() { }, [engine, space]); useEffect(() => { document.documentElement.setAttribute("data-space", space); - localStorage.setItem(SPACE_KEY, space); - }, [space]); + localStorage.setItem(LEGACY_SPACE_KEY, space); + spacesByEngineRef.current = rememberEngineSpace( + spacesByEngineRef.current, engine, space); + localStorage.setItem(ENGINE_SPACES_KEY, JSON.stringify(spacesByEngineRef.current)); + }, [engine, space]); useEffect(() => { if (newChatCwd === null || state.connState !== "connected" || !state.wrapperOnline || newChatCodexProfileMissing) return; @@ -1389,6 +1486,7 @@ export default function App() { nextSpace: Space, preserveAuthority = false, ) => { + clearForkFocusLease(false); rememberSurfaceFocus(engine, space); const surfaceKey = `${nextSpace}:${nextEngine}`; const focusScopeKey = sessionScopeKey( @@ -1421,7 +1519,7 @@ export default function App() { : null, }); setNewChatAutoFocus(false); - }, [engine, machineId, rememberSurfaceFocus, space]); + }, [clearForkFocusLease, engine, machineId, rememberSurfaceFocus, space]); const focusListedSession = useCallback((selected: SessionInfo) => { const selectedEngine: Engine = selected.engine === "codex" @@ -1431,6 +1529,9 @@ export default function App() { || selected.space === "code" ? selected.space : spaceRef.current; const id = selected.session_id; + if (forkFocusLeaseRef.current?.childSessionId !== id) { + clearForkFocusLease(false); + } pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); @@ -1445,7 +1546,7 @@ export default function App() { wsRef.current?.sendGetWorkArtifacts(selectedEngine, id); } if (isMobile()) setSidebarOpen(false); - }, [requestHistory]); + }, [clearForkFocusLease, requestHistory]); useEffect(() => { const target = pendingNotificationTarget; @@ -1554,14 +1655,18 @@ export default function App() { const toggleEngine = () => { cancelPendingNotificationTarget(); const nextEngine: Engine = engine === "codex" ? "claude" : "codex"; + const nextSpace = spacesByEngineRef.current[nextEngine]; pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); setUsageActivityOpen(false); setWorkArtifactsOpen(false); setWorkProjectId(null); - prepareSurfaceSwitch(nextEngine, space); + prepareSurfaceSwitch(nextEngine, nextSpace); + engineRef.current = nextEngine; + spaceRef.current = nextSpace; setEngine(nextEngine); + setSpace(nextSpace); if (isMobile()) setSidebarOpen(false); }; @@ -1575,6 +1680,7 @@ export default function App() { setForkWorktreeError(null); setWorkArtifactsOpen(false); prepareSurfaceSwitch(engine, next); + spaceRef.current = next; setSpace(next); }; @@ -1614,9 +1720,33 @@ export default function App() { onEvent: (msg, ownership) => { if (!acceptsLifecycle()) return; if (msg.type === "history_invalidated") { - planProgressCacheRef.current.clear(msg.session_id); + const session = stateRef.current.sessions.find( + (candidate) => candidate.session_id === msg.session_id); + const scoped = ownership ?? { + machineId, + engine: session?.engine ?? engineRef.current, + space: session?.space ?? spaceRef.current, + }; + planProgressCacheRef.current.clear({ + machineId: scoped.machineId, + engine: scoped.engine, + space: scoped.space, + sid: msg.session_id, + }); } else if (msg.type === "session_rekey") { - planProgressCacheRef.current.rekey(msg.old_key, msg.session_id); + const session = stateRef.current.sessions.find( + (candidate) => candidate.session_id === msg.old_key + || candidate.session_id === msg.session_id); + const scoped = ownership ?? { + machineId, + engine: session?.engine ?? engineRef.current, + space: session?.space ?? spaceRef.current, + }; + planProgressCacheRef.current.rekey({ + machineId: scoped.machineId, + engine: scoped.engine, + space: scoped.space, + }, msg.old_key, msg.session_id); } // SessionList is a scoped response, not a broadcast catalog. Without // the exact request ownership it may belong to an older surface or @@ -1644,12 +1774,20 @@ export default function App() { ? `${machineId}\0${msg.sid}\0${msg.goal_id}` : null; if (migrationKey && authoritativeDismissed) { goalDismissMigrationsRef.current.delete(migrationKey); + for (const [requestId, pendingKey] of + goalDismissMigrationByRequestRef.current) { + if (pendingKey === migrationKey) { + goalDismissMigrationByRequestRef.current.delete(requestId); + } + } } else if (migrationKey && legacyDismissed && !goalDismissMigrationsRef.current.has(migrationKey)) { const requestId = ws.sendDismissGoalTo( msg.sid, msg.goal_id!); if (requestId) { goalDismissMigrationsRef.current.add(migrationKey); + goalDismissMigrationByRequestRef.current.set( + requestId, migrationKey); } } const reconciled = reconcileGoalUiPreference( @@ -1724,6 +1862,12 @@ export default function App() { return next; }); } else if (msg.type === "error" && msg.request_id) { + const failedMigration = + goalDismissMigrationByRequestRef.current.get(msg.request_id); + if (failedMigration && msg.code !== "wrapper_offline") { + goalDismissMigrationByRequestRef.current.delete(msg.request_id); + goalDismissMigrationsRef.current.delete(failedMigration); + } const request = goalRequestScopeByIdRef.current.get(msg.request_id); if (request) { goalRequestScopeByIdRef.current.delete(msg.request_id); @@ -2025,8 +2169,22 @@ export default function App() { } } if (msg.type === "history") { - const browseWaiters = + const completedHistory = historyRequestsRef.current.complete(msg); + const browseWaiters = completedHistory.matched; + for (const browse of completedHistory.stale) { + dispatch({ + type: "history_browse_page_failed", + sid: msg.session_id, + scopeKey: browse.scopeKey, + revision: stateRef.current.historyBrowse?.revision + ?? msg.revision, + generation: stateRef.current.historyBrowse?.generation, + viewId: browse.viewId, + windowEpoch: browse.windowEpoch, + before: browse.pendingBefore, + }); + } const retryKey = ["history", msg.session_id, msg.before ?? "", msg.revision ?? ""].join("\u0000"); let retryScheduled = false; @@ -2053,6 +2211,24 @@ export default function App() { recoverableReads.complete(retryKey); } if (msg.before) { + if (completedHistory.stale.length > 0 + && stateRef.current.focusedSid === msg.session_id) { + // The cursor came from an obsolete revision/generation. Exit + // that read-only browse lifetime and refresh the canonical head + // rather than leaving mobile paging in a permanent spinner. + dispatch({ type: "return_to_latest", sid: msg.session_id }); + const currentRuntime = + stateRef.current.runtimes[msg.session_id]; + const currentGeneration = ws.generationFor(msg.session_id) + ?? currentRuntime?.pendingHistoryGeneration + ?? currentRuntime?.historyGeneration; + const currentRevision = currentRuntime?.pendingHistoryRevision + ?? (currentRuntime?.historyInvalidated + ? undefined : currentRuntime?.historyRevision); + requestHistory( + msg.session_id, undefined, HISTORY_INITIAL_PAGE, + currentGeneration, currentRevision); + } if (msg.authoritative !== false) { void installBrowseHistoryPage(msg, browseWaiters); } else if (!retryScheduled) { @@ -2299,7 +2475,23 @@ export default function App() { ? stateRef.current.sessions.find( (session) => session.session_id === msg.parent_session_id, )?.codex_profile_id + ?? codexProfileIdForSession( + msg.parent_session_id, + stateRef.current.defaultCodexProfileId, + ) : undefined; + startForkFocusLease({ + requestId: msg.request_id, + parentSessionId: msg.parent_session_id, + childSessionId: msg.session_id, + engine: targetEngine, + space: "code", + machineId, + cwd: msg.cwd, + gitBranch: msg.git_branch, + codexProfileId: parentProfileId, + refreshAt: Date.now() + FORK_FOCUS_REFRESH_MS, + }); ws.setSessionEngines([{ session_id: msg.session_id, engine: targetEngine, @@ -2507,12 +2699,30 @@ export default function App() { stateRef.current.defaultCodexProfileId, msg, ); - normalizedListedSessions = normalized.sessions; + const lease = forkFocusLeaseSession( + forkFocusLeaseRef.current, + normalized.sessions, + machineId, + msg.engine, + listedSpace, + ); + if (!lease && forkFocusLeaseRef.current + && forkFocusLeaseRef.current.machineId === machineId + && forkFocusLeaseRef.current.engine === msg.engine + && forkFocusLeaseRef.current.space === listedSpace) { + const materialized = normalized.sessions.some( + (session) => session.session_id + === forkFocusLeaseRef.current?.childSessionId, + ); + clearForkFocusLease(false, !materialized); + } + normalizedListedSessions = lease + ? [lease, ...normalized.sessions] : normalized.sessions; historySessionListsRef.current[ surfaceKey - ] = normalized.sessions; - ws.setSessionEngines(normalized.sessions); - sessionListsBySurfaceRef.current[surfaceKey] = normalized.sessions; + ] = normalizedListedSessions; + ws.setSessionEngines(normalizedListedSessions); + sessionListsBySurfaceRef.current[surfaceKey] = normalizedListedSessions; authoritativeSurfaceListsRef.current.add(surfaceKey); bumpNotificationListRevision(); prefetchedSurfacesRef.current.add(surfaceKey); @@ -2615,7 +2825,13 @@ export default function App() { || (msg.type === "error" && msg.request_id === statusRuntimeBeforeEvent.statusRequestId) ); - dispatch({ type: "event", event: msg, ownership }); + if (msg.type === "session_list" && normalizedListedSessions) { + dispatch({ type: "event", event: { + ...msg, sessions: normalizedListedSessions, + }, ownership }); + } else { + dispatch({ type: "event", event: msg, ownership }); + } if (msg.sid && completesStatusRequest && deferredStatusRefreshRef.current.delete(msg.sid)) { const requestId = ws.sendGetStatusTo(msg.sid); @@ -2693,12 +2909,26 @@ export default function App() { dispatch({ type: "conn", connState: s, detail }); if (s !== "connected") { skillCatalogRequestsRef.current?.resetReads(); + // The fork result is authoritative only for this live connection. + // A reconnect will obtain a fresh native SessionList, so do not + // preserve a synthetic child indefinitely across disconnects. + clearForkFocusLease(false); } if (s === "connected") { goalRecoveryRequestsRef.current.clear(); goalRequestScopeByIdRef.current.clear(); + // Recovery preamble replay happens before this callback. Forget + // both halves of the old-socket correlation together, then let + // the focused Goal's fresh GetGoal response deliberately enqueue + // a new idempotent migration. Keeping only the in-flight key would + // suppress that retry forever when the replay returns an Error. + resetGoalDismissMigrationTracking( + goalDismissMigrationsRef.current, + goalDismissMigrationByRequestRef.current, + ); recoverableReads.clear(); - historyRequestsRef.current.beginConnection(); + settleCancelledHistoryBrowse( + historyRequestsRef.current.beginConnection()); clearHistoryDetailRequests(); notificationListRequestRef.current = null; bumpNotificationListRevision(); @@ -2724,6 +2954,7 @@ export default function App() { pendingSessionForkRef.current = null; pendingWorktreeForkRef.current = null; pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -2830,6 +3061,7 @@ export default function App() { }, [ acceptSkillCatalog, authed, + clearForkFocusLease, clearHistoryDetailRequests, installBrowseHistoryPage, invalidateHistoryPageScopes, @@ -2838,6 +3070,8 @@ export default function App() { requestHistory, requestSkillCatalog, setBtwOpeningFor, + settleCancelledHistoryBrowse, + startForkFocusLease, ]); // Land on the preferred/recent session only after an accepted list for the @@ -4166,6 +4400,7 @@ export default function App() { pendingSessionForkRef.current = null; pendingWorktreeForkRef.current = null; pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -4224,6 +4459,15 @@ export default function App() { const effectiveState = mergeSessionActivityState( focusedSessionState, rt.state, rt.mirroredRunning, ) ?? rt.state; + // Fail closed when a migrated cache contains colliding display aliases. A + // session-level running bit alone must never animate the wrong historical + // row, another account, or a read-only browse projection. + const activeTurnId = exactActiveTurnId( + historyView.turns, + rt.liveOwner?.turnId, + !historyView.browsing && !historyView.recovering + && (rt.state !== "idle" || rt.mirroredRunning), + ); return (
@@ -4252,8 +4496,8 @@ export default function App() { const selected = state.sessions.find((s) => s.session_id === id); if (selected) focusListedSession(selected); }} - onNew={(codexProfileId) => { if (!confirmArtifactDiscard()) return; cancelPendingNotificationTarget(); pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); setNewChatAutoFocus(true); wsRef.current?.setFocusedSid(null); dispatch({ type: "enter_new_chat", cwd: "~", cwdSource: "default", codexProfileId: codexProfileId ?? newChatCodexProfileId }); if (isMobile()) setSidebarOpen(false); }} - onNewInDir={(cwd) => { if (!confirmArtifactDiscard()) return; cancelPendingNotificationTarget(); pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); setNewChatAutoFocus(true); wsRef.current?.setFocusedSid(null); dispatch({ type: "enter_new_chat", cwd, cwdSource: "explicit", codexProfileId: newChatCodexProfileId }); if (isMobile()) setSidebarOpen(false); }} + onNew={(codexProfileId) => { if (!confirmArtifactDiscard()) return; clearForkFocusLease(false); cancelPendingNotificationTarget(); pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); setNewChatAutoFocus(true); wsRef.current?.setFocusedSid(null); dispatch({ type: "enter_new_chat", cwd: "~", cwdSource: "default", codexProfileId: codexProfileId ?? newChatCodexProfileId }); if (isMobile()) setSidebarOpen(false); }} + onNewInDir={(cwd) => { if (!confirmArtifactDiscard()) return; clearForkFocusLease(false); cancelPendingNotificationTarget(); pendingCreateRef.current = null; setCreateError(null); setStatusOpenSid(null); setNewChatAutoFocus(true); wsRef.current?.setFocusedSid(null); dispatch({ type: "enter_new_chat", cwd, cwdSource: "explicit", codexProfileId: newChatCodexProfileId }); if (isMobile()) setSidebarOpen(false); }} onClose={() => setSidebarOpen(false)} onRename={(id, title) => wsRef.current?.sendRenameSession(id, title, engine, space)} onArchive={(id, archived) => { wsRef.current?.sendArchiveSession(id, archived, engine, space); }} @@ -4279,6 +4523,9 @@ export default function App() { const target = deleted ? sessionCommandTarget(deleted, engine, space) : { engine, space }; + if (forkFocusLeaseRef.current?.childSessionId === id) { + clearForkFocusLease(false); + } composerDraftsRef.current.delete(composerDraftKey( machineId, target.space, target.engine, id, )); @@ -4288,7 +4535,8 @@ export default function App() { type: "enter_new_chat", cwd: "~", cwdSource: "default", codexProfileId: newChatCodexProfileId, }); - wsRef.current?.sendDeleteSession(id, engine, space); + wsRef.current?.sendDeleteSession( + id, target.engine, target.space); }} onForkWorktree={openForkWorktree} onMigrate={openSessionMigration} @@ -4422,8 +4670,9 @@ export default function App() { onSend={sendFirstMessage} /> ) : ( <> - diff --git a/web/src/chat-dialog-geometry.ts b/web/src/chat-dialog-geometry.ts new file mode 100644 index 0000000..2b9fb9f --- /dev/null +++ b/web/src/chat-dialog-geometry.ts @@ -0,0 +1,352 @@ +import { + useLayoutEffect, + useState, + type RefObject, +} from "react"; + +export interface ChatDialogGeometry { + left: number; + top: number; + width: number; + maxHeight: number; +} + +export interface AnchoredPopoverGeometry extends ChatDialogGeometry { + placement: "above" | "below"; +} + +interface DialogGeometryOptions { + open: boolean; + maxWidth: number; + maxHeight: number; + scopeRef?: RefObject; + gutter?: number; + minimumHeight?: number; +} + +interface AnchoredPopoverGeometryOptions { + open: boolean; + anchorRef: RefObject; + maxWidth: number; + maxHeight: number; + gap?: number; + gutter?: number; + minimumHeight?: number; +} + +interface Bounds { + left: number; + top: number; + right: number; + bottom: number; +} + +function finite(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) ? value : fallback; +} + +function cssPixelProperty(name: string): number | null { + const raw = getComputedStyle(document.documentElement) + .getPropertyValue(name).trim(); + if (!/^-?(?:\d+|\d*\.\d+)px$/.test(raw)) return null; + const value = Number.parseFloat(raw); + return Number.isFinite(value) ? value : null; +} + +function visualBounds(): Bounds { + const viewport = window.visualViewport; + const layoutWidth = Math.max( + 1, + window.innerWidth || document.documentElement.clientWidth, + ); + const layoutHeight = Math.max( + 1, + window.innerHeight || document.documentElement.clientHeight, + ); + const left = finite(viewport?.offsetLeft, 0); + const top = finite(viewport?.offsetTop, 0); + const width = Math.max(1, finite(viewport?.width, layoutWidth)); + const height = Math.max(1, finite(viewport?.height, layoutHeight)); + const visual = { + left, + top, + right: left + width, + bottom: top + height, + }; + + // useMobileViewport mirrors the keyboard-sized visual viewport into these + // variables. Reading the px form also covers the brief Safari interval in + // which the CSS shell has settled but visualViewport is still catching up. + const appTop = cssPixelProperty("--app-offset-top"); + const appHeight = cssPixelProperty("--app-height"); + if (appTop === null || appHeight === null || appHeight <= 0) return visual; + const constrainedTop = Math.max(visual.top, appTop); + const constrainedBottom = Math.min(visual.bottom, appTop + appHeight); + return constrainedBottom > constrainedTop + ? { ...visual, top: constrainedTop, bottom: constrainedBottom } + : visual; +} + +function elementBounds(element: Element): Bounds | null { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }; +} + +function intersection(first: Bounds, second: Bounds): Bounds | null { + const result = { + left: Math.max(first.left, second.left), + top: Math.max(first.top, second.top), + right: Math.min(first.right, second.right), + bottom: Math.min(first.bottom, second.bottom), + }; + return result.right > result.left && result.bottom > result.top + ? result + : null; +} + +function visibleThreadShell(scope: HTMLElement | null): HTMLElement | null { + const containingThread = scope?.closest(".thread-shell"); + if (containingThread && elementBounds(containingThread)) { + return containingThread; + } + + const pane = scope?.closest(".pane") + ?? document.querySelector(".pane"); + const paneThread = pane?.querySelector(".thread-shell"); + if (paneThread && elementBounds(paneThread)) return paneThread; + + return [...document.querySelectorAll(".thread-shell")] + .find((element) => elementBounds(element) !== null) ?? null; +} + +function fallbackChatBounds(scope: HTMLElement | null): Bounds | null { + const pane = scope?.closest(".pane") + ?? document.querySelector(".pane"); + if (!pane) return null; + const paneBounds = elementBounds(pane); + if (!paneBounds) return null; + const header = pane.querySelector(".c-head"); + const composer = pane.querySelector(".composer"); + const headerBounds = header ? elementBounds(header) : null; + const composerBounds = composer ? elementBounds(composer) : null; + return { + ...paneBounds, + top: Math.max(paneBounds.top, headerBounds?.bottom ?? paneBounds.top), + bottom: Math.min( + paneBounds.bottom, + composerBounds?.top ?? paneBounds.bottom, + ), + }; +} + +function sameGeometry( + first: ChatDialogGeometry | null, + second: ChatDialogGeometry, +): boolean { + if (!first) return false; + return Math.abs(first.left - second.left) < 0.25 + && Math.abs(first.top - second.top) < 0.25 + && Math.abs(first.width - second.width) < 0.25 + && Math.abs(first.maxHeight - second.maxHeight) < 0.25; +} + +function sameAnchoredGeometry( + first: AnchoredPopoverGeometry | null, + second: AnchoredPopoverGeometry, +): boolean { + return sameGeometry(first, second) + && first?.placement === second.placement; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum); +} + +/** Center a floating dialog in the visible conversation, above the composer. */ +export function useChatDialogGeometry({ + open, + maxWidth, + maxHeight, + scopeRef, + gutter = 16, + minimumHeight = 96, +}: DialogGeometryOptions): ChatDialogGeometry | null { + const [geometry, setGeometry] = useState(null); + + useLayoutEffect(() => { + if (!open) { + setGeometry(null); + return; + } + + let frame: number | null = null; + const scope = scopeRef?.current ?? null; + const threadShell = visibleThreadShell(scope); + const resizeObserver = typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => schedule()); + + const place = () => { + frame = null; + const visual = visualBounds(); + const chat = (threadShell && elementBounds(threadShell)) + ?? fallbackChatBounds(scope); + const bounds = chat ? intersection(visual, chat) ?? visual : visual; + const rawWidth = Math.max(1, bounds.right - bounds.left); + const availableWidth = rawWidth > gutter * 2 + ? rawWidth - gutter * 2 + : rawWidth; + const availableHeight = Math.max(1, bounds.bottom - bounds.top - gutter * 2); + const usableHeight = availableHeight >= minimumHeight + ? availableHeight + : Math.max(1, bounds.bottom - bounds.top); + const next = { + left: (bounds.left + bounds.right) / 2, + top: (bounds.top + bounds.bottom) / 2, + width: Math.min(maxWidth, availableWidth), + maxHeight: Math.min(maxHeight, usableHeight), + }; + setGeometry((current) => sameGeometry(current, next) ? current : next); + }; + function schedule() { + if (frame !== null) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(place); + } + + place(); + window.addEventListener("resize", schedule); + window.visualViewport?.addEventListener("resize", schedule); + window.visualViewport?.addEventListener("scroll", schedule); + if (threadShell) resizeObserver?.observe(threadShell); + const pane = scope?.closest(".pane") + ?? document.querySelector(".pane"); + if (pane && pane !== threadShell) resizeObserver?.observe(pane); + + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + window.removeEventListener("resize", schedule); + window.visualViewport?.removeEventListener("resize", schedule); + window.visualViewport?.removeEventListener("scroll", schedule); + resizeObserver?.disconnect(); + }; + }, [gutter, maxHeight, maxWidth, minimumHeight, open, scopeRef]); + + return open ? geometry : null; +} + +/** Place a floating card beside its trigger without leaving the chat view. */ +export function useAnchoredPopoverGeometry({ + open, + anchorRef, + maxWidth, + maxHeight, + gap = 8, + gutter = 16, + minimumHeight = 64, +}: AnchoredPopoverGeometryOptions): AnchoredPopoverGeometry | null { + const [geometry, setGeometry] = useState(null); + + useLayoutEffect(() => { + if (!open) { + setGeometry(null); + return; + } + + let frame: number | null = null; + const anchor = anchorRef.current; + const scope = anchor; + const threadShell = visibleThreadShell(scope); + const pane = scope?.closest(".pane") + ?? document.querySelector(".pane"); + const composer = pane?.querySelector(".composer") ?? null; + const resizeObserver = typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => schedule()); + + const place = () => { + frame = null; + const anchorBounds = anchor ? elementBounds(anchor) : null; + if (!anchorBounds) { + setGeometry(null); + return; + } + + const visual = visualBounds(); + const chat = (threadShell && elementBounds(threadShell)) + ?? fallbackChatBounds(scope); + const bounds = chat ? intersection(visual, chat) ?? visual : visual; + const rawWidth = Math.max(1, bounds.right - bounds.left); + const horizontalGutter = Math.min(gutter, Math.max(0, (rawWidth - 1) / 2)); + const availableWidth = Math.max(1, rawWidth - horizontalGutter * 2); + const width = Math.min(maxWidth, availableWidth); + const minimumCenter = bounds.left + horizontalGutter + width / 2; + const maximumCenter = bounds.right - horizontalGutter - width / 2; + const anchorCenter = (anchorBounds.left + anchorBounds.right) / 2; + const left = minimumCenter <= maximumCenter + ? clamp(anchorCenter, minimumCenter, maximumCenter) + : (bounds.left + bounds.right) / 2; + const verticalGutter = Math.min( + gutter, + Math.max(0, (bounds.bottom - bounds.top - 1) / 2), + ); + const safeTop = bounds.top + verticalGutter; + const safeBottom = bounds.bottom - verticalGutter; + const aboveTop = anchorBounds.top - gap; + const belowTop = anchorBounds.bottom + gap; + const aboveHeight = Math.max(0, aboveTop - safeTop); + const belowHeight = Math.max(0, safeBottom - belowTop); + const usableHeight = Math.min( + maxHeight, + Math.max(1, minimumHeight), + ); + // Preserve the preferred above-anchor layout whenever it has enough room + // for a useful card. Near the top edge, flip below instead of collapsing + // a fully populated Plan to a one-pixel scrolling viewport. + const placement = aboveHeight >= usableHeight || aboveHeight >= belowHeight + ? "above" as const + : "below" as const; + const top = placement === "above" ? aboveTop : belowTop; + const availableHeight = placement === "above" + ? aboveHeight : belowHeight; + const next = { + left, + top, + width, + maxHeight: Math.min(maxHeight, Math.max(1, availableHeight)), + placement, + }; + setGeometry((current) => sameAnchoredGeometry(current, next) + ? current : next); + }; + function schedule() { + if (frame !== null) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(place); + } + + place(); + window.addEventListener("resize", schedule); + window.visualViewport?.addEventListener("resize", schedule); + window.visualViewport?.addEventListener("scroll", schedule); + document.addEventListener("scroll", schedule, true); + for (const element of new Set([anchor, threadShell, pane, composer])) { + if (element) resizeObserver?.observe(element); + } + + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + window.removeEventListener("resize", schedule); + window.visualViewport?.removeEventListener("resize", schedule); + window.visualViewport?.removeEventListener("scroll", schedule); + document.removeEventListener("scroll", schedule, true); + resizeObserver?.disconnect(); + }; + }, [anchorRef, gap, gutter, maxHeight, maxWidth, minimumHeight, open]); + + return open ? geometry : null; +} diff --git a/web/src/codex-profile-presentation.ts b/web/src/codex-profile-presentation.ts index 019613d..eede5c9 100644 --- a/web/src/codex-profile-presentation.ts +++ b/web/src/codex-profile-presentation.ts @@ -22,6 +22,21 @@ export interface CodexProfilePresentation { tone: number; } +/** Resolve account ownership from the routing id while a fresh/forked row is + * still waiting for the authoritative catalog. Multi-profile Codex wire ids + * are always ``profile@native``; falling back to the default during this gap + * would paint the wrong account's model and capability catalogs. */ +export function codexProfileIdForSession( + sessionId: string | null | undefined, + defaultProfileId: string | null | undefined, +): string | null { + if (!sessionId) return defaultProfileId ?? null; + const separator = sessionId.indexOf("@"); + return separator > 0 + ? sessionId.slice(0, separator) + : defaultProfileId ?? null; +} + export function codexProfilePresentation( profiles: readonly CodexProfileInfo[], defaultProfileId: string | null | undefined, diff --git a/web/src/compaction-orphans.ts b/web/src/compaction-orphans.ts new file mode 100644 index 0000000..2baf1d3 --- /dev/null +++ b/web/src/compaction-orphans.ts @@ -0,0 +1,157 @@ +import type { ProcessBlock, Turn } from "./domain/conversation"; + +function nativeTaskId(turn: Turn): string | undefined { + return turn.liveTaskId ?? turn.forkPointId ?? turn.codexTurnId; +} + +function exactAliases(turn: Turn): string[] { + return [turn.id, turn.clientMsgId, turn.historyTurnId] + .filter((value): value is string => !!value); +} + +function orphanNativeId( + turn: Turn, + allowCompleted: boolean, +): string | undefined { + if ((!allowCompleted && turn.done) || turn.prompt + || turn.clientMsgId || turn.historyTurnId + || turn.forkPointId || turn.checkpointId || turn.codexTurnId + || turn.liveTaskId || turn.images?.length || turn.imageRefs?.length + || turn.files?.length || turn.error || turn.interrupted + || turn.detailProjection || turn.liveSpillBlocks?.length + || turn.blocks.length === 0) return undefined; + let nativeId: string | undefined; + for (const block of turn.blocks) { + if (block.kind !== "process" || block.processKind !== "compaction" + || !block.turn_id || (nativeId && nativeId !== block.turn_id)) { + return undefined; + } + nativeId = block.turn_id; + } + return nativeId; +} + +function mergeLiveCompaction(owner: Turn, orphan: Turn): Turn | null { + const source = orphan.blocks as ProcessBlock[]; + const incoming = source[0]; + const nativeId = incoming?.turn_id; + const archive = owner.liveSpillBlocks ?? []; + if (!incoming) return null; + const observed = [ + ...owner.blocks, + ...archive, + ...(owner.detailProjection?.blocks ?? []), + ].filter((block): block is ProcessBlock => + block.kind === "process" && block.processKind === "compaction"); + if (observed.some((block) => block.item_id === incoming.item_id)) { + return owner; + } + // One native task can compact more than once. Without a cross-row order we + // cannot tell where a distinct second marker belongs, so leave both rows for + // authoritative History instead of deleting a real occurrence. + if (observed.some((block) => block.turn_id === nativeId)) return null; + const orders = [...archive, ...owner.blocks] + .map((block) => block.liveOrder); + const hasReliableOrder = orders.every( + (order): order is number => Number.isFinite(order), + ) && new Set(orders).size === orders.length; + const shift = ( + blocks: readonly (typeof owner.blocks)[number][], fallbackStart: number, + ) => + blocks.map((block, index) => ({ + ...block, + liveOrder: hasReliableOrder + ? block.liveOrder! + 1 : fallbackStart + index, + })); + const shiftedArchive = archive.length > 0 ? shift(archive, 1) : undefined; + const shifted = shift(owner.blocks, archive.length + 1); + return { + ...owner, + blocks: [{ ...incoming, liveOrder: 0 }, ...shifted], + liveSpillBlocks: shiftedArchive, + nextLiveBlockOrder: shifted.reduce( + (maximum, block) => Math.max(maximum, block.liveOrder + 1), + shiftedArchive?.reduce((maximum, block) => + Math.max(maximum, (block.liveOrder ?? -1) + 1), 1) ?? 1, + ), + }; +} + +export interface BoundCompactionOrphanReconciliation { + turns: Turn[]; + owner: Turn | null; + orphan: Turn | null; +} + +export function reconcileBoundCompactionOrphanDetailed( + turns: readonly Turn[], + ownerAliases: readonly string[], + nativeId: string, +): BoundCompactionOrphanReconciliation { + const aliases = new Set(ownerAliases.filter(Boolean)); + const owners = turns.flatMap((turn, index) => + exactAliases(turn).some((alias) => aliases.has(alias)) ? [index] : []); + const orphans = turns.flatMap((turn, index) => + orphanNativeId(turn, false) === nativeId && turn.blocks.length === 1 + ? [index] : []); + if (owners.length !== 1 || orphans.length !== 1 + || owners[0] === orphans[0]) { + return { turns: [...turns], owner: null, orphan: null }; + } + const merged = mergeLiveCompaction( + turns[owners[0]], turns[orphans[0]]); + if (!merged) return { turns: [...turns], owner: null, orphan: null }; + const next = [...turns]; + next[owners[0]] = merged; + next.splice(orphans[0], 1); + return { turns: next, owner: merged, orphan: turns[orphans[0]] }; +} + +export function reconcileBoundCompactionOrphan( + turns: readonly Turn[], + ownerAliases: readonly string[], + nativeId: string, +): Turn[] { + return reconcileBoundCompactionOrphanDetailed( + turns, ownerAliases, nativeId).turns; +} + +export function reconcileProvenCompactionOrphans( + turns: readonly Turn[], +): Turn[] { + const owners = new Map(); + const orphans = new Map(); + turns.forEach((turn, index) => { + const nativeId = nativeTaskId(turn); + if (nativeId) owners.set(nativeId, [...owners.get(nativeId) ?? [], index]); + const orphanId = orphanNativeId(turn, true); + if (orphanId) orphans.set(orphanId, + [...orphans.get(orphanId) ?? [], index]); + }); + const next = [...turns]; + const removed = new Set(); + next.forEach((owner) => { + const nativeId = nativeTaskId(owner); + const candidates = nativeId ? orphans.get(nativeId) : undefined; + if (!nativeId || !owner.prompt || owners.get(nativeId)?.length !== 1 + || candidates?.length !== 1) return; + const ownerItemIds = new Set(owner.blocks.flatMap((block) => + block.kind === "process" && block.processKind === "compaction" + && block.turn_id === nativeId ? [block.item_id] : [])); + const orphan = next[candidates[0]]; + const orphanItemIds = orphan.blocks.flatMap((block) => + block.kind === "process" && block.processKind === "compaction" + && block.turn_id === nativeId ? [block.item_id] : []); + // A native task may compact repeatedly. The native turn id proves only the + // owner task, not the occurrence, so remove a row only when every marker it + // contains is already represented by the exact source-backed item id. + if (orphanItemIds.length !== orphan.blocks.length + || orphanItemIds.length === 0 + || !orphanItemIds.every((itemId) => ownerItemIds.has(itemId))) return; + // The source-backed History owner is canonical. The cache/live orphan is + // now proven to contain only exact duplicate occurrences; copying any of + // its payload back would resurrect stale or duplicated compactions. + removed.add(candidates[0]); + }); + return next.filter((_, index) => !removed.has(index)); +} diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx index 8473dad..1906795 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -27,9 +27,19 @@ import { type SendMode, } from "../composer-submit"; import { canEnqueueQuery, type QueueCapacity } from "../runtime-drain"; -import { effortsFor, modelsFor, type Catalog } from "../data"; +import { + effortNameForDisplay, modelsFor, type Catalog, +} from "../data"; import { QueuedQueryChip } from "./QueuedQueryDialog"; import type { InlineImageAsset } from "../inline-image-assets"; +import { + composePastePrompt, + LONG_PASTE_THRESHOLD, + makeComposerPaste, +} from "../composer-pastes"; +import { PasteCards } from "./PasteCards"; +import { uuid } from "../util"; +import { exactActiveTurnId } from "../process-blocks"; interface Props { sid?: string; @@ -89,6 +99,11 @@ export function BtwPanel(p: Props) { const input = draft.input; const turns = p.rt?.turns ?? []; const runtimeState = p.rt?.state ?? "idle"; + const activeTurnId = exactActiveTurnId( + turns, + p.rt?.liveOwner?.turnId, + runtimeState !== "idle" || p.rt?.mirroredRunning === true, + ); // The query outbox can be awaiting its first authoritative echo while the // last lifecycle frame still says idle. Treat that short acceptance window // as settling-busy so a second submit becomes pending/queued instead of @@ -98,7 +113,7 @@ export function BtwPanel(p: Props) { ? "draining" : runtimeState; const runtimeBusy = isComposerBusy(submitState); const busy = !!p.opening || runtimeBusy; - const hasText = input.trim().length > 0; + const hasText = input.trim().length > 0 || draft.pastes.length > 0; const updateDraft = useCallback(( update: (current: ComposerDraft) => ComposerDraft, @@ -117,7 +132,7 @@ export function BtwPanel(p: Props) { })); }, [updateDraft]); const clearDraft = useCallback(() => { - updateDraft(() => ({ input: "", images: [], files: [] })); + updateDraft(() => ({ input: "", images: [], files: [], pastes: [] })); }, [updateDraft]); useLayoutEffect(() => { @@ -168,7 +183,12 @@ export function BtwPanel(p: Props) { const submit = (value = taRef.current?.value ?? input) => { if (p.opening || !p.sid) return; - const prompt = value.trim(); + const composed = composePastePrompt(draft.pastes, value.trim()); + if (!composed.ok) { + flash(`消息内容超过上限(最多 ${composed.maxChars.toLocaleString()} 个字符)`); + return; + } + const prompt = composed.prompt; const query: PendingQuery = { prompt }; if (runtimeBusy) { const action = classifyBusySubmit( @@ -214,7 +234,7 @@ export function BtwPanel(p: Props) { const value = taRef.current?.value ?? input; // Stopping is an explicit button action. Empty Enter goes through submit // and remains a no-op. - if (runtimeBusy && !value.trim()) { + if (runtimeBusy && !value.trim() && draft.pastes.length === 0) { if (runtimeState === "running") p.onInterrupt(); return; } @@ -227,11 +247,7 @@ export function BtwPanel(p: Props) { ? (modelList.find((candidate) => candidate.id === p.rt?.model) ?? { id: p.rt.model, name: p.rt.model, ds: "", ic: "cpu" }) : null; - const effortList = effortsFor(p.engine, model?.id, p.catalog); - const effort = p.rt?.effort - ? (effortList.find((candidate) => candidate.id === p.rt?.effort) - ?? { id: p.rt.effort, name: p.rt.effort, ds: "", ic: "gauge3" }) - : null; + const effortName = effortNameForDisplay(p.rt?.effort); const stopping = runtimeBusy && !hasText; const interruptSettling = isInterruptSettling(submitState); const primaryIsInterrupt = p.engine !== "codex"; @@ -276,6 +292,7 @@ export function BtwPanel(p: Props) { 问一个基于当前会话的侧边问题 —— 回答不会写进主线,关闭即丢弃。
: {}} onGetDiff={() => {}} onOpenFile={p.onOpenFile} imageAssets={p.imageAssets} @@ -301,6 +318,15 @@ export function BtwPanel(p: Props) { ))} )} + {draft.pastes.length > 0 && ( +
+ updateDraft((current) => ({ + ...current, pastes, + }))} /> +
+ )} {runtimeBusy && (
@@ -337,6 +363,18 @@ export function BtwPanel(p: Props) { imeSubmitRef.current.endComposition(); setInput(event.currentTarget.value); }} + onPaste={(event) => { + const text = event.clipboardData.getData("text/plain"); + if (text.length <= LONG_PASTE_THRESHOLD) return; + event.preventDefault(); + updateDraft((current) => ({ + ...current, + pastes: [ + ...current.pastes, + makeComposerPaste(text, uuid()), + ], + })); + }} onKeyDown={(event) => { if (!imeSubmitRef.current.shouldSubmitKey({ key: event.key, @@ -366,7 +404,7 @@ export function BtwPanel(p: Props) { + disabled={busy}>{effortName ?? "强度读取中"}
(null); const contentSizerRef = useRef(null); @@ -409,11 +413,12 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading // non-destructive recovery path. Deep-history browsing supplies an explicit // stable view id: revision/view changes reset, window paging does not. const resolvedHistoryViewId = historyViewId ?? historyViewRevision ?? ""; - const scrollScope = historyViewId == null + const incomingScrollScope = historyViewId == null ? `${historyScopeKey ?? ""}\u0000${sid ?? ""}\u0000${resolvedHistoryViewId}` : `${historyScopeKey ?? ""}\u0000${sid ?? ""}\u0000${historyRevision ?? ""}\u0000${resolvedHistoryViewId}`; const incomingHistoryPresentation: HistoryViewportPresentation = { - scope: scrollScope, + sid, + scope: incomingScrollScope, authorityScope: [ historyScopeKey ?? "", sid ?? "", historyRevision ?? "", historyGeneration ?? "", @@ -443,9 +448,23 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading presentedHistory, incomingHistoryPresentation, ); - const scopedPresentedHistory = presentedHistory.scope === scrollScope + // A revision/generation handoff can briefly expose an empty replacement. + // Keep the same session's complete old scope mounted until its rows arrive; + // same-authority rollback invalidation deliberately does not qualify. + const retainPendingHistory = loading && sid + && presentedHistory.sid === sid && presentedHistory.turns.length + && !incomingTurns.length + && presentedHistory.authorityScope + !== incomingHistoryPresentation.authorityScope; + const scopedPresentedHistory = retainPendingHistory ? presentedHistory - : transitionPresentation ?? incomingHistoryPresentation; + : (presentedHistory.scope === incomingScrollScope + ? presentedHistory + : transitionPresentation ?? incomingHistoryPresentation); + // Every scroll/virtualization transaction must use the presentation's own + // scope. During a pending empty handoff that is intentionally the old scope, + // never the incoming revision's scope. + const scrollScope = scopedPresentedHistory.scope; const { turns, hasMore, @@ -458,6 +477,10 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading useLayoutEffect(() => { const incoming = latestHistoryPresentationRef.current; + if (retainPendingHistory) { + pendingHistoryPresentationRef.current = incoming; + return; + } if (presentedHistory.scope !== incoming.scope) { const retained = acceptedHistoryViewportTransition( historyViewportLeaseRef.current, @@ -492,7 +515,7 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading incomingBrowseMode, incomingHasMore, incomingHasNewer, incomingHistoryCursor, incomingHistoryWindowEpoch, incomingTurns, incomingHistoryPresentation.authorityScope, - presentedHistory, scrollScope, + incomingScrollScope, presentedHistory, retainPendingHistory, ]); const beginHistoryViewportLease = useCallback(() => { @@ -1867,6 +1890,12 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading }; const onKeyDown = (event: KeyboardEvent) => { + // Native keyboard scrolling can update scrollTop before React receives the + // preceding scroll event (and browsers may coalesce that event with the + // next key's movement). Use the physical position at this input boundary + // as the baseline so End -> Home still registers as historyward movement. + const el = scrollRef.current; + if (el) lastScrollTopRef.current = el.scrollTop; if (["ArrowUp", "PageUp", "Home"].includes(event.key)) { markUserScrollIntent("history"); } else if (["ArrowDown", "PageDown", "End", " "].includes(event.key)) { @@ -2442,18 +2471,21 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading const activeTimeline = activeProcess || processItems.some((block) => !block.done); const finalBlocks = finalTextBlocks(t.blocks); + const enclosingTaskActive = activeTurnId === t.id; // A failed/interrupted enclosing terminal is authoritative. A // successful answer may still own genuine background agent work, // but a stale child flag must never animate beside "已打断". const terminalProblem = t.done && (!!t.interrupted || !!t.error); - const working = !terminalProblem && (!t.done || activeTimeline); + const working = !terminalProblem && ( + enclosingTaskActive || !t.done || activeTimeline); const hasProcessTimeline = processItems.length > 0 || (!!t.detailEventCount && !t.detailLoaded) || !!t.detailError; const activePhase = !working ? "complete" : hasProcessTimeline - && (activeTimeline || finalBlocks.length === 0) + && (enclosingTaskActive + || activeTimeline || finalBlocks.length === 0) ? "process" : finalBlocks.length > 0 ? "answering" : "waiting"; const showProcessTimeline = hasProcessTimeline; @@ -2462,6 +2494,10 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading // tool stream grows, so it must not be the only place which tells // the reader that the turn is still active. const showWorking = working; + // Compact can close a display segment before the enclosing native + // task reaches its terminal boundary. Completion time, copy and + // fork belong to that real boundary, never to the segment bit. + const showCompletionFooter = t.done && !working; const workingLabel = t.progress ?? (activePhase === "answering" ? "回答中" @@ -2639,7 +2675,7 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading onAuthorizeImage={onAuthorizeImage} onPreviewImage={(src, alt) => setZoom({ kind: "data", src, alt })} /> ))} - {t.done && ( + {showCompletionFooter && ( <>
{t.doneTs && {formatTime(t.doneTs)}} @@ -2659,7 +2695,7 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading )}
- {ti === turns.length - 1 && !working + {ti === turns.length - 1 &&
} )} diff --git a/web/src/components/CommandSheet.tsx b/web/src/components/CommandSheet.tsx index 3c462b5..c561cba 100644 --- a/web/src/components/CommandSheet.tsx +++ b/web/src/components/CommandSheet.tsx @@ -1,5 +1,5 @@ import { - isCmd, commandsFor, modelsFor, effortsFor, permsFor, + isCmd, commandsFor, modelsFor, effortsFor, effortIsSelectable, permsFor, permissionProfilesFor, type Cmd, type CmdGroup, type Catalog, } from "../data"; @@ -163,7 +163,9 @@ export function CommandSheet({ EFFORTS.map((ef) => ( diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 42465a0..5d58314 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -18,7 +18,7 @@ import { Icon } from "../icons"; import { clientSlashesFor, CODEX_PROMPTS, isKnownCodeOnlySlash, slashToken, matchCommands, matchSkills, parseSlash, skillToken, - modelsFor, effortsFor, permsFor, + modelsFor, effortNameForDisplay, permsFor, permissionProfileLabel, type Catalog, } from "../data"; import { CommandSheet } from "./CommandSheet"; @@ -35,9 +35,16 @@ import { } from "../composer-submit"; import { workContextMetrics } from "../work-context"; import type { ComposerDraft, ComposerDraftStore } from "../composer-drafts"; +import { + composePastePrompt, + LONG_PASTE_THRESHOLD, + makeComposerPaste, +} from "../composer-pastes"; import { PendingImageAttachments } from "./PendingImageAttachments"; import { QueuedQueryChip } from "./QueuedQueryDialog"; import { UsageMeter } from "./UsageMeter"; +import { PasteCards } from "./PasteCards"; +import { uuid } from "../util"; interface Props { draftKey: string; @@ -123,6 +130,7 @@ export function Composer(p: Props) { const input = draft.input; const images = draft.images; const files = draft.files; + const pastes = draft.pastes; const updateDraft = useCallback(( update: (current: ComposerDraft) => ComposerDraft, ) => { @@ -146,7 +154,7 @@ export function Composer(p: Props) { files: typeof next === "function" ? next(current.files) : next, })), [updateDraft]); const clearDraft = useCallback(() => updateDraft(() => ({ - input: "", images: [], files: [], + input: "", images: [], files: [], pastes: [], })), [updateDraft]); // Only the modal pickers live in state now; the "/" command palette is a live // popover DERIVED from the composer text (no second input box). @@ -254,7 +262,7 @@ export function Composer(p: Props) { // controls to the Agent SDK. Keep Remote's saved takeover preferences visible, // but never present them as the active native runtime state. const deferredClaudeControls = externalClaudeOwner !== null; - const hasText = input.trim().length > 0; + const hasText = input.trim().length > 0 || pastes.length > 0; const hasAttachments = images.length > 0 || files.length > 0; useEffect(() => { @@ -268,11 +276,11 @@ export function Composer(p: Props) { // edit: refill the input box with a past prompt (user-bubble edit button) useEffect(() => { if (editPrompt != null) { - setInput(editPrompt); + updateDraft((current) => ({ ...current, input: editPrompt, pastes: [] })); onEditConsumed(); setTimeout(() => taRef.current?.focus(), 0); } - }, [editPrompt, onEditConsumed, setInput]); + }, [editPrompt, onEditConsumed, updateDraft]); const nativeTextareaSizing = typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content"); @@ -301,9 +309,7 @@ export function Composer(p: Props) { noticeTimer.current = window.setTimeout(() => setNotice(null), 2200); }; - const onInput = (v: string) => { - setInput(v); - }; + const onInput = (v: string) => setInput(v); // Command/Skill palette = live suggestions derived from the input. A slash // matches cc-remote/native commands; "$" matches the Codex app-server's real @@ -395,7 +401,8 @@ export function Composer(p: Props) { }; }, []); - // paste images/files straight into the textarea (clipboard API) + // Keep the native textarea for reliable selection/undo/IME. Large text is + // retained privately by the draft and represented only by an editable card. const onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; @@ -407,25 +414,41 @@ export function Composer(p: Props) { if (f) files.push(f); } } - if (files.length) { e.preventDefault(); void onPickFiles(files); } + if (files.length) { e.preventDefault(); void onPickFiles(files); return; } + const text = e.clipboardData.getData("text/plain"); + if (text.length <= LONG_PASTE_THRESHOLD) return; + e.preventDefault(); + const textarea = e.currentTarget; + const id = uuid(); + updateDraft((current) => ({ + ...current, + pastes: [...current.pastes, makeComposerPaste(text, id)], + })); + window.setTimeout(() => textarea.focus(), 0); }; // Send prompt text to cc, honoring busy/queue/interrupt rules. const submitPrompt = (prompt: string) => { if (importing) { flash("请等待附件导入完成"); return; } + const composed = composePastePrompt(pastes, prompt); + if (!composed.ok) { + flash(`消息内容超过上限(最多 ${composed.maxChars.toLocaleString()} 个字符)`); + return; + } + const expandedPrompt = composed.prompt; const query: PendingQuery = { - prompt, + prompt: expandedPrompt, images: images.length ? images : undefined, files: files.length ? files : undefined, }; if (busy) { const action = classifyBusySubmit( p.state, p.sendMode, p.engine ?? "claude", - !!prompt || hasAttachments); + !!expandedPrompt || hasAttachments); if (action === "noop") return; if (action === "steer") { if (p.onSteerQuery( - prompt, + expandedPrompt, images.length ? images : undefined, files.length ? files : undefined)) { clearDraft(); resetTaHeight(); @@ -452,9 +475,9 @@ export function Composer(p: Props) { } return; } - if (!prompt && !hasAttachments) return; + if (!expandedPrompt && !hasAttachments) return; if (p.onSendQuery( - prompt, images.length ? images : undefined, files.length ? files : undefined)) { + expandedPrompt, images.length ? images : undefined, files.length ? files : undefined)) { clearDraft(); resetTaHeight(); } }; @@ -600,7 +623,7 @@ export function Composer(p: Props) { buttonSendTimerRef.current = window.setTimeout(() => { buttonSendTimerRef.current = null; const buttonPrompt = taRef.current?.value ?? input; - if (busy && !buttonPrompt.trim() && !hasAttachments) { + if (busy && !buttonPrompt.trim() && !hasAttachments && pastes.length === 0) { if (p.state === "running") p.onInterrupt(); return; } @@ -634,11 +657,7 @@ export function Composer(p: Props) { ? (MODELS_E.find((m) => m.id === p.model) || { id: p.model, name: p.model, ds: "", ic: "cpu" }) : null; - const EFFORTS_E = effortsFor(p.engine, model?.id, p.catalog); - const effort = p.effort - ? (EFFORTS_E.find((e) => e.id === p.effort) - || { id: p.effort, name: p.effort, ds: "", ic: "gauge3" }) - : null; + const effortName = effortNameForDisplay(p.effort); const perm = p.perm ? (PERMS_E.find((x) => x.id === p.perm) || { id: p.perm, name: p.perm, short: p.perm, ds: "", ic: "shield" }) @@ -757,7 +776,7 @@ export function Composer(p: Props) { )} - {hasAttachments && ( + {(hasAttachments || pastes.length > 0) && (
setImages((previous) => @@ -769,6 +788,10 @@ export function Composer(p: Props) { ))} + updateDraft((current) => ({ + ...current, pastes: nextPastes, + }))} />
)} @@ -828,7 +851,7 @@ export function Composer(p: Props) { + ? `Remote 接管后思考强度:${effortName ?? "读取中"};不是${externalClaudeOwner}当前强度` + : "思考强度"}>{effortName ?? "强度读取中"} {p.engine === "codex" && p.collaborationMode === "plan" && ( @@ -107,7 +127,7 @@ export function GoalPanel(p: Props) { )} {goalRevealed && !goal && -
} - {goalRevealed && goal &&
+ {goalRevealed && goal &&
} - {p.open && <> + {p.open && dialogGeometry && <>
-
+
@@ -158,16 +180,14 @@ export function GoalPanel(p: Props) { if (next) p.onLoadPlanDetail?.(); setPlanOpen(next); }}> - 计划 - {planPresentation.currentStep - ?? planPresentation.description - ?? planPresentation.stateLabel} + {planHeadline} {planPresentation.progressLabel} ([]); const [files, setFiles] = useState([]); + const [pastes, setPastes] = useState([]); const [importing, setImporting] = useState(false); const [creating, setCreating] = useState(false); const [sheetKind, setSheetKind] = @@ -318,7 +327,7 @@ export function NewChatView({ cwd, controlScopeKey, const selectedProfileWarning = selectedProfileMissing ? "所选 Codex 账号已移除,请重新选择。" : selectedCodexProfile?.error ?? null; - const canSend = (text.trim().length > 0 || hasAttachments) + const canSend = (text.trim().length > 0 || hasAttachments || pastes.length > 0) && !creating && !importing && !selectedProfileMissing; const modelList = modelsFor(engine, catalog); const effectiveModel = model ?? defaultModel; @@ -381,11 +390,23 @@ export function NewChatView({ cwd, controlScopeKey, const it = items[i]; if (it.kind === "file") { const f = it.getAsFile(); if (f) fs.push(f); } } - if (fs.length) { e.preventDefault(); void onPick(fs); } + if (fs.length) { e.preventDefault(); void onPick(fs); return; } + const pastedText = e.clipboardData.getData("text/plain"); + if (pastedText.length <= LONG_PASTE_THRESHOLD) return; + e.preventDefault(); + setPastes((current) => [ + ...current, + makeComposerPaste(pastedText, uuid()), + ]); }; const send = (value = taRef.current?.value ?? text) => { - const prompt = value.trim(); + const composed = composePastePrompt(pastes, value.trim()); + if (!composed.ok) { + window.alert(`消息内容超过上限(最多 ${composed.maxChars.toLocaleString()} 个字符)`); + return; + } + const prompt = composed.prompt; if ((!prompt && !hasAttachments) || creating || importing || selectedProfileMissing) return; setCreating(true); @@ -503,7 +524,7 @@ export function NewChatView({ cwd, controlScopeKey, )} - {space === "work" && !text && !hasAttachments && ( + {space === "work" && !text && !hasAttachments && pastes.length === 0 && (
{[ ["read", "整理文档", "帮我整理这份资料,输出一份结构清晰的文档。"], @@ -518,7 +539,7 @@ export function NewChatView({ cwd, controlScopeKey,
)} - {hasAttachments && ( + {(hasAttachments || pastes.length > 0) && (
setImages((previous) => @@ -530,6 +551,8 @@ export function NewChatView({ cwd, controlScopeKey, ))} +
)} diff --git a/web/src/components/PasteCards.tsx b/web/src/components/PasteCards.tsx new file mode 100644 index 0000000..08ae00e --- /dev/null +++ b/web/src/components/PasteCards.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from "react"; + +import type { ComposerPaste } from "../composer-pastes"; +import { + composerPastePreview, + countTextLines, + makeComposerPaste, +} from "../composer-pastes"; +import { Icon } from "../icons"; + +interface Props { + pastes: readonly ComposerPaste[]; + onChange: (pastes: ComposerPaste[]) => void; + disabled?: boolean; +} + +export function PasteCards({ pastes, onChange, disabled = false }: Props) { + const [editingId, setEditingId] = useState(null); + const [editingChars, setEditingChars] = useState(0); + const [editingLines, setEditingLines] = useState(1); + const editorRef = useRef(null); + const lineCountTimerRef = useRef(null); + const editingPaste = editingId + ? pastes.find((paste) => paste.id === editingId) ?? null + : null; + + const cancelLineCount = () => { + if (lineCountTimerRef.current === null) return; + window.clearTimeout(lineCountTimerRef.current); + lineCountTimerRef.current = null; + }; + + const closeEditor = () => { + cancelLineCount(); + setEditingId(null); + setEditingChars(0); + setEditingLines(1); + }; + + useEffect(() => { + if (editingId && !pastes.some((paste) => paste.id === editingId)) { + if (lineCountTimerRef.current !== null) { + window.clearTimeout(lineCountTimerRef.current); + lineCountTimerRef.current = null; + } + setEditingId(null); + setEditingChars(0); + setEditingLines(1); + } + }, [editingId, pastes]); + + useEffect(() => () => { + if (lineCountTimerRef.current !== null) { + window.clearTimeout(lineCountTimerRef.current); + } + }, []); + + if (pastes.length === 0) return null; + + return <> + {pastes.map((paste) => ( +
+ + +
+ ))} + + {editingPaste && ( +
{ + if (event.target === event.currentTarget) closeEditor(); + }}> +
{ + if (event.key === "Escape") closeEditor(); + }}> +
+ + 编辑粘贴内容 + {editingChars} 字符 · {editingLines} 行 + + +
+