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 |
| `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: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). |
| `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 `<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
+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). |
| `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
+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 |
| `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: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`.
> - `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
+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 |
| `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
+5
View File
@@ -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({
</Suspense>
{children}
</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>
<BottomTabBar />
</div>
@@ -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({
<div className="text-xs text-muted-foreground">
From:{" "}
<HelpTip label={notification.from_agent}>
<span>{notification.from_agent.slice(0, 8)}</span>
<span>{getAgentDisplayName(notification.from_agent)}</span>
</HelpTip>{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</div>
+4 -1
View File
@@ -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],
);
@@ -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>
{isLoading ? (
<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" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
@@ -47,6 +47,10 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
<CardContent>
{isLoading ? (
<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}>
<PieChart>
@@ -61,6 +61,10 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<CardContent>
{isLoading ? (
<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" ? (
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
@@ -59,6 +59,10 @@ export function UsageTimeSeriesChart({
<CardContent>
{isLoading ? (
<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}>
<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 { 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<string>("");
const routeKey = `${pathname}?${searchParams.toString()}`;
const routeKey = buildRouteKey(pathname, searchParams);
// Track last visited route per section
useEffect(() => {
@@ -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 -------- */}
<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>
</HelpTip>
<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",
"secretary-1": "Secretary",
"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 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)
# =============================================================================
-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
# 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
+43 -19
View File
@@ -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()
+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() == []
@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
@@ -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"