cli-agent-runner 0.2.9__tar.gz → 0.2.10__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 (273) hide show
  1. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.vulture-whitelist.py +2 -0
  2. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/CHANGELOG.md +12 -0
  3. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/PKG-INFO +1 -1
  4. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_emit.py +7 -1
  5. cli_agent_runner-0.2.10/agent_runner/_throttle.py +220 -0
  6. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_version.py +2 -2
  7. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/agent_runtime.py +14 -12
  8. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/api.py +7 -7
  9. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/api_types.py +2 -0
  10. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/claude_rate_limit.py +6 -4
  11. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/codewhale.py +6 -3
  12. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/gemini.py +5 -3
  13. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/kimi.py +5 -3
  14. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/pi.py +3 -2
  15. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/events_cmd.py +3 -3
  16. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/peek_cmd.py +2 -2
  17. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/serve_cmd.py +132 -40
  18. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/service_cmd.py +3 -4
  19. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/upgrade_cmd.py +10 -10
  20. cli_agent_runner-0.2.10/agent_runner/clock.py +79 -0
  21. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/events.py +5 -3
  22. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/http_progress.py +1 -0
  23. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/metrics.py +2 -2
  24. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/monitor.py +3 -3
  25. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/phase_select.py +30 -12
  26. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/remote_relay.py +3 -3
  27. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/runner.py +11 -9
  28. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/schedule.py +5 -4
  29. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/vcs_state.py +4 -3
  30. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/configuration.md +5 -1
  31. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/migrations/0.2.md +20 -0
  32. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/runbook.md +15 -1
  33. cli_agent_runner-0.2.10/tests/_clock.py +43 -0
  34. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_architecture.py +3 -1
  35. cli_agent_runner-0.2.10/tests/invariants/test_no_raw_time.py +136 -0
  36. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_phase_select_stateless.py +11 -8
  37. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_observation.py +1 -1
  38. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_claude_error_detector.py +6 -6
  39. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_events.py +2 -4
  40. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_events_cmd_tail.py +2 -2
  41. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_gemini_plugin.py +2 -2
  42. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_kimi_plugin.py +1 -1
  43. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_metrics.py +2 -7
  44. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_phase_select.py +47 -0
  45. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_pi_plugin.py +1 -1
  46. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_runner_throttle.py +115 -27
  47. cli_agent_runner-0.2.10/tests/unit/test_serve_phase_schedule.py +454 -0
  48. cli_agent_runner-0.2.9/agent_runner/_throttle.py +0 -142
  49. cli_agent_runner-0.2.9/tests/unit/test_serve_phase_schedule.py +0 -193
  50. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.codecov.yml +0 -0
  51. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  52. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  53. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  55. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/workflows/ci.yml +0 -0
  56. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.github/workflows/release.yml +0 -0
  57. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/.gitignore +0 -0
  58. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/BACKLOG.md +0 -0
  59. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/CODE_OF_CONDUCT.md +0 -0
  60. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/CONTRIBUTING.md +0 -0
  61. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/LICENSE +0 -0
  62. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/README.md +0 -0
  63. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/README.zh.md +0 -0
  64. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/SECURITY.md +0 -0
  65. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/__init__.py +0 -0
  66. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_docgen.py +0 -0
  67. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_redact.py +0 -0
  68. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_registry.py +0 -0
  69. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/_substrate.py +0 -0
  70. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/__init__.py +0 -0
  71. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/_constants.py +0 -0
  72. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/builtin_plugins/default_dirty_handler.py +0 -0
  73. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/__init__.py +0 -0
  74. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/__main__.py +0 -0
  75. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/common.py +0 -0
  76. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/init_cmd.py +0 -0
  77. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/install_cmd.py +0 -0
  78. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/migrate_cmd.py +0 -0
  79. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/monitor_cmd.py +0 -0
  80. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/cli/round_cmd.py +0 -0
  81. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/config.py +0 -0
  82. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/context_store.py +0 -0
  83. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/defenses.py +0 -0
  84. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/hooks.py +0 -0
  85. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/lifecycle.py +0 -0
  86. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/migrations.py +0 -0
  87. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/__init__.py +0 -0
  88. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/aider.toml +0 -0
  89. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/claude.toml +0 -0
  90. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/codewhale.toml +0 -0
  91. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/gemini.toml +0 -0
  92. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/kimi.toml +0 -0
  93. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/presets/pi.toml +0 -0
  94. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/prompt_loader.py +0 -0
  95. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/round_log.py +0 -0
  96. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/round_view.py +0 -0
  97. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/scaffold.py +0 -0
  98. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/service_unit.py +0 -0
  99. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/agent_runner/startup_check.py +0 -0
  100. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/build.sh +0 -0
  101. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/README.md +0 -0
  102. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/architecture.md +0 -0
  103. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/commands.md +0 -0
  104. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/events.md +0 -0
  105. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/long-running-agents.md +0 -0
  106. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/plugins.md +0 -0
  107. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/quickstart.md +0 -0
  108. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/recipes/aider.md +0 -0
  109. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/recipes/codewhale.md +0 -0
  110. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/recipes/kimi.md +0 -0
  111. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/recipes/pi.md +0 -0
  112. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/docs/thesis.md +0 -0
  113. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/pyproject.toml +0 -0
  114. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/__init__.py +0 -0
  115. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/_test_helpers.py +0 -0
  116. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/conftest.py +0 -0
  117. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/contract/__init__.py +0 -0
  118. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/contract/test_public_api_surface.py +0 -0
  119. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/__init__.py +0 -0
  120. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/conftest.py +0 -0
  121. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  122. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/test_e2e_install_systemd.py +0 -0
  123. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  124. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  125. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  126. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  127. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  128. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/generate_vulture_whitelist.py +0 -0
  129. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/__init__.py +0 -0
  130. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_bounded_run.py +0 -0
  131. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_context_enricher_namespacing.py +0 -0
  132. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_dirty_handler_seam.py +0 -0
  133. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_fresh_eyes_signal.py +0 -0
  134. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_grace_kill_emission.py +0 -0
  135. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_install_dry_run.py +0 -0
  136. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_monitor_seeded.py +0 -0
  137. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_plugin_detector_loaded.py +0 -0
  138. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_plugin_owned_paths.py +0 -0
  139. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_plugin_real_flow.py +0 -0
  140. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_prompt_delivery_stdin.py +0 -0
  141. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  142. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_scaffold_presets.py +0 -0
  143. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_serve_loop.py +0 -0
  144. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_substrate_fingerprint.py +0 -0
  145. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/integration/test_transient_error_backoff.py +0 -0
  146. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/__init__.py +0 -0
  147. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/_docs.py +0 -0
  148. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/_event_scan.py +0 -0
  149. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_atomic_write_enforced.py +0 -0
  150. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_authored_facts_declared.py +0 -0
  151. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_auto_stop_policy_ssot.py +0 -0
  152. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_catalogs.py +0 -0
  153. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_classification_ssot.py +0 -0
  154. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_config_error_consistency.py +0 -0
  155. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_config_value_set_ssot.py +0 -0
  156. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_builtin_plugins.py +0 -0
  157. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  158. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_cli_claims.py +0 -0
  159. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_operator_surface.py +0 -0
  160. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_test_pointers_exist.py +0 -0
  161. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_transient_error_claims.py +0 -0
  162. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_doc_usage_event_fields.py +0 -0
  163. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_docs_generated.py +0 -0
  164. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_docs_index_complete.py +0 -0
  165. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_entry_points_resolve.py +0 -0
  166. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_event_kind_registry.py +0 -0
  167. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_event_kinds_ssot.py +0 -0
  168. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_events_doc_contract.py +0 -0
  169. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_gen_blocks_name_their_source.py +0 -0
  170. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_hook_contract_docs.py +0 -0
  171. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_layer_2_loop_size.py +0 -0
  172. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_module_boundaries.py +0 -0
  173. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_module_sizes.py +0 -0
  174. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_no_ai_signatures.py +0 -0
  175. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  176. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_peek_schema_version.py +0 -0
  177. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_plugins_config_stable.py +0 -0
  178. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_readme_zh_is_pointer.py +0 -0
  179. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  180. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_round_result_stable.py +0 -0
  181. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_set_diff_for_auto_tool_classification.py +0 -0
  182. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  183. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_upstream_schema_canary.py +0 -0
  184. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/invariants/test_vulture_whitelist_generated.py +0 -0
  185. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/literate/__init__.py +0 -0
  186. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/literate/parser.py +0 -0
  187. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/literate/test_parser.py +0 -0
  188. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/literate/test_quickstart.py +0 -0
  189. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/__init__.py +0 -0
  190. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_agent_runtime.py +0 -0
  191. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_agent_runtime_grace.py +0 -0
  192. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_agent_runtime_progress.py +0 -0
  193. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_agent_runtime_signal_name.py +0 -0
  194. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_assemble_prompt.py +0 -0
  195. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_events_stream.py +0 -0
  196. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_install.py +0 -0
  197. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_read_round_num.py +0 -0
  198. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_resolve_phase.py +0 -0
  199. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_service.py +0 -0
  200. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_api_types.py +0 -0
  201. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_auto_stop_gating.py +0 -0
  202. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_catalogs.py +0 -0
  203. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli.py +0 -0
  204. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli_common.py +0 -0
  205. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli_init_install.py +0 -0
  206. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli_monitor_http.py +0 -0
  207. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  208. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_cli_upgrade.py +0 -0
  209. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_codewhale_plugin.py +0 -0
  210. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config.py +0 -0
  211. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_fresh_eyes.py +0 -0
  212. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_max_rounds.py +0 -0
  213. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_prompt_delivery.py +0 -0
  214. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_schedule.py +0 -0
  215. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_stop_file.py +0 -0
  216. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  217. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_config_transient_error_action.py +0 -0
  218. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_context_store.py +0 -0
  219. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_default_dirty_handler.py +0 -0
  220. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_defenses.py +0 -0
  221. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_detector_protocol.py +0 -0
  222. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_dirty_handlers.py +0 -0
  223. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_docgen.py +0 -0
  224. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_emit_config_migrated.py +0 -0
  225. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_emit_schedule.py +0 -0
  226. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_emit_schedule_phase_skipped.py +0 -0
  227. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_events_cmd.py +0 -0
  228. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_example_dirty_handler.py +0 -0
  229. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_example_plugin.py +0 -0
  230. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_exit_cause.py +0 -0
  231. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  232. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_hook_failure_isolation.py +0 -0
  233. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_hooks.py +0 -0
  234. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_http_progress.py +0 -0
  235. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_init_entry_points.py +0 -0
  236. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_lifecycle.py +0 -0
  237. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_migrate_cmd.py +0 -0
  238. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_migrations.py +0 -0
  239. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_assembly.py +0 -0
  240. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  241. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  242. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  243. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_detectors.py +0 -0
  244. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_remote.py +0 -0
  245. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_monitor_schedule_pause.py +0 -0
  246. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_network_blip_signal_guard.py +0 -0
  247. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_peek_argparse.py +0 -0
  248. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_peek_schedule.py +0 -0
  249. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_peek_select.py +0 -0
  250. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_phase_agent_launch.py +0 -0
  251. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_phase_profiles.py +0 -0
  252. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_plugin_constants.py +0 -0
  253. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_presets.py +0 -0
  254. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_prompt_loader.py +0 -0
  255. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_redact.py +0 -0
  256. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_round_log_helpers.py +0 -0
  257. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_round_view.py +0 -0
  258. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_runner.py +0 -0
  259. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_scaffold.py +0 -0
  260. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_schedule.py +0 -0
  261. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_cmd_bounded.py +0 -0
  262. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_config_broken.py +0 -0
  263. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_crash_loop.py +0 -0
  264. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_round_log.py +0 -0
  265. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_schedule_gate.py +0 -0
  266. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_sentinel.py +0 -0
  267. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_serve_startup_hooks.py +0 -0
  268. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_service_unit.py +0 -0
  269. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_startup_check.py +0 -0
  270. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_substrate.py +0 -0
  271. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_throttle_plugin_classification.py +0 -0
  272. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_upgrade_migrate.py +0 -0
  273. {cli_agent_runner-0.2.9 → cli_agent_runner-0.2.10}/tests/unit/test_vcs_state.py +0 -0
@@ -57,6 +57,7 @@ throttled_until_epoch
57
57
  limit_type
58
58
  agent
59
59
  since_round
60
+ phase
60
61
 
61
62
  # agent_runner.api_types.RoundResult
62
63
  round_num
@@ -106,6 +107,7 @@ reset_at_epoch
106
107
  classification
107
108
  agent
108
109
  since_round
110
+ phase
109
111
 
110
112
  # agent_runner.config.AgentConfig
111
113
  command
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.10] - 2026-08-29
9
+
10
+ ### Added
11
+ - Throttle-aware skip: under `[phases] phase_policy = "skip"`, a phase whose provider is currently rate-limited/erroring is stepped over in the rotation — a healthy sibling runs instead of the whole loop sleeping on the throttle. No new config; the trigger is the existing `skip` policy.
12
+ - `transient_error_detected` now carries the failing `phase`; `peek --json` `rate_limit` and the HTTP progress state surface which phase is throttled.
13
+ - `transient_error_recovered` is emitted when a skip-around throttle clears (events-derived, restart-safe, no double-emit with the back-off path).
14
+
15
+ ### Notes
16
+ - Only `skip` routes around a throttle; `wait`, `--ignore-schedule`, and non-`[phases]` configs keep the 0.2.9 global back-off. When every phase is throttled or window-closed, the loop waits until the earlier of a window opening or the throttle resetting.
17
+
18
+ See `docs/migrations/0.2.md`.
19
+
8
20
  ## [0.2.9] - 2026-08-28
9
21
 
10
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-agent-runner
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Restart-on-exit supervisor for autonomous CLI agents
5
5
  Project-URL: Homepage, https://github.com/wan9yu/cli-agent-runner
6
6
  Project-URL: Documentation, https://github.com/wan9yu/cli-agent-runner#readme
@@ -188,8 +188,13 @@ def emit_transient_error_detected(
188
188
  reset_at_epoch: int,
189
189
  round_num: int,
190
190
  raw: str,
191
+ phase: str = "",
191
192
  ) -> None:
192
- """Emit detection of a transient agent error (rate limit / 5xx / timeout)."""
193
+ """Emit detection of a transient agent error (rate limit / 5xx / timeout).
194
+
195
+ ``phase`` is the rotation phase the failing round ran under ("" when the
196
+ config has no ``[phases]``). It lets a ``phase_policy = "skip"`` serve loop
197
+ step over *this* phase while a healthy sibling keeps running."""
193
198
  from agent_runner._redact import redact_secrets
194
199
  from agent_runner.events import TRANSIENT_ERROR_DETECTED, emit
195
200
 
@@ -202,6 +207,7 @@ def emit_transient_error_detected(
202
207
  reset_at_epoch=reset_at_epoch,
203
208
  round_num=round_num,
204
209
  raw=raw,
210
+ phase=phase,
205
211
  )
206
212
 
207
213
 
@@ -0,0 +1,220 @@
1
+ """Throttle state helpers — read events.jsonl tail for transient error state.
2
+
3
+ Internal module. Callers: runner.py (serve loop back-off), api.py (peek).
4
+ Separated from runner.py to satisfy the ouroboros defense: runner.py writes
5
+ events.jsonl but must never read it back (§3 module boundary invariant).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import UTC
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agent_runner.api_types import TransientErrorState
16
+ from agent_runner.clock import SYSTEM_CLOCK, Clock
17
+ from agent_runner.events import (
18
+ TRANSIENT_ERROR_DETECTED,
19
+ TRANSIENT_ERROR_RECOVERED,
20
+ parse_iso_ms,
21
+ )
22
+
23
+ # _scan_events_for_transient sentinel: file held no transient event at all
24
+ # (distinct from "latest transient was a recovered" → None).
25
+ _NO_TRANSIENT = object()
26
+
27
+
28
+ def _scan_events_for_transient(path: Path):
29
+ """The file's LAST transient event: the detected dict, ``None`` (latest was a
30
+ recovered), or ``_NO_TRANSIENT`` (no transient event in the file).
31
+
32
+ Forward single-pass, O(1) memory — it keeps only the latest transient event,
33
+ never a 100-line tail. That matters for throttle-aware skip: the loop keeps
34
+ emitting rounds on healthy phases during a throttle, so the ``detected`` event
35
+ can be thousands of lines back; a bounded tail would scroll it out and make the
36
+ supervisor forget the throttle mid-window."""
37
+ latest: Any = _NO_TRANSIENT
38
+ with path.open() as f:
39
+ for line in f:
40
+ line = line.strip()
41
+ if not line:
42
+ continue
43
+ try:
44
+ ev = json.loads(line)
45
+ except json.JSONDecodeError:
46
+ continue
47
+ kind = ev.get("event")
48
+ if kind == TRANSIENT_ERROR_DETECTED:
49
+ latest = ev
50
+ elif kind == TRANSIENT_ERROR_RECOVERED:
51
+ latest = None
52
+ return latest
53
+
54
+
55
+ def _latest_unrecovered_detected(log_dir: Path) -> dict[str, Any] | None:
56
+ """The most recent ``transient_error_detected`` with no
57
+ ``transient_error_recovered`` after it, or None. Shared by
58
+ :func:`_check_throttle_state` (still-throttled?) and :func:`pending_recovered`
59
+ (cleared-without-a-breadcrumb?).
60
+
61
+ Scans the newest monthly ``events-*.jsonl`` and, only if it holds no transient
62
+ event yet, the previous month's — so a throttle that spans a month boundary
63
+ (detected in the old file, cleared in the new) is not orphaned."""
64
+ candidates = sorted(log_dir.glob("events-*.jsonl"))
65
+ for path in reversed(candidates[-2:]):
66
+ result = _scan_events_for_transient(path)
67
+ if result is not _NO_TRANSIENT:
68
+ return result # a detected dict, or None (latest transient was recovered)
69
+ return None
70
+
71
+
72
+ def _check_throttle_state(
73
+ log_dir: Path, *, clock: Clock = SYSTEM_CLOCK
74
+ ) -> TransientErrorState | None:
75
+ """Scan events.jsonl tail for latest unmatched transient error.
76
+
77
+ Reads `transient_error_detected` / `transient_error_recovered` event names.
78
+ Returns TransientErrorState if currently throttled (reset still in future,
79
+ no matching recovered after). Restart-safe.
80
+
81
+ ``clock`` supplies the wall clock the reset is compared against — inject a
82
+ ``FakeClock`` in tests to pin an exact instant. Only the reset comparison
83
+ reads it; the event scan is pure.
84
+ """
85
+ now = clock.epoch()
86
+ latest_detected = _latest_unrecovered_detected(log_dir)
87
+ if latest_detected is None:
88
+ return None
89
+ reset_at = int(latest_detected.get("reset_at_epoch", 0))
90
+ if reset_at <= now:
91
+ return None # Reset already passed without recovery emit; treat as recovered
92
+
93
+ classification = str(latest_detected.get("classification", "rate_limit_account"))
94
+
95
+ return TransientErrorState(
96
+ reset_at_epoch=reset_at,
97
+ classification=classification,
98
+ agent=str(latest_detected.get("agent", "unknown")),
99
+ since_round=int(latest_detected.get("round_num", 0)),
100
+ phase=str(latest_detected.get("phase", "")),
101
+ )
102
+
103
+
104
+ def _throttled_for_s(ts: Any, *, clock: Clock = SYSTEM_CLOCK) -> int:
105
+ """Seconds since a detected event's ``ts`` (best-effort; 0 if unparseable).
106
+ ``clock`` supplies now; inject a ``FakeClock`` for exact-value tests."""
107
+ if not ts:
108
+ return 0
109
+ try:
110
+ detected = parse_iso_ms(str(ts)) # events.py owns the Z→+00:00 workaround
111
+ except (ValueError, TypeError):
112
+ return 0
113
+ if detected.tzinfo is None:
114
+ detected = detected.replace(tzinfo=UTC) # hand-injected naive ts → treat as UTC
115
+ return max(0, int(clock.epoch() - detected.timestamp()))
116
+
117
+
118
+ def pending_recovered(log_dir: Path, *, clock: Clock = SYSTEM_CLOCK) -> tuple[str, str, int] | None:
119
+ """``(classification, agent, throttled_for_s)`` iff a transient throttle
120
+ cleared WITHOUT a recovered breadcrumb — the latest
121
+ ``transient_error_detected`` has ``reset_at <= now`` and NO
122
+ ``transient_error_recovered`` after it.
123
+
124
+ This is the throttle-aware-skip path: the loop rotated to a healthy phase
125
+ instead of calling ``_apply_back_off`` (which emits its own recovered), so
126
+ the multi-hour throttle would otherwise close with no breadcrumb. Being
127
+ events-derived, it is restart-safe AND never double-emits — once the caller
128
+ emits recovered, that event sits after the detected one and the predicate
129
+ goes quiet. Returns None while still throttled (reset in the future) or when
130
+ the back-off path already left a recovered."""
131
+ now = clock.epoch()
132
+ latest_detected = _latest_unrecovered_detected(log_dir)
133
+ if latest_detected is None:
134
+ return None
135
+ if int(latest_detected.get("reset_at_epoch", 0)) > now:
136
+ return None # still throttled — not a clear transition
137
+ return (
138
+ str(latest_detected.get("classification", "rate_limit_account")),
139
+ str(latest_detected.get("agent", "unknown")),
140
+ _throttled_for_s(latest_detected.get("ts"), clock=clock),
141
+ )
142
+
143
+
144
+ # Module-level supervisor state — bucket → consecutive-failure count.
145
+ # Cleared by reset_counters() or by serve restart.
146
+ _consecutive_failures: dict[str, int] = {}
147
+
148
+
149
+ def compute_adjusted_reset_at(
150
+ *,
151
+ classification: str,
152
+ original_reset_at_epoch: int,
153
+ agent: str,
154
+ log_dir: Path,
155
+ clock: Clock = SYSTEM_CLOCK,
156
+ ) -> tuple[int, int, bool]:
157
+ """Apply exp backoff for estimated-class transient errors.
158
+
159
+ Returns (applied_reset_at_epoch, consecutive_count, capped_by_absolute_max).
160
+
161
+ For server-authoritative classification (``rate_limit_account``): returns
162
+ the original reset epoch verbatim, never increments the counter, and
163
+ never emits an adjustment event. Anthropic's resetsAt is authoritative.
164
+
165
+ For any classification absent from ``_BACK_OFF_DEFAULTS`` — i.e. one a
166
+ third-party plugin defined itself, which ``api_types.py`` types
167
+ ``classification`` as ``str`` to permit — the same verbatim path applies:
168
+ core has no base duration for it, so the emitter's reset_at_epoch is the
169
+ only non-invented answer.
170
+
171
+ For estimated classifications (``rate_limit_model``, ``api_transient_5xx``,
172
+ ``api_timeout``): increments the counter for this bucket, computes
173
+ duration = base × 2^min(n, _EXP_CAP), caps at _ABSOLUTE_CAP_S, emits
174
+ ``transient_error_backoff_capped`` if multiplier > 1 or capped.
175
+ """
176
+ from agent_runner._emit import emit_transient_error_backoff_capped
177
+ from agent_runner.builtin_plugins._constants import (
178
+ _ABSOLUTE_CAP_S,
179
+ _BACK_OFF_DEFAULTS,
180
+ _EXP_CAP,
181
+ )
182
+
183
+ if classification == "rate_limit_account" or classification not in _BACK_OFF_DEFAULTS:
184
+ # Server-authoritative, or a plugin's own classification (api_types.py types
185
+ # `classification` as str precisely so plugins can add their own): the emitter
186
+ # supplied reset_at_epoch, so respect it verbatim and never touch the counter.
187
+ return (original_reset_at_epoch, 0, False)
188
+
189
+ # Estimated class: apply exp backoff.
190
+ base = _BACK_OFF_DEFAULTS[classification]
191
+ n = _consecutive_failures.get(classification, 0)
192
+ multiplier = 2 ** min(n, _EXP_CAP)
193
+ extended_duration = base * multiplier
194
+ capped_by_absolute_max = extended_duration > _ABSOLUTE_CAP_S
195
+ applied_duration = min(extended_duration, _ABSOLUTE_CAP_S)
196
+ applied_reset_at = int(clock.epoch()) + applied_duration
197
+
198
+ new_count = n + 1
199
+ _consecutive_failures[classification] = new_count
200
+
201
+ # Emit observability event when supervisor adjusted the wait.
202
+ if multiplier > 1 or capped_by_absolute_max:
203
+ emit_transient_error_backoff_capped(
204
+ log_dir,
205
+ classification=classification,
206
+ agent=agent,
207
+ requested_sleep_s=int(base),
208
+ applied_sleep_s=applied_duration,
209
+ original_reset_at_epoch=original_reset_at_epoch,
210
+ applied_reset_at_epoch=applied_reset_at,
211
+ consecutive_count=new_count,
212
+ capped_by_absolute_max=capped_by_absolute_max,
213
+ )
214
+
215
+ return (applied_reset_at, new_count, capped_by_absolute_max)
216
+
217
+
218
+ def reset_counters() -> None:
219
+ """Clear all bucket counters. Called by serve loop when no active throttle."""
220
+ _consecutive_failures.clear()
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.9'
22
- __version_tuple__ = version_tuple = (0, 2, 9)
21
+ __version__ = version = '0.2.10'
22
+ __version_tuple__ = version_tuple = (0, 2, 10)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,13 +15,14 @@ import shutil
15
15
  import signal
16
16
  import subprocess # noqa: TID251 — sanctioned subprocess caller
17
17
  import threading
18
- import time
19
18
  from collections.abc import Callable
20
19
  from dataclasses import dataclass, field
21
20
  from pathlib import Path
22
21
 
23
22
  import psutil
24
23
 
24
+ from agent_runner.clock import SYSTEM_CLOCK, Clock
25
+
25
26
  REAP_GRACE_S = 5
26
27
 
27
28
 
@@ -68,15 +69,15 @@ def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str)
68
69
  return list(command) + [a.replace("{prompt}", prompt) for a in prompt_arg_template]
69
70
 
70
71
 
71
- def _kill_pgroup(proc: subprocess.Popen) -> None:
72
+ def _kill_pgroup(proc: subprocess.Popen, clock: Clock = SYSTEM_CLOCK) -> None:
72
73
  pgid = proc.pid
73
74
  try:
74
75
  os.killpg(pgid, signal.SIGTERM)
75
76
  except OSError:
76
77
  pass
77
- deadline = time.time() + REAP_GRACE_S
78
- while time.time() < deadline and proc.poll() is None:
79
- time.sleep(0.1)
78
+ deadline = clock.epoch() + REAP_GRACE_S
79
+ while clock.epoch() < deadline and proc.poll() is None:
80
+ clock.sleep(0.1)
80
81
  try:
81
82
  os.killpg(pgid, signal.SIGKILL)
82
83
  except OSError:
@@ -173,6 +174,7 @@ def run(
173
174
  progress_interval_s: int = 0,
174
175
  on_grace_extended: Callable[[list[dict], list[dict]], None] | None = None,
175
176
  grace_kill_ignore_patterns: list[re.Pattern[str]] | None = None,
177
+ clock: Clock = SYSTEM_CLOCK,
176
178
  ) -> RunResult:
177
179
  """Spawn the agent subprocess and wait for exit or timeout.
178
180
 
@@ -213,7 +215,7 @@ def run(
213
215
  env = {**os.environ, **env_extra, "PWD": str(work_dir)}
214
216
  log_path.parent.mkdir(parents=True, exist_ok=True)
215
217
  log_file = log_path.open("w", encoding="utf-8")
216
- start = time.time()
218
+ start = clock.epoch()
217
219
  last_progress_at = start
218
220
  proc = subprocess.Popen(
219
221
  argv,
@@ -246,13 +248,13 @@ def run(
246
248
  try:
247
249
  while True:
248
250
  ret = proc.poll()
249
- now = time.time()
251
+ now = clock.epoch()
250
252
  if ret is not None:
251
253
  duration = now - start
252
254
  return RunResult(exit_code=ret, duration_s=duration, timed_out=False, pid=proc.pid)
253
255
  if now - start > timeout_s:
254
- _kill_pgroup(proc)
255
- duration = time.time() - start
256
+ _kill_pgroup(proc, clock)
257
+ duration = clock.epoch() - start
256
258
  exit_code = proc.returncode if proc.returncode is not None else -1
257
259
  return RunResult(
258
260
  exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
@@ -276,8 +278,8 @@ def run(
276
278
  on_grace_extended(live, ignored)
277
279
  grace_extended_emitted = True
278
280
  else:
279
- _kill_pgroup(proc)
280
- duration = time.time() - start
281
+ _kill_pgroup(proc, clock)
282
+ duration = clock.epoch() - start
281
283
  exit_code = proc.returncode if proc.returncode is not None else -1
282
284
  return RunResult(
283
285
  exit_code=exit_code,
@@ -305,6 +307,6 @@ def run(
305
307
  }
306
308
  )
307
309
  last_progress_at = now
308
- time.sleep(0.2)
310
+ clock.sleep(0.2)
309
311
  finally:
310
312
  log_file.close()
@@ -15,7 +15,6 @@ import shutil
15
15
  import signal
16
16
  import subprocess # noqa: TID251 — api uses systemctl + ssh, both subprocess
17
17
  import sysconfig
18
- import time
19
18
  from collections.abc import Iterator, Sequence
20
19
  from pathlib import Path
21
20
  from typing import Any, Literal, TextIO
@@ -30,6 +29,7 @@ from agent_runner.api_types import (
30
29
  ServiceStatus,
31
30
  select_path,
32
31
  )
32
+ from agent_runner.clock import SYSTEM_CLOCK
33
33
  from agent_runner.config import Config, RuntimeConfig, load_config
34
34
  from agent_runner.lifecycle import (
35
35
  PIDFile,
@@ -323,13 +323,13 @@ def kill(project: str | Path) -> ServiceStatus:
323
323
  if pid is None:
324
324
  return status(project)
325
325
  send_signal_to_pid(pid, signal.SIGTERM)
326
- deadline = time.time() + 5
326
+ deadline = SYSTEM_CLOCK.epoch() + 5
327
327
  alive = True
328
- while time.time() < deadline:
328
+ while SYSTEM_CLOCK.epoch() < deadline:
329
329
  alive = pid_alive(pid)
330
330
  if not alive:
331
331
  break
332
- time.sleep(0.1)
332
+ SYSTEM_CLOCK.sleep(0.1)
333
333
  if alive:
334
334
  send_signal_to_pid(pid, signal.SIGKILL)
335
335
  return ServiceStatus(mode=ServiceMode.PID_FILE, active=alive, pid=pid)
@@ -448,6 +448,7 @@ def peek(
448
448
  limit_type=throttle.classification,
449
449
  agent=throttle.agent,
450
450
  since_round=throttle.since_round,
451
+ phase=throttle.phase,
451
452
  )
452
453
  raw_service = status(project if project is not None else work_dir)
453
454
  svc = dataclasses.replace(raw_service, rate_limit=rate_limit)
@@ -567,7 +568,7 @@ def _monitor_loop_iter(
567
568
  log_dir=cfg.runtime.log_dir,
568
569
  allowed_stop_names=cfg.monitor.auto_stop_on,
569
570
  )
570
- time.sleep(interval_s)
571
+ SYSTEM_CLOCK.sleep(interval_s)
571
572
 
572
573
 
573
574
  def _tail_events_jsonl(
@@ -586,7 +587,6 @@ def _tail_events_jsonl(
586
587
  appears, it is picked up from byte 0.
587
588
  """
588
589
  import json as _json
589
- import time as _time
590
590
 
591
591
  seen_positions: dict[Path, int] = {}
592
592
  if start_at_now:
@@ -620,7 +620,7 @@ def _tail_events_jsonl(
620
620
  any_new = True
621
621
  seen_positions[path] = f.tell()
622
622
  if not any_new:
623
- _time.sleep(poll_interval_s)
623
+ SYSTEM_CLOCK.sleep(poll_interval_s)
624
624
 
625
625
 
626
626
  def _primary_prompt_file(cfg: Config) -> Path | None:
@@ -48,6 +48,7 @@ class RateLimitState:
48
48
  limit_type: str
49
49
  agent: str
50
50
  since_round: int
51
+ phase: str = "" # which rotation phase is throttled ("" = no [phases])
51
52
 
52
53
 
53
54
  @dataclass(frozen=True)
@@ -151,6 +152,7 @@ class TransientErrorState:
151
152
  classification: str
152
153
  agent: str
153
154
  since_round: int
155
+ phase: str = "" # rotation phase the failing round ran under ("" = no [phases])
154
156
 
155
157
 
156
158
  @dataclass(frozen=True)
@@ -14,7 +14,6 @@ rate-limit detector was generalized to multi-classification in 0.1.23
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import time
18
17
  from pathlib import Path
19
18
  from typing import Any
20
19
 
@@ -29,6 +28,7 @@ from agent_runner.builtin_plugins._constants import (
29
28
  classify_transient_status,
30
29
  json_events,
31
30
  )
31
+ from agent_runner.clock import SYSTEM_CLOCK
32
32
  from agent_runner.hooks import HookContext, register_post_round_hook
33
33
 
34
34
 
@@ -51,7 +51,9 @@ class ClaudeErrorDetector:
51
51
 
52
52
  if parsed.get("transient_error"):
53
53
  te = parsed["transient_error"]
54
- emit_transient_error_detected(ctx.log_dir, round_num=ctx.round_num, **te)
54
+ emit_transient_error_detected(
55
+ ctx.log_dir, round_num=ctx.round_num, phase=ctx.phase or "", **te
56
+ )
55
57
 
56
58
  if parsed.get("usage"):
57
59
  emit_agent_usage_recorded(
@@ -175,7 +177,7 @@ def _classify_transient_error(
175
177
  return {
176
178
  "classification": "rate_limit_account",
177
179
  "agent": "claude",
178
- "reset_at_epoch": int(rate_limit_info.get("resetsAt", time.time() + 300)),
180
+ "reset_at_epoch": int(rate_limit_info.get("resetsAt", SYSTEM_CLOCK.epoch() + 300)),
179
181
  "raw": str((result_event or {}).get("result", ""))[:_RAW_CAP],
180
182
  }
181
183
  # rate_limit_event with null/other rateLimitType falls through to status-based
@@ -230,7 +232,7 @@ def _classify(classification: str, raw: str) -> dict[str, Any]:
230
232
  return {
231
233
  "classification": classification,
232
234
  "agent": "claude",
233
- "reset_at_epoch": int(time.time() + duration),
235
+ "reset_at_epoch": int(SYSTEM_CLOCK.epoch() + duration),
234
236
  "raw": raw,
235
237
  }
236
238
 
@@ -12,7 +12,6 @@ is added when a real rate-limit sample is captured.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- import time
16
15
  from pathlib import Path
17
16
  from typing import Any
18
17
 
@@ -26,6 +25,7 @@ from agent_runner.builtin_plugins._constants import (
26
25
  classify_transient_status,
27
26
  json_events,
28
27
  )
28
+ from agent_runner.clock import SYSTEM_CLOCK
29
29
  from agent_runner.hooks import HookContext, register_post_round_hook
30
30
 
31
31
 
@@ -43,7 +43,10 @@ class CodewhaleErrorDetector:
43
43
  parsed = _parse_codewhale_log(log_path)
44
44
  if parsed.get("transient_error"):
45
45
  emit_transient_error_detected(
46
- ctx.log_dir, round_num=ctx.round_num, **parsed["transient_error"]
46
+ ctx.log_dir,
47
+ round_num=ctx.round_num,
48
+ phase=ctx.phase or "",
49
+ **parsed["transient_error"],
47
50
  )
48
51
  if parsed.get("usage"):
49
52
  emit_agent_usage_recorded(
@@ -92,7 +95,7 @@ def _parse_codewhale_log(log_path: Path) -> dict[str, Any]:
92
95
  out["transient_error"] = {
93
96
  "classification": classification,
94
97
  "agent": "codewhale",
95
- "reset_at_epoch": int(time.time() + duration),
98
+ "reset_at_epoch": int(SYSTEM_CLOCK.epoch() + duration),
96
99
  "raw": str(error_event.get("error", "error"))[:_RAW_CAP],
97
100
  }
98
101
  return out
@@ -7,7 +7,6 @@ agent_usage_recorded event families without any agent-runner core changes.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import time
11
10
  from pathlib import Path
12
11
  from typing import Any
13
12
 
@@ -21,6 +20,7 @@ from agent_runner.builtin_plugins._constants import (
21
20
  classify_transient_status,
22
21
  json_events,
23
22
  )
23
+ from agent_runner.clock import SYSTEM_CLOCK
24
24
  from agent_runner.hooks import HookContext, register_post_round_hook
25
25
 
26
26
 
@@ -38,7 +38,9 @@ class GeminiErrorDetector:
38
38
  parsed = _parse_gemini_log(log_path)
39
39
  if parsed.get("transient_error"):
40
40
  te = parsed["transient_error"]
41
- emit_transient_error_detected(ctx.log_dir, round_num=ctx.round_num, **te)
41
+ emit_transient_error_detected(
42
+ ctx.log_dir, round_num=ctx.round_num, phase=ctx.phase or "", **te
43
+ )
42
44
  if parsed.get("usage"):
43
45
  emit_agent_usage_recorded(
44
46
  ctx.log_dir,
@@ -82,7 +84,7 @@ def _parse_gemini_log(log_path: Path) -> dict[str, Any]:
82
84
  out["transient_error"] = {
83
85
  "classification": classification,
84
86
  "agent": "gemini",
85
- "reset_at_epoch": int(time.time() + duration),
87
+ "reset_at_epoch": int(SYSTEM_CLOCK.epoch() + duration),
86
88
  "raw": raw,
87
89
  }
88
90
  return out
@@ -30,7 +30,6 @@ retry record -- they arrive as plain text on stderr, e.g.
30
30
 
31
31
  from __future__ import annotations
32
32
 
33
- import time
34
33
  from pathlib import Path
35
34
  from typing import Any
36
35
 
@@ -41,6 +40,7 @@ from agent_runner.builtin_plugins._constants import (
41
40
  classify_transient_status,
42
41
  json_events,
43
42
  )
43
+ from agent_runner.clock import SYSTEM_CLOCK
44
44
  from agent_runner.hooks import HookContext, register_post_round_hook
45
45
 
46
46
 
@@ -62,7 +62,9 @@ class KimiErrorDetector:
62
62
  return
63
63
  transient_error = _parse_kimi_log(log_path)
64
64
  if transient_error:
65
- emit_transient_error_detected(ctx.log_dir, round_num=ctx.round_num, **transient_error)
65
+ emit_transient_error_detected(
66
+ ctx.log_dir, round_num=ctx.round_num, phase=ctx.phase or "", **transient_error
67
+ )
66
68
 
67
69
 
68
70
  def _parse_kimi_log(log_path: Path) -> dict[str, Any] | None:
@@ -85,7 +87,7 @@ def _parse_kimi_log(log_path: Path) -> dict[str, Any] | None:
85
87
  return {
86
88
  "classification": classification,
87
89
  "agent": "kimi",
88
- "reset_at_epoch": int(time.time() + _BACK_OFF_DEFAULTS[classification]),
90
+ "reset_at_epoch": int(SYSTEM_CLOCK.epoch() + _BACK_OFF_DEFAULTS[classification]),
89
91
  "raw": str(retry_event.get("error_message", "retrying"))[:_RAW_CAP],
90
92
  }
91
93
 
@@ -57,7 +57,6 @@ from __future__ import annotations
57
57
 
58
58
  import json
59
59
  import re
60
- import time
61
60
  from datetime import datetime
62
61
  from itertools import islice
63
62
  from pathlib import Path
@@ -74,6 +73,7 @@ from agent_runner.builtin_plugins._constants import (
74
73
  classify_transient_status,
75
74
  json_events,
76
75
  )
76
+ from agent_runner.clock import SYSTEM_CLOCK
77
77
  from agent_runner.hooks import HookContext, register_post_round_hook
78
78
 
79
79
  _STATUS_RE = re.compile(r"^\s*(\d{3})\b")
@@ -118,8 +118,9 @@ class PiErrorDetector:
118
118
  round_num=ctx.round_num,
119
119
  classification=classification,
120
120
  agent="pi",
121
- reset_at_epoch=int(time.time() + _BACK_OFF_DEFAULTS[classification]),
121
+ reset_at_epoch=int(SYSTEM_CLOCK.epoch() + _BACK_OFF_DEFAULTS[classification]),
122
122
  raw=str(error_text)[:_RAW_CAP],
123
+ phase=ctx.phase or "",
123
124
  )
124
125
  elif _is_auth_error(error_text):
125
126
  # No transient classification on purpose — an auth failure is
@@ -18,10 +18,10 @@ import argparse
18
18
  import json
19
19
  import signal
20
20
  import sys
21
- import time
22
21
  from datetime import UTC, datetime
23
22
  from pathlib import Path
24
23
 
24
+ from agent_runner.clock import SYSTEM_CLOCK
25
25
  from agent_runner.events import parse_iso_ms
26
26
 
27
27
  # Sentinel for "user did not explicitly set --window" so we can detect
@@ -160,7 +160,7 @@ def cmd_events(args) -> int:
160
160
 
161
161
 
162
162
  def _current_month_events_file(log_dir: Path) -> Path:
163
- month = datetime.now(UTC).strftime("%Y-%m")
163
+ month = SYSTEM_CLOCK.now_utc().strftime("%Y-%m")
164
164
  return log_dir / f"events-{month}.jsonl"
165
165
 
166
166
 
@@ -308,6 +308,6 @@ def _tail_events(log_dir: Path, kind_set: set[str], since: datetime | None = Non
308
308
  elif size < last_size:
309
309
  # File truncated / rotated underneath us; reset
310
310
  last_size = 0
311
- time.sleep(1.0)
311
+ SYSTEM_CLOCK.sleep(1.0)
312
312
  except KeyboardInterrupt:
313
313
  return 0