labnotes 0.0.103__tar.gz → 0.0.104__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 (171) hide show
  1. {labnotes-0.0.103 → labnotes-0.0.104}/PKG-INFO +1 -1
  2. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/context.md +17 -2
  3. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiments.py +9 -2
  4. labnotes-0.0.104/src/labnotes/is_non_negative_int.py +2 -0
  5. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/models.py +3 -0
  6. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/read_config.py +6 -0
  7. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/read_path.py +1 -1
  8. labnotes-0.0.104/src/labnotes/retries.py +4 -0
  9. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/describe_event.py +11 -0
  10. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/execute_run.py +3 -0
  11. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/prepare_experiment.py +1 -0
  12. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/read_experiment_config.py +18 -2
  13. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_experiment.py +58 -5
  14. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_app.py +31 -4
  15. {labnotes-0.0.103 → labnotes-0.0.104}/testing-conventions.toml +6 -0
  16. {labnotes-0.0.103 → labnotes-0.0.104}/.gitignore +0 -0
  17. {labnotes-0.0.103 → labnotes-0.0.104}/CHANGELOG.md +0 -0
  18. {labnotes-0.0.103 → labnotes-0.0.104}/MIGRATIONS.md +0 -0
  19. {labnotes-0.0.103 → labnotes-0.0.104}/README.md +0 -0
  20. {labnotes-0.0.103 → labnotes-0.0.104}/pyproject.toml +0 -0
  21. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/__main__.py +0 -0
  23. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/compose_experiment.py +0 -0
  27. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/context_guide.py +0 -0
  28. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/count_groups.py +0 -0
  29. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/count_runs.py +0 -0
  30. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_app.py +0 -0
  31. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_experiment.py +0 -0
  32. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/create_note.py +0 -0
  33. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/__init__.py +0 -0
  34. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/control_run.py +0 -0
  35. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_app.py +0 -0
  36. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_client.py +0 -0
  37. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_running.py +0 -0
  38. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/daemon_server.py +0 -0
  39. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/describe_status.py +0 -0
  40. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/ensure_daemon.py +0 -0
  41. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/exit_when_idle.py +0 -0
  42. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/remote_start.py +0 -0
  43. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/run_daemon.py +0 -0
  44. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/run_logs.py +0 -0
  45. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/serve_daemon.py +0 -0
  46. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/socket_path.py +0 -0
  47. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/stop_runs.py +0 -0
  48. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/watch_daemon.py +0 -0
  49. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon/watch_status.py +0 -0
  50. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/daemon_command.py +0 -0
  51. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/data_html.py +0 -0
  52. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/default_config_text.py +0 -0
  53. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/default_counts.py +0 -0
  54. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/drain_watch.py +0 -0
  55. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiment_notes.py +0 -0
  56. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/experiment_runs.py +0 -0
  57. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/file_changes.py +0 -0
  58. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_duration.py +0 -0
  59. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_started.py +0 -0
  60. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/format_value.py +0 -0
  61. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/group_runs.py +0 -0
  62. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/init.py +0 -0
  63. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/invocation_started.py +0 -0
  64. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/is_positive_int.py +0 -0
  65. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/list_experiments.py +0 -0
  66. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/list_runs.py +0 -0
  67. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_config.py +0 -0
  68. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_experiment.py +0 -0
  69. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/load_run.py +0 -0
  70. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/log.py +0 -0
  71. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/log_path.py +0 -0
  72. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/modes.py +0 -0
  73. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/nests_runs.py +0 -0
  74. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/note_path.py +0 -0
  75. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/notebook_kernel.py +0 -0
  76. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/output_html.py +0 -0
  77. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/parse_run_view.py +0 -0
  78. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/print_rows.py +0 -0
  79. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/python_blocks.py +0 -0
  80. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/render_markdown.py +0 -0
  81. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_command.py +0 -0
  82. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_control.py +0 -0
  83. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_output.py +0 -0
  84. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/run_status.py +0 -0
  85. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/__init__.py +0 -0
  86. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/hold_folder.py +0 -0
  87. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_param_run_id.py +0 -0
  88. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_run_id.py +0 -0
  89. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  90. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_args.py +0 -0
  91. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_grid.py +0 -0
  92. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/param_run_name.py +0 -0
  93. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/plan_runs.py +0 -0
  94. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_folder.py +0 -0
  95. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/run_headless.py +0 -0
  96. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/runs_parent.py +0 -0
  97. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/shell_command.py +0 -0
  98. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/signal_tree.py +0 -0
  99. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/stop_then_kill.py +0 -0
  100. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/wait_for_tree.py +0 -0
  101. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/runner/write_run_json.py +0 -0
  102. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/save_conflict_copy.py +0 -0
  103. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/save_note.py +0 -0
  104. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/saved_outputs.py +0 -0
  105. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/serve_app.py +0 -0
  106. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/server.py +0 -0
  107. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/shared_params.py +0 -0
  108. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/sort_href.py +0 -0
  109. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/sql_quote.py +0 -0
  110. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/elapsed.js +0 -0
  111. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/experiment.js +0 -0
  112. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/labnotes.css +0 -0
  113. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/local-time.js +0 -0
  114. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/new-note.js +0 -0
  115. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/note-edit.js +0 -0
  116. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/note-editor.js +0 -0
  117. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/row-link.js +0 -0
  118. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/run-cell.js +0 -0
  119. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/run-output.js +0 -0
  120. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/static/view-toggle.js +0 -0
  121. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/summarize_config.py +0 -0
  122. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/__init__.py +0 -0
  123. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_rows.py +0 -0
  124. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_run_ids.py +0 -0
  125. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiment_toml.py +0 -0
  126. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/experiments_ddl.py +0 -0
  127. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/file_prefix.py +0 -0
  128. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/file_text.py +0 -0
  129. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/iso_time.py +0 -0
  130. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/list_experiment_runs.py +0 -0
  131. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/note_text.py +0 -0
  132. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/open_tables.py +0 -0
  133. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/path_glob.py +0 -0
  134. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/path_pattern.py +0 -0
  135. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_blocks.py +0 -0
  136. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_experiment.py +0 -0
  137. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_file.py +0 -0
  138. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_log.py +0 -0
  139. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/read_run.py +0 -0
  140. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tables/summarize_experiments.py +0 -0
  141. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/base.html +0 -0
  142. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/editor_imports.html +0 -0
  143. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/experiment.html +0 -0
  144. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/index.html +0 -0
  145. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/note.html +0 -0
  146. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/templates/run.html +0 -0
  147. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/__init__.py +0 -0
  148. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/browse_app.py +0 -0
  149. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/browse_tui.py +0 -0
  150. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/dim.py +0 -0
  151. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/experiment_screen.py +0 -0
  152. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/experiments_screen.py +0 -0
  153. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/fill_table.py +0 -0
  154. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/format_elapsed.py +0 -0
  155. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/group_cells.py +0 -0
  156. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/invocation_columns.py +0 -0
  157. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/invocation_rows.py +0 -0
  158. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/leave_prompt.py +0 -0
  159. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/log_block.py +0 -0
  160. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/log_blocks.py +0 -0
  161. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/reload_on_change.py +0 -0
  162. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_cells.py +0 -0
  163. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_output.py +0 -0
  164. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_screen.py +0 -0
  165. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/run_tui.py +0 -0
  166. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/runs_table.py +0 -0
  167. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/tui/stopped_title.py +0 -0
  168. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/wait_for_note.py +0 -0
  169. {labnotes-0.0.103 → labnotes-0.0.104}/src/labnotes/watch_rows.py +0 -0
  170. {labnotes-0.0.103 → labnotes-0.0.104}/tests/integration/conftest.py +0 -0
  171. {labnotes-0.0.103 → labnotes-0.0.104}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.103
3
+ Version: 0.0.104
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -92,14 +92,28 @@ A script run by labnotes should:
92
92
  - take each param as a `--name value` flag, parsing lists and tables from JSON;
93
93
  - take its output folder as an argument, passed in `command` as `{run_dir}`, and write everything it produces there;
94
94
  - exit non-zero when it fails, so the run counts as failed and runs again next time;
95
+ - exit 75 (`EX_TEMPFAIL`) only when the run couldn't do its work for a reason outside the experiment, like no Docker network or no capacity, so labnotes retries it (see below). labnotes never retries any other exit on its own;
95
96
  - stop any background process it starts before it exits; the run ends when `command` exits.
96
97
  - flush its output as it goes (`print(..., flush=True)`, or `PYTHONUNBUFFERED=1`), so the live view shows it as it happens.
97
98
 
99
+ A run that exits 75 is retried on its own, with the same params, in a new run
100
+ folder whose `run.json` holds `"retry_of": "<run id>"`. The first retry waits 30
101
+ seconds, and each one after waits twice as long. `retries` in `experiment.toml`
102
+ sets how many (0 turns them off), then `retries` under `[experiments]` in
103
+ `.labnotes.toml`, then 2. The failed run keeps its folder, logs and exit code,
104
+ and the default `counts` rule skips it, so a retry fills its place rather than
105
+ adding a repeat. When the retries run out, the last run keeps exit 75: it never
106
+ got to test its params, so treat it as blocked, not as a result. Stopping the
107
+ runs, or `x` on a run waiting to retry, cancels the retry. A run that was stopped never retries. The live view
108
+ shows a waiting retry as `retry in 30s` and the end as `gave up (exit 75)`;
109
+ `run status --json` reports `retrying`, `retried`, `gave up` and
110
+ `retry cancelled` events.
111
+
98
112
  Runs can take a long time. Start `run` in the background, then check on it with `labnotes experiments foo runs --format json`; a run with no `ended` is still going.
99
113
 
100
114
  In a terminal, `run` shows a live table of runs (status and time) and stays
101
115
  open when they finish. Failed runs show their status in red; `f` hides or shows them, and `r` on one
102
- runs it again under a new id. `j` and `k` move the cursor, `g` and `G` jump to the
116
+ runs it again under a new id, with `retry_of` set. `j` and `k` move the cursor, `g` and `G` jump to the
103
117
  first and last row. Runs not started yet show grayed as `pending`, and as
104
118
  `skipped` once stopped. Move the cursor to a pending run and press `n` to run it
105
119
  next. `m` grabs a pending run; `j` and `k` move it through the queue, Enter or `m`
@@ -140,7 +154,8 @@ going. Elsewhere, it prints a line per event instead.
140
154
  run to a place in the queue counted from 0,
141
155
  `set_concurrency` to a number, `interrupt` or `finish`.
142
156
  - A second `run` of the same experiment fails while the first is going.
143
- - It exits 0 even if runs fail; the summary line counts failures. If it can't
157
+ - It exits 0 even if runs fail; the summary line counts failures. A run that
158
+ was retried counts as retried, not failed. If it can't
144
159
  start or reach the daemon, it prints the cause on one line and exits 1.
145
160
 
146
161
  `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file. Clicking a run opens its `stdout.log` and `stderr.log` together, followed as they're written until the run ends.
@@ -79,8 +79,15 @@ def experiments(
79
79
  results = run_headless(start)
80
80
  except ValueError as error:
81
81
  raise click.ClickException(str(error)) from error
82
- failed = sum(result["exit_code"] != 0 for result in results)
83
- click.echo(f"{len(results) - failed} passed, {failed} failed.")
82
+ superseded = {r["retry_of"] for r in results if "retry_of" in r}
83
+ retried = sum(result.get("run") in superseded for result in results)
84
+ failed = sum(
85
+ result["exit_code"] != 0 and result.get("run") not in superseded
86
+ for result in results
87
+ )
88
+ again = f" {retried} retried." if retried else ""
89
+ passed = len(results) - failed - retried
90
+ click.echo(f"{passed} passed, {failed} failed.{again}")
84
91
  return
85
92
  if action not in (None, "runs"):
86
93
  raise click.UsageError(f"Unknown action {action}. Use runs or run.")
@@ -0,0 +1,2 @@
1
+ def is_non_negative_int(value: object) -> bool:
2
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
@@ -5,6 +5,7 @@ from typing import Any, Protocol
5
5
 
6
6
  from labnotes.default_counts import DEFAULT_COUNTS
7
7
  from labnotes.modes import DEFAULT_MODE
8
+ from labnotes.retries import DEFAULT_RETRIES
8
9
 
9
10
  Report = Callable[[dict[str, object]], None]
10
11
 
@@ -47,6 +48,7 @@ class LabnotesConfig:
47
48
  runs: str
48
49
  mode: str = DEFAULT_MODE
49
50
  concurrency: int = 1
51
+ retries: int = DEFAULT_RETRIES
50
52
  # Set when the config's folder is itself the one experiment.
51
53
  experiment: str | None = None
52
54
  # [experiment] in .labnotes.toml, under each experiment.toml.
@@ -62,3 +64,4 @@ class ExperimentConfig:
62
64
  counts: str = DEFAULT_COUNTS
63
65
  mode: str = DEFAULT_MODE
64
66
  concurrency: int = 1
67
+ retries: int = DEFAULT_RETRIES
@@ -2,10 +2,12 @@ import tomllib
2
2
  from pathlib import Path
3
3
 
4
4
  from labnotes.models import LabnotesConfig
5
+ from labnotes.is_non_negative_int import is_non_negative_int
5
6
  from labnotes.is_positive_int import is_positive_int
6
7
  from labnotes.modes import DEFAULT_MODE, MODES
7
8
  from labnotes.runner.read_experiment_config import SETTINGS
8
9
  from labnotes.read_path import DEFAULT_PATHS, read_path
10
+ from labnotes.retries import DEFAULT_RETRIES
9
11
 
10
12
  CONFIG_FILE = ".labnotes.toml"
11
13
 
@@ -45,12 +47,16 @@ def read_config(path: Path) -> LabnotesConfig:
45
47
  raise ValueError(
46
48
  f"{path}: [experiments] concurrency must be a positive integer"
47
49
  )
50
+ retries = experiments.get("retries", DEFAULT_RETRIES)
51
+ if not is_non_negative_int(retries):
52
+ raise ValueError(f"{path}: [experiments] retries must be 0 or more")
48
53
  return LabnotesConfig(
49
54
  path.resolve(),
50
55
  root,
51
56
  **paths,
52
57
  mode=mode,
53
58
  concurrency=concurrency,
59
+ retries=retries,
54
60
  experiment=root.name if single else None,
55
61
  defaults=defaults,
56
62
  )
@@ -16,7 +16,7 @@ PLACEHOLDERS = {
16
16
  # experiments is missing: one experiment has no folder to point at.
17
17
  SINGLE_PLACEHOLDERS: dict[str, set[str]] = {"files": set(), "runs": {"run"}}
18
18
  # Keys other readers own, so the path check lets them through.
19
- OTHER_KEYS = {"experiments": {"mode", "concurrency"}}
19
+ OTHER_KEYS = {"experiments": {"mode", "concurrency", "retries"}}
20
20
  PLACEHOLDER = re.compile(r"\{([^}]*)\}")
21
21
  SINGLE = "experiment.toml sits next to .labnotes.toml, so this folder is the experiment"
22
22
 
@@ -0,0 +1,4 @@
1
+ # EX_TEMPFAIL from sysexits.h: the only exit labnotes retries on its own.
2
+ RETRY_EXIT_CODE = 75
3
+ DEFAULT_RETRIES = 2
4
+ RETRY_SECONDS = 30.0
@@ -1,4 +1,6 @@
1
+ from labnotes.retries import RETRY_EXIT_CODE
1
2
  from labnotes.runner.param_args import param_args
3
+ from labnotes.tui.format_elapsed import format_elapsed
2
4
 
3
5
 
4
6
  def describe_event(event: dict[str, object]) -> str:
@@ -28,6 +30,15 @@ def describe_event(event: dict[str, object]) -> str:
28
30
  return f"Killing {event['run']}."
29
31
  case "retried":
30
32
  return f"{event['run']} retried as {event['retry']}."
33
+ case "retrying":
34
+ wait = format_elapsed(event["seconds"]) # type: ignore[arg-type]
35
+ attempt = f"{event['attempt']} of {event['retries']}"
36
+ return f"{event['run']} retries in {wait} (retry {attempt})."
37
+ case "gave up":
38
+ tried = f"after {event['retries']} retries"
39
+ return f"{event['run']} gave up (exit {RETRY_EXIT_CODE}) {tried}."
40
+ case "retry cancelled":
41
+ return f"{event['run']} won't retry."
31
42
  case "idle":
32
43
  return "No runs left."
33
44
  case "concurrency":
@@ -23,6 +23,7 @@ async def execute_run(
23
23
  running: set[asyncio.subprocess.Process],
24
24
  stopping: asyncio.Event,
25
25
  tree: set[psutil.Process],
26
+ retry_of: str | None = None,
26
27
  timeout: float | None = None,
27
28
  stop: Callable[[], None] = lambda: None,
28
29
  ) -> dict[str, object]:
@@ -33,6 +34,8 @@ async def execute_run(
33
34
  "exit_code": None,
34
35
  "error": None,
35
36
  }
37
+ if retry_of is not None:
38
+ run["retry_of"] = retry_of
36
39
  write_run_json(folder, run)
37
40
  with (
38
41
  (folder / "stdout.log").open("wb") as stdout,
@@ -26,6 +26,7 @@ async def prepare_experiment(
26
26
  source or "",
27
27
  config.mode,
28
28
  config.concurrency,
29
+ config.retries,
29
30
  config.defaults,
30
31
  )
31
32
  return experiment, experiment.concurrency if concurrency is None else concurrency
@@ -6,10 +6,20 @@ from typing import Any
6
6
  from labnotes.compose_experiment import compose_experiment
7
7
  from labnotes.default_counts import DEFAULT_COUNTS
8
8
  from labnotes.models import ExperimentConfig
9
+ from labnotes.is_non_negative_int import is_non_negative_int
9
10
  from labnotes.is_positive_int import is_positive_int
10
11
  from labnotes.modes import MODES
11
12
 
12
- SETTINGS = {"name", "command", "repeats", "params", "counts", "mode", "concurrency"}
13
+ SETTINGS = {
14
+ "name",
15
+ "command",
16
+ "repeats",
17
+ "params",
18
+ "counts",
19
+ "mode",
20
+ "concurrency",
21
+ "retries",
22
+ }
13
23
 
14
24
 
15
25
  def read_experiment_config(
@@ -17,6 +27,7 @@ def read_experiment_config(
17
27
  source: str,
18
28
  mode: str,
19
29
  concurrency: int,
30
+ retries: int,
20
31
  defaults: dict[str, object] | None = None,
21
32
  ) -> ExperimentConfig:
22
33
  try:
@@ -55,4 +66,9 @@ def read_experiment_config(
55
66
  concurrency = values.get("concurrency", concurrency)
56
67
  if not is_positive_int(concurrency):
57
68
  raise ValueError(f"{path}: concurrency must be a positive integer")
58
- return ExperimentConfig(command, repeats, params, name, counts, mode, concurrency)
69
+ retries = values.get("retries", retries)
70
+ if not is_non_negative_int(retries):
71
+ raise ValueError(f"{path}: retries must be 0 or more")
72
+ return ExperimentConfig(
73
+ command, repeats, params, name, counts, mode, concurrency, retries
74
+ )
@@ -11,6 +11,7 @@ from labnotes.models import (
11
11
  Listen,
12
12
  Report,
13
13
  )
14
+ from labnotes.retries import RETRY_EXIT_CODE, RETRY_SECONDS
14
15
  from labnotes.runner.execute_run import execute_run
15
16
  from labnotes.runner.new_param_run_id import new_param_run_id
16
17
  from labnotes.runner.new_stamped_run_id import new_stamped_run_id
@@ -33,6 +34,7 @@ async def run_experiment(
33
34
  report: Report,
34
35
  listen: Listen,
35
36
  timeout: float | None = None,
37
+ retry_seconds: float = RETRY_SECONDS,
36
38
  ) -> list[dict[str, object]]:
37
39
  tables = open_tables(config)
38
40
  existing = await experiment_run_ids(tables, name)
@@ -94,11 +96,19 @@ async def run_experiment(
94
96
  stopping.set()
95
97
  report({"event": "stopping"})
96
98
  signal_runs(signal.SIGTERM)
99
+ if waiting:
100
+ for run in list(waiting):
101
+ cancel_retry(run)
102
+ fill()
97
103
  elif any(running.values()):
98
104
  report({"event": "killing"})
99
105
  signal_runs(signal.SIGKILL)
100
106
 
101
107
  def kill(run: str) -> None:
108
+ if run in waiting:
109
+ cancel_retry(run)
110
+ fill()
111
+ return
102
112
  if not running.get(run):
103
113
  return
104
114
  if run in stopped:
@@ -147,16 +157,54 @@ async def run_experiment(
147
157
  fill()
148
158
 
149
159
  ended: dict[str, dict[str, object]] = {}
160
+ parents: dict[str, str] = {}
161
+ attempts: dict[str, int] = {}
162
+ waiting: dict[str, asyncio.TimerHandle] = {}
150
163
 
151
- def retry(run: str) -> None:
152
- if stopping.is_set() or run not in ended:
153
- return
164
+ def requeue(run: str, attempt: int) -> None:
154
165
  again = new_id(ended[run])
155
166
  planned.add(again)
167
+ parents[again] = run
168
+ attempts[again] = attempt
156
169
  plan.append((again, ended[run]))
157
170
  report({"event": "retried", "run": run, "retry": again, "params": ended[run]})
158
171
  fill()
159
172
 
173
+ def retry(run: str) -> None:
174
+ if stopping.is_set() or run not in ended:
175
+ return
176
+ if run in waiting:
177
+ waiting.pop(run).cancel()
178
+ requeue(run, attempts.get(run, 0) + 1)
179
+ else:
180
+ requeue(run, 0)
181
+
182
+ def retry_later(run: str) -> None:
183
+ tried = attempts.get(run, 0)
184
+ if tried >= experiment.retries:
185
+ report({"event": "gave up", "run": run, "retries": tried})
186
+ return
187
+ seconds = retry_seconds * 2**tried
188
+
189
+ def due() -> None:
190
+ del waiting[run]
191
+ requeue(run, tried + 1)
192
+
193
+ waiting[run] = asyncio.get_running_loop().call_later(seconds, due)
194
+ report(
195
+ {
196
+ "event": "retrying",
197
+ "run": run,
198
+ "attempt": tried + 1,
199
+ "retries": experiment.retries,
200
+ "seconds": seconds,
201
+ }
202
+ )
203
+
204
+ def cancel_retry(run: str) -> None:
205
+ waiting.pop(run).cancel()
206
+ report({"event": "retry cancelled", "run": run})
207
+
160
208
  limit = concurrency
161
209
  live: set[asyncio.Task[None]] = set()
162
210
  started: list[asyncio.Task[None]] = []
@@ -169,7 +217,7 @@ async def run_experiment(
169
217
  task.add_done_callback(end)
170
218
  live.add(task)
171
219
  started.append(task)
172
- if not live:
220
+ if not (live or waiting):
173
221
  report({"event": "idle"})
174
222
  if finishing:
175
223
  done.set()
@@ -177,7 +225,7 @@ async def run_experiment(
177
225
  def finish() -> None:
178
226
  nonlocal finishing
179
227
  finishing = True
180
- if not (live or plan):
228
+ if not (live or plan or waiting):
181
229
  done.set()
182
230
 
183
231
  def end(task: asyncio.Task[None]) -> None:
@@ -204,6 +252,7 @@ async def run_experiment(
204
252
  running[run],
205
253
  stopping,
206
254
  trees[run],
255
+ parents.get(run),
207
256
  timeout=timeout,
208
257
  stop=lambda: kill(run),
209
258
  )
@@ -211,6 +260,10 @@ async def run_experiment(
211
260
  report({"event": "ended", "run": run, **result})
212
261
  ended[run] = params
213
262
  results.append(result)
263
+ if result["exit_code"] == RETRY_EXIT_CODE and not (
264
+ stopping.is_set() or run in stopped
265
+ ):
266
+ retry_later(run)
214
267
 
215
268
  listen(
216
269
  Controls(
@@ -9,6 +9,7 @@ from textual.binding import Binding
9
9
  from textual.widgets import DataTable, Footer, Header, Input, Static
10
10
 
11
11
  from labnotes.models import Controls, Start
12
+ from labnotes.retries import RETRY_EXIT_CODE
12
13
  from labnotes.runner.describe_event import describe_event
13
14
  from labnotes.runner.stop_then_kill import GRACE_SECONDS, stop_then_kill
14
15
  from labnotes.runner.param_args import param_args
@@ -75,6 +76,8 @@ class RunApp(App[None]):
75
76
  self.cells: dict[str, list[object]] = {}
76
77
  self.filter = ""
77
78
  self.failed: set[str] = set()
79
+ self.retried: set[str] = set()
80
+ self.waiting: dict[str, float] = {}
78
81
  self.hiding = False
79
82
  self.folders: dict[str, Path] = {}
80
83
  self.viewing: str | None = None
@@ -178,7 +181,24 @@ class RunApp(App[None]):
178
181
  self.set_cell(run, "status", dim(PENDING))
179
182
  self.reorder(event, table.cursor_row)
180
183
  self.show(describe_event(event))
184
+ case "retrying":
185
+ self.waiting[run] = time.monotonic() + float(event["seconds"]) # type: ignore[arg-type]
186
+ self.tick()
187
+ self.show(describe_event(event))
188
+ case "gave up":
189
+ gave_up = f"{FAILED} gave up (exit {RETRY_EXIT_CODE})"
190
+ self.set_cell(run, "status", Text(gave_up, style="red"))
191
+ self.show(describe_event(event))
192
+ case "retry cancelled":
193
+ del self.waiting[run]
194
+ outcome = f"{FAILED} exited {RETRY_EXIT_CODE}"
195
+ self.set_cell(run, "status", Text(outcome, style="red"))
196
+ self.show(describe_event(event))
181
197
  case "retried":
198
+ if self.waiting.pop(run, None) is not None:
199
+ outcome = f"{FAILED} exited {RETRY_EXIT_CODE}, retried"
200
+ self.set_cell(run, "status", Text(outcome, style="red"))
201
+ self.retried.add(run)
182
202
  again = str(event["retry"])
183
203
  args = " ".join(param_args(event["params"])) # type: ignore[arg-type]
184
204
  self.cells[again] = [
@@ -200,9 +220,11 @@ class RunApp(App[None]):
200
220
  self.show(describe_event(event))
201
221
 
202
222
  def show_summary(self) -> None:
203
- failed = len(self.failed)
204
- passed = len(self.ended) - failed
205
- self.show(f"{passed} passed, {failed} failed. Ctrl-C to quit.")
223
+ retried = len(self.retried)
224
+ failed = len(self.failed - self.retried)
225
+ passed = len(self.ended) - failed - retried
226
+ again = f" {retried} retried." if retried else ""
227
+ self.show(f"{passed} passed, {failed} failed.{again} Ctrl-C to quit.")
206
228
 
207
229
  def reposition(self, event: dict[str, object], run: str) -> None:
208
230
  table = self.query_one(DataTable)
@@ -246,8 +268,13 @@ class RunApp(App[None]):
246
268
  self.query_one("#concurrency", Static).update(f"{self.concurrency} at a time")
247
269
 
248
270
  def tick(self) -> None:
271
+ now = time.monotonic()
249
272
  for run, began in self.started.items():
250
- self.set_cell(run, "time", format_elapsed(time.monotonic() - began))
273
+ self.set_cell(run, "time", format_elapsed(now - began))
274
+ for run, due in self.waiting.items():
275
+ wait = format_elapsed(max(0, due - now))
276
+ outcome = f"{FAILED} exited {RETRY_EXIT_CODE}, retry in {wait}"
277
+ self.set_cell(run, "status", Text(outcome, style="yellow"))
251
278
 
252
279
  def spin(self) -> None:
253
280
  self.frame = (self.frame + 1) % len(SPINNER)
@@ -63,3 +63,9 @@ path = "src/labnotes/tui/experiments_screen.py"
63
63
  rules = ["mutation"]
64
64
  lines = [36]
65
65
  reason = "WorkerState members are singletons, so is equals =="
66
+
67
+ [[python.exempt]]
68
+ path = "src/labnotes/runner/run_experiment.py"
69
+ rules = ["mutation"]
70
+ lines = [184, 263]
71
+ reason = "a retry is only scheduled below the budget and each one adds one, so tried never passes retries: >= equals ==; both sides are small ints, which CPython caches, so is equals =="
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes