fix(release): close the 0.19.0 scan findings — sandbox mongo tag, flow-verb timeout walls, video hardening (#329)

- mongo:8-alpine → mongo:8 (tag never existed; a mongo-opted project could spawn no agents) + a Docker Hub tag-existence e2e guard for every sandbox engine
- flow-verb timeouts at both walls: shared SLOW_VERBS policy (i_am_done / submit_up / submit_root / open_pr / i_will_work_on get the 900s server budget); the MCP client now outlasts the server budget (+10s headroom, orchestrator-injected env) so agents receive the middleware's clean 504 envelope instead of dying at the old flat 30s client timeout
- cancellation safety: the quality gate kills+reaps its child on CancelledError; create_pr records the PR via a shield-with-wait-out helper so the write can neither be skipped nor race get_db's rollback
- video engine: renderer sidecar isolated on a render-only network, 2g/2cpu caps, 570s render watchdog with exit-on-hang, 512MB tar decompression cap, CEO notification on terminal render failure, reject under the approve mutex (fail-closed on Redis-down)
- dead python-jose dependency removed (drops ecdsa and its unfixable Minerva advisory PYSEC-2026-1325); panel --font-mono now a real monospace stack

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 03:26:12 +02:00
committed by GitHub
co-authored by Renn F
parent 2a9d9e25d9
commit 0bf0cd69b3
36 changed files with 865 additions and 55 deletions
+14 -2
View File
@@ -6,11 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.19.0] - 2026-07-05 ## [0.19.0] - 2026-07-08
### Added ### Added
- **Pluggable sandbox engine registry (postgres / redis / mongo).** The per-agent-spawn sandbox service set is now a registry instead of hardcoded postgres+redis branches. A new pure module `roboco/models/sandbox.py` defines a `SandboxEngine` ABC plus three concrete engines (`_PostgresEngine` `postgres:16-alpine`, `_RedisEngine` `redis:8-alpine`, `_MongoEngine` `mongo:8-alpine`); each engine declares its image, run args, readiness probe, connection shape, and `ROBOCO_TEST_*` env emission. `VALID_SANDBOX_SERVICES = frozenset(SANDBOX_ENGINES)` is the single source of truth — derived from the registry, shared by the pydantic validators in `models/project.py` and the orchestrator-side provisioner, so they can never drift. The provisioner (`roboco/runtime/sandbox.py`) iterates the registry via a generic `_provision_engine`; the orchestrator's `_append_sandbox_env` collapses to `cmd.extend(info.emit_env())`; teardown iterates every engine. Adding a sandbox engine is now one class + one registry line — no branch edited in the provisioner or the env emitter, and the panel's edit-project dialog surfaces it via a `SANDBOX_SERVICES` catalog. Mongo rides the existing `projects.sandbox_services` opt-in (migration 057) with no new migration and no new feature flag; it adds `ROBOCO_TEST_MONGO_*` (+ `ROBOCO_TEST_MONGO_AUTH_DB=admin`). Env var names `ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` are preserved so existing project conftests need no change. The panel's sandbox toggles became a `Set<string>` multi-select driven by the catalog. - **Pluggable sandbox engine registry (postgres / redis / mongo).** The per-agent-spawn sandbox service set is now a registry instead of hardcoded postgres+redis branches. A new pure module `roboco/models/sandbox.py` defines a `SandboxEngine` ABC plus three concrete engines (`_PostgresEngine` `postgres:16-alpine`, `_RedisEngine` `redis:8-alpine`, `_MongoEngine` `mongo:8`); each engine declares its image, run args, readiness probe, connection shape, and `ROBOCO_TEST_*` env emission. `VALID_SANDBOX_SERVICES = frozenset(SANDBOX_ENGINES)` is the single source of truth — derived from the registry, shared by the pydantic validators in `models/project.py` and the orchestrator-side provisioner, so they can never drift. The provisioner (`roboco/runtime/sandbox.py`) iterates the registry via a generic `_provision_engine`; the orchestrator's `_append_sandbox_env` collapses to `cmd.extend(info.emit_env())`; teardown iterates every engine. Adding a sandbox engine is now one class + one registry line — no branch edited in the provisioner or the env emitter, and the panel's edit-project dialog surfaces it via a `SANDBOX_SERVICES` catalog. Mongo rides the existing `projects.sandbox_services` opt-in (migration 057) with no new migration and no new feature flag; it adds `ROBOCO_TEST_MONGO_*` (+ `ROBOCO_TEST_MONGO_AUTH_DB=admin`). Env var names `ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` are preserved so existing project conftests need no change. The panel's sandbox toggles became a `Set<string>` multi-select driven by the catalog.
- **RoboCo video engine (default-off).** With `ROBOCO_VIDEO_ENGINE_ENABLED`, a release/feature-spotlight/on-demand CEO trigger opens a normal, assigned UX/UI authoring task (balanced across the two ux-devs) instead of a held draft — the dev builds a HyperFrames HTML composition under `motion/compositions/<id>/` and proposes its composition id + per-platform captions via the team-gated `propose_video` do-tool, then ships it through the standard commit/PR/QA/doc/review lifecycle. Once that task completes, an orchestrator render loop tars the merged `motion/` source to a new credential-free `video-renderer` sidecar, renders both the 9:16 and 1:1 MP4 cuts, and materializes a held `video_post` draft (mirroring the X-post/release-proposal shape: Secretary-owned, skipped by every dispatcher). The CEO previews, edits, approves, or rejects each draft in a new panel video queue; approving posts the rendered clip to X (native video, v2 media upload) and/or TikTok (inbox upload) under a heartbeat-renewed lock and is idempotent — an already-posted draft is a no-op. `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT` gate the two automatic triggers independently of the CEO's on-demand `POST /video/request`; TikTok's OAuth2 secrets live Fernet-encrypted alongside the existing X credentials, and every unconfigured leg (renderer, X, TikTok) degrades to a graceful no-op rather than a crash. Rendered MP4s persist under `ROBOCO_VIDEO_OUTPUT_DIR` (bind-mounted in all three compose files so renders survive container recreation). - **RoboCo video engine (default-off).** With `ROBOCO_VIDEO_ENGINE_ENABLED`, a release/feature-spotlight/on-demand CEO trigger opens a normal, assigned UX/UI authoring task (balanced across the two ux-devs) instead of a held draft — the dev builds a HyperFrames HTML composition under `motion/compositions/<id>/` and proposes its composition id + per-platform captions via the team-gated `propose_video` do-tool, then ships it through the standard commit/PR/QA/doc/review lifecycle. Once that task completes, an orchestrator render loop tars the merged `motion/` source to a new credential-free `video-renderer` sidecar, renders both the 9:16 and 1:1 MP4 cuts, and materializes a held `video_post` draft (mirroring the X-post/release-proposal shape: Secretary-owned, skipped by every dispatcher). The CEO previews, edits, approves, or rejects each draft in a new panel video queue; approving posts the rendered clip to X (native video, v2 media upload) and/or TikTok (inbox upload) under a heartbeat-renewed lock and is idempotent — an already-posted draft is a no-op. `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT` gate the two automatic triggers independently of the CEO's on-demand `POST /video/request`; TikTok's OAuth2 secrets live Fernet-encrypted alongside the existing X credentials, and every unconfigured leg (renderer, X, TikTok) degrades to a graceful no-op rather than a crash. Rendered MP4s persist under `ROBOCO_VIDEO_OUTPUT_DIR` (bind-mounted in all three compose files so renders survive container recreation).
- **Per-project video-engine opt-in.** `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global `ROBOCO_VIDEO_ENGINE_ENABLED` flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/` dir — `VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it in the panel's edit-project dialog. Existing projects stay opted out. - **Per-project video-engine opt-in.** `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global `ROBOCO_VIDEO_ENGINE_ENABLED` flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/` dir — `VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it in the panel's edit-project dialog. Existing projects stay opted out.
@@ -22,6 +22,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed ### Fixed
- **Mongo sandbox image tag existed nowhere (`mongo:8-alpine``mongo:8`).** MongoDB has never published an Alpine variant, so the mongo engine's pre-pull always failed and — because provisioning failure refuses the spawn by design — a project opted into a mongo sandbox could spawn no agents at all. The registry now pins `mongo:8`, the dead `MONGO_INITDB_DATABASE` env is dropped (nothing consumed it; the connection already hands agents the `admin` auth DB), and a new network-gated e2e test (`tests/e2e_smoke/test_sandbox_image_tags.py`) asserts every `SANDBOX_ENGINES` image:tag actually exists on Docker Hub — the check the fully-mocked provisioner unit tests structurally cannot make.
- **Flow-verb timeouts now match what the verbs actually do — at both walls.** Live agents were abandoning tool calls at the MCP client's flat 30s `httpx` timeout (`flow_server.py`) while the server kept executing: `i_am_done` died mid-quality-gate, `i_will_work_on` died mid-workspace-clone, and planning verbs reported "timed out but succeeded". A new pure policy module (`roboco/foundation/policy/flow_timeouts.py`) defines the slow-verb set `{i_am_done, submit_up, submit_root, open_pr, i_will_work_on}` shared by both sides: the `FlowVerbTimeoutMiddleware` gives slow verbs `flow_verb_slow_timeout_seconds` (default 900) instead of the 120s default, and the agent-side client reads orchestrator-injected `ROBOCO_FLOW_VERB_TIMEOUT_SECONDS` / `ROBOCO_FLOW_VERB_SLOW_TIMEOUT_SECONDS` and always outlasts the server budget by 10s — so an agent receives the middleware's clean 504 envelope, never a raw client timeout. `i_will_plan`/`delegate` deliberately stay on the default budget: the slow wall also bounds how long a wedged verb can hold the `SELECT FOR UPDATE` task row, and `i_will_plan` is exactly the verb the row-lock wedge fix targets. The do-server `commit` tool similarly outlasts its server-side 180s git budget.
- **Cancellation no longer orphans gate subprocesses or drops PR records.** `quality_gate._run_one` killed its child only on its own `TimeoutError`; an outer cancellation (the flow-verb middleware firing mid-gate) skipped the kill and left mypy/ruff running in the workspace while the agent retried into a second concurrent gate run — it now kills and reaps on `CancelledError` too. And `GitService.create_pr` shields the local `_record_pr_atomically` commit, so a cancellation landing after the PR exists on GitHub can no longer leave it locally unrecorded.
- **Video engine hardening (scan follow-ups).** The `video-renderer` sidecar moves off the agent-mesh network onto a dedicated `render` bridge reachable only by the orchestrator (its headless Chrome executes agent-authored composition JS), gains `mem_limit: 2g` / `cpus: 2`, a server-side render watchdog (`RENDER_TIMEOUT_SECONDS`, default 570 — under the orchestrator's 600s client budget) that flushes a 500 and exits so Docker revives a clean container instead of accumulating wedged Chrome trees, and a 512MB tar decompression cap (`MAX_EXTRACTED_BYTES`) alongside the existing compressed-size limit. A terminally-failed render (all retry attempts spent) now sends the CEO an ack-required notification instead of dying as a log line, and `VideoPostService.reject` takes the same heartbeat mutex as `approve`, closing a narrow status-clobber race between a concurrent approve and reject.
- **Dead `python-jose` dependency removed (closes PYSEC-2026-1325 exposure).** `python-jose` was declared but imported nowhere — the auth stack uses fastapi-users' pyjwt — and it transitively pinned `ecdsa`, whose Minerva timing-attack advisory (CVE-2024-23342) has no fix and never will (the maintainers consider side-channel resistance out of scope). Removing the dead dependency (+ its `types-python-jose` stubs and deptry whitelist entry) drops `ecdsa` from the tree entirely, so `pip-audit` goes green by deletion instead of by waiver.
- **Panel `--font-mono` resolved to Inter.** The mono CSS var pointed at the proportional Inter font, so SHAs, task IDs, and code snippets rendered proportional; it now uses a real system monospace stack.
- **Sandbox cold-pull loop + empty provisioning error string.** `docker run` pulled the sandbox image inline under a 20s run deadline, so a NAS cold pull was killed, the pull cancelled, and every retry re-pulled from scratch — a persistent loop that stranded v0.19.0 board-agent spawns with `"error": ""` (a bare `TimeoutError` stringifies to `""`). `_ensure_image` now inspects the image and pulls it under a 300s deadline before `docker run`, and provisioning failures log `f"{type(e).__name__}: {e}"` so the error is never an empty string. - **Sandbox cold-pull loop + empty provisioning error string.** `docker run` pulled the sandbox image inline under a 20s run deadline, so a NAS cold pull was killed, the pull cancelled, and every retry re-pulled from scratch — a persistent loop that stranded v0.19.0 board-agent spawns with `"error": ""` (a bare `TimeoutError` stringifies to `""`). `_ensure_image` now inspects the image and pulls it under a 300s deadline before `docker run`, and provisioning failures log `f"{type(e).__name__}: {e}"` so the error is never an empty string.
- **Conventions + release-readiness I/O no longer blocks the API event loop.** `ConventionsService.get_map/health/restore` and `ReleaseManagerEngine._production_assess` ran sync `git rev-parse`, filesystem walks, and yaml parses inline on the orchestrator's shared uvicorn event loop, stalling API responsiveness during conventions reads (reachable from `GET /api/projects/{id}/conventions` and the agent spawn-prepare path) and the release-manager background loop. Each blocking call is now wrapped in `asyncio.to_thread` at the async boundary; no signature changes. A concurrency audit confirmed the rest of the heavy paths (agent spawn via `docker run -d`, the video render loop, git ops via the 16-worker ThreadPoolExecutor, workspace subprocess calls) already offload correctly — no API/worker container split is warranted. - **Conventions + release-readiness I/O no longer blocks the API event loop.** `ConventionsService.get_map/health/restore` and `ReleaseManagerEngine._production_assess` ran sync `git rev-parse`, filesystem walks, and yaml parses inline on the orchestrator's shared uvicorn event loop, stalling API responsiveness during conventions reads (reachable from `GET /api/projects/{id}/conventions` and the agent spawn-prepare path) and the release-manager background loop. Each blocking call is now wrapped in `asyncio.to_thread` at the async boundary; no signature changes. A concurrency audit confirmed the rest of the heavy paths (agent spawn via `docker run -d`, the video render loop, git ops via the 16-worker ThreadPoolExecutor, workspace subprocess calls) already offload correctly — no API/worker container split is warranted.
+15 -2
View File
@@ -126,11 +126,16 @@ services:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-video-renderer:${ROBOCO_VERSION:-latest} image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-video-renderer:${ROBOCO_VERSION:-latest}
container_name: roboco-video-renderer container_name: roboco-video-renderer
restart: unless-stopped restart: unless-stopped
# Isolated sidecar network: headless Chrome here executes
# agent-authored composition HTML/JS. Only the orchestrator (also
# homed on `render`) can reach it; it can reach nothing else.
networks: networks:
- default - render
# Chrome headless rendering can crash under Docker's default 64MB # Chrome headless rendering can crash under Docker's default 64MB
# /dev/shm ("Chrome crashed"); give it real shared memory. # /dev/shm ("Chrome crashed"); give it real shared memory.
shm_size: "1gb" shm_size: "1gb"
mem_limit: "2g"
cpus: 2
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s interval: 10s
@@ -221,10 +226,12 @@ services:
container_name: roboco-orchestrator container_name: roboco-orchestrator
restart: unless-stopped restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel / # Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis. # ollama, the data network for postgres/redis, plus render to reach the
# isolated video-renderer sidecar.
networks: networks:
- default - default
- data - data
- render
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -373,3 +380,9 @@ networks:
# ports keep working. # ports keep working.
data: data:
name: roboco_data name: roboco_data
# Sidecar isolation: video-renderer executes agent-authored composition
# HTML/JS in headless Chrome. It lives ONLY here, reachable only by the
# orchestrator (multi-homed onto this network too); it can reach nothing
# else on roboco_default or roboco_data.
render:
name: roboco_render
+15 -2
View File
@@ -166,11 +166,16 @@ services:
image: roboco-video-renderer image: roboco-video-renderer
container_name: roboco-video-renderer container_name: roboco-video-renderer
restart: unless-stopped restart: unless-stopped
# Isolated sidecar network: headless Chrome here executes
# agent-authored composition HTML/JS. Only the orchestrator (also
# homed on `render`) can reach it; it can reach nothing else.
networks: networks:
- default - render
# Chrome headless rendering can crash under Docker's default 64MB # Chrome headless rendering can crash under Docker's default 64MB
# /dev/shm ("Chrome crashed"); give it real shared memory. # /dev/shm ("Chrome crashed"); give it real shared memory.
shm_size: "1gb" shm_size: "1gb"
mem_limit: "2g"
cpus: 2
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s interval: 10s
@@ -364,10 +369,12 @@ services:
container_name: roboco-orchestrator container_name: roboco-orchestrator
restart: unless-stopped restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel / # Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis. # ollama, the data network for postgres/redis, plus render to reach the
# isolated video-renderer sidecar.
networks: networks:
- default - default
- data - data
- render
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -662,6 +669,12 @@ networks:
# ports keep working. # ports keep working.
data: data:
name: roboco_data name: roboco_data
# Sidecar isolation: video-renderer executes agent-authored composition
# HTML/JS in headless Chrome. It lives ONLY here, reachable only by the
# orchestrator (multi-homed onto this network too); it can reach nothing
# else on roboco_default or roboco_data.
render:
name: roboco_render
volumes: volumes:
# Named volume for MinIO — keeps rendered-video storage out of the # Named volume for MinIO — keeps rendered-video storage out of the
+15 -2
View File
@@ -166,11 +166,16 @@ services:
image: roboco-video-renderer image: roboco-video-renderer
container_name: roboco-video-renderer container_name: roboco-video-renderer
restart: unless-stopped restart: unless-stopped
# Isolated sidecar network: headless Chrome here executes
# agent-authored composition HTML/JS. Only the orchestrator (also
# homed on `render`) can reach it; it can reach nothing else.
networks: networks:
- default - render
# Chrome headless rendering can crash under Docker's default 64MB # Chrome headless rendering can crash under Docker's default 64MB
# /dev/shm ("Chrome crashed"); give it real shared memory. # /dev/shm ("Chrome crashed"); give it real shared memory.
shm_size: "1gb" shm_size: "1gb"
mem_limit: "2g"
cpus: 2
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s interval: 10s
@@ -364,10 +369,12 @@ services:
container_name: roboco-orchestrator container_name: roboco-orchestrator
restart: unless-stopped restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel / # Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis. # ollama, the data network for postgres/redis, plus render to reach the
# isolated video-renderer sidecar.
networks: networks:
- default - default
- data - data
- render
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -662,6 +669,12 @@ networks:
# ports keep working. # ports keep working.
data: data:
name: roboco_data name: roboco_data
# Sidecar isolation: video-renderer executes agent-authored composition
# HTML/JS in headless Chrome. It lives ONLY here, reachable only by the
# orchestrator (multi-homed onto this network too); it can reach nothing
# else on roboco_default or roboco_data.
render:
name: roboco_render
volumes: volumes:
# Named volume for MinIO — keeps rendered-video storage out of the # Named volume for MinIO — keeps rendered-video storage out of the
+1 -1
View File
@@ -620,7 +620,7 @@ Backend: `EventType.A2A_MESSAGE_SENT` published from `A2AService.send` (excerpt-
## Delta 2026-07-03 (5) — wave 3 → v0.17.0 (branch `feat/wave-3`, SDD/Sonnet 5, reviewed) ## Delta 2026-07-03 (5) — wave 3 → v0.17.0 (branch `feat/wave-3`, SDD/Sonnet 5, reviewed)
Six subsystems, all default-off + additive: Six subsystems, all default-off + additive:
1. **Sandboxed dev DB/Redis/Mongo** (`ROBOCO_SANDBOX_DB_ENABLED`, migration 057 `projects.sandbox_services`): `SandboxProvisioner` (`roboco/runtime/sandbox.py`) `docker run`s throwaway `postgres:16-alpine`/`redis:8-alpine`/`mongo:8-alpine` sibling containers per spawn (random creds, tmpfs, labeled), injecting `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*`/`ROBOCO_TEST_MONGO_*` in place of the prod-creds gate-env. The service set is a pluggable engine registry (`roboco/models/sandbox.py`: `SandboxEngine` ABC + `_PostgresEngine`/`_RedisEngine`/`_MongoEngine`, `SANDBOX_ENGINES` / `VALID_SANDBOX_SERVICES` derived from it); adding an engine is one class + one registry line — no provisioner or env-emitter branch. Container-tracked lifetime + orphan janitor (grace-windowed). REVIEW FIX: pre-spawn stale-clear must not tear down the just-provisioned sandbox (`teardown_sandbox=False`) + provision pre-clears stale + janitor grace; `_ensure_image` inspects+pulls (300s) before `docker run` so a NAS cold pull isn't killed at the 20s run deadline. 1. **Sandboxed dev DB/Redis/Mongo** (`ROBOCO_SANDBOX_DB_ENABLED`, migration 057 `projects.sandbox_services`): `SandboxProvisioner` (`roboco/runtime/sandbox.py`) `docker run`s throwaway `postgres:16-alpine`/`redis:8-alpine`/`mongo:8` sibling containers per spawn (random creds, tmpfs, labeled), injecting `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*`/`ROBOCO_TEST_MONGO_*` in place of the prod-creds gate-env. The service set is a pluggable engine registry (`roboco/models/sandbox.py`: `SandboxEngine` ABC + `_PostgresEngine`/`_RedisEngine`/`_MongoEngine`, `SANDBOX_ENGINES` / `VALID_SANDBOX_SERVICES` derived from it); adding an engine is one class + one registry line — no provisioner or env-emitter branch. Container-tracked lifetime + orphan janitor (grace-windowed). REVIEW FIX: pre-spawn stale-clear must not tear down the just-provisioned sandbox (`teardown_sandbox=False`) + provision pre-clears stale + janitor grace; `_ensure_image` inspects+pulls (300s) before `docker run` so a NAS cold pull isn't killed at the 20s run deadline.
2. **DB network isolation** (`ROBOCO_DB_NETWORK_ISOLATED`): second `roboco_data` compose bridge = postgres+redis only, orchestrator multi-homed; agents can't reach prod DB. Suppresses `_append_gate_env` prod-creds injection. In BOTH build+registry composes (topology-coupled). 2. **DB network isolation** (`ROBOCO_DB_NETWORK_ISOLATED`): second `roboco_data` compose bridge = postgres+redis only, orchestrator multi-homed; agents can't reach prod DB. Suppresses `_append_gate_env` prod-creds injection. In BOTH build+registry composes (topology-coupled).
3. **Mobile UI** (panel): `useIsMobile` (useSyncExternalStore, hydration-safe), `ResponsiveTable` table→card, snap `TabsList`, bottom tab bar, Comms/A2A single-pane drill-down below lg, vh→dvh. REVIEW FIX: `justify-center-safe` (overflow clip), memoized matchMedia subscribe. 3. **Mobile UI** (panel): `useIsMobile` (useSyncExternalStore, hydration-safe), `ResponsiveTable` table→card, snap `TabsList`, bottom tab bar, Comms/A2A single-pane drill-down below lg, vh→dvh. REVIEW FIX: `justify-center-safe` (overflow clip), memoized matchMedia subscribe.
4. **Cloud auth** (`ROBOCO_CLOUD_AUTH_ENABLED`, migration 058 `users`): FastAPI Users, single seeded user, cookie sliding 30-day session (pwd-fingerprint JWT), `get_agent_context` dual-path (deps.py). Off = byte-identical. `proxy.ts` (Next 16). REVIEW FIX: on-mode rejects EVERY non-CEO role without a token (not just ceo — closed the PM/board :8000 spoof). 4. **Cloud auth** (`ROBOCO_CLOUD_AUTH_ENABLED`, migration 058 `users`): FastAPI Users, single seeded user, cookie sliding 30-day session (pwd-fingerprint JWT), `get_agent_context` dual-path (deps.py). Off = byte-identical. `proxy.ts` (Next 16). REVIEW FIX: on-mode rejects EVERY non-CEO role without a token (not just ceo — closed the PM/board :8000 spoof).
+1 -1
View File
@@ -619,7 +619,7 @@ Backend: `EventType.A2A_MESSAGE_SENT` published from `A2AService.send` (excerpt-
## Delta 2026-07-03 (5) — wave 3 → v0.17.0 (branch `feat/wave-3`, SDD/Sonnet 5, reviewed) ## Delta 2026-07-03 (5) — wave 3 → v0.17.0 (branch `feat/wave-3`, SDD/Sonnet 5, reviewed)
Six subsystems, all default-off + additive: Six subsystems, all default-off + additive:
1. **Sandboxed dev DB/Redis/Mongo** (`ROBOCO_SANDBOX_DB_ENABLED`, migration 057 `projects.sandbox_services`): `SandboxProvisioner` (`roboco/runtime/sandbox.py`) `docker run`s throwaway `postgres:16-alpine`/`redis:8-alpine`/`mongo:8-alpine` sibling containers per spawn (random creds, tmpfs, labeled), injecting `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*`/`ROBOCO_TEST_MONGO_*` in place of the prod-creds gate-env. The service set is a pluggable engine registry (`roboco/models/sandbox.py`: `SandboxEngine` ABC + `_PostgresEngine`/`_RedisEngine`/`_MongoEngine`, `SANDBOX_ENGINES` / `VALID_SANDBOX_SERVICES` derived from it); adding an engine is one class + one registry line — no provisioner or env-emitter branch. Container-tracked lifetime + orphan janitor (grace-windowed). REVIEW FIX: pre-spawn stale-clear must not tear down the just-provisioned sandbox (`teardown_sandbox=False`) + provision pre-clears stale + janitor grace; `_ensure_image` inspects+pulls (300s) before `docker run` so a NAS cold pull isn't killed at the 20s run deadline. 1. **Sandboxed dev DB/Redis/Mongo** (`ROBOCO_SANDBOX_DB_ENABLED`, migration 057 `projects.sandbox_services`): `SandboxProvisioner` (`roboco/runtime/sandbox.py`) `docker run`s throwaway `postgres:16-alpine`/`redis:8-alpine`/`mongo:8` sibling containers per spawn (random creds, tmpfs, labeled), injecting `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*`/`ROBOCO_TEST_MONGO_*` in place of the prod-creds gate-env. The service set is a pluggable engine registry (`roboco/models/sandbox.py`: `SandboxEngine` ABC + `_PostgresEngine`/`_RedisEngine`/`_MongoEngine`, `SANDBOX_ENGINES` / `VALID_SANDBOX_SERVICES` derived from it); adding an engine is one class + one registry line — no provisioner or env-emitter branch. Container-tracked lifetime + orphan janitor (grace-windowed). REVIEW FIX: pre-spawn stale-clear must not tear down the just-provisioned sandbox (`teardown_sandbox=False`) + provision pre-clears stale + janitor grace; `_ensure_image` inspects+pulls (300s) before `docker run` so a NAS cold pull isn't killed at the 20s run deadline.
2. **DB network isolation** (`ROBOCO_DB_NETWORK_ISOLATED`): second `roboco_data` compose bridge = postgres+redis only, orchestrator multi-homed; agents can't reach prod DB. Suppresses `_append_gate_env` prod-creds injection. In BOTH build+registry composes (topology-coupled). 2. **DB network isolation** (`ROBOCO_DB_NETWORK_ISOLATED`): second `roboco_data` compose bridge = postgres+redis only, orchestrator multi-homed; agents can't reach prod DB. Suppresses `_append_gate_env` prod-creds injection. In BOTH build+registry composes (topology-coupled).
3. **Mobile UI** (panel): `useIsMobile` (useSyncExternalStore, hydration-safe), `ResponsiveTable` table→card, snap `TabsList`, bottom tab bar, Comms/A2A single-pane drill-down below lg, vh→dvh. REVIEW FIX: `justify-center-safe` (overflow clip), memoized matchMedia subscribe. 3. **Mobile UI** (panel): `useIsMobile` (useSyncExternalStore, hydration-safe), `ResponsiveTable` table→card, snap `TabsList`, bottom tab bar, Comms/A2A single-pane drill-down below lg, vh→dvh. REVIEW FIX: `justify-center-safe` (overflow clip), memoized matchMedia subscribe.
4. **Cloud auth** (`ROBOCO_CLOUD_AUTH_ENABLED`, migration 058 `users`): FastAPI Users, single seeded user, cookie sliding 30-day session (pwd-fingerprint JWT), `get_agent_context` dual-path (deps.py). Off = byte-identical. `proxy.ts` (Next 16). REVIEW FIX: on-mode rejects EVERY non-CEO role without a token (not just ceo — closed the PM/board :8000 spoof). 4. **Cloud auth** (`ROBOCO_CLOUD_AUTH_ENABLED`, migration 058 `users`): FastAPI Users, single seeded user, cookie sliding 30-day session (pwd-fingerprint JWT), `get_agent_context` dual-path (deps.py). Off = byte-identical. `proxy.ts` (Next 16). REVIEW FIX: on-mode rejects EVERY non-CEO role without a token (not just ceo — closed the PM/board :8000 spoof).
+1 -1
View File
@@ -10,7 +10,7 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent
| roboco/runtime/streaming.py | Global reasoning-stream callback holder+setter for live UI streaming | 53 | | roboco/runtime/streaming.py | Global reasoning-stream callback holder+setter for live UI streaming | 53 |
| roboco/runtime/transcript_retention.py | Pure selector of agent-owned old Claude transcripts to prune (never operator dirs) | 74 | | roboco/runtime/transcript_retention.py | Pure selector of agent-owned old Claude transcripts to prune (never operator dirs) | 74 |
| roboco/runtime/sandbox.py | `SandboxProvisioner` — throwaway per-agent-spawn engine sibling containers (postgres/redis/mongo via the `SANDBOX_ENGINES` registry in `roboco/models/sandbox.py`, orchestrator-side, never docker-in-agent); generic `_provision_engine` per engine, provision/teardown/janitor_sweep, standalone + unit-testable via an injected `DockerRunner` | 344 | | roboco/runtime/sandbox.py | `SandboxProvisioner` — throwaway per-agent-spawn engine sibling containers (postgres/redis/mongo via the `SANDBOX_ENGINES` registry in `roboco/models/sandbox.py`, orchestrator-side, never docker-in-agent); generic `_provision_engine` per engine, provision/teardown/janitor_sweep, standalone + unit-testable via an injected `DockerRunner` | 344 |
| roboco/models/sandbox.py | Pure engine registry — `SandboxEngine` ABC + `_PostgresEngine` (`postgres:16-alpine`) / `_RedisEngine` (`redis:8-alpine`) / `_MongoEngine` (`mongo:8-alpine`, mongosh readiness probe, auth db `admin`); `SandboxConnection` / `SandboxInfo` (with `emit_env`); `SANDBOX_ENGINES` + `VALID_SANDBOX_SERVICES` (derived). Adding an engine = one class + one registry line — no provisioner or env-emitter branch. Lives in the models layer so `roboco/models/project.py` can derive the allowlist without importing the runtime layer. | 232 | | roboco/models/sandbox.py | Pure engine registry — `SandboxEngine` ABC + `_PostgresEngine` (`postgres:16-alpine`) / `_RedisEngine` (`redis:8-alpine`) / `_MongoEngine` (`mongo:8`, mongosh readiness probe, auth db `admin`); `SandboxConnection` / `SandboxInfo` (with `emit_env`); `SANDBOX_ENGINES` + `VALID_SANDBOX_SERVICES` (derived). Adding an engine = one class + one registry line — no provisioner or env-emitter branch. Lives in the models layer so `roboco/models/project.py` can derive the allowlist without importing the runtime layer. | 232 |
| roboco/llm/__init__.py | Re-exports ToonAdapter/ToonMetrics singletons | 17 | | roboco/llm/__init__.py | Re-exports ToonAdapter/ToonMetrics singletons | 17 |
| roboco/llm/metrics.py | Singleton holder for TOON token-savings metrics | 21 | | roboco/llm/metrics.py | Singleton holder for TOON token-savings metrics | 21 |
| roboco/llm/toon_adapter.py | TOON serialization adapter for token-efficient LLM communication (JSON fallback) | 188 | | roboco/llm/toon_adapter.py | TOON serialization adapter for token-efficient LLM communication (JSON fallback) | 188 |
+1 -1
View File
@@ -86,7 +86,7 @@ Env-gated subsystems. Most are default-off; `ROBOCO_OVERLOAD_BREAK_ENABLED`, `RO
| `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` | `true` | PR-gate turn cut: when every child of an assembled parent is terminal, run the real `submit_up` / `submit_root` system-side as the owning PM (`_try_auto_submit`) instead of spawning the PM for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code. A gate rejection falls back to the classic PM closure spawn; the PM keeps the judgment turns (merge, revision). Each auto-submit leaves a `task.auto_submitted` audit row. Off = every closure spawns the PM to submit. | | `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` | `true` | PR-gate turn cut: when every child of an assembled parent is terminal, run the real `submit_up` / `submit_root` system-side as the owning PM (`_try_auto_submit`) instead of spawning the PM for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code. A gate rejection falls back to the classic PM closure spawn; the PM keeps the judgment turns (merge, revision). Each auto-submit leaves a `task.auto_submitted` audit row. Off = every closure spawns the PM to submit. |
| `ROBOCO_SPAWN_PREFLIGHT_ENABLED` | `false` | Refuse to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES` (no manifest → can never claim → would respawn on the same task forever); refuse + alert the overseer once instead. Inert in practice (all delivery roles are gateway-enabled). Armed on the NAS composes. | | `ROBOCO_SPAWN_PREFLIGHT_ENABLED` | `false` | Refuse to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES` (no manifest → can never claim → would respawn on the same task forever); refuse + alert the overseer once instead. Inert in practice (all delivery roles are gateway-enabled). Armed on the NAS composes. |
| `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a — task-less, so the readiness gate and respawn breaker never see them): one spawn per (agent, notification) per window; the notification stays pending so the next window retries. `0` = legacy every-tick respawn. | | `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a — task-less, so the readiness gate and respawn breaker never see them): one spawn per (agent, notification) per window; the notification stays pending so the next window retries. `0` = legacy every-tick respawn. |
| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Sandboxed per-agent-spawn test DB/Redis/Mongo: throwaway sibling containers provisioned from the engine registry in `roboco/models/sandbox.py` (postgres:16-alpine / redis:8-alpine / mongo:8-alpine), per-project opt-in. The valid-service set is `VALID_SANDBOX_SERVICES` (registry-derived). See "Sandboxed Dev DB/Redis/Mongo" below and `docs/rag/architecture/sandbox-db.md`. | | `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Sandboxed per-agent-spawn test DB/Redis/Mongo: throwaway sibling containers provisioned from the engine registry in `roboco/models/sandbox.py` (postgres:16-alpine / redis:8-alpine / mongo:8), per-project opt-in. The valid-service set is `VALID_SANDBOX_SERVICES` (registry-derived). See "Sandboxed Dev DB/Redis/Mongo" below and `docs/rag/architecture/sandbox-db.md`. |
| `ROBOCO_X_ENGINE_ENABLED` | `false` | The X (Twitter) engine: draft release/mention posts, ALL held for per-post CEO approval. See "X (Twitter) Engine" below and `docs/rag/architecture/x-engine.md`. | | `ROBOCO_X_ENGINE_ENABLED` | `false` | The X (Twitter) engine: draft release/mention posts, ALL held for per-post CEO approval. See "X (Twitter) Engine" below and `docs/rag/architecture/x-engine.md`. |
| `ROBOCO_ROADMAP_ENGINE_ENABLED` | `false` | The board roadmap engine: weekly Product-Owner-authored cycle, CEO approves each item individually into BACKLOG. See "Board Roadmap Engine" below. | | `ROBOCO_ROADMAP_ENGINE_ENABLED` | `false` | The board roadmap engine: weekly Product-Owner-authored cycle, CEO approves each item individually into BACKLOG. See "Board Roadmap Engine" below. |
+1 -1
View File
@@ -12,7 +12,7 @@ The service set is a **pluggable engine registry**, not a hardcoded postgres+red
- `_PostgresEngine``postgres:16-alpine`, tmpfs `/var/lib/postgresql/data`, `pg_isready` probe (60s), env `ROBOCO_TEST_DB_*` (incl. `ROBOCO_TEST_DB_ADMIN_DB`). - `_PostgresEngine``postgres:16-alpine`, tmpfs `/var/lib/postgresql/data`, `pg_isready` probe (60s), env `ROBOCO_TEST_DB_*` (incl. `ROBOCO_TEST_DB_ADMIN_DB`).
- `_RedisEngine``redis:8-alpine`, no tmpfs, `redis-cli -a … ping` probe (15s), env `ROBOCO_TEST_REDIS_*`. - `_RedisEngine``redis:8-alpine`, no tmpfs, `redis-cli -a … ping` probe (15s), env `ROBOCO_TEST_REDIS_*`.
- `_MongoEngine``mongo:8-alpine`, tmpfs `/data/db`, `mongosh` ping against auth db `admin` (60s), env `ROBOCO_TEST_MONGO_*` (incl. `ROBOCO_TEST_MONGO_AUTH_DB=admin`). - `_MongoEngine``mongo:8` (MongoDB ships no Alpine variant), tmpfs `/data/db`, `mongosh` ping against auth db `admin` (60s), env `ROBOCO_TEST_MONGO_*` (incl. `ROBOCO_TEST_MONGO_AUTH_DB=admin`).
`SANDBOX_ENGINES: dict[str, SandboxEngine]` registers them by name; `VALID_SANDBOX_SERVICES = frozenset(SANDBOX_ENGINES)` is the single source of truth the provisioner, the orchestrator's env injection, and `projects.sandbox_services` validation all consult. **Adding an engine is one class + one registry line** — no branch edited in the provisioner or the env emitter, which both iterate the registry. `SANDBOX_ENGINES: dict[str, SandboxEngine]` registers them by name; `VALID_SANDBOX_SERVICES = frozenset(SANDBOX_ENGINES)` is the single source of truth the provisioner, the orchestrator's env injection, and `projects.sandbox_services` validation all consult. **Adding an engine is one class + one registry line** — no branch edited in the provisioner or the env emitter, which both iterate the registry.
+2 -1
View File
@@ -8,7 +8,8 @@
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-inter); --font-sans: var(--font-inter);
--font-mono: var(--font-inter); --font-mono:
ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+1 -2
View File
@@ -395,7 +395,7 @@ DEP002 = [
"python-multipart", "python-multipart",
# Database migrations (CLI tool) # Database migrations (CLI tool)
"alembic", "alembic",
# Auth libraries (used via passlib[bcrypt]; JWT via PyJWT + fastapi_users.jwt) # Auth libraries (used via passlib[bcrypt])
"passlib", "passlib",
# LLM utilities (embeddings/token counting) # LLM utilities (embeddings/token counting)
"openai", "openai",
@@ -423,7 +423,6 @@ DEP002 = [
"rich", "rich",
# Type stubs (used by mypy) # Type stubs (used by mypy)
"types-passlib", "types-passlib",
"types-python-jose",
"types-PyYAML", "types-PyYAML",
] ]
# DEP003: Starlette is a transitive dep of FastAPI, but BaseHTTPMiddleware is needed # DEP003: Starlette is a transitive dep of FastAPI, but BaseHTTPMiddleware is needed
+14 -1
View File
@@ -30,6 +30,7 @@ from roboco.exceptions import (
RobocoError, RobocoError,
ValidationError, ValidationError,
) )
from roboco.foundation.policy.flow_timeouts import SLOW_VERBS as _SLOW_VERBS
from roboco.services.base import ( from roboco.services.base import (
ConflictError as ServiceConflictError, ConflictError as ServiceConflictError,
) )
@@ -504,6 +505,13 @@ class FlowVerbTimeoutMiddleware:
Reads (``evidence``) and journal writes (``note``) don't touch the task Reads (``evidence``) and journal writes (``note``) don't touch the task
row, so they are unaffected; only task-row writes route through ``claim``. row, so they are unaffected; only task-row writes route through ``claim``.
``_SLOW_VERBS`` (from ``roboco.foundation.policy.flow_timeouts`` git
push + quality gate, a multi-step PR-create chain, workspace clone, or
planning writes) get the longer ``flow_verb_slow_timeout_seconds`` budget
instead of the default routine calls to those verbs otherwise exceed
120s. The same set drives the agent-side MCP client's timeout
(``roboco/mcp/flow_server.py``) so the two walls can't drift apart.
""" """
def __init__(self, app: ASGIApp) -> None: def __init__(self, app: ASGIApp) -> None:
@@ -513,7 +521,12 @@ class FlowVerbTimeoutMiddleware:
if scope["type"] != "http" or not scope["path"].startswith("/api/v1/flow/"): if scope["type"] != "http" or not scope["path"].startswith("/api/v1/flow/"):
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
timeout = settings.flow_verb_timeout_seconds verb = scope["path"].rstrip("/").rsplit("/", 1)[-1]
timeout = (
settings.flow_verb_slow_timeout_seconds
if verb in _SLOW_VERBS
else settings.flow_verb_timeout_seconds
)
started = False started = False
async def send_wrapper(message: Any) -> None: async def send_wrapper(message: Any) -> None:
+12
View File
@@ -1304,6 +1304,18 @@ class Settings(BaseSettings):
"legitimate verbs are unaffected." "legitimate verbs are unaffected."
), ),
) )
flow_verb_slow_timeout_seconds: int = Field(
default=900,
ge=1,
description=(
"Server-side timeout for the slow flow verbs (i_am_done, "
"submit_up, submit_root, open_pr) — a git push plus a "
"per-command-budgeted quality gate, or a multi-step PR-create "
"chain, routinely exceeds flow_verb_timeout_seconds. "
"FlowVerbTimeoutMiddleware picks this budget for those verbs by "
"request path instead of the default."
),
)
git_commit_timeout_seconds: int = Field( git_commit_timeout_seconds: int = Field(
default=180, default=180,
ge=30, ge=30,
+35
View File
@@ -0,0 +1,35 @@
"""Flow-verb timeout budgets — single source for both timeout walls.
Two independent walls exist on every ``/api/v1/flow/*`` call: the server's
``FlowVerbTimeoutMiddleware`` (``roboco/api/middleware.py``) and the agent-side
MCP client's ``httpx.Client(timeout=...)`` (``roboco/mcp/flow_server.py``). The
client wall must always OUTLAST the server wall otherwise the agent's httpx
client gives up with a raw transport error before the middleware ever gets to
return its clean, retryable 504 ``gateway_timeout`` envelope, and the agent
sees a Python exception instead of a directed remediation hint. Both sides
import ``SLOW_VERBS`` from here so they can never classify a verb differently.
"""
from __future__ import annotations
# Verbs whose own work routinely exceeds the default flow-verb budget:
# i_am_done (git push + the pre-submit quality gate), submit_up / submit_root
# / open_pr (a multi-step PR-create chain), i_will_work_on (workspace clone,
# up to 300s). Deliberately EXCLUDES i_will_plan / delegate: the slow budget
# also governs the server middleware wall, and i_will_plan is exactly the
# verb that wedged in #326 holding the SELECT FOR UPDATE task-row lock — a
# 900s budget would let a wedged planning verb block its task row for 15
# minutes instead of 2. Healthy planning verbs are DB writes that complete
# in seconds; their 30s+ production runs were contention symptoms.
SLOW_VERBS = frozenset(
{
"i_am_done",
"submit_up",
"submit_root",
"open_pr",
"i_will_work_on",
}
)
# Client-side margin added on top of the matching server budget.
CLIENT_HEADROOM_SECONDS = 10
+19 -3
View File
@@ -35,6 +35,13 @@ AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"] AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
_TIMEOUT = 30 _TIMEOUT = 30
# commit() stages + `git commit`s in-process (no push — push is the flow
# verb open_pr, already covered by flow_server's per-verb timeout), bounded
# server-side by git_commit_timeout_seconds (default 180s: a large changeset,
# e.g. the panel's hundreds of files, can legitimately take that long). The
# shared _TIMEOUT above is tuned for fast content-tool calls (note/dm/
# evidence) and would give up first — client must outlast the server op.
_COMMIT_TIMEOUT = 190
# Tight timeout for SDK loopback — local sidecar; gateway path must not stall. # Tight timeout for SDK loopback — local sidecar; gateway path must not stall.
_SDK_TIMEOUT = 2.0 _SDK_TIMEOUT = 2.0
# FastAPI's default missing-route status. Every /api/v1/do/* route returns # FastAPI's default missing-route status. Every /api/v1/do/* route returns
@@ -247,7 +254,9 @@ def _build_headers() -> dict[str, str]:
return headers return headers
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: def _post(
path: str, body: dict[str, Any], *, timeout: float = _TIMEOUT
) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope. """POST a request to the orchestrator and return the JSON envelope.
Mirrors flow_server._post: surfaces the orchestrator's envelope on Mirrors flow_server._post: surfaces the orchestrator's envelope on
@@ -260,8 +269,11 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
REPLACED with circuit_open. Dogfooding surfaced the gap: do-server had REPLACED with circuit_open. Dogfooding surfaced the gap: do-server had
no breaker and `note(scope='decision')` looped 8 times returning no breaker and `note(scope='decision')` looped 8 times returning
incomplete_input. incomplete_input.
``timeout`` overrides the default for a slow tool (e.g. commit's
_COMMIT_TIMEOUT) must always outlast that tool's server-side budget.
""" """
with httpx.Client(timeout=_TIMEOUT) as client: with httpx.Client(timeout=timeout) as client:
response = client.post( response = client.post(
f"{ORCHESTRATOR_URL}{path}", f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(), headers=_build_headers(),
@@ -434,7 +446,11 @@ def _record_and_check_circuit(
def commit(message: str, files: list[str] | None = None) -> dict[str, Any]: def commit(message: str, files: list[str] | None = None) -> dict[str, Any]:
"""Make a git commit. [task-id] prefix auto-applied. Validates message.""" """Make a git commit. [task-id] prefix auto-applied. Validates message."""
return _post("/api/v1/do/commit", {"message": message, "files": files}) return _post(
"/api/v1/do/commit",
{"message": message, "files": files},
timeout=_COMMIT_TIMEOUT,
)
def note( def note(
+30 -2
View File
@@ -27,6 +27,7 @@ from pydantic import BeforeValidator
from roboco.agents_config import get_agent_team from roboco.agents_config import get_agent_team
from roboco.foundation.policy.content.validators import coerce_str_list from roboco.foundation.policy.content.validators import coerce_str_list
from roboco.foundation.policy.flow_timeouts import CLIENT_HEADROOM_SECONDS, SLOW_VERBS
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input # A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
# parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives # parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives
@@ -50,7 +51,22 @@ SDK_URL = os.environ.get("ROBOCO_SDK_URL", "http://localhost:9000")
AGENT_ID = os.environ["ROBOCO_AGENT_ID"] AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"] AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"]
_TIMEOUT = 30 # Client wall = the matching server wall (FlowVerbTimeoutMiddleware) plus
# headroom, so the client always outlasts the server's asyncio.timeout and
# sees the clean 504 gateway_timeout envelope instead of a raw transport
# timeout. This module can't read Settings (it's a subprocess in the agent
# container), so the two server budgets are mirrored via env vars the
# orchestrator injects at spawn from settings.flow_verb_timeout_seconds /
# flow_verb_slow_timeout_seconds; the literal fallbacks match those settings'
# own defaults (120 / 900).
_SERVER_TIMEOUT_SECONDS = float(
os.environ.get("ROBOCO_FLOW_VERB_TIMEOUT_SECONDS", "120")
)
_SERVER_SLOW_TIMEOUT_SECONDS = float(
os.environ.get("ROBOCO_FLOW_VERB_SLOW_TIMEOUT_SECONDS", "900")
)
_TIMEOUT = _SERVER_TIMEOUT_SECONDS + CLIENT_HEADROOM_SECONDS
_SLOW_TIMEOUT = _SERVER_SLOW_TIMEOUT_SECONDS + CLIENT_HEADROOM_SECONDS
# Tight timeout for SDK loopback — the SDK is a local sidecar; anything # Tight timeout for SDK loopback — the SDK is a local sidecar; anything
# slower than 2s is unhealthy and the gateway path must not stall on it. # slower than 2s is unhealthy and the gateway path must not stall on it.
_SDK_TIMEOUT = 2.0 _SDK_TIMEOUT = 2.0
@@ -285,6 +301,16 @@ def _build_headers() -> dict[str, str]:
return headers return headers
def _client_timeout_for(verb: str) -> float:
"""The httpx client timeout for one verb — must outlast its server wall.
Mirrors ``FlowVerbTimeoutMiddleware``'s own budget selection so the two
walls agree: a slow verb gets the slow server budget + headroom, every
other verb gets the default budget + headroom.
"""
return _SLOW_TIMEOUT if verb in SLOW_VERBS else _TIMEOUT
def _post(path: str, body: dict[str, Any]) -> dict[str, Any]: def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
"""POST a request to the orchestrator and return the JSON envelope. """POST a request to the orchestrator and return the JSON envelope.
@@ -301,7 +327,9 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
being returned to the agent preventing further hammering on a verb being returned to the agent preventing further hammering on a verb
that won't succeed. Successful (ok) envelopes never touch the SDK. that won't succeed. Successful (ok) envelopes never touch the SDK.
""" """
with httpx.Client(timeout=_TIMEOUT) as client: # Client must outlast the server middleware budget so agents get the
# 504 envelope, not a raw timeout.
with httpx.Client(timeout=_client_timeout_for(_verb_from_path(path))) as client:
response = client.post( response = client.post(
f"{ORCHESTRATOR_URL}{path}", f"{ORCHESTRATOR_URL}{path}",
headers=_build_headers(), headers=_build_headers(),
+1 -3
View File
@@ -169,7 +169,7 @@ class _RedisEngine(SandboxEngine):
class _MongoEngine(SandboxEngine): class _MongoEngine(SandboxEngine):
name = "mongo" name = "mongo"
image = "mongo:8-alpine" image = "mongo:8"
container_port = 27017 container_port = 27017
ready_deadline = 60.0 ready_deadline = 60.0
tmpfs = ("/data/db",) tmpfs = ("/data/db",)
@@ -181,8 +181,6 @@ class _MongoEngine(SandboxEngine):
"MONGO_INITDB_ROOT_USERNAME=sandbox", "MONGO_INITDB_ROOT_USERNAME=sandbox",
"-e", "-e",
f"MONGO_INITDB_ROOT_PASSWORD={password}", f"MONGO_INITDB_ROOT_PASSWORD={password}",
"-e",
"MONGO_INITDB_DATABASE=sandbox",
] ]
def run_command(self, _password: str) -> list[str]: def run_command(self, _password: str) -> list[str]:
+34
View File
@@ -3150,6 +3150,14 @@ class AgentOrchestrator:
"ROBOCO_ORCHESTRATOR_URL": api_url, "ROBOCO_ORCHESTRATOR_URL": api_url,
"ROBOCO_AGENT_ID": agent_uuid, "ROBOCO_AGENT_ID": agent_uuid,
"ROBOCO_AGENT_ROLE": agent_role, "ROBOCO_AGENT_ROLE": agent_role,
# Mirrors the server-side FlowVerbTimeoutMiddleware budgets so the
# roboco-flow MCP client's per-verb timeout (flow_server.py, which
# can't read Settings directly) stays coherent with operator
# tuning of either setting.
"ROBOCO_FLOW_VERB_TIMEOUT_SECONDS": str(settings.flow_verb_timeout_seconds),
"ROBOCO_FLOW_VERB_SLOW_TIMEOUT_SECONDS": str(
settings.flow_verb_slow_timeout_seconds
),
# Every MCP server is launched as `uv run python -m # Every MCP server is launched as `uv run python -m
# roboco.mcp.<server>` by Claude Code, with cwd = the agent's # roboco.mcp.<server>` by Claude Code, with cwd = the agent's
# WORKSPACE (not /app). Without this, `uv run` resolves a # WORKSPACE (not /app). Without this, `uv run` resolves a
@@ -7801,6 +7809,32 @@ Start by:
terminal=terminal, terminal=terminal,
error=str(exc), error=str(exc),
) )
if terminal:
await self._notify_video_render_failure(task, str(exc))
async def _notify_video_render_failure(self, task: Any, last_error: str) -> None:
"""Send one CEO alert that a video render exhausted its retries.
Best-effort, mirroring ``_notify_strategy_engine_failure`` a
notification-send failure must never raise out of the render loop.
"""
try:
from roboco.services.notification import NotificationService
await NotificationService().send_ack_notification(
from_agent="system",
to_agent="ceo",
body=(
f"[video engine] render terminally failed for task "
f"{task.title!r} ({_MAX_VIDEO_RENDER_ATTEMPTS} attempts "
f"exhausted): {last_error}"
),
task_id=task.id,
)
except Exception:
logger.exception(
"video-render failure-notify dropped", task_id=str(task.id)
)
async def _render_both_cuts( async def _render_both_cuts(
self, db: Any, draft: dict[str, Any], composition_id: str, render_key: str self, db: Any, draft: dict[str, Any], composition_id: str, render_key: str
+8
View File
@@ -91,6 +91,14 @@ async def _run_one(workspace: Path, command: str) -> tuple[int, str]:
# the process lingers as a transient zombie and the FDs leak. # the process lingers as a transient zombie and the FDs leak.
await proc.wait() await proc.wait()
return 124, f"command timed out after {_GATE_TIMEOUT_SECONDS}s" return 124, f"command timed out after {_GATE_TIMEOUT_SECONDS}s"
except asyncio.CancelledError:
# An outer cancellation (e.g. FlowVerbTimeoutMiddleware's own
# asyncio.timeout expiring around the whole submit) throws in here
# instead of the wait_for's own TimeoutError above — same orphaned
# child + leaked FDs if left unkilled. Kill/reap, then propagate.
proc.kill()
await proc.wait()
raise
rc = proc.returncode rc = proc.returncode
if rc is None: if rc is None:
# communicate() returned without a recorded exit code (the process # communicate() returned without a recorded exit code (the process
+38 -2
View File
@@ -25,6 +25,8 @@ from uuid import UUID
import httpx import httpx
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
# `api.schemas.git` would trigger `api/__init__.py` (which historically # `api.schemas.git` would trigger `api/__init__.py` (which historically
@@ -97,6 +99,26 @@ def _commit_git_timeout() -> int:
return settings.git_commit_timeout_seconds return settings.git_commit_timeout_seconds
async def _await_shielded[T](coro: Coroutine[Any, Any, T]) -> T:
"""Run a session write shielded from cancellation, waiting it out.
A bare ``asyncio.shield(coro)`` detaches the write on cancellation but
lets the CancelledError propagate immediately the still-running write
then races ``get_db``'s rollback on the SAME AsyncSession and asyncpg
raises ``InterfaceError: another operation is in progress`` (a 500
instead of the middleware's clean 504). On cancellation, await the
in-flight write to completion BEFORE re-raising, so the session is quiet
by the time the rollback runs. Mirrors
``VideoPostService._commit_shielded``.
"""
task = asyncio.ensure_future(coro)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
await asyncio.gather(task, return_exceptions=True)
raise
def _completed_branchful_children(children: list[Any]) -> list[Any]: def _completed_branchful_children(children: list[Any]) -> list[Any]:
"""Children whose completed work can be integrity-checked: completed, """Children whose completed work can be integrity-checked: completed,
branch-bearing, with recorded commits.""" branch-bearing, with recorded commits."""
@@ -3546,7 +3568,13 @@ class GitService(BaseService):
if found: if found:
pr_number = int(found["number"]) pr_number = int(found["number"])
pr_url = str(found["html_url"]) pr_url = str(found["html_url"])
await self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url) # Shielded + waited-out: the PR already exists on GitHub, so
# a cancellation here must not skip recording it locally —
# and shield alone would leave the detached write racing
# get_db's rollback on the same session (see _await_shielded).
await _await_shielded(
self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
)
return { return {
"pr_number": pr_number, "pr_number": pr_number,
"pr_url": pr_url, "pr_url": pr_url,
@@ -3563,7 +3591,15 @@ class GitService(BaseService):
pr_data = resp.json() pr_data = resp.json()
pr_number = int(pr_data["number"]) pr_number = int(pr_data["number"])
pr_url = str(pr_data["html_url"]) pr_url = str(pr_data["html_url"])
await self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url) # _post_pr already created the PR on GitHub — shield the local record
# so a cancellation landing between the POST and this commit can't
# leave it unrecorded (self-heals on retry via _find_existing_pr, but
# only after this window closes). Waited-out, not bare shield: the
# detached write must finish before get_db's rollback touches the
# same session (see _await_shielded).
await _await_shielded(
self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
)
return {"pr_number": pr_number, "pr_url": pr_url, "is_root_pr": is_root_pr} return {"pr_number": pr_number, "pr_url": pr_url, "is_root_pr": is_root_pr}
async def _lock_parent_task_for_merge(self, parent_task_id: UUID | None) -> None: async def _lock_parent_task_for_merge(self, parent_task_id: UUID | None) -> None:
+5
View File
@@ -323,6 +323,11 @@ class LearningPropagationService:
# One bulk INSERT — replaces N sequential _create_notification calls # One bulk INSERT — replaces N sequential _create_notification calls
# each opening their own session/transaction (pool pressure). # each opening their own session/transaction (pool pressure).
# id set explicitly so delivery can address each row by UUID. # id set explicitly so delivery can address each row by UUID.
# This bypasses NotificationService's dedup guards (_duplicate_unacked_exists
# + the delivery-side coalesce) entirely — safe only because KNOWLEDGE_SHARE
# is exempt from both (ACK_REQUIRED_BY_TYPE[KNOWLEDGE_SHARE]=False: one-shot,
# not ack-required). Reclassifying KNOWLEDGE_SHARE as ack-required must
# revisit this path or dedup silently stops applying to it.
notification_ids = [uuid4() for _ in agents] notification_ids = [uuid4() for _ in agents]
rows = [ rows = [
{ {
+41 -4
View File
@@ -483,7 +483,17 @@ class VideoPostService(BaseService):
) )
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None: async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
"""Record the CEO's reason and cancel the draft (never posted).""" """Record the CEO's reason and cancel the draft (never posted).
Acquires the same post-mutex ``approve()`` holds (same key, same
non-blocking acquire style) so a reject can't interleave with a
concurrent in-flight approve. Fails CLOSED like approve, both when
the lock is held (approve mid-post) and when Redis is unreachable:
an approve that took the lock while Redis was up stays authoritative
through the heartbeat grace window after Redis drops, so an unlocked
reject could CANCEL a draft that approve is mid-posting the CEO
retries the reject once Redis is back.
"""
task = await get_task_service(self.session).get(task_id) task = await get_task_service(self.session).get(task_id)
if task is None or task.source != VIDEO_POST_SOURCE: if task is None or task.source != VIDEO_POST_SOURCE:
return None return None
@@ -491,10 +501,37 @@ class VideoPostService(BaseService):
raise TaskAlreadyCompletedError( raise TaskAlreadyCompletedError(
f"video post {task_id} already posted (COMPLETED); cannot be rejected" f"video post {task_id} already posted (COMPLETED); cannot be rejected"
) )
markers.set_video_reject_reason(task, reason)
task.status = TaskStatus.CANCELLED mutex = HeartbeatMutex(
f"{_LOCK_PREFIX}{task_id}",
ttl_seconds=_LOCK_TTL_SECONDS,
heartbeat_seconds=_LOCK_HEARTBEAT_SECONDS,
)
try:
token = await mutex.acquire()
except HeartbeatLockUnavailable as exc:
logger.error("video-post reject lock unavailable (redis down): %s", exc)
return None
if token is None:
return None # a concurrent approve is mid-post; refuse the reject
try:
# Re-read under the lock: a concurrent approve may have posted +
# committed COMPLETED between the pre-lock check and here.
self.session.expire(task)
locked = await get_task_service(self.session).get(task_id)
if locked is None:
return None
if locked.status == TaskStatus.COMPLETED:
raise TaskAlreadyCompletedError(
f"video post {task_id} already posted (COMPLETED); "
"cannot be rejected"
)
markers.set_video_reject_reason(locked, reason)
locked.status = TaskStatus.CANCELLED
await self.session.flush() await self.session.flush()
return task return locked
finally:
await mutex.release(token)
def get_video_post_service( def get_video_post_service(
+5 -3
View File
@@ -212,9 +212,11 @@ def test_flow_verb_holds_lock_when_timeout_disarmed(
_patch_hang_in_set_plan(monkeypatch) _patch_hang_in_set_plan(monkeypatch)
# Prime the per-agent flow_server reload with a no-op verb (i_am_idle # Prime the per-agent flow_server reload with a no-op verb (i_am_idle
# touches no task). The reload resets module globals (_TIMEOUT=30), so a # touches no task). The reload resets module globals (the env-derived
# pre-call patch would be clobbered; after this call the module is # _TIMEOUT), so a pre-call patch would be clobbered; after this call the
# pinned to this agent and the patch below survives. # module is pinned to this agent and the patch below survives.
# i_will_plan is a default-budget verb (not in SLOW_VERBS), so the
# client selects _TIMEOUT for it.
main_pm.flow("i_am_idle") main_pm.flow("i_am_idle")
monkeypatch.setattr(flow_server, "_TIMEOUT", _MCP_CLIENT_TIMEOUT_SECONDS) monkeypatch.setattr(flow_server, "_TIMEOUT", _MCP_CLIENT_TIMEOUT_SECONDS)
@@ -0,0 +1,48 @@
"""Regression guard for the 2026-07-08 ``mongo:8-alpine`` ghost-tag bug.
``roboco/models/sandbox.py`` pinned ``_MongoEngine.image = "mongo:8-alpine"``,
a tag that has never existed on Docker Hub (MongoDB ships no Alpine variant).
Every unit test mocks the docker CLI, so none of them ever touch a real
registry and none caught it the bug only surfaces the moment a real
``docker run`` pulls the image. This test queries the Docker Hub registry API
for every ``SANDBOX_ENGINES`` entry's pinned ``image:tag`` and fails if the
tag does not actually exist, which is the check that would have caught it.
Network-dependent by design; skips cleanly when the registry is unreachable
rather than failing (mirrors ``test_background_engines.py``'s local-Redis
reachability skip).
"""
from __future__ import annotations
import httpx
import pytest
from roboco.models.sandbox import SANDBOX_ENGINES
_REGISTRY_URL = (
"https://registry.hub.docker.com/v2/repositories/library/{name}/tags/{tag}"
)
_TIMEOUT_SECONDS = 10.0
_HTTP_OK = 200
def _split_image(image: str) -> tuple[str, str]:
name, _, tag = image.partition(":")
return name, tag or "latest"
@pytest.mark.parametrize("engine_name", sorted(SANDBOX_ENGINES))
def test_sandbox_engine_image_tag_exists_on_docker_hub(engine_name: str) -> None:
image = SANDBOX_ENGINES[engine_name].image
name, tag = _split_image(image)
url = _REGISTRY_URL.format(name=name, tag=tag)
try:
resp = httpx.get(url, timeout=_TIMEOUT_SECONDS)
except httpx.TransportError:
pytest.skip(f"Docker Hub registry unreachable, cannot verify {image!r}")
assert resp.status_code == _HTTP_OK, (
f"{engine_name}: pinned image {image!r} not found on Docker Hub "
f"(library/{name}, tag {tag!r}, status {resp.status_code}) — {url}"
)
+1
View File
@@ -460,6 +460,7 @@ async def test_reject_cancels_and_records_reason(
db_session: AsyncSession, ceo_client: AsyncClient db_session: AsyncSession, ceo_client: AsyncClient
) -> None: ) -> None:
task = await _seed_draft(db_session) task = await _seed_draft(db_session)
with _LOCKED[0], _LOCKED[1]:
resp = await ceo_client.post( resp = await ceo_client.post(
f"/api/video/posts/{task.id}/reject", json={"reason": "Not our voice"} f"/api/video/posts/{task.id}/reject", json={"reason": "Not our voice"}
) )
+52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
@@ -18,6 +19,7 @@ from roboco.api.middleware import (
get_status_code, get_status_code,
setup_middleware, setup_middleware,
) )
from roboco.config import settings
from roboco.exceptions import ( from roboco.exceptions import (
AuthenticationError, AuthenticationError,
InvalidStateError, InvalidStateError,
@@ -417,3 +419,53 @@ def test_request_validation_handler_log_preserves_non_secret_fields() -> None:
assert logged_body["title"] == "visible-title" # non-secret preserved assert logged_body["title"] == "visible-title" # non-secret preserved
assert logged_body["git_token"] == "***REDACTED***" # secret redacted assert logged_body["git_token"] == "***REDACTED***" # secret redacted
assert "ghp_secret_xyz" not in str(logged_body) assert "ghp_secret_xyz" not in str(logged_body)
# ---------------------------------------------------------------------------
# FlowVerbTimeoutMiddleware — per-verb budget selection
# ---------------------------------------------------------------------------
def _make_flow_app() -> FastAPI:
"""Two /api/v1/flow/* routes that each sleep past the fast budget but
under the slow one, so the picked timeout is observable by outcome."""
app = FastAPI()
@app.post("/api/v1/flow/developer/give_me_work")
async def _normal_verb() -> Any:
await asyncio.sleep(0.3)
return {"status": "ok"}
@app.post("/api/v1/flow/developer/i_am_done")
async def _slow_verb() -> Any:
await asyncio.sleep(0.3)
return {"status": "ok"}
setup_middleware(app)
return app
def test_flow_verb_timeout_normal_verb_uses_default_budget(
monkeypatch: Any,
) -> None:
"""A verb outside _SLOW_VERBS keeps the short default budget — a 0.3s
handler exceeds a 0.05s budget and comes back as a 504."""
monkeypatch.setattr(settings, "flow_verb_timeout_seconds", 0.05)
monkeypatch.setattr(settings, "flow_verb_slow_timeout_seconds", 5)
client = TestClient(_make_flow_app())
response = client.post("/api/v1/flow/developer/give_me_work")
assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT
assert response.json()["error"] == "gateway_timeout"
def test_flow_verb_timeout_slow_verb_uses_slow_budget(monkeypatch: Any) -> None:
"""A _SLOW_VERBS verb gets the longer budget — the same 0.3s handler that
times out on the default budget completes fine under the slow one."""
monkeypatch.setattr(settings, "flow_verb_timeout_seconds", 0.05)
monkeypatch.setattr(settings, "flow_verb_slow_timeout_seconds", 5)
client = TestClient(_make_flow_app())
response = client.post("/api/v1/flow/developer/i_am_done")
assert response.status_code == HTTPStatus.OK
assert response.json() == {"status": "ok"}
+37 -2
View File
@@ -5,9 +5,10 @@ i_am_done, blocking a red submit before it reaches QA. Full tests stay on CI.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from types import SimpleNamespace from types import SimpleNamespace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
if TYPE_CHECKING: if TYPE_CHECKING:
import pathlib import pathlib
@@ -128,6 +129,40 @@ async def test_run_one_reaps_killed_timeout_process(
fake_proc.wait.assert_awaited_once() fake_proc.wait.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_one_kills_child_on_outer_cancellation(
tmp_path: pathlib.Path,
) -> None:
"""An outer cancellation (e.g. FlowVerbTimeoutMiddleware's own
asyncio.timeout firing around the whole i_am_done submit) throws
CancelledError into the wait_for, bypassing the TimeoutError handler
above. Without a dedicated handler the child is orphaned and keeps
running past the cancelled request; _run_one must kill + reap it and
re-raise.
"""
real_create_subprocess_shell = asyncio.create_subprocess_shell
spawned: dict[str, asyncio.subprocess.Process] = {}
async def _capturing_create(*args: Any, **kwargs: Any) -> Any:
proc = await real_create_subprocess_shell(*args, **kwargs)
spawned["proc"] = proc
return proc
with patch.object(asyncio, "create_subprocess_shell", _capturing_create):
task = asyncio.ensure_future(quality_gate._run_one(tmp_path, "sleep 30"))
while "proc" not in spawned:
await asyncio.sleep(0.01)
await asyncio.sleep(0.1) # let the shell actually exec sleep
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
proc = spawned["proc"]
assert proc.returncode is not None, "child was not reaped after cancellation"
with pytest.raises(ProcessLookupError):
os.kill(proc.pid, 0)
# --- GitService command selection ------------------------------------------- # --- GitService command selection -------------------------------------------
+6 -1
View File
@@ -52,13 +52,18 @@ def test_commit_posts_message_and_files(do_module: Any) -> None:
fake_response.json.return_value = {"status": "in_progress", "task_id": "x"} fake_response.json.return_value = {"status": "in_progress", "task_id": "x"}
fake_client.post.return_value = fake_response fake_client.post.return_value = fake_response
with patch("httpx.Client", return_value=fake_client): with patch("httpx.Client", return_value=fake_client) as client_cls:
result = do_module.commit("feat(api): add /healthz", files=["foo.py"]) result = do_module.commit("feat(api): add /healthz", files=["foo.py"])
assert result["status"] == "in_progress" assert result["status"] == "in_progress"
args, kwargs = fake_client.post.call_args args, kwargs = fake_client.post.call_args
assert "/api/v1/do/commit" in args[0] assert "/api/v1/do/commit" in args[0]
assert kwargs["json"] == {"message": "feat(api): add /healthz", "files": ["foo.py"]} assert kwargs["json"] == {"message": "feat(api): add /healthz", "files": ["foo.py"]}
# commit stages+commits a large changeset server-side (up to
# git_commit_timeout_seconds, default 180s) — the shared _TIMEOUT (30s)
# is tuned for fast content-tool calls and would give up first.
assert client_cls.call_args.kwargs["timeout"] == do_module._COMMIT_TIMEOUT
assert do_module._COMMIT_TIMEOUT > do_module._TIMEOUT
def test_note_default_scope_note(do_module: Any) -> None: def test_note_default_scope_note(do_module: Any) -> None:
@@ -504,6 +504,80 @@ def test_escalate_up_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
} }
# ---------------------------------------------------------------------------
# Client-side timeout selection — must always outlast the matching server
# wall (FlowVerbTimeoutMiddleware) so the agent sees the clean 504 envelope
# instead of a raw httpx timeout. See roboco.foundation.policy.flow_timeouts.
# ---------------------------------------------------------------------------
def test_client_timeout_normal_verb_is_default_plus_headroom(
flow_module: types.ModuleType,
) -> None:
assert flow_module._TIMEOUT == (
flow_module._SERVER_TIMEOUT_SECONDS + flow_module.CLIENT_HEADROOM_SECONDS
)
assert flow_module._client_timeout_for("give_me_work") == flow_module._TIMEOUT
def test_client_timeout_slow_verb_is_slow_budget_plus_headroom(
flow_module: types.ModuleType,
) -> None:
assert flow_module._SLOW_TIMEOUT == (
flow_module._SERVER_SLOW_TIMEOUT_SECONDS + flow_module.CLIENT_HEADROOM_SECONDS
)
assert flow_module._client_timeout_for("i_am_done") == flow_module._SLOW_TIMEOUT
# Every SLOW_VERBS member routes through the same slow budget.
for verb in flow_module.SLOW_VERBS:
assert flow_module._client_timeout_for(verb) == flow_module._SLOW_TIMEOUT
def test_client_timeout_env_override_respected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manifest_path = tmp_path / "tool-manifest.json"
manifest_path.write_text(json.dumps(_FULL_MANIFEST))
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
monkeypatch.setenv("ROBOCO_FLOW_VERB_TIMEOUT_SECONDS", "45")
monkeypatch.setenv("ROBOCO_FLOW_VERB_SLOW_TIMEOUT_SECONDS", "600")
import roboco.mcp.flow_server as srv
importlib.reload(srv)
try:
assert srv._TIMEOUT == 45 + srv.CLIENT_HEADROOM_SECONDS
assert srv._SLOW_TIMEOUT == 600 + srv.CLIENT_HEADROOM_SECONDS
assert srv._client_timeout_for("give_me_work") == srv._TIMEOUT
assert srv._client_timeout_for("i_am_done") == srv._SLOW_TIMEOUT
finally:
importlib.reload(srv) # restore module state for later tests
def test_post_opens_httpx_client_with_slow_timeout_for_slow_verb(
flow_module: types.ModuleType,
) -> None:
fake_client = _make_fake_client({"status": "awaiting_qa"})
with patch("httpx.Client", return_value=fake_client) as client_cls:
flow_module.i_am_done("task-abc", notes="done")
assert client_cls.call_args.kwargs["timeout"] == flow_module._SLOW_TIMEOUT
def test_post_opens_httpx_client_with_default_timeout_for_normal_verb(
flow_module: types.ModuleType,
) -> None:
fake_client = _make_fake_client({"status": "idle"})
with patch("httpx.Client", return_value=fake_client) as client_cls:
flow_module.give_me_work()
assert client_cls.call_args.kwargs["timeout"] == flow_module._TIMEOUT
def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None: def test_escalate_to_ceo_passes_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Board / Main PM verb forwards to /api/v1/flow/<role>/escalate_to_ceo.""" """Board / Main PM verb forwards to /api/v1/flow/<role>/escalate_to_ceo."""
srv = _reload_for_role( srv = _reload_for_role(
@@ -128,7 +128,7 @@ async def test_provision_mongo_engine() -> None:
assert mongo.user == "sandbox" assert mongo.user == "sandbox"
assert mongo.database == "admin" assert mongo.database == "admin"
run_call = next(c for c in runner.calls if c[0] == "run") run_call = next(c for c in runner.calls if c[0] == "run")
assert "mongo:8-alpine" in run_call assert "mongo:8" in run_call
# MONGO_INITDB_ROOT_PASSWORD env is baked into the run. # MONGO_INITDB_ROOT_PASSWORD env is baked into the run.
assert any(a.startswith("MONGO_INITDB_ROOT_PASSWORD=") for a in run_call) assert any(a.startswith("MONGO_INITDB_ROOT_PASSWORD=") for a in run_call)
# /data/db tmpfs mount for the engine. # /data/db tmpfs mount for the engine.
@@ -14,6 +14,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from roboco.config import settings
from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext
from roboco.runtime.orchestrator import AgentOrchestrator from roboco.runtime.orchestrator import AgentOrchestrator
@@ -102,3 +103,19 @@ class TestMcpConfigPinsBakedVenv:
f"a drifted workspace-clone lock can't trigger a resync stall; " f"a drifted workspace-clone lock can't trigger a resync stall; "
f"got args={spec['args']}" f"got args={spec['args']}"
) )
@pytest.mark.asyncio
async def test_mcp_env_mirrors_flow_verb_timeout_settings(self) -> None:
"""flow_server.py (a subprocess, can't read Settings) mirrors the two
server-side flow-verb timeout budgets via env so its client timeout
stays coherent with operator tuning of either setting."""
orch = AgentOrchestrator.__new__(AgentOrchestrator)
config_path = await orch._generate_mcp_config("be-dev-1")
config = json.loads(Path(config_path).read_text())
env = config["mcpServers"]["roboco-flow"]["env"]
assert env["ROBOCO_FLOW_VERB_TIMEOUT_SECONDS"] == str(
settings.flow_verb_timeout_seconds
)
assert env["ROBOCO_FLOW_VERB_SLOW_TIMEOUT_SECONDS"] == str(
settings.flow_verb_slow_timeout_seconds
)
+53 -1
View File
@@ -431,7 +431,15 @@ async def test_render_video_task_terminal_after_max_attempts(
workspace = _fake_workspace() workspace = _fake_workspace()
orch = _orch() orch = _orch()
p1, p2 = _render_patches(renderer, workspace) p1, p2 = _render_patches(renderer, workspace)
with p1, p2: notify_svc = AsyncMock()
with (
p1,
p2,
patch(
"roboco.services.notification.NotificationService",
return_value=notify_svc,
),
):
await orch._render_video_task(db_session, task) # tips to terminal await orch._render_video_task(db_session, task) # tips to terminal
calls_at_terminal = len(renderer.calls) calls_at_terminal = len(renderer.calls)
await orch._render_video_task(db_session, task) # now a no-op await orch._render_video_task(db_session, task) # now a no-op
@@ -443,3 +451,47 @@ async def test_render_video_task_terminal_after_max_attempts(
assert len(renderer.calls) == calls_at_terminal # not retried after terminal assert len(renderer.calls) == calls_at_terminal # not retried after terminal
posts = await get_task_service(db_session).list_open_video_posts() posts = await get_task_service(db_session).list_open_video_posts()
assert posts == [] assert posts == []
# Exactly one CEO alert — the second (no-op) call must not re-notify.
notify_svc.send_ack_notification.assert_awaited_once()
notify_kwargs = notify_svc.send_ack_notification.await_args.kwargs
assert notify_kwargs["to_agent"] == "ceo"
assert task.title in notify_kwargs["body"]
assert "render blew up" in notify_kwargs["body"]
assert notify_kwargs["task_id"] == task.id
@pytest.mark.asyncio
async def test_render_video_task_notify_failure_does_not_raise(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken notification path (e.g. the second DB connection is down)
must not surface out of the render loop best-effort, like the
strategy-engine failure notifier."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="notify-fails", composition_id="Intro"
)
seeded = markers.get_video_draft(task) or {}
markers.set_video_draft(
task, {**seeded, "render_attempts": _MAX_VIDEO_RENDER_ATTEMPTS - 1}
)
await db_session.flush()
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with (
p1,
p2,
patch(
"roboco.services.notification.NotificationService",
side_effect=RuntimeError("notification DB unreachable"),
),
):
await orch._render_video_task(db_session, task) # must not raise
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "failed"
+117
View File
@@ -7,6 +7,7 @@ mock the network and filesystem boundaries.
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -512,6 +513,122 @@ async def test_create_pr_returns_pr_dict() -> None:
assert out["is_root_pr"] is True assert out["is_root_pr"] is True
@pytest.mark.asyncio
async def test_create_pr_records_pr_despite_cancellation_after_post() -> None:
"""A cancellation landing after the GitHub POST succeeds but before the
local record commits must not lose the record: asyncio.shield lets
_record_pr_atomically finish, the cancellation still propagates."""
project_id = uuid4()
fake_task = MagicMock(
id=uuid4(),
project_id=project_id,
assigned_to=uuid4(),
title="Add login",
description="A short description",
)
fake_project = MagicMock(slug="roboco")
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
recorded = {"done": False}
async def _slow_record(*_args: object, **_kwargs: object) -> None:
await asyncio.sleep(0.05)
recorded["done"] = True
_bind(svc, "_record_pr_atomically", _slow_record)
fake_resp = MagicMock()
fake_resp.is_success = True
fake_resp.status_code = 201
fake_resp.json.return_value = {
"number": _EXPECTED_PR_NUMBER,
"html_url": f"https://github.com/acme/repo/pull/{_EXPECTED_PR_NUMBER}",
}
_bind(svc, "_post_pr", AsyncMock(return_value=fake_resp))
with _patch_project_service(fake_project):
task = asyncio.ensure_future(
svc.create_pr("feature/backend/abc12345", parent="master", is_root_pr=True)
)
await asyncio.sleep(0.01) # let create_pr reach the shielded await
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# _await_shielded waits the in-flight record out BEFORE re-raising, so
# by the time `await task` raised, the record had already completed.
assert recorded["done"] is True, (
"shield must let the record finish despite cancellation"
)
@pytest.mark.asyncio
async def test_create_pr_cancellation_waits_out_record_before_reraising() -> None:
"""Ordering guard for _await_shielded: on cancellation the in-flight
_record_pr_atomically must run to COMPLETION before CancelledError
re-raises to the caller. A bare asyncio.shield detaches the write and
re-raises immediately the write then races get_db's rollback on the
same AsyncSession and asyncpg raises InterfaceError ('another operation
is in progress'), escaping as a 500 instead of the middleware's 504."""
project_id = uuid4()
fake_task = MagicMock(
id=uuid4(),
project_id=project_id,
assigned_to=uuid4(),
title="Add login",
description="A short description",
)
fake_project = MagicMock(slug="roboco")
svc = _service()
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
order: list[str] = []
async def _slow_record(*_args: object, **_kwargs: object) -> None:
await asyncio.sleep(0.05)
order.append("record_done")
_bind(svc, "_record_pr_atomically", _slow_record)
fake_resp = MagicMock()
fake_resp.is_success = True
fake_resp.status_code = 201
fake_resp.json.return_value = {
"number": _EXPECTED_PR_NUMBER,
"html_url": f"https://github.com/acme/repo/pull/{_EXPECTED_PR_NUMBER}",
}
_bind(svc, "_post_pr", AsyncMock(return_value=fake_resp))
with _patch_project_service(fake_project):
task = asyncio.ensure_future(
svc.create_pr("feature/backend/abc12345", parent="master", is_root_pr=True)
)
await asyncio.sleep(0.01) # mid-record: cancellation lands in the shield
task.cancel()
# pytest.raises re-raises any OTHER exception type (e.g. the
# InterfaceError a racing rollback would surface), failing the test —
# that is assertion (a): CancelledError and nothing else propagates.
with pytest.raises(asyncio.CancelledError):
await task
# Appended synchronously right after propagation: no other coroutine
# can run in between, so this marker coming SECOND proves the record
# coroutine had already completed before the cancellation re-raised.
order.append("cancel_raised")
assert order == ["record_done", "cancel_raised"], (
f"record must complete BEFORE the cancellation re-raises; got {order}"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_pr_raises_when_branch_not_found() -> None: async def test_create_pr_raises_when_branch_not_found() -> None:
svc = _service() svc = _service()
@@ -500,6 +500,7 @@ async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> No
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None: async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
task = await _seed_video_post(db_session) task = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
updated = await _svc( updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster() db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release") ).reject(_id(task), "Doesn't match the release")
@@ -508,6 +509,59 @@ async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> No
assert markers.get_video_reject_reason(updated) == "Doesn't match the release" assert markers.get_video_reject_reason(updated) == "Doesn't match the release"
@pytest.mark.asyncio
async def test_reject_takes_the_same_lock_approve_holds(
db_session: AsyncSession,
) -> None:
"""A reject under the real acquire/release path still lands (mirrors the
approve happy-path locking) proves the mutex round-trip, not just the
mutation."""
task = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert updated is not None
assert updated.status == TS.CANCELLED
_LOCKED[0].new.assert_awaited()
_LOCKED[1].new.assert_awaited()
@pytest.mark.asyncio
async def test_reject_concurrent_lock_held_refuses(db_session: AsyncSession) -> None:
"""A reject arriving while a concurrent approve holds the lock must not
cancel a draft that may be mid-post same refusal as approve's own
already-in-progress case."""
task = await _seed_video_post(db_session)
with patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value=None)):
result = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert result is None
await db_session.refresh(task)
assert task.status == TS.PENDING # never cancelled while the lock was held
@pytest.mark.asyncio
async def test_reject_redis_unavailable_refuses(db_session: AsyncSession) -> None:
"""Reject fails CLOSED when Redis is unreachable, mirroring approve: an
approve that took the lock while Redis was up stays authoritative through
the heartbeat grace window after Redis drops, so an unlocked reject could
CANCEL a draft that approve is mid-posting. The CEO retries once Redis is
back."""
task = await _seed_video_post(db_session)
broken = MagicMock()
broken.set = AsyncMock(side_effect=ConnectionError("redis down"))
broken.aclose = AsyncMock()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=broken):
result = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert result is None
await db_session.refresh(task)
assert task.status == TS.PENDING # never cancelled without the mutex
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_held_video_posts_excludes_terminal( async def test_list_held_video_posts_excludes_terminal(
db_session: AsyncSession, db_session: AsyncSession,
@@ -515,6 +569,7 @@ async def test_list_held_video_posts_excludes_terminal(
open_task = await _seed_video_post(db_session) open_task = await _seed_video_post(db_session)
rejected_task = await _seed_video_post(db_session) rejected_task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()) svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(rejected_task), "not relevant") await svc.reject(_id(rejected_task), "not relevant")
held = await svc.list_held_video_posts() held = await svc.list_held_video_posts()
ids = {t.id for t in held} ids = {t.id for t in held}
+63 -3
View File
@@ -20,15 +20,61 @@ const DIMENSIONS = {
square: { width: 1080, height: 1080 }, square: { width: 1080, height: 1080 },
}; };
// Caps the DECOMPRESSED size (a gzip bomb inflates a tiny upload into a huge
// tar stream); MAX_UPLOAD_BYTES in server.js only bounds the compressed
// bytes on the wire.
const MAX_EXTRACTED_BYTES = Number(
process.env.MAX_EXTRACTED_BYTES ?? 512 * 1024 * 1024,
);
/** Thrown when the tar stream's cumulative entry size crosses
* MAX_EXTRACTED_BYTES server.js maps this to a 413. */
export class ExtractedSizeExceededError extends Error {
constructor(maxBytes) {
super(`extracted archive exceeds ${maxBytes} byte cap`);
this.name = "ExtractedSizeExceededError";
this.statusCode = 413;
}
}
async function extractTar(tarBuffer, destDir) { async function extractTar(tarBuffer, destDir) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const extractor = tar.extract({ cwd: destDir }); let extractedBytes = 0;
// ponytail: header-declared entry.size, summed per entry via onentry —
// not a byte-exact streaming cap, but tar headers carry the true
// (post-gunzip) size, so this catches a bomb before most of it lands.
const extractor = tar.extract({
cwd: destDir,
onentry: (entry) => {
extractedBytes += entry.size;
if (extractedBytes > MAX_EXTRACTED_BYTES) {
extractor.destroy(new ExtractedSizeExceededError(MAX_EXTRACTED_BYTES));
}
},
});
extractor.on("finish", resolve); extractor.on("finish", resolve);
extractor.on("error", reject); extractor.on("error", reject);
Readable.from(tarBuffer).pipe(extractor); Readable.from(tarBuffer).pipe(extractor);
}); });
} }
// Deliberately under the orchestrator's 600s client-side HTTP timeout, so
// this fires first and the caller gets a clean error instead of an abandoned
// connection while Chrome is still wedged server-side.
const RENDER_TIMEOUT_SECONDS = Number(
process.env.RENDER_TIMEOUT_SECONDS ?? 570,
);
/** Thrown when a render exceeds RENDER_TIMEOUT_SECONDS server.js maps
* this to a 500 and then hard-exits the process (see server.js for why). */
export class RenderTimeoutError extends Error {
constructor(seconds) {
super(`render exceeded ${seconds}s timeout`);
this.name = "RenderTimeoutError";
this.statusCode = 500;
}
}
/** Thrown when the requested orientation's HTML file isn't present in the /** Thrown when the requested orientation's HTML file isn't present in the
* composition dir server.js maps this to a 400 instead of the generic 500 * composition dir server.js maps this to a 400 instead of the generic 500
* a deep-in-executeRenderJob failure would otherwise produce. The known * a deep-in-executeRenderJob failure would otherwise produce. The known
@@ -115,17 +161,31 @@ export async function renderComposition({
height, height,
fps: FPS, fps: FPS,
}); });
let timer;
try { try {
await executeRenderJob(job, (progress) => { const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(
() => reject(new RenderTimeoutError(RENDER_TIMEOUT_SECONDS)),
RENDER_TIMEOUT_SECONDS * 1000,
);
});
await Promise.race([
executeRenderJob(job, (progress) => {
console.log( console.log(
`hyperframes-renderer: ${compositionId}/${orientation} ${Math.round( `hyperframes-renderer: ${compositionId}/${orientation} ${Math.round(
progress.percent * 100, progress.percent * 100,
)}%`, )}%`,
); );
}); }),
timeout,
]);
} catch (err) { } catch (err) {
await rm(outDir, { recursive: true, force: true }).catch(() => {}); await rm(outDir, { recursive: true, force: true }).catch(() => {});
throw err; throw err;
} finally {
// Clear on both success AND timeout-throw so a completed render never
// leaves a dangling timer that fires the watchdog's exit path late.
clearTimeout(timer);
} }
return { return {
+21 -2
View File
@@ -10,7 +10,12 @@ import express from "express";
import multer from "multer"; import multer from "multer";
import rateLimit from "express-rate-limit"; import rateLimit from "express-rate-limit";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { renderComposition, UnknownCompositionError } from "./render.js"; import {
renderComposition,
ExtractedSizeExceededError,
RenderTimeoutError,
UnknownCompositionError,
} from "./render.js";
const PORT = Number(process.env.PORT ?? 3001); const PORT = Number(process.env.PORT ?? 3001);
@@ -123,10 +128,24 @@ app.post("/render", renderLimiter, upload.single("source"), async (req, res) =>
}); });
stream.pipe(res); stream.pipe(res);
} catch (err) { } catch (err) {
if (err instanceof UnknownCompositionError) { if (
err instanceof UnknownCompositionError ||
err instanceof ExtractedSizeExceededError
) {
res.status(err.statusCode).json({ error: err.message }); res.status(err.statusCode).json({ error: err.message });
return; return;
} }
if (err instanceof RenderTimeoutError) {
console.error("video-renderer: render timed out", err.message);
res.status(500).json({ error: err.message });
// ponytail: Promise.race in render.js abandons the wait but can't
// cancel a wedged headless-Chrome render tree — hard-exiting this
// process is the only reliable kill. Docker's restart policy brings
// up a clean container; wait for the response to flush first so the
// caller still gets the 500 instead of a dropped connection.
res.on("finish", () => process.exit(1));
return;
}
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error("video-renderer: render failed", message); console.error("video-renderer: render failed", message);
res.status(500).json({ error: `render failed: ${message}` }); res.status(500).json({ error: `render failed: ${message}` });