Repository navigation
v0.30.0
Added
-
Three observability/capacity fixes for a stuck-orchestrator incident nobody could diagnose live. Tonight the orchestrator process sat at 113% CPU for 80 minutes with no way to see why: the image had no profiler and Postgres had no per-query stats, and separately
pg_stat_user_tablesshowedchunks_journalstaking 196 sequential scans reading 6.1M tuples against only 387 live rows because its only vector index (ivfflat, added in migration-era code back in June) trains its k-means clusters on whatever rows exist at CREATE TIME -- nothing, on a brand-new table -- and the planner never trusted it. Fix one: the orchestrator image now shipspy-spyas a standaloneuv tool install, never a roboco dependency, sodocker compose exec orchestrator py-spy dump --pid 1dumps every thread's stack without a restart; it needsSYS_PTRACE, added viacap_addon theorchestratorservice in all three compose files. Fix two: migration095_pg_stat_statementsenablespg_stat_statementsinside anautocommit_block(mirroring migration 090's enum-add pattern), and thepostgresservice in all three compose files now carriescommand: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]so the extension actually collects something -- without the GUC,CREATE EXTENSIONsucceeds but the stats view stays empty; the postgres container restarts once on the next bring-up to pick up the flag. The migration'sCREATE EXTENSIONis still wrapped in a try/except, narrowed tosqlalchemy.exc.DBAPIErrorand only two SQLSTATEs verified against a live Postgres (not the file-access class58P01a control-file-missing error might suggest -- Postgres actually codes that path0A000feature_not_supported):0A000(extension not available, e.g. an image without pgvector's contrib packages) and42501(insufficient privilege); it also checks the error namespg_stat_statementsspecifically, so a typo'd extension name in this file's own SQL re-raises instead of quietly no-oping forever. Anything else re-raises too. Fix three:VectorStore.initialize()(the single ensure-schema point behind everychunks_*table) now provisionsCREATE INDEX CONCURRENTLY ..._embedding_hnsw_idx USING hnsw (embedding vector_cosine_ops)instead of the old ivfflat index, matching the<=>cosine operator thatsearch/hybrid_searchalready order by: HNSW builds incrementally as rows are added instead of training once on an empty table,initialize()repairs a previously-failedCONCURRENTLYbuild (indisvalid = false) by dropping and recreating the index instead of leaving it dead forever, and the old ivfflat index -- useless in production for the reason above -- is dropped once the HNSW index is valid. -
New gateway verb
cancel_leaf(task_id, reason)gives a Cell/Main PM the mechanical path to close a zero-diff leaf (task de9379cc, 2026-09-05). A fix leaf whose findings a sibling had already resolved on the parent branch left no legitimate diff behind, but nothing in the gateway could close it:i_am_donerefused (NO_COMMITS gate),completerefused (no open PR),unblockjust re-spawned the developer, and 8 from-scratch verifications by fe-dev-1, fe-dev-2, fe-pm, and main-pm all agreed nothing remained to fix before the PM's own escalate/unblock loop tripped the oscillation breaker and forced the CEO to cancel it by hand viaPOST /api/tasks/{id}/cancel.cancel_leafreusesTaskService.cancel, refuses up front if the target has any children of its own so its cascade never reaches a real descendant, requires the caller own the leaf's parent coordination task (a Main PM may instead act on any root's descendant), and fails CLOSED on a zero-diff check reusing the sameGitService.is_behind_baseahead-count_maybe_waive_pr_creationuses - an open PR, any commit ahead of the leaf's base, or an unanswerable git error all refuse rather than guess, the opposite of that check's fail-OPEN since this one authorizes discarding a task outright.reasonis recorded as both the PM'sjournal:decisionand the task's cancellation note; the returnednexthint points the PM at re-submit_up/submit_rooton the parent. The oscillation breaker's own remediation text (_oscillation_unblock_guard/_maybe_trip_oscillation_breaker) now namescancel_leafso the next PM stuck in the same loop finds the exit without escalating to the CEO. A cancelled leaf was already excluded from the sequence bar's candidate set for every cancel path; a new test pins that for this one too. -
ROBOCO_ROLEsplits the singlepython -m roboco.cliprocess into three (api/dispatcher/indexer), so the fleet stops pinning one core. Measured live on the production NAS (2026-09-05): the whole backend ran as ONE process, FastAPI plus the Orchestrator's roughly twenty background loops (dispatcher, sweeper, self-heal, CI-watch, dep-update, release manager, board programs, vault, telegram, ...) plus the RAG/embedding pipeline, and the GIL pinned it near one core (113% CPU on a 12-core box), starving every gateway verb (120s timeouts, 60 evidence-leg timeouts per 30 minutes). The newrolesetting (envROBOCO_ROLE, defaultall) gates a single seam inAgentOrchestrator.start():apiserves stateless FastAPI request/response routes, but never runs the once-per-fleet startup reconciliation or launches a single background loop;dispatcheris the fleet owner, running that reconciliation plus every loop;indexerruns only the KB/embedding worker (roboco/services/optimal_brain/indexer_worker.py, landing on a parallel effort).allkeeps today's single-process behavior byte-for-byte, including running the indexer worker in-process, somake quickstartand the test suite are unaffected.AgentOrchestrator._instances/_waiting_records(the live fleet) are per-process dicts, sodocker/nginx.confgained adispatcherupstream and routes every fleet-touching path there instead of to the orchestrator (whose copy of that state is always empty in split-role mode):/api/orchestrator/*(status/spawn/stop/waiting-agent),/api/secretary/live/*and/api/prompter/live/*(the on-demand Secretary/Intake chat spawn + SSE relay, which also needs to run where the reconciliation/reaper loops that must see their containers run); everything else stays on the orchestrator.OptimalService's auto-index-on-startup + periodic RAG update, the FastAPI app's own RAG-index reconcile pass, andregister_default_handlers's fleet-side-effect event handlers (waiting-record resolution, auditor spawn) are role-gated the same way (all/indexerown the KB work;dispatcher/allown the fleet-side-effect handlers;apiowns neither); the WebSocket bridge is the mirror image, running only where/ws/actually terminates (api/all, neverdispatcher, which has no live WS connections to forward events to). The event-bus consumer name is noworchestrator-{role}-{hostname}(previouslyorchestrator-{host}:{port}, which collided across the api/dispatcher containers since both setROBOCO_HOST=0.0.0.0/ROBOCO_PORT=8000, putting two processes under one Redis consumer-group identity). The dispatcher's own task/notification queries go over HTTP to its OWN uvicorn:internal_api_urlresolves to loopback for the dispatcher role by construction, never through the orchestrator (api-role) service. All three compose files gaineddispatcherandindexerservices sharing the orchestrator's image, environment and volumes via YAML anchors,depends_ona new orchestrator healthcheck (python -c "import urllib.request; ..."against/health, since curl is purged from the runtime image), andROBOCO_ROLE=apiset on the orchestrator itself. -
CEO-gated
GET /api/dashboard/portfolio— the first per-project metrics view of the governed fleet (task1e0b4c7b, PR #966, the assembled portfolio branch; the route leaf was delivered by sibling taskb3960175, PR #965). The CEO governs 5 products but every prior dashboard endpoint was per-team or org-wide. The new endpoint returns onePortfolioProjectMetricsrow per project —project_id/project_slug/project_name,active_task_count,median_lead_time_hours,rework_rate,open_findings_count,monthly_budget_burn_usd— sorted most-active-first; a project with no activity still appears, zeroed. All aggregation is server-side (task1e0b4c7b'sMetricsService.get_portfolio_metrics): active = statuses minus{backlog, completed, cancelled}; median lead time + rework rate are read from ONE windowed completed-tasks pass (median, not mean — agent lead times are heavy-tailed); open findings are SQL-grouped from the revision-findings ledger rather than the cappedReviewFindingsRepository.list_open_findingsdashboard fetch; the calendar-month burn mirrorsTaskService.project_month_spend_usd's pricing (open spawn sessions priced live from token counts) in one batched query instead of N per-project queries. The route carries the dashboard router's FIRST explicit per-route role gate (non-CEO panel token →403), on top of the router-wide panel-token dependency. Integration tests cover the seeded aggregation math (month-boundary-safe) and the CEO-only 403. Seedocs/backend/api/dashboard-portfolio.md. -
Branch/parent-topology validation moves from the terminal verbs to
open_prand the re-parent path (taskdf9803b2). Previously a task's branch/PR-base mismatch against its real parent was only caught atcomplete/submit_root— the CEO_ONLY head-branch routing incell_pm_complete— after all work and review had already been spent (the incident class behind task 5612b225/PR #856, which bounced 3+ times over two days, plus 6866e888/6b9e19aa/7de89c6e).find_topology_issue(roboco/services/gateway/merge_chain.py), reusing the existingresolve_parent_branchrather than re-deriving a base from the branch-name string, detects two bad shapes: a parented task whose recorded PR base is the project's integration/head branch instead of its parent's own branch (parented_base_is_head), and a parentless root whose branch name encodes a nested hierarchy — the signature of a task detached from a coordination ancestor during a restructure, never a genuinely standalone root (parentless_in_coordination_tree). It is wired into two earlier catch points:open_pr's preflight (roboco/services/gateway/choreographer/_impl.py) refuses BEFOREcreate_prruns, naming the expected base branch and the concrete repair; and theparent_task_idre-parent write-through inTaskService.update()(roboco/services/task.py, via the newTaskService.recorded_pr_base()comparison input) records the same mismatch via atopology_issuemarker rather than blocking the PATCH, since a re-parent PATCH is often itself the fix for a prior mismatch. A follow-up round (QA finding F-1256daf6) closed a gap where a detach-style PATCH ({parent_task_id: null}) bypassed the recheck entirely — that field is popped out of the generic update loop and applied via a directsetattrin_apply_null_clears, never touchingTaskService.update()'s field-update loop.TaskService.recheck_topology_after_reparentis now public and called both fromupdate()'s existing set-path and from a new_recheck_topology_after_detachhelper inroboco/api/utils/tasks.py, invoked in the PATCH route right after_apply_null_clears. The existing terminal-verb CEO_ONLY checks are unchanged and remain the final gate.
Changed
-
AgentOrchestrator(roboco/runtime/orchestrator.py, formerly a single ~19,700-line class) is mechanically decomposed into per-family mixins under the newroboco/runtime/engines/package, with no behavior change. Every non-core method's body moved byte-for-byte verbatim into the mixin matching its family, dispatch (dispatch_routing,dispatch_claim,dispatch_prompts,dispatch_work,dispatch_breaker), spawn (spawn_config,spawn_launch,spawn_exit), the sweeper loop and the standalone startup-reconciliation/health/board-program loops (sweeps,reconcile), the rate-limit probe loop, the on-demand intake/secretary session subsystem (interactive_sessions), a small set of cross-family helpers (_shared), and the remaining default-off autonomy-engine/board-program poll loops (ci_watch,dep_update,env_sync,release_manager,strategy,telegram_poll,vault,video_render,x_mentions),AgentOrchestratornow composes all of them and keeps only its core lifecycle (__new__/__init__/start/stop) and module-level constants/functions, every one of which still resolves atroboco.runtime.orchestrator.<name>for any caller or test that patches it there. A new TYPE_CHECKING-onlyAgentOrchestratorSelfProtocol (roboco/runtime/engines/_types.py) gives every mixin's methods typed access to the attributes and methods sibling mixins provide, so mypy passes without touching a single moved method signature. -
The agent gateway (all 102 endpoints across
roboco/api/routes/v1/do.pyandflow_*.py) is now WAF-scanned for real, without pulling the docker mesh off the global whitelist or exposing it to rate limiting. The mesh (and only the mesh) previously skipped the signature WAF, rate limiting, and IP-ban checks entirely on every route via_INTERNAL_NETWORKS's membership in guard-core's globalwhitelist, which is EXCLUSIVE and also shared by the panel container, the SDK sidecar, nginx, and both bridges, so the mesh could not simply be removed from it.mesh_scanned(roboco/security.py) applies a route-levelrequire_ipallowlist to every gateway endpoint instead: guard-core'sIpSecurityCheckskips the global whitelist for a route withroute_config.ip_whitelistset and stampsis_whitelisted=False, soSuspiciousActivityCheck(the WAF) now actually runs against agent traffic there.bypass(["ip_ban", "rate_limit"])on the same routes keeps the mesh scanned but never rate-limited and never banned on the gateway -- a 429 on a verb is a wedge vector, not a security win, on an already HMAC-authenticated peer, and the route-levelrate_limitbypass was confirmed to also skip guard-core's GLOBAL 120/60 bucket for that route, not just its own per-route decorators. Every other route (panel,/api/optimal/*,/health, ...) stays exactly as exempt as before._WAF_FREETEXT_BODY_FIELDS(the exclusion set shielding agent-authored free text from the signature WAF) turned out to match key names at ANY depth, not top-level only as its comment claimed -- it skips the whole subtree under an excluded container key -- so 22 more field/container names (resolved_findings,note,commit,finding_id,evidence,items,reply_body,posts,process_change, ...) were added after auditing every request model reachable from the gateway, closing false positives reproduced oni_am_done/submit_up/submit_root'sresolved_findings[].note,waive_finding.note, and every Boardpropose_*_hunt/*_fill/*_fixes'sitems[].evidence. A parametrized regression test builds a request body from every v1 gateway endpoint's pydantic model (enumerated fromapp.routes, so a future endpoint is covered automatically), fills every free-text-shaped field with a shell/SQL/XSS/path-traversal/diff/URL corpus string, and asserts none of the 102 endpoints get WAF-blocked. Ban safety is separate and load-bearing:IPBanManager.ban_ipis patched at import time so a mesh-overlapping IP or CIDR range is blocked per request on a WAF hit but never recorded as banned, since a ban overrides the whitelist on every route and would otherwise wedge an agent container fleet-wide from a single false positive; the refusal now also catches a CIDR-form ban request that overlaps the mesh (ipaddress.overlaps), not just an exact address match._INTERNAL_NETWORKS,trusted_proxies, and the route-level allowlist now derive from one shared_MESH_NETWORKSconstant instead of three hand-typed copies.roboco/mcp/flow_server.pyanddo_server.pynow recognize a guard block's plain-text response (400/403/429, not JSON) and surface agateway_blockedenvelope telling the agent to move shell/SQL/HTML-looking content into a free-text verb field and retry, instead of the generictransport_errorthat read as the orchestrator being down. -
Adopted fastapi-guard 8.0.0 / guard-core 4.0.1. Deploy note: guard-core 4.0.0 hashes the endpoint path segment of its Redis rate-limit keys, so every in-flight rate-limit counter resets once on deploy (one-time, not a recurring reset). guard-core 4.0.1 makes
IPBanManager.ban_ipreturn a bool that every ban call site now reads (the WAF threshold ban and rate-limit auto-ban, the identity-violation escalation, and the behavioralusage_monitor(action="ban")action), soroboco/security.py's mesh-safe wrapper now propagates it (False on a mesh refusal) instead of returning None; without that, every ban RoboCo actually applies would have been logged and reported to telemetry astrackedrather thanbanned(the identity-escalation path that catches an external attacker at the exclusive whitelist, and everyusage_monitorroute left on its defaultaction="ban"), and on the WAF threshold path, which no client class reaches in the deployed topology but a test now exercises with an emptied whitelist, the banning response would have been 400 "Suspicious activity detected" instead of 403 "IP has been banned" while the IP was in fact banned. 4.0.1 also fixes a ReDoS validator stall (up to 40s per catastrophic custom pattern arriving through config, decorators, or dynamic rules, present since 3.13.0); RoboCo's only exposure isenable_dynamic_rules=True. -
Embedding/RAG-index writes now flow through a Redis stream to a dedicated indexer worker instead of running inline in whichever process happened to schedule them. Measured on the production NAS: ollama (qwen3-embedding:0.6b, CPU) runs at 200% CPU, one
/api/embedcall takes 40 to 55 seconds, and "Parallel embedding starting" fired roughly 4 times a minute inside the single backend process (journal entries, doc writes,i_am_idle), stealing CPU and event-loop time from the API/dispatcher rather than adding request latency, since indexing was already backgrounded. Producers (roboco/services/journal.py,roboco/services/task.py) now callenqueue_index_request(kind, payload)(newroboco/services/optimal_brain/indexer_worker.py) instead of awaitingOptimalService.index_journal_entry/index_documentation/unindex_journal_entry/record_learningdirectly; the request publishes onto theroboco:stream:indexRedis stream (reusingStreamEventBus, newEventType.INDEX_REQUESTED, category "index") and consumer groupindexerconsumes it viarun_indexer(), which also owns the periodic RAG re-index sweep (OptimalService.ensure_periodic_update_running, new);run_indexer()runs in the standaloneROBOCO_ROLE=indexerprocess, or as an in-process task underROBOCO_ROLE=all, both wired by the process-roles change landing alongside this one.StreamEventBusgained a genericMAX_DELIVERY_COUNT(5) reclaim cap, but delivery count alone is never enough:_recover_streamdead-letters a message only once it is BOTH past that cap AND idle at least as long as the reclaim loop's own idle threshold, so a handler still actively working a message on its 6th delivery is never dead-lettered out from under it. The backlog check also measures the real thing:_check_backlogreads theindexerconsumer group's lag fromXINFO GROUPS(falling back toXPENDING's pending count on Redis <7, wherelagis absent) instead ofXLEN, which also counts already-acked history that nothing everXTRIMs/XDELs, so a fully-drained stream no longer looks permanently over-backlog; pastindexer_max_backlog(new setting, default 500) lag, the oldest UNDELIVEREDjournal_entry/documentationrequests (read from strictly after the group'slast-delivered-id) are dead-lettered with reasonbacklog_shed;journal_unindexis never shed, since a skipped de-index leaves stale or private content searchable.enqueue_index_requestfalls back to running the index inline (a tracked background task, own dead-letter handling forjournal_entryvia the existingrag_index_failurestable) whenever the stream is disabled (indexer_stream_enabled, new setting, default true) or Redis is unreachable, resolving the OptimalService through an optionaloptimal_resolverthe producer passes in (journal.py's own lazily-cached instance) rather than always re-resolving the global singleton, so a single-processROBOCO_ROLE=alldeployment with no separate consumer never loses indexing. Producers await the enqueue call itself directly (a single Redis XADD, or the inline fallback's own backgrounded task) rather than scheduling the whole enqueue as a background task, so a journal entry's index request can no longer race behind a same-entry unindex request issued right after it.roboco/api/routes/optimal.py's explicit CEO-triggeredPOST /kb/index/docsandrag_index_failures.py's startup-onlyreclaim_dueretry path are both left callingOptimalServicedirectly, on request and off the request path respectively. ollama itself is a single CPU-bound server shared by indexing AND by query embeds (KB search, institutional memory), and the same night's incident also had KB searches timing out at 30 seconds because an indexing batch held every ollama concurrency slot;run_indexer(dedicated_embedder=True)(the standaloneindexerprocess only) seeds this process's shared-embedder singleton (set_shared_embedder, new, inroboco/services/optimal_brain/shared_embedder.py) with its ownOllamaEmbedderbuilt atindexer_embed_concurrency/indexer_embed_batch_size(new settings, default 1 / 8) instead of the shared default ofmax_concurrent=4/ batch 32 (OllamaEmbeddergained abatch_sizeconstructor field thatembed_chunks/aembed_chunksnow use), so a query embed in that process always finds a free slot; underROBOCO_ROLE=all,dedicated_embedderstays False and the shared embedder is never touched, since that same process also serves interactive query embeds through it and seeding it there would throttle every one of those too. -
Migrated the eight MCP servers to the mcp 2.x SDK. The 2026-08-31 lock refresh (GLM 5.3 upgrade, commit
5a6d25805) bumpedmcp1.29.0 to 2.1.1 under pyproject's unpinned"mcp"dependency, and mcp 2.x removesmcp.server.fastmcpoutright (FastMCPis nowMCPServerinmcp.server.mcpserver), which broke all 30 e2e-smoke lifecycle tests at import and produced 46 mypy errors across everyroboco/mcp/*server. Nothing was missing from the venv — the API moved. All servers (flow,do,optimal,docs,search,intake,secretary,git_readonly) and the two mcp unit tests now importMCPServer;json_response=Truewas dropped from the three factory constructors (docs,optimal,search) because mcp 2.x moves transport params off the constructor torun()/sse_app()/streamable_http_app(), and it was inert for these stdio-only servers anyway.call_tool()'s direct-call return changed from the 1.x(content_blocks, structured_dict)pair to aCallToolResult, sotest_optimal_role_scopereads.structured_contentinstead of unpacking the tuple.submit_directive'spayloadannotation gained| None = Noneto match its own runtime tolerance (payload or {}) — the mismatch was always there but mcp 1.x's untyped@mcp.tool()decorator hid it from mypy; mcp 2.x types the decorator, surfacing it. mcp 2.x also stops readingMCP_*env vars andpydantic-settingsfiles (none were relied on) and swaps its HTTP stack tohttpx2(lock-level only; no code imports httpx via mcp). -
Split-role deployment hardening.
- "split the backend into api, dispatcher, and indexer processes"
- "split the orchestrator into engine mixins"
- "never pull the dispatcher and indexer images"
- "pin two mixin attribute types mypy inferred as non-Optional"
- "start nginx after the dispatcher it proxies to"
- "resolve the dispatcher per request so nginx starts without it"
- "keep the dispatcher's loopback API URL out of agent MCP configs"
- "health-check the indexer role and wait for both split roles at bootstrap"
- "run the boot-time RAG reconcile in the indexer role"
- "drop the indexer healthcheck, the worker role serves no HTTP"
-
Claiming and dispatch fairness.
- "commit the claim before branch work and keep row locks off git I/O"
- "one in-flight claim per agent and release parked claims"
- "hand a developer its bounced work before new pending work"
- "hand out work oldest root first"
- "hand out work oldest first"
- "assign the PR reviewer at gate entry and stop overflow double-dispatch"
- "clear PM ownership at PR-gate entry; oldest-first offer order; skip blocked in give_me_work"
- "remove dead blocked-skip from give_me_work; correct stale submit_up docstring"
- "wait for a parent's branch instead of auto-blocking the child"
- "log the provisioning wait at debug and cover status=claimed"
- "hand root-run install outputs back to the agent user"
- "stop holding task row locks across the post-merge chown walk"
- "keep the MCP gateway reachable under spawn bursts and stale clones"
- "cancel_leaf gives PMs a path to close a zero-diff leaf"
- "move embedding and indexing to an indexer worker fed by a Redis stream"
- "ship py-spy, pg_stat_statements, and HNSW indexes for the KB"
- "record_learning best-effort skip on embedder timeout"
-
Uptime-aware staleness.
- "task_time verb reports uptime-adjusted task times"
- "discount fleet downtime in every staleness check"
- "derive fleet active time from dispatcher heartbeats"
-
Gateway and transport fixes.
- "WAF-scan agent gateway traffic from the docker mesh"
- "gate /ws handshakes with guard, arm rate-limit auto-ban, drop guard-agent"
- "guard_ws fails open when SecurityMiddleware never mounted"
- "send empty JSON body on bodyless POSTs tripping content_type_filter (415)"
- "park Anthropic agents on an expired host login and cap crash retries per task"
- "Sonnet 5 promo runs through 2026-09-13, not 08-31"
Fixed
-
The boot-time RAG index reconcile ran nowhere in a split-role deploy (2026-09-06). The FastAPI lifespan scheduled it for
ROBOCO_ROLEallandindexer, but the indexer role never runs that lifespan (roboco/bootstrap.pyruns onlyrun_indexer), so after the split no process re-indexed unindexed approved playbooks, reclaimed dead-lettered index writes, or backfilled zero-chunk journals/learnings at startup. The three passes now live inroboco/services/optimal_brain/indexer_worker.pyasreconcile_rag_indexes(optimal):run_indexerschedules them in the background under the indexer role (and cancels a still-running pass on stop), while the lifespan schedules them only forall. -
The indexer service's HTTP healthcheck could never pass, since the indexer role never runs uvicorn (2026-09-06). Commit
bfdd3c44added the orchestrator's/healthprobe to the indexer service in both compose files on the claim that "every role runs uvicorn, only the lifespan work is role-gated" - false forindexer:roboco/bootstrap.py's_run_indexer_roleonly awaitsrun_indexer(dedicated_embedder=True)and returns before_run_api_serveris ever created. On the NASroboco-indexersatUp 2 hours (unhealthy)(FailingStreak 811, connection refused) while its logs showed normal embedding work; nothingdepends_onthe indexer with a health condition andscripts/bootstrap.shwaits on running state, not health, so the bad probe was misleading, never blocking. Thehealthcheck:block is dropped from the indexer service in both compose files, replaced with a comment naming why one can never apply (no HTTP endpoint; process death is covered byrestart: unless-stopped). -
A loopback
ROBOCO_API_URLpinned on the dispatcher leaked into every spawned agent container's MCP config, breaking every gateway verb fleet-wide for hours (2026-09-06)._generate_mcp_configwritessettings.api_urlstraight into every agent'sROBOCO_API_URL/ROBOCO_ORCHESTRATOR_URL, but both compose files pinned the dispatcher'sROBOCO_API_URLtohttp://127.0.0.1:8000so its owninternal_api_url(used for the dispatcher's task/notification queries) would resolve to its own uvicorn -- the same env var doubled as the agent-facing URL handed to every spawned container, so every MCP server inside every agent container was told the API lives at127.0.0.1:8000, which is nothing inside that container, and every gateway verb crashed withhttpx.ConnectError.internal_api_url(roboco/config.py) now derives the loopback URL fromrole == "dispatcher"directly instead of fromapi_url, so both compose files drop theROBOCO_API_URL: http://127.0.0.1:8000line from the dispatcher service entirely --api_urlis now only ever the agent-facing URL handed to spawned containers' MCP servers, never the dispatcher's own address. -
i_will_work_on/i_will_planstart the task after the branch is provisioned, not before. The claim durability boundary moved branch creation out of the verb runner's composed savepoint, butstartstayed inside it, and theclaimed -> in_progressbranch gate rejected every real (branchless) claim with "Cannot start work: no branch assigned" (the E2E smoke went red on slave; the real-DB lifecycle test never saw it because its fixture pre-seeds a branch). The intents now compose(claim, set_plan); the choreographer provisions the claim, then callsstart, so the durable ladder is claim fields, then branch, then in_progress. A branchless real-DB test and a call-order unit test pin it. -
The developer/PM claim (
POST /api/tasks/{id}/claim→TaskService.claim_task_for_agent→claim, and the verb runner's composedi_will_work_on/i_will_plan) now commits the claim fields before branch auto-creation, commits AGAIN right after the branch lands, and never runs branch creation, upstream-base inherit, or work-session setup inside a savepoint on either path. The prior fix (PR #951's boundary, ported onto this claim) committed the claim fields early but left two gaps a critic caught on real Postgres: a failure AFTER a successful branch create but before the caller's trailing commit (_create_work_session_if_needed,_inherit_upstream_base,session.refresh) rolled back the uncommitted branch_name while the earlier CLAIMED commit stood, landingstatus=CLAIMED, branch_name=NULLwith the branch already pushed to origin (non-idempotent on retry); and the verb runner's_do_claimran the WHOLE claim (including_ensure_branch_for_task's network git) inside_run_composed_actions'sbegin_nested()savepoint, holding the task row'sFOR UPDATElock and the agent row's implicit update-lock for up to the verb timeout (120-900s) oni_will_work_on/i_will_planand every MegaTask root-subtask / self-heal claim that routes through them.TaskService._finalize_claimis now split:_apply_claim_fields(status transition plus claimant fields, commits durably unless nested) and_provision_claim(branch creation, then a SECOND durable commit, then upstream-base inherit plus work session plus proactive context, never nested on either path).claim()takesprovision: bool = True; the verb runner's_do_claimcallsclaim(..., provision=False)inside its savepoint (fields only), andChoreographer._claim_plan_start_runcalls the newTaskService.provision_claim(task_id, agent_id)once that savepoint closes and before building the success envelope;provision_claimitself commits the savepoint's result durably first (so a choreographer built against a mockedTaskService, every gateway unit test, never needs to mock a raw session call it did not already have to), then re-reads the task/agent fresh and runs_provision_claim. A provisioning failure reverts the claim/plan/start fields durably (the same revert-and-commit_provision_claimalways ran on a branch failure) and surfaces the same "verb runner failed" rejection a savepoint failure already returns. The pre-claim field snapshot_provision_claim's revert needs crosses this boundary viaTaskService._pending_claim_snapshots, a per-instance (per-request) dict keyed by task id, populated by_apply_claim_fieldswhen nested and popped byprovision_claim. That snapshot also capturesplanandstarted_at: the composedclaim/set_plan/startsequence commits both before_provision_claimever runs, and without them in the snapshot a branch-failure revert put the task back to PENDING while leaving the failed attempt's plan and started_at in place, corrupting cycle-time and showing a plan on a pending task. Net failure semantics, by point of failure: before_apply_claim_fieldsflushes, nothing happened, safe to retry; between that flush and its commit, durable only if non-nested (nested rolls back with the enclosing savepoint on any later raise); between the claim commit and a branch-creation failure, reverted to the pre-claim snapshot durably (fields, plan, and started_at all restored), claim is retryable; between a successful branch create and its own follow-up commit, no failure point exists there; after the branch commit but before work-session/upstream-base/refresh completes,CLAIMEDplusbranch_namestand, NOT reverted. Two different things retry-heal from there:_ensure_branch_for_tasktrust-but-verifies a pre-setbranch_name, so a laterprovision_claimre-run only redoes the failed remainder; a missing work session specifically heals on the agent's own next verb call instead, since_resume_from_claimed(thei_will_work_on/i_will_planre-entry for a task alreadyCLAIMEDand assigned to the calling agent) callsTaskService.ensure_work_sessionright afterstart(), a no-op once the session exists, so it is safe on every re-entry.claim_pr_reviewwas never on this boundary: its verb body dispatchesTaskService.pr_review_claimdirectly, bypassing the verb runner and_finalize_claimentirely, so an earlier draft of this note naming it here was wrong. -
The Cost-Tiered button in Settings → AI Providers stopped promising a seed the backend no longer performs (PR #983). The ModeButton description, the confirm dialog and the success toast in
panel/src/components/settings/ai-routing-card.tsxall claimed "Seed developer:low → Haiku (see below)", but_COST_TIERED_SEED(roboco/services/llm.py) has been an empty tuple since the 2026-07-24 retirement ("haiku retired from delivery-lifecycle roles", #680) —_apply_cost_tieredseeds nothing, so every click toasted an action that did not happen. The copy now states what actually happens: the description reads "Pin cheaper models per role and complexity in the Complexity overrides table below. No rows are seeded automatically.", the confirm dialog says the same, and the success toast derives its message from theapplyModeresponse snapshot — "Cost-tiered mode applied. No rows seeded." orN complexity override row(s) active., countingROLE-scope rows whosescope_valuecontains a colon (the compoundrole:complexityconvention). The sweep also fixed the same button'slabelHint, which still advertised a re-runnable one-time additive seed, and replaced the hardcodedactive={false}withactive={currentMode === 'cost_tiered'}so the mode's live state highlights like every other mode button (thoughderive_modedoes not returncost_tieredtoday, so the badge stays dark in practice; the mode stays wired for a future above-floor re-seed).roboco/services/llm.pyis untouched — the seed retirement is a deliberate product decision, not a regression to re-fix. Interaction tests inai-routing-card.test.tsxpin the corrected copy (the card renders no "Haiku" anywhere), the response-derived toast, a zero-rows case, an apply-failure error toast, and the active-state highlight. Seedocs/map/panel.mdfor the companion note. -
The dispatcher no longer stacks a second claim onto the same not-yet-active agent, and the stale-claim reaper now releases a claim an agent parked while working elsewhere. Live production, 2026-09-05: at 22:07:16 to 22:08:16 UTC the dispatcher routed three pending backend tasks (
c15af9ff,c0d0f4f4,60c3feb2) to the same idlebe-dev-1within 60 seconds; each claim runs_finalize_claim(setsagent.current_task_id, then creates the task's branch, 53s or more on that host), so the second claim blocked 42s on the agents-row lock and the third queued behind it, both eventually dying on the 60slock_timeoutwhile the dispatcher's own 30s client gave up and re-routed, growing the pile; separately,be-dev-1ended up holding fourclaimedtasks (7daca5ffclaimed 21:17,5d95b38521:41,c15af9ff22:07,c0d0f4f422:07) whose heartbeat never advanced past the claim for up to an hour while its container worked a different task andbe-dev-2idled, because the reaper's live-container skip only checks whether the agent has any live container, not whether it is working THIS task. Fix: a new in-memory registry,AgentOrchestrator._claims_in_flight(agent slug mapped to a task_id and monotonic deadline), is set the moment a claim is issued and cleared once it has a definite outcome (success, after the follow-up spawn, or an immediate server rejection); a client-side timeout or exception leaves the entry parked untildispatch_claim_inflight_ttl_seconds(new setting, default 600) elapses, since the server may still be finishing the claim._select_agent_for_cell, the single chokepoint for dev/qa/doc/pm/pr_reviewer selection, now treats an in-flight agent exactly like an active one and returns None (nevercandidates[0]) when every candidate is busy or in flight, so the task waits for the next scan instead of stacking a claim;_resolve_routing_target's dev branch now distinguishes that wait from a genuinely unroutable team, since falling back to main-pm for a code task hits the MAIN_PM_NO_CODE claim-reject loop. The reaper's new_maybe_release_parked_claimreleases aclaimedrow whose heartbeat never advanced past its own claim, stale paststale_claim_reap_secondsof fleet-active time, once the same agent's other claimed or in_progress row proves it is alive and busy elsewhere; it never fires on a genuinely advancing heartbeat, an agent's only claim (left to the existing dead-run logic), or while_claims_in_flightstill holds an unexpired entry for that agent. -
A child dev's spawn no longer auto-blocks on its parent's branch the instant that parent's branch creation runs long, since
_apply_claim_fieldsnow commits the claim (status,assigned_to,claimed_at) before_provision_claimcreates the branch and commits again, so a slow git network op under load can leave a healthy, mid-claim parent branchless for minutes rather than a sub-second gap._check_parent_branch_ready(roboco/runtime/engines/dispatch_claim.py) still fast-retries a mid-claim parent (statusclaimed/in_progress withassigned_toset) 3x250ms for the sub-second commit gap, but once that retry is exhausted it now compares the parent'sclaimed_atage, measured in fleet-active time via_active_ageso a paused fleet never eats into the budget, against the newparent_branch_provisioning_grace_secondssetting (default 900, matchingflow_verb_slow_timeout_seconds, the same budget a slow claim/branch verb already gets server-side); inside the grace the child is skipped for this dispatch tick with a debug log instead of auto-blocked, and the next tick retries. A parent past the grace, or one that was never mid-claim to begin with (pending/unassigned, or terminal - no branch is ever coming), auto-blocks exactly as before. -
Post-merge panel hotfixes (Fixed).
- "dedupe the ShieldCheck import left by back-to-back root merges"
- "task detail grew to 10 tabs after the root merges; widen the grid and the tab-count test"