labnotes 0.0.102__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.102 → labnotes-0.0.104}/PKG-INFO +1 -1
  2. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/context.md +21 -2
  3. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_app.py +1 -0
  4. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/remote_start.py +2 -0
  5. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiments.py +18 -3
  6. labnotes-0.0.104/src/labnotes/is_non_negative_int.py +2 -0
  7. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_run.py +1 -1
  8. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/models.py +3 -0
  9. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/read_config.py +6 -0
  10. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/read_path.py +1 -1
  11. labnotes-0.0.104/src/labnotes/retries.py +4 -0
  12. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/describe_event.py +11 -0
  13. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/execute_run.py +23 -2
  14. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/prepare_experiment.py +1 -0
  15. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/read_experiment_config.py +18 -2
  16. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_experiment.py +61 -5
  17. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/run.html +1 -1
  18. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_app.py +31 -4
  19. {labnotes-0.0.102 → labnotes-0.0.104}/testing-conventions.toml +6 -0
  20. {labnotes-0.0.102 → labnotes-0.0.104}/.gitignore +0 -0
  21. {labnotes-0.0.102 → labnotes-0.0.104}/CHANGELOG.md +0 -0
  22. {labnotes-0.0.102 → labnotes-0.0.104}/MIGRATIONS.md +0 -0
  23. {labnotes-0.0.102 → labnotes-0.0.104}/README.md +0 -0
  24. {labnotes-0.0.102 → labnotes-0.0.104}/pyproject.toml +0 -0
  25. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/__init__.py +0 -0
  26. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/__main__.py +0 -0
  27. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/asset_url.py +0 -0
  28. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/check_counts.py +0 -0
  29. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/cli.py +0 -0
  30. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/compose_experiment.py +0 -0
  31. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/context_guide.py +0 -0
  32. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/count_groups.py +0 -0
  33. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/count_runs.py +0 -0
  34. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_app.py +0 -0
  35. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_experiment.py +0 -0
  36. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_note.py +0 -0
  37. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/__init__.py +0 -0
  38. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/control_run.py +0 -0
  39. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_client.py +0 -0
  40. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_running.py +0 -0
  41. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_server.py +0 -0
  42. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/describe_status.py +0 -0
  43. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/ensure_daemon.py +0 -0
  44. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/exit_when_idle.py +0 -0
  45. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/run_daemon.py +0 -0
  46. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/run_logs.py +0 -0
  47. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/serve_daemon.py +0 -0
  48. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/socket_path.py +0 -0
  49. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/stop_runs.py +0 -0
  50. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/watch_daemon.py +0 -0
  51. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/watch_status.py +0 -0
  52. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon_command.py +0 -0
  53. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/data_html.py +0 -0
  54. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/default_config_text.py +0 -0
  55. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/default_counts.py +0 -0
  56. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/drain_watch.py +0 -0
  57. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiment_notes.py +0 -0
  58. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiment_runs.py +0 -0
  59. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/file_changes.py +0 -0
  60. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_duration.py +0 -0
  61. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_started.py +0 -0
  62. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_value.py +0 -0
  63. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/group_runs.py +0 -0
  64. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/init.py +0 -0
  65. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/invocation_started.py +0 -0
  66. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/is_positive_int.py +0 -0
  67. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/list_experiments.py +0 -0
  68. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/list_runs.py +0 -0
  69. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_config.py +0 -0
  70. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_experiment.py +0 -0
  71. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/log.py +0 -0
  72. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/log_path.py +0 -0
  73. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/modes.py +0 -0
  74. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/nests_runs.py +0 -0
  75. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/note_path.py +0 -0
  76. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/notebook_kernel.py +0 -0
  77. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/output_html.py +0 -0
  78. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/parse_run_view.py +0 -0
  79. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/print_rows.py +0 -0
  80. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/python_blocks.py +0 -0
  81. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/render_markdown.py +0 -0
  82. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_command.py +0 -0
  83. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_control.py +0 -0
  84. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_output.py +0 -0
  85. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_status.py +0 -0
  86. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/__init__.py +0 -0
  87. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/hold_folder.py +0 -0
  88. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_param_run_id.py +0 -0
  89. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_run_id.py +0 -0
  90. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  91. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_args.py +0 -0
  92. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_grid.py +0 -0
  93. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_run_name.py +0 -0
  94. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/plan_runs.py +0 -0
  95. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_folder.py +0 -0
  96. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_headless.py +0 -0
  97. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/runs_parent.py +0 -0
  98. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/shell_command.py +0 -0
  99. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/signal_tree.py +0 -0
  100. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/stop_then_kill.py +0 -0
  101. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/wait_for_tree.py +0 -0
  102. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/write_run_json.py +0 -0
  103. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/save_conflict_copy.py +0 -0
  104. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/save_note.py +0 -0
  105. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/saved_outputs.py +0 -0
  106. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/serve_app.py +0 -0
  107. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/server.py +0 -0
  108. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/shared_params.py +0 -0
  109. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/sort_href.py +0 -0
  110. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/sql_quote.py +0 -0
  111. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/elapsed.js +0 -0
  112. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/experiment.js +0 -0
  113. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/labnotes.css +0 -0
  114. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/local-time.js +0 -0
  115. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/new-note.js +0 -0
  116. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/note-edit.js +0 -0
  117. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/note-editor.js +0 -0
  118. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/row-link.js +0 -0
  119. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/run-cell.js +0 -0
  120. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/run-output.js +0 -0
  121. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/view-toggle.js +0 -0
  122. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/summarize_config.py +0 -0
  123. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/__init__.py +0 -0
  124. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_rows.py +0 -0
  125. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_run_ids.py +0 -0
  126. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_toml.py +0 -0
  127. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiments_ddl.py +0 -0
  128. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/file_prefix.py +0 -0
  129. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/file_text.py +0 -0
  130. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/iso_time.py +0 -0
  131. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/list_experiment_runs.py +0 -0
  132. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/note_text.py +0 -0
  133. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/open_tables.py +0 -0
  134. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/path_glob.py +0 -0
  135. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/path_pattern.py +0 -0
  136. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_blocks.py +0 -0
  137. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_experiment.py +0 -0
  138. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_file.py +0 -0
  139. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_log.py +0 -0
  140. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_run.py +0 -0
  141. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/summarize_experiments.py +0 -0
  142. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/base.html +0 -0
  143. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/editor_imports.html +0 -0
  144. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/experiment.html +0 -0
  145. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/index.html +0 -0
  146. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/note.html +0 -0
  147. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/__init__.py +0 -0
  148. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/browse_app.py +0 -0
  149. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/browse_tui.py +0 -0
  150. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/dim.py +0 -0
  151. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/experiment_screen.py +0 -0
  152. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/experiments_screen.py +0 -0
  153. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/fill_table.py +0 -0
  154. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/format_elapsed.py +0 -0
  155. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/group_cells.py +0 -0
  156. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/invocation_columns.py +0 -0
  157. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/invocation_rows.py +0 -0
  158. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/leave_prompt.py +0 -0
  159. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/log_block.py +0 -0
  160. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/log_blocks.py +0 -0
  161. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/reload_on_change.py +0 -0
  162. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_cells.py +0 -0
  163. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_output.py +0 -0
  164. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_screen.py +0 -0
  165. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_tui.py +0 -0
  166. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/runs_table.py +0 -0
  167. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/stopped_title.py +0 -0
  168. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/wait_for_note.py +0 -0
  169. {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/watch_rows.py +0 -0
  170. {labnotes-0.0.102 → labnotes-0.0.104}/tests/integration/conftest.py +0 -0
  171. {labnotes-0.0.102 → 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.102
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`
@@ -117,6 +131,10 @@ going. Elsewhere, it prints a line per event instead.
117
131
  in `experiment.toml` sets it, then `concurrency` under `[experiments]` in
118
132
  `.labnotes.toml`, then 1.
119
133
  - `-e PATH` reads another file instead of `experiment.toml`.
134
+ - `--timeout SECONDS` stops any one run still going after that many seconds:
135
+ SIGTERM, then SIGKILL 5 seconds later. Its
136
+ `run.json` gets `"error": "timed out after ..."` next to its exit code.
137
+ Unset, runs have no time limit.
120
138
  - `run` hands its runs to the project's labnotes daemon, starting it if
121
139
  needed, and shows or waits for them. The daemon listens on a socket in
122
140
  `/tmp/labnotes-<uid>/` and exits once nothing is running and no client has
@@ -136,7 +154,8 @@ going. Elsewhere, it prints a line per event instead.
136
154
  run to a place in the queue counted from 0,
137
155
  `set_concurrency` to a number, `interrupt` or `finish`.
138
156
  - A second `run` of the same experiment fails while the first is going.
139
- - 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
140
159
  start or reach the daemon, it prints the cause on one line and exits 1.
141
160
 
142
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.
@@ -102,6 +102,7 @@ def daemon_app(config: LabnotesConfig) -> Starlette:
102
102
  concurrency,
103
103
  lambda event: record({**event, "experiment": name}),
104
104
  listen,
105
+ body.get("timeout"), # type: ignore[arg-type]
105
106
  )
106
107
  # Clients hear why the session ended; the daemon keeps serving.
107
108
  except Exception as failure: # noqa: BLE001
@@ -15,6 +15,7 @@ def remote_start(
15
15
  name: str,
16
16
  experiment_config: Path | None,
17
17
  concurrency: int | None,
18
+ timeout: float | None = None,
18
19
  ) -> Start:
19
20
  async def start(report: Report, listen: Listen) -> list[dict[str, object]]:
20
21
  try:
@@ -44,6 +45,7 @@ def remote_start(
44
45
  if experiment_config is None
45
46
  else str(experiment_config.resolve()),
46
47
  "concurrency": concurrency,
48
+ "timeout": timeout,
47
49
  },
48
50
  )
49
51
  if reply.status_code != 202:
@@ -40,6 +40,13 @@ from labnotes.tui.run_tui import run_tui
40
40
  type=click.Path(exists=True, dir_okay=False, path_type=Path),
41
41
  help="With run: read this file instead of the experiment's experiment.toml.",
42
42
  )
43
+ @click.option(
44
+ "--timeout",
45
+ type=click.IntRange(min=1),
46
+ metavar="SECONDS",
47
+ help="With run: stop any run still going after this many seconds."
48
+ " Unset, runs have no time limit.",
49
+ )
43
50
  @click.pass_context
44
51
  def experiments(
45
52
  ctx: click.Context,
@@ -48,6 +55,7 @@ def experiments(
48
55
  output_format: str | None,
49
56
  concurrency: int | None,
50
57
  experiment_config: Path | None,
58
+ timeout: int | None,
51
59
  ) -> None:
52
60
  config = load_config(ctx)
53
61
  if config is None:
@@ -63,7 +71,7 @@ def experiments(
63
71
  click.echo(f"Created {path / 'experiment.toml'}")
64
72
  return
65
73
  case str(), "run":
66
- start = remote_start(config, name, experiment_config, concurrency)
74
+ start = remote_start(config, name, experiment_config, concurrency, timeout)
67
75
  try:
68
76
  if sys.stdout.isatty() and sys.stderr.isatty():
69
77
  results = run_tui(start, name)
@@ -71,8 +79,15 @@ def experiments(
71
79
  results = run_headless(start)
72
80
  except ValueError as error:
73
81
  raise click.ClickException(str(error)) from error
74
- failed = sum(result["exit_code"] != 0 for result in results)
75
- 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}")
76
91
  return
77
92
  if action not in (None, "runs"):
78
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
@@ -10,7 +10,7 @@ async def load_run(
10
10
  ) -> dict[str, object] | None:
11
11
  rows = await tables.query(
12
12
  "SELECT runs.experiment, experiments.display_name, runs.id, runs.folder,"
13
- " runs.created, runs.ended, runs.exit_code, runs.params"
13
+ " runs.created, runs.ended, runs.exit_code, runs.error, runs.params"
14
14
  " FROM runs JOIN experiments ON experiments.name = runs.experiment"
15
15
  f" WHERE runs.experiment = {quote_string(experiment)}"
16
16
  f" AND runs.id = {quote_string(run)}"
@@ -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":
@@ -1,15 +1,18 @@
1
1
  import asyncio
2
2
  import os
3
3
  import time
4
+ from collections.abc import Callable
4
5
  from pathlib import Path
5
6
 
6
7
  import psutil
7
8
 
8
9
  from labnotes.log_path import LOG_ENV
9
10
  from labnotes.runner.shell_command import shell_command
11
+ from labnotes.runner.stop_then_kill import GRACE_SECONDS
10
12
  from labnotes.runner.wait_for_tree import wait_for_tree
11
13
  from labnotes.runner.write_run_json import write_run_json
12
14
  from labnotes.tables.iso_time import iso_time
15
+ from labnotes.tui.format_elapsed import format_elapsed
13
16
 
14
17
 
15
18
  async def execute_run(
@@ -20,6 +23,9 @@ async def execute_run(
20
23
  running: set[asyncio.subprocess.Process],
21
24
  stopping: asyncio.Event,
22
25
  tree: set[psutil.Process],
26
+ retry_of: str | None = None,
27
+ timeout: float | None = None,
28
+ stop: Callable[[], None] = lambda: None,
23
29
  ) -> dict[str, object]:
24
30
  run: dict[str, object] = {
25
31
  "params": params,
@@ -28,6 +34,8 @@ async def execute_run(
28
34
  "exit_code": None,
29
35
  "error": None,
30
36
  }
37
+ if retry_of is not None:
38
+ run["retry_of"] = retry_of
31
39
  write_run_json(folder, run)
32
40
  with (
33
41
  (folder / "stdout.log").open("wb") as stdout,
@@ -50,14 +58,27 @@ async def execute_run(
50
58
  run["error"] = str(error)
51
59
  else:
52
60
  running.add(process)
61
+ exited = asyncio.ensure_future(process.wait())
62
+ kill_later = None
53
63
  try:
54
- run["exit_code"] = await process.wait()
64
+ if (
65
+ timeout is not None
66
+ and not (await asyncio.wait({exited}, timeout=timeout))[0]
67
+ ):
68
+ run["error"] = f"timed out after {format_elapsed(timeout)}"
69
+ stop()
70
+ kill_later = asyncio.get_running_loop().call_later(
71
+ GRACE_SECONDS, stop
72
+ )
73
+ run["exit_code"] = await exited
55
74
  # The shell can exit on SIGTERM before what it started. Once
56
75
  # stopping, the run stays killable until everything signalled
57
76
  # is gone. A normal exit ends the run even if helpers linger.
58
- if stopping.is_set():
77
+ if stopping.is_set() or kill_later is not None:
59
78
  await wait_for_tree(tree)
60
79
  finally:
80
+ if kill_later is not None:
81
+ kill_later.cancel()
61
82
  running.discard(process)
62
83
  run["ended"] = iso_time(time.time())
63
84
  write_run_json(folder, run)
@@ -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
@@ -32,6 +33,8 @@ async def run_experiment(
32
33
  concurrency: int,
33
34
  report: Report,
34
35
  listen: Listen,
36
+ timeout: float | None = None,
37
+ retry_seconds: float = RETRY_SECONDS,
35
38
  ) -> list[dict[str, object]]:
36
39
  tables = open_tables(config)
37
40
  existing = await experiment_run_ids(tables, name)
@@ -93,11 +96,19 @@ async def run_experiment(
93
96
  stopping.set()
94
97
  report({"event": "stopping"})
95
98
  signal_runs(signal.SIGTERM)
99
+ if waiting:
100
+ for run in list(waiting):
101
+ cancel_retry(run)
102
+ fill()
96
103
  elif any(running.values()):
97
104
  report({"event": "killing"})
98
105
  signal_runs(signal.SIGKILL)
99
106
 
100
107
  def kill(run: str) -> None:
108
+ if run in waiting:
109
+ cancel_retry(run)
110
+ fill()
111
+ return
101
112
  if not running.get(run):
102
113
  return
103
114
  if run in stopped:
@@ -146,16 +157,54 @@ async def run_experiment(
146
157
  fill()
147
158
 
148
159
  ended: dict[str, dict[str, object]] = {}
160
+ parents: dict[str, str] = {}
161
+ attempts: dict[str, int] = {}
162
+ waiting: dict[str, asyncio.TimerHandle] = {}
149
163
 
150
- def retry(run: str) -> None:
151
- if stopping.is_set() or run not in ended:
152
- return
164
+ def requeue(run: str, attempt: int) -> None:
153
165
  again = new_id(ended[run])
154
166
  planned.add(again)
167
+ parents[again] = run
168
+ attempts[again] = attempt
155
169
  plan.append((again, ended[run]))
156
170
  report({"event": "retried", "run": run, "retry": again, "params": ended[run]})
157
171
  fill()
158
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
+
159
208
  limit = concurrency
160
209
  live: set[asyncio.Task[None]] = set()
161
210
  started: list[asyncio.Task[None]] = []
@@ -168,7 +217,7 @@ async def run_experiment(
168
217
  task.add_done_callback(end)
169
218
  live.add(task)
170
219
  started.append(task)
171
- if not live:
220
+ if not (live or waiting):
172
221
  report({"event": "idle"})
173
222
  if finishing:
174
223
  done.set()
@@ -176,7 +225,7 @@ async def run_experiment(
176
225
  def finish() -> None:
177
226
  nonlocal finishing
178
227
  finishing = True
179
- if not (live or plan):
228
+ if not (live or plan or waiting):
180
229
  done.set()
181
230
 
182
231
  def end(task: asyncio.Task[None]) -> None:
@@ -203,11 +252,18 @@ async def run_experiment(
203
252
  running[run],
204
253
  stopping,
205
254
  trees[run],
255
+ parents.get(run),
256
+ timeout=timeout,
257
+ stop=lambda: kill(run),
206
258
  )
207
259
  del running[run], trees[run]
208
260
  report({"event": "ended", "run": run, **result})
209
261
  ended[run] = params
210
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)
211
267
 
212
268
  listen(
213
269
  Controls(
@@ -5,7 +5,7 @@
5
5
  <section class="intro">
6
6
  <h1 class="mono">{{ run.id }}</h1>
7
7
  <p class="config-summary">
8
- {% if run.ended is none %}<span class="chip run-state">running</span>{% elif run.exit_code is none %}<span class="chip run-state bad">ended</span>{% else %}<span class="chip run-state {{ 'good' if run.exit_code == 0 else 'bad' }}">exit {{ run.exit_code }}</span>{% endif %}
8
+ {% if run.ended is none %}<span class="chip run-state">running</span>{% elif run.error %}<span class="chip run-state bad">{{ run.error }}</span>{% elif run.exit_code is none %}<span class="chip run-state bad">ended</span>{% else %}<span class="chip run-state {{ 'good' if run.exit_code == 0 else 'bad' }}">exit {{ run.exit_code }}</span>{% endif %}
9
9
  {% if run.created %}<span class="setting">started <code>{{ run.created }}</code></span>{% endif %}
10
10
  {% for key, value in run.params.items() %}<span class="setting">{{ key }} <code>{{ value | value }}</code></span>{% endfor %}
11
11
  </p>
@@ -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