cloudagent-daemon 0.5.1__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.1 → cloudagent_daemon-0.5.3}/CHANGELOG.md +24 -0
  2. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.5.1 → 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.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/claude_runner.py +144 -23
  6. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/main_loop.py +24 -3
  7. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/mcp_bridge.py +78 -15
  8. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/outbound_queue.py +3 -0
  9. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/protocol.py +10 -1
  10. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_mcp_bridge_round_trip.py +25 -0
  11. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_spawn_mcp_bridge_e2e.py +26 -0
  12. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner.py +302 -0
  13. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_agent_retrieval.py +14 -2
  14. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_send_message.py +5 -0
  15. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_outbound_queue.py +11 -0
  16. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/uv.lock +1 -1
  17. cloudagent_daemon-0.5.1/src/cloudagent_daemon/__init__.py +0 -1
  18. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/.gitignore +0 -0
  19. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/LICENSE +0 -0
  20. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/README.md +0 -0
  21. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/scripts/release.sh +0 -0
  22. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/__main__.py +0 -0
  23. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_download.py +0 -0
  24. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload.py +0 -0
  25. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  26. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/auth_preflight.py +0 -0
  27. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/cli.py +0 -0
  28. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/config.py +0 -0
  29. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/context_fetch.py +0 -0
  30. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/delegation_report.py +0 -0
  31. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/doctor.py +0 -0
  32. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  33. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/http_outbound.py +0 -0
  34. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/injection_state.py +0 -0
  35. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  36. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/login.py +0 -0
  37. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics.py +0 -0
  38. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics_server.py +0 -0
  39. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/paths.py +0 -0
  40. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  41. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  42. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_config.py +0 -0
  43. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_detect.py +0 -0
  44. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/service.py +0 -0
  45. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  46. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_queue.py +0 -0
  47. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/sse_client.py +0 -0
  48. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/transport.py +0 -0
  49. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/unix_socket.py +0 -0
  50. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/workspace.py +0 -0
  51. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/ws_client.py +0 -0
  52. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/__init__.py +0 -0
  53. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/_fake_sse_server.py +0 -0
  54. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/conftest.py +0 -0
  55. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  56. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_sse.py +0 -0
  57. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_full_e2e.py +0 -0
  58. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_one_shot_cli.py +0 -0
  59. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_handshake.py +0 -0
  60. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_watchdog.py +0 -0
  61. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_handshake.py +0 -0
  62. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_reconnect.py +0 -0
  63. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/conftest.py +0 -0
  64. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_download.py +0 -0
  65. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_upload.py +0 -0
  66. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_auth_preflight.py +0 -0
  67. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  68. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  69. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_w3.py +0 -0
  70. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_multi_profile.py +0 -0
  71. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_smoke.py +0 -0
  72. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_config.py +0 -0
  73. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_context_fetch.py +0 -0
  74. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_delegation_report.py +0 -0
  75. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_doctor.py +0 -0
  76. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_frame_dispatch.py +0 -0
  77. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_http_outbound.py +0 -0
  78. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_injection_state.py +0 -0
  79. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_jsonl_logging.py +0 -0
  80. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_login.py +0 -0
  81. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_assign.py +0 -0
  82. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_preflight.py +0 -0
  83. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics.py +0 -0
  84. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics_server.py +0 -0
  85. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_multi_tool_wake.py +0 -0
  86. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.1 → 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.1
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.1"
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:
@@ -391,6 +398,7 @@ class ClaudeRunner:
391
398
  partial_publisher: PartialPublisher | None = None,
392
399
  event_publisher: EventPublisher | None = None,
393
400
  agent_to_session: "dict[str, str | None] | None" = None,
401
+ agent_to_delivery: "dict[str, str | None] | None" = None,
394
402
  claude_permission_mode: str = "bypassPermissions",
395
403
  runtime_binaries: dict[str, str] | None = None,
396
404
  http: object | None = None,
@@ -440,6 +448,10 @@ class ClaudeRunner:
440
448
  # unix socket ``get_current_session`` op instead of reading a file.
441
449
  # None means feature disabled (legacy / test path).
442
450
  self._agent_to_session: dict[str, str | None] | None = agent_to_session
451
+ # #112 — opaque backend delivery provenance for the currently running
452
+ # turn. The MCP bridge queries this map through daemon IPC and adds it
453
+ # to outbound calls without exposing a forgeable tool parameter.
454
+ self._agent_to_delivery: dict[str, str | None] | None = agent_to_delivery
443
455
 
444
456
  # ------------------------------------------------------------------
445
457
  # Public API
@@ -462,6 +474,22 @@ class ClaudeRunner:
462
474
  "no rollout found for thread",
463
475
  )
464
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
+
465
493
  #: 会产生外部副作用的工具(发消息/建任务类)。瞬时错误重试只在本次 wake
466
494
  #: **没**调过这些工具时才做 —— 只读工具(list_messages 等)重跑无害。
467
495
  SIDE_EFFECT_TOOLS: frozenset[str] = frozenset({
@@ -618,6 +646,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
618
646
  # reading the file. Both paths coexist for backward compat.
619
647
  if self._agent_to_session is not None:
620
648
  self._agent_to_session[agent_id] = _effective_session_id
649
+ current_delivery_id = payload.get("delivery_id")
650
+ _effective_delivery_id = (
651
+ current_delivery_id
652
+ if isinstance(current_delivery_id, str) and current_delivery_id
653
+ else None
654
+ )
655
+ if self._agent_to_delivery is not None:
656
+ self._agent_to_delivery[agent_id] = _effective_delivery_id
621
657
 
622
658
  # M7 W3 — localagent company-context injection. BEFORE spawning
623
659
  # claude, if this deliver belongs to a task whose context has not
@@ -649,10 +685,22 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
649
685
  self._qoder_lane_session(ws, stale_conv)
650
686
  if stale_is_qoder and stale_conv else None
651
687
  )
688
+ # #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
689
+ # 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
690
+ # 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
691
+ # 而不是掉进下面的瞬时退避重试——那会带着同一份撑满的历史重放,
692
+ # 注定再次失败,白烧一轮唤醒和一次计费,然后累计成 crash 进隔离。
693
+ context_exhausted = self._is_context_exhausted(attempt)
652
694
  if (
653
695
  self._run_mode_for(payload) != "read_analyze"
654
696
  and attempt.exit_code != 0
655
- 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
+ )
656
704
  and (ws.last_session_id() is not None or qoder_lane_sid is not None)
657
705
  ):
658
706
  stale_sid = qoder_lane_sid or ws.last_session_id()
@@ -660,11 +708,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
660
708
  if stale_is_qoder and stale_conv:
661
709
  self._qoder_set_lane(ws, stale_conv, None)
662
710
  logger.warning(
663
- "claude_runner: resume rejected stale "
664
- "session_id=%s (stderr: %r). Clearing and "
665
- "retrying without resume.",
711
+ "claude_runner: dropping unusable session_id=%s "
712
+ "(cause=%s, detail=%r). Clearing and retrying "
713
+ "without resume.",
666
714
  stale_sid,
667
- attempt.stderr_tail[-160:],
715
+ "context_exhausted" if context_exhausted else "stale_session",
716
+ (attempt.api_error_text or attempt.stderr_tail)[-160:],
668
717
  )
669
718
  try:
670
719
  ws.clear_last_session_id()
@@ -684,6 +733,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
684
733
  attempt.exit_code != 0
685
734
  and attempt.had_api_error
686
735
  and not attempt.timeout_hit
736
+ # #114 — 永久性失败不走瞬时重试。退避 8 秒不会让上下文变小,
737
+ # 带 resume 重放同一份历史必然再次撑满;上面那条路径已经用
738
+ # "清会话 + 免 resume" 处理过它了。注意这里重新判定的是**重试
739
+ # 之后**的 attempt:若换新会话仍然撑满(例如注入的正文本身过
740
+ # 大),同样不该再退避重放。
741
+ and not self._is_context_exhausted(attempt)
687
742
  and not any(
688
743
  t.rsplit("__", 1)[-1] in self.SIDE_EFFECT_TOOLS
689
744
  for t in attempt.tool_uses
@@ -709,6 +764,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
709
764
  # (c) IPC architecture upgrade — clear from memory map.
710
765
  if self._agent_to_session is not None:
711
766
  self._agent_to_session.pop(agent_id, None)
767
+ if self._agent_to_delivery is not None:
768
+ self._agent_to_delivery.pop(agent_id, None)
712
769
 
713
770
  captured_session = attempt.captured_session_id
714
771
  rc = attempt.exit_code
@@ -830,6 +887,11 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
830
887
  "content": attempt.final_result_text,
831
888
  "conversation_id": platform_conv_id,
832
889
  "acknowledge_new_messages": True,
890
+ **(
891
+ {"delivery_id": _effective_delivery_id}
892
+ if _effective_delivery_id
893
+ else {}
894
+ ),
833
895
  },
834
896
  timeout=15.0,
835
897
  )
@@ -876,6 +938,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
876
938
  payload, attempt.final_result_text
877
939
  )
878
940
  )
941
+ if _effective_delivery_id:
942
+ fallback_args["delivery_id"] = _effective_delivery_id
879
943
  await self.outbound.send_tool_call(
880
944
  agent_id=agent_id,
881
945
  session_id=fallback_session,
@@ -982,7 +1046,13 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
982
1046
  and parent_for_abort not in (None, "")
983
1047
  ):
984
1048
  if attempt.had_api_error:
985
- 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
+ )
986
1056
  elif timeout_hit:
987
1057
  reason = "wake_timeout"
988
1058
  else:
@@ -1006,7 +1076,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1006
1076
  agent_id=agent_id,
1007
1077
  session_id=fallback_session,
1008
1078
  tool_name="send_message",
1009
- args=self._fallback_reply_args(payload, abort_body),
1079
+ args={
1080
+ **self._fallback_reply_args(payload, abort_body),
1081
+ **(
1082
+ {"delivery_id": _effective_delivery_id}
1083
+ if _effective_delivery_id
1084
+ else {}
1085
+ ),
1086
+ },
1010
1087
  timeout=15.0,
1011
1088
  )
1012
1089
  try:
@@ -1026,15 +1103,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1026
1103
  reason,
1027
1104
  )
1028
1105
 
1029
- await self.outbound.send_agent_status(
1030
- agent_id,
1031
- final_status,
1032
- exit_code=rc,
1033
- conversation_id=(
1106
+ final_status_kwargs: dict[str, Any] = {
1107
+ "exit_code": rc,
1108
+ "conversation_id": (
1034
1109
  str(payload["conversation_id"])
1035
1110
  if payload.get("conversation_id")
1036
1111
  else None
1037
1112
  ),
1113
+ }
1114
+ if _effective_delivery_id:
1115
+ final_status_kwargs["delivery_id"] = _effective_delivery_id
1116
+ await self.outbound.send_agent_status(
1117
+ agent_id,
1118
+ final_status,
1119
+ **final_status_kwargs,
1038
1120
  )
1039
1121
 
1040
1122
  wake_secs = time.monotonic() - wake_start
@@ -1403,14 +1485,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1403
1485
  await self.outbound.send_ack(
1404
1486
  seq=None, message_id=message_id, status="started"
1405
1487
  )
1406
- await self.outbound.send_agent_status(
1407
- agent_id,
1408
- "running",
1409
- conversation_id=(
1488
+ running_status_kwargs: dict[str, Any] = {
1489
+ "conversation_id": (
1410
1490
  str(payload["conversation_id"])
1411
1491
  if payload.get("conversation_id")
1412
1492
  else None
1413
- ),
1493
+ )
1494
+ }
1495
+ delivery_id = payload.get("delivery_id")
1496
+ if isinstance(delivery_id, str) and delivery_id:
1497
+ running_status_kwargs["delivery_id"] = delivery_id
1498
+ await self.outbound.send_agent_status(
1499
+ agent_id,
1500
+ "running",
1501
+ **running_status_kwargs,
1414
1502
  )
1415
1503
 
1416
1504
  # 1k74 — token-level streaming. Forward the deliver's session_id
@@ -1425,6 +1513,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1425
1513
  final_text,
1426
1514
  result_usage,
1427
1515
  had_api_error,
1516
+ api_error_text,
1428
1517
  ) = await self._consume_stdout(
1429
1518
  proc,
1430
1519
  parse_mode=(
@@ -1455,12 +1544,34 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1455
1544
  final_result_text=final_text,
1456
1545
  result_usage=result_usage,
1457
1546
  had_api_error=had_api_error,
1547
+ api_error_text=api_error_text,
1458
1548
  )
1459
1549
 
1460
1550
  # ------------------------------------------------------------------
1461
1551
  # Internal helpers
1462
1552
  # ------------------------------------------------------------------
1463
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
+
1464
1575
  #: 1i Bug E — the daemon-injected ``cloudagent`` MCP toolset MUST run
1465
1576
  #: unattended in ``--print`` headless mode (otherwise claude prompts
1466
1577
  #: the user for permission, the prompt is never answered, and the
@@ -2246,7 +2357,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2246
2357
  *,
2247
2358
  partial_session_id: str | None = None,
2248
2359
  partial_agent_id: str | None = None,
2249
- ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
2360
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
2250
2361
  """Consume ``codex exec --json`` NDJSON.
2251
2362
 
2252
2363
  Codex emits ``thread.started`` with ``thread_id`` and one or more
@@ -2264,13 +2375,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2264
2375
  # codex 把上游 API 错误(模型 at capacity / 限流)作为 turn.failed/error
2265
2376
  # 事件吐在 stdout —— 记为 had_api_error,wake 层据此做瞬时错误重试。
2266
2377
  had_api_error = False
2378
+ # #114 — 同时留下引擎给的原因文本。只有一个布尔时,"限流"和"上下文
2379
+ # 撑满"在 wake 层无法区分,于是永久性失败被当成瞬时错误反复重放。
2380
+ api_error_text: str | None = None
2267
2381
  publish_enabled = bool(
2268
2382
  self._partial_publisher
2269
2383
  and partial_session_id
2270
2384
  and partial_agent_id
2271
2385
  )
2272
2386
  if proc.stdout is None:
2273
- return None, tool_uses, None, None, False
2387
+ return None, tool_uses, None, None, False, None
2274
2388
 
2275
2389
  async def _emit(event_type: str, payload: "dict[str, Any]") -> None:
2276
2390
  if (
@@ -2428,9 +2542,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2428
2542
  msg = raw if isinstance(raw, str) else None
2429
2543
  logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
2430
2544
  had_api_error = True
2545
+ # 第一条为准:后续事件通常是同一个失败的级联噪声,首条才是根因。
2546
+ if api_error_text is None and isinstance(msg, str) and msg:
2547
+ api_error_text = msg
2431
2548
 
2432
2549
  final_text = "\n\n".join(final_chunks) if final_chunks else None
2433
- 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
2434
2551
 
2435
2552
  async def _consume_stdout(
2436
2553
  self,
@@ -2439,7 +2556,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2439
2556
  parse_mode: str = "stream_json",
2440
2557
  partial_session_id: str | None = None,
2441
2558
  partial_agent_id: str | None = None,
2442
- ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
2559
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
2443
2560
  """Stream NDJSON; return ``(session_id, tool_uses, result_text, result_usage, had_api_error)``.
2444
2561
 
2445
2562
  Accepts BOTH session-init event shapes for forward/backward
@@ -2491,6 +2608,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2491
2608
  # M8 — set True when claude emits a synthetic isApiErrorMessage
2492
2609
  # assistant event (upstream API error swallowed by the CLI).
2493
2610
  had_api_error: bool = False
2611
+ # #114 — 与 codex 侧同构:保留引擎自己的错误文本供 wake 层判定原因。
2612
+ api_error_text: str | None = None
2494
2613
  # 1k74 — running buffer for token-level streaming. claude emits
2495
2614
  # one ``stream_event`` per content_block_delta (~80 chars at a
2496
2615
  # time); we concatenate them and forward the running total to
@@ -2530,7 +2649,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2530
2649
  )
2531
2650
 
2532
2651
  if proc.stdout is None:
2533
- 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
2534
2653
 
2535
2654
  async for line_b in proc.stdout:
2536
2655
  line = line_b.decode("utf-8", errors="replace").strip()
@@ -2645,6 +2764,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2645
2764
  if isinstance(t, str):
2646
2765
  err_text = t[:200]
2647
2766
  break
2767
+ if api_error_text is None and err_text:
2768
+ api_error_text = err_text
2648
2769
  logger.warning(
2649
2770
  "ClaudeRunner: stdout isApiErrorMessage event — "
2650
2771
  "wake will be marked crashed (text=%r)",
@@ -2784,7 +2905,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
2784
2905
  "claude_runner: cache metrics publish failed", exc_info=True
2785
2906
  )
2786
2907
 
2787
- 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
2788
2909
 
2789
2910
  async def _wait_with_timeout(
2790
2911
  self, proc: asyncio.subprocess.Process
@@ -637,6 +637,7 @@ async def _run_sse_post(
637
637
  # IPC op instead of reading a file; file fallback kept for backward
638
638
  # compat (old daemons without this map still work via the file).
639
639
  _agent_to_session: dict[str, str | None] = {}
640
+ _agent_to_delivery: dict[str, str | None] = {}
640
641
 
641
642
  # M7 W3 — localagent context injection + delegation reporting.
642
643
  # ``injection_state`` is the file-backed (agent,task) dedup store
@@ -657,6 +658,7 @@ async def _run_sse_post(
657
658
  partial_publisher=_publish_partial,
658
659
  event_publisher=_publish_event,
659
660
  agent_to_session=_agent_to_session,
661
+ agent_to_delivery=_agent_to_delivery,
660
662
  claude_permission_mode=cfg.claude_permission_mode,
661
663
  runtime_binaries=runtime_binaries,
662
664
  http=http,
@@ -752,11 +754,17 @@ async def _run_sse_post(
752
754
  a = req.get("args") or {}
753
755
  agent_id_q = a.get("agent_id") or ""
754
756
  sid = _agent_to_session.get(agent_id_q)
757
+ delivery_id = _agent_to_delivery.get(agent_id_q)
755
758
  logger.info(
756
- "main_loop.ipc_handler: get_current_session agent=%s sid=%s",
757
- agent_id_q, sid,
759
+ "main_loop.ipc_handler: get_current_session agent=%s sid=%s delivery=%s",
760
+ agent_id_q, sid, delivery_id,
758
761
  )
759
- return {"result": {"session_id": sid}}
762
+ return {
763
+ "result": {
764
+ "session_id": sid,
765
+ "delivery_id": delivery_id,
766
+ }
767
+ }
760
768
 
761
769
  if op != "tool_call":
762
770
  logger.warning(
@@ -981,6 +989,8 @@ async def _run_legacy_ws(
981
989
  delegation_outbox = DelegationOutbox(
982
990
  profile_delegation_outbox_path(profile)
983
991
  )
992
+ agent_to_session_ws: dict[str, str | None] = {}
993
+ agent_to_delivery_ws: dict[str, str | None] = {}
984
994
 
985
995
  runner = ClaudeRunner(
986
996
  outbound=outbound,
@@ -991,6 +1001,8 @@ async def _run_legacy_ws(
991
1001
  http=ws_http,
992
1002
  injection_state=injection_state,
993
1003
  delegation_outbox=delegation_outbox,
1004
+ agent_to_session=agent_to_session_ws,
1005
+ agent_to_delivery=agent_to_delivery_ws,
994
1006
  )
995
1007
 
996
1008
  # #25 — pre-flight auth self-check (parity with the SSE path). Detached
@@ -1071,6 +1083,15 @@ async def _run_legacy_ws(
1071
1083
  sock_path.parent.mkdir(parents=True, exist_ok=True)
1072
1084
 
1073
1085
  async def ipc_handler(req: dict) -> dict:
1086
+ if req.get("op") == "get_current_session":
1087
+ a = req.get("args") or {}
1088
+ agent_id_q = a.get("agent_id") or ""
1089
+ return {
1090
+ "result": {
1091
+ "session_id": agent_to_session_ws.get(agent_id_q),
1092
+ "delivery_id": agent_to_delivery_ws.get(agent_id_q),
1093
+ }
1094
+ }
1074
1095
  if req.get("op") != "tool_call":
1075
1096
  return {"error": {"code": "UNKNOWN_OP", "message": str(req.get("op"))}}
1076
1097
  a = req.get("args") or {}
@@ -121,6 +121,35 @@ def _read_identity(agent_id: str) -> dict:
121
121
  return {}
122
122
 
123
123
 
124
+ async def _query_daemon_context(
125
+ agent_id: str, socket_path: Path
126
+ ) -> tuple[str | None, str | None]:
127
+ """Return the daemon-owned ``(session_id, delivery_id)`` for this turn.
128
+
129
+ Old daemons return no delivery id (or reject the IPC op), which cleanly
130
+ degrades to ``(None, None)``. The delivery id is intentionally absent from
131
+ every MCP tool schema: the bridge attaches it after the model has chosen
132
+ its ordinary tool arguments.
133
+ """
134
+ try:
135
+ async with UnixSocketClient(socket_path) as ipc:
136
+ result = await ipc.call(
137
+ "get_current_session",
138
+ {"agent_id": agent_id},
139
+ timeout=5.0,
140
+ )
141
+ sid = result.get("session_id")
142
+ delivery_id = result.get("delivery_id")
143
+ return (
144
+ sid if isinstance(sid, str) and sid else None,
145
+ delivery_id
146
+ if isinstance(delivery_id, str) and delivery_id
147
+ else None,
148
+ )
149
+ except Exception: # noqa: BLE001
150
+ return None, None
151
+
152
+
124
153
  async def _query_daemon_session(
125
154
  agent_id: str, socket_path: Path
126
155
  ) -> str | None:
@@ -134,19 +163,8 @@ async def _query_daemon_session(
134
163
  Returns ``None`` on any failure (socket missing, timeout, old daemon
135
164
  without the op) so the caller can fall back to the file path.
136
165
  """
137
- try:
138
- async with UnixSocketClient(socket_path) as ipc:
139
- result = await ipc.call(
140
- "get_current_session",
141
- {"agent_id": agent_id},
142
- timeout=5.0,
143
- )
144
- sid = result.get("session_id")
145
- return sid if isinstance(sid, str) and sid else None
146
- except Exception: # noqa: BLE001
147
- # Socket missing, old daemon (UNKNOWN_OP), or timeout — fall
148
- # through to file-based path. Not an error: backward compat.
149
- return None
166
+ sid, _delivery_id = await _query_daemon_context(agent_id, socket_path)
167
+ return sid
150
168
 
151
169
 
152
170
  def _read_current_session(
@@ -190,6 +208,7 @@ async def _ipc_tool_call(
190
208
  tool_name: str,
191
209
  tool_args: dict,
192
210
  session_id: str | None,
211
+ delivery_id: str | None = None,
193
212
  ) -> dict:
194
213
  """Single round-trip: open socket → send tool_call → return result."""
195
214
  # 1k #55 — log the tool dispatch BEFORE we touch the socket so we can
@@ -200,6 +219,9 @@ async def _ipc_tool_call(
200
219
  tool_name, agent_id, session_id,
201
220
  )
202
221
  try:
222
+ effective_tool_args = dict(tool_args)
223
+ if delivery_id:
224
+ effective_tool_args["delivery_id"] = delivery_id
203
225
  async with UnixSocketClient(socket_path) as ipc:
204
226
  logger.info(
205
227
  "mcp_bridge: ipc connected socket=%s tool=%s agent=%s session=%s",
@@ -211,7 +233,7 @@ async def _ipc_tool_call(
211
233
  "agent_id": agent_id,
212
234
  "session_id": session_id,
213
235
  "tool_name": tool_name,
214
- "tool_args": tool_args,
236
+ "tool_args": effective_tool_args,
215
237
  },
216
238
  )
217
239
  logger.info(
@@ -227,11 +249,30 @@ async def _ipc_tool_call(
227
249
  raise
228
250
 
229
251
 
252
+ # Resolve the module-level helper at CALL time, not import time.
253
+ #
254
+ # ``build_server``'s provenance-stamping closure shadows the name
255
+ # ``_ipc_tool_call`` locally, so it needs another way to reach the module
256
+ # helper. A plain alias (``_raw_ipc_tool_call = _ipc_tool_call``) binds the
257
+ # original function object once, at import — which silently defeats every
258
+ # test that does ``monkeypatch.setattr(bridge, "_ipc_tool_call", fake)``:
259
+ # the patch rebinds the module attribute, the frozen alias keeps pointing at
260
+ # the real implementation, and the "unit" test goes off and opens a real unix
261
+ # socket. That is exactly how 10 tests started failing with FileNotFoundError.
262
+ #
263
+ # Looking the attribute up through the module at call time keeps the closure
264
+ # working AND lets the patch take effect. Production behaviour is unchanged:
265
+ # unpatched, this returns the same function the alias used to hold.
266
+ def _module_ipc_tool_call(): # type: ignore[no-untyped-def]
267
+ return sys.modules[__name__]._ipc_tool_call # type: ignore[attr-defined,no-any-return]
268
+
269
+
230
270
  def build_server(
231
271
  agent_id: str,
232
272
  socket_path: Path = DEFAULT_SOCKET_PATH,
233
273
  identity: dict | None = None,
234
274
  session_id: str | None = None,
275
+ delivery_id: str | None = None,
235
276
  tool_profile: str = "full",
236
277
  ) -> FastMCP:
237
278
  """Construct a FastMCP server filtered by identity and tool profile.
@@ -269,6 +310,25 @@ def build_server(
269
310
 
270
311
  server = FastMCP(name=f"cloudagent-bridge-{agent_id}")
271
312
 
313
+ # Keep the opaque provenance out of every public MCP schema. The nested
314
+ # wrapper shadows the module helper for all tool closures below and stamps
315
+ # the daemon-provided value only after the model's arguments are built.
316
+ async def _ipc_tool_call( # type: ignore[no-redef]
317
+ socket_path: Path,
318
+ agent_id: str,
319
+ tool_name: str,
320
+ tool_args: dict,
321
+ session_id: str | None,
322
+ ) -> dict:
323
+ return await _module_ipc_tool_call()(
324
+ socket_path,
325
+ agent_id,
326
+ tool_name,
327
+ tool_args,
328
+ session_id,
329
+ delivery_id,
330
+ )
331
+
272
332
  # ---- Common tools (all agents) ----------------------------------------
273
333
 
274
334
  @server.tool(
@@ -924,7 +984,9 @@ async def run_bridge(
924
984
  # freshly-written current-session.txt). Fall back to file on any
925
985
  # failure so old daemons without the ``get_current_session`` op
926
986
  # keep working.
927
- current_session_id = await _query_daemon_session(agent_id, socket_path)
987
+ current_session_id, current_delivery_id = await _query_daemon_context(
988
+ agent_id, socket_path
989
+ )
928
990
  if current_session_id is not None:
929
991
  logger.info(
930
992
  "mcp_bridge: agent=%s current_session=%s (via socket IPC)",
@@ -951,6 +1013,7 @@ async def run_bridge(
951
1013
  socket_path=socket_path,
952
1014
  identity=identity,
953
1015
  session_id=current_session_id,
1016
+ delivery_id=current_delivery_id,
954
1017
  tool_profile=tool_profile,
955
1018
  )
956
1019
  logger.info(
@@ -156,6 +156,7 @@ class OutboundQueue:
156
156
  status: str,
157
157
  exit_code: int | None = None,
158
158
  conversation_id: str | None = None,
159
+ delivery_id: str | None = None,
159
160
  ) -> None:
160
161
  """Send an ``agent_status`` frame."""
161
162
  payload: dict = {"agent_id": agent_id, "status": status}
@@ -163,6 +164,8 @@ class OutboundQueue:
163
164
  payload["exit_code"] = exit_code
164
165
  if conversation_id:
165
166
  payload["conversation_id"] = conversation_id
167
+ if delivery_id:
168
+ payload["delivery_id"] = delivery_id
166
169
 
167
170
  await self.send_raw({
168
171
  "v": 1,