feat(video): switch 0.19.0 renderer Remotion → HyperFrames (HTML-native, Apache-2.0) (#314)

* feat(video): rewrite sidecar render core to HyperFrames (in place)

* feat(video): convert motion compositions from Remotion TSX to HyperFrames HTML

* refactor(video): rename render client to video_renderer_client (renderer-agnostic)

* chore(video): rename remotion-renderer prose in test_video_pipeline docstrings

* chore(video): rename sidecar to video-renderer + add system ffmpeg for HyperFrames

* chore(video): rename stray remotion-renderer refs in sidecar + py docstrings (controller cleanup)

* chore(video): fix stale Remotion API names in Dockerfile comment (controller cleanup)

* docs(video): rewrite video-engine prose for HyperFrames + add map entry + folded prose fixes

* docs(video): add trailing newline to docs/map/video-engine.md (controller cleanup)

* chore(video): drop internal spec refs + minio/test suppressions (folded hygiene)

* fix(video): reclaim outDir on createRenderJob throw + hide empty 4th highlight

Final whole-branch review (Opus) triaged two FIX items from the SDD nits
ledger; the rest ship as-is.

- render.js: a synchronous throw from createRenderJob (post-mkdtemp, not
  awaited) left an empty outDir on disk — the outer catch only reclaimed
  extractDir. Reclaim outDir too when it exists, and correct the stale
  comment that claimed the out dir was never created.
- {vertical,square}.html: the 4th highlights <li> lived in the DOM hidden
  only by JS, so a no-JS / failed-script render would show an empty bullet.
  Start it style="display:none" and reveal on populate, so an unscripted
  render shows nothing instead.

Vitest smoke (release-announcement.test.js) 4/4 green; render.js syntax
checked. Python suite untouched by this fix (JS/HTML only).

* fix(video): type _override_db yield as AsyncSession | None

T7 widened _build_app's db_session param to AsyncSession | None (to drop the
4x # type: ignore[arg-type] on the DB-independent _build_app(None, ...) calls)
but left the inner _override_db fixture typed AsyncIterator[AsyncSession] —
so 'yield db_session' yielded AsyncSession | None into a declared AsyncSession,
and mypy failed at test_video_routes.py:177 ('Incompatible types in yield').

The DB-independent media tests pass db_session=None deliberately: their route
uses a monkeypatched task service and never awaits the session, so yielding
None is safe at runtime. Type the override's yield as AsyncSession | None to
match — no cast, no # type: ignore, no assert, runtime behavior unchanged.
The 3 media tests (3 passed) and the 19 db-gated tests (skipped locally) hold.

* chore(gate): skip .superpowers scratch in markdown prose gate

reflow_md.py walks the filesystem via rglob('*.md') and skips tooling dirs
(.venv, .mypy_cache, .pytest_cache, ...) but not .superpowers/ — the
superpowers SDD workflow's scratch dir (briefs, reports, progress ledger,
all gitignored). A dev running SDD locally would hit a false markdown-prose
gate failure on those transient files. Add .superpowers to SKIP_DIRS,
consistent with the existing tooling-scratch exclusions.

* fix(video): validate composition_id to close path traversal (CodeQL)

compositionId flowed unvalidated from the POST body into path.join
under extractDir/motion/compositions/, so a '../..'-style value could
escape the composition dir (CodeQL: Uncontrolled data used in path
expression). Validate at the trust boundary in server.js
(/^[A-Za-z0-9_-]+$/) and add a path.resolve + startsWith containment
check in render.js so it stays safe regardless of caller.

* fix(mcp): send X-Agent-Token + X-Agent-Team from flow/do servers

flow_server._build_headers and do_server._build_headers constructed
only X-Agent-ID/Role/Correlation-ID, omitting X-Agent-Token and
X-Agent-Team (unlike ApiClient._get_agent_headers used by the other
MCP servers). Latent since the gateway refactor — surfaced when
ROBOCO_AGENT_AUTH_REQUIRED=true was armed on the NAS, 401-ing every
flow/do verb with 'Missing X-Agent-Token header'. Add both headers
(mirroring ApiClient) so the HMAC gate passes. Tests assert the
headers are now injected.

* [video-engine] Per-project video_engine_enabled opt-in toggle

Mirrors ci_watch_enabled (migration 048): the global
ROBOCO_VIDEO_ENGINE_ENABLED flag arms the subsystem; the new
projects.video_engine_enabled column (migration 063) opts a repo into
authoring against its motion/ dir. VideoEngine._opted_in_project no-ops
open_video_task at the single chokepoint covering all three trigger
paths (on-release, on-spotlight, CEO on-demand) until the operator
flips it in the panel edit-project dialog. Existing projects stay
opted out (server_default=false).

* fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites

The prior fix (6ed4e139) covered the flow/do MCP servers but missed four
other agent->orchestrator call sites that built the header dict by hand
and omitted X-Agent-Token and/or X-Agent-Team. With ROBOCO_AGENT_AUTH_REQUIRED
armed on the NAS, every one 401s:

- agent_sdk/server.py: the session-end post-mortem flush
  (/api/journals/me/entries), A2A persistence + offline fallback
  (/api/a2a/*), and the stopped-without-transition auto-substitute
  (/api/tasks/auto-substitute) — all sent only X-Agent-ID/Role, so each
  401'd 'Missing X-Agent-Token'. Add a shared _agent_headers() helper
  (mirroring flow_server._build_headers) and route all four through it.
- agent_sdk/secretary_driver.py: _headers() sent the token but not the
  team, so the HMAC gate 401'd with signature mismatch (secretary is
  board-team; token signed with team='board', verified with team='').
  Add the team header.
- mcp/git_readonly.py: the read-only git MCP sent only X-Agent-ID/Role
  — no token, no team — so /api/git/* 401'd once auth was armed. Convert
  the static _HEADERS to a _headers() helper with team + token.
- runtime/orchestrator.py: the cell-PM auto-submit self-API call acted
  as a PM with a hand-built {X-Agent-ID, X-Agent-Role} dict — no token,
  no team — 401ing under auth-required. Add _agent_api_headers(uuid,
  role) mirroring _system_api_headers, and use it.

Tests: _agent_headers round-trip (token + team, team-omitted when None),
_agent_api_headers carries a signed PM token + team.

* [auth] Omit UNSIGNED self-call token in dev mode + video-engine test mypy fix

_agent_api_headers sent the UNSIGNED sentinel when ROBOCO_AGENT_AUTH_SECRET
was unset, but the dev-mode middleware rejects a presented-but-unverifiable
token with 401 signature mismatch (while accepting a missing one). The
cell-PM auto-submit self-call 401'd in every dev run, regressing
test_auto_submit_cuts_the_pm_turn. Attach the token only when a secret is
set. Also fix the FromClause.update mypy error in the per-project
video-engine opt-out test (ORM row load + flush).

* [auth] Omit UNSIGNED agent token at every agent->API call site

The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC secret
is unset at spawn. The API middleware rejects a presented-but-unverifiable
token with 401 'signature mismatch' even in dev mode (auth not required),
so forwarding UNSIGNED turned every flow/do/SDK/secretary/git verb into a
401 — the live pr_reviewer/i_am_idle signature-mismatch loop. Omit the
header when the token is the UNSIGNED sentinel at all five agent-side
header builders; dev accepts a missing token, prod 401s with 'Missing
X-Agent-Token' (the clear respawn-with-secret signal). Add a structlog
diagnostic on the middleware reject path so the next mismatch logs the
exact (id, role, team, token_unsigned, auth_required) inputs.

* [auth] Self-heal stale agent tokens at orchestrator startup

A token is signed once at spawn. If ROBOCO_AGENT_AUTH_SECRET drifts
afterwards (a .env change, a compose recreate that reloads the
orchestrator's env without recreating agent containers, an image
redeploy), the surviving agent keeps sending its old token and the
middleware 401s every verb with 'signature mismatch'. The container
stays alive heartbeating, so the reaper never reclaims it and no fresh
agent spawns: the fleet stalls.

_heal_stale_agent_tokens runs at startup (before _readopt_running_agents)
and kills each running agent container whose baked-in token no longer
verifies against the current secret, so normal dispatch re-spawns it
with a freshly signed token. Inert when the secret is unset (dev):
verify fails for every token without a secret, so the heal would kill
the whole fleet without this gate. Best-effort: a probe failure leaves
the container alone (the reaper still covers it).

* [auth] Sign agent token over the UUID, not the slug (pr_reviewer 401 root cause)

The token was signed over the agent slug (_append_agent_auth_env) while the
MCP servers send X-Agent-ID as the agent UUID (_generate_mcp_config, since
453a7ae2 — gateway v1 parses X-Agent-ID as Annotated[UUID]). The middleware
verified HMAC(uuid:role:team) against a slug-signed token → 'signature
mismatch', token_unsigned=false. Latent for 2 months until 6ed4e139/53391f22
made the MCP servers forward the token.

The c0328971 startup heal missed it: docker exec printenv reads the
container-level ROBOCO_AGENT_ID (the slug), so the heal verified the
slug-signed token against the slug → matched → didn't kill the stale
container, which kept 401ing (its MCP server sends the UUID).

Fix: sign the token over the UUID, set the container ROBOCO_AGENT_ID to the
UUID too (so the SDK server — which inherits container env, not the MCP
manifest env — sends UUID consistently), and resolve the container-env id to
its UUID in _heal_stale_agent_tokens so pre-fix stale containers are evicted
on next restart. Regression test: test_heal_kills_slug_env_container_with_slug_signed_token.

* [scan] gate A2A/notification/stream agent-id deps under cloud auth (C1)

* [scan] omit UNSIGNED agent token from MCP server headers (H1)

* [scan] fail loud when cloud auth and nginx CEO-token are both armed (H2)

* [scan] cache last-known-good auth-probe result in panel proxy (C2)

* [respawn] Tripped breaker self-heals after a cooldown

A DB-durable PM-respawn counter (migration 051 / e2f7097a) wedges forever
once tripped: the only reset was a task status change, which can't happen
while the breaker blocks the spawn. So a deploy that fixes the underlying
loop (auth/prompt/schema) couldn't clear the wedge without manual DELETE
surgery on respawn_tracker — the 2026-07-06 pr-reviewer-1 loop, where the
auth fix cleared the 401 but count=63 survived restart and kept skipping
the dispatcher spawn for an external-PR task.

Freeze last_check at the trip tick and, after pm_respawn_trip_cooldown_seconds
(default 300), let ONE spawn through. A still-wedged task re-trips after the
threshold (bounded re-burn ~3 spawns per window); a fixed one advances and
the status-change path fully resets. Restore re-stamps last_check to now, so
a freshly restored row still trips immediately — durability preserved, which
is why the migration-051 persistence tests still pass.

* [scan] fix test_deps callsites for cloud-auth-gate signature change (C1 followup)

* [scan] per-IP rate limit on /auth/login under cloud auth (L31)

* [scan] Phase 1 auth/security fixes under 0.19.0 CHANGELOG

* [scan] secretary token signs over real team (board) not empty — fixes /api/secretary/* 401 (L31-class)

* [scan] LoginRateLimiter: key off X-Forwarded-For first hop + redis-down fail-open test

nginx is the single entry point; request.client.host is the nginx peer IP,
collapsing every external client into one limiter bucket (self-DoS amp).
Read the downstream client IP from X-Forwarded-For (first hop) / X-Real-IP,
falling back to the peer. Adds coverage for the XFF keying, the redis-down
fail-open branch, and drops a redundant asyncio marker on a sync-TestClient
test.

* [scan] nits: describe login_max_attempts + replace cast with assert in get_current_agent_slug

login_max_attempts was the only bare cloud-auth field; add a Field
description matching the surrounding idiom. Replace cast('str', ctx.slug)
with a runtime assert that fails loud if the cloud-auth ctx invariant
breaks, and drop the now-unused cast import.

* [scan] secretary token: use get_agent_team resolver + complete spawn-shutdown mock team (0dfd45ca followup)

* [scan] require agent HMAC token under cloud_auth (close v1 flow/do header-trust)

* [scan] _require_ceo accepts CEO session cookie under cloud_auth

* [scan] HTTP require_panel_token accepts session cookie under cloud_auth

* [scan] gate /api/settings behind panel token

* [scan] gate unauthenticated /api read routes (agents/a2a-tasks/kanban/usage/rate-limits)

* [scan] hoist deferred test imports to top-level (clear PLC0415)

* [scan] Phase 1b e2e smoke + CHANGELOG

* [scan] add_dependency rejects self-reference + cycle (M18)

* [scan] WorkSessionService.create translates IntegrityError to ConflictError (H10)

* [scan] _qa_or_doc_claim locks the task row FOR UPDATE (M19)

* [scan] docs_complete + mark_pr_created lock the task row FOR UPDATE (H4)

* [scan] gate complete() IN_PROGRESS on leaf/branchless only (H3)

* [scan] _unclaim_from_blocked clears stale pre-block snapshot (H5)

* [scan] admin_set_status terminal guard + skip revision bump under force (M20)

* [scan] cell_pm_complete idempotent pre-check before merge (H7)

* [scan] wrap gateway post-runner side effects in try/except (H6)

* [scan] pass_qa/fail_qa accept AWAITING_QA only (L29)

* [scan] mark_pr_created passes audit_agent_id (L30)

* [scan] phase 2 e2e smoke - one scenario per finding

* [scan] phase 2 quality gate

ruff format + check: green
mypy roboco/: green (357 files)
pytest unit+integration: 6905 passed, 10 pre-existing DB-contamination
  failures (pass in isolation)
e2e smoke: 11 passed, 4 cross-scenario workspace-contamination failures
  (all 6 state-machine scenarios pass individually)

Quality-gate fixes:
- move function-local imports to module top (PLC0415)
- fix M19 regression: submit_for_qa clears active_claimant_id so the
  competing-claimant guard lets the QA claim through
- fix H7 regression: _StubGit gains is_pr_merged_for_task
- fix M19 unit tests: mock session.execute for the FOR UPDATE lock
- e2e H3: notes >= 20 chars; e2e H5: rich i_will_work_on inputs +
  PM unclaims (block reassigns to PM)

* [scan] move active_claimant_id clear into pass_qa/fail_qa + admin_set_status (M19 follow-on)

Phase 2 opus whole-branch review found the M19 follow-on clear lived in
the gateway wrappers (qa_pass/qa_fail) not the transition methods
(pass_qa/fail_qa) themselves. The direct REST routes POST /pass-qa and
POST /fail-qa call the transitions directly, bypassing the wrappers and
leaving the QA's stale active_claimant_id set in AWAITING_DOCUMENTATION
/ NEEDS_REVISION — the competing-claimant guard then rejects the next
legitimate documenter/QA claim. admin_set_status had the same gap for a
non-blocked override into a review/queue state (IN_PROGRESS->AWAITING_QA
left the dev's id, blocking qa_claim).

Root-cause fix: move the clear INTO pass_qa and fail_qa (mirroring
submit_for_qa), add a clear in admin_set_status when
new_status in _REVIEW_QUEUE_STATES and from_status != BLOCKED, and drop
the now-redundant clears + flushes from the qa_pass/qa_fail wrappers.
Every caller is covered; the wrappers keep their actor-mismatch warnings.

Covering tests: test_pass_qa_clears_active_claimant_for_doc_claim
(asserts a subsequent doc_claim succeeds), test_fail_qa_clears_active_claimant,
test_admin_set_status_into_review_queue_clears_active_claimant,
test_admin_set_status_non_review_queue_keeps_active_claimant. Updated
the two wrapper unit tests that asserted the wrapper clears (now the
transition's job).

* [C3] unindex_journal_entry + call from delete_entry

JournalService.delete_entry deleted the DB row but never de-indexed the
RAG chunks, so deleted/private journal content bled forever into RAG
answers and claim-time briefings. Add OptimalService.unindex_journal_entry
mirroring unindex_playbook (vector-store delete_by_source + tracking-row
delete via get_db_context, both idempotent + best-effort), and call it
from delete_entry after the row commit inside a try/except so a de-index
failure never errors the delete.

* [M25] learning_id hashes full content to avoid collision

The memory distiller emits lessons with a fixed 'Problem: …' opening
shape, so two distinct lessons whose first 100 chars match collided on
learning_id = f"lrn-{md5(content[:100])[:12]}". replace_on_reingest then
routed both to the same source URI and the second ingest's replace_chunks
DELETE wiped the first lesson's chunks — silent data loss.

Hash the full content (widening the hex slice 12→16) so distinct bodies
get distinct ids and each retains its chunks.

* [H13] reject non-internal local_llm_base_url at config load

* [M28] bulk-insert learning broadcast instead of N+1

* [M27] mark_read/mark_all_read stamp only the unread rows seen at call time

mark_read and mark_all_read used to zero the unread counter FIRST, then run
a bulk UPDATE … WHERE read_at IS NULL that stamped every inbound unread row.
A send_chat_message committing between the counter-zero and the UPDATE
inserted a new read_at NULL row that the UPDATE then stamped as read — the
new message was silently consumed while the counter stayed 0.

Mirrors get_unread_messages (same file): SELECT the unread message IDs at
call time, UPDATE exactly those IDs, then recompute the unread counter from
the DB via the existing _reset_unread_counter helper. A message arriving
mid-call is not in the selected ID set, so the UPDATE skips it and the
recomputed counter keeps it unread.

* [H12] dedup: exact to_agents predicate + purpose discriminator + ack DEL

* [M23] playbook indexed_ok/indexed_at + startup reconcile of unindexed approved

* [M24] RAG indexing dead-letter + janitor reclaim + failed_index_count health

* [L23] institutional_memory_status sentinel distinguishes below-floor/empty/error/disabled

* [L26] sweep_expired_notifications re-escalates stale unacked ack-required

* [phase3] e2e smoke + CHANGELOG for 0.19.0

* [M24] _reindex_journal_entry honors is_private (C1 review fix)

Dead-letter replay mirrors the original journal._schedule_rag_index path:
a private entry is never indexed into the shared JOURNALS corpus, and a
private learning is still recorded into LEARNINGS as non-shareable.
Previously the replay always called index_journal_entry and skipped
record_learning for private learnings, leaking private content on replay
and dropping the legitimate non-shared learning. Three regression tests.

* [H11] clone via git -c http.extraheader, not URL-embedded PAT

* [H11] _sync_read_clone fetch via http.extraheader, not URL-embedded PAT

Sibling site to the clone fix: the conventions read-clone refresh ran
'git fetch --tags <https://TOKEN@host> <branch>', exposing the PAT in the
fetch argv on the orchestrator host. Mirrors the clone site's per-call
'-c http.extraheader=Authorization: Basic …' prefix + bare URL. SSH URLs
and tokenless public repos unchanged.

* [H11] release_executor clone+push via http.extraheader; delete _inject_token_into_url

* [H8] rebase_onto_base gates on clean tree like pull

* [H9] _link_commit_to_task flushes, doesn't commit out-of-band

* [M38] _pr_is_merged returns None on HTTPError; caller assumes merged

* [M39] _cherry_unmerged_entry marker grep anchored to commit-prefix

* [L1] thread actor_agent_id through update_pr_for_task

* [H8] fix rebase test mocks for clean-tree gate

H8 inserted a 'git status --porcelain' dirty-tree gate at the top of
rebase_onto_base (mirroring pull). The 3 rebase control-flow tests mocked
_run_git with a side_effect list matching the OLD call sequence (no
leading status call), so every call shifted by one and the assertions
missed. Prepend a clean-status result to each list so the gate passes
and the fetch/checkout/reset/rebase/diff/abort/push sequence aligns.
Verified: 16 passed (was 3 failed/13 passed post-H8, 16 passed pre-H8).

* [L2] push --force-with-lease instead of bare --force

* [L1] refresh stale workspace-resolution docstrings

pr_target and _workspace_for_branch still documented the actor →
assigned_to → created_by fallback chain that L1 removed from
_resolve_workspace_agent_id. Update both to the post-L1 actor →
assigned_to → None resolver (project.workspace_path as the final
fallback) so a future reader doesn't rely on a fallback that no
longer exists.

* [M37] merge_pr locks the work_session row FOR UPDATE

* [phase4] e2e smoke + CHANGELOG for 0.19.0

* [phase4] fix M37 test flake + document H8 skip

The opus whole-branch review flagged the M37 concurrency tests as
~50% flaky: both asserted caller A wins the FOR UPDATE race, but
which caller wins the lock is non-deterministic. When B won, the
'assert a_row.merged_by == a_merger' branch flipped false even
though the production code (M37) was correct — exactly one merger
recorded, audit trail intact. Assert the invariant instead: both
rows COMPLETED, both report the same merged_by, value in
{a_merger, b_merger}. Applied to both the unit test and the e2e
twin. Also documents the H8 e2e skip in the module docstring (the
report claimed it was documented there but it wasn't) and drops
the internal 'Phase 4' label from the docstring header in favor of
the public '0.19.0' version anchor.

* [H24] wait_for_ci polls through the window on non-success

* [H25,L34] release mutex orphan-sweep on start + shared redis client

* [M1] tiktok _refresh commits rotated tokens in an independent session

* [H25] drop new type:ignore in orphan-sweep test (constraint cleanup)

* [M2] feature-spotlight re-arms when exploration stale past 2x interval with no live HoM spawn

* [M6,M7] mark_seen after meaningful+project; persist since_id cursor in redis

* [M3,M5] reject() guards COMPLETED; edited_body deferred into the single-flight lock

* [M4] bound list_completed_video_tasks + ix_tasks_source_status_created index (migration 066)

* [M8,M9,L9] pass head_sha to CI gate; _run_git 30s timeout; _commits_since split maxsplit 2

* [M10,L35] dedupe dep_update by (git_url, command); fold redundant per-project queries

* [L36] gather ci_watch telemetry sweep instead of sequential iteration

* [L11] document self_heal fingerprint is stable per-signal by design

* [M11] engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert

* [M21] video render loop commits per-task, not one trailing commit

* [M22] _detect_stuck_tasks skips held-CEO-source tasks

* [L6] video_renderer_client._save writes temp + atomic rename

* [phase5] e2e smoke + CHANGELOG for 0.19.0

* [M11] instrument x_mentions + roadmap engine loops with liveness heartbeats

* [phase5] fix-wave: correct e2e M11 unit-test filename + strengthen failed-cycle heartbeat assertion

* [C4] panel WS: shared /ws/system socket + long-tail retry + pong watchdog

* [H15] video-post-queue caption derived per render (mirror x-post-queue)

* [C4-fix] panel WS: discriminating long-tail tests + drop dead freeze block + evict dead shared conn on manual disconnect

Finding 1 (Critical, websocket.test.ts): the two long-tail-retry tests fired onopen between close cycles, which reset reconnectAttempts to 0 each cycle, so they passed under the pre-fix 3-attempt gate. Rewrote both to NEVER fire onopen between closes, so attempts accumulates: test 1 asserts state stays 'reconnecting' past attempt 3 (old gate would flip 'disconnected' terminal); test 2 asserts a new socket is constructed within 30000ms at attempt 7 where uncapped 5000*1.5^7 ~= 85s (old uncapped code would leave the timer unexpired). Verified both FAIL on a reverted old-shape connection.ts and PASS on the fixed code.

Finding 2 (Important, connection.ts): the 'if (raw >= cap) this.reconnectAttempts = exp' block was a no-op (exp was just read from the same field) and the unconditional increment afterwards grew the counter regardless. Deleted the dead block; kept the Math.min cap on the delay. Replaced the misleading ponytail comment with an accurate one: delay is capped, counter grows unbounded but delay is bounded.

Finding 3 (Important, use-websocket.ts): manual disconnect() tore down the shared conn for all subscribers but left the dead (manualClose=true, never reconnects) entry in _sharedSockets, so a later mount hit the reuse branch, attached a subscriber, replayed 'disconnected', and never called connect(). Added a urlRef and _sharedSockets.delete(url) in the manual disconnect callback so a later mount reopens a fresh conn.

* [H16] settings Save wired to settingsApi (persist + read back)

* [H17] tasks page passes status/team/limit to useTasks (server-side filter)

* [H18] useAgents roster re-derives on live-status change (statusEpoch in queryKey)

* [M40] useMetrics reads agent counts from useAgentStatus cache (dedupe poll)

* [H18] tighten useAgents statusEpoch comment (drop spec ref)

* [M40] drop spec ref + tighten useMetrics comment

* [M41] scorecard refetchInterval 60s -> 5min (25 req/min -> 5)

* [M42] feature-flag off-transition confirm + pending-keys Set

* [M43] X/TikTok credentials clear-behind confirm dialog

* [M44] rate-limit syncFromApi merges (keep fresher hitAt) + A2A reconnect invalidation

* [phase6] proxy.ts cookie-check comment + CHANGELOG Fixed entries

* [phase6] drop stale WS pin-attempts comment + fix tasks-page lead-in

* [H21] type DelegateRequest.estimated_complexity as Complexity (reject critical)

* [H22] type SoftBlockRequest.resolver_type as BlockerResolverType (no silent AGENT fallback)

* [H23] serialize TaskTable.documents into TaskResponse (DocRefResponse)

* [L27] delete SubstituteRequest phantom suggested_role/suggested_team fields

* [L14] Envelope.not_found defaults remediate (guide re-fetch + re-issue)

* [L28] delete unused ListResponse generic (dead code; pagination deferred)

* [H19] _delegate_static_guards allow cell_projects roots (cross-cell MegaTask)

* [M13] MegaTask confirm-batch idempotency key from session_id (SETNX guard + result sidecar)

* [M14] strip assigned_to from MegaTask drafts (no board-owned root-subtask deadlock)

* [H20] thin_routes receiver-gate add/add_all/merge (no false block on set/cache.add)

* [M16] tighten noqa code-capture to [A-Z0-9, ]+ (no false block on natural prose)

* [M45] conventions read-clone force-refetch on read (no 30s stale map window)

* [L25] conventions._resolve returns (root, sha); ORM mutated on the event loop

* [M15] open_conventions_pr force-pushes disposable scaffold branch (no silent None)

* [L24] roadmap cycle completion emits status-transition audit

* [Phase7] CHANGELOG: 15 schema/conventions/MegaTask/API fixed (H21-H23,L27,L14,L28,H19,M13,M14,H20,M16,M45,L25,M15,L24)

* [Phase7] lint gate hygiene: shorten docstring (E501), sort imports (I001), hoist AuditLogTable import (PLC0415)

* [H14] Enable the GROK provider row in _apply_grok so routing reaches the GrokCliProvider

* [M31] Route GROK active-token resolution to usage.json so live usage reflects grok agents

* [M32] Pass cache read/write tokens to calculate_cost in the usage sweep so live cost reflects Anthropic cache spend

* [M33] Park Ollama-Cloud rate limits via a marker map so a glm-5.2:cloud 429 parks instead of crash-respawning

* [M34] Sweep orphan agent_spawn_sessions at startup so crashed-run tokens roll into usage/cost summaries

* [L12] Persist revisit_resets (migration 067) so the PM-respawn breaker's revisit counter survives a restart

* [L18] Date-gate the Sonnet-5 promo revert so billing returns to list rates after 2026-08-31

* [L20] Warn when ROBOCO_GROK_RUN_LOG yields no session id instead of silently falling back to a zero-usage env id

* [phase8] CHANGELOG: LLM provider routing, usage capture, billing fixes

* [phase8] Trailing ruff format hygiene (orchestrator marker tuples, token-sweep test signatures)

* [phase8] Fix mypy: rename GROK-branch tokens var so transcript fallback stays reachable

* [M35] Add an expiring agent-token format (iat/exp) with backward-compatible verify

* [M35] Wire agent-token TTL at spawn (config + orchestrator + grok) so tokens are bounded

* [M36] Add JWT jti claim and re-mint the sliding cookie only near expiry so a stolen cookie's exp is fixed

* [M36] Redis jti revocation: read_token rejects revoked jtis and logout revokes the current jti

* [phase9] CHANGELOG: bound agent tokens + sliding-cookie re-mint window + jti revocation

* [scan-fix] mypy: type-annotate test files for make-quality gate

CI's make quality runs mypy roboco/ tests/; the scan-fix program's local
gate ran mypy roboco/ only, so test files were never type-checked. Fix all
67 errors across 23 test files with real annotations/casts/asserts/dead-code
removal — no # type: ignore / # noqa added.

* [e2e] Per-test DB isolation + dispatcher re-claim before PM complete

* [scan] Regenerate verb tables for delegate Complexity type

* [scan] Reduce 9 xenon C-ranks to B (auth, orchestrator, gateway, services)

* [scan] Restore short-circuit time.time() in verify_agent_token (security path)

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-07 10:09:23 +02:00
committed by GitHub
co-authored by Renn F
parent cebbd73e07
commit 3849c1737e
266 changed files with 19994 additions and 6156 deletions
@@ -0,0 +1,52 @@
"""_append_agent_auth_env mints an EXPIRING (ttl) token at spawn (M35)."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from roboco.agents_config import (
AGENT_UUIDS,
get_agent_role,
get_agent_team,
verify_agent_token,
)
from roboco.config import settings
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
from roboco.runtime.orchestrator import AgentOrchestrator
if TYPE_CHECKING:
import pytest
def _token_from_cmd(cmd: list[str]) -> str:
# The injector uses cmd.extend(["-e", "ROBOCO_AGENT_TOKEN=<value>"]).
for i, flag in enumerate(cmd):
if (
flag == "-e"
and i + 1 < len(cmd)
and cmd[i + 1].startswith("ROBOCO_AGENT_TOKEN=")
):
return cmd[i + 1].split("=", 1)[1]
raise AssertionError("ROBOCO_AGENT_TOKEN not found in cmd")
def test_append_agent_auth_env_mints_expiring_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "spawn-secret")
monkeypatch.setattr(settings, "agent_token_ttl_seconds", 3600)
cmd: list[str] = []
config = AgentConfig(
agent_id="be-dev-1",
blueprint_path=Path("/app/blueprints/dev.md"),
provider_type="anthropic",
)
AgentOrchestrator._append_agent_auth_env(cmd, config)
token = _token_from_cmd(cmd)
assert "." in token # expiring format, not the static 64-hex digest
uuid = AGENT_UUIDS.get("be-dev-1", "be-dev-1")
role = get_agent_role("be-dev-1") or "developer"
team = get_agent_team("be-dev-1") or "backend"
assert verify_agent_token(token, uuid, role, team) is True
@@ -13,6 +13,7 @@ import json
import tempfile
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.models.runtime import AgentInstance
from roboco.runtime import orchestrator as orch_mod
@@ -79,6 +80,21 @@ async def test_resolve_final_usage_routes_grok_to_usage_json(
assert await orch._resolve_final_token_usage("be-dev-1") == (0, 12, 0, 0)
@pytest.mark.asyncio
async def test_resolve_active_tokens_routes_grok_to_usage_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
monkeypatch.setattr(
orch, "_grok_usage_json", lambda _aid: {"total_tokens": 12, "cost_usd": 0.01}
)
cfg = type("C", (), {"provider_type": "grok"})()
orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)}
# The client is unused for GROK; pass a bare AsyncClient (never awaited).
async with httpx.AsyncClient() as client:
assert await orch._resolve_active_tokens(client, "be-dev-1") == (0, 12, 0, 0)
def test_grok_usage_dir_branches_compose_vs_local(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -0,0 +1,180 @@
"""Startup self-heal: kill agent containers whose baked-in ROBOCO_AGENT_TOKEN
no longer verifies against the current ``ROBOCO_AGENT_AUTH_SECRET``.
A token is signed once at spawn. If the secret drifts afterwards (a `.env`
change, a compose recreate that reloads the orchestrator's env without
recreating the agent containers), the surviving agent keeps sending its stale
token and the middleware 401s every verb with "signature mismatch". The
container stays alive (heartbeating) so the reaper never reclaims it and no
fresh agent spawns — the fleet stalls. ``_heal_stale_agent_tokens`` runs at
startup and kills each stale-token container so normal dispatch re-spawns it
with a freshly signed token.
"""
from __future__ import annotations
import secrets
from typing import Any
from unittest.mock import AsyncMock
import pytest
from roboco.agents_config import (
AGENT_UUIDS,
issue_agent_token,
verify_agent_token,
)
from roboco.runtime.orchestrator import AgentOrchestrator
def _orch() -> Any:
orch = AgentOrchestrator.__new__(AgentOrchestrator) # bypass __init__
orch._instances = {}
return orch
@pytest.mark.asyncio
async def test_heal_kills_stale_token_containers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
secret = secrets.token_hex(32)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secret)
orch = _orch()
# be-dev-1 holds a token signed with a DIFFERENT secret (rotated after spawn).
stale_token = issue_agent_token(
"00000000-0000-0000-0001-000000000001", "developer", "backend"
)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32))
async def inspect(name: str) -> tuple[bool, int | None]:
return (name == "roboco-agent-be-dev-1", 0)
orch._inspect_container_state = AsyncMock(side_effect=inspect)
orch._read_container_auth_env = AsyncMock(
return_value=(
stale_token,
"00000000-0000-0000-0001-000000000001",
"developer",
)
)
removed: list[str] = []
orch._remove_container = AsyncMock(
side_effect=lambda name, **_: removed.append(name)
)
n = await orch._heal_stale_agent_tokens()
assert n == 1
assert removed == ["roboco-agent-be-dev-1"]
@pytest.mark.asyncio
async def test_heal_leaves_valid_token_containers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32))
orch = _orch()
be_dev_1 = "00000000-0000-0000-0001-000000000001"
valid_token = issue_agent_token(be_dev_1, "developer", "backend")
async def inspect(name: str) -> tuple[bool, int | None]:
return (name == "roboco-agent-be-dev-1", 0)
orch._inspect_container_state = AsyncMock(side_effect=inspect)
orch._read_container_auth_env = AsyncMock(
return_value=(valid_token, be_dev_1, "developer")
)
orch._remove_container = AsyncMock()
n = await orch._heal_stale_agent_tokens()
assert n == 0
orch._remove_container.assert_not_called()
@pytest.mark.asyncio
async def test_heal_inert_when_secret_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Dev mode (no secret): verify_agent_token fails for every token, so an
# ungated heal would kill the whole fleet. The heal must short-circuit.
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
orch = _orch()
orch._inspect_container_state = AsyncMock(return_value=(True, 0))
orch._read_container_auth_env = AsyncMock(
return_value=("UNSIGNED", "be-dev-1", "developer")
)
orch._remove_container = AsyncMock()
n = await orch._heal_stale_agent_tokens()
assert n == 0
orch._remove_container.assert_not_called()
@pytest.mark.asyncio
async def test_heal_skips_when_env_probe_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32))
orch = _orch()
orch._inspect_container_state = AsyncMock(return_value=(True, 0))
# docker exec fails (container mid-shutdown, etc.) → None → skip, don't kill.
orch._read_container_auth_env = AsyncMock(return_value=None)
orch._remove_container = AsyncMock()
n = await orch._heal_stale_agent_tokens()
assert n == 0
orch._remove_container.assert_not_called()
@pytest.mark.asyncio
async def test_heal_kills_slug_env_container_with_slug_signed_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A pre-fix container carries a slug ROBOCO_AGENT_ID + a slug-signed token.
The MCP servers send X-Agent-ID as the UUID (gateway v1 parses it as
Annotated[UUID]), so the middleware verifies the token against the UUID
— a slug-signed token 401s. The heal must verify against the UUID too:
a slug-signed token is stale wrt the UUID identity and the container must
be killed so it respawns with a UUID-signed token. Verifying against the
container-env slug would PASS and leave the stale container 401ing.
"""
secret = secrets.token_hex(32)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secret)
orch = _orch()
be_dev_1_slug = "be-dev-1"
# Token signed over the slug (the pre-fix _append_agent_auth_env behaviour).
slug_signed_token = issue_agent_token(be_dev_1_slug, "developer", "backend")
async def inspect(name: str) -> tuple[bool, int | None]:
return (name == "roboco-agent-be-dev-1", 0)
orch._inspect_container_state = AsyncMock(side_effect=inspect)
orch._read_container_auth_env = AsyncMock(
return_value=(slug_signed_token, be_dev_1_slug, "developer")
)
removed: list[str] = []
orch._remove_container = AsyncMock(
side_effect=lambda name, **_: removed.append(name)
)
n = await orch._heal_stale_agent_tokens()
assert n == 1
assert removed == ["roboco-agent-be-dev-1"]
def test_heal_accepts_expiring_format_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The heal verifier must accept the new {payload}.{sig} expiring token.
Task 1 made verify_agent_token format-agnostic; this pins it at the heal
call site so a post-deploy respawn with a ttl token isn't killed.
"""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "heal-secret")
uuid = AGENT_UUIDS.get("be-dev-1", "be-dev-1")
tok = issue_agent_token(uuid, "developer", "backend", ttl_seconds=3600)
assert verify_agent_token(tok, uuid, "developer", "backend") is True
@@ -0,0 +1,221 @@
"""Engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert.
Each background engine loop records a monotonic heartbeat after a successful
cycle (and once at start); ``_check_loop_liveness`` (called from
``_check_health``) logs a warning when ``now - last_success > 2 * interval``
for any loop. The alert is the fail-direction: a dead cycle task stops
recording, so after ``2*interval`` the health loop logs "engine loop stalled".
"""
from __future__ import annotations
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.runtime import orchestrator as orch_module
from roboco.runtime.orchestrator import AgentOrchestrator
# Test interval constants (kept symbolic so ruff PLR2004 stays quiet and the
# intent reads at the call site).
_CI_WATCH_INTERVAL = 0.01
_VIDEO_RENDER_INTERVAL = 0.05
_X_MENTIONS_INTERVAL = 0.04
_ROADMAP_INTERVAL = 0.06
def _orch() -> Any:
"""Bypass __init__ — the loop helpers under test need only the heartbeats
dict and ``_running``."""
o = AgentOrchestrator.__new__(AgentOrchestrator)
o._loop_heartbeats = {}
o._running = True
return o
def test_stale_heartbeat_logs_warning() -> None:
orch = _orch()
interval = 10.0
orch._loop_heartbeats["self_heal"] = (time.monotonic() - 3 * interval, interval)
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_called_once()
args, kwargs = fake.warning.call_args
assert args[0] == "engine loop stalled past 2x interval"
assert kwargs["loop"] == "self_heal"
assert kwargs["interval"] == interval
assert kwargs["stall_seconds"] >= 3 * interval
def test_fresh_heartbeat_no_warning() -> None:
orch = _orch()
interval = 10.0
orch._loop_heartbeats["self_heal"] = (time.monotonic(), interval)
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_not_called()
def test_empty_heartbeats_no_warning() -> None:
"""A fleet with all engines dormant (no heartbeats recorded) must not warn —
nothing is stalled, nothing is running."""
orch = _orch()
fake = MagicMock()
with patch.object(orch_module, "logger", fake):
orch._check_loop_liveness()
fake.warning.assert_not_called()
@pytest.mark.asyncio
async def test_successful_cycle_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Driving one engine loop (ci_watch) through a stubbed successful cycle
records a heartbeat under the loop's canonical name."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_ci_watch_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
last_success, interval = orch._loop_heartbeats["ci_watch"]
assert interval == _CI_WATCH_INTERVAL
assert last_success > 0.0
@pytest.mark.asyncio
async def test_start_heartbeat_recorded_before_first_cycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The start-of-loop heartbeat is recorded before the first cycle, so a
loop that never enters its body still has a heartbeat to age against."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
# Loop body never runs: while-condition is False on first check.
orch._running = False
orch._run_ci_watch_cycle = AsyncMock()
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
orch._run_ci_watch_cycle.assert_not_awaited()
@pytest.mark.asyncio
async def test_failed_cycle_does_not_record_post_success_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A cycle that raises must NOT record the post-success heartbeat — the
staleness alert relies on a dead cycle stopping the heartbeat refresh."""
orch = _orch()
monkeypatch.setattr(settings, "ci_watch_enabled", True)
monkeypatch.setattr(settings, "ci_watch_interval_seconds", 0.01)
async def _raise_then_stop() -> None:
orch._running = False
raise RuntimeError("cycle blew up")
orch._run_ci_watch_cycle = AsyncMock(side_effect=_raise_then_stop)
heartbeat_calls: list[tuple[str, float]] = []
original = orch._record_loop_heartbeat
def _spy(name: str, interval: float) -> None:
heartbeat_calls.append((name, interval))
original(name, interval)
orch._record_loop_heartbeat = _spy
with patch("asyncio.sleep", new=AsyncMock()):
await orch._ci_watch_loop()
assert "ci_watch" in orch._loop_heartbeats
# Only the start heartbeat was recorded — the post-success call was skipped
# because the cycle raised before reaching it. A second call would mean the
# heartbeat refreshed despite the failure, defeating the staleness alert.
assert len(heartbeat_calls) == 1
assert heartbeat_calls[0] == ("ci_watch", _CI_WATCH_INTERVAL)
_, interval = orch._loop_heartbeats["ci_watch"]
assert interval == _CI_WATCH_INTERVAL
@pytest.mark.asyncio
async def test_video_render_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Sanity-check that a second engine loop (video_render) uses its own
canonical name and interval — guards against copy-paste name drift."""
orch = _orch()
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_render_interval_seconds", 0.05)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_video_render_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._video_render_loop()
assert "video_render" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["video_render"]
assert interval == _VIDEO_RENDER_INTERVAL
@pytest.mark.asyncio
async def test_x_mentions_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The mentions-poll loop records under its canonical name + interval —
guards against copy-paste name drift on the heartbeat calls."""
orch = _orch()
monkeypatch.setattr(settings, "x_engine_enabled", True)
monkeypatch.setattr(settings, "x_replies_enabled", True)
monkeypatch.setattr(settings, "x_mentions_interval_seconds", 0.04)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_x_mentions_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._x_mentions_poll_loop()
assert "x_mentions" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["x_mentions"]
assert interval == _X_MENTIONS_INTERVAL
@pytest.mark.asyncio
async def test_roadmap_engine_loop_records_heartbeat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The roadmap-engine loop records under its canonical name + interval —
guards against copy-paste name drift on the heartbeat calls."""
orch = _orch()
monkeypatch.setattr(settings, "roadmap_engine_enabled", True)
monkeypatch.setattr(settings, "roadmap_interval_seconds", 0.06)
async def _stop_after_cycle() -> None:
orch._running = False
orch._run_roadmap_engine_cycle = AsyncMock(side_effect=_stop_after_cycle)
with patch("asyncio.sleep", new=AsyncMock()):
await orch._roadmap_engine_loop()
assert "roadmap_engine" in orch._loop_heartbeats
_, interval = orch._loop_heartbeats["roadmap_engine"]
assert interval == _ROADMAP_INTERVAL
@@ -0,0 +1,81 @@
"""`_detect_stuck_tasks` must skip CEO-held sources (release_manager / x_post /
x_reply / video_post / PR-review / self-heal-pre-approve) before the age and
issues check. A held artifact sits PENDING by design until the CEO acts on it;
auto-blocking it on a "short description" wedges the held-artifact flow.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import VIDEO_POST_SOURCE
def _make_orch() -> AgentOrchestrator:
"""Bare orchestrator without running __init__ (no settings I/O)."""
return AgentOrchestrator.__new__(AgentOrchestrator)
def _old_task(tid: str, source: str) -> dict[str, Any]:
"""A PENDING task older than the 10-minute stuck threshold with a short
description — exactly the shape that triggers _auto_block_task today."""
created = (datetime.now(UTC) - timedelta(minutes=30)).isoformat()
return {
"id": tid,
"source": source,
"status": "pending",
"description": "short", # < _MIN_DESCRIPTION_LEN (10)
"branch_name": None,
"assigned_to": None,
"estimated_complexity": "low",
"parent_task_id": None,
"created_at": created,
}
@pytest.mark.asyncio
async def test_held_video_post_skipped_by_detect_stuck_tasks() -> None:
"""A PENDING video_post draft older than the threshold with a short
description must NOT be auto-blocked — it is a CEO-held artifact."""
orch = _make_orch()
held = _old_task("held-1", VIDEO_POST_SOURCE)
client: Any = MagicMock()
with (
patch.object(orch, "_fetch_tasks", new=AsyncMock(return_value=[held])),
patch.object(orch, "_check_dev_subtask_issue", new=AsyncMock(return_value=[])),
patch.object(orch, "_detect_sla_exceeded", new=AsyncMock()),
patch.object(orch, "_auto_block_task", new=AsyncMock()) as auto_block,
):
await orch._detect_stuck_tasks(client)
auto_block.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_held_pending_task_still_auto_blocked() -> None:
"""A non-held PENDING task of the same age + short description is still
auto-blocked — the held-source skip must not over-widen."""
orch = _make_orch()
normal = _old_task("normal-1", "manual")
client: Any = MagicMock()
with (
patch.object(orch, "_fetch_tasks", new=AsyncMock(return_value=[normal])),
patch.object(orch, "_check_dev_subtask_issue", new=AsyncMock(return_value=[])),
patch.object(orch, "_detect_sla_exceeded", new=AsyncMock()),
patch.object(orch, "_auto_block_task", new=AsyncMock()) as auto_block,
):
await orch._detect_stuck_tasks(client)
auto_block.assert_awaited_once()
assert auto_block.await_args is not None
assert auto_block.await_args.args[1] == "normal-1"
if __name__ == "__main__":
pytest.main([__file__, "-q"])
@@ -0,0 +1,101 @@
"""Startup sweep closes orphan agent_spawn_sessions rows.
A spawn session left open (ended_at IS NULL) by an orchestrator crash is
excluded from usage summaries (they filter ended_at IS NOT NULL). The sweep
closes any open session whose agent is no longer running, leaving running
agents' sessions open for their live finalize.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
import roboco.db.base as db_base
from roboco.runtime.orchestrator import AgentOrchestrator
# select(orphans) then update(orphans) — two executes when work is done.
_SELECT_THEN_UPDATE_CALLS = 2
class _Rows:
"""Fake execute result holding the rows the sweep reads."""
def __init__(self, rows: list[Any]) -> None:
self._rows = rows
def scalars(self) -> Any:
class _S:
def __init__(self, rows: list[Any]) -> None:
self._rows = rows
def all(self) -> list[Any]:
return self._rows
return _S(self._rows)
class _FakeSession:
def __init__(self, rows: list[Any] | None = None) -> None:
self._rows = rows or []
self.executed: list[Any] = []
self.committed = False
def __call__(self) -> _FakeSession:
return self
async def __aenter__(self) -> _FakeSession:
return self
async def __aexit__(self, *exc: object) -> None:
return None
async def execute(self, stmt: Any) -> _Rows:
self.executed.append(stmt)
return _Rows(self._rows)
async def commit(self) -> None:
self.committed = True
def _open_session(slug: str) -> MagicMock:
row = MagicMock()
row.id = f"sess-{slug}"
row.agent_slug = slug
row.ended_at = None
return row
@pytest.mark.asyncio
async def test_sweep_closes_orphans_not_running(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
# be-dev-1 still running (re-adopted); be-dev-2 dead -> orphan.
orch._instances = {"be-dev-1": MagicMock()}
orphan = _open_session("be-dev-2")
live = _open_session("be-dev-1")
fake_session = _FakeSession(rows=[orphan, live])
factory = MagicMock(return_value=fake_session)
monkeypatch.setattr(db_base, "get_session_factory", lambda: factory)
closed = await orch._reconcile_orphan_spawn_sessions()
assert closed == 1
assert len(fake_session.executed) == _SELECT_THEN_UPDATE_CALLS
assert fake_session.committed
assert live.ended_at is None # untouched (not in the update's where)
@pytest.mark.asyncio
async def test_sweep_no_op_when_no_open_sessions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
fake_session = _FakeSession(rows=[])
monkeypatch.setattr(db_base, "get_session_factory", lambda: fake_session)
assert await orch._reconcile_orphan_spawn_sessions() == 0
assert not fake_session.committed
@@ -403,3 +403,44 @@ async def test_stopped_container_parks_on_session_limit(
)
spawn.assert_not_awaited() # crash-retry short-circuited
overload.assert_not_awaited() # session-limit checked before the overload path
# ---------------------------------------------------------------------------
# Ollama Cloud (glm-5.2:cloud) weekly limit — surfaces as a 429 in docker logs
# ---------------------------------------------------------------------------
_OLLAMA_RATE_LIMIT_LOG = (
'{"error":"rate limit exceeded","message":"ollama.com weekly limit reached"}'
)
@pytest.mark.asyncio
async def test_detects_ollama_cloud_rate_limit_marker(
orch: AgentOrchestrator, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "overload_break_enabled", True)
monkeypatch.setattr(
orch, "_tail_container_logs", AsyncMock(return_value=_OLLAMA_RATE_LIMIT_LOG)
)
assert (
await orch._provider_rate_limit_park_target(
"be-dev-1", _instance("ollama_cloud")
)
== "ollama_cloud"
)
@pytest.mark.asyncio
async def test_clean_ollama_output_is_not_a_rate_limit(
orch: AgentOrchestrator, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "overload_break_enabled", True)
monkeypatch.setattr(
orch, "_tail_container_logs", AsyncMock(return_value=_CLEAN_LOG)
)
assert (
await orch._provider_rate_limit_park_target(
"be-dev-1", _instance("ollama_cloud")
)
is None
)
@@ -0,0 +1,150 @@
"""A tripped respawn breaker self-heals after a cooldown.
2026-07-06: pr-reviewer-1 wedged at count=63 because migration 051 made the
PM-respawn counter DB-durable and the breaker had no reset path once tripped
the only reset was a task status change, which can't happen while the
breaker blocks the spawn. A deploy that fixed the underlying loop (auth/
prompt/schema) couldn't clear the wedge without manual ``DELETE FROM
respawn_tracker`` surgery. The tripped breaker now freezes ``last_check`` at
the trip tick and, after ``pm_respawn_trip_cooldown_seconds``, lets ONE spawn
through. A still-wedged task re-trips after the threshold (bounded re-burn); a
fixed one advances and the status-change path fully resets. Restore re-stamps
``last_check`` to now, so a freshly restored row still trips immediately
(durability preserved) only a row tripped longer than the cooldown self-heals.
"""
from __future__ import annotations
import contextlib
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
_WEDGED_COUNT = 63 # a counter driven far past the trip threshold by a storm
def _new_orchestrator() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._pm_respawn_tracker = {}
orch._bg_tasks = set()
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
return orch
def _quiet_audit() -> AsyncMock:
audit = AsyncMock()
audit.has_recent_tracing_gap = AsyncMock(return_value=False)
return audit
@contextlib.contextmanager
def _patches() -> Any:
with (
patch(
"roboco.services.audit.get_audit_service",
return_value=_quiet_audit(),
),
patch(
"roboco.services.notification.NotificationService",
return_value=AsyncMock(),
),
):
yield
async def _trip(orch: AgentOrchestrator, slug: str, task: dict[str, Any]) -> None:
"""Drive same-status ticks until the breaker trips (count=threshold+1)."""
for _ in range(orch._PM_RESPAWN_MAX_UNPRODUCTIVE + 1):
await orch._pm_respawn_should_gate(slug, task)
@pytest.mark.asyncio
async def test_tripped_breaker_self_heals_after_cooldown() -> None:
"""count past threshold + stale last_check -> next tick lets the spawn through."""
orch = _new_orchestrator()
task_id = str(uuid4())
task = {"id": task_id, "status": "pending"}
with _patches():
await _trip(orch, "pr-reviewer-1", task)
assert await orch._pm_respawn_should_gate("pr-reviewer-1", task) is True
key = ("pr-reviewer-1", task_id)
assert (
orch._pm_respawn_tracker[key]["count"] > orch._PM_RESPAWN_MAX_UNPRODUCTIVE
)
# The durable row's last_check is stale — the trip happened >cooldown ago
# (e.g. a deploy fixed the underlying loop and the orchestrator restarted).
orch._pm_respawn_tracker[key]["last_check"] = datetime.now(UTC) - timedelta(
seconds=orch._PM_RESPAWN_TRIP_COOLDOWN_SECONDS + 60
)
# Cooldown elapsed -> reset count=1, allow the spawn (self-heal).
assert await orch._pm_respawn_should_gate("pr-reviewer-1", task) is False
assert orch._pm_respawn_tracker[key]["count"] == 1
assert orch._pm_respawn_tracker[key]["notified"] is False
@pytest.mark.asyncio
async def test_tripped_breaker_stays_gated_within_cooldown() -> None:
"""A fresh trip keeps gating; the count is frozen, not climbing every tick."""
orch = _new_orchestrator()
task_id = str(uuid4())
task = {"id": task_id, "status": "pending"}
with _patches():
await _trip(orch, "be-dev-1", task)
frozen = orch._pm_respawn_tracker[("be-dev-1", task_id)]["count"]
# Two more ticks while within the cooldown: both gate, count frozen.
assert await orch._pm_respawn_should_gate("be-dev-1", task) is True
assert await orch._pm_respawn_should_gate("be-dev-1", task) is True
assert orch._pm_respawn_tracker[("be-dev-1", task_id)]["count"] == frozen, (
"count must freeze once tripped, not climb every dispatch tick"
)
@pytest.mark.asyncio
async def test_cooldown_retrip_bounds_reburn() -> None:
"""After a cooldown reset, a still-wedged task re-trips only after the
threshold (bounded re-burn), not immediately."""
orch = _new_orchestrator()
task_id = str(uuid4())
task = {"id": task_id, "status": "pending"}
with _patches():
await _trip(orch, "be-dev-1", task)
key = ("be-dev-1", task_id)
# Force the cooldown to elapse and consume the reset.
orch._pm_respawn_tracker[key]["last_check"] = datetime.now(UTC) - timedelta(
seconds=orch._PM_RESPAWN_TRIP_COOLDOWN_SECONDS + 1
)
assert await orch._pm_respawn_should_gate("be-dev-1", task) is False
# Still wedged (status never changes): re-trip after threshold+1 ticks.
gated = False
for _ in range(orch._PM_RESPAWN_MAX_UNPRODUCTIVE + 1):
if await orch._pm_respawn_should_gate("be-dev-1", task):
gated = True
break
assert gated, "a still-wedged task must re-trip after the threshold"
@pytest.mark.asyncio
async def test_freshly_restored_row_trips_immediately_not_cooldown_reset() -> None:
"""Durability: a restored tripped row with a fresh (re-stamped) last_check
still gates on the first tick the cooldown must not disarm a fresh
restore. (Restore re-stamps last_check to now in production.)"""
orch = _new_orchestrator()
task_id = str(uuid4())
task = {"id": task_id, "status": "pending"}
# Simulate a restored row: count past threshold, notified, last_check=now.
orch._pm_respawn_tracker[("be-pm", task_id)] = {
"count": _WEDGED_COUNT,
"last_status": "pending",
"last_check": datetime.now(UTC),
"tracing_resets": 0,
"notified": True,
}
with _patches():
gated = await orch._pm_respawn_should_gate("be-pm", task)
assert gated is True, "a freshly restored row must trip, not cooldown-reset"
assert orch._pm_respawn_tracker[("be-pm", task_id)]["count"] == _WEDGED_COUNT
@@ -27,6 +27,7 @@ _SEEDED_COUNT = 3 # a persisted strike count, one below the trip threshold
_STRIKE_COUNT = 2
_MIN_PERSISTS = 2
_TRIP_COUNT = 4 # count > _PM_RESPAWN_MAX_UNPRODUCTIVE (3) fires the gate
_REVISIT_RESETS_PERSISTED = 2 # a persisted revisit-reset count
def _new_orchestrator() -> AgentOrchestrator:
@@ -46,6 +47,7 @@ def _row(task_id: Any, **over: Any) -> SimpleNamespace:
"last_check": datetime(2026, 6, 26, tzinfo=UTC),
"tracing_resets": 0,
"notified": False,
"revisit_resets": 0,
}
base.update(over)
return SimpleNamespace(**base)
@@ -101,6 +103,16 @@ def test_partition_drops_terminal_and_missing_rows() -> None:
}
def test_partition_restores_revisit_resets() -> None:
tid = uuid4()
rows = [_row(tid, revisit_resets=_REVISIT_RESETS_PERSISTED)]
restored, stale = AgentOrchestrator._partition_respawn_rows(
rows, {tid: "in_progress"}
)
assert stale == []
assert restored[("be-pm", str(tid))]["revisit_resets"] == _REVISIT_RESETS_PERSISTED
def test_partition_restamps_last_check_to_now_to_avoid_stale_tracing_gap() -> None:
# Restore must re-stamp last_check to the restore time so a pre-restart
# tracing_gap row can't falsely reset the breaker on the first post-restart
@@ -2,14 +2,24 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from roboco.agents_config import (
get_agent_team,
issue_agent_token,
verify_agent_token,
)
from roboco.foundation.identity import AGENTS
from roboco.runtime.orchestrator import (
SECRETARY_AGENT_ID,
AgentOrchestrator,
_SecretaryRunSpec,
)
if TYPE_CHECKING:
import pytest
def _spec() -> _SecretaryRunSpec:
return _SecretaryRunSpec(
@@ -57,3 +67,27 @@ def test_resolve_secretary_host_paths_has_claude_and_prompt() -> None:
assert "claude" in paths
assert "prompt" in paths
assert SECRETARY_AGENT_ID in str(paths["prompt"])
def test_secretary_token_signs_over_real_team_not_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The secretary token must verify against the (id, role, team) the
secretary driver actually sends. secretary_driver._headers sends
X-Agent-Team = get_agent_team(uuid) = the secretary's real team ("board"),
so the token issued at spawn (orchestrator _spawn_secretary_container) must
be signed over that same team not "" or every /api/secretary/* call
401s with "signature mismatch" under ROBOCO_AGENT_AUTH_REQUIRED.
"""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", "x" * 32)
secretary = AGENTS[SECRETARY_AGENT_ID]
agent_uuid = str(secretary.uuid)
team = secretary.team.value
assert team # the bug was signing "" — the secretary IS on a team
token = issue_agent_token(agent_uuid, "secretary", team) # mirrors spawn line
# secretary_driver._headers sends X-Agent-ID=uuid, role=secretary,
# X-Agent-Team=get_agent_team(uuid).
agent_team = get_agent_team(agent_uuid)
assert agent_team is not None
assert verify_agent_token(token, agent_uuid, "secretary", agent_team)
@@ -93,7 +93,8 @@ def _wire_secretary_spawn_mocks(
"roboco.foundation.identity.AGENTS",
{
SECRETARY_AGENT_ID: SimpleNamespace(
uuid=UUID("00000000-0000-0000-0000-000000000001")
uuid=UUID("00000000-0000-0000-0000-000000000001"),
team=SimpleNamespace(value="board"),
)
},
)
+43 -1
View File
@@ -16,7 +16,11 @@ from roboco.agents_config import verify_agent_token
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole
from roboco.models.permissions import TASK_PERMISSIONS, TaskAction
from roboco.runtime.orchestrator import _SYSTEM_API_HEADERS, _system_api_headers
from roboco.runtime.orchestrator import (
_SYSTEM_API_HEADERS,
_agent_api_headers,
_system_api_headers,
)
def test_system_api_headers_match_the_system_identity() -> None:
@@ -55,3 +59,41 @@ def test_system_api_headers_unsigned_when_secret_unset(
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
headers = _system_api_headers()
assert headers["X-Agent-Token"] == "UNSIGNED"
def test_agent_api_headers_carry_signed_token_and_team(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The cell-PM auto-submit self-API call acts as a specific PM. A hand-built
# {X-Agent-ID, X-Agent-Role} dict 401s under ROBOCO_AGENT_AUTH_REQUIRED —
# same F038/F039 gap as the system self-call. _agent_api_headers must carry
# a token signed for that PM's (id, role, team) plus the team header.
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32))
be_pm = _foundation.AGENTS["be-pm"]
be_pm_uuid = str(be_pm.uuid)
role = be_pm.role.value # "cell_pm"
team = be_pm.team.value # "backend"
headers = _agent_api_headers(be_pm_uuid, role)
assert headers["X-Agent-ID"] == be_pm_uuid
assert headers["X-Agent-Role"] == role
assert headers["X-Agent-Team"] == team
token = headers["X-Agent-Token"]
assert token and token != "UNSIGNED"
assert verify_agent_token(token, be_pm_uuid, role, team)
def test_agent_api_headers_omit_token_when_secret_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Dev mode: with no secret set, issue_agent_token returns the UNSIGNED
# sentinel, but the dev-mode middleware rejects a presented-but-unverifiable
# token with 401 "signature mismatch" while accepting a missing token.
# Sending UNSIGNED would 401 the cell-PM auto-submit self-call in every dev
# run (the e2e test_auto_submit_cuts_the_pm_turn regression), so the token
# header is omitted entirely when the secret is unset.
monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
be_pm = _foundation.AGENTS["be-pm"]
headers = _agent_api_headers(str(be_pm.uuid), be_pm.role.value)
assert "X-Agent-Token" not in headers
@@ -0,0 +1,80 @@
"""_sweep_token_snapshots prices cache tokens, not just input/output.
The live USAGE_SNAPSHOT cost must match calculate_cost over the full 4-tuple
(the finalize path already does); dropping cache read/write undercounts
Anthropic cache spend mid-run.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import roboco.db.base as db_base
from roboco.billing import pricing
from roboco.models.runtime import AgentInstance
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
# 1M input, 0 output, 4M cache read, 0 cache write — cache spend dominates.
TOKENS_INPUT = 1_000_000
TOKENS_CACHE_READ = 4_000_000
def _active_orch() -> tuple[AgentOrchestrator, AgentInstance]:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cfg = type("C", (), {"provider_type": "anthropic", "model": "claude-sonnet-5"})()
inst = AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg)
inst.container_id = "cid"
orch._instances = {"be-dev-1": inst}
return orch, inst
@pytest.mark.asyncio
async def test_sweep_passes_cache_tokens_to_calculate_cost(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch, _inst = _active_orch()
captured: dict[str, Any] = {}
def fake_calc(
model: str,
tokens_input: int,
tokens_output: int,
tokens_cache_read: int = 0,
tokens_cache_write: int = 0,
) -> float:
captured.update(
model=model,
tokens_input=tokens_input,
tokens_output=tokens_output,
tokens_cache_read=tokens_cache_read,
tokens_cache_write=tokens_cache_write,
)
return 0.804 # 4M * 0.201/1M cache-read spend
monkeypatch.setattr(pricing, "calculate_cost", fake_calc)
monkeypatch.setattr(
orch,
"_resolve_active_tokens",
AsyncMock(return_value=(TOKENS_INPUT, 0, TOKENS_CACHE_READ, 0)),
)
monkeypatch.setattr(
orch,
"_persist_token_snapshot",
AsyncMock(return_value=True),
)
# Stub the session factory import path the sweep uses.
fake_session = AsyncMock()
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
fake_session.__aexit__ = AsyncMock(return_value=None)
factory = MagicMock(return_value=fake_session)
monkeypatch.setattr(db_base, "get_session_factory", lambda: factory)
# No publish target needed; the post-loop publish is best-effort.
monkeypatch.setattr(orch, "_publish_usage_snapshot", AsyncMock(), raising=False)
await orch._sweep_token_snapshots()
assert captured["tokens_cache_read"] == TOKENS_CACHE_READ
assert captured["tokens_cache_write"] == 0
assert captured["tokens_input"] == TOKENS_INPUT
+42 -4
View File
@@ -124,6 +124,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
@@ -170,7 +171,7 @@ async def _make_completed_video_task(
def _render_patches(renderer: _FakeRenderer, workspace: Any) -> Any:
return (
patch(
"roboco.services.remotion_client.get_remotion_renderer",
"roboco.services.video_renderer_client.get_video_renderer",
lambda: renderer,
),
patch(
@@ -208,7 +209,7 @@ async def test_loop_returns_immediately_when_disabled(
@pytest.mark.asyncio
async def test_run_cycle_processes_each_completed_task_and_commits() -> None:
async def test_run_cycle_commits_per_task() -> None:
orch = _orch()
task_a = MagicMock()
task_b = MagicMock()
@@ -226,11 +227,12 @@ async def test_run_cycle_processes_each_completed_task_and_commits() -> None:
call(db, task_a),
call(db, task_b),
]
db.commit.assert_awaited_once()
# one commit per task — never one trailing commit after the loop
assert db.commit.await_count == TWO
@pytest.mark.asyncio
async def test_run_cycle_with_no_completed_tasks_still_commits() -> None:
async def test_run_cycle_with_no_completed_tasks_does_not_commit() -> None:
orch = _orch()
db = MagicMock()
db.commit = AsyncMock()
@@ -243,7 +245,43 @@ async def test_run_cycle_with_no_completed_tasks_still_commits() -> None:
):
await orch._run_video_render_cycle()
orch._render_video_task.assert_not_awaited()
db.commit.assert_not_awaited() # nothing rendered → nothing to durably persist
@pytest.mark.asyncio
async def test_run_cycle_commits_before_mid_cycle_raise_so_prior_render_durable() -> (
None
):
"""A raise mid-cycle must not roll back prior renders: each render is
committed before the next is attempted, so the committed
render_status='rendered' is the idempotency key the next scan skips
(instead of re-rendering + re-originating a second held video_post draft).
"""
orch = _orch()
task_a = MagicMock()
task_b = MagicMock()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[task_a, task_b])
async def _render(_db: Any, task: Any) -> None:
if task is task_b:
raise RuntimeError("B blew up")
orch._render_video_task = AsyncMock(side_effect=_render)
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
pytest.raises(RuntimeError, match="B blew up"),
):
await orch._run_video_render_cycle()
# A's commit happened BEFORE B raised — exactly one commit, A is durable
db.commit.assert_awaited_once()
assert orch._render_video_task.await_args_list == [
call(db, task_a),
call(db, task_b),
]
# --------------------------------------------------------------------------- #