feat: clarify request lifecycle status

This commit is contained in:
2026-07-10 07:20:56 +08:00
parent e4b7fc679e
commit ccecee2a44
5 changed files with 149 additions and 31 deletions
+1 -1
View File
@@ -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 <code>`, 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
+104 -27
View File
@@ -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)}"
+1 -1
View File
@@ -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"
+42 -1
View File
@@ -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(""))
Generated
+1 -1
View File
@@ -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" },