flashruntime 0.3.0__py3-none-any.whl

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 (95) hide show
  1. flashml_workloads/__init__.py +7 -0
  2. flashml_workloads/fedavg_driver.py +569 -0
  3. flashml_workloads/fedavg_weights.py +223 -0
  4. flashml_workloads/fedavg_worker.py +166 -0
  5. flashml_workloads/kmeans_driver.py +134 -0
  6. flashml_workloads/kmeans_shard.py +69 -0
  7. flashml_workloads/sgd_trainer.py +127 -0
  8. flashml_workloads/sharded_kmeans.py +323 -0
  9. flashml_workloads/sklearn_trial.py +89 -0
  10. flashruntime/__init__.py +125 -0
  11. flashruntime/artifacts/__init__.py +25 -0
  12. flashruntime/artifacts/store.py +228 -0
  13. flashruntime/backends/__init__.py +26 -0
  14. flashruntime/backends/base.py +63 -0
  15. flashruntime/backends/kuberay.py +465 -0
  16. flashruntime/checkpoint/__init__.py +20 -0
  17. flashruntime/checkpoint/catalog.py +198 -0
  18. flashruntime/checkpoint/local.py +109 -0
  19. flashruntime/checkpoint/store.py +86 -0
  20. flashruntime/integrations/__init__.py +5 -0
  21. flashruntime/integrations/huggingface.py +59 -0
  22. flashruntime/integrations/pytorch.py +52 -0
  23. flashruntime/integrations/sklearn.py +42 -0
  24. flashruntime/launchers/__init__.py +130 -0
  25. flashruntime/launchers/local.py +126 -0
  26. flashruntime/leases/__init__.py +27 -0
  27. flashruntime/leases/manager.py +365 -0
  28. flashruntime/leases/sqlite_store.py +169 -0
  29. flashruntime/leases/store.py +103 -0
  30. flashruntime/monitor/__init__.py +7 -0
  31. flashruntime/monitor/sampler.py +232 -0
  32. flashruntime/planner/__init__.py +56 -0
  33. flashruntime/planner/candidates.py +597 -0
  34. flashruntime/planner/catalog.py +129 -0
  35. flashruntime/planner/comm.py +95 -0
  36. flashruntime/planner/explain.py +109 -0
  37. flashruntime/planner/memory.py +166 -0
  38. flashruntime/planner/resolve.py +120 -0
  39. flashruntime/planner/selector.py +169 -0
  40. flashruntime/planner/timecost.py +81 -0
  41. flashruntime/profiling/__init__.py +113 -0
  42. flashruntime/protocol/__init__.py +18 -0
  43. flashruntime/protocol/plan_v1alpha1.py +320 -0
  44. flashruntime/protocol/v1alpha1.py +465 -0
  45. flashruntime/providers/__init__.py +138 -0
  46. flashruntime/py.typed +0 -0
  47. flashruntime/recipes/__init__.py +135 -0
  48. flashruntime/recipes/command.py +166 -0
  49. flashruntime/recovery/__init__.py +21 -0
  50. flashruntime/recovery/policy.py +170 -0
  51. flashruntime/recovery/signals.py +135 -0
  52. flashruntime/recovery/taxonomy.py +91 -0
  53. flashruntime/scheduler/__init__.py +170 -0
  54. flashruntime/sdk.py +402 -0
  55. flashruntime/service/__init__.py +3 -0
  56. flashruntime/service/app.py +391 -0
  57. flashruntime/service/auth.py +180 -0
  58. flashruntime/service/checkpoints.py +90 -0
  59. flashruntime/service/cli.py +167 -0
  60. flashruntime/service/dashboard.py +193 -0
  61. flashruntime/service/ledger.py +101 -0
  62. flashruntime/service/modea.py +821 -0
  63. flashruntime/strategies/__init__.py +156 -0
  64. flashruntime/strategies/command.py +56 -0
  65. flashruntime/torch/__init__.py +274 -0
  66. flashruntime/viewer/__init__.py +20 -0
  67. flashruntime/viewer/_docs/benchmarks.html +771 -0
  68. flashruntime/viewer/_docs/concepts/architecture.html +302 -0
  69. flashruntime/viewer/_docs/get-started.html +263 -0
  70. flashruntime/viewer/_docs/guides/federated-averaging.html +363 -0
  71. flashruntime/viewer/_docs/guides/huggingface.html +223 -0
  72. flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +271 -0
  73. flashruntime/viewer/_docs/guides/pytorch.html +313 -0
  74. flashruntime/viewer/_docs/guides/sklearn.html +232 -0
  75. flashruntime/viewer/_docs/index.html +251 -0
  76. flashruntime/viewer/_docs/reference/cli.html +254 -0
  77. flashruntime/viewer/_docs/reference/integrations.html +240 -0
  78. flashruntime/viewer/_docs/reference/sdk.html +341 -0
  79. flashruntime/viewer/_docs/reference/torch-helper.html +244 -0
  80. flashruntime/viewer/_docs/search-index.json +1 -0
  81. flashruntime/viewer/_docs/tutorials/convnet.html +571 -0
  82. flashruntime/viewer/_docs/tutorials/fault-tolerance.html +375 -0
  83. flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +278 -0
  84. flashruntime/viewer/flowmap.py +307 -0
  85. flashruntime/viewer/page.py +594 -0
  86. flashruntime/viewer/server.py +134 -0
  87. flashruntime/viewer/state.py +250 -0
  88. flashruntime/workloads/__init__.py +6 -0
  89. flashruntime/workloads/command.py +127 -0
  90. flashruntime-0.3.0.dist-info/METADATA +365 -0
  91. flashruntime-0.3.0.dist-info/RECORD +95 -0
  92. flashruntime-0.3.0.dist-info/WHEEL +5 -0
  93. flashruntime-0.3.0.dist-info/entry_points.txt +2 -0
  94. flashruntime-0.3.0.dist-info/licenses/LICENSE +202 -0
  95. flashruntime-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,91 @@
1
+ """Deterministic failure classification from raw signals.
2
+
3
+ Turns what was *observed* (exit codes, heartbeat loss, NCCL messages, XID
4
+ events, storage errors) into one typed `FailureClass`. Rules are
5
+ precedence-ordered — the first matching class wins — and the order encodes
6
+ root-cause priority: systemic evidence (correlated incidents, control-plane
7
+ loss) beats node evidence beats process evidence, because a worker crash
8
+ *during* a correlated incident is the incident, not the worker.
9
+
10
+ No inference beyond the table. Ambiguity resolves to conservative classes
11
+ (UNKNOWN classifies, policy decides what UNKNOWN deserves) rather than
12
+ optimistic ones.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+
19
+ from flashruntime.protocol.v1alpha1 import FailureClass
20
+
21
+ # Substrings that identify communication-layer failures in worker logs.
22
+ # [assumption: covers the common NCCL/Gloo surfaces; extend from ledger data]
23
+ _COMM_MARKERS = (
24
+ "nccl",
25
+ "rccl",
26
+ "gloo",
27
+ "rendezvous",
28
+ "allreduce",
29
+ "all_reduce",
30
+ "process group",
31
+ )
32
+ _STORAGE_MARKERS = ("s3", "oss", "minio", "object store", "storage timeout", "slowdown")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FailureSignals:
37
+ """The raw evidence available when something broke. All fields optional —
38
+ classification works from whatever was actually observed."""
39
+
40
+ exit_code: int | None = None
41
+ exit_deterministic: bool = False # same error at the same step on retry
42
+ heartbeat_lost: bool = False
43
+ node_unreachable: bool = False
44
+ xid_event: bool = False # NVIDIA XID / ECC / NVML health failure
45
+ driver_fault: bool = False
46
+ hash_mismatch: bool = False
47
+ validation_failed: bool = False
48
+ preemption_notice: bool = False
49
+ storage_errors: int = 0
50
+ control_plane_unreachable: bool = False
51
+ concurrent_failures: int = 1 # failures in the correlation window, incl. this one
52
+ message: str = ""
53
+
54
+ def _msg(self) -> str:
55
+ return self.message.lower()
56
+
57
+ def has_comm_marker(self) -> bool:
58
+ return any(m in self._msg() for m in _COMM_MARKERS)
59
+
60
+ def has_storage_marker(self) -> bool:
61
+ return any(m in self._msg() for m in _STORAGE_MARKERS)
62
+
63
+
64
+ # How many failures inside the correlation window turn individual failures
65
+ # into one systemic incident. [assumption: recalibrate from ledger data]
66
+ CORRELATED_THRESHOLD = 3
67
+
68
+
69
+ def classify(s: FailureSignals) -> FailureClass:
70
+ """Precedence-ordered classification. Systemic > node > process > app."""
71
+ if s.concurrent_failures >= CORRELATED_THRESHOLD:
72
+ return FailureClass.CORRELATED_INCIDENT
73
+ if s.control_plane_unreachable:
74
+ return FailureClass.CONTROL_PLANE_FAILURE
75
+ if s.preemption_notice:
76
+ return FailureClass.PREEMPTION
77
+ if s.xid_event or s.driver_fault:
78
+ return FailureClass.ACCELERATOR_FAILURE
79
+ if s.heartbeat_lost or s.node_unreachable:
80
+ return FailureClass.NODE_LOSS
81
+ if s.has_comm_marker():
82
+ return FailureClass.COMMUNICATION_ERROR
83
+ if s.storage_errors > 0 or s.has_storage_marker():
84
+ return FailureClass.STORAGE_TIMEOUT
85
+ if s.hash_mismatch or s.validation_failed:
86
+ return FailureClass.ARTIFACT_CORRUPTION
87
+ if s.exit_code is not None and s.exit_code != 0:
88
+ if s.exit_deterministic:
89
+ return FailureClass.APPLICATION_ERROR
90
+ return FailureClass.WORKER_CRASH
91
+ return FailureClass.UNKNOWN
@@ -0,0 +1,170 @@
1
+ """Placement policy: which task should a claiming node receive?
2
+
3
+ Today's live behavior is FIFO over PENDING tasks (`LeaseStore.next_pending`
4
+ insertion order) — honest and correct while tasks are CPU-sized and
5
+ homogeneous. `FifoPlacement` encodes exactly that behavior as the first
6
+ concrete policy, so wiring this interface into the claim path changes
7
+ nothing until a richer policy is chosen deliberately.
8
+
9
+ This package becomes load-bearing when payloads carry resource
10
+ requirements (vram_gb, min_cpus) and claims must filter by node
11
+ capability — research item R9 in `HANDBOOK.md` §7. The master report's §9
12
+ discipline applies: capability ("can it run this?"), reliability ("will it
13
+ finish?") and trust ("may this data go there?") stay SEPARATE assessments;
14
+ this interface handles capability filtering + preference ordering only.
15
+ Reliability scoring waits for ledger volume; trust tiers are cloud policy.
16
+
17
+ Integration point (when the time comes): `LeaseManager.claim` consults the
18
+ policy instead of calling `next_pending` directly — the store keeps
19
+ returning candidates in insertion order; the policy filters and picks.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from typing import Any
26
+
27
+ from flashruntime.protocol.v1alpha1 import TaskSpec
28
+
29
+ __all__ = ["PlacementPolicy", "FifoPlacement", "IsolationAwarePlacement"]
30
+
31
+ #: A node as the policy sees it: the registry's view dict
32
+ #: ({"node_id", "capabilities": {...}, ...}). Kept as a mapping (not a
33
+ #: model) until the capability schema for placement stabilizes with R9.
34
+ NodeView = dict[str, Any]
35
+
36
+
37
+ class PlacementPolicy(ABC):
38
+ """Filter + order candidate tasks for one claiming node.
39
+
40
+ Contracts:
41
+ - `eligible` is a pure predicate — no I/O, no side effects; called
42
+ once per (task, node) per claim, so it must be cheap.
43
+ - `score` orders eligible tasks (higher = better for THIS node);
44
+ ties break by queue order (fairness/FIFO stays the default
45
+ tiebreak so starvation cannot be introduced accidentally).
46
+ - `choose` is a template method most policies should NOT override —
47
+ override `eligible`/`score` and keep the selection semantics shared.
48
+ - A policy must never *create* work or mutate tasks; it only selects.
49
+ """
50
+
51
+ @abstractmethod
52
+ def eligible(self, task: TaskSpec, node: NodeView) -> bool:
53
+ """May this node run this task at all? (capability gate — e.g.
54
+ payload wants vram_gb the node lacks → False). Unknown/absent
55
+ requirements must default to True: fail-open on *placement*,
56
+ because the executor's allowlists still fail-closed on *safety*."""
57
+
58
+ def score(self, task: TaskSpec, node: NodeView) -> float:
59
+ """Preference among eligible tasks for this node (higher wins).
60
+ Default 0.0 for all ⇒ pure FIFO. Examples later: prefer tasks
61
+ whose input artifacts this node already cached; prefer short
62
+ tasks for soon-to-drain nodes."""
63
+ return 0.0
64
+
65
+ def choose(self, pending: list[TaskSpec], node: NodeView) -> TaskSpec | None:
66
+ """Select the task to lease to `node` from queue-ordered `pending`.
67
+
68
+ Semantics: filter by `eligible`, take the max `score`, break ties
69
+ by earliest queue position. Returns None when nothing is eligible
70
+ (the claim endpoint answers 204, exactly like an empty queue).
71
+ """
72
+ best: TaskSpec | None = None
73
+ best_score = float("-inf")
74
+ for task in pending: # queue order ⇒ first max wins ties
75
+ if not self.eligible(task, node):
76
+ continue
77
+ s = self.score(task, node)
78
+ if s > best_score:
79
+ best, best_score = task, s
80
+ return best
81
+
82
+
83
+ class FifoPlacement(PlacementPolicy):
84
+ """The current system, as a policy: everything eligible, no
85
+ preference, first-come-first-served. Wiring this in is a pure
86
+ refactor — behavior is bit-identical to `next_pending`."""
87
+
88
+ def eligible(self, task: TaskSpec, node: NodeView) -> bool:
89
+ return True
90
+
91
+
92
+ class IsolationAwarePlacement(PlacementPolicy):
93
+ """FIFO plus the one fail-closed capability gate the isolation contract
94
+ requires: a task whose payload demands sandboxed execution may only go
95
+ to a node advertising `sandbox_capable` — an ABSENT capability counts as
96
+ NOT capable (security-relevant fields fail closed, AGENTS.md rule 3).
97
+
98
+ The gate requires *true booleans*; type-confused values fail closed:
99
+
100
+ - A node is capable only when `sandbox_capable is True`. A truthy
101
+ stand-in (the JSON string ``"false"``, ``1``, ``"yes"``) does NOT
102
+ count as capable.
103
+ - The task's own `allowFallback` waives the requirement only when it is
104
+ exactly `True`; any other value (including the string ``"false"``)
105
+ does not waive.
106
+ - The isolation payload must be a mapping. If it is present but not a
107
+ dict (e.g. the bare string ``"sandboxed"``), the task is ineligible
108
+ everywhere — fail closed without crashing the predicate.
109
+ - Only tiers `None` / ``""`` / ``"standard"`` run anywhere. ANY other
110
+ tier value (an unknown or mistyped literal like ``"Sandboxed"``) is
111
+ treated like ``"sandboxed"`` and requires capability — no silent
112
+ downgrade to unsandboxed placement.
113
+
114
+ A second, independent gate applies to tasks carrying an `argv` payload
115
+ (arbitrary user command lines): the claiming node must advertise
116
+ `argv_capable is True`. This is checked BEFORE the isolation block's
117
+ `allowFallback` waiver below, and the waiver does not apply to it — a
118
+ submitter setting `allowFallback: true` waives the sandbox-tier
119
+ requirement only, never the argv-runner requirement, or arbitrary argv
120
+ could land on a node with no argv runner at all.
121
+
122
+ A third gate applies to tasks carrying a `module` payload (the
123
+ "python -m <allowlisted module>" tier): the claiming node must not be
124
+ `module_capable is False`. Its polarity is deliberately the MIRROR of
125
+ the argv gate above, not a copy of it:
126
+
127
+ - `argv_capable` is fail-closed (`is True` required) because argv is a
128
+ *safety* boundary — placing argv on a node with no argv runner is a
129
+ security failure (ArgvDockerRunner's own payload check would refuse
130
+ it, but the whole point of placement is to not burn attempts getting
131
+ there).
132
+ - `module_capable` is fail-OPEN (`is False` excludes) because it is an
133
+ *availability* concern only — a module task misplaced on an
134
+ argv-only node wastes retry attempts, it never escapes a sandbox.
135
+ Defaulting to capable means an already-deployed node whose
136
+ registration predates this field (module_capable absent ⇒ None) —
137
+ or any node that simply never opted into an argv-only runner — keeps
138
+ receiving module work. Only a node that explicitly advertises
139
+ `module_capable: false` (an argv-only volunteer) is excluded.
140
+
141
+ Do NOT "harmonize" these two gates onto the same polarity — copying
142
+ the argv gate's `is True` pattern onto module_capable would silently
143
+ cut every already-deployed node off from module work the moment this
144
+ field shipped, without anyone having opted out of anything.
145
+
146
+ Everything genuinely standard keeps the fail-open placement default."""
147
+
148
+ def eligible(self, task: TaskSpec, node: NodeView) -> bool:
149
+ # Checked before the allowFallback waiver below: the waiver relaxes
150
+ # the sandbox-tier requirement, and must never be readable as
151
+ # permission to run argv on a node with no argv runner.
152
+ if "argv" in task.payload and node.get("argv_capable") is not True:
153
+ return False
154
+ # Availability gate, mirrored polarity from the argv gate above —
155
+ # see the class docstring. An argv-only volunteer poisons every
156
+ # module job in the pool otherwise: it claims, ArgvDockerRunner
157
+ # rejects the payload, the attempt fails, and the task requeues
158
+ # into the same node's path until attempts are exhausted.
159
+ if "module" in task.payload and node.get("module_capable") is False:
160
+ return False
161
+ isolation = task.payload.get("isolation")
162
+ if isolation is None:
163
+ return True # no isolation payload ⇒ standard, runs anywhere
164
+ if not isinstance(isolation, dict):
165
+ return False # type-confused payload ⇒ fail closed, no crash
166
+ if isolation.get("tier") in (None, "", "standard"):
167
+ return True # only the known non-isolated tiers run anywhere
168
+ if isolation.get("allowFallback") is True:
169
+ return True # explicit waiver — genuine boolean only
170
+ return node.get("sandbox_capable") is True # capable ⇒ genuine boolean only
flashruntime/sdk.py ADDED
@@ -0,0 +1,402 @@
1
+ """flash.submit(): run a CommandWorkload locally and hand back a Run.
2
+
3
+ The execution model is compile → launch → wait → collect, once per Mode A
4
+ param set (mirroring the M0 engine's replay model). Sequential-by-design
5
+ keeps collection correct (a trial's outputs are copied out of the source
6
+ dir before the next trial can overwrite them). Service submission is a
7
+ different door: `workloads.command.to_jobspec()` POSTed to the coordinator.
8
+
9
+ `wait=True` (default) drives that loop inline and returns a finished Run —
10
+ byte-for-byte the old synchronous behavior. `wait=False` returns the Run
11
+ immediately and drives the loop on a daemon thread, so the caller can watch
12
+ it live. Either way the Run mirrors itself to `<output_dir>/run.json` after
13
+ every state change (the `viewer_v1` contract Task 6 renders from disk).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ import threading
24
+ import time
25
+ import webbrowser
26
+ from pathlib import Path
27
+
28
+ from flashruntime.launchers import LaunchState
29
+ from flashruntime.launchers.local import LocalProcessLauncher
30
+ from flashruntime.monitor import ResourceSampler
31
+ from flashruntime.protocol.v1alpha1 import RecoveryActionType
32
+ from flashruntime.recovery import classify, decide
33
+ from flashruntime.recovery.signals import from_local_launch
34
+ from flashruntime.strategies.command import compile_workload
35
+ from flashruntime.workloads.command import CommandWorkload
36
+
37
+ _JOB_ID = "local" # deterministic: rerunning with the same output_dir resumes checkpoints
38
+
39
+ # Viewers opened by watch=True. A RunViewerServer runs on a daemon thread, so
40
+ # it never blocks interpreter exit; we hold a reference here so it survives for
41
+ # the whole process lifetime (a wait=True submit returns before the human is
42
+ # done looking at the page, and the server must outlive that return).
43
+ _VIEWERS: list = []
44
+
45
+
46
+ class Run:
47
+ """Result handle for one submit().
48
+
49
+ Lock discipline (stated once, here): a `wait=False` submit drives the
50
+ launch loop on a daemon thread while the caller reads state/events live.
51
+ A single `threading.Lock` (`_lock`) guards the live-read fields
52
+ (`state`, `finished_at`, `_events`, `_attempts`) *and* the run.json
53
+ write, so the file is always serialized from a consistent snapshot and
54
+ a concurrent reader never observes a half-updated Run. `trials`,
55
+ `artifacts`, and `_logs` are mutated only by the single driver thread
56
+ and serialized only under the lock — they need no lock of their own.
57
+ Readers take copies under the lock; the driver holds it only for the
58
+ brief mutate-then-persist critical section. `_done` (a `threading.Event`)
59
+ is set exactly once, on the terminal transition, so `wait()` blocks
60
+ without polling.
61
+ """
62
+
63
+ def __init__(self, workload: CommandWorkload, output_dir: Path, max_restarts: int = 0):
64
+ self.workload = workload
65
+ self.output_dir = Path(output_dir)
66
+ self.max_restarts = max_restarts # the real recovery budget the viewer reports
67
+ self.state: LaunchState = LaunchState.PENDING
68
+ self.trials: list[dict] = []
69
+ self.artifacts: list[Path] = []
70
+ self.viewer_url: str | None = None # set when submit(watch=True) opens a live viewer
71
+ self.started_at: float = time.time()
72
+ self.finished_at: float | None = None
73
+ self._logs: list[str] = []
74
+ self._events: list[dict] = []
75
+ self._attempts: list[dict] = []
76
+ self._lock = threading.Lock()
77
+ self._done = threading.Event()
78
+ with self._lock: # materialize the PENDING record so run.json exists at once
79
+ self._write_run_json()
80
+
81
+ @property
82
+ def run_json_path(self) -> Path:
83
+ """Path to the viewer_v1 document this Run mirrors itself to."""
84
+ return self.output_dir / "run.json"
85
+
86
+ @property
87
+ def events(self) -> list[dict]:
88
+ """Snapshot copy of the append-only event log — safe to read while
89
+ the driver thread appends (a bare reference could tear)."""
90
+ with self._lock:
91
+ return [dict(e) for e in self._events]
92
+
93
+ @property
94
+ def attempts(self) -> list[dict]:
95
+ """Snapshot copy of the per-launch attempt rows (see `events` for why
96
+ it copies)."""
97
+ with self._lock:
98
+ return [dict(a) for a in self._attempts]
99
+
100
+ def wait(self, timeout: float | None = None) -> LaunchState:
101
+ """Block until the run is terminal (or `timeout` seconds elapse),
102
+ then return the current state. Event-based, not a poll loop: the
103
+ driver sets `_done` on the terminal transition, so a waiter wakes the
104
+ instant the run finishes with zero CPU spin — a polling loop would
105
+ burn cycles and add up-to-interval latency. Callers check
106
+ `.terminal` on the result (a timeout returns the last state seen)."""
107
+ self._done.wait(timeout)
108
+ return self.state
109
+
110
+ def record_event(self, type: str, message: str) -> None:
111
+ """Append one event to the append-only log and re-persist run.json.
112
+
113
+ Reason: lifecycle/recovery decisions must reach the viewer (and Task
114
+ 4's recovery wiring) the moment they happen. Takes `_lock` so the
115
+ append and the file write are one critical section."""
116
+ with self._lock:
117
+ self._events.append({"ts": time.time(), "type": type, "message": message})
118
+ self._write_run_json()
119
+
120
+ def _set_state(self, state: LaunchState) -> None:
121
+ """Transition to `state` and re-persist run.json. On a terminal
122
+ state, stamp `finished_at` and release `wait()`ers (sets `_done`).
123
+ Under `_lock` so state + finished_at + write land atomically."""
124
+ with self._lock:
125
+ self.state = state
126
+ if state.terminal and self.finished_at is None:
127
+ self.finished_at = time.time()
128
+ self._write_run_json()
129
+ if state.terminal:
130
+ self._done.set()
131
+
132
+ def _add_attempt(self, attempt_id: str, job_id: str, handle, started_at: float) -> None:
133
+ """Append a RUNNING attempt row (one per launch) and persist. `pid`
134
+ is the launcher's execution_id (a string), so the viewer can show
135
+ what is live."""
136
+ with self._lock:
137
+ self._attempts.append(
138
+ {
139
+ "attempt_id": attempt_id,
140
+ "job_id": job_id,
141
+ "state": LaunchState.RUNNING.value,
142
+ "pid": handle.execution_id,
143
+ "started_at": started_at,
144
+ "finished_at": None,
145
+ "output_dir": str(handle.output_dir),
146
+ }
147
+ )
148
+ self._write_run_json()
149
+
150
+ def _finish_attempt(self, attempt_id: str, state: LaunchState) -> None:
151
+ """Settle an attempt's terminal state + finished_at and persist.
152
+ Matches the last row with this id, so restarts (Task 4) that append a
153
+ fresh row settle the right one."""
154
+ with self._lock:
155
+ for row in reversed(self._attempts):
156
+ if row["attempt_id"] == attempt_id:
157
+ row["state"] = state.value
158
+ row["finished_at"] = time.time()
159
+ break
160
+ self._write_run_json()
161
+
162
+ def _write_run_json(self) -> None:
163
+ """Serialize the Run to <output_dir>/run.json atomically.
164
+
165
+ Write a temp file in the same directory, then `os.replace` it onto
166
+ the final path: os.replace is atomic on POSIX and Windows, so a
167
+ viewer reading concurrently sees either the whole old file or the
168
+ whole new one — never a torn, half-written document. Callers hold
169
+ `_lock`, so the snapshot serialized here is internally consistent."""
170
+ doc = {
171
+ "contract": "viewer_v1",
172
+ "workload": {
173
+ "command": self.workload.argv(),
174
+ "mode": self.workload.resolved_mode(),
175
+ "source": self.workload.source.path,
176
+ },
177
+ "state": self.state.value,
178
+ "started_at": self.started_at,
179
+ "finished_at": self.finished_at,
180
+ "max_restarts": self.max_restarts,
181
+ "attempts": [dict(a) for a in self._attempts],
182
+ "events": [dict(e) for e in self._events],
183
+ "trials": [dict(t) for t in self.trials],
184
+ }
185
+ self.output_dir.mkdir(parents=True, exist_ok=True)
186
+ tmp = self.output_dir / f".run.json.{os.getpid()}.tmp"
187
+ tmp.write_text(json.dumps(doc, indent=2))
188
+ os.replace(tmp, self.run_json_path)
189
+
190
+ def logs(self, tail_lines: int = 200) -> str:
191
+ return "\n".join("\n".join(self._logs).splitlines()[-tail_lines:])
192
+
193
+ def best_trial(self, metric: str | None = None, maximize: bool | None = None) -> dict | None:
194
+ """Highest/lowest `metric` among trials that reported it. Defaults
195
+ come from the workload's OutputSpec (adapters set them)."""
196
+ metric = metric or self.workload.outputs.primary_metric
197
+ if maximize is None:
198
+ maximize = self.workload.outputs.maximize
199
+ if metric is None:
200
+ raise ValueError("no metric named: pass metric= or set outputs.primary_metric")
201
+ scored = [t for t in self.trials if metric in t]
202
+ if not scored:
203
+ return None
204
+ return max(scored, key=lambda t: t[metric]) if maximize else min(scored, key=lambda t: t[metric])
205
+
206
+
207
+ def _watch_enabled(watch: bool | None) -> bool:
208
+ """Resolve the `watch` tri-state. Explicit True/False wins; None is auto —
209
+ on only at an interactive terminal AND outside CI. A pipeline or a piped
210
+ stdout must never open a browser or hold a server open, so auto stays off
211
+ there even though the run is identical."""
212
+ if watch is not None:
213
+ return watch
214
+ return sys.stdout.isatty() and os.environ.get("CI") is None
215
+
216
+
217
+ def _open_viewer(run: Run) -> None:
218
+ """Start a read-only RunViewerServer over the run's output dir, record its
219
+ URL on the Run, open a browser at it, and keep the server alive for the
220
+ process lifetime (`_VIEWERS`). Best-effort by design: the viewer is a
221
+ convenience layered on top of the run, never load-bearing — a headless box
222
+ with no browser, or a port that will not bind, must still run the job. So
223
+ every failure here is swallowed and the submit proceeds unwatched."""
224
+ try:
225
+ from flashruntime.viewer.server import RunViewerServer
226
+
227
+ server = RunViewerServer(run.output_dir)
228
+ url = server.start()
229
+ _VIEWERS.append(server) # hold a reference: daemon thread, freed at exit
230
+ run.viewer_url = url
231
+ print(f"viewer: {url}")
232
+ try:
233
+ webbrowser.open(url)
234
+ except Exception: # noqa: BLE001 — headless / no browser must not fail the run
235
+ pass
236
+ except Exception: # noqa: BLE001 — the viewer must never be able to fail a submit
237
+ pass
238
+
239
+
240
+ def submit(
241
+ workload: CommandWorkload,
242
+ output_dir: str | Path | None = None,
243
+ wait: bool = True,
244
+ max_restarts: int = 0,
245
+ watch: bool | None = None,
246
+ ) -> Run:
247
+ """Run a CommandWorkload locally. `max_restarts` (default 0 — no retry, the
248
+ old behavior) is the automatic fault-tolerance budget: a FAILED attempt is
249
+ classified and run against the versioned recovery policy, and unless the
250
+ failure is a deterministic application error (FAIL_JOB → fail fast) the
251
+ same spec is relaunched from the job-scoped checkpoint, up to this many
252
+ times. Applies to a single launch and per-trial in a fan-out.
253
+
254
+ `watch` opens the live run page (`flashruntime.viewer`) in a browser and
255
+ prints its URL (recorded on `Run.viewer_url`). None (default) auto-decides:
256
+ on at an interactive terminal, off in pipes/CI. The viewer server binds
257
+ 127.0.0.1 on a daemon thread, so it works for both `wait=True` (it outlives
258
+ the synchronous return, so the page stays viewable) and `wait=False`."""
259
+ out_root = Path(output_dir) if output_dir else Path(tempfile.mkdtemp(prefix="flashruntime-run-"))
260
+ run = Run(workload, out_root, max_restarts=max_restarts)
261
+ launcher = LocalProcessLauncher(out_root)
262
+
263
+ # Open the viewer before driving so the page is live for the whole run —
264
+ # including a wait=False submit the caller watches from the first frame.
265
+ if _watch_enabled(watch):
266
+ _open_viewer(run)
267
+
268
+ fanout = workload.resolved_mode() == "independent_tasks" and workload.task_params
269
+ param_sets: list[dict | None] = list(workload.task_params) if fanout else [None]
270
+
271
+ if wait:
272
+ # inline drive == today's synchronous behavior (Run finished on return)
273
+ _drive(run, workload, launcher, param_sets)
274
+ else:
275
+ # daemon so an unwaited Run never blocks interpreter shutdown
276
+ threading.Thread(
277
+ target=_drive, args=(run, workload, launcher, param_sets), daemon=True
278
+ ).start()
279
+ return run
280
+
281
+
282
+ def _drive(
283
+ run: Run, workload: CommandWorkload, launcher: LocalProcessLauncher, param_sets: list
284
+ ) -> None:
285
+ """The compile → launch → wait → collect loop, once per param set, then
286
+ settle the Run's terminal state. Extracted from submit() so wait=True can
287
+ call it inline (identical to the old synchronous path) and wait=False can
288
+ run it on a daemon thread.
289
+
290
+ Around each param set is the automatic-recovery loop (Task 4): on a FAILED
291
+ launch, translate the exit into FailureSignals, classify it, and run the
292
+ versioned policy — fail fast on a deterministic bug (FAIL_JOB), else
293
+ relaunch from the job-scoped checkpoint until the restart budget is spent.
294
+ `run.max_restarts == 0` reduces this to exactly one launch, the pre-Task-4
295
+ behavior."""
296
+ run._set_state(LaunchState.RUNNING)
297
+ source_dir = Path(workload.source.path).expanduser()
298
+
299
+ fanout = workload.resolved_mode() == "independent_tasks" and workload.task_params
300
+ # The policy's blast-radius axis: independent trials retry one task; a
301
+ # single/coordinated run restarts the whole (coordinated-training) group.
302
+ mode = "independent_tasks" if fanout else "coordinated_training"
303
+
304
+ states: list[LaunchState] = []
305
+ for i, params in enumerate(param_sets):
306
+ base_attempt = f"task-{i:03d}"
307
+ # Fan-out trials are DIFFERENT workloads (distinct params) — each needs
308
+ # its own checkpoint tree, or trial i could restore trial j's weights
309
+ # (FLASHML_CKPT_DIR is per job id). The single-workload path keeps the
310
+ # stable "local" id so a resubmit against the same output_dir resumes.
311
+ job_id = f"local-{i:03d}" if fanout else _JOB_ID
312
+ spec = compile_workload(workload, params)
313
+
314
+ final_state = LaunchState.FAILED
315
+ trial_metrics: dict | None = None
316
+ # restart 0 is the first try; 1..max_restarts are recovery attempts.
317
+ for restart in range(run.max_restarts + 1):
318
+ # Restart attempts append -r1/-r2… but keep the SAME job_id, so the
319
+ # launcher's job-scoped FLASHML_CKPT_DIR is shared across attempt
320
+ # names — that is the whole trick: the child's ft.prepare() resumes
321
+ # from the predecessor's newest valid manifest even though this
322
+ # attempt has a different id.
323
+ attempt_id = base_attempt if restart == 0 else f"{base_attempt}-r{restart}"
324
+ started_at = time.time()
325
+ handle = launcher.launch(spec, job_id, attempt_id)
326
+ run.record_event("LAUNCH_STARTED", f"{attempt_id} launched (pid {handle.execution_id})")
327
+ run._add_attempt(attempt_id, job_id, handle, started_at)
328
+ # Telemetry beside the attempt (machine + process tree → the run
329
+ # viewer's flow map). Best-effort like the viewer itself: a
330
+ # sampler that cannot start must never fail the run.
331
+ sampler = None
332
+ try:
333
+ sampler = ResourceSampler(handle.output_dir, int(handle.execution_id))
334
+ sampler.start()
335
+ except Exception: # noqa: BLE001 — observability never fails a run
336
+ sampler = None
337
+ try:
338
+ final_state = handle.wait()
339
+ finally:
340
+ if sampler is not None:
341
+ sampler.stop()
342
+ run._logs.append(f"--- {attempt_id} ({final_state.value}) ---\n{handle.logs()}")
343
+ run._finish_attempt(attempt_id, final_state)
344
+
345
+ collected = _collect(source_dir, workload.outputs.collect, handle.output_dir, since=started_at)
346
+ run.artifacts.extend(collected)
347
+ metrics_path = handle.output_dir / "metrics.json"
348
+ if metrics_path.is_file():
349
+ try:
350
+ metrics = json.loads(metrics_path.read_text())
351
+ except ValueError:
352
+ metrics = None
353
+ if isinstance(metrics, dict):
354
+ if params:
355
+ metrics.setdefault("params", params)
356
+ trial_metrics = metrics # the winning attempt's metrics (one trial per param set)
357
+
358
+ if final_state is LaunchState.SUCCEEDED:
359
+ break
360
+
361
+ # FAILED: consult the recovery policy. Signals → class → decision,
362
+ # both events recorded (with the decision's human reason) so the
363
+ # ledger/viewer can explain every retry.
364
+ exit_code = handle.exit_code
365
+ decision = decide(classify(from_local_launch(exit_code, handle.logs())), mode)
366
+ run.record_event(
367
+ "FAILURE_CLASSIFIED", f"{attempt_id}: {decision.failure_class.value} (exit {exit_code})"
368
+ )
369
+ run.record_event(
370
+ "RECOVERY_ACTION_SELECTED", f"{attempt_id}: {decision.action.value} — {decision.reason}"
371
+ )
372
+ # Fail fast on a deterministic application error — a retry only
373
+ # re-hits the same bug. Any other action means "retry", and the
374
+ # loop's range() enforces the budget, so it cannot spin forever.
375
+ if decision.action is RecoveryActionType.FAIL_JOB:
376
+ break
377
+
378
+ if trial_metrics is not None:
379
+ run.trials.append(trial_metrics)
380
+ states.append(final_state)
381
+
382
+ run._set_state(
383
+ LaunchState.SUCCEEDED
384
+ if states and all(s is LaunchState.SUCCEEDED for s in states)
385
+ else LaunchState.FAILED
386
+ )
387
+
388
+
389
+ def _collect(source_dir: Path, patterns: list[str], dest: Path, since: float) -> list[Path]:
390
+ """Copy collect-globs from the script's cwd into the attempt's output
391
+ dir. `since` skips files older than this launch — a stale metrics.json
392
+ from a previous trial must never be credited to a failed one."""
393
+ out: list[Path] = []
394
+ for pattern in patterns:
395
+ for src in sorted(source_dir.glob(pattern)):
396
+ if not src.is_file() or src.stat().st_mtime < since:
397
+ continue
398
+ target = dest / src.relative_to(source_dir)
399
+ target.parent.mkdir(parents=True, exist_ok=True)
400
+ shutil.copy2(src, target)
401
+ out.append(target)
402
+ return out
@@ -0,0 +1,3 @@
1
+ """FlashRuntime API service: accepts JobSpecs, drives an ExecutionBackend,
2
+ keeps the job/event/artifact ledger, and serves it to FlashML Cloud (or any
3
+ self-hosted client — the runtime is useful without the cloud)."""