From 5997e124f393e739f31b04220db62f9701d0da0d Mon Sep 17 00:00:00 2001 From: muggle-stack Date: Thu, 13 Aug 2026 15:00:43 +0800 Subject: [PATCH 01/18] fix(runtime): stabilize session recovery and presentation - preserve authoritative history handoffs and ordered live projections - reconcile fork ownership, profile-scoped receipts, effort and Work context - keep Plan and Goal controls stable across responsive layouts --- cc_remote/wrapper/claude_forks.py | 109 +- cc_remote/wrapper/codex_forks.py | 120 +- cc_remote/wrapper/codex_handle.py | 173 ++- cc_remote/wrapper/codex_models.py | 18 + cc_remote/wrapper/codex_sessions.py | 62 + cc_remote/wrapper/machine.py | 1282 ++++++++++++++++++-- cc_remote/wrapper/session_ctx.py | 6 + cc_remote/wrapper/session_plans.py | 77 +- cc_remote/wrapper/session_presentation.py | 303 ++++- cc_remote/wrapper/stream.py | 44 + cc_remote/wrapper/work_context.py | 112 +- tests/test_atomic_new_session.py | 55 + tests/test_claude_permission_state.py | 47 + tests/test_claude_session_fork.py | 43 + tests/test_claude_storage_root.py | 18 +- tests/test_codex_archived_rollout.py | 21 + tests/test_codex_context_interrupt.py | 118 ++ tests/test_codex_controls.py | 1181 +++++++++++++++++- tests/test_codex_forks.py | 38 + tests/test_codex_profiles.py | 95 ++ tests/test_codex_session_delete.py | 12 +- tests/test_codex_shared_machine.py | 164 +++ tests/test_command_reliability.py | 82 ++ tests/test_history.py | 27 +- tests/test_presentation_sync.py | 208 +++- tests/test_session_plans.py | 17 + tests/test_session_presentation.py | 179 ++- tests/test_work_context.py | 182 +++ web/package.json | 2 +- web/playwright.config.ts | 9 + web/src/App.css | 15 +- web/src/App.tsx | 248 +++- web/src/chat-dialog-geometry.ts | 318 +++++ web/src/compaction-orphans.ts | 146 +++ web/src/components/BtwPanel.tsx | 12 +- web/src/components/ChatView.tsx | 37 +- web/src/components/CommandSheet.tsx | 8 +- web/src/components/Composer.tsx | 14 +- web/src/components/GoalPanel.tsx | 29 +- web/src/components/PlanProgressPopover.tsx | 63 +- web/src/data.ts | 18 + web/src/history-browse.ts | 32 +- web/src/history-merge.ts | 104 +- web/src/history-page-cache.ts | 49 +- web/src/history-requests.ts | 189 ++- web/src/index.css | 4 +- web/src/plan-progress.ts | 70 +- web/src/protocol.ts | 2 + web/src/reducer.ts | 107 +- web/src/session-list.ts | 3 +- web/src/session-worktree.ts | 56 + web/tests/history-browse.test.ts | 67 + web/tests/history-browser.fixture.tsx | 68 +- web/tests/history-browser.spec.ts | 256 +++- web/tests/history-live-order.test.ts | 193 +++ web/tests/history-page-cache.test.ts | 81 +- web/tests/history-requests.test.ts | 159 ++- web/tests/reliability.test.ts | 836 ++++++++++++- web/tests/session-worktree.test.ts | 61 + web/vite.config.ts | 5 +- 60 files changed, 7559 insertions(+), 495 deletions(-) create mode 100644 web/src/chat-dialog-geometry.ts create mode 100644 web/src/compaction-orphans.ts create mode 100644 web/tests/history-live-order.test.ts 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_forks.py b/cc_remote/wrapper/codex_forks.py index b8443b1..3169f78 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,97 @@ 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 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 +639,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..5d08ad0 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 @@ -1352,6 +1354,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 +1392,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 @@ -1432,6 +1436,32 @@ def __init__(self, cfg, cwd: Optional[str] = None, else codex_effort(codex_home=self.codex_home) ) # low | medium | high | xhigh 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 +1712,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: @@ -1880,6 +1930,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 @@ -3725,6 +3776,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 +3792,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 +3856,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 +3951,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 +3959,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 +4598,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.work_mode and 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) ) @@ -6372,7 +6534,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_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..f0c256a 100644 --- a/cc_remote/wrapper/codex_sessions.py +++ b/cc_remote/wrapper/codex_sessions.py @@ -11,7 +11,9 @@ import json import math import os +from pathlib import Path import re +import sqlite3 from typing import Any, Optional from cc_remote.log import logger @@ -41,6 +43,7 @@ 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$") def _codex_home(codex_home: str | os.PathLike[str] | None = None) -> str: @@ -213,6 +216,65 @@ def codex_rollout_path( ) +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 + 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 + db_path = max(candidates, key=lambda item: item[0])[1] + 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_model( default: str = "gpt-5-codex", *, diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py index ff7834a..64f9c82 100644 --- a/cc_remote/wrapper/machine.py +++ b/cc_remote/wrapper/machine.py @@ -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 ( @@ -243,9 +243,14 @@ ) from cc_remote.wrapper.codex_sessions import ( list_codex_sessions, codex_session_cwd, codex_rollout_path, codex_model, - codex_session_settings, + codex_session_settings, codex_session_presence, +) +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, ) @@ -309,6 +314,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]", @@ -966,8 +973,31 @@ 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 @@ -1539,6 +1569,11 @@ 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() # 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() @@ -1703,7 +1738,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 +1746,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 +1786,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 +1805,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 +1830,59 @@ 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 + 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 ---- @@ -2057,13 +2171,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 +2193,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)}" @@ -2127,9 +2381,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_history_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) @@ -2969,7 +3235,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( @@ -3578,6 +3843,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 +5273,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() @@ -5804,6 +6105,7 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None: try: dismissed = await asyncio.to_thread( self._session_presentation.reconcile_goal, + ctx.engine, msg.sid, goal_id, ) @@ -5902,6 +6204,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 +7182,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 +7268,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 +7567,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 +7614,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"), @@ -10094,9 +10497,16 @@ async def _build_requested_history( # 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, @@ -12447,22 +12857,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 = ( @@ -12473,6 +12882,176 @@ async def _handle_set_model(self, cmd): 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 handle. Returns the APPLIED level (may differ from the request); the caller @@ -12494,8 +13073,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 +13211,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 +13249,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 +13593,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 +13798,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( @@ -14256,6 +15003,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 +15580,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 +15632,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 +17564,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 +17598,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 +17636,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: @@ -17368,6 +18145,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 +18284,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 +18413,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 +18441,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 +18502,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 +18632,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( @@ -18410,10 +19193,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( @@ -19323,6 +20116,43 @@ 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 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: @@ -19345,6 +20175,10 @@ async def _handle_delete_session(self, cmd): error_type=type(exc).__name__, ) if isinstance(delete_result, Error): + if delete_result.message != ( + "会话删除结果暂时无法确认,请刷新后重试" + ): + await abort_fork_delete() return delete_result deleted_sids = delete_result if transient_codex_ctx: @@ -19359,6 +20193,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 +20212,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 +20220,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 +20266,7 @@ async def _handle_delete_session(self, cmd): try: await asyncio.to_thread( self._session_presentation.delete, + engine, deleted_sid, ) except SessionPresentationStoreError: @@ -19536,6 +20386,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 +20621,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 +21544,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 +21633,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 @@ -20964,7 +21819,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 +21843,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 +21863,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, @@ -21019,6 +21886,7 @@ async def _finish_same_cwd_fork( self._invalidate_codex_session_catalog() 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 +21964,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 +22044,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 +22068,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 +22089,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 +22110,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 +22133,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 +22250,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 +22461,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( @@ -21812,7 +22706,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 +22733,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 +22753,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 +22775,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, @@ -21885,6 +22794,7 @@ async def _finish_worktree_fork( self._invalidate_codex_session_catalog() 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 +23016,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) @@ -22779,6 +23691,7 @@ 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_effort = engine == "codex" and effort is not None codex_profile = ( self._codex_profile(codex_profile_id) if engine == "codex" else None @@ -23372,6 +24285,13 @@ async def codex_profile_allowed(profile_id: str) -> bool: 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 +24309,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 +24480,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 +24554,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 +24620,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 +24664,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 +24700,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 +25391,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() @@ -24795,6 +25850,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 +26065,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 +26096,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 +26107,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 +26174,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 +26188,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 +26359,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..394117f 100644 --- a/cc_remote/wrapper/session_plans.py +++ b/cc_remote/wrapper/session_plans.py @@ -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 @@ -133,7 +133,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) @@ -212,7 +244,9 @@ def retire_completed( self._plans = updated return True - def _load(self) -> OrderedDict[str, SessionPlanSnapshot]: + 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 +255,31 @@ 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} 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() for session_id, value in raw["plans"].items(): loaded[_session_id(session_id)] = _snapshot(value) 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 +287,12 @@ def _load(self) -> OrderedDict[str, SessionPlanSnapshot]: @staticmethod def _payload( plans: OrderedDict[str, SessionPlanSnapshot], + *, + profile_revision: int, ) -> bytes: return json.dumps({ - "version": 1, + "version": 2, + "profile_revision": profile_revision, "plans": { session_id: snapshot.as_dict() for session_id, snapshot in plans.items() @@ -252,12 +302,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/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..00feb2b 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,23 @@ 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 diff --git a/tests/test_codex_context_interrupt.py b/tests/test_codex_context_interrupt.py index b46c907..8fe09be 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,122 @@ 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_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..77805d2 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,610 @@ 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_does_not_promote_rollout_effort_after_authoritative_null( + 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 + + @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 + # thread/resume is authoritative: null means no thread override. + 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 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": "gpt-test", + "effort": "high", + }, + ) + + 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_effort == "high" + assert ctx.sdk.effort is None + assert ctx.sdk.applied_effort is None + assert ctx.sdk.display_effort == "medium" + assert ctx.announced_effort == "medium" + + 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) @@ -2838,6 +3745,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 +4055,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 +4070,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()) @@ -5732,7 +6677,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 +6723,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 +6738,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 +7203,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 +7322,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_forks.py b/tests/test_codex_forks.py index b228678..a8b05dc 100644 --- a/tests/test_codex_forks.py +++ b/tests/test_codex_forks.py @@ -70,6 +70,26 @@ 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.begin_delete("child") == "delete_pending" + 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.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_rollout_marker_recovery_scans_active_and_archived_with_bounds(tmp_path): active = tmp_path / "sessions" archived = tmp_path / "archived_sessions" @@ -221,3 +241,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_profiles.py b/tests/test_codex_profiles.py index 0e34722..55cf33f 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: @@ -1727,6 +1730,98 @@ 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" + + +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: diff --git a/tests/test_codex_session_delete.py b/tests/test_codex_session_delete.py index 2e31bfb..b5ee4fb 100644 --- a/tests/test_codex_session_delete.py +++ b/tests/test_codex_session_delete.py @@ -47,6 +47,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 @@ -456,7 +462,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 +649,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 +723,7 @@ async def run(): cleanup_calls, ) machine._session_plans = _DeleteRecorder("plan", cleanup_calls) - machine._session_presentation = _DeleteRecorder( + machine._session_presentation = _PresentationDeleteRecorder( "presentation", cleanup_calls, ) diff --git a/tests/test_codex_shared_machine.py b/tests/test_codex_shared_machine.py index e124122..a943dc6 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 @@ -1766,6 +1794,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 +1830,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 +1882,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_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_history.py b/tests/test_history.py index 25e3bf5..c684d2a 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1556,12 +1556,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 +1593,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()) 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_session_plans.py b/tests/test_session_plans.py index 0a387e6..d91cb69 100644 --- a/tests/test_session_plans.py +++ b/tests/test_session_plans.py @@ -88,3 +88,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_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..ed36329 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/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", "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/src/App.css b/web/src/App.css index 3a12667..38014bf 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -311,15 +311,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..1400f4f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -81,6 +81,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 +90,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 +149,7 @@ import { HistoryRequestCoordinator, HistoryDetailRequestCoordinator, resolveHistoryCwdHint, + type CancelledHistoryBrowseRequest, type HistoryBrowseRequestContext, type HistoryDetailRequestContext, } from "./history-requests"; @@ -375,6 +380,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 +457,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 +517,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 +583,8 @@ export default function App() { before, limit, resolveHistoryCwdHint(historySessionListsRef.current, sid), - )); - }, []); + ), settleCancelledHistoryBrowse); + }, [settleCancelledHistoryBrowse]); const cancelPendingNotificationTarget = useCallback(() => { setPendingNotificationTarget(null); notificationListRequestRef.current = null; @@ -551,6 +623,7 @@ export default function App() { pendingCreateRef.current = null; createRequestsRef.current.clear(); pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -564,6 +637,8 @@ export default function App() { btwDraftsRef.current.clear(); setCompletionReceipts({}); goalDismissMigrationsRef.current.clear(); + goalDismissMigrationByRequestRef.current.clear(); + planProgressCacheRef.current.reset(); sessionListsBySurfaceRef.current = {}; historySessionListsRef.current = {}; preferredSurfaceFocusRef.current = null; @@ -609,7 +684,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. @@ -727,7 +802,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 +820,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; @@ -1389,6 +1469,7 @@ export default function App() { nextSpace: Space, preserveAuthority = false, ) => { + clearForkFocusLease(false); rememberSurfaceFocus(engine, space); const surfaceKey = `${nextSpace}:${nextEngine}`; const focusScopeKey = sessionScopeKey( @@ -1421,7 +1502,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 +1512,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 +1529,7 @@ export default function App() { wsRef.current?.sendGetWorkArtifacts(selectedEngine, id); } if (isMobile()) setSidebarOpen(false); - }, [requestHistory]); + }, [clearForkFocusLease, requestHistory]); useEffect(() => { const target = pendingNotificationTarget; @@ -1614,9 +1698,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 +1752,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 +1840,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 +2147,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 +2189,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) { @@ -2300,6 +2454,18 @@ export default function App() { (session) => session.session_id === msg.parent_session_id, )?.codex_profile_id : 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 +2673,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 +2799,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 +2883,18 @@ 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(); + goalDismissMigrationByRequestRef.current.clear(); recoverableReads.clear(); - historyRequestsRef.current.beginConnection(); + settleCancelledHistoryBrowse( + historyRequestsRef.current.beginConnection()); clearHistoryDetailRequests(); notificationListRequestRef.current = null; bumpNotificationListRevision(); @@ -2724,6 +2920,7 @@ export default function App() { pendingSessionForkRef.current = null; pendingWorktreeForkRef.current = null; pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -2830,6 +3027,7 @@ export default function App() { }, [ acceptSkillCatalog, authed, + clearForkFocusLease, clearHistoryDetailRequests, installBrowseHistoryPage, invalidateHistoryPageScopes, @@ -2838,6 +3036,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 +4366,7 @@ export default function App() { pendingSessionForkRef.current = null; pendingWorktreeForkRef.current = null; pendingSessionMigrationRef.current = null; + clearForkFocusLease(false); setMigrateSession(null); setMigrateCreating(false); setMigrateError(null); @@ -4252,8 +4453,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 +4480,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 +4492,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 +4627,9 @@ export default function App() { onSend={sendFirstMessage} /> ) : ( <> - ; + gutter?: number; + minimumHeight?: number; +} + +interface AnchoredPopoverGeometryOptions { + open: boolean; + anchorRef: RefObject; + maxWidth: number; + maxHeight: number; + gap?: number; + gutter?: 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 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 immediately above its trigger without leaving view. */ +export function useAnchoredPopoverGeometry({ + open, + anchorRef, + maxWidth, + maxHeight, + gap = 8, + gutter = 16, +}: AnchoredPopoverGeometryOptions): ChatDialogGeometry | 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 top = anchorBounds.top - gap; + const safeTop = bounds.top + Math.min( + gutter, + Math.max(0, bounds.bottom - bounds.top - 1), + ); + const next = { + left, + top, + width, + maxHeight: Math.min(maxHeight, Math.max(1, top - safeTop)), + }; + 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); + 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, open]); + + return open ? geometry : null; +} diff --git a/web/src/compaction-orphans.ts b/web/src/compaction-orphans.ts new file mode 100644 index 0000000..ae5e616 --- /dev/null +++ b/web/src/compaction-orphans.ts @@ -0,0 +1,146 @@ +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 || !owner.blocks.some((block) => + block.kind === "process" && block.processKind === "compaction" + && block.turn_id === nativeId)) return; + // The source-backed History owner is canonical. The cache/live orphan is + // useful only as proof that its standalone row is disposable; 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..59a528f 100644 --- a/web/src/components/BtwPanel.tsx +++ b/web/src/components/BtwPanel.tsx @@ -27,7 +27,9 @@ 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"; @@ -227,11 +229,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"; @@ -366,7 +364,7 @@ export function BtwPanel(p: Props) { + disabled={busy}>{effortName ?? "强度读取中"} { const incoming = latestHistoryPresentationRef.current; + if (retainPendingHistory) { + pendingHistoryPresentationRef.current = incoming; + return; + } if (presentedHistory.scope !== incoming.scope) { const retained = acceptedHistoryViewportTransition( historyViewportLeaseRef.current, @@ -492,7 +511,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 +1886,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)) { 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..c3b5832 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"; @@ -634,11 +634,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" }) @@ -828,7 +824,7 @@ export function Composer(p: Props) { + ? `Remote 接管后思考强度:${effortName ?? "读取中"};不是${externalClaudeOwner}当前强度` + : "思考强度"}>{effortName ?? "强度读取中"} {p.engine === "codex" && p.collaborationMode === "plan" && ( } - {goalRevealed && goal &&
+ {goalRevealed && goal &&
} - {p.open && <> + {p.open && dialogGeometry && <>
-
+
diff --git a/web/src/components/PlanProgressPopover.tsx b/web/src/components/PlanProgressPopover.tsx index 894713d..7abc7ad 100644 --- a/web/src/components/PlanProgressPopover.tsx +++ b/web/src/components/PlanProgressPopover.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useId, - useLayoutEffect, useRef, useState, type CSSProperties, type RefObject, } from "react"; import { createPortal } from "react-dom"; +import { useAnchoredPopoverGeometry } from "../chat-dialog-geometry"; import type { ProcessBlock } from "../domain/conversation"; import { Icon } from "../icons"; import { planProgressPresentation } from "../plan-progress"; @@ -18,14 +18,6 @@ import { releaseDraggedPointer, } from "../pointer-tap"; -interface PlanPopoverPosition { - left: number; - width: number; - maxHeight: number; - top?: number; - bottom?: number; -} - export function PlanProgressContent({ block, detailLoading = false }: { block: ProcessBlock; detailLoading?: boolean; @@ -71,51 +63,13 @@ export function PlanProgressFloatingCard({ anchorRef, block, open, detailLoading?: boolean; compact?: boolean; }) { - const [position, setPosition] = useState(null); const cardRef = useRef(null); - - useLayoutEffect(() => { - if (!open) { - setPosition(null); - return; - } - const place = () => { - const trigger = anchorRef.current?.getBoundingClientRect(); - if (!trigger) return; - // WebKit's fixed-position layout viewport can differ from innerWidth by - // a few CSS pixels around the safe-area. Use the document viewport and a - // wider gutter so the card never grazes an iPhone edge. - const gutter = 20; - const viewportWidth = Math.min( - window.innerWidth, - document.documentElement.clientWidth, - ); - const width = Math.min(compact ? 320 : 360, viewportWidth - gutter * 2); - const left = Math.min( - Math.max(gutter, trigger.right - width), - viewportWidth - width - gutter, - ); - const below = window.innerHeight - trigger.bottom - gutter; - const above = trigger.top - gutter; - const openUp = below < 240 && above > below; - const available = Math.max(96, (openUp ? above : below) - 6); - setPosition({ - left, - width, - maxHeight: Math.min(compact ? 360 : 420, available), - ...(openUp - ? { bottom: window.innerHeight - trigger.top + 6 } - : { top: trigger.bottom + 6 }), - }); - }; - place(); - window.addEventListener("resize", place); - document.addEventListener("scroll", place, true); - return () => { - window.removeEventListener("resize", place); - document.removeEventListener("scroll", place, true); - }; - }, [anchorRef, compact, open]); + const position = useAnchoredPopoverGeometry({ + open, + anchorRef, + maxWidth: compact ? 360 : 400, + maxHeight: compact ? 440 : 500, + }); useEffect(() => { if (!open) return; @@ -141,7 +95,8 @@ export function PlanProgressFloatingCard({ anchorRef, block, open, return createPortal( , document.body, diff --git a/web/src/data.ts b/web/src/data.ts index bcf9002..5f7faaf 100644 --- a/web/src/data.ts +++ b/web/src/data.ts @@ -256,6 +256,24 @@ export const effortsFor = (engine?: string, model?: string | null, catalog?: Cat return engine === "codex" ? CODEX_EFFORTS : EFFORTS; }; +/** A null app-server thread effort is a real model-default state, not loading. + * Ordinary effort labels intentionally remain their raw CLI/config ids. */ +export function effortNameForDisplay( + id: string | null | undefined, +): string | null { + const normalized = id?.trim(); + return normalized + ? (normalized === "model-default" ? "模型默认" : normalized) + : null; +} + +export function effortIsSelectable( + id: string | null | undefined, +): id is string { + const normalized = id?.trim(); + return !!normalized && normalized !== "model-default"; +} + /** Default effort = the HIGHEST level the selected model supports (product decision: * always think as hard as the model allows — we deliberately ignore the server's * own `default_effort`, which is `low` for sol). Also what we clamp to when the user diff --git a/web/src/history-browse.ts b/web/src/history-browse.ts index d2610f6..b5f7eae 100644 --- a/web/src/history-browse.ts +++ b/web/src/history-browse.ts @@ -1,6 +1,7 @@ import { installAuthoritativeTurnDetailPage, mergeAuthoritativeTurnDetail, + reconcileProvenCompactionOrphans, } from "./history-merge.ts"; import { MAX_RUNTIME_COMPLETED_UNITS, @@ -197,13 +198,36 @@ function flattenSegments(segments: readonly HistoryBrowseSegment[]): Turn[] { return turns; } +/** Persist a canonical compaction repair in its owning page segment. Keeping + * empty segments is intentional: page keys and cursors remain the authority + * for walking back to an evicted neighbour even when the only polluted row in + * one byte-window page was absorbed by the adjacent canonical page. */ +function reconcileCompactionSegments( + segments: readonly HistoryBrowseSegment[], +): HistoryBrowseSegment[] { + const source = flattenSegments(segments); + const repaired = reconcileProvenCompactionOrphans(source); + if (repaired.length === source.length) return [...segments]; + // The repair is deletion-only and preserves the exact source objects. + // Display ids are not unique across every cache migration: two legitimate + // rows can share an optimistic id while carrying distinct historyTurnId + // authorities. Filtering by object identity avoids replacing both with the + // last Map entry for that display id. + const retained = new Set(repaired); + return segments.map((segment) => ({ + ...segment, + turns: segment.turns.filter((turn) => retained.has(turn)), + })); +} + function materializeProjection( projection: Omit< HistoryBrowseProjection, "turns" | "loadedPageKeys" | "oldestPageKey" | "newestPageKey" >, ): HistoryBrowseProjection { - const segments = dedupeSegments(projection.segments); + const segments = reconcileCompactionSegments( + dedupeSegments(projection.segments)); return { ...projection, segments, @@ -423,7 +447,8 @@ export function prependOlderPage( const withoutSamePage = projection.segments.filter( (segment) => segment.pageKey !== incoming.pageKey, ); - const normalized = dedupeSegments([incoming, ...withoutSamePage]); + const normalized = reconcileCompactionSegments( + dedupeSegments([incoming, ...withoutSamePage])); const bounded = boundSegments( normalized, "tail", @@ -463,7 +488,8 @@ export function appendNewerPage( const withoutSamePage = projection.segments.filter( (segment) => segment.pageKey !== incoming.pageKey, ); - const normalized = dedupeSegments([...withoutSamePage, incoming]); + const normalized = reconcileCompactionSegments( + dedupeSegments([...withoutSamePage, incoming])); const bounded = boundSegments( normalized, "head", diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts index 76d9d25..1073987 100644 --- a/web/src/history-merge.ts +++ b/web/src/history-merge.ts @@ -6,6 +6,8 @@ import type { Turn, TurnDetailProjection, } from "./domain/conversation"; +import { reconcileProvenCompactionOrphans } from "./compaction-orphans.ts"; +export { reconcileProvenCompactionOrphans } from "./compaction-orphans.ts"; function combineText(first: string, second: string): string { if (!first) return second; @@ -291,21 +293,22 @@ export function mergeDetailWithLiveTail( preferCompletedDetailPayload ? "first" : "combine", ); - const identity = (block: Block): string => block.kind === "text" - ? `text:${block.message_id}` - : block.kind === "tool" - ? `tool:${block.tool_use_id}` - : `process:${block.item_id}`; - const liveIdentities = filteredLive.map(identity); - const liveIds = new Set(liveIdentities); - const detailIdentities = filteredDetail.map(identity); - const liveOrders = filteredLive.map((block) => block.liveOrder); - const hasAuthoritativeLiveOrder = liveOrders.every( - (order): order is number => Number.isFinite(order), - ) - && new Set(liveOrders).size === liveOrders.length - && liveIds.size === liveIdentities.length - && new Set(detailIdentities).size === detailIdentities.length; + // Spill archives and the bounded live row are two slices of one reducer + // chronology. Compaction repair may rebase both slices together, so their + // array concatenation is no longer chronological. If every merged block has + // one finite unique order, use that complete proof for the final render. + // Any source-only block without liveOrder deliberately keeps the existing + // detail-first behavior below. + const mergedOrders = merged.map((block) => block.liveOrder); + if (merged.length > 0 + && mergedOrders.every( + (order): order is number => Number.isFinite(order), + ) + && new Set(mergedOrders).size === merged.length) { + return [...merged].sort( + (left, right) => left.liveOrder! - right.liveOrder!); + } + // Official Codex full/summary views can omit command/tool items while the // browser has already observed the complete interleaved live sequence. When // the source page is a subset of that live sequence, its array order is not a @@ -313,18 +316,23 @@ export function mergeDetailWithLiveTail( // commentary. Keep the source payload merge above, but paint in the complete // live order. A genuine source superset (normal paged detail + live tail) // retains its source order and simply appends the new tail as before. + const liveIds = new Set(filteredLive.map(blockIdentity)); + const liveOrders = filteredLive.map((block) => block.liveOrder); if (filteredLive.length > 0 - && hasAuthoritativeLiveOrder - && detailIdentities.every((key) => liveIds.has(key))) { - const byId = new Map(merged.map((block) => [identity(block), block])); + && liveOrders.every((order): order is number => Number.isFinite(order)) + && new Set(liveOrders).size === filteredLive.length + && liveIds.size === filteredLive.length + && new Set(filteredDetail.map(blockIdentity)).size === filteredDetail.length + && filteredDetail.every((block) => liveIds.has(blockIdentity(block)))) { + const byId = new Map(merged.map((block) => [blockIdentity(block), block])); const ordered = [...filteredLive] .sort((left, right) => left.liveOrder! - right.liveOrder!) .flatMap((block) => { - const key = identity(block); - const resolved = byId.get(key); - if (!resolved) return []; - byId.delete(key); - return [resolved]; + const key = blockIdentity(block); + const resolved = byId.get(key); + if (!resolved) return []; + byId.delete(key); + return [resolved]; }); return [...ordered, ...byId.values()]; } @@ -353,6 +361,14 @@ function nativeTaskIdentity(turn: Turn): string | undefined { return turn.liveTaskId ?? turn.forkPointId ?? turn.codexTurnId; } +function blockIdentity(block: Block): string { + return block.kind === "text" + ? `text:${block.message_id}` + : block.kind === "tool" + ? `tool:${block.tool_use_id}` + : `process:${block.item_id}`; +} + function compactionTurnAliases(turn: Turn): Set { return new Set(turn.blocks.flatMap((block) => block.kind === "process" @@ -658,6 +674,7 @@ function mergeTurn( preserveLiveOpen = false, completedTextAuthority: "first" | "second" | "combine" = "first", settledCanonicalText = false, + preserveCompleteLiveOrder = false, ): Turn { const historyImageRefs = history.imageRefs?.length ? history.imageRefs : undefined; @@ -676,6 +693,32 @@ function mergeTurn( const liveOwnsLifecycle = preserveLiveOpen && history.interrupted !== true && history.error == null; + const blocks = mergeBlocks( + history.blocks, + live.blocks, + preserveLiveOpen, + false, + true, + completedTextAuthority, + settledCanonicalText, + ); + if (preserveCompleteLiveOrder) { + const historyIds = history.blocks.map(blockIdentity); + const liveOrders = live.blocks.map((block) => block.liveOrder); + const liveIds = new Set(live.blocks.map(blockIdentity)); + const mergedIds = blocks.map(blockIdentity); + if (liveOrders.every((order): order is number => Number.isFinite(order)) + && new Set(liveOrders).size === live.blocks.length + && liveIds.size === live.blocks.length + && new Set(historyIds).size === historyIds.length + && new Set(mergedIds).size === mergedIds.length + && historyIds.every((identity) => liveIds.has(identity))) { + const byId = new Map(blocks.map((block) => [blockIdentity(block), block])); + blocks.splice(0, blocks.length, ...[...live.blocks] + .sort((left, right) => left.liveOrder! - right.liveOrder!) + .flatMap((block) => byId.get(blockIdentity(block)) ?? [])); + } + } return { ...history, id: live.id, @@ -684,15 +727,7 @@ function mergeTurn( forkPointId: history.forkPointId ?? live.forkPointId, checkpointId: history.checkpointId ?? live.checkpointId, prompt: history.prompt || live.prompt, - blocks: mergeBlocks( - history.blocks, - live.blocks, - preserveLiveOpen, - false, - true, - completedTextAuthority, - settledCanonicalText, - ), + blocks, // A transcript has no ResultMessage, so its EOF is represented by a // synthetic TurnEnd. While this same live tail is still running, that // marker is only a snapshot boundary and must not close the turn early. @@ -1192,6 +1227,8 @@ export function mergeInitialHistory( const historyTurn = merged[index]; const bound = mergeTurn( historyTurn, liveTurn, isOpenLiveTail, "first", settledCodex, + !!options.preserveLiveTailOpen && !!options.reconcileReplayOrphans + && liveTurn === live[live.length - 1], ); merged[index] = settledCodex && sharesExactTurnAlias(historyTurn, liveTurn) @@ -1247,5 +1284,6 @@ export function mergeInitialHistory( result[duplicate] = mergeTurn(turn, result[duplicate]); } } - return result; + return options.reconcileReplayOrphans + ? reconcileProvenCompactionOrphans(result) : result; } diff --git a/web/src/history-page-cache.ts b/web/src/history-page-cache.ts index 1e4d092..557ecf2 100644 --- a/web/src/history-page-cache.ts +++ b/web/src/history-page-cache.ts @@ -2,7 +2,7 @@ import { canonicalTurnId, type HistoryBrowsePage, } from "./history-browse.ts"; -import type { Turn } from "./domain/conversation.ts"; +import type { Block, Turn } from "./domain/conversation.ts"; /** Deliberately independent from cache.ts. Deep-history browsing is best-effort * page storage and must never make an upgrade/failure of the replay/session @@ -14,8 +14,10 @@ const HISTORY_PAGE_CACHE_SCOPE_INDEX = "scope"; const HISTORY_PAGE_CACHE_SESSION_INDEX = "session"; const HISTORY_PAGE_CACHE_LRU_INDEX = "lru"; const DEFAULT_HISTORY_PAGE_CACHE_BYTES = 64 * 1024 * 1024; -const LEGACY_RECORD_VERSIONS = new Set([1, 2] as const); -const RECORD_VERSION = 3; +// v4 preserves a payload-free context-compaction identity shell. Older page +// records can otherwise turn a repaired compact orphan into an unprovable +// empty row after a hard refresh, so they must be rebuilt from History. +const RECORD_VERSION = 4; export interface HistoryPageCacheSessionScope { machineId: string; @@ -29,7 +31,7 @@ export interface HistoryPageCacheScope extends HistoryPageCacheSessionScope { } export interface HistoryPageCacheStoredRecord { - version: 1 | 2 | typeof RECORD_VERSION; + version: 1 | 2 | 3 | typeof RECORD_VERSION; key: string; scopeKey: string; sessionKey: string; @@ -108,15 +110,39 @@ export function historyPageCachePageKey( } function sanitizeTurn(turn: Turn): Turn { - const summaryBlocks = turn.blocks.filter((block) => - block.kind === "text" && block.channel === "final"); + const summaryBlocks = turn.blocks.flatMap((block): Block[] => { + if (block.kind === "text" && block.channel === "final") { + return [{ ...block }]; + } + // Context compaction is lightweight narrative metadata, and its native + // turn id is the proof used to repair the historical standalone-row bug. + // Preserve a payload-free shell across page eviction/cache reload; ordinary + // command/tool/reasoning bodies still remain detail-only. + if (block.kind === "process" && block.processKind === "compaction") { + return [{ + kind: "process" as const, + item_id: block.item_id, + processKind: block.processKind, + phase: block.phase, + status: block.status, + turn_id: block.turn_id, + parent_id: block.parent_id, + title: block.title, + summary: block.summary, + duration_ms: block.duration_ms, + truncated: block.truncated, + done: block.done, + }]; + } + return []; + }); const deferredBlocks = turn.blocks.length - summaryBlocks.length; return { ...turn, // Tool/process/thinking bodies are intentionally deferred to // GetTurnDetail. Keeping their stripped shells produced dozens of // expandable "运行命令" rows with empty bodies after an IndexedDB paint. - blocks: summaryBlocks.map((block) => ({ ...block })), + blocks: summaryBlocks, // Summary pages keep canonical metadata only. Attachment bytes are fetched // lazily through GetHistoryImage when this row re-enters the viewport. images: undefined, @@ -197,10 +223,7 @@ function validRecord( ): value is HistoryPageCacheStoredRecord { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const record = value as Partial; - const compatibleVersion = record.version === RECORD_VERSION - || (LEGACY_RECORD_VERSIONS.has(record.version as 1 | 2) - && expected.engine === "codex"); - return compatibleVersion + return record.version === RECORD_VERSION && record.key === expected.key && record.scopeKey === expected.scopeKey && record.sessionKey === expected.sessionKey @@ -403,8 +426,8 @@ class IndexedDbHistoryPageStorage implements HistoryPageCacheStorage { } if (!store.indexNames.contains(HISTORY_PAGE_CACHE_LRU_INDEX)) { // The compound index exposes ordering and byte sizes through a key - // cursor. A v1 upgrade keeps existing page records and indexes them - // from the metadata already stored with each page. + // cursor. Schema upgrades index legacy records for bounded accounting; + // semantic record-version validation removes them lazily on read. store.createIndex( HISTORY_PAGE_CACHE_LRU_INDEX, ["savedAt", "byteSize", "key"], diff --git a/web/src/history-requests.ts b/web/src/history-requests.ts index 0a4aada..d8a2499 100644 --- a/web/src/history-requests.ts +++ b/web/src/history-requests.ts @@ -28,6 +28,18 @@ export interface HistoryBrowseRequestContext { anchorTurnId?: string | null; } +function sameBrowseWaiter( + left: HistoryBrowseRequestContext, + right: HistoryBrowseRequestContext, +): boolean { + return left.scopeKey === right.scopeKey + && left.viewId === right.viewId + && left.windowEpoch === right.windowEpoch + && left.pendingBefore === right.pendingBefore + && left.sourcePageKey === right.sourcePageKey + && (left.anchorTurnId ?? null) === (right.anchorTurnId ?? null); +} + /** Resolve an optional acceleration hint from ownership-accepted session lists. * * The wrapper remains authoritative. If two accepted engine/space scopes ever @@ -52,6 +64,32 @@ interface PendingHistoryRequest extends HistoryRequestKey { browseWaiters: HistoryBrowseRequestContext[]; } +interface RetiredHistoryRequest { + sid: string; + before?: string | null; + generation?: string | null; + revision?: string | null; +} + +// RelayWs can retain this many reliable commands. Keep the same bounded number +// of response authorities: a transcript scan may legitimately outlive several +// ordinary request timeouts, especially on a phone reconnecting over a slow +// uplink. Time-based expiry would then let a delayed old page consume the exact +// same-cursor request issued by the replacement connection. +const MAX_RETIRED_HISTORY_REQUESTS = 256; + +export interface HistoryRequestCompletion { + matched: HistoryBrowseRequestContext[]; + stale: HistoryBrowseRequestContext[]; +} + +export interface CancelledHistoryBrowseRequest { + sid: string; + generation?: string | null; + revision: string; + browse: HistoryBrowseRequestContext; +} + /** One authority for every focus/reconnect/rebuild history trigger. * * App historically had four independent call sites. Each generated a new @@ -63,6 +101,7 @@ interface PendingHistoryRequest extends HistoryRequestKey { export class HistoryRequestCoordinator { private connectionEpoch = 0; private readonly pending = new Map(); + private readonly retired: RetiredHistoryRequest[] = []; private readonly now: () => number; private readonly timeoutMs: number; @@ -74,20 +113,74 @@ export class HistoryRequestCoordinator { this.timeoutMs = timeoutMs; } - beginConnection(): void { + beginConnection(): CancelledHistoryBrowseRequest[] { + const cancelled = this.retirePending(); this.connectionEpoch += 1; + return cancelled; + } + + clear(): CancelledHistoryBrowseRequest[] { + const cancelled = this.cancelledBrowseWaiters(); this.pending.clear(); + this.retired.length = 0; + return cancelled; } - clear(): void { + private cancelledBrowseWaiters( + requests: Iterable = this.pending.values(), + ): CancelledHistoryBrowseRequest[] { + const cancelled: CancelledHistoryBrowseRequest[] = []; + for (const pending of requests) { + if (!pending.revision) continue; + for (const browse of pending.browseWaiters) { + cancelled.push({ + sid: pending.sid, + generation: pending.generation, + revision: pending.revision, + browse: { ...browse }, + }); + } + } + return cancelled; + } + + private boundRetired(): void { + if (this.retired.length > MAX_RETIRED_HISTORY_REQUESTS) { + this.retired.splice( + 0, + this.retired.length - MAX_RETIRED_HISTORY_REQUESTS, + ); + } + } + + private retirePending( + requests: Iterable = this.pending.values(), + ): CancelledHistoryBrowseRequest[] { + const retained = [...requests]; + const cancelled = this.cancelledBrowseWaiters(retained); + for (const pending of retained) { + this.retired.push({ + sid: pending.sid, + before: pending.before, + generation: pending.generation, + revision: pending.revision, + }); + } this.pending.clear(); + this.boundRetired(); + return cancelled; } private static key(request: HistoryRequestKey): string { return `${request.sid}\u0000${request.before ?? ""}\u0000${request.limit}`; } - request(request: HistoryRequestKey, send: () => boolean): boolean { + request( + request: HistoryRequestKey, + send: () => boolean, + onCancelled: (cancelled: CancelledHistoryBrowseRequest[]) => void = + () => undefined, + ): boolean { const key = HistoryRequestCoordinator.key(request); const existing = this.pending.get(key); const now = this.now(); @@ -111,13 +204,7 @@ export class HistoryRequestCoordinator { if (sameRevision && sameGeneration) { if (request.browse) { const duplicate = existing.browseWaiters.some((waiter) => - waiter.scopeKey === request.browse!.scopeKey - && waiter.viewId === request.browse!.viewId - && waiter.windowEpoch === request.browse!.windowEpoch - && waiter.pendingBefore === request.browse!.pendingBefore - && waiter.sourcePageKey === request.browse!.sourcePageKey - && (waiter.anchorTurnId ?? null) - === (request.browse!.anchorTurnId ?? null)); + sameBrowseWaiter(waiter, request.browse!)); if (!duplicate) existing.browseWaiters.push({ ...request.browse }); // The local anchor has a real waiter even though the immutable wire // page was already in flight. @@ -136,6 +223,20 @@ export class HistoryRequestCoordinator { // phantom pending entry which suppresses the user's next pagination // attempt while no command exists on the wire. if (!send()) return false; + if (existing) { + const cancelled = this.cancelledBrowseWaiters([existing]).filter( + (candidate) => !pending.browseWaiters.some( + (waiter) => sameBrowseWaiter(candidate.browse, waiter)), + ); + this.retired.push({ + sid: existing.sid, + before: existing.before, + generation: existing.generation, + revision: existing.revision, + }); + this.boundRetired(); + if (cancelled.length > 0) onCancelled(cancelled); + } this.pending.set(key, pending); return true; } @@ -145,19 +246,71 @@ export class HistoryRequestCoordinator { before?: string | null; generation?: string | null; revision?: string | null; - }): HistoryBrowseRequestContext[] { - const browseWaiters: HistoryBrowseRequestContext[] = []; + }): HistoryRequestCompletion { + const matched: HistoryBrowseRequestContext[] = []; + const stale: HistoryBrowseRequestContext[] = []; + let retiredMatch = -1; + for (let index = this.retired.length - 1; index >= 0; index -= 1) { + const retired = this.retired[index]; + if (retired.sid !== response.session_id + || (retired.before ?? "") !== (response.before ?? "") + || (retired.generation + && retired.generation !== response.generation) + || (retired.revision + && retired.revision !== response.revision)) continue; + retiredMatch = index; + break; + } + let matchedActive: PendingHistoryRequest | null = null; + const mismatched: [string, PendingHistoryRequest][] = []; for (const [key, pending] of this.pending) { if (pending.sid !== response.session_id || (pending.before ?? "") !== (response.before ?? "")) continue; - if (pending.generation - && pending.generation !== response.generation) continue; - if (pending.revision - && pending.revision !== response.revision) continue; - browseWaiters.push(...pending.browseWaiters.map((waiter) => ({ ...waiter }))); + if ((pending.generation + && pending.generation !== response.generation) + || (pending.revision + && pending.revision !== response.revision)) { + mismatched.push([key, pending]); + continue; + } + matchedActive = pending; + matched.push(...pending.browseWaiters.map((waiter) => ({ ...waiter }))); this.pending.delete(key); } - return browseWaiters; + if (retiredMatch >= 0) { + if (matchedActive) { + // The response can render the active waiter, but without a wire request + // id we cannot know whether it answered that request or its delayed + // predecessor. Keep one conservative tombstone for the still-possible + // response: a field remains constrained only when both requests agree. + const retired = this.retired[retiredMatch]; + this.retired[retiredMatch] = { + sid: retired.sid, + before: retired.before, + generation: retired.generation === matchedActive.generation + ? retired.generation : undefined, + revision: retired.revision === matchedActive.revision + ? retired.revision : undefined, + }; + } else { + // One response accounts for exactly one retired wire request. Preserve + // duplicate tombstones so a second delayed response cannot consume a + // newer same-cursor request later. + this.retired.splice(retiredMatch, 1); + } + } + // Only an otherwise-unattributable response proves that the active browse + // request itself crossed a revision/generation boundary. A response which + // matches a retired request is delayed old work and must leave its exact + // same-cursor replacement untouched. + if (!matchedActive && retiredMatch < 0) { + for (const [key, pending] of mismatched) { + if (pending.browseWaiters.length === 0) continue; + stale.push(...pending.browseWaiters.map((waiter) => ({ ...waiter }))); + this.pending.delete(key); + } + } + return { matched, stale }; } size(): number { diff --git a/web/src/index.css b/web/src/index.css index 0295b2d..daa6ff3 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -548,7 +548,9 @@ button.message-image-error{ font:inherit; cursor:pointer; } .plan-progress-ring svg{ position:relative; } .plan-progress-popover{ position:fixed; z-index:78; overflow:auto; box-sizing:border-box; padding:13px; border:1px solid var(--border-strong); border-radius:15px; color:var(--text); - background:var(--surface); box-shadow:var(--shadow-lg); animation:panel-in .15s var(--ease); } + transform:translate(-50%,-100%); background:color-mix(in srgb,var(--surface) 96%,var(--raised)); + box-shadow:0 18px 54px color-mix(in srgb,#000 18%,transparent),var(--shadow-lg); + overscroll-behavior:contain; } .plan-progress-popover.compact{ padding:11px; border-radius:13px; } .plan-progress-content>header{ display:flex; align-items:center; gap:9px; } .plan-progress-content>header>span:nth-child(2){ min-width:0; flex:1; display:flex; flex-direction:column; } diff --git a/web/src/plan-progress.ts b/web/src/plan-progress.ts index d484f21..8936709 100644 --- a/web/src/plan-progress.ts +++ b/web/src/plan-progress.ts @@ -15,6 +15,13 @@ export interface TurnPlanProgress { export type TurnPlanProgressSource = "runtime" | "history"; +export interface SessionPlanProgressScope { + machineId: string; + engine: "claude" | "codex"; + space: "code" | "work"; + sid: string; +} + export interface ScopedTurnPlanProgress extends TurnPlanProgress { source: TurnPlanProgressSource; } @@ -62,6 +69,9 @@ export class SessionPlanProgressCache { } resolve({ + machineId = "legacy", + engine = "codex", + space = "code", sid, runtime, history, @@ -70,6 +80,9 @@ export class SessionPlanProgressCache { recovering, runtimeLoading, }: { + machineId?: string; + engine?: "claude" | "codex"; + space?: "code" | "work"; sid: string; runtime: TurnPlanProgress | null; history: TurnPlanProgress | null; @@ -78,19 +91,20 @@ export class SessionPlanProgressCache { recovering: boolean; runtimeLoading: boolean; }): ScopedTurnPlanProgress | null { + const cacheKey = this.key({ machineId, engine, space, sid }); const selected = runtime ?? history; if (selected) { const selectedTurns = runtime ? runtimeTurns : historyTurns; const newerTurns = runtime ? [] : runtimeTurns; if (terminalPlanHasNewerTurn( selected, selectedTurns, newerTurns)) { - this.entries.delete(sid); + this.entries.delete(cacheKey); return null; } const entry = copyProgress( selected, runtime ? "runtime" : "history"); - this.entries.delete(sid); - this.entries.set(sid, entry); + this.entries.delete(cacheKey); + this.entries.set(cacheKey, entry); while (this.entries.size > this.maxEntries) { const oldest = this.entries.keys().next().value; if (typeof oldest !== "string") break; @@ -99,7 +113,7 @@ export class SessionPlanProgressCache { return entry; } - const retained = this.entries.get(sid); + const retained = this.entries.get(cacheKey); if (!retained) return null; const turns = retained.source === "history" ? historyTurns : runtimeTurns; const owner = turns.find((turn) => turnOwnsProgress( @@ -107,15 +121,15 @@ export class SessionPlanProgressCache { const newerTurns = retained.source === "history" ? runtimeTurns : []; if (owner && terminalPlanHasNewerTurn( retained, turns, newerTurns)) { - this.entries.delete(sid); + this.entries.delete(cacheKey); return null; } if (!owner && !recovering && !runtimeLoading) { - this.entries.delete(sid); + this.entries.delete(cacheKey); return null; } // Touch on focus so the bounded cache evicts genuinely old sessions first. - this.entries.delete(sid); + this.entries.delete(cacheKey); const resolved = { ...retained, detailLoading: owner?.detailLoading === true, @@ -123,21 +137,45 @@ export class SessionPlanProgressCache { ? !owner.detailLoaded && (owner.detailEventCount ?? 0) > 0 : retained.needsDetail, }; - this.entries.set(sid, resolved); + this.entries.set(cacheKey, resolved); return resolved; } - clear(sid: string): void { - this.entries.delete(sid); + clear(scope: SessionPlanProgressScope | string): void { + this.entries.delete(typeof scope === "string" + ? this.key({ + machineId: "legacy", engine: "codex", space: "code", sid: scope, + }) + : this.key(scope)); } - rekey(oldSid: string, sid: string): void { - if (oldSid === sid) return; - const retained = this.entries.get(oldSid); + rekey( + scope: Omit | string, + oldSid: string, + sid?: string, + ): void { + const owner = typeof scope === "string" + ? { machineId: "legacy", engine: "codex" as const, space: "code" as const } + : scope; + const oldSessionId = typeof scope === "string" ? scope : oldSid; + const sessionId = typeof scope === "string" ? oldSid : sid; + if (!sessionId) return; + if (oldSessionId === sessionId) return; + const oldKey = this.key({ ...owner, sid: oldSessionId }); + const key = this.key({ ...owner, sid: sessionId }); + const retained = this.entries.get(oldKey); if (!retained) return; - this.entries.delete(oldSid); - this.entries.delete(sid); - this.entries.set(sid, retained); + this.entries.delete(oldKey); + this.entries.delete(key); + this.entries.set(key, retained); + } + + reset(): void { + this.entries.clear(); + } + + private key(scope: SessionPlanProgressScope): string { + return [scope.machineId, scope.engine, scope.space, scope.sid].join("\0"); } } diff --git a/web/src/protocol.ts b/web/src/protocol.ts index fdc6c57..e0a3175 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -263,6 +263,8 @@ export interface SessionInfo { completion_id?: string | null; completion_unread?: boolean | null; completion_revision?: number | null; + /** Browser-only row used while the native fork catalog catches up. */ + provisional_fork?: boolean; } export interface ListSessions extends Base { type: "list_sessions"; engine?: Engine; space?: Space } export interface SwitchSession extends Base { type: "switch_session"; session_id: string; engine?: Engine; space?: Space } diff --git a/web/src/reducer.ts b/web/src/reducer.ts index e6c53c1..78ca2ab 100644 --- a/web/src/reducer.ts +++ b/web/src/reducer.ts @@ -39,6 +39,7 @@ import { mergeAuthoritativeTurnDetail, mergeDetailWithLiveTail, mergeInitialHistory, restoreCachedTurnDetails, restoreObservedLiveTurnDetails, } from "./history-merge"; +import { reconcileBoundCompactionOrphanDetailed } from "./compaction-orphans.ts"; import { installTurnDetailProjectionPage, } from "./history-detail-projection"; @@ -469,6 +470,7 @@ export type Action = | { type: "clear_all_btw" } | { type: "clear_session_list" } | { type: "restore_session_list"; sessions: SessionInfo[] } + | { type: "drop_fork_placeholder"; sid: string; parentSid: string } | { type: "set_session_pinned"; sid: string; pinned: boolean } | { type: "focus_session"; sid: string } | { type: "turn_detail_requested"; sid: string; turnId: string; before?: string | null; autoLoad?: boolean } @@ -658,6 +660,55 @@ function turnsShareIdentityAlias( firstAliases.has(alias)); } +function reconcileBoundCompactionOrphan( + runtime: SessionRuntime, + turns: Turn[], + msgIds: readonly (string | null | undefined)[], + nativeTurnId: string | null | undefined, +): Turn[] { + if (!nativeTurnId) return turns; + const aliases = msgIds.filter((value): value is string => !!value); + const reconciliation = reconcileBoundCompactionOrphanDetailed( + turns, aliases, nativeTurnId); + const repaired = reconciliation.turns; + if (repaired.length === turns.length) return turns; + const owner = reconciliation.owner; + const orphan = reconciliation.orphan; + if (!owner || !orphan) return turns; + const orphanAliases = new Set(turnIdentityAliases(orphan)); + // liveOwner stores an identity, not an object reference. If a distinct + // retained row shares the orphan's display id, that id is still a valid + // owner and must not be moved merely because the removed object used it too. + // A later ordered binding can supersede it normally. Transfer only when the + // identity disappeared with the exact orphan object. + if (runtime.liveOwner && !repaired.some((turn) => + turnHasIdentityAlias(turn, runtime.liveOwner!.turnId))) { + runtime.liveOwner = { ...runtime.liveOwner, turnId: owner.id }; + } + const hydratedIds = runtime.hydratedCacheTurnIds.flatMap((id) => { + const retained = repaired.some((turn) => turnHasIdentityAlias(turn, id)); + // Hydrated ids gate provisional detail restoration just like live-detail + // ids below. Transfer the exact removed orphan's cache provenance to its + // surviving owner, while preserving a distinct row whose display id only + // happens to collide with that orphan. + if (!orphanAliases.has(id)) return retained ? [id] : []; + return retained ? [id, owner.id] : [owner.id]; + }); + runtime.hydratedCacheTurnIds = [...new Set(hydratedIds)].filter((id) => + repaired.some((turn) => turnHasIdentityAlias(turn, id))); + const detailIds = runtime.liveDetailTurnIds.flatMap((id) => { + const retained = repaired.some((turn) => turnHasIdentityAlias(turn, id)); + // Display ids can collide across cache migrations. Preserve a still-valid + // colliding id, but also transfer the removed live orphan's observation to + // its exact surviving owner instead of trying to infer deletion by id. + if (!orphanAliases.has(id)) return retained ? [id] : []; + return retained ? [id, owner.id] : [owner.id]; + }); + runtime.liveDetailTurnIds = [...new Set(detailIds)].filter((id) => + repaired.some((turn) => turnHasIdentityAlias(turn, id))); + return repaired; +} + function pendingOptimisticSteerIndex( runtime: SessionRuntime, turns: Turn[], ): number { @@ -2027,6 +2078,23 @@ export function reduce(state: AppState, action: Action): AppState { historyRecovery: null, historyBrowse: null, retainedHistoryBrowse: null, }; + case "drop_fork_placeholder": { + const sessions = state.sessions.filter((session) => !( + session.provisional_fork + && session.session_id === action.sid + && session.forked_from_id === action.parentSid + )); + if (sessions.length === state.sessions.length) return state; + const focused = state.focusedSid === action.sid; + return { + ...state, + sessions, + focusedSid: focused ? null : state.focusedSid, + historyRecovery: focused ? null : state.historyRecovery, + historyBrowse: focused ? null : state.historyBrowse, + retainedHistoryBrowse: focused ? null : state.retainedHistoryBrowse, + }; + } case "set_session_pinned": { const sessions = setSessionPinned(state.sessions, action.sid, action.pinned); return sessions === state.sessions ? state : { ...state, sessions }; @@ -4532,7 +4600,15 @@ function reduceEvent( ts: stamp, }); } - rt.turns = turns; + const binding = rt.pendingLiveBinding; + const boundNativeTurnId = binding + && acceptedIds.has(binding.msgId) ? binding.turnId : undefined; + rt.turns = reconcileBoundCompactionOrphan( + rt, + turns, + [e.msg_id, e.client_msg_id], + boundNativeTurnId, + ); }); const sessions = e.sid ? bumpSessionActivity(next.sessions, e.sid, Math.round(e.ts * 1000)) @@ -4916,7 +4992,7 @@ function reduceEvent( if (rt.acceptancePending === e.msg_id) { clearAcceptance(rt); } - const turns = cloneTurns(rt.turns); + let turns = cloneTurns(rt.turns); const seq = typeof e.seq === "number" ? e.seq : 0; const binding = { msgId: e.msg_id, @@ -4943,6 +5019,14 @@ function reduceEvent( bindAuthoritativeActiveHistoryHead( rt, turns, e.msg_id, e.turn_id, seq); } + // A native Codex task may contain multiple visible steer rows. Once a + // newer row owns that task, an older delayed binding cannot prove which + // row a standalone compaction belongs to; leave it for canonical + // History instead of moving it into the completed predecessor. + if (bindingCanSupersedeOwner) { + turns = reconcileBoundCompactionOrphan( + rt, turns, [e.msg_id], e.turn_id); + } const exact = turns.filter((turn) => turnHasIdentityAlias(turn, e.msg_id)); if (exact.length === 1) { @@ -4968,14 +5052,17 @@ function reduceEvent( && authoritativeCandidates.length === 1) { const authoritative = authoritativeCandidates[0]; const authoritativeIndex = turns.indexOf(authoritative); - const merged = mergeInitialHistory( - [authoritative], [owner])[0] ?? owner; - merged.forkPointId = e.turn_id; - const first = Math.min(ownerIndex, authoritativeIndex); - const second = Math.max(ownerIndex, authoritativeIndex); - turns.splice(second, 1); - turns.splice(first, 1, merged); - rt.liveOwner = { turnId: merged.id, seq }; + const mergedTurns = mergeInitialHistory( + [authoritative], [owner]); + if (mergedTurns.length === 1) { + const merged = mergedTurns[0]; + merged.forkPointId = e.turn_id; + const first = Math.min(ownerIndex, authoritativeIndex); + const second = Math.max(ownerIndex, authoritativeIndex); + turns.splice(second, 1); + turns.splice(first, 1, merged); + rt.liveOwner = { turnId: merged.id, seq }; + } } } if (boundCompletedTurns) replaceWithBoundedTurns(rt, turns); diff --git a/web/src/session-list.ts b/web/src/session-list.ts index 5992ec8..646fdaf 100644 --- a/web/src/session-list.ts +++ b/web/src/session-list.ts @@ -48,7 +48,8 @@ export function normalizeSessionList( const retained = unavailableProfileIds.size === 0 ? [] : previousSessions.filter((session) => - session.engine === "codex" + !session.provisional_fork + && session.engine === "codex" && (session.space ?? "code") === listedSpace && !!session.codex_profile_id && configuredProfileIds.has(session.codex_profile_id) diff --git a/web/src/session-worktree.ts b/web/src/session-worktree.ts index 6084335..72bdaeb 100644 --- a/web/src/session-worktree.ts +++ b/web/src/session-worktree.ts @@ -25,6 +25,62 @@ export interface PendingSessionMigration { sessionId: string; } +export interface ForkFocusLease { + requestId: string; + parentSessionId: string; + childSessionId: string; + engine: "claude" | "codex"; + space: "code"; + machineId: string; + cwd: string; + gitBranch?: string | null; + codexProfileId?: string | null; + refreshAt: number; +} + +export const FORK_FOCUS_REFRESH_MS = 15_000; + +export function forkFocusLeaseSession( + lease: ForkFocusLease | null, + sessions: readonly SessionInfo[], + machineId: string, + engine: "claude" | "codex", + space: "code" | "work", +): SessionInfo | null { + if (!lease || lease.machineId !== machineId || lease.engine !== engine + || lease.space !== space + || sessions.some((session) => ( + session.session_id === lease.childSessionId + ))) return null; + return { + session_id: lease.childSessionId, + summary: "派生会话", + cwd: lease.cwd, + git_branch: lease.gitBranch, + engine: lease.engine, + space: lease.space, + forked_from_id: lease.parentSessionId, + codex_profile_id: lease.codexProfileId, + state: "idle", + provisional_fork: true, + }; +} + +export function withoutForkFocusPlaceholder( + sessions: readonly SessionInfo[], + lease: ForkFocusLease | null, +): SessionInfo[] { + if (!lease) return [...sessions]; + return sessions.filter((session) => !( + session.provisional_fork + && + session.session_id === lease.childSessionId + && session.forked_from_id === lease.parentSessionId + && session.engine === lease.engine + && (session.space ?? "code") === lease.space + )); +} + export function sessionMenuCapabilities(session: SessionInfo): SessionMenuCapabilities { return { rename: true, diff --git a/web/tests/history-browse.test.ts b/web/tests/history-browse.test.ts index a9284bf..ff88776 100644 --- a/web/tests/history-browse.test.ts +++ b/web/tests/history-browse.test.ts @@ -88,6 +88,73 @@ assert.equal(canonicalTurnId(turn("optimistic", { historyTurnId: "native-user-message", })), "native-user-message"); assert.equal(canonicalTurnId(turn("plain")), "plain"); + +const sharedDisplayIdOlder = turn("shared-display", { + historyTurnId: "canonical-older", + prompt: "older legitimate row", +}); +const sharedDisplayIdNewer = turn("shared-display", { + historyTurnId: "canonical-newer", + prompt: "newer legitimate row", +}); +const compactionNativeId = "cross-page-compaction-native"; +const compactionOwner = turn("compaction-owner", { + forkPointId: compactionNativeId, + prompt: "canonical compaction owner", + blocks: [{ + kind: "process", + item_id: "canonical-compaction", + processKind: "compaction", + phase: "end", + status: "succeeded", + turn_id: compactionNativeId, + title: "压缩上下文", + done: true, + }], +}); +const compactionOrphan = turn("compaction-orphan", { + prompt: "", + blocks: [{ + kind: "process", + item_id: "stale-compaction", + processKind: "compaction", + phase: "end", + status: "succeeded", + turn_id: compactionNativeId, + title: "压缩上下文", + done: true, + }], +}); +const displayAliasRepair = createHistoryBrowse({ + scopeKey, + sid: "display-alias-repair", + revision: "display-alias-revision", + generation: "display-alias-generation", + viewId: "display-alias-view", + baseTurns: [sharedDisplayIdNewer, compactionOwner], + basePageKey: "display-alias-head", + hasOlder: true, + olderCursor: "display-alias-cursor", + olderPage: { + pageKey: "display-alias-older", + turns: [sharedDisplayIdOlder, compactionOrphan], + hasOlder: false, + olderCursor: null, + newerPageKey: "display-alias-head", + }, + limits: limits(10), +}); +assert.deepEqual( + displayAliasRepair.projection.turns.map((item) => [ + item.id, item.historyTurnId, item.prompt, + ]), + [ + ["shared-display", "canonical-older", "older legitimate row"], + ["shared-display", "canonical-newer", "newer legitimate row"], + ["compaction-owner", undefined, "canonical compaction owner"], + ], + "cross-page compaction repair preserves rows which share only a display id", +); assert.deepEqual(nextAutoLoadDetailTurn([ turn("complete", { detailAutoLoad: true, diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx index 6f842af..1b24e61 100644 --- a/web/tests/history-browser.fixture.tsx +++ b/web/tests/history-browser.fixture.tsx @@ -1074,6 +1074,7 @@ function HistoryConversationBrowserFixture() { const longProfile = params.has("long-profile"); const manyProfiles = params.has("many-profiles"); const recoveryReplacement = params.has("recovery-replace"); + const pendingRevisionReplacement = params.has("pending-revision-replace"); const deepBrowse = params.has("deep-browse"); const runtimeBrowse = params.has("runtime-browse"); const generationShift = params.has("generation-shift"); @@ -1182,6 +1183,10 @@ function HistoryConversationBrowserFixture() { const [historyGeneration, setHistoryGeneration] = useState("fixture-generation-1"); const [historyViewRevision, setHistoryViewRevision] = useState("revision-1"); + const [historyTransitionPending, setHistoryTransitionPending] = + useState(false); + const [sessionAuthorityScope, setSessionAuthorityScope] = + useState("fixture-authority-a"); const [historyViewId, setHistoryViewId] = useState( deepBrowse ? "browse-1" : "runtime", ); @@ -1586,7 +1591,7 @@ function HistoryConversationBrowserFixture() { }); }; - const replaceHistoryRevision = () => { + const replaceHistoryRevision = (shiftAuthority = false) => { if (recoveryReplacement) { setSessions((current) => { const session = current[sid]; @@ -1610,6 +1615,11 @@ function HistoryConversationBrowserFixture() { { length: 24 }, (_, index) => finalTurn(`r${index + 1}`, 3), ); + setHistoryTransitionPending(pendingRevisionReplacement); + if (shiftAuthority) { + setSessionAuthorityScope((current) => current === "fixture-authority-a" + ? "fixture-authority-b" : "fixture-authority-a"); + } setSessions((current) => ({ ...current, [sid]: { @@ -1633,7 +1643,8 @@ function HistoryConversationBrowserFixture() { pagesLoaded: 0, }, })); - }, 0); + setHistoryTransitionPending(false); + }, pendingRevisionReplacement ? 400 : 0); }; return ( @@ -1644,6 +1655,12 @@ function HistoryConversationBrowserFixture() { { active.turns[active.turns.length - 1]?.id ?? "" } + { + historyTransitionPending ? "pending" : "ready" + } + { + sessionAuthorityScope + } + {pendingRevisionReplacement && ( + + )} {generationShift && (
) : (