Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Files that must change in the apps (the template-repository change is a later ta
* `labconstrictor-tools run` without `--out` writes its results to `<LC_HOME>/results/<time>_<app>_<tool>` (the newest 20 finished ones are kept by `labconstrictor-tools prune`, which the hosts start after a run; Napari and Fiji use their own temporary folders and remove them). `prune` removes only folders that carry LabConstrictor's own marker files (`.lc-owner.json` and `.lc-done.json`): a folder you made yourself, or one from an older version without markers, is never touched. Look first with `labconstrictor-tools prune --dry-run`; the rules are in [PROTOCOL.md](PROTOCOL.md#result-folders-and-run-records-retention).
* Every run writes `<LC_HOME>/runs/<time>_<app>_<tool>/run.json` (parameters as file paths and values, never pixel data; status,
error, traceback, interpreter, worker output). Napari's *Details...* button shows it; attach it to bug reports. The newest 50 finished records are kept (`prune`, same rules).
A Python host that makes its own record folder (Napari) builds the content with `labconstrictor_tools.runs.build_record`, writes it with `runs.record_json` and picks the done status with `runs.done_status` (a crash after Cancel is `cancelled`); none of them prunes, the host starts `labconstrictor-tools prune` itself ([PROTOCOL.md](PROTOCOL.md#result-folders-and-run-records-retention)). They exist from the first release after 0.2.0rc1 (same release as `retention_prune`): when you cut it, write its number in that PROTOCOL.md paragraph so hosts can use it as their minimum, and tell the host repositories. There is no feature id for them.
* First run slow? Imports dominate (a repeat run in a kept worker is ~10x faster). Napari keeps the worker for 10 minutes by default.
* Tool hangs on Windows? Please report with the run record; two such hangs (numpy/scipy imports vs a pending stdin read) were found
and fixed during development.
Expand Down
7 changes: 7 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ Under `<LC_HOME>` two folders hold what runs leave behind: `results/<YYYYMMDDTHH
* **What `prune` removes**: the oldest folders beyond the newest 20 (`results`) or 50 (`runs`), counted over eligible folders only. Eligible = a real directory (not a symlink, junction or other reparse point) that is a direct child of the root, with a name of `A-Za-z0-9_.-`, a valid owner marker whose `name` is the folder's own name and whose `kind` matches the root, and a done marker. Foreign folders, legacy folders without a marker, folders with no done marker (active or crashed), links and files are never touched and never use up the quota. Order is the marker's `created`, then the name.
* **How**: one non-blocking lock on `<LC_HOME>/.retention.lock` (if it cannot be taken, `prune` exits 0 and deletes nothing); a folder is renamed to `.lc-deleting-<token>` inside the root, then removed without following links; a leftover of an interrupted removal is finished by the next `prune`. `--dry-run` lists exactly what a real run would remove and changes nothing. Exit code: 0, also when the lock is held; 1 when a folder that should go could not be removed (the report says which and why). `--out` folders, `state`, `logs` and everything outside the two roots are never touched.

* **A Python host that writes its own run record** (Napari). `runs.record` is for the CLI and the Python client: it makes the folder when the run is over, writes `.lc-owner.json`, `run.json` and `.lc-done.json`, and then prunes. A host makes its folder when the run *starts* (owner marker first, so a crash leaves an owned, unfinished folder that nobody removes) and must never prune, so it uses three functions that create, delete and prune nothing, instead of copying them:
* `runs.build_record(app, tool_id, inputs, task, seconds, stderr="", *, run_id=None, started_utc=None, finished_utc=None) -> dict`: the content of `run.json`, the same dict `runs.record` writes (the host-owned `_job_dir` and `matrix_yx` are left out, the stderr tail is cut to `runs.STDERR_TAIL_CHARS`, `finished_utc` defaults to now). With `run_id` and `started_utc` the record also carries them (`run_id` first, `started_utc` after `tool`); without them it has neither key, which is what the CLI writes.
* `runs.record_json(content) -> str`: the text to put in `run.json` (indented, anything that is not JSON written as text).
* `runs.done_status(task_status, cancel_requested=False) -> str`: the status for `.lc-done.json` ([SPEC_WRONG_SCIENCE.md](SPEC_WRONG_SCIENCE.md) 3.2): `COMPLETE` is `ok`, `CANCELED` is `cancelled`, `FAILED` is `failed`, and `CRASHED` is `cancelled` when the person pressed Cancel (`Task.cancel_requested`) and `failed` when not.

The host writes the owner marker with `retention.write_owner(folder, "run", "<host>")`, `run.json` with the text above, the done marker with `retention.write_done(folder, runs.done_status(...))`, and then starts `labconstrictor-tools prune` in a subprocess (compatibility rule 6 above). **Version:** the three functions arrive in the first Tools release after 0.2.0rc1, the release that also lists `retention_prune`; they are not in 0.2.0rc1. A host that calls them requires that release as its `min_tools_version` (the number is written here when the release is tagged; until then it checks `hasattr(runs, "build_record")`). **There is no `features` id for them**: an id says a worker or CLI behaviour exists, which a host in any language can check in the schema, and these are additive functions of the Python package that only a Python host imports; a version minimum says the same thing without a second list. `runs.record` and the CLI behave as before, except that a crash after Cancel now gets `cancelled` (the CLI cannot produce that case: it never calls `Task.cancel`).

## Reserved inputs
A request's `inputs` may carry fields that are not tool parameters. Their names start with an underscore and belong to the protocol; a tool cannot declare a parameter with one of these names (declaration error). **A worker accepts every name that starts with an underscore and ignores the ones it does not know**: a newer host may send a field an older worker predates, and that must never fail the task. Tools never see reserved inputs. The list (`labconstrictor_tools.protocol.RESERVED_INPUTS`, tested against this table):

Expand Down
1 change: 1 addition & 0 deletions docs/REGRESSION_LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ How to add one: [TESTING.md](TESTING.md#the-ledger).
| 2026-10-12 | An app registered with a relative `--prefix` or `--pythonpath` disappeared from `list` / loaded other code when a host started from another folder | `registry.register` stored the paths as typed, and hosts read them relative to their own working directory | [#102](https://github.com/CellMigrationLab/LabConstrictor-Tools/pull/102) | `test_roundtrip_registration_paths.py::RelativeRegistration.test_a_relative_prefix_and_pythonpath_still_work_from_another_folder` |
| 2026-10-12 | Found in the versioning plan, before any user saw it: enforcing issue #51 (reject undeclared inputs) would have refused the reserved fields `_calibration` and `_axes` that new hosts send, so every task from a new host would fail on an old or a strict worker | Only `_job_dir` was known to the worker; "reserved" was a convention, not a list that the input check consults | this PR: `protocol.RESERVED_INPUTS` (documented in PROTOCOL.md), every other leading-underscore name accepted and ignored, `convert.undeclared_inputs` is the single check #51 must use, a tool cannot declare a reserved name | `tests/test_reserved_inputs.py::ReservedList.test_load_inputs_consults_the_reserved_list`, `tests/test_reserved_inputs.py::WorkerAcceptsThem.test_a_real_worker_ignores_reserved_inputs_it_knows_and_those_it_does_not`, `tests/test_reserved_inputs.py::ReservedList.test_the_documented_list_is_the_code_list` |
| 2026-10-12 | Found in the versioning plan: a host could not tell which Tools wrote a cached schema or whether a rule exists, so a host that needs a newer rule would misbehave silently on an app with an old Tools | Schema and registry entry recorded neither a Tools version nor features | this PR: `tools_version` and `features` in schema and entry (additive, protocol 1), `compat.check_schema` (ok/warn/refuse), CI job `compat` against the last tag with frozen 0.1/0.2 schema fixtures | `tests/test_compat.py::Rules.test_every_rule`, `tests/test_tools_features.py::RegistryAndDoctor.test_register_writes_them_into_the_entry_and_the_cached_schema`, `tests/test_compat_matrix.py::Script.test_every_check_of_the_matrix_passes_against_itself` |
| 2026-10-13 | Found by Napari phase 2 (LabConstrictor-napari #51): a host that makes its own run-record folder and must never prune had no way to write the record except by copying `runs.record` (the content of `run.json` and the status mapping), so its copy could drift from the CLI's, and a task that crashed after the person pressed Cancel was recorded `failed` | `runs.record` did everything in one function (folder, owner marker, content, done marker, prune) and mapped only `CANCELED` to `cancelled` | this PR: `runs.build_record`, `runs.record_json` and `runs.done_status` (pure, no folder, no prune); `runs.record` uses them and its behaviour is unchanged except that a crash after Cancel is `cancelled` (SPEC 3.2) | `test_run_record_builder.py::BuildRecord.test_the_content_is_what_a_run_record_holds_and_nothing_else`, `test_run_record_builder.py::BuildRecord.test_it_writes_nothing_and_prunes_nothing`, `test_run_record_builder.py::BuildRecord.test_record_writes_the_same_content`, `test_run_record_builder.py::DoneStatus.test_every_way_a_task_ends`, `test_run_record_builder.py::HostFlow.test_a_host_record_is_finished_without_a_prune_and_pruned_later_by_tools_only` |

## Found by the round-trip matrix, fix PRs pending

Expand Down
90 changes: 67 additions & 23 deletions labconstrictor_tools/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
One folder per run under <LC_HOME>/runs/, carrying the owner and done markers of docs/SPEC_WRONG_SCIENCE.md section 3;
`retention.prune` keeps the newest 50 finished ones and never touches a folder it did not make. Everything stays on the local machine. A record holds
file *paths* and parameter values you passed, never image or table contents.

`record` is what the CLI and the Python client use: it makes the folder, writes everything and prunes. A host that makes its own
folder (owner marker when the run starts, so a crash leaves an owned, unfinished folder) and must never prune uses the pieces
instead: `build_record` (the content of `run.json`), `record_json` (its text) and `done_status` (the status for `.lc-done.json`),
then `retention.write_done`, then starts `labconstrictor-tools prune` itself (docs/PROTOCOL.md, "Result folders and run records").
"""

import json
Expand All @@ -23,6 +28,65 @@ def runs_dir() -> Path:
return registry.home() / "runs"


def done_status(task_status: str, cancel_requested: bool = False) -> str:
"""The `.lc-done.json` status of a task that ended as `task_status` (docs/SPEC_WRONG_SCIENCE.md 3.2, decided 13 Oct).

COMPLETE is ok, CANCELED is cancelled, FAILED is failed; a CRASHED task is cancelled when the person had pressed Cancel
(`Task.cancel_requested`: the worker was killed because the tool did not stop) and failed otherwise. Any other status is failed.
"""
if task_status == "CRASHED" and cancel_requested:
return "cancelled"
return _DONE_STATUS.get(task_status, "failed")


def build_record(
app: str,
tool_id: str,
inputs: dict[str, Any],
task: Any,
seconds: float,
stderr: str = "",
*,
run_id: str | None = None,
started_utc: str | None = None,
finished_utc: str | None = None,
) -> dict[str, Any]:
"""The content of `run.json` for one finished task, exactly what `record` writes; no folder, no marker, no pruning.

`finished_utc` defaults to now (UTC, ISO-8601). A host that knows its own `run_id` and the time the run started passes them and
gets `run_id` and `started_utc` in the record; without them the record has no such keys (the CLI's record is unchanged).
It reads the registry for the app's version and writes nothing. Write the text with `record_json`.
"""
entry: Mapping[str, Any] = registry.load_all().get(app) or {}
return {
**({"run_id": run_id} if run_id is not None else {}),
"app": app,
"app_version": entry.get("version"),
"tool": tool_id,
**({"started_utc": started_utc} if started_utc is not None else {}),
"finished_utc": finished_utc or datetime.now(timezone.utc).isoformat(),
"seconds": round(seconds, 2),
"status": task.status,
"error": task.error,
"code": task.code,
"traceback": task.traceback,
"inputs": {
k: v for k, v in inputs.items() if k != JOB_DIR_KEY
}, # the host-owned folder is not a user input
"results": [
{k: v for k, v in r.items() if k != "matrix_yx"} for r in task.outputs.get("results", [])
],
"timings": task.outputs.get("timings"),
"interpreter": task.outputs.get("diagnostics"),
"stderr_tail": stderr[-STDERR_TAIL_CHARS:],
}


def record_json(content: Mapping[str, Any]) -> str:
"""The text of `run.json` for `content` (what `record` writes: indented, anything that is not JSON written as text)."""
return json.dumps(content, indent=2, default=str)


def record(
app: str, tool_id: str, inputs: dict[str, Any], task: Any, seconds: float, stderr: str = ""
) -> Path | None:
Expand All @@ -33,29 +97,9 @@ def record(
runs_dir().mkdir(parents=True, exist_ok=True)
folder.mkdir() # never an existing folder: it would be marked as ours
retention.write_owner(folder, "run", "cli")
entry: Mapping[str, Any] = registry.load_all().get(app) or {}
content = {
"app": app,
"app_version": entry.get("version"),
"tool": tool_id,
"finished_utc": started.isoformat(),
"seconds": round(seconds, 2),
"status": task.status,
"error": task.error,
"code": task.code,
"traceback": task.traceback,
"inputs": {
k: v for k, v in inputs.items() if k != JOB_DIR_KEY
}, # the host-owned folder is not a user input
"results": [
{k: v for k, v in r.items() if k != "matrix_yx"} for r in task.outputs.get("results", [])
],
"timings": task.outputs.get("timings"),
"interpreter": task.outputs.get("diagnostics"),
"stderr_tail": stderr[-STDERR_TAIL_CHARS:],
}
(folder / "run.json").write_text(json.dumps(content, indent=2, default=str), encoding="utf-8")
retention.write_done(folder, _DONE_STATUS.get(task.status, "failed"))
content = build_record(app, tool_id, inputs, task, seconds, stderr, finished_utc=started.isoformat())
(folder / "run.json").write_text(record_json(content), encoding="utf-8")
retention.write_done(folder, done_status(task.status, getattr(task, "cancel_requested", False)))
retention.prune("runs")
return folder
except (
Expand Down
Loading
Loading