From f62c1f7e2f0860b13bea547ce75e0f9598a7514b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 9 Nov 2026 02:51:07 +0100 Subject: [PATCH] fix(local-model): a view read while the job ends no longer shows paused The install window polls GET /api/local-model every second. A read of the state file that was still out while the job wrote its last state and let go of this.job came back with the old `installing`, and settling that with no job in the process read it as an interrupted job: `paused` with Resume for one tick before `failed`. Under CI load this made test/local-model-install-e2e.test.ts fail on a different failure case per run (master 86502b69, PR #790). currentState() now reads again when the job ended during the read. The job writes before it lets go, so the second read has the final state. Co-Authored-By: Claude Opus 5.5 --- src/local-model/install-job.ts | 11 ++++- test/local-model-install-service.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index cc2ae0d8..77efab96 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -1053,9 +1053,16 @@ class LocalModelServiceImpl implements LocalModelService { return progress ? { ...next, progress } : next; } - /** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */ + /** + * The stored state as a view or a verb must see it: settled, unless a verb is just starting a + * job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while + * this read was out, the read may hold the `installing` from before that write; it is read + * again rather than settled, or it would show as an interrupted (`paused`) job for a tick. + */ private async currentState(): Promise { - const state = await this.deps.readState(); + const jobAtRead = this.job; + let state = await this.deps.readState(); + if (jobAtRead && !this.job) state = await this.deps.readState(); return this.jobStarting ? state : this.settled(state); } diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index bae8ffbe..e1206d99 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -1938,6 +1938,57 @@ describe('view()', () => { await h.service.cancel(); }); + it('a view whose read began while the job ran shows how the job ended, never paused', async () => { + // The window polls every second, so a read of the state file can be under way while the + // job writes its last state and lets go. That read returns the old `installing`, and + // settling it with no job in this process read it as an interrupted job: the window (and + // the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`. + const slow = { + read: undefined as undefined | (() => Promise), + hold: undefined as undefined | Promise, + entered: undefined as undefined | (() => void), + }; + const h = setup({ + overrides: { + readState: async () => { + const snapshot = await slow.read!(); + const hold = slow.hold; + slow.hold = undefined; + if (hold) { + slow.entered?.(); + await hold; + } + return snapshot; + }, + }, + }); + slow.read = h.store.readState; + let failVerify!: () => void; + const verifying = new Promise((resolve) => (failVerify = resolve)); + h.deps.verifyModel.mockImplementationOnce(async () => { + await verifying; + return { ok: false, code: 'no-tool-call' } as never; + }); + + await h.service.install(REQUEST, ADMIN); + await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled()); + + // A view starts reading while the job is still in the verify step... + let releaseRead!: () => void; + slow.hold = new Promise((resolve) => (releaseRead = resolve)); + const entered = new Promise((resolve) => (slow.entered = resolve)); + const viewing = h.service.view(); + await entered; + // ...the job fails, writes it and ends while that read is still out... + failVerify(); + await h.service.idle(); + expect(h.store.get().state).toBe('failed'); + // ...and only then does the read come back, with the file as it was before. + releaseRead(); + const view = await viewing; + expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } }); + }); + it('names the model to try next only when the catalogue still has it', async () => { const state: LocalModelStateFile = { ...jobAt('verify', 'paused'),