607 lines
51 KiB
Markdown
607 lines
51 KiB
Markdown
# 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 4k–16k 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 `~4k–16k 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 1–2 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.
|