From 9b4ce6b9c8b30507a0190deef503f728f8509b15 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:42:13 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20wave=201=20quick=20wins=20=E2=80=94=20ag?= =?UTF-8?q?ent=20names,=20scroll=20bounce-back,=20chart=20empty=20states,?= =?UTF-8?q?=20model-pin=20preservation,=20UUID=20spawn=20normalization=20(?= =?UTF-8?q?#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(panel): notifications show agent names, metrics charts get empty states * fix(panel): stop expand/collapse scroll bounce-back; add floating scroll-jump buttons * fix(llm): provider mode switches preserve per-agent model pins * fix(api): normalize agent UUID to slug at the orchestrator route boundary * fix(panel,docs): align routing-card copy and map docs with preserved-pin mode switches * fix(panel): drop dead unfiltered scroll hook, re-observe on Suspense swap, name system sender * docs(map): reflect preserved-pin mode switches, UUID-slug normalization, panel wave-1 deltas --------- Co-authored-by: Renn F --- docs/map/_complete_map.md | 18 ++- docs/map/api-routes-schemas.md | 2 + docs/map/models.md | 7 +- docs/map/panel.md | 3 + docs/map/support-services.md | 5 +- panel/src/app/(dashboard)/layout.tsx | 5 + .../app/(dashboard)/notifications/page.tsx | 3 +- panel/src/app/(dashboard)/tasks/page.tsx | 5 +- .../__tests__/scroll-restoration.test.tsx | 19 ++++ .../usage-time-series-chart.test.tsx | 24 ++++ .../components/metrics/agent-usage-chart.tsx | 4 + .../components/metrics/model-usage-donut.tsx | 4 + .../components/metrics/team-usage-chart.tsx | 4 + .../metrics/usage-time-series-chart.tsx | 4 + panel/src/components/scroll-jump-buttons.tsx | 105 ++++++++++++++++++ panel/src/components/scroll-restoration.tsx | 15 ++- .../components/settings/ai-routing-card.tsx | 32 ++++-- panel/src/hooks/use-scroll-restoration.ts | 81 -------------- panel/src/lib/agent-utils.ts | 2 + roboco/api/routes/orchestrator.py | 13 ++- roboco/models/llm_catalog.py | 29 ----- roboco/services/llm.py | 62 +++++++---- tests/integration/test_llm_routing.py | 52 ++++++++- .../api/test_orchestrator_manual_spawn.py | 77 ++++++++++++- 24 files changed, 415 insertions(+), 160 deletions(-) create mode 100644 panel/src/components/__tests__/scroll-restoration.test.tsx create mode 100644 panel/src/components/metrics/__tests__/usage-time-series-chart.test.tsx create mode 100644 panel/src/components/scroll-jump-buttons.tsx delete mode 100644 panel/src/hooks/use-scroll-restoration.ts diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index ab50063d..08d3611c 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -1589,7 +1589,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, | `project.py` | `Project` + `BranchReason` + `ProjectCreate`/`ProjectUpdate` (CI-watch, dep-update, quality_command fields) | 178 | | `agent.py` | `Agent` API model + `ModelConfig`/`AgentPermissions`/`AgentMetrics` + `AgentCreate`/`AgentUpdate` | 172 | | `kanban.py` | `KanbanBoard`/`KanbanColumn`/`KanbanCard`/`KanbanSwimlane` + per-role column configs + `get_column_config` | 159 | -| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_ROLE_DEFAULTS`/`OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 132 | +| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 103 | | `message.py` | `ExtractedMessage` + `RawStream` | 137 | | `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 | | `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 | @@ -1654,7 +1654,6 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, | `ROLE_MODEL_MAP` | dict | runtime.py:114 | per-role default tier: developer/cell_pm/main_pm→sonnet, qa/documenter→haiku, pr_reviewer/auditor/board/ceo→opus (qa→haiku, main_pm→sonnet, pr_reviewer→opus are the cost-tuned defaults) | | `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) | | `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` | -| `OLLAMA_ROLE_DEFAULTS` | dict | llm_catalog.py:107 | Per-role model for "pure Ollama" mode | | `PermissionLevel` | IntEnum | permissions.py:15 | CEO=0/BOARD=1/MAIN_PM=2/CELL_PM=3/CELL_MEMBER=4/AUDITOR=99 | | `COMMUNICATION_MATRIX` | dict | permissions.py:83 | Who can directly communicate with whom (role→role set) | | `IndexType` | StrEnum | optimal.py:13 | code/documentation/conversations/journals/errors/standards/decisions/reviews/learnings/playbooks | @@ -1738,7 +1737,7 @@ models/ │ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage ├── llm │ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics -│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_ROLE_DEFAULTS, OLLAMA_DEFAULT_MODEL +│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_DEFAULT_MODEL │ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP ├── metrics │ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard @@ -1831,7 +1830,7 @@ Logic-touching commits: | `TaskCreate._exactly_one_target` 3-way validator | task.py:405 | Tests/clients asserting the old 2-way error message ("a task needs either a project_id… or a product_id…") will fail against the new message ("a task needs exactly one target: … or cell_projects …"). Any caller that constructed a `TaskCreate` with both `project_id` and `product_id` was already rejected; callers passing `cell_projects` + another target are newly rejected. | Medium | | `Task.cell_projects` requires migration 052 | task.py:179 | The `cell_projects` field exists on the model regardless of DB state, but persistence (`TaskTable.cell_projects` relationship + `task_cell_projects` table) needs migration 052. On a DB where 052 hasn't run, `TaskService.create` with a non-empty `cell_projects` will fail at insert. | Medium | | `SpawnGitContext.task_short_id` consumer parity | runtime.py:38 | The field is additive with a `None` default, but every spawn-path consumer that should route the agent into the per-task worktree must read it; a missed consumer silently falls back to the clone root (the old behavior). | Low | -| Ollama catalog defaults vs persisted assignments | llm_catalog.py:107 | `OLLAMA_ROLE_DEFAULTS` / `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the defaults only apply when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code / glm-5.2 instead of minimax-m3 / kimi-k2.6. Verify the new tags actually work on the Ollama Cloud plan before relying on this. | Low | +| Ollama catalog defaults vs persisted assignments | llm_catalog.py:103 | `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the default only applies when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code instead of minimax-m3. Verify the tag actually works on the Ollama Cloud plan before relying on this. (`OLLAMA_ROLE_DEFAULTS` was removed 2026-07-17 as dead code — it was never consulted by routing.) | Low | | `ProductCellMapping` import cycle risk | task.py:24 | `task.py` now imports from `product.py` at module load. `product.py` imports only from `foundation.identity` and `base.py` — no cycle today, but a future `product.py` → `task.py` import would create one. | Low | | `AgentRole`/`Team` alias removal pending | base.py:21–24 | The aliases to `foundation.identity` are a migration shim ("Removed in Phase 4 housekeeping"). Consumers still importing `AgentRole`/`Team` from `roboco.models.base` will break when the shim is removed. | Low | @@ -2522,6 +2521,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | `require_panel_token` | dep | api/deps.py:251 | CEO-signed HMAC gate for live-chat bridges (HTTP analog of WS gate). | | `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. | | `_require_ceo` | dep | routes/orchestrator.py:37 | Router-level CEO-HMAC guard on orchestrator control routes. | +| `_validated_agent_id` | fn | routes/orchestrator.py:99 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. | | `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). | | `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. | | `_scrub_secrets` | fn | api/middleware.py:389 | Deep-redact known secret fields from logged 422 bodies. | @@ -2665,6 +2665,7 @@ roboco/api/ > - `d1cf6ecb` Wave 1 (#295) — adds `GET /api/tasks/summary?q=` search (`TaskService.search_tasks`), `GET /api/prompter/live/{id}/search-tasks` (intake memory), `GET /api/secretary/tasks?q=` (Secretary task-by-name lookup), and the Secretary `edit` directive action. > - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO. > - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass. +> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses. ## Regression Risks @@ -4339,7 +4340,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro | `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path | | `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` | | `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider | -| `apply_mode` | method | `services/llm.py:418` | Wipe + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | +| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | | `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments | | `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row | | `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session | @@ -4582,11 +4583,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441`. Range `fd10cc86..HEAD` (3a No logic-touching commits to list — IMPACT: none. -> Post-snapshot updates (since 2026-06-29): four commits touched this slice after the baseline was cut. +> Post-snapshot updates (since 2026-06-29): five commits touched this slice after the baseline was cut. > - `e4ed970f` [chore] stream-bus: poison-pill ACK + dead-letter (`DEAD_LETTER_STREAM`, `_dead_letter`), periodic `_reclaim_loop` spawned alongside `_listen_loop`, `_run_handler_guarded` catches `BaseException` for cancelled-handler marker cleanup (3 gaps). > - `6b441e42` [chore] converters: `InvalidIdentifierError(ValueError)` introduced; `require_uuid` now raises it for both None and unparseable input; `repo_key` git-URL normalizer added; orchestrator reaper now logs the typed error instead of silently swallowing it. > - `321e68d7` [sweep] proactive: `_find_code_patterns` method, its call, summary line, and count removed; `ContextPackage.code_patterns` field retained (always-empty, back-compat). > - `536bbb64` Chore/all/logical-gaps-sweep (#286) — merge commit pulling the above into the branch. +> - `d83104e9` (2026-07-17, PR #546, "wave-1 quick wins") fix(llm): provider mode switches preserve per-agent model pins — `_apply_anthropic`/`_apply_grok`/`_apply_ollama`/`_apply_self_hosted` now delete only ROLE/GLOBAL `model_assignments` rows (`scope != AGENT_SLUG`) instead of wiping the whole table, so an AGENT_SLUG pin survives a mode switch; `OLLAMA_ROLE_DEFAULTS` removed from `llm_catalog.py` as dead code (it was never consulted by routing — see `models.md`). ## Regression Risks @@ -8217,6 +8219,10 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog > - `da563487` Wave 2: A2A live view (#297) — new `app/(dashboard)/a2a/page.tsx` (classic list view + transcript + `A2AReplyComposer`), `hooks/use-a2a-live.ts`, `lib/api/a2a.ts` admin client, `useA2ALiveStream` added to `use-websocket.ts`. Backend pairs with `EventType.A2A_MESSAGE_SENT` + `websocket_bridge._handle_a2a_message_event`. > - `876e19b3` A2A switchboard (#298) — `page.tsx` gains the switchboard/list view toggle (default switchboard) + `peekedPair` state; new `components/a2a/{a2a-switchboard,a2a-switchboard-utils,a2a-pair-card}.tsx`; `useA2AAdminPairs` added to `use-a2a-live.ts`. > - `a7147702` feat(panel): full mobile responsiveness pass — touches the A2A page's single-visible-pane layout (`h-dvh`, back affordance) among other routes. +> - (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: new `tab-findings.tsx` (7th task-detail tab, `useTaskFindings` → `GET /tasks/{id}/findings`), a `bounced xN` chip on `task-header.tsx` (`revision_count`), and "PM rejects"/"CEO rejects" columns on `delivery-tab.tsx`'s rework table. See `docs/map/review-findings.md`. +> - `abf4b35f` (2026-07-17, PR #546, "wave-1 quick wins") — notifications page resolves `from_agent` via `getAgentDisplayName` (was `notification.from_agent.slice(0, 8)`, a raw UUID prefix); metrics charts (usage time-series, agent/team usage, model donut) gained a "no data" empty state alongside the existing loading skeleton. +> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `
` sibling in `(dashboard)/layout.tsx`) re-observes `
`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events. +> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`. ## Regression Risks diff --git a/docs/map/api-routes-schemas.md b/docs/map/api-routes-schemas.md index 2d64c56e..3ddaaca0 100644 --- a/docs/map/api-routes-schemas.md +++ b/docs/map/api-routes-schemas.md @@ -94,6 +94,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | `require_panel_token` | dep | api/deps.py:251 | CEO-signed HMAC gate for live-chat bridges (HTTP analog of WS gate). | | `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. | | `_require_ceo` | dep | routes/orchestrator.py:37 | Router-level CEO-HMAC guard on orchestrator control routes. | +| `_validated_agent_id` | fn | routes/orchestrator.py:99 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. | | `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). | | `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. | | `_scrub_secrets` | fn | api/middleware.py:389 | Deep-redact known secret fields from logged 422 bodies. | @@ -237,6 +238,7 @@ roboco/api/ > - `d1cf6ecb` Wave 1 (#295) — adds `GET /api/tasks/summary?q=` search (`TaskService.search_tasks`), `GET /api/prompter/live/{id}/search-tasks` (intake memory), `GET /api/secretary/tasks?q=` (Secretary task-by-name lookup), and the Secretary `edit` directive action. > - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO. > - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass. +> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses. ## Regression Risks diff --git a/docs/map/models.md b/docs/map/models.md index 4f23ac27..6d0a3da8 100644 --- a/docs/map/models.md +++ b/docs/map/models.md @@ -22,7 +22,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, | `project.py` | `Project` + `BranchReason` + `ProjectCreate`/`ProjectUpdate` (CI-watch, dep-update, quality_command fields) | 178 | | `agent.py` | `Agent` API model + `ModelConfig`/`AgentPermissions`/`AgentMetrics` + `AgentCreate`/`AgentUpdate` | 172 | | `kanban.py` | `KanbanBoard`/`KanbanColumn`/`KanbanCard`/`KanbanSwimlane` + per-role column configs + `get_column_config` | 159 | -| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_ROLE_DEFAULTS`/`OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 132 | +| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 103 | | `message.py` | `ExtractedMessage` + `RawStream` | 137 | | `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 | | `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 | @@ -87,7 +87,6 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, | `ROLE_MODEL_MAP` | dict | runtime.py:114 | per-role default tier: developer/cell_pm/main_pm→sonnet, qa/documenter→haiku, pr_reviewer/auditor/board/ceo→opus (qa→haiku, main_pm→sonnet, pr_reviewer→opus are the cost-tuned defaults) | | `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) | | `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` | -| `OLLAMA_ROLE_DEFAULTS` | dict | llm_catalog.py:107 | Per-role model for "pure Ollama" mode | | `PermissionLevel` | IntEnum | permissions.py:15 | CEO=0/BOARD=1/MAIN_PM=2/CELL_PM=3/CELL_MEMBER=4/AUDITOR=99 | | `COMMUNICATION_MATRIX` | dict | permissions.py:83 | Who can directly communicate with whom (role→role set) | | `IndexType` | StrEnum | optimal.py:13 | code/documentation/conversations/journals/errors/standards/decisions/reviews/learnings/playbooks | @@ -171,7 +170,7 @@ models/ │ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage ├── llm │ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics -│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_ROLE_DEFAULTS, OLLAMA_DEFAULT_MODEL +│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_DEFAULT_MODEL │ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP ├── metrics │ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard @@ -264,7 +263,7 @@ Logic-touching commits: | `TaskCreate._exactly_one_target` 3-way validator | task.py:405 | Tests/clients asserting the old 2-way error message ("a task needs either a project_id… or a product_id…") will fail against the new message ("a task needs exactly one target: … or cell_projects …"). Any caller that constructed a `TaskCreate` with both `project_id` and `product_id` was already rejected; callers passing `cell_projects` + another target are newly rejected. | Medium | | `Task.cell_projects` requires migration 052 | task.py:179 | The `cell_projects` field exists on the model regardless of DB state, but persistence (`TaskTable.cell_projects` relationship + `task_cell_projects` table) needs migration 052. On a DB where 052 hasn't run, `TaskService.create` with a non-empty `cell_projects` will fail at insert. | Medium | | `SpawnGitContext.task_short_id` consumer parity | runtime.py:38 | The field is additive with a `None` default, but every spawn-path consumer that should route the agent into the per-task worktree must read it; a missed consumer silently falls back to the clone root (the old behavior). | Low | -| Ollama catalog defaults vs persisted assignments | llm_catalog.py:107 | `OLLAMA_ROLE_DEFAULTS` / `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the defaults only apply when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code / glm-5.2 instead of minimax-m3 / kimi-k2.6. Verify the new tags actually work on the Ollama Cloud plan before relying on this. | Low | +| Ollama catalog defaults vs persisted assignments | llm_catalog.py:103 | `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the default only applies when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code instead of minimax-m3. Verify the tag actually works on the Ollama Cloud plan before relying on this. (`OLLAMA_ROLE_DEFAULTS` was removed 2026-07-17 as dead code — it was never consulted by routing.) | Low | | `ProductCellMapping` import cycle risk | task.py:24 | `task.py` now imports from `product.py` at module load. `product.py` imports only from `foundation.identity` and `base.py` — no cycle today, but a future `product.py` → `task.py` import would create one. | Low | | `AgentRole`/`Team` alias removal pending | base.py:21–24 | The aliases to `foundation.identity` are a migration shim ("Removed in Phase 4 housekeeping"). Consumers still importing `AgentRole`/`Team` from `roboco.models.base` will break when the shim is removed. | Low | diff --git a/docs/map/panel.md b/docs/map/panel.md index 8a3f7acf..3a643fec 100644 --- a/docs/map/panel.md +++ b/docs/map/panel.md @@ -249,6 +249,9 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog > - `876e19b3` A2A switchboard (#298) — `page.tsx` gains the switchboard/list view toggle (default switchboard) + `peekedPair` state; new `components/a2a/{a2a-switchboard,a2a-switchboard-utils,a2a-pair-card}.tsx`; `useA2AAdminPairs` added to `use-a2a-live.ts`. > - `a7147702` feat(panel): full mobile responsiveness pass — touches the A2A page's single-visible-pane layout (`h-dvh`, back affordance) among other routes. > - (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: new `tab-findings.tsx` (7th task-detail tab, `useTaskFindings` → `GET /tasks/{id}/findings`), a `bounced xN` chip on `task-header.tsx` (`revision_count`), and "PM rejects"/"CEO rejects" columns on `delivery-tab.tsx`'s rework table. See `docs/map/review-findings.md`. +> - `abf4b35f` (2026-07-17, PR #546, "wave-1 quick wins") — notifications page resolves `from_agent` via `getAgentDisplayName` (was `notification.from_agent.slice(0, 8)`, a raw UUID prefix); metrics charts (usage time-series, agent/team usage, model donut) gained a "no data" empty state alongside the existing loading skeleton. +> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `
` sibling in `(dashboard)/layout.tsx`) re-observes `
`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events. +> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`. ## Regression Risks diff --git a/docs/map/support-services.md b/docs/map/support-services.md index fbe5ab00..0d081dd3 100644 --- a/docs/map/support-services.md +++ b/docs/map/support-services.md @@ -64,7 +64,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro | `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path | | `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` | | `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider | -| `apply_mode` | method | `services/llm.py:418` | Wipe + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | +| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | | `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments | | `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row | | `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session | @@ -308,11 +308,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441`. Range `fd10cc86..HEAD` (3a No logic-touching commits to list — IMPACT: none. -> Post-snapshot updates (since 2026-06-29): four commits touched this slice after the baseline was cut. +> Post-snapshot updates (since 2026-06-29): five commits touched this slice after the baseline was cut. > - `e4ed970f` [chore] stream-bus: poison-pill ACK + dead-letter (`DEAD_LETTER_STREAM`, `_dead_letter`), periodic `_reclaim_loop` spawned alongside `_listen_loop`, `_run_handler_guarded` catches `BaseException` for cancelled-handler marker cleanup (3 gaps). > - `6b441e42` [chore] converters: `InvalidIdentifierError(ValueError)` introduced; `require_uuid` now raises it for both None and unparseable input; `repo_key` git-URL normalizer added; orchestrator reaper now logs the typed error instead of silently swallowing it. > - `321e68d7` [sweep] proactive: `_find_code_patterns` method, its call, summary line, and count removed; `ContextPackage.code_patterns` field retained (always-empty, back-compat). > - `536bbb64` Chore/all/logical-gaps-sweep (#286) — merge commit pulling the above into the branch. +> - `d83104e9` (2026-07-17, PR #546, "wave-1 quick wins") fix(llm): provider mode switches preserve per-agent model pins — `_apply_anthropic`/`_apply_grok`/`_apply_ollama`/`_apply_self_hosted` now delete only ROLE/GLOBAL `model_assignments` rows (`scope != AGENT_SLUG`) instead of wiping the whole table, so an AGENT_SLUG pin survives a mode switch; `OLLAMA_ROLE_DEFAULTS` removed from `llm_catalog.py` as dead code (it was never consulted by routing — see `models.md`). ## Regression Risks diff --git a/panel/src/app/(dashboard)/layout.tsx b/panel/src/app/(dashboard)/layout.tsx index 222993c5..92a2a82f 100644 --- a/panel/src/app/(dashboard)/layout.tsx +++ b/panel/src/app/(dashboard)/layout.tsx @@ -3,6 +3,7 @@ import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; import { BottomTabBar } from "@/components/layout/bottom-tab-bar"; import { ScrollRestoration } from "@/components/scroll-restoration"; +import { ScrollJumpButtons } from "@/components/scroll-jump-buttons"; import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner"; import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver"; @@ -27,6 +28,10 @@ export default function DashboardLayout({ {children}
+ {/* Sibling of
, not a child — fixed positioning overlays it + regardless, and staying out keeps ScrollJumpButtons' own DOM node + from being mistaken for the page content root it measures. */} + diff --git a/panel/src/app/(dashboard)/notifications/page.tsx b/panel/src/app/(dashboard)/notifications/page.tsx index 34f1c35f..ef0ee6f9 100644 --- a/panel/src/app/(dashboard)/notifications/page.tsx +++ b/panel/src/app/(dashboard)/notifications/page.tsx @@ -23,6 +23,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { usePageRefresh } from "@/hooks"; +import { getAgentDisplayName } from "@/lib/agent-utils"; import { Bell, Check, @@ -163,7 +164,7 @@ function NotificationCard({
From:{" "} - {notification.from_agent.slice(0, 8)} + {getAgentDisplayName(notification.from_agent)} {" "} • {formatDistanceToNow(new Date(notification.timestamp))} ago
diff --git a/panel/src/app/(dashboard)/tasks/page.tsx b/panel/src/app/(dashboard)/tasks/page.tsx index 5ce971eb..53b9f645 100644 --- a/panel/src/app/(dashboard)/tasks/page.tsx +++ b/panel/src/app/(dashboard)/tasks/page.tsx @@ -75,7 +75,10 @@ function TasksPageContent() { } }); const query = params.toString(); - router.push(query ? `/tasks?${query}` : "/tasks"); + // scroll: false — a UI-only param write (e.g. row expand/collapse) + // must not reset scroll on its own; ScrollRestoration's route key + // already excludes `expanded`, this is defense in depth. + router.push(query ? `/tasks?${query}` : "/tasks", { scroll: false }); }, [router, searchParams], ); diff --git a/panel/src/components/__tests__/scroll-restoration.test.tsx b/panel/src/components/__tests__/scroll-restoration.test.tsx new file mode 100644 index 00000000..4d5fe2af --- /dev/null +++ b/panel/src/components/__tests__/scroll-restoration.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { buildRouteKey } from "../scroll-restoration"; + +describe("buildRouteKey", () => { + it("drops UI-only params so they don't fork the saved scroll position", () => { + const withExpanded = new URLSearchParams("status=open&expanded=abc,def"); + const withoutExpanded = new URLSearchParams("status=open"); + + expect(buildRouteKey("/tasks", withExpanded)).toBe( + buildRouteKey("/tasks", withoutExpanded), + ); + }); + + it("keeps real navigation params", () => { + expect(buildRouteKey("/tasks", new URLSearchParams("status=open"))).toBe( + "/tasks?status=open", + ); + }); +}); diff --git a/panel/src/components/metrics/__tests__/usage-time-series-chart.test.tsx b/panel/src/components/metrics/__tests__/usage-time-series-chart.test.tsx new file mode 100644 index 00000000..c1ac95e3 --- /dev/null +++ b/panel/src/components/metrics/__tests__/usage-time-series-chart.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { UsageTimeSeriesChart } from "../usage-time-series-chart"; + +describe("UsageTimeSeriesChart", () => { + it("renders the card title", () => { + render(); + expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument(); + }); + + it("shows an empty state when there is no data", () => { + render(); + expect( + screen.getByText("No usage recorded in this window yet."), + ).toBeInTheDocument(); + }); + + it("does not show the empty state while loading", () => { + render(); + expect( + screen.queryByText("No usage recorded in this window yet."), + ).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/components/metrics/agent-usage-chart.tsx b/panel/src/components/metrics/agent-usage-chart.tsx index 0fe7219d..f396da9f 100644 --- a/panel/src/components/metrics/agent-usage-chart.tsx +++ b/panel/src/components/metrics/agent-usage-chart.tsx @@ -64,6 +64,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) { {isLoading ? ( + ) : tableRows.length === 0 ? ( +

+ No usage recorded in this window yet. +

) : view === "table" ? (
diff --git a/panel/src/components/metrics/model-usage-donut.tsx b/panel/src/components/metrics/model-usage-donut.tsx index eddc07dd..b6076f3c 100644 --- a/panel/src/components/metrics/model-usage-donut.tsx +++ b/panel/src/components/metrics/model-usage-donut.tsx @@ -47,6 +47,10 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) { {isLoading ? ( + ) : chartData.length === 0 ? ( +

+ No usage recorded in this window yet. +

) : ( diff --git a/panel/src/components/metrics/team-usage-chart.tsx b/panel/src/components/metrics/team-usage-chart.tsx index d8436965..c8718f54 100644 --- a/panel/src/components/metrics/team-usage-chart.tsx +++ b/panel/src/components/metrics/team-usage-chart.tsx @@ -61,6 +61,10 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) { {isLoading ? ( + ) : tableRows.length === 0 ? ( +

+ No usage recorded in this window yet. +

) : view === "table" ? (
diff --git a/panel/src/components/metrics/usage-time-series-chart.tsx b/panel/src/components/metrics/usage-time-series-chart.tsx index 5cd26485..f26fc29a 100644 --- a/panel/src/components/metrics/usage-time-series-chart.tsx +++ b/panel/src/components/metrics/usage-time-series-chart.tsx @@ -59,6 +59,10 @@ export function UsageTimeSeriesChart({ {isLoading ? ( + ) : chartData.length === 0 ? ( +

+ No usage recorded in this window yet. +

) : ( + * scroll container (see layout.tsx) — same querySelector("main") target + * scroll-restoration.tsx uses. Self-contained: no context/store. + */ +export function ScrollJumpButtons() { + const pathname = usePathname(); + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); + + useEffect(() => { + const mainElement = document.querySelector("main"); + if (!mainElement) return; + + const update = () => { + const { scrollTop, scrollHeight, clientHeight } = mainElement; + const overflows = scrollHeight > clientHeight + 1; + const threshold = clientHeight * ENGAGE_RATIO; + setCanScrollUp(overflows && scrollTop > threshold); + setCanScrollDown( + overflows && scrollHeight - scrollTop - clientHeight > threshold, + ); + }; + + update(); + mainElement.addEventListener("scroll", update, { passive: true }); + + // main's own box is pinned by the flex layout, so overflowing content + // never resizes main itself — watch its children (a page may render a + // multi-root fragment, so all of them, not just the first). main is + // observed too for viewport resizes. + const observer = new ResizeObserver(update); + const observeContent = () => { + observer.disconnect(); + observer.observe(mainElement); + Array.from(mainElement.children).forEach((child) => + observer.observe(child), + ); + update(); + }; + observeContent(); + + // A Suspense fallback→content swap replaces main's top-level children + // after mount — re-observe on childList changes so the ResizeObserver + // never ends up watching a detached fallback node. + const mutations = new MutationObserver(observeContent); + mutations.observe(mainElement, { childList: true }); + + return () => { + mainElement.removeEventListener("scroll", update); + mutations.disconnect(); + observer.disconnect(); + }; + }, [pathname]); + + if (!canScrollUp && !canScrollDown) return null; + + const scrollTo = (top: number) => + document.querySelector("main")?.scrollTo({ top, behavior: "smooth" }); + + return ( +
+ {canScrollUp && ( + + + + )} + {canScrollDown && ( + + + + )} +
+ ); +} diff --git a/panel/src/components/scroll-restoration.tsx b/panel/src/components/scroll-restoration.tsx index ff60b68c..edf2f352 100644 --- a/panel/src/components/scroll-restoration.tsx +++ b/panel/src/components/scroll-restoration.tsx @@ -4,6 +4,19 @@ import { useEffect, useRef } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store"; +// Params that reflect UI-only state (e.g. tasks/page.tsx row expand/collapse) +// rather than a distinct "page" a user navigated to — excluded from the +// route key so toggling them doesn't fork/reset the saved scroll position. +const UI_ONLY_PARAMS = ["expanded"]; + +// Exported for a cheap direct unit test — no need to render the component +// or mock next/navigation/zustand just to check param filtering. +export function buildRouteKey(pathname: string, searchParams: URLSearchParams) { + const filtered = new URLSearchParams(searchParams); + UI_ONLY_PARAMS.forEach((param) => filtered.delete(param)); + return `${pathname}?${filtered.toString()}`; +} + /** * Global scroll restoration component. * Add this to the layout to automatically save/restore scroll positions. @@ -17,7 +30,7 @@ export function ScrollRestoration() { const hasRestored = useRef(false); const prevRouteKey = useRef(""); - const routeKey = `${pathname}?${searchParams.toString()}`; + const routeKey = buildRouteKey(pathname, searchParams); // Track last visited route per section useEffect(() => { diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx index 2a7f43df..233314ff 100644 --- a/panel/src/components/settings/ai-routing-card.tsx +++ b/panel/src/components/settings/ai-routing-card.tsx @@ -223,11 +223,15 @@ export function AIRoutingCard() { // --- Mode toggle handlers --- const flipToAnthropic = async () => { - if (!confirm("Switch every agent to Anthropic? Clears any overrides.")) + if ( + !confirm( + "Switch every agent to Anthropic? Per-agent pins are kept; role/global assignments are replaced.", + ) + ) return; try { await applyMode.mutateAsync({ mode: "anthropic" }); - toast.success("All agents now on Anthropic"); + toast.success("Role/global routing now on Anthropic — per-agent pins kept"); } catch (e) { toast.error("Switch failed: " + errMsg(e)); } @@ -238,10 +242,15 @@ export function AIRoutingCard() { toast.error("Save the Grok (xAI) API key first"); return; } - if (!confirm("Switch every agent to Grok? Clears any overrides.")) return; + if ( + !confirm( + "Switch every agent to Grok? Per-agent pins are kept; role/global assignments are replaced.", + ) + ) + return; try { await applyMode.mutateAsync({ mode: "grok" }); - toast.success("All agents now on Grok"); + toast.success("Role/global routing now on Grok — per-agent pins kept"); } catch (e) { toast.error("Switch failed: " + errMsg(e)); } @@ -252,10 +261,15 @@ export function AIRoutingCard() { toast.error("Save an Ollama API key first"); return; } - if (!confirm("Switch every agent to Ollama? Clears any overrides.")) return; + if ( + !confirm( + "Switch every agent to Ollama? Per-agent pins are kept; role/global assignments are replaced.", + ) + ) + return; try { await applyMode.mutateAsync({ mode: "ollama" }); - toast.success("All agents now on Ollama"); + toast.success("Role/global routing now on Ollama — per-agent pins kept"); } catch (e) { toast.error("Switch failed: " + errMsg(e)); } @@ -268,7 +282,7 @@ export function AIRoutingCard() { } if ( !confirm( - "Switch every agent to the self-hosted LLM? Clears any overrides.", + "Switch every agent to the self-hosted LLM? Per-agent pins are kept; role/global assignments are replaced.", ) ) return; @@ -277,7 +291,7 @@ export function AIRoutingCard() { mode: "self_hosted", ...(selfHostedModel ? { default_model: selfHostedModel } : {}), }); - toast.success("All agents now on Self-Hosted LLM"); + toast.success("Role/global routing now on Self-Hosted LLM — per-agent pins kept"); } catch (e) { toast.error("Switch failed: " + errMsg(e)); } @@ -480,7 +494,7 @@ export function AIRoutingCard() { {/* -------- Mode toggle -------- */}
- +
diff --git a/panel/src/hooks/use-scroll-restoration.ts b/panel/src/hooks/use-scroll-restoration.ts deleted file mode 100644 index 10dd7bca..00000000 --- a/panel/src/hooks/use-scroll-restoration.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Scroll Restoration Hook - * - * Saves and restores scroll position when navigating between pages. - */ - -"use client"; - -import { useEffect, useRef } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; -import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store"; - -export function useScrollRestoration( - scrollContainerRef?: React.RefObject, -) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const { setScrollPosition, getScrollPosition } = useScrollRestorationStore(); - - // Create a unique key for current route including search params - const routeKey = `${pathname}?${searchParams.toString()}`; - const hasRestored = useRef(false); - - // Save scroll position on scroll - useEffect(() => { - const container = scrollContainerRef?.current ?? window; - const isWindow = container === window; - - const handleScroll = () => { - const position = isWindow - ? { x: window.scrollX, y: window.scrollY } - : { - x: (container as HTMLElement).scrollLeft, - y: (container as HTMLElement).scrollTop, - }; - - setScrollPosition(routeKey, position); - }; - - // Debounce scroll handler - let timeout: NodeJS.Timeout; - const debouncedScroll = () => { - clearTimeout(timeout); - timeout = setTimeout(handleScroll, 100); - }; - - container.addEventListener("scroll", debouncedScroll, { passive: true }); - - return () => { - clearTimeout(timeout); - container.removeEventListener("scroll", debouncedScroll); - }; - }, [routeKey, scrollContainerRef, setScrollPosition]); - - // Restore scroll position on mount - useEffect(() => { - if (hasRestored.current) return; - - const savedPosition = getScrollPosition(routeKey); - if (savedPosition) { - const container = scrollContainerRef?.current ?? window; - const isWindow = container === window; - - // Delay restoration to ensure content is rendered - requestAnimationFrame(() => { - if (isWindow) { - window.scrollTo(savedPosition.x, savedPosition.y); - } else { - (container as HTMLElement).scrollLeft = savedPosition.x; - (container as HTMLElement).scrollTop = savedPosition.y; - } - hasRestored.current = true; - }); - } - }, [routeKey, scrollContainerRef, getScrollPosition]); - - // Reset restoration flag when route changes - useEffect(() => { - hasRestored.current = false; - }, [routeKey]); -} diff --git a/panel/src/lib/agent-utils.ts b/panel/src/lib/agent-utils.ts index cc12bae8..917f3721 100644 --- a/panel/src/lib/agent-utils.ts +++ b/panel/src/lib/agent-utils.ts @@ -111,6 +111,8 @@ const AGENT_NAMES: Record = { "intake-1": "Intake", "secretary-1": "Secretary", "pr-reviewer-1": "PR Reviewer", + // Backend-authored notifications/events (not an agent) + system: "System", }; /** diff --git a/roboco/api/routes/orchestrator.py b/roboco/api/routes/orchestrator.py index 30785032..d624b71a 100644 --- a/roboco/api/routes/orchestrator.py +++ b/roboco/api/routes/orchestrator.py @@ -11,7 +11,7 @@ from uuid import UUID from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, status from guard_core.handlers.behavior_handler import BehaviorRule -from roboco.agents_config import CEO_AGENT_ID, verify_agent_token +from roboco.agents_config import CEO_AGENT_ID, _resolve_to_slug, verify_agent_token from roboco.api.auth.backend import SESSION_COOKIE_NAME from roboco.api.auth.session import resolve_session_user from roboco.api.deps import ( @@ -97,7 +97,8 @@ __all__ = ["router", "set_orchestrator"] def _validated_agent_id(agent_id: str) -> str: - """Reject an ``agent_id`` that could traverse a filesystem path downstream. + """Reject an ``agent_id`` that could traverse a filesystem path downstream, + then normalize it to the canonical slug the runtime addresses containers by. ``agent_id`` is an opaque slug / uuid the orchestrator assigns, but it is a request path parameter and flows into per-agent paths (e.g. the grok usage @@ -106,6 +107,12 @@ def _validated_agent_id(agent_id: str) -> str: it reaches any path. Explicit guards (not a regex) so CodeQL models this as a path-injection barrier; the runtime ``_grok_usage_dir`` repeats the check as defense in depth for non-HTTP callers. + + A caller (e.g. the panel) may pass an agent's DB UUID instead of its slug — + ``_resolve_to_slug`` maps it to the canonical slug so the runtime container + (named ``roboco-agent-{slug}``) and instance registry are addressed + consistently regardless of which identifier form was sent. An unknown UUID + (not in the seed map) passes through unchanged, same as today. """ if ( not agent_id @@ -118,7 +125,7 @@ def _validated_agent_id(agent_id: str) -> str: status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid agent_id", ) - return agent_id + return _resolve_to_slug(agent_id) # ============================================================================= diff --git a/roboco/models/llm_catalog.py b/roboco/models/llm_catalog.py index 2e100df2..600e66ad 100644 --- a/roboco/models/llm_catalog.py +++ b/roboco/models/llm_catalog.py @@ -96,35 +96,6 @@ def provider_type_for_model(model_name: str) -> ModelProvider | None: return entry.provider_type if entry else None -# Defaults per role when the user flips to "pure Ollama" mode. -# Assignments reflect the 2026-04 public benchmarks for each cloud tag: -# Kimi K2.6 — HLE 44.9%, AIME 95.6%, Agent Swarm (100 parallel sub-agents), -# 200-300 sequential tool calls. Best at reasoning, orchestration, tool use. -# MiniMax M3 — SWE-Bench 73.8%, SWE-Pro 56.2%, 10B active params (fastest, -# cheapest). Explicitly "built for Max coding & agentic workflows". -# GLM 5.2 — SWE-Bench 77.8% (highest of the three, 94.6% of Claude Opus 4.6), -# self-correcting across hundreds of iterations, strong creative writing. -OLLAMA_ROLE_DEFAULTS: dict[str, str] = { - # High-volume agentic coding — M3 is purpose-built for this. - "developer": "kimi-k2.7-code:cloud", - # Deep code review — GLM 5.2 has the highest SWE-Bench and iterates thoroughly. - "qa": "glm-5.2:cloud", - # Orchestration + tool coordination — Kimi K2.6's Agent Swarm is the exact fit. - "cell_pm": "kimi-k2.7-code:cloud", - "main_pm": "kimi-k2.7-code:cloud", - # Quality reasoning — Kimi K2.6 leads HLE by a wide margin. - "auditor": "kimi-k2.7-code:cloud", - # Product reasoning — same profile as PM work. - "product_owner": "glm-5.2:cloud", - # Writing with code-context — GLM 5.2's creative writing + SWE-Bench combo. - "documenter": "kimi-k2.7-code:cloud", - # Stylistic writing — GLM 5.2's creative-writing strength. - "head_marketing": "glm-5.2:cloud", - # CEO is human-in-the-loop; keep an entry in case someone forces - # a route to it, but the Settings UI intentionally excludes it. - "ceo": "glm-5.2:cloud", -} - # The Ollama model picked for "pure Ollama" mode's GLOBAL row when the # caller doesn't override. Minimax M3 wins as the generalist because it has # the strongest reasoning/tool-use profile and can fall back to coding/writing diff --git a/roboco/services/llm.py b/roboco/services/llm.py index f7b6b128..ee528852 100644 --- a/roboco/services/llm.py +++ b/roboco/services/llm.py @@ -444,17 +444,20 @@ class ModelRoutingService(BaseService): ) -> None: """Apply a routing "mode" in a single transactional call. + All modes below preserve AGENT_SLUG pins — only ROLE/GLOBAL rows are + replaced, so a per-agent override survives a mode switch (mixed-provider + routing is already a supported state; see "mix"). + Modes: - - "anthropic": wipe all assignments so every spawn falls through - to the legacy ROLE_MODEL_MAP + mounted ~/.claude path. - - "ollama": wipe role/agent overrides, set GLOBAL to the given - Ollama model (default: OLLAMA_DEFAULT_MODEL). CEO-type pins can be - layered back manually if the user wants them. - - "self_hosted": wipe all assignments, enable the LOCAL provider, - and set the GLOBAL default to `default_model` (a self-hosted - model name — not validated against the static catalog). - - "grok": wipe all assignments, set the GLOBAL default to a - Grok (xAI) model (default grok-build-0.1). Requires the xAI key. + - "anthropic": wipe role/global assignments so every spawn falls + through to the legacy ROLE_MODEL_MAP + mounted ~/.claude path. + - "ollama": wipe role/global assignments, set GLOBAL to the given + Ollama model (default: OLLAMA_DEFAULT_MODEL). + - "self_hosted": wipe role/global assignments, enable the LOCAL + provider, and set the GLOBAL default to `default_model` (a + self-hosted model name — not validated against the static catalog). + - "grok": wipe role/global assignments, set the GLOBAL default + to a Grok (xAI) model (default grok-build-0.1). Requires the xAI key. - "mix": apply per-agent map verbatim. Any agent not in the map falls through to the GLOBAL default — which is whatever it was (preserves prior state). Self-hosted model names (not in the @@ -477,10 +480,16 @@ class ModelRoutingService(BaseService): ) async def _apply_anthropic(self) -> None: - """Wipe all assignments so every spawn uses the legacy Anthropic path.""" - await self.session.execute(sa_delete(ModelAssignmentTable)) + """Wipe role/global assignments so every spawn uses the legacy Anthropic + path. AGENT_SLUG pins are preserved — mixed-provider routing is a + supported state (see `_apply_mix`).""" + await self.session.execute( + sa_delete(ModelAssignmentTable).where( + ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG + ) + ) await self.session.flush() - self.log.info("Mode applied: anthropic (all assignments cleared)") + self.log.info("Mode applied: anthropic (role/global assignments cleared)") async def _apply_grok(self, default_model: str | None) -> None: """Wipe assignments, set the GLOBAL default to a Grok (xAI) model. @@ -490,9 +499,13 @@ class ModelRoutingService(BaseService): must be enabled here for resolve_for_agent() to route to it, mirroring self_hosted enabling LOCAL. Without it the seeded GROK row stays disabled (no key set) and agents fall back to Anthropic at spawn even - in grok mode. + in grok mode. AGENT_SLUG pins are preserved (see `_apply_mix`). """ - await self.session.execute(sa_delete(ModelAssignmentTable)) + await self.session.execute( + sa_delete(ModelAssignmentTable).where( + ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG + ) + ) await self.session.flush() grok = await self._get_seeded_provider(ModelProvider.GROK) provider_svc = ProviderService(self.session) @@ -509,8 +522,14 @@ class ModelRoutingService(BaseService): self.log.info("Mode applied: grok", default_model=model_name) async def _apply_ollama(self, default_model: str | None) -> None: - """Wipe assignments, set the GLOBAL default to an Ollama Cloud model.""" - await self.session.execute(sa_delete(ModelAssignmentTable)) + """Wipe role/global assignments, set GLOBAL to an Ollama Cloud model. + + AGENT_SLUG pins are preserved (see `_apply_mix`).""" + await self.session.execute( + sa_delete(ModelAssignmentTable).where( + ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG + ) + ) await self.session.flush() model_name = default_model or OLLAMA_DEFAULT_MODEL await self.upsert_assignment( @@ -521,12 +540,17 @@ class ModelRoutingService(BaseService): self.log.info("Mode applied: ollama", default_model=model_name) async def _apply_self_hosted(self, default_model: str | None) -> None: - """Wipe assignments, enable the LOCAL provider, point GLOBAL at it.""" + """Wipe role/global assignments, enable the LOCAL provider, point GLOBAL + at it. AGENT_SLUG pins are preserved (see `_apply_mix`).""" if not default_model: raise ValueError( "self_hosted mode requires a default_model (self-hosted model name)" ) - await self.session.execute(sa_delete(ModelAssignmentTable)) + await self.session.execute( + sa_delete(ModelAssignmentTable).where( + ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG + ) + ) await self.session.flush() # Enable the LOCAL provider row so resolve_for_agent() will use it. local = await self._find_local_provider() diff --git a/tests/integration/test_llm_routing.py b/tests/integration/test_llm_routing.py index 8c500ac2..a0a6ae21 100644 --- a/tests/integration/test_llm_routing.py +++ b/tests/integration/test_llm_routing.py @@ -223,6 +223,28 @@ async def test_apply_mode_anthropic_clears_all(llm_setup: dict) -> None: assert await svc.list_assignments() == [] +@pytest.mark.asyncio +async def test_apply_mode_anthropic_preserves_agent_pin(llm_setup: dict) -> None: + """A mode switch must not wipe per-agent pins — only role/global rows.""" + svc = llm_setup["svc"] + ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD) + anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC) + await svc.upsert_assignment( + scope=AssignmentScope.AGENT_SLUG, + scope_value="be-dev-1", + model_name=ollama_model, + ) + await svc.upsert_assignment( + scope=AssignmentScope.GLOBAL, scope_value=None, model_name=anthropic_model + ) + await svc.apply_mode(mode="anthropic") + assignments = await svc.list_assignments() + assert len(assignments) == 1 # GLOBAL row cleared, AGENT_SLUG pin survives. + assert assignments[0].scope == AssignmentScope.AGENT_SLUG + assert assignments[0].scope_value == "be-dev-1" + assert assignments[0].model_name == ollama_model + + @pytest.mark.asyncio async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None: svc = llm_setup["svc"] @@ -233,6 +255,26 @@ async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None: assert assignments[0].scope == AssignmentScope.GLOBAL +@pytest.mark.asyncio +async def test_apply_mode_ollama_preserves_agent_pin(llm_setup: dict) -> None: + """The Ollama mode-switch button must not wipe per-agent model pins.""" + svc = llm_setup["svc"] + anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC) + ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD) + await svc.upsert_assignment( + scope=AssignmentScope.AGENT_SLUG, + scope_value="be-dev-1", + model_name=anthropic_model, + ) + await svc.apply_mode(mode="ollama", default_model=ollama_model) + assignments = await svc.list_assignments() + assert len(assignments) == 2 # noqa: PLR2004 AGENT_SLUG pin kept + new GLOBAL row. + by_scope = {a.scope: a for a in assignments} + assert by_scope[AssignmentScope.AGENT_SLUG].scope_value == "be-dev-1" + assert by_scope[AssignmentScope.AGENT_SLUG].model_name == anthropic_model + assert by_scope[AssignmentScope.GLOBAL].model_name == ollama_model + + @pytest.mark.asyncio async def test_apply_mode_grok_sets_global(llm_setup: dict) -> None: svc = llm_setup["svc"] @@ -479,7 +521,8 @@ async def test_apply_mode_self_hosted_requires_default_model( async def test_apply_mode_self_hosted_clears_prior_assignments( llm_setup_with_local: dict, ) -> None: - """apply_mode('self_hosted') clears ALL prior assignments.""" + """apply_mode('self_hosted') clears role/global assignments but preserves + AGENT_SLUG pins (mixed-provider routing is a supported state).""" svc = llm_setup_with_local["svc"] anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC) await svc.upsert_assignment( @@ -495,8 +538,11 @@ async def test_apply_mode_self_hosted_clears_prior_assignments( assert len(await svc.list_assignments()) == 2 # noqa: PLR2004 await svc.apply_mode(mode="self_hosted", default_model="gemma2:9b") assignments = await svc.list_assignments() - assert len(assignments) == 1 # Only the new GLOBAL row. - assert assignments[0].provider.type == ModelProvider.LOCAL + assert len(assignments) == 2 # noqa: PLR2004 AGENT_SLUG pin kept + new GLOBAL row. + by_scope = {a.scope: a for a in assignments} + assert by_scope[AssignmentScope.AGENT_SLUG].scope_value == "be-dev-1" + assert by_scope[AssignmentScope.AGENT_SLUG].model_name == anthropic_model + assert by_scope[AssignmentScope.GLOBAL].provider.type == ModelProvider.LOCAL @pytest.mark.asyncio diff --git a/tests/unit/api/test_orchestrator_manual_spawn.py b/tests/unit/api/test_orchestrator_manual_spawn.py index 134a694e..578b31ed 100644 --- a/tests/unit/api/test_orchestrator_manual_spawn.py +++ b/tests/unit/api/test_orchestrator_manual_spawn.py @@ -20,12 +20,14 @@ from uuid import uuid4 import pytest import pytest_asyncio import roboco.api.routes.orchestrator as orch_route -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from httpx import ASGITransport, AsyncClient +from roboco.agents_config import AGENT_UUIDS from roboco.api.deps import _ServiceHolder, set_orchestrator from roboco.api.routes.orchestrator import ( _build_manual_spawn_prompt, _resolve_manual_spawn_prompt, + _validated_agent_id, ) from roboco.api.routes.orchestrator import ( router as orch_router, @@ -275,3 +277,76 @@ async def test_spawn_offline_agent_not_flagged_already_running( ) assert response.status_code == HTTPStatus.CREATED assert response.json()["already_running"] is False + + +# --------------------------------------------------------------------------- +# _validated_agent_id — UUID -> slug normalization (root fix: a caller that +# addresses a runtime container/instance by an agent's DB UUID instead of its +# slug, e.g. the panel spawn button, must resolve to the same canonical slug +# the orchestrator's instance registry and container names use). +# --------------------------------------------------------------------------- + + +def test_validated_agent_id_resolves_known_uuid_to_slug() -> None: + uuid_str = AGENT_UUIDS["head-marketing"] + assert _validated_agent_id(uuid_str) == "head-marketing" + + +def test_validated_agent_id_passes_through_slug_unchanged() -> None: + assert _validated_agent_id("head-marketing") == "head-marketing" + + +def test_validated_agent_id_passes_through_unknown_uuid_unchanged() -> None: + # A uuid4 is never a seeded agent UUID (the seeds are deterministic, + # low-cardinality values) — genuinely absent from the UUID -> slug map. + unknown_uuid = str(uuid4()) + assert unknown_uuid not in AGENT_UUIDS.values() + assert _validated_agent_id(unknown_uuid) == unknown_uuid + + +def test_validated_agent_id_still_rejects_traversal() -> None: + with pytest.raises(HTTPException) as exc_info: + _validated_agent_id("../etc/passwd") + assert exc_info.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_spawn_by_uuid_reaches_orchestrator_by_slug( + orch_client: tuple[AsyncClient, MagicMock], +) -> None: + """The panel (or any caller) posting the agent's DB UUID as the path + param must not produce a container/instance keyed by that UUID — the + orchestrator only ever sees the canonical slug.""" + client, orch = orch_client + orch.get_instance = MagicMock(return_value=None) + instance = SimpleNamespace( + id=uuid4(), + agent_id="head-marketing", + state=AgentState.STARTING, + current_task_id=None, + error_count=0, + started_at=datetime.now(UTC), + ) + orch.spawn_agent = AsyncMock(return_value=instance) + uuid_str = AGENT_UUIDS["head-marketing"] + response = await client.post( + f"/api/orchestrator/agents/{uuid_str}/spawn", headers=_HDR + ) + assert response.status_code == HTTPStatus.CREATED + orch.spawn_agent.assert_awaited_once() + assert orch.spawn_agent.await_args.kwargs["agent_id"] == "head-marketing" + + +@pytest.mark.asyncio +async def test_stop_by_uuid_reaches_orchestrator_by_slug( + orch_client: tuple[AsyncClient, MagicMock], +) -> None: + client, orch = orch_client + orch.stop_agent = AsyncMock(return_value=None) + uuid_str = AGENT_UUIDS["be-dev-1"] + response = await client.post( + f"/api/orchestrator/agents/{uuid_str}/stop", headers=_HDR + ) + assert response.status_code == HTTPStatus.NO_CONTENT + orch.stop_agent.assert_awaited_once() + assert orch.stop_agent.await_args.args[0] == "be-dev-1"