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

51 KiB
Raw Blame History

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.tsstartService() → 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/healthHTTP 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.ts11/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.modelclaude-sonnet-4-6
    • /health.defaultModelclaude-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.ts9/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.ts6/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:
    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 undefinedundefined.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.ts14/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.ts3/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.538agent:started @ 16:26:50.539agent:completed @ 16:26:52.187 with inputTokens: 13221, outputTokens: 9, toolsUsed: [].
  • Session file ~/.waggle/workspaces/default-workspace/sessions/spawn-1777566410538.jsonl:
    {"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.ts12/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 listPersonasawait 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.