diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index dead6b93..e17d63da 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -724,7 +724,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`). -⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. +⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again. **The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed. diff --git a/docs/local-models.md b/docs/local-models.md index a90d4817..1b412904 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -333,7 +333,8 @@ install does; a checked model is never deleted that way. ## Stop, Start, Remove - **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes - that. + that, and so does a switch to another model or the install of one: the job starts the + runtime, and the model it ends on is in use. - **Remove** stops the runtime, removes the endpoint record, switches the setting off again only when this feature switched it on and no other endpoint is saved, and deletes the runtime and its private home. The downloaded models (all of them) are deleted only when diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index 66cba2e1..f8835bf1 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -1955,6 +1955,7 @@ class LocalModelServiceImpl implements LocalModelService { steps: jobSteps({ agentInstalled: harness.installed }), }); if (refused) return refused; + await this.undoStop(); this.forgetStore(); void this.deps .audit({ @@ -2015,6 +2016,7 @@ class LocalModelServiceImpl implements LocalModelService { cli, }); if (refused) return refused; + await this.undoStop(); this.forgetStore(); void this.deps .audit({ @@ -2183,6 +2185,19 @@ class LocalModelServiceImpl implements LocalModelService { }); } + /** + * A job that starts from a model the person had stopped runs the runtime, and that model is + * then in use: the Stop is undone, like Start undoes it, so the runtime starts with the + * server again. Without this the model would be running now and stay down after a restart. + */ + private async undoStop(): Promise { + await this.record((current) => + current.runtime && !current.runtime.enabled + ? { ...current, runtime: { ...current.runtime, enabled: true } } + : undefined + ); + } + /** 409 `sessions-open` while live sessions use a connection of ours, else `undefined`. */ private sessionsOpen( state: LocalModelStateFile, diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index e2fc64dc..08ab420f 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -3046,6 +3046,31 @@ describe('use: the switch to a checked model', () => { expect(h.store.get().state).toBe('installed'); }); + it('a switch or a new model from a stopped model starts the runtime and undoes the Stop', async () => { + const stopped: LocalModelStateFile = { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } }; + const switched = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true }); + await switched.service.boot(); + expect(switched.supervisor.start).not.toHaveBeenCalled(); + await switched.service.use({ modelId: GPT.id }, ADMIN); + await switched.service.idle(); + // The model is in use and running now, so it starts with the server again, like after Start. + expect(switched.store.get()).toMatchObject({ state: 'installed', runtime: { enabled: true } }); + expect((await switched.service.view()).running).toBe(true); + + const added = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + added.world.runtimeOnDisk = true; + added.world.agentInstalled = true; + await added.service.boot(); + await added.service.install(ADD_GPT, ADMIN); + await added.service.idle(); + expect(added.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id }, runtime: { enabled: true } }); + + // A switch that was refused changed nothing: the model stays stopped. + const refused = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true }); + await refused.service.use({ modelId: 'someone-else:7b' }, ADMIN); + expect(refused.store.get().runtime?.enabled).toBe(false); + }); + it('Cancel pauses a switch like any job, and Resume goes on with it', async () => { const h = setup({ state: TWO_MODELS,