Skip to content

Commit 27b342a

Browse files
authored
fix(engine-manager): report install steps without fake percentages (#145)
## Summary Engine installs sat at a hardcoded **75%** for the whole install step, and `verified` was hardcoded at **50%**, so the bar went 100% → 50% → 75%. The install step runs opaque commands (`tar`/`unzip` for Ollama, LM Studio's vendor installer) with no streamed output, so real progress isn't measurable. This PR stops inventing numbers: those steps are now reported as indeterminate, and the UI shows just the stage ("Installing"). - **`nvpair-engine-manager`**: `emitInstallProgress` omits `percent` from `engine:install-progress` when it is 0 (indeterminate), matching the existing pull-progress convention (`wirePercentIncluded`). `verified` and both `installing` paths now send 0. Real download byte progress and terminal frames (`done`/`already-installed` = 100, `failed` = -1) are unchanged. A download without `Content-Length` now shows "Downloading" instead of "0%". - **`nvpair-tui`**: renders `(N%)` only for a measured percent (previously would show `(0%)`, and already showed `(-1%)` on failure). - **Desktop**: the bridge projected a missing percent through `numberValue`, which returns `0`, so the UI would have shown "Installing · 0%". Both install projections (local `applyEngineManagerProgress`, remote `applyRemoteEngineProgress`) now map a missing or zero percent to `undefined` via `measuredPercent`, so `BackendHeader` and `WelcomeEngineRow` render the stage alone. Pull keeps its existing `mergePullProgressPercent` behavior. - **Failure frames**: `reportInstallFailed` now goes through the shared install emitter, and the TUI shows the failure reason (`install <engine> failed: <error>`). - **Docs**: `spec.md` / `README.md` mark `percent` optional on `engine:install-progress`, `engine:pull-progress`, and `engine:remote-progress`, and state the shared rule once. ### Audited components - Producer: `nvpair-engine-manager` (`install.go`, `executor.go`, `progress.go`). - Remote relay: `controlstream.go` / `remote.go` already omit percent 0; unchanged. - Broker: relays `engine:*` verbatim; unchanged. - Consumers: desktop `modular-state.ts` updated (local and remote install projections); `BackendHeader.tsx` / `WelcomeEngineRow.tsx` render the stage alone once `percent` is `undefined`; model-pull UI is unaffected; `nvpair-tui` updated. ## Testing - `go test ./...` in `services/nvpair-engine-manager` and `services/nvpair-tui` - New Go tests: indeterminate install frame omits `percent`; full fetch+run install emits a measured percent on download, none on `verified`/`installing`, 100 on `done`, and no unexpected stages - New desktop tests: local and remote install frames drop to `percent: undefined` after a 100% download (verified failing without the fix) - New TUI tests: `percentSuffix` and `installStatus` - `npm run lint`, `npm run typecheck`, `npm run dead-code:check`, `npm run service-contracts:check`, `npm run test:unit` in `desktop/` <!-- pair-release-intent:v1 --> ### Changelog title Engine install no longer shows a fake percentage ### Changelog body - Installing an engine no longer sits at a made-up 75%. Download progress still shows a real percentage; the install step itself now shows "Installing" with no number. ### Bumps - services: patch - nvpair-cluster-manager: none - nvpair-engine-manager: patch - nvpair-errors: none - nvpair-job-scheduler: none - nvpair-manual-nodes: none - nvpair-node-info: none - nvpair-node-scanner: none - nvpair-node-settings: none - nvpair-proxy: none - nvpair-tui: patch - nvpair-ui-broker: none - nvpair-workload-manager: none <!-- /pair-release-intent:v1 --> --------- Signed-off-by: Chris Kelsey <ckelsey@nvidia.com>
1 parent d539160 commit 27b342a

8 files changed

Lines changed: 230 additions & 29 deletions

File tree

‎desktop/src/electron/service-bridge/modular-state.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,16 @@ function numberValue(value: JsonValue | undefined): number {
232232
return typeof value === 'number' ? value : 0
233233
}
234234

235+
/**
236+
* An install frame's percent, or undefined when the step is indeterminate. The
237+
* engine-manager omits percent (or sends 0) for steps it cannot measure; each
238+
* frame replaces the last, so a missing percent must not carry forward.
239+
*/
240+
function measuredPercent(value: JsonValue | undefined): number | undefined {
241+
const pct = numberValue(value)
242+
return pct > 0 ? pct : undefined
243+
}
244+
235245
function booleanValue(value: JsonValue | undefined): boolean {
236246
return typeof value === 'boolean' ? value : false
237247
}
@@ -1568,11 +1578,10 @@ class ModularBridgeState {
15681578
? engineProgressKey({ nodeId, engineType, operation: 'pull', model })
15691579
: ''
15701580
const existingPull = pullKey ? this.activePulls.get(pullKey) : undefined
1571-
const framePercent = numberValue(obj.percent)
15721581
const percent =
15731582
operation === 'pull'
1574-
? mergePullProgressPercent(framePercent, existingPull?.percent)
1575-
: framePercent
1583+
? mergePullProgressPercent(numberValue(obj.percent), existingPull?.percent)
1584+
: measuredPercent(obj.percent)
15761585
const progress: EngineProgress = {
15771586
engineType,
15781587
nodeId,
@@ -2142,7 +2151,7 @@ class ModularBridgeState {
21422151
nodeName: node?.name ?? nodeId,
21432152
operation: 'install',
21442153
status: stage,
2145-
percent: numberValue(obj.percent)
2154+
percent: measuredPercent(obj.percent)
21462155
}
21472156
emitBridgePush('engines:progress-changed', progress)
21482157
}

‎desktop/tests/modular/install-state.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
66
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
77
vi.mock('@/electron/window', () => ({ createOverviewWindow: vi.fn() }))
88

9-
import type { EngineProcessStatus } from '@/shared/types/engines'
9+
import type { EngineProcessStatus, EngineProgress } from '@/shared/types/engines'
1010
import { subscribePush } from '@/electron/service-bridge/push-bus'
1111
import { getModularBridgeState } from '@/electron/service-bridge/modular-state'
1212

@@ -71,7 +71,7 @@ describe('LM Studio install state', () => {
7171
vi.advanceTimersByTime(120_000)
7272
expect(statuses.at(-1)).toBe('installing')
7373

74-
state.applyEngineManagerProgress({ engine: 'lmstudio', stage: 'installing', percent: 75 })
74+
state.applyEngineManagerProgress({ engine: 'lmstudio', stage: 'installing' })
7575
vi.advanceTimersByTime(30 * 60_000 - 1)
7676
expect(statuses.at(-1)).toBe('installing')
7777
vi.advanceTimersByTime(1)
@@ -82,3 +82,47 @@ describe('LM Studio install state', () => {
8282
expect(statuses.at(-1)).toBe('not-installed')
8383
})
8484
})
85+
86+
describe('install progress percent', () => {
87+
function captureInstallProgress(): EngineProgress[] {
88+
const frames: EngineProgress[] = []
89+
unsubscribe = subscribePush(event => {
90+
if (
91+
event.channel === 'engines:progress-changed' &&
92+
event.payload.operation === 'install'
93+
) {
94+
frames.push(event.payload)
95+
}
96+
})
97+
return frames
98+
}
99+
100+
it('drops a local install to indeterminate after a measured download', () => {
101+
const state = getModularBridgeState()
102+
const frames = captureInstallProgress()
103+
state.setSelfId('local-node')
104+
105+
state.applyEngineManagerProgress({ engine: 'lmstudio', stage: 'downloading', percent: 42 })
106+
expect(frames.at(-1)?.percent).toBe(42)
107+
108+
state.applyEngineManagerProgress({ engine: 'lmstudio', stage: 'downloading', percent: 100 })
109+
state.applyEngineManagerProgress({ engine: 'lmstudio', stage: 'installing' })
110+
expect(frames.at(-1)?.status).toBe('installing')
111+
expect(frames.at(-1)?.percent).toBeUndefined()
112+
})
113+
114+
it('drops a remote install to indeterminate after a measured download', () => {
115+
const state = getModularBridgeState()
116+
const frames = captureInstallProgress()
117+
const frame = { node: 'remote-node', engine: 'ollama', op: 'install' }
118+
119+
state.applyRemoteEngineProgress({ ...frame, stage: 'downloading', percent: 100 })
120+
expect(frames.at(-1)?.percent).toBe(100)
121+
122+
state.applyRemoteEngineProgress({ ...frame, stage: 'installing' })
123+
expect(frames.at(-1)?.status).toBe('installing')
124+
expect(frames.at(-1)?.percent).toBeUndefined()
125+
126+
state.applyRemoteEngineProgress({ ...frame, stage: 'failed', percent: -1 })
127+
})
128+
})

‎services/nvpair-engine-manager/README.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,15 @@ Notifications (service → caller): `engine:ready{version}`,
8383
loaded (in-memory) models changes (explicit load/unload, JIT auto-load, or
8484
TTL/idle eviction); `models` is the full `engine:models` shape (incl.
8585
`loadedByEngine`) so a consumer swaps its whole snapshot,
86-
`engine:install-progress{engine, stage, percent}`,
87-
`engine:pull-progress{engine, op, stage, percent, message}` (live progress for a
86+
`engine:install-progress{engine, stage, percent?, error?}` (`error` on the
87+
terminal `failed` frame),
88+
`engine:pull-progress{engine, op, stage, percent?, message}` (live progress for a
8889
local model pull driven via `engine:action{action:"pull_model"}` — the local
8990
counterpart of `engine:remote-progress`; frames are coalesced to changes in
9091
stage/percent, the engine's terminal success surfaces as `stage:"success"`, and
9192
a failed pull emits a terminal `stage:"error", percent:-1, message` frame so a
9293
UI converges even if its synchronous call already timed out),
93-
`engine:remote-progress{opId, node, engine, op, stage, percent, message}`
94+
`engine:remote-progress{opId, node, engine, op, stage, percent?, message}`
9495
(relayed live progress for a remote install/pull), and — for the error
9596
pipeline — `errors:report` / `errors:clear` (consumed by `nvpair-errors`
9697
via the broker; see below).
@@ -117,6 +118,12 @@ If the start acknowledgement is lost or unreadable, acceptance and cancellation
117118
are reported as unconfirmed without unloading an unowned download. The monitored
118119
model must match `params.model`; mismatches are rejected before subscribing.
119120

121+
`percent` on all three progress notifications is present only when the step is
122+
measurable (download bytes, byte-progress pulls) or terminal (`100` on install
123+
`done` / `already-installed`, `-1` on install `failed` and pull `error`). An
124+
indeterminate step omits it — the install `verified` and `installing` stages
125+
always do — so a UI renders the stage alone.
126+
120127
The `engine:remote-*` methods are the client half of remote engine
121128
management: engine-manager resolves the target `node` in an `ec` peer
122129
directory (fed by its own `discovery:subscribe{services:[ec]}` to the broker

‎services/nvpair-engine-manager/executor.go‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,26 @@ func (st *engineState) pathVars() map[string]string {
255255
return vars
256256
}
257257

258-
func progress(engine, stage string, pct int) map[string]any {
259-
return map[string]any{"engine": engine, "stage": stage, "percent": pct}
260-
}
261-
262258
// emitInstallProgress reports one install-progress step to both consumers: the
263259
// local engine:install-progress notification (this node's UI) and the progress
264-
// hub (so an ec streaming handler can relay it to a remote initiator).
260+
// hub (so an ec streaming handler can relay it to a remote initiator). A pct of
261+
// 0 marks a step with no measurable progress and is omitted from the wire.
265262
func (e *Executor) emitInstallProgress(engine, stage string, pct int) {
266-
e.notify("engine:install-progress", progress(engine, stage, pct))
267-
e.progress.publish(ProgressEvent{Engine: engine, Op: "install", Stage: stage, Percent: pct})
263+
e.emitInstallFrame(engine, stage, pct, "")
264+
}
265+
266+
// emitInstallFrame is emitInstallProgress with an optional failure reason,
267+
// carried as "error" on the notification and Message on the hub event.
268+
func (e *Executor) emitInstallFrame(engine, stage string, pct int, errMsg string) {
269+
params := map[string]any{"engine": engine, "stage": stage}
270+
if wirePercentIncluded(pct) {
271+
params["percent"] = pct
272+
}
273+
if errMsg != "" {
274+
params["error"] = errMsg
275+
}
276+
e.notify("engine:install-progress", params)
277+
e.progress.publish(ProgressEvent{Engine: engine, Op: "install", Stage: stage, Percent: pct, Message: errMsg})
268278
}
269279

270280
// emitPullProgress reports one model-pull step to both consumers: the local

‎services/nvpair-engine-manager/install.go‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func (e *Executor) Install(ctx context.Context, engine string) error {
7070
// Escape hatch: vendor-script install with no checksum. Logged
7171
// loudly so the weaker guarantee is never silent.
7272
slog.Warn("running UNPINNED script install (no checksum verification)", "engine", engine)
73-
e.emitInstallProgress(engine, "installing", 50)
73+
e.emitInstallProgress(engine, "installing", 0)
7474
argv, err := resolveArgs(inst.Script, vars)
7575
if err != nil {
7676
e.reportInstallFailed(engine, err)
@@ -94,7 +94,7 @@ func (e *Executor) Install(ctx context.Context, engine string) error {
9494
}
9595
defer os.Remove(dp)
9696
vars["download"] = dp
97-
e.emitInstallProgress(engine, "verified", 50)
97+
e.emitInstallProgress(engine, "verified", 0)
9898
}
9999
if len(inst.Artifacts) > 0 {
100100
paths, artifactVars, err := e.downloadInstallArtifacts(ctx, engine, inst.Artifacts)
@@ -109,7 +109,7 @@ func (e *Executor) Install(ctx context.Context, engine string) error {
109109
e.emitInstallProgress(engine, "verified", 50)
110110
}
111111
if len(inst.Run) > 0 {
112-
e.emitInstallProgress(engine, "installing", 75)
112+
e.emitInstallProgress(engine, "installing", 0)
113113
args, err := resolveArgs(inst.Run, vars)
114114
if err != nil {
115115
e.reportInstallFailed(engine, err)
@@ -456,8 +456,7 @@ func runManifestCommand(ctx context.Context, argv []string, environment map[stri
456456
}
457457

458458
func (e *Executor) reportInstallFailed(engine string, err error) {
459-
e.notify("engine:install-progress", map[string]any{"engine": engine, "stage": "failed", "percent": -1, "error": err.Error()})
460-
e.progress.publish(ProgressEvent{Engine: engine, Op: "install", Stage: "failed", Percent: -1, Message: err.Error()})
459+
e.emitInstallFrame(engine, "failed", -1, err.Error())
461460
e.reporter.report(serviceError{
462461
ID: installFailedID(engine), Message: err.Error(),
463462
Severity: "error", Action: "retry", EngineType: engine, Operation: "install",

‎services/nvpair-engine-manager/progress.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ type ProgressEvent struct {
1717
Message string `json:"message,omitempty"`
1818
}
1919

20-
// wirePercentIncluded reports whether a pull progress percent should appear on
21-
// the wire. Zero means indeterminate (CLI pulls without byte progress); negative
22-
// values are terminal error sentinels. Install progress still always includes
23-
// percent (including 0) via emitInstallProgress.
20+
// wirePercentIncluded reports whether an install or pull progress percent should
21+
// appear on the wire. Zero means indeterminate (an opaque install command, or a
22+
// CLI pull or download without byte progress); negative values are terminal
23+
// error sentinels.
2424
func wirePercentIncluded(pct int) bool {
2525
return pct > 0 || pct < 0
2626
}

‎services/nvpair-engine-manager/progress_test.go‎

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
package main
55

66
import (
7+
"context"
8+
"crypto/sha256"
9+
"encoding/hex"
10+
"net/http"
11+
"net/http/httptest"
12+
"path/filepath"
13+
"sync"
714
"testing"
815
"time"
916
)
@@ -65,22 +72,144 @@ func TestProgressHubDropsWhenFull(t *testing.T) {
6572
// TestEmitInstallProgressPublishesToHub verifies the executor helper both
6673
// notifies and feeds the hub.
6774
func TestEmitInstallProgressPublishesToHub(t *testing.T) {
68-
e := &Executor{progress: newProgressHub()}
75+
var gotParams map[string]any
76+
e := &Executor{
77+
progress: newProgressHub(),
78+
emit: func(_ string, params any) {
79+
gotParams, _ = params.(map[string]any)
80+
},
81+
}
6982
ch, cancel := e.progress.subscribe("ollama")
7083
defer cancel()
7184

72-
e.emitInstallProgress("ollama", "installing", 75)
85+
e.emitInstallProgress("ollama", "downloading", 42)
7386

87+
if gotParams["stage"] != "downloading" || gotParams["percent"] != 42 {
88+
t.Fatalf("unexpected notification params: %+v", gotParams)
89+
}
7490
select {
7591
case ev := <-ch:
76-
if ev.Op != "install" || ev.Stage != "installing" || ev.Percent != 75 {
92+
if ev.Op != "install" || ev.Stage != "downloading" || ev.Percent != 42 {
7793
t.Fatalf("unexpected event: %+v", ev)
7894
}
7995
case <-time.After(time.Second):
8096
t.Fatal("emitInstallProgress did not publish to the hub")
8197
}
8298
}
8399

100+
// TestEmitInstallProgressOmitsIndeterminatePercent verifies an unmeasurable
101+
// install step (percent 0) reaches the wire without a percent, so the UI shows
102+
// the stage alone instead of a made-up number.
103+
func TestEmitInstallProgressOmitsIndeterminatePercent(t *testing.T) {
104+
var gotParams map[string]any
105+
e := &Executor{
106+
progress: newProgressHub(),
107+
emit: func(_ string, params any) {
108+
gotParams, _ = params.(map[string]any)
109+
},
110+
}
111+
ch, cancel := e.progress.subscribe("ollama")
112+
defer cancel()
113+
114+
e.emitInstallProgress("ollama", "installing", 0)
115+
116+
if gotParams["engine"] != "ollama" || gotParams["stage"] != "installing" {
117+
t.Fatalf("unexpected notification params: %+v", gotParams)
118+
}
119+
if _, ok := gotParams["percent"]; ok {
120+
t.Fatalf("indeterminate install progress carried a percent: %+v", gotParams)
121+
}
122+
select {
123+
case ev := <-ch:
124+
if ev.Stage != "installing" || ev.Percent != 0 {
125+
t.Fatalf("unexpected hub event: %+v", ev)
126+
}
127+
case <-time.After(time.Second):
128+
t.Fatal("emitInstallProgress did not publish to the hub")
129+
}
130+
}
131+
132+
// TestInstallFlowReportsOnlyMeasurablePercents drives a full fetch+run install
133+
// and checks that only measurable (download) and terminal frames carry a
134+
// percent; the verified and installing steps are indeterminate.
135+
func TestInstallFlowReportsOnlyMeasurablePercents(t *testing.T) {
136+
payload := []byte("engine payload")
137+
sum := sha256.Sum256(payload)
138+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
139+
_, _ = w.Write(payload)
140+
}))
141+
defer srv.Close()
142+
bin := filepath.Join(t.TempDir(), "engine"+exeExt())
143+
m := &Manifest{
144+
Engine: "fake", DisplayName: "Fake", ManifestVersion: 1,
145+
Platforms: map[string]Platform{hostKey(): {
146+
Detect: []string{bin},
147+
Install: &Install{
148+
Fetch: &Fetch{URL: srv.URL, SHA256: hex.EncodeToString(sum[:])},
149+
Run: []string{fakeEngineBin, "touch", bin},
150+
Mode: "user",
151+
},
152+
Runtime: Runtime{Bin: fakeEngineBin},
153+
}},
154+
}
155+
156+
var mu sync.Mutex
157+
var frames []map[string]any
158+
reg := NewRegistry()
159+
reg.engines[m.Engine] = m
160+
ex := NewExecutor(reg, NewReporter(nil), func(method string, params any) {
161+
if method != "engine:install-progress" {
162+
return
163+
}
164+
p, _ := params.(map[string]any)
165+
mu.Lock()
166+
frames = append(frames, p)
167+
mu.Unlock()
168+
}, t.TempDir())
169+
ex.detectTimeout = 2 * time.Second
170+
171+
if err := ex.Install(context.Background(), m.Engine); err != nil {
172+
t.Fatalf("install: %v", err)
173+
}
174+
175+
mu.Lock()
176+
defer mu.Unlock()
177+
stages := map[string]bool{}
178+
measuredDownload := false
179+
for _, f := range frames {
180+
stage, _ := f["stage"].(string)
181+
stages[stage] = true
182+
pct, hasPct := f["percent"]
183+
switch stage {
184+
case "downloading":
185+
if hasPct {
186+
if p, _ := pct.(int); p <= 0 {
187+
t.Errorf("downloading frame carried a non-positive percent: %+v", f)
188+
}
189+
measuredDownload = true
190+
}
191+
case "verified", "installing":
192+
if hasPct {
193+
t.Errorf("%s frame carried a percent: %+v", stage, f)
194+
}
195+
case "done":
196+
if pct != 100 {
197+
t.Errorf("done frame percent = %v, want 100", pct)
198+
}
199+
default:
200+
t.Errorf("unexpected install stage %q: %+v", stage, f)
201+
}
202+
}
203+
if !measuredDownload {
204+
t.Errorf("no downloading frame carried a measured percent; got %+v", frames)
205+
}
206+
for _, want := range []string{"downloading", "verified", "installing", "done"} {
207+
if !stages[want] {
208+
t.Errorf("missing %q frame; got %+v", want, frames)
209+
}
210+
}
211+
}
212+
84213
// TestEmitPullProgressNotifiesAndPublishes verifies the pull helper emits the
85214
// local engine:pull-progress notification (with op/message) and also feeds the
86215
// hub, so a local pull surfaces progress like a remote pull.

0 commit comments

Comments
 (0)