cloudagent-daemon 0.5.2__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/CHANGELOG.md +24 -0
  2. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.5.3/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/claude_runner.py +93 -12
  6. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner.py +280 -0
  7. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/uv.lock +1 -1
  8. cloudagent_daemon-0.5.2/src/cloudagent_daemon/__init__.py +0 -1
  9. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/.gitignore +0 -0
  10. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/LICENSE +0 -0
  11. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/README.md +0 -0
  12. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/scripts/release.sh +0 -0
  13. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/__main__.py +0 -0
  14. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_download.py +0 -0
  15. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload.py +0 -0
  16. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  17. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/auth_preflight.py +0 -0
  18. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/cli.py +0 -0
  19. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/config.py +0 -0
  20. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/context_fetch.py +0 -0
  21. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/delegation_report.py +0 -0
  22. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/doctor.py +0 -0
  23. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  24. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/http_outbound.py +0 -0
  25. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/injection_state.py +0 -0
  26. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  27. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/login.py +0 -0
  28. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/main_loop.py +0 -0
  29. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  30. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics.py +0 -0
  31. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics_server.py +0 -0
  32. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/outbound_queue.py +0 -0
  33. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/paths.py +0 -0
  34. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/protocol.py +0 -0
  35. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  36. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  37. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_config.py +0 -0
  38. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_detect.py +0 -0
  39. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/service.py +0 -0
  40. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  41. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_queue.py +0 -0
  42. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/sse_client.py +0 -0
  43. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/transport.py +0 -0
  44. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/unix_socket.py +0 -0
  45. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/workspace.py +0 -0
  46. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/ws_client.py +0 -0
  47. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/__init__.py +0 -0
  48. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/_fake_sse_server.py +0 -0
  49. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/conftest.py +0 -0
  50. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  51. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_sse.py +0 -0
  52. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_full_e2e.py +0 -0
  53. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  54. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_one_shot_cli.py +0 -0
  55. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  56. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_handshake.py +0 -0
  57. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_watchdog.py +0 -0
  58. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_handshake.py +0 -0
  59. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_reconnect.py +0 -0
  60. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/conftest.py +0 -0
  61. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_download.py +0 -0
  62. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_upload.py +0 -0
  63. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_auth_preflight.py +0 -0
  64. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  65. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  66. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_w3.py +0 -0
  67. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_multi_profile.py +0 -0
  68. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_smoke.py +0 -0
  69. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_config.py +0 -0
  70. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_context_fetch.py +0 -0
  71. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_delegation_report.py +0 -0
  72. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_doctor.py +0 -0
  73. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_frame_dispatch.py +0 -0
  74. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_http_outbound.py +0 -0
  75. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_injection_state.py +0 -0
  76. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_jsonl_logging.py +0 -0
  77. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_login.py +0 -0
  78. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_assign.py +0 -0
  79. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_preflight.py +0 -0
  80. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_agent_retrieval.py +0 -0
  81. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  82. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics.py +0 -0
  83. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics_server.py +0 -0
  84. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_multi_tool_wake.py +0 -0
  85. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_outbound_queue.py +0 -0
  86. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.2 → cloudagent_daemon-0.5.3}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -4,6 +4,30 @@ All notable changes to the local daemon. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are
5
5
  [PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
6
6
 
7
+ ## [0.5.3] — 2026-08-22
8
+
9
+ ### Fixed
10
+ - A wake that dies because the model's context window is full now drops the
11
+ oversized session and re-spawns WITHOUT `--resume`, instead of replaying the
12
+ same history after an 8s backoff. Replaying could never fit, so the retry was
13
+ guaranteed to fail; three of them in a row crossed the crash threshold and
14
+ quarantined the agent — silently, since a quarantined agent stops being woken
15
+ (#114). The cleared session id is persisted, so later wakes do not resume the
16
+ oversized thread either.
17
+ - The engine's own error text is now carried on the spawn result. Previously
18
+ only a bare `had_api_error` boolean survived, so the retry layer could not
19
+ tell a transient rate-limit (retry with resume) from a permanent
20
+ context-window overflow (start a new thread) and treated both the same.
21
+ - A context-exhausted wake reports `context_exhausted` rather than the generic
22
+ `upstream_api_error`, so the cause is visible without reading daemon logs.
23
+
24
+ ### Notes
25
+ - Detection matches an apostrophe-free substring: the engine writes "the
26
+ model's context window", and a smart-quote rewrite upstream would otherwise
27
+ silently disable the whole fix. A test pins that invariant.
28
+ - No 0.5.2 entry exists in this file; 0.5.2 was published manually and its
29
+ changelog entry was never written.
30
+
7
31
  ## [0.5.1] — 2026-08-20
8
32
 
9
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.5.2"
7
+ version = "0.5.3"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.3"
@@ -338,6 +338,13 @@ class _SpawnAttempt:
338
338
  # M8 — set True when claude emitted an ``isApiErrorMessage:true``
339
339
  # event during the wake (upstream API error swallowed by the CLI).
340
340
  had_api_error: bool = False
341
+ # #114 — the engine's OWN explanation for that error. codex reports
342
+ # upstream failures as a ``turn.failed``/``error`` stdout event whose
343
+ # message previously reached only the log, so the retry layer saw a
344
+ # bare boolean and could not tell a transient rate-limit apart from a
345
+ # permanent context-window overflow — and retried both the same way.
346
+ # Keeping the text is what lets the wake layer branch on the cause.
347
+ api_error_text: str | None = None
341
348
 
342
349
 
343
350
  def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
@@ -467,6 +474,22 @@ class ClaudeRunner:
467
474
  "no rollout found for thread",
468
475
  )
469
476
 
477
+ #: #114 — 上下文窗口撑满。这是**永久性**前提失败,不是瞬时错误:退避几秒
478
+ #: 再把同一份历史送一次,必然再次溢出。codex 自己给出的处置就是 "Start a
479
+ #: new thread or clear earlier history",与 stale-session 的补救动作完全
480
+ #: 一致(清掉会话、免 resume 重跑),故复用同一条路径而非退避重试。
481
+ #: 匹配按**小写子串**做,同时覆盖 codex 的自然语言提示和 OpenAI API 的
482
+ #: 标准错误码,避免只认一种措辞。
483
+ #: 刻意**不含撇号**:codex 的原文是 "the model's context window",但撇号是
484
+ #: 最容易被改写成弯引号(’)的字符,一旦不匹配整个修复就静默失效——而这个修复
485
+ #: 存在的意义正是消除静默失败。去掉撇号后的子串依然足够specific。
486
+ CONTEXT_EXHAUSTED_MARKERS: tuple[str, ...] = (
487
+ "ran out of room in the model",
488
+ "context_length_exceeded",
489
+ "maximum context length",
490
+ "context window",
491
+ )
492
+
470
493
  #: 会产生外部副作用的工具(发消息/建任务类)。瞬时错误重试只在本次 wake
471
494
  #: **没**调过这些工具时才做 —— 只读工具(list_messages 等)重跑无害。
472
495
  SIDE_EFFECT_TOOLS: frozenset[str] = frozenset({
@@ -662,10 +685,22 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
662
685
  self._qoder_lane_session(ws, stale_conv)
663
686
  if stale_is_qoder and stale_conv else None
664
687
  )
688
+ # #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
689
+ # 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
690
+ # 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
691
+ # 而不是掉进下面的瞬时退避重试——那会带着同一份撑满的历史重放,
692
+ # 注定再次失败,白烧一轮唤醒和一次计费,然后累计成 crash 进隔离。
693
+ context_exhausted = self._is_context_exhausted(attempt)
665
694
  if (
666
695
  self._run_mode_for(payload) != "read_analyze"
667
696
  and attempt.exit_code != 0
668
- and any(m in attempt.stderr_tail for m in self.STALE_SESSION_STDERR_MARKERS)
697
+ and (
698
+ any(
699
+ m in attempt.stderr_tail
700
+ for m in self.STALE_SESSION_STDERR_MARKERS
701
+ )
702
+ or context_exhausted
703
+ )
669
704
  and (ws.last_session_id() is not None or qoder_lane_sid is not None)
670
705
  ):
671
706
  stale_sid = qoder_lane_sid or ws.last_session_id()
@@ -673,11 +708,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
673
708
  if stale_is_qoder and stale_conv:
674
709
  self._qoder_set_lane(ws, stale_conv, None)
675
710
  logger.warning(
676
- "claude_runner: resume rejected stale "
677
- "session_id=%s (stderr: %r). Clearing and "
678
- "retrying without resume.",
711
+ "claude_runner: dropping unusable session_id=%s "
712
+ "(cause=%s, detail=%r). Clearing and retrying "
713
+ "without resume.",
679
714
  stale_sid,
680
- attempt.stderr_tail[-160:],
715
+ "context_exhausted" if context_exhausted else "stale_session",
716
+ (attempt.api_error_text or attempt.stderr_tail)[-160:],
681
717
  )
682
718
  try:
683
719
  ws.clear_last_session_id()
@@ -697,6 +733,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
697
733
  attempt.exit_code != 0
698
734
  and attempt.had_api_error
699
735
  and not attempt.timeout_hit
736
+ # #114 — 永久性失败不走瞬时重试。退避 8 秒不会让上下文变小,
737
+ # 带 resume 重放同一份历史必然再次撑满;上面那条路径已经用
738
+ # "清会话 + 免 resume" 处理过它了。注意这里重新判定的是**重试
739
+ # 之后**的 attempt:若换新会话仍然撑满(例如注入的正文本身过
740
+ # 大),同样不该再退避重放。
741
+ and not self._is_context_exhausted(attempt)
700
742
  and not any(
701
743
  t.rsplit("__", 1)[-1] in self.SIDE_EFFECT_TOOLS
702
744
  for t in attempt.tool_uses
@@ -1004,7 +1046,13 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1004
1046
  and parent_for_abort not in (None, "")
1005
1047
  ):
1006
1048
  if attempt.had_api_error:
1007
- reason = "upstream_api_error"
1049
+ # #114 — 把永久性的"会话撑满"和笼统的上游错误区分开:否则频道
1050
+ # 里那条 wake_aborted 只说"上游错误",真实原因只能靠翻日志。
1051
+ reason = (
1052
+ "context_exhausted"
1053
+ if self._is_context_exhausted(attempt)
1054
+ else "upstream_api_error"
1055
+ )
1008
1056
  elif timeout_hit:
1009
1057
  reason = "wake_timeout"
1010
1058
  else:
@@ -1465,6 +1513,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1465
1513
  final_text,
1466
1514
  result_usage,
1467
1515
  had_api_error,
1516
+ api_error_text,
1468
1517
  ) = await self._consume_stdout(
1469
1518
  proc,
1470
1519
  parse_mode=(
@@ -1495,12 +1544,34 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1495
1544
  final_result_text=final_text,
1496
1545
  result_usage=result_usage,
1497
1546
  had_api_error=had_api_error,
1547
+ api_error_text=api_error_text,
1498
1548
  )
1499
1549
 
1500
1550
  # ------------------------------------------------------------------
1501
1551
  # Internal helpers
1502
1552
  # ------------------------------------------------------------------
1503
1553
 
1554
+ def _is_context_exhausted(self, attempt: _SpawnAttempt) -> bool:
1555
+ """#114 — True iff *attempt* failed because the model's context
1556
+ window is full.
1557
+
1558
+ A **permanent** premise failure, not a transient upstream error:
1559
+ the same history replayed after any backoff overflows the window
1560
+ again, so retrying with ``--resume`` burns a wake and a billing
1561
+ cycle to reach the identical failure. The remedy is the one the
1562
+ engine itself prints — start a new thread — which is exactly what
1563
+ the stale-session path already does, so both share it.
1564
+
1565
+ Reads only :attr:`_SpawnAttempt.api_error_text` (the engine's own
1566
+ message). An attempt that carried no explanation returns False:
1567
+ unknown cause keeps the previous, more conservative handling
1568
+ rather than silently discarding a session that may be fine.
1569
+ """
1570
+ text = (attempt.api_error_text or "").lower()
1571
+ if not text:
1572
+ return False
1573
+ return any(m in text for m in self.CONTEXT_EXHAUSTED_MARKERS)
1574
+
1504
1575
  #: 1i Bug E — the daemon-injected ``cloudagent`` MCP toolset MUST run
1505
1576
  #: unattended in ``--print`` headless mode (otherwise claude prompts
1506
1577
  #: the user for permission, the prompt is never answered, and the
@@ -2286,7 +2357,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2286
2357
  *,
2287
2358
  partial_session_id: str | None = None,
2288
2359
  partial_agent_id: str | None = None,
2289
- ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
2360
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
2290
2361
  """Consume ``codex exec --json`` NDJSON.
2291
2362
 
2292
2363
  Codex emits ``thread.started`` with ``thread_id`` and one or more
@@ -2304,13 +2375,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2304
2375
  # codex 把上游 API 错误(模型 at capacity / 限流)作为 turn.failed/error
2305
2376
  # 事件吐在 stdout —— 记为 had_api_error,wake 层据此做瞬时错误重试。
2306
2377
  had_api_error = False
2378
+ # #114 — 同时留下引擎给的原因文本。只有一个布尔时,"限流"和"上下文
2379
+ # 撑满"在 wake 层无法区分,于是永久性失败被当成瞬时错误反复重放。
2380
+ api_error_text: str | None = None
2307
2381
  publish_enabled = bool(
2308
2382
  self._partial_publisher
2309
2383
  and partial_session_id
2310
2384
  and partial_agent_id
2311
2385
  )
2312
2386
  if proc.stdout is None:
2313
- return None, tool_uses, None, None, False
2387
+ return None, tool_uses, None, None, False, None
2314
2388
 
2315
2389
  async def _emit(event_type: str, payload: "dict[str, Any]") -> None:
2316
2390
  if (
@@ -2468,9 +2542,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2468
2542
  msg = raw if isinstance(raw, str) else None
2469
2543
  logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
2470
2544
  had_api_error = True
2545
+ # 第一条为准:后续事件通常是同一个失败的级联噪声,首条才是根因。
2546
+ if api_error_text is None and isinstance(msg, str) and msg:
2547
+ api_error_text = msg
2471
2548
 
2472
2549
  final_text = "\n\n".join(final_chunks) if final_chunks else None
2473
- return captured, tool_uses, final_text, result_usage, had_api_error
2550
+ return captured, tool_uses, final_text, result_usage, had_api_error, api_error_text
2474
2551
 
2475
2552
  async def _consume_stdout(
2476
2553
  self,
@@ -2479,7 +2556,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2479
2556
  parse_mode: str = "stream_json",
2480
2557
  partial_session_id: str | None = None,
2481
2558
  partial_agent_id: str | None = None,
2482
- ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
2559
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
2483
2560
  """Stream NDJSON; return ``(session_id, tool_uses, result_text, result_usage, had_api_error)``.
2484
2561
 
2485
2562
  Accepts BOTH session-init event shapes for forward/backward
@@ -2531,6 +2608,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2531
2608
  # M8 — set True when claude emits a synthetic isApiErrorMessage
2532
2609
  # assistant event (upstream API error swallowed by the CLI).
2533
2610
  had_api_error: bool = False
2611
+ # #114 — 与 codex 侧同构:保留引擎自己的错误文本供 wake 层判定原因。
2612
+ api_error_text: str | None = None
2534
2613
  # 1k74 — running buffer for token-level streaming. claude emits
2535
2614
  # one ``stream_event`` per content_block_delta (~80 chars at a
2536
2615
  # time); we concatenate them and forward the running total to
@@ -2570,7 +2649,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2570
2649
  )
2571
2650
 
2572
2651
  if proc.stdout is None:
2573
- return None, tool_uses, final_text, result_usage, had_api_error
2652
+ return None, tool_uses, final_text, result_usage, had_api_error, api_error_text
2574
2653
 
2575
2654
  async for line_b in proc.stdout:
2576
2655
  line = line_b.decode("utf-8", errors="replace").strip()
@@ -2685,6 +2764,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2685
2764
  if isinstance(t, str):
2686
2765
  err_text = t[:200]
2687
2766
  break
2767
+ if api_error_text is None and err_text:
2768
+ api_error_text = err_text
2688
2769
  logger.warning(
2689
2770
  "ClaudeRunner: stdout isApiErrorMessage event — "
2690
2771
  "wake will be marked crashed (text=%r)",
@@ -2824,7 +2905,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2824
2905
  "claude_runner: cache metrics publish failed", exc_info=True
2825
2906
  )
2826
2907
 
2827
- return captured, tool_uses, final_text, result_usage, had_api_error
2908
+ return captured, tool_uses, final_text, result_usage, had_api_error, api_error_text
2828
2909
 
2829
2910
  async def _wait_with_timeout(
2830
2911
  self, proc: asyncio.subprocess.Process
@@ -3310,3 +3310,283 @@ async def test_qoder_stale_resume_clears_only_that_lane(
3310
3310
  assert "--resume" not in a1
3311
3311
  lanes = json.loads((tmp_path / ".meta" / "qoder-sessions.json").read_text())
3312
3312
  assert lanes == {"conv_A": "new_A", "conv_B": "keep_B"}
3313
+
3314
+
3315
+ # ---------------------------------------------------------------------------
3316
+ # #114 — context-window exhaustion is a PERMANENT failure, not a transient one
3317
+ # ---------------------------------------------------------------------------
3318
+
3319
+
3320
+ _CTX_FULL_MSG = (
3321
+ "Codex ran out of room in the model's context window. "
3322
+ "Start a new thread or clear earlier history before retrying."
3323
+ )
3324
+
3325
+
3326
+ def _codex_turn_failed(msg: str) -> str:
3327
+ """One codex NDJSON stdout event reporting a failed turn."""
3328
+ return json.dumps({"type": "turn.failed", "error": {"message": msg}})
3329
+
3330
+
3331
+ def _codex_ok(thread_id: str = "thread_fresh") -> list[str]:
3332
+ return [
3333
+ json.dumps({"type": "thread.started", "thread_id": thread_id}),
3334
+ json.dumps({
3335
+ "type": "item.completed",
3336
+ "item": {"type": "agent_message", "text": "ok"},
3337
+ }),
3338
+ ]
3339
+
3340
+
3341
+ def _codex_runner(fake: str, ws: FakeWorkspace, outbound: FakeOutbound):
3342
+ return ClaudeRunner(
3343
+ outbound=outbound,
3344
+ workspace=ws,
3345
+ runtime_binaries={"codex": fake},
3346
+ single_wake_timeout=10.0,
3347
+ )
3348
+
3349
+
3350
+ async def test_context_exhausted_starts_a_fresh_session_instead_of_replaying(
3351
+ stateful_fake_claude, tmp_path
3352
+ ):
3353
+ """#114 — a wake that dies because the context window is full must drop
3354
+ the oversized session and re-spawn WITHOUT resume.
3355
+
3356
+ This is the failure that took Dex down on 2026-08-21: the runner treated
3357
+ "ran out of room in the model's context window" as a transient upstream
3358
+ error and replayed the *same* oversized history, which cannot ever fit.
3359
+ Three of those in a row crossed the crash threshold and the agent was
3360
+ quarantined — silently, since a quarantined agent stops being woken.
3361
+ """
3362
+ mcp_cfg = tmp_path / "mcp.json"
3363
+ mcp_cfg.write_text("{}")
3364
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_big")
3365
+ ws = FakeWorkspace(agent_ws)
3366
+ outbound = FakeOutbound()
3367
+
3368
+ fake = stateful_fake_claude([
3369
+ {"stdout": [_codex_turn_failed(_CTX_FULL_MSG)], "stderr": [], "exit_code": 1},
3370
+ {"stdout": _codex_ok("thread_fresh"), "stderr": [], "exit_code": 0},
3371
+ ])
3372
+ runner = _codex_runner(fake, ws, outbound)
3373
+
3374
+ result = await runner.run_one(
3375
+ "agent-A", {**_make_payload(), "runtime_engine": "codex"}
3376
+ )
3377
+
3378
+ argv0 = _read_argv_n(tmp_path, 0)
3379
+ argv1 = _read_argv_n(tmp_path, 1)
3380
+ # The first attempt resumed the oversized thread…
3381
+ assert "resume" in argv0 and "thread_big" in argv0
3382
+ # …and the retry must abandon it entirely — resuming it again is the bug.
3383
+ assert "resume" not in argv1, (
3384
+ "retry replayed the oversized history; that can only fail again"
3385
+ )
3386
+ assert "thread_big" not in argv1
3387
+
3388
+ # The poisoned id is cleared, so LATER wakes don't resume it either —
3389
+ # without this the agent recovers for one wake and dies on the next.
3390
+ assert agent_ws.clear_last_session_calls == 1
3391
+ assert agent_ws.set_calls == ["thread_fresh"]
3392
+
3393
+ assert result.exit_code == 0
3394
+ final_status = [c for c in outbound.calls if c[0] == "agent_status"][-1]
3395
+ assert final_status == ("agent_status", "agent-A", "idle", 0)
3396
+
3397
+
3398
+ async def test_context_exhausted_is_not_retried_as_a_transient_error(
3399
+ stateful_fake_claude, tmp_path
3400
+ ):
3401
+ """#114 — when even the fresh session overflows, STOP.
3402
+
3403
+ The transient path backs off 8s and re-spawns *with* resume. Waiting does
3404
+ not shrink a context window, so letting an exhausted wake fall through to
3405
+ it burns a third spawn (and a third bill) to reach the same failure.
3406
+ Exactly two spawns may happen: the original and the fresh-session retry.
3407
+ """
3408
+ mcp_cfg = tmp_path / "mcp.json"
3409
+ mcp_cfg.write_text("{}")
3410
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_big")
3411
+ ws = FakeWorkspace(agent_ws)
3412
+ outbound = FakeOutbound()
3413
+
3414
+ fake = stateful_fake_claude([
3415
+ {"stdout": [_codex_turn_failed(_CTX_FULL_MSG)], "stderr": [], "exit_code": 1},
3416
+ {"stdout": [_codex_turn_failed(_CTX_FULL_MSG)], "stderr": [], "exit_code": 1},
3417
+ {"stdout": _codex_ok(), "stderr": [], "exit_code": 0},
3418
+ ])
3419
+ runner = _codex_runner(fake, ws, outbound)
3420
+
3421
+ await runner.run_one("agent-A", {**_make_payload(), "runtime_engine": "codex"})
3422
+
3423
+ assert (tmp_path / "argv-1.json").exists(), "fresh-session retry must happen"
3424
+ assert not (tmp_path / "argv-2.json").exists(), (
3425
+ "a permanently-failing wake was retried a third time via the "
3426
+ "transient-error backoff path"
3427
+ )
3428
+
3429
+
3430
+ async def test_transient_upstream_error_still_backs_off_and_resumes(
3431
+ stateful_fake_claude, tmp_path, monkeypatch
3432
+ ):
3433
+ """#114 regression guard — narrowing the retry must not disarm it.
3434
+
3435
+ A genuine transient upstream error (rate limit / model at capacity) still
3436
+ has to take the original path: back off, then retry WITH resume so the
3437
+ conversation keeps its history. If this goes red, the fix over-matched.
3438
+ """
3439
+ mcp_cfg = tmp_path / "mcp.json"
3440
+ mcp_cfg.write_text("{}")
3441
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_ok")
3442
+ ws = FakeWorkspace(agent_ws)
3443
+ outbound = FakeOutbound()
3444
+
3445
+ slept: list[float] = []
3446
+
3447
+ async def _no_sleep(seconds: float) -> None:
3448
+ slept.append(seconds)
3449
+
3450
+ monkeypatch.setattr(claude_runner_module.asyncio, "sleep", _no_sleep)
3451
+
3452
+ fake = stateful_fake_claude([
3453
+ {
3454
+ "stdout": [_codex_turn_failed("stream error: model is at capacity")],
3455
+ "stderr": [],
3456
+ "exit_code": 1,
3457
+ },
3458
+ {"stdout": _codex_ok("thread_ok"), "stderr": [], "exit_code": 0},
3459
+ ])
3460
+ runner = _codex_runner(fake, ws, outbound)
3461
+
3462
+ await runner.run_one("agent-A", {**_make_payload(), "runtime_engine": "codex"})
3463
+
3464
+ assert slept == [8], "transient error must still back off before retrying"
3465
+ argv1 = _read_argv_n(tmp_path, 1)
3466
+ assert "resume" in argv1 and "thread_ok" in argv1, (
3467
+ "a transient failure must keep the session — only exhaustion drops it"
3468
+ )
3469
+ # The session was NOT thrown away for a merely transient hiccup.
3470
+ assert agent_ws.clear_last_session_calls == 0
3471
+
3472
+
3473
+ async def test_read_analyze_context_exhaustion_never_clears_the_owner_session(
3474
+ stateful_fake_claude, tmp_path
3475
+ ):
3476
+ """§7.4 + #114 — a read_analyze wake must not touch the owner's lane.
3477
+
3478
+ read_analyze never resumes, so its own overflow says nothing about the
3479
+ owner's session. Clearing it here would let a read-only round destroy
3480
+ state it is explicitly forbidden to write.
3481
+ """
3482
+ mcp_cfg = tmp_path / "mcp.json"
3483
+ mcp_cfg.write_text("{}")
3484
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_owner")
3485
+ ws = FakeWorkspace(agent_ws)
3486
+ outbound = FakeOutbound()
3487
+
3488
+ fake = stateful_fake_claude([
3489
+ {"stdout": [_codex_turn_failed(_CTX_FULL_MSG)], "stderr": [], "exit_code": 1},
3490
+ {"stdout": _codex_ok(), "stderr": [], "exit_code": 0},
3491
+ ])
3492
+ runner = _codex_runner(fake, ws, outbound)
3493
+
3494
+ await runner.run_one("agent-A", _ra_payload(runtime_engine="codex"))
3495
+
3496
+ assert agent_ws.clear_last_session_calls == 0
3497
+ assert agent_ws.set_calls == []
3498
+
3499
+
3500
+ async def test_context_exhausted_survives_a_curly_apostrophe(
3501
+ stateful_fake_claude, tmp_path
3502
+ ):
3503
+ """#114 — a pretty-quoted variant of the message is still handled.
3504
+
3505
+ End-to-end guard only: it asserts the wake recovers, without pinning WHICH
3506
+ marker matched. The apostrophe-free property itself is pinned by
3507
+ :func:`test_context_markers_avoid_rewritable_punctuation` — reverse mutation
3508
+ showed this test alone passes via the generic ``context window`` marker, so
3509
+ on its own it does NOT prove the hardening.
3510
+ """
3511
+ mcp_cfg = tmp_path / "mcp.json"
3512
+ mcp_cfg.write_text("{}")
3513
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_big")
3514
+ ws = FakeWorkspace(agent_ws)
3515
+
3516
+ curly = (
3517
+ "Codex ran out of room in the model’s context window. "
3518
+ "Start a new thread or clear earlier history before retrying."
3519
+ )
3520
+ fake = stateful_fake_claude([
3521
+ {"stdout": [_codex_turn_failed(curly)], "stderr": [], "exit_code": 1},
3522
+ {"stdout": _codex_ok("thread_fresh"), "stderr": [], "exit_code": 0},
3523
+ ])
3524
+ runner = _codex_runner(fake, ws, FakeOutbound())
3525
+
3526
+ await runner.run_one("agent-A", {**_make_payload(), "runtime_engine": "codex"})
3527
+
3528
+ assert agent_ws.clear_last_session_calls == 1, (
3529
+ "a curly apostrophe defeated the marker — detection is punctuation-fragile"
3530
+ )
3531
+ assert "resume" not in _read_argv_n(tmp_path, 1)
3532
+
3533
+
3534
+ async def test_rate_limit_mentioning_tokens_is_not_read_as_exhaustion(
3535
+ stateful_fake_claude, tmp_path, monkeypatch
3536
+ ):
3537
+ """#114 — pins the deliberately-broad ``context window`` marker.
3538
+
3539
+ Detection is intentionally generous (a missed exhaustion costs a crash
3540
+ loop; a false positive only costs one session's history). "Generous" must
3541
+ still exclude the common transient that talks about tokens and limits —
3542
+ a quota error must keep its session and take the backoff path.
3543
+ """
3544
+ mcp_cfg = tmp_path / "mcp.json"
3545
+ mcp_cfg.write_text("{}")
3546
+ agent_ws = FakeAgentWorkspace(mcp_path=mcp_cfg, last_session="thread_ok")
3547
+ ws = FakeWorkspace(agent_ws)
3548
+
3549
+ async def _no_sleep(seconds: float) -> None:
3550
+ return None
3551
+
3552
+ monkeypatch.setattr(claude_runner_module.asyncio, "sleep", _no_sleep)
3553
+
3554
+ quota = (
3555
+ "Rate limit reached: limit 30000 tokens per minute. "
3556
+ "Please try again later."
3557
+ )
3558
+ fake = stateful_fake_claude([
3559
+ {"stdout": [_codex_turn_failed(quota)], "stderr": [], "exit_code": 1},
3560
+ {"stdout": _codex_ok("thread_ok"), "stderr": [], "exit_code": 0},
3561
+ ])
3562
+ runner = _codex_runner(fake, ws, FakeOutbound())
3563
+
3564
+ await runner.run_one("agent-A", {**_make_payload(), "runtime_engine": "codex"})
3565
+
3566
+ assert agent_ws.clear_last_session_calls == 0, (
3567
+ "a rate-limit error was misread as context exhaustion — marker too broad"
3568
+ )
3569
+ assert "resume" in _read_argv_n(tmp_path, 1)
3570
+
3571
+
3572
+ def test_context_markers_avoid_rewritable_punctuation():
3573
+ """#114 — no marker may hinge on a character that gets auto-rewritten.
3574
+
3575
+ Straight apostrophes and quotes are exactly what smart-quote passes
3576
+ rewrite. A marker containing one can be silently defeated by a cosmetic
3577
+ transformation upstream, switching the whole fix off with no signal — the
3578
+ opposite of what this fix is for. Markers must also stay lowercase because
3579
+ matching lowercases the text first; an uppercase marker can never match.
3580
+ """
3581
+ for marker in ClaudeRunner.CONTEXT_EXHAUSTED_MARKERS:
3582
+ assert "'" not in marker and "\u2019" not in marker, (
3583
+ f"marker {marker!r} hinges on an apostrophe — a smart-quote pass "
3584
+ f"upstream would silently disable context-exhaustion detection"
3585
+ )
3586
+ assert '"' not in marker and "\u201c" not in marker, (
3587
+ f"marker {marker!r} hinges on a quote character"
3588
+ )
3589
+ assert marker == marker.lower(), (
3590
+ f"marker {marker!r} is not lowercase; _is_context_exhausted "
3591
+ f"lowercases the text, so this marker can never match"
3592
+ )
@@ -122,7 +122,7 @@ wheels = [
122
122
 
123
123
  [[package]]
124
124
  name = "cloudagent-daemon"
125
- version = "0.5.2"
125
+ version = "0.5.3"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.2"