From cad446da2b9d08cbbd21fc2118761829ba088785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Troed=20S=C3=A5ngberg?= Date: Fri, 21 Aug 2026 13:35:52 +0200 Subject: [PATCH] docs: implementation plan for session-scoped server selection --- ...6-08-21-session-scoped-server-selection.md | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-session-scoped-server-selection.md diff --git a/docs/superpowers/plans/2026-08-21-session-scoped-server-selection.md b/docs/superpowers/plans/2026-08-21-session-scoped-server-selection.md new file mode 100644 index 0000000..d62bc54 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-session-scoped-server-selection.md @@ -0,0 +1,319 @@ +# Session-Scoped Server Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Poll only the llama-server backing the current session's model provider, falling back to polling all detected servers when the mapping is unknown; fold the result into the existing v1.3.0 release. + +**Architecture:** New pure function `selectSessionUrls(candidates, providers, providerID)` in `src/stats.ts` narrows the candidate URL list per poll cycle using the route session's `providerID` matched against `api.state.provider`. `tui.tsx` calls it inside `pollMetrics`. `resolveServerUrls` additionally dedupes detected lists. + +**Tech Stack:** TypeScript, node:test via `npx -y tsx test-backoff.ts` (the repo has no node_modules/tsconfig; `npm run prepack` runs the same test file). + +**Spec:** `docs/superpowers/specs/2026-08-21-session-scoped-server-selection-design.md` + +--- + +### Task 1: `selectSessionUrls` in src/stats.ts + +**Files:** +- Modify: `src/stats.ts` (append after `resolveServerUrls`, end of file) +- Test: `test-backoff.ts` + +- [ ] **Step 1: Write failing tests** + +Add to the end of `test-backoff.ts`: + +```ts +test("selectSessionUrls narrows to the matching provider's baseURL", () => { + const candidates = ["http://a:1", "http://b:2"] + const providers = [ + { id: "vision", name: "Vision", options: { baseURL: "http://c:3" } }, + { id: "llamacpp-a", name: "Llama A", options: { baseURL: "http://b:2/v1" } }, + ] + assert.deepEqual(selectSessionUrls(candidates, providers, "llamacpp-a"), ["http://b:2"]) +}) + +test("selectSessionUrls matches base_url variant", () => { + const candidates = ["http://localhost:9090"] + const providers = [{ id: "llamacpp-b", name: "L", options: { base_url: "http://localhost:9090" } }] + assert.deepEqual(selectSessionUrls(candidates, providers, "llamacpp-b"), ["http://localhost:9090"]) +}) + +test("selectSessionUrls normalizes /v1 before comparing", () => { + const candidates = ["http://x:9"] + const providers = [{ id: "p", name: "P", options: { baseURL: "http://x:9/v1" } }] + assert.deepEqual(selectSessionUrls(candidates, providers, "p"), ["http://x:9"]) +}) + +test("selectSessionUrls returns candidates unchanged when provider not found", () => { + const candidates = ["http://a:1"] + assert.deepEqual(selectSessionUrls(candidates, [], "nope"), ["http://a:1"]) +}) + +test("selectSessionUrls returns candidates unchanged when matched provider URL is not a candidate", () => { + const candidates = ["http://a:1"] + const providers = [{ id: "ollama", name: "Ollama", options: { baseURL: "http://d:4" } }] + assert.deepEqual(selectSessionUrls(candidates, providers, "ollama"), ["http://a:1"]) +}) + +test("selectSessionUrls returns candidates unchanged for missing or empty providerID", () => { + assert.deepEqual(selectSessionUrls(["http://a:1"], [], undefined), ["http://a:1"]) + assert.deepEqual(selectSessionUrls(["http://a:1"], [], ""), ["http://a:1"]) +}) + +test("selectSessionUrls returns empty candidates unchanged", () => { + assert.deepEqual(selectSessionUrls([], [], "p"), []) +}) + +test("selectSessionUrls tolerates non-array and malformed providers", () => { + const c = ["http://a:1"] + assert.deepEqual(selectSessionUrls(c, undefined, "p"), c) + assert.deepEqual(selectSessionUrls(c, [null, 42, {}, { id: "p" }], "p"), c) +}) + +test("selectSessionUrls first match wins for duplicate ids", () => { + const candidates = ["http://a:1", "http://b:2"] + const providers = [ + { id: "dup", name: "first", options: { baseURL: "http://b:2/v1" } }, + { id: "dup", name: "second", options: { baseURL: "http://a:1" } }, + ] + assert.deepEqual(selectSessionUrls(candidates, providers, "dup"), ["http://b:2"]) +}) +``` + +Also update the import line at the top of `test-backoff.ts` to include `selectSessionUrls` alongside the existing stats imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx -y tsx test-backoff.ts` +Expected: FAIL — `does not provide an export named 'selectSessionUrls'`. + +- [ ] **Step 3: Implement** + +Append to `src/stats.ts` after `resolveServerUrls`: + +```ts +export function selectSessionUrls( + candidates: string[], + providers: unknown, + providerID: string | undefined, +): string[] { + if (!providerID || providerID.trim() === "" || candidates.length === 0) return candidates + if (!Array.isArray(providers)) return candidates + for (const entry of providers) { + if (!entry || typeof entry !== "object") continue + const provider = entry as Record + if (provider.id !== providerID) continue + const opts = provider.options + if (opts && typeof opts === "object") { + const baseURL = + (opts as Record).baseURL || (opts as Record).base_url + if (typeof baseURL === "string") { + const normalized = stripBaseUrlPath(baseURL) + if (candidates.includes(normalized)) return [normalized] + } + } + break + } + return candidates +} +``` + +Note the `break`: the first entry whose `id` equals `providerID` decides the outcome; later duplicates are never consulted. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx -y tsx test-backoff.ts` +Expected: PASS — all prior 21 plus the new ones, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/stats.ts test-backoff.ts +git commit -m "feat: narrow polled servers to the current session's provider" +``` + +--- + +### Task 2: Dedup detected lists in `resolveServerUrls` + +**Files:** +- Modify: `src/stats.ts` (`resolveServerUrls` body, last four lines of the function) +- Test: `test-backoff.ts` + +- [ ] **Step 1: Write failing test** + +Append to `test-backoff.ts`: + +```ts +test("duplicate provider URLs are deduped", () => { + const providers = [ + { id: "llamacpp-a", name: "A", options: { baseURL: "http://same:7" } }, + { id: "llamacpp-b", name: "B", options: { baseURL: "http://same:7/v1" } }, + ] + assert.deepEqual(resolveServerUrls(undefined, providers, {}), ["http://same:7"]) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `npx -y tsx test-backoff.ts` +Expected: FAIL — actual result contains `"http://same:7"` twice. + +- [ ] **Step 3: Implement** + +In `src/stats.ts`, change the tail of `resolveServerUrls` from: + +```ts + const detected = extractProviderUrlsFromList(providers) + if (detected.length > 0) return detected + const legacy = extractProviderUrls(config) + return legacy.length > 0 ? legacy : ["http://localhost:8080"] +``` + +to: + +```ts + const detected = [...new Set(extractProviderUrlsFromList(providers))] + if (detected.length > 0) return detected + const legacy = [...new Set(extractProviderUrls(config))] + return legacy.length > 0 ? legacy : ["http://localhost:8080"] +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `npx -y tsx test-backoff.ts` +Expected: PASS, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/stats.ts test-backoff.ts +git commit -m "fix: dedupe discovered server URLs" +``` + +--- + +### Task 3: Wire session-scoped selection into tui.tsx + +**Files:** +- Modify: `tui.tsx` (import line ~5; `pollMetrics` body lines ~183–197) + +No unit-test step: `tui.tsx` is host wiring, untested by the suite (established convention). Verify via grep + test run. + +- [ ] **Step 1: Extend the import from `./src/stats`** + +The import at the top currently includes `resolveServerUrls`; add `selectSessionUrls` so it reads (keep alphabetical order as in the file): + +```tsx +import { backoffDelayMs, fetchSlots, processPoll, resolveServerUrls, selectSessionUrls } from "./src/stats" +``` + +(Match the exact current import statement style — single import from `./src/stats`.) + +- [ ] **Step 2: Capture providerID and select per cycle** + +In `pollMetrics`, replace: + +```tsx + let model = llamaServerModel + if (!model) { + const routeSessionID = api.route.current.params?.sessionID + if (routeSessionID) { + const session = api.state.session.get(routeSessionID) + if (session?.model?.id) { + model = session.model.id + } + } + } + + for (const baseUrl of llamaServerUrls) { +``` + +with: + +```tsx + let model = llamaServerModel + let sessionProviderId: string | undefined + if (!model) { + const routeSessionID = api.route.current.params?.sessionID + if (routeSessionID) { + const session = api.state.session.get(routeSessionID) + if (session?.model?.id) { + model = session.model.id + } + sessionProviderId = session?.model?.providerID + } + } + + const activeUrls = selectSessionUrls(llamaServerUrls, api.state.provider, sessionProviderId) + if (!activeUrls.length) return + for (const baseUrl of activeUrls) { +``` + +Leave the rest of the loop body untouched. The startup block at ~line 308 (`llamaServerUrls = resolveServerUrls(...)`) stays exactly as is. + +- [ ] **Step 3: Verify** + +Run: `npx -y tsx test-backoff.ts` → all pass. +Run: `grep -n "selectSessionUrls\|sessionProviderId\|activeUrls" tui.tsx` → import + the three new usages, nothing else changed. + +- [ ] **Step 4: Commit** + +```bash +git add tui.tsx +git commit -m "feat: poll only the current session's llama-server" +``` + +--- + +### Task 4: README note + +**Files:** +- Modify: `README.md` (Server Discovery section, after the provider-list paragraph) + +- [ ] **Step 1: Add one sentence** + +After the paragraph beginning "When no explicit `server` option is set…" (the provider-list discovery paragraph), add: + +```markdown +When several llama-server providers are detected, stats follow the provider used by the current session; until a session has picked a model, every detected server is polled. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: describe session-scoped server selection" +``` + +--- + +### Task 5: Fold into the v1.3.0 release + +**Files:** +- None modified. Tag/publish operations only. + +- [ ] **Step 1: Full verification** + +Run: `npm run prepack` +Expected: all tests pass, exit 0. Working tree clean. + +- [ ] **Step 2: Move tag and push** + +```bash +git tag -f v1.3.0 +git push origin main +git push --force origin refs/tags/v1.3.0 +``` + +Expected: tag now points at HEAD (which still has `package.json` version `1.3.0`); remote updated. + +- [ ] **Step 3: Republish** + +`npm publish` publishes to the `@troed` scope registry configured in `~/.npmrc` (git.sync.wtf). If the registry rejects re-publishing an existing version number, retry with `npm publish --force`. + +Verify afterwards: `npm view @troed/oc-ls-stats versions --registry $(npm config get "@troed:registry" 2>/dev/null || npm config get registry)` shows 1.3.0, and the tarball for 1.3.0 contains `selectSessionUrls` in `src/stats.ts` (check `npm pack --dry-run` output lists files, then inspect the packed tarball). + +- [ ] **Step 4: Report** + +Report final commit list since `cfd9d93`, the moved tag SHA, and publish result.