fix: wave 1 quick wins — agent names, scroll bounce-back, chart empty states, model-pin preservation, UUID spawn normalization (#546)

* 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 <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:42:13 +02:00
committed by GitHub
co-authored by Renn F
parent 4c8a9fc008
commit 9b4ce6b9c8
24 changed files with 415 additions and 160 deletions
+12 -6
View File
@@ -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 | | `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 | | `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 | | `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 | | `message.py` | `ExtractedMessage` + `RawStream` | 137 |
| `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 | | `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 |
| `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 | | `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_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) | | `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` | | `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 | | `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) | | `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 | | `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 │ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage
├── llm ├── llm
│ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics │ ├── 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 │ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP
├── metrics ├── metrics
│ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard │ └── 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 | | `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 | | `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 | | `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 | | `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:2124 | 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 | | `AgentRole`/`Team` alias removal pending | base.py:2124 | 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). | | `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`. | | `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. | | `_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). | | `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. | | `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. | | `_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. > - `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. > - `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. > - `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 ## 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 | | `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)` | | `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 | | `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 | | `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 | | `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 | | `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. 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). > - `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. > - `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). > - `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. > - `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 ## 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`. > - `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`. > - `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. > - `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 `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'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 ## Regression Risks
+2
View File
@@ -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). | | `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`. | | `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. | | `_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). | | `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. | | `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. | | `_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. > - `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. > - `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. > - `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 ## Regression Risks
+3 -4
View File
@@ -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 | | `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 | | `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 | | `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 | | `message.py` | `ExtractedMessage` + `RawStream` | 137 |
| `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 | | `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 |
| `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 | | `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_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) | | `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` | | `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 | | `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) | | `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 | | `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 │ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage
├── llm ├── llm
│ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics │ ├── 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 │ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP
├── metrics ├── metrics
│ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard │ └── 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 | | `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 | | `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 | | `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 | | `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:2124 | 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 | | `AgentRole`/`Team` alias removal pending | base.py:2124 | 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 |
+3
View File
@@ -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`. > - `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. > - `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`. > - (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 `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'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 ## Regression Risks
+3 -2
View File
@@ -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 | | `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)` | | `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 | | `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 | | `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 | | `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 | | `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. 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). > - `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. > - `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). > - `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. > - `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 ## Regression Risks
+5
View File
@@ -3,6 +3,7 @@ import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { BottomTabBar } from "@/components/layout/bottom-tab-bar"; import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
import { ScrollRestoration } from "@/components/scroll-restoration"; import { ScrollRestoration } from "@/components/scroll-restoration";
import { ScrollJumpButtons } from "@/components/scroll-jump-buttons";
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner"; import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver"; import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver";
@@ -27,6 +28,10 @@ export default function DashboardLayout({
</Suspense> </Suspense>
{children} {children}
</main> </main>
{/* Sibling of <main>, 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. */}
<ScrollJumpButtons />
</div> </div>
<BottomTabBar /> <BottomTabBar />
</div> </div>
@@ -23,6 +23,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { usePageRefresh } from "@/hooks"; import { usePageRefresh } from "@/hooks";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { import {
Bell, Bell,
Check, Check,
@@ -163,7 +164,7 @@ function NotificationCard({
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
From:{" "} From:{" "}
<HelpTip label={notification.from_agent}> <HelpTip label={notification.from_agent}>
<span>{notification.from_agent.slice(0, 8)}</span> <span>{getAgentDisplayName(notification.from_agent)}</span>
</HelpTip>{" "} </HelpTip>{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago {formatDistanceToNow(new Date(notification.timestamp))} ago
</div> </div>
+4 -1
View File
@@ -75,7 +75,10 @@ function TasksPageContent() {
} }
}); });
const query = params.toString(); 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], [router, searchParams],
); );
@@ -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",
);
});
});
@@ -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(<UsageTimeSeriesChart data={undefined} isLoading={false} />);
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
});
it("shows an empty state when there is no data", () => {
render(<UsageTimeSeriesChart data={[]} isLoading={false} />);
expect(
screen.getByText("No usage recorded in this window yet."),
).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<UsageTimeSeriesChart data={[]} isLoading />);
expect(
screen.queryByText("No usage recorded in this window yet."),
).not.toBeInTheDocument();
});
});
@@ -64,6 +64,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : tableRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : view === "table" ? ( ) : view === "table" ? (
<div className="max-h-52 overflow-y-auto"> <div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -47,6 +47,10 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : ( ) : (
<ResponsiveContainer width="100%" height={208}> <ResponsiveContainer width="100%" height={208}>
<PieChart> <PieChart>
@@ -61,6 +61,10 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : tableRows.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : view === "table" ? ( ) : view === "table" ? (
<div className="max-h-52 overflow-y-auto"> <div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -59,6 +59,10 @@ export function UsageTimeSeriesChart({
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No usage recorded in this window yet.
</p>
) : ( ) : (
<ResponsiveContainer width="100%" height={208}> <ResponsiveContainer width="100%" height={208}>
<AreaChart <AreaChart
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
// Fraction of main's visible height a user must have scrolled (or have left
// to scroll) before the corresponding button engages.
const ENGAGE_RATIO = 0.5;
/**
* Floating back-to-top / jump-to-bottom control for the shared <main>
* 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 (
<div className="fixed right-4 bottom-20 z-30 flex flex-col gap-2 md:right-6 md:bottom-6">
{canScrollUp && (
<HelpTip label="Back to top" side="left">
<Button
variant="secondary"
size="icon"
className="rounded-full shadow-lg"
onClick={() => scrollTo(0)}
>
<ArrowUp className="h-4 w-4" />
<span className="sr-only">Back to top</span>
</Button>
</HelpTip>
)}
{canScrollDown && (
<HelpTip label="Jump to bottom" side="left">
<Button
variant="secondary"
size="icon"
className="rounded-full shadow-lg"
onClick={() =>
scrollTo(document.querySelector("main")?.scrollHeight ?? 0)
}
>
<ArrowDown className="h-4 w-4" />
<span className="sr-only">Jump to bottom</span>
</Button>
</HelpTip>
)}
</div>
);
}
+14 -1
View File
@@ -4,6 +4,19 @@ import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store"; 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. * Global scroll restoration component.
* Add this to the layout to automatically save/restore scroll positions. * Add this to the layout to automatically save/restore scroll positions.
@@ -17,7 +30,7 @@ export function ScrollRestoration() {
const hasRestored = useRef(false); const hasRestored = useRef(false);
const prevRouteKey = useRef<string>(""); const prevRouteKey = useRef<string>("");
const routeKey = `${pathname}?${searchParams.toString()}`; const routeKey = buildRouteKey(pathname, searchParams);
// Track last visited route per section // Track last visited route per section
useEffect(() => { useEffect(() => {
@@ -223,11 +223,15 @@ export function AIRoutingCard() {
// --- Mode toggle handlers --- // --- Mode toggle handlers ---
const flipToAnthropic = async () => { 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; return;
try { try {
await applyMode.mutateAsync({ mode: "anthropic" }); 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) { } catch (e) {
toast.error("Switch failed: " + errMsg(e)); toast.error("Switch failed: " + errMsg(e));
} }
@@ -238,10 +242,15 @@ export function AIRoutingCard() {
toast.error("Save the Grok (xAI) API key first"); toast.error("Save the Grok (xAI) API key first");
return; 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 { try {
await applyMode.mutateAsync({ mode: "grok" }); 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) { } catch (e) {
toast.error("Switch failed: " + errMsg(e)); toast.error("Switch failed: " + errMsg(e));
} }
@@ -252,10 +261,15 @@ export function AIRoutingCard() {
toast.error("Save an Ollama API key first"); toast.error("Save an Ollama API key first");
return; 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 { try {
await applyMode.mutateAsync({ mode: "ollama" }); 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) { } catch (e) {
toast.error("Switch failed: " + errMsg(e)); toast.error("Switch failed: " + errMsg(e));
} }
@@ -268,7 +282,7 @@ export function AIRoutingCard() {
} }
if ( if (
!confirm( !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; return;
@@ -277,7 +291,7 @@ export function AIRoutingCard() {
mode: "self_hosted", mode: "self_hosted",
...(selfHostedModel ? { default_model: selfHostedModel } : {}), ...(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) { } catch (e) {
toast.error("Switch failed: " + errMsg(e)); toast.error("Switch failed: " + errMsg(e));
} }
@@ -480,7 +494,7 @@ export function AIRoutingCard() {
{/* -------- Mode toggle -------- */} {/* -------- Mode toggle -------- */}
<section className="space-y-3"> <section className="space-y-3">
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted route every agent to one provider and clear all per-agent overrides below. Mix keeps whatever's picked in the table."> <HelpTip label="Anthropic / Grok / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
<Label className="text-sm font-medium">Routing mode</Label> <Label className="text-sm font-medium">Routing mode</Label>
</HelpTip> </HelpTip>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
-81
View File
@@ -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<HTMLElement>,
) {
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]);
}
+2
View File
@@ -111,6 +111,8 @@ const AGENT_NAMES: Record<string, string> = {
"intake-1": "Intake", "intake-1": "Intake",
"secretary-1": "Secretary", "secretary-1": "Secretary",
"pr-reviewer-1": "PR Reviewer", "pr-reviewer-1": "PR Reviewer",
// Backend-authored notifications/events (not an agent)
system: "System",
}; };
/** /**
+10 -3
View File
@@ -11,7 +11,7 @@ from uuid import UUID
from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, status from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, status
from guard_core.handlers.behavior_handler import BehaviorRule 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.backend import SESSION_COOKIE_NAME
from roboco.api.auth.session import resolve_session_user from roboco.api.auth.session import resolve_session_user
from roboco.api.deps import ( from roboco.api.deps import (
@@ -97,7 +97,8 @@ __all__ = ["router", "set_orchestrator"]
def _validated_agent_id(agent_id: str) -> str: 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 ``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 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 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 path-injection barrier; the runtime ``_grok_usage_dir`` repeats the check as
defense in depth for non-HTTP callers. 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 ( if (
not agent_id not agent_id
@@ -118,7 +125,7 @@ def _validated_agent_id(agent_id: str) -> str:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid agent_id", detail="Invalid agent_id",
) )
return agent_id return _resolve_to_slug(agent_id)
# ============================================================================= # =============================================================================
-29
View File
@@ -96,35 +96,6 @@ def provider_type_for_model(model_name: str) -> ModelProvider | None:
return entry.provider_type if entry else 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 # 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 # 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 # the strongest reasoning/tool-use profile and can fall back to coding/writing
+43 -19
View File
@@ -444,17 +444,20 @@ class ModelRoutingService(BaseService):
) -> None: ) -> None:
"""Apply a routing "mode" in a single transactional call. """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: Modes:
- "anthropic": wipe all assignments so every spawn falls through - "anthropic": wipe role/global assignments so every spawn falls
to the legacy ROLE_MODEL_MAP + mounted ~/.claude path. through to the legacy ROLE_MODEL_MAP + mounted ~/.claude path.
- "ollama": wipe role/agent overrides, set GLOBAL to the given - "ollama": wipe role/global assignments, set GLOBAL to the given
Ollama model (default: OLLAMA_DEFAULT_MODEL). CEO-type pins can be Ollama model (default: OLLAMA_DEFAULT_MODEL).
layered back manually if the user wants them. - "self_hosted": wipe role/global assignments, enable the LOCAL
- "self_hosted": wipe all assignments, enable the LOCAL provider, provider, and set the GLOBAL default to `default_model` (a
and set the GLOBAL default to `default_model` (a self-hosted self-hosted model name not validated against the static catalog).
model name not validated against the static catalog). - "grok": wipe role/global assignments, set the GLOBAL default
- "grok": wipe all assignments, set the GLOBAL default to a to a Grok (xAI) model (default grok-build-0.1). Requires the xAI key.
Grok (xAI) model (default grok-build-0.1). Requires the xAI key.
- "mix": apply per-agent map verbatim. Any agent not in the - "mix": apply per-agent map verbatim. Any agent not in the
map falls through to the GLOBAL default which is whatever it map falls through to the GLOBAL default which is whatever it
was (preserves prior state). Self-hosted model names (not in the was (preserves prior state). Self-hosted model names (not in the
@@ -477,10 +480,16 @@ class ModelRoutingService(BaseService):
) )
async def _apply_anthropic(self) -> None: async def _apply_anthropic(self) -> None:
"""Wipe all assignments so every spawn uses the legacy Anthropic path.""" """Wipe role/global assignments so every spawn uses the legacy Anthropic
await self.session.execute(sa_delete(ModelAssignmentTable)) 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() 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: async def _apply_grok(self, default_model: str | None) -> None:
"""Wipe assignments, set the GLOBAL default to a Grok (xAI) model. """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 must be enabled here for resolve_for_agent() to route to it, mirroring
self_hosted enabling LOCAL. Without it the seeded GROK row stays self_hosted enabling LOCAL. Without it the seeded GROK row stays
disabled (no key set) and agents fall back to Anthropic at spawn even 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() await self.session.flush()
grok = await self._get_seeded_provider(ModelProvider.GROK) grok = await self._get_seeded_provider(ModelProvider.GROK)
provider_svc = ProviderService(self.session) provider_svc = ProviderService(self.session)
@@ -509,8 +522,14 @@ class ModelRoutingService(BaseService):
self.log.info("Mode applied: grok", default_model=model_name) self.log.info("Mode applied: grok", default_model=model_name)
async def _apply_ollama(self, default_model: str | None) -> None: async def _apply_ollama(self, default_model: str | None) -> None:
"""Wipe assignments, set the GLOBAL default to an Ollama Cloud model.""" """Wipe role/global assignments, set GLOBAL to an Ollama Cloud model.
await self.session.execute(sa_delete(ModelAssignmentTable))
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() await self.session.flush()
model_name = default_model or OLLAMA_DEFAULT_MODEL model_name = default_model or OLLAMA_DEFAULT_MODEL
await self.upsert_assignment( await self.upsert_assignment(
@@ -521,12 +540,17 @@ class ModelRoutingService(BaseService):
self.log.info("Mode applied: ollama", default_model=model_name) self.log.info("Mode applied: ollama", default_model=model_name)
async def _apply_self_hosted(self, default_model: str | None) -> None: 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: if not default_model:
raise ValueError( raise ValueError(
"self_hosted mode requires a default_model (self-hosted model name)" "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() await self.session.flush()
# Enable the LOCAL provider row so resolve_for_agent() will use it. # Enable the LOCAL provider row so resolve_for_agent() will use it.
local = await self._find_local_provider() local = await self._find_local_provider()
+49 -3
View File
@@ -223,6 +223,28 @@ async def test_apply_mode_anthropic_clears_all(llm_setup: dict) -> None:
assert await svc.list_assignments() == [] 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 @pytest.mark.asyncio
async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None: async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None:
svc = llm_setup["svc"] 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 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 @pytest.mark.asyncio
async def test_apply_mode_grok_sets_global(llm_setup: dict) -> None: async def test_apply_mode_grok_sets_global(llm_setup: dict) -> None:
svc = llm_setup["svc"] 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( async def test_apply_mode_self_hosted_clears_prior_assignments(
llm_setup_with_local: dict, llm_setup_with_local: dict,
) -> None: ) -> 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"] svc = llm_setup_with_local["svc"]
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC) anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.upsert_assignment( 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 assert len(await svc.list_assignments()) == 2 # noqa: PLR2004
await svc.apply_mode(mode="self_hosted", default_model="gemma2:9b") await svc.apply_mode(mode="self_hosted", default_model="gemma2:9b")
assignments = await svc.list_assignments() assignments = await svc.list_assignments()
assert len(assignments) == 1 # Only the new GLOBAL row. assert len(assignments) == 2 # noqa: PLR2004 AGENT_SLUG pin kept + new GLOBAL row.
assert assignments[0].provider.type == ModelProvider.LOCAL 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 @pytest.mark.asyncio
@@ -20,12 +20,14 @@ from uuid import uuid4
import pytest import pytest
import pytest_asyncio import pytest_asyncio
import roboco.api.routes.orchestrator as orch_route import roboco.api.routes.orchestrator as orch_route
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from roboco.agents_config import AGENT_UUIDS
from roboco.api.deps import _ServiceHolder, set_orchestrator from roboco.api.deps import _ServiceHolder, set_orchestrator
from roboco.api.routes.orchestrator import ( from roboco.api.routes.orchestrator import (
_build_manual_spawn_prompt, _build_manual_spawn_prompt,
_resolve_manual_spawn_prompt, _resolve_manual_spawn_prompt,
_validated_agent_id,
) )
from roboco.api.routes.orchestrator import ( from roboco.api.routes.orchestrator import (
router as orch_router, 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.status_code == HTTPStatus.CREATED
assert response.json()["already_running"] is False 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"