labcode 0.1.5__tar.gz → 0.1.6__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 (72) hide show
  1. {labcode-0.1.5 → labcode-0.1.6}/PKG-INFO +2 -2
  2. {labcode-0.1.5 → labcode-0.1.6}/SPECIFICATIONS.md +50 -5
  3. {labcode-0.1.5 → labcode-0.1.6}/examples/README.md +12 -0
  4. {labcode-0.1.5 → labcode-0.1.6}/labcode/backend.py +31 -1
  5. {labcode-0.1.5 → labcode-0.1.6}/labcode/dialect.py +8 -2
  6. {labcode-0.1.5 → labcode-0.1.6}/labcode/extension.py +59 -1
  7. {labcode-0.1.5 → labcode-0.1.6}/labcode/run_cli.py +44 -2
  8. {labcode-0.1.5 → labcode-0.1.6}/labcode/runner.py +13 -4
  9. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/PKG-INFO +2 -2
  10. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/requires.txt +1 -1
  11. labcode-0.1.6/labcode.egg-info/scm_version.json +8 -0
  12. {labcode-0.1.5 → labcode-0.1.6}/pyproject.toml +8 -7
  13. {labcode-0.1.5 → labcode-0.1.6}/tests/test_backend.py +52 -0
  14. {labcode-0.1.5 → labcode-0.1.6}/tests/test_dialect.py +29 -0
  15. {labcode-0.1.5 → labcode-0.1.6}/tests/test_run_cli.py +76 -0
  16. labcode-0.1.5/labcode.egg-info/scm_version.json +0 -8
  17. {labcode-0.1.5 → labcode-0.1.6}/.github/workflows/ci.yml +0 -0
  18. {labcode-0.1.5 → labcode-0.1.6}/.github/workflows/publish.yml +0 -0
  19. {labcode-0.1.5 → labcode-0.1.6}/.gitignore +0 -0
  20. {labcode-0.1.5 → labcode-0.1.6}/LICENSE +0 -0
  21. {labcode-0.1.5 → labcode-0.1.6}/MANIFEST.in +0 -0
  22. {labcode-0.1.5 → labcode-0.1.6}/README.md +0 -0
  23. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.boundary.yaml +0 -0
  24. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.observation.yaml +0 -0
  25. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.plan.yaml +0 -0
  26. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.svg +0 -0
  27. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  28. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  29. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  30. {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.svg +0 -0
  31. {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.boundary.yaml +0 -0
  32. {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.env.yaml +0 -0
  33. {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.workflow.yaml +0 -0
  34. {labcode-0.1.5 → labcode-0.1.6}/examples/render_plate_line.py +0 -0
  35. {labcode-0.1.5 → labcode-0.1.6}/examples/render_sila2_plate_cycle.py +0 -0
  36. {labcode-0.1.5 → labcode-0.1.6}/examples/run_all_sila2_examples.py +0 -0
  37. {labcode-0.1.5 → labcode-0.1.6}/examples/run_sila2_plate_cycle.py +0 -0
  38. {labcode-0.1.5 → labcode-0.1.6}/examples/run_sila2_seal.py +0 -0
  39. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  40. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  41. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
  42. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.boundary.yaml +0 -0
  43. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.env.yaml +0 -0
  44. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.workflow.yaml +0 -0
  45. {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.wrapped.env.yaml +0 -0
  46. {labcode-0.1.5 → labcode-0.1.6}/labcode/__init__.py +0 -0
  47. {labcode-0.1.5 → labcode-0.1.6}/labcode/__main__.py +0 -0
  48. {labcode-0.1.5 → labcode-0.1.6}/labcode/_child.py +0 -0
  49. {labcode-0.1.5 → labcode-0.1.6}/labcode/cli.py +0 -0
  50. {labcode-0.1.5 → labcode-0.1.6}/labcode/idgen.py +0 -0
  51. {labcode-0.1.5 → labcode-0.1.6}/labcode/objectid.py +0 -0
  52. {labcode-0.1.5 → labcode-0.1.6}/labcode/probe.py +0 -0
  53. {labcode-0.1.5 → labcode-0.1.6}/labcode/py.typed +0 -0
  54. {labcode-0.1.5 → labcode-0.1.6}/labcode/sila2.py +0 -0
  55. {labcode-0.1.5 → labcode-0.1.6}/labcode/sila2_commands.py +0 -0
  56. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/SOURCES.txt +0 -0
  57. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/dependency_links.txt +0 -0
  58. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/entry_points.txt +0 -0
  59. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/scm_file_list.json +0 -0
  60. {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/top_level.txt +0 -0
  61. {labcode-0.1.5 → labcode-0.1.6}/setup.cfg +0 -0
  62. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/device_script.env.yaml +0 -0
  63. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/device_script.workflow.yaml +0 -0
  64. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/reroute_device.env.yaml +0 -0
  65. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  66. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/transport.env.yaml +0 -0
  67. {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/transport.workflow.yaml +0 -0
  68. {labcode-0.1.5 → labcode-0.1.6}/tests/test_cli.py +0 -0
  69. {labcode-0.1.5 → labcode-0.1.6}/tests/test_objectid.py +0 -0
  70. {labcode-0.1.5 → labcode-0.1.6}/tests/test_probe.py +0 -0
  71. {labcode-0.1.5 → labcode-0.1.6}/tests/test_sila2.py +0 -0
  72. {labcode-0.1.5 → labcode-0.1.6}/tests/test_sila2_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate>=0.1.3
24
24
  Requires-Dist: ofplang-schedule>=0.1.3
25
- Requires-Dist: ofplang-run>=0.1.11
25
+ Requires-Dist: ofplang-run>=0.1.12
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Provides-Extra: dev
@@ -367,8 +367,11 @@ equally available to a `raw` script, and stays visible in the code that depends
367
367
  `settle` that times out fails the *operation* while the instrument carries on. Whatever
368
368
  state that leaves the lab in is the operator's to restore, as for any operation that
369
369
  failed part way. The default timeout is therefore generous rather than tight: its purpose
370
- is to turn a hang into a diagnosable failure, since nothing else in the stack bounds an
371
- operation's running time.
370
+ is to turn a hang into a diagnosable failure.
371
+ - **It is the inner of two limits.** This one is per command, chosen by the script that
372
+ knows what it is waiting for, and its failure can name the command that hung. The outer
373
+ one (§1.8) is per operation and lab-wide, and catches the hangs no script is watching
374
+ for. The outer default is looser than this one, so where both apply this is what fires.
372
375
  - **Its timeout is in real seconds**, and is unrelated to the mode's `duration` — which is
373
376
  an *estimate*, in environment time, for scheduling. A schedule's estimate is not a
374
377
  deadline, and `--seconds-per-tick` does not rescale the timeout.
@@ -379,8 +382,9 @@ equally available to a `raw` script, and stays visible in the code that depends
379
382
 
380
383
  ### 1.7 Where an `x-labcode` may appear
381
384
 
382
- The positions of §1 are the only ones: the environment **root** (`probe` defaults only),
383
- `processes.<p>.modes[]`, `transports[]`, `devices[]` and `transporters[]`. An `x-labcode`
385
+ The positions of §1 are the only ones: the environment **root** (`probe` defaults and
386
+ `op_timeout`), `processes.<p>.modes[]`, `transports[]`, `devices[]` and
387
+ `transporters[]`. An `x-labcode`
384
388
  anywhere else in the environment — on a process, beside `time` — is an **error**, as is a
385
389
  key at a position that does not define it (a `connection` at the root, a `probe` on a mode).
386
390
  Nothing would read it, and `ofplang-schedule` tolerates an `x-` key at *every* position
@@ -390,6 +394,45 @@ This rule covers the environment only. An `x-labcode` in the **workflow** is not
390
394
  that document is portable v0, read by other implementations, and what extension keys it
391
395
  carries is not labcode's business.
392
396
 
397
+ ### 1.8 Operation timeout — `op_timeout`
398
+
399
+ How long **one operation** may run before labcode stops waiting for it, in **real
400
+ seconds**. It lives at the environment root and nowhere else:
401
+
402
+ ```yaml
403
+ x-labcode:
404
+ op_timeout: 7200 # a positive number of real seconds (the default)
405
+ # op_timeout: null # or: wait as long as it takes
406
+ ```
407
+
408
+ - `op_timeout` MUST be a positive, finite number, or `null` for **no limit**. `0` is not a
409
+ way to say "no limit" and is an error; a machine may not declare one (a per-machine key
410
+ is an unknown key, §1.1).
411
+ - **One value for the whole lab.** The fine-grained waits belong to the scripts, which know
412
+ what they are waiting for (`settle`, §1.6.1); this value only has to clear the longest
413
+ operation the lab legitimately runs. Its default (7200 s) is twice the `settle` default,
414
+ so where both apply the inner one — which can name the command — fires first.
415
+ - **The clock is real seconds**, from the moment the operation starts, covering everything
416
+ it does: connecting, every command it issues, and its own waiting. It is unrelated to the
417
+ mode's `duration` (an *estimate*, in environment time, for scheduling) and is not
418
+ rescaled by `--seconds-per-tick`.
419
+ - **What happens when it expires**: the operation's child process is stopped and the
420
+ operation **fails** with the reason code `op_timeout`. That is an ordinary graceful
421
+ failure — the run stops, the status document is written, the reason is reported, the exit
422
+ code is 1 — which is the point: without a limit, an instrument that stops answering
423
+ leaves a run polling with *no* status document and no reason at all.
424
+ - **A timeout is not a cancel**, exactly as in §1.6.1: nothing here can stop a command the
425
+ instrument has already accepted. It keeps running, and the state that leaves behind —
426
+ including material a transport was part way through moving — is the operator's to
427
+ restore. The run stops there, so labcode's own picture of the lab is not relied on
428
+ afterwards.
429
+ - The machine that hung is **not** treated as unavailable: `op_timeout` does not add it to
430
+ the down machines (§1.5), because "not answering" is not "not there", and re-routing work
431
+ onto other machines while this one is still physically running its command would make the
432
+ lab less consistent, not more.
433
+ - `lc run` overrides it for one run: `--op-timeout SECONDS`, or `--no-op-timeout` for no
434
+ limit at all. The order is flag, then document, then default.
435
+
393
436
  ## 2. Code source resolution and exclusivity
394
437
 
395
438
  For a dispatched `(process, mode)`, labcode resolves the code to run in this order:
@@ -412,7 +455,9 @@ Each dispatched operation runs in its own child process (real, wall-clock-paced)
412
455
  runner discovers completion by polling, so a multi-minute computation never blocks it.
413
456
  The advisory `duration` is the scheduler's estimate; the real duration is the script's.
414
457
  A script error (an exception, a wrong/ missing output name, a non-conformant value) is a
415
- graceful runtime failure (§22.2): the operation ends `failed` and the run stops.
458
+ graceful runtime failure (§22.2): the operation ends `failed` and the run stops. An
459
+ operation that never finishes at all ends the same way once it passes `op_timeout` (§1.8) —
460
+ polling for completion is not the same as waiting forever for it.
416
461
 
417
462
  Cadence: the nominal poll period is `poll_interval × seconds_per_tick`. labcode defaults
418
463
  `seconds_per_tick` to ~20 s (so a real op is polled at an observable cadence, not
@@ -168,6 +168,18 @@ arm, so there is nothing to route around, and what a stopped lab should produce
168
168
  instrument command failing where it was issued. `lc run --no-probe` turns probing off for a
169
169
  run without editing the environment.
170
170
 
171
+ ### If a machine stops answering *mid-operation*
172
+
173
+ Probing catches a machine that is not there; it does not catch one that accepted a command
174
+ and never came back. That is what the **operation timeout** is for
175
+ ([`../SPECIFICATIONS.md`](../SPECIFICATIONS.md) §1.8): every operation has a real-seconds
176
+ deadline (7200 s by default, declared lab-wide at the environment root as
177
+ `x-labcode.op_timeout`), and one that passes it is stopped and failed with the reason
178
+ `op_timeout` — a run that ends with a status document and a reason instead of one that
179
+ polls forever. A script that knows its own commands should still bound them itself with
180
+ `settle(..., timeout=...)`: it fires first and can say *which command* hung. `lc run
181
+ --op-timeout SECONDS` / `--no-op-timeout` change the outer limit for a single run.
182
+
171
183
  ### Prerequisites
172
184
 
173
185
  **Verified against [ofplang-sila2-backend](https://github.com/kaizu/sila2-demo) v0.3.0 (commit
@@ -33,6 +33,7 @@ import subprocess
33
33
  import sys
34
34
  import time
35
35
  from collections.abc import Callable
36
+ from typing import Literal
36
37
 
37
38
  from ofplang.run.simulator import (
38
39
  DeviceComputationError,
@@ -41,7 +42,9 @@ from ofplang.run.simulator import (
41
42
  )
42
43
 
43
44
  from labcode.extension import (
45
+ DEFAULT_OP_TIMEOUT,
44
46
  FLAVOR_SILA2,
47
+ declared_op_timeout,
45
48
  device_connections,
46
49
  python_code,
47
50
  script_endpoints,
@@ -65,6 +68,11 @@ from labcode.sila2 import failing_code, plan_clients, wrap
65
68
  # reads as discrete, observable steps rather than a burst of sub-second replans.
66
69
  DEFAULT_SECONDS_PER_TICK = 20.0
67
70
 
71
+ #: `op_timeout="environment"` -- take the operation timeout from the environment document
72
+ #: (or the default). It is a distinct value from `None`, which says the opposite: run with
73
+ #: no deadline at all, whatever the document declares.
74
+ FROM_ENVIRONMENT: Literal["environment"] = "environment"
75
+
68
76
 
69
77
  def _flavored(code: str, script, machines, label: str) -> str:
70
78
  """`code` as its flavor asks for it: a `sila2` script wrapped so its clients are open
@@ -333,6 +341,21 @@ class LabcodeBackend(SubprocessBackend):
333
341
  return uuid
334
342
 
335
343
 
344
+ def _effective_op_timeout(
345
+ environment: dict, requested: float | None | Literal["environment"]
346
+ ) -> float | None:
347
+ """How long an operation may run in this environment, in real seconds (None = no
348
+ limit): what the caller asked for, else what the document declares, else the default.
349
+
350
+ The three layers are `lc run --op-timeout` / `--no-op-timeout`, the environment root's
351
+ ``x-labcode.op_timeout``, and `DEFAULT_OP_TIMEOUT` -- a lab that says nothing still
352
+ gets a limit, because a hang that nobody bounded is the thing this is here to stop."""
353
+ if isinstance(requested, str): # the sentinel: the document decides
354
+ declared, value = declared_op_timeout(environment)
355
+ return value if declared else DEFAULT_OP_TIMEOUT
356
+ return requested
357
+
358
+
336
359
  def labcode_backend_factory(
337
360
  *,
338
361
  seconds_per_tick: float = DEFAULT_SECONDS_PER_TICK,
@@ -345,6 +368,7 @@ def labcode_backend_factory(
345
368
  prober: Prober | None = None,
346
369
  on_availability_change: ChangeReporter | None = None,
347
370
  on_cadence_slip: CadenceReporter | None = None,
371
+ op_timeout: float | None | Literal["environment"] = FROM_ENVIRONMENT,
348
372
  ) -> Callable[[dict], SubprocessBackend]:
349
373
  """Build a ``backend_factory(environment) -> SubprocessBackend`` for the runner,
350
374
  wired with the labcode env-script resolver (`make_code_resolver`).
@@ -361,7 +385,12 @@ def labcode_backend_factory(
361
385
  ``lc run --no-probe`` does). `prober` decides how a machine is checked (default: a TCP
362
386
  connection), and `on_availability_change(id, reachable)` is told when one changes, so a
363
387
  caller can report it. `on_cadence_slip(skipped, budget, spent)` is told once if a poll
364
- cycle outgrows its poll period (see `LabcodeBackend.advance`)."""
388
+ cycle outgrows its poll period (see `LabcodeBackend.advance`).
389
+
390
+ `op_timeout` bounds how long one operation may run, in **real seconds**: by default the
391
+ environment's ``x-labcode.op_timeout`` (or `DEFAULT_OP_TIMEOUT` if it declares none),
392
+ which a caller overrides with a number of its own or `None` for no limit at all -- what
393
+ ``lc run --op-timeout`` / ``--no-op-timeout`` pass."""
365
394
 
366
395
  def factory(environment: dict) -> LabcodeBackend:
367
396
  kwargs: dict = {
@@ -373,6 +402,7 @@ def labcode_backend_factory(
373
402
  "monotonic": monotonic,
374
403
  "sleep": sleep,
375
404
  "on_cadence_slip": on_cadence_slip,
405
+ "op_timeout": _effective_op_timeout(environment, op_timeout),
376
406
  }
377
407
  if probe:
378
408
  kwargs["availability"] = build_availability(
@@ -57,6 +57,7 @@ from labcode.extension import (
57
57
  is_supported_position,
58
58
  merge_probe,
59
59
  parse_connection,
60
+ parse_op_timeout,
60
61
  parse_probe,
61
62
  script_flavor,
62
63
  spot_device,
@@ -143,8 +144,8 @@ def _validate_positions(environment: dict, errors: list) -> None:
143
144
 
144
145
 
145
146
  def _validate_root(environment: dict, errors: list) -> dict:
146
- """Shape-check the environment root's ``x-labcode`` -- document-wide probing defaults,
147
- and nothing else (an address belongs to the machine that has it). Returns the probe
147
+ """Shape-check the environment root's ``x-labcode`` -- document-wide defaults, and
148
+ nothing else (an address belongs to the machine that has it). Returns the probe
148
149
  fields it declares, for the effective policies below."""
149
150
  extension = environment.get(EXTENSION_KEY)
150
151
  if extension is None:
@@ -156,6 +157,11 @@ def _validate_root(environment: dict, errors: list) -> dict:
156
157
  f"environment root: {message}"
157
158
  for message in unknown_key_messages(extension, "x-labcode", ROOT_KEYS)
158
159
  )
160
+ # The operation timeout is one value for the whole lab, so it lives here and nowhere
161
+ # else -- a mode that needs a different wait says so inside its own script.
162
+ if "op_timeout" in extension:
163
+ _value, messages = parse_op_timeout(extension["op_timeout"])
164
+ errors.extend(f"environment root: x-labcode.{message}" for message in messages)
159
165
  raw = extension.get("probe")
160
166
  if raw is None:
161
167
  return {}
@@ -43,7 +43,7 @@ SCRIPT_SITE_KEYS: tuple[str, ...] = ("script",)
43
43
  #: `x-labcode` on a device or a transporter -- how to reach it, and whether to check.
44
44
  NODE_KEYS: tuple[str, ...] = ("connection", "probe")
45
45
  #: `x-labcode` at the environment root -- document-wide defaults, and nothing else.
46
- ROOT_KEYS: tuple[str, ...] = ("probe",)
46
+ ROOT_KEYS: tuple[str, ...] = ("probe", "op_timeout")
47
47
  CONNECTION_KEYS: tuple[str, ...] = ("kind", "host", "port", "insecure")
48
48
  PROBE_KEYS: tuple[str, ...] = ("enabled", "timeout", "interval")
49
49
 
@@ -168,6 +168,64 @@ def parse_connection(raw: Any) -> tuple[Connection | None, list[str]]:
168
168
  return connection, []
169
169
 
170
170
 
171
+ # -- operation timeout ----------------------------------------------------------
172
+
173
+ #: How long one operation may run before labcode gives up on it, in **real seconds**.
174
+ #:
175
+ #: Deliberately generous, and deliberately finite. Its job is not to notice a slow
176
+ #: instrument -- a script that knows what it is waiting for should say so with its own
177
+ #: `labcode.sila2_commands.settle(timeout=...)`, which can name the command that hung.
178
+ #: This is the outer net: it catches the hangs no script is watching for (a `raw` script,
179
+ #: a forgotten `settle`, a stall while connecting), and it exists at all because without
180
+ #: it a silent instrument stops a run with no status document and no reason written.
181
+ #:
182
+ #: It is **one value for the whole lab**, not a per-machine one: the fine-grained waits
183
+ #: belong to the scripts, so the value here only has to clear the longest operation the
184
+ #: lab legitimately runs. It is twice the `settle` default so that when both apply, the
185
+ #: inner wait -- which can say *which command* did not answer -- is the one that fires.
186
+ DEFAULT_OP_TIMEOUT = 7200.0
187
+
188
+
189
+ def parse_op_timeout(raw: Any) -> tuple[float | None, list[str]]:
190
+ """Parse a **declared** ``x-labcode.op_timeout``: seconds, or None for `null` (wait
191
+ forever). Returns ``(value, errors)``.
192
+
193
+ Only call this when the key is present: a declared `null` and no declaration at all
194
+ mean different things (wait forever / use the default), and a mapping cannot tell
195
+ them apart once the value is out."""
196
+ if raw is None:
197
+ return None, []
198
+ if (
199
+ isinstance(raw, bool)
200
+ or not isinstance(raw, (int, float))
201
+ or raw <= 0
202
+ or raw in (float("inf"), float("-inf"))
203
+ or raw != raw # NaN
204
+ ):
205
+ return None, [
206
+ "op_timeout must be a positive, finite number of seconds, or null (no limit)"
207
+ ]
208
+ return float(raw), []
209
+
210
+
211
+ def declared_op_timeout(environment: Any) -> tuple[bool, float | None]:
212
+ """The operation timeout the environment root declares: ``(declared?, seconds)``,
213
+ where `seconds` is None for "wait forever".
214
+
215
+ Best-effort, like the other readers here: a malformed value reads as *not declared*
216
+ (the dialect validator is what reports it), so execution falls back to the default
217
+ rather than silently to no limit at all."""
218
+ if not isinstance(environment, dict):
219
+ return False, None
220
+ extension = environment.get(EXTENSION_KEY)
221
+ if not isinstance(extension, dict) or "op_timeout" not in extension:
222
+ return False, None
223
+ value, errors = parse_op_timeout(extension["op_timeout"])
224
+ if errors:
225
+ return False, None
226
+ return True, value
227
+
228
+
171
229
  # -- probe ---------------------------------------------------------------------
172
230
 
173
231
  #: Probing is **off unless asked for**: writing a policy says *how* to probe, never
@@ -31,8 +31,9 @@ from ofplang.run import FrontDoorResult, front_door_check
31
31
  from ofplang.run.runner import ContractSyntaxError, RunnerError, load_document, serialize_document
32
32
  from ofplang.run.simulator import SimulatorError
33
33
 
34
- from labcode.backend import DEFAULT_SECONDS_PER_TICK
34
+ from labcode.backend import DEFAULT_SECONDS_PER_TICK, FROM_ENVIRONMENT
35
35
  from labcode.dialect import validate_dialect
36
+ from labcode.extension import DEFAULT_OP_TIMEOUT
36
37
  from labcode.runner import run_labcode
37
38
 
38
39
  EXIT_OK = 0
@@ -84,6 +85,19 @@ def _build_parser() -> argparse.ArgumentParser:
84
85
  help="ignore the environment's x-labcode.probe policies and treat every machine as"
85
86
  " reachable (the document is still validated)",
86
87
  )
88
+ # One operation timeout for the whole lab, in real seconds. The two forms exclude each
89
+ # other: asking for a limit and for no limit in the same breath is a mistake, not a
90
+ # precedence puzzle to resolve quietly.
91
+ timeout = p.add_mutually_exclusive_group()
92
+ timeout.add_argument(
93
+ "--op-timeout", type=float, default=None, metavar="S",
94
+ help="stop and fail an operation that has run for S real seconds (default: the"
95
+ f" environment's x-labcode.op_timeout, else {DEFAULT_OP_TIMEOUT:g})",
96
+ )
97
+ timeout.add_argument(
98
+ "--no-op-timeout", action="store_true",
99
+ help="let an operation run for as long as it takes, whatever the environment says",
100
+ )
87
101
  return p
88
102
 
89
103
 
@@ -131,6 +145,16 @@ def main(argv: Sequence[str] | None = None) -> int:
131
145
  print(f"lc run: {label} not found: {path!r}", file=sys.stderr)
132
146
  return EXIT_USAGE
133
147
 
148
+ # A limit of zero (or less) is not "no limit" -- that is what --no-op-timeout says --
149
+ # so it is a usage error rather than something to interpret.
150
+ if args.op_timeout is not None and not args.op_timeout > 0:
151
+ print(
152
+ f"lc run: --op-timeout must be a positive number of seconds "
153
+ f"(got {args.op_timeout:g}); use --no-op-timeout for no limit",
154
+ file=sys.stderr,
155
+ )
156
+ return EXIT_USAGE
157
+
134
158
  # Shared workflow front door (ofplang-validate + capability gate; validate skippable).
135
159
  fd = front_door_check(args.workflow, validate=not args.no_validate)
136
160
  if not fd.ok:
@@ -193,6 +217,16 @@ def main(argv: Sequence[str] | None = None) -> int:
193
217
  else ""
194
218
  )
195
219
 
220
+ def timeout_note(kind: str) -> str:
221
+ # The upstream failure says what happened and that nothing cancelled the
222
+ # instrument; what it cannot say is how *this* CLI's user changes the limit.
223
+ if kind != "op_timeout":
224
+ return ""
225
+ return (
226
+ " (raise the limit with --op-timeout SECONDS or the environment root's"
227
+ " x-labcode.op_timeout, or drop it entirely with --no-op-timeout)"
228
+ )
229
+
196
230
  try:
197
231
  # Validation already ran at the front doors above, so run trusting. `run_labcode`
198
232
  # owns the labcode Object-identity setup (rewrite the workflow's Object types to
@@ -214,6 +248,13 @@ def main(argv: Sequence[str] | None = None) -> int:
214
248
  probe=not args.no_probe,
215
249
  on_availability_change=report_availability,
216
250
  on_cadence_slip=report_cadence_slip,
251
+ # Three layers, outermost first: this flag, the environment, the default.
252
+ # `FROM_ENVIRONMENT` is "the flag says nothing"; None is "no limit at all".
253
+ op_timeout=(
254
+ None if args.no_op_timeout
255
+ else FROM_ENVIRONMENT if args.op_timeout is None
256
+ else args.op_timeout
257
+ ),
217
258
  )
218
259
  except (yaml.YAMLError, ContractSyntaxError) as exc:
219
260
  print(f"lc run: invalid input: {exc}", file=sys.stderr)
@@ -232,7 +273,8 @@ def main(argv: Sequence[str] | None = None) -> int:
232
273
  failure = result.failure
233
274
  if failure is not None:
234
275
  print(
235
- f"lc run: execution failed: {failure.kind}: {failure.detail}{probe_note()}",
276
+ f"lc run: execution failed: {failure.kind}: {failure.detail}"
277
+ f"{probe_note()}{timeout_note(failure.kind)}",
236
278
  file=sys.stderr,
237
279
  )
238
280
  else:
@@ -18,11 +18,16 @@ from __future__ import annotations
18
18
 
19
19
  import time
20
20
  from collections.abc import Callable
21
+ from typing import Literal
21
22
 
22
23
  from ofplang.run.app import RunResult
23
24
  from ofplang.run.runner import RollingRunner, RunnerError, load_document
24
25
 
25
- from labcode.backend import DEFAULT_SECONDS_PER_TICK, labcode_backend_factory
26
+ from labcode.backend import (
27
+ DEFAULT_SECONDS_PER_TICK,
28
+ FROM_ENVIRONMENT,
29
+ labcode_backend_factory,
30
+ )
26
31
  from labcode.idgen import IdGenerator, SeededUuid4Generator
27
32
  from labcode.objectid import inject_boundary_ids, inject_id_field, reserved_collisions
28
33
  from labcode.probe import CadenceReporter, ChangeReporter, Prober
@@ -35,9 +40,11 @@ class LabcodeRunner(RollingRunner):
35
40
  runner reads it itself). Object types are rewritten to declare ``_id``, the boundary's
36
41
  Object inputs are minted, and the labcode backend is wired with a shared `IdGenerator`
37
42
  (default: reproducible, seeded). `seconds_per_tick` / `speed` / `spawn` / `monotonic` /
38
- `sleep` configure the wall-clock backend and `probe` / `prober` /
39
- `on_availability_change` its availability probing (`labcode.probe`); any other keyword
40
- is forwarded to `RollingRunner` (e.g. `random_seed`, `down_scope`, `observation_out`).
43
+ `sleep` configure the wall-clock backend, `probe` / `prober` /
44
+ `on_availability_change` its availability probing (`labcode.probe`), and `op_timeout`
45
+ how long one operation may run before it is stopped and failed (default: whatever the
46
+ environment's ``x-labcode.op_timeout`` says); any other keyword is forwarded to
47
+ `RollingRunner` (e.g. `random_seed`, `down_scope`, `observation_out`).
41
48
 
42
49
  **`running_task_margin` defaults to the poll interval here**, not to the upstream 0.
43
50
  The margin is how far ahead of *now* a still-running operation is assumed to finish
@@ -64,6 +71,7 @@ class LabcodeRunner(RollingRunner):
64
71
  prober: Prober | None = None,
65
72
  on_availability_change: ChangeReporter | None = None,
66
73
  on_cadence_slip: CadenceReporter | None = None,
74
+ op_timeout: float | None | Literal["environment"] = FROM_ENVIRONMENT,
67
75
  poll_interval: int | None = 1,
68
76
  running_task_margin: int | None = None,
69
77
  **rolling_kwargs,
@@ -91,6 +99,7 @@ class LabcodeRunner(RollingRunner):
91
99
  prober=prober,
92
100
  on_availability_change=on_availability_change,
93
101
  on_cadence_slip=on_cadence_slip,
102
+ op_timeout=op_timeout,
94
103
  )
95
104
  # A margin of at least one tick, defaulting to the poll interval (see the class
96
105
  # docstring). An explicit value is honoured as given -- including 0, for a caller
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate>=0.1.3
24
24
  Requires-Dist: ofplang-schedule>=0.1.3
25
- Requires-Dist: ofplang-run>=0.1.11
25
+ Requires-Dist: ofplang-run>=0.1.12
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Provides-Extra: dev
@@ -1,6 +1,6 @@
1
1
  ofplang-validate>=0.1.3
2
2
  ofplang-schedule>=0.1.3
3
- ofplang-run>=0.1.11
3
+ ofplang-run>=0.1.12
4
4
 
5
5
  [dev]
6
6
  pytest>=7.0
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.6",
3
+ "distance": 0,
4
+ "node": "g6ac79222ac2f8e4280981ac7b9c860b5862bdbfe",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-15"
8
+ }
@@ -25,16 +25,17 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  ]
27
27
  # labcode bundles the toolchain by depending on the ofplang sibling packages. `lc run`
28
- # is the labcode dialect backend, so the floors reflect what it needs: run must take a
29
- # *transporter* out of the plan when `down_devices` reports one, which is what availability
30
- # probing depends on for the machines that carry material (>=0.1.11); schedule's in-memory
31
- # workflow acceptance (>=0.1.3); validate's `$import` expansion API so the front door hands
32
- # the expanded document on (>=0.1.3). `validate` and `schedule` subcommands still forward
33
- # unchanged.
28
+ # is the labcode dialect backend, so the floors reflect what it needs: run must bound how
29
+ # long one operation may run (`SubprocessBackend(op_timeout=...)`), which is what the
30
+ # lab-wide `x-labcode.op_timeout` is carried by (>=0.1.12, and which also takes a
31
+ # *transporter* out of the plan when `down_devices` reports one, from 0.1.11); schedule's
32
+ # in-memory workflow acceptance (>=0.1.3); validate's `$import` expansion API so the front
33
+ # door hands the expanded document on (>=0.1.3). `validate` and `schedule` subcommands
34
+ # still forward unchanged.
34
35
  dependencies = [
35
36
  "ofplang-validate>=0.1.3",
36
37
  "ofplang-schedule>=0.1.3",
37
- "ofplang-run>=0.1.11",
38
+ "ofplang-run>=0.1.12",
38
39
  ]
39
40
 
40
41
  [project.urls]
@@ -19,6 +19,7 @@ from labcode.backend import (
19
19
  make_code_resolver,
20
20
  make_transport_resolver,
21
21
  )
22
+ from labcode.extension import DEFAULT_OP_TIMEOUT
22
23
 
23
24
  WF_DEF = {"script": {"language": "python", "code": "WF"}}
24
25
 
@@ -84,6 +85,57 @@ def test_factory_builds_labcode_backend():
84
85
  backend.close()
85
86
 
86
87
 
88
+ # -- the operation timeout: three layers, outermost first ----------------------
89
+
90
+
91
+ def _timeout_env(**extension) -> dict:
92
+ env = {
93
+ "time": {"unit": "second"},
94
+ "devices": [{"id": "rack", "spots": ["slot"]}],
95
+ "transporters": [{"id": "arm"}],
96
+ "transports": [],
97
+ "processes": {},
98
+ "objective": {"kind": "makespan"},
99
+ }
100
+ if extension:
101
+ env["x-labcode"] = extension
102
+ return env
103
+
104
+
105
+ def _built(env: dict, **kw) -> float | None:
106
+ backend = labcode_backend_factory(seconds_per_tick=0.001, **kw)(env)
107
+ try:
108
+ return backend._op_timeout
109
+ finally:
110
+ backend.close()
111
+
112
+
113
+ def test_a_lab_that_says_nothing_still_gets_a_limit():
114
+ # The point of the default: the hang that stops a run silently is exactly the one
115
+ # nobody thought to bound.
116
+ assert _built(_timeout_env()) == DEFAULT_OP_TIMEOUT
117
+
118
+
119
+ def test_the_environment_sets_the_limit():
120
+ assert _built(_timeout_env(op_timeout=120)) == 120.0
121
+
122
+
123
+ def test_the_environment_can_ask_to_wait_forever():
124
+ # A declared null is a decision on the record, unlike saying nothing.
125
+ assert _built(_timeout_env(op_timeout=None)) is None
126
+
127
+
128
+ def test_the_caller_overrides_the_environment():
129
+ assert _built(_timeout_env(op_timeout=120), op_timeout=30.0) == 30.0
130
+ assert _built(_timeout_env(op_timeout=120), op_timeout=None) is None
131
+
132
+
133
+ def test_a_malformed_limit_falls_back_to_the_default():
134
+ # The dialect front door rejects it; if one reaches execution anyway, falling back to
135
+ # the default is the safe reading -- silently meaning "no limit" is not.
136
+ assert _built(_timeout_env(op_timeout="soon")) == DEFAULT_OP_TIMEOUT
137
+
138
+
87
139
  # -- transport resolution / dispatch -------------------------------------------
88
140
 
89
141
 
@@ -145,6 +145,35 @@ def test_a_connection_at_the_environment_root_is_rejected():
145
145
  assert any("unknown key 'connection'" in e for e in result.errors)
146
146
 
147
147
 
148
+ def test_the_root_holds_the_operation_timeout():
149
+ # One value for the whole lab: how long any operation may run before labcode stops
150
+ # waiting for it. A positive number of real seconds, or null for no limit at all.
151
+ for value in (7200, 0.5, None):
152
+ env = _device_env(_device())
153
+ env["x-labcode"] = {"op_timeout": value}
154
+ result = validate_dialect({}, env)
155
+ assert result.ok, result.errors
156
+
157
+
158
+ def test_a_nonsensical_operation_timeout_is_rejected():
159
+ # Zero is not "no limit" (null is), and neither a string nor an infinity is a wait.
160
+ for value in (0, -1, "3600", True, float("inf"), float("nan")):
161
+ env = _device_env(_device())
162
+ env["x-labcode"] = {"op_timeout": value}
163
+ result = validate_dialect({}, env)
164
+ assert not result.ok, value
165
+ assert any("op_timeout" in e for e in result.errors)
166
+
167
+
168
+ def test_an_operation_timeout_on_a_machine_is_rejected():
169
+ # Per-machine limits are deliberately not a thing: a script that knows what it waits
170
+ # for says so itself (`settle`), and the outer net is one number for the lab.
171
+ env = _device_env(_device(**{"x-labcode": {"op_timeout": 60}}))
172
+ result = validate_dialect({}, env)
173
+ assert not result.ok
174
+ assert any("unknown key 'op_timeout'" in e for e in result.errors)
175
+
176
+
148
177
  def test_x_labcode_on_a_process_is_rejected():
149
178
  env = _env({"id": "v0"})
150
179
  env["processes"]["m"]["x-labcode"] = {"connection": CONNECTION}
@@ -275,6 +275,82 @@ def test_cli_no_probe_ignores_the_policy(tmp_path, capsys):
275
275
  assert "unreachable" not in capsys.readouterr().err
276
276
 
277
277
 
278
+ # -- the operation timeout -----------------------------------------------------
279
+
280
+ #: `device_script.env.yaml`, but the measurement never returns. This is what an
281
+ #: instrument that has stopped answering looks like from labcode's side: the child is
282
+ #: alive, so no poll will ever complete the op.
283
+ HANGING_ENV = """\
284
+ time: {unit: second}
285
+ devices: [{id: rack, spots: [slot]}]
286
+ transporters: [{id: arm}]
287
+ transports: []
288
+ processes:
289
+ measure:
290
+ modes:
291
+ - id: v0
292
+ duration: 3
293
+ x-labcode:
294
+ script:
295
+ language: python
296
+ code: |
297
+ import time
298
+ time.sleep(600)
299
+ return {"od": 0.42}
300
+ objective: {kind: makespan}
301
+ """
302
+
303
+
304
+ def test_cli_stops_a_hung_operation_and_says_how_to_change_the_limit(tmp_path, capsys):
305
+ # End to end with a real child process: the script hangs, the deadline stops it, and
306
+ # the run ends the way any failure does -- a status document, a reason, exit 1 --
307
+ # instead of polling for as long as anyone is willing to wait.
308
+ pytest.importorskip("ofplang.schedule", reason="ofplang-schedule not installed")
309
+ env = tmp_path / "hanging.env.yaml"
310
+ env.write_text(HANGING_ENV, encoding="utf-8")
311
+ status = tmp_path / "status.yaml"
312
+ code = run_cli.main([
313
+ WF, "--env", str(env), "--seconds-per-tick", "0.01",
314
+ "--op-timeout", "1", "-o", str(status),
315
+ ])
316
+ assert code == 1
317
+ err = capsys.readouterr().err
318
+ assert "op_timeout" in err
319
+ assert "did not finish within 1s" in err
320
+ assert "--no-op-timeout" in err # the way out is in the message
321
+ assert status.exists() # the failure is on the record, not just on stderr
322
+
323
+
324
+ def test_cli_rejects_a_zero_timeout(tmp_path, capsys):
325
+ # Zero is not a way to say "no limit"; --no-op-timeout is.
326
+ code = run_cli.main([WF, "--env", ENV, "--op-timeout", "0"])
327
+ assert code == 2
328
+ assert "--no-op-timeout" in capsys.readouterr().err
329
+
330
+
331
+ def test_cli_rejects_asking_for_both_a_limit_and_no_limit():
332
+ with pytest.raises(SystemExit) as excinfo:
333
+ run_cli.main([WF, "--env", ENV, "--op-timeout", "10", "--no-op-timeout"])
334
+ assert excinfo.value.code == 2
335
+
336
+
337
+ def test_cli_no_op_timeout_asks_for_no_deadline(monkeypatch):
338
+ # The flag has to reach the backend, since nothing else can express "wait forever".
339
+ seen = {}
340
+
341
+ def fake_run(*args, **kwargs):
342
+ from ofplang.run.app import RunResult
343
+
344
+ seen["op_timeout"] = kwargs.get("op_timeout")
345
+ return RunResult(
346
+ status={"now": 0, "activities": []}, result_boundary={}, failed=False, failure=None
347
+ )
348
+
349
+ monkeypatch.setattr(run_cli, "run_labcode", fake_run)
350
+ assert run_cli.main([WF, "--env", ENV, "--no-op-timeout"]) == 0
351
+ assert seen["op_timeout"] is None
352
+
353
+
278
354
  def test_cli_warns_on_typed_default(tmp_path, capsys, monkeypatch):
279
355
  # A process with no script warns (typed-default no-op) but still runs. Stub the run so
280
356
  # the test does not spend real wall-clock time.
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.1.5",
3
- "distance": 0,
4
- "node": "gad4beebfe8287e650c5d40dfe4ce43a0e9f47139",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-10"
8
- }
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