From ccecee2a44429332341d650954e2b9f0b6965da8 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Fri, 10 Jul 2026 07:20:56 +0800 Subject: [PATCH] feat: clarify request lifecycle status --- README.md | 2 +- codex_retry_gateway_tui.py | 131 +++++++++++++++++++++++++++++-------- pyproject.toml | 2 +- tests/test_payload.py | 43 +++++++++++- uv.lock | 2 +- 5 files changed, 149 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7875e5c..1e7ea7b 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ uv run codex-retry-gateway-tui Views: - overview: current gateway status and summary counts -- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at` +- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and lifecycle status: `waiting` before the first response, `streaming` after it, final `HTTP `, red `stalled` after no activity, or red `discarded` when the client cancels - logs: recent gateway logs - profiles: independently managed text profiles and image profiles, each with its own active selection and actions diff --git a/codex_retry_gateway_tui.py b/codex_retry_gateway_tui.py index 1339418..21f8834 100644 --- a/codex_retry_gateway_tui.py +++ b/codex_retry_gateway_tui.py @@ -23,7 +23,7 @@ from typing import Any APP_NAME = "codex-retry-gateway-tui" -FALLBACK_VERSION = "0.1.6" +FALLBACK_VERSION = "0.1.7" DEFAULT_GATEWAY_ADMIN_PATH = "/__codex_retry_gateway" DEFAULT_GATEWAY_URL = "http://127.0.0.1:4610/__codex_retry_gateway" DEFAULT_API_URL = DEFAULT_GATEWAY_URL @@ -47,6 +47,8 @@ DEFAULT_PROFILE_RETRYABLE_ERROR_MESSAGES = [ DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_ATTEMPTS = 5 DEFAULT_PROFILE_UPSTREAM_FETCH_RETRY_BACKOFF_MS = 350 DEFAULT_PROFILE_REQUEST_HISTORY_LIMIT = 0 +REQUEST_STATUS_SLOW_AFTER_SECONDS = 20 +REQUEST_STATUS_STALLED_AFTER_SECONDS = 120 DEFAULT_PROFILE_ENDPOINTS = [ "/responses", "/chat/completions", @@ -102,7 +104,7 @@ REQUEST_TABLE_WIDTH_PROFILES = { "resp_id": 18, "thread": 18, "started": 19, - "status": 7, + "status": 12, "path": 22, "model": 16, "effort": 8, @@ -123,7 +125,7 @@ REQUEST_TABLE_WIDTH_PROFILES = { "resp_id": 24, "thread": 36, "started": 19, - "status": 7, + "status": 14, "path": 40, "model": 24, "effort": 8, @@ -986,7 +988,7 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None = short_text(response_id_text(row), limits.get("resp_id", 18)) or "-", short_text(row["thread_id"], limits.get("thread", 18)) or "-", short_time(row["started_at"]), - f"{status_symbol(row)} {row.get('status_code') or '-'}", + request_status_label(row), short_text(row["path"], limits.get("path", 22)), short_text(request_model_text(row), limits.get("model", 16)), short_text(request_effort_text(row), limits.get("effort", 10)), @@ -1020,16 +1022,16 @@ def request_visible_cells( visible_column_keys: list[str], ) -> list[Any]: visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in visible_column_keys] - thread_color = request_thread_color(row.get("thread_id")) - if not thread_color: - return visible_cells try: from rich.text import Text except ImportError: return visible_cells + thread_color = request_thread_color(row.get("thread_id")) styled_cells: list[Any] = [] for column_key, cell in zip(visible_column_keys, visible_cells): - if column_key in THREAD_COLOR_COLUMN_KEYS: + if column_key == "status": + styled_cells.append(Text(str(cell), style=request_status_style(row))) + elif thread_color and column_key in THREAD_COLOR_COLUMN_KEYS: styled_cells.append(Text(str(cell), style=f"bold {thread_color}")) else: styled_cells.append(cell) @@ -1316,6 +1318,8 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool: "requested_model", "forwarded_model", "reasoning_effort", + "lifecycle_state", + "discard_reason", "error", "upstream_origin", "upstream_path", @@ -1335,25 +1339,92 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool: return needle in haystack -def status_symbol(entry: dict[str, Any]) -> str: - if entry.get("error"): - return "!" - if entry.get("matched"): - return "*" - if entry.get("response_stream"): - return "~" - return " " - - def request_lifecycle_label(row: dict[str, Any]) -> str: state = str(row.get("lifecycle_state") or "").strip().lower() - if state == "finish": - return "done" - if state == "receive_first": - return "first" - if state == "streaming": - return "live" - return "sent" + if request_is_discarded(row): + return "discarded" + if state in {"finish", "finished", "complete", "completed"} or parse_datetime(row.get("finished_at")): + return "finished" + if state in {"receive_first", "streaming"} or parse_datetime(row.get("first_response_at")): + return "streaming" + return "waiting" + + +def request_is_discarded(row: dict[str, Any]) -> bool: + state = str(row.get("lifecycle_state") or "").strip().lower() + if bool(row.get("discarded")) or state in {"discarded", "abandoned", "cancelled", "canceled"}: + return True + error = str(row.get("error") or "").strip().lower() + return any( + marker in error + for marker in ( + "client disconnected", + "client closed", + "cancelled by client", + "canceled by client", + "request aborted", + ) + ) + + +def request_activity_at(row: dict[str, Any]) -> Any: + for key in ("last_activity_at", "first_response_at", "started_at"): + value = row.get(key) + if parse_datetime(value): + return value + return None + + +def request_activity_age_seconds(row: dict[str, Any], now: dt.datetime | None = None) -> float | None: + activity_at = parse_datetime(request_activity_at(row)) + if not activity_at: + return None + current = now or dt.datetime.now(activity_at.tzinfo) + if current.tzinfo is None and activity_at.tzinfo is not None: + current = current.replace(tzinfo=activity_at.tzinfo) + return max(0.0, (current - activity_at).total_seconds()) + + +def request_status_label(row: dict[str, Any], now: dt.datetime | None = None) -> str: + if request_is_discarded(row): + return "discarded" + status_code = parse_int_value(row.get("status_code")) + if status_code is not None: + return f"HTTP {status_code}" + lifecycle = request_lifecycle_label(row) + if lifecycle == "finished": + return "finished" + age_seconds = request_activity_age_seconds(row, now) + if age_seconds is not None and age_seconds >= REQUEST_STATUS_STALLED_AFTER_SECONDS: + return f"stalled {format_elapsed_seconds(age_seconds)}" + if age_seconds is not None and age_seconds >= REQUEST_STATUS_SLOW_AFTER_SECONDS: + return f"{lifecycle} {format_elapsed_seconds(age_seconds)}" + return lifecycle + + +def request_status_style(row: dict[str, Any], now: dt.datetime | None = None) -> str: + label = request_status_label(row, now).lower() + if label.startswith(("discarded", "stalled")): + return "bold #ff5f5f" + if label.startswith("waiting"): + return "bold #ffaf5f" + if label.startswith("streaming"): + return "bold #5fd7ff" + if label.startswith("http "): + status_code = parse_int_value(row.get("status_code")) or 0 + if 200 <= status_code < 400: + return "bold #87d75f" + if 400 <= status_code < 500: + return "bold #ffaf5f" + return "bold #ff5f5f" + if row.get("error"): + return "bold #ff5f5f" + return "#b0b0b0" + + +def status_symbol(entry: dict[str, Any]) -> str: + """Compatibility helper for callers that previously consumed a status marker.""" + return request_status_label(entry) def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]: @@ -1381,6 +1452,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li "reasoning_summary": str(entry.get("reasoning_summary") or ""), "request_stream": bool(entry.get("request_stream")), "response_stream": bool(entry.get("response_stream")), + "lifecycle_state": str(entry.get("lifecycle_state") or ""), + "discarded": bool(entry.get("discarded")), + "discard_reason": str(entry.get("discard_reason") or ""), "matched": bool(entry.get("matched")), "inspected": bool(entry.get("inspected")), "status_code": entry.get("status_code"), @@ -1394,6 +1468,7 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li "request_body_bytes": entry.get("request_body_bytes"), "response_bytes_received": entry.get("response_bytes_received"), "stream_chunk_count": entry.get("stream_chunk_count"), + "first_response_at": entry.get("first_response_at"), "first_response_delay_ms": entry.get("first_response_delay_ms"), "reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")), "reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")), @@ -1425,7 +1500,7 @@ def render_request_detail(row: dict[str, Any]) -> str: f"started {short_time(row['started_at'])}", f"{row['method']} {row['path']}", f"profile {row.get('profile_name') or '-'}", - f"status {row['status_code'] or '-'}", + f"status {request_status_label(row)}", f"upstream {row['upstream_status_code'] or '-'}", f"attempts {row['upstream_attempt_count'] or 0}", f"first {request_first_text(row)}", @@ -1456,6 +1531,8 @@ def render_request_detail(row: dict[str, Any]) -> str: bits.append(f"cached raw {format_count(row['cached_tokens'])}") if row.get("error"): bits.append(f"error {row['error']}") + if row.get("discard_reason"): + bits.append(f"discard {row['discard_reason']}") if row.get("upstream_origin"): bits.append(f"origin {row['upstream_origin']}") if row.get("upstream_path"): @@ -2742,7 +2819,7 @@ def print_once(snapshot: dict[str, Any], filter_text: str = "") -> None: for row in snapshot.get("requests") or []: try: print( - f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {status_symbol(row)} {row.get('status_code') or '-':<4} " + f"{row['seq']:>6} {short_text(request_id_text(row), 16):<16} {short_text(response_id_text(row), 16):<16} {short_text(row.get('thread_id'), 14):<14} {short_time(row.get('started_at')):<19} {short_text(request_status_label(row), 14):<14} " f"{short_text(row['path'], 20):<20} {short_text(row['model'] or row['requested_model'] or row['forwarded_model'], 16):<16} {short_text(request_effort_text(row), 6):<6} {request_reasoning_tokens_text(row):<6} " f"{short_text(request_usage_summary(row), 26):<26} {format_bytes(row.get('request_body_bytes')):<8} {request_chunk_progress(row):<14} {format_duration_ms_as_seconds(row['duration_ms']):<8} {request_updated_elapsed(row):<8} " f"{short_text(request_retry_note(row), 30)}" diff --git a/pyproject.toml b/pyproject.toml index e5b5288..7df42dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-retry-gateway-tui" -version = "0.1.6" +version = "0.1.7" description = "Terminal UI for codex-retry-gateway monitoring and control" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_payload.py b/tests/test_payload.py index 3734bae..46a368b 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as dt import importlib.util import json import os @@ -129,6 +130,45 @@ class CodexRetryGatewayTUITests(unittest.TestCase): } self.assertEqual(mod.request_usage_summary(row), "in 75 | out 40 | cached 25 (25%)") + def test_request_status_labels_follow_lifecycle_and_age(self) -> None: + mod = load_module() + now = dt.datetime(2026, 7, 10, 12, 0, tzinfo=dt.timezone.utc) + waiting = { + "lifecycle_state": "sent", + "started_at": "2026-07-10T11:59:50Z", + } + streaming = { + "lifecycle_state": "receive_first", + "started_at": "2026-07-10T11:59:00Z", + "first_response_at": "2026-07-10T11:59:55Z", + "last_activity_at": "2026-07-10T11:59:55Z", + } + complete = {"lifecycle_state": "finish", "status_code": 200} + discarded = { + "lifecycle_state": "finish", + "status_code": 502, + "error": "client disconnected before reasoning retry completed", + } + slow_waiting = { + "lifecycle_state": "sent", + "started_at": "2026-07-10T11:59:30Z", + } + stalled = { + "lifecycle_state": "streaming", + "first_response_at": "2026-07-10T11:57:50Z", + "last_activity_at": "2026-07-10T11:57:50Z", + } + + self.assertEqual(mod.request_status_label(waiting, now), "waiting") + self.assertEqual(mod.request_status_label(streaming, now), "streaming") + self.assertEqual(mod.request_status_label(complete, now), "HTTP 200") + self.assertEqual(mod.request_status_label(discarded, now), "discarded") + self.assertEqual(mod.request_status_label(slow_waiting, now), "waiting 30.0s") + self.assertEqual(mod.request_status_label(stalled, now), "stalled 130s") + self.assertNotEqual(mod.request_status_style(waiting, now), mod.request_status_style(streaming, now)) + self.assertEqual(mod.request_status_style(discarded, now), "bold #ff5f5f") + self.assertEqual(mod.request_status_style(stalled, now), "bold #ff5f5f") + def test_request_row_cells_expand_with_wider_limits(self) -> None: mod = load_module() row = { @@ -316,7 +356,8 @@ class CodexRetryGatewayTUITests(unittest.TestCase): self.assertEqual(visible[1].plain, "req_1") self.assertEqual(visible[2].plain, "resp_1") self.assertEqual(visible[3].plain, "thread_same") - self.assertEqual(visible[4], "ยท 200") + self.assertIsInstance(visible[4], Text) + self.assertEqual(visible[4].plain, "HTTP 200") self.assertEqual(mod.request_thread_color("thread_same"), mod.request_thread_color("thread_same")) self.assertIsNone(mod.request_thread_color("")) diff --git a/uv.lock b/uv.lock index fd4742c..df2e362 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "codex-retry-gateway-tui" -version = "0.1.4" +version = "0.1.7" source = { editable = "." } dependencies = [ { name = "textual" },