cli-agent-runner 0.2.6__tar.gz → 0.2.7__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 (280) hide show
  1. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.vulture-whitelist.py +18 -0
  2. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/CHANGELOG.md +12 -0
  3. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/PKG-INFO +2 -2
  4. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_docgen.py +2 -0
  5. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_emit.py +24 -0
  6. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_version.py +2 -2
  7. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/api.py +3 -0
  8. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/api_types.py +1 -0
  9. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/serve_cmd.py +72 -0
  10. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/config.py +43 -0
  11. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/events.py +2 -0
  12. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/monitor.py +52 -1
  13. cli_agent_runner-0.2.7/agent_runner/schedule.py +178 -0
  14. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/architecture.md +2 -0
  15. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/configuration.md +80 -0
  16. cli_agent_runner-0.2.7/docs/migrations/0.2.7.md +121 -0
  17. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/runbook.md +47 -1
  18. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/_test_helpers.py +2 -0
  19. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_architecture.py +3 -0
  20. cli_agent_runner-0.2.7/tests/unit/test_config_schedule.py +65 -0
  21. cli_agent_runner-0.2.7/tests/unit/test_emit_schedule.py +32 -0
  22. cli_agent_runner-0.2.7/tests/unit/test_monitor_schedule_pause.py +103 -0
  23. cli_agent_runner-0.2.7/tests/unit/test_peek_schedule.py +35 -0
  24. cli_agent_runner-0.2.7/tests/unit/test_schedule.py +183 -0
  25. cli_agent_runner-0.2.7/tests/unit/test_serve_schedule_gate.py +143 -0
  26. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.codecov.yml +0 -0
  27. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  28. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/workflows/ci.yml +0 -0
  32. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.github/workflows/release.yml +0 -0
  33. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/.gitignore +0 -0
  34. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/BACKLOG.md +0 -0
  35. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/CODE_OF_CONDUCT.md +0 -0
  36. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/CONTRIBUTING.md +0 -0
  37. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/LICENSE +0 -0
  38. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/README.md +0 -0
  39. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/README.zh.md +0 -0
  40. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/SECURITY.md +0 -0
  41. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/__init__.py +0 -0
  42. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_redact.py +0 -0
  43. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_registry.py +0 -0
  44. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_substrate.py +0 -0
  45. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/_throttle.py +0 -0
  46. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/agent_runtime.py +0 -0
  47. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/__init__.py +0 -0
  48. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/_constants.py +0 -0
  49. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/claude_rate_limit.py +0 -0
  50. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/codewhale.py +0 -0
  51. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/default_dirty_handler.py +0 -0
  52. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/gemini.py +0 -0
  53. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/kimi.py +0 -0
  54. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/builtin_plugins/pi.py +0 -0
  55. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/__init__.py +0 -0
  56. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/__main__.py +0 -0
  57. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/common.py +0 -0
  58. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/events_cmd.py +0 -0
  59. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/init_cmd.py +0 -0
  60. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/install_cmd.py +0 -0
  61. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/monitor_cmd.py +0 -0
  62. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/peek_cmd.py +0 -0
  63. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/round_cmd.py +0 -0
  64. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/service_cmd.py +0 -0
  65. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/cli/upgrade_cmd.py +0 -0
  66. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/context_store.py +0 -0
  67. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/defenses.py +0 -0
  68. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/hooks.py +0 -0
  69. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/http_progress.py +0 -0
  70. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/lifecycle.py +0 -0
  71. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/metrics.py +0 -0
  72. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/__init__.py +0 -0
  73. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/aider.toml +0 -0
  74. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/claude.toml +0 -0
  75. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/codewhale.toml +0 -0
  76. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/gemini.toml +0 -0
  77. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/kimi.toml +0 -0
  78. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/presets/pi.toml +0 -0
  79. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/prompt_loader.py +0 -0
  80. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/remote_relay.py +0 -0
  81. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/round_log.py +0 -0
  82. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/round_view.py +0 -0
  83. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/runner.py +0 -0
  84. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/scaffold.py +0 -0
  85. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/service_unit.py +0 -0
  86. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/startup_check.py +0 -0
  87. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/agent_runner/vcs_state.py +0 -0
  88. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/build.sh +0 -0
  89. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/README.md +0 -0
  90. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/commands.md +0 -0
  91. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/events.md +0 -0
  92. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/long-running-agents.md +0 -0
  93. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.16.md +0 -0
  94. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.17.md +0 -0
  95. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.19.md +0 -0
  96. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.20.md +0 -0
  97. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.21.md +0 -0
  98. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.22.md +0 -0
  99. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.23.md +0 -0
  100. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.24.md +0 -0
  101. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.25.md +0 -0
  102. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.26.md +0 -0
  103. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.27.md +0 -0
  104. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.28.md +0 -0
  105. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.29.md +0 -0
  106. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.30.md +0 -0
  107. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.31.md +0 -0
  108. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.32.md +0 -0
  109. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.33.md +0 -0
  110. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.34.md +0 -0
  111. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.35.md +0 -0
  112. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.36.md +0 -0
  113. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.37.md +0 -0
  114. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.38.md +0 -0
  115. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.39.md +0 -0
  116. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.40.md +0 -0
  117. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.1.42.md +0 -0
  118. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.0.md +0 -0
  119. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.1.md +0 -0
  120. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.2.md +0 -0
  121. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.3.md +0 -0
  122. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.4.md +0 -0
  123. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.5.md +0 -0
  124. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/migrations/0.2.6.md +0 -0
  125. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/plugins.md +0 -0
  126. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/quickstart.md +0 -0
  127. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/recipes/aider.md +0 -0
  128. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/recipes/codewhale.md +0 -0
  129. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/recipes/kimi.md +0 -0
  130. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/recipes/pi.md +0 -0
  131. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/docs/thesis.md +0 -0
  132. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/pyproject.toml +0 -0
  133. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/__init__.py +0 -0
  134. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/conftest.py +0 -0
  135. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/contract/__init__.py +0 -0
  136. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/contract/test_public_api_surface.py +0 -0
  137. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/__init__.py +0 -0
  138. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/conftest.py +0 -0
  139. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  140. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/test_e2e_install_systemd.py +0 -0
  141. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  142. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  143. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  144. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  145. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  146. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/generate_vulture_whitelist.py +0 -0
  147. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/__init__.py +0 -0
  148. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_bounded_run.py +0 -0
  149. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_context_enricher_namespacing.py +0 -0
  150. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_dirty_handler_seam.py +0 -0
  151. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_fresh_eyes_signal.py +0 -0
  152. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_grace_kill_emission.py +0 -0
  153. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_install_dry_run.py +0 -0
  154. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_monitor_seeded.py +0 -0
  155. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_plugin_detector_loaded.py +0 -0
  156. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_plugin_owned_paths.py +0 -0
  157. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_plugin_real_flow.py +0 -0
  158. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_prompt_delivery_stdin.py +0 -0
  159. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  160. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_scaffold_presets.py +0 -0
  161. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_serve_loop.py +0 -0
  162. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_substrate_fingerprint.py +0 -0
  163. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/integration/test_transient_error_backoff.py +0 -0
  164. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/__init__.py +0 -0
  165. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/_event_scan.py +0 -0
  166. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_atomic_write_enforced.py +0 -0
  167. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_auto_stop_policy_ssot.py +0 -0
  168. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_catalogs.py +0 -0
  169. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_classification_ssot.py +0 -0
  170. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_config_error_consistency.py +0 -0
  171. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_config_value_set_ssot.py +0 -0
  172. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_doc_builtin_plugins.py +0 -0
  173. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_doc_claims_match_ssot.py +0 -0
  174. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_doc_cli_claims.py +0 -0
  175. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_doc_operator_surface.py +0 -0
  176. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_doc_transient_error_claims.py +0 -0
  177. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_docs_generated.py +0 -0
  178. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_docs_index_complete.py +0 -0
  179. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_entry_points_resolve.py +0 -0
  180. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_event_kind_registry.py +0 -0
  181. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_event_kinds_ssot.py +0 -0
  182. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_events_doc_contract.py +0 -0
  183. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_hook_contract_docs.py +0 -0
  184. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_layer_2_loop_size.py +0 -0
  185. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_module_boundaries.py +0 -0
  186. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_module_sizes.py +0 -0
  187. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_no_ai_signatures.py +0 -0
  188. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  189. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_peek_schema_version.py +0 -0
  190. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_plugins_config_stable.py +0 -0
  191. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_readme_zh_is_pointer.py +0 -0
  192. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  193. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_round_result_stable.py +0 -0
  194. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_set_diff_for_auto_tool_classification.py +0 -0
  195. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  196. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_upstream_schema_canary.py +0 -0
  197. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/invariants/test_vulture_whitelist_generated.py +0 -0
  198. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/literate/__init__.py +0 -0
  199. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/literate/parser.py +0 -0
  200. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/literate/test_parser.py +0 -0
  201. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/literate/test_quickstart.py +0 -0
  202. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/__init__.py +0 -0
  203. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_agent_runtime.py +0 -0
  204. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_agent_runtime_grace.py +0 -0
  205. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_agent_runtime_progress.py +0 -0
  206. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_agent_runtime_signal_name.py +0 -0
  207. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_assemble_prompt.py +0 -0
  208. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_events_stream.py +0 -0
  209. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_install.py +0 -0
  210. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_observation.py +0 -0
  211. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_read_round_num.py +0 -0
  212. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_resolve_phase.py +0 -0
  213. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_service.py +0 -0
  214. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_api_types.py +0 -0
  215. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_auto_stop_gating.py +0 -0
  216. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_catalogs.py +0 -0
  217. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_claude_error_detector.py +0 -0
  218. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli.py +0 -0
  219. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli_common.py +0 -0
  220. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli_init_install.py +0 -0
  221. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli_monitor_http.py +0 -0
  222. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  223. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_cli_upgrade.py +0 -0
  224. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_codewhale_plugin.py +0 -0
  225. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config.py +0 -0
  226. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_fresh_eyes.py +0 -0
  227. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_max_rounds.py +0 -0
  228. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_prompt_delivery.py +0 -0
  229. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_stop_file.py +0 -0
  230. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  231. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_config_transient_error_action.py +0 -0
  232. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_context_store.py +0 -0
  233. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_default_dirty_handler.py +0 -0
  234. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_defenses.py +0 -0
  235. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_detector_protocol.py +0 -0
  236. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_dirty_handlers.py +0 -0
  237. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_docgen.py +0 -0
  238. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_events.py +0 -0
  239. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_events_cmd.py +0 -0
  240. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_events_cmd_tail.py +0 -0
  241. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_exit_cause.py +0 -0
  242. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  243. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_gemini_plugin.py +0 -0
  244. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_hook_failure_isolation.py +0 -0
  245. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_hooks.py +0 -0
  246. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_http_progress.py +0 -0
  247. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_init_entry_points.py +0 -0
  248. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_kimi_plugin.py +0 -0
  249. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_lifecycle.py +0 -0
  250. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_metrics.py +0 -0
  251. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_assembly.py +0 -0
  252. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  253. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  254. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  255. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_detectors.py +0 -0
  256. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_monitor_remote.py +0 -0
  257. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_network_blip_signal_guard.py +0 -0
  258. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_peek_argparse.py +0 -0
  259. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_peek_select.py +0 -0
  260. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_pi_plugin.py +0 -0
  261. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_plugin_constants.py +0 -0
  262. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_presets.py +0 -0
  263. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_prompt_loader.py +0 -0
  264. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_redact.py +0 -0
  265. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_round_log_helpers.py +0 -0
  266. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_round_view.py +0 -0
  267. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_runner.py +0 -0
  268. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_runner_throttle.py +0 -0
  269. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_scaffold.py +0 -0
  270. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_cmd_bounded.py +0 -0
  271. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_config_broken.py +0 -0
  272. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_crash_loop.py +0 -0
  273. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_round_log.py +0 -0
  274. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_sentinel.py +0 -0
  275. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_serve_startup_hooks.py +0 -0
  276. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_service_unit.py +0 -0
  277. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_startup_check.py +0 -0
  278. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_substrate.py +0 -0
  279. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_throttle_plugin_classification.py +0 -0
  280. {cli_agent_runner-0.2.6 → cli_agent_runner-0.2.7}/tests/unit/test_vcs_state.py +0 -0
@@ -50,6 +50,7 @@ service
50
50
  recent_events
51
51
  recent_hook_failures
52
52
  recent_blips
53
+ schedule
53
54
 
54
55
  # agent_runner.api_types.RateLimitState
55
56
  throttled_until_epoch
@@ -121,6 +122,7 @@ vcs
121
122
  monitor
122
123
  phases
123
124
  plugins
125
+ schedule
124
126
 
125
127
  # agent_runner.config.MonitorConfig
126
128
  auth_fail_patterns
@@ -176,6 +178,11 @@ dry_run
176
178
  max_grace_after_result_s
177
179
  grace_kill_ignore_patterns
178
180
 
181
+ # agent_runner.config.ScheduleConfig
182
+ timezone
183
+ run_windows
184
+ pause_windows
185
+
179
186
  # agent_runner.config.VcsConfig
180
187
  stash_idempotency_s
181
188
  dirty_action
@@ -233,6 +240,17 @@ deleted
233
240
  deferred
234
241
  existing
235
242
 
243
+ # agent_runner.schedule.PauseDecision
244
+ paused
245
+ resume_at
246
+ active_window
247
+
248
+ # agent_runner.schedule.Window
249
+ start_min
250
+ end_min
251
+ label
252
+ days
253
+
236
254
  # agent_runner.startup_check.CheckResult
237
255
  name
238
256
  ok
@@ -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.7] - 2026-08-25
9
+
10
+ ### Added
11
+ - `[schedule]` config gates the serve loop between rounds: `pause_windows` / `run_windows` decide whether the next round launches now or the supervisor idle-sleeps until a window opens, then auto-resumes. Windows are `[WEEKDAYS ]HH:MM-HH:MM` (24h, end-exclusive, midnight-wrap; optional `Mon-Fri` / `Sat,Sun` weekday prefix, bare = every day). Timezone-aware (`timezone`, IANA; omit → host local). Evaluation is `run AND NOT pause`. Absent section → unchanged 7×24 behavior. An in-flight round is never interrupted; `agent-runner round` is never gated.
12
+ - `schedule_paused` / `schedule_resumed` events; `peek` surfaces the current pause state (`schedule` field).
13
+ - `serve --ignore-schedule` runs rounds regardless of the windows (catch-up / testing).
14
+
15
+ ### Fixed
16
+ - Monitor no longer raises `supervisor_stale` during an intentional schedule pause (suppressed until `resume_at` plus one staleness window).
17
+
18
+ See `docs/migrations/0.2.7.md`.
19
+
8
20
  ## [0.2.6] - 2026-07-28
9
21
 
10
22
  ### Changed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: cli-agent-runner
3
- Version: 0.2.6
3
+ Version: 0.2.7
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
@@ -22,6 +22,7 @@ from agent_runner.config import (
22
22
  PluginsConfig,
23
23
  PromptConfig,
24
24
  RuntimeConfig,
25
+ ScheduleConfig,
25
26
  VcsConfig,
26
27
  )
27
28
  from agent_runner.defenses import catalog
@@ -45,6 +46,7 @@ _SECTIONS: list[tuple[str, type, list[tuple[str, type]]]] = [
45
46
  ("monitor", MonitorConfig, [("monitor.host_health", MonitorHostHealthConfig)]),
46
47
  ("phases", PhasesConfig, []),
47
48
  ("plugins", PluginsConfig, []),
49
+ ("schedule", ScheduleConfig, []),
48
50
  ]
49
51
 
50
52
 
@@ -26,6 +26,8 @@ __all__ = [
26
26
  "emit_round_progress",
27
27
  "emit_round_substrate_after",
28
28
  "emit_round_substrate_before",
29
+ "emit_schedule_paused",
30
+ "emit_schedule_resumed",
29
31
  "emit_stop_file_detected",
30
32
  "emit_transient_error_backoff_capped",
31
33
  "emit_transient_error_detected",
@@ -85,6 +87,28 @@ def emit_stop_file_detected(
85
87
  )
86
88
 
87
89
 
90
+ def emit_schedule_paused(
91
+ log_dir: Path, *, active_window: str, resume_at: str, timezone: str
92
+ ) -> None:
93
+ """Emit schedule_paused when the serve loop enters a configured pause window."""
94
+ from agent_runner.events import SCHEDULE_PAUSED, emit
95
+
96
+ emit(
97
+ log_dir,
98
+ SCHEDULE_PAUSED,
99
+ active_window=active_window,
100
+ resume_at=resume_at,
101
+ timezone=timezone,
102
+ )
103
+
104
+
105
+ def emit_schedule_resumed(log_dir: Path, *, paused_for_s: int) -> None:
106
+ """Emit schedule_resumed when the serve loop exits a pause window."""
107
+ from agent_runner.events import SCHEDULE_RESUMED, emit
108
+
109
+ emit(log_dir, SCHEDULE_RESUMED, paused_for_s=paused_for_s)
110
+
111
+
88
112
  def emit_round_substrate_before(
89
113
  log_dir: Path, *, round_num: int, git_head: str | None, paths_hash: str | None
90
114
  ) -> None:
@@ -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.6'
22
- __version_tuple__ = version_tuple = (0, 2, 6)
21
+ __version__ = version = '0.2.7'
22
+ __version_tuple__ = version_tuple = (0, 2, 7)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -473,6 +473,7 @@ def peek(
473
473
  recent_events=recent,
474
474
  recent_hook_failures=recent_hook_failures,
475
475
  recent_blips=recent_blips,
476
+ schedule=monitor.latest_schedule_state(parsed_events),
476
477
  )
477
478
  return state if select is None else select_path(state, select)
478
479
 
@@ -746,6 +747,8 @@ from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-ex
746
747
  emit_round_progress,
747
748
  emit_round_substrate_after,
748
749
  emit_round_substrate_before,
750
+ emit_schedule_paused,
751
+ emit_schedule_resumed,
749
752
  emit_stop_file_detected,
750
753
  emit_transient_error_backoff_capped,
751
754
  emit_transient_error_detected,
@@ -98,6 +98,7 @@ class ProjectState:
98
98
  recent_events: list[dict[str, Any]] = field(default_factory=list)
99
99
  recent_hook_failures: list[dict[str, Any]] = field(default_factory=list)
100
100
  recent_blips: list[dict[str, Any]] = field(default_factory=list)
101
+ schedule: dict | None = None
101
102
 
102
103
 
103
104
  @dataclass(frozen=True)
@@ -17,6 +17,7 @@ import sys
17
17
  import time
18
18
  from pathlib import Path
19
19
 
20
+ from agent_runner import schedule
20
21
  from agent_runner._substrate import compute_git_head, compute_paths_hash
21
22
  from agent_runner._throttle import _check_throttle_state
22
23
  from agent_runner._throttle import reset_counters as _reset_counters
@@ -30,6 +31,8 @@ from agent_runner.api import (
30
31
  emit_round_logs_prune_deferred,
31
32
  emit_round_substrate_after,
32
33
  emit_round_substrate_before,
34
+ emit_schedule_paused,
35
+ emit_schedule_resumed,
33
36
  emit_stop_file_detected,
34
37
  post_round_decision,
35
38
  )
@@ -58,6 +61,68 @@ def _resolve_max_rounds(*, cli_value: int | None, config_value: int | None) -> i
58
61
  return effective
59
62
 
60
63
 
64
+ def _maybe_pause_for_schedule(
65
+ cfg,
66
+ log_dir,
67
+ stop,
68
+ *,
69
+ now_fn=schedule.now_in_zone,
70
+ sleep_fn=time.sleep,
71
+ chunk_s: int = 30,
72
+ ) -> bool:
73
+ """If the current time is outside the run schedule, pause until it opens.
74
+
75
+ Returns True if a pause was entered (caller should ``continue`` so the
76
+ top-of-loop guards re-run), False if runnable now. During a pause, sleeps
77
+ in <= chunk_s slices so SIGTERM / SIGINT (which set stop["requested"]) lands
78
+ within one slice.
79
+
80
+ We do NOT check the self-terminate sentinel here: no round runs during a
81
+ pause, so no new sentinel can appear, and any pre-existing one already broke
82
+ the loop at its top before this gate. schedule_resumed is emitted ONLY when
83
+ the window actually opens — an interrupted pause (stop) is a termination, not
84
+ a resume, and correctly leaves schedule_paused as the newest event."""
85
+ sched = cfg.schedule
86
+ if not sched.enabled:
87
+ return False
88
+ decision = schedule.evaluate(
89
+ run_windows=sched.run_windows,
90
+ pause_windows=sched.pause_windows,
91
+ now_local=now_fn(sched.timezone),
92
+ )
93
+ if not decision.paused:
94
+ return False
95
+
96
+ started = time.monotonic()
97
+ emit_schedule_paused(
98
+ log_dir,
99
+ active_window=decision.active_window or "",
100
+ resume_at=decision.resume_at.isoformat() if decision.resume_at else "",
101
+ timezone=sched.timezone or "local",
102
+ )
103
+ stop_file = cfg.runtime.stop_file
104
+ window_opened = False
105
+ while not stop["requested"]:
106
+ if stop_file is not None and stop_file.exists():
107
+ # Operator stop_file dropped mid-pause: break without emitting
108
+ # schedule_resumed (the window did not open). The serve loop's
109
+ # top-of-loop stop_file check then emits stop_file_detected and exits.
110
+ break
111
+ # #3: should_run (not evaluate) avoids the 8-day next_resume_at scan on
112
+ # every poll; should_run is the negation of the pre-loop paused decision.
113
+ if schedule.should_run(
114
+ now_fn(sched.timezone),
115
+ run_windows=sched.run_windows,
116
+ pause_windows=sched.pause_windows,
117
+ ):
118
+ window_opened = True
119
+ break
120
+ sleep_fn(chunk_s)
121
+ if window_opened:
122
+ emit_schedule_resumed(log_dir, paused_for_s=int(time.monotonic() - started))
123
+ return True
124
+
125
+
61
126
  def _prune_serve_round_logs(log_dir: Path, retention: int) -> None:
62
127
  """Prune the serve-level ``round-<N>.log`` family; report a deferred prune.
63
128
 
@@ -101,6 +166,11 @@ def add_parser(sub, parent) -> None:
101
166
  p = sub.add_parser("serve", parents=[parent], help="Long-running supervisor loop")
102
167
  p.add_argument("--once", action="store_true", help="Run a single round then exit (debug)")
103
168
  _add_max_rounds_arg(p)
169
+ p.add_argument(
170
+ "--ignore-schedule",
171
+ action="store_true",
172
+ help="Run rounds regardless of [schedule] pause/run windows (testing / catch-up)",
173
+ )
104
174
  p.set_defaults(func=cmd)
105
175
 
106
176
 
@@ -177,6 +247,8 @@ def cmd(args) -> int:
177
247
  max_rounds=effective_max_rounds,
178
248
  )
179
249
  break
250
+ if not args.ignore_schedule and _maybe_pause_for_schedule(cfg, log_dir, stop):
251
+ continue
180
252
  round_num = next_round_num(log_dir)
181
253
  git_head_before = compute_git_head(work_dir)
182
254
  paths_hash_before = compute_paths_hash(
@@ -8,6 +8,8 @@ from dataclasses import dataclass, field
8
8
  from pathlib import Path
9
9
  from typing import Any, Literal
10
10
 
11
+ from agent_runner import schedule
12
+
11
13
  _VALID_INJECTION_MODES: frozenset[str] = frozenset({"prepend", "file", "none"})
12
14
  _VALID_DIRTY_ACTIONS: frozenset[str] = frozenset({"stash", "ignore", "auto_commit"})
13
15
  _VALID_TRANSIENT_ERROR_ACTIONS: frozenset[str] = frozenset({"back_off", "skip", "stop"})
@@ -168,6 +170,17 @@ class MonitorConfig:
168
170
  """
169
171
 
170
172
 
173
+ @dataclass(frozen=True)
174
+ class ScheduleConfig:
175
+ timezone: str | None = None # IANA name; None = host local time
176
+ run_windows: tuple[schedule.Window, ...] = ()
177
+ pause_windows: tuple[schedule.Window, ...] = ()
178
+
179
+ @property
180
+ def enabled(self) -> bool:
181
+ return bool(self.run_windows or self.pause_windows)
182
+
183
+
171
184
  @dataclass(frozen=True)
172
185
  class Config:
173
186
  agent: AgentConfig
@@ -177,6 +190,7 @@ class Config:
177
190
  monitor: MonitorConfig = field(default_factory=MonitorConfig)
178
191
  phases: PhasesConfig = field(default_factory=PhasesConfig)
179
192
  plugins: PluginsConfig = field(default_factory=PluginsConfig)
193
+ schedule: ScheduleConfig = field(default_factory=ScheduleConfig)
180
194
 
181
195
 
182
196
  def _require(d: dict, *path: str) -> object:
@@ -357,6 +371,32 @@ def _parse_fresh_eyes_every_n(runtime_d: dict) -> int | None:
357
371
  return _require_positive_int(raw, field="runtime.fresh_eyes_every_n")
358
372
 
359
373
 
374
+ def _parse_schedule(schedule_d: dict) -> ScheduleConfig:
375
+ tz = schedule_d.get("timezone")
376
+ if tz is not None:
377
+ tz = str(tz)
378
+ if not schedule.valid_timezone(tz):
379
+ raise ConfigError(f"schedule.timezone: unknown IANA zone {tz!r}")
380
+
381
+ def _parse_list(key: str) -> tuple[schedule.Window, ...]:
382
+ raw = schedule_d.get(key, [])
383
+ if not isinstance(raw, list):
384
+ raise ConfigError(f"schedule.{key} must be a list of window strings")
385
+ out = []
386
+ for w in raw:
387
+ try:
388
+ out.append(schedule.parse_window(str(w)))
389
+ except ValueError as e:
390
+ raise ConfigError(f"schedule.{key}: {e}") from e
391
+ return tuple(out)
392
+
393
+ return ScheduleConfig(
394
+ timezone=tz,
395
+ run_windows=_parse_list("run_windows"),
396
+ pause_windows=_parse_list("pause_windows"),
397
+ )
398
+
399
+
360
400
  def load_config(toml_path: Path) -> Config:
361
401
  if not toml_path.exists():
362
402
  raise FileNotFoundError(f"config not found: {toml_path}")
@@ -571,6 +611,8 @@ def load_config(toml_path: Path) -> Config:
571
611
  disable = list(plugins_raw.pop("disable", []))
572
612
  plugins = PluginsConfig(disable=disable, raw=plugins_raw)
573
613
 
614
+ schedule_cfg = _parse_schedule(raw.get("schedule", {}))
615
+
574
616
  cfg = Config(
575
617
  agent=agent,
576
618
  runtime=runtime,
@@ -579,6 +621,7 @@ def load_config(toml_path: Path) -> Config:
579
621
  monitor=monitor,
580
622
  phases=phases_cfg,
581
623
  plugins=plugins,
624
+ schedule=schedule_cfg,
582
625
  )
583
626
 
584
627
  # Honor [plugins] disable — must happen after registries are populated by
@@ -61,6 +61,8 @@ ROUND_START = "round_start"
61
61
  ROUND_SUBSTRATE_AFTER = "round_substrate_after"
62
62
  ROUND_SUBSTRATE_BEFORE = "round_substrate_before"
63
63
  ROUND_TIMEOUT_KILL = "round_timeout_kill"
64
+ SCHEDULE_PAUSED = "schedule_paused"
65
+ SCHEDULE_RESUMED = "schedule_resumed"
64
66
  SELF_TERMINATED = "agent_self_terminated"
65
67
  SERVE_STARTUP_HOOK_FAILED = "serve_startup_hook_failed"
66
68
  SERVICE_UPGRADE_ROLLBACK_FAILED = "service_upgrade_rollback_failed"
@@ -22,7 +22,7 @@ import time
22
22
  from collections import deque
23
23
  from collections.abc import Iterable
24
24
  from dataclasses import dataclass
25
- from datetime import UTC, datetime
25
+ from datetime import UTC, datetime, timedelta
26
26
  from pathlib import Path
27
27
  from typing import Any, Protocol
28
28
 
@@ -48,6 +48,8 @@ from agent_runner.events import (
48
48
  ORPHAN_STASHED,
49
49
  ROUND_END,
50
50
  ROUND_START,
51
+ SCHEDULE_PAUSED,
52
+ SCHEDULE_RESUMED,
51
53
  TRANSIENT_ERROR_DETECTED,
52
54
  TRANSIENT_ERROR_RECOVERED,
53
55
  now_iso_ms,
@@ -447,6 +449,16 @@ def detect_anomaly_repetitive_active(
447
449
  )
448
450
 
449
451
 
452
+ def _latest_schedule_event(events: list[dict[str, Any]]) -> dict[str, Any] | None:
453
+ """Newest schedule_paused/schedule_resumed event by stream order, or None.
454
+
455
+ Reverse-walk with early break — events grows unboundedly over a project's life."""
456
+ for e in reversed(events):
457
+ if e.get("event") in (SCHEDULE_PAUSED, SCHEDULE_RESUMED):
458
+ return e
459
+ return None
460
+
461
+
450
462
  def detect_supervisor_stale(
451
463
  events: list[dict[str, Any]],
452
464
  *,
@@ -471,6 +483,30 @@ def detect_supervisor_stale(
471
483
  age_s = (now - parse_iso_ms(last_ts_str)).total_seconds()
472
484
  if age_s <= stale_threshold_s:
473
485
  return None
486
+
487
+ newest = _latest_schedule_event(events)
488
+ if newest is not None and newest.get("event") == SCHEDULE_PAUSED:
489
+ # Suppress only while the pause is plausibly still live: until its
490
+ # announced resume_at plus one staleness window. A supervisor that died
491
+ # mid-pause therefore still alarms once that bound passes.
492
+ paused = newest
493
+ bound = None
494
+ try:
495
+ resume_dt = parse_iso_ms(paused.get("resume_at") or "")
496
+ if resume_dt.tzinfo is not None:
497
+ bound = resume_dt
498
+ except (ValueError, TypeError):
499
+ bound = None
500
+ if bound is None:
501
+ # No usable resume_at (empty/unparseable/naive) → bound by the pause's
502
+ # own timestamp + the 8-day resume horizon, so an always-paused config
503
+ # is not a permanent silent-death blind spot.
504
+ try:
505
+ bound = parse_iso_ms(paused["ts"]) + timedelta(days=8)
506
+ except (KeyError, ValueError, TypeError):
507
+ return None # truly no anchor → suppress conservatively
508
+ if now <= bound + timedelta(seconds=stale_threshold_s):
509
+ return None
474
510
  return _alert(
475
511
  "supervisor_stale",
476
512
  "warning",
@@ -480,6 +516,21 @@ def detect_supervisor_stale(
480
516
  )
481
517
 
482
518
 
519
+ def latest_schedule_state(events: list[dict[str, Any]]) -> dict[str, Any] | None:
520
+ """Current schedule-pause state derived from the event stream.
521
+
522
+ Returns None when there is no pause in effect (no schedule events, or the
523
+ newest one is a resume). When paused, returns the paused indicator dict."""
524
+ newest = _latest_schedule_event(events)
525
+ if newest is None or newest.get("event") != SCHEDULE_PAUSED:
526
+ return None
527
+ return {
528
+ "paused": True,
529
+ "resume_at": newest.get("resume_at", ""),
530
+ "active_window": newest.get("active_window", ""),
531
+ }
532
+
533
+
483
534
  # ---------------------------------------------------------------------------
484
535
  # State-tree assembly (Task 3.2)
485
536
  # ---------------------------------------------------------------------------
@@ -0,0 +1,178 @@
1
+ """Time-window scheduling core (pure). The serve loop uses this to decide,
2
+ between rounds, whether to run the next round now or idle-sleep until a
3
+ configured window opens. All functions are clock-injectable for testing."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta
11
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
+
13
+ _WINDOW_RE = re.compile(r"^(\d{2}):(\d{2})-(\d{2}):(\d{2})$")
14
+ _MINUTES_PER_DAY = 24 * 60
15
+ _WEEKDAYS = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6}
16
+ _SEARCH_HORIZON_MIN = 8 * _MINUTES_PER_DAY # weekday-scoped run_windows can pause a full weekend
17
+
18
+
19
+ def _parse_weekday_spec(spec: str) -> frozenset[int]:
20
+ days: set[int] = set()
21
+ for token in spec.split(","):
22
+ token = token.strip().lower()
23
+ if not token:
24
+ raise ValueError(f"empty weekday token in {spec!r}")
25
+ if "-" in token:
26
+ a, _, b = token.partition("-")
27
+ if a not in _WEEKDAYS or b not in _WEEKDAYS:
28
+ raise ValueError(f"invalid weekday range {token!r}")
29
+ start, end = _WEEKDAYS[a], _WEEKDAYS[b]
30
+ if start > end:
31
+ raise ValueError(f"weekday range must be ascending Mon..Sun: {token!r}")
32
+ days.update(range(start, end + 1))
33
+ elif token not in _WEEKDAYS:
34
+ raise ValueError(f"invalid weekday {token!r}")
35
+ else:
36
+ days.add(_WEEKDAYS[token])
37
+ return frozenset(days)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Window:
42
+ start_min: int # minutes from midnight, [0, 1440)
43
+ end_min: int # minutes from midnight, (0, 1440]
44
+ label: str # original "[WEEKDAYS ]HH:MM-HH:MM" for event payloads
45
+ days: frozenset[int] = frozenset() # empty = every day
46
+
47
+ def contains(self, weekday: int, minute: int) -> bool:
48
+ wraps = self.start_min >= self.end_min
49
+ if wraps: # spans past midnight
50
+ in_time = minute >= self.start_min or minute < self.end_min
51
+ else:
52
+ in_time = self.start_min <= minute < self.end_min
53
+ if not in_time:
54
+ return False
55
+ if not self.days:
56
+ return True
57
+ start_day = weekday
58
+ if wraps and minute < self.end_min:
59
+ start_day = (weekday - 1) % 7 # wrapped tail belongs to the start day
60
+ return start_day in self.days
61
+
62
+
63
+ def _to_minutes(h: int, m: int, *, allow_24: bool, where: str) -> int:
64
+ if h == 24:
65
+ if not allow_24 or m != 0:
66
+ raise ValueError(f"{where}: 24:00 is only valid as an end bound")
67
+ return _MINUTES_PER_DAY
68
+ if not 0 <= h <= 23:
69
+ raise ValueError(f"{where}: hour {h} out of range")
70
+ if not 0 <= m <= 59:
71
+ raise ValueError(f"{where}: minute {m} out of range")
72
+ return h * 60 + m
73
+
74
+
75
+ def parse_window(s: str) -> Window:
76
+ spec = s.strip()
77
+ days: frozenset[int] = frozenset()
78
+ parts = spec.split(None, 1)
79
+ if len(parts) == 2 and _WINDOW_RE.match(parts[1]):
80
+ days = _parse_weekday_spec(parts[0])
81
+ time_part = parts[1]
82
+ else:
83
+ time_part = spec
84
+ match = _WINDOW_RE.match(time_part)
85
+ if not match:
86
+ raise ValueError(f"invalid window {s!r}: expected [WEEKDAYS ]HH:MM-HH:MM")
87
+ sh, sm, eh, em = (int(g) for g in match.groups())
88
+ start = _to_minutes(sh, sm, allow_24=False, where=f"window {s!r} start")
89
+ end = _to_minutes(eh, em, allow_24=True, where=f"window {s!r} end")
90
+ if start == end:
91
+ raise ValueError(f"invalid window {s!r}: start equals end (zero-length)")
92
+ return Window(start_min=start, end_min=end, label=s, days=days)
93
+
94
+
95
+ def _minute_of_day(now_local: datetime) -> int:
96
+ return now_local.hour * 60 + now_local.minute
97
+
98
+
99
+ def should_run(
100
+ now_local: datetime,
101
+ *,
102
+ run_windows: Sequence[Window],
103
+ pause_windows: Sequence[Window],
104
+ ) -> bool:
105
+ minute = _minute_of_day(now_local)
106
+ weekday = now_local.weekday()
107
+ in_run = (not run_windows) or any(w.contains(weekday, minute) for w in run_windows)
108
+ in_pause = any(w.contains(weekday, minute) for w in pause_windows)
109
+ return in_run and not in_pause
110
+
111
+
112
+ def next_resume_at(
113
+ now_local: datetime,
114
+ *,
115
+ run_windows: Sequence[Window],
116
+ pause_windows: Sequence[Window],
117
+ ) -> datetime | None:
118
+ """First minute-boundary within the next 8 days where should_run flips True.
119
+ Called only while currently paused; returns None if no window opens in 8 days."""
120
+ base = now_local.replace(second=0, microsecond=0)
121
+ for k in range(1, _SEARCH_HORIZON_MIN + 1):
122
+ cand = base + timedelta(minutes=k)
123
+ if should_run(cand, run_windows=run_windows, pause_windows=pause_windows):
124
+ return cand
125
+ return None
126
+
127
+
128
+ def _active_window_label(
129
+ now_local: datetime,
130
+ *,
131
+ run_windows: Sequence[Window],
132
+ pause_windows: Sequence[Window],
133
+ ) -> str:
134
+ minute = _minute_of_day(now_local)
135
+ weekday = now_local.weekday()
136
+ for w in pause_windows:
137
+ if w.contains(weekday, minute):
138
+ return w.label
139
+ return "outside run_windows"
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class PauseDecision:
144
+ paused: bool
145
+ resume_at: datetime | None
146
+ active_window: str | None
147
+
148
+
149
+ def evaluate(
150
+ *,
151
+ run_windows: Sequence[Window],
152
+ pause_windows: Sequence[Window],
153
+ now_local: datetime,
154
+ ) -> PauseDecision:
155
+ if should_run(now_local, run_windows=run_windows, pause_windows=pause_windows):
156
+ return PauseDecision(paused=False, resume_at=None, active_window=None)
157
+ return PauseDecision(
158
+ paused=True,
159
+ resume_at=next_resume_at(now_local, run_windows=run_windows, pause_windows=pause_windows),
160
+ active_window=_active_window_label(
161
+ now_local, run_windows=run_windows, pause_windows=pause_windows
162
+ ),
163
+ )
164
+
165
+
166
+ def now_in_zone(tz_name: str | None) -> datetime:
167
+ """Timezone-aware now. tz_name None → host local time."""
168
+ if tz_name is None:
169
+ return datetime.now().astimezone()
170
+ return datetime.now(ZoneInfo(tz_name))
171
+
172
+
173
+ def valid_timezone(tz_name: str) -> bool:
174
+ try:
175
+ ZoneInfo(tz_name)
176
+ except (ZoneInfoNotFoundError, ValueError):
177
+ return False
178
+ return True
@@ -226,6 +226,8 @@ See `docs/plugins.md` for the `DirtyHandler` protocol and override recipe.
226
226
  - `round_substrate_after`
227
227
  - `round_substrate_before`
228
228
  - `round_timeout_kill`
229
+ - `schedule_paused`
230
+ - `schedule_resumed`
229
231
  - `serve_startup_hook_failed`
230
232
  - `service_upgrade_rollback_failed`
231
233
  - `service_upgrade_rolled_back`