From 2eead7ef1ae5f24a8d25f27bc96817890bd67cf2 Mon Sep 17 00:00:00 2001 From: Dhi13man Date: Mon, 3 Aug 2026 10:43:18 +0000 Subject: [PATCH 01/35] fix(codex): accept bounded collaboration events --- CHANGELOG.md | 4 + skivolve/codex_app_server.py | 508 ++++++++++++++++++++++++++++---- tests/test_codex_app_server.py | 511 ++++++++++++++++++++++++++++++++- 3 files changed, 972 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed28c74..1994fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Skivolve are documented in this file. The format follows ## [Unreleased] +### Fixed + +- Accepted bounded Codex collaboration lifecycles without letting child-agent traffic mutate the main result, and cleaned unreadable evaluator runtime directories without following symlinks. + ## [0.5.0] - 2026-07-29 ### Changed diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index cd82d43..d8de16f 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -41,6 +41,8 @@ _MAX_MODELS = 4_096 _MAX_SKILLS = 1_024 _MAX_COMPLETED_MESSAGES = 64 +_MAX_COLLAB_THREADS = 32 +_MAX_COLLAB_TURNS = 128 _MAX_RETAINED_TEXT_BYTES = 8 * 1024 * 1024 _MAX_RATE_LIMIT_BUCKETS = 32 _MAX_AUTH_BYTES = 1024 * 1024 @@ -122,6 +124,7 @@ _ALLOWED_ITEM_TYPES = frozenset( { "agentMessage", + "collabAgentToolCall", "commandExecution", "contextCompaction", "fileChange", @@ -185,6 +188,12 @@ def as_json(self) -> dict[str, int | str]: return asdict(self) +@dataclass(frozen=True) +class _WorkspaceRuntime: + parent_descriptor: int + directories: tuple[tuple[str, int], ...] + + class _RecoveryProbe(Protocol): def confirm_unit_clean( self, @@ -1233,6 +1242,11 @@ def __init__( self._announced_turn_id: str | None = None self._turn_completed: dict[str, Any] | None = None self._completed_messages: list[dict[str, str | None]] = [] + self._collab_thread_ids: set[str] = set() + self._collab_turn_ids: dict[str, set[str]] = {} + self._collab_turn_count = 0 + self._pending_spawn_items: dict[tuple[str, str], str | None] = {} + self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 self._last_usage: dict[str, int] | None = None self._rate_limits: dict[str, Any] | None = None @@ -1676,6 +1690,10 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: announced = _require_protocol_id( thread.get("id"), "thread/started.thread.id" ) + if self._thread_id is not None and announced != self._thread_id: + if not self._claim_collab_thread_scope(announced): + raise ProviderError("Codex thread announcement changed scope") + return if self._announced_thread_id is not None: raise ProviderError("Codex announced more than one thread") self._announced_thread_id = announced @@ -1684,26 +1702,49 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: thread_id = _require_protocol_id( params.get("threadId"), "turn/started.threadId" ) - if self._thread_id is None or thread_id != self._thread_id: - raise ProviderError("Codex turn announcement changed thread scope") turn = _require_object(params.get("turn"), "turn/started.turn") announced = _require_protocol_id(turn.get("id"), "turn/started.turn.id") + if self._thread_id is None: + raise ProviderError("Codex turn announcement changed thread scope") + if thread_id != self._thread_id: + if not self._claim_collab_thread_scope(thread_id): + raise ProviderError("Codex turn announcement changed thread scope") + turns = self._collab_turn_ids.setdefault(thread_id, set()) + if announced in turns: + raise ProviderError("Codex announced a child turn more than once") + if self._collab_turn_count >= _MAX_COLLAB_TURNS: + raise ProviderError("collaboration turn count exceeds the limit") + turns.add(announced) + self._collab_turn_count += 1 + return if self._announced_turn_id is not None: raise ProviderError("Codex announced more than one turn") self._announced_turn_id = announced return if method == "thread/tokenUsage/updated": - if not self._matches_turn(params): + main_turn = self._matches_turn(params) + if not main_turn and not self._matches_collab_turn(params): raise ProviderError("Codex token usage targeted an unknown turn") usage = _require_object(params.get("tokenUsage"), "tokenUsage") last = _require_object(usage.get("last"), "tokenUsage.last") - self._last_usage = self._validate_usage(last) + validated = self._validate_usage(last) + if main_turn: + self._last_usage = validated return if method == "item/completed": - if not self._matches_turn(params): + main_turn = self._matches_turn(params) + if not main_turn and not self._matches_collab_turn(params): raise ProviderError("Codex item completion targeted an unknown turn") item = _require_object(params.get("item"), "item/completed.item") - self._validate_item(item) + self._validate_item( + item, + owner_thread_id=_require_protocol_id( + params.get("threadId"), "item/completed.threadId" + ), + lifecycle="completed", + ) + if not main_turn: + return if item.get("type") == "agentMessage": if len(self._completed_messages) >= _MAX_COMPLETED_MESSAGES: raise ProviderError( @@ -1723,29 +1764,42 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: ) return if method == "item/started": - if not self._matches_turn(params): + if not self._matches_turn(params) and not self._matches_collab_turn(params): raise ProviderError("Codex item start targeted an unknown turn") self._validate_item( - _require_object(params.get("item"), "item/started.item") + _require_object(params.get("item"), "item/started.item"), + owner_thread_id=_require_protocol_id( + params.get("threadId"), "item/started.threadId" + ), + lifecycle="started", ) return if method == "turn/completed": completed_thread_id = _require_protocol_id( params.get("threadId"), "turn/completed.threadId" ) - if self._thread_id is None or completed_thread_id != self._thread_id: - raise ProviderError("Codex completed an unknown thread") turn = _require_object(params.get("turn"), "turn/completed.turn") completed_turn_id = _require_protocol_id( turn.get("id"), "turn/completed.turn.id" ) + encoded_size = len(_canonical_json(turn, "turn/completed.turn")) + if encoded_size > _MAX_RETAINED_TEXT_BYTES: + raise ProviderError("completed turn exceeds the retained-byte limit") + if self._thread_id is None: + raise ProviderError("Codex completed an unknown thread") + if completed_thread_id != self._thread_id: + if completed_turn_id not in self._collab_turn_ids.get( + completed_thread_id, set() + ): + raise ProviderError("Codex completed an unknown child turn") + self._validate_completed_turn(turn, completed_thread_id) + self._collab_turn_ids[completed_thread_id].remove(completed_turn_id) + return if self._turn_id is None or completed_turn_id != self._turn_id: raise ProviderError("Codex completed an unknown turn") if self._turn_completed is not None: raise ProviderError("Codex completed the same turn more than once") - encoded_size = len(_canonical_json(turn, "turn/completed.turn")) - if encoded_size > _MAX_RETAINED_TEXT_BYTES: - raise ProviderError("completed turn exceeds the retained-byte limit") + self._validate_completed_turn(turn, completed_thread_id) self._turn_completed = turn return if method in _IGNORED_NOTIFICATIONS: @@ -1755,11 +1809,211 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: raise ProviderError(f"prohibited Codex notification: {method}") raise ProviderError("unexpected Codex notification") - @staticmethod - def _validate_item(item: dict[str, Any]) -> None: + def _validate_item( + self, + item: dict[str, Any], + *, + owner_thread_id: str | None = None, + lifecycle: str | None = None, + ) -> None: item_type = _require_string(item.get("type"), "thread item type") if item_type not in _ALLOWED_ITEM_TYPES: raise ProviderError("prohibited or unknown Codex item type") + if item_type == "collabAgentToolCall": + self._validate_collab_item( + item, + owner_thread_id=owner_thread_id, + lifecycle=lifecycle, + ) + elif item_type == "agentMessage": + _require_exact_keys( + item, + "agent message", + required={"id", "text", "type"}, + optional={"memoryCitation", "phase"}, + ) + _require_protocol_id(item.get("id"), "agent message id") + if not isinstance(item.get("text"), str): + raise ProviderError("agent message text must be a string") + self._validate_message_phase(item.get("phase")) + + def _validate_collab_item( + self, + item: dict[str, Any], + *, + owner_thread_id: str | None, + lifecycle: str | None, + ) -> None: + _require_exact_keys( + item, + "collaboration item", + required={ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type", + }, + optional={"model", "prompt", "reasoningEffort"}, + ) + item_id = _require_protocol_id(item.get("id"), "collaboration item id") + sender = _require_protocol_id( + item.get("senderThreadId"), "collaboration sender thread id" + ) + if self._thread_id is None or ( + sender != self._thread_id and sender not in self._collab_thread_ids + ): + raise ProviderError("collaboration item changed sender thread scope") + if owner_thread_id is not None and sender != owner_thread_id: + raise ProviderError("collaboration item sender disagrees with its envelope") + tool = _require_string(item.get("tool"), "collaboration tool") + if tool not in {"spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent"}: + raise ProviderError("collaboration tool is unknown") + raw_receivers = _require_list( + item.get("receiverThreadIds"), + "collaboration receiver thread ids", + maximum=_MAX_COLLAB_THREADS, + ) + receivers = [ + _require_protocol_id(value, "collaboration receiver thread id") + for value in raw_receivers + ] + if len(receivers) != len(set(receivers)) or self._thread_id in receivers: + raise ProviderError("collaboration receiver thread ids are invalid") + status = _require_string(item.get("status"), "collaboration item status") + if status not in {"inProgress", "completed", "failed"}: + raise ProviderError("collaboration item status is unknown") + if lifecycle == "started" and status != "inProgress": + raise ProviderError("started collaboration item is not in progress") + if lifecycle in {"completed", "snapshot"} and status == "inProgress": + raise ProviderError("completed collaboration item is still in progress") + for field in ("model", "prompt", "reasoningEffort"): + value = item.get(field) + if value is not None and not isinstance(value, str): + raise ProviderError(f"collaboration {field} must be a string or null") + if item.get("reasoningEffort") == "": + raise ProviderError( + "collaboration reasoningEffort must be a non-empty string or null" + ) + states = _require_object(item.get("agentsStates"), "collaboration agent states") + if len(states) > _MAX_COLLAB_THREADS: + raise ProviderError("collaboration agent-state count exceeds the limit") + for raw_thread_id, raw_state in states.items(): + thread_id = _require_protocol_id( + raw_thread_id, "collaboration agent-state thread id" + ) + if thread_id not in receivers: + raise ProviderError("collaboration state targeted an unknown receiver") + state = _require_object(raw_state, "collaboration agent state") + _require_exact_keys( + state, + "collaboration agent state", + required={"status"}, + optional={"message"}, + ) + agent_status = _require_string( + state.get("status"), "collaboration agent status" + ) + if agent_status not in { + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound", + }: + raise ProviderError("collaboration agent status is unknown") + message = state.get("message") + if message is not None: + if ( + len( + _require_string(message, "collaboration agent message").encode( + "utf-8" + ) + ) + > _MAX_RETAINED_TEXT_BYTES + ): + raise ProviderError("collaboration agent message exceeds the limit") + if tool == "spawnAgent": + pending_key = (sender, item_id) + pending = pending_key in self._pending_spawn_items + expected_receiver = self._pending_spawn_items.get(pending_key) + if expected_receiver is not None and receivers != [expected_receiver]: + raise ProviderError( + "spawnAgent receiver did not match its pending child thread" + ) + unbound_slots = sum( + receiver is None for receiver in self._pending_spawn_items.values() + ) + receiver = receivers[0] if receivers else None + if status != "inProgress" and pending and expected_receiver is None: + if receiver in self._unbound_collab_thread_ids: + self._unbound_collab_thread_ids.remove(receiver) + elif receiver is not None and receiver in self._collab_thread_ids: + raise ProviderError("spawnAgent receiver was already claimed") + elif len(self._unbound_collab_thread_ids) >= unbound_slots: + raise ProviderError( + "spawnAgent receiver did not match a pending child thread" + ) + elif ( + not pending + and status != "inProgress" + and any( + receiver not in self._collab_thread_ids for receiver in receivers + ) + ): + raise ProviderError("spawnAgent completed without a pending child") + new_receivers = set(receivers) - self._collab_thread_ids + if len(self._collab_thread_ids) + len(new_receivers) > _MAX_COLLAB_THREADS: + raise ProviderError("collaboration thread count exceeds the limit") + if status == "inProgress": + if not pending: + if any( + receiver in self._collab_thread_ids for receiver in receivers + ): + raise ProviderError("spawnAgent receiver was already claimed") + if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: + raise ProviderError("pending spawn count exceeds the limit") + self._pending_spawn_items[pending_key] = receiver + elif expected_receiver is None and receiver is not None: + if receiver in self._unbound_collab_thread_ids: + self._unbound_collab_thread_ids.remove(receiver) + elif receiver in self._collab_thread_ids: + raise ProviderError("spawnAgent receiver was already claimed") + elif len(self._unbound_collab_thread_ids) >= unbound_slots: + raise ProviderError( + "spawnAgent receiver did not match a pending child thread" + ) + self._pending_spawn_items[pending_key] = receiver + else: + self._pending_spawn_items.pop(pending_key, None) + self._collab_thread_ids.update(receivers) + elif any(receiver not in self._collab_thread_ids for receiver in receivers): + raise ProviderError("collaboration item targeted an unknown child thread") + + def _validate_completed_turn( + self, turn: dict[str, Any], owner_thread_id: str + ) -> None: + status = _require_string(turn.get("status"), "completed turn status") + if status not in {"completed", "interrupted", "failed"}: + raise ProviderError("completed turn has a non-terminal status") + items = _require_list( + turn.get("items"), "completed turn items", maximum=_MAX_MESSAGES + ) + items_view = turn.get("itemsView", "full") + if items_view not in {"full", "notLoaded", "summary"}: + raise ProviderError("completed turn returned an unsupported item view") + if items_view == "notLoaded" and items: + raise ProviderError("Codex not-loaded turn items must be empty") + for index, raw_item in enumerate(items): + self._validate_item( + _require_object(raw_item, f"turn.items[{index}]"), + owner_thread_id=owner_thread_id, + lifecycle="snapshot", + ) @staticmethod def _validate_message_phase(value: Any) -> str | None: @@ -1779,27 +2033,84 @@ def _matches_turn(self, params: dict[str, Any]) -> bool: and turn_id == self._turn_id ) + def _claim_collab_thread_scope(self, thread_id: str) -> bool: + if thread_id in self._collab_thread_ids: + return True + open_spawn_slots = sum( + receiver is None for receiver in self._pending_spawn_items.values() + ) + if len(self._unbound_collab_thread_ids) >= open_spawn_slots: + return False + if len(self._collab_thread_ids) >= _MAX_COLLAB_THREADS: + raise ProviderError("collaboration thread count exceeds the limit") + self._unbound_collab_thread_ids.add(thread_id) + self._collab_thread_ids.add(thread_id) + return True + + def _matches_collab_turn(self, params: dict[str, Any]) -> bool: + thread_id = _require_protocol_id( + params.get("threadId"), "collaboration notification.threadId" + ) + turn_id = _require_protocol_id( + params.get("turnId"), "collaboration notification.turnId" + ) + return turn_id in self._collab_turn_ids.get(thread_id, set()) + def _validate_ignored_notification_scope( self, method: str, params: dict[str, Any] ) -> None: - if method.startswith("item/"): - if not self._matches_turn(params): - raise ProviderError(f"{method} omitted or changed turn scope") - return - if method.startswith("turn/"): - if not self._matches_turn(params): + if method.startswith(("item/", "turn/", "model/")): + if not self._matches_turn(params) and not self._matches_collab_turn(params): raise ProviderError(f"{method} omitted or changed turn scope") return if method == "thread/status/changed": + _require_exact_keys( + params, + "thread/status/changed", + required={"status", "threadId"}, + ) thread_id = _require_protocol_id( params.get("threadId"), "thread/status/changed.threadId" ) - if self._thread_id is None or thread_id != self._thread_id: + status = _require_object( + params.get("status"), "thread/status/changed.status" + ) + status_type = _require_string( + status.get("type"), "thread/status/changed.status.type" + ) + if status_type == "active": + _require_exact_keys( + status, + "thread/status/changed.status", + required={"activeFlags", "type"}, + ) + flags = _require_list( + status.get("activeFlags"), + "thread/status/changed.status.activeFlags", + maximum=2, + ) + if any( + _require_string(flag, "thread active flag") + not in {"waitingOnApproval", "waitingOnUserInput"} + for flag in flags + ): + raise ProviderError("thread status has an unknown active flag") + elif status_type in {"notLoaded", "idle", "systemError"}: + _require_exact_keys( + status, + "thread/status/changed.status", + required={"type"}, + ) + else: + raise ProviderError("thread status is unknown") + if self._thread_id is None: raise ProviderError("thread/status/changed changed thread scope") - return - if method.startswith("model/"): - if not self._matches_turn(params): - raise ProviderError(f"{method} omitted or changed turn scope") + if ( + thread_id != self._thread_id + and thread_id not in self._collab_thread_ids + ): + if not self._claim_collab_thread_scope(thread_id): + raise ProviderError("thread/status/changed changed thread scope") return thread_id = params.get("threadId") turn_id = params.get("turnId") @@ -1851,7 +2162,11 @@ def _finalize_turn(self) -> tuple[str, dict[str, Any]]: messages = [] for index, raw_item in enumerate(items): item = _require_object(raw_item, f"turn.items[{index}]") - self._validate_item(item) + self._validate_item( + item, + owner_thread_id=self._thread_id, + lifecycle="snapshot", + ) if item.get("type") == "agentMessage": messages.append( { @@ -3137,13 +3452,15 @@ def _remove_invocation_root(path: Path) -> None: raise ProviderError(f"cannot remove Codex invocation root: {exc}") from exc -def _prepare_workspace_runtime(workspace: Path) -> tuple[Path, ...]: +def _prepare_workspace_runtime(workspace: Path) -> _WorkspaceRuntime: paths = ( workspace / ".skill-eval-tmp", workspace / ".skill-eval-cache", workspace / ".skill-eval-home", ) created: list[Path] = [] + parent_descriptor: int | None = None + directories: list[tuple[str, int]] = [] try: for path in paths: if path.exists() or path.is_symlink(): @@ -3152,32 +3469,125 @@ def _prepare_workspace_runtime(workspace: Path) -> tuple[Path, ...]: ) path.mkdir(mode=_PRIVATE_DIRECTORY_MODE) created.append(path) + flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_DIRECTORY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + parent_descriptor = os.open(workspace, flags) + for path in paths: + descriptor = os.open(path.name, flags, dir_fd=parent_descriptor) + os.fchmod(descriptor, _PRIVATE_DIRECTORY_MODE) + directories.append((path.name, descriptor)) except BaseException: + for _name, descriptor in reversed(directories): + os.close(descriptor) + if parent_descriptor is not None: + os.close(parent_descriptor) for path in reversed(created): shutil.rmtree(path, ignore_errors=True) raise - return paths + assert parent_descriptor is not None + return _WorkspaceRuntime(parent_descriptor, tuple(directories)) -def _cleanup_workspace_runtime(paths: tuple[Path, ...]) -> None: - for path in paths: - try: - metadata = path.lstat() - except FileNotFoundError: - continue - except OSError as exc: - raise ProviderError( - f"cannot inspect workspace runtime path: {exc}" - ) from exc - try: - if stat.S_ISLNK(metadata.st_mode) or stat.S_ISREG(metadata.st_mode): - path.unlink() - elif stat.S_ISDIR(metadata.st_mode): - shutil.rmtree(path) - else: - raise ProviderError("workspace runtime path changed to an unsafe type") - except OSError as exc: - raise ProviderError(f"cannot clean workspace runtime path: {exc}") from exc +def _open_workspace_runtime_directory( + parent_descriptor: int, + name: str, + expected_identity: tuple[int, int], +) -> int: + path_flag = getattr(os, "O_PATH", None) + if path_flag is None: + raise ProviderError("workspace runtime cleanup requires Linux O_PATH") + path_descriptor = os.open( + name, + path_flag | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_descriptor, + ) + try: + metadata = os.fstat(path_descriptor) + if ( + not stat.S_ISDIR(metadata.st_mode) + or ( + metadata.st_dev, + metadata.st_ino, + ) + != expected_identity + ): + raise ProviderError("workspace runtime entry changed to an unsafe type") + os.chmod(f"/proc/self/fd/{path_descriptor}", _PRIVATE_DIRECTORY_MODE) + descriptor = os.open( + ".", + os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=path_descriptor, + ) + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino): + os.close(descriptor) + raise ProviderError("workspace runtime directory identity changed") + return descriptor + finally: + os.close(path_descriptor) + + +def _clear_workspace_runtime_directory(descriptor: int) -> None: + os.fchmod(descriptor, _PRIVATE_DIRECTORY_MODE) + for name in os.listdir(descriptor): + metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + child_descriptor = _open_workspace_runtime_directory( + descriptor, + name, + (metadata.st_dev, metadata.st_ino), + ) + try: + child_identity = os.fstat(child_descriptor) + _clear_workspace_runtime_directory(child_descriptor) + current = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + if (current.st_dev, current.st_ino) != ( + child_identity.st_dev, + child_identity.st_ino, + ): + raise ProviderError("workspace runtime directory identity changed") + os.rmdir(name, dir_fd=descriptor) + if os.fstat(child_descriptor).st_nlink != 0: + raise ProviderError( + "workspace runtime directory removal was replaced" + ) + finally: + os.close(child_descriptor) + else: + os.unlink(name, dir_fd=descriptor) + + +def _cleanup_workspace_runtime(runtime: _WorkspaceRuntime) -> None: + try: + for name, descriptor in runtime.directories: + identity = os.fstat(descriptor) + _clear_workspace_runtime_directory(descriptor) + try: + current = os.stat( + name, + dir_fd=runtime.parent_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + if identity.st_nlink != 0: + raise ProviderError( + "workspace runtime directory was renamed" + ) from None + continue + if not stat.S_ISDIR(current.st_mode) or ( + current.st_dev, + current.st_ino, + ) != (identity.st_dev, identity.st_ino): + raise ProviderError("workspace runtime directory identity changed") + os.rmdir(name, dir_fd=runtime.parent_descriptor) + if os.fstat(descriptor).st_nlink != 0: + raise ProviderError("workspace runtime directory removal was replaced") + except OSError as exc: + raise ProviderError(f"cannot clean workspace runtime path: {exc}") from exc + finally: + for _name, descriptor in runtime.directories: + os.close(descriptor) + os.close(runtime.parent_descriptor) def _fsync_private_directory(path: Path) -> None: @@ -3551,7 +3961,7 @@ def run_agent(self, request: AgentRequest) -> ProviderResult: poison_recovered = poison_store.recover(poison_binding) _prepare_runtime_mountpoint(mounted_root) invocation_root = _new_invocation_root(self._runtime_root) - workspace_runtime: tuple[Path, ...] | None = None + workspace_runtime: _WorkspaceRuntime | None = None tool_attestations: list[VerifiedExecutable] = [] try: try: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 7514804..41f7bc4 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -39,13 +39,16 @@ _CleanupPoisonStore, _JsonRpcSession, _LAUNCH_GATE_SCRIPT, + _MAX_COLLAB_THREADS, _PoisonBinding, _ProcessTransport, _SystemdRecoveryProbe, _auth_lock, + _cleanup_workspace_runtime, _command_sha256, _load_protocol_lock, _linux_process_start_time, + _prepare_workspace_runtime, _static_config, _resolve_gate_shell, _resolve_system_tool, @@ -1048,6 +1051,7 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: protocol._turn_id = "turn-1" allowed = ( "agentMessage", + "collabAgentToolCall", "commandExecution", "contextCompaction", "fileChange", @@ -1058,18 +1062,45 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: self.assertEqual(_ALLOWED_ITEM_TYPES, frozenset(allowed)) for item_type in allowed: with self.subTest(item_type=item_type, disposition="allowed"): + item = {"id": "item-1", "type": item_type} + if item_type == "agentMessage": + item["text"] = "message" + elif item_type == "collabAgentToolCall": + item.update( + { + "agentsStates": {}, + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + } + ) protocol._handle_notification( "item/started", { - "item": {"id": "item-1", "type": item_type}, + "item": item, "threadId": "thread-1", "turnId": "turn-1", }, ) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) + with self.assertRaisesRegex( + ProviderError, "thread/status/changed changed thread scope" + ): + protocol._handle_notification( + "thread/status/changed", + {"status": {"type": "idle"}, "threadId": "unknown-child"}, + ) + sentinel = "SENTINEL_MUST_NOT_BE_DISCLOSED" prohibited = ( - "collabAgentToolCall", "dynamicToolCall", "enteredReviewMode", "exitedReviewMode", @@ -1097,6 +1128,423 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: ) self.assertNotIn(sentinel, str(raised.exception)) + def test_spawn_agent_allows_schema_defined_empty_initial_receivers(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(initial) + self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + + completed = { + **initial, + "agentsStates": {"child-1": {"status": "completed"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + protocol._validate_item(completed) + self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + self.assertEqual(protocol._pending_spawn_items, {}) + + def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + receivers = [f"child-{index}" for index in range(_MAX_COLLAB_THREADS)] + base = { + "agentsStates": {}, + "senderThreadId": "thread-1", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + for index, receiver in enumerate(receivers): + item = { + **base, + "id": f"item-{index}", + "receiverThreadIds": [receiver], + } + protocol._validate_item({**item, "status": "inProgress"}) + protocol._validate_item({**item, "status": "completed"}) + + with self.assertRaisesRegex(ProviderError, "thread count exceeds"): + protocol._validate_item( + { + **base, + "id": "overflow-item", + "receiverThreadIds": ["overflow-child"], + "status": "inProgress", + } + ) + + protocol._validate_item( + { + **base, + "id": "item-3", + "receiverThreadIds": [], + "status": "inProgress", + } + ) + with self.assertRaisesRegex(ProviderError, "thread count exceeds"): + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "overflow-child", + }, + ) + self.assertEqual(protocol._collab_thread_ids, set(receivers)) + self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-3"): None}) + + def test_pending_spawn_bounds_early_child_status_scope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(initial) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) + self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + self.assertEqual(protocol._unbound_collab_thread_ids, {"child-1"}) + + protocol._turn_id = "main-turn" + protocol._handle_notification( + "turn/started", + {"threadId": "child-1", "turn": {"id": "child-turn"}}, + ) + protocol._handle_notification( + "item/started", + { + "item": {"id": "reasoning-1", "type": "reasoning"}, + "threadId": "child-1", + "turnId": "child-turn", + }, + ) + protocol._handle_notification( + "model/verification", + {"threadId": "child-1", "turnId": "child-turn"}, + ) + protocol._handle_notification( + "item/completed", + { + "item": { + "id": "message-1", + "phase": "final_answer", + "text": "child result", + "type": "agentMessage", + }, + "threadId": "child-1", + "turnId": "child-turn", + }, + ) + protocol._handle_notification( + "turn/completed", + { + "threadId": "child-1", + "turn": { + "id": "child-turn", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + }, + }, + ) + self.assertIsNone(protocol._announced_turn_id) + self.assertEqual(protocol._completed_messages, []) + self.assertIsNone(protocol._turn_completed) + + with self.assertRaisesRegex( + ProviderError, "thread/status/changed changed thread scope" + ): + protocol._handle_notification( + "thread/status/changed", + {"status": {"type": "idle"}, "threadId": "unknown-child"}, + ) + with self.assertRaisesRegex(ProviderError, "changed thread scope"): + protocol._handle_notification( + "turn/started", + {"threadId": "unknown-child", "turn": {"id": "unknown-turn"}}, + ) + with self.assertRaisesRegex(ProviderError, "did not match"): + protocol._validate_item( + { + **initial, + "receiverThreadIds": ["different-child"], + "status": "completed", + } + ) + + protocol._validate_item( + { + **initial, + "agentsStates": {"child-1": {"status": "completed"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + ) + self.assertEqual(protocol._pending_spawn_items, {}) + self.assertEqual(protocol._unbound_collab_thread_ids, set()) + + def test_parallel_pending_spawns_accept_cross_ordered_child_announcements( + self, + ) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item({**initial, "id": "spawn-1"}) + protocol._validate_item({**initial, "id": "spawn-2"}) + + for child in ("child-2", "child-1"): + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": child, + }, + ) + + for item_id, child in (("spawn-1", "child-1"), ("spawn-2", "child-2")): + protocol._validate_item( + { + **initial, + "agentsStates": {child: {"status": "running"}}, + "id": item_id, + "receiverThreadIds": [child], + "status": "completed", + } + ) + + self.assertEqual(protocol._pending_spawn_items, {}) + self.assertEqual(protocol._unbound_collab_thread_ids, set()) + self.assertEqual(protocol._collab_thread_ids, {"child-1", "child-2"}) + + def test_pending_spawn_ids_are_scoped_to_the_sender_thread(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._collab_thread_ids.add("child-a") + base = { + "agentsStates": {}, + "id": "same-id", + "receiverThreadIds": [], + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item({**base, "senderThreadId": "main-thread"}) + protocol._validate_item({**base, "senderThreadId": "child-a"}) + self.assertEqual( + set(protocol._pending_spawn_items), + {("main-thread", "same-id"), ("child-a", "same-id")}, + ) + + protocol._validate_item( + { + **base, + "agentsStates": {"child-b": {"status": "running"}}, + "receiverThreadIds": ["child-b"], + "senderThreadId": "child-a", + "status": "completed", + } + ) + self.assertEqual( + protocol._pending_spawn_items, {("main-thread", "same-id"): None} + ) + protocol._validate_item( + { + **base, + "agentsStates": {"child-c": {"status": "running"}}, + "receiverThreadIds": ["child-c"], + "senderThreadId": "main-thread", + "status": "completed", + } + ) + self.assertEqual(protocol._pending_spawn_items, {}) + + def test_one_child_cannot_complete_two_pending_spawns(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + base = { + "agentsStates": {}, + "receiverThreadIds": [], + "senderThreadId": "main-thread", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item({**base, "id": "spawn-1"}) + protocol._validate_item({**base, "id": "spawn-2"}) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "only-child", + }, + ) + completed = { + **base, + "agentsStates": {"only-child": {"status": "running"}}, + "receiverThreadIds": ["only-child"], + "status": "completed", + } + protocol._validate_item({**completed, "id": "spawn-1"}) + with self.assertRaisesRegex(ProviderError, "already claimed"): + protocol._validate_item({**completed, "id": "spawn-2"}) + self.assertEqual( + protocol._pending_spawn_items, {("main-thread", "spawn-2"): None} + ) + + def test_collaboration_optional_metadata_rejects_non_strings(self) -> None: + base = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + for field in ("model", "prompt", "reasoningEffort"): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + with ( + self.subTest(field=field), + self.assertRaisesRegex( + ProviderError, f"collaboration {field} must be a string or null" + ), + ): + protocol._validate_item({**base, field: {"malformed": True}}) + + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + with self.assertRaisesRegex(ProviderError, "non-empty string or null"): + protocol._validate_item({**base, "reasoningEffort": ""}) + + def test_collaboration_sender_and_lifecycle_match_event_envelope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") + protocol._collab_turn_ids["child-1"] = {"child-turn"} + item = { + "agentsStates": {}, + "id": "spawn-1", + "receiverThreadIds": [], + "senderThreadId": "main-thread", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + + with self.assertRaisesRegex(ProviderError, "disagrees with its envelope"): + protocol._handle_notification( + "item/started", + { + "item": item, + "threadId": "child-1", + "turnId": "child-turn", + }, + ) + self.assertEqual(protocol._pending_spawn_items, {}) + + with self.assertRaisesRegex(ProviderError, "still in progress"): + protocol._handle_notification( + "item/completed", + { + "item": item, + "threadId": "main-thread", + "turnId": "main-turn", + }, + ) + self.assertEqual(protocol._pending_spawn_items, {}) + + def test_child_payloads_are_validated_before_they_are_discarded(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") + protocol._collab_turn_ids["child-1"] = {"child-turn"} + + with self.assertRaisesRegex(ProviderError, "agent message omitted keys"): + protocol._handle_notification( + "item/completed", + { + "item": {"type": "agentMessage"}, + "threadId": "child-1", + "turnId": "child-turn", + }, + ) + with self.assertRaisesRegex(ProviderError, "status must be a non-empty string"): + protocol._handle_notification( + "turn/completed", + { + "threadId": "child-1", + "turn": { + "id": "child-turn", + "items": "not-an-array", + "status": 42, + }, + }, + ) + + def test_child_turn_rejects_duplicate_completion_and_late_items(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") + protocol._collab_turn_ids["child-1"] = {"child-turn"} + completed = { + "threadId": "child-1", + "turn": { + "id": "child-turn", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + }, + } + protocol._handle_notification("turn/completed", completed) + + with self.assertRaisesRegex(ProviderError, "unknown child turn"): + protocol._handle_notification("turn/completed", completed) + with self.assertRaisesRegex(ProviderError, "unknown turn"): + protocol._handle_notification( + "item/completed", + { + "item": { + "id": "message-1", + "text": "late", + "type": "agentMessage", + }, + "threadId": "child-1", + "turnId": "child-turn", + }, + ) + def test_thread_instruction_runtime_root_and_source_drift_fail_closed(self) -> None: class DriftingTransport(ScriptedTransport): def __init__(self, key: str, value: Any) -> None: @@ -2404,6 +2852,65 @@ def factory( provider.run_agent(self.request()) self.assertTrue(self.transport.closed) + def test_workspace_runtime_cleanup_handles_unreadable_directories_safely( + self, + ) -> None: + paths = tuple( + self.workspace / name + for name in ( + ".skill-eval-tmp", + ".skill-eval-cache", + ".skill-eval-home", + ) + ) + runtime = _prepare_workspace_runtime(self.workspace) + unreadable = paths[0] / "d" + nested = unreadable / "nested" + nested.mkdir(parents=True) + (nested / "proof").write_text("temporary", encoding="ascii") + outside = self.workspace / "outside-proof" + outside.write_text("preserve", encoding="ascii") + (paths[0] / "outside-link").symlink_to(outside) + nested.chmod(0) + unreadable.chmod(0) + + try: + _cleanup_workspace_runtime(runtime) + finally: + for directory in (nested, unreadable): + if directory.exists(): + directory.chmod(0o700) + + self.assertTrue(all(not path.exists() for path in paths)) + self.assertEqual(outside.read_text(encoding="ascii"), "preserve") + + def test_workspace_runtime_cleanup_does_not_follow_replaced_root(self) -> None: + runtime = _prepare_workspace_runtime(self.workspace) + target = self.workspace / ".skill-eval-tmp" + displaced = self.workspace / "displaced-runtime" + outside = self.workspace / "outside-runtime" + outside_nested = outside / "nested" + outside_nested.mkdir(parents=True) + outside.chmod(0o755) + outside_nested.chmod(0o755) + target.rename(displaced) + target.symlink_to(outside, target_is_directory=True) + + try: + with self.assertRaisesRegex(ProviderError, "identity changed"): + _cleanup_workspace_runtime(runtime) + self.assertEqual(stat.S_IMODE(outside.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE(outside_nested.stat().st_mode), 0o755) + finally: + if target.is_symlink(): + target.unlink() + if displaced.exists(): + displaced.rmdir() + for name in (".skill-eval-cache", ".skill-eval-home"): + path = self.workspace / name + if path.exists(): + path.rmdir() + def test_exact_systemd_app_server_launch_and_cleanup_without_protocol_turn( self, ) -> None: From 942376cc1791e45ff4db4d107b86126032218862 Mon Sep 17 00:00:00 2001 From: Dhi13man Date: Mon, 3 Aug 2026 16:16:39 +0000 Subject: [PATCH 02/35] fix(codex): classify confirmed provider timeouts --- skivolve/codex_app_server.py | 46 ++++++--- tests/test_codex_app_server.py | 169 +++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 14 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index d8de16f..b349213 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -34,6 +34,16 @@ ) +class ProviderTimeoutError(ProviderError): + """Raised when a Codex operation exceeds its configured deadline.""" + + def __init__(self, message: str, *, cleanup_confirmed: bool = False) -> None: + if type(cleanup_confirmed) is not bool: + raise TypeError("cleanup_confirmed must be a bool") + super().__init__(message) + self.cleanup_confirmed = cleanup_confirmed + + _MAX_FRAME_BYTES = 8 * 1024 * 1024 _MAX_MESSAGES = 20_000 _MAX_PAGES = 64 @@ -483,7 +493,7 @@ def _require_exact_keys( def _remaining(deadline: float, label: str) -> float: remaining = deadline - time.monotonic() if remaining <= 0: - raise ProviderError(f"{label} timed out") + raise ProviderTimeoutError(f"{label} timed out") return remaining @@ -2412,7 +2422,7 @@ def send(self, payload: bytes, deadline: float) -> None: while view: events = selector.select(_remaining(deadline, "Codex protocol write")) if not events: - raise ProviderError("Codex protocol write timed out") + raise ProviderTimeoutError("Codex protocol write timed out") try: written = os.write(self._stdin_fd, view) except BlockingIOError: @@ -2442,7 +2452,7 @@ def receive(self, deadline: float) -> bytes: raise ProviderError("Codex protocol frame exceeds the byte limit") events = selector.select(_remaining(deadline, "Codex protocol read")) if not events: - raise ProviderError("Codex protocol read timed out") + raise ProviderTimeoutError("Codex protocol read timed out") try: chunk = os.read(self._stdout_fd, 64 * 1024) except BlockingIOError: @@ -3931,6 +3941,7 @@ def run_agent(self, request: AgentRequest) -> ProviderResult: started = time.monotonic() transport: _Transport | None = None outcome: _TurnOutcome | None = None + timeout_error: ProviderTimeoutError | None = None sandbox: dict[str, Any] | None = None expected_command_sha256: str | None = None coordination_root = _validate_private_directory( @@ -4028,17 +4039,20 @@ def run_agent(self, request: AgentRequest) -> ProviderResult: raise ProviderError( "Codex system context retained the host skill path" ) - outcome = _AppServerProtocol( - session, - model=self._config.model, - reasoning_effort=self._config.reasoning_effort, - workspace=mounted_paths["work"], - system_context=system_context, - locked_efforts=self._lock.model_efforts[self._config.model], - locked_thread_cli_version=self._lock.thread_cli_version, - expected_codex_home=mounted_paths["codex-home"], - on_dispatched=request.on_dispatched, - ).run(request.prompt, deadline) + try: + outcome = _AppServerProtocol( + session, + model=self._config.model, + reasoning_effort=self._config.reasoning_effort, + workspace=mounted_paths["work"], + system_context=system_context, + locked_efforts=self._lock.model_efforts[self._config.model], + locked_thread_cli_version=self._lock.thread_cli_version, + expected_codex_home=mounted_paths["codex-home"], + on_dispatched=request.on_dispatched, + ).run(request.prompt, deadline) + except ProviderTimeoutError as exc: + timeout_error = exc finally: session.close() if not self._transport_is_injected: @@ -4095,6 +4109,10 @@ def run_agent(self, request: AgentRequest) -> ProviderResult: ) finally: _remove_invocation_root(invocation_root) + if timeout_error is not None: + raise ProviderTimeoutError( + str(timeout_error), cleanup_confirmed=True + ) from timeout_error assert outcome is not None and sandbox is not None return self._build_result( request, diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 41f7bc4..d66bae1 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -49,12 +49,15 @@ _load_protocol_lock, _linux_process_start_time, _prepare_workspace_runtime, + _remaining, + _remove_invocation_root, _static_config, _resolve_gate_shell, _resolve_system_tool, validate_codex_protocol_lock, _validate_cli_version_output, CodexAppServerProvider, + ProviderTimeoutError, ) from skivolve.manifest import ProviderConfig # noqa: E402 from skivolve.providers import AgentRequest, ProviderError # noqa: E402 @@ -610,6 +613,18 @@ def _protocol( class CodexProtocolTests(unittest.TestCase): + def test_expired_deadline_raises_typed_timeout(self) -> None: + with self.assertRaisesRegex( + ProviderTimeoutError, "operation timed out" + ) as caught: + _remaining(time.monotonic() - 1, "operation") + self.assertFalse(caught.exception.cleanup_confirmed) + confirmed = ProviderTimeoutError("operation timed out", cleanup_confirmed=True) + self.assertTrue(confirmed.cleanup_confirmed) + self.assertIsInstance(confirmed, ProviderError) + with self.assertRaisesRegex(TypeError, "cleanup_confirmed"): + ProviderTimeoutError("operation timed out", cleanup_confirmed=1) # type: ignore[arg-type] + def test_happy_path_paginates_disables_skills_merges_quota_and_uses_last_usage( self, ) -> None: @@ -2852,6 +2867,160 @@ def factory( provider.run_agent(self.request()) self.assertTrue(self.transport.closed) + def test_timeout_is_confirmed_only_after_cleanup_and_cleanup_failure_wins( + self, + ) -> None: + def exercise(*, fail_workspace_cleanup: bool) -> tuple[list[str], Exception]: + events: list[str] = [] + + def assert_no_active_confirmed_timeout() -> None: + active = sys.exception() + if isinstance(active, ProviderTimeoutError): + self.assertFalse(active.cleanup_confirmed) + + @contextlib.contextmanager + def record_exit(name: str, value: Any) -> Iterator[Any]: + try: + yield value + finally: + assert_no_active_confirmed_timeout() + events.append(name) + + class TimeoutTransport(ScriptedTransport): + def __init__( + self, + command: tuple[str, ...], + cwd: Path, + _environment: dict[str, str], + _unit_name: str, + **_callbacks: Any, + ) -> None: + super().__init__(cwd) + self.evidence.update( + cleanup_confirmed=False, + command_sha256=_command_sha256(command), + kind="systemd-run-user+codex-permission-profile", + launch_confirmed=True, + ) + + def receive(self, _deadline: float) -> bytes: + events.append("protocol_timeout") + raise ProviderTimeoutError("Codex protocol read timed out") + + def close(self) -> None: + assert_no_active_confirmed_timeout() + super().close() + self.evidence["cleanup_confirmed"] = True + events.append("transport_close") + + poison_store = mock.Mock() + poison_store.lock.side_effect = lambda _deadline: record_exit( + "provider_lock_exit", (1, 2) + ) + poison_store.recover.return_value = False + poison_store.arm.side_effect = lambda *_args: events.append("poison_arm") + + def disarm_poison( + _binding: _PoisonBinding, evidence: dict[str, Any] + ) -> None: + assert_no_active_confirmed_timeout() + self.assertIs(evidence["cleanup_confirmed"], True) + events.append("poison_disarm") + + poison_store.disarm.side_effect = disarm_poison + + auth_checks = 0 + + def assert_auth_path_matches_descriptor( + _path: Path, _descriptor: int + ) -> tuple[int, int]: + nonlocal auth_checks + auth_checks += 1 + if auth_checks == 3: + assert_no_active_confirmed_timeout() + events.append("post_auth_identity") + return (3, 4) + + def cleanup_workspace_runtime(runtime: Any) -> None: + assert_no_active_confirmed_timeout() + _cleanup_workspace_runtime(runtime) + events.append("workspace_cleanup") + if fail_workspace_cleanup: + raise ProviderError("injected workspace cleanup failure") + + def remove_invocation_root(path: Path) -> None: + assert_no_active_confirmed_timeout() + _remove_invocation_root(path) + events.append("invocation_root_cleanup") + + provider = CodexAppServerProvider( + self.config(), + transport_factory=self.factory, + auth_path=self.auth, + runtime_root=self.runtime, + validate_lock=False, + ) + self.addCleanup(provider.close) + provider._transport_is_injected = False + with ( + mock.patch( + "skivolve.codex_app_server._ProcessTransport", TimeoutTransport + ), + mock.patch( + "skivolve.codex_app_server._CleanupPoisonStore", + return_value=poison_store, + ), + mock.patch( + "skivolve.codex_app_server._auth_lock", + side_effect=lambda *_args: record_exit("auth_lock_exit", None), + ), + mock.patch( + "skivolve.codex_app_server._held_auth_descriptor", + side_effect=lambda *_args: record_exit("auth_descriptor_exit", 1), + ), + mock.patch( + "skivolve.codex_app_server._assert_auth_path_matches_descriptor", + assert_auth_path_matches_descriptor, + ), + mock.patch( + "skivolve.codex_app_server._cleanup_workspace_runtime", + cleanup_workspace_runtime, + ), + mock.patch( + "skivolve.codex_app_server._remove_invocation_root", + remove_invocation_root, + ), + ): + try: + provider.run_agent(self.request()) + except Exception as exc: + return events, exc + self.fail("timeout transport unexpectedly returned a provider result") + + expected_order = ( + "poison_arm", + "protocol_timeout", + "transport_close", + "poison_disarm", + "workspace_cleanup", + "post_auth_identity", + "invocation_root_cleanup", + "auth_descriptor_exit", + "auth_lock_exit", + "provider_lock_exit", + ) + for fail_workspace_cleanup in (False, True): + with self.subTest(fail_workspace_cleanup=fail_workspace_cleanup): + events, error = exercise(fail_workspace_cleanup=fail_workspace_cleanup) + positions = [events.index(event) for event in expected_order] + self.assertEqual(positions, sorted(positions)) + if fail_workspace_cleanup: + self.assertIs(type(error), ProviderError) + self.assertEqual(str(error), "injected workspace cleanup failure") + else: + self.assertIs(type(error), ProviderTimeoutError) + self.assertTrue(error.cleanup_confirmed) + def test_workspace_runtime_cleanup_handles_unreadable_directories_safely( self, ) -> None: From 390db8aa720b1d36c99cbd12c3425d57df3a9526 Mon Sep 17 00:00:00 2001 From: Dhi13man Date: Tue, 4 Aug 2026 04:12:35 +0000 Subject: [PATCH 03/35] fix(codex): preserve retryable turns --- skivolve/codex_app_server.py | 20 +++++++- tests/test_codex_app_server.py | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index b349213..ebee361 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1653,7 +1653,25 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if method == "model/rerouted": raise ProviderError("Codex rerouted the pinned model") if method == "error": - _require_object(params.get("error"), "error notification") + _require_exact_keys( + params, + "error notification", + required={"error", "threadId", "turnId", "willRetry"}, + ) + error = _require_object(params.get("error"), "error notification.error") + _require_exact_keys( + error, + "error notification.error", + required={"additionalDetails", "codexErrorInfo", "message"}, + ) + _require_string(error.get("message"), "error notification.error.message") + if not self._matches_turn(params) and not self._matches_collab_turn(params): + raise ProviderError("Codex error notification changed turn scope") + will_retry = params.get("willRetry") + if type(will_retry) is not bool: + raise ProviderError("Codex error notification has invalid retry state") + if will_retry: + return raise ProviderError("Codex reported a turn error") if method == "account/rateLimits/updated": update = _sanitize_rate_snapshot( diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index d66bae1..cf6e926 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -255,6 +255,7 @@ def __init__( identifier_sentinel: str | None = None, omit_item_completed: bool = False, not_loaded_has_items: bool = False, + error_will_retry: bool | None = None, skill_disable_succeeds: bool = True, thread_cli_version: str = "0.144.3", turn_error: dict[str, Any] | None = None, @@ -271,6 +272,7 @@ def __init__( self.full_has_non_object_item = full_has_non_object_item self.omit_item_completed = omit_item_completed self.not_loaded_has_items = not_loaded_has_items + self.error_will_retry = error_will_retry self.thread_id = ( f"{identifier_sentinel}.thread" if identifier_sentinel else "thread-1" ) @@ -435,6 +437,28 @@ def send(self, payload: bytes, _deadline: float) -> None: self._queue_turn_events() def _queue_turn_events(self) -> None: + if self.error_will_retry is not None: + self.incoming.append( + _line( + { + "method": "error", + "params": { + "error": { + "additionalDetails": None, + "codexErrorInfo": { + "responseStreamDisconnected": { + "httpStatusCode": None + } + }, + "message": "SENTINEL_MUST_NOT_BE_DISCLOSED", + }, + "threadId": self.thread_id, + "turnId": self.turn_id, + "willRetry": self.error_will_retry, + }, + } + ) + ) if self.reroute: self.incoming.append( _line( @@ -699,6 +723,74 @@ def test_reroute_fails_closed(self) -> None: expected_codex_home=Path("/runtime/codex-home"), ).run("request", time.monotonic() + 5) + def test_retryable_error_notification_does_not_interrupt_turn(self) -> None: + transport = ScriptedTransport(Path("/runtime/work"), error_will_retry=True) + + outcome = _protocol(transport).run("request", time.monotonic() + 5) + + self.assertEqual(outcome.final_output, "completed fixture") + + def test_non_retryable_error_notification_fails_closed(self) -> None: + transport = ScriptedTransport(Path("/runtime/work"), error_will_retry=False) + + with self.assertRaises(ProviderError) as caught: + _protocol(transport).run("request", time.monotonic() + 5) + self.assertEqual(str(caught.exception), "Codex reported a turn error") + self.assertNotIn("SENTINEL_MUST_NOT_BE_DISCLOSED", str(caught.exception)) + + def test_error_notification_requires_boolean_retry_state(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + for retry_state in (None, 0, 1, "true", {}): + with self.subTest(retry_state=retry_state): + with self.assertRaisesRegex(ProviderError, "invalid retry state"): + protocol._handle_notification( + "error", + { + "error": { + "additionalDetails": None, + "codexErrorInfo": None, + "message": "untrusted", + }, + "threadId": "thread-1", + "turnId": "turn-1", + "willRetry": retry_state, + }, + ) + + def test_retryable_error_notification_validates_shape_and_scope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + valid = { + "error": { + "additionalDetails": None, + "codexErrorInfo": None, + "message": "untrusted", + }, + "threadId": "thread-1", + "turnId": "turn-1", + "willRetry": True, + } + invalid = { + "missing error field": { + **valid, + "error": {"codexErrorInfo": None, "message": "untrusted"}, + }, + "invalid message": { + **valid, + "error": {**valid["error"], "message": None}, + }, + "wrong thread": {**valid, "threadId": "thread-2"}, + "wrong turn": {**valid, "turnId": "turn-2"}, + } + + for label, params in invalid.items(): + with self.subTest(label=label): + with self.assertRaises(ProviderError): + protocol._handle_notification("error", params) + def test_missing_usage_rejects_completed_turn(self) -> None: transport = ScriptedTransport(Path("/runtime/work"), omit_usage=True) with self.assertRaisesRegex(ProviderError, "token usage"): From d633cb04f045f48087f522c3a24969d1d96471bc Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Wed, 5 Aug 2026 12:29:01 +0000 Subject: [PATCH 04/35] chore(codex): refresh app-server lock for 0.146.0 Regenerate binary, runtime-bundle, and protocol-schema attestations against the current standalone Codex release. Co-Authored-By: Dhiman's Agentic Suite --- codex-app-server-lock.json | 14 +++++++------- tests/test_codex_app_server.py | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/codex-app-server-lock.json b/codex-app-server-lock.json index bc5a3a2..1f03b0b 100644 --- a/codex-app-server-lock.json +++ b/codex-app-server-lock.json @@ -1,6 +1,6 @@ { - "codex_cli_version": "codex-cli 0.144.3", - "executable_sha256": "37e6f5953f191b04f7b62cb07dae90f51d0947ad89f0355665b421fbde28700b", + "codex_cli_version": "codex-cli 0.146.0", + "executable_sha256": "2e863156ed35ecc5253b1e2f907a9143077b9f7cb51942070c61996471ff6e04", "models": { "gpt-5.6-luna": { "reasoning_efforts": [ @@ -24,7 +24,7 @@ }, "protocol": { "bundle": "codex_app_server_protocol.v2.schemas.json", - "canonical_bytes": 308970, + "canonical_bytes": 331046, "canonicalization": "json-sort-keys-compact-ascii-v1", "generate_argv": [ "app-server", @@ -33,17 +33,17 @@ "--out", "{output_dir}" ], - "sha256": "f5e8d20f3a8f9bb5e5b23ab0c5aa6bde7b12e7e0713606c5d0132651a4959d37" + "sha256": "e554a74bd59d38d16acb1744750b2999156ee3d65d0fe906b22ab52edf17fbbc" }, "runtime_bundle": { "canonicalization": "json-sort-keys-compact-ascii-v1", "files": { - "bin/codex": "37e6f5953f191b04f7b62cb07dae90f51d0947ad89f0355665b421fbde28700b", - "codex-path/rg": "ebeaf56f8a25e102e9419933423738b3a2a613a444fd749d695e15eba53f71f2", + "bin/codex": "2e863156ed35ecc5253b1e2f907a9143077b9f7cb51942070c61996471ff6e04", + "codex-path/rg": "e62198eb19b136b88c330af83647b5a962cb99b6b1f066758568f12de1974849", "codex-resources/bwrap": "77360cb751ccedc5971391444ac86a8a33c15b04d6b4a6fe45f5d25496e62c4c", "codex-resources/zsh/bin/zsh": "67faaaa89242c4a332e16e508a1977cffc24bf7fca31d4411cdfd101f3831ef3" }, - "sha256": "dd0959589aaf01d8e9f18147255d885ca8eb9e5b4373575225da98698498bcdf" + "sha256": "134da631c1d438ae8c28fe3ac24aabf9bc38828a1892038cb650ab0102b4188c" }, "schema_version": 1 } diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index cf6e926..cc0b238 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -257,7 +257,7 @@ def __init__( not_loaded_has_items: bool = False, error_will_retry: bool | None = None, skill_disable_succeeds: bool = True, - thread_cli_version: str = "0.144.3", + thread_cli_version: str = "0.146.0", turn_error: dict[str, Any] | None = None, turn_items_view: str | None = "notLoaded", ) -> None: @@ -630,7 +630,7 @@ def _protocol( workspace=Path("/runtime/work"), system_context="isolated evaluation", locked_efforts=("low", "medium", "high", "xhigh", "max"), - locked_thread_cli_version="0.144.3", + locked_thread_cli_version="0.146.0", expected_codex_home=Path("/runtime/codex-home"), on_dispatched=on_dispatched, ) @@ -661,7 +661,7 @@ def test_happy_path_paginates_disables_skills_merges_quota_and_uses_last_usage( workspace=Path("/runtime/work"), system_context="isolated evaluation", locked_efforts=("low", "medium", "high", "xhigh", "max"), - locked_thread_cli_version="0.144.3", + locked_thread_cli_version="0.146.0", expected_codex_home=Path("/runtime/codex-home"), ).run("implement request", time.monotonic() + 5) @@ -719,7 +719,7 @@ def test_reroute_fails_closed(self) -> None: workspace=Path("/runtime/work"), system_context="test", locked_efforts=("low", "medium", "high", "xhigh", "max"), - locked_thread_cli_version="0.144.3", + locked_thread_cli_version="0.146.0", expected_codex_home=Path("/runtime/codex-home"), ).run("request", time.monotonic() + 5) @@ -801,7 +801,7 @@ def test_missing_usage_rejects_completed_turn(self) -> None: workspace=Path("/runtime/work"), system_context="test", locked_efforts=("low", "medium", "high", "xhigh", "max"), - locked_thread_cli_version="0.144.3", + locked_thread_cli_version="0.146.0", expected_codex_home=Path("/runtime/codex-home"), ).run("request", time.monotonic() + 5) @@ -965,7 +965,7 @@ def test_skill_disable_refusal_is_rejected(self) -> None: workspace=Path("/runtime/work"), system_context="test", locked_efforts=("low", "medium", "high", "xhigh", "max"), - locked_thread_cli_version="0.144.3", + locked_thread_cli_version="0.146.0", expected_codex_home=Path("/runtime/codex-home"), ).run("request", time.monotonic() + 5) @@ -2906,7 +2906,7 @@ def test_injected_transport_cannot_produce_result_but_exercises_launch_contract( self.assertEqual( provider.protocol_provenance["schema_sha256"], - "f5e8d20f3a8f9bb5e5b23ab0c5aa6bde7b12e7e0713606c5d0132651a4959d37", + "e554a74bd59d38d16acb1744750b2999156ee3d65d0fe906b22ab52edf17fbbc", ) self.assertEqual(provider.execution_policy.concurrency, "serialized") self.assertFalse(provider.execution_policy.release_authoritative) @@ -3634,13 +3634,13 @@ def test_lock_matches_installed_binary_and_regenerated_protocol_without_model_tu executable = _require_real_codex(self) lock = _load_protocol_lock(LOCK_PATH) validate_codex_protocol_lock(executable, lock) - self.assertEqual(lock.cli_version, "codex-cli 0.144.3") - self.assertEqual(lock.thread_cli_version, "0.144.3") + self.assertEqual(lock.cli_version, "codex-cli 0.146.0") + self.assertEqual(lock.thread_cli_version, "0.146.0") self.assertEqual( lock.protocol_sha256, - "f5e8d20f3a8f9bb5e5b23ab0c5aa6bde7b12e7e0713606c5d0132651a4959d37", + "e554a74bd59d38d16acb1744750b2999156ee3d65d0fe906b22ab52edf17fbbc", ) - self.assertEqual(lock.protocol_canonical_bytes, 308970) + self.assertEqual(lock.protocol_canonical_bytes, 331046) self.assertEqual( hashlib.sha256(LOCK_PATH.read_bytes()).hexdigest(), lock.sha256 ) From 4116d3182a68dbb52394c4ab41115be950160149 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Wed, 5 Aug 2026 12:54:51 +0000 Subject: [PATCH 05/35] docs(changelog): record Codex 0.146.0 lock Co-Authored-By: Dhiman's Agentic Suite --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1994fd5..db633a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Skivolve are documented in this file. The format follows ## [Unreleased] +### Changed + +- Refreshed the Codex app-server runtime lock from 0.144.3 to 0.146.0, including the executable, bundled tools, and generated protocol schema. + ### Fixed - Accepted bounded Codex collaboration lifecycles without letting child-agent traffic mutate the main result, and cleaned unreadable evaluator runtime directories without following symlinks. From 62395d0af0e96bd715a76271fb092f5e025a6431 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Wed, 5 Aug 2026 16:28:33 +0000 Subject: [PATCH 06/35] fix(codex): require one completed spawn receiver Co-Authored-By: Dhiman's Agentic Suite --- CHANGELOG.md | 2 +- skivolve/codex_app_server.py | 4 ++++ tests/test_codex_app_server.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db633a3..820f190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to Skivolve are documented in this file. The format follows ### Fixed -- Accepted bounded Codex collaboration lifecycles without letting child-agent traffic mutate the main result, and cleaned unreadable evaluator runtime directories without following symlinks. +- Accepted bounded Codex collaboration lifecycles, required each completed spawn to bind exactly one child, kept child-agent traffic from mutating the main result, and cleaned unreadable evaluator runtime directories without following symlinks. ## [0.5.0] - 2026-07-29 diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index ebee361..6695e4e 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1966,6 +1966,10 @@ def _validate_collab_item( ): raise ProviderError("collaboration agent message exceeds the limit") if tool == "spawnAgent": + if status == "completed" and len(receivers) != 1: + raise ProviderError( + "completed spawnAgent must have exactly one receiver" + ) pending_key = (sender, item_id) pending = pending_key in self._pending_spawn_items expected_receiver = self._pending_spawn_items.get(pending_key) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index cc0b238..ab817f2 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1261,6 +1261,34 @@ def test_spawn_agent_allows_schema_defined_empty_initial_receivers(self) -> None self.assertEqual(protocol._collab_thread_ids, {"child-1"}) self.assertEqual(protocol._pending_spawn_items, {}) + def test_completed_spawn_requires_exactly_one_receiver(self) -> None: + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + for receivers in ([], ["child-1", "child-2"]): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._validate_item(initial) + with self.subTest(receivers=receivers): + with self.assertRaisesRegex(ProviderError, "exactly one receiver"): + protocol._validate_item( + { + **initial, + "receiverThreadIds": receivers, + "status": "completed", + } + ) + self.assertEqual( + protocol._pending_spawn_items, {("thread-1", "item-1"): None} + ) + self.assertEqual(protocol._collab_thread_ids, set()) + def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" From 154143ca41e4b5c82f6a05a005784fd2a39ea14e Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 03:28:54 +0000 Subject: [PATCH 07/35] fix(codex): enforce child turn boundaries Reject drift from pinned model settings while keeping child failures scoped to their own turns. Refs #28 Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 11 +++++- tests/test_codex_app_server.py | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 6695e4e..ac314f5 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1665,12 +1665,13 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: required={"additionalDetails", "codexErrorInfo", "message"}, ) _require_string(error.get("message"), "error notification.error.message") - if not self._matches_turn(params) and not self._matches_collab_turn(params): + main_turn = self._matches_turn(params) + if not main_turn and not self._matches_collab_turn(params): raise ProviderError("Codex error notification changed turn scope") will_retry = params.get("willRetry") if type(will_retry) is not bool: raise ProviderError("Codex error notification has invalid retry state") - if will_retry: + if will_retry or not main_turn: return raise ProviderError("Codex reported a turn error") if method == "account/rateLimits/updated": @@ -1925,6 +1926,12 @@ def _validate_collab_item( raise ProviderError( "collaboration reasoningEffort must be a non-empty string or null" ) + if item.get("model") not in {None, self._model}: + raise ProviderError("collaboration model differs from the pinned model") + if item.get("reasoningEffort") not in {None, self._reasoning_effort}: + raise ProviderError( + "collaboration reasoningEffort differs from the pinned reasoning effort" + ) states = _require_object(item.get("agentsStates"), "collaboration agent states") if len(states) > _MAX_COLLAB_THREADS: raise ProviderError("collaboration agent-state count exceeds the limit") diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index ab817f2..1b50d0e 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -791,6 +791,42 @@ def test_retryable_error_notification_validates_shape_and_scope(self) -> None: with self.assertRaises(ProviderError): protocol._handle_notification("error", params) + def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") + protocol._collab_turn_ids["child-1"] = {"child-turn"} + + protocol._handle_notification( + "error", + { + "error": { + "additionalDetails": None, + "codexErrorInfo": None, + "message": "untrusted child failure", + }, + "threadId": "child-1", + "turnId": "child-turn", + "willRetry": False, + }, + ) + protocol._handle_notification( + "turn/completed", + { + "threadId": "child-1", + "turn": { + "id": "child-turn", + "items": [], + "itemsView": "notLoaded", + "status": "failed", + }, + }, + ) + + self.assertIsNone(protocol._turn_completed) + self.assertEqual(protocol._collab_turn_ids["child-1"], set()) + def test_missing_usage_rejects_completed_turn(self) -> None: transport = ScriptedTransport(Path("/runtime/work"), omit_usage=True) with self.assertRaisesRegex(ProviderError, "token usage"): @@ -1580,6 +1616,41 @@ def test_collaboration_optional_metadata_rejects_non_strings(self) -> None: with self.assertRaisesRegex(ProviderError, "non-empty string or null"): protocol._validate_item({**base, "reasoningEffort": ""}) + def test_collaboration_model_metadata_matches_pinned_configuration(self) -> None: + base = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + mismatches = ( + ("model", "other-model", "pinned model"), + ("reasoningEffort", "high", "pinned reasoning effort"), + ) + for field, value, message in mismatches: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + with ( + self.subTest(field=field), + self.assertRaisesRegex(ProviderError, message), + ): + protocol._validate_item({**base, field: value}) + self.assertEqual(protocol._pending_spawn_items, {}) + + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._validate_item( + { + **base, + "model": "gpt-5.6-luna", + "reasoningEffort": "low", + } + ) + self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + def test_collaboration_sender_and_lifecycle_match_event_envelope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" From 11d84dac61368086b092e39b88a41c5021913244 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 03:42:02 +0000 Subject: [PATCH 08/35] fix(codex): accept optional turn error details Keep message mandatory while accepting the protocol-defined optional error metadata. Cover omitted optional fields without weakening shape or scope validation. Refs #28 Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 3 ++- tests/test_codex_app_server.py | 11 ++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index ac314f5..a55f981 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1662,7 +1662,8 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: _require_exact_keys( error, "error notification.error", - required={"additionalDetails", "codexErrorInfo", "message"}, + required={"message"}, + optional={"additionalDetails", "codexErrorInfo"}, ) _require_string(error.get("message"), "error notification.error.message") main_turn = self._matches_turn(params) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 1b50d0e..f071d2b 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -764,19 +764,15 @@ def test_retryable_error_notification_validates_shape_and_scope(self) -> None: protocol._thread_id = "thread-1" protocol._turn_id = "turn-1" valid = { - "error": { - "additionalDetails": None, - "codexErrorInfo": None, - "message": "untrusted", - }, + "error": {"message": "untrusted"}, "threadId": "thread-1", "turnId": "turn-1", "willRetry": True, } invalid = { - "missing error field": { + "missing message": { **valid, - "error": {"codexErrorInfo": None, "message": "untrusted"}, + "error": {"codexErrorInfo": None}, }, "invalid message": { **valid, @@ -786,6 +782,7 @@ def test_retryable_error_notification_validates_shape_and_scope(self) -> None: "wrong turn": {**valid, "turnId": "turn-2"}, } + protocol._handle_notification("error", valid) for label, params in invalid.items(): with self.subTest(label=label): with self.assertRaises(ProviderError): From 07bbd5ff22e21dd0f852ac287001eee0339e02d6 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 03:55:12 +0000 Subject: [PATCH 09/35] fix(codex): bound spawn receiver cardinality Reject multiple receivers in every spawn lifecycle state before pending or child scope changes. Preserve the exactly-one invariant for successful completion. Refs #28 Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 2 ++ tests/test_codex_app_server.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index a55f981..4e4cc18 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1978,6 +1978,8 @@ def _validate_collab_item( raise ProviderError( "completed spawnAgent must have exactly one receiver" ) + if len(receivers) > 1: + raise ProviderError("spawnAgent must have at most one receiver") pending_key = (sender, item_id) pending = pending_key in self._pending_spawn_items expected_receiver = self._pending_spawn_items.get(pending_key) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index f071d2b..89a35fd 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1322,6 +1322,38 @@ def test_completed_spawn_requires_exactly_one_receiver(self) -> None: ) self.assertEqual(protocol._collab_thread_ids, set()) + def test_spawn_rejects_multiple_receivers_before_successful_completion( + self, + ) -> None: + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + for status in ("inProgress", "failed"): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + if status == "failed": + protocol._validate_item(initial) + with self.subTest(status=status): + with self.assertRaisesRegex(ProviderError, "at most one receiver"): + protocol._validate_item( + { + **initial, + "receiverThreadIds": ["child-1", "child-2"], + "status": status, + } + ) + expected_pending = ( + {("thread-1", "item-1"): None} if status == "failed" else {} + ) + self.assertEqual(protocol._pending_spawn_items, expected_pending) + self.assertEqual(protocol._collab_thread_ids, set()) + def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" From 0c929d5f29773a7117a97c01086750cce26191e8 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 04:50:20 +0000 Subject: [PATCH 10/35] fix(codex): validate collaboration protocol edges Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 76 +++++++++++++++++++++++++++++++++- tests/test_codex_app_server.py | 62 ++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 4e4cc18..286cd49 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1666,6 +1666,72 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: optional={"additionalDetails", "codexErrorInfo"}, ) _require_string(error.get("message"), "error notification.error.message") + additional_details = error.get("additionalDetails") + if additional_details is not None and not isinstance( + additional_details, str + ): + raise ProviderError( + "error notification additionalDetails must be a string or null" + ) + error_info = error.get("codexErrorInfo") + if isinstance(error_info, str): + if error_info not in { + "badRequest", + "contextWindowExceeded", + "cyberPolicy", + "internalServerError", + "other", + "sandboxError", + "serverOverloaded", + "sessionBudgetExceeded", + "threadRollbackFailed", + "unauthorized", + "usageLimitExceeded", + }: + raise ProviderError( + "error notification has unknown Codex error info" + ) + elif error_info is not None: + info = _require_object(error_info, "error notification.codexErrorInfo") + if len(info) != 1: + raise ProviderError( + "error notification has invalid Codex error info" + ) + variant, raw_info = next(iter(info.items())) + variant_info = _require_object( + raw_info, f"error notification.codexErrorInfo.{variant}" + ) + if variant in { + "httpConnectionFailed", + "responseStreamConnectionFailed", + "responseStreamDisconnected", + "responseTooManyFailedAttempts", + }: + _require_exact_keys( + variant_info, + f"error notification.codexErrorInfo.{variant}", + required=set(), + optional={"httpStatusCode"}, + ) + _optional_bounded_integer( + variant_info.get("httpStatusCode"), + "error notification HTTP status", + maximum=65535, + ) + elif variant == "activeTurnNotSteerable": + _require_exact_keys( + variant_info, + "error notification active-turn error info", + required={"turnKind"}, + ) + if variant_info.get("turnKind") not in {"review", "compact"}: + raise ProviderError( + "error notification has unknown active turn kind" + ) + else: + raise ProviderError( + "error notification has unknown Codex error info" + ) main_turn = self._matches_turn(params) if not main_turn and not self._matches_collab_turn(params): raise ProviderError("Codex error notification changed turn scope") @@ -1910,7 +1976,10 @@ def _validate_collab_item( _require_protocol_id(value, "collaboration receiver thread id") for value in raw_receivers ] - if len(receivers) != len(set(receivers)) or self._thread_id in receivers: + if len(receivers) != len(set(receivers)) or ( + self._thread_id in receivers + and (sender == self._thread_id or tool == "spawnAgent") + ): raise ProviderError("collaboration receiver thread ids are invalid") status = _require_string(item.get("status"), "collaboration item status") if status not in {"inProgress", "completed", "failed"}: @@ -2033,7 +2102,10 @@ def _validate_collab_item( else: self._pending_spawn_items.pop(pending_key, None) self._collab_thread_ids.update(receivers) - elif any(receiver not in self._collab_thread_ids for receiver in receivers): + elif any( + receiver != self._thread_id and receiver not in self._collab_thread_ids + for receiver in receivers + ): raise ProviderError("collaboration item targeted an unknown child thread") def _validate_completed_turn( diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 89a35fd..f2e9f46 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -778,11 +778,54 @@ def test_retryable_error_notification_validates_shape_and_scope(self) -> None: **valid, "error": {**valid["error"], "message": None}, }, + "invalid additional details": { + **valid, + "error": {**valid["error"], "additionalDetails": {}}, + }, + "unknown error code": { + **valid, + "error": {**valid["error"], "codexErrorInfo": "futureError"}, + }, + "unknown error variant": { + **valid, + "error": {**valid["error"], "codexErrorInfo": {"futureError": {}}}, + }, + "invalid HTTP status": { + **valid, + "error": { + **valid["error"], + "codexErrorInfo": { + "responseStreamDisconnected": {"httpStatusCode": 65536} + }, + }, + }, + "invalid active turn kind": { + **valid, + "error": { + **valid["error"], + "codexErrorInfo": {"activeTurnNotSteerable": {"turnKind": "exec"}}, + }, + }, "wrong thread": {**valid, "threadId": "thread-2"}, "wrong turn": {**valid, "turnId": "turn-2"}, } - protocol._handle_notification("error", valid) + for error in ( + valid["error"], + {**valid["error"], "additionalDetails": "upstream detail"}, + {**valid["error"], "codexErrorInfo": "serverOverloaded"}, + { + **valid["error"], + "codexErrorInfo": { + "responseStreamDisconnected": {"httpStatusCode": 503} + }, + }, + { + **valid["error"], + "codexErrorInfo": {"activeTurnNotSteerable": {"turnKind": "review"}}, + }, + ): + protocol._handle_notification("error", {**valid, "error": error}) for label, params in invalid.items(): with self.subTest(label=label): with self.assertRaises(ProviderError): @@ -1586,6 +1629,23 @@ def test_pending_spawn_ids_are_scoped_to_the_sender_thread(self) -> None: ) self.assertEqual(protocol._pending_spawn_items, {}) + def test_child_collaboration_item_can_target_root_thread(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._collab_thread_ids.add("child-1") + + protocol._validate_item( + { + "agentsStates": {}, + "id": "message-1", + "receiverThreadIds": ["main-thread"], + "senderThreadId": "child-1", + "status": "completed", + "tool": "sendInput", + "type": "collabAgentToolCall", + } + ) + def test_one_child_cannot_complete_two_pending_spawns(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" From 69faddd4de63afe281dab1448b2cc0e8ff77c8f0 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 05:11:19 +0000 Subject: [PATCH 11/35] fix(codex): bind terminal spawn lifecycle Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 8 +------- tests/test_codex_app_server.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 286cd49..febcc89 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2069,13 +2069,7 @@ def _validate_collab_item( raise ProviderError( "spawnAgent receiver did not match a pending child thread" ) - elif ( - not pending - and status != "inProgress" - and any( - receiver not in self._collab_thread_ids for receiver in receivers - ) - ): + elif not pending and status != "inProgress" and lifecycle != "snapshot": raise ProviderError("spawnAgent completed without a pending child") new_receivers = set(receivers) - self._collab_thread_ids if len(self._collab_thread_ids) + len(new_receivers) > _MAX_COLLAB_THREADS: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index f2e9f46..420de09 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1397,6 +1397,37 @@ def test_spawn_rejects_multiple_receivers_before_successful_completion( self.assertEqual(protocol._pending_spawn_items, expected_pending) self.assertEqual(protocol._collab_thread_ids, set()) + def test_live_terminal_spawn_requires_a_pending_start(self) -> None: + base = { + "agentsStates": {}, + "id": "spawn-1", + "senderThreadId": "thread-1", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + for status, receivers in (("failed", []), ("completed", ["child-1"])): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + protocol._collab_thread_ids.add("child-1") + item = { + **base, + "receiverThreadIds": receivers, + "status": status, + } + with self.subTest(status=status): + with self.assertRaisesRegex(ProviderError, "without a pending child"): + protocol._handle_notification( + "item/completed", + { + "item": item, + "threadId": "thread-1", + "turnId": "turn-1", + }, + ) + + protocol._validate_item(item, lifecycle="snapshot") + def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" From 82fc5c3f74b9f1a4dc823c728d90092b580cf8d7 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 05:36:37 +0000 Subject: [PATCH 12/35] fix(codex): validate delegated usage provenance Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 167 ++++++++++++++++++++++++++------- tests/test_codex_app_server.py | 133 +++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 36 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index febcc89..bd78bae 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1247,13 +1247,16 @@ def __init__( self._expected_codex_home = expected_codex_home self._on_dispatched = on_dispatched self._thread_id: str | None = None + self._session_id: str | None = None self._announced_thread_id: str | None = None self._turn_id: str | None = None self._announced_turn_id: str | None = None self._turn_completed: dict[str, Any] | None = None self._completed_messages: list[dict[str, str | None]] = [] self._collab_thread_ids: set[str] = set() + self._validated_collab_thread_ids: set[str] = set() self._collab_turn_ids: dict[str, set[str]] = {} + self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._collab_turn_count = 0 self._pending_spawn_items: dict[tuple[str, str], str | None] = {} self._unbound_collab_thread_ids: set[str] = set() @@ -1278,6 +1281,10 @@ def run(self, prompt: str, deadline: float) -> _TurnOutcome: after_limits = self._read_rate_limits(deadline) if self._last_usage is None: raise ProviderError("Codex turn omitted last-turn token usage") + total_usage = dict(self._last_usage) + for child_usage in self._collab_turn_usage.values(): + for key, value in child_usage.items(): + total_usage[key] += value quota = { "before": before_limits, "rolling": self._rate_limits, @@ -1291,11 +1298,11 @@ def run(self, prompt: str, deadline: float) -> _TurnOutcome: "thread_id_sha256": _opaque_sha256(self._thread_id), "turn": turn, "turn_id_sha256": _opaque_sha256(self._turn_id), - "usage": dict(self._last_usage), + "usage": dict(total_usage), } return _TurnOutcome( final_output=final_output, - tokens=dict(self._last_usage), + tokens=total_usage, quota=quota, raw_response=raw, ) @@ -1584,7 +1591,7 @@ def _start_thread(self, deadline: float) -> None: ) if created_at is None or updated_at is None or updated_at < created_at: raise ProviderError("Codex thread timestamps are missing or inconsistent") - _require_protocol_id(thread.get("sessionId"), "thread.sessionId") + session_id = _require_protocol_id(thread.get("sessionId"), "thread.sessionId") status = _require_object(thread.get("status"), "thread.status") _require_exact_keys(status, "thread.status", required={"type"}) if status != {"type": "idle"} or thread.get("preview") != "": @@ -1618,6 +1625,7 @@ def _start_thread(self, deadline: float) -> None: thread_id = _require_protocol_id(thread.get("id"), "thread.id") if self._announced_thread_id not in {None, thread_id}: raise ProviderError("Codex thread announcement disagrees with thread/start") + self._session_id = session_id self._thread_id = thread_id def _start_turn(self, prompt: str, deadline: float) -> None: @@ -1787,8 +1795,89 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: thread.get("id"), "thread/started.thread.id" ) if self._thread_id is not None and announced != self._thread_id: + if announced in self._validated_collab_thread_ids: + raise ProviderError("Codex announced a child thread more than once") + created_at = _optional_bounded_integer( + thread.get("createdAt"), "child thread.createdAt" + ) + updated_at = _optional_bounded_integer( + thread.get("updatedAt"), "child thread.updatedAt" + ) + session_id = _require_protocol_id( + thread.get("sessionId"), "child thread.sessionId" + ) + parent_id = _require_protocol_id( + thread.get("parentThreadId"), "child thread.parentThreadId" + ) + source = _require_object(thread.get("source"), "child thread.source") + _require_exact_keys( + source, "child thread.source", required={"subAgent"} + ) + subagent = _require_object( + source.get("subAgent"), "child thread.source.subAgent" + ) + _require_exact_keys( + subagent, + "child thread.source.subAgent", + required={"thread_spawn"}, + ) + spawn = _require_object( + subagent.get("thread_spawn"), + "child thread.source.subAgent.thread_spawn", + ) + _require_exact_keys( + spawn, + "child thread.source.subAgent.thread_spawn", + required={"depth", "parent_thread_id"}, + optional={"agent_nickname", "agent_path", "agent_role"}, + ) + source_parent_id = _require_protocol_id( + spawn.get("parent_thread_id"), + "child thread.source parent thread id", + ) + depth = _optional_bounded_integer( + spawn.get("depth"), + "child thread.source depth", + minimum=1, + maximum=_MAX_COLLAB_THREADS, + ) + self._validate_thread_status( + _require_object(thread.get("status"), "child thread.status"), + "child thread.status", + ) + preview = thread.get("preview") + if not isinstance(preview, str): + raise ProviderError("child thread.preview must be a string") + if len(preview.encode("utf-8")) > _MAX_RETAINED_TEXT_BYTES: + raise ProviderError("child thread.preview exceeds the byte limit") + if ( + created_at is None + or updated_at is None + or updated_at < created_at + or depth is None + or self._session_id is None + or session_id != self._session_id + or parent_id != source_parent_id + or ( + parent_id != self._thread_id + and parent_id not in self._validated_collab_thread_ids + ) + or thread.get("modelProvider") != "openai" + or thread.get("cliVersion") != self._locked_thread_cli_version + or thread.get("cwd") != str(self._workspace) + or thread.get("ephemeral") is not True + or thread.get("historyMode") != "paginated" + or thread.get("path") is not None + or thread.get("forkedFromId") is not None + or thread.get("threadSource") != "skill-eval" + or thread.get("turns") != [] + ): + raise ProviderError( + "Codex child thread provenance differs from the root request" + ) if not self._claim_collab_thread_scope(announced): raise ProviderError("Codex thread announcement changed scope") + self._validated_collab_thread_ids.add(announced) return if self._announced_thread_id is not None: raise ProviderError("Codex announced more than one thread") @@ -1803,8 +1892,10 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if self._thread_id is None: raise ProviderError("Codex turn announcement changed thread scope") if thread_id != self._thread_id: - if not self._claim_collab_thread_scope(thread_id): - raise ProviderError("Codex turn announcement changed thread scope") + if thread_id not in self._validated_collab_thread_ids: + raise ProviderError( + "Codex child turn preceded its provenance announcement" + ) turns = self._collab_turn_ids.setdefault(thread_id, set()) if announced in turns: raise ProviderError("Codex announced a child turn more than once") @@ -1826,6 +1917,14 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: validated = self._validate_usage(last) if main_turn: self._last_usage = validated + else: + thread_id = _require_protocol_id( + params.get("threadId"), "tokenUsage.threadId" + ) + turn_id = _require_protocol_id( + params.get("turnId"), "tokenUsage.turnId" + ) + self._collab_turn_usage[(thread_id, turn_id)] = validated return if method == "item/completed": main_turn = self._matches_turn(params) @@ -2180,37 +2279,10 @@ def _validate_ignored_notification_scope( thread_id = _require_protocol_id( params.get("threadId"), "thread/status/changed.threadId" ) - status = _require_object( - params.get("status"), "thread/status/changed.status" + self._validate_thread_status( + _require_object(params.get("status"), "thread/status/changed.status"), + "thread/status/changed.status", ) - status_type = _require_string( - status.get("type"), "thread/status/changed.status.type" - ) - if status_type == "active": - _require_exact_keys( - status, - "thread/status/changed.status", - required={"activeFlags", "type"}, - ) - flags = _require_list( - status.get("activeFlags"), - "thread/status/changed.status.activeFlags", - maximum=2, - ) - if any( - _require_string(flag, "thread active flag") - not in {"waitingOnApproval", "waitingOnUserInput"} - for flag in flags - ): - raise ProviderError("thread status has an unknown active flag") - elif status_type in {"notLoaded", "idle", "systemError"}: - _require_exact_keys( - status, - "thread/status/changed.status", - required={"type"}, - ) - else: - raise ProviderError("thread status is unknown") if self._thread_id is None: raise ProviderError("thread/status/changed changed thread scope") if ( @@ -2229,6 +2301,31 @@ def _validate_ignored_notification_scope( if _require_protocol_id(turn_id, f"{method}.turnId") != self._turn_id: raise ProviderError(f"{method} targeted an unknown turn") + @staticmethod + def _validate_thread_status(status: dict[str, Any], label: str) -> None: + status_type = _require_string(status.get("type"), f"{label}.type") + if status_type == "active": + _require_exact_keys( + status, + label, + required={"activeFlags", "type"}, + ) + flags = _require_list( + status.get("activeFlags"), + f"{label}.activeFlags", + maximum=2, + ) + if any( + _require_string(flag, "thread active flag") + not in {"waitingOnApproval", "waitingOnUserInput"} + for flag in flags + ): + raise ProviderError("thread status has an unknown active flag") + elif status_type in {"notLoaded", "idle", "systemError"}: + _require_exact_keys(status, label, required={"type"}) + else: + raise ProviderError("thread status is unknown") + @staticmethod def _validate_usage(raw: dict[str, Any]) -> dict[str, int]: mapping = { diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 420de09..01fffe2 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -158,6 +158,39 @@ def _model(model: str, efforts: tuple[str, ...]) -> dict[str, Any]: } +def _child_thread( + thread_id: str = "child-1", + *, + parent_id: str = "thread-1", + session_id: str = "session-1", +) -> dict[str, Any]: + return { + "cliVersion": "0.146.0", + "createdAt": 1_700_000_001, + "cwd": "/runtime/work", + "ephemeral": True, + "historyMode": "paginated", + "id": thread_id, + "modelProvider": "openai", + "parentThreadId": parent_id, + "path": None, + "preview": "delegated task", + "sessionId": session_id, + "source": { + "subAgent": { + "thread_spawn": { + "depth": 1, + "parent_thread_id": parent_id, + } + } + }, + "status": {"activeFlags": [], "type": "active"}, + "threadSource": "skill-eval", + "turns": [], + "updatedAt": 1_700_000_001, + } + + def _rate_limits(used: int, limit_id: str = "codex") -> dict[str, Any]: snapshot = { "limitId": limit_id, @@ -694,6 +727,57 @@ def test_happy_path_paginates_disables_skills_merges_quota_and_uses_last_usage( self.assertEqual(turn["params"]["model"], "gpt-5.6-luna") self.assertNotIn("environments", turn["params"]) + def test_child_usage_retains_only_the_latest_turn_update(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + protocol._collab_turn_ids["child-1"] = {"child-turn"} + for input_tokens, output_tokens, total_tokens in ((3, 1, 4), (20, 8, 28)): + protocol._handle_notification( + "thread/tokenUsage/updated", + { + "threadId": "child-1", + "tokenUsage": { + "last": { + "cachedInputTokens": 4, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "reasoningOutputTokens": 3, + "totalTokens": total_tokens, + } + }, + "turnId": "child-turn", + }, + ) + + self.assertEqual( + protocol._collab_turn_usage[("child-1", "child-turn")]["total_tokens"], + 28, + ) + + def test_happy_path_adds_child_usage_to_reported_totals(self) -> None: + protocol = _protocol(ScriptedTransport(Path("/runtime/work"))) + protocol._collab_turn_usage[("child-1", "child-turn")] = { + "cached_input_tokens": 4, + "input_tokens": 20, + "output_tokens": 8, + "reasoning_output_tokens": 3, + "total_tokens": 28, + } + outcome = protocol.run("request", time.monotonic() + 5) + + self.assertEqual( + outcome.tokens, + { + "cached_input_tokens": 6, + "input_tokens": 30, + "output_tokens": 13, + "reasoning_output_tokens": 4, + "total_tokens": 43, + }, + ) + self.assertEqual(outcome.raw_response["usage"], outcome.tokens) + def test_thread_cli_version_mismatch_fails_before_turn_start(self) -> None: for version in ("codex-cli 0.144.1", "0.144.2"): with self.subTest(version=version): @@ -1479,6 +1563,7 @@ def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: def test_pending_spawn_bounds_early_child_status_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" + protocol._session_id = "session-1" initial = { "agentsStates": {}, "id": "item-1", @@ -1501,6 +1586,12 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: self.assertEqual(protocol._unbound_collab_thread_ids, {"child-1"}) protocol._turn_id = "main-turn" + with self.assertRaisesRegex(ProviderError, "preceded its provenance"): + protocol._handle_notification( + "turn/started", + {"threadId": "child-1", "turn": {"id": "child-turn"}}, + ) + protocol._handle_notification("thread/started", {"thread": _child_thread()}) protocol._handle_notification( "turn/started", {"threadId": "child-1", "turn": {"id": "child-turn"}}, @@ -1553,7 +1644,7 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: "thread/status/changed", {"status": {"type": "idle"}, "threadId": "unknown-child"}, ) - with self.assertRaisesRegex(ProviderError, "changed thread scope"): + with self.assertRaisesRegex(ProviderError, "preceded its provenance"): protocol._handle_notification( "turn/started", {"threadId": "unknown-child", "turn": {"id": "unknown-turn"}}, @@ -1578,6 +1669,46 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: self.assertEqual(protocol._pending_spawn_items, {}) self.assertEqual(protocol._unbound_collab_thread_ids, set()) + def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: + initial = { + "agentsStates": {}, + "id": "spawn-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + mutations = { + "model provider": lambda thread: thread.update(modelProvider="other"), + "CLI version": lambda thread: thread.update(cliVersion="0.145.0"), + "working directory": lambda thread: thread.update(cwd="/other"), + "session": lambda thread: thread.update(sessionId="other-session"), + "parent": lambda thread: thread.update(parentThreadId="other-parent"), + "source parent": lambda thread: thread["source"]["subAgent"][ + "thread_spawn" + ].update(parent_thread_id="other-parent"), + } + for label, mutate in mutations.items(): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + protocol._validate_item(initial) + thread = _child_thread() + mutate(thread) + with self.subTest(label=label), self.assertRaises(ProviderError): + protocol._handle_notification("thread/started", {"thread": thread}) + self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._unbound_collab_thread_ids, set()) + self.assertEqual(protocol._validated_collab_thread_ids, set()) + + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + protocol._validate_item(initial) + protocol._handle_notification("thread/started", {"thread": _child_thread()}) + self.assertEqual(protocol._validated_collab_thread_ids, {"child-1"}) + def test_parallel_pending_spawns_accept_cross_ordered_child_announcements( self, ) -> None: From ee18d587558e300742a15a86c50ab04770dad45f Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 06:13:10 +0000 Subject: [PATCH 13/35] fix(codex): bind delegated lifecycle identities Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 33 ++++++++++++++++---- tests/test_codex_app_server.py | 56 +++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index bd78bae..29899eb 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1255,9 +1255,10 @@ def __init__( self._completed_messages: list[dict[str, str | None]] = [] self._collab_thread_ids: set[str] = set() self._validated_collab_thread_ids: set[str] = set() + self._collab_parent_ids: dict[str, str] = {} self._collab_turn_ids: dict[str, set[str]] = {} + self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} - self._collab_turn_count = 0 self._pending_spawn_items: dict[tuple[str, str], str | None] = {} self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 @@ -1875,9 +1876,20 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: raise ProviderError( "Codex child thread provenance differs from the root request" ) + if not any( + sender == parent_id and receiver in {None, announced} + for ( + sender, + _item_id, + ), receiver in self._pending_spawn_items.items() + ): + raise ProviderError( + "Codex child thread lacked a parent-owned pending spawn" + ) if not self._claim_collab_thread_scope(announced): raise ProviderError("Codex thread announcement changed scope") self._validated_collab_thread_ids.add(announced) + self._collab_parent_ids[announced] = parent_id return if self._announced_thread_id is not None: raise ProviderError("Codex announced more than one thread") @@ -1897,12 +1909,13 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: "Codex child turn preceded its provenance announcement" ) turns = self._collab_turn_ids.setdefault(thread_id, set()) - if announced in turns: + turn_scope = (thread_id, announced) + if turn_scope in self._seen_collab_turn_ids: raise ProviderError("Codex announced a child turn more than once") - if self._collab_turn_count >= _MAX_COLLAB_TURNS: + if len(self._seen_collab_turn_ids) >= _MAX_COLLAB_TURNS: raise ProviderError("collaboration turn count exceeds the limit") turns.add(announced) - self._collab_turn_count += 1 + self._seen_collab_turn_ids.add(turn_scope) return if self._announced_turn_id is not None: raise ProviderError("Codex announced more than one turn") @@ -2161,7 +2174,7 @@ def _validate_collab_item( receiver = receivers[0] if receivers else None if status != "inProgress" and pending and expected_receiver is None: if receiver in self._unbound_collab_thread_ids: - self._unbound_collab_thread_ids.remove(receiver) + self._bind_unbound_collab_thread(receiver, sender) elif receiver is not None and receiver in self._collab_thread_ids: raise ProviderError("spawnAgent receiver was already claimed") elif len(self._unbound_collab_thread_ids) >= unbound_slots: @@ -2184,7 +2197,7 @@ def _validate_collab_item( self._pending_spawn_items[pending_key] = receiver elif expected_receiver is None and receiver is not None: if receiver in self._unbound_collab_thread_ids: - self._unbound_collab_thread_ids.remove(receiver) + self._bind_unbound_collab_thread(receiver, sender) elif receiver in self._collab_thread_ids: raise ProviderError("spawnAgent receiver was already claimed") elif len(self._unbound_collab_thread_ids) >= unbound_slots: @@ -2254,6 +2267,14 @@ def _claim_collab_thread_scope(self, thread_id: str) -> bool: self._collab_thread_ids.add(thread_id) return True + def _bind_unbound_collab_thread(self, thread_id: str, sender: str) -> None: + parent_id = self._collab_parent_ids.get(thread_id) + if parent_id is not None and parent_id != sender: + raise ProviderError( + "spawnAgent receiver parent disagrees with its spawning sender" + ) + self._unbound_collab_thread_ids.remove(thread_id) + def _matches_collab_turn(self, params: dict[str, Any]) -> bool: thread_id = _require_protocol_id( params.get("threadId"), "collaboration notification.threadId" diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 01fffe2..c6a5da9 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1709,6 +1709,51 @@ def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: protocol._handle_notification("thread/started", {"thread": _child_thread()}) self.assertEqual(protocol._validated_collab_thread_ids, {"child-1"}) + def test_child_thread_parent_must_own_a_pending_spawn(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + protocol._collab_thread_ids.add("child-a") + protocol._validated_collab_thread_ids.add("child-a") + protocol._validate_item( + { + "agentsStates": {}, + "id": "spawn-a", + "receiverThreadIds": [], + "senderThreadId": "child-a", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + ) + + with self.assertRaisesRegex(ProviderError, "parent-owned pending spawn"): + protocol._handle_notification("thread/started", {"thread": _child_thread()}) + self.assertNotIn("child-1", protocol._validated_collab_thread_ids) + + root_spawn = { + "agentsStates": {}, + "id": "spawn-root", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(root_spawn) + protocol._handle_notification("thread/started", {"thread": _child_thread()}) + with self.assertRaisesRegex(ProviderError, "spawning sender"): + protocol._validate_item( + { + **root_spawn, + "agentsStates": {"child-1": {"status": "running"}}, + "id": "spawn-a", + "receiverThreadIds": ["child-1"], + "senderThreadId": "child-a", + "status": "completed", + } + ) + def test_parallel_pending_spawns_accept_cross_ordered_child_announcements( self, ) -> None: @@ -1974,7 +2019,11 @@ def test_child_turn_rejects_duplicate_completion_and_late_items(self) -> None: protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" protocol._collab_thread_ids.add("child-1") - protocol._collab_turn_ids["child-1"] = {"child-turn"} + protocol._validated_collab_thread_ids.add("child-1") + protocol._handle_notification( + "turn/started", + {"threadId": "child-1", "turn": {"id": "child-turn"}}, + ) completed = { "threadId": "child-1", "turn": { @@ -1986,6 +2035,11 @@ def test_child_turn_rejects_duplicate_completion_and_late_items(self) -> None: } protocol._handle_notification("turn/completed", completed) + with self.assertRaisesRegex(ProviderError, "more than once"): + protocol._handle_notification( + "turn/started", + {"threadId": "child-1", "turn": {"id": "child-turn"}}, + ) with self.assertRaisesRegex(ProviderError, "unknown child turn"): protocol._handle_notification("turn/completed", completed) with self.assertRaisesRegex(ProviderError, "unknown turn"): From 16fd54a5287eda20bdd4f249e57bab8f50c81ca1 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 06:28:47 +0000 Subject: [PATCH 14/35] fix(codex): reject terminal item reuse Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 10 ++++++++++ tests/test_codex_app_server.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 29899eb..c713827 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1260,6 +1260,7 @@ def __init__( self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[tuple[str, str], str | None] = {} + self._terminal_collab_item_ids: set[tuple[str, str]] = set() self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 self._last_usage: dict[str, int] | None = None @@ -2100,6 +2101,9 @@ def _validate_collab_item( raise ProviderError("started collaboration item is not in progress") if lifecycle in {"completed", "snapshot"} and status == "inProgress": raise ProviderError("completed collaboration item is still in progress") + item_scope = (sender, item_id) + if lifecycle != "snapshot" and item_scope in self._terminal_collab_item_ids: + raise ProviderError("collaboration item ID was reused after termination") for field in ("model", "prompt", "reasoningEffort"): value = item.get(field) if value is not None and not isinstance(value, str): @@ -2213,6 +2217,12 @@ def _validate_collab_item( for receiver in receivers ): raise ProviderError("collaboration item targeted an unknown child thread") + if lifecycle != "snapshot" and status in {"completed", "failed"}: + if len(self._terminal_collab_item_ids) >= _MAX_MESSAGES: + raise ProviderError( + "terminal collaboration item count exceeds the limit" + ) + self._terminal_collab_item_ids.add(item_scope) def _validate_completed_turn( self, turn: dict[str, Any], owner_thread_id: str diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index c6a5da9..ee4c84e 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1512,6 +1512,29 @@ def test_live_terminal_spawn_requires_a_pending_start(self) -> None: protocol._validate_item(item, lifecycle="snapshot") + def test_terminal_collaboration_item_id_cannot_restart(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + item = { + "agentsStates": {}, + "id": "spawn-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + envelope = {"threadId": "thread-1", "turnId": "turn-1"} + protocol._handle_notification("item/started", {**envelope, "item": item}) + protocol._handle_notification( + "item/completed", {**envelope, "item": {**item, "status": "failed"}} + ) + + with self.assertRaisesRegex(ProviderError, "reused after termination"): + protocol._handle_notification("item/started", {**envelope, "item": item}) + protocol._validate_item({**item, "status": "failed"}, lifecycle="snapshot") + def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" @@ -1544,7 +1567,7 @@ def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: protocol._validate_item( { **base, - "id": "item-3", + "id": "pending-overflow", "receiverThreadIds": [], "status": "inProgress", } @@ -1558,7 +1581,10 @@ def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: }, ) self.assertEqual(protocol._collab_thread_ids, set(receivers)) - self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-3"): None}) + self.assertEqual( + protocol._pending_spawn_items, + {("thread-1", "pending-overflow"): None}, + ) def test_pending_spawn_bounds_early_child_status_scope(self) -> None: protocol = _protocol(QueueTransport([])) From a915caefd844b799628b60667373225ae7d3d910 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 06:46:46 +0000 Subject: [PATCH 15/35] fix(codex): await accounted child turns Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 7 ++++ tests/test_codex_app_server.py | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index c713827..660d80b 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2002,6 +2002,11 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: ): raise ProviderError("Codex completed an unknown child turn") self._validate_completed_turn(turn, completed_thread_id) + if ( + completed_thread_id, + completed_turn_id, + ) not in self._collab_turn_usage: + raise ProviderError("Codex child turn omitted token usage") self._collab_turn_ids[completed_thread_id].remove(completed_turn_id) return if self._turn_id is None or completed_turn_id != self._turn_id: @@ -2009,6 +2014,8 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if self._turn_completed is not None: raise ProviderError("Codex completed the same turn more than once") self._validate_completed_turn(turn, completed_thread_id) + if any(self._collab_turn_ids.values()): + raise ProviderError("Codex completed root turn with active child turns") self._turn_completed = turn return if method in _IGNORED_NOTIFICATIONS: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index ee4c84e..976ecc6 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -921,6 +921,13 @@ def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: protocol._turn_id = "main-turn" protocol._collab_thread_ids.add("child-1") protocol._collab_turn_ids["child-1"] = {"child-turn"} + protocol._collab_turn_usage[("child-1", "child-turn")] = { + "cached_input_tokens": 0, + "input_tokens": 1, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 1, + } protocol._handle_notification( "error", @@ -951,6 +958,50 @@ def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: self.assertIsNone(protocol._turn_completed) self.assertEqual(protocol._collab_turn_ids["child-1"], set()) + def test_child_completion_without_usage_keeps_turn_active(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_turn_ids["child-1"] = {"child-turn"} + + with self.assertRaisesRegex(ProviderError, "child turn omitted token usage"): + protocol._handle_notification( + "turn/completed", + { + "threadId": "child-1", + "turn": { + "id": "child-turn", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + }, + }, + ) + + self.assertEqual(protocol._collab_turn_ids["child-1"], {"child-turn"}) + + def test_root_completion_with_active_child_is_rejected(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_turn_ids["child-1"] = {"child-turn"} + + with self.assertRaisesRegex(ProviderError, "root turn with active child turns"): + protocol._handle_notification( + "turn/completed", + { + "threadId": "main-thread", + "turn": { + "id": "main-turn", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + }, + }, + ) + + self.assertIsNone(protocol._turn_completed) + def test_missing_usage_rejects_completed_turn(self) -> None: transport = ScriptedTransport(Path("/runtime/work"), omit_usage=True) with self.assertRaisesRegex(ProviderError, "token usage"): @@ -1647,6 +1698,22 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: "turnId": "child-turn", }, ) + protocol._handle_notification( + "thread/tokenUsage/updated", + { + "threadId": "child-1", + "tokenUsage": { + "last": { + "cachedInputTokens": 0, + "inputTokens": 1, + "outputTokens": 1, + "reasoningOutputTokens": 0, + "totalTokens": 2, + } + }, + "turnId": "child-turn", + }, + ) protocol._handle_notification( "turn/completed", { @@ -2046,6 +2113,13 @@ def test_child_turn_rejects_duplicate_completion_and_late_items(self) -> None: protocol._turn_id = "main-turn" protocol._collab_thread_ids.add("child-1") protocol._validated_collab_thread_ids.add("child-1") + protocol._collab_turn_usage[("child-1", "child-turn")] = { + "cached_input_tokens": 0, + "input_tokens": 1, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 1, + } protocol._handle_notification( "turn/started", {"threadId": "child-1", "turn": {"id": "child-turn"}}, From 35c7f9416862fe1d224cec0eb347fc90f9d701b5 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:05:27 +0000 Subject: [PATCH 16/35] fix(codex): await pending child lifecycle Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 13 +++++++-- tests/test_codex_app_server.py | 49 ++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 660d80b..4d773a4 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2014,8 +2014,17 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if self._turn_completed is not None: raise ProviderError("Codex completed the same turn more than once") self._validate_completed_turn(turn, completed_thread_id) - if any(self._collab_turn_ids.values()): - raise ProviderError("Codex completed root turn with active child turns") + children_with_turns = { + thread_id for thread_id, _turn_id in self._seen_collab_turn_ids + } + if ( + self._pending_spawn_items + or any(self._collab_turn_ids.values()) + or not self._validated_collab_thread_ids <= children_with_turns + ): + raise ProviderError( + "Codex completed root turn with outstanding child work" + ) self._turn_completed = turn return if method in _IGNORED_NOTIFICATIONS: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 976ecc6..03b759c 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -980,27 +980,42 @@ def test_child_completion_without_usage_keeps_turn_active(self) -> None: self.assertEqual(protocol._collab_turn_ids["child-1"], {"child-turn"}) - def test_root_completion_with_active_child_is_rejected(self) -> None: + def test_root_completion_requires_terminal_child_work(self) -> None: + completed = { + "threadId": "main-thread", + "turn": { + "id": "main-turn", + "items": [], + "itemsView": "notLoaded", + "status": "completed", + }, + } + for state in ("pending-spawn", "awaiting-turn", "active-turn"): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + if state == "pending-spawn": + protocol._pending_spawn_items[("main-thread", "spawn-1")] = None + elif state == "awaiting-turn": + protocol._validated_collab_thread_ids.add("child-1") + else: + protocol._collab_turn_ids["child-1"] = {"child-turn"} + + with ( + self.subTest(state=state), + self.assertRaisesRegex(ProviderError, "outstanding child work"), + ): + protocol._handle_notification("turn/completed", completed) + self.assertIsNone(protocol._turn_completed) + protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" - protocol._collab_turn_ids["child-1"] = {"child-turn"} - - with self.assertRaisesRegex(ProviderError, "root turn with active child turns"): - protocol._handle_notification( - "turn/completed", - { - "threadId": "main-thread", - "turn": { - "id": "main-turn", - "items": [], - "itemsView": "notLoaded", - "status": "completed", - }, - }, - ) + protocol._validated_collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "child-turn")) + protocol._handle_notification("turn/completed", completed) - self.assertIsNone(protocol._turn_completed) + self.assertIsNotNone(protocol._turn_completed) def test_missing_usage_rejects_completed_turn(self) -> None: transport = ScriptedTransport(Path("/runtime/work"), omit_usage=True) From 32190c0c678bcfff77970555dcf6b2ded3d31d48 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:11:57 +0000 Subject: [PATCH 17/35] fix(codex): await bound child provenance Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 2 +- tests/test_codex_app_server.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 4d773a4..c9f9428 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2020,7 +2020,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if ( self._pending_spawn_items or any(self._collab_turn_ids.values()) - or not self._validated_collab_thread_ids <= children_with_turns + or not self._collab_thread_ids <= children_with_turns ): raise ProviderError( "Codex completed root turn with outstanding child work" diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 03b759c..dd00c7a 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -990,15 +990,24 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "status": "completed", }, } - for state in ("pending-spawn", "awaiting-turn", "active-turn"): + for state in ( + "pending-spawn", + "bound-thread", + "awaiting-turn", + "active-turn", + ): protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" if state == "pending-spawn": protocol._pending_spawn_items[("main-thread", "spawn-1")] = None + elif state == "bound-thread": + protocol._collab_thread_ids.add("child-1") elif state == "awaiting-turn": + protocol._collab_thread_ids.add("child-1") protocol._validated_collab_thread_ids.add("child-1") else: + protocol._collab_thread_ids.add("child-1") protocol._collab_turn_ids["child-1"] = {"child-turn"} with ( @@ -1011,6 +1020,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") protocol._validated_collab_thread_ids.add("child-1") protocol._seen_collab_turn_ids.add(("child-1", "child-turn")) protocol._handle_notification("turn/completed", completed) From d372a138e95b890d7bed9b724e84eb01396f2d5f Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:22:36 +0000 Subject: [PATCH 18/35] fix(codex): track resumed child activity Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 13 +++++++++++-- tests/test_codex_app_server.py | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index c9f9428..82b25c8 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1257,6 +1257,7 @@ def __init__( self._validated_collab_thread_ids: set[str] = set() self._collab_parent_ids: dict[str, str] = {} self._collab_turn_ids: dict[str, set[str]] = {} + self._active_collab_thread_ids: set[str] = set() self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[tuple[str, str], str | None] = {} @@ -2008,6 +2009,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: ) not in self._collab_turn_usage: raise ProviderError("Codex child turn omitted token usage") self._collab_turn_ids[completed_thread_id].remove(completed_turn_id) + self._active_collab_thread_ids.discard(completed_thread_id) return if self._turn_id is None or completed_turn_id != self._turn_id: raise ProviderError("Codex completed an unknown turn") @@ -2020,6 +2022,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if ( self._pending_spawn_items or any(self._collab_turn_ids.values()) + or self._active_collab_thread_ids or not self._collab_thread_ids <= children_with_turns ): raise ProviderError( @@ -2326,7 +2329,7 @@ def _validate_ignored_notification_scope( thread_id = _require_protocol_id( params.get("threadId"), "thread/status/changed.threadId" ) - self._validate_thread_status( + status_type = self._validate_thread_status( _require_object(params.get("status"), "thread/status/changed.status"), "thread/status/changed.status", ) @@ -2338,6 +2341,11 @@ def _validate_ignored_notification_scope( ): if not self._claim_collab_thread_scope(thread_id): raise ProviderError("thread/status/changed changed thread scope") + if thread_id != self._thread_id: + if status_type == "active": + self._active_collab_thread_ids.add(thread_id) + else: + self._active_collab_thread_ids.discard(thread_id) return thread_id = params.get("threadId") turn_id = params.get("turnId") @@ -2349,7 +2357,7 @@ def _validate_ignored_notification_scope( raise ProviderError(f"{method} targeted an unknown turn") @staticmethod - def _validate_thread_status(status: dict[str, Any], label: str) -> None: + def _validate_thread_status(status: dict[str, Any], label: str) -> str: status_type = _require_string(status.get("type"), f"{label}.type") if status_type == "active": _require_exact_keys( @@ -2372,6 +2380,7 @@ def _validate_thread_status(status: dict[str, Any], label: str) -> None: _require_exact_keys(status, label, required={"type"}) else: raise ProviderError("thread status is unknown") + return status_type @staticmethod def _validate_usage(raw: dict[str, Any]) -> dict[str, int]: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index dd00c7a..a874141 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -921,6 +921,7 @@ def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: protocol._turn_id = "main-turn" protocol._collab_thread_ids.add("child-1") protocol._collab_turn_ids["child-1"] = {"child-turn"} + protocol._active_collab_thread_ids.add("child-1") protocol._collab_turn_usage[("child-1", "child-turn")] = { "cached_input_tokens": 0, "input_tokens": 1, @@ -957,6 +958,7 @@ def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: self.assertIsNone(protocol._turn_completed) self.assertEqual(protocol._collab_turn_ids["child-1"], set()) + self.assertEqual(protocol._active_collab_thread_ids, set()) def test_child_completion_without_usage_keeps_turn_active(self) -> None: protocol = _protocol(QueueTransport([])) @@ -995,6 +997,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "bound-thread", "awaiting-turn", "active-turn", + "resumed-thread", ): protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" @@ -1006,9 +1009,19 @@ def test_root_completion_requires_terminal_child_work(self) -> None: elif state == "awaiting-turn": protocol._collab_thread_ids.add("child-1") protocol._validated_collab_thread_ids.add("child-1") - else: + elif state == "active-turn": protocol._collab_thread_ids.add("child-1") protocol._collab_turn_ids["child-1"] = {"child-turn"} + else: + protocol._collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) with ( self.subTest(state=state), From 3d028e39f712194e9b0a99a3328e238d26a9ac19 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:30:42 +0000 Subject: [PATCH 19/35] fix(codex): reconcile child agent states Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 31 ++++++++++++++++++++++++- tests/test_codex_app_server.py | 42 +++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 82b25c8..1714509 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2140,6 +2140,7 @@ def _validate_collab_item( states = _require_object(item.get("agentsStates"), "collaboration agent states") if len(states) > _MAX_COLLAB_THREADS: raise ProviderError("collaboration agent-state count exceeds the limit") + agent_statuses: dict[str, str] = {} for raw_thread_id, raw_state in states.items(): thread_id = _require_protocol_id( raw_thread_id, "collaboration agent-state thread id" @@ -2166,6 +2167,7 @@ def _validate_collab_item( "notFound", }: raise ProviderError("collaboration agent status is unknown") + agent_statuses[thread_id] = agent_status message = state.get("message") if message is not None: if ( @@ -2195,6 +2197,16 @@ def _validate_collab_item( receiver is None for receiver in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None + if ( + status == "failed" + and receiver is not None + and ( + receiver in self._validated_collab_thread_ids + or receiver in self._active_collab_thread_ids + or self._collab_turn_ids.get(receiver) + ) + ): + raise ProviderError("failed spawn retained an active child") if status != "inProgress" and pending and expected_receiver is None: if receiver in self._unbound_collab_thread_ids: self._bind_unbound_collab_thread(receiver, sender) @@ -2230,12 +2242,29 @@ def _validate_collab_item( self._pending_spawn_items[pending_key] = receiver else: self._pending_spawn_items.pop(pending_key, None) - self._collab_thread_ids.update(receivers) + if status == "failed": + for failed_receiver in receivers: + self._unbound_collab_thread_ids.discard(failed_receiver) + self._collab_thread_ids.discard(failed_receiver) + self._active_collab_thread_ids.discard(failed_receiver) + else: + self._collab_thread_ids.update(receivers) elif any( receiver != self._thread_id and receiver not in self._collab_thread_ids for receiver in receivers ): raise ProviderError("collaboration item targeted an unknown child thread") + if lifecycle != "snapshot": + for thread_id, agent_status in agent_statuses.items(): + if ( + thread_id == self._thread_id + or thread_id not in self._collab_thread_ids + ): + continue + if agent_status in {"pendingInit", "running"}: + self._active_collab_thread_ids.add(thread_id) + else: + self._active_collab_thread_ids.discard(thread_id) if lifecycle != "snapshot" and status in {"completed", "failed"}: if len(self._terminal_collab_item_ids) >= _MAX_MESSAGES: raise ProviderError( diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index a874141..a75b965 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -998,6 +998,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "awaiting-turn", "active-turn", "resumed-thread", + "resumed-item", ): protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" @@ -1012,7 +1013,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: elif state == "active-turn": protocol._collab_thread_ids.add("child-1") protocol._collab_turn_ids["child-1"] = {"child-turn"} - else: + elif state == "resumed-thread": protocol._collab_thread_ids.add("child-1") protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) protocol._handle_notification( @@ -1022,6 +1023,20 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "threadId": "child-1", }, ) + else: + protocol._collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) + protocol._validate_item( + { + "agentsStates": {"child-1": {"status": "running"}}, + "id": "resume-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "main-thread", + "status": "completed", + "tool": "resumeAgent", + "type": "collabAgentToolCall", + } + ) with ( self.subTest(state=state), @@ -1510,6 +1525,31 @@ def test_spawn_agent_allows_schema_defined_empty_initial_receivers(self) -> None self.assertEqual(protocol._collab_thread_ids, {"child-1"}) self.assertEqual(protocol._pending_spawn_items, {}) + def test_failed_spawn_releases_unstarted_receiver(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(initial) + protocol._validate_item( + { + **initial, + "agentsStates": {"child-1": {"status": "errored"}}, + "receiverThreadIds": ["child-1"], + "status": "failed", + } + ) + + self.assertEqual(protocol._pending_spawn_items, {}) + self.assertEqual(protocol._collab_thread_ids, set()) + def test_completed_spawn_requires_exactly_one_receiver(self) -> None: initial = { "agentsStates": {}, From 731de78ec8f3be87e2aad2a0498676fde4429e27 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:40:02 +0000 Subject: [PATCH 20/35] fix(codex): keep snapshots non-mutating Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 15 ++++++++++++--- tests/test_codex_app_server.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 1714509..0a8a8ce 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2207,7 +2207,12 @@ def _validate_collab_item( ) ): raise ProviderError("failed spawn retained an active child") - if status != "inProgress" and pending and expected_receiver is None: + if ( + lifecycle != "snapshot" + and status != "inProgress" + and pending + and expected_receiver is None + ): if receiver in self._unbound_collab_thread_ids: self._bind_unbound_collab_thread(receiver, sender) elif receiver is not None and receiver in self._collab_thread_ids: @@ -2221,7 +2226,9 @@ def _validate_collab_item( new_receivers = set(receivers) - self._collab_thread_ids if len(self._collab_thread_ids) + len(new_receivers) > _MAX_COLLAB_THREADS: raise ProviderError("collaboration thread count exceeds the limit") - if status == "inProgress": + if lifecycle == "snapshot": + pass + elif status == "inProgress": if not pending: if any( receiver in self._collab_thread_ids for receiver in receivers @@ -2242,7 +2249,9 @@ def _validate_collab_item( self._pending_spawn_items[pending_key] = receiver else: self._pending_spawn_items.pop(pending_key, None) - if status == "failed": + if lifecycle == "snapshot": + pass + elif status == "failed": for failed_receiver in receivers: self._unbound_collab_thread_ids.discard(failed_receiver) self._collab_thread_ids.discard(failed_receiver) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index a75b965..8728db4 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1550,6 +1550,31 @@ def test_failed_spawn_releases_unstarted_receiver(self) -> None: self.assertEqual(protocol._pending_spawn_items, {}) self.assertEqual(protocol._collab_thread_ids, set()) + def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._collab_thread_ids.add("child-1") + protocol._pending_spawn_items[("thread-1", "item-1")] = "child-1" + + protocol._validate_item( + { + "agentsStates": {"child-1": {"status": "errored"}}, + "id": "item-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "failed", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + }, + owner_thread_id="thread-1", + lifecycle="snapshot", + ) + + self.assertEqual( + protocol._pending_spawn_items, {("thread-1", "item-1"): "child-1"} + ) + self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + def test_completed_spawn_requires_exactly_one_receiver(self) -> None: initial = { "agentsStates": {}, From 599e586a32def26236c88beaa62b24482de51069 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:53:34 +0000 Subject: [PATCH 21/35] fix(codex): bind spawn completion provenance Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 29 ++++++++++++ tests/test_codex_app_server.py | 84 +++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 0a8a8ce..df5eddc 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1261,6 +1261,7 @@ def __init__( self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[tuple[str, str], str | None] = {} + self._completed_spawn_receivers: dict[tuple[str, str], str] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 @@ -1838,6 +1839,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: spawn.get("parent_thread_id"), "child thread.source parent thread id", ) + bound_parent_id = self._collab_parent_ids.get(announced) depth = _optional_bounded_integer( spawn.get("depth"), "child thread.source depth", @@ -1861,6 +1863,9 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: or self._session_id is None or session_id != self._session_id or parent_id != source_parent_id + or ( + bound_parent_id is not None and bound_parent_id != parent_id + ) or ( parent_id != self._thread_id and parent_id not in self._validated_collab_thread_ids @@ -2197,6 +2202,24 @@ def _validate_collab_item( receiver is None for receiver in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None + if ( + lifecycle == "snapshot" + and status == "completed" + and ( + self._completed_spawn_receivers.get(item_scope) != receiver + or receiver not in self._collab_thread_ids + ) + ): + raise ProviderError( + "completed spawnAgent snapshot lacked a matching live completion" + ) + if lifecycle != "snapshot" and status != "failed": + for claimed_receiver in receivers: + parent_id = self._collab_parent_ids.get(claimed_receiver) + if parent_id is not None and parent_id != sender: + raise ProviderError( + "spawnAgent receiver parent disagrees with its spawning sender" + ) if ( status == "failed" and receiver is not None @@ -2256,7 +2279,10 @@ def _validate_collab_item( self._unbound_collab_thread_ids.discard(failed_receiver) self._collab_thread_ids.discard(failed_receiver) self._active_collab_thread_ids.discard(failed_receiver) + self._collab_parent_ids.pop(failed_receiver, None) else: + for claimed_receiver in receivers: + self._collab_parent_ids[claimed_receiver] = sender self._collab_thread_ids.update(receivers) elif any( receiver != self._thread_id and receiver not in self._collab_thread_ids @@ -2280,6 +2306,8 @@ def _validate_collab_item( "terminal collaboration item count exceeds the limit" ) self._terminal_collab_item_ids.add(item_scope) + if tool == "spawnAgent" and status == "completed": + self._completed_spawn_receivers[item_scope] = receivers[0] def _validate_completed_turn( self, turn: dict[str, Any], owner_thread_id: str @@ -2340,6 +2368,7 @@ def _bind_unbound_collab_thread(self, thread_id: str, sender: str) -> None: raise ProviderError( "spawnAgent receiver parent disagrees with its spawning sender" ) + self._collab_parent_ids[thread_id] = sender self._unbound_collab_thread_ids.remove(thread_id) def _matches_collab_turn(self, params: dict[str, Any]) -> bool: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 8728db4..d6efa49 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1549,6 +1549,7 @@ def test_failed_spawn_releases_unstarted_receiver(self) -> None: self.assertEqual(protocol._pending_spawn_items, {}) self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._collab_parent_ids, {}) def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: protocol = _protocol(QueueTransport([])) @@ -1575,6 +1576,44 @@ def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: ) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + def test_completed_spawn_snapshot_requires_matching_live_completion(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + completed = { + **initial, + "agentsStates": {"child-1": {"status": "running"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + + with self.assertRaisesRegex(ProviderError, "matching live completion"): + protocol._validate_item(completed, lifecycle="snapshot") + protocol._validate_item(initial) + protocol._validate_item(completed) + live_scope = ( + dict(protocol._completed_spawn_receivers), + set(protocol._collab_thread_ids), + dict(protocol._collab_parent_ids), + ) + protocol._validate_item(completed, lifecycle="snapshot") + self.assertEqual( + live_scope, + ( + protocol._completed_spawn_receivers, + protocol._collab_thread_ids, + protocol._collab_parent_ids, + ), + ) + def test_completed_spawn_requires_exactly_one_receiver(self) -> None: initial = { "agentsStates": {}, @@ -1664,7 +1703,8 @@ def test_live_terminal_spawn_requires_a_pending_start(self) -> None: }, ) - protocol._validate_item(item, lifecycle="snapshot") + with self.assertRaisesRegex(ProviderError, "matching live completion"): + protocol._validate_item(item, lifecycle="snapshot") def test_terminal_collaboration_item_id_cannot_restart(self) -> None: protocol = _protocol(QueueTransport([])) @@ -1950,6 +1990,48 @@ def test_child_thread_parent_must_own_a_pending_spawn(self) -> None: } ) + def test_child_thread_parent_matches_preannouncement_spawn_binding(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + root_spawn = { + "agentsStates": {}, + "id": "spawn-root", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(root_spawn) + protocol._validate_item( + { + **root_spawn, + "agentsStates": {"child-1": {"status": "running"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + ) + protocol._collab_thread_ids.add("child-a") + protocol._validated_collab_thread_ids.add("child-a") + protocol._validate_item( + { + **root_spawn, + "id": "spawn-child", + "senderThreadId": "child-a", + } + ) + thread = _child_thread() + thread["parentThreadId"] = "child-a" + thread["source"]["subAgent"]["thread_spawn"][ + "parent_thread_id" + ] = "child-a" + + with self.assertRaisesRegex(ProviderError, "provenance"): + protocol._handle_notification("thread/started", {"thread": thread}) + self.assertEqual(protocol._collab_parent_ids["child-1"], "thread-1") + self.assertNotIn("child-1", protocol._validated_collab_thread_ids) + def test_parallel_pending_spawns_accept_cross_ordered_child_announcements( self, ) -> None: From e2f5cad7a093d15966afe01e242f4a9ff47f31ea Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 07:56:02 +0000 Subject: [PATCH 22/35] style(codex): satisfy formatting gate Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 4 +--- tests/test_codex_app_server.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index df5eddc..f2e9712 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1863,9 +1863,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: or self._session_id is None or session_id != self._session_id or parent_id != source_parent_id - or ( - bound_parent_id is not None and bound_parent_id != parent_id - ) + or (bound_parent_id is not None and bound_parent_id != parent_id) or ( parent_id != self._thread_id and parent_id not in self._validated_collab_thread_ids diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index d6efa49..4d7b2c9 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -2023,9 +2023,7 @@ def test_child_thread_parent_matches_preannouncement_spawn_binding(self) -> None ) thread = _child_thread() thread["parentThreadId"] = "child-a" - thread["source"]["subAgent"]["thread_spawn"][ - "parent_thread_id" - ] = "child-a" + thread["source"]["subAgent"]["thread_spawn"]["parent_thread_id"] = "child-a" with self.assertRaisesRegex(ProviderError, "provenance"): protocol._handle_notification("thread/started", {"thread": thread}) From a2e34e8fc55e3c40a1540d4514c30349bad632fe Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 08:10:51 +0000 Subject: [PATCH 23/35] fix(codex): reconcile terminal spawn history Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 49 ++++++++++++++-------- tests/test_codex_app_server.py | 75 +++++++++++++++++++++++++--------- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index f2e9712..906f0d3 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1261,7 +1261,9 @@ def __init__( self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[tuple[str, str], str | None] = {} - self._completed_spawn_receivers: dict[tuple[str, str], str] = {} + self._terminal_spawn_history: dict[ + tuple[str, str], tuple[str, tuple[str, ...]] + ] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 @@ -1881,15 +1883,29 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: raise ProviderError( "Codex child thread provenance differs from the root request" ) - if not any( - sender == parent_id and receiver in {None, announced} - for ( - sender, - _item_id, - ), receiver in self._pending_spawn_items.items() + if not ( + any( + sender == parent_id and receiver in {None, announced} + for ( + sender, + _item_id, + ), receiver in self._pending_spawn_items.items() + ) + or any( + sender == parent_id + and spawn_status == "completed" + and spawn_receivers == (announced,) + for ( + sender, + _item_id, + ), ( + spawn_status, + spawn_receivers, + ) in self._terminal_spawn_history.items() + ) ): raise ProviderError( - "Codex child thread lacked a parent-owned pending spawn" + "Codex child thread lacked parent-owned spawn history" ) if not self._claim_collab_thread_scope(announced): raise ProviderError("Codex thread announcement changed scope") @@ -2200,16 +2216,13 @@ def _validate_collab_item( receiver is None for receiver in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None - if ( - lifecycle == "snapshot" - and status == "completed" - and ( - self._completed_spawn_receivers.get(item_scope) != receiver - or receiver not in self._collab_thread_ids - ) + if lifecycle == "snapshot" and ( + self._terminal_spawn_history.get(item_scope) + != (status, tuple(receivers)) + or (status == "completed" and receiver not in self._collab_thread_ids) ): raise ProviderError( - "completed spawnAgent snapshot lacked a matching live completion" + "spawnAgent snapshot lacked matching live terminal history" ) if lifecycle != "snapshot" and status != "failed": for claimed_receiver in receivers: @@ -2304,8 +2317,8 @@ def _validate_collab_item( "terminal collaboration item count exceeds the limit" ) self._terminal_collab_item_ids.add(item_scope) - if tool == "spawnAgent" and status == "completed": - self._completed_spawn_receivers[item_scope] = receivers[0] + if tool == "spawnAgent": + self._terminal_spawn_history[item_scope] = (status, tuple(receivers)) def _validate_completed_turn( self, turn: dict[str, Any], owner_thread_id: str diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 4d7b2c9..f2058aa 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1557,26 +1557,27 @@ def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: protocol._collab_thread_ids.add("child-1") protocol._pending_spawn_items[("thread-1", "item-1")] = "child-1" - protocol._validate_item( - { - "agentsStates": {"child-1": {"status": "errored"}}, - "id": "item-1", - "receiverThreadIds": ["child-1"], - "senderThreadId": "thread-1", - "status": "failed", - "tool": "spawnAgent", - "type": "collabAgentToolCall", - }, - owner_thread_id="thread-1", - lifecycle="snapshot", - ) + with self.assertRaisesRegex(ProviderError, "live terminal history"): + protocol._validate_item( + { + "agentsStates": {"child-1": {"status": "errored"}}, + "id": "item-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "failed", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + }, + owner_thread_id="thread-1", + lifecycle="snapshot", + ) self.assertEqual( protocol._pending_spawn_items, {("thread-1", "item-1"): "child-1"} ) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) - def test_completed_spawn_snapshot_requires_matching_live_completion(self) -> None: + def test_spawn_snapshot_requires_matching_live_terminal_history(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" initial = { @@ -1595,20 +1596,30 @@ def test_completed_spawn_snapshot_requires_matching_live_completion(self) -> Non "status": "completed", } - with self.assertRaisesRegex(ProviderError, "matching live completion"): + with self.assertRaisesRegex(ProviderError, "live terminal history"): protocol._validate_item(completed, lifecycle="snapshot") protocol._validate_item(initial) protocol._validate_item(completed) live_scope = ( - dict(protocol._completed_spawn_receivers), + dict(protocol._terminal_spawn_history), set(protocol._collab_thread_ids), dict(protocol._collab_parent_ids), ) protocol._validate_item(completed, lifecycle="snapshot") + with self.assertRaisesRegex(ProviderError, "live terminal history"): + protocol._validate_item( + { + **completed, + "agentsStates": {}, + "receiverThreadIds": [], + "status": "failed", + }, + lifecycle="snapshot", + ) self.assertEqual( live_scope, ( - protocol._completed_spawn_receivers, + protocol._terminal_spawn_history, protocol._collab_thread_ids, protocol._collab_parent_ids, ), @@ -1703,7 +1714,7 @@ def test_live_terminal_spawn_requires_a_pending_start(self) -> None: }, ) - with self.assertRaisesRegex(ProviderError, "matching live completion"): + with self.assertRaisesRegex(ProviderError, "live terminal history"): protocol._validate_item(item, lifecycle="snapshot") def test_terminal_collaboration_item_id_cannot_restart(self) -> None: @@ -1963,7 +1974,7 @@ def test_child_thread_parent_must_own_a_pending_spawn(self) -> None: } ) - with self.assertRaisesRegex(ProviderError, "parent-owned pending spawn"): + with self.assertRaisesRegex(ProviderError, "parent-owned spawn history"): protocol._handle_notification("thread/started", {"thread": _child_thread()}) self.assertNotIn("child-1", protocol._validated_collab_thread_ids) @@ -2030,6 +2041,32 @@ def test_child_thread_parent_matches_preannouncement_spawn_binding(self) -> None self.assertEqual(protocol._collab_parent_ids["child-1"], "thread-1") self.assertNotIn("child-1", protocol._validated_collab_thread_ids) + def test_child_thread_accepts_matching_completed_spawn_history(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + spawn = { + "agentsStates": {}, + "id": "spawn-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(spawn) + protocol._validate_item( + { + **spawn, + "agentsStates": {"child-1": {"status": "running"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + ) + + protocol._handle_notification("thread/started", {"thread": _child_thread()}) + self.assertEqual(protocol._validated_collab_thread_ids, {"child-1"}) + def test_parallel_pending_spawns_accept_cross_ordered_child_announcements( self, ) -> None: From 142219487cf7845f444e9f88e8cf8da13062eade Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 08:22:46 +0000 Subject: [PATCH 24/35] fix(codex): await active collaboration items Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 24 +++++++++++++ tests/test_codex_app_server.py | 65 +++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 906f0d3..eb0d299 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1265,6 +1265,9 @@ def __init__( tuple[str, str], tuple[str, tuple[str, ...]] ] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() + self._active_nonspawn_items: dict[ + tuple[str, str], tuple[str, tuple[str, ...]] + ] = {} self._unbound_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 self._last_usage: dict[str, int] | None = None @@ -2040,6 +2043,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: } if ( self._pending_spawn_items + or self._active_nonspawn_items or any(self._collab_turn_ids.values()) or self._active_collab_thread_ids or not self._collab_thread_ids <= children_with_turns @@ -2198,6 +2202,17 @@ def _validate_collab_item( > _MAX_RETAINED_TEXT_BYTES ): raise ProviderError("collaboration agent message exceeds the limit") + active_nonspawn_item = self._active_nonspawn_items.get(item_scope) + if tool != "spawnAgent" and lifecycle == "started": + if active_nonspawn_item is not None: + raise ProviderError("collaboration item was already in progress") + elif ( + tool != "spawnAgent" + and lifecycle == "completed" + and active_nonspawn_item is not None + and active_nonspawn_item != (tool, tuple(receivers)) + ): + raise ProviderError("terminal collaboration item disagreed with its start") if tool == "spawnAgent": if status == "completed" and len(receivers) != 1: raise ProviderError( @@ -2319,6 +2334,15 @@ def _validate_collab_item( self._terminal_collab_item_ids.add(item_scope) if tool == "spawnAgent": self._terminal_spawn_history[item_scope] = (status, tuple(receivers)) + if lifecycle == "started" and tool != "spawnAgent": + if ( + item_scope not in self._active_nonspawn_items + and len(self._active_nonspawn_items) >= _MAX_MESSAGES + ): + raise ProviderError("active collaboration item count exceeds the limit") + self._active_nonspawn_items[item_scope] = (tool, tuple(receivers)) + elif lifecycle == "completed" and tool != "spawnAgent": + self._active_nonspawn_items.pop(item_scope, None) def _validate_completed_turn( self, turn: dict[str, Any], owner_thread_id: str diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index f2058aa..8f61731 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -999,6 +999,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "active-turn", "resumed-thread", "resumed-item", + "started-resume-item", ): protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" @@ -1023,7 +1024,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "threadId": "child-1", }, ) - else: + elif state == "resumed-item": protocol._collab_thread_ids.add("child-1") protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) protocol._validate_item( @@ -1037,6 +1038,25 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "type": "collabAgentToolCall", } ) + else: + protocol._collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) + protocol._handle_notification( + "item/started", + { + "item": { + "agentsStates": {}, + "id": "resume-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "main-thread", + "status": "inProgress", + "tool": "resumeAgent", + "type": "collabAgentToolCall", + }, + "threadId": "main-thread", + "turnId": "main-turn", + }, + ) with ( self.subTest(state=state), @@ -1045,6 +1065,49 @@ def test_root_completion_requires_terminal_child_work(self) -> None: protocol._handle_notification("turn/completed", completed) self.assertIsNone(protocol._turn_completed) + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "main-thread" + protocol._turn_id = "main-turn" + protocol._collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) + resume = { + "agentsStates": {}, + "id": "resume-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "main-thread", + "status": "inProgress", + "tool": "resumeAgent", + "type": "collabAgentToolCall", + } + envelope = {"threadId": "main-thread", "turnId": "main-turn"} + protocol._handle_notification("item/started", {**envelope, "item": resume}) + with self.assertRaisesRegex(ProviderError, "disagreed with its start"): + protocol._handle_notification( + "item/completed", + { + **envelope, + "item": { + **resume, + "status": "completed", + "tool": "sendInput", + }, + }, + ) + protocol._handle_notification( + "item/completed", + { + **envelope, + "item": { + **resume, + "agentsStates": {"child-1": {"status": "completed"}}, + "status": "completed", + }, + }, + ) + self.assertEqual(protocol._active_nonspawn_items, {}) + protocol._handle_notification("turn/completed", completed) + self.assertIsNotNone(protocol._turn_completed) + protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" From 99ceda23362f1f8423dc0571be46e7e50582252b Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 08:35:37 +0000 Subject: [PATCH 25/35] fix(codex): reconcile collaboration history Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 50 ++++++++++++++++++++++------------ tests/test_codex_app_server.py | 15 ++++++++-- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index eb0d299..91b5d81 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1261,8 +1261,9 @@ def __init__( self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[tuple[str, str], str | None] = {} - self._terminal_spawn_history: dict[ - tuple[str, str], tuple[str, tuple[str, ...]] + self._terminal_collab_history: dict[ + tuple[str, str], + tuple[str, str, tuple[str, ...], tuple[tuple[str, str], ...]], ] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() self._active_nonspawn_items: dict[ @@ -1895,16 +1896,19 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: ), receiver in self._pending_spawn_items.items() ) or any( - sender == parent_id + spawn_tool == "spawnAgent" + and sender == parent_id and spawn_status == "completed" and spawn_receivers == (announced,) for ( sender, _item_id, ), ( + spawn_tool, spawn_status, spawn_receivers, - ) in self._terminal_spawn_history.items() + _agent_statuses, + ) in self._terminal_collab_history.items() ) ): raise ProviderError( @@ -2202,17 +2206,30 @@ def _validate_collab_item( > _MAX_RETAINED_TEXT_BYTES ): raise ProviderError("collaboration agent message exceeds the limit") + terminal_item = ( + tool, + status, + tuple(receivers), + tuple(sorted(agent_statuses.items())), + ) + if ( + lifecycle == "snapshot" + and self._terminal_collab_history.get(item_scope) != terminal_item + ): + raise ProviderError( + "collaboration snapshot lacked matching live terminal history" + ) active_nonspawn_item = self._active_nonspawn_items.get(item_scope) if tool != "spawnAgent" and lifecycle == "started": if active_nonspawn_item is not None: raise ProviderError("collaboration item was already in progress") - elif ( - tool != "spawnAgent" - and lifecycle == "completed" - and active_nonspawn_item is not None - and active_nonspawn_item != (tool, tuple(receivers)) - ): - raise ProviderError("terminal collaboration item disagreed with its start") + elif tool != "spawnAgent" and lifecycle == "completed": + if active_nonspawn_item is None: + raise ProviderError("collaboration item completed without a start") + if active_nonspawn_item != (tool, tuple(receivers)): + raise ProviderError( + "terminal collaboration item disagreed with its start" + ) if tool == "spawnAgent": if status == "completed" and len(receivers) != 1: raise ProviderError( @@ -2231,10 +2248,10 @@ def _validate_collab_item( receiver is None for receiver in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None - if lifecycle == "snapshot" and ( - self._terminal_spawn_history.get(item_scope) - != (status, tuple(receivers)) - or (status == "completed" and receiver not in self._collab_thread_ids) + if ( + lifecycle == "snapshot" + and status == "completed" + and receiver not in self._collab_thread_ids ): raise ProviderError( "spawnAgent snapshot lacked matching live terminal history" @@ -2332,8 +2349,7 @@ def _validate_collab_item( "terminal collaboration item count exceeds the limit" ) self._terminal_collab_item_ids.add(item_scope) - if tool == "spawnAgent": - self._terminal_spawn_history[item_scope] = (status, tuple(receivers)) + self._terminal_collab_history[item_scope] = terminal_item if lifecycle == "started" and tool != "spawnAgent": if ( item_scope not in self._active_nonspawn_items diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 8f61731..d6e715e 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1080,6 +1080,17 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "type": "collabAgentToolCall", } envelope = {"threadId": "main-thread", "turnId": "main-turn"} + completed_resume = { + **resume, + "agentsStates": {"child-1": {"status": "running"}}, + "status": "completed", + } + with self.assertRaisesRegex(ProviderError, "live terminal history"): + protocol._validate_item(completed_resume, lifecycle="snapshot") + with self.assertRaisesRegex(ProviderError, "completed without a start"): + protocol._handle_notification( + "item/completed", {**envelope, "item": completed_resume} + ) protocol._handle_notification("item/started", {**envelope, "item": resume}) with self.assertRaisesRegex(ProviderError, "disagreed with its start"): protocol._handle_notification( @@ -1664,7 +1675,7 @@ def test_spawn_snapshot_requires_matching_live_terminal_history(self) -> None: protocol._validate_item(initial) protocol._validate_item(completed) live_scope = ( - dict(protocol._terminal_spawn_history), + dict(protocol._terminal_collab_history), set(protocol._collab_thread_ids), dict(protocol._collab_parent_ids), ) @@ -1682,7 +1693,7 @@ def test_spawn_snapshot_requires_matching_live_terminal_history(self) -> None: self.assertEqual( live_scope, ( - protocol._terminal_spawn_history, + protocol._terminal_collab_history, protocol._collab_thread_ids, protocol._collab_parent_ids, ), From 3046038191ee32d3ec8f3829675ec54ba4857f4e Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 08:49:39 +0000 Subject: [PATCH 26/35] fix(codex): release failed child reservations Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 27 +++++++++++++++++++-- tests/test_codex_app_server.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 91b5d81..9dfd117 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1841,6 +1841,12 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: required={"depth", "parent_thread_id"}, optional={"agent_nickname", "agent_path", "agent_role"}, ) + for field in ("agent_nickname", "agent_path", "agent_role"): + value = spawn.get(field) + if value is not None and not isinstance(value, str): + raise ProviderError( + f"child thread.source {field} must be a string or null" + ) source_parent_id = _require_protocol_id( spawn.get("parent_thread_id"), "child thread.source parent thread id", @@ -2248,6 +2254,18 @@ def _validate_collab_item( receiver is None for receiver in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None + failed_reservation = None + if ( + status == "failed" + and receiver is None + and expected_receiver is None + and self._unbound_collab_thread_ids + ): + if unbound_slots != 1 or len(self._unbound_collab_thread_ids) != 1: + raise ProviderError( + "receiver-less spawn failure had ambiguous child scope" + ) + failed_reservation = next(iter(self._unbound_collab_thread_ids)) if ( lifecycle == "snapshot" and status == "completed" @@ -2279,7 +2297,9 @@ def _validate_collab_item( and pending and expected_receiver is None ): - if receiver in self._unbound_collab_thread_ids: + if status == "failed" and receiver is None: + pass + elif receiver in self._unbound_collab_thread_ids: self._bind_unbound_collab_thread(receiver, sender) elif receiver is not None and receiver in self._collab_thread_ids: raise ProviderError("spawnAgent receiver was already claimed") @@ -2318,7 +2338,10 @@ def _validate_collab_item( if lifecycle == "snapshot": pass elif status == "failed": - for failed_receiver in receivers: + failed_receivers = set(receivers) + if failed_reservation is not None: + failed_receivers.add(failed_reservation) + for failed_receiver in failed_receivers: self._unbound_collab_thread_ids.discard(failed_receiver) self._collab_thread_ids.discard(failed_receiver) self._active_collab_thread_ids.discard(failed_receiver) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index d6e715e..eda3f49 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1990,6 +1990,33 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: self.assertEqual(protocol._pending_spawn_items, {}) self.assertEqual(protocol._unbound_collab_thread_ids, set()) + def test_receiverless_spawn_failure_releases_unambiguous_child_scope(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + item = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(item) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) + + protocol._validate_item({**item, "status": "failed"}) + + self.assertEqual(protocol._pending_spawn_items, {}) + self.assertEqual(protocol._unbound_collab_thread_ids, set()) + self.assertEqual(protocol._collab_thread_ids, set()) + def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: initial = { "agentsStates": {}, @@ -2023,6 +2050,22 @@ def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: self.assertEqual(protocol._unbound_collab_thread_ids, set()) self.assertEqual(protocol._validated_collab_thread_ids, set()) + for field in ("agent_nickname", "agent_path", "agent_role"): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + protocol._validate_item(initial) + thread = _child_thread() + thread["source"]["subAgent"]["thread_spawn"][field] = {} + with ( + self.subTest(field=field), + self.assertRaisesRegex(ProviderError, "string or null"), + ): + protocol._handle_notification("thread/started", {"thread": thread}) + self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._unbound_collab_thread_ids, set()) + self.assertEqual(protocol._validated_collab_thread_ids, set()) + protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" protocol._session_id = "session-1" From 44f199e6e3ed3b6ca09100b8c86030cd0627ec60 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 08:57:14 +0000 Subject: [PATCH 27/35] fix(codex): bind child depth provenance Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 9 +++++ tests/test_codex_app_server.py | 61 +++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 9dfd117..b3afbb6 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1256,6 +1256,7 @@ def __init__( self._collab_thread_ids: set[str] = set() self._validated_collab_thread_ids: set[str] = set() self._collab_parent_ids: dict[str, str] = {} + self._collab_depths: dict[str, int] = {} self._collab_turn_ids: dict[str, set[str]] = {} self._active_collab_thread_ids: set[str] = set() self._seen_collab_turn_ids: set[tuple[str, str]] = set() @@ -1852,6 +1853,11 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: "child thread.source parent thread id", ) bound_parent_id = self._collab_parent_ids.get(announced) + parent_depth = ( + 0 + if parent_id == self._thread_id + else self._collab_depths.get(parent_id) + ) depth = _optional_bounded_integer( spawn.get("depth"), "child thread.source depth", @@ -1872,6 +1878,8 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: or updated_at is None or updated_at < created_at or depth is None + or parent_depth is None + or depth != parent_depth + 1 or self._session_id is None or session_id != self._session_id or parent_id != source_parent_id @@ -1924,6 +1932,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: raise ProviderError("Codex thread announcement changed scope") self._validated_collab_thread_ids.add(announced) self._collab_parent_ids[announced] = parent_id + self._collab_depths[announced] = depth return if self._announced_thread_id is not None: raise ProviderError("Codex announced more than one thread") diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index eda3f49..7c7d809 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -161,6 +161,7 @@ def _model(model: str, efforts: tuple[str, ...]) -> dict[str, Any]: def _child_thread( thread_id: str = "child-1", *, + depth: int = 1, parent_id: str = "thread-1", session_id: str = "session-1", ) -> dict[str, Any]: @@ -179,7 +180,7 @@ def _child_thread( "source": { "subAgent": { "thread_spawn": { - "depth": 1, + "depth": depth, "parent_thread_id": parent_id, } } @@ -2118,6 +2119,64 @@ def test_child_thread_parent_must_own_a_pending_spawn(self) -> None: } ) + def test_child_thread_depth_matches_its_parent_chain(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + spawn = { + "agentsStates": {}, + "id": "spawn-root", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(spawn) + with self.assertRaisesRegex(ProviderError, "provenance"): + protocol._handle_notification( + "thread/started", + {"thread": _child_thread(depth=_MAX_COLLAB_THREADS)}, + ) + self.assertEqual(protocol._collab_depths, {}) + + protocol._handle_notification("thread/started", {"thread": _child_thread()}) + protocol._validate_item( + { + **spawn, + "agentsStates": {"child-1": {"status": "running"}}, + "receiverThreadIds": ["child-1"], + "status": "completed", + } + ) + nested_spawn = { + **spawn, + "id": "spawn-nested", + "senderThreadId": "child-1", + } + protocol._validate_item(nested_spawn) + with self.assertRaisesRegex(ProviderError, "provenance"): + protocol._handle_notification( + "thread/started", + { + "thread": _child_thread( + "child-2", + parent_id="child-1", + ) + }, + ) + protocol._handle_notification( + "thread/started", + { + "thread": _child_thread( + "child-2", + depth=2, + parent_id="child-1", + ) + }, + ) + self.assertEqual(protocol._collab_depths, {"child-1": 1, "child-2": 2}) + def test_child_thread_parent_matches_preannouncement_spawn_binding(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" From acff621c85e0dd892f8d287ee122e464e4603d0a Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 09:09:03 +0000 Subject: [PATCH 28/35] fix(codex): validate child thread metadata Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 102 ++++++++++++++++++++++++++++----- tests/test_codex_app_server.py | 48 +++++++++++++++- 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index b3afbb6..e66b68c 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1806,6 +1806,72 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: thread.get("id"), "thread/started.thread.id" ) if self._thread_id is not None and announced != self._thread_id: + _require_exact_keys( + thread, + "child thread", + required={ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt", + }, + optional={ + "agentNickname", + "agentRole", + "canAcceptDirectInput", + "extra", + "forkedFromId", + "gitInfo", + "historyMode", + "isPinned", + "name", + "parentThreadId", + "path", + "recencyAt", + "threadSource", + }, + ) + for field in ("agentNickname", "agentRole", "name"): + value = thread.get(field) + if value is not None and not isinstance(value, str): + raise ProviderError( + f"child thread.{field} must be a string or null" + ) + direct_input = thread.get("canAcceptDirectInput") + if direct_input is not None and type(direct_input) is not bool: + raise ProviderError( + "child thread.canAcceptDirectInput must be a bool or null" + ) + if "isPinned" in thread and type(thread["isPinned"]) is not bool: + raise ProviderError("child thread.isPinned must be a bool") + if thread.get("extra") is not None: + _require_object(thread["extra"], "child thread.extra") + git_info = thread.get("gitInfo") + if git_info is not None: + git_info = _require_object(git_info, "child thread.gitInfo") + _require_exact_keys( + git_info, + "child thread.gitInfo", + required=set(), + optional={"branch", "originUrl", "sha"}, + ) + for field in ("branch", "originUrl", "sha"): + value = git_info.get(field) + if value is not None and not isinstance(value, str): + raise ProviderError( + f"child thread.gitInfo.{field} must be a string or null" + ) + _optional_bounded_integer( + thread.get("recencyAt"), "child thread.recencyAt" + ) if announced in self._validated_collab_thread_ids: raise ProviderError("Codex announced a child thread more than once") created_at = _optional_bounded_integer( @@ -3160,6 +3226,7 @@ def _owner_lock( except OSError as exc: raise ProviderError(f"cannot open {label}: {exc}") from exc body_error: BaseException | None = None + serialization_timeout: ProviderTimeoutError | None = None try: identity = _validate_owner_file_descriptor(lock_path, descriptor, label) while True: @@ -3167,27 +3234,36 @@ def _owner_lock( fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) break except BlockingIOError: - _remaining(deadline, f"{label} serialization") + try: + _remaining(deadline, f"{label} serialization") + except ProviderTimeoutError as exc: + serialization_timeout = exc + break time.sleep(min(0.05, max(0.0, deadline - time.monotonic()))) - identity = _validate_owner_file_descriptor(lock_path, descriptor, label) - try: - yield identity - except BaseException as exc: - body_error = exc - raise - finally: + if serialization_timeout is None: + identity = _validate_owner_file_descriptor(lock_path, descriptor, label) try: - _validate_owner_file_descriptor(lock_path, descriptor, label) + yield identity except BaseException as exc: - if body_error is not None: - body_error.add_note(f"{label} integrity also failed: {exc}") - else: - raise + body_error = exc + raise + finally: + try: + _validate_owner_file_descriptor(lock_path, descriptor, label) + except BaseException as exc: + if body_error is not None: + body_error.add_note(f"{label} integrity also failed: {exc}") + else: + raise finally: try: fcntl.flock(descriptor, fcntl.LOCK_UN) finally: os.close(descriptor) + if serialization_timeout is not None: + raise ProviderTimeoutError( + str(serialization_timeout), cleanup_confirmed=True + ) from serialization_timeout @contextmanager diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 7c7d809..a683658 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -2067,11 +2067,47 @@ def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: self.assertEqual(protocol._unbound_collab_thread_ids, set()) self.assertEqual(protocol._validated_collab_thread_ids, set()) + invalid_metadata = { + "agentNickname": {}, + "agentRole": [], + "canAcceptDirectInput": "true", + "extra": [], + "gitInfo": {"branch": []}, + "isPinned": 0, + "name": {}, + "recencyAt": "now", + "unknownMetadata": None, + } + for field, value in invalid_metadata.items(): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._session_id = "session-1" + protocol._validate_item(initial) + thread = _child_thread() + thread[field] = value + with self.subTest(field=field), self.assertRaises(ProviderError): + protocol._handle_notification("thread/started", {"thread": thread}) + self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._validated_collab_thread_ids, set()) + protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" protocol._session_id = "session-1" protocol._validate_item(initial) - protocol._handle_notification("thread/started", {"thread": _child_thread()}) + thread = _child_thread() + thread.update( + { + "agentNickname": "swift-fox", + "agentRole": None, + "canAcceptDirectInput": True, + "extra": {}, + "gitInfo": {"branch": None, "originUrl": None, "sha": None}, + "isPinned": False, + "name": None, + "recencyAt": 1_700_000_001, + } + ) + protocol._handle_notification("thread/started", {"thread": thread}) self.assertEqual(protocol._validated_collab_thread_ids, {"child-1"}) def test_child_thread_parent_must_own_a_pending_spawn(self) -> None: @@ -3635,9 +3671,12 @@ def holder() -> None: self.assertTrue(entered.wait(1)) try: other = _CleanupPoisonStore(self.root) - with self.assertRaisesRegex(ProviderError, "serialization timed out"): + with self.assertRaisesRegex( + ProviderTimeoutError, "serialization timed out" + ) as caught: with other.lock(time.monotonic() + 0.1): self.fail("independent provider unexpectedly acquired the lock") + self.assertTrue(caught.exception.cleanup_confirmed) finally: release.set() thread.join(2) @@ -4453,9 +4492,12 @@ def holder() -> None: thread.start() self.assertTrue(entered.wait(1)) try: - with self.assertRaisesRegex(ProviderError, "serialization timed out"): + with self.assertRaisesRegex( + ProviderTimeoutError, "serialization timed out" + ) as caught: with _auth_lock(self.auth, time.monotonic() + 0.1): self.fail("second auth lock unexpectedly acquired") + self.assertTrue(caught.exception.cleanup_confirmed) finally: release.set() thread.join(2) From a2fe3673a3c72b4398774da8a1163076bdb48d5b Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 09:17:43 +0000 Subject: [PATCH 29/35] fix(codex): seal post-terminal turn traffic Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 11 +++++++++++ tests/test_codex_app_server.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index e66b68c..9c05411 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1669,6 +1669,17 @@ def _start_turn(self, prompt: str, deadline: float) -> None: def _handle_notification(self, method: str, raw_params: Any) -> None: params = _require_object(raw_params, "notification params") + if self._turn_completed is not None and ( + method.startswith(("item/", "model/", "turn/")) + or method + in { + "error", + "thread/started", + "thread/status/changed", + "thread/tokenUsage/updated", + } + ): + raise ProviderError("Codex emitted turn traffic after root completion") if method == "model/rerouted": raise ProviderError("Codex rerouted the pinned model") if method == "error": diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index a683658..784cec8 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -779,6 +779,41 @@ def test_happy_path_adds_child_usage_to_reported_totals(self) -> None: ) self.assertEqual(outcome.raw_response["usage"], outcome.tokens) + def test_post_completion_quota_read_rejects_new_collaboration_work(self) -> None: + class PostCompletionTransport(ScriptedTransport): + def send(self, payload: bytes, deadline: float) -> None: + message = json.loads(payload) + if ( + message.get("method") == "account/rateLimits/read" + and self.rate_reads == 1 + ): + self.incoming.append( + _line( + { + "method": "item/started", + "params": { + "item": { + "agentsStates": {}, + "id": "late-spawn", + "receiverThreadIds": [], + "senderThreadId": self.thread_id, + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + }, + "threadId": self.thread_id, + "turnId": self.turn_id, + }, + } + ) + ) + super().send(payload, deadline) + + with self.assertRaisesRegex(ProviderError, "after root completion"): + _protocol(PostCompletionTransport(Path("/runtime/work"))).run( + "request", time.monotonic() + 5 + ) + def test_thread_cli_version_mismatch_fails_before_turn_start(self) -> None: for version in ("codex-cli 0.144.1", "0.144.2"): with self.subTest(version=version): From 5c1adc5617934647dc18f787b55223b20b688ea2 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 09:31:57 +0000 Subject: [PATCH 30/35] fix(codex): preserve collaboration lifecycle boundaries Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 18 ++++++++++++ tests/test_codex_app_server.py | 50 +++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 9c05411..f797508 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1271,6 +1271,7 @@ def __init__( tuple[str, str], tuple[str, tuple[str, ...]] ] = {} self._unbound_collab_thread_ids: set[str] = set() + self._failed_collab_thread_ids: set[str] = set() self._retained_text_bytes = 0 self._last_usage: dict[str, int] | None = None self._rate_limits: dict[str, Any] | None = None @@ -2232,6 +2233,8 @@ def _validate_collab_item( and (sender == self._thread_id or tool == "spawnAgent") ): raise ProviderError("collaboration receiver thread ids are invalid") + if any(receiver in self._failed_collab_thread_ids for receiver in receivers): + raise ProviderError("failed spawn child thread ID was reused") status = _require_string(item.get("status"), "collaboration item status") if status not in {"inProgress", "completed", "failed"}: raise ProviderError("collaboration item status is unknown") @@ -2428,6 +2431,7 @@ def _validate_collab_item( if failed_reservation is not None: failed_receivers.add(failed_reservation) for failed_receiver in failed_receivers: + self._failed_collab_thread_ids.add(failed_receiver) self._unbound_collab_thread_ids.discard(failed_receiver) self._collab_thread_ids.discard(failed_receiver) self._active_collab_thread_ids.discard(failed_receiver) @@ -2452,6 +2456,18 @@ def _validate_collab_item( self._active_collab_thread_ids.add(thread_id) else: self._active_collab_thread_ids.discard(thread_id) + if ( + lifecycle == "completed" + and tool in {"sendInput", "resumeAgent"} + and status == "completed" + ): + self._active_collab_thread_ids.update( + receiver + for receiver in receivers + if receiver != self._thread_id + and receiver in self._collab_thread_ids + and receiver not in agent_statuses + ) if lifecycle != "snapshot" and status in {"completed", "failed"}: if len(self._terminal_collab_item_ids) >= _MAX_MESSAGES: raise ProviderError( @@ -2509,6 +2525,8 @@ def _matches_turn(self, params: dict[str, Any]) -> bool: ) def _claim_collab_thread_scope(self, thread_id: str) -> bool: + if thread_id in self._failed_collab_thread_ids: + raise ProviderError("failed spawn child thread ID was reused") if thread_id in self._collab_thread_ids: return True open_spawn_slots = sum( diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 784cec8..23677f5 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1036,6 +1036,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "resumed-thread", "resumed-item", "started-resume-item", + "completed-resume-without-state", ): protocol = _protocol(QueueTransport([])) protocol._thread_id = "main-thread" @@ -1074,25 +1075,35 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "type": "collabAgentToolCall", } ) - else: + elif state in {"started-resume-item", "completed-resume-without-state"}: protocol._collab_thread_ids.add("child-1") protocol._seen_collab_turn_ids.add(("child-1", "prior-turn")) + resume = { + "agentsStates": {}, + "id": "resume-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "main-thread", + "status": "inProgress", + "tool": "resumeAgent", + "type": "collabAgentToolCall", + } protocol._handle_notification( "item/started", { - "item": { - "agentsStates": {}, - "id": "resume-1", - "receiverThreadIds": ["child-1"], - "senderThreadId": "main-thread", - "status": "inProgress", - "tool": "resumeAgent", - "type": "collabAgentToolCall", - }, + "item": resume, "threadId": "main-thread", "turnId": "main-turn", }, ) + if state == "completed-resume-without-state": + protocol._handle_notification( + "item/completed", + { + "item": {**resume, "status": "completed"}, + "threadId": "main-thread", + "turnId": "main-turn", + }, + ) with ( self.subTest(state=state), @@ -2052,6 +2063,25 @@ def test_receiverless_spawn_failure_releases_unambiguous_child_scope(self) -> No self.assertEqual(protocol._pending_spawn_items, {}) self.assertEqual(protocol._unbound_collab_thread_ids, set()) self.assertEqual(protocol._collab_thread_ids, set()) + self.assertEqual(protocol._failed_collab_thread_ids, {"child-1"}) + + protocol._validate_item({**item, "id": "item-2"}) + with self.assertRaisesRegex(ProviderError, "failed spawn child"): + protocol._validate_item( + { + **item, + "id": "item-2", + "receiverThreadIds": ["child-1"], + } + ) + with self.assertRaisesRegex(ProviderError, "failed spawn child"): + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) def test_child_thread_provenance_is_validated_before_scope_claim(self) -> None: initial = { From 1d7ff20adc96dc5a8da0cd544c8edfb211b0e8a4 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 09:40:17 +0000 Subject: [PATCH 31/35] fix(codex): claim assigned pending receivers Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 3 +++ tests/test_codex_app_server.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index f797508..18b54d7 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -2412,6 +2412,9 @@ def _validate_collab_item( if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: raise ProviderError("pending spawn count exceeds the limit") self._pending_spawn_items[pending_key] = receiver + if receiver is not None: + self._collab_parent_ids[receiver] = sender + self._collab_thread_ids.add(receiver) elif expected_receiver is None and receiver is not None: if receiver in self._unbound_collab_thread_ids: self._bind_unbound_collab_thread(receiver, sender) diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 23677f5..4fe0304 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1646,6 +1646,34 @@ def test_spawn_agent_allows_schema_defined_empty_initial_receivers(self) -> None self.assertEqual(protocol._collab_thread_ids, {"child-1"}) self.assertEqual(protocol._pending_spawn_items, {}) + def test_pending_spawn_claims_its_explicit_receiver(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + item = { + "agentsStates": {}, + "id": "item-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + + protocol._validate_item(item) + protocol._handle_notification( + "thread/status/changed", + { + "status": {"activeFlags": [], "type": "active"}, + "threadId": "child-1", + }, + ) + + self.assertEqual( + protocol._pending_spawn_items, {("thread-1", "item-1"): "child-1"} + ) + self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) + def test_failed_spawn_releases_unstarted_receiver(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" From f19eaac4a32aa49e9a08d5d12f28b50c2f1d19b0 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 10:09:31 +0000 Subject: [PATCH 32/35] fix: match pinned Codex collaboration events Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 95 ++++++++++++++------- tests/test_codex_app_server.py | 150 +++++++++++++++++++++------------ 2 files changed, 160 insertions(+), 85 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 18b54d7..903a605 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1264,7 +1264,15 @@ def __init__( self._pending_spawn_items: dict[tuple[str, str], str | None] = {} self._terminal_collab_history: dict[ tuple[str, str], - tuple[str, str, tuple[str, ...], tuple[tuple[str, str], ...]], + tuple[ + str, + str, + tuple[str, ...], + tuple[tuple[str, str], ...], + str | None, + str | None, + str | None, + ], ] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() self._active_nonspawn_items: dict[ @@ -2000,6 +2008,9 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: spawn_status, spawn_receivers, _agent_statuses, + _model, + _prompt, + _reasoning_effort, ) in self._terminal_collab_history.items() ) ): @@ -2245,17 +2256,37 @@ def _validate_collab_item( item_scope = (sender, item_id) if lifecycle != "snapshot" and item_scope in self._terminal_collab_item_ids: raise ProviderError("collaboration item ID was reused after termination") - for field in ("model", "prompt", "reasoningEffort"): - value = item.get(field) + model = item.get("model") + prompt = item.get("prompt") + reasoning_effort = item.get("reasoningEffort") + for field, value in ( + ("model", model), + ("prompt", prompt), + ("reasoningEffort", reasoning_effort), + ): if value is not None and not isinstance(value, str): raise ProviderError(f"collaboration {field} must be a string or null") - if item.get("reasoningEffort") == "": + spawn_placeholder = tool == "spawnAgent" and ( + lifecycle == "started" + or ( + lifecycle in {"completed", "snapshot"} + and status == "failed" + and not receivers + ) + ) + if spawn_placeholder: + if model != "" or reasoning_effort != "medium": + raise ProviderError("spawnAgent placeholder metadata is invalid") + elif tool == "spawnAgent" and lifecycle == "completed": + if model != self._model or reasoning_effort != self._reasoning_effort: + raise ProviderError("terminal spawnAgent metadata is invalid") + elif reasoning_effort == "": raise ProviderError( "collaboration reasoningEffort must be a non-empty string or null" ) - if item.get("model") not in {None, self._model}: + elif model not in {None, self._model}: raise ProviderError("collaboration model differs from the pinned model") - if item.get("reasoningEffort") not in {None, self._reasoning_effort}: + elif reasoning_effort not in {None, self._reasoning_effort}: raise ProviderError( "collaboration reasoningEffort differs from the pinned reasoning effort" ) @@ -2306,6 +2337,9 @@ def _validate_collab_item( status, tuple(receivers), tuple(sorted(agent_statuses.items())), + model, + prompt, + reasoning_effort, ) if ( lifecycle == "snapshot" @@ -2326,6 +2360,8 @@ def _validate_collab_item( "terminal collaboration item disagreed with its start" ) if tool == "spawnAgent": + if lifecycle == "started" and receivers: + raise ProviderError("spawnAgent start must not have receivers") if status == "completed" and len(receivers) != 1: raise ProviderError( "completed spawnAgent must have exactly one receiver" @@ -2335,6 +2371,8 @@ def _validate_collab_item( pending_key = (sender, item_id) pending = pending_key in self._pending_spawn_items expected_receiver = self._pending_spawn_items.get(pending_key) + if status == "inProgress" and pending: + raise ProviderError("spawnAgent item was already in progress") if expected_receiver is not None and receivers != [expected_receiver]: raise ProviderError( "spawnAgent receiver did not match its pending child thread" @@ -2404,27 +2442,11 @@ def _validate_collab_item( if lifecycle == "snapshot": pass elif status == "inProgress": - if not pending: - if any( - receiver in self._collab_thread_ids for receiver in receivers - ): - raise ProviderError("spawnAgent receiver was already claimed") - if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: - raise ProviderError("pending spawn count exceeds the limit") - self._pending_spawn_items[pending_key] = receiver - if receiver is not None: - self._collab_parent_ids[receiver] = sender - self._collab_thread_ids.add(receiver) - elif expected_receiver is None and receiver is not None: - if receiver in self._unbound_collab_thread_ids: - self._bind_unbound_collab_thread(receiver, sender) - elif receiver in self._collab_thread_ids: - raise ProviderError("spawnAgent receiver was already claimed") - elif len(self._unbound_collab_thread_ids) >= unbound_slots: - raise ProviderError( - "spawnAgent receiver did not match a pending child thread" - ) - self._pending_spawn_items[pending_key] = receiver + if any(receiver in self._collab_thread_ids for receiver in receivers): + raise ProviderError("spawnAgent receiver was already claimed") + if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: + raise ProviderError("pending spawn count exceeds the limit") + self._pending_spawn_items[pending_key] = receiver else: self._pending_spawn_items.pop(pending_key, None) if lifecycle == "snapshot": @@ -2497,7 +2519,7 @@ def _validate_completed_turn( items = _require_list( turn.get("items"), "completed turn items", maximum=_MAX_MESSAGES ) - items_view = turn.get("itemsView", "full") + items_view = _require_string(turn.get("itemsView"), "completed turn itemsView") if items_view not in {"full", "notLoaded", "summary"}: raise ProviderError("completed turn returned an unsupported item view") if items_view == "notLoaded" and items: @@ -2661,13 +2683,13 @@ def _finalize_turn(self) -> tuple[str, dict[str, Any]]: if turn.get("error") is not None: raise ProviderError("completed Codex turn included an error") items = _require_list(turn.get("items"), "turn.items", maximum=_MAX_MESSAGES) - items_view = turn.get("itemsView", "full") + items_view = _require_string(turn.get("itemsView"), "turn.itemsView") messages: list[dict[str, str | None]] if items_view == "notLoaded": if items: raise ProviderError("Codex not-loaded turn items must be empty") messages = list(self._completed_messages) - elif items_view == "full": + elif items_view in {"full", "summary"}: messages = [] for index, raw_item in enumerate(items): item = _require_object(raw_item, f"turn.items[{index}]") @@ -2688,6 +2710,19 @@ def _finalize_turn(self) -> tuple[str, dict[str, Any]]: ), } ) + if items_view == "summary": + if len(items) != 1 or len(messages) != 1: + raise ProviderError( + "Codex summary turn must contain one agent message" + ) + if ( + not self._completed_messages + or messages[0] != self._completed_messages[-1] + ): + raise ProviderError( + "Codex summary message disagrees with its completion event" + ) + messages = list(self._completed_messages) else: raise ProviderError("Codex turn returned an unsupported item view") if not messages or not self._completed_messages: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 4fe0304..7ea038e 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -293,7 +293,7 @@ def __init__( skill_disable_succeeds: bool = True, thread_cli_version: str = "0.146.0", turn_error: dict[str, Any] | None = None, - turn_items_view: str | None = "notLoaded", + turn_items_view: str | None = "summary", ) -> None: self.workspace = workspace self.additional_item_completed = additional_item_completed @@ -599,11 +599,18 @@ def _queue_turn_events(self) -> None: full_items.append(self.additional_item_completed) if self.full_has_non_object_item: full_items.append(None) - turn_items = ( - full_items - if self.turn_items_view in {None, "full"} or self.not_loaded_has_items - else [] - ) + if self.turn_items_view == "summary": + turn_items = [ + next( + item + for item in reversed(full_items) + if isinstance(item, dict) and item.get("type") == "agentMessage" + ) + ] + elif self.turn_items_view == "full" or self.not_loaded_has_items: + turn_items = full_items + else: + turn_items = [] turn = { "durationMs": 25, "id": self.turn_id, @@ -702,7 +709,7 @@ def test_happy_path_paginates_disables_skills_merges_quota_and_uses_last_usage( self.assertEqual(outcome.final_output, "completed fixture") self.assertEqual(outcome.tokens["input_tokens"], 10) self.assertEqual(outcome.tokens["total_tokens"], 15) - self.assertEqual(outcome.raw_response["turn"]["items_view"], "notLoaded") + self.assertEqual(outcome.raw_response["turn"]["items_view"], "summary") rolling = outcome.quota["rolling"]["rateLimits"] self.assertEqual(rolling["planType"], "pro") self.assertEqual(rolling["primary"]["usedPercent"], 40) @@ -1191,15 +1198,16 @@ def test_missing_usage_rejects_completed_turn(self) -> None: ).run("request", time.monotonic() + 5) def test_disagreeing_final_message_is_rejected(self) -> None: - for field in ("id", "phase", "text"): - with self.subTest(field=field): - transport = ScriptedTransport( - Path("/runtime/work"), - disagree_final_field=field, - turn_items_view="full", - ) - with self.assertRaisesRegex(ProviderError, "disagrees"): - _protocol(transport).run("request", time.monotonic() + 5) + for items_view in ("full", "summary"): + for field in ("id", "phase", "text"): + with self.subTest(items_view=items_view, field=field): + transport = ScriptedTransport( + Path("/runtime/work"), + disagree_final_field=field, + turn_items_view=items_view, + ) + with self.assertRaisesRegex(ProviderError, "disagrees"): + _protocol(transport).run("request", time.monotonic() + 5) def test_full_turn_rejects_duplicate_matching_completion_events(self) -> None: transport = ScriptedTransport( @@ -1212,7 +1220,9 @@ def test_full_turn_rejects_duplicate_matching_completion_events(self) -> None: def test_not_loaded_turn_rejects_duplicate_completion_events(self) -> None: transport = ScriptedTransport( - Path("/runtime/work"), duplicate_item_completed=True + Path("/runtime/work"), + duplicate_item_completed=True, + turn_items_view="notLoaded", ) with self.assertRaisesRegex(ProviderError, "repeated an agent-message id"): _protocol(transport).run("request", time.monotonic() + 5) @@ -1226,6 +1236,7 @@ def test_not_loaded_turn_rejects_multiple_final_messages(self) -> None: "text": "second final", "type": "agentMessage", }, + turn_items_view="notLoaded", ) with self.assertRaisesRegex(ProviderError, "multiple final-answer messages"): _protocol(transport).run("request", time.monotonic() + 5) @@ -1235,7 +1246,7 @@ def test_last_agent_message_must_be_terminal(self) -> None: ("commentary", "ended with commentary"), (None, "final-answer message was not terminal"), ) - for items_view in ("notLoaded", "full"): + for items_view in ("notLoaded", "full", "summary"): for phase, expected in cases: with self.subTest(items_view=items_view, phase=phase): transport = ScriptedTransport( @@ -1252,7 +1263,7 @@ def test_last_agent_message_must_be_terminal(self) -> None: _protocol(transport).run("request", time.monotonic() + 5) def test_terminal_agent_message_must_contain_non_whitespace_text(self) -> None: - for items_view in ("notLoaded", "full"): + for items_view in ("notLoaded", "full", "summary"): with self.subTest(items_view=items_view): transport = ScriptedTransport( Path("/runtime/work"), @@ -1287,15 +1298,15 @@ def test_duplicate_agent_message_ids_are_rejected(self) -> None: with self.assertRaisesRegex(ProviderError, "repeated an agent-message id"): _protocol(transport).run("request", time.monotonic() + 5) - def test_full_and_legacy_full_turn_items_remain_compatible(self) -> None: - for items_view in ("full", None): + def test_full_and_summary_turn_items_are_validated(self) -> None: + for items_view in ("full", "summary"): with self.subTest(items_view=items_view): outcome = _protocol( ScriptedTransport(Path("/runtime/work"), turn_items_view=items_view) ).run("request", time.monotonic() + 5) self.assertEqual(outcome.final_output, "completed fixture") - self.assertEqual(outcome.raw_response["turn"]["items_view"], "full") + self.assertEqual(outcome.raw_response["turn"]["items_view"], items_view) def test_full_turn_rejects_non_object_items(self) -> None: transport = ScriptedTransport( @@ -1308,34 +1319,44 @@ def test_full_turn_rejects_non_object_items(self) -> None: ): _protocol(transport).run("request", time.monotonic() + 5) - def test_summary_and_unknown_turn_item_views_are_rejected(self) -> None: - for items_view in ("summary", "future"): - with self.subTest(items_view=items_view): - with self.assertRaisesRegex(ProviderError, "unsupported item view"): - _protocol( - ScriptedTransport( - Path("/runtime/work"), turn_items_view=items_view - ) - ).run("request", time.monotonic() + 5) + def test_missing_and_unknown_turn_item_views_are_rejected(self) -> None: + for items_view, expected in ( + (None, "itemsView"), + ("future", "unsupported item view"), + ): + with ( + self.subTest(items_view=items_view), + self.assertRaisesRegex(ProviderError, expected), + ): + _protocol( + ScriptedTransport(Path("/runtime/work"), turn_items_view=items_view) + ).run("request", time.monotonic() + 5) def test_not_loaded_turn_items_must_be_empty(self) -> None: - transport = ScriptedTransport(Path("/runtime/work"), not_loaded_has_items=True) + transport = ScriptedTransport( + Path("/runtime/work"), + not_loaded_has_items=True, + turn_items_view="notLoaded", + ) with self.assertRaisesRegex( ProviderError, "not-loaded turn items must be empty" ): _protocol(transport).run("request", time.monotonic() + 5) def test_turn_requires_authoritative_completed_message(self) -> None: - for items_view in ("notLoaded", "full"): + for items_view in ("notLoaded", "full", "summary"): with self.subTest(items_view=items_view): transport = ScriptedTransport( Path("/runtime/work"), omit_item_completed=True, turn_items_view=items_view, ) - with self.assertRaisesRegex( - ProviderError, "completed final agent message" - ): + expected = ( + "summary message disagrees" + if items_view == "summary" + else "completed final agent message" + ) + with self.assertRaisesRegex(ProviderError, expected): _protocol(transport).run("request", time.monotonic() + 5) def test_skill_disable_refusal_is_rejected(self) -> None: @@ -1561,7 +1582,10 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: item.update( { "agentsStates": {}, - "receiverThreadIds": ["child-1"], + "model": "", + "prompt": "delegated task", + "reasoningEffort": "medium", + "receiverThreadIds": [], "senderThreadId": "thread-1", "status": "inProgress", "tool": "spawnAgent", @@ -1620,38 +1644,52 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: ) self.assertNotIn(sentinel, str(raised.exception)) - def test_spawn_agent_allows_schema_defined_empty_initial_receivers(self) -> None: + def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + envelope = {"threadId": "thread-1", "turnId": "turn-1"} initial = { "agentsStates": {}, "id": "item-1", + "model": "", + "prompt": "delegated task", + "reasoningEffort": "medium", "receiverThreadIds": [], "senderThreadId": "thread-1", "status": "inProgress", "tool": "spawnAgent", "type": "collabAgentToolCall", } - protocol._validate_item(initial) + protocol._handle_notification("item/started", {**envelope, "item": initial}) self.assertEqual(protocol._collab_thread_ids, set()) self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + with self.assertRaisesRegex(ProviderError, "already in progress"): + protocol._handle_notification("item/started", {**envelope, "item": initial}) completed = { **initial, "agentsStates": {"child-1": {"status": "completed"}}, + "model": "gpt-5.6-luna", + "reasoningEffort": "low", "receiverThreadIds": ["child-1"], "status": "completed", } - protocol._validate_item(completed) + protocol._handle_notification("item/completed", {**envelope, "item": completed}) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) + self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) self.assertEqual(protocol._pending_spawn_items, {}) - def test_pending_spawn_claims_its_explicit_receiver(self) -> None: + def test_spawn_start_rejects_an_explicit_receiver(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" item = { "agentsStates": {}, "id": "item-1", + "model": "", + "prompt": "delegated task", + "reasoningEffort": "medium", "receiverThreadIds": ["child-1"], "senderThreadId": "thread-1", "status": "inProgress", @@ -1659,20 +1697,12 @@ def test_pending_spawn_claims_its_explicit_receiver(self) -> None: "type": "collabAgentToolCall", } - protocol._validate_item(item) - protocol._handle_notification( - "thread/status/changed", - { - "status": {"activeFlags": [], "type": "active"}, - "threadId": "child-1", - }, - ) - - self.assertEqual( - protocol._pending_spawn_items, {("thread-1", "item-1"): "child-1"} - ) - self.assertEqual(protocol._collab_thread_ids, {"child-1"}) - self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) + with self.assertRaisesRegex(ProviderError, "must not have receivers"): + protocol._handle_notification( + "item/started", + {"item": item, "threadId": "thread-1", "turnId": "turn-1"}, + ) + self.assertEqual(protocol._pending_spawn_items, {}) def test_failed_spawn_releases_unstarted_receiver(self) -> None: protocol = _protocol(QueueTransport([])) @@ -1741,6 +1771,8 @@ def test_spawn_snapshot_requires_matching_live_terminal_history(self) -> None: completed = { **initial, "agentsStates": {"child-1": {"status": "running"}}, + "model": "gpt-5.6-luna", + "reasoningEffort": "low", "receiverThreadIds": ["child-1"], "status": "completed", } @@ -1760,6 +1792,8 @@ def test_spawn_snapshot_requires_matching_live_terminal_history(self) -> None: { **completed, "agentsStates": {}, + "model": "", + "reasoningEffort": "medium", "receiverThreadIds": [], "status": "failed", }, @@ -1849,6 +1883,9 @@ def test_live_terminal_spawn_requires_a_pending_start(self) -> None: protocol._collab_thread_ids.add("child-1") item = { **base, + "model": "" if status == "failed" else "gpt-5.6-luna", + "prompt": "delegated task", + "reasoningEffort": "medium" if status == "failed" else "low", "receiverThreadIds": receivers, "status": status, } @@ -1873,6 +1910,9 @@ def test_terminal_collaboration_item_id_cannot_restart(self) -> None: item = { "agentsStates": {}, "id": "spawn-1", + "model": "", + "prompt": "delegated task", + "reasoningEffort": "medium", "receiverThreadIds": [], "senderThreadId": "thread-1", "status": "inProgress", From 85c53ba20324953bf7e0c0d96c4264a048bcfb06 Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 11:02:53 +0000 Subject: [PATCH 33/35] fix: bind pinned Codex lifecycle semantics Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 71 ++++++++-- tests/test_codex_app_server.py | 241 +++++++++++++++++++++++++++++---- 2 files changed, 277 insertions(+), 35 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 903a605..f2eb0c4 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1261,7 +1261,9 @@ def __init__( self._active_collab_thread_ids: set[str] = set() self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} - self._pending_spawn_items: dict[tuple[str, str], str | None] = {} + self._pending_spawn_items: dict[ + tuple[str, str], tuple[str | None, str | None] + ] = {} self._terminal_collab_history: dict[ tuple[str, str], tuple[ @@ -1989,11 +1991,11 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: ) if not ( any( - sender == parent_id and receiver in {None, announced} + sender == parent_id and pending[0] in {None, announced} for ( sender, _item_id, - ), receiver in self._pending_spawn_items.items() + ), pending in self._pending_spawn_items.items() ) or any( spawn_tool == "spawnAgent" @@ -2075,6 +2077,15 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: main_turn = self._matches_turn(params) if not main_turn and not self._matches_collab_turn(params): raise ProviderError("Codex item completion targeted an unknown turn") + if ( + _optional_bounded_integer( + params.get("completedAtMs"), + "item/completed.completedAtMs", + minimum=-(2**63), + ) + is None + ): + raise ProviderError("item/completed.completedAtMs is required") item = _require_object(params.get("item"), "item/completed.item") self._validate_item( item, @@ -2106,6 +2117,15 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: if method == "item/started": if not self._matches_turn(params) and not self._matches_collab_turn(params): raise ProviderError("Codex item start targeted an unknown turn") + if ( + _optional_bounded_integer( + params.get("startedAtMs"), + "item/started.startedAtMs", + minimum=-(2**63), + ) + is None + ): + raise ProviderError("item/started.startedAtMs is required") self._validate_item( _require_object(params.get("item"), "item/started.item"), owner_thread_id=_require_protocol_id( @@ -2370,15 +2390,22 @@ def _validate_collab_item( raise ProviderError("spawnAgent must have at most one receiver") pending_key = (sender, item_id) pending = pending_key in self._pending_spawn_items - expected_receiver = self._pending_spawn_items.get(pending_key) + expected_receiver, expected_prompt = self._pending_spawn_items.get( + pending_key, (None, None) + ) if status == "inProgress" and pending: raise ProviderError("spawnAgent item was already in progress") + if lifecycle == "completed" and pending and prompt != expected_prompt: + raise ProviderError( + "terminal spawnAgent prompt disagreed with its start" + ) if expected_receiver is not None and receivers != [expected_receiver]: raise ProviderError( "spawnAgent receiver did not match its pending child thread" ) unbound_slots = sum( - receiver is None for receiver in self._pending_spawn_items.values() + receiver is None + for receiver, _prompt in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None failed_reservation = None @@ -2446,7 +2473,7 @@ def _validate_collab_item( raise ProviderError("spawnAgent receiver was already claimed") if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: raise ProviderError("pending spawn count exceeds the limit") - self._pending_spawn_items[pending_key] = receiver + self._pending_spawn_items[pending_key] = (receiver, prompt) else: self._pending_spawn_items.pop(pending_key, None) if lifecycle == "snapshot": @@ -2478,7 +2505,16 @@ def _validate_collab_item( ): continue if agent_status in {"pendingInit", "running"}: - self._active_collab_thread_ids.add(thread_id) + child_has_completed_turn = any( + seen_thread_id == thread_id + for seen_thread_id, _turn_id in self._seen_collab_turn_ids + ) and not self._collab_turn_ids.get(thread_id) + if not ( + tool == "spawnAgent" + and lifecycle == "completed" + and child_has_completed_turn + ): + self._active_collab_thread_ids.add(thread_id) else: self._active_collab_thread_ids.discard(thread_id) if ( @@ -2555,7 +2591,7 @@ def _claim_collab_thread_scope(self, thread_id: str) -> bool: if thread_id in self._collab_thread_ids: return True open_spawn_slots = sum( - receiver is None for receiver in self._pending_spawn_items.values() + receiver is None for receiver, _prompt in self._pending_spawn_items.values() ) if len(self._unbound_collab_thread_ids) >= open_spawn_slots: return False @@ -2715,19 +2751,30 @@ def _finalize_turn(self) -> tuple[str, dict[str, Any]]: raise ProviderError( "Codex summary turn must contain one agent message" ) + summary_index = next( + ( + index + for index in range(len(self._completed_messages) - 1, -1, -1) + if str(self._completed_messages[index]["text"]).strip() + ), + None, + ) + if summary_index is None: + summary_index = len(self._completed_messages) - 1 if ( - not self._completed_messages - or messages[0] != self._completed_messages[-1] + summary_index < 0 + or messages[0] != self._completed_messages[summary_index] ): raise ProviderError( "Codex summary message disagrees with its completion event" ) - messages = list(self._completed_messages) + messages = self._completed_messages[: summary_index + 1] else: raise ProviderError("Codex turn returned an unsupported item view") if not messages or not self._completed_messages: raise ProviderError("Codex turn omitted a completed final agent message") - message_ids = [message["id"] for message in messages] + id_source = self._completed_messages if items_view == "summary" else messages + message_ids = [message["id"] for message in id_source] if len(set(message_ids)) != len(message_ids): raise ProviderError("Codex turn repeated an agent-message id") if items_view == "full" and messages != self._completed_messages: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 7ea038e..4796999 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -602,9 +602,18 @@ def _queue_turn_events(self) -> None: if self.turn_items_view == "summary": turn_items = [ next( - item - for item in reversed(full_items) - if isinstance(item, dict) and item.get("type") == "agentMessage" + ( + item + for item in reversed(full_items) + if isinstance(item, dict) + and item.get("type") == "agentMessage" + and str(item.get("text", "")).strip() + ), + next( + item + for item in reversed(full_items) + if isinstance(item, dict) and item.get("type") == "agentMessage" + ), ) ] elif self.turn_items_view == "full" or self.not_loaded_has_items: @@ -808,6 +817,7 @@ def send(self, payload: bytes, deadline: float) -> None: "tool": "spawnAgent", "type": "collabAgentToolCall", }, + "startedAtMs": 1, "threadId": self.thread_id, "turnId": self.turn_id, }, @@ -1049,7 +1059,10 @@ def test_root_completion_requires_terminal_child_work(self) -> None: protocol._thread_id = "main-thread" protocol._turn_id = "main-turn" if state == "pending-spawn": - protocol._pending_spawn_items[("main-thread", "spawn-1")] = None + protocol._pending_spawn_items[("main-thread", "spawn-1")] = ( + None, + None, + ) elif state == "bound-thread": protocol._collab_thread_ids.add("child-1") elif state == "awaiting-turn": @@ -1098,6 +1111,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "item/started", { "item": resume, + "startedAtMs": 1, "threadId": "main-thread", "turnId": "main-turn", }, @@ -1107,6 +1121,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "item/completed", { "item": {**resume, "status": "completed"}, + "completedAtMs": 2, "threadId": "main-thread", "turnId": "main-turn", }, @@ -1143,14 +1158,18 @@ def test_root_completion_requires_terminal_child_work(self) -> None: protocol._validate_item(completed_resume, lifecycle="snapshot") with self.assertRaisesRegex(ProviderError, "completed without a start"): protocol._handle_notification( - "item/completed", {**envelope, "item": completed_resume} + "item/completed", + {**envelope, "completedAtMs": 2, "item": completed_resume}, ) - protocol._handle_notification("item/started", {**envelope, "item": resume}) + protocol._handle_notification( + "item/started", {**envelope, "item": resume, "startedAtMs": 1} + ) with self.assertRaisesRegex(ProviderError, "disagreed with its start"): protocol._handle_notification( "item/completed", { **envelope, + "completedAtMs": 2, "item": { **resume, "status": "completed", @@ -1162,6 +1181,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "item/completed", { **envelope, + "completedAtMs": 2, "item": { **resume, "agentsStates": {"child-1": {"status": "completed"}}, @@ -1308,6 +1328,23 @@ def test_full_and_summary_turn_items_are_validated(self) -> None: self.assertEqual(outcome.final_output, "completed fixture") self.assertEqual(outcome.raw_response["turn"]["items_view"], items_view) + def test_summary_uses_the_last_nonblank_completed_agent_message(self) -> None: + outcome = _protocol( + ScriptedTransport( + Path("/runtime/work"), + additional_item_completed={ + "id": "message-2", + "phase": None, + "text": " ", + "type": "agentMessage", + }, + turn_items_view="summary", + ) + ).run("request", time.monotonic() + 5) + + self.assertEqual(outcome.final_output, "completed fixture") + self.assertEqual(outcome.raw_response["turn"]["items_view"], "summary") + def test_full_turn_rejects_non_object_items(self) -> None: transport = ScriptedTransport( Path("/runtime/work"), @@ -1499,6 +1536,7 @@ def test_untrusted_protocol_fields_are_not_echoed_in_errors(self) -> None: notification_protocol._handle_notification( "item/completed", { + "completedAtMs": 1, "item": {"id": "item-1", "type": secret}, "threadId": "thread-1", "turnId": "turn-1", @@ -1595,6 +1633,7 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: "item/started", { "item": item, + "startedAtMs": 1, "threadId": "thread-1", "turnId": "turn-1", }, @@ -1638,6 +1677,7 @@ def test_item_type_allowlist_matches_isolated_local_tools(self) -> None: "item/started", { "item": {"id": "item-1", "type": item_type}, + "startedAtMs": 1, "threadId": "thread-1", "turnId": "turn-1", }, @@ -1661,11 +1701,18 @@ def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: "tool": "spawnAgent", "type": "collabAgentToolCall", } - protocol._handle_notification("item/started", {**envelope, "item": initial}) + protocol._handle_notification( + "item/started", {**envelope, "item": initial, "startedAtMs": 1} + ) self.assertEqual(protocol._collab_thread_ids, set()) - self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + self.assertEqual( + protocol._pending_spawn_items, + {("thread-1", "item-1"): (None, "delegated task")}, + ) with self.assertRaisesRegex(ProviderError, "already in progress"): - protocol._handle_notification("item/started", {**envelope, "item": initial}) + protocol._handle_notification( + "item/started", {**envelope, "item": initial, "startedAtMs": 1} + ) completed = { **initial, @@ -1675,11 +1722,127 @@ def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: "receiverThreadIds": ["child-1"], "status": "completed", } - protocol._handle_notification("item/completed", {**envelope, "item": completed}) + protocol._handle_notification( + "item/completed", {**envelope, "completedAtMs": 2, "item": completed} + ) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) self.assertEqual(protocol._pending_spawn_items, {}) + def test_item_lifecycle_requires_pinned_timestamp_fields(self) -> None: + item = { + "id": "message-1", + "phase": "final_answer", + "text": "result", + "type": "agentMessage", + } + for method, field in ( + ("item/started", "startedAtMs"), + ("item/completed", "completedAtMs"), + ): + for value, expected in ((None, "required"), (True, "supported range")): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + params = { + "item": item, + "threadId": "thread-1", + "turnId": "turn-1", + } + if value is not None: + params[field] = value + with ( + self.subTest(method=method, value=value), + self.assertRaisesRegex(ProviderError, expected), + ): + protocol._handle_notification(method, params) + + def test_spawn_completion_prompt_must_match_its_start(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "model": "", + "prompt": "original task", + "reasoningEffort": "medium", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + envelope = {"threadId": "thread-1", "turnId": "turn-1"} + protocol._handle_notification( + "item/started", {**envelope, "item": initial, "startedAtMs": 1} + ) + + with self.assertRaisesRegex(ProviderError, "prompt disagreed"): + protocol._handle_notification( + "item/completed", + { + **envelope, + "completedAtMs": 2, + "item": { + **initial, + "agentsStates": {"child-1": {"status": "running"}}, + "model": "gpt-5.6-luna", + "prompt": "changed task", + "reasoningEffort": "low", + "receiverThreadIds": ["child-1"], + "status": "completed", + }, + }, + ) + + self.assertEqual( + protocol._pending_spawn_items, + {("thread-1", "item-1"): (None, "original task")}, + ) + + def test_stale_spawn_completion_does_not_reactivate_completed_child(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._turn_id = "turn-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "model": "", + "prompt": "delegated task", + "reasoningEffort": "medium", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + envelope = {"threadId": "thread-1", "turnId": "turn-1"} + protocol._handle_notification( + "item/started", {**envelope, "item": initial, "startedAtMs": 1} + ) + protocol._collab_thread_ids.add("child-1") + protocol._unbound_collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "child-turn")) + + protocol._handle_notification( + "item/completed", + { + **envelope, + "completedAtMs": 2, + "item": { + **initial, + "agentsStates": {"child-1": {"status": "running"}}, + "model": "gpt-5.6-luna", + "reasoningEffort": "low", + "receiverThreadIds": ["child-1"], + "status": "completed", + }, + }, + ) + + self.assertNotIn("child-1", protocol._active_collab_thread_ids) + def test_spawn_start_rejects_an_explicit_receiver(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" @@ -1700,7 +1863,12 @@ def test_spawn_start_rejects_an_explicit_receiver(self) -> None: with self.assertRaisesRegex(ProviderError, "must not have receivers"): protocol._handle_notification( "item/started", - {"item": item, "threadId": "thread-1", "turnId": "turn-1"}, + { + "item": item, + "startedAtMs": 1, + "threadId": "thread-1", + "turnId": "turn-1", + }, ) self.assertEqual(protocol._pending_spawn_items, {}) @@ -1734,7 +1902,10 @@ def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" protocol._collab_thread_ids.add("child-1") - protocol._pending_spawn_items[("thread-1", "item-1")] = "child-1" + protocol._pending_spawn_items[("thread-1", "item-1")] = ( + "child-1", + None, + ) with self.assertRaisesRegex(ProviderError, "live terminal history"): protocol._validate_item( @@ -1752,7 +1923,8 @@ def test_spawn_snapshot_does_not_mutate_live_scope(self) -> None: ) self.assertEqual( - protocol._pending_spawn_items, {("thread-1", "item-1"): "child-1"} + protocol._pending_spawn_items, + {("thread-1", "item-1"): ("child-1", None)}, ) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) @@ -1832,7 +2004,8 @@ def test_completed_spawn_requires_exactly_one_receiver(self) -> None: } ) self.assertEqual( - protocol._pending_spawn_items, {("thread-1", "item-1"): None} + protocol._pending_spawn_items, + {("thread-1", "item-1"): (None, None)}, ) self.assertEqual(protocol._collab_thread_ids, set()) @@ -1863,7 +2036,7 @@ def test_spawn_rejects_multiple_receivers_before_successful_completion( } ) expected_pending = ( - {("thread-1", "item-1"): None} if status == "failed" else {} + {("thread-1", "item-1"): (None, None)} if status == "failed" else {} ) self.assertEqual(protocol._pending_spawn_items, expected_pending) self.assertEqual(protocol._collab_thread_ids, set()) @@ -1894,6 +2067,7 @@ def test_live_terminal_spawn_requires_a_pending_start(self) -> None: protocol._handle_notification( "item/completed", { + "completedAtMs": 2, "item": item, "threadId": "thread-1", "turnId": "turn-1", @@ -1920,13 +2094,22 @@ def test_terminal_collaboration_item_id_cannot_restart(self) -> None: "type": "collabAgentToolCall", } envelope = {"threadId": "thread-1", "turnId": "turn-1"} - protocol._handle_notification("item/started", {**envelope, "item": item}) protocol._handle_notification( - "item/completed", {**envelope, "item": {**item, "status": "failed"}} + "item/started", {**envelope, "item": item, "startedAtMs": 1} + ) + protocol._handle_notification( + "item/completed", + { + **envelope, + "completedAtMs": 2, + "item": {**item, "status": "failed"}, + }, ) with self.assertRaisesRegex(ProviderError, "reused after termination"): - protocol._handle_notification("item/started", {**envelope, "item": item}) + protocol._handle_notification( + "item/started", {**envelope, "item": item, "startedAtMs": 1} + ) protocol._validate_item({**item, "status": "failed"}, lifecycle="snapshot") def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: @@ -1977,7 +2160,7 @@ def test_spawn_agent_bounds_total_child_thread_scope(self) -> None: self.assertEqual(protocol._collab_thread_ids, set(receivers)) self.assertEqual( protocol._pending_spawn_items, - {("thread-1", "pending-overflow"): None}, + {("thread-1", "pending-overflow"): (None, None)}, ) def test_pending_spawn_bounds_early_child_status_scope(self) -> None: @@ -2002,7 +2185,9 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: }, ) self.assertEqual(protocol._collab_thread_ids, {"child-1"}) - self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + self.assertEqual( + protocol._pending_spawn_items, {("thread-1", "item-1"): (None, None)} + ) self.assertEqual(protocol._unbound_collab_thread_ids, {"child-1"}) protocol._turn_id = "main-turn" @@ -2020,6 +2205,7 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: "item/started", { "item": {"id": "reasoning-1", "type": "reasoning"}, + "startedAtMs": 1, "threadId": "child-1", "turnId": "child-turn", }, @@ -2031,6 +2217,7 @@ def test_pending_spawn_bounds_early_child_status_scope(self) -> None: protocol._handle_notification( "item/completed", { + "completedAtMs": 2, "item": { "id": "message-1", "phase": "final_answer", @@ -2481,7 +2668,8 @@ def test_pending_spawn_ids_are_scoped_to_the_sender_thread(self) -> None: } ) self.assertEqual( - protocol._pending_spawn_items, {("main-thread", "same-id"): None} + protocol._pending_spawn_items, + {("main-thread", "same-id"): (None, None)}, ) protocol._validate_item( { @@ -2541,7 +2729,8 @@ def test_one_child_cannot_complete_two_pending_spawns(self) -> None: with self.assertRaisesRegex(ProviderError, "already claimed"): protocol._validate_item({**completed, "id": "spawn-2"}) self.assertEqual( - protocol._pending_spawn_items, {("main-thread", "spawn-2"): None} + protocol._pending_spawn_items, + {("main-thread", "spawn-2"): (None, None)}, ) def test_collaboration_optional_metadata_rejects_non_strings(self) -> None: @@ -2603,7 +2792,9 @@ def test_collaboration_model_metadata_matches_pinned_configuration(self) -> None "reasoningEffort": "low", } ) - self.assertEqual(protocol._pending_spawn_items, {("thread-1", "item-1"): None}) + self.assertEqual( + protocol._pending_spawn_items, {("thread-1", "item-1"): (None, None)} + ) def test_collaboration_sender_and_lifecycle_match_event_envelope(self) -> None: protocol = _protocol(QueueTransport([])) @@ -2626,6 +2817,7 @@ def test_collaboration_sender_and_lifecycle_match_event_envelope(self) -> None: "item/started", { "item": item, + "startedAtMs": 1, "threadId": "child-1", "turnId": "child-turn", }, @@ -2636,6 +2828,7 @@ def test_collaboration_sender_and_lifecycle_match_event_envelope(self) -> None: protocol._handle_notification( "item/completed", { + "completedAtMs": 2, "item": item, "threadId": "main-thread", "turnId": "main-turn", @@ -2654,6 +2847,7 @@ def test_child_payloads_are_validated_before_they_are_discarded(self) -> None: protocol._handle_notification( "item/completed", { + "completedAtMs": 2, "item": {"type": "agentMessage"}, "threadId": "child-1", "turnId": "child-turn", @@ -2711,6 +2905,7 @@ def test_child_turn_rejects_duplicate_completion_and_late_items(self) -> None: protocol._handle_notification( "item/completed", { + "completedAtMs": 2, "item": { "id": "message-1", "text": "late", From a3a27d6659065287f7503a9420584443f593c83d Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Thu, 6 Aug 2026 19:40:45 +0000 Subject: [PATCH 34/35] fix(codex): bound retained collaboration prompts Terminal collaboration history can accumulate model-controlled items, so retaining each raw prompt permits evaluator memory exhaustion. Store fixed-size SHA-256 digests while preserving lifecycle equality checks. Refs #28 Co-Authored-By: Dhiman's Agentic Suite --- skivolve/codex_app_server.py | 28 ++++++++++++++------- tests/test_codex_app_server.py | 45 ++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index f2eb0c4..321067f 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1262,7 +1262,7 @@ def __init__( self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} self._pending_spawn_items: dict[ - tuple[str, str], tuple[str | None, str | None] + tuple[str, str], tuple[str | None, bytes | None] ] = {} self._terminal_collab_history: dict[ tuple[str, str], @@ -1272,7 +1272,7 @@ def __init__( tuple[str, ...], tuple[tuple[str, str], ...], str | None, - str | None, + bytes | None, str | None, ], ] = {} @@ -2011,7 +2011,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: spawn_receivers, _agent_statuses, _model, - _prompt, + _prompt_digest, _reasoning_effort, ) in self._terminal_collab_history.items() ) @@ -2286,6 +2286,11 @@ def _validate_collab_item( ): if value is not None and not isinstance(value, str): raise ProviderError(f"collaboration {field} must be a string or null") + prompt_digest = ( + hashlib.sha256(prompt.encode("utf-8")).digest() + if prompt is not None + else None + ) spawn_placeholder = tool == "spawnAgent" and ( lifecycle == "started" or ( @@ -2358,7 +2363,7 @@ def _validate_collab_item( tuple(receivers), tuple(sorted(agent_statuses.items())), model, - prompt, + prompt_digest, reasoning_effort, ) if ( @@ -2390,12 +2395,16 @@ def _validate_collab_item( raise ProviderError("spawnAgent must have at most one receiver") pending_key = (sender, item_id) pending = pending_key in self._pending_spawn_items - expected_receiver, expected_prompt = self._pending_spawn_items.get( + expected_receiver, expected_prompt_digest = self._pending_spawn_items.get( pending_key, (None, None) ) if status == "inProgress" and pending: raise ProviderError("spawnAgent item was already in progress") - if lifecycle == "completed" and pending and prompt != expected_prompt: + if ( + lifecycle == "completed" + and pending + and prompt_digest != expected_prompt_digest + ): raise ProviderError( "terminal spawnAgent prompt disagreed with its start" ) @@ -2405,7 +2414,7 @@ def _validate_collab_item( ) unbound_slots = sum( receiver is None - for receiver, _prompt in self._pending_spawn_items.values() + for receiver, _prompt_digest in self._pending_spawn_items.values() ) receiver = receivers[0] if receivers else None failed_reservation = None @@ -2473,7 +2482,7 @@ def _validate_collab_item( raise ProviderError("spawnAgent receiver was already claimed") if len(self._pending_spawn_items) >= _MAX_COLLAB_THREADS: raise ProviderError("pending spawn count exceeds the limit") - self._pending_spawn_items[pending_key] = (receiver, prompt) + self._pending_spawn_items[pending_key] = (receiver, prompt_digest) else: self._pending_spawn_items.pop(pending_key, None) if lifecycle == "snapshot": @@ -2591,7 +2600,8 @@ def _claim_collab_thread_scope(self, thread_id: str) -> bool: if thread_id in self._collab_thread_ids: return True open_spawn_slots = sum( - receiver is None for receiver, _prompt in self._pending_spawn_items.values() + receiver is None + for receiver, _prompt_digest in self._pending_spawn_items.values() ) if len(self._unbound_collab_thread_ids) >= open_spawn_slots: return False diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index 4796999..bd8a217 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -1707,7 +1707,12 @@ def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: self.assertEqual(protocol._collab_thread_ids, set()) self.assertEqual( protocol._pending_spawn_items, - {("thread-1", "item-1"): (None, "delegated task")}, + { + ("thread-1", "item-1"): ( + None, + hashlib.sha256(b"delegated task").digest(), + ) + }, ) with self.assertRaisesRegex(ProviderError, "already in progress"): protocol._handle_notification( @@ -1729,6 +1734,37 @@ def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) self.assertEqual(protocol._pending_spawn_items, {}) + def test_terminal_collaboration_history_retains_prompt_digests(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._collab_thread_ids.add("child-1") + prompt = "delegated " + "☄" * 50_000 + initial = { + "agentsStates": {}, + "id": "item-1", + "prompt": prompt, + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "sendInput", + "type": "collabAgentToolCall", + } + + protocol._validate_item(initial, lifecycle="started") + protocol._validate_item( + { + **initial, + "status": "completed", + }, + lifecycle="completed", + ) + retained_prompt = protocol._terminal_collab_history[("thread-1", "item-1")][5] + self.assertEqual( + retained_prompt, + hashlib.sha256(prompt.encode("utf-8")).digest(), + ) + self.assertEqual(len(retained_prompt), hashlib.sha256().digest_size) + def test_item_lifecycle_requires_pinned_timestamp_fields(self) -> None: item = { "id": "message-1", @@ -1798,7 +1834,12 @@ def test_spawn_completion_prompt_must_match_its_start(self) -> None: self.assertEqual( protocol._pending_spawn_items, - {("thread-1", "item-1"): (None, "original task")}, + { + ("thread-1", "item-1"): ( + None, + hashlib.sha256(b"original task").digest(), + ) + }, ) def test_stale_spawn_completion_does_not_reactivate_completed_child(self) -> None: From 21b42f2d03c537b2d9c5f001377ae355b5dbb7ac Mon Sep 17 00:00:00 2001 From: Dhiman Seal Date: Fri, 7 Aug 2026 02:28:30 +0000 Subject: [PATCH 35/35] fix(codex): harden collaboration lifecycle Codex 0.146 emits partial wait completions and pre-close child states that the prior validator could reject or misclassify. Bind lifecycle fields to their starts, preserve bounded child evidence, and make runtime cleanup iterative so valid deep trees cannot strand residue. Co-Authored-By: Dhiman's Agentic Suite --- CHANGELOG.md | 2 +- skivolve/codex_app_server.py | 240 ++++++++++++++++++++++++--------- tests/test_codex_app_server.py | 189 +++++++++++++++++++++++++- 3 files changed, 357 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 820f190..9a1769b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to Skivolve are documented in this file. The format follows ### Fixed -- Accepted bounded Codex collaboration lifecycles, required each completed spawn to bind exactly one child, kept child-agent traffic from mutating the main result, and cleaned unreadable evaluator runtime directories without following symlinks. +- Accepted bounded Codex collaboration lifecycles, matched wait and send-input completions to their starts, recorded child status and token evidence without retaining unbounded messages, kept child-agent traffic from mutating the main result, and cleaned deeply nested unreadable runtime directories without following symlinks. ## [0.5.0] - 2026-07-29 diff --git a/skivolve/codex_app_server.py b/skivolve/codex_app_server.py index 321067f..d9a82f0 100644 --- a/skivolve/codex_app_server.py +++ b/skivolve/codex_app_server.py @@ -1261,24 +1261,19 @@ def __init__( self._active_collab_thread_ids: set[str] = set() self._seen_collab_turn_ids: set[tuple[str, str]] = set() self._collab_turn_usage: dict[tuple[str, str], dict[str, int]] = {} + self._collab_turn_statuses = { + "completed": 0, + "failed": 0, + "interrupted": 0, + } self._pending_spawn_items: dict[ tuple[str, str], tuple[str | None, bytes | None] ] = {} - self._terminal_collab_history: dict[ - tuple[str, str], - tuple[ - str, - str, - tuple[str, ...], - tuple[tuple[str, str], ...], - str | None, - bytes | None, - str | None, - ], - ] = {} + self._terminal_collab_history: dict[tuple[str, str], bytes] = {} self._terminal_collab_item_ids: set[tuple[str, str]] = set() + self._completed_spawn_edges: set[tuple[str, str]] = set() self._active_nonspawn_items: dict[ - tuple[str, str], tuple[str, tuple[str, ...]] + tuple[str, str], tuple[str, tuple[str, ...], bytes | None] ] = {} self._unbound_collab_thread_ids: set[str] = set() self._failed_collab_thread_ids: set[str] = set() @@ -1304,9 +1299,11 @@ def run(self, prompt: str, deadline: float) -> _TurnOutcome: if self._last_usage is None: raise ProviderError("Codex turn omitted last-turn token usage") total_usage = dict(self._last_usage) - for child_usage in self._collab_turn_usage.values(): - for key, value in child_usage.items(): + child_usage = {key: 0 for key in self._last_usage} + for usage in self._collab_turn_usage.values(): + for key, value in usage.items(): total_usage[key] += value + child_usage[key] += value quota = { "before": before_limits, "rolling": self._rate_limits, @@ -1315,6 +1312,12 @@ def run(self, prompt: str, deadline: float) -> _TurnOutcome: assert self._thread_id is not None and self._turn_id is not None raw = { "account": account, + "collaboration": { + "child_thread_count": len(self._validated_collab_thread_ids), + "child_turn_count": len(self._seen_collab_turn_ids), + "child_turn_statuses": dict(self._collab_turn_statuses), + "usage": child_usage, + }, "model": self._model, "reasoning_effort": self._reasoning_effort, "thread_id_sha256": _opaque_sha256(self._thread_id), @@ -1997,24 +2000,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: _item_id, ), pending in self._pending_spawn_items.items() ) - or any( - spawn_tool == "spawnAgent" - and sender == parent_id - and spawn_status == "completed" - and spawn_receivers == (announced,) - for ( - sender, - _item_id, - ), ( - spawn_tool, - spawn_status, - spawn_receivers, - _agent_statuses, - _model, - _prompt_digest, - _reasoning_effort, - ) in self._terminal_collab_history.items() - ) + or (parent_id, announced) in self._completed_spawn_edges ): raise ProviderError( "Codex child thread lacked parent-owned spawn history" @@ -2152,7 +2138,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: completed_thread_id, set() ): raise ProviderError("Codex completed an unknown child turn") - self._validate_completed_turn(turn, completed_thread_id) + child_status = self._validate_completed_turn(turn, completed_thread_id) if ( completed_thread_id, completed_turn_id, @@ -2160,6 +2146,7 @@ def _handle_notification(self, method: str, raw_params: Any) -> None: raise ProviderError("Codex child turn omitted token usage") self._collab_turn_ids[completed_thread_id].remove(completed_turn_id) self._active_collab_thread_ids.discard(completed_thread_id) + self._collab_turn_statuses[child_status] += 1 return if self._turn_id is None or completed_turn_id != self._turn_id: raise ProviderError("Codex completed an unknown turn") @@ -2291,6 +2278,8 @@ def _validate_collab_item( if prompt is not None else None ) + if tool == "sendInput" and prompt_digest is None: + raise ProviderError("sendInput prompt must be a string") spawn_placeholder = tool == "spawnAgent" and ( lifecycle == "started" or ( @@ -2300,7 +2289,10 @@ def _validate_collab_item( ) ) if spawn_placeholder: - if model != "" or reasoning_effort != "medium": + if model not in {"", self._model} or reasoning_effort not in { + "medium", + self._reasoning_effort, + }: raise ProviderError("spawnAgent placeholder metadata is invalid") elif tool == "spawnAgent" and lifecycle == "completed": if model != self._model or reasoning_effort != self._reasoning_effort: @@ -2357,15 +2349,28 @@ def _validate_collab_item( > _MAX_RETAINED_TEXT_BYTES ): raise ProviderError("collaboration agent message exceeds the limit") - terminal_item = ( - tool, - status, - tuple(receivers), - tuple(sorted(agent_statuses.items())), - model, - prompt_digest, - reasoning_effort, - ) + if ( + tool == "wait" + and lifecycle in {"completed", "snapshot"} + and set(agent_statuses) != set(receivers) + ): + raise ProviderError("wait completion states disagree with its receivers") + terminal_item = hashlib.sha256( + _canonical_json( + { + "agent_statuses": agent_statuses, + "model": model, + "prompt_sha256": ( + prompt_digest.hex() if prompt_digest is not None else None + ), + "reasoning_effort": reasoning_effort, + "receivers": receivers, + "status": status, + "tool": tool, + }, + "collaboration terminal item", + ) + ).digest() if ( lifecycle == "snapshot" and self._terminal_collab_history.get(item_scope) != terminal_item @@ -2380,10 +2385,24 @@ def _validate_collab_item( elif tool != "spawnAgent" and lifecycle == "completed": if active_nonspawn_item is None: raise ProviderError("collaboration item completed without a start") - if active_nonspawn_item != (tool, tuple(receivers)): + expected_tool, expected_receivers, expected_prompt = active_nonspawn_item + if expected_tool != tool: + raise ProviderError( + "terminal collaboration item disagreed with its start" + ) + if tool == "wait": + if not set(receivers).issubset(expected_receivers): + raise ProviderError( + "terminal collaboration item disagreed with its start" + ) + elif tuple(receivers) != expected_receivers: raise ProviderError( "terminal collaboration item disagreed with its start" ) + if tool == "sendInput" and prompt_digest != expected_prompt: + raise ProviderError( + "terminal sendInput prompt disagreed with its start" + ) if tool == "spawnAgent": if lifecycle == "started" and receivers: raise ProviderError("spawnAgent start must not have receivers") @@ -2501,6 +2520,8 @@ def _validate_collab_item( for claimed_receiver in receivers: self._collab_parent_ids[claimed_receiver] = sender self._collab_thread_ids.update(receivers) + if status == "completed": + self._completed_spawn_edges.add((sender, receivers[0])) elif any( receiver != self._thread_id and receiver not in self._collab_thread_ids for receiver in receivers @@ -2538,6 +2559,22 @@ def _validate_collab_item( and receiver in self._collab_thread_ids and receiver not in agent_statuses ) + if ( + lifecycle == "completed" + and tool == "closeAgent" + and status == "completed" + ): + closed = set(receivers) + while True: + descendants = { + thread_id + for thread_id, parent_id in self._collab_parent_ids.items() + if parent_id in closed + } + if descendants <= closed: + break + closed.update(descendants) + self._active_collab_thread_ids.difference_update(closed) if lifecycle != "snapshot" and status in {"completed", "failed"}: if len(self._terminal_collab_item_ids) >= _MAX_MESSAGES: raise ProviderError( @@ -2551,13 +2588,17 @@ def _validate_collab_item( and len(self._active_nonspawn_items) >= _MAX_MESSAGES ): raise ProviderError("active collaboration item count exceeds the limit") - self._active_nonspawn_items[item_scope] = (tool, tuple(receivers)) + self._active_nonspawn_items[item_scope] = ( + tool, + tuple(receivers), + prompt_digest, + ) elif lifecycle == "completed" and tool != "spawnAgent": self._active_nonspawn_items.pop(item_scope, None) def _validate_completed_turn( self, turn: dict[str, Any], owner_thread_id: str - ) -> None: + ) -> str: status = _require_string(turn.get("status"), "completed turn status") if status not in {"completed", "interrupted", "failed"}: raise ProviderError("completed turn has a non-terminal status") @@ -2575,6 +2616,7 @@ def _validate_completed_turn( owner_thread_id=owner_thread_id, lifecycle="snapshot", ) + return status @staticmethod def _validate_message_phase(value: Any) -> str | None: @@ -2778,6 +2820,13 @@ def _finalize_turn(self) -> tuple[str, dict[str, Any]]: raise ProviderError( "Codex summary message disagrees with its completion event" ) + if any( + message["phase"] == "final_answer" + for message in self._completed_messages[summary_index + 1 :] + ): + raise ProviderError( + "Codex summary omitted a completed final-answer message" + ) messages = self._completed_messages[: summary_index + 1] else: raise ProviderError("Codex turn returned an unsupported item view") @@ -4140,32 +4189,89 @@ def _open_workspace_runtime_directory( def _clear_workspace_runtime_directory(descriptor: int) -> None: os.fchmod(descriptor, _PRIVATE_DIRECTORY_MODE) - for name in os.listdir(descriptor): - metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - if stat.S_ISDIR(metadata.st_mode): - child_descriptor = _open_workspace_runtime_directory( - descriptor, - name, - (metadata.st_dev, metadata.st_ino), + root = os.fstat(descriptor) + stack: list[tuple[list[str], int, str | None, tuple[int, int]]] = [ + (os.listdir(descriptor), 0, None, (root.st_dev, root.st_ino)) + ] + current_descriptor = descriptor + flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW + try: + while stack: + names, index, name, identity = stack[-1] + if index < len(names): + child_name = names[index] + stack[-1] = (names, index + 1, name, identity) + metadata = os.stat( + child_name, + dir_fd=current_descriptor, + follow_symlinks=False, + ) + if not stat.S_ISDIR(metadata.st_mode): + os.unlink(child_name, dir_fd=current_descriptor) + continue + child_descriptor = _open_workspace_runtime_directory( + current_descriptor, + child_name, + (metadata.st_dev, metadata.st_ino), + ) + try: + child_identity = os.fstat(child_descriptor) + os.fchmod(child_descriptor, _PRIVATE_DIRECTORY_MODE) + child_names = os.listdir(child_descriptor) + except BaseException: + os.close(child_descriptor) + raise + if current_descriptor != descriptor: + os.close(current_descriptor) + current_descriptor = child_descriptor + stack.append( + ( + child_names, + 0, + child_name, + (child_identity.st_dev, child_identity.st_ino), + ) + ) + continue + + if len(stack) == 1: + break + stack.pop() + parent_identity = stack[-1][3] + parent_descriptor = ( + descriptor + if len(stack) == 1 + else os.open("..", flags, dir_fd=current_descriptor) ) try: - child_identity = os.fstat(child_descriptor) - _clear_workspace_runtime_directory(child_descriptor) - current = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - if (current.st_dev, current.st_ino) != ( - child_identity.st_dev, - child_identity.st_ino, - ): + observed_parent = os.fstat(parent_descriptor) + if (observed_parent.st_dev, observed_parent.st_ino) != parent_identity: + raise ProviderError( + "workspace runtime parent directory identity changed" + ) + assert name is not None + current = os.stat( + name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if (current.st_dev, current.st_ino) != identity: raise ProviderError("workspace runtime directory identity changed") - os.rmdir(name, dir_fd=descriptor) - if os.fstat(child_descriptor).st_nlink != 0: + os.rmdir(name, dir_fd=parent_descriptor) + if os.fstat(current_descriptor).st_nlink != 0: raise ProviderError( "workspace runtime directory removal was replaced" ) - finally: - os.close(child_descriptor) - else: - os.unlink(name, dir_fd=descriptor) + except BaseException: + if parent_descriptor != descriptor: + os.close(parent_descriptor) + raise + os.close(current_descriptor) + current_descriptor = parent_descriptor + except BaseException: + if current_descriptor != descriptor: + os.close(current_descriptor) + raise def _cleanup_workspace_runtime(runtime: _WorkspaceRuntime) -> None: diff --git a/tests/test_codex_app_server.py b/tests/test_codex_app_server.py index bd8a217..1b8cf7a 100644 --- a/tests/test_codex_app_server.py +++ b/tests/test_codex_app_server.py @@ -774,6 +774,9 @@ def test_child_usage_retains_only_the_latest_turn_update(self) -> None: def test_happy_path_adds_child_usage_to_reported_totals(self) -> None: protocol = _protocol(ScriptedTransport(Path("/runtime/work"))) + protocol._validated_collab_thread_ids.add("child-1") + protocol._seen_collab_turn_ids.add(("child-1", "child-turn")) + protocol._collab_turn_statuses["failed"] = 1 protocol._collab_turn_usage[("child-1", "child-turn")] = { "cached_input_tokens": 4, "input_tokens": 20, @@ -794,6 +797,25 @@ def test_happy_path_adds_child_usage_to_reported_totals(self) -> None: }, ) self.assertEqual(outcome.raw_response["usage"], outcome.tokens) + self.assertEqual( + outcome.raw_response["collaboration"], + { + "child_thread_count": 1, + "child_turn_count": 1, + "child_turn_statuses": { + "completed": 0, + "failed": 1, + "interrupted": 0, + }, + "usage": { + "cached_input_tokens": 4, + "input_tokens": 20, + "output_tokens": 8, + "reasoning_output_tokens": 3, + "total_tokens": 28, + }, + }, + ) def test_post_completion_quota_read_rejects_new_collaboration_work(self) -> None: class PostCompletionTransport(ScriptedTransport): @@ -1012,6 +1034,7 @@ def test_non_retryable_child_error_does_not_abort_main_turn(self) -> None: self.assertIsNone(protocol._turn_completed) self.assertEqual(protocol._collab_turn_ids["child-1"], set()) self.assertEqual(protocol._active_collab_thread_ids, set()) + self.assertEqual(protocol._collab_turn_statuses["failed"], 1) def test_child_completion_without_usage_keeps_turn_active(self) -> None: protocol = _protocol(QueueTransport([])) @@ -1172,6 +1195,7 @@ def test_root_completion_requires_terminal_child_work(self) -> None: "completedAtMs": 2, "item": { **resume, + "prompt": "follow-up", "status": "completed", "tool": "sendInput", }, @@ -1345,6 +1369,22 @@ def test_summary_uses_the_last_nonblank_completed_agent_message(self) -> None: self.assertEqual(outcome.final_output, "completed fixture") self.assertEqual(outcome.raw_response["turn"]["items_view"], "summary") + def test_summary_cannot_discard_a_blank_final_answer(self) -> None: + transport = ScriptedTransport( + Path("/runtime/work"), + additional_item_completed={ + "id": "message-2", + "phase": "final_answer", + "text": " ", + "type": "agentMessage", + }, + final_phase=None, + turn_items_view="summary", + ) + + with self.assertRaisesRegex(ProviderError, "omitted.*final-answer"): + _protocol(transport).run("request", time.monotonic() + 5) + def test_full_turn_rejects_non_object_items(self) -> None: transport = ScriptedTransport( Path("/runtime/work"), @@ -1734,7 +1774,43 @@ def test_spawn_agent_matches_the_pinned_wire_lifecycle(self) -> None: self.assertEqual(protocol._collab_parent_ids, {"child-1": "thread-1"}) self.assertEqual(protocol._pending_spawn_items, {}) - def test_terminal_collaboration_history_retains_prompt_digests(self) -> None: + def test_spawn_agent_accepts_explicit_pinned_start_and_failure_metadata( + self, + ) -> None: + for terminal_status, receivers in ( + ("completed", ["child-1"]), + ("failed", []), + ): + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + initial = { + "agentsStates": {}, + "id": "item-1", + "model": "gpt-5.6-luna", + "prompt": "delegated task", + "reasoningEffort": "low", + "receiverThreadIds": [], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "spawnAgent", + "type": "collabAgentToolCall", + } + + with self.subTest(terminal_status=terminal_status): + protocol._validate_item(initial, lifecycle="started") + protocol._validate_item( + { + **initial, + "agentsStates": { + receiver: {"status": "running"} for receiver in receivers + }, + "receiverThreadIds": receivers, + "status": terminal_status, + }, + lifecycle="completed", + ) + + def test_terminal_collaboration_history_retains_fixed_size_signatures(self) -> None: protocol = _protocol(QueueTransport([])) protocol._thread_id = "thread-1" protocol._collab_thread_ids.add("child-1") @@ -1758,12 +1834,100 @@ def test_terminal_collaboration_history_retains_prompt_digests(self) -> None: }, lifecycle="completed", ) - retained_prompt = protocol._terminal_collab_history[("thread-1", "item-1")][5] - self.assertEqual( - retained_prompt, - hashlib.sha256(prompt.encode("utf-8")).digest(), + signature = protocol._terminal_collab_history[("thread-1", "item-1")] + self.assertIsInstance(signature, bytes) + self.assertEqual(len(signature), hashlib.sha256().digest_size) + + def test_wait_completion_accepts_timeout_subset_and_unordered_receivers( + self, + ) -> None: + cases = ( + ([], {}), + (["child-1"], {"child-1": {"status": "completed"}}), + ( + ["child-2", "child-1"], + { + "child-2": {"status": "completed"}, + "child-1": {"status": "completed"}, + }, + ), + ) + for receivers, states in cases: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._collab_thread_ids.update(("child-1", "child-2")) + initial = { + "agentsStates": {}, + "id": "wait-1", + "receiverThreadIds": ["child-1", "child-2"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "wait", + "type": "collabAgentToolCall", + } + + with self.subTest(receivers=receivers): + protocol._validate_item(initial, lifecycle="started") + protocol._validate_item( + { + **initial, + "agentsStates": states, + "receiverThreadIds": receivers, + "status": "completed", + }, + lifecycle="completed", + ) + + def test_send_input_completion_prompt_must_match_its_start(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._collab_thread_ids.add("child-1") + initial = { + "agentsStates": {}, + "id": "send-1", + "prompt": "original input", + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "sendInput", + "type": "collabAgentToolCall", + } + protocol._validate_item(initial, lifecycle="started") + + with self.assertRaisesRegex(ProviderError, "prompt disagreed"): + protocol._validate_item( + {**initial, "prompt": "changed input", "status": "completed"}, + lifecycle="completed", + ) + + def test_successful_close_deactivates_receiver_and_descendants(self) -> None: + protocol = _protocol(QueueTransport([])) + protocol._thread_id = "thread-1" + protocol._collab_thread_ids.update(("child-1", "grandchild-1")) + protocol._collab_parent_ids.update( + {"child-1": "thread-1", "grandchild-1": "child-1"} + ) + protocol._active_collab_thread_ids.update(("child-1", "grandchild-1")) + initial = { + "agentsStates": {}, + "id": "close-1", + "receiverThreadIds": ["child-1"], + "senderThreadId": "thread-1", + "status": "inProgress", + "tool": "closeAgent", + "type": "collabAgentToolCall", + } + protocol._validate_item(initial, lifecycle="started") + protocol._validate_item( + { + **initial, + "agentsStates": {"child-1": {"status": "running"}}, + "status": "completed", + }, + lifecycle="completed", ) - self.assertEqual(len(retained_prompt), hashlib.sha256().digest_size) + + self.assertEqual(protocol._active_collab_thread_ids, set()) def test_item_lifecycle_requires_pinned_timestamp_fields(self) -> None: item = { @@ -2732,6 +2896,7 @@ def test_child_collaboration_item_can_target_root_thread(self) -> None: { "agentsStates": {}, "id": "message-1", + "prompt": "root follow-up", "receiverThreadIds": ["main-thread"], "senderThreadId": "child-1", "status": "completed", @@ -4453,6 +4618,18 @@ def test_workspace_runtime_cleanup_handles_unreadable_directories_safely( self.assertTrue(all(not path.exists() for path in paths)) self.assertEqual(outside.read_text(encoding="ascii"), "preserve") + def test_workspace_runtime_cleanup_handles_deep_directory_trees(self) -> None: + runtime = _prepare_workspace_runtime(self.workspace) + current = self.workspace / ".skill-eval-tmp" + for _ in range(sys.getrecursionlimit() + 64): + current /= "d" + current.mkdir() + + _cleanup_workspace_runtime(runtime) + + for name in (".skill-eval-tmp", ".skill-eval-cache", ".skill-eval-home"): + self.assertFalse(self.workspace.joinpath(name).exists()) + def test_workspace_runtime_cleanup_does_not_follow_replaced_root(self) -> None: runtime = _prepare_workspace_runtime(self.workspace) target = self.workspace / ".skill-eval-tmp"