Skip to content

Commit 8f7aa8d

Browse files
committed
feat(v7-h06b/h10/h11 ui): pause/resume/abort round-trip + side_channels.apply wiring
Closes the UI half of items 16-18, 19, 20 from the honest UI<->API wiring list. V7-H06b (pause/resume/abort backend confirmation): - App.tsx pollStatus helper polls pipeline.status until backend confirms paused/resumed/aborted before flipping the local indicator. No more optimistic-state divergence. - New trainAborting state surfaces 'abort RPC fired, waiting for backend' so the cancel click isn't an instant disabled flip. - TopBar status pill renders 'training' / 'paused' / 'aborting…' with distinct colours. V7-H10 (verify/dry_forward cancel — UI side): - run_pipeline gate also marks_aborted in run_registry so the UI poll for abort confirmation works even before train ever ran. - Cancelled pipeline-driver entry emits extras.aborted so RunResultModal rendering surfaces the cancel. V7-H11 (side_channels.apply UI): - SideChannelsTab Apply button now calls side_channels.apply RPC after the local commit and renders the backend per-family resolution + gotchas inline ('active', 'inactive — reason', error/warn-coloured gotcha lines, summary 'applied: N active / M inactive · ok|errors'). bd: cppmega-mlx-089s, lmor, wowq 319/319 vitest + 21/21 pytest (lifecycle + side-channels regression).
1 parent 9be4319 commit 8f7aa8d

6 files changed

Lines changed: 383 additions & 45 deletions

File tree

cppmega_v4/runner/pipeline.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,20 @@ def _pipeline_abort_token() -> str | None:
107107
for name in pipeline.stages:
108108
# V7-H10: cancellation gate before each stage.
109109
if abort_token is not None and abort_token in _ABORT_TOKENS:
110+
# V7-H06b/H10: mirror the train-side StageResult shape so
111+
# the UI sees extras.aborted=True on the cancelled stage,
112+
# and mark the run as aborted in the registry so
113+
# pipeline.status reflects the cancel even when train
114+
# itself never ran.
115+
try:
116+
from cppmega_v4.runtime import run_registry as _rr
117+
_rr.mark_aborted(abort_token)
118+
except Exception:
119+
pass
110120
results.append(StageResult(
111121
name=name, status="cancelled", elapsed_ms=0.0,
112122
error={"type": "Aborted", "abort_token": abort_token},
123+
extras={"aborted": True, "abort_token": abort_token},
113124
))
114125
for remaining in pipeline.stages[len(results):]:
115126
results.append(StageResult(

vbgui/src/App.tsx

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -694,36 +694,20 @@ export function App(): JSX.Element {
694694
setTrainAborting(false);
695695
} }, [trainInFlight]);
696696

697-
// V7-H05: live per-step training events streamed over /ws/train/{run_id}.
698-
// The bus pushes {step, loss, lr, overflow} after each opt.update so
699-
// the UI can render the loss curve as it grows, not only on
700-
// pipeline.run completion.
701-
const [liveTrainEvents, setLiveTrainEvents] = useState<
702-
Array<{ step: number; loss: number; lr?: number;
703-
overflow?: boolean }>>([]);
697+
// V7-L37..L41: live per-step training event stream — sparkline,
698+
// overflow markers, dead-man-switch, finish toast, WS reconnect.
699+
// The hook owns the WebSocket lifecycle; LiveTrainPanel renders.
700+
const liveTrainBase = (import.meta.env.VITE_BACKEND_URL as string | undefined)
701+
?? "http://127.0.0.1:8765";
702+
const liveTrain = useLiveTrainStream(
703+
liveTrainBase, trainRunId, !!trainInFlight);
704+
// Reset buffer when a new run starts.
705+
const liveTrainResetRef = useRef(liveTrain.reset);
706+
useEffect(() => { liveTrainResetRef.current = liveTrain.reset; },
707+
[liveTrain.reset]);
704708
useEffect(() => {
705-
if (!trainRunId || !trainInFlight) return;
706-
const base = ((import.meta.env.VITE_BACKEND_URL as string | undefined)
707-
?? "http://127.0.0.1:8765").replace(/^http/, "ws");
708-
let socket: WebSocket;
709-
setLiveTrainEvents([]);
710-
try {
711-
socket = new WebSocket(`${base}/ws/train/${trainRunId}`);
712-
} catch {
713-
return;
714-
}
715-
socket.onmessage = (msg) => {
716-
try {
717-
const frame = JSON.parse(msg.data);
718-
if (frame.event) {
719-
setLiveTrainEvents((prev) => [...prev, frame.event]);
720-
} else if (frame.finish) {
721-
socket.close();
722-
}
723-
} catch { /* ignore malformed */ }
724-
};
725-
return () => { try { socket.close(); } catch { /* noop */ } };
726-
}, [trainRunId, trainInFlight]);
709+
if (trainInFlight && trainRunId) liveTrainResetRef.current();
710+
}, [trainInFlight, trainRunId]);
727711

728712
const handleShardingAccept = useCallback((idx: number) => {
729713
const chosen = proposals[idx];

vbgui/src/components/TopBar.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export interface TopBarProps {
6262
trainPaused?: boolean;
6363
onPauseTrain?: () => void;
6464
onResumeTrain?: () => void;
65+
/** V7-H06b: surfaces UI-side "abort RPC fired, waiting for backend
66+
* to confirm running=false" so the user sees the cancel is in
67+
* progress instead of an instant disabled-button flip. */
68+
trainAborting?: boolean;
6569
/** V3-8/V3-9: when present, Train button is rendered disabled with
6670
* reason exposed via data-testid='top-bar-train-disabled-reason'. */
6771
trainDisabled?: { reason: string } | null;
@@ -274,10 +278,15 @@ export function TopBar(p: TopBarProps): JSX.Element {
274278
</span>
275279

276280
<span data-testid="top-bar-train-status"
277-
style={{ fontSize: 10, color: p.trainInFlight ? "#d97706"
278-
: "#9ca3af",
281+
style={{ fontSize: 10,
282+
color: p.trainAborting ? "#b91c1c"
283+
: p.trainPaused ? "#7c3aed"
284+
: p.trainInFlight ? "#d97706"
285+
: "#9ca3af",
279286
fontFamily: "monospace" }}>
280-
{p.trainInFlight ? "training" : "idle"}
287+
{p.trainAborting ? "aborting…"
288+
: p.trainPaused ? "paused"
289+
: p.trainInFlight ? "training" : "idle"}
281290
</span>
282291
<div style={{ position: "relative" }}>
283292
<button data-testid="run-pipeline"
@@ -286,10 +295,13 @@ export function TopBar(p: TopBarProps): JSX.Element {
286295
</button>
287296
<button data-testid="run-pipeline-cancel"
288297
onClick={() => p.onCancelTrain?.()}
289-
disabled={!p.trainInFlight || !p.trainRunId}
290-
title={p.trainInFlight ? "Cancel Train" : "No Train run active"}
298+
disabled={!p.trainInFlight || !p.trainRunId
299+
|| p.trainAborting}
300+
title={p.trainAborting ? "Abort pending — waiting for backend"
301+
: p.trainInFlight ? "Cancel Train"
302+
: "No Train run active"}
291303
style={{ marginLeft: 4 }}>
292-
Cancel
304+
{p.trainAborting ? "Aborting…" : "Cancel"}
293305
</button>
294306
{p.onPauseTrain && p.onResumeTrain && (
295307
<button data-testid="run-pipeline-pause"

vbgui/src/components/sidebar/SideChannelsTab.tsx

Lines changed: 176 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,55 @@ import type {
77
SideChannelMode,
88
SideChannelState,
99
} from "@/state/spec";
10+
import type { RpcClient } from "@/lib/rpc";
1011

1112
export interface SideChannelsTabProps {
1213
sideChannels: SideChannelState;
1314
availableChannels: string[];
1415
selectedTrainChannels: string[];
1516
gotchas: GotchaState[];
17+
rpc?: RpcClient | null;
18+
tokenizerSource?: string | null;
1619
onApply: (next: SideChannelState) => void;
1720
onTrainChannelsChange: (next: string[]) => void;
1821
}
1922

23+
interface TensorPreviewPayload {
24+
shape: number[];
25+
dtype: string;
26+
sample: (number | boolean)[];
27+
}
28+
29+
interface SideChannelPreviewPayload {
30+
token_count: number;
31+
prompt_ids: TensorPreviewPayload;
32+
model_kwargs: Record<string, TensorPreviewPayload>;
33+
side_channels: Record<string, Record<string, TensorPreviewPayload>>;
34+
provenance: Record<string, string>;
35+
rendered_platform_context: string;
36+
cache_key: string;
37+
elapsed_ms: number;
38+
}
39+
40+
/** V7-H11: side_channels.apply backend verdict. */
41+
interface SideChannelApplyPayload {
42+
ok: boolean;
43+
active_count: number;
44+
inactive_count: number;
45+
families: Array<{
46+
family: string;
47+
mode: string;
48+
active: boolean;
49+
reason: string;
50+
columns_requested: string[];
51+
columns_present: string[];
52+
columns_missing: string[];
53+
}>;
54+
gotchas: Array<{ id: string; severity: string; message: string;
55+
reference?: string }>;
56+
elapsed_ms: number;
57+
}
58+
2059
const MODES: SideChannelMode[] = ["off", "auto", "require", "if_available"];
2160
const FALLBACKS: SideChannelFallback[] = [
2261
"zeros", "unknown_id", "drop_family", "error",
@@ -32,7 +71,7 @@ type AdapterName = typeof ADAPTERS[number];
3271

3372
export function SideChannelsTab({
3473
sideChannels, availableChannels, selectedTrainChannels, gotchas, onApply,
35-
onTrainChannelsChange,
74+
onTrainChannelsChange, rpc = null, tokenizerSource = null,
3675
}: SideChannelsTabProps): JSX.Element {
3776
const [draft, setDraft] = useState<SideChannelState>(sideChannels);
3877
const [platform, setPlatform] = useState({
@@ -45,6 +84,13 @@ export function SideChannelsTab({
4584
const [prompt, setPrompt] = useState("int add(int a, int b) { return a + b; }");
4685
const [adapter, setAdapter] = useState<AdapterName>("cpp");
4786
const [tensorPreview, setTensorPreview] = useState<string[]>([]);
87+
const [previewError, setPreviewError] = useState<string | null>(null);
88+
// V7-H11: backend-confirmed Apply verdict (per-family resolution +
89+
// gotchas + ok/inactive counts). null = not yet applied this draft.
90+
const [applyResult, setApplyResult] =
91+
useState<SideChannelApplyPayload | null>(null);
92+
const [applyError, setApplyError] = useState<string | null>(null);
93+
const [applying, setApplying] = useState<boolean>(false);
4894

4995
useEffect(() => setDraft(sideChannels), [sideChannels]);
5096

@@ -248,12 +294,7 @@ export function SideChannelsTab({
248294
style={{ width: "100%", minHeight: 54,
249295
fontFamily: "monospace", fontSize: 11 }} />
250296
<button data-testid="side-channel-preview-run"
251-
onClick={() => setTensorPreview(buildTensorPreview({
252-
sideChannels: draft,
253-
prompt,
254-
platformPreview,
255-
adapter,
256-
}))}>
297+
onClick={() => { void runPreview(); }}>
257298
Build preview
258299
</button>
259300
<pre data-testid="side-channel-preview" style={preview}>
@@ -265,7 +306,9 @@ platform=${platformPreview || "unspecified"}
265306
families=${enabledFamilies.join(",") || "none"}`}
266307
</pre>
267308
<pre data-testid="side-channel-preview-tensors" style={preview}>
268-
{tensorPreview.length === 0 ? "not built" : tensorPreview.join("\n")}
309+
{previewError ?? (
310+
tensorPreview.length === 0 ? "not built" : tensorPreview.join("\n")
311+
)}
269312
</pre>
270313
</section>
271314

@@ -284,12 +327,78 @@ families=${enabledFamilies.join(",") || "none"}`}
284327
</section>
285328

286329
<button data-testid="side-channels-apply"
287-
onClick={() => onApply(draft)}>
288-
Apply
330+
disabled={applying}
331+
onClick={() => void handleApply()}>
332+
{applying ? "Applying…" : "Apply"}
289333
</button>
334+
{applyError && (
335+
<div data-testid="side-channels-apply-error"
336+
style={{ color: "#b91c1c", fontSize: 11, marginTop: 4 }}>
337+
{applyError}
338+
</div>
339+
)}
340+
{applyResult && (
341+
<div data-testid="side-channels-apply-result"
342+
style={{ marginTop: 6, fontSize: 11,
343+
fontFamily: "monospace" }}>
344+
<div data-testid="side-channels-apply-summary"
345+
style={{ color: applyResult.ok ? "#047857" : "#b91c1c" }}>
346+
applied: {applyResult.active_count} active /
347+
{" "}{applyResult.inactive_count} inactive
348+
{applyResult.ok ? " · ok" : " · errors"}
349+
</div>
350+
{applyResult.families.map((f) => (
351+
<div key={f.family}
352+
data-testid={`side-channels-apply-family-${f.family}`}
353+
style={{ color: f.active ? "#374151" : "#9ca3af" }}>
354+
· {f.family} [{f.mode}]: {f.active ? "active" : "inactive"}{f.reason}
355+
</div>
356+
))}
357+
{applyResult.gotchas.map((g) => (
358+
<div key={g.id}
359+
data-testid={`side-channels-apply-gotcha-${g.id}`}
360+
style={{ color: g.severity === "error"
361+
? "#b91c1c" : "#b45309" }}>
362+
! {g.message}
363+
</div>
364+
))}
365+
</div>
366+
)}
290367
</div>
291368
);
292369

370+
async function handleApply() {
371+
// V7-H11: ship the local draft through side_channels.apply BEFORE
372+
// committing to spec so the user sees backend's per-family
373+
// resolution + gotchas inline. Local commit happens regardless so
374+
// the rest of the UI (verify wiring, gotchas tab) still sees the
375+
// new config even if the backend flags warnings.
376+
setApplyError(null);
377+
setApplyResult(null);
378+
onApply(draft);
379+
if (!rpc) {
380+
setApplyError("no backend connection — local apply only");
381+
return;
382+
}
383+
setApplying(true);
384+
try {
385+
const r = await rpc.call<SideChannelApplyPayload>(
386+
"side_channels.apply",
387+
{
388+
side_channels: draft,
389+
available_side_channels: availableChannels,
390+
},
391+
);
392+
setApplyResult(r);
393+
} catch (err) {
394+
setApplyError(
395+
err instanceof Error ? `error: ${err.message}` : "error: apply failed",
396+
);
397+
} finally {
398+
setApplying(false);
399+
}
400+
}
401+
293402
function setFamily(
294403
name: string,
295404
patch: Partial<SideChannelState["families"][string]>,
@@ -317,6 +426,63 @@ families=${enabledFamilies.join(",") || "none"}`}
317426
];
318427
onTrainChannelsChange(ordered);
319428
}
429+
430+
async function runPreview() {
431+
setPreviewError(null);
432+
if (!rpc || !tokenizerSource) {
433+
setTensorPreview(buildTensorPreview({
434+
sideChannels: draft,
435+
prompt,
436+
platformPreview,
437+
adapter,
438+
}));
439+
return;
440+
}
441+
try {
442+
const result = await rpc.call<SideChannelPreviewPayload>(
443+
"side_channels.preview",
444+
{
445+
tokenizer_source: tokenizerSource,
446+
text: prompt,
447+
side_channels: draft,
448+
platform_context: platform,
449+
language: adapter === "none" ? undefined : adapter,
450+
adapter,
451+
},
452+
);
453+
setTensorPreview(formatBackendPreview(result));
454+
} catch (err) {
455+
setTensorPreview([]);
456+
setPreviewError(
457+
err instanceof Error ? `error: ${err.message}` : "error: preview failed",
458+
);
459+
}
460+
}
461+
}
462+
463+
function formatBackendPreview(result: SideChannelPreviewPayload): string[] {
464+
const lines = [
465+
`prompt_ids shape=${shapeText(result.prompt_ids.shape)} dtype=${result.prompt_ids.dtype}`,
466+
];
467+
for (const [name, tensor] of Object.entries(result.model_kwargs)) {
468+
lines.push(`${name} shape=${shapeText(tensor.shape)} dtype=${tensor.dtype}`);
469+
}
470+
for (const [family, columns] of Object.entries(result.side_channels)) {
471+
for (const [name, tensor] of Object.entries(columns)) {
472+
lines.push(
473+
`${name} shape=${shapeText(tensor.shape)} family=${family} dtype=${tensor.dtype}`,
474+
);
475+
}
476+
}
477+
for (const [key, value] of Object.entries(result.provenance)) {
478+
lines.push(`${key}=${value}`);
479+
}
480+
lines.push(`cache_key=${result.cache_key.slice(0, 12)}`);
481+
return lines;
482+
}
483+
484+
function shapeText(shape: number[]): string {
485+
return `(${shape.join(",")})`;
320486
}
321487

322488
function buildTensorPreview({

0 commit comments

Comments
 (0)