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.
- {labnotes-0.0.102 → labnotes-0.0.104}/PKG-INFO +1 -1
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/context.md +21 -2
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_app.py +1 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/remote_start.py +2 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiments.py +18 -3
- labnotes-0.0.104/src/labnotes/is_non_negative_int.py +2 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_run.py +1 -1
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/models.py +3 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/read_config.py +6 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/read_path.py +1 -1
- labnotes-0.0.104/src/labnotes/retries.py +4 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/describe_event.py +11 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/execute_run.py +23 -2
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/prepare_experiment.py +1 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/read_experiment_config.py +18 -2
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_experiment.py +61 -5
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/run.html +1 -1
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_app.py +31 -4
- {labnotes-0.0.102 → labnotes-0.0.104}/testing-conventions.toml +6 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/.gitignore +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/CHANGELOG.md +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/MIGRATIONS.md +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/README.md +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/pyproject.toml +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/__main__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/compose_experiment.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/count_groups.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_app.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/create_note.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/__init__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/control_run.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_client.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_running.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/daemon_server.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/describe_status.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/ensure_daemon.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/exit_when_idle.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/run_daemon.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/run_logs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/serve_daemon.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/socket_path.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/stop_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/watch_daemon.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon/watch_status.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/daemon_command.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/file_changes.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_started.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/group_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/init.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/invocation_started.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/load_experiment.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/log.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/log_path.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/nests_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/note_path.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_command.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_control.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_output.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/run_status.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_param_run_id.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/param_run_name.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/signal_tree.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/wait_for_tree.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/save_conflict_copy.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/server.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/shared_params.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/elapsed.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/experiment.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/labnotes.css +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/local-time.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/new-note.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/note-edit.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/note-editor.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/run-output.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_run_ids.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiment_toml.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/experiments_ddl.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/file_text.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/note_text.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_blocks.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_log.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/editor_imports.html +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/experiment.html +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/templates/note.html +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/browse_app.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/browse_tui.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/experiment_screen.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/experiments_screen.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/fill_table.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/group_cells.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/invocation_columns.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/invocation_rows.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/leave_prompt.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/log_block.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/log_blocks.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/reload_on_change.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_cells.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_output.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_screen.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/tui/stopped_title.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/wait_for_note.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/src/labnotes/watch_rows.py +0 -0
- {labnotes-0.0.102 → labnotes-0.0.104}/tests/integration/conftest.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
75
|
-
|
|
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.")
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
self.
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|