cli-agent-runner 0.1.37__tar.gz → 0.1.38__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 (224) hide show
  1. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/CHANGELOG.md +10 -0
  2. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/PKG-INFO +1 -1
  3. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_emit.py +35 -6
  4. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_version.py +2 -2
  5. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/agent_runtime.py +57 -15
  6. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/api.py +1 -0
  7. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/events.py +1 -0
  8. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/runner.py +10 -0
  9. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/architecture.md +1 -0
  10. cli_agent_runner-0.1.38/docs/migrations/0.1.38.md +53 -0
  11. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/runbook.md +19 -0
  12. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_grace_kill_emission.py +33 -1
  13. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_catalogs.py +7 -0
  14. cli_agent_runner-0.1.38/tests/unit/test_agent_runtime_grace.py +159 -0
  15. cli_agent_runner-0.1.37/tests/unit/test_agent_runtime_grace.py +0 -72
  16. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.codecov.yml +0 -0
  17. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  20. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/workflows/ci.yml +0 -0
  22. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.github/workflows/release.yml +0 -0
  23. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.gitignore +0 -0
  24. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/.vulture-whitelist.py +0 -0
  25. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/CODE_OF_CONDUCT.md +0 -0
  26. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/CONTRIBUTING.md +0 -0
  27. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/LICENSE +0 -0
  28. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/README.md +0 -0
  29. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/README.zh.md +0 -0
  30. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/SECURITY.md +0 -0
  31. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/__init__.py +0 -0
  32. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_docgen.py +0 -0
  33. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_registry.py +0 -0
  34. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_substrate.py +0 -0
  35. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/_throttle.py +0 -0
  36. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/api_types.py +0 -0
  37. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/builtin_plugins/__init__.py +0 -0
  38. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/builtin_plugins/_constants.py +0 -0
  39. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/builtin_plugins/claude_rate_limit.py +0 -0
  40. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/builtin_plugins/gemini.py +0 -0
  41. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/__init__.py +0 -0
  42. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/__main__.py +0 -0
  43. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/common.py +0 -0
  44. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/events_cmd.py +0 -0
  45. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/init_cmd.py +0 -0
  46. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/install_cmd.py +0 -0
  47. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/monitor_cmd.py +0 -0
  48. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/peek_cmd.py +0 -0
  49. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/round_cmd.py +0 -0
  50. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/serve_cmd.py +0 -0
  51. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/service_cmd.py +0 -0
  52. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/cli/upgrade_cmd.py +0 -0
  53. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/config.py +0 -0
  54. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/context_store.py +0 -0
  55. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/defenses.py +0 -0
  56. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/detector_helpers.py +0 -0
  57. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/hooks.py +0 -0
  58. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/http_progress.py +0 -0
  59. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/lifecycle.py +0 -0
  60. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/metrics.py +0 -0
  61. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/monitor.py +0 -0
  62. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/presets/__init__.py +0 -0
  63. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/presets/aider.toml +0 -0
  64. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/presets/claude.toml +0 -0
  65. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/presets/gemini.toml +0 -0
  66. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/prompt_loader.py +0 -0
  67. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/round_log.py +0 -0
  68. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/round_view.py +0 -0
  69. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/scaffold.py +0 -0
  70. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/service_unit.py +0 -0
  71. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/startup_check.py +0 -0
  72. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/agent_runner/vcs_state.py +0 -0
  73. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/build.sh +0 -0
  74. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/deploy/example-agent-runner.toml +0 -0
  75. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/deploy/launchd.plist.tmpl +0 -0
  76. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/deploy/run-loop.sh +0 -0
  77. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/deploy/systemd.service.tmpl +0 -0
  78. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/README.md +0 -0
  79. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/commands.md +0 -0
  80. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/configuration.md +0 -0
  81. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/events.md +0 -0
  82. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/long-running-agents.md +0 -0
  83. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/marketing/README.md +0 -0
  84. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/marketing/promo-cn.html +0 -0
  85. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.16.md +0 -0
  86. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.17.md +0 -0
  87. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.19.md +0 -0
  88. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.20.md +0 -0
  89. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.21.md +0 -0
  90. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.22.md +0 -0
  91. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.23.md +0 -0
  92. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.24.md +0 -0
  93. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.25.md +0 -0
  94. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.26.md +0 -0
  95. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.27.md +0 -0
  96. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.28.md +0 -0
  97. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.29.md +0 -0
  98. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.30.md +0 -0
  99. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.31.md +0 -0
  100. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.32.md +0 -0
  101. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.33.md +0 -0
  102. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.34.md +0 -0
  103. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.35.md +0 -0
  104. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.36.md +0 -0
  105. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/migrations/0.1.37.md +0 -0
  106. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/plugins.md +0 -0
  107. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/quickstart.md +0 -0
  108. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/recipes/aider.md +0 -0
  109. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/docs/thesis.md +0 -0
  110. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/pyproject.toml +0 -0
  111. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/__init__.py +0 -0
  112. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/_test_helpers.py +0 -0
  113. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/conftest.py +0 -0
  114. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/contract/__init__.py +0 -0
  115. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/contract/test_public_api_surface.py +0 -0
  116. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/__init__.py +0 -0
  117. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/conftest.py +0 -0
  118. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  119. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/test_e2e_install_systemd.py +0 -0
  120. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  121. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  122. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/fixtures/cli-real-output/claude-2.1.143-assistant-tool-use.jsonl +0 -0
  123. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/fixtures/cli-real-output/claude-2.1.143-result-event.jsonl +0 -0
  124. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/fixtures/cli-real-output/gemini-0.42.0-result-event.jsonl +0 -0
  125. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/__init__.py +0 -0
  126. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_bounded_run.py +0 -0
  127. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_context_enricher_namespacing.py +0 -0
  128. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_fresh_eyes_signal.py +0 -0
  129. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_install_dry_run.py +0 -0
  130. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_monitor_seeded.py +0 -0
  131. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_plugin_detector_loaded.py +0 -0
  132. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_plugin_owned_paths.py +0 -0
  133. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_plugin_real_flow.py +0 -0
  134. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  135. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_scaffold_presets.py +0 -0
  136. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_serve_loop.py +0 -0
  137. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_substrate_fingerprint.py +0 -0
  138. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/integration/test_transient_error_backoff.py +0 -0
  139. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/__init__.py +0 -0
  140. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_architecture.py +0 -0
  141. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_atomic_write_enforced.py +0 -0
  142. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_classification_ssot.py +0 -0
  143. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_docs_generated.py +0 -0
  144. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_entry_points_resolve.py +0 -0
  145. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_event_kind_registry.py +0 -0
  146. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_event_kinds_ssot.py +0 -0
  147. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_events_doc_contract.py +0 -0
  148. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_layer_2_loop_size.py +0 -0
  149. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_module_boundaries.py +0 -0
  150. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_module_sizes.py +0 -0
  151. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_no_ai_signatures.py +0 -0
  152. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  153. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_peek_schema_version.py +0 -0
  154. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  155. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_round_result_stable.py +0 -0
  156. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  157. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/invariants/test_upstream_schema_canary.py +0 -0
  158. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/literate/__init__.py +0 -0
  159. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/literate/parser.py +0 -0
  160. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/literate/test_parser.py +0 -0
  161. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/literate/test_quickstart.py +0 -0
  162. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/__init__.py +0 -0
  163. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_agent_runtime.py +0 -0
  164. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_agent_runtime_progress.py +0 -0
  165. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_assemble_prompt.py +0 -0
  166. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_events_stream.py +0 -0
  167. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_install.py +0 -0
  168. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_observation.py +0 -0
  169. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_read_round_num.py +0 -0
  170. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_resolve_phase.py +0 -0
  171. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_service.py +0 -0
  172. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_api_types.py +0 -0
  173. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_auto_stop_gating.py +0 -0
  174. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_claude_error_detector.py +0 -0
  175. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli.py +0 -0
  176. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli_common.py +0 -0
  177. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli_init_install.py +0 -0
  178. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli_monitor_http.py +0 -0
  179. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  180. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_cli_upgrade.py +0 -0
  181. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config.py +0 -0
  182. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config_fresh_eyes.py +0 -0
  183. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config_max_rounds.py +0 -0
  184. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config_stop_file.py +0 -0
  185. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config_substrate_fingerprint_paths.py +0 -0
  186. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_config_transient_error_action.py +0 -0
  187. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_context_store.py +0 -0
  188. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_defenses.py +0 -0
  189. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_detector_helpers.py +0 -0
  190. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_detector_protocol.py +0 -0
  191. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_docgen.py +0 -0
  192. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_events.py +0 -0
  193. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_events_cmd.py +0 -0
  194. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_fresh_eyes_trigger.py +0 -0
  195. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_gemini_plugin.py +0 -0
  196. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_hook_failure_isolation.py +0 -0
  197. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_hooks.py +0 -0
  198. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_http_progress.py +0 -0
  199. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_init_entry_points.py +0 -0
  200. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_lifecycle.py +0 -0
  201. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_metrics.py +0 -0
  202. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_assembly.py +0 -0
  203. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_detect_anomaly_repetitive.py +0 -0
  204. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_detect_rate_limit.py +0 -0
  205. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_detect_supervisor_stale.py +0 -0
  206. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_detectors.py +0 -0
  207. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_monitor_remote.py +0 -0
  208. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_peek_argparse.py +0 -0
  209. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_peek_select.py +0 -0
  210. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_presets.py +0 -0
  211. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_prompt_loader.py +0 -0
  212. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_round_log_helpers.py +0 -0
  213. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_round_view.py +0 -0
  214. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_runner.py +0 -0
  215. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_runner_throttle.py +0 -0
  216. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_scaffold.py +0 -0
  217. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_serve_cmd_bounded.py +0 -0
  218. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_serve_round_log.py +0 -0
  219. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_serve_sentinel.py +0 -0
  220. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_serve_startup_hooks.py +0 -0
  221. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_service_unit.py +0 -0
  222. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_startup_check.py +0 -0
  223. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_substrate.py +0 -0
  224. {cli_agent_runner-0.1.37 → cli_agent_runner-0.1.38}/tests/unit/test_vcs_state.py +0 -0
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.38] - 2026-05-24
11
+
12
+ ### Fixed
13
+ - Grace-kill (`max_grace_after_result_s`) no longer reaps a round that emitted `type=result` while a backgrounded child process (e.g. a long build) is still running. It now reaps only when the agent's process group has no live worker processes left (a genuine hang); otherwise it waits for the round to finish or for the `round_timeout_s` ceiling.
14
+ - Corrected `round_grace_kill`'s description: the kill is gated on the process group being idle (no live workers), not on log silence.
15
+
16
+ ### Added
17
+ - New event `round_grace_extended` — emitted once when grace elapsed after `type=result` but a live worker process kept the round busy; carries the worker cmdlines.
18
+ - `round_grace_kill` now carries `live_children` (cmdlines observed at kill time; empty for a genuine idle hang).
19
+
10
20
  ## [0.1.37] - 2026-05-22
11
21
 
12
22
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-agent-runner
3
- Version: 0.1.37
3
+ Version: 0.1.38
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
@@ -19,6 +19,7 @@ __all__ = [
19
19
  "emit_fresh_eyes_round_triggered",
20
20
  "emit_max_rounds_reached",
21
21
  "emit_rate_limit_stop",
22
+ "emit_round_grace_extended",
22
23
  "emit_round_grace_kill",
23
24
  "emit_round_progress",
24
25
  "emit_round_substrate_after",
@@ -233,16 +234,44 @@ def emit_round_grace_kill(
233
234
  *,
234
235
  round_num: int,
235
236
  grace_s: int,
237
+ live_children: list[str] | None = None,
236
238
  ) -> None:
237
- """Emit when subprocess killed because grace-after-result timer expired.
238
-
239
- Subprocess emitted type=result in JSONL log then sat silent for longer
240
- than max_grace_after_result_s seconds. Distinguishes from round_timeout_kill
241
- (wall-clock exceeded without result event).
239
+ """Emit when the subprocess was killed because the grace-after-result timer
240
+ expired AND the agent's process group had no live worker processes left
241
+ (a genuine hang). Distinct from round_grace_extended (grace elapsed but a
242
+ worker was still running) and round_timeout_kill (wall-clock exceeded).
242
243
  """
243
244
  from agent_runner.events import ROUND_GRACE_KILL, emit
244
245
 
245
- emit(log_dir, ROUND_GRACE_KILL, round_num=round_num, grace_s=grace_s)
246
+ emit(
247
+ log_dir,
248
+ ROUND_GRACE_KILL,
249
+ round_num=round_num,
250
+ grace_s=grace_s,
251
+ live_children=live_children or [],
252
+ )
253
+
254
+
255
+ def emit_round_grace_extended(
256
+ log_dir: Path,
257
+ *,
258
+ round_num: int,
259
+ grace_s: int,
260
+ live_children: list[str],
261
+ ) -> None:
262
+ """Emit when the grace-after-result timer expired but the agent still had
263
+ live worker processes (e.g. a backgrounded build), so the round was NOT
264
+ killed; it continues until it finishes or hits round_timeout_s.
265
+ """
266
+ from agent_runner.events import ROUND_GRACE_EXTENDED, emit
267
+
268
+ emit(
269
+ log_dir,
270
+ ROUND_GRACE_EXTENDED,
271
+ round_num=round_num,
272
+ grace_s=grace_s,
273
+ live_children=live_children,
274
+ )
246
275
 
247
276
 
248
277
  def emit_anomaly_repetitive_tool(
@@ -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.1.37'
22
- __version_tuple__ = version_tuple = (0, 1, 37)
21
+ __version__ = version = '0.1.38'
22
+ __version_tuple__ = version_tuple = (0, 1, 38)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,9 +15,11 @@ import signal
15
15
  import subprocess # noqa: TID251 — sanctioned subprocess caller
16
16
  import time
17
17
  from collections.abc import Callable
18
- from dataclasses import dataclass
18
+ from dataclasses import dataclass, field
19
19
  from pathlib import Path
20
20
 
21
+ import psutil
22
+
21
23
  REAP_GRACE_S = 5
22
24
 
23
25
 
@@ -28,6 +30,7 @@ class RunResult:
28
30
  timed_out: bool
29
31
  pid: int
30
32
  killed_for_grace: bool = False
33
+ grace_kill_children: list[str] = field(default_factory=list)
31
34
 
32
35
 
33
36
  def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str) -> list[str]:
@@ -54,6 +57,31 @@ def _kill_pgroup(proc: subprocess.Popen) -> None:
54
57
  pass
55
58
 
56
59
 
60
+ def _live_children(proc: subprocess.Popen, *, max_n: int = 5, max_len: int = 120) -> list[str]:
61
+ """Cmdlines of live (non-zombie) descendant processes of ``proc``.
62
+
63
+ Empty when ``proc`` has no live workers (a stuck agent that emitted
64
+ type=result then hung). Non-empty when the round backgrounded work (e.g. a
65
+ build) still running. Bounded so the resulting event stays small.
66
+ """
67
+ try:
68
+ parent = psutil.Process(proc.pid)
69
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
70
+ return []
71
+ out: list[str] = []
72
+ for child in parent.children(recursive=True):
73
+ try:
74
+ if child.status() == psutil.STATUS_ZOMBIE:
75
+ continue
76
+ line = " ".join(child.cmdline()) or child.name()
77
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
78
+ continue
79
+ out.append(line[:max_len])
80
+ if len(out) >= max_n:
81
+ break
82
+ return out
83
+
84
+
57
85
  # Exact compact bytes — matches claude CLI's no-whitespace JSONL output.
58
86
  # A future CLI variant emitting `{"type": "result", ...}` (with space) would
59
87
  # bypass this scan; revisit if that happens.
@@ -71,14 +99,18 @@ def run(
71
99
  max_grace_after_result_s: int = 0,
72
100
  progress_callback: Callable[[dict], None] | None = None,
73
101
  progress_interval_s: int = 0,
102
+ on_grace_extended: Callable[[list[str]], None] | None = None,
74
103
  ) -> RunResult:
75
104
  """Spawn the agent subprocess and wait for exit or timeout.
76
105
 
77
106
  Wall-clock timeout (R1128). On timeout: SIGTERM pgroup → REAP_GRACE_S → SIGKILL.
78
107
 
79
108
  max_grace_after_result_s: when > 0, start a countdown after the first
80
- type=result event is detected in the log; kill if subprocess is still
81
- running after this many seconds (HUNG defense). 0 = disabled.
109
+ type=result event is detected in the log. After it elapses, reap the
110
+ process group only if the agent has no live worker processes left (a
111
+ genuine hang). If a worker is still running (e.g. a backgrounded build),
112
+ do not reap — invoke ``on_grace_extended`` once and keep waiting until the
113
+ round finishes or hits the wall-clock ``timeout_s`` ceiling. 0 = disabled.
82
114
 
83
115
  progress_callback: when not None and progress_interval_s > 0, called every
84
116
  progress_interval_s seconds with a dict of log stats (log_size_kb,
@@ -100,6 +132,7 @@ def run(
100
132
  start_new_session=True,
101
133
  )
102
134
  result_seen_at: float | None = None
135
+ grace_extended_emitted = False
103
136
  try:
104
137
  while True:
105
138
  ret = proc.poll()
@@ -114,10 +147,9 @@ def run(
114
147
  return RunResult(
115
148
  exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
116
149
  )
117
- # Grace kill: result emitted but subprocess still running
150
+ # Grace kill: result emitted but subprocess still running.
118
151
  if max_grace_after_result_s > 0:
119
152
  if result_seen_at is None:
120
- # Cheap check: byte-scan log for marker substring
121
153
  try:
122
154
  with log_path.open("rb") as f:
123
155
  if _RESULT_MARKER in f.read():
@@ -125,16 +157,26 @@ def run(
125
157
  except OSError:
126
158
  pass # log not flushed yet; check next tick
127
159
  if result_seen_at is not None and now - result_seen_at > max_grace_after_result_s:
128
- _kill_pgroup(proc)
129
- duration = time.time() - start
130
- exit_code = proc.returncode if proc.returncode is not None else -1
131
- return RunResult(
132
- exit_code=exit_code,
133
- duration_s=duration,
134
- timed_out=True,
135
- pid=proc.pid,
136
- killed_for_grace=True,
137
- )
160
+ children = _live_children(proc)
161
+ if children:
162
+ # Busy: a backgrounded worker is still running. Don't
163
+ # reap — defer to the wall-clock ceiling. Signal once.
164
+ if not grace_extended_emitted:
165
+ if on_grace_extended is not None:
166
+ on_grace_extended(children)
167
+ grace_extended_emitted = True
168
+ else:
169
+ _kill_pgroup(proc)
170
+ duration = time.time() - start
171
+ exit_code = proc.returncode if proc.returncode is not None else -1
172
+ return RunResult(
173
+ exit_code=exit_code,
174
+ duration_s=duration,
175
+ timed_out=True,
176
+ pid=proc.pid,
177
+ killed_for_grace=True,
178
+ grace_kill_children=[],
179
+ )
138
180
  # Progress heartbeat: call back if interval elapsed
139
181
  if progress_callback is not None and progress_interval_s > 0:
140
182
  if now - last_progress_at >= progress_interval_s:
@@ -733,6 +733,7 @@ from agent_runner._emit import ( # noqa: E402,F401 — intentional bottom re-ex
733
733
  emit_fresh_eyes_round_triggered,
734
734
  emit_max_rounds_reached,
735
735
  emit_rate_limit_stop,
736
+ emit_round_grace_extended,
736
737
  emit_round_grace_kill,
737
738
  emit_round_progress,
738
739
  emit_round_substrate_after,
@@ -49,6 +49,7 @@ ORPHAN_STASHED = "orphan_stashed"
49
49
  PACKAGE_UPGRADED = "package_upgraded"
50
50
  PROMPT_OVERWRITTEN = "prompt_overwritten"
51
51
  ROUND_END = "round_end"
52
+ ROUND_GRACE_EXTENDED = "round_grace_extended"
52
53
  ROUND_GRACE_KILL = "round_grace_kill"
53
54
  ROUND_PROGRESS = "round_progress"
54
55
  ROUND_START = "round_start"
@@ -466,6 +466,14 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
466
466
  **stats,
467
467
  )
468
468
 
469
+ def _grace_extended_emit(children: list[str]) -> None:
470
+ api.emit_round_grace_extended(
471
+ log_dir,
472
+ round_num=round_num,
473
+ grace_s=cfg.runtime.max_grace_after_result_s,
474
+ live_children=children,
475
+ )
476
+
469
477
  result = agent_runtime.run(
470
478
  command=cfg.agent.command,
471
479
  prompt_arg_template=cfg.agent.prompt_arg_template,
@@ -476,6 +484,7 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
476
484
  max_grace_after_result_s=cfg.runtime.max_grace_after_result_s,
477
485
  progress_callback=_progress_emit,
478
486
  progress_interval_s=cfg.monitor.round_progress_interval_s,
487
+ on_grace_extended=_grace_extended_emit,
479
488
  )
480
489
  events.emit(
481
490
  log_dir,
@@ -549,6 +558,7 @@ def _run_one_round_inner(cfg: Config, *, phase_override: str | None = None) -> R
549
558
  log_dir,
550
559
  round_num=round_num,
551
560
  grace_s=cfg.runtime.max_grace_after_result_s,
561
+ live_children=result.grace_kill_children,
552
562
  )
553
563
  elif result.timed_out:
554
564
  events.emit(
@@ -168,6 +168,7 @@ hook (vs ALL pre-round hooks), use `[plugins] disable = ["that_entry_point_name"
168
168
  - `package_upgraded`
169
169
  - `prompt_overwritten`
170
170
  - `round_end`
171
+ - `round_grace_extended`
171
172
  - `round_grace_kill`
172
173
  - `round_progress`
173
174
  - `round_start`
@@ -0,0 +1,53 @@
1
+ # Migrating to 0.1.38
2
+
3
+ ## TL;DR
4
+
5
+ ```bash
6
+ pip install --upgrade cli-agent-runner==0.1.38
7
+ ```
8
+
9
+ No action or config change. If you use `max_grace_after_result_s`, grace-kill
10
+ now distinguishes a hung agent from a still-busy one.
11
+
12
+ ## What changed
13
+
14
+ Previously, grace-kill reaped the whole process group `max_grace_after_result_s`
15
+ seconds after the agent emitted `type=result`, with no awareness of child
16
+ processes. A round that backgrounded a long build and emitted `type=result`
17
+ ("waiting for build…") could have the still-running build reaped.
18
+
19
+ Now, at grace expiry, agent-runner checks for live worker processes in the
20
+ agent's process group:
21
+
22
+ - **No live workers** → genuine hang → reaped (`round_grace_kill`, as before).
23
+ - **A live worker** (e.g. a build) → not reaped; agent-runner emits
24
+ `round_grace_extended` once and waits until the round finishes or hits the
25
+ `round_timeout_s` wall-clock ceiling.
26
+
27
+ The check re-runs each poll tick, so once a backgrounded worker exits, a still-
28
+ stuck agent is reaped promptly rather than waiting out `round_timeout_s`.
29
+
30
+ ## Three distinct outcomes
31
+
32
+ - `round_grace_extended` — grace elapsed but a worker is still running (busy).
33
+ - `round_grace_kill` — grace elapsed and the process group is idle (hang).
34
+ - `round_timeout_kill` — wall-clock `round_timeout_s` exceeded (hard ceiling).
35
+
36
+ ## Recommended agent contract
37
+
38
+ Treat the safety net as a net, not a crutch: emit `type=result` only when the
39
+ turn is truly done. Run long builds/tests foreground and commit before ending
40
+ the turn, rather than backgrounding work past `type=result`.
41
+
42
+ ## Known limitation
43
+
44
+ The "live worker" check treats *any* live non-zombie descendant as busy. If
45
+ your agent keeps persistent helper subprocesses alive past `type=result` (e.g.
46
+ MCP servers), grace-kill will defer hangs to the `round_timeout_s` ceiling. This
47
+ never false-kills; the `round_grace_extended` events make it visible if it
48
+ happens.
49
+
50
+ ## What did NOT change
51
+
52
+ - `max_grace_after_result_s` config, default, and `0 = disabled`.
53
+ - `round_timeout_s` wall-clock kill (still the hard ceiling and backstop).
@@ -565,6 +565,25 @@ agent-runner kill # force terminate
565
565
  ls -la ~/.agent-runner/<project>/logs/rounds/ # most recent R*.log
566
566
  ```
567
567
 
568
+ ### Grace-kill and backgrounded work (`max_grace_after_result_s`)
569
+
570
+ Grace-kill is now process-group-liveness-aware. At grace expiry, agent-runner
571
+ inspects the agent's process group for live (non-zombie) worker processes and
572
+ takes one of three paths:
573
+
574
+ - **`round_grace_extended`** — grace elapsed but a live worker is still running
575
+ (e.g. a backgrounded build). Round is NOT killed; agent-runner waits until the
576
+ round finishes or hits the `round_timeout_s` wall-clock ceiling.
577
+ - **`round_grace_kill`** — grace elapsed and the process group is idle (genuine
578
+ hang). Round is reaped, same as pre-0.1.38.
579
+ - **`round_timeout_kill`** — `round_timeout_s` wall-clock exceeded (hard ceiling,
580
+ fires regardless of process-group state).
581
+
582
+ If you see repeated `round_grace_extended` events, the agent is backgrounding
583
+ work past `type=result`. Check the `live_children` field in the event to identify
584
+ the process; consider restructuring the agent to emit `type=result` only when
585
+ truly done.
586
+
568
587
  ### Disk pressure
569
588
 
570
589
  **Symptom:** `[WARN] disk_warning` at >90%; `[CRIT] disk_critical` at >95% (auto-stops).
@@ -57,7 +57,7 @@ def test_grace_kill_emits_round_grace_kill_event(tmp_path: Path) -> None:
57
57
 
58
58
  script = tmp_path / "agent.sh"
59
59
  script.write_text(
60
- '#!/bin/bash\necho \'{"type":"result","is_error":false}\'\nsleep 10\n',
60
+ '#!/bin/bash\necho \'{"type":"result","is_error":false}\'\nexec sleep 10\n',
61
61
  encoding="utf-8",
62
62
  )
63
63
  script.chmod(0o755)
@@ -77,3 +77,35 @@ def test_grace_kill_emits_round_grace_kill_event(tmp_path: Path) -> None:
77
77
  # round_timeout_kill must NOT appear (grace kill is distinct)
78
78
  timeout_events = [e for e in events if e.get("event") == "round_timeout_kill"]
79
79
  assert len(timeout_events) == 0
80
+
81
+
82
+ def test_round_grace_extended_emitted_when_worker_alive(tmp_path: Path) -> None:
83
+ """Full runner flow: subprocess emits result then backgrounds a long child;
84
+ round_grace_extended event fires (not round_grace_kill); wall timeout reaps."""
85
+ _init_git(tmp_path)
86
+
87
+ script = tmp_path / "agent.sh"
88
+ script.write_text(
89
+ '#!/bin/bash\necho \'{"type":"result","is_error":false}\'\nsleep 30 &\nwait\n',
90
+ encoding="utf-8",
91
+ )
92
+ script.chmod(0o755)
93
+
94
+ cfg = _make_grace_config(tmp_path, script, grace_s=1)
95
+ result = run_one_round(cfg)
96
+
97
+ assert result.killed_for_grace is False # spared by liveness
98
+ assert result.timed_out is True # wall-clock ceiling reaped it
99
+
100
+ events = read_events_for_current_month(cfg.runtime.log_dir)
101
+
102
+ # round_grace_extended must appear with live_children populated
103
+ extended_events = [e for e in events if e.get("event") == "round_grace_extended"]
104
+ assert len(extended_events) == 1
105
+ assert extended_events[0]["round_num"] == 1
106
+ assert extended_events[0]["grace_s"] == 1
107
+ assert any("sleep" in c for c in extended_events[0]["live_children"])
108
+
109
+ # round_grace_kill must NOT appear (round was busy, not idle)
110
+ grace_kill_events = [e for e in events if e.get("event") == "round_grace_kill"]
111
+ assert len(grace_kill_events) == 0
@@ -220,3 +220,10 @@ def test_given_package_upgraded_kind_when_registered_then_in_known_event_kinds()
220
220
 
221
221
  assert PACKAGE_UPGRADED == "package_upgraded"
222
222
  assert PACKAGE_UPGRADED in _BUILTIN_KINDS
223
+
224
+
225
+ def test_given_round_grace_extended_kind_when_registered_then_in_known_event_kinds() -> None:
226
+ from agent_runner.events import _BUILTIN_KINDS, ROUND_GRACE_EXTENDED
227
+
228
+ assert ROUND_GRACE_EXTENDED == "round_grace_extended"
229
+ assert ROUND_GRACE_EXTENDED in _BUILTIN_KINDS
@@ -0,0 +1,159 @@
1
+ """Tests for max_grace_after_result_s HUNG defense (0.1.31+)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from agent_runner.agent_runtime import run
12
+
13
+
14
+ def _write_fake_script(tmp_path: Path, body: str) -> Path:
15
+ p = tmp_path / "fake.sh"
16
+ p.write_text(f"#!/bin/bash\nset -e\n{body}\n", encoding="utf-8")
17
+ p.chmod(0o755)
18
+ return p
19
+
20
+
21
+ def test_grace_kill_fires_when_result_then_idle(tmp_path):
22
+ """Agent writes type=result then becomes a childless sleeper (exec) -> no
23
+ live workers -> reaped within grace + tick latency."""
24
+ script = _write_fake_script(
25
+ tmp_path,
26
+ 'echo \'{"type":"result","is_error":false}\'\nexec sleep 5\n',
27
+ )
28
+ log_path = tmp_path / "round.log"
29
+ result = run(
30
+ command=[str(script)],
31
+ prompt_arg_template=[],
32
+ prompt="x",
33
+ timeout_s=10,
34
+ log_path=log_path,
35
+ env_extra={},
36
+ max_grace_after_result_s=1,
37
+ )
38
+ assert result.killed_for_grace is True
39
+ assert result.grace_kill_children == []
40
+ assert result.duration_s < 4
41
+
42
+
43
+ def test_no_grace_kill_when_disabled(tmp_path):
44
+ """max_grace=0 -> grace logic disabled; wall timeout governs."""
45
+ script = _write_fake_script(
46
+ tmp_path,
47
+ 'echo \'{"type":"result","is_error":false}\'\nsleep 5\n',
48
+ )
49
+ log_path = tmp_path / "round.log"
50
+ result = run(
51
+ command=[str(script)],
52
+ prompt_arg_template=[],
53
+ prompt="x",
54
+ timeout_s=2, # short wall timeout
55
+ log_path=log_path,
56
+ env_extra={},
57
+ max_grace_after_result_s=0,
58
+ )
59
+ assert result.killed_for_grace is False
60
+ assert result.timed_out is True # killed by wall timeout instead
61
+
62
+
63
+ def test_no_grace_kill_when_result_not_emitted(tmp_path):
64
+ """No result event -> grace countdown never starts."""
65
+ script = _write_fake_script(tmp_path, 'echo "no result here"\nexit 0\n')
66
+ log_path = tmp_path / "round.log"
67
+ result = run(
68
+ command=[str(script)],
69
+ prompt_arg_template=[],
70
+ prompt="x",
71
+ timeout_s=5,
72
+ log_path=log_path,
73
+ env_extra={},
74
+ max_grace_after_result_s=1,
75
+ )
76
+ assert result.killed_for_grace is False
77
+ assert result.timed_out is False
78
+ assert result.exit_code == 0
79
+
80
+
81
+ def test_live_children_empty_when_no_children():
82
+ from agent_runner.agent_runtime import _live_children
83
+
84
+ p = subprocess.Popen(["sleep", "3"], start_new_session=True)
85
+ try:
86
+ assert _live_children(p) == []
87
+ finally:
88
+ os.killpg(p.pid, signal.SIGKILL)
89
+ p.wait()
90
+
91
+
92
+ def test_live_children_lists_backgrounded_child():
93
+ from agent_runner.agent_runtime import _live_children
94
+
95
+ p = subprocess.Popen(["bash", "-c", "sleep 30 & wait"], start_new_session=True)
96
+ try:
97
+ time.sleep(0.5) # let the backgrounded child spawn
98
+ kids = _live_children(p)
99
+ assert any("sleep" in k for k in kids)
100
+ finally:
101
+ os.killpg(p.pid, signal.SIGKILL)
102
+ p.wait()
103
+
104
+
105
+ def test_live_children_empty_when_process_gone():
106
+ from agent_runner.agent_runtime import _live_children
107
+
108
+ p = subprocess.Popen(["true"])
109
+ p.wait()
110
+ assert _live_children(p) == [] # NoSuchProcess swallowed
111
+
112
+
113
+ def test_grace_extended_when_result_but_child_running(tmp_path):
114
+ """Agent emits result then backgrounds a long child -> live worker -> NOT
115
+ grace-killed; round_timeout_s (wall) reaps it instead; extended fired once."""
116
+ script = _write_fake_script(
117
+ tmp_path,
118
+ 'echo \'{"type":"result"}\'\nsleep 30 &\nwait\n',
119
+ )
120
+ log_path = tmp_path / "round.log"
121
+ extended = []
122
+ result = run(
123
+ command=[str(script)],
124
+ prompt_arg_template=[],
125
+ prompt="x",
126
+ timeout_s=4,
127
+ log_path=log_path,
128
+ env_extra={},
129
+ max_grace_after_result_s=1,
130
+ on_grace_extended=lambda kids: extended.append(kids),
131
+ )
132
+ assert result.killed_for_grace is False # spared by liveness
133
+ assert result.timed_out is True # round_timeout_s backstop reaped it
134
+ assert len(extended) == 1 # emitted once, not per-tick
135
+ assert any("sleep" in k for k in extended[0])
136
+
137
+
138
+ def test_grace_kill_after_child_exits_then_idle(tmp_path):
139
+ """Live child first (extend), child exits, agent becomes childless (exec)
140
+ -> next tick reaps via grace (well before wall timeout)."""
141
+ script = _write_fake_script(
142
+ tmp_path,
143
+ 'echo \'{"type":"result"}\'\nsleep 2 &\nwait\nexec sleep 30\n',
144
+ )
145
+ log_path = tmp_path / "round.log"
146
+ extended = []
147
+ result = run(
148
+ command=[str(script)],
149
+ prompt_arg_template=[],
150
+ prompt="x",
151
+ timeout_s=10,
152
+ log_path=log_path,
153
+ env_extra={},
154
+ max_grace_after_result_s=1,
155
+ on_grace_extended=lambda kids: extended.append(kids),
156
+ )
157
+ assert result.killed_for_grace is True # reaped after child exited
158
+ assert result.duration_s < 6 # ~2s child + reap, well under timeout
159
+ assert len(extended) == 1
@@ -1,72 +0,0 @@
1
- """Tests for max_grace_after_result_s HUNG defense (0.1.31+)."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- from agent_runner.agent_runtime import run
8
-
9
-
10
- def _write_fake_script(tmp_path: Path, body: str) -> Path:
11
- p = tmp_path / "fake.sh"
12
- p.write_text(f"#!/bin/bash\nset -e\n{body}\n", encoding="utf-8")
13
- p.chmod(0o755)
14
- return p
15
-
16
-
17
- def test_grace_kill_fires_when_result_then_silent(tmp_path):
18
- """Fake agent writes type=result then sleeps 5s. max_grace=1s -> kill within 3s."""
19
- script = _write_fake_script(
20
- tmp_path,
21
- 'echo \'{"type":"result","is_error":false}\'\nsleep 5\n',
22
- )
23
- log_path = tmp_path / "round.log"
24
- result = run(
25
- command=[str(script)],
26
- prompt_arg_template=[],
27
- prompt="x",
28
- timeout_s=10,
29
- log_path=log_path,
30
- env_extra={},
31
- max_grace_after_result_s=1,
32
- )
33
- assert result.killed_for_grace is True
34
- assert result.duration_s < 4 # ~1s grace + tick latency + reap grace
35
-
36
-
37
- def test_no_grace_kill_when_disabled(tmp_path):
38
- """max_grace=0 -> grace logic disabled; wall timeout governs."""
39
- script = _write_fake_script(
40
- tmp_path,
41
- 'echo \'{"type":"result","is_error":false}\'\nsleep 5\n',
42
- )
43
- log_path = tmp_path / "round.log"
44
- result = run(
45
- command=[str(script)],
46
- prompt_arg_template=[],
47
- prompt="x",
48
- timeout_s=2, # short wall timeout
49
- log_path=log_path,
50
- env_extra={},
51
- max_grace_after_result_s=0,
52
- )
53
- assert result.killed_for_grace is False
54
- assert result.timed_out is True # killed by wall timeout instead
55
-
56
-
57
- def test_no_grace_kill_when_result_not_emitted(tmp_path):
58
- """No result event -> grace countdown never starts."""
59
- script = _write_fake_script(tmp_path, 'echo "no result here"\nexit 0\n')
60
- log_path = tmp_path / "round.log"
61
- result = run(
62
- command=[str(script)],
63
- prompt_arg_template=[],
64
- prompt="x",
65
- timeout_s=5,
66
- log_path=log_path,
67
- env_extra={},
68
- max_grace_after_result_s=1,
69
- )
70
- assert result.killed_for_grace is False
71
- assert result.timed_out is False
72
- assert result.exit_code == 0