Skip to content

Commit f305c3b

Browse files
committed
Preserve live engine status across delayed startup snapshots
Ignore per-engine hydration results when a newer state notification arrived while the request was in flight. This prevents an already-running engine from displaying Start and then stopping when toggled. Cover both running and stopped updates for llama.cpp, Ollama, and LM Studio, plus a later uncontested refresh. Native UI reproduction retained privately; fixed-build UI retest remains pending. Signed-off-by: pgoode41 <pgoode41@gmail.com>
1 parent 172c8bd commit f305c3b

2 files changed

Lines changed: 54 additions & 0 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ function proxyEngineFromRelaySource(source: string): ProxyEngine | null {
335335
*/
336336
class ModularSupervisor {
337337
private processes = new Map<ModularProcessName, JsonRpcSubprocess>()
338+
private engineEventRevisions = new Map<EngineType, number>()
338339
private readinessWaiters = new Map<number, ReadinessWaiter>()
339340
private nextReadinessWaiterId = 0
340341
private readinessReported = false
@@ -1126,10 +1127,14 @@ class ModularSupervisor {
11261127
private async hydrateEngineManager(): Promise<void> {
11271128
if (!this.processes.has('broker')) return
11281129
try {
1130+
// A push received while this snapshot is in flight wins for its engine.
1131+
const atStart = new Map(this.engineEventRevisions)
11291132
const result = await this.callProcess('broker', 'engine:get-installed')
11301133
const obj = objectValue(result)
11311134
if (!obj || !Array.isArray(obj.engines)) return
11321135
for (const engine of obj.engines) {
1136+
const type = engineTypeFromManagerName(stringValue(objectValue(engine)?.engine))
1137+
if (!type || this.engineEventRevisions.get(type) !== atStart.get(type)) continue
11331138
getModularBridgeState().applyEngineManagerStatus(engine)
11341139
this.refreshManagedEngineModels(engine)
11351140
}
@@ -1637,6 +1642,12 @@ class ModularSupervisor {
16371642
return
16381643
}
16391644
if (notification.method === 'engine:state-changed') {
1645+
const type = engineTypeFromManagerName(
1646+
stringValue(objectValue(notification.params)?.engine)
1647+
)
1648+
if (type) {
1649+
this.engineEventRevisions.set(type, (this.engineEventRevisions.get(type) ?? 0) + 1)
1650+
}
16401651
getModularBridgeState().applyEngineManagerStatus(notification.params)
16411652
this.refreshManagedEngineModels(notification.params)
16421653
this.updateLocalNodeBridgeFromEngineState(notification.params)

‎desktop/tests/modular/modular-supervisor-readiness.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
const mocks = vi.hoisted(() => ({
1111
bridgeState: {
1212
handleNotification: vi.fn(),
13+
applyEngineManagerStatus: vi.fn(),
14+
modelPullTarget: vi.fn(() => null),
1315
getSelfId: vi.fn(() => null),
1416
getProxyPort: vi.fn(() => null)
1517
},
@@ -51,6 +53,7 @@ vi.mock('@/electron/service-bridge/node-info-poller', () => ({
5153

5254
vi.mock('@/electron/service-bridge/modular-state', () => ({
5355
getModularBridgeState: () => mocks.bridgeState,
56+
isProxyEngine: () => false,
5457
isUpstreamUnreachableError: () => false,
5558
parseServiceErrors: () => [],
5659
parseWorkloadsInitial: () => [],
@@ -66,6 +69,10 @@ import {
6669
interface ReadinessHarness {
6770
readonly ready: boolean
6871
processes: Map<string, JsonRpcSubprocess>
72+
engineEventRevisions: Map<string, number>
73+
hydrateEngineManager: () => Promise<void>
74+
handleEngineManagerNotification: (notification: JsonRpcNotification) => void
75+
callProcess: (process: string, method: string) => Promise<unknown>
6976
isReady: boolean
7077
brokerReady: boolean
7178
brokerHydrationDone: boolean
@@ -99,11 +106,14 @@ describe('modular supervisor readiness', () => {
99106
supervisor.brokerReady = false
100107
supervisor.brokerHydrationDone = false
101108
supervisor.processes.clear()
109+
supervisor.engineEventRevisions.clear()
110+
mocks.bridgeState.applyEngineManagerStatus.mockClear()
102111
supervisor.onReady = undefined
103112
supervisor.onBrokerReady = vi.fn().mockResolvedValue(undefined)
104113
})
105114

106115
afterEach(() => {
116+
vi.restoreAllMocks()
107117
for (const waiter of supervisor.readinessWaiters.values()) {
108118
clearTimeout(waiter.timeout)
109119
}
@@ -194,4 +204,37 @@ describe('modular supervisor readiness', () => {
194204

195205
expect(supervisor.ready).toBe(true)
196206
})
207+
208+
it.each(['llamacpp', 'ollama', 'lmstudio'])(
209+
'keeps newer %s pushes when the initial engine snapshot arrives late',
210+
async engine => {
211+
supervisor.processes.set('broker', new JsonRpcSubprocess('broker', 'unused'))
212+
for (const running of [true, false]) {
213+
let resolve!: (value: unknown) => void
214+
vi.spyOn(supervisor, 'callProcess').mockImplementation(
215+
() =>
216+
new Promise(value => {
217+
resolve = value
218+
})
219+
)
220+
const pending = supervisor.hydrateEngineManager()
221+
const newer = { engine, installed: true, running, healthy: running }
222+
supervisor.handleEngineManagerNotification({
223+
source: 'broker',
224+
method: 'engine:state-changed',
225+
params: newer
226+
})
227+
resolve({ engines: [{ ...newer, running: !running }] })
228+
await pending
229+
expect(mocks.bridgeState.applyEngineManagerStatus).toHaveBeenLastCalledWith(newer)
230+
mocks.bridgeState.applyEngineManagerStatus.mockClear()
231+
232+
const fresh = { ...newer, running: !running }
233+
vi.spyOn(supervisor, 'callProcess').mockResolvedValue({ engines: [fresh] })
234+
await supervisor.hydrateEngineManager()
235+
expect(mocks.bridgeState.applyEngineManagerStatus).toHaveBeenCalledWith(fresh)
236+
vi.restoreAllMocks()
237+
}
238+
}
239+
)
197240
})

0 commit comments

Comments
 (0)