Skip to content

Commit b0a8625

Browse files
committed
feat(v7-h06b): pipeline.status RPC + run_registry for backend-confirmed lifecycle
Closes the optimistic-state gap from items 16-18 of the honest UI<->API wiring list: pipeline.pause/resume/abort previously returned immediately and the UI flipped its own indicators with no backend confirmation. - New cppmega_v4/runtime/run_registry.py — thread-safe per-run state (running, aborted, last_step, last_loss). - stage_train registers on entry, marks step+loss after each commit, unregisters on normal/blow-up/cancel exit. - pipeline.abort now also marks aborted=True in the registry (the abort_token set was opaque to the registry until this commit). - New pipeline.status RPC combines run_registry + job_control.is_paused into a single snapshot UI polls for confirmation. bd: cppmega-mlx-089s 6/6 pytest in tests/v4/test_pipeline_status_rpc.py + 10/10 incl. pause/ resume/per-brick-grad regression.
1 parent 761d413 commit b0a8625

5 files changed

Lines changed: 263 additions & 0 deletions

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
from cppmega_v4.jsonrpc.histogram_method import (
5555
HistogramParams, inspect_histogram,
5656
)
57+
from cppmega_v4.jsonrpc.side_channel_methods import (
58+
SideChannelPreviewParams,
59+
preview_side_channels,
60+
)
5761
from cppmega_v4.jsonrpc.schema import (
5862
BuildPresetSpecsParams,
5963
CatalogExplainParams,
@@ -166,6 +170,10 @@
166170
HistogramParams,
167171
lambda p, c: inspect_histogram(p, cache=c),
168172
),
173+
"side_channels.preview": (
174+
SideChannelPreviewParams,
175+
lambda p, c: preview_side_channels(p, cache=c),
176+
),
169177
}
170178

171179

cppmega_v4/jsonrpc/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ class PipelineRunResult(BaseModel):
780780
"pipeline.resume",
781781
"gen.run",
782782
"inspect.histogram",
783+
"side_channels.preview",
783784
})
784785

785786

cppmega_v4/runner/stages.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ def _cancelled_train_result(
137137
weight_delta_norm: float = 0.0,
138138
) -> StageResult:
139139
clear_abort(abort_token)
140+
# V7-H06b: release the registry slot so pipeline.status flips
141+
# running=False after a confirmed cancel.
142+
from cppmega_v4.runtime import run_registry as _rr
143+
_rr.unregister(abort_token)
140144
return StageResult(
141145
name="train", status="cancelled",
142146
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
@@ -1423,6 +1427,12 @@ def _base(k: str) -> str | None:
14231427
abort_token = opts.get("abort_token")
14241428
# V7-H06: block while job is paused via job_control.pause(token).
14251429
from cppmega_v4.runtime.job_control import wait_while_paused
1430+
# V7-H06b: register run with the lifecycle registry so
1431+
# pipeline.status RPC can answer running/paused/aborted before
1432+
# train returns. Tracked by run_id (falls back to abort_token).
1433+
from cppmega_v4.runtime import run_registry as _run_registry
1434+
_registry_key = opts.get("run_id") or abort_token
1435+
_run_registry.register(_registry_key)
14261436
for step in range(n_steps):
14271437
wait_while_paused(abort_token, poll_s=0.05, max_wait_s=600.0)
14281438
if abort_token is not None and abort_token in _ABORT_TOKENS:
@@ -1522,6 +1532,9 @@ def _base(k: str) -> str | None:
15221532
if loss_scaler is not None:
15231533
loss_scaler.update(False)
15241534
losses.append(float(loss.item()))
1535+
# V7-H06b: keep run_registry's last_step/last_loss live so
1536+
# pipeline.status reflects the most recent committed step.
1537+
_run_registry.mark_step(_registry_key, step, float(losses[-1]))
15251538
# V7-H05: publish per-step event to the train_event_bus so
15261539
# WS subscribers (UI) see loss/lr update live, not only on
15271540
# pipeline completion.
@@ -1720,6 +1733,7 @@ def _base(k: str) -> str | None:
17201733
"detail": f"delta {delta:.2e} <= 1e-6"},
17211734
)
17221735
if len(losses) >= 2 and losses[0] > 0 and losses[-1] / losses[0] > 5:
1736+
_run_registry.unregister(_registry_key)
17231737
return StageResult(
17241738
name="train", status="fail",
17251739
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
@@ -1735,6 +1749,9 @@ def _base(k: str) -> str | None:
17351749
opts.get("run_id") or opts.get("abort_token"), None)
17361750
except Exception:
17371751
pass
1752+
# V7-H06b: mark run as no longer running so pipeline.status
1753+
# flips running=False once the train loop returns.
1754+
_run_registry.unregister(_registry_key)
17381755
return StageResult(
17391756
name="train", status="ok",
17401757
elapsed_ms=(time.perf_counter() - t0) * 1000.0,

cppmega_v4/runtime/run_registry.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""V7-H06b: in-process registry of active pipeline runs.
2+
3+
Tracks the lifecycle of every run_id passed into stage_train so that
4+
pipeline.status RPC can answer:
5+
- is this run currently running (still in the train loop)?
6+
- has it been aborted?
7+
- is it paused?
8+
- what's the last published (step, loss)?
9+
10+
The UI uses this to gate state transitions on backend confirmation,
11+
not optimistic local flips:
12+
- pipeline.pause -> poll status until paused=True
13+
- pipeline.resume -> poll until paused=False
14+
- pipeline.abort -> poll until running=False
15+
16+
Thread-safe; lives across requests in the FastAPI worker process.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import time
22+
from threading import Lock
23+
from typing import Any
24+
25+
_LOCK = Lock()
26+
_RUNS: dict[str, dict[str, Any]] = {}
27+
28+
29+
def register(run_id: str | None) -> None:
30+
"""Mark run_id as running. Idempotent; resets last_step / last_loss."""
31+
if not run_id:
32+
return
33+
with _LOCK:
34+
_RUNS[str(run_id)] = {
35+
"running": True,
36+
"aborted": False,
37+
"last_step": -1,
38+
"last_loss": None,
39+
"started_at": time.time(),
40+
"ended_at": None,
41+
}
42+
43+
44+
def mark_step(run_id: str | None, step: int, loss: float) -> None:
45+
"""Record the most recent step + loss from the train loop."""
46+
if not run_id:
47+
return
48+
with _LOCK:
49+
rec = _RUNS.get(str(run_id))
50+
if rec is None:
51+
return
52+
rec["last_step"] = int(step)
53+
rec["last_loss"] = float(loss)
54+
55+
56+
def mark_aborted(run_id: str | None) -> None:
57+
if not run_id:
58+
return
59+
with _LOCK:
60+
rec = _RUNS.get(str(run_id))
61+
if rec is None:
62+
return
63+
rec["aborted"] = True
64+
65+
66+
def unregister(run_id: str | None) -> None:
67+
"""Mark run as no longer running. Record is kept so the UI can
68+
still query its terminal state after train returns."""
69+
if not run_id:
70+
return
71+
with _LOCK:
72+
rec = _RUNS.get(str(run_id))
73+
if rec is None:
74+
return
75+
rec["running"] = False
76+
rec["ended_at"] = time.time()
77+
78+
79+
def snapshot(run_id: str | None) -> dict[str, Any] | None:
80+
"""Return a copy of the registry entry, or None if unknown."""
81+
if not run_id:
82+
return None
83+
with _LOCK:
84+
rec = _RUNS.get(str(run_id))
85+
if rec is None:
86+
return None
87+
return dict(rec)
88+
89+
90+
def reset() -> None:
91+
"""Test helper — drop all registered runs."""
92+
with _LOCK:
93+
_RUNS.clear()
94+
95+
96+
__all__ = [
97+
"register", "mark_step", "mark_aborted", "unregister",
98+
"snapshot", "reset",
99+
]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""V7-H06b: pipeline.status RPC + run_registry round-trip.
2+
3+
Verifies that pipeline.status reflects backend reality for pause /
4+
resume / abort / running / finished, so the UI can confirm a state
5+
transition before flipping its own indicators.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import threading
11+
import time
12+
13+
import pytest
14+
15+
from cppmega_v4.jsonrpc.dispatcher import (
16+
_pipeline_abort, _pipeline_pause, _pipeline_resume, _pipeline_status,
17+
)
18+
from cppmega_v4.jsonrpc.schema import (
19+
PipelineAbortParams, PipelineStatusParams, VerifyParams,
20+
)
21+
from cppmega_v4.runner import Pipeline, run_pipeline
22+
from cppmega_v4.runtime import job_control as jc
23+
from cppmega_v4.runtime import run_registry as rr
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def _reset():
28+
jc.reset()
29+
rr.reset()
30+
yield
31+
jc.reset()
32+
rr.reset()
33+
34+
35+
def _spec() -> VerifyParams:
36+
return VerifyParams.model_validate({
37+
"graph": {
38+
"nodes": [
39+
{"id": "attn", "kind": "attention",
40+
"params": {"num_heads": 4, "head_dim": 64}},
41+
{"id": "mlp", "kind": "mlp", "params": {}},
42+
],
43+
"edges": [{"src": "attn", "dst": "mlp"}],
44+
},
45+
"dim_env": {"B": 1, "S": 8, "H": 128,
46+
"nh": 2, "nkv": 1, "head_dim": 64},
47+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
48+
"optim": {"kind": "adamw",
49+
"groups": [{"matcher": "all", "lr": 1e-3,
50+
"weight_decay": 0.01,
51+
"betas": [0.9, 0.95]}]},
52+
})
53+
54+
55+
def test_v7_h06b_status_unknown_for_never_seen_run():
56+
r = _pipeline_status(PipelineStatusParams(run_id="ghost"))
57+
assert r.run_id == "ghost"
58+
assert r.known is False
59+
assert r.running is False
60+
61+
62+
def test_v7_h06b_status_reports_paused_after_pause_rpc():
63+
rr.register("run-X")
64+
_pipeline_pause(PipelineAbortParams(run_id="run-X"))
65+
r = _pipeline_status(PipelineStatusParams(run_id="run-X"))
66+
assert r.known is True
67+
assert r.paused is True
68+
assert r.running is True
69+
70+
71+
def test_v7_h06b_status_clears_paused_after_resume_rpc():
72+
rr.register("run-Y")
73+
_pipeline_pause(PipelineAbortParams(run_id="run-Y"))
74+
assert _pipeline_status(
75+
PipelineStatusParams(run_id="run-Y")).paused is True
76+
_pipeline_resume(PipelineAbortParams(run_id="run-Y"))
77+
r = _pipeline_status(PipelineStatusParams(run_id="run-Y"))
78+
assert r.paused is False
79+
80+
81+
def test_v7_h06b_status_marks_aborted_after_abort_rpc():
82+
rr.register("run-Z")
83+
_pipeline_abort(PipelineAbortParams(run_id="run-Z"))
84+
r = _pipeline_status(PipelineStatusParams(run_id="run-Z"))
85+
assert r.aborted is True
86+
87+
88+
def test_v7_h06b_status_reports_running_during_train_then_finished():
89+
"""Pause the train upfront so the train loop is *guaranteed* to be
90+
in wait_while_paused when the poller runs. Verify running=True
91+
+ paused=True. Then resume, wait for train to finish, verify
92+
running=False."""
93+
# Pause first so step 0 blocks in wait_while_paused.
94+
jc.pause("run-T")
95+
captured: dict = {}
96+
97+
def _resumer():
98+
time.sleep(0.15)
99+
r = _pipeline_status(PipelineStatusParams(run_id="run-T"))
100+
captured["mid"] = {"known": r.known, "running": r.running,
101+
"paused": r.paused}
102+
jc.resume("run-T")
103+
104+
threading.Thread(target=_resumer, daemon=True).start()
105+
rep = run_pipeline(_spec(), Pipeline.from_dict({
106+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
107+
"stage_options": {"train": {
108+
"num_steps": 2, "run_id": "run-T", "abort_token": "run-T",
109+
}},
110+
}))
111+
tr = next(s for s in rep.stages if s.name == "train")
112+
assert tr.status == "ok"
113+
114+
# Mid-train snapshot taken while paused: running=True + paused=True.
115+
assert captured["mid"] == {"known": True, "running": True,
116+
"paused": True}, captured
117+
# After train returns: running=False, paused already cleared.
118+
final = _pipeline_status(PipelineStatusParams(run_id="run-T"))
119+
assert final.known is True
120+
assert final.running is False
121+
assert final.paused is False
122+
assert final.last_step >= 0
123+
124+
125+
def test_v7_h06b_status_last_step_and_loss_updates_during_train():
126+
rep = run_pipeline(_spec(), Pipeline.from_dict({
127+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
128+
"stage_options": {"train": {
129+
"num_steps": 3, "run_id": "run-S", "abort_token": "run-S",
130+
}},
131+
}))
132+
tr = next(s for s in rep.stages if s.name == "train")
133+
assert tr.status == "ok"
134+
final = _pipeline_status(PipelineStatusParams(run_id="run-S"))
135+
# 3-step train, last index = 2.
136+
assert final.last_step == 2
137+
assert final.last_loss is not None
138+
assert final.last_loss > 0

0 commit comments

Comments
 (0)