Archived
docs(sessions): document background catalog refresh and contain offline env window
Finding 6: serialize createOfflineModelRuntime so overlapping calls cannot interleave their PI_OFFLINE save/restore pairs and leave the process offline, and name the process-wide visibility of that window in the docblock. Finding 7: assert the offline construction through the public refresh seam via reloadConfig() — the request path that regressed — instead of reading upstream's private modelNetworkEnabled field. Finding 8.4/8.5: document the background provider-catalog refresh in docs/config.md and docs/config.html (cadence, timeout, single retry, offline opt-out via PI_WEB_OFFLINE / PI_OFFLINE only), and update the changeset to match the behavior after the earlier fixes.
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@jmfederico/pi-web": patch
|
||||||
---
|
---
|
||||||
|
|
||||||
Fix multi-minute stalls when opening the model selector, starting sessions, or using the auth dialogs. Provider catalog refreshes no longer run unbounded network fetches on request paths: the shared model runtime now operates offline and pi-web refreshes provider catalogs itself on a bounded, background schedule and after provider logins.
|
Fix multi-minute stalls when opening the model selector, starting sessions, or using the auth dialogs. Provider model catalogs are no longer fetched on request paths: the session daemon now refreshes them in the background on a bounded schedule — shortly after startup and hourly, plus immediately after a provider login or logout — with a per-run timeout and a single retry, keeping the stored catalogs when a provider fails. Setting `PI_WEB_OFFLINE` or `PI_OFFLINE` disables these background refreshes entirely. See the configuration reference for details.
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
<a href="#manual-uploads">Manual uploads</a>
|
<a href="#manual-uploads">Manual uploads</a>
|
||||||
<a href="#agent-runtime">Agent profile and companion CLI</a>
|
<a href="#agent-runtime">Agent profile and companion CLI</a>
|
||||||
<a href="#pi-extension-provider-baseline">Pi extension providers</a>
|
<a href="#pi-extension-provider-baseline">Pi extension providers</a>
|
||||||
|
<a href="#catalog-refresh">Model catalog refresh</a>
|
||||||
<a href="#session-tools">Session tools</a>
|
<a href="#session-tools">Session tools</a>
|
||||||
<a href="#completion-tools">Completion tools</a>
|
<a href="#completion-tools">Completion tools</a>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -479,6 +480,17 @@
|
|||||||
<td>Not supported locally</td>
|
<td>Not supported locally</td>
|
||||||
<td>Restart web/API after env changes</td>
|
<td>Restart web/API after env changes</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Offline mode</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td><code>PI_WEB_OFFLINE</code>, <code>PI_OFFLINE</code></td>
|
||||||
|
<td>Web/API + session daemon env</td>
|
||||||
|
<td>Not supported locally</td>
|
||||||
|
<td>
|
||||||
|
Restart session daemon and web/API after env changes; also disables the
|
||||||
|
<a href="#catalog-refresh">background model catalog refresh</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,6 +654,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="catalog-refresh">
|
||||||
|
<h2>Background model catalog refresh</h2>
|
||||||
|
<p>
|
||||||
|
PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the
|
||||||
|
network only on the session daemon's own background schedule. Nothing a browser or API request triggers
|
||||||
|
waits on a provider catalog fetch, so a slow or unreachable provider cannot stall opening the model
|
||||||
|
selector, starting a session, or the auth dialogs.
|
||||||
|
</p>
|
||||||
|
<p>The session daemon runs the refresh:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>15 seconds after the daemon starts</strong>, then <strong>hourly</strong>. Pi treats stored
|
||||||
|
catalogs as fresh for four hours, so most hourly ticks make no network request at all; the shorter tick
|
||||||
|
only makes sure a due refresh is not delayed to the next tick.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Immediately after a provider login or logout</strong>, bypassing that freshness window, because
|
||||||
|
the cached catalog is known to be wrong.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Each run is bounded: it is aborted after <strong>60 seconds</strong>, and a run that times out or fails
|
||||||
|
earns <strong>one retry after five minutes</strong>. Failures never clear the stored catalogs — the last
|
||||||
|
successfully fetched models stay in use and the daemon log records what failed. A refresh in flight is
|
||||||
|
also aborted when the daemon shuts down.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Models fetched by a background refresh appear the next time a client asks for the model list, so a model
|
||||||
|
selector left open across a refresh may need to be reopened.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To turn the background refresh off entirely, set <code>PI_WEB_OFFLINE</code> or <code>PI_OFFLINE</code> in
|
||||||
|
the session daemon's environment and restart it. In offline mode PI WEB performs no provider catalog
|
||||||
|
network requests, including after logins, and sessions use the catalogs already stored in the agent
|
||||||
|
profile. The <code>PI_WEB_SKIP_VERSION_CHECK</code> and <code>PI_SKIP_VERSION_CHECK</code> keys do
|
||||||
|
<strong>not</strong> affect this refresh; they only suppress PI WEB release checks.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="session-tools">
|
<section id="session-tools">
|
||||||
<h2>Session daemon tools</h2>
|
<h2>Session daemon tools</h2>
|
||||||
<h3><code>spawnSessions</code></h3>
|
<h3><code>spawnSessions</code></h3>
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
|
|||||||
| Agent profile session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override |
|
| Agent profile session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override |
|
||||||
| Agent profile state directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services |
|
| Agent profile state directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services |
|
||||||
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
|
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
|
||||||
|
| Offline mode | — | `PI_WEB_OFFLINE`, `PI_OFFLINE` | Web/API + session daemon env | Not supported locally | Restart session daemon and web/API after env changes; also disables the [background model catalog refresh](#background-model-catalog-refresh) |
|
||||||
|
|
||||||
## Key details
|
## Key details
|
||||||
|
|
||||||
@@ -218,6 +219,21 @@ Ignored mutations are written to the session-daemon log once per operation and p
|
|||||||
|
|
||||||
Configure providers before the daemon starts: use the active agent directory's `models.json`, or install the Pi extension globally in that agent profile. Project Pi extensions and project-level `models.json` files cannot add providers to PI WEB's shared baseline. After updating PI WEB—or after installing, removing, or updating a global Pi extension that registers providers—manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the baseline.
|
Configure providers before the daemon starts: use the active agent directory's `models.json`, or install the Pi extension globally in that agent profile. Project Pi extensions and project-level `models.json` files cannot add providers to PI WEB's shared baseline. After updating PI WEB—or after installing, removing, or updating a global Pi extension that registers providers—manually restart `pi-web-sessiond.service` (`systemctl --user restart pi-web-sessiond`). Restarting only the web/API service and running `/reload` do not rebuild the baseline.
|
||||||
|
|
||||||
|
### Background model catalog refresh
|
||||||
|
|
||||||
|
PI WEB shares one model runtime across all sessions, and provider model catalogs are refreshed over the network only on the session daemon's own background schedule. Nothing a browser or API request triggers waits on a provider catalog fetch, so a slow or unreachable provider cannot stall opening the model selector, starting a session, or the auth dialogs.
|
||||||
|
|
||||||
|
The session daemon runs the refresh:
|
||||||
|
|
||||||
|
- **15 seconds after the daemon starts**, then **hourly**. Pi treats stored catalogs as fresh for four hours, so most hourly ticks make no network request at all; the shorter tick only makes sure a due refresh is not delayed to the next tick.
|
||||||
|
- **Immediately after a provider login or logout**, bypassing that freshness window, because the cached catalog is known to be wrong.
|
||||||
|
|
||||||
|
Each run is bounded: it is aborted after **60 seconds**, and a run that times out or fails earns **one retry after five minutes**. Failures never clear the stored catalogs — the last successfully fetched models stay in use and the daemon log records what failed. A refresh in flight is also aborted when the daemon shuts down.
|
||||||
|
|
||||||
|
Models fetched by a background refresh appear the next time a client asks for the model list, so a model selector left open across a refresh may need to be reopened.
|
||||||
|
|
||||||
|
To turn the background refresh off entirely, set `PI_WEB_OFFLINE` or `PI_OFFLINE` in the session daemon's environment and restart it. In offline mode PI WEB performs no provider catalog network requests, including after logins, and sessions use the catalogs already stored in the agent profile. The `PI_WEB_SKIP_VERSION_CHECK` and `PI_SKIP_VERSION_CHECK` keys do **not** affect this refresh; they only suppress PI WEB release checks.
|
||||||
|
|
||||||
### Session daemon tools
|
### Session daemon tools
|
||||||
|
|
||||||
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
|
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
|
||||||
|
|||||||
@@ -471,14 +471,16 @@ describe("AuthService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createModelRuntimeForAgentDir", () => {
|
describe("createModelRuntimeForAgentDir", () => {
|
||||||
it("disables runtime-owned network refreshes so request paths stay local", async () => {
|
it("keeps runtime-owned refreshes local so request paths cannot stall", async () => {
|
||||||
// The stall fix relies on this construction-time flag: reloadConfig(),
|
// reloadConfig() is the request-path call site that regressed: it refreshes
|
||||||
// login(), and logout() refresh with allowNetwork = modelNetworkEnabled.
|
// with allowNetwork = the construction-time network flag and no abort signal.
|
||||||
// Asserting the private field is proportionate here because the flag is
|
|
||||||
// exactly the contract this change depends on.
|
|
||||||
const agentDir = await tempAgentDir();
|
const agentDir = await tempAgentDir();
|
||||||
const runtime = await createModelRuntimeForAgentDir(agentDir);
|
const runtime = await createModelRuntimeForAgentDir(agentDir);
|
||||||
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
|
const refresh = vi.spyOn(runtime, "refresh");
|
||||||
|
|
||||||
|
await runtime.reloadConfig();
|
||||||
|
|
||||||
|
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a previously set PI_OFFLINE after runtime creation", async () => {
|
it("restores a previously set PI_OFFLINE after runtime creation", async () => {
|
||||||
@@ -495,12 +497,36 @@ describe("createModelRuntimeForAgentDir", () => {
|
|||||||
try {
|
try {
|
||||||
const agentDir = await tempAgentDir();
|
const agentDir = await tempAgentDir();
|
||||||
const runtime = await createModelRuntimeForAgentDir(agentDir);
|
const runtime = await createModelRuntimeForAgentDir(agentDir);
|
||||||
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
|
const refresh = vi.spyOn(runtime, "refresh");
|
||||||
|
|
||||||
|
await runtime.reloadConfig();
|
||||||
|
|
||||||
|
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
|
||||||
expect(process.env["PI_OFFLINE"]).toBeUndefined();
|
expect(process.env["PI_OFFLINE"]).toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
|
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores PI_OFFLINE when creations overlap, because the env windows are serialized", async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
const previous = process.env["PI_OFFLINE"];
|
||||||
|
delete process.env["PI_OFFLINE"];
|
||||||
|
try {
|
||||||
|
const dirs = await Promise.all([tempAgentDir(), tempAgentDir(), tempAgentDir()]);
|
||||||
|
const runtimes = await Promise.all(dirs.map((dir) => createModelRuntimeForAgentDir(dir)));
|
||||||
|
|
||||||
|
// Interleaved save/restore pairs would leave PI_OFFLINE set process-wide.
|
||||||
|
expect(process.env["PI_OFFLINE"]).toBeUndefined();
|
||||||
|
for (const runtime of runtimes) {
|
||||||
|
const refresh = vi.spyOn(runtime, "refresh");
|
||||||
|
await runtime.reloadConfig();
|
||||||
|
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
|
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ interface AuthChangeContext {
|
|||||||
|
|
||||||
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
|
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the `PI_OFFLINE` windows below, which is what keeps their
|
||||||
|
* save/restore pairs properly nested. Two overlapping calls would otherwise both
|
||||||
|
* capture the forced `"1"` and restore it, leaving the whole process offline
|
||||||
|
* permanently. Owned by this module only; nothing else may mutate it.
|
||||||
|
*/
|
||||||
|
let offlineRuntimeCreations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the shared model runtime with runtime-owned network refreshes disabled.
|
* Create the shared model runtime with runtime-owned network refreshes disabled.
|
||||||
*
|
*
|
||||||
@@ -42,12 +50,33 @@ const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
|
|||||||
* fetch. Forcing `PI_OFFLINE` during construction makes every runtime-driven
|
* fetch. Forcing `PI_OFFLINE` during construction makes every runtime-driven
|
||||||
* refresh local-only; pi-web performs its own bounded catalog refreshes in the
|
* refresh local-only; pi-web performs its own bounded catalog refreshes in the
|
||||||
* background instead (see modelCatalogRefresher.ts).
|
* background instead (see modelCatalogRefresher.ts).
|
||||||
|
*
|
||||||
|
* `modelNetworkEnabled` is computed once from the environment inside
|
||||||
|
* `ModelRuntime.create()`, so the env var is the only lever upstream exposes.
|
||||||
|
* Calls are queued so their env windows never overlap.
|
||||||
*/
|
*/
|
||||||
async function createOfflineModelRuntime(options: CreateModelRuntimeOptions): Promise<ModelRuntime> {
|
function createOfflineModelRuntime(options: CreateModelRuntimeOptions): Promise<ModelRuntime> {
|
||||||
|
const created = offlineRuntimeCreations.then(() => forceOfflineWhile(() => ModelRuntime.create(options)));
|
||||||
|
// A failed creation must not poison the queue; the caller still sees the rejection.
|
||||||
|
offlineRuntimeCreations = created.then(() => undefined, () => undefined);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force `PI_OFFLINE` for the duration of `create`, then restore what was there.
|
||||||
|
*
|
||||||
|
* `process.env` is process-wide, so this window is a real global side effect:
|
||||||
|
* anything reading `PI_OFFLINE` while `create` awaits observes offline mode,
|
||||||
|
* including upstream's package manager, tools manager, and version check. That
|
||||||
|
* is acceptable because pi-web only builds runtimes during daemon startup and in
|
||||||
|
* tests, and the window is one `ModelRuntime.create()` call — but it is why
|
||||||
|
* callers must stay serialized rather than run concurrently.
|
||||||
|
*/
|
||||||
|
async function forceOfflineWhile<T>(create: () => Promise<T>): Promise<T> {
|
||||||
const previous = process.env["PI_OFFLINE"];
|
const previous = process.env["PI_OFFLINE"];
|
||||||
process.env["PI_OFFLINE"] = "1";
|
process.env["PI_OFFLINE"] = "1";
|
||||||
try {
|
try {
|
||||||
return await ModelRuntime.create(options);
|
return await create();
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env["PI_OFFLINE"];
|
if (previous === undefined) delete process.env["PI_OFFLINE"];
|
||||||
else process.env["PI_OFFLINE"] = previous;
|
else process.env["PI_OFFLINE"] = previous;
|
||||||
|
|||||||
Reference in New Issue
Block a user