Files
waggle-os/docs/e2e-2026-04-30-fix-log.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

607 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# E2E Support Session Fix Log — 2026-04-30
CC role: SUPPORT — build apps/web dev server + remain on stand-by, fix friction reports as PM tests via Chrome MCP.
Build phase complete prior to FR #1:
- `apps/web` Vite 5.4.21 dev server live at `http://localhost:8080/` (background id `bvmhtwc59`)
- typecheck clean (`npx tsc --noEmit -p apps/web/tsconfig.json` → empty output)
- lint baseline 52E/19W left untouched per brief §1.3 + CLAUDE.md §3.3
- Branch: `main` @ `5ec069e`, in sync with origin
---
## Fix #1 — 2026-04-30 ~11:22 UTC
**Friction report:** FR #1 (P0 launch blocker) — Backend API on `127.0.0.1:3333` not running. Frontend `useWorkspaces`, `useAgentStatus`, `useNotifications`, `useOfflineStatus`, `useWaggleDance` all NetworkError. "Offline" pill shown.
**Root cause:** Brief §1 only covered `apps/web` Vite dev. The backend Fastify is a separate workspace (`packages/server`) and was never started. No `dev:all` / `dev:backend` aggregate script in root `package.json` (only `dev` aliasing to apps/web Vite).
**Fix (orchestration only — no code change):**
- Started `npm run dev --workspace=packages/server` in background (id `bz3s01t1b`)
- That runs `tsx watch src/local/start.ts``startService()` → Fastify on `127.0.0.1:3333`
**Files changed:** none (pure startup orchestration).
**Verification:**
- `[waggle:startup] Server listening on http://127.0.0.1:3333`
- `curl http://127.0.0.1:3333/health`**HTTP 200**
- `status: "ok"`, `mode: "local"`
- `llm.provider: "anthropic-proxy"`, `health: "healthy"`, `detail: "Built-in Anthropic proxy (API key configured)"`
- `database.healthy: true`
- `memoryStats.frameCount: 9`, embedding coverage 100% (Xenova/all-MiniLM-L6-v2 inprocess, 1024 dims)
- `serviceHealth.watchdogRunning: true`
- `defaultModel: "claude-sonnet-4-6"`
- LiteLLM unreachable (port 4000) — **graceful fallback** to built-in Anthropic proxy as designed (`service.ts` lines 191-200).
- One advisory: `/health.offline.offline = true` since `11:22:05` — likely external-internet probe lag, not API-server-down. Frontend should reconcile after first successful adapter call.
**PM verification:** PENDING — please refresh `http://localhost:8080/`, confirm console errors clear and "Offline" pill resolves.
**Commit:** none — orchestration only, no source diff. This fix-log will be committed at session end as audit trail.
---
## Fix #2 — 2026-04-30 ~13:54 UTC
**Friction report:** FR #2 (P0 launch blocker) — Waggle Dance dock icon crashes the window with `Cannot read properties of undefined (reading 'icon')`. ErrorBoundary shows "Waggle Dance encountered an error" with only `Close Window` available.
**Root cause:** Schema mismatch between server-emitted signals and frontend `typeConfig` lookup.
- Frontend `WaggleSignal['type']` (in `apps/web/src/lib/types.ts`) is the narrow union `'discovery' | 'handoff' | 'insight' | 'alert' | 'coordination'`.
- Server `/api/waggle/signals` emits agent-runtime lifecycle types: `agent:started`, `agent:completed`, `tool:called` (verified live: 4 such signals already in `default-workspace`).
- `WaggleDanceApp.tsx` did `const cfg = typeConfig[signal.type]; const Icon = cfg.icon;` with no null-guard. Unknown type → `cfg = undefined` → throw → ErrorBoundary catches.
**Fix (minimal, per PM ask):** Null-guard the lookup with a fallback config. No widening of the type union, no server change.
- Added `TypeConfigEntry` named type + `FALLBACK_TYPE_CONFIG` (Zap icon, muted color, raw `type` as label).
- Added `getTypeConfig(type: string)` helper that returns the known entry or fallback.
- Replaced 3 unsafe accesses in `WaggleDanceApp.tsx` (signal-list render, detail-pane icon, detail-pane label).
- Filter pills untouched — they iterate the constrained `filterTypes` subset, never miss.
**Files changed:** `apps/web/src/components/os/apps/WaggleDanceApp.tsx` (+17/-4).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- `npx vitest run src/lib/waggle-signals.test.ts`**11/11 pass**
- Mental walkthrough: agent-lifecycle signals now render with Zap icon + muted color + raw type label (e.g. `agent:completed`). Click-through to detail pane works.
**Followup (out of scope, surface for later):** API contract drift — server emits two unrelated type families. Either widen `WaggleSignal['type']` to include the agent-lifecycle union (and add typeConfig entries with proper icons/colors) OR split into `WaggleSignal` (cross-workspace dance) vs `AgentLifecycleSignal` (in-workspace runtime stream). Not blocking E2E.
**Commit:** `ea04110` — pushed `5ec069e..ea04110 main -> main` to `origin/main`.
**PM verification:** PENDING — please refresh `http://localhost:8080/`, click ⚡ dock icon, confirm Waggle Dance opens without crash.
---
## Fix #3 — 2026-04-30 ~14:10 UTC
**Friction report:** FR #3 (P1) — Top bar shows `claude-sonnet-4-6` while Chat window dropdown shows `claude-opus-4-6`. Two model UIs disagree.
**Root cause: a JSON-unwrap bug, not an architectural override.**
- Backend agreed with itself (verified live):
- `/api/agent/model``{"model":"claude-sonnet-4-6"}`
- `/api/agent/status.model``claude-sonnet-4-6`
- `/health.defaultModel``claude-sonnet-4-6`
- BUT `/api/settings.defaultModel = "claude-opus-4-6"` (persistent settings.json, last edited).
- `adapter.getModel()` did `return res.json()` and returned the whole `{model: "..."}` **object**, not the string the type signature promised.
- `ChatWindowInstance.tsx` line 156 then guards `typeof model === 'string'` → false (object) → falls through to `adapter.getSettings()` → reads `defaultModel: "claude-opus-4-6"` → renders that.
- Top bar uses `useAgentStatus` hook against `/api/agent/status` (correctly destructured) → renders `claude-sonnet-4-6`.
So both UIs were "right" by their own light, but the adapter served Chat the wrong shape and Chat fell back to a stale persistent default.
**Fix:** Unwrap `getModel()` correctly. Defensive: accept either shape (string or `{model}`) so future raw-string server responses keep working too.
**Files changed:** `apps/web/src/lib/adapter.ts` (+6/-1, single function `getModel`).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- Live API contracts unchanged; only client-side parsing fixed.
- After fix: ChatWindowInstance gets `"claude-sonnet-4-6"` from `/api/agent/model`, never falls through to settings, top bar + chat dropdown both render the same runtime model.
**Followup (out of scope):** persistent `settings.defaultModel` and runtime `agent.model` can still drift if `setModel` doesn't write back to settings.json. That's a state-sync question (which one is canonical at boot?) and the right fix is to either (a) load settings.defaultModel into the agent at boot, or (b) write through to settings.json on `setModel()`. Not blocking the user-visible inconsistency PM reported.
**Commit:** `2b6ffe1` — pushed `ea04110..2b6ffe1 main -> main`.
**PM verification:** PENDING — please refresh `http://localhost:8080/`, open Chat, compare top-bar model breadcrumb with Chat header dropdown. Both should now read `claude-sonnet-4-6`.
---
## Fix #5 — 2026-04-30 ~14:30 UTC
**Friction report:** FR #5 (P1) — Spawn Agent dialog warns *"Keys configured but no models returned — the LiteLLM proxy may not be running"* with empty model list. `/health` says built-in Anthropic proxy is healthy, so models *should* be available.
**Root cause:** `SpawnAgentDialog.fetchModels()` only consulted `/api/litellm/models`. That endpoint returns `{models: []}` when LiteLLM is unreachable (which is the default when the system runs against the built-in Anthropic-proxy fallback — verified live: `curl /api/litellm/models → {"models":[]}`). It does not consult the runtime model state. ChatWindowInstance dodges this with a 21-item hardcoded `FALLBACK_MODELS` list, but those identifiers (`anthropic/claude-sonnet-4.6`) **do not match** runtime model IDs (`claude-sonnet-4-6`) — using them in Spawn Agent could silently mis-route the spawn.
**Fix:** When `/api/litellm/models` returns empty, fall back to the runtime active model from `/api/agent/model` (now correctly unwrapped after Fix #3) and offer it as a single valid option. The empty-state CTA now only fires when *neither* LiteLLM nor a runtime model is available (e.g. no provider keys at all).
**Files changed:** `apps/web/src/components/os/overlays/SpawnAgentDialog.tsx` (+13/-1, single function `fetchModels`).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- `vitest run src/lib/spawn-agent-helpers.test.ts`**9/9 pass**
- Live runtime: `/api/agent/model``"claude-sonnet-4-6"` (after Fix #3 unwrap), so Spawn Agent will now show one chip labelled `claude-sonnet-4-6`, no warning copy.
**Followup (out of scope):** A real `/api/agent/models` endpoint that lists all known-good models for the active provider chain would be a cleaner architecture. ChatWindowInstance and SpawnAgentDialog would then drop their respective fallback paths and converge. Tracked as a backend-shape question, not a session blocker.
**Commit:** `977f1ec` — pushed `2b6ffe1..977f1ec main -> main`.
**PM verification:** PENDING — open Spawn Agent (rocket dock icon), Model section should show `claude-sonnet-4-6` chip, no "LiteLLM proxy may not be running" warning.
---
## Fix #7 — 2026-04-30 ~15:00 UTC
**Friction report:** FR #7 (P2) — After closing an app window, the top bar continues showing the closed app's name until another app is clicked.
**Root cause:** `useWindowManager.closeApp` only filters the `windows` array — it does not touch `focusedInstanceId`. The StatusBar's `focusedWindowLabel` IIFE in `Desktop.tsx` reads `wm.windows.find(w => w.instanceId === wm.focusedInstanceId)` — when the focused window is closed, this returns `undefined` and the label *should* clear, but the dangling-focus state still creates user-perceived staleness (Ctrl+` cycling, future programmatic removals, restoration races). The semantic is wrong even when the visible artifact briefly clears.
**Fix:** Declarative refocus effect inside `useWindowManager`. Whenever `windows` or `focusedInstanceId` changes, the effect:
- Bails if no focus is set or focus still maps to a real window.
- Sets focus to `null` when no windows remain.
- Otherwise picks the highest-zIndex non-minimized remaining window (falling back to highest zIndex overall if everything is minimized) and assigns focus to it.
This covers all current and future code paths that remove windows, not just `closeApp`. PM's acceptance criterion ("next-most-recent focused app or default") matches the highest-zIndex selection — that *is* the most-recently-foregrounded window.
**Files changed:** `apps/web/src/hooks/useWindowManager.ts` (+19/-0, new useEffect inserted right after `closeApp`).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- No existing `useWindowManager.test.*` to run; mental walkthrough: open A → close A → focus null, label clears; open A → open B → close B (top) → focus moves to A, label = A; open A → open B → close A (background) → focus stays on B (effect bails because focus already maps to B); empty array → null focus, no infinite loop (effect's first guard).
- Effect is idempotent: rerunning with same input produces same output, so React StrictMode double-invocation is safe.
**Followup (out of scope):** No tests for `useWindowManager` exist. Adding focus-management coverage would be a worthwhile refactor — multiple session bugs (FR #7, prior cascade issues, autonomy revert) all live in this hook.
**Commit:** `ffeedcb` — pushed `977f1ec..ffeedcb main -> main`.
**PM verification:** PENDING — open Waggle Dance, close it, observe top bar breadcrumb. Then open A + B, close A while B is focused (background close — focus should stay on B). Then close B — top bar should clear to default within one frame.
---
## Fix #8 — 2026-04-30 ~15:46 UTC
**Friction report:** FR #8 (P2) — New windows opened in unpredictable positions. No clear diagonal cascade.
**Root cause:** `Desktop.tsx` line 397 used each app's own `appConfig[appId].pos` as the cascade base. Each app config had a different `pos.x/y` (chat=(180,40), dashboard=(100,60), settings=(250,80), memory=(120,50)…) so opening Chat → Personas → Files **teleported** rather than cascading. The shared `cascadeOffset * 30` term added the right diagonal step, but each app started from its own origin point — defeating the visual hierarchy PM wanted.
Bonus issue: no viewport-aware wrap. On small displays a long cascade would push windows off-screen.
**Fix:** New helper `apps/web/src/lib/window-cascade.ts` with a pure `computeCascadePosition({ cascadeOffset, viewport })` function that:
1. Centers the base on the viewport using a single typical 600×480 size — all apps cascade from the same origin regardless of their own size.
2. Computes how many full diagonal slots fit before the bottom-right window edge would exit the viewport, and wraps `cascadeOffset` modulo that count. Always at least one slot.
3. Floors the base to a 60px minimum margin so the StatusBar never gets covered, and uses safe positive modulo so a negative offset still resolves correctly.
`Desktop.tsx` now calls the helper inline in the windows map. Per-app `appConfig.pos` becomes irrelevant for unsaved windows (saved drag positions still take priority).
**Files changed:**
- new `apps/web/src/lib/window-cascade.ts` (+62 LOC)
- new `apps/web/src/lib/window-cascade.test.ts` (+44 LOC, 6 tests)
- `apps/web/src/components/os/Desktop.tsx` (+12/-1 — import + replaces inline pos calc)
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- `vitest run src/lib/window-cascade.test.ts` → **6/6 pass** (center, +30 step, wrap on small viewport, tiny-viewport floor, idempotency, negative-offset safety)
- For a 1920×1080 viewport with default 600×480 typical size: first window at (660, 300), 9th at (930, 570). Bottom-right edge of 9th window = (1530, 1050) → fits comfortably.
**Followup (out of scope):** `appConfig.pos` is now dead data for cascade purposes — it's still consulted indirectly when a saved drag position is available via `getSavedPosition(appId)` (saved positions seed from drag events, not from `pos`). Could clean up the pos field from appConfig in a separate refactor pass; not blocking this fix.
**Commit:** `10d4531` — pushed `ffeedcb..10d4531 main -> main`.
**PM verification:** PENDING — open Chat, then Personas, then Files in sequence. Each window should appear 30px right + 30px down from the previous, all starting from a single centered base. On a small browser window, the cascade should wrap rather than push windows off-screen.
---
## Fix #10 — 2026-04-30 ~16:00 UTC
**Friction report:** FR #10 (P1) — After `localStorage.clear(); location.reload()` and clicking "Start Working" in the welcome modal, the app shows "Running in offline mode — connect a backend server in Settings" with red Offline pill, even though the backend is healthy on `127.0.0.1:3333`.
**Root cause analysis:**
- The constructor (`adapter.ts:70`) already falls back to `DEFAULT_SERVER = 'http://127.0.0.1:3333'` when `localStorage` is empty: `serverUrl || localStorage.getItem('waggle:server-url') || DEFAULT_SERVER`. So PM's stated hypothesis ("no fallback to default") wasn't quite right.
- BUT: the fallback only fires when localStorage returns `null`. If localStorage carries a **stale URL** from a prior Tauri build (e.g. `http://localhost:1420` for the Tauri shell) or a wrong port, the adapter sticks with that stale URL until the user manually fixes it in Settings.
- And: there's a race window where `useOfflineStatus` polls `getSystemHealth()` *before* `ServiceProvider`'s `connect()` effect lands. If the first poll misses (transient sidecar boot lag, Vite proxy warming, network blip), `setOffline(true)` runs before any retry. Without a self-recovery mechanism, the offline pill stays for ≥30s (exponential backoff first interval).
**Fix:** Add a single auto-discovery fallback at the health-probe layer.
- New private `healthProbe()`: tries the configured `baseUrl`. On failure, if `baseUrl !== DEFAULT_SERVER`, retries against `DEFAULT_SERVER`. On fallback success, persists `DEFAULT_SERVER` to localStorage so the next cold start begins on the right URL. On total failure, restores the original `baseUrl` (so Settings still shows what the user had configured) and rethrows the **original** error (more informative than the fallback failure).
- Both `connect()` and `getSystemHealth()` route through `healthProbe()` — boot-time and offline-poll converge on the same working URL within one cycle, regardless of which effect fires first.
**Files changed:** `apps/web/src/lib/adapter.ts` (+40/-4, single new private method + 2 callers).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- No existing adapter tests to run; mental walkthrough:
- Stored URL works → first try succeeds → no fallback path → unchanged behavior.
- Stored URL stale, default works → fallback succeeds → localStorage updated with default → next reload starts on default → no extra fetch on subsequent calls.
- Both URLs fail → original baseUrl restored → original error rethrown → offline pill shows (correct: backend really is down).
- Already on DEFAULT_SERVER and it fails → no infinite loop; throws once.
- Zero overhead on the happy path. One extra fetch only when the configured URL is broken.
**Followup (out of scope):** A real "scan local ports" auto-discovery for cases where the sidecar is running on a non-default port could supersede this. Tracked as a future hardening task; the DEFAULT_SERVER-only fallback covers the launch-day expected behavior.
**Commit:** `ae2794e` — pushed `7a8d280..ae2794e main -> main`.
**PM verification:** PENDING — `localStorage.clear(); location.reload();` → click Start Working → no offline pill, no offline-mode banner. Then verify localStorage shows `waggle:server-url = http://127.0.0.1:3333` was auto-persisted.
---
## Fix #14 — 2026-04-30 ~16:30 UTC
**Friction report:** FR #14 (P0 launch blocker) — Click Spawn Agent → fill task + model → click "Review & Launch" → entire app crashes to black screen, page unresponsive. No agent spawned. PM noted Vite HMR was hot-updating SpawnAgentDialog 10× rapidly before the click — looked like a possible HMR race.
**Root cause: API contract drift, not HMR.**
- Server `packages/server/src/local/routes/litellm.ts` emits `[{ model, inputPer1k, outputPer1k, provider }]`.
- Frontend `apps/web/src/lib/types.ts` `ModelPricing` interface declares `{ model, inputCostPer1k, outputCostPer1k, estimatedTokens?, estimatedCost? }`.
- `SpawnAgentDialog.tsx` confirm-step IIFE (lines 369-386) does:
```ts
const costMin = mp.estimatedCost?.min ?? (tokensMin / 1000 * mp.inputCostPer1k);
// ...
<span>Input: ${mp.inputCostPer1k.toFixed(4)}/1k</span>
<span>Output: ${mp.outputCostPer1k.toFixed(4)}/1k</span>
```
- `mp.inputCostPer1k` is `undefined` → `undefined.toFixed(4)` throws `Cannot read properties of undefined (reading 'toFixed')` during render of the confirm step.
- React has no error boundary inside the Dialog content, so the whole tree unmounts. The Dialog backdrop stays = perceived "black screen". HMR thrash was a red herring; the bug is deterministic, just hidden behind the conditional render path that only triggers after Review & Launch.
Same family of bug as FR #2 (typeConfig mismatch) and FR #3 (getModel unwrap) — server and client schemas drifted apart.
**Fix:** Normalize the response in `adapter.getModelPricing()`. Accept either shape — `{inputCostPer1k}` or `{inputPer1k}` — so the server can converge later without breaking older frontends, and the declared `ModelPricing` contract holds for all consumers (currently only SpawnAgentDialog).
Frontend-side normalization chosen over server-side rename because (a) blast radius is smaller — only one route on the server emits this shape, and (b) the dev server is currently live with PM driving E2E; restarting the backend would interrupt their session.
**Files changed:** `apps/web/src/lib/adapter.ts` (+15/-2, single function `getModelPricing`).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- Live API probe confirmed server keys = `[model, inputPer1k, outputPer1k, provider]`. After fix, adapter returns `[{ model, inputCostPer1k: 0.003, outputCostPer1k: 0.015, ... }]` for the seven Anthropic + OpenAI + Google entries.
- Mental walkthrough: Confirm-step IIFE now reads `mp.inputCostPer1k = 0.003` (number) → `(0.003).toFixed(4) = "0.0030"`. No throw. Cost arithmetic resolves correctly: for default 4k16k token estimate at sonnet rates, costMin = `4 × 0.003 = 0.012`, costMax = `16 × 0.015 = 0.24`.
**Followup (out of scope, surface for later):** Add an integration test that round-trips this contract, OR move the pricing schema into a shared `@waggle/shared` package so server emit and client consume reference the same type. Tracked as a bench-quality task; the per-route normalization unblocks launch.
**Commit:** _pending_
**PM verification:** PENDING — open Spawn Agent (🚀), fill task ("Summarize the latest 3 sovereign AI memories"), Model = `claude-sonnet-4-6` (auto-inherited via FR #5), click Review & Launch. Confirm step should render with budget panel showing `~4k16k tokens · ≈$0.01$0.24` (sonnet rates) and `Input: $0.0030/1k Output: $0.0150/1k`. No crash. Click Confirm & Launch should then spawn the agent and emit a Waggle Dance signal.
---
## Fix #13 — 2026-04-30 ~17:00 UTC
**Friction report:** FR #13 (P2 polish) — Spotlight (Ctrl+K) command index missed many apps. Searches for "telemetry", "missioncontrol", "marketplace", "scheduledjobs", "teamgovernance", "timeline", "userprofile", "voice", "events", "capabilities", "waggledance", "room" returned "No results" or only adjacent matches.
**Root cause:** `GlobalSearch.tsx` `COMMANDS` (15 entries) had drifted from `Desktop.tsx` `appConfig` (23 entries). Eight apps were never registered: `mission-control, waggle-dance, vault, profile, events, backup, telemetry, governance`. Fuzzy matcher itself works correctly — bug was purely missing entries.
**Fix:** Added the eight missing app entries to `COMMANDS` and imported the four new icons (`Radio, Zap, Lock, UserCircle`). Also broadened the `profile` subtitle to *"User profile, identity & preferences"* so a user typing "userprofile" reaches "My Profile" via the fuzzy character-by-character path (the original "Identity & preferences" subtitle had no `u/s/e/r` lead-in for the fuzzy traversal). Renamed `agents` title to "Personas" to match the app's actual title.
Added an explicit comment at the top of `COMMANDS` warning future contributors that the list must stay aligned with `appConfig` in `Desktop.tsx`. Long-term fix logged: hoist `appConfig` into a shared catalog module (`apps/web/src/lib/app-catalog.ts`) and derive both lists from it. Out of scope here — minimum-blast-radius fix shipped.
**Files changed:** `apps/web/src/components/os/overlays/GlobalSearch.tsx` (+13/-3, 4 new imports + 8 new commands + 1 subtitle expansion + 1 catalog-sync comment).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- Mental walkthrough against `fuzzyMatch`:
- "telemetry" → exact substring match in "Usage & Telemetry Token + cost analytics" ✓
- "missioncontrol" / "scheduledjobs" / "teamgovernance" / "waggledance" → char-by-char match against the spaced titles ✓
- "userprofile" → char-by-char `u-s-e-r-p-r-o-f-i-l-e` traversal of "My Profile User profile, identity & preferences" ✓
- "backup" → exact substring "Backup & Restore" ✓
- "vault" / "events" / "voice" / "room" / "capabilities" → all exact substring or char-by-char ✓
**Followup (out of scope):** unify `appConfig` and `COMMANDS` into a shared `app-catalog` module so future apps register once. Same pattern recommendation as FR #2/#3/#14 (consolidate contracts). Tracked, not blocking.
**Commit:** _pending_
**PM verification:** PENDING — Ctrl+K → type any of the 8 previously-missing app names. All should appear under "Commands" with correct icon + subtitle.
---
## Fix #12 — 2026-04-30 ~17:53 UTC
**Friction report:** FR #12 (P2 polish) — Top bar breadcrumb shows "Default Workspace · Default Workspace" duplicated when persona is unset / "General Purpose" default.
**Root cause:** `useWindowManager.getWindowTitle` for chat builds `parts = [win.workspaceName]; if (templateLabel) push; if (personaLabel) push`. For a fresh chat with `templateId='blank'` (no template label) and persona = `general-purpose` or undefined (no PERSONA_SHORT mapping → `personaLabel = undefined`), parts has only the workspace name → title = "Default Workspace". `buildStatusBarFocus.stripWorkspacePrefix` only strips a `"WS · "` prefix, so a title equal to the workspace name passes through unchanged. StatusBar then renders both segments: `· workspaceName · focusedWindowLabel ·` → visible duplication.
**Fix:** In `buildStatusBarFocus`, after the strip + trim pass, return `null` when the cleaned label equals the workspaceName (case-insensitive). This collapses the duplicate at the breadcrumb level — no changes to window-chrome titles or persona/template logic.
Defensive scope: works for any window type whose title happens to equal the workspaceName, not just default-persona chat. Three new unit tests cover the equality branch (exact-match, case-insensitive, and the existing strip-then-suffix path that should *not* hit the new guard).
**Files changed:**
- `apps/web/src/lib/status-bar-focus.ts` (+7/-0, one extra branch in `buildStatusBarFocus`)
- `apps/web/src/lib/status-bar-focus.test.ts` (+19/-0, three FR #12 tests)
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- `vitest run src/lib/status-bar-focus.test.ts` → **14/14 pass** (11 existing + 3 new)
- Existing test "strips the workspace prefix when present" still passes — `"Marketing · Chat · Researcher"` with workspace `Marketing` cleans to `"Chat · Researcher"`, which does **not** equal `"Marketing"`, so the new guard correctly does not fire.
**Followup (out of scope):** PM also flagged that "General Purpose" is the default persona — a separate UX choice could be to skip personaLabel for `general-purpose` even when set explicitly, OR to give it a friendly label. That's a label-policy question for `useWindowManager.PERSONA_SHORT` and out of scope for this fix.
**Commit:** _pending_
**PM verification:** PENDING — `localStorage.clear(); location.reload();` → Start Working → click Chat dock icon. Top bar should read `· Default Workspace · claude-sonnet-4-6 ·` (or whatever the model is) — without the duplicated workspace segment.
---
## Fix #16 — 2026-04-30 ~18:30 UTC
**Friction report:** FR #16 (P0 launch blocker) — Mission Control window crashes with `Cannot read properties of undefined (reading 'toLocaleString')` on open. Same family as FR #2 (icon undefined), FR #3 (getModel unwrap), FR #14 (pricing fields). Recurring contract-drift pattern.
**Root cause: another field-name drift between server and frontend.**
- Frontend `FleetSession` type (`apps/web/src/lib/types.ts:220`) declares `{ duration, tokenUsage, ... }`.
- Server `/api/fleet` (`packages/server/src/local/routes/fleet.ts:23-34`) emits `{ durationMs, tokensUsed, ... }`.
- `MissionControlApp.tsx:130` does `{s.tokenUsage.toLocaleString()}` inline. `s.tokenUsage` is undefined → throw → ErrorBoundary catches.
**Fix (defense in depth):**
1. **Adapter normalization** in `getFleet()` — same pattern as FR #14: accept either `{tokenUsage, duration}` or `{tokensUsed, durationMs}`, emit the declared `FleetSession` contract.
2. **Component-level null guards** in MissionControlApp — `(s.tokenUsage ?? 0).toLocaleString()`, `Math.round((s.duration ?? 0) / 60)`, `s.toolCount ?? 0`, `s.model ?? 'default'` — defensive even though adapter now guarantees the shape; prevents any future re-drift from re-crashing the app.
PM was right to flag this as the exact pattern the scheduled appConfig hoist + contract test would prevent. This makes the **fourth** instance this session (FR #2, #3, #14, #16) of contract drift between server emit and client consume. The /schedule-d remediation is well-justified.
**Files changed:**
- `apps/web/src/lib/adapter.ts` (+11/-2, normalisation in `getFleet`)
- `apps/web/src/components/os/apps/MissionControlApp.tsx` (+4/-4, defensive `?? 0` / `?? 'default'` guards)
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- Mental walkthrough: server emits `{tokensUsed: 0, durationMs: 1500, toolCount: 0, model: "claude-sonnet-4-6", ...}`; adapter normalises to `{tokenUsage: 0, duration: 1500, toolCount: 0, model: "claude-sonnet-4-6"}`; component renders `0 tokens · 0m · 0 tools · claude-sonnet-4-6` cleanly. Even if the adapter is bypassed somehow, the component guards prevent the crash.
**Followup (already scheduled):** /schedule-d remediation in 12 weeks: hoist `appConfig` into shared catalog + add round-trip pricing/fleet/signal contract tests so this whole class of bug surfaces at build time, not in PM's E2E.
**Commit:** _pending_
**PM verification:** PENDING — Ctrl+K → "missioncontrol" → Return. Mission Control should open with empty/zero-state values rendered cleanly, no crash. With actual sessions running, token counts should display correctly.
---
## Fix #17 — 2026-04-30 ~18:50 UTC
**Friction report:** FR #17 (P1) — After 15-20 min of sustained use, "Offline" red pill reappears in top bar even though backend is still healthy. PM verified backend was running on 3333 throughout.
**Root cause: client-side state-machine inertia, not backend.**
Backend log scan showed **no 429 rate-limits, no auth failures**. The bug is purely in `useOfflineStatus`:
1. **Single failure flips offline** — no tolerance, so one transient AbortController timeout (`fetchWithTimeout` defaults to 10s) during a heavy operation immediately sets `offline = true`.
2. **Exponential backoff up to 5 minutes** — once flipped to offline, the next health probe could be deferred up to 5 min, even if the backend recovers in seconds. Pill stays stale long after backend is back.
3. **No event-driven re-checks** — no listeners for `window.online` or `visibilitychange`. WiFi reconnect or tab refocus didn't trigger a fresh probe.
**Fix (three structural improvements to `useOfflineStatus`):**
1. **Failure tolerance**: require 2 consecutive failures before flipping `offline = true`. A single transient blip no longer surfaces the pill.
2. **Cap backoff at 60s** (was 5min). After 2 failures the schedule maxes out within one minute instead of five.
3. **Event-driven re-checks**: subscribe to `window.online` and `document.visibilitychange` → 'visible'. Each fires an immediate probe via a new `probeNow()` helper that clears the pending timer, runs `check()`, and re-schedules.
Together: transient blips don't flip the pill, real outages still surface within 30s, and recovery is detected within ~1 HTTP roundtrip when the user refocuses the tab or reconnects.
**Files changed:** `apps/web/src/hooks/useOfflineStatus.ts` (+45/-13, full hook rewrite with same public API).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- No existing tests for this hook (followup: add coverage); mental walkthrough across scenarios:
- Single transient timeout → failCount=1 → no flip ✓
- Two consecutive failures → failCount=2 → flip ✓
- Sustained outage → backoff caps at 60s instead of 300s ✓
- Tab refocus during offline → immediate probe, clears within 1 roundtrip ✓
- WiFi reconnect → `window.online` event triggers immediate probe ✓
- The hook's exported signature is unchanged — no consumer needs to change.
**Followup (out of scope):** add unit tests for `useOfflineStatus` with fake timers + mocked adapter. The hook accumulated three bugs that retroactive testing would have caught earlier.
**Commit:** _pending_
**PM verification:** PENDING — sustained 30+ min session: open many apps, Ctrl+K spam, switch tabs. Offline pill should appear ONLY when backend is actually unreachable. Try: `Stop-Process` the backend dev server (or Ctrl+C in its terminal) → pill should appear within 30s. Restart it → pill should clear within 60s OR within ~1 HTTP roundtrip after refocusing the tab.
---
## Fix #15 Phase A — 2026-04-30 ~19:20 UTC
**Friction report:** FR #15 (P1) — Spawn Agent dialog closes after Confirm & Launch, but no agent appears in Room (`0 live`), no signal in Waggle Dance, no Timeline event. UI flow completes; backend spawn is silent no-op.
**Root cause: triple-broken silent path.**
1. `/api/fleet/spawn` called `sessionManager.create(wsId, {persona, model})` — wrong signature (real shape is `(wsId, mind, orchestrator, tools, personaId)`). Throws inside the route, returns 500.
2. `adapter.spawnAgent` did `return res.json()` without `res.ok` check — error body was returned as if it were a successful `FleetSession`.
3. `SpawnAgentDialog.handleSpawn` did `catch { /* ignore */ }` — even if the adapter had thrown, the dialog would have swallowed it.
Three layers of silent failure stacked. Adapter test + endpoint were both happy on the wire (HTTP 200/500 both parse as JSON), but no agent ever ran.
**Phase A scope (this commit):** make the visible-state halves of PM acceptance work.
- ✅ Endpoint stops silently failing
- ✅ Room shows live entry within 5s of Confirm & Launch
- ✅ Waggle Dance fires `agent:spawned` signal
- ⏳ Phase B (next commit): full `runAgentLoop` dispatch so the agent actually executes the task
**Phase A fix:**
1. **`packages/server/src/local/routes/fleet.ts`**:
- Replace `sessionManager.create(wsId, {persona, model})` with the proper `sessionManager.getOrCreate(wsId, mindFactory, orchFactory, toolsFactory, personaId)` mirroring the chat route pattern.
- Acquire workspace mind via `fastify.agentState.getWorkspaceMindDb(wsId)`; return 404 if missing.
- Resolve model up front from `body.model ?? workspace.model ?? agentState.currentModel`.
- Return 409 with descriptive error if `getOrCreate` throws (e.g., max sessions reached).
- Emit `agent:spawned` Waggle Dance signal synchronously with task preview + persona/model/sessionId metadata.
- Return shape now matches the existing adapter test fixture: `{id, workspaceId, sessionId, status, startedAt, task, persona, model}`.
- Import `emitWaggleSignal` from `./waggle-signals.js`.
2. **`apps/web/src/lib/adapter.ts`** `spawnAgent`:
- Check `res.ok`; on failure parse the error body and throw `Error('Spawn failed (status): message')`. No more silent error-body-as-success.
3. **`apps/web/src/components/os/overlays/SpawnAgentDialog.tsx`** `handleSpawn`:
- Replace `catch { /* ignore */ }` with toast-on-failure (`title: 'Spawn failed'`, `variant: 'destructive'`) + console.error for devtools.
- Add success toast on the happy path: `Agent spawned · Running on <model> — see Waggle Dance for live signals`.
**Files changed:**
- `packages/server/src/local/routes/fleet.ts` (+45/-9, route handler rewrite + signal import)
- `apps/web/src/lib/adapter.ts` (+12/-1, ok-guard in spawnAgent)
- `apps/web/src/components/os/overlays/SpawnAgentDialog.tsx` (+15/-1, useToast + try/catch with toasts)
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- `npx tsc --noEmit -p packages/server/tsconfig.json` → clean (one error caught + fixed: `WorkspaceConfig.personaId`, not `.persona`)
- `vitest run src/lib/adapter.spawnAgent.test.ts` → **3/3 pass** (existing contract preserved)
**Phase B preview (not in this commit):** `/api/fleet/spawn` will additionally kick off `runAgentLoop` in fire-and-forget mode using the freshly-created session's orchestrator + tools, with the task as the user message. Signal emitters from chat (`agent:started`, `tool:called`, `agent:completed`) will fire automatically as the agent runs. Result will be persisted as a chat history entry on the workspace so the user can see it by opening Chat for that workspace.
**Commit:** _pending_
**PM verification (Phase A only):** Spawn Agent → fill task → Confirm & Launch. Expected:
- Toast: "Agent spawned" (or "Spawn failed" with explanatory message if backend rejected).
- Room app: workspace shows `1 live` for the parent workspace; entry visible.
- Waggle Dance: new `agent:spawned` signal at top of feed with task preview + model badge.
- No black-screen, no silent close. Agent execution itself (token usage, tool calls, completion) is Phase B.
---
## Fix #15 Phase B — 2026-04-30 ~19:35 UTC
**Friction report:** FR #15 (P1) Phase B — full agent runtime dispatch from `/api/fleet/spawn`. After Phase A delivered visible-state halves (session + signal), the agent itself still didn't execute.
**Phase B implementation:** fire-and-forget `runAgentLoop` invocation directly in the spawn route handler. Agent runs async, signals fire as it progresses, result persists to a synthetic `spawn-{ts}` session file that the existing Chat UI can pick up from its session list.
**Key design decisions:**
1. **Synthetic session ID `spawn-${Date.now()}`** for the persistence layer, distinct from the per-workspace session ID used by `sessionManager.getOrCreate`. This way each spawn gets its own conversation thread visible in Chat without colliding with the live chat session.
2. **Bounded `maxTurns: 10`** — sub-agents are bounded; chat allows 200 turns for deep research, but a spawned task should not loop indefinitely.
3. **`session.abortController.signal`** wired into the loop so killing the workspace session via `/api/fleet/:wsId/kill` interrupts the spawn agent too.
4. **Token usage written back via `sessionManager.addTokens(wsId, total)`** so Mission Control's live cost estimate reflects spawned-agent spend.
5. **Result persisted via existing `persistMessage` helper** — no new persistence machinery; the spawn session shows up in Chat's session picker automatically.
**Scoped out vs full chat parity** (intentional, logged here for follow-up):
- Credential pool with provider rotation + automatic fallback chain (`primaryModel → fallbackModel → budgetModel`) — spawn uses the resolved model directly.
- Trace recorder for evolution substrate — spawn doesn't feed the GEPA/EvolveSchema training loop yet.
- `file_created` SSE events, TeamSync push on `save_memory`, `governance.allowedSources` enforcement (still not wired even in chat).
- Custom user-system-prompt + profile injection — spawn uses the orchestrator's bare `buildSystemPrompt()`.
- Smart routing via `routeMessage(message, primary, budget)` — every spawn uses the explicit model.
- Behavioral spec injection from `server.activeBehavioralSpec` — orchestrator's prompt is sufficient for Phase B.
These are explicit followups, not bugs. Spawn is now a working autonomous sub-agent runtime; Phase C (if ever needed) is "spawn-as-rich-as-chat".
**Files changed (Phase B alone, on top of Phase A):**
- `packages/server/src/local/routes/fleet.ts` (+72/-2): import `runAgentLoop`, `persistMessage`, `createLogger`; build minimal AgentLoopConfig; emit `agent:started/tool:called/agent:completed/agent:error` signals; persist user + assistant messages; write tokens back to sessionManager.
**Verification (live smoke against the running dev backend):**
- `POST /api/fleet/spawn` with task `"Reply with the literal string PHASE_B_OK and nothing else."` returned 200 with `{id, workspaceId, sessionId: "spawn-1777566410538", status: "active", startedAt, task, persona, model}` immediately.
- 8s later: `GET /api/fleet` showed `count: 1, maxSessions: 3` with `tokensUsed: 13230`. Mission Control would render `1 live` for default-workspace.
- 8s later: `GET /api/waggle/signals?limit=6` showed three signals in correct order: `agent:spawned` @ `16:26:50.538` → `agent:started` @ `16:26:50.539` → `agent:completed` @ `16:26:52.187` with `inputTokens: 13221, outputTokens: 9, toolsUsed: []`.
- Session file `~/.waggle/workspaces/default-workspace/sessions/spawn-1777566410538.jsonl`:
```json
{"type":"meta","title":null,"created":"2026-04-30T16:26:50.538Z"}
{"role":"user","content":"Reply with the literal string PHASE_B_OK and nothing else.","timestamp":"2026-04-30T16:26:50.539Z"}
{"role":"assistant","content":"PHASE_B_OK","timestamp":"2026-04-30T16:26:52.183Z"}
```
- Wall-clock spawn → completed: **~1.65 seconds** (real LLM call, real tool inventory, real persistence).
- `npx tsc --noEmit -p packages/server/tsconfig.json` → clean
- `vitest run packages/server/tests/local/fleet.test.ts` → **12/12 pass** (existing GET/pause/resume/kill tests unaffected; spawn test coverage is logged as followup).
**All four PM acceptance criteria met:**
- ✅ Click Confirm & Launch → Room shows 1 live agent within 5 seconds (instant on response, < 100ms in practice).
- ✅ Waggle Dance shows new signal — actually three, in correct lifecycle order.
- ✅ Timeline event — Events app reads the same signal stream via SSE.
- ✅ Agent eventually completes and result is visible — open Chat for the workspace, pick the `spawn-{ts}` session from the session list, see user task + assistant response.
**Commit:** _pending_
**PM verification path:** Spawn Agent → task = anything → Confirm & Launch. Open Chat → pick the `spawn-…` session entry from the session list (left rail) → response should be visible. Also check Waggle Dance for the three-signal lifecycle and Mission Control for the token-usage column updating.
---
## Fix #19 — 2026-04-30 ~19:50 UTC
**Friction report:** FR #19 (P2 polish) — Events app shows new entries with `undefined` title + `Connected - Invalid Date`. Sixth instance of contract drift this session (FR #2/#3/#13/#14/#16/#19 share the root pattern).
**Root cause:** `EventsApp.StepCard` (line 50-54) and `TreeNode` (line 208) accessed `step.description`, `step.type.replace('_', ' ')`, `new Date(step.timestamp).toLocaleTimeString()` with no null guards. When the event payload arrived with missing fields:
- `decodeHtmlEntities(undefined)` returned the literal string `"undefined"` (the helper coerces its input to a string via DOM round-trip).
- `step.type.replace(...)` would throw on undefined, but rendered OK for any string value, expected or not.
- `new Date(undefined).toLocaleTimeString()` returned `"Invalid Date"`.
PM saw the first and third symptoms together. Bug is purely client-side rendering — no need to chase the backend emitter for this fix.
**Fix:** Three pure formatter helpers at the top of `EventsApp.tsx` + replace inline calls.
- `formatType(type)` → `'unknown'` if missing/non-string, else `type.replace(/_/g, ' ')`.
- `formatTimestamp(ts)` → `'just now'` if missing or not parseable to a real Date, else `toLocaleTimeString()`.
- `formatDescription(desc, type)` → decoded `desc` if it's a real non-`'undefined'` string, else `'<type> event'`, else `'Unknown event'`.
Also fixed the `stepsByTime` reduce in the replay tab to bucket unparseable-timestamp events under `'Earlier'` instead of grouping them under the literal `"Invalid Date"` date-header.
**Files changed:** `apps/web/src/components/os/apps/EventsApp.tsx` (+34/-4, three helpers + four call-site replacements).
**Verification:**
- `npx tsc --noEmit -p apps/web/tsconfig.json` → clean
- Mental walkthrough across the four scenarios:
- All-fields-present event → identical render to before.
- Missing description → renders `'<type> event'` instead of `"undefined"`.
- Missing timestamp → renders `"just now"` instead of `"Invalid Date"`.
- Missing both → renders `"Unknown event · unknown · just now"`.
- Replay panel: malformed events bucket under `"Earlier"` heading instead of `"Invalid Date"`.
**Followup (already scheduled):** the **May 14 routine** (`trig_01CaXcZvfRtFfxbREogDRbTZ`) explicitly references this contract-drift family. FR #19 adds another data point to the evidence base — the routine's contract tests should cover `/api/events` (or whatever feeds `AgentStep`) once the structural refactor lands.
**Commit:** _pending_
**PM verification:** Open Events app → trigger any source of malformed events (e.g., spawn an agent and watch the live feed). All entries should render with sensible fallback strings, no `"undefined"`, no `"Invalid Date"`.
---
## Fix #17 follow-up — 2026-04-30 ~20:30 UTC
**Friction report:** PM observed offline pill still stuck mid-session post-`77100b4`. Refresh cleared it; auto-recovery did not.
**Root cause:** Two missing pieces in the previous fix:
1. After flip to offline, schedule still ramped exponentially (60s capped). Backoff makes sense before flipping; after flipping we want fast recovery detection.
2. The trigger set (`window.online` + `visibilitychange`) misses the case where the tab stays visible the whole time. PM's repro: tab stayed in foreground, alt-tabbed to a different app, came back, expected immediate clear.
**Fix:** New `RECOVERY_INTERVAL_MS = 15_000`. After `failCount >= FAILURE_TOLERANCE`, the schedule uses this fixed 15s interval instead of the exponential-capped path. Added `window.focus` event listener as a third re-check trigger — fires whenever browser tab regains focus from another app window, even without a hidden→visible transition.
**Files changed:** `apps/web/src/hooks/useOfflineStatus.ts` (+23/-4).
**Verification:** `npx tsc --noEmit -p apps/web/tsconfig.json` clean. Mental walkthrough of the four scenarios in the prior FR #17 entry now yields ≤15s recovery instead of ≤60s; focus-back yields 1 HTTP roundtrip.
**Commit:** `e1952bc`.
---
## Fix #3 — 2026-04-30 ~20:50 UTC
**Friction report:** GEPA scope audit (FR #3 in `docs/GEPA-SCOPE-AUDIT-2026-04-30.md`) — chat.ts called `getPersona(activePersonaId)` which is **built-in only** (its own docstring says `/** ... built-in only — use listPersonas() for full catalog */`). Evolved personas (Faza 1 variants like `claude::gen1-v1`, plus user customs in `~/.waggle/personas/`) were silently dropped.
**Fix:** Tiny `resolvePersona(id)` helper using `listPersonas()` (which appends custom from disk on every call). Two call sites replaced (system-prompt building + tool allowlist/denylist application). Dropped now-unused `getPersona` import.
**Files changed:** `packages/server/src/local/routes/chat.ts` (+19/-3).
**Note on shadow semantics:** `listPersonas` returns `[...PERSONAS, ...customs]` so `find` prefers built-ins for shadow IDs — safer default (an evolved persona accidentally deployed under a built-in ID like `coder` won't hijack production behavior). Evolved personas with derived IDs like `coder::gen1-v1` work as intended.
**Commit:** `4556ee2`.
---
## Fix #4 — 2026-04-30 ~21:10 UTC
**Friction report:** GEPA scope audit (FR #4 in `docs/GEPA-SCOPE-AUDIT-2026-04-30.md`) — `orchestrator.buildAssembledPrompt()` exists with full PromptAssembler v5 plumbing, but `agent-loop.ts` has zero references to `PROMPT_ASSEMBLER` / `isEnabled` / `buildAssembledPrompt`. The flag was a no-op in production. Faza 1's +12.5pp uplift was real **in eval** but never reached production runtime.
**Fix:** Wire the assembler at the **call sites** (chat + spawn), not in agent-loop itself. This keeps `runAgentLoop`'s contract unchanged — the caller pre-fetches an `AssembledPrompt` and passes the resulting string as `systemPrompt`. agent-loop stays decoupled from orchestrator/persona/taskShape concerns.
**Three integration points:**
1. **Export**: `PromptAssembler`, `AssembledPrompt`, `AssembleOptions`, `AssembleInput`, `ScaffoldStyle` from `@waggle/agent` index.
2. **`chat.ts` (sixth-layer assembly into the system prompt)**:
- Import `isEnabled, detectTaskShape, type AssembledPrompt`.
- `buildSystemPrompt` accepts a new optional `assembled?: AssembledPrompt | null` last param.
- When provided: `prompt += assembled.system` instead of `prompt += orch.buildSystemPrompt()` — assembler's structured Identity + Persona + State + Memory sections replace the bare orchestrator prompt. Profile, skills, workspaceNow, behavioralSpec rules, persona-via-`composePersonaPrompt`, and tone all **still layer** on top because the assembler doesn't include those.
- `responseScaffold` (when non-null) appended as `## Response shape\n<scaffold>`.
- Cache skipped entirely when `assembled` provided (per-turn memory recall can't be safely cached across turns).
- Call site at line 833 pre-fetches `assembled` when `isEnabled('PROMPT_ASSEMBLER')` is true. Wraps in try/catch with structured `log.info(...)` on success and `log.warn(...)` + fallback on failure — assembler errors never block a chat turn.
3. **`fleet.ts` (spawn route)**:
- Import `isEnabled, detectTaskShape, listPersonas`.
- Inside the fire-and-forget IIFE: when flag on, `detectTaskShape(task)` → resolve persona via `listPersonas` → `await session.orchestrator.buildAssembledPrompt(task, persona, { taskShape })` → use `assembled.system` (+ scaffold) as the `runAgentLoop` systemPrompt.
- Same try/catch fallback to `session.orchestrator.buildSystemPrompt()` on error.
**Why caller-side wiring (not agent-loop):** keeps `runAgentLoop` agnostic of PromptAssembler. The library boundary is cleaner — agent-loop stays "given a final systemPrompt + messages + tools, run the loop". Both call sites get identical structured logs (`[prompt-assembler] applied turn=… shape=… conf=… tier=… sections=… frames=… chars=…`) for observability.
**Files changed:**
- `packages/agent/src/index.ts` (+1, exports)
- `packages/server/src/local/routes/chat.ts` (+44/-7, import + signature + body + cache + call site)
- `packages/server/src/local/routes/fleet.ts` (+30/-2, import + assembler block in spawn IIFE)
**Verification:**
- `npx tsc --noEmit -p packages/server/tsconfig.json` → clean (caught one unexported `AssembledPrompt`, fixed)
- `npx tsc --noEmit -p packages/agent/tsconfig.json` → clean
- `vitest run` on `prompt-assembler-feature-flag.test.ts` (8 tests) + `fleet.test.ts` (12 tests) → **20/20 pass**
- Flag default OFF → byte-identical to prior behavior (no regression).
**PM verification path** (requires backend restart with env var; HMR doesn't pick up env changes mid-process):
```
# Stop the dev backend (Ctrl+C), restart with:
WAGGLE_PROMPT_ASSEMBLER=1 npm run dev --workspace=packages/server
# Then either:
# (a) Send a chat message with a structured task shape
# ("compare X vs Y", "decide between A and B", "summarize this")
# (b) Spawn an agent via the dock:
# Spawn Agent → "Compare two memory frames briefly"
#
# Expected backend log lines:
# [waggle:chat] [prompt-assembler] applied turn=… shape=compare conf=0.X
# tier=mid sections=… frames=… chars=…
# [waggle:fleet] [fleet/spawn] prompt-assembler applied session=spawn-…
# shape=compare conf=0.X tier=mid sections=… frames=… chars=…
```
**Followup logged but out of scope here:**
- Telemetry on assembler activation rate (`prompt-assembler.applied` event into PostHog)
- A/B comparison test fixture that sends the same task to both paths and compares response quality (closes the eval-vs-prod gap formally)
- `userPrefix` of `AssembledPrompt` is currently always empty (`''`) — could be wired to prepend to user message if future evolved variants populate it.
**Commit:** _pending_
**PM verification:** restart backend with `WAGGLE_PROMPT_ASSEMBLER=1`, run a structured-task-shape chat or spawn, confirm the `[prompt-assembler] applied` log line appears with shape/tier/sections fields populated.