From dd4c3c3ed0ab7bcdf89ad11608f060569877ec99 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:33:02 +0200 Subject: [PATCH] =?UTF-8?q?docs(release):=20prepare=200.27.0=20=E2=80=94?= =?UTF-8?q?=20curated=20changelog,=20rag/map=20sweep,=20concat=20rebuild?= =?UTF-8?q?=20script=20(#662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 29 +++ README.md | 4 +- docs/map/_complete_map.md | 236 ++++++++++++++----- docs/map/api-core-websocket.md | 7 + docs/map/api-routes-schemas.md | 5 +- docs/map/db-migrations.md | 16 +- docs/map/deployment-tooling.md | 9 +- docs/map/foundation-policy-misc.md | 8 + docs/map/gateway-support.md | 5 +- docs/map/notification.md | 17 +- docs/map/orchestrator.md | 14 ++ docs/map/product-strategy-research-pitch.md | 8 +- docs/map/release-manager.md | 1 + docs/map/runtime-providers.md | 46 ++++ docs/map/support-services.md | 17 +- docs/map/task-service.md | 8 +- docs/map/tests.md | 2 + docs/map/video-engine.md | 2 + docs/map/worksession-git.md | 16 +- docs/rag/architecture/http-security-guard.md | 6 + docs/rag/architecture/x-engine.md | 4 +- docs/rag/tools/git-tools.md | 2 + docs/rag/troubleshooting/git-errors.md | 6 + docs/rag/troubleshooting/task-errors.md | 8 + docs/rag/workflows/task-claiming.md | 4 +- scripts/rebuild_map_concat.py | 102 ++++++++ 26 files changed, 501 insertions(+), 81 deletions(-) create mode 100644 scripts/rebuild_map_concat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 060c4fcc..c412fad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Security + +- **The WAF now resolves the real client behind host-proxy hops instead of banning the hop (#646, #650).** With Tailscale terminating on the host, every panel request reached the guard as the host-gateway IP, so one bad signature banned the operator's whole path. A dedicated `ClientIpResolutionMiddleware` stamps guard-core's pre-resolution seam with the true tailnet client — but only for the exact operator-declared chain shape (`ROBOCO_GUARD_TRUSTED_CHAIN_PEERS`, now actually wired through the compose files); every other chain abstains to the baseline depth-1 X-Forwarded-For posture, so nothing becomes spoofable that wasn't. The live-incident follow-up (#650) deliberately whitelists the tailnet CGNAT range after the honest resolution got the CEO's own device banned ("IP not allowed"), and the internal-mesh WAF exemption (#605) is scoped down to loopback + the docker bridge (scope the internal-mesh whitelist to loopback + docker bridge) rather than all of RFC1918. +- **Destructive git operations gained an operator-declared floor.** `projects.protected_branches` (#649, panel chips editor in the edit-project dialog) is unioned — only ever tightening — into the hardcoded `{master, main}` floor for rebase/force-push refusal and remote-branch deletion; the deletion chokepoint additionally protects every env-ladder rung branch (#651), fails closed on a lookup error, and covers all three remote-delete paths (task cleanup, stale-branch sweep, post-merge source cleanup). + +### Added + +- **Telegram Mini App V6 — the premium overhaul (#609).** A native-type design system on the `#tg-shell` tokens (borderless elevated cards, wallet-style tabular-numeral heroes, floating dock) with Telegram window-chrome painting riding the theme bridge; Inbox moves behind a header bell as a pushed sub-page with humanized notifications; a new Metrics tab (period-segmented spend hero, by-agent/team/model splits, delivery/efficiency, per-agent drilldowns over the previously-untapped time-series); Chat rebuilt with honest scopes — a participant-scoped Mine list with real unread counts and plain CEO send, a watch-or-interject Fleet list, and a pinned Secretary live chat on the same SSE runtime the panel drives; and the Board task sheet carries the CEO's own decide verbs (approve / request-changes / unblock). The `/api/dashboard/*` router is now token-gated at router level, closing an unauthenticated metrics exposure. +- **Codex and Gemini join the agent-provider roster (#659, #660).** `ModelProvider.OPENAI` runs OpenAI's official `codex` CLI on a ChatGPT subscription: read-only `~/.codex` directory mount with an orchestrator-side refresh loop for the single-use OAuth refresh token, per-role `--sandbox` levels plus a shared execpolicy rules file standing in for the missing tool-scoping flags, the fleet-wide subagent ban expressed as `[agents] enabled = false`, structured error-field-only exit classification (no false parks on transcript prose), and real four-bucket token capture from `turn.completed` events; the agent image installs the CLI via npm (install codex CLI via npm, not the CDN-denied installer). `ModelProvider.GEMINI` runs Google's official `gemini` CLI on an OAuth login: the host `~/.gemini` is staged read-only and copied container-local so the CLI's own token refresh writes locally with no orchestrator daemon (Google's refresh tokens are reusable — the deliberate contrast to grok/codex), tool scoping rides a rendered TOML Policy Engine plus `settings.json`, quota/auth exits park the provider exactly like grok's, and per-model usage pricing reads the run's own stream-json terminal stats. Both are V1 delivery-roles-only: the Intake and Secretary chats stay on Anthropic even under a fleet-wide mode switch (#661), and an explicit per-agent pin to either is refused loudly at spawn instead of silently misbehaving. +- **Cost-tiered model routing + saved routing presets (#656).** A compound `role:complexity` assignment rung slots into the routing precedence (`AGENT_SLUG > ROLE:complexity > ROLE > GLOBAL`) so a role can ride a cheaper model at a given task complexity without touching the plain role row; the opt-in `cost_tiered` mode seeds exactly one day-1 override (`developer:low` → haiku) additively — every other mode's wipe-then-seed behavior is unchanged, and mode switches now spare complexity rows. On top, **routing presets** let an operator name-and-snapshot the full routing state (mode + every assignment, per-agent pins included) and restore it wholesale later — validate-every-entry-first, so an invalid payload never triggers the wipe. Panel: preset save/apply/delete plus one-click Codex/Gemini mode cards on the AI routing settings card (#661). +- **Per-task and per-project cost budgets, flag-gated (#654).** `tasks.budget_usd` + `projects.monthly_budget_usd` under `ROBOCO_TASK_BUDGETS_ENABLED` (default off): a claim-time project-month spend guard applies only to work-starting claims (reviews/docs/gates stay exempt so in-flight work can always finish), the orchestrator's budget sweep blocks an over-budget task before the graceful stop so the dispatcher never respawns onto it, `unblock` re-checks live spend, and the CEO notification names both recovery steps. Server-side `gt=0` validation rejects the self-DoS zero cap (#661). +- **Golden-task eval harness (#655).** `roboco/eval/` replays fixture tasks through the real delivery lifecycle in a disposable environment (fake GitHub, real local git origin, throwaway DB) and scores each on deterministic metrics plus a clearly-marked non-deterministic local-model judge; `agent_spawn_sessions.doctrine_version` stamps each spawn's composed-prompt doctrine so a cohort's model+doctrine combination is identifiable after the fact. Real-spawn is deliberately cut this release (scripted spawner only) — a real spawn would wire into the production orchestrator. +- **Work re-claims inherit an advanced upstream base (#644).** When a developer or PM re-claims a pre-existing task branch whose base has moved on, the claim merges the upstream base in (pending/needs_revision claims only — QA, documentation, and gate claims never move the branch); a merge conflict surfaces as a `base_inheritance_conflict` transition note plus a dev note pointing at `sync_branch` (#661). +- **GitHub App authentication (#621, #633).** Projects can authenticate through an installed GitHub App instead of a PAT: an App credentials card, installation-token minting with the PAT fallback covering every mint failure (PAT fallback covers every mint failure; video/motion/x cleanups), a Select-repo picker on project creation, and binding an existing project to the App from the Edit dialog. +- **The X account grew a craft bar and a rework loop.** Drafted posts follow a real voice guide with an anti-slop ban and caption craft (#615), release captions compose from curated CHANGELOG headlines instead of raw commit subjects (#607), and a CEO reject with a reason now re-enters the draft flow as a redraft carrying the feedback (#648) instead of dead-ending the draft. +- **CEO ergonomics.** The CEO display name is configurable and the last Renzo hardcode is gone (#612), with the settings write-through renaming the CEO agent row (ceo_name write-through renames the CEO agent row); the CEO can open DMs to the Auditor and PR reviewers (#623); the Secretary and Intake agent cards open their chat screens directly (#624); and the CEO can preview a video authoring task's rendered frames before approving (#608). +- **Codegen drift heals itself before push (#632).** The push path auto-regenerates generated artifacts, stages only the codegen-produced drift, and links the auto-commit to the task (stage only codegen-produced drift and link the auto-commit). +- **One-command quickstart (#653).** `make quickstart` runs the idempotent bootstrap: a fresh `.env` from the example with the three required secrets injected using the documented formulas, registry pull + up, and a doctor-style readiness sweep grounded in real surfaces — each stage failing loud with the exact next command; the release workflow's new pull-smoke job literally pulls the registry compose against every just-published tag. +- **Off-disk backup mirror (#645).** The backup sidecar can mirror each dump to a second disk (`ROBOCO_BACKUP_MIRROR_DIR`, env-gated), with a quarterly restore-drill doc. + +### Fixed + +- **Expired-unacked notifications re-escalate with backoff instead of forever (#652).** The re-escalation sweep fired on every ~60s tick for every expired unacked row — 3 escalations/minute to the CEO in the live incident. Each row now carries a backoff schedule (first fire at expiry, doubling from a 1h base capped at 24h, permanently parked past 5 attempts), and the attempt slot is claimed by compare-and-set before any delivery, so racing sweep ticks can never double-send. +- **Post-finale completeness sweep (#661).** The gap sweep over the provider/routing program: Codex/Gemini apply-modes and panel mode cards existed nowhere (the features were unreachable from the panel), the Gemini provider row was seeded disabled with no enable path (migration 086), `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` and `ROBOCO_TASK_BUDGETS_ENABLED` reached no compose `environment:` stanza (dead on arrival), the Codex path was missing the fleet-wide subagent ban, budgets accepted a silently-blocking `0` cap, and the one-click Codex/Gemini modes would have killed the Intake/Secretary chats. +- **Notification plumbing hardening.** Notifications render task titles and agent names instead of raw UUIDs (#616); every singleton-role lookup tolerates duplicate rows through one shared helper (#620, one duplicate-tolerant role lookup for every singleton-role site); ack writes can join the caller's transaction so a bell ping can't be lost to a foreign-key race (#603); and the orchestrator no longer respawns agents off their own notification churn (#643). +- **Silent bug sweep across concurrency, state integrity, engine edge-cases, and panel data freshness (#638),** plus the V6 review-gap fixes (V6 review gaps — honest errors, safe secretary start, real tests) and the release-approve write committing under the release lock (commit the approve() COMPLETED write under the release lock). +- **Panel accuracy basket.** Branch lists classify remote refs correctly and prune stale ones (#610); charts get real axes, humanized ticks, and dark-theme tooltips (#611) and days-view timeseries show dates instead of `02:00` (#622); the AI Providers mix grid derives from the live roster (#613); forms catch up with the backend (#614); and A2A transcripts fall back to polling when the socket drops (#639). +- **Telegram Mini App responsiveness (#647).** The missing `viewport` export meant safe-area insets were zero on notched phones; scoped to the `/tg` group along with truncation fixes, contained video, and ≥36px touch targets. + ## [0.26.0] - 2026-07-20 ### Security diff --git a/README.md b/README.md index 9a57941d..982db3cb 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ uv run mypy roboco/ | RAG Engine | in-house (asyncpg + pgvector, hybrid retrieval) | | Embeddings | qwen3-embedding:0.6b (Ollama) | | Local LLM | Ollama (glm-5.2:cloud) | -| Cloud LLM | Claude API (Anthropic) + xAI Grok (official `grok` CLI, SuperGrok subscription) | +| Cloud LLM | Claude API (Anthropic) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) | | Package Manager | uv | ## Status @@ -320,7 +320,7 @@ uv run mypy roboco/ - [x] RAG/Knowledge base (in-house pgvector engine) - [x] Agent orchestrator - [x] CEO approval workflow -- [x] Pluggable agent providers (Claude Code + xAI Grok on the official `grok` CLI) +- [x] Pluggable agent providers (Claude Code + xAI Grok + OpenAI Codex + Google Gemini, each on its official CLI) - [x] Inbound PR review (read-only PR-reviewer + CEO supersede/dismiss queue) - [x] Self-healing CI loop for RoboCo's own repo (default-off, CEO-gated) - [x] Business Goals tab with a live Company Scorecard (delivery, spend-vs-budget, lead time) diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index 33b757b8..611bc4d6 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -966,7 +966,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/postgres-pgvector.Dockerfile | Example custom pgvector build (pg17) — currently unused; compose uses pgvector/pgvector:pg16 image directly | 15 | | docker/nginx.conf | nginx default.conf template: /health /ready /api/ /ws/ -> orchestrator (with X-Agent-Token header), everything else -> panel | 67 | | docker/postgres-init/01-create-extensions.sql | First-init SQL: CREATE EXTENSION IF NOT EXISTS vector + availability check (used by the pgvector image entrypoint) | 17 | -| docker/scripts/backup-entrypoint.sh | `backup` sidecar's own loop (not a Claude hook): `pg_dump -Fc` on start + every `BACKUP_INTERVAL_SECONDS` (default 24h), `.tmp`-suffix-then-rename, prune to newest `BACKUP_KEEP` (default 14) by mtime; runs on the data-only network | 46 | +| docker/scripts/backup-entrypoint.sh | `backup` sidecar's own loop (not a Claude hook): `pg_dump -Fc` on start + every `BACKUP_INTERVAL_SECONDS` (default 24h), `.tmp`-suffix-then-rename, prune to newest `BACKUP_KEEP` (default 14) by mtime; runs on the data-only network; env-gated off-disk mirror step (`ROBOCO_BACKUP_MIRROR_DIR`) after every successful dump | 80 | | docker/scripts/playwright-mcp-entrypoint.sh | Wrapper entrypoint for `@playwright/mcp`: points it at the image's own baked `chromium-headless-shell` instead of letting the MCP package download a second browser | 20 | | docker/scripts/sdk-startup-hook.sh | Claude SessionStart hook: start SDK server on :9000 with UV_PROJECT_ENVIRONMENT=/app/.venv + --no-sync, reset budget, print briefing/precompact | 58 | | docker/scripts/a2a-check-hook.sh | PostToolUse hook: poll SDK /inbox/count and remind the agent of pending A2A messages (always exits 0) | 30 | @@ -985,6 +985,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | scripts/verify_postgres_enums.py | Foundation drift gate: compare postgres agentrole/team enum labels to foundation identity; exit 0 on match/skip(unreachable or unmigrated), 1 on drift | 127 | | scripts/reflow_md.py | Reflow hard-wrapped markdown prose to one line per paragraph (token-invariant safety check); --apply / --check modes for the CI gate | 214 | | scripts/reset_runtime_state.sh | Host/container smoke-test reset: stop agent containers, run reset_runtime_state.sql, FLUSH Redis, optional FULL_RESET data wipe, per-workspace git hard-reset + stray-branch prune | 263 | +| scripts/bootstrap.sh | `make quickstart` entrypoint: idempotent one-command bring-up for the registry compose — copies `.env.example` to `.env` on first run (never touches a reused `.env`), injects the three required secrets via documented one-liners (the panel token via the exact `issue_panel_token` HMAC formula) with a standing-credential warning (louder if cloud auth is detected), pre-validates the three required vars with pointed remedies before `docker compose ... up -d` (compose's own `:?` guard on an empty token is an opaque interpolation error otherwise), then runs a doctor-style readiness sweep (root `/health`, `/api/auth/status` through nginx, the verbatim "Alembic upgrade finished" log line, `ollama list`), each stage failing loud with the exact next command | 258 | | scripts/reset_runtime_state.sql | Transactional DELETE of runtime tables (tasks/.../a2a_*) preserving agents/projects/alembic_version; resets agents.metrics | 159 | ## Key Symbols @@ -1150,6 +1151,7 @@ deployment-tooling | scripts/reflow_md.py --check | scripts/reflow_md.py | make reflow-check / make quality | | scripts/regenerate_verb_tables.py | scripts/regenerate_verb_tables.py | manual after role_config / schema change (not wired into make quality) | | scripts/reset_runtime_state.sh | scripts/reset_runtime_state.sh | manual smoke-test reset (host or ssh into NAS) | +| scripts/bootstrap.sh | scripts/bootstrap.sh | `make quickstart` (pull-and-run registry-compose deploy) | | docker compose up | docker-compose.yaml | operator deploy (build) or -f docker-compose.registry.yml up (pull) | ## Config Flags @@ -1192,6 +1194,7 @@ deployment-tooling - ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED / _INTERVAL_SECONDS (default 259200/3d) — X-engine feature-spotlight sub-switch (requires ROBOCO_X_ENGINE_ENABLED also on), default off - ROBOCO_OBSIDIAN_VAULT_ENABLED / ROBOCO_VAULT_PATH (default `/data/vault`) — Obsidian vault V1 projection master switch, config-default off but both compose files set it `true`; ROBOCO_VAULT_INTAKE_ENABLED / _INTERVAL_SECONDS / _DIR / _MAX_PER_CYCLE / _MAX_OPEN_DRAFTS — the independently-gated `#roboco`-tag inbox watcher - ROBOCO_FABLE_MODE_ENABLED — opus-fable-playbook adoption (doctrine layer in the composed prompt + 5 Claude-path hook scripts + 1 grok-path hook), default off; off = byte-for-byte unchanged spawn path +- `ROBOCO_BACKUP_MIRROR_DIR` (unset by default) — arms the `backup` sidecar's off-disk mirror step; the host path should live on a DIFFERENT disk (external/remote mount) — a same-disk mirror protects nothing. Compose only sets the container env when the `.env` variable is set. - ROBOCO_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET / _REGION — MinIO object storage (default-off; empty endpoint = disabled, media route falls back to `FileResponse`; when set, `video_renderer_client._save` PUTs each render to MinIO after the local write and `GET /api/video/posts/{id}/media` streams it via `StreamingResponse` over `minio_client.get_object_stream`, key = basename, `_require_ceo` kept so auth stays end-to-end — no presigned URLs; `S3Error` falls back to `FileResponse`); NAS compose runs `minio` + `minio-init` on the `data` network with a named `minio-data` volume, registry compose omits MinIO; see `docs/rag/architecture/minio-storage.md` - ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS - ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS @@ -1251,6 +1254,10 @@ deployment-tooling > **Local branch (not on master, NOT deployed):** `feature/fastapi-guard-hardening` landed `ROBOCO_GUARD_ENABLED` / `_PASSIVE_MODE` / `_FAIL_SECURE` / `_TELEMETRY_ENABLED` / `_AGENT_API_KEY` / `_PROJECT_ID` / `_EMERGENCY` / `_EMERGENCY_WHITELIST` in `config.py` (6 commits, `896532a3`..`99ee666e`) and set both NAS composes' `ROBOCO_GUARD_ENABLED=true` / `ROBOCO_GUARD_PASSIVE_MODE=true` / `ROBOCO_GUARD_FAIL_SECURE=false` (`c496b677`, Phase 5); `docker-compose.registry.yml` is untouched and stays off. See api-core-websocket for the `roboco/security.py` module + `create_app` wiring detail. +> **Off-disk backup mirror (#645, `98a96bcd`).** The `backup` sidecar wrote its dumps to the same disk it protects — one disk failure lost both the live DB and the backups. `mirror_backup()` (backup-entrypoint.sh) copies the fresh dump to `BACKUP_MIRROR_DIR` (tmp+rename, same crash safety as the primary write) after every successful `pg_dump` and prunes the mirror to the same `BACKUP_KEEP`; an unmounted/unwritable mirror logs and skips without blocking the primary dump. Unset (the default) means the script never attempts a copy. `docs/backend/ops/database-backups.md` gained the mirror setup plus a quarterly restore-drill procedure (throwaway pgvector container, `pg_restore`, row-count sanity check). +> +> **`make quickstart` (#653, `31a489b4`).** New `scripts/bootstrap.sh` backs a one-command pull-and-run bring-up for `docker-compose.registry.yml`: idempotent `.env` bootstrap (copies `.env.example` on first run, never touches a reused file), the three required secrets pre-validated with pointed remedies instead of compose's opaque interpolation error, then pull + `up -d` + a doctor-style readiness sweep grounded in real surfaces (root `/health`, `/api/auth/status` through nginx, the verbatim "Alembic upgrade finished" log line, `ollama list`). `.github/workflows/release.yml` gained a `pull-smoke` job (fresh runner, own GHCR login, needs `publish-images`) that literally pulls the registry compose against the just-published tag on every release, guarding the missing-image regression class documented elsewhere in this file's own Gotchas. +> > **v0.18.0** (2026-07-04): Fable mode — `agent-base.Dockerfile` now `COPY`s 5 vendored `docker/scripts/fable-*.sh` hook scripts (stop-gate, bash-discipline, honesty-nudge, prompt-nudge, precompact), installed at spawn time only when `ROBOCO_FABLE_MODE_ENABLED` is set (default off; `session-start.sh` from the upstream `opus-fable-playbook` was deliberately not ported — redundant with the doctrine layer). X feature-spotlight adds `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`/`_INTERVAL_SECONDS` to `config.py` as a sub-switch of the existing X-engine flag. ## Regression Risks @@ -2129,8 +2136,10 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog |---|---|---|---| | parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL | | NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) | -| NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (AUDITOR, PR_REVIEWER, PROMPTER, SECRETARY) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart | +| NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (PROMPTER, SECRETARY — human-only, own dedicated chat pages) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart. AUDITOR/PR_REVIEWER carry `dm`/`read_a2a` (CEO-reachable, reply-only) but are not in this set — the auditor's peer-silence is enforced separately in `can_a2a_direct`'s `from_role == "auditor"` branch | | ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) | +| ReescalationPolicy | dataclass | roboco/foundation/policy/communications.py:113 | Bundled `base_seconds`/`max_reescalations` — kept as one dataclass so `reescalation_decision` stays under the 5-arg lint ceiling | +| reescalation_decision | function | roboco/foundation/policy/communications.py:123 | Pure due/wait/capped verdict for one notification's re-escalation attempt: first fire at expiry, then exponential doubling from `base_seconds` capped at 24h between attempts; past `max_reescalations` always "capped" regardless of elapsed time | | Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) | | SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping | | ReadTier | enum | roboco/foundation/policy/journaling.py:41 | Journal read-breadth StrEnum (own/cell/cell_and_pms/all_cells/all) | @@ -2167,6 +2176,8 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog | VERB_RETRY_LIMITS | constant | roboco/foundation/policy/agent_loop.py:54 | Per-verb retry caps over 60s window keyed by MCP-exposed verb names (pass/fail, i_am_done, open_pr=5, etc.) | | UNLIMITED_RETRY_VERBS | constant | roboco/foundation/policy/agent_loop.py:80 | frozenset of verbs exempt from the per-verb circuit breaker (discovery/claim verbs) | | retry_limit_for | function | roboco/foundation/policy/agent_loop.py:97 | Return per-verb retry cap or None for unlimited; falls back to default for unknown | +| default_budget_usd_for | function | roboco/foundation/policy/agent_loop.py:99 | Per-`TaskType` default $ cap consulted only when a task's own `budget_usd` is null AND `ROBOCO_TASK_BUDGETS_ENABLED` is on | +| effective_task_budget_usd | function | roboco/foundation/policy/agent_loop.py:111 | A task's effective $ cap: its own `budget_usd` if set, else the `TaskType` default; the single resolver the orchestrator's budget sweep and the panel's budget-input validation both consult | | Severity | enum | roboco/foundation/policy/content/enums.py:14 | PR-review finding severity ladder (blocker/major/minor/nit) | | Verdict | enum | roboco/foundation/policy/content/enums.py:23 | Review outcome StrEnum (approved/changes_requested/passed/failed) | | HasMarkers | protocol | roboco/foundation/policy/content/markers.py:19 | Protocol for anything carrying orchestration_markers column | @@ -2370,6 +2381,10 @@ foundation/policy (misc slice) > Post-snapshot updates (since 2026-06-29): a squash-merge commit landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING). No other symbols in this slice changed. > +> `1d5a8e84` (#652, "exponential backoff + CAS claim for expired-unacked re-escalation"): adds `ReescalationPolicy` + `reescalation_decision` (communications.py:113/123) — the pure due/wait/capped decision `NotificationDeliveryService._maybe_reescalate` (`docs/map/notification.md`) consults before ever attempting a re-escalation delivery. +> +> `7c8453e2` (#654, "per-task and per-project cost budgets"): adds `default_budget_usd_for`/`effective_task_budget_usd` (agent_loop.py:99/111) — the per-`TaskType` fallback + single resolver consumed by the orchestrator's budget sweep and `claim_guards.project_budget_exceeded_guard` (`docs/map/gateway-support.md`). +> > `56b6693e` ("security-hygiene-sweep"): adds `NO_COMMS_ROLES` (communications.py:66) — the four no-agent-comms-surface roles (auditor/pr_reviewer/prompter/secretary), extracted as the single canonical set two previously-independent hand-maintained literals now both derive from: `content_actions.py`'s `_NO_COMMS_ROLES` (the `dm()` sender-side guard) and a new `agents_config.py` helper, `_check_ceo_a2a` (called from `can_a2a_direct`'s CEO branch — previously an unconditional `True` for any CEO-initiated target; the "one asymmetric rule" now itself refuses a no-comms target). A downstream consequence with no separate code edit: `agents_config.py`'s statically-derived `A2A_ALLOWED_PAIRS` shrank 93→88 (`ceo` group 23→18). ## Regression Risks @@ -2426,7 +2441,8 @@ The support layer of the agent gateway: pure/cheap components the Choreographer | Envelope.as_dict | method | roboco/services/gateway/envelope.py:257 | Wire-format dict; drops None fields except error; always emits error/correlation_id/current_state/valid_next_verbs. | | already_active_guard | function | roboco/services/gateway/claim_guards.py:35 | Reject claim if agent has any active (claimed/in_progress/verifying/blocked) task other than target. | | paused_tasks_guard | function | roboco/services/gateway/claim_guards.py:61 | Reject claim if agent has a paused task other than target (PM re-entry on own paused umbrella exempt). | -| unmet_dependency_guard | function | roboco/services/gateway/claim_guards.py:86 | Reject claim while task has non-terminal depends_on; holds pre-assigned dev at claim verb. | +| project_budget_exceeded_guard | function | roboco/services/gateway/claim_guards.py:86 | `ROBOCO_TASK_BUDGETS_ENABLED`-gated: reject a WORK-STARTING claim (`i_will_work_on`/`i_will_plan` only — review/doc/gate/inbound-PR claims are exempt so in-flight work can always finish) once the project's `monthly_budget_usd` has been reached this calendar month; fires only when `monthly_budget_usd` is set (`None` = no cap). Spend counts closed sessions' `estimated_cost_usd` plus open sessions priced live from token snapshots. | +| unmet_dependency_guard | function | roboco/services/gateway/claim_guards.py:112 | Reject claim while task has non-terminal depends_on; holds pre-assigned dev at claim verb. | | ClaimDecision | enum | roboco/services/gateway/claimant_lock.py:18 | GRANTED / GRANTED_AFTER_STALE_RELEASE / BLOCKED_OTHER_ACTIVE. | | is_stale | function | roboco/services/gateway/claimant_lock.py:24 | True when no heartbeat or heartbeat older than threshold_seconds. | | try_acquire | function | roboco/services/gateway/claimant_lock.py:35 | Decide whether agent may acquire/refresh claim on task (pure; caller persists). | @@ -2700,7 +2716,7 @@ gateway-support ## Drift from CLAUDE.md - CLAUDE.md: 'pr_reviewer ... no agent comms' — content_actions._NO_COMMS_ROLES enforces this at the handler (defence-in-depth), matching the doc. No drift. -- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get (read-only inbox). This is an additive expansion beyond the doc's literal 'note + evidence' but is consistent with the doc's 'Wave 1 receivers get inbox read' footnote; arguably doc under-states the surface. Not a code drift. +- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get (read-only inbox), and — since `73c05cfa` (#623) — `dm`/`read_a2a` so the CEO can open a DM with a mid-flight auditor and it can reply in-thread (it still never INITIATES peer A2A; that refusal lives in `agents_config.can_a2a_direct`, not in the tool grant itself). `_PR_REVIEWER_DO` gained the identical `dm`/`read_a2a` pair in the same commit. CLAUDE.md's own prose already documents both additions; this file's Files/Key Symbols tables were the stale side and are now current. - CLAUDE.md: 'The note/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget'. content_actions._curate_playbook instead does an EXPLICIT await self.task.session.commit() before svc.index_approved / unindex_playbook — commit-before-index is intentional (gates the corpus). Not drift but a deliberate exception to the fire-and-forget pattern for the playbook curation path. - CLAUDE.md: claim-time concurrency guards 'are skipped for the coordinator PM roles (_COORDINATOR_ROLES = {main_pm, cell_pm}, consulted in _run_claim_guards)'. That skip lives in the Choreographer, NOT in claim_guards.py (this slice). The predicates in this slice have no PM exclusion — importing them directly elsewhere would re-introduce the PM self-block. By design, but the module boundary is easy to misread. @@ -3023,6 +3039,7 @@ This slice implements RoboCo's formal-notification backbone: NotificationService | roboco/services/telegram_inbound.py | V2: `TelegramInboundEngine` — getUpdates poll cycle (offset persisted as `telegram_last_update_id` in system_settings), chat-id AND sender-id authorization, `BOT_COMMANDS` registry driving both `/help` and a once-per-process `setMyCommands` sync, `/status`/`/queue`/`/task`/`/agents`/`/usage`/`/blocked`/`/secretary`/`/newtask`/`/end` command router, `apv|rej::` callback codec, force_reply reject/approve-notes state machine (in-memory `_PENDING_REPLIES`, TTL), per-kind dispatch to the SAME service methods the CEO-gated HTTP routes call (task/release/xpost/video/roadmap), `via=telegram` audit rows | 1295 | | roboco/services/tg_cockpit.py | V4: `TgCockpitService` — DB-only, one-round-trip aggregate for the Mini App home screen (`today()`) and the bot's `/agents` command (`fleet()`); no live GitHub calls, no orchestrator singleton | 217 | | roboco/services/telegram_bridge.py | V4: bridges `/secretary`/`/newtask` Telegram chat into the SAME in-process live-chat runtimes the panel drives — a per-chat consumer task drains `PrompterLiveRegistry.stream`, forwards `turn_end`/`draft`/`batch`/`error` events as Telegram messages, and routes a `draft` event's Send-to-Board confirm through `PrompterService.confirm_live_draft` + registry `park` | 291 | +| roboco/services/notification_text.py | `task_display` (title-first, `#id8` fallback) / `agent_display` (identity-map slug first, DB lookup fallback) — feeds every notification producer so subjects/bodies read `Task ` / `<agent-slug>` instead of a raw UUID; tool-call examples (`unblock('<uuid>')`) keep the raw id on purpose, agents need it | 65 | ## Data Flow Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event. @@ -3084,7 +3101,7 @@ notification ├── EscalationError / EscalationOutcome / BlockerDetails ├── deliver (delivered_at in-tx, defer per-recipient events) ├── get_notification / _notification_is_fully_acked / _log_expired_notification - ├── sweep_expired_notifications (log stale unacked) + ├── sweep_expired_notifications / _maybe_reescalate / _claim_reescalation_slot / _re_escalate_unacked (exponential backoff, CAS claim before delivery) ├── get_pending_for_agent / get_unacknowledged_for_agent / get_notification_count ├── acknowledge / mark_read / bulk_acknowledge / get_ack_status / get_delivery_summary ├── Task-handoff / audit-bridge notifications @@ -3120,15 +3137,19 @@ telegram_inbound.py (TelegramInboundEngine, V2) | Name | File | Trigger | |---|---|---| | NotificationService.send_*_notification | roboco/services/notification.py | TaskService / orchestrator lifecycle transitions (blocker, qa-ready, docs, a2a, board-review) | -| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only) | +| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only); optional `db_session` param lets a caller whose OWN open transaction just created the referenced task (e.g. the release engine's just-originated proposal) join that transaction, so the `related_task_id` FK doesn't reject an insert made through a fresh session against an as-yet-uncommitted task | | NotificationDeliveryService.notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation / notify_auditor_of_rework | roboco/services/notification_delivery.py | api/routes/tasks.py i_am_blocked / escalate / ceo-approval routes; TaskService._alert_auditor_of_rework at QA-fail / rework chokepoints | | NotificationDeliveryService.acknowledge / list_for_agent / get_for_recipient_and_mark_read | roboco/services/notification_delivery.py | api/routes/notifications.py ACK + list endpoints | -| sweep_expired_notifications | roboco/services/notification_delivery.py | orchestrator periodic loop (orchestrator.py:5780) | +| `reescalation_decision` | roboco/foundation/policy/communications.py:123 | Pure due/wait/capped decision for one notification (`now`, `expires_at`, `count`, `last_reescalated_at`, `ReescalationPolicy`) — called by `_maybe_reescalate` before any delivery is attempted | +| `NotificationDeliveryService._claim_reescalation_slot` | roboco/services/notification_delivery.py:443 | Compare-and-set `UPDATE ... WHERE reescalation_count = :n` claimed BEFORE delivery — two sweep ticks racing the same row can never both deliver; the slot is consumed (count bumped) even on a subsequent delivery failure, so a permanently-broken chain still counts toward the cap instead of looping forever | +| sweep_expired_notifications | roboco/services/notification_delivery.py:367 | orchestrator periodic loop (orchestrator.py:5780); re-escalates a still-unacked row only when `reescalation_decision` says it's due, not every tick | | TelegramInboundEngine.run_cycle | roboco/services/telegram_inbound.py | orchestrator `_telegram_poll_loop` (default off, `telegram_enabled` AND `telegram_inbound_enabled`) | ## Config Flags - settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT) - `ROBOCO_NOTIFICATION_ACK_TTL_HOURS` (default `48`, `ge=0`) — hours until an ack-required notification's `expires_at` is stamped at creation (`_create_notification`, config.py:270); consumed by `NotificationDeliveryService.sweep_expired_notifications`'s re-escalation. `0` disables stamping entirely (`expires_at` stays `NULL`, legacy behavior — never expires). Only ack-required notifications (per `ACK_REQUIRED_BY_TYPE`) get a deadline; informational ones never do regardless of this setting. +- `ROBOCO_NOTIFICATION_REESCALATION_BASE_SECONDS` (default `3600`, `ge=1`) — base interval for the re-escalation backoff: the first re-escalation fires at expiry, each one after that doubles the wait from this base (1h, 2h, 4h, 8h, ...) capped at 24h between attempts. +- `ROBOCO_NOTIFICATION_MAX_REESCALATIONS` (default `5`, `ge=1`) — hard cap on re-escalations per notification; past this many a still-unacked row is logged once as permanently-unacked and never re-escalated again. - `telegram_enabled` (default off) — V1 master switch; `_notify_telegram` no-ops without it AND stored credentials. - `telegram_inbound_enabled` (default off, sub-switch on top of `telegram_enabled`) — V2: arms `TelegramInboundEngine.run_cycle` (the poll loop) and makes escalation DMs carry an actionable keyboard; with it off the bot only sends, never listens, and any inline button on an old message is inert. - `telegram_poll_interval_seconds` (5.0) / `telegram_poll_timeout_seconds` (25, Bot API long-poll `timeout`) / `telegram_max_updates_per_cycle` (50) / `telegram_pending_reply_ttl_seconds` (300) — V2 poll-loop tuning. @@ -3169,6 +3190,12 @@ telegram_inbound.py (TelegramInboundEngine, V2) > `cd978d11`+fixes (2026-07-18, wave-13): Telegram sends are HTML-styled — `_esc` (text nodes) / `_esc_attr` (href attributes) escaping discipline, balance-aware `_truncate`, `parse_mode`/`disable_link_preview` on the client; new `notify_ceo_of_queue_item` pushes a styled keyboard DM at each held-draft origination (release/x/video engines + `propose_roadmap`), sharing `telegram_inbound.render_queue_item_text`. > `3b9fd0e0`+`11915f36` (PR #551, Telegram V2): `3b9fd0e0` adds `telegram_inbound.py` (new file, `TelegramInboundEngine`), extends `telegram_client.py` with `get_updates`/`answer_callback_query`/`edit_message_reply_markup`/`edit_message_text`, adds `actionable=True` to `_notify_telegram` (escalation only) so the DM carries an Approve/Reject/Open keyboard, and wires the orchestrator's `_telegram_poll_loop`. `11915f36` closes a live-reproduced approve-after-reject hole reachable via a stale Telegram button (or the pre-existing HTTP routes for X/video): `ReleaseProposalService.approve()` now refuses CANCELLED (`already_rejected`) and COMPLETED (`already_published`) proposals via a new `_approve_precheck`, `.reject()` refuses COMPLETED by raising a new `TaskAlreadyCompletedError`, and `XPostService`/`VideoPostService.approve()` each add a CANCELLED pre-lock-and-under-lock guard returning `already_rejected`. Also adds `_authorized_sender` (chat-id auth is defense-in-depth'd with a sender-id check) and widens `_resolve_task`'s search limit 10→50 so a genuine id-prefix hit can't be pushed out by newer title/description matches. > `baa87d58`+`c7605b0d` (2026-07-19, PR #576 + #582, Telegram Mini App V4): new `tg_cockpit.py` — `TgCockpitService.today()` (tg_cockpit.py:59) assembles `needs_you`/`fleet`/`spend`/`velocity`/`ship` in one DB-only round trip backing `GET /api/telegram/today` (`api/routes/telegram.py:89`, `require_ceo_role` + 30/60s rate limit); `TgCockpitService.fleet()` (tg_cockpit.py:110) is shared verbatim by the bot's new `/agents` command. New `telegram_bridge.py` — `BridgeSession` (per-chat, in-memory) lifecycle via `start_secretary`/`start_intake`, a sole-consumer `_consume` task draining `PrompterLiveRegistry.stream`, `_forward_event` turning `turn_end`/`draft`/`batch`/`error` stream events into Telegram messages, and `mark_parked`/`discard_draft` routing a draft's Send-to-Board confirm through `PrompterService.confirm_live_draft(route="board")` + registry `park`; `sweep_idle()` reuses `settings.interactive_idle_reap_seconds` and skips parked sessions. `telegram_inbound.py` gains `BOT_COMMANDS` (a single registry driving both `/help`'s `_HELP_TEXT` and a once-per-process `client.set_my_commands` sync via `TelegramInboundEngine._ensure_commands_menu`, called from `run_cycle()`) plus `/agents` (`_render_agents`, calls `TgCockpitService.fleet()`), `/usage` (`_render_usage`, `UsageService.get_today_summary`), `/blocked` (`_render_blocked`, capped `awaiting_ceo_approval`+`blocked` lists with deep-linked rows), and `/secretary`/`/newtask`/`/end` (dispatch straight into `telegram_bridge.py`). `telegram_client.py` gains `set_my_commands` (`LiveTelegramClient`, best-effort `httpx.HTTPError`-suppressed) + a `NullTelegramClient` no-op. No orchestrator wiring changed — `_telegram_poll_loop`/`_run_telegram_poll_cycle` are byte-for-byte unchanged; the bridge's idle sweep and the commands sync both run *inside* the existing `run_cycle()` tick. New response schemas in `api/schemas/telegram.py`: `TodayTaskItem`/`TodayNeedsYou`/`TodayFleetAgent`/`TodayFleet`/`TodaySpend` (gains `series`/`delta_pct` in the `c7605b0d` follow-up)/`TodayVelocity` (new in `c7605b0d`)/`TodayShip`/`TelegramTodayResponse`. +> `1d5a8e84` (#652, "exponential backoff + CAS claim for expired-unacked re-escalation"): `sweep_expired_notifications` used to re-escalate EVERY expired unacked row on EVERY ~60s tick forever — the live incident was 3 fresh blocker escalations + Telegram DMs per minute from a static stale pile. Migration 079 adds `reescalation_count`/`last_reescalated_at`/`reescalation_delivered_count`; the due/wait/capped decision is the pure `reescalation_decision` (`docs/map/foundation-policy-misc.md`); `_claim_reescalation_slot`'s compare-and-set claims the attempt BEFORE delivery is even attempted (NOT backstopped by the loop-prone-type Redis dedup — `BLOCKER_ESCALATION`, the type every re-escalation fires as, is deliberately excluded from that dedup set) so two racing sweep ticks can never both deliver. Legacy rows (`count=0`) keep the original first-fire-at-expiry semantics. +> +> `d362858f` (#603, "ack notifications can join the caller's transaction"): `send_ack_notification` gains an optional `db_session` param forwarded to `_create_notification` — the release engine's bell notification for a just-originated proposal was inserting through a FRESH session while the proposal task still sat uncommitted in the engine's own transaction, so the `related_task_id` FK rejected the row and the ping was silently lost (the DB-free Telegram DM still went out, masking the loss). The release engine now passes its own session; the other five `send_ack_notification` callers pass no `task_id` or reference already-committed tasks, so they're unaffected. +> +> `c8f55be9` (#616, "task titles and agent slugs replace raw UUIDs"): new `notification_text.py` (`task_display`/`agent_display`) feeds every notification producer — all 13 `NotificationService` methods, the delivery-service task-handoff bodies, the substitute-PM ad-hoc insert, and the orchestrator/choreographer callers (threading the task row's title one call deeper) — so subjects/bodies read `Task <title>` (falling back to `#<id8>` when no title) and `<agent-slug>` instead of raw UUIDs. Also fixed a literal `'cell_pm'` role string that was being sent as an agent slug in the merge-conflict notification body. Tool-call examples in remediation hints (`unblock('<uuid>')`) keep the raw id on purpose — agents still need it to call the tool. +> > `56b6693e` ("security-hygiene-sweep"): root-causes a previously dead-on-arrival sweep — `NotificationDeliveryService.sweep_expired_notifications` already ran a real `expires_at < now()` query, but `NotificationService._create_notification` never WROTE `expires_at`, so the query always matched zero rows and every ack-required notification was effectively immortal. `_create_notification` now computes `requires_ack` up front (same derivation as before) and, when ack-required AND `settings.notification_ack_ttl_hours > 0`, stamps `expires_at = now() + timedelta(hours=notification_ack_ttl_hours)` (default 48h) on the `NotificationTable` row; `0` leaves `expires_at` `NULL` (never expires). Informational notifications never get a deadline regardless of the setting. ## Regression Risks @@ -3294,6 +3321,15 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock | AgentOrchestrator._run_telegram_poll_cycle | method | roboco/runtime/orchestrator.py:8476 | One Telegram poll pass: `get_telegram_inbound_engine(db).run_cycle()` + commit; testable without the sleep. | | AgentOrchestrator._pending_claim_blocked | method | roboco/runtime/orchestrator.py:11586 | Dispatch-time probe reusing `TaskService.is_pending_claim_blocked` (the exact claim-gate predicate — dependency OR sequence) so `_route_unassigned_pm_task` (called from `_dispatch_pm_work` per PENDING coordination-root task) can filter a doomed later-wave/sequence-held task before attempting the raw claim, instead of one failed claim per tick. Fails open (False) on any lookup error — the claim attempt itself is the safety net. | | AgentOrchestrator._pm_respawn_should_gate | method | roboco/runtime/orchestrator.py:8915 | Per-(slug,task) respawn circuit breaker; tracing_gap rule-following resets (bounded) + durable persist; CEO notify once when tripped. | +| AgentOrchestrator._notification_spawn_cooled | method | roboco/runtime/orchestrator.py:4738 | Cross-tick cooldown for notification-triggered spawns (escalation/approval/audit/a2a): one spawn per (agent, notification) per `notification_spawn_cooldown_seconds`; `0` disables (legacy every-tick respawn). | +| AgentOrchestrator._notification_spawn_over_cap | method | roboco/runtime/orchestrator.py:4780 | Hard per-(agent, notification) attempt cap (`notification_spawn_max_attempts`, default 5) — the notification-driven analogue of the PM respawn breaker; these dispatchers carry no `task_id` so that breaker never sees them. Past the cap, stop respawning and log once; the count is id-scoped and survives map pruning. | +| AgentOrchestrator._prune_notification_spawn_maps | method | roboco/runtime/orchestrator.py:4811 | Evict `_notification_spawn_at` entries older than a cutoff so the in-memory cooldown map doesn't grow unbounded. | +| AgentOrchestrator._notification_has_live_work | coroutine | roboco/runtime/orchestrator.py:4848 | Live-work check before spawning a notification's recipient: skip when the notification has expired, is stale past `notification_spawn_max_age_seconds` (default 6h — wedged or reloaded from before a restart), or its related task is already terminal. | +| AgentOrchestrator._fetch_budget_status | coroutine | roboco/runtime/orchestrator.py:8021 | `ROBOCO_TASK_BUDGETS_ENABLED` sweep helper: live-priced spend snapshot for one active task (closed sessions' `estimated_cost_usd` + open sessions priced from token snapshots). | +| AgentOrchestrator._task_budget_breach | coroutine | roboco/runtime/orchestrator.py:8053 | `(spend, cap)` tuple when a task's own spend exceeds its `budget_usd` (falling back to the `TaskType` default via `effective_task_budget_usd`), else `None`. | +| AgentOrchestrator._handle_task_budget_breach | coroutine | roboco/runtime/orchestrator.py:8090 | BLOCKs a breaching task (HUMAN resolver, a budget marker) BEFORE the graceful stop so the ensuing unclaim no-ops and the dispatcher never respawns onto it; notifies the CEO naming both recovery steps (raise the cap or `unblock`). | +| AgentOrchestrator._sweep_budget_exceeded | coroutine | roboco/runtime/orchestrator.py:8152 | Iterates active agents past `_check_budget_for_agent`; gated entirely by `ROBOCO_TASK_BUDGETS_ENABLED` — off, neither cap is ever consulted. | +| AgentOrchestrator._check_budget_for_agent | coroutine | roboco/runtime/orchestrator.py:8187 | Per-agent budget check called from the sweep; resolves the active task, checks breach, and calls `_handle_task_budget_breach`. | | AgentOrchestrator._handle_pm_assigned_task | method | roboco/runtime/orchestrator.py:9084 | Spawn/respawn the PM for an assigned coordination root subject to the respawn gate. | | AgentOrchestrator._AUTO_SUBMIT_VERB_BY_ROLE | ClassVar[dict] | roboco/runtime/orchestrator.py:10650 | Wave-1 PR-gate turn cut: maps cell_pm -> (cell_pm, submit_up) and main_pm -> (main_pm, submit_root), the flow route+verb that assembles the parent's PR for each coordinator role. | | AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. Unconditional otherwise — no flag (the `pr_gate_auto_submit_enabled` kill-switch was removed post-0.19.0). | @@ -3409,6 +3445,7 @@ stateDiagram-v2 - No flag — the PR-gate turn cut is unconditional: when every child of an assembled parent is terminal, `_try_auto_submit` always runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity/AC-coverage/race) falls back to the classic PM closure spawn (the sole safety net), with the reason threaded into the PM's closure prompt. The `pr_gate_auto_submit_enabled` kill-switch that gated this through 0.19.0 has been removed. - `ROBOCO_OBSIDIAN_VAULT_ENABLED` (default off; both compose files set `true`) — gates `_dispatch_vault_curation_work` (the vault-curation Auditor-spawn trigger), `_vault_intake_loop`'s outer check, and (V2) `_vault_janitor_loop`'s sole gate. `ROBOCO_VAULT_INTAKE_ENABLED` (default off; both compose files set `true`) — the `_vault_intake_loop`'s own switch, consulted only when the vault master switch is also on. V2: `ROBOCO_VAULT_ARCHIVE_DAYS` (default 30) / `ROBOCO_VAULT_REPORT_ENABLED` (default true) tune the janitor's archival pass and weekly report, both folded into `_vault_janitor_loop` with no separate loop. `ROBOCO_VAULT_KB_ENABLED` (+ `_KB_DIRS` default `RoboCo/Notes` / `_KB_INTERVAL_SECONDS` default 900, default off; NAS compose sets it `true`, registry compose leaves it `false`) — the second, independent switch `_vault_kb_loop` checks alongside the vault master switch. - No flag — `_pending_claim_blocked` (the dependency/sequence claim-gate prefilter) is unconditional, mirroring `TaskService.claim`'s own always-on sequence gate; it is a pure dispatch-time optimization (fails open on lookup error) with no behavior change vs. attempting and failing the claim. +- `ROBOCO_TASK_BUDGETS_ENABLED` (default off) — arms `_sweep_budget_exceeded`; off = the sweep never runs and neither `tasks.budget_usd` nor `projects.monthly_budget_usd` is ever consulted regardless of field values. `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` / `_MAX_ATTEMPTS` (default 5) / `_MAX_AGE_SECONDS` (default 21600/6h) — the notification-driven-respawn breaker (`_notification_spawn_cooled`/`_over_cap`/`_notification_has_live_work`), independent of `ROBOCO_TASK_BUDGETS_ENABLED` and always on (`0` on any one of the three tunables disables just that guard). - `ROBOCO_TELEGRAM_ENABLED` (default off) — V1 master switch; `telegram_inbound_enabled` is a sub-switch on top of it, so both plus stored credentials are required before `_telegram_poll_loop` does anything. `ROBOCO_TELEGRAM_INBOUND_ENABLED` (default off, NAS compose arms it `true`) — gates `_telegram_poll_loop`; also makes escalation DMs (not completion DMs) carry an actionable Approve/Reject/Open keyboard. `ROBOCO_TELEGRAM_POLL_INTERVAL_SECONDS` (5.0) / `_POLL_TIMEOUT_SECONDS` (25) / `_MAX_UPDATES_PER_CYCLE` (50) / `_PENDING_REPLY_TTL_SECONDS` (300) tune the poll cadence and the force_reply prompt lifetime. ## Gotchas @@ -3422,6 +3459,8 @@ stateDiagram-v2 - Budget-kill (`_enforce_grok_cost_budget`) finalizes the spawn session BEFORE popping the instance so captured usage/cost isn't lost; the reaper then releases the freed claim. - `_should_skip_live_reap` short-circuits like the original `and`: when not live, none of the three kill checks is awaited. The three kill paths are: `_maybe_kill_wedged_grok` (grok idle TTL), `_maybe_kill_stuck_claude` (non-GROK agent stuck past `claude_stuck_kill_seconds`, default 3600s), and `_maybe_recover_broken_gateway`. A Claude agent stuck in a genuine verb loop (still firing gateway verbs, so heartbeat advances) remains spared — the stuck-claude TTL only catches heartbeat-stale containers. - Tests/helpers that construct `AgentOrchestrator` via `AgentOrchestrator.__new__(AgentOrchestrator)` to bypass `__init__` rely on `__new__` pre-initializing `_instances`, `_last_audit_spawn_at`, and `_notification_spawn_at`. Any refactor that moves those initializations out of `__new__` or adds new instance attributes read by `_dispatch_audit_work`/`_is_agent_active`/notification-cooldown paths without also pre-initializing them will re-break those helpers. `__init__` must continue to re-initialize `_instances` so normal construction is unaffected. +- The escalation/approval/audit/a2a dispatchers spawn a notification's recipient every cooldown window for as long as it stays pending, and these spawns carry no `task_id` — the PM respawn breaker (`_pm_respawn_should_gate`) never sees them, so before `_notification_spawn_over_cap`/`_notification_has_live_work` existed a single wedged alert whose recipient never resolved it respawned that recipient forever (observed live: 6+ hours of a ~2-3min respawn cycle). Both guards gate AFTER the existing cooldown, not instead of it. +- `_handle_task_budget_breach` blocks the task (HUMAN resolver) BEFORE the graceful stop specifically so the unclaim that follows is a no-op onto an already-`blocked` task — ordering it the other way would let the dispatcher immediately respawn onto the freshly-unclaimed, still-over-budget task. `unblock` on a budget-blocked task re-checks live spend and refuses while still over, closing the silent re-breach loop that ordering alone doesn't. - `_auto_submit_target` requires BOTH `branch_name` and `project_id` on the parent — a MegaTask umbrella (branchless coordination) always fails this check and falls through to the classic PM closure spawn, which is correct (an umbrella assembles no PR) but means the turn cut never applies to the top of a MegaTask tree, only its root-subtasks. ## Drift from CLAUDE.md @@ -3443,6 +3482,8 @@ stateDiagram-v2 > - `49b93248` (PR #504, 2026-07-13): Extended `AgentOrchestrator.__new__` to also pre-initialize `_last_audit_spawn_at` and `_notification_spawn_at`. Hardens the `__init__`-bypass construction path against `AttributeError` when the auditor-dispatch and notification-cooldown paths read those attributes; the existing `_fresh_orchestrator` helper's explicit initialization is now defensive rather than required. `__init__` still re-initializes the registries for normal construction. > - `fd621f0d` (PR #544, 2026-07-17): adds `_is_video_authoring_spawn` (a fail-closed role/team/task-source probe) and threads a new `task_id` param through `_generate_mcp_config` so `_append_role_scoped_mcp_servers` can register the `playwright` MCP for a ux-dev spawned onto a `source=video` task, alongside its existing fe-qa/ux-qa grant — see `docs/map/video-engine.md` for the composition-authoring side of this wire. > - `3b9fd0e0` (PR #551, Telegram V2, 2026-07-17): adds `_telegram_poll_task` + `_telegram_poll_loop`/`_run_telegram_poll_cycle` (mirrors the `_x_mentions_poll_loop` shape: gated on BOTH `telegram_enabled` AND `telegram_inbound_enabled`, started in `start()`, cancelled in `stop()`) driving the new `TelegramInboundEngine` (`roboco/services/telegram_inbound.py`) — long-polls Telegram `getUpdates` and dispatches `/status` `/queue` `/task` commands plus Approve/Reject button callbacks to the SAME CEO-gated service methods the HTTP routes call. +> - `b91229f4` (#643, "break the notification-driven respawn loop"): adds `_notification_spawn_cooled` (line 4738), `_notification_spawn_over_cap` (line 4780), `_prune_notification_spawn_maps` (line 4811), and `_notification_has_live_work` (line 4848) — both new guards gate AFTER the existing cross-tick cooldown. `_instances.__init__`/`__new__` gain `_notification_spawn_at: dict[tuple[str, str], float]` (existing cooldown map) plus a companion hard-cap counter; every notification-triggered spawn dispatcher (escalation/approval/audit/a2a) is threaded through both new checks. +> - `7c8453e2` (#654, "per-task and per-project cost budgets"): adds `_fetch_budget_status`/`_task_budget_breach`/`_handle_task_budget_breach`/`_sweep_budget_exceeded`/`_check_budget_for_agent` (lines 8021-8214) gated by `ROBOCO_TASK_BUDGETS_ENABLED`. The claim-time project-month-spend guard (`project_budget_exceeded_guard`) lives in `docs/map/gateway-support.md`; this is the sweep-side task-budget half. The same PR bundles `1d5a8e84` (#652's re-escalation backoff, see `docs/map/notification.md`) as a prerequisite commit in its branch history. ## Regression Risks @@ -4016,10 +4057,10 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon | _check_cell_pm_a2a | function | roboco/agents_config.py:590 | A2A permission for cell PM (own cell / other PMs / main-pm allowed; board escalated) | | _check_cell_member_a2a | function | roboco/agents_config.py:604 | A2A permission for cell members (same-cell allowed; cross-cell via PMs) | | _check_main_pm_a2a | function | roboco/agents_config.py:624 | A2A permission for main PM (_MAIN_PM_TARGETS allowed) | -| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via notify, board/main_pm/cell-member via handlers; the CEO branch now consults `_check_ceo_a2a` (below) instead of an unconditional `True` | -| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (auditor/pr_reviewer/prompter/secretary — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed | +| can_a2a_direct | function | roboco/agents_config.py:632 | (allowed, error) for direct A2A from one agent to another; routes CEO via `_check_ceo_a2a`, `from_role == "auditor"` unconditionally refuses (silent peer observer — replies to a CEO-opened DM go through A2AService's stateful reply path instead, not this matrix), board/main_pm/cell-member via handlers | +| _check_ceo_a2a | function | roboco/agents_config.py:635 | CEO-initiated A2A target check: refuses `to_role in NO_COMMS_ROLES` (prompter/secretary only — no `dm`/`read_a2a` on the manifest, so nothing on the other end could read or answer it), else allowed. auditor/pr_reviewer carry `dm`/`read_a2a` now (CEO-reachable, reply-only) so they're no longer in NO_COMMS_ROLES | | get_a2a_route_hint | function | roboco/agents_config.py:670 | Human-readable routing hint for an A2A message | -| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 88 (`ceo` group 18) after `_check_ceo_a2a` excludes no-comms roles; the panel switchboard's section matrix reads off this same computation | +| A2A_ALLOWED_PAIRS | constant | roboco/agents_config.py | Statically-derived (via `_compute_a2a_allowed_pairs()`, calling `can_a2a_direct` for every pair) set of legal A2A pairs — sized 93 (`ceo` group 23) now that auditor + the 4 pr_reviewer slugs are CEO-reachable; the panel switchboard's section matrix reads off this same computation | | _PATTERNS | list | roboco/agent_sdk/prompt_guard.py:28 | Five (regex, reason) injection patterns: ignore-previous, role-override, fake role prefix, control-token mimicry, fake executive-order | | detect_injection | function | roboco/agent_sdk/prompt_guard.py:63 | Return deny reason if text matches an injection pattern (lowercased), else None | | refusal_message | function | roboco/agent_sdk/prompt_guard.py:72 | Guidance string shown on denial (mirrors bash hook text) | @@ -4456,6 +4497,7 @@ release-manager slice > - b3558d4e [chore] complexity: split 5 C-rank blocks to <=B for the xenon gate — refactored large methods in executor/proposal for xenon compliance; no behavior change. > - 8621d01d / fe9940de / d80dfb8b (#534, env-branches ladder) — replaces `default_branch`-keyed release targeting: `_ReleaseContext` gains `prod_branch` (renamed from `default_branch`) + `env_chain`; `get_release_executor` resolves the clone/commit/tag target via `roboco.models.env_branches.prod_branch` and computes `env_chain` via `promotion_chain`; `_GitReleaseOps` gains `promote_env_chain` (fetch + head-first merge of `env_chain` into the prod checkout, fail-closed `promotion_failed` on conflict), run as the first step of `_run_fresh_release`, before any version bump; `release_readiness.gather_snapshot` takes an optional `prod_branch` and diffs `prod..head` (falling back to `last_tag..HEAD` when unset) with a new `_tag_drift_gaps` check (last-tag sha vs. prod tip); `release_manager_engine._production_assess` best-effort fetches the prod rung into the head-pinned read clone before gathering the snapshot. A project with no declared ladder is unaffected (the shim resolves prod_branch/head_branch to the same `default_branch` value). > - `11915f36` (PR #551, Telegram V2 security follow-up) — `_approve_precheck` (new) makes `approve()` refuse a CANCELLED proposal (`already_rejected`) or a COMPLETED one (`already_published`) BEFORE touching the Redis lock/executor; `reject()` now raises the new `TaskAlreadyCompletedError` on a COMPLETED proposal instead of silently cancelling an already-public release. Closes a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call). +> - `f74131a1` ("commit the approve() COMPLETED write under the release lock"): `approve()` used to `flush()` the published proposal's COMPLETED status and leave the durable commit to the background caller (`_run_approve_background`), which runs AFTER `approve()`'s `finally` has already released the Redis lock — in that window a concurrent `reject()` could acquire the freed lock, re-read a row whose COMPLETED write was only flushed (invisible to its own session under READ COMMITTED), pass its guard, and flip the just-published proposal to CANCELLED (last writer wins). The earlier bug-sweep (#638) fixed `reject()`'s side of this race but left `approve()`'s, so `reject()`'s "fails closed on a terminal proposal" guarantee didn't hold end to end. `approve()` now `commit()`s COMPLETED WHILE STILL HOLDING the lock (mirroring `XPostService._post`'s commit-under-lock), so it's durable before release and a racing reject sees it and refuses. ## Regression Risks @@ -4471,7 +4513,7 @@ release-manager slice The slice is well-structured: deterministic correctness lives in pure primitives (release_readiness) with a Protocol seam (ReleaseOps) making the fail-closed ordering unit-testable, and the detect→originate→hold→CEO-approve→publish separation is clean and matches CLAUDE.md. Post-snapshot hardening rounds (2759edf7, 05616607, 0bf6c848) resolved all four previously-flagged regression risks: (1) the Redis mutex TTL race is closed by a heartbeat loop that keeps the TTL refreshed and aborts execute fail-closed on lock-loss; (2) commit_and_push/publish_release RuntimeErrors are now caught by execute and returned as structured commit_failed/publish_failed results (no 500); (3) Redis outage now returns redis_unavailable (not already_in_progress) so the CEO knows to fix Redis; (4) the zombie-on-timeout is fixed by awaiting proc.wait(). The approve route is now async-202 with a background dispatcher (dispatch_approve). The half-landed (publish_failed) retry path (release_commit_sha) closes the prior gap where a second CEO approve re-inserted the changelog entry and created a duplicate release commit. The release CI gate is decoupled from self_heal_ci_workflow via a dedicated settings.release_ci_workflow. One low-severity known-by-design item remains: first-release version_ref gap suppression (intentional). release_manager_engine.py and release_readiness.py are unchanged since 15effce0. ## Purpose -This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live. The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. +This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex and Gemini are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. ## Files @@ -4495,6 +4537,14 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | roboco/llm/providers/grok_auth.py | SuperGrok token refresh-token grant loop + --check backstop CLI; atomic auth.json rewrite | 317 | | roboco/llm/providers/grok_cli_config.py | Entrypoint renderer: mcp-config -> ~/.grok/config.toml, per-role grok flags, AGENTS.md, bash-guard hook, + default-off fable-mode honesty-nudge hook | 317 | | roboco/llm/providers/grok_cli_usage.py | Capture token usage from grok sessions/updates.jsonl -> usage.json (notional cost) | 201 | +| roboco/llm/providers/codex.py | CodexCliProvider: spawns roboco-agent-codex container, mounts ~/.codex dir (RO) + usage dir + codex env | 239 | +| roboco/llm/providers/codex_auth.py | ChatGPT-subscription refresh-token grant loop + --check backstop CLI; JWT-exp decode (only expiry signal), atomic auth.json rewrite | 298 | +| roboco/llm/providers/codex_cli_config.py | Entrypoint renderer: mcp-config -> ~/.codex/config.toml, Starlark execpolicy deny rules, per-role --sandbox level, combined system+task prompt (no verified system-prompt-file mechanism) | 277 | +| roboco/llm/providers/codex_cli_usage.py | Capture token usage from codex exec --json turn.completed events -> usage.json (real input/output/cache-read/cache-write split, priced per-bucket) | 184 | +| roboco/llm/providers/codex_cli_sniff.py | Classify a codex run's terminal state (rate_limit/auth/none) from ONLY structured error.message JSONL fields + stderr, never the model's own transcript | 124 | +| roboco/llm/providers/gemini.py | GeminiCliProvider: spawns roboco-agent-gemini container, copies host ~/.gemini OAuth creds into a container-local writable copy + usage dir + gemini env | 264 | +| roboco/llm/providers/gemini_cli_config.py | Entrypoint renderer: mcp-config -> ~/.gemini/settings.json + per-role TOML Policy Engine deny rules (no native tool-removal flag), GEMINI.md blueprint | 293 | +| roboco/llm/providers/gemini_cli_usage.py | Capture token usage from gemini --output-format stream-json terminal result event -> usage.json (per-GA-model pricing); remaps quota/rate-limit errors to exit 75 | 290 | | roboco/agent_sdk/__init__.py | Package docstring only | 10 | | roboco/agent_sdk/models.py | Pydantic models: A2A messages, budget/terminal/verb-circuit/token-usage request+status | 258 | | roboco/agent_sdk/prompt_guard.py | Prompt-injection detector (5 patterns) + CLI for grok entrypoint turn scan | 93 | @@ -4537,6 +4587,28 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | GrokCliProvider._append_grok_auth_mount | staticmethod | roboco/llm/providers/grok.py:161 | Mount host ~/.grok DIRECTORY RO to /home/agent/.grok-auth-ro (dir, not file, so tmp+rename refresh propagates); warn if auth.json missing | | GrokCliProvider._append_usage_mount | staticmethod | roboco/llm/providers/grok.py:193 | Mount per-agent data dir so entrypoint writes usage.json orchestrator reads at finalize | | GrokCliProvider._append_grok_env | method | roboco/llm/providers/grok.py:205 | Append ROBOCO_AGENT_ID/MODEL/MCP_CONFIG/INITIAL_PROMPT/GROK_USAGE_FILE env to docker run | +| CodexCliProvider | class | roboco/llm/providers/codex.py:110 | Spawns roboco-agent-codex container: mounts host `~/.codex` (RO, DIRECTORY not single file — a bind-mounted single file pins the inode, same concern as grok's mount) with the entrypoint symlinking `~/.codex/auth.json` from it while codex's own writable state (config.toml, rules/, sessions/) lives in the image's own `~/.codex` | +| default_auth_path (codex) | function | roboco/llm/providers/codex_auth.py:73 | `CODEX_HOME`/`~/.codex/auth.json` path resolution, mirrors grok_auth's own | +| refresh_if_stale (codex) | function | roboco/llm/providers/codex_auth.py:251 | Orchestrator-side refresh loop for the JWT access token (the ONLY expiry signal is its `exp` claim — unlike grok's bundle there's no sibling `expires_at` field); the refresh-token grant against `auth.openai.com/oauth/token` is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern (`_recheck_or_refresh`, line 228) that protects grok's rotation from a concurrent double-burn | +| _exp_from_jwt | function | roboco/llm/providers/codex_auth.py:91 | Decode the access token's JWT `exp` claim — codex has no `expires_in`/`expires_at` sibling field at all, so this is the ONLY signal (grok has this only as a fallback) | +| codex_auth.main | function | roboco/llm/providers/codex_auth.py:282 | `--check` backstop CLI (entrypoint refuses to start on a missing/expired token) or bare orchestrator refresh-if-stale | +| sandbox_level_for_role | function | roboco/llm/providers/codex_cli_config.py:181 | Per-role `--sandbox` level: `workspace-write` for `developer`, `read-only` for every other role — codex's tool-scoping has no CLI-flag equivalent to grok's `--disallowed-tools` | +| render_execpolicy_rules / write_execpolicy_rules | functions | roboco/llm/providers/codex_cli_config.py:210 / 230 | One shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s denying git-mutation/destructive/raw-package-manager commands) rendered per role | +| render_combined_prompt / write_combined_prompt | functions | roboco/llm/providers/codex_cli_config.py:236 / 252 | Codex has no verified system-prompt-file mechanism, so the composed role blueprint is PREPENDED to the task prompt itself rather than mounted separately | +| codex_cli_config.main | function | roboco/llm/providers/codex_cli_config.py:282 | Entrypoint renderer: mcp-config → `~/.codex/config.toml`, execpolicy rules, combined prompt | +| aggregate_usage_from_jsonl | function | roboco/llm/providers/codex_cli_usage.py:80 | Sums `turn.completed` events' real input/output/cache-read/cache-write split — a genuine four-bucket split, unlike grok's output-only fallback | +| usage_and_cost (codex) | function | roboco/llm/providers/codex_cli_usage.py:113 | Prices the aggregated four-bucket usage into `usage.json` | +| capture_run_usage (codex) | function | roboco/llm/providers/codex_cli_usage.py:133 | Entrypoint: write usage.json for the run | +| extract_error_text / is_rate_limited / is_auth_failure / classify | functions | roboco/llm/providers/codex_cli_sniff.py:52-94 | Classifies a codex run's terminal state (`rate_limit`/`auth`/none) from ONLY the structured `error.message` JSONL field + stderr — NEVER the model's own transcript (which could false-positive on ordinary on-topic prose; this repo's own prompts use the phrase "quota-limited"). Codex has no exit-code taxonomy (every failure exits 1), so this sniff is the only signal the orchestrator has to park the provider. | +| GeminiCliProvider | class | roboco/llm/providers/gemini.py:135 | Spawns roboco-agent-gemini container: copies the RO-staged host `~/.gemini` OAuth creds into a container-local WRITABLE copy so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy — unlike grok/codex, Google's refresh token is reusable, so there is NO orchestrator refresh daemon for Gemini at all | +| policy_rules_for_role / render_policy_toml | functions | roboco/llm/providers/gemini_cli_config.py:158 / 189 | Per-role TOML Policy Engine deny rules (`~/.gemini/policies/roboco.toml`, keyed by `toolName`/`commandPrefix`) — gemini has no CLI-flag equivalent to grok's `--disallowed-tools` either | +| render_settings_json | function | roboco/llm/providers/gemini_cli_config.py:195 | `~/.gemini/settings.json`: `security.auth.selectedType` for headless OAuth, `experimental.enableAgents=false` (fleet-wide subagent ban), `advanced.autoConfigureMemory=false` | +| gemini_cli_args | function | roboco/llm/providers/gemini_cli_config.py:225 | `--approval-mode yolo` (universal headless auto-approval) + `--max-turns` cap | +| write_gemini_memory | function | roboco/llm/providers/gemini_cli_config.py:248 | Writes the composed role blueprint as `GEMINI.md` | +| gemini_cli_config.main | function | roboco/llm/providers/gemini_cli_config.py:276 | Entrypoint renderer: mcp-config → settings.json + per-role policy TOML + GEMINI.md | +| extract_model_stats / usage_and_cost (gemini) | functions | roboco/llm/providers/gemini_cli_usage.py:130 / 146 | Reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats — no session-file scraping — and prices each of the three GA models (`gemini-2.5-pro`/`-flash`/`-flash-lite`) at its own rate | +| classify_exit_code (gemini) | function | roboco/llm/providers/gemini_cli_usage.py:232 | Remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, mirroring codex/grok's park signal; exit 41 (the CLI's own auth-failure code) passes straight through | +| gemini_cli_usage.main | function | roboco/llm/providers/gemini_cli_usage.py:268 | Entrypoint: write usage.json for the run | | refresh_if_stale | function | roboco/llm/providers/grok_auth.py:307 | Mint fresh access token from refresh_token grant if expiry within skew; acquires _refresh_lock then delegates to _recheck_or_refresh to prevent concurrent double-rotation; returns fresh/refreshed/missing/no_refresh_token/failed (best-effort, never raises) | | _recheck_or_refresh | function | roboco/llm/providers/grok_auth.py:283 | Locked body of refresh_if_stale: re-load bundle + re-check staleness inside _refresh_lock, then call _do_refresh if still stale (prevents concurrent double-rotation of the single-use refresh grant, #94) | | _atomic_write | function | roboco/llm/providers/grok_auth.py:138 | Rewrite auth.json atomically (tmp+replace) with direct-write fallback so a rotated single-use refresh_token is never lost (F006) | @@ -4611,6 +4683,12 @@ Interactive intake (prompter) and secretary containers run intake_main/secretary The streaming.py callback is set once at bootstrap (websocket_bridge) and invoked by the agent reasoning path to broadcast chunks to /ws/agents/{id}. transcript_retention.select_prunable_transcripts is invoked by the orchestrator's cleanup loop against ~/.claude/projects to delete agent-owned old transcripts (never the operator's own session dirs). +**Codex spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.OPENAI` → `CodexCliProvider`. Spawn mounts the host `~/.codex` directory (from a one-time `codex login`, `ROBOCO_HOST_CODEX_DIR`) read-only; the entrypoint symlinks `~/.codex/auth.json` from that RO mount while codex's own writable state (`config.toml`, `rules/`, `sessions/`) lives in the image's own `~/.codex`, so the orchestrator's atomic tmp+rename refresh can still reach the credential without a single-file-mount inode pin. `roboco/llm/providers/codex_auth.py` runs the orchestrator-side refresh loop (`refresh_if_stale`, mirroring `grok_auth.py`): the access token is a JWT whose `exp` claim is the ONLY expiry signal, and the refresh-token grant is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern that protects grok's rotation from a concurrent double-burn. Tool scoping is a per-role `--sandbox` level (`workspace-write` for developer, `read-only` otherwise) plus one shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s) instead of a `--disallowed-tools` flag; the composed role blueprint is prepended to the task prompt itself (no verified system-prompt-file mechanism). Codex has no exit-code taxonomy (every failure exits 1) — `codex_cli_sniff.py` classifies a run's terminal state from ONLY the structured `error.message` JSONL field + stderr, never the model's own transcript. `codex_cli_usage.py` sums `turn.completed` events into a real four-bucket (input/output/cache-read/cache-write) usage split. + +**Gemini spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.GEMINI` → `GeminiCliProvider`. Spawn stages the host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) read-only, then the entrypoint COPIES it into a container-local writable `~/.gemini` so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy — Google's refresh token is REUSABLE (unlike grok's single-use one), so each container refreshing its own copy independently is safe with NO orchestrator refresh daemon for Gemini at all (a deliberate contrast the module docstring spells out against grok/codex). Tool scoping is expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`experimental.enableAgents=false` fleet-wide subagent ban); `--approval-mode yolo` is universal headless auto-approval. `gemini_cli_usage.py` reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats (no session-file scraping), prices each of the three GA models at its own rate, and remaps a quota/rate-limit error (parsed from the run's JSON `error.type`, no dedicated CLI exit code) to exit 75 — exit 41 (the CLI's own auth-failure code) passes straight through. Both Codex and Gemini are one-shot delivery-role runtimes only in this release — no interactive Intake/Secretary support (only Claude and Grok drive those chats). + +**Interactive-role exemption (post-finale sweep, #661).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex or Gemini, since neither provider supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. + ## Mermaid ```mermaid graph TD @@ -4761,6 +4839,11 @@ runtime-providers - ROBOCO_GROK_TURN_TIMEOUT_SECONDS (grok_cli_session per-turn watchdog, default 600) - ROBOCO_PROMPTER_SESSION_ID / ROBOCO_SECRETARY_SESSION_ID / ROBOCO_WORKSPACE / CLAUDE_CODE_SUBAGENT_MODEL (intake/secretary entrypoints) - ROBOCO_AGENT_ROLE / ROBOCO_AGENT_TOKEN (secretary_driver HMAC auth, server, grok_cli_session role resolve) +- ROBOCO_HOST_CODEX_DIR (codex.py host `~/.codex` mount source, from a one-time `codex login`) +- ROBOCO_CODEX_CLI_MODEL (default `gpt-5.3-codex` — codex has no reliable default) +- CODEX_HOME (codex_auth.default_auth_path) +- ROBOCO_HOST_GEMINI_DIR (gemini.py host `~/.gemini` staging mount source, from a one-time interactive `gemini` login) +- ROBOCO_GEMINI_CLI_MODEL (pins one of the three GA ids: `gemini-2.5-pro`/`-flash`/`-flash-lite`) ## Gotchas @@ -4780,6 +4863,11 @@ runtime-providers - grok_cli_config: --always-approve is REQUIRED for headless runs (without it grok cancels the first tool call). Safety holds via --disallowed-tools (removes tools) + --deny (hard-blocks patterns) regardless of approval. - grok_cli_config denies git mutation via native --deny (graceful, run continues) but exfil categories via the bash-guard PreToolUse hook (which CANCELS the run on deny) — deliberate split: a reflexive git op must not drop a task, but an exfil attempt should hard-cancel. - grok -p ignores -s (requested session id); the real session id is read back from the run log's end event (session_id_from_run_log) and reused via -r on subsequent turns to persist conversation context. +- codex's RO host mount is the DIRECTORY (like grok's, unlike a naive single-file mount) for the same reason: a single-file bind mount pins the inode, so the orchestrator's atomic tmp+rename refresh would never reach a running container. The Codex CLI's own writable state (config.toml, rules/, sessions/) is NOT on that RO mount — it lives in the image's own `~/.codex`, so only `auth.json` is symlinked from the RO side. +- codex's refresh token is single-use (like grok's) — `codex_auth.py`'s process-wide lock + re-check-inside-the-lock mirrors `grok_auth._recheck_or_refresh` exactly to prevent a concurrent double-burn of the same grant. +- gemini's refresh token is REUSABLE (unlike grok/codex) — this is the one structural asymmetry in the whole provider family: no orchestrator-side refresh daemon exists for Gemini at all, by design, because each container can safely refresh its own local copy independently without a single writer serializing rotation. +- codex has NO exit-code taxonomy — every failure exits 1, so `codex_cli_sniff.py` must classify from structured JSONL `error.message` fields only; it deliberately never inspects the model's own transcript text, since this repo's own prompts legitimately use phrases like "quota-limited" that would false-positive a transcript-text classifier. +- gemini's quota/rate-limit signal has no dedicated CLI exit code either — `gemini_cli_usage.classify_exit_code` parses the run's own JSON `error.type` and remaps to exit 75 (the same park signal grok's 429 and codex's rate-limit sniff produce), so the orchestrator's park-and-probe loop treats all three providers identically at that seam despite three different underlying signals. ## Drift from CLAUDE.md @@ -4797,6 +4885,12 @@ runtime-providers > Post-snapshot updates (since 2026-06-29): 536bbb64 (Chore/all/logical gaps sweep #286) — grok_auth.py: added module-level `_refresh_lock = threading.Lock()` (line 48) and new `_recheck_or_refresh` helper (line 283); `refresh_if_stale` now acquires `_refresh_lock` before the grant POST and delegates to `_recheck_or_refresh` inside the lock so a concurrent caller that waited finds the already-refreshed token and returns `fresh` instead of re-POSTing the now-dead single-use refresh grant (#94). All other commits touching roboco/runtime/ in this window only modified orchestrator.py (out-of-scope for this slice). > +> **Codex CLI provider (#659, `c70ff3cf` + npm-install fix `13abb2ec`).** New `codex.py`/`codex_auth.py`/`codex_cli_config.py`/`codex_cli_usage.py`/`codex_cli_sniff.py` add `ModelProvider.OPENAI` running OpenAI's official `codex` CLI on a ChatGPT subscription — `ProviderRegistry` now registers OPENAI alongside GROK. Migration 083 seeds the provider row. The install method itself needed a follow-up fix: the CDN-hosted `install.sh` the image first tried is blocked from RoboCo's build network, so the Dockerfile installs the CLI via `npm install -g` instead (no other behavior change). +> +> **Gemini CLI provider (#660, `21d67304`).** New `gemini.py`/`gemini_cli_config.py`/`gemini_cli_usage.py` add `ModelProvider.GEMINI` running Google's official `gemini` CLI on an OAuth login — `ProviderRegistry` now registers GEMINI alongside GROK/OPENAI. Migrations 084 (enum value) / 085 (seed row) / 086 (enable the row). +> +> **Post-finale completeness sweep (#661, `d4b7e1e7`).** Closes gaps found after the Codex/Gemini rollout: one-click `apply_mode` entries + panel mode cards for both new providers (`docs/map/support-services.md`); the interactive-role exemption (GLOBAL/ROLE rows on OPENAI/GEMINI are not-applicable to `intake-1`/`secretary-1`, an explicit AGENT_SLUG pin onto either is refused loudly by the spawn guard rather than silently breaking the interactive chat driver); compose env passthrough for `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` and `ROBOCO_TASK_BUDGETS_ENABLED` (the provider host-mount dirs were already wired by the provider PRs themselves); and a `gt=0` validation tightening on the task/project budget fields (`docs/map/gateway-support.md`, `docs/map/orchestrator.md`) so a `0` budget — which would silently block everything — is rejected outright. +> > **v0.18.0** (2026-07-04): Fable mode's grok side — `fable_honesty_nudge_hook_config`/`write_grok_fable_hooks` (grok_cli_config.py:308-345), gated by `fable_mode_enabled` (default off). Deliberately narrower than the Claude path's 5 hooks: only the never-denying PostToolUse honesty-nudge is ported, because a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) — the same asymmetry this file's Gotchas section already documents for the bash-guard's git-deny-vs-exfil-cancel split. ## Regression Risks @@ -4809,6 +4903,8 @@ runtime-providers | /usage/sync path guard may reject legit symlinked transcripts | roboco/agent_sdk/server.py:770 | _contained_transcript_path rejects any transcript whose resolved path is not under the resolved transcript root. A legitimately symlinked transcript dir under ~/.claude/projects pointing outside (e.g. a shared NAS location) now returns HTTP 400 and usage_sync silently returns the current snapshot — token usage for that agent stops updating without a loud failure. | low | | _coerce_draft drops null/non-array the_work to [] | roboco/agent_sdk/intake_driver.py:116 | New coercion wraps non-list the_work via _coerce_to_list, which returns [] for numbers/bools/None. A draft the agent emitted mid-spec with the_work: null now arrives downstream as the_work: [] (empty list) instead of null/absent. Downstream 'has work?' presence checks that distinguish null from empty may now see an empty list and behave differently (e.g. treat an incomplete draft as a zero-work draft). | low | | Missing auth.json now logs warning but still spawns doomed container | roboco/llm/providers/grok.py:185 | When ~/.grok/auth.json is absent, _append_grok_auth_mount now only logs a warning (was: silently skipped the mount). The spawn still proceeds and the container exits 78 at the entrypoint --check. Behavior of the spawn path is unchanged, but an operator who does not tail logs will still diagnose a later exit-78; the warning is only useful if someone reads it. | low | +| codex_cli_sniff classifies from error.message text only | roboco/llm/providers/codex_cli_sniff.py:94 | Codex has no exit-code taxonomy, so every quota/rate-limit/auth distinction rests entirely on the shape of a structured JSONL `error.message` field. If a future codex CLI version changes that field's wording or moves the signal elsewhere, the sniff silently falls through to "none" and the orchestrator crash-retries straight back into the same rate limit instead of parking the provider — the same failure mode the overload-break feature exists to prevent for the other providers. | medium | +| Gemini has no orchestrator-side refresh daemon at all | roboco/llm/providers/gemini.py:135 | The reusable-refresh-token design is safe under the CURRENT assumption (each container's local copy refreshes independently, no shared writer to serialize). If Google ever changes the refresh grant to single-use (matching grok/codex), every container refreshing concurrently would race to burn the same grant with no lock protecting it, unlike grok_auth/codex_auth's process-wide lock — this provider has no equivalent safety net because none was ever needed under the current contract. | low | ## Health This slice is coherent and well-factored: the provider ABC + registry cleanly isolates the Grok backend while the Anthropic/Ollama/LOCAL paths stay on the built-in spawn (additive seam, no destabilization), and the agent_sdk sidecar centralizes budget/loop/verb-circuit/token state that hooks share. The single baseline-to-HEAD commit (15effce0) landed three genuine hardening fixes — the F006 refresh_token-loss guard with direct-write fallback, the JWT-exp decode so a refreshed token isn't forever rejected, and the /usage/sync path-traversal guard — plus the grok directory-mount fix that resolves the inode-pinning hang. The main integrity concerns are operational rather than structural: the grok directory mount widens RO exposure to host grok state, the 6h expires_at default can burn the single-use refresh_token on the rare double-miss, the in-process SDK state is lost on every container restart (by design, but means verb-circuit/budget counters reset), and ClaudeCodeProvider is dead reference code whose 'default' label in CLAUDE.md is misleading. Interactive intake/secretary parity between Claude and Grok is real (shared IntakeDriver, only the SessionFactory differs). No obviously broken logic was introduced; the regression risks are edge-case behavior shifts, not holes. Recommend re-running the grok auth refresh test against a token that omits expires_in to confirm the JWT-exp path, and a /usage/sync test with a symlinked transcript to confirm the new guard fails loud where appropriate. @@ -4903,6 +4999,7 @@ The RoboCo video engine: a default-off subsystem that authors bespoke short mark | `roboco/services/gateway/content_actions.py` `propose_video` | Server-side action: team-gated (`_caller_team` rejects be-dev/fe-dev), resolves the caller's open video task, `markers.set_video_draft` with the metadata. | — | | `roboco/services/gateway/content_actions.py` `request_render` | Do-verb (developer/QA): renders the caller's ACTUAL composition to keyframe PNGs via the sidecar's frames mode and stamps the `render_preview` marker — dev renders their own tree (worktree-aware, `head_sha`/`dirty` stamped), QA a read-only branch export (`WorkspaceService.export_branch_motion`). Frames land at the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`. | — | | `roboco/foundation/policy/tracing.py` `RENDER_VERIFIED` | `i_am_done` requirement on `source=video` tasks: no stamped `render_preview` → tracing gap naming `render_preview` (hint: call `request_render`, Read every frame). Mirrored in the possibilities-matrix fast path. | — | +| `roboco/api/routes/video.py` `GET /video/preview-frames/{task_id}` / `.../{orientation}/{filename}` | CEO-gated routes serving the dev's already-rendered `request_render` preview frames — a `source=video` authoring task reaches `awaiting_ceo_approval` with no MP4 yet (rendering only happens post-completion), so the CEO otherwise has nothing to review. Frame listing is parsed from the self-describing `.previews/{task8}/{orientation}/frame-<idx>-of-<n>-at-<t>s.png` filenames (`_FRAME_NAME_RE`) rather than the `render_preview` marker (which only holds the LAST call's single orientation); the per-frame route streams a PNG behind the same `_resolve_preview_path` confinement guard the composition-HTML proxy already used. | — | | `alembic/versions/062_tiktok_credentials.py` | Migration 062 — the `tiktok_credentials` singleton row (Fernet-encrypted OAuth2 secrets, all-or-nothing set/clear, mirroring the git-token / `x_credentials` pattern). | 44 | | `video-renderer/` | The sidecar: `server.js` (HTTP+tarball boundary), `render.js` (`@hyperframes/producer` `createRenderJob` + `executeRenderJob`, system `ffmpeg`, headless Chromium). Credential-free and git-free — reads only what's POSTed. pnpm-managed (`pnpm-lock.yaml`, no npm `package-lock.json`); `@hyperframes/producer` pinned exact at `0.7.36` (`0.7.60` fails every render). | — | | `docker/video-renderer.Dockerfile` | Sidecar image (`roboco-video-renderer`): Node + Chromium + system `ffmpeg`; installs `@hyperframes/producer`. No RoboCo source, no creds. | — | @@ -4931,6 +5028,7 @@ CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET ## Changes Since Baseline +- **2026-07-20** (#608, `a5d8c6bd`, "CEO can preview a video authoring task's frames before approving"): two CEO-gated routes serve the `request_render` preview frames (see Files above) so the CEO has something concrete to review at `awaiting_ceo_approval` before any MP4 exists. The task-detail Overview gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame stepper with composition id, duration, and a dirty badge — shown for a video task with preview frames or awaiting CEO approval. - **2026-07-17** (PR #543, `3e801697`): Two renderer root causes fixed — `@hyperframes/producer` was floating (`^0.7.36`, no lockfile), so image builds silently picked up `0.7.60`, which fails EVERY render ("Cannot access 'rt' before initialization"); pinned exact (`0.7.36`, no caret) and committed a lockfile (regenerated as `pnpm-lock.yaml` by the immediate follow-up `a12fefcb`, not the npm `package-lock.json` this PR first wrote — this package is pnpm-managed). Second: the producer's per-clip visibility scheduler runs on a clock that lags ~50% behind the encoded timeline on a long cut, so tail scenes (past roughly the halfway mark) were silently missing from the MP4 regardless of authoring — fixed by treating `class="clip"` + `data-start`/`data-duration` as a structural-layer-only primitive and driving every beat with base-hidden styles + a delayed CSS animation instead (documented in `motion/README.md`'s "Clip windows are for structural layers only" rule). Also added the two choreography engines to `motion/kit/kit.js` (`choreographCursor` / `choreographCamera`, see Files above) plus a "Cinematography & rhythm" section in `motion/README.md` and a craft-bar block in the dev video spawn prompt (`roboco/runtime/orchestrator.py`) so a locked-off camera or a popping/freezing cursor reads as an automatic revision. - **2026-07-17** (PR #544, `fd621f0d`): The three craft capabilities wired one hop closer to the hands doing video work — vendored the vendor's own official HyperFrames agent skills (`hyperframes-core`/`-creative`/`-keyframes.md`, see Files above; supersedes the external-pointer-only version briefly added by the intervening `1416bd1d`); registered the `playwright` MCP for a ux-dev spawned onto a `source=video` task (`_is_video_authoring_spawn`, fail-closed role/team/task-source probe — gating-only, `agent-ux`'s image already bakes the browser); and added a video-mode override to the `ux_ui` team prompt's design bar ("video-authoring tasks are FILMS, not UI — these dials do not apply") so a video task no longer reads its own "dense product UI → motion 2-3" dial as license to ship a static slideshow. - **2026-07-17** (Wave 6, PR #550): Authoring craft, not engine code — `motion/README.md` gained `## Visual design bar (demo/kit register)` (spacing/hierarchy, beat pacing, `pk-chip`/`pk-pill` semantic discipline, camera+cursor+rhythm, anti-generic tells for the `kit/` register), four upstream HyperFrames craft references vendored verbatim under `motion/skills/references/` (fixing `hyperframes-creative.md`'s previously-dead `references/` pointers), and a new `motion/skills/hyperframes-catalog-index.md` (133-entry HyperFrames catalog vocabulary index, read-on-demand). No service/verb/schema change; the render/post pipeline documented above is untouched. @@ -4947,8 +5045,6 @@ Default-off, CEO-gated at two independent points (the flags, then per-clip appro - `docs/map/release-manager.md` — the sibling held-artifact engine whose lock shape `VideoPostService.approve` mirrors - `docs/map/engines-heal-ciwatch-depupdate.md` — the other default-off originate-and-stop engines -# models slice - ## Purpose The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, services, orchestrator, and agent runtimes all speak. These are **not** the ORM tables (`roboco/db/tables.py` owns persistence); models here are request/response schemas, domain aggregates, runtime DTOs, and enums that get crossed into SQLAlchemy rows by the service layer. Validation (`RobocoBase`: `extra="forbid"`, `validate_assignment`, `use_enum_values`) lives here, and several files (`agents.py`, `runtime.py`, `optimal.py`, `metrics.py`, `llm.py`, `audit.py`, `dashboard.py`, `transcription.py`, `extraction.py`, `permissions.py`) are pure dataclasses/StrEnums with no Pydantic model at all — runtime value types the orchestrator and services pass around. @@ -5219,10 +5315,9 @@ Logic-touching commits: ## Health The models package is coherent and well-layered: a single `RobocoBase` config drives consistency, enums are centralized in `base.py` (with the `AgentRole`/`Team` alias-to-`foundation.identity` migration clearly commented), and the API/Pydantic vs runtime/dataclass split is explicit (`agent.py` vs `agents.py`, with a docstring calling it out). The recent MegaTask per-cell-map change is small, additive, and validator-guarded; the main follow-on risk is migration 052 parity and test assertions on the renamed validator message — both mechanical. Two long-standing cleanliness items linger: `handoff.py` is a reserved-but-unwired model (file header says so), and several files (`dashboard.py`, `transcription.py`, `extraction.py`) are pure dataclasses that read as service-layer DTOs rather than domain models — harmless but slightly muddies the "models = typed contract surface" framing. Enum parity with the ORM (`tables.py`) is enforced by the test gate. No blocking issues; the package is in good shape. -# db-migrations slice ## Purpose -The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→061) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 38 tables). +The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→086) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 38+ tables — not recomputed for this delta, several 077-086 migrations add columns to existing tables rather than new ones). ## Files @@ -5234,7 +5329,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for | `roboco/db/seed.py` | `bootstrap_database()` — runs `init_db` then seeds agents. | | `alembic/env.py` | Async Alembic env; imports `roboco.db.tables` to register metadata, overrides `sqlalchemy.url` from settings, `compare_type` + `compare_server_default` on. | | `alembic.ini` | Standard config; `script_location=alembic`, `prepend_sys_path=.`, no URL (set in env.py). | -| `alembic/versions/` | 61 migration files 001..061 (two share number 026 — chained, not a collision). | +| `alembic/versions/` | 86 migration files 001..086 (two share number 026 — chained, not a collision). | ## Key Symbols @@ -5336,6 +5431,16 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for | 074 | 074_telegram_credentials.py | `telegram_credentials` (singleton Fernet-encrypted `bot_token_encrypted` + `chat_id_encrypted`, mirrors `x_credentials`) — the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, default off). | | 075 | 075_company_goals_company_name.py | `company_goals.company_name` (Text, `server_default=""`) — CEO-authored product/company name, mirroring `brand_voice`. Feeds `CompanyGoalsService.resolve_product_name` (project name → this field → the "RoboCo" literal fallback), which `XEngine`/`VideoEngine` both call so release posts/videos stop hardcoding "RoboCo". Additive and inert until the CEO sets it in the Business → Goals editor. | | 076 | 076_project_git_provider.py | `projects.git_provider` (nullable `String(16)`, not a pg enum — validated at the service layer by `roboco.foundation.policy.forge.validate_project_forge`) — Phase 0 of the forge-providers spec (GitHub + Gitea + GitLab). Null = auto-detect from the `git_url` host (github.com → github; anything else is a registration-time rejection unless the operator sets this column explicitly — the GitHub Enterprise / self-hosted escape hatch). Additive: every existing project keeps resolving to GitHub behavior until GitLab/Gitea providers are set. See `docs/map/worksession-git.md`. | +| 077 | 077_github_app.py | `github_app_credentials` (singleton Fernet-encrypted App id + private key) + `projects.github_installation_id` (BigInteger, nullable) — a project can bind to a GitHub App installation instead of a bare PAT, with PAT fallback on any mint failure. See `docs/map/worksession-git.md`. | +| 078 | 078_project_codegen_command.py | `projects.codegen_command` (String(500), nullable) — per-project command run in the task's worktree right before every push, auto-committing any codegen drift into the same push (RoboCo itself sets `make codegen`). See `docs/map/worksession-git.md`. | +| 079 | 079_notification_backoff.py | `notifications.reescalation_count` / `.last_reescalated_at` / `.reescalation_delivered_count` — the per-notification exponential re-escalation backoff schedule, replacing the prior every-sweep-tick-forever re-escalation. See `docs/map/notification.md`. | +| 080 | 080_task_project_budgets.py | `tasks.budget_usd` (Float, nullable) + `projects.monthly_budget_usd` (Float, nullable) — per-task and per-project cost budgets (`ROBOCO_TASK_BUDGETS_ENABLED`, default off). See `docs/map/orchestrator.md` / `docs/map/gateway-support.md`. | +| 081 | 081_doctrine_version.py | `agent_spawn_sessions.doctrine_version` (String(32), nullable) — stamped at spawn-session finalize from the composed prompt layers, so a golden-task eval cohort's model+doctrine combination (e.g. Fable-mode on vs. off) is durably identifiable after the fact. See `docs/map/tests.md`. | +| 082 | 082_routing_presets.py | `routing_presets` (id, name, `payload` JSONB, timestamps) — named, full-snapshot save/restore of the routing state (mode + every assignment row, AGENT_SLUG pins included). See `docs/map/support-services.md`. | +| 083 | 083_seed_openai_provider.py | Seeds the OpenAI (Codex CLI) provider row — `ModelProvider.OPENAI`. See `docs/map/runtime-providers.md`. | +| 084 | 084_modelprovider_gemini.py | Adds `gemini` to the `modelprovider` postgres enum. | +| 085 | 085_seed_gemini_provider.py | Seeds the Gemini CLI provider row — `ModelProvider.GEMINI`. | +| 086 | 086_enable_gemini_provider.py | `UPDATE provider_configs SET enabled = true` for the Gemini row 085 seeded `enabled=false` — Grok gets force-enabled via its `apply_mode="grok"` write path, but `apply_mode` grew no `"gemini"` case until this same change, so without this migration a Mix-mode assignment to a Gemini model would resolve against a permanently-disabled row and silently fall back to Anthropic (wired end-to-end everywhere except reachable). Codex (083) sidesteps this by seeding `enabled=true` directly. See `docs/map/runtime-providers.md`. | ## Data Flow On boot, `init_db()` probes for application tables and `alembic_version`; if a pre-Alembic DB exists it stamps it at revision 001, then always runs `run_migrations()` → `alembic upgrade head` (in a thread via `asyncio.to_thread`). `env.py` imports `roboco.db.tables` so `Base.metadata` is fully populated, overrides `sqlalchemy.url` from `settings.database_url`, and runs online with an async NullPool engine. `compare_type` + `compare_server_default` are on so autogenerate drift is detectable. `tables.py` classes are the ORM mapping the migrations build; the domain layer reads them through `roboco/models/` dataclasses, not the tables directly. @@ -5498,6 +5603,8 @@ Migration chain 001..059 > Delta 2026-07-04 (v0.18.0): `060_drop_messaging` (the comms-teardown migration — drops `messages`/`session_tasks`/`sessions`/`groups`/`channels` + 4 enum types + `journal_entries.session_id`; A2A is now the sole directed-message channel; one-way, `downgrade()` raises `NotImplementedError`) had already landed on master but was never appended to this doc; `061_x_feature_spotlight` adds `x_seen_features` (`XSeenFeatureTable`) + `company_goals.brand_voice` (X feature-spotlight, `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`, sub-switch of `x_engine_enabled`). Chain head is now 061. ORM table count is now 38 (verified via `grep -c '^class .*Table' roboco/db/tables.py`), up from this doc's previously-stated 37 (that figure predates 055-061 and was never recomputed). > > Delta 2026-07-18/19: `075_company_goals_company_name` adds `company_goals.company_name` (X/video product-branding fallback) and `076_project_git_provider` adds `projects.git_provider` (forge-providers Phase 0 — GitHub/Gitea/GitLab). Chain head is now 076 (062-072 remain the pre-existing table gap noted above — this delta only closes 073-076). +> +> Delta 2026-07-21/23 (10 migrations, 077-086): `077_github_app` (GitHub App credentials + `projects.github_installation_id`), `078_project_codegen_command` (`projects.codegen_command`, auto-regenerate-before-push), `079_notification_backoff` (re-escalation columns replacing the every-tick-forever sweep), `080_task_project_budgets` (`tasks.budget_usd` + `projects.monthly_budget_usd`), `081_doctrine_version` (`agent_spawn_sessions.doctrine_version`, the golden-task eval cohort stamp), `082_routing_presets` (named full-snapshot routing presets), `083_seed_openai_provider` + `084_modelprovider_gemini` + `085_seed_gemini_provider` + `086_enable_gemini_provider` (Codex + Gemini CLI provider rows — see `docs/map/runtime-providers.md`). Chain head is now 086. ## Regression Risks @@ -5515,9 +5622,6 @@ Migration chain 001..059 ## Health The chain is linear and complete (001→076), with `init_db` running `upgrade head` on every boot so deployed schemas stay current. The two structural risks are the `sa.Enum(create_type=False)` no-op in 001 (latent on clean re-applies) and the enum-parity gate's dependence on a populated migrated DB. New migrations consistently use the `postgresql.ENUM(create_type=False)` pattern and `ALTER TYPE ... ADD VALUE IF NOT EXISTS` for enum widening, so recent additions are safe. -# RoboCo Slice Map — `api-core-websocket` - -Slice key: `api-core-websocket` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco` Baseline commit: `fd10cc862c2020b3f639cdb686d427b0198a2441` Head: `15effce0` (2026-06-29, "Chore: 141 Gaps fill-in (#283)") ## Purpose @@ -5600,6 +5704,10 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out | `build_security_config` | func | security.py:329 | Assembles the global `SecurityConfig` from settings: passive_mode, fail_secure, enforce_https, WAF calibration fields | | `security_config` / `guard_deco` | module singletons | security.py:374-375 | Built once at import (pure, no I/O); `guard_deco` is the `SecurityDecorator` route files decorate with `@guard_deco.<verb>` | | `prompt_injection_validator`/`secret_exfil_validator`/`internal_ssrf_validator` | async funcs | security.py:116/128/139 | Custom `@guard_deco.custom_validation` content checks the signature WAF can't cover; each returns a generic 400 (no rule detail leaked) | +| `_guard_whitelist` / `_INTERNAL_NETWORKS` | func / const | security.py | WAF/IP-ban/rate-limit exemption for the internal agent mesh: loopback + docker's default bridge pool (`172.16.0.0/12`) ONLY — deliberately excludes `10.0.0.0/8`/`192.168.0.0/16` (those would also exempt a real LAN client hitting nginx, not just the docker mesh, since a genuine LAN browser's real IP survives the one XFF hop under `trusted_proxy_depth=1`) | +| `resolve_forwarded_client_ip` | func | security.py:479 | Peels loopback/operator-named (`guard_trusted_chain_peers`) hops from the right of XFF; the remaining candidate is honored ONLY if at least one hop peeled AND the candidate is in the tailnet CGNAT range (`100.64.0.0/10`) — every other shape (direct LAN, an unnamed 172.x relay even carrying a forged prefix, all-hops, malformed) abstains to the guard's own depth-1 resolution | +| `ClientIpResolutionMiddleware` | class | security.py:521 | Pure ASGI, mounted OUTSIDE `SecurityMiddleware` (added after it, so it runs first): stamps `request.state.client_ip` (guard_core's supported override seam) with `resolve_forwarded_client_ip`'s verdict; only honors XFF when the CONNECTING peer itself is trusted to present it (`_CONNECTING_PEER_NETWORKS`, broader than and independent of the hop-peel set) | +| `_warn_unconfigured_tailnet_gateway_once` | func | security.py:453 | Logs once per distinct gateway IP (never the full XFF) when a host-proxied tailnet chain is detected behind a real docker bridge gateway but no `guard_trusted_chain_peers` is configured — the one regression left genuinely silent otherwise (traffic resolves to a whitelisted bridge IP, WAF stays inert, zero signal) | | `_WAF_FREETEXT_BODY_FIELDS` | const | security.py:211 | Top-level free-text body-field exclusion set (`excluded_detection_body_fields`) — the WAF calibration; includes free-form container fields (plan/risks/findings/section/payload/...) whose nested prose is stringified and scanned | ## Data Flow @@ -5728,6 +5836,7 @@ roboco/api/ - `settings.app_version` / `settings.environment` / `settings.debug` — logged at startup; docs/redoc URLs are unconditional (the `if settings.debug` is commented out, app.py:207-208). - `settings.host` / `settings.port` — no longer used in websocket.py (the httpx self-call was removed); still referenced elsewhere. - `ROBOCO_GUARD_ENABLED` / `_PASSIVE_MODE` / `_FAIL_SECURE` / `_TELEMETRY_ENABLED` / `_AGENT_API_KEY` / `_PROJECT_ID` / `_EMERGENCY` / `_EMERGENCY_WHITELIST` — read by `roboco/security.py`, wired into `create_app` via `apply_guard(app)` (app.py:234) + `guarded_lifespan(lifespan)` (app.py:212); `ROBOCO_ENVIRONMENT` additionally drives `enforce_https` (production only). +- `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` (empty by default) — comma-separated exact IP(s), never a CIDR, trusted to appear as a recorded proxy hop inside XFF beyond loopback (e.g. the docker bridge gateway a host-proxied Tailscale Serve chain terminates behind); consumed by `resolve_forwarded_client_ip`/`ClientIpResolutionMiddleware`. See `docs/rag/architecture/http-security-guard.md`. - Otherwise no direct ROBOCO_* feature flags live in this slice; the lifespan applies persisted flag overlays via `apply_persisted_feature_flags` but does not itself read individual subsystem flags. ## Gotchas @@ -5773,6 +5882,8 @@ Diff stat: `app.py +20`, `deps.py +44`, `middleware.py +49`, `websocket.py +309/ > **Post-snapshot update (2026-07-01, logical-gap sweep `536bbb64`):** `deps.py` gained `require_ceo_role` (deps.py:412) — single source-of-truth CEO-role check shared by the orchestrator router gate and the release handler, replacing two diverged inline comparisons. `websocket.py` gated `/ws/system` with `_require_panel_token` (websocket.py:621), closing the medium regression risk; all `/ws/*` endpoints are now consistently gated (five at the time of this commit; the comms-subsystem teardown since removed `/ws/channels` + `/ws/sessions`, leaving `/ws/agents`, `/ws/notifications`, `/ws/system`). +> **Internal-mesh whitelist scoping + tailnet client-IP resolution (#605, `07bdef3d`, #646, #650).** `bf1012e9` (#605) first exempted the internal agent mesh (agents reach the orchestrator DIRECTLY on the docker bridge, HMAC-authenticated) from WAF/IP-ban entirely via a `whitelist` of ALL RFC1918 + loopback — but that also silently exempted any real LAN client reaching the host-published nginx port, since a genuine LAN browser's real IP survives the guard's depth-1 XFF resolution. `07bdef3d` scoped the whitelist down to loopback + docker's default bridge pool ONLY (`_INTERNAL_NETWORKS`), dropping `10.0.0.0/8`/`192.168.0.0/16`. Separately, `583d43f2` (#646) + `3806317a` (#650) added `ClientIpResolutionMiddleware`/`resolve_forwarded_client_ip` (security.py:479/521) to fix a real live incident: a host-proxied Tailscale Serve chain (tailnet client → Serve on loopback → nginx) resolved under depth-1 XFF to a whitelisted bridge/loopback IP, going WAF-inert for the whole `/tg` Mini App surface. The fix is deliberately narrow — it peels ONLY loopback or an operator-named `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` entry from the right of XFF, and only trusts the result if the remaining candidate is in the tailnet CGNAT range; every other shape (a direct LAN client, an unnamed 172.x relay even carrying a forged prefix, all-hops, malformed) abstains to the guard's own baseline depth-1 logic rather than trying to be a general XFF-chain resolver. `_warn_unconfigured_tailnet_gateway_once` logs once per gateway IP when this shape is detected with nothing configured, so the residual gap isn't silent. +> > **Local branch (not on master, NOT deployed):** `feature/fastapi-guard-hardening` (6 fastapi-guard commits `896532a3`..`99ee666e`, branched off `ab69851d`, plus 2 unrelated bundled commits) adds `roboco/security.py` and wires it into this slice — `apply_guard(app)` mounts `SecurityMiddleware` last in `create_app` (app.py:234) and `guarded_lifespan(lifespan)` wraps the async lifespan (app.py:212), both gated by `ROBOCO_GUARD_ENABLED` (default off, byte-for-byte unchanged request path when off). Per-route `@guard_deco.*` decorators (rate_limit/max_request_size/content_type_filter/behavior_analysis/block_clouds/honeypot_detection/usage_monitor/suspicious_detection/custom_validation — 9 kinds) are applied across 21 route files outside this slice (api-routes-schemas + v1 flow/do). `build_security_config` also carries a WAF false-positive calibration: `excluded_detection_body_fields` (75 free-text top-level body fields, including container fields like plan/risks/findings/section/payload) plus `enable_penetration_detection=True`, dropping active-mode false positives on RoboCo's own code/SQL/diff/URL-bearing traffic to zero while leaving the three custom validators and the WAF on non-excluded (id/enum/slug/branch) fields fully in force. New tests: `tests/unit/test_security.py` (unit) + `tests/unit/test_security_middleware.py` (integration — mounts the real middleware end-to-end). Both NAS composes (`docker-compose.yml`/`.yaml`) arm the layer passive/log-only (`c496b677`, Phase 5) — see deployment-tooling. Logic-touching changes in that commit, scoped to this slice: @@ -5811,7 +5922,6 @@ Logic-touching changes in that commit, scoped to this slice: ## Health This slice is the API spine and is in **good structural shape**. The baseline→head refactor (`15effce0`) materially hardened it: the WebSocket fan-out no longer back-pressures on a slow client (bounded per-connection queues + sender tasks), half-open sockets are reaped by an idle timeout, the lifespan shutdown ordering fix stops silent final-write drops, 422 logs no longer leak credentials, and the WS + HTTP panel-token gates close the operator-only invariant. Post-snapshot, `536bbb64` closed the remaining medium risk by gating `/ws/system` (all `/ws/*` endpoints are now consistently gated — five then, three after the comms-subsystem teardown) and added `require_ceo_role` as a single-source CEO check; `76ce53e3` wired the live message-delivery path (`_handle_message_event`, since removed by the teardown). The event-bus bridge is clean and follows the documented `_handle_*` extension pattern. The main remaining integrity concerns are minor: the `websocket.py` module docstring is stale vs the HMAC implementation, and the concurrent-set-mutation window between the sender task's error-path `disconnect` and the receive loop's `finally disconnect` is theoretically present under asyncio single-threadedness. No logic-touching change since baseline looks broken; the regression risks above are edge-case and mostly low severity. The unchanged files (`middleware_docs.py`, `utils/*`) are stable; `websocket_bridge.py` gained `_handle_message_event` (`76ce53e3`, since removed by the comms-subsystem teardown — the surviving forwarder is `_handle_a2a_message_event`). Recommended follow-ups: refresh the `websocket.py` docstring, and consider a lock or snapshot-iteration in `ConnectionManager` broadcast paths. -# Slice: api-routes-schemas ## Purpose The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the operator/panel `api/*` CRUD + dashboard/orchestrator/a2a/live bridges) and the agent-gateway `api/v1/flow/*` (intent verbs) + `api/v1/do/*` (content tools), with Pydantic request/response schemas under `roboco/api/schemas/`. Routes are thin handlers that resolve services via `Depends` and return typed responses; all agent-gateway verbs funnel through the Choreographer. @@ -5845,7 +5955,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | roboco/api/routes/release.py | CEO-only release proposal approve/reject. | | roboco/api/routes/playbooks.py | Playbook approve/reject/archive (Auditor/CEO). | | roboco/api/routes/pitch.py | Pitch create/list/approve/reject. | -| roboco/api/routes/provider.py | Provider catalog + ollama/grok/self-hosted key + mode. | +| roboco/api/routes/provider.py | Provider catalog + ollama/grok/self-hosted key + mode + routing presets (`/presets` list/save/apply/delete). | +| roboco/api/routes/github_app.py | GitHub App credentials CRUD (App id + private key) + installation/installation-repo listing (backs the create/edit-project repo picker). | | roboco/api/routes/usage.py | Token usage summary/time-series/by-agent/team/model/role/sessions, cache-efficiency, spawn-waste (per-role unproductive-spawn rate + respawn strikes). | | roboco/api/routes/system.py | System-wide info. | | roboco/api/routes/docs.py | Project docs write/read/list/delete. | @@ -5888,6 +5999,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | GET/POST | /api/x/posts, /posts/{id}/{approve,reject}, /credentials | x.py | `require_ceo_role` (agent context) | | GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) | | GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) | +| GET/POST/DELETE | /api/providers/presets, /presets/{id}/apply, /presets/{id} | provider.py | agent context — save/apply/delete a named full routing snapshot (`docs/map/support-services.md`) | +| GET/PUT/DELETE | /api/github-app/credentials ; GET /installations, /installations/{id}/repos | github_app.py | agent context — App id + private key CRUD, installation/repo listing for the panel's repo picker | | POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` | | GET | /api/telegram/today | telegram.py | `require_ceo_role` (agent context) + 30/60s rate limit — Mini App V4's "Today" brief, backed by `TgCockpitService.today()` (one DB round trip, see `docs/map/notification.md`) | | GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login | @@ -6081,9 +6194,6 @@ roboco/api/ ## Health The route layer is thin, consistently organized (one router per domain, one schema file per router), and the agent-gateway HMAC guard is centralized in `_role_dep.py` + `deps.py`. Main risks are the any-role `do`/`a2a` gate (relies on service-layer scope), the 422 response echoing secrets, and the two divergent CEO guards — all addressable without structural change. SSE live-chat streams are the fragile transport path. -# RoboCo Slice Map — `mcp-servers` - -Scope: `roboco/mcp/` (every server file + `schemas/` + `utils.py`). Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco`. ## Purpose @@ -6432,7 +6542,6 @@ No other commits in this slice since baseline. ## Health The slice is coherent and well-defended: the flow/do servers share a near-identical, heavily-commented breaker/404-synthesis contract (with the duplication acknowledged in comments as a deliberate mirror), the manifest-gated registration is fail-loud and blocks the off-role-verb class, and the `StrList` / dict-error / 404 fixes added in `15effce0` close real observed retry-storm and validation-rejection loops. The main integrity concerns are (a) the duplicated breaker logic across flow/do is a drift hazard — a future change to `_CIRCUIT_REJECTION_KINDS` or classification must be applied in both files or the two servers diverge; (b) CLAUDE.md's server table is stale (3 servers + many tools unlisted), which could mislead a reader into thinking intake/secretary/search are not agent-facing MCP servers; (c) the breaker's fail-open posture is correct but means the protection is only as good as the SDK loopback staying responsive within 2s. No correctness bugs observed; the slice is fit for purpose. -# Choreographer Slice Map ## Purpose The Choreographer is the server-side composition layer that turns agent intent-verbs (`give_me_work`, `i_will_work_on`, `i_am_done`, `delegate`, `submit_up`, `submit_root`, `complete`, …) into ordered sequences of atomic TaskService / GitService actions. It owns the precondition gates the lifecycle spec does not model (concurrency invariants, tracing/progress gates, free-text soup, conventions, behind-base, unchanged-PR loop-stoppers) and wraps every composed mutation in a SAVEPOINT via `VerbRunner`. Every verb returns a standardized `Envelope` (`ok` / `error` + `next` + `remediate` + `context_briefing`). @@ -6608,7 +6717,6 @@ Choreographer (composed class, _impl.py) ## Health The slice is structurally sound: the SAVEPOINT boundary, intermediate-None INVALID_STATE guard, and role-exempt coordinator concurrency model are coherent and well-documented. The highest-temperature areas are the new (since baseline) fail-open loop-stoppers and fail-closed lane guard — both correct by design but tightly coupled to resolvers (`_current_pr_head_sha`, `has_earlier_incomplete_code_sibling`) whose regressions silently revert the protection. The 814-line delta is concentrated in guard additions rather than control-flow rewrites, so baseline behavior is largely preserved; the main residual risk is verb-body discipline around the trailing-None contract for any future verb. -# task-service slice ## Purpose `TaskService` is the authoritative owner of the task lifecycle: CRUD, hierarchical create (incl. MegaTask umbrella + root-subtasks), claim/locking, every status transition, completion/CEO approval/cancellation, dependency DAG wiring, rework routing, and completion-time learning capture. All status writes funnel through `_validate_and_set_status` + `_emit_status_transition_audit` so the audit journey and the `revision_count` rework counter stay in lockstep with real task state. @@ -6646,7 +6754,10 @@ The slice is structurally sound: the SAVEPOINT boundary, intermediate-None INVAL | `is_pending_claim_blocked` | method | task.py:2864 | Read-only wrapper over `_claim_blocked_by_sequencing` (dependency OR sequence) so the orchestrator dispatcher can filter a doomed claim before attempting it (`_pending_claim_blocked` in orchestrator.py). | | `stamp_wave_sequence` | method | task.py:7452 | Stamps a freshly delegated subtask's `sequence` as `1 + max(sequence of each same-parent dependency target)`, or `0` when independent — so independent siblings tie (parallel under the sequence gate) while colliding/ordered work ascends. Runs POST-wiring (after the collision DAG / cross-cell edges land); PM-authored sequences are never rewritten. | | `_apply_dependency_lineage` / `_merge_one_dependency` | method | task.py:2308 / 2337 | Claim-time content assist (not a gate): merges each same-repo dependency's landed work into a freshly cut branch when it lies outside the branch's own ancestor chain (`GitService.merge_dependency_lineage`); a real conflict aborts the merge and stamps a `dependency_lineage_conflict` transition note instead of failing the claim. | -| `_finalize_claim` | method | task.py:2925 | Work-session create/inherit, branch cut, proactive-context injection. | +| `_finalize_claim` | method | task.py:3265 | Work-session create/inherit, branch cut, proactive-context injection; calls `_inherit_upstream_base` when `_should_inherit_base` says the reclaimed branch may need an upstream merge. | +| `_should_inherit_base` | function | task.py:404 | Pure double gate for base inheritance on a re-claim: role must WORK the branch (developer/cell_pm/main_pm — QA/documenter/gate claims review the branch as pushed and never move it) AND pre-claim status must be pending/needs_revision (never a PM's `i_will_plan` re-claim of its own `awaiting_pm_review` task, which already passed review on that branch). | +| `_inherit_upstream_base` | method | task.py:2640 | Reuses the dependency-lineage merge to fold the advanced parent/base branch into a pre-existing task branch on re-claim: already-ancestor is a no-op, a clean merge logs an audit trail, a real conflict aborts at the cut point and appends a `base_inheritance_conflict` transition note + dev-note steering the assignee to `sync_branch` — never fails the claim itself. | +| `_note_base_inheritance_conflict` / `_append_base_inheritance_dev_note` | methods | task.py:2708 / 2736 | Log + record the conflict as a transition note, then surface it to the assignee via `dev_notes`. | | `_inject_proactive_context` | method | task.py:3154 | Briefing injection at claim (institutional memory when `org_memory_enabled`). | | `_completion_learnings_for` | method | task.py:2798 | Distill one lesson (ON) vs legacy raw capture (OFF). | | `_extract_completion_learnings` | method | task.py:2837 | Fire-and-forget learning record + RAG indexing. | @@ -6752,6 +6863,7 @@ stateDiagram-v2 - `_apply_dependency_lineage` is scoped to SAME-REPO dependencies only (`dep_task.project_id != ctx.project.id` short-circuits) — a cross-repo dependency edge (e.g. a MegaTask root-subtask in another project) has no shared git history to merge and is silently skipped; the dependency TIMING gate still holds the claim regardless of repo. - `TaskTable.orchestration_markers` is generic `JSON`, not `JSONB`. Any SQL predicate on a marker key must use `.as_string()` (or the JSON dialect's generic comparator), not `.astext`, which is JSONB-only and raises `AttributeError` at compile time. `list_open_docs_sync_tasks(version=...)` at task.py:1596 is the current example; the inline comment records the rationale. - Both cancel and terminal-completion now force-delete (`-D`) the task's LOCAL branch ref in the assignee's clone alongside the worktree — a completed task's PR was squash-merged (its local ref is never an ancestor of base, so a "safe" `-d` refuses unconditionally) and a cancelled task's work is discarded by decision, so the ref is spent either way. Skipped when the branch name coincides with an environment-ladder rung (`effective_environments`), which outlives any one task. +- Upstream-base inheritance on re-claim (`_finalize_claim` → `_should_inherit_base` → `_inherit_upstream_base`) reuses the SAME merge primitive as `_apply_dependency_lineage` (`GitService.merge_dependency_lineage`), just pointed at the branch's own parent instead of a `dependency_ids` entry — it is a content assist, not a gate, so a merge conflict never fails the claim, only appends a note. It is double-gated (role AND pre-claim status) precisely so a QA/doc claim, or a PM re-claiming its own already-reviewed `awaiting_pm_review` task, never silently moves a branch that has already passed review. ## Drift from CLAUDE.md - CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale. @@ -6768,6 +6880,8 @@ stateDiagram-v2 > (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `_audit_events_for` (task.py:997) gains `task.request_changes` (agent_role `cell_pm`/`main_pm`) and `task.ceo_reject` (agent_role `ceo`) branches alongside the existing `task.qa_fail`/`task.pr_fail`; `ceo_reject` gains reason validation + a ledger `Finding` insert (see above); `qa_fail` and `request_changes` drop their raw `dev_notes` appends (the mirror-column data-loss bug) in favor of the ledger + a structured note. Full detail: `docs/map/review-findings.md`. > > (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Local branch refs stop leaking: `_delete_task_branch_best_effort`/`_remove_task_worktree_on_terminal` now also force-delete the assignee's local branch ref (via new `WorkspaceService.delete_local_branch`) and rmtree the task's `.previews/{task8}` video-preview dir, both skipped for environment-ladder rungs. See `docs/map/worksession-git.md` for the paired `GitService.cleanup_stale_branches` sweep. +> +> `2e889c70` (#644, "work re-claims inherit an advanced upstream base"): a re-claim (pool release / reaper unclaim / escalation redirect / a dev's own `i_will_work_on` re-entry) reused whatever branch had been cut at the EARLIER claim, so any work that landed on the parent/base branch since then (e.g. a sibling cell merging into root, or UX/UI landing before BE/FE re-claims) never reached the reclaimed branch — silent divergence, avoidable conflicts at PR time. `_finalize_claim` now calls `_inherit_upstream_base` after resolving the work session, gated by the double check above; a conflict never fails the claim, it steers the dev to `sync_branch` via a transition note + `dev_notes`. ## Regression Risks @@ -6786,15 +6900,6 @@ stateDiagram-v2 ## Health `TaskService` is the most load-bearing service and the most hardened: the audit chokepoint, `revision_count` centralization, branchless/umbrella exemptions, and worktree-on-terminal cleanup all landed in the two recent gap-closure commits. The residual risk is concentrated in the two CEO-path asymmetries (`ceo_approve` not closing the work session / not running the full completion hooks) and in `fail_qa`/`ceo_reject` rework routing, which depends on the unreliable `original_developer` marker and a work-session fallback that has no guarantee a developer session exists. -# RoboCo Slice Map — `worksession-git` - -Scope key: `worksession-git` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco` Files in scope: -- `roboco/services/work_session.py` -- `roboco/services/git.py` -- `roboco/templates/git/` (`__init__.py`, `branch.py`, `commit.py`, `constants.py`, `pr_internal.py`, `pr_root.py`) -- `roboco/services/forge/` (`__init__.py`, `base.py`, `github.py`, `gitea.py`, `gitlab.py`, `registry.py`, `router.py`, `shaping.py`) -- `roboco/foundation/policy/forge.py` -- `roboco/foundation/policy/pr_labels.py` ## Purpose @@ -6858,7 +6963,9 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService.create_branch` | method | git.py:989 | Build branch name, fetch base, `worktree add` (F123), push -u | | `GitService.create_branch_for_task` | method | git.py:1188 | Resolve workspace/team, create branch, commit DB | | `GitService.checkout_branch_for_agent` | method | git.py:1276 | Allowlist-bounded checkout for agent verb | -| `GitService.push_for_task` | method | git.py:1428 | Push the task's recorded branch by name (clone-checkout-independent) | +| `GitService.push_for_task` | method | git.py:1428 | Push the task's recorded branch by name (clone-checkout-independent); runs `_run_codegen_and_commit` first so any codegen drift lands in the SAME push | +| `GitService._codegen_command_for` | staticmethod | git.py:4397 | The project's `codegen_command` (nullable string column, migration 078), or `None` — a bare `MagicMock` test double auto-vivifies attributes, so the `isinstance` check also keeps unrelated tests inert | +| `GitService._run_codegen_and_commit` | method | git.py:4474 | Runs the project's `codegen_command` in the task's worktree right before push (hooked at both `push_for_task`/`push_branch` at git.py:1886 and `push_task_branch` at git.py:4689); fail-open (a broken/timeout command logs a warning and lets the push proceed — CI's own drift gate is the safety net) and a no-op for the common case of no declared command; a clean run with actual drift stages + commits it under the task's normal commit format before the push carries it | | `GitService.push_task_branch` | method | git.py:1467 | Gateway branch-keyed push | | `GitService.create_pull_request` | method | git.py:2132 | Open PR via GitHub API (legacy project-scoped) | | `GitService.create_pr_for_task` | method | git.py:2639 | Agent-facing open_pr verb | @@ -6884,7 +6991,9 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService.sync_task_branch` | method | git.py:3847 | Task-keyed rebase through dev `sync_branch` verb (pre-PR) | | `GitService.is_behind_base` | method | git.py:3889 | `(behind, ahead)` counts for i_am_done submit gate | | `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) | -| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) | +| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches, UNIONED with `_protected_branches_for_deletion(project_slug)` — the shared chokepoint every remote-delete path (task cleanup, PR-merge/close cleanup, the stale-branch sweep) routes through; returns `bool` (issued vs skipped/failed) | +| `GitService._protected_branches_for` | method | git.py:1183 | The project's own declared `protected_branches`, normalized (stripped, deduped into a `frozenset`) and unioned into the hardcoded `{master, main}` floor for rebase refusal (`rebase_onto_base`) and `sync_task_branch`'s force-push refusal; fails OPEN (empty set, logs a warning) on a project-lookup error — a wrongly-blocked rebase over a transient DB blip is the worse tradeoff | +| `GitService._protected_branches_for_deletion` | method | git.py:1219 | Deletion-only superset of `_protected_branches_for`: the declared field UNIONED with the project's env-ladder rung branches (`effective_environments` — a null ladder's synthesized single rung off `default_branch` is protected too, so a renamed trunk like `trunk` is covered even with no declared list); fails CLOSED (returns the hardcoded floor only, on a lookup error) since a skipped delete is free (next sweep retries) but silently deleting a real declared rung on an unresolvable project is not | | `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` | | `GitService.close_task_pr_best_effort` | method | git.py:3655 | No-clone-needed cancel-path cleanup: resolves the project token + `git_url`→`RepoRef`, fetches the PR via `self._forge.get_pr`, no-ops unless `state=="open"`, else `update_pr(payload={"state":"closed"})`; every failure path (missing token/project, unparseable URL, `httpx.HTTPError`) returns `False` rather than raising | | `GitService.cleanup_stale_branches` | method | git.py:3697 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable | @@ -7095,6 +7204,8 @@ Module-level tunables (not env): `_SLOW_GIT_OP_MS=5000`, `_CI_RUN_WINDOW=20`, `_ ## Gotchas +- **`protected_branches` is exact-match, case-sensitive, and additive-only**: an operator-declared list (panel: chips editor in the edit-project dialog) is UNIONED into — never replaces — the hardcoded `{master, main}` floor. The two consuming scopes deliberately fail differently on a project-lookup error: `_protected_branches_for` (rebase + `sync_task_branch` force-push refusal) fails OPEN to the hardcoded floor (a skipped rebase gets no free retry, so refusing wrongly is worse); `_protected_branches_for_deletion` (the shared remote-delete chokepoint) fails CLOSED (a skipped delete just retries at the next sweep, so silently proceeding on an unresolvable project is worse). An empty `protected_branches` list degrades to exactly the prior hardcoded-only behavior. +- **Token resolution is GitHub-App-first, PAT-fallback**: `ProjectService._resolve_token` (`roboco/services/project.py`, called from `GitService._token_for_project` → `get_decrypted_token_by_slug`) mints a short-lived installation token via `github_app_auth.mint_installation_token` when `project.github_installation_id` is set AND App credentials are stored; ANY minting failure (App not configured, installation revoked, network hiccup, the JWT/HTTP/parse path all wrapped so nothing escapes uncaught) falls back to the stored PAT instead of raising — a GitHub App outage never bricks git ops for an App-bound project that also has a PAT on file. `github_app_credentials.py` mirrors the `x_credentials`/`telegram_credentials` singleton-Fernet-row pattern (migration 077); the PEM is validated at credential-SET time (not first-mint) and the installation-token cache clears when credentials are deleted. - **Token transport split**: git-over-HTTPS uses HTTP **Basic** (`x-access-token:...`) via `http.extraheader` (git.py:289); the GitHub REST API uses **Bearer**. Swapping them causes silent credential-prompt failures. - **`pr_number` is NOT repo-scoped in `tasks.pr_number`** — every gateway merge/close path (`pr_merge`, `close_pull_request`, `pr_target`, `rebase_pr_for_task`) requires `project_id` to scope the task lookup. The route `merge_pr_for_task` instead verifies `data.pr_number == task.pr_number` (recorded PR is source of truth). - **`get_active_for_task` returns the most-recent ACTIVE row**, not `scalar_one_or_none` — the historical duplicate-ACTIVE defect would otherwise raise `MultipleResultsFound`. The invariant is also enforced at `create` and by a DB partial-unique index (migration 047). @@ -7139,6 +7250,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr > > **Forge providers — GitHub + Gitea + GitLab (2026-07-18/19, PRs #569/#571/#575/#579/#581).** A new `roboco/services/forge/` package (`base.py`/`github.py`/`gitea.py`/`gitlab.py`/`registry.py`/`router.py`/`shaping.py`) plus `roboco/foundation/policy/forge.py` route every REST call `GitService` makes (PRs/CI/reviews/labels/releases/provisioning) through a provider-agnostic transport. `388bab24` (Phase 0, #569): `projects.git_provider` column (migration 076, nullable, plain string not a pg enum — validated at the service layer, not the DB) + `validate_project_forge`/`detect_provider` (github.com auto-detects, self-hosted needs an explicit column value — the GHE/self-hosted escape hatch). `461a6e1a` (Phase 1, #571): the `GitProvider` ABC + `GitHubProvider` extracted byte-for-byte from `GitService`'s old inline `httpx` calls; `GitService._forge` (git.py:399) becomes the seam every call site routes through. `96401f4c` (Phases 2/2.1/3, #575): `GiteaProvider` + `GitLabProvider` + `ForgeRouter` (per-call transport dispatch off `RepoRef.host`) + the local-git `merge_branch` fallback for forges with no server-side merges API. `5f32d876` (Phase 4, #581): `roboco/services/github_provisioning.py` becomes provider-aware (`ROBOCO_PROVISIONING_PROVIDER`/`ROBOCO_PROVISIONING_HOST` — see `docs/map/product-strategy-research-pitch.md`) so pitch-driven repo creation works on all three forges. `d4cb5797` (#579) + the pre-existing `tests/e2e_smoke/test_gitea_live.py` are the live contract suites (self-seeding against a dockerized `gitea/gitea` / real `gitlab.com`, env-gated) that caught the slash-encoding and http-scheme gaps in the Gitea provider. Panel: the edit-project dialog's "Forge" `<Select>` (`edit-project-dialog.tsx`) offers Auto-detect/GitHub/Gitea/GitLab. > +> **Protected branches + env-ladder deletion protection (#649, #651).** `GitService._protected_branches_for`/`_protected_branches_for_deletion` (git.py:1183/1219) consult the new `projects.protected_branches` column end-to-end for the first time (the column previously existed with nothing reading it): the deletion scope additionally unions in the project's env-ladder rung branches (`effective_environments`), closing the gap where the post-merge PR-source cleanup and the stale-branch sweep could still delete a branch that IS a ladder rung — `delete_task_branch`'s own local rung check is now exactly subsumed and removed. Panel: a chips editor on the edit-project dialog. +> +> **GitHub App credentials + auto-regenerated codegen drift (#621/#633/5ca8a9c4, #632).** `roboco/services/github_app_auth.py`/`github_app_credentials.py` (new files) add a singleton Fernet-encrypted App id + private key (migration 077) and RS256 JWT + cached-installation-token minting; `ProjectTable.github_installation_id` lets a project bind to an installation (create-dialog repo picker, or an existing PAT project via the edit dialog's Unbind/re-bind section) with PAT fallback on any mint failure (see Gotchas). Independently, `GitService._run_codegen_and_commit` (git.py:4474, migration 078's `projects.codegen_command`) runs a per-project codegen command in the task's worktree right before every push and commits any resulting drift into the SAME push — RoboCo's own `codegen_command='make codegen'` keeps its checked-in lifecycle renders/verb tables from drifting invisibly past the agent's own gate (which omits `foundation-check`) until CI's separate drift gate catches it with no link back to the task. +> +> **Branch-list route fix (#610).** `roboco/api/routes/git.py`'s `list_branches` classified a remote-tracking ref by a literal `'remotes/'` prefix that `git for-each-ref --format=%(refname:short)` never emits, so every `origin/*` ref (including the symbolic `origin/HEAD`) rendered as a fake LOCAL branch. Listing now reads the full `%(refname)` and `_parse_branch_line` classifies on `refs/heads/` vs `refs/remotes/` (dropping `origin/HEAD`); the route also runs a best-effort `git remote prune origin` before listing so a branch already deleted upstream stops persisting in the panel's Git page forever, and the manual Fetch action now fetches with `--prune`. +> > **PR label base-branch agnosticism + cancel-path PR close (2026-07-18/19, "agnosticism-residue" + "cancel-pr-dependents-guard").** `derive_pr_labels` gained a required `base_branch: str` keyword-only param; the label is now `f"to {base_branch}"` (the PR's real resolved target) instead of `"to master" if is_root_pr else "to slave"` — a prerequisite for the env-branches ladder and non-GitHub forges, where "master"/"slave" aren't universal. All three `GitService` call sites (`_labels_for_pr_request`, the assembled cell→root/root→master PR opener) now thread the real resolved base branch through. Separately, `GitService.close_task_pr_best_effort` (git.py:3655) closes a task's forge PR on cancel — `TaskService._close_task_pr_best_effort` (task.py:6767) calls it once per cascaded descendant and once for the cancelled task itself, alongside the existing `_delete_task_branch_best_effort` — closing a gap where a cancelled task's branch was force-deleted but its open PR lingered on the forge forever. The same change adds `GitService._live_task_dependents` (git.py:3810) so `cleanup_stale_branches` never deletes a terminal task's branch while a non-terminal task still records it, or is a direct child of it (whose future PR base would resolve to that branch before it even opens a PR). ## Regression Risks @@ -7161,7 +7278,6 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr ## Health Integrity is **good and actively hardened**. The slice carries the scars of multiple live meltdowns (single-active work-session defect, pr_fail re-submit loop, cell_pm merge block<->unblock, MegaTask depth cap, cross-repo PR-number collision) and each is closed with a deterministic guard plus a comment explaining the failure mode. The F123 per-task-worktree routing is consistently threaded through commit/checkout/rebase/conventions, and the merge path has layered defenses (parent-row lock, 409 retry, already-merged disambiguation, CEO-only master guard). Two formerly-medium risks in the merge path have since been closed: `_merge_with_retry` now has the 405 method-fallback that `merge_pull_request` has (`536bbb64`), and `close_pull_request` now defaults to `delete_branch=False` (`536bbb64`). The remaining residual risk is **`pr_merge`'s post-merge target sync** running in the clone root (not the worktree) and best-effort-swallowing a checkout conflict — the local target ref may stay stale for the next sibling merge. Test coverage of the work-session lifecycle is solid; the newer `pr_merge`/`sync_task_branch`/`rebase_onto_base`/`close_pull_request` quartet deserves the most scrutiny on any future change. No outright bugs found; the drift vs CLAUDE.md is documentation undersell (commit header format, gateway merge-path description), not behavioral mismatch. The forge-providers rollout (GitHub/Gitea/GitLab) added real breadth without touching the merge-path defenses above — `GitService`'s callers still reason in GitHub-shaped responses, and `ForgeRouter`/`GiteaProvider`/`GitLabProvider` carry the entire adaptation burden behind that seam; the live-forge e2e suites (`tests/e2e_smoke/test_gitea_live.py`/`test_gitlab_live.py`, both env-gated and self-seeding) are the only coverage that exercises a real forge over the wire rather than a mocked transport, and they already caught two real gaps (slash-encoding, http scheme) the mocked unit tests couldn't have found. -# workspace slice ## Purpose WorkspaceService manages the per-agent git clone layout under {workspaces_root}/{project}/{team}/{agent}/, plus the F123 per-task linked worktrees under {clone_root}/.worktrees/{task}/. It clones, refresh-fetches, repairs ownership, installs dev deps, scaffolds the conventions standard on first clone, maintains a project-level read clone for the conventions engine, and runs the read-only dep-upgrade probe. It is the filesystem/git-clone substrate every agent spawn and every git verb eventually lands on. @@ -7401,10 +7517,6 @@ WorkspaceService slice ## Health WorkspaceService is a mature, heavily-instrumented slice with strong defensive hygiene: per-(project,agent) asyncio locks, partial-clone detection + rmtree, a real .git+HEAD+objects health check, scoped + TTL-cached refresh fetches, PAT injection that is never persisted to .git/config, a belt-and-suspenders leak scan that destroys the workspace on any hit, idempotent lockfile-digest-gated dev-deps install, and F123 per-task worktrees that eliminated the coordinator-PM clobber. The F063 + F116 + F123 + F-fix wave closed real deploy-blocker races (PAT leak on half-configured clone, read-clone mid-clone race, root-clobber, dangling venv symlink). Residual risk is concentrated in the worktree venv-symlink timing (first-claim skip depends on a later ensure to self-heal), the resume path reusing a possibly-stale local branch ref without a freshness check, and the process-local (not cross-process) ensure-workspace lock. The slice diverges from CLAUDE.md's "fresh claim git reset --hard" narrative — by design, post-F123 — and that doc drift should be reconciled. Overall integrity is high; the regression risks are edge-case rather than core-path. -# RoboCo Slice Map — `support-services` - -Slice key: `support-services` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco` Scope: `roboco/services/{agent,health,settings,toolchain,provider,llm,proactive,transcription,base,exceptions}.py`, `roboco/events/`, `roboco/seeds/`, `roboco/utils/` - ## Purpose Cross-cutting support layer beneath the delivery services: the service-base/error hierarchy every service inherits, Fernet crypto + UUID converters, the Redis-Streams event bus + workflow trigger handlers, the static seed data that bootstraps agents, infrastructure health probes, the runtime-editable settings/feature-flag store, agent-record lookups, per-project Python interpreter resolution, provider-config CRUD + per-agent model routing, proactive knowledge injection, and raw-stream transcription buffering. None of these own the task lifecycle; they are the plumbing the lifecycle services, orchestrator, and API routes compose on top. @@ -7465,12 +7577,19 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro | `get_decrypted_token` | method | `services/provider.py:211` | Decrypt provider token or None; raises `EncryptionError` on bad key | | `ModelRoutingService` | class | `services/llm.py:119` | Per-agent route resolution + assignment CRUD + mode apply | | `AgentRoute` | dataclass | `services/llm.py:95` | Frozen resolved route `(provider_id, type, base_url, auth_token, model_name)`; None base_url/token = Anthropic default | -| `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path | +| `resolve_for_agent` | method | `services/llm.py:194` | Precedence ladder AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL; optional `complexity` (a task's `estimated_complexity`, lowercased) enables the compound `ROLE:complexity` rung; never raises — downgrades to legacy Anthropic path | +| `_resolve_assignment` | method | `services/llm.py:248` | Tries AGENT_SLUG first, then (when `role` AND `complexity` both given) the compound `scope_value=f"{role}:{complexity}"` ROLE row, then the plain `role` ROLE row, then GLOBAL — the compound rung reuses the existing ROLE scope + `scope_value` column, no schema change | | `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` | | `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider | -| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | -| `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments | +| `apply_mode` | method | `services/llm.py:567` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted/codex/gemini; per-agent map for mix. `apply_mode('cost_tiered')` is the one exception — it ADDITIVELY seeds a single day-1 `developer:low` → haiku override instead of wipe-then-seed, so it composes with whatever mode is already active. | +| `derive_mode` | method | `services/llm.py:443` | Settings UI label from current assignments | | `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row | +| `_interactive_exempt` | func | `services/llm.py:176` | True when `agent_slug` is `intake-1`/`secretary-1` AND the resolved assignment routes to OPENAI/GEMINI — neither provider supports the interactive chat driver, so these two agents stay on Anthropic regardless of a fleet-wide mode switch; an explicit AGENT_SLUG pin onto either provider for these two slugs is refused loudly at the spawn/save boundary rather than silently producing a broken interactive session | +| `list_routing_presets` / `get_routing_preset` | methods | `services/llm.py:837,845` | List / fetch one saved `RoutingPresetTable` row | +| `save_routing_preset` | method | `services/llm.py:848` | Snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) into a named `RoutingPresetTable.payload` (JSONB) | +| `apply_routing_preset` | method | `services/llm.py:931` | Restore a saved preset wholesale — a full swap, validating every entry via `_validate_preset_entry` FIRST so an invalid payload never triggers the wipe (unlike a mode switch's pin-preserving behavior) | +| `_validate_preset_entry` | method | `services/llm.py:896` | Per-entry validation for `apply_routing_preset` (scope/provider/model shape) run before any row is touched | +| `delete_routing_preset` | method | `services/llm.py:886` | Delete a saved preset by id | | `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session | | `ContextPackage` | dataclass | `services/proactive.py:27` | Aggregates similar_tasks/learnings/code_patterns/standards/decisions/known_issues + summary | | `on_task_claimed` / `on_session_started` | methods | `services/proactive.py:116,201` | Best-effort multi-index search; each source wrapped in try/except | @@ -7501,7 +7620,9 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro ## Data Flow -**Spawn routing.** The orchestrator (`runtime/orchestrator.py:3294`) opens a DB session, calls `get_model_routing_service(db).resolve_for_agent(agent_slug)`. `ModelRoutingService` walks `model_assignments` (AGENT_SLUG → ROLE → GLOBAL), joins the `provider_configs` row, decrypts any token via `ProviderService.get_decrypted_token` (Fernet from `utils/crypto`), probes LOCAL providers via `probe_ollama_tags`, and returns an `AgentRoute`. On any failure (decrypt, unreachable server, missing row) it falls back to `_legacy_route` (role → `MODEL_MAP` short name, Anthropic, mounted `~/.claude`). The orchestrator injects `ANTHROPIC_*`/base_url/auth env into the container only when the route is non-Anthropic. +**Spawn routing.** The orchestrator (`runtime/orchestrator.py:3294`) opens a DB session, calls `get_model_routing_service(db).resolve_for_agent(agent_slug, complexity=...)` — `complexity` threads through from the spawning task's `estimated_complexity` when one is bound, letting the compound `ROLE:complexity` rung (e.g. `developer:low`) pin a cheaper model at a given complexity without touching the plain ROLE row everything else still uses. `ModelRoutingService` walks `model_assignments` (AGENT_SLUG → ROLE:complexity → ROLE → GLOBAL via `_resolve_assignment`), joins the `provider_configs` row, decrypts any token via `ProviderService.get_decrypted_token` (Fernet from `utils/crypto`), probes LOCAL providers via `probe_ollama_tags`, and returns an `AgentRoute`. `_interactive_exempt` overrides the resolved route back to Anthropic for `intake-1`/`secretary-1` whenever it would otherwise land on OPENAI/GEMINI (neither provider supports the interactive chat driver). On any failure (decrypt, unreachable server, missing row) it falls back to `_legacy_route` (role → `MODEL_MAP` short name, Anthropic, mounted `~/.claude`). The orchestrator injects `ANTHROPIC_*`/base_url/auth env into the container only when the route is non-Anthropic. + +**Routing presets.** On top of modes, an operator can `save_routing_preset(name)` to snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) into a `RoutingPresetTable` row, then `apply_routing_preset(preset_id)` later to restore it wholesale — a full swap that validates every entry via `_validate_preset_entry` BEFORE the wipe, unlike a mode switch's pin-preserving, validate-as-you-go behavior. Panel: the AI routing settings card (`ai-routing-card.tsx`) exposes preset save/apply/delete alongside the existing mode buttons and the complexity-override editor. **Settings/flags.** At FastAPI lifespan (`api/app.py:117`), `apply_persisted_feature_flags(db)` reads each `FEATURE_FLAGS` key from `system_settings` and `setattr`s the live `roboco.config.settings` singleton so the rest of the app reads panel choices; an unset key keeps the env default. The Settings panel reads effective values via `feature_flag_effective_values` and writes via `SettingsService.set` (validates → upsert → flush; route commits). @@ -7739,7 +7860,6 @@ No files in this slice changed between `fd10cc86` and `HEAD`, so there are no *r ## Health This slice is a mature, mostly-stable support layer: the service-base/error hierarchy and crypto/UUID helpers are well-factored and widely reused; the Redis-Streams event bus is correctly durable (consumer groups, ACK-on-success, pending recovery) with the one real caveat that handler idempotency is the caller's job. Post-snapshot commits improved the bus (poison-pill dead-letter + periodic reclaim loop + `BaseException` marker cleanup), typed the UUID error surface (`InvalidIdentifierError`), and removed the vestigial `_find_code_patterns` call from `ProactiveKnowledgeService`. `TranscriptionService` (sync-callback + unbounded-buffer risks) remains the softest spot. Model routing's fail-safe-quietly design is intentional (a stalled spawn is worse than a wrong provider) but shifts diagnosis to logs. Overall integrity: solid, with `TranscriptionService` the one service worth either finishing or marking clearly as legacy. -# RoboCo Map — `review-findings` slice ## Purpose @@ -7921,8 +8041,6 @@ None as of this doc's authoring — CLAUDE.md was updated in the same pass to ad The slice is a clean, well-centralized refactor: one shared helper module (`choreographer/findings.py`) is the single chokepoint for validation/capping/rendering/insertion across all three choreographer-level producers, and `ceo_reject` (the one producer outside the choreographer) mirrors the same primitives by hand rather than drifting to its own shape. The append-only ledger design (contrast the overwrite-in-place `notes_structured` it supplements) genuinely fixes the "rounds erase each other" defect the design spec named, and the write-then-gate resolution flow (`resolved_findings` applied before `FINDINGS_ADDRESSED` re-reads) is a sound pattern already proven by the AC-coverage gate it mirrors. The two real gaps — `criterion`'s missing char cap and `mark_waived`'s unwired status — are both small, named, and low-severity (the first is defense-in-depth the DB column still bounds in practice; the second is a deliberate release-scoped deferral, not a broken feature). Best-effort posture is applied precisely where it should be (vault fetch, `ceo_approve` stamp) and withheld where a silent failure would be worse (the three same-transaction verify-stamps). -# RoboCo Map — `metrics-observability` slice - ## Purpose The metrics & observability slice is the read-only measurement layer of RoboCo: it turns task/audit-log/spawn-session state into the numbers the panel renders and the CEO/Auditor act on. `MetricsService` reconstructs delivery flow (cycle-time, bottlenecks, rework, scorecards) from the audit-log transition journey plus `tasks.revision_count`; `UsageService` aggregates per-agent/per-team/per-model token spend and projections from `agent_spawn_sessions` / `daily_usage_rollups`; `DashboardService` adds auditor flags/reports (in-memory) and CEO overview aggregations; `CockpitService` fuses goals + delivery + spend + strategy signals into one CEO summary; `telemetry/source.py` reads CI health for self-heal / multi-repo CI-watch; `billing/pricing.py` is the provider-aware per-token cost function every cost field is derived from. @@ -8175,7 +8293,6 @@ Because the slice is unchanged since baseline, there are no *recent* changes wit ## Health The slice is internally coherent and well-documented at the method level; the observability reconstruction (cycle-time/bottleneck/rework) is correctly designed around the audit-log event-naming contract and `revision_count` chokepoint, and the provider-aware pricing is sound. The main integrity concerns are coupling, not correctness: cycle-time and rework-cost are tightly bound to upstream audit-log event naming and `agent_spawn_sessions.task_id` population, so any drift there silently degrades panels without an in-slice guard. The in-memory `DashboardService` store is the clearest remaining local hygiene debt (the `get_summary` docstring mismatch and blocked-hours heuristic were resolved in 536bbb64). Commit 536bbb64 also adds `CostResult.unpriced` attribution — the mitigation for the silent-$0 risk — though orchestrator callers haven't switched to `calculate_cost_result` yet. The standing landmines above (especially cycle-time SQL naming convention and rework-cost task_id join) warrant upstream-contract tests. -# Slice Map — conventions-service-validator ## Purpose @@ -8455,7 +8572,6 @@ No other logic-touching commits in the range. ## Health Integrity is solid and actively improving. The validator is pure, layered cleanly (scan → effective_map → runner → per-check-family), and obeys its fail-loud contract (exit 3 / `ValidatorCouldNotRun`) consistently; the service keeps the read path resilient (degraded → last-good uncached, missing → auto-derived, concurrent-cache → savepoint with UNIQUE-only swallow). The 15effce0 + 536bbb64 delta fixed five real defects (concurrent cache-poison F042; bypassable docs path-traversal on read/delete; IntegrityError over-swallow in `_cache_put`; degraded-row stickiness hiding in-place repairs; `stream`/`stream_scalars` thin-route false-negatives) and added a typo-language-scope warn signal and a repo-commit outcome surface on `doc_ref.commit_status`, with no guard dropped. Remaining risks are low-severity under-enforcement paths (dialect family lookup doesn't cover future `jsx`/`mts`, backfill write-side-effect on `get_map`, same-filename RAG dedup still collapses on content match, broad `except Exception` in `_commit_doc_to_repo` still catches) — all consistent with the "precision over recall" stance. The standard is default-off and gated. No blockers. -# Slice Map — product-strategy-research-pitch ## Purpose @@ -8552,13 +8668,16 @@ The product / strategy / research / pitch slice covers the "company layer" above | `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors | | `XEngine` | class | x_engine.py:230 | Dormant "engine 4": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape across THREE responsibilities — release posts, mention replies, feature spotlights | | `XEngine._voice_guide` | method | x_engine.py:259 | `_voice_guide(product_name)`: baseline house-voice constant (`_HOM_VOICE`) plus the CEO's `company_goals.brand_voice` sample when set — feeds release/reply prompts AND is the mechanism the HoM identity file points to for its own drafting; `product_name` is resolved once per call site via `CompanyGoalsService.resolve_product_name(project)` (project's own name → charter `company_name` → "RoboCo" literal), not hardcoded | +| `_hom_voice` / `_HOM_VOICE_GUIDE` | func / const | x_engine.py | The baseline voice fed into every local-model draft prompt (release/reply) — ported from the reasoning-backed Head-of-Marketing identity's own VOICE GUIDE (previously a one-sentence stub reaching only the off-by-default spotlight path) plus a slop-ban list (banned words, em-dash ban, exclamation-pileup ban, "X isn't just Y" ban, rule-of-three ban) adapted from the ux_ui team's "AI tells to avoid", and three fixed style exemplars. Both prompts now target well-under-240-chars so the 280 clamp never truncates mid-sentence. | +| `changelog_highlights` | func | x_engine.py | Pure extraction of the bold feature-lead lines (`- **Headline (#N).**`) from a curated CHANGELOG release entry, stripping PR refs and trailing periods, capped at `limit`; `approve()` prefers these over raw `change_summary` (per-commit subjects) for the release-post caption, falling back to `change_summary` only when the changelog yields nothing | +| `XEngine.redraft_from_rejection` | method | x_engine.py | Routes a CEO's non-blank rejection reason into a fresh held draft of the SAME source (release/reply/spotlight), mirroring `VideoEngine.reauthor_from_rejection` — reads the rejected draft's source-specific reference marker, asks the local model to revise the rejected body with the reason folded in as guidance via `_revision_prompt`; a local-model failure or empty output originates NOTHING (a degraded copy is worse than none). Deduped per underlying item (`_redraft_already_open`, excludes CANCELLED so a further redraft is allowed once THAT one is also rejected) under a per-identity Redis lock (`_acquire_redraft_lock`, mirroring `XPostService`'s lock style) so two racing rejects can't both originate. | | `XEngine.draft_release_post` | method | x_engine.py:279 | Event-driven hook (called from `ReleaseProposalService.approve`'s publish-success branch); local-model-drafted, deduped per version, capped by `x_max_open_posts`; resolves and threads `product_name` from the release's own project | | `XEngine.run_cycle` | method | x_engine.py:353 | Periodic mentions poll; no-op unless `x_engine_enabled` AND `x_replies_enabled`; filters bot-like/low-engagement mentions, dedupes by mention id (`XSeenMentionTable`); each mention's text is run through `screen_external_text` before the local-model reply prompt sees it; resolves `product_name` once per cycle and threads it through `_originate_reply`/`_draft_reply_body`/`_reply_prompt` | | `screen_external_text` | function | foundation/policy/injection_guard.py:95 | Shared screen-and-neutralize guard for unattended attacker-writable text feeds (X mentions, vault inbox notes): wraps the text in an untrusted-content envelope and flags any matched injection-pattern LINE inline — nothing is removed, so the CEO-facing draft still shows what the source really said | | `XEngine.open_feature_spotlight_exploration` | method | x_engine.py:487 | No-ops unless `x_engine_enabled` AND `x_feature_spotlight_enabled`, no creds, a cycle already open, the open-post cap reached, or project unresolvable; else opens ONE held PENDING exploration task for the Head of Marketing (`source=x_feature_exploration`) carrying a `x_seen_features` marker snapshot; the description is built by `_feature_exploration_description(product_name)`, no longer the fixed `_FEATURE_EXPLORATION_DESCRIPTION` string | | `XEngine.materialize_feature_spotlight` | method | x_engine.py:841 | Called from the `propose_feature_spotlight` do-tool: marks the feature slug seen (`XSeenFeatureTable`), creates the held draft (`source=x_feature`, identical shape to a release/reply draft), completes the exploration task | | `XPostService.approve` | method | x_post_service.py:92 | The ONLY caller of `x_client.post_tweet`; Redis single-flight lock, re-reads task under lock, idempotent on an already-posted draft (`already_posted`); a CANCELLED draft is refused both pre-lock and re-checked under lock (`already_rejected`) — a stale approve (e.g. a queued Telegram button) can't resurrect a draft the CEO already rejected | -| `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft | +| `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft; a non-blank reason schedules `XEngine.redraft_from_rejection` via `defer_after_commit` (a fresh session, never blocks or fails the HTTP response) so the feedback re-enters the draft flow instead of dying with the cancel | | `XClient` / `NullXClient` / `LiveXClient` | ABC/class | x_client.py:150 / 166 / 186 | `NullXClient.configured` is False (no creds) — drafting still runs (content nobody can post is a no-op upstream), just never originates; `LiveXClient` signs OAuth 1.0a HMAC-SHA1 | | `build_x_client` | factory | x_client.py:306 | Returns `LiveXClient` when credentials decrypt, else `NullXClient` | | `XCredentialsService.set_credentials` / `.get_decrypted` | method | x_credentials.py:61 / 116 | All-or-nothing Fernet-encrypted singleton credential set/clear; decrypts server-side only, never exposed to agents | @@ -8829,6 +8948,9 @@ product-strategy-research-pitch > - `b3558d4e` ([chore] complexity: split 5 C-rank blocks to <=B, 2026-06-30): `kanban.py` `get_main_pm_board_flat` — refactored if/elif routing to a dict-dispatch (`status_col` + `team_col` maps) for xenon complexity gate; no functional change. > - **v0.18.0** (2026-07-04): the X feature-spotlight content in this slice (`XEngine` feature-spotlight methods, `_x_feature_spotlight_loop`/`_dispatch_feature_spotlight_exploration`, migration 061, `x_feature_spotlight_enabled`) was authored directly into this file's Files/Key Symbols/Data Flow/Mermaid/Logical Tree/Entry Points sections at implementation time rather than landing as a dated delta — noted here for changelog continuity; the body text above is current as of this date. (Config Flags is unchanged — the X-engine flags live in deployment-tooling.md's comprehensive list, not here.) > - `11915f36` (PR #551, Telegram V2 security follow-up, 2026-07-17): `x_post_service.py` — `XPostService.approve`/`_approve_locked` add a CANCELLED-task guard (pre-lock and re-checked under lock) returning a new `already_rejected` status, closing a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call). +> - `57b9e76b` (#607, "release caption uses curated CHANGELOG headlines, not commit subjects"): `draft_release_post` used to feed `highlights=list(report.change_summary)` — raw per-commit subjects — so the announcement model parroted the top commit's literal text. New pure `changelog_highlights()` extracts the bold feature leads from the curated release entry (`report.drafted_changelog`); `approve()` prefers those, falling back to `change_summary` only when the changelog yields nothing. The video pipeline's captions were already good (the authoring dev reads the changelog directly) — this brings X captions to the same source. +> - `16fa018a` (#615, "real voice guide, slop ban, and caption craft"): release/reply drafts previously ran on a one-sentence voice stub while the reasoning-backed Head-of-Marketing voice guide only reached the off-by-default spotlight path. `_hom_voice`/`_HOM_VOICE_GUIDE` port the full VOICE GUIDE (confident-not-hedgy, one idea per post, no emoji spam, plain text, never invent facts) plus a slop-ban list (em dashes, "game-changer"/"seamless"/etc., exclamation pileups, "X isn't just Y" constructions, rule-of-three chains) and three fixed style exemplars into every drafting prompt; both prompts target well-under-240-chars so the 280 clamp never truncates mid-sentence. An empty `brand_voice` now nudges the CEO exactly once (a durable `system_settings` marker, `_BRAND_VOICE_NUDGE_KEY`) instead of silently shipping baseline voice forever; a failed reply draft now skips origination instead of shipping a generic "Thanks for the mention!". +> - `4585a248` (#648, "redraft loop on CEO reject"): `XPostService.reject` with a non-blank reason now schedules `XEngine.redraft_from_rejection` after its commit (`defer_after_commit`, fresh session, never blocks/fails the reject) — mirroring `VideoEngine.reauthor_from_rejection`. Deduped via `_redraft_already_open` (excludes CANCELLED, so a further redraft is allowed once THAT one is itself rejected) under a per-identity Redis lock (`_acquire_redraft_lock`/`_REDRAFT_RELEASE_SCRIPT`, mirroring `XPostService`'s own lock style) so two racing rejects can't stack duplicate drafts. A local-model failure or empty revision output originates nothing — a degraded copy is worse than none. > - `7e01c0ce` (PR #570, "project-branded drafts + project badges", 2026-07-18): migration 075 adds `company_goals.company_name`; `CompanyGoalsService.resolve_product_name` (company_goals.py:79) is the new single fallback chain (project name → charter `company_name` → "RoboCo") consumed by both `XEngine._voice_guide`/`draft_release_post` and `VideoEngine` (see `docs/map/video-engine.md`) so their prompt builders stop hardcoding "RoboCo". New `roboco/api/schemas/project_fields.py`'s `task_project_fields` helper adds `project_slug`/`project_name` to the X and video post-queue API responses (`api/routes/x.py`, `api/routes/video.py`); the panel renders them via a shared `ProjectBadge` — see `docs/map/panel.md`. > - `461a6e1a`+`96401f4c`+`5f32d876` (Phases 1/2-3/4, 2026-07-18/19, #571/#575/#581) — Phase 4 makes `GitHubProvisioningService` provider-aware: `_build_provider` (github_provisioning.py:68) dispatches to `GitHubProvider`/`GiteaProvider`/`GitLabProvider` by `ROBOCO_PROVISIONING_PROVIDER`, `.enabled` additionally requires `ROBOCO_PROVISIONING_HOST` for gitlab/gitea, and `_is_already_exists` (github_provisioning.py:61) matches the "already exists" idempotency signal across all three forges' differing status codes/phrasing. The forge transport package itself (`GitProvider`/`ForgeRouter`/provider implementations) is documented in `docs/map/worksession-git.md` — this slice only covers the provisioning consumer. > - `a0baf94b` ("agnosticism-residue", agnosticism audit items B6/B8): `x_engine.py`'s remaining hardcoded `"RoboCo"` literals (the reply-prompt builder and the feature-spotlight exploration description — `draft_release_post`/`_voice_guide` were already fixed by `7e01c0ce` above) are threaded out: `_reply_prompt` gains a `product_name` param, `_FEATURE_EXPLORATION_DESCRIPTION` (a module constant) becomes `_feature_exploration_description(product_name)` (a function), and `run_cycle`/`open_feature_spotlight_exploration` each resolve `product_name` once via `resolve_product_name` and thread it through. @@ -8851,9 +8973,6 @@ No commit since `fd10cc86` modified any file in this slice, so there are **no re ## Health This slice is internally coherent and consistent with CLAUDE.md: every documented flag, default, and behavior matches the code, and the two slices-of-flow (CEO-driven pitch origination into the normal lifecycle; dormant notify-only strategy watcher) are cleanly separated and default-safe. The services follow a uniform `BaseService` + session-bound factory pattern, provider/research quotas fail open where cost-control (not security) is the goal, and the provisioning path is inert without token+org. The pitch approval path's external-side-effect non-atomicity remains (GitHub repo creation cannot roll back with the DB transaction), but re-approval is now idempotent end-to-end: `create_repo` handles GitHub 422 "already exists" by fetching the existing repo, and Project/Product rows are reused by slug, so a CEO re-approving after a partial failure recovers cleanly. The remaining open risk is `_seed_main_pm_task` failing after repos are already created (missing `main-pm` agent row). Post-snapshot three commits updated this slice's files, resolving four standing risks (kanban subtask counts, flat-board dropped cards, `project.update` None-field skip, and the pitch re-approval collision). -# RoboCo Slice Map — `engine-docs-sync` - -Slice key: `engine-docs-sync`. Repo root: `/Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco`. Scope: `roboco/services/docs_sync_engine.py`, the docs-sync touch points in `roboco/services/release_proposal.py` and `roboco/services/task.py`, and the release-version marker in `roboco/foundation/policy/content/markers.py`. ## Purpose @@ -9047,8 +9166,6 @@ The flag is registered in `roboco/services/settings.py`'s `FEATURE_FLAGS` tuple, The engine is intentionally small and conservative: default-off, no background loop, bounded, deduped, and flush-only. It follows the same safety model as the other autonomy engines (never start/approve/merge/deploy) while staying out of the orchestrator's periodic loops entirely. The main dependency on operator action is registering `roboco-website` as a project before enabling the flag. Health is good. -# Panel — RoboCo Control Panel Map - ## Purpose The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.25.0) is the single operator UI for the human CEO: it drives intake/prompter chats, task/kanban management, agent observability, metrics, release approval, playbook curation, and feature-flag arming. It is served internally on port 3000 behind the nginx reverse proxy and talks to the orchestrator exclusively over relative `/api` + `/ws` URLs. @@ -9386,7 +9503,6 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog ## Health The panel is a mature, well-structured Next.js 16 App-Router app: clean domain folders, TanStack Query for server state, zustand for client state, a single shared `/ws/system` subscriber, and recent hardening around the high-stakes CEO surfaces (release proposal, playbook queue, kanban override, usage snapshot). The chief fragility is concentration: nearly every logic-relevant fix since the baseline landed in one 141-gaps fill-in commit, so any partial revert risks re-opening several independent blinds spots at once. The `ui-store` duplicate was resolved (536bbb64); the remaining standing hygiene item is the kanban admin-override bypass (no-PR / QA-skip / docs-incomplete moves can still be confirmed). -# tests slice ## Purpose The pytest test suite for RoboCo: 571 test_*.py files across tests/foundation, tests/integration (+services/+v1), tests/property, and tests/unit (mirroring the roboco/ package layout). This slice covers the test INFRASTRUCTURE only — the two conftest.py layers, the JSON smoke-trace fixture, the two standalone shell smoke scripts, the pyproject pytest/coverage config, and the Makefile gates — plus the categorical structure of the 571 test files (NOT every individual test, per the slice scope). The suite is async-first (pytest-asyncio auto mode), coverage-gated at 80% via sys.monitoring, and DB tests run against an ephemeral real Postgres+pgvector DB created per session. @@ -9416,6 +9532,7 @@ The pytest test suite for RoboCo: 571 test_*.py files across tests/foundation, t | tests/e2e_smoke/harness.py | E2E harness: E2EStack app + orchestrator client + per-test agent manifests; used by the e2e_smoke tier | ~520 | | tests/e2e_smoke/test_gitea_live.py | Live-Gitea contract suite for `GiteaProvider` — fully self-seeding (creates its own uniquely-named repo, pushes real commits) against a real Gitea instance; skipped unless `ROBOCO_GITEA_E2E_URL`/`ROBOCO_GITEA_E2E_TOKEN` are both set; exercises PR open → duplicate-409→422 reshape → list/filter → diff → comment review → commit-status CI reshape → squash merge → branch delete → release, plus the git-CLI Basic-auth extraheader claim | 250 | | tests/e2e_smoke/test_gitlab_live.py | Live-GitLab contract suite for `GitLabProvider` — mirrors `test_gitea_live.py`, self-seeding against a real GitLab instance (gitlab.com works, project deleted afterward best-effort); skipped unless `ROBOCO_GITLAB_E2E_URL`/`ROBOCO_GITLAB_E2E_TOKEN` are both set; exercises MR open → duplicate reshape → GitHub-shape adaptation → diff reassembly → note review → commit-status CI reshape → squash merge → branch delete → release → the oauth2 Basic-auth git-CLI claim | 265 | +| roboco/eval/ (`fixtures.py`/`runner.py`/`__main__.py`) | Golden-task offline CLI bench harness (source-checkout-only, NOT a pytest suite) — replays fixed `BenchTaskSpec` fixtures through the real delivery lifecycle in a disposable environment REUSED from `tests/e2e_smoke/harness.py` (fake GitHub REST, a real local git origin, a throwaway DB), scoring on deterministic metrics + a local-model judge. `python -m roboco.eval run` is wired but not functional this release — `OrchestratorStageSpawner` raises `NotImplementedError` at construction (a real spawn would resolve to the production orchestrator under real agent UUIDs, unsafe for a bench run); the only working path today is driving `EvalRunner` with an injected scripted `StageSpawner` from Python. Scoped to developer-role fixtures only; only runs from a source checkout. See `tests/e2e_smoke/test_eval_bench.py`. | — | ## E2E smoke harness @@ -9632,6 +9749,7 @@ tests/ > - **1f129199** auditor-trigger e2e smoke test: added `tests/e2e_smoke/test_auditor_triggers.py` exercising scheduled sweep and reactive QA-fail alert paths end-to-end, plus harness mount of `/api/notifications` so `_dispatch_audit_work` can poll ALERT rows. > - **babffe0a** fix(e2e_smoke): repair auditor-trigger smoke tests and harden harness (#498): fixed `tests/e2e_smoke/test_auditor_triggers.py` so scheduled/reactive auditor-trigger tests reach their spawn assertions, hardened `tests/conftest.py` to tolerate missing pgvector, and cleared leaked `ROBOCO_AGENT_TOKEN` in `tests/e2e_smoke/harness.py` before scripted agents load `flow_server`. See the E2E smoke harness section above for the exact patterns. > - **f081a574** (PR #502, 2026-07-13): Follow-up e2e lifecycle smoke fix. Restored the `ROBOCO_AGENT_TOKEN` pop in `tests/e2e_smoke/harness.py:ScriptedAgent._module` after it was accidentally removed, and clarified the `/api/notifications` mount comment so it no longer implies the router was newly added. The orchestrator `__new__` pre-init from `89b68786` means `_fresh_orchestrator` no longer needs to manually set `_instances`. +> - **`10f039c3`** (#655, "golden-task eval harness + doctrine cohort stamp"): adds `roboco/eval/` (see Files above), a bench harness reusing `tests/e2e_smoke/harness.py`'s disposable environment rather than a mock — real isolation. `agent_spawn_sessions.doctrine_version` (migration 081) is stamped at spawn-session finalize from the composed prompt layers, so a cohort's model+doctrine combination (e.g. Fable-mode on vs. off) is durably identifiable after the fact — see `docs/map/db-migrations.md`. Bench runs also patch every vault flag off so a bench task/note/journal write never lands in the operator's real Obsidian vault. > - **Forge providers, 2026-07-18/19 (#569/#571/#575/#579/#581)**: adds `tests/unit/services/forge/` (`test_gitea_provider.py` 297 lines/17 tests, `test_gitlab_provider.py` 713 lines/33 tests, `test_router.py` 90 lines/9 tests — all mocked-transport) + `tests/unit/foundation/policy/test_forge.py` (132 lines/23 tests, `extract_host`/`detect_provider`/`validate_project_forge`) + the live-forge contract suites `test_gitea_live.py`/`test_gitlab_live.py` (see the "Live-forge contract suites" section above). See `docs/map/worksession-git.md` for the forge package itself. ## Regression Risks diff --git a/docs/map/api-core-websocket.md b/docs/map/api-core-websocket.md index a04055ef..8d77d180 100644 --- a/docs/map/api-core-websocket.md +++ b/docs/map/api-core-websocket.md @@ -83,6 +83,10 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out | `build_security_config` | func | security.py:329 | Assembles the global `SecurityConfig` from settings: passive_mode, fail_secure, enforce_https, WAF calibration fields | | `security_config` / `guard_deco` | module singletons | security.py:374-375 | Built once at import (pure, no I/O); `guard_deco` is the `SecurityDecorator` route files decorate with `@guard_deco.<verb>` | | `prompt_injection_validator`/`secret_exfil_validator`/`internal_ssrf_validator` | async funcs | security.py:116/128/139 | Custom `@guard_deco.custom_validation` content checks the signature WAF can't cover; each returns a generic 400 (no rule detail leaked) | +| `_guard_whitelist` / `_INTERNAL_NETWORKS` | func / const | security.py | WAF/IP-ban/rate-limit exemption for the internal agent mesh: loopback + docker's default bridge pool (`172.16.0.0/12`) ONLY — deliberately excludes `10.0.0.0/8`/`192.168.0.0/16` (those would also exempt a real LAN client hitting nginx, not just the docker mesh, since a genuine LAN browser's real IP survives the one XFF hop under `trusted_proxy_depth=1`) | +| `resolve_forwarded_client_ip` | func | security.py:479 | Peels loopback/operator-named (`guard_trusted_chain_peers`) hops from the right of XFF; the remaining candidate is honored ONLY if at least one hop peeled AND the candidate is in the tailnet CGNAT range (`100.64.0.0/10`) — every other shape (direct LAN, an unnamed 172.x relay even carrying a forged prefix, all-hops, malformed) abstains to the guard's own depth-1 resolution | +| `ClientIpResolutionMiddleware` | class | security.py:521 | Pure ASGI, mounted OUTSIDE `SecurityMiddleware` (added after it, so it runs first): stamps `request.state.client_ip` (guard_core's supported override seam) with `resolve_forwarded_client_ip`'s verdict; only honors XFF when the CONNECTING peer itself is trusted to present it (`_CONNECTING_PEER_NETWORKS`, broader than and independent of the hop-peel set) | +| `_warn_unconfigured_tailnet_gateway_once` | func | security.py:453 | Logs once per distinct gateway IP (never the full XFF) when a host-proxied tailnet chain is detected behind a real docker bridge gateway but no `guard_trusted_chain_peers` is configured — the one regression left genuinely silent otherwise (traffic resolves to a whitelisted bridge IP, WAF stays inert, zero signal) | | `_WAF_FREETEXT_BODY_FIELDS` | const | security.py:211 | Top-level free-text body-field exclusion set (`excluded_detection_body_fields`) — the WAF calibration; includes free-form container fields (plan/risks/findings/section/payload/...) whose nested prose is stringified and scanned | ## Data Flow @@ -211,6 +215,7 @@ roboco/api/ - `settings.app_version` / `settings.environment` / `settings.debug` — logged at startup; docs/redoc URLs are unconditional (the `if settings.debug` is commented out, app.py:207-208). - `settings.host` / `settings.port` — no longer used in websocket.py (the httpx self-call was removed); still referenced elsewhere. - `ROBOCO_GUARD_ENABLED` / `_PASSIVE_MODE` / `_FAIL_SECURE` / `_TELEMETRY_ENABLED` / `_AGENT_API_KEY` / `_PROJECT_ID` / `_EMERGENCY` / `_EMERGENCY_WHITELIST` — read by `roboco/security.py`, wired into `create_app` via `apply_guard(app)` (app.py:234) + `guarded_lifespan(lifespan)` (app.py:212); `ROBOCO_ENVIRONMENT` additionally drives `enforce_https` (production only). +- `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` (empty by default) — comma-separated exact IP(s), never a CIDR, trusted to appear as a recorded proxy hop inside XFF beyond loopback (e.g. the docker bridge gateway a host-proxied Tailscale Serve chain terminates behind); consumed by `resolve_forwarded_client_ip`/`ClientIpResolutionMiddleware`. See `docs/rag/architecture/http-security-guard.md`. - Otherwise no direct ROBOCO_* feature flags live in this slice; the lifespan applies persisted flag overlays via `apply_persisted_feature_flags` but does not itself read individual subsystem flags. ## Gotchas @@ -256,6 +261,8 @@ Diff stat: `app.py +20`, `deps.py +44`, `middleware.py +49`, `websocket.py +309/ > **Post-snapshot update (2026-07-01, logical-gap sweep `536bbb64`):** `deps.py` gained `require_ceo_role` (deps.py:412) — single source-of-truth CEO-role check shared by the orchestrator router gate and the release handler, replacing two diverged inline comparisons. `websocket.py` gated `/ws/system` with `_require_panel_token` (websocket.py:621), closing the medium regression risk; all `/ws/*` endpoints are now consistently gated (five at the time of this commit; the comms-subsystem teardown since removed `/ws/channels` + `/ws/sessions`, leaving `/ws/agents`, `/ws/notifications`, `/ws/system`). +> **Internal-mesh whitelist scoping + tailnet client-IP resolution (#605, `07bdef3d`, #646, #650).** `bf1012e9` (#605) first exempted the internal agent mesh (agents reach the orchestrator DIRECTLY on the docker bridge, HMAC-authenticated) from WAF/IP-ban entirely via a `whitelist` of ALL RFC1918 + loopback — but that also silently exempted any real LAN client reaching the host-published nginx port, since a genuine LAN browser's real IP survives the guard's depth-1 XFF resolution. `07bdef3d` scoped the whitelist down to loopback + docker's default bridge pool ONLY (`_INTERNAL_NETWORKS`), dropping `10.0.0.0/8`/`192.168.0.0/16`. Separately, `583d43f2` (#646) + `3806317a` (#650) added `ClientIpResolutionMiddleware`/`resolve_forwarded_client_ip` (security.py:479/521) to fix a real live incident: a host-proxied Tailscale Serve chain (tailnet client → Serve on loopback → nginx) resolved under depth-1 XFF to a whitelisted bridge/loopback IP, going WAF-inert for the whole `/tg` Mini App surface. The fix is deliberately narrow — it peels ONLY loopback or an operator-named `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` entry from the right of XFF, and only trusts the result if the remaining candidate is in the tailnet CGNAT range; every other shape (a direct LAN client, an unnamed 172.x relay even carrying a forged prefix, all-hops, malformed) abstains to the guard's own baseline depth-1 logic rather than trying to be a general XFF-chain resolver. `_warn_unconfigured_tailnet_gateway_once` logs once per gateway IP when this shape is detected with nothing configured, so the residual gap isn't silent. +> > **Local branch (not on master, NOT deployed):** `feature/fastapi-guard-hardening` (6 fastapi-guard commits `896532a3`..`99ee666e`, branched off `ab69851d`, plus 2 unrelated bundled commits) adds `roboco/security.py` and wires it into this slice — `apply_guard(app)` mounts `SecurityMiddleware` last in `create_app` (app.py:234) and `guarded_lifespan(lifespan)` wraps the async lifespan (app.py:212), both gated by `ROBOCO_GUARD_ENABLED` (default off, byte-for-byte unchanged request path when off). Per-route `@guard_deco.*` decorators (rate_limit/max_request_size/content_type_filter/behavior_analysis/block_clouds/honeypot_detection/usage_monitor/suspicious_detection/custom_validation — 9 kinds) are applied across 21 route files outside this slice (api-routes-schemas + v1 flow/do). `build_security_config` also carries a WAF false-positive calibration: `excluded_detection_body_fields` (75 free-text top-level body fields, including container fields like plan/risks/findings/section/payload) plus `enable_penetration_detection=True`, dropping active-mode false positives on RoboCo's own code/SQL/diff/URL-bearing traffic to zero while leaving the three custom validators and the WAF on non-excluded (id/enum/slug/branch) fields fully in force. New tests: `tests/unit/test_security.py` (unit) + `tests/unit/test_security_middleware.py` (integration — mounts the real middleware end-to-end). Both NAS composes (`docker-compose.yml`/`.yaml`) arm the layer passive/log-only (`c496b677`, Phase 5) — see deployment-tooling. Logic-touching changes in that commit, scoped to this slice: diff --git a/docs/map/api-routes-schemas.md b/docs/map/api-routes-schemas.md index c3bc69f8..3b53ecd1 100644 --- a/docs/map/api-routes-schemas.md +++ b/docs/map/api-routes-schemas.md @@ -32,7 +32,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | roboco/api/routes/release.py | CEO-only release proposal approve/reject. | | roboco/api/routes/playbooks.py | Playbook approve/reject/archive (Auditor/CEO). | | roboco/api/routes/pitch.py | Pitch create/list/approve/reject. | -| roboco/api/routes/provider.py | Provider catalog + ollama/grok/self-hosted key + mode. | +| roboco/api/routes/provider.py | Provider catalog + ollama/grok/self-hosted key + mode + routing presets (`/presets` list/save/apply/delete). | +| roboco/api/routes/github_app.py | GitHub App credentials CRUD (App id + private key) + installation/installation-repo listing (backs the create/edit-project repo picker). | | roboco/api/routes/usage.py | Token usage summary/time-series/by-agent/team/model/role/sessions, cache-efficiency, spawn-waste (per-role unproductive-spawn rate + respawn strikes). | | roboco/api/routes/system.py | System-wide info. | | roboco/api/routes/docs.py | Project docs write/read/list/delete. | @@ -75,6 +76,8 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the | GET/POST | /api/x/posts, /posts/{id}/{approve,reject}, /credentials | x.py | `require_ceo_role` (agent context) | | GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) | | GET/POST | /api/telegram/credentials | telegram.py | `require_ceo_role` (agent context) | +| GET/POST/DELETE | /api/providers/presets, /presets/{id}/apply, /presets/{id} | provider.py | agent context — save/apply/delete a named full routing snapshot (`docs/map/support-services.md`) | +| GET/PUT/DELETE | /api/github-app/credentials ; GET /installations, /installations/{id}/repos | github_app.py | agent context — App id + private key CRUD, installation/repo listing for the panel's repo picker | | POST | /api/telegram/webapp-auth | telegram.py | public, pre-auth — Telegram `initData` HMAC validation; mounted only when `telegram_miniapp_enabled` AND `cloud_auth_enabled` | | GET | /api/telegram/today | telegram.py | `require_ceo_role` (agent context) + 30/60s rate limit — Mini App V4's "Today" brief, backed by `TgCockpitService.today()` (one DB round trip, see `docs/map/notification.md`) | | GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login | diff --git a/docs/map/db-migrations.md b/docs/map/db-migrations.md index 318ba3ac..540d0389 100644 --- a/docs/map/db-migrations.md +++ b/docs/map/db-migrations.md @@ -1,7 +1,7 @@ # db-migrations slice ## Purpose -The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→061) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 38 tables). +The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→086) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 38+ tables — not recomputed for this delta, several 077-086 migrations add columns to existing tables rather than new ones). ## Files @@ -13,7 +13,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for | `roboco/db/seed.py` | `bootstrap_database()` — runs `init_db` then seeds agents. | | `alembic/env.py` | Async Alembic env; imports `roboco.db.tables` to register metadata, overrides `sqlalchemy.url` from settings, `compare_type` + `compare_server_default` on. | | `alembic.ini` | Standard config; `script_location=alembic`, `prepend_sys_path=.`, no URL (set in env.py). | -| `alembic/versions/` | 61 migration files 001..061 (two share number 026 — chained, not a collision). | +| `alembic/versions/` | 86 migration files 001..086 (two share number 026 — chained, not a collision). | ## Key Symbols @@ -115,6 +115,16 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for | 074 | 074_telegram_credentials.py | `telegram_credentials` (singleton Fernet-encrypted `bot_token_encrypted` + `chat_id_encrypted`, mirrors `x_credentials`) — the Telegram notifications bridge (`ROBOCO_TELEGRAM_ENABLED`, default off). | | 075 | 075_company_goals_company_name.py | `company_goals.company_name` (Text, `server_default=""`) — CEO-authored product/company name, mirroring `brand_voice`. Feeds `CompanyGoalsService.resolve_product_name` (project name → this field → the "RoboCo" literal fallback), which `XEngine`/`VideoEngine` both call so release posts/videos stop hardcoding "RoboCo". Additive and inert until the CEO sets it in the Business → Goals editor. | | 076 | 076_project_git_provider.py | `projects.git_provider` (nullable `String(16)`, not a pg enum — validated at the service layer by `roboco.foundation.policy.forge.validate_project_forge`) — Phase 0 of the forge-providers spec (GitHub + Gitea + GitLab). Null = auto-detect from the `git_url` host (github.com → github; anything else is a registration-time rejection unless the operator sets this column explicitly — the GitHub Enterprise / self-hosted escape hatch). Additive: every existing project keeps resolving to GitHub behavior until GitLab/Gitea providers are set. See `docs/map/worksession-git.md`. | +| 077 | 077_github_app.py | `github_app_credentials` (singleton Fernet-encrypted App id + private key) + `projects.github_installation_id` (BigInteger, nullable) — a project can bind to a GitHub App installation instead of a bare PAT, with PAT fallback on any mint failure. See `docs/map/worksession-git.md`. | +| 078 | 078_project_codegen_command.py | `projects.codegen_command` (String(500), nullable) — per-project command run in the task's worktree right before every push, auto-committing any codegen drift into the same push (RoboCo itself sets `make codegen`). See `docs/map/worksession-git.md`. | +| 079 | 079_notification_backoff.py | `notifications.reescalation_count` / `.last_reescalated_at` / `.reescalation_delivered_count` — the per-notification exponential re-escalation backoff schedule, replacing the prior every-sweep-tick-forever re-escalation. See `docs/map/notification.md`. | +| 080 | 080_task_project_budgets.py | `tasks.budget_usd` (Float, nullable) + `projects.monthly_budget_usd` (Float, nullable) — per-task and per-project cost budgets (`ROBOCO_TASK_BUDGETS_ENABLED`, default off). See `docs/map/orchestrator.md` / `docs/map/gateway-support.md`. | +| 081 | 081_doctrine_version.py | `agent_spawn_sessions.doctrine_version` (String(32), nullable) — stamped at spawn-session finalize from the composed prompt layers, so a golden-task eval cohort's model+doctrine combination (e.g. Fable-mode on vs. off) is durably identifiable after the fact. See `docs/map/tests.md`. | +| 082 | 082_routing_presets.py | `routing_presets` (id, name, `payload` JSONB, timestamps) — named, full-snapshot save/restore of the routing state (mode + every assignment row, AGENT_SLUG pins included). See `docs/map/support-services.md`. | +| 083 | 083_seed_openai_provider.py | Seeds the OpenAI (Codex CLI) provider row — `ModelProvider.OPENAI`. See `docs/map/runtime-providers.md`. | +| 084 | 084_modelprovider_gemini.py | Adds `gemini` to the `modelprovider` postgres enum. | +| 085 | 085_seed_gemini_provider.py | Seeds the Gemini CLI provider row — `ModelProvider.GEMINI`. | +| 086 | 086_enable_gemini_provider.py | `UPDATE provider_configs SET enabled = true` for the Gemini row 085 seeded `enabled=false` — Grok gets force-enabled via its `apply_mode="grok"` write path, but `apply_mode` grew no `"gemini"` case until this same change, so without this migration a Mix-mode assignment to a Gemini model would resolve against a permanently-disabled row and silently fall back to Anthropic (wired end-to-end everywhere except reachable). Codex (083) sidesteps this by seeding `enabled=true` directly. See `docs/map/runtime-providers.md`. | ## Data Flow On boot, `init_db()` probes for application tables and `alembic_version`; if a pre-Alembic DB exists it stamps it at revision 001, then always runs `run_migrations()` → `alembic upgrade head` (in a thread via `asyncio.to_thread`). `env.py` imports `roboco.db.tables` so `Base.metadata` is fully populated, overrides `sqlalchemy.url` from `settings.database_url`, and runs online with an async NullPool engine. `compare_type` + `compare_server_default` are on so autogenerate drift is detectable. `tables.py` classes are the ORM mapping the migrations build; the domain layer reads them through `roboco/models/` dataclasses, not the tables directly. @@ -277,6 +287,8 @@ Migration chain 001..059 > Delta 2026-07-04 (v0.18.0): `060_drop_messaging` (the comms-teardown migration — drops `messages`/`session_tasks`/`sessions`/`groups`/`channels` + 4 enum types + `journal_entries.session_id`; A2A is now the sole directed-message channel; one-way, `downgrade()` raises `NotImplementedError`) had already landed on master but was never appended to this doc; `061_x_feature_spotlight` adds `x_seen_features` (`XSeenFeatureTable`) + `company_goals.brand_voice` (X feature-spotlight, `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`, sub-switch of `x_engine_enabled`). Chain head is now 061. ORM table count is now 38 (verified via `grep -c '^class .*Table' roboco/db/tables.py`), up from this doc's previously-stated 37 (that figure predates 055-061 and was never recomputed). > > Delta 2026-07-18/19: `075_company_goals_company_name` adds `company_goals.company_name` (X/video product-branding fallback) and `076_project_git_provider` adds `projects.git_provider` (forge-providers Phase 0 — GitHub/Gitea/GitLab). Chain head is now 076 (062-072 remain the pre-existing table gap noted above — this delta only closes 073-076). +> +> Delta 2026-07-21/23 (10 migrations, 077-086): `077_github_app` (GitHub App credentials + `projects.github_installation_id`), `078_project_codegen_command` (`projects.codegen_command`, auto-regenerate-before-push), `079_notification_backoff` (re-escalation columns replacing the every-tick-forever sweep), `080_task_project_budgets` (`tasks.budget_usd` + `projects.monthly_budget_usd`), `081_doctrine_version` (`agent_spawn_sessions.doctrine_version`, the golden-task eval cohort stamp), `082_routing_presets` (named full-snapshot routing presets), `083_seed_openai_provider` + `084_modelprovider_gemini` + `085_seed_gemini_provider` + `086_enable_gemini_provider` (Codex + Gemini CLI provider rows — see `docs/map/runtime-providers.md`). Chain head is now 086. ## Regression Risks diff --git a/docs/map/deployment-tooling.md b/docs/map/deployment-tooling.md index c5b58083..4a74c758 100644 --- a/docs/map/deployment-tooling.md +++ b/docs/map/deployment-tooling.md @@ -35,7 +35,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/postgres-pgvector.Dockerfile | Example custom pgvector build (pg17) — currently unused; compose uses pgvector/pgvector:pg16 image directly | 15 | | docker/nginx.conf | nginx default.conf template: /health /ready /api/ /ws/ -> orchestrator (with X-Agent-Token header), everything else -> panel | 67 | | docker/postgres-init/01-create-extensions.sql | First-init SQL: CREATE EXTENSION IF NOT EXISTS vector + availability check (used by the pgvector image entrypoint) | 17 | -| docker/scripts/backup-entrypoint.sh | `backup` sidecar's own loop (not a Claude hook): `pg_dump -Fc` on start + every `BACKUP_INTERVAL_SECONDS` (default 24h), `.tmp`-suffix-then-rename, prune to newest `BACKUP_KEEP` (default 14) by mtime; runs on the data-only network | 46 | +| docker/scripts/backup-entrypoint.sh | `backup` sidecar's own loop (not a Claude hook): `pg_dump -Fc` on start + every `BACKUP_INTERVAL_SECONDS` (default 24h), `.tmp`-suffix-then-rename, prune to newest `BACKUP_KEEP` (default 14) by mtime; runs on the data-only network; env-gated off-disk mirror step (`ROBOCO_BACKUP_MIRROR_DIR`) after every successful dump | 80 | | docker/scripts/playwright-mcp-entrypoint.sh | Wrapper entrypoint for `@playwright/mcp`: points it at the image's own baked `chromium-headless-shell` instead of letting the MCP package download a second browser | 20 | | docker/scripts/sdk-startup-hook.sh | Claude SessionStart hook: start SDK server on :9000 with UV_PROJECT_ENVIRONMENT=/app/.venv + --no-sync, reset budget, print briefing/precompact | 58 | | docker/scripts/a2a-check-hook.sh | PostToolUse hook: poll SDK /inbox/count and remind the agent of pending A2A messages (always exits 0) | 30 | @@ -54,6 +54,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | scripts/verify_postgres_enums.py | Foundation drift gate: compare postgres agentrole/team enum labels to foundation identity; exit 0 on match/skip(unreachable or unmigrated), 1 on drift | 127 | | scripts/reflow_md.py | Reflow hard-wrapped markdown prose to one line per paragraph (token-invariant safety check); --apply / --check modes for the CI gate | 214 | | scripts/reset_runtime_state.sh | Host/container smoke-test reset: stop agent containers, run reset_runtime_state.sql, FLUSH Redis, optional FULL_RESET data wipe, per-workspace git hard-reset + stray-branch prune | 263 | +| scripts/bootstrap.sh | `make quickstart` entrypoint: idempotent one-command bring-up for the registry compose — copies `.env.example` to `.env` on first run (never touches a reused `.env`), injects the three required secrets via documented one-liners (the panel token via the exact `issue_panel_token` HMAC formula) with a standing-credential warning (louder if cloud auth is detected), pre-validates the three required vars with pointed remedies before `docker compose ... up -d` (compose's own `:?` guard on an empty token is an opaque interpolation error otherwise), then runs a doctor-style readiness sweep (root `/health`, `/api/auth/status` through nginx, the verbatim "Alembic upgrade finished" log line, `ollama list`), each stage failing loud with the exact next command | 258 | | scripts/reset_runtime_state.sql | Transactional DELETE of runtime tables (tasks/.../a2a_*) preserving agents/projects/alembic_version; resets agents.metrics | 159 | ## Key Symbols @@ -219,6 +220,7 @@ deployment-tooling | scripts/reflow_md.py --check | scripts/reflow_md.py | make reflow-check / make quality | | scripts/regenerate_verb_tables.py | scripts/regenerate_verb_tables.py | manual after role_config / schema change (not wired into make quality) | | scripts/reset_runtime_state.sh | scripts/reset_runtime_state.sh | manual smoke-test reset (host or ssh into NAS) | +| scripts/bootstrap.sh | scripts/bootstrap.sh | `make quickstart` (pull-and-run registry-compose deploy) | | docker compose up | docker-compose.yaml | operator deploy (build) or -f docker-compose.registry.yml up (pull) | ## Config Flags @@ -261,6 +263,7 @@ deployment-tooling - ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED / _INTERVAL_SECONDS (default 259200/3d) — X-engine feature-spotlight sub-switch (requires ROBOCO_X_ENGINE_ENABLED also on), default off - ROBOCO_OBSIDIAN_VAULT_ENABLED / ROBOCO_VAULT_PATH (default `/data/vault`) — Obsidian vault V1 projection master switch, config-default off but both compose files set it `true`; ROBOCO_VAULT_INTAKE_ENABLED / _INTERVAL_SECONDS / _DIR / _MAX_PER_CYCLE / _MAX_OPEN_DRAFTS — the independently-gated `#roboco`-tag inbox watcher - ROBOCO_FABLE_MODE_ENABLED — opus-fable-playbook adoption (doctrine layer in the composed prompt + 5 Claude-path hook scripts + 1 grok-path hook), default off; off = byte-for-byte unchanged spawn path +- `ROBOCO_BACKUP_MIRROR_DIR` (unset by default) — arms the `backup` sidecar's off-disk mirror step; the host path should live on a DIFFERENT disk (external/remote mount) — a same-disk mirror protects nothing. Compose only sets the container env when the `.env` variable is set. - ROBOCO_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET / _REGION — MinIO object storage (default-off; empty endpoint = disabled, media route falls back to `FileResponse`; when set, `video_renderer_client._save` PUTs each render to MinIO after the local write and `GET /api/video/posts/{id}/media` streams it via `StreamingResponse` over `minio_client.get_object_stream`, key = basename, `_require_ceo` kept so auth stays end-to-end — no presigned URLs; `S3Error` falls back to `FileResponse`); NAS compose runs `minio` + `minio-init` on the `data` network with a named `minio-data` volume, registry compose omits MinIO; see `docs/rag/architecture/minio-storage.md` - ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS - ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS @@ -320,6 +323,10 @@ deployment-tooling > **Local branch (not on master, NOT deployed):** `feature/fastapi-guard-hardening` landed `ROBOCO_GUARD_ENABLED` / `_PASSIVE_MODE` / `_FAIL_SECURE` / `_TELEMETRY_ENABLED` / `_AGENT_API_KEY` / `_PROJECT_ID` / `_EMERGENCY` / `_EMERGENCY_WHITELIST` in `config.py` (6 commits, `896532a3`..`99ee666e`) and set both NAS composes' `ROBOCO_GUARD_ENABLED=true` / `ROBOCO_GUARD_PASSIVE_MODE=true` / `ROBOCO_GUARD_FAIL_SECURE=false` (`c496b677`, Phase 5); `docker-compose.registry.yml` is untouched and stays off. See api-core-websocket for the `roboco/security.py` module + `create_app` wiring detail. +> **Off-disk backup mirror (#645, `98a96bcd`).** The `backup` sidecar wrote its dumps to the same disk it protects — one disk failure lost both the live DB and the backups. `mirror_backup()` (backup-entrypoint.sh) copies the fresh dump to `BACKUP_MIRROR_DIR` (tmp+rename, same crash safety as the primary write) after every successful `pg_dump` and prunes the mirror to the same `BACKUP_KEEP`; an unmounted/unwritable mirror logs and skips without blocking the primary dump. Unset (the default) means the script never attempts a copy. `docs/backend/ops/database-backups.md` gained the mirror setup plus a quarterly restore-drill procedure (throwaway pgvector container, `pg_restore`, row-count sanity check). +> +> **`make quickstart` (#653, `31a489b4`).** New `scripts/bootstrap.sh` backs a one-command pull-and-run bring-up for `docker-compose.registry.yml`: idempotent `.env` bootstrap (copies `.env.example` on first run, never touches a reused file), the three required secrets pre-validated with pointed remedies instead of compose's opaque interpolation error, then pull + `up -d` + a doctor-style readiness sweep grounded in real surfaces (root `/health`, `/api/auth/status` through nginx, the verbatim "Alembic upgrade finished" log line, `ollama list`). `.github/workflows/release.yml` gained a `pull-smoke` job (fresh runner, own GHCR login, needs `publish-images`) that literally pulls the registry compose against the just-published tag on every release, guarding the missing-image regression class documented elsewhere in this file's own Gotchas. +> > **v0.18.0** (2026-07-04): Fable mode — `agent-base.Dockerfile` now `COPY`s 5 vendored `docker/scripts/fable-*.sh` hook scripts (stop-gate, bash-discipline, honesty-nudge, prompt-nudge, precompact), installed at spawn time only when `ROBOCO_FABLE_MODE_ENABLED` is set (default off; `session-start.sh` from the upstream `opus-fable-playbook` was deliberately not ported — redundant with the doctrine layer). X feature-spotlight adds `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED`/`_INTERVAL_SECONDS` to `config.py` as a sub-switch of the existing X-engine flag. ## Regression Risks diff --git a/docs/map/foundation-policy-misc.md b/docs/map/foundation-policy-misc.md index 84154ea3..e3f0d5f7 100644 --- a/docs/map/foundation-policy-misc.md +++ b/docs/map/foundation-policy-misc.md @@ -24,6 +24,8 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog | NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) | | NO_COMMS_ROLES | constant | roboco/foundation/policy/communications.py:66 | frozenset of roles with NO agent-comms surface at all (PROMPTER, SECRETARY — human-only, own dedicated chat pages) — the canonical set both `content_actions.dm()`'s sender-side guard and `agents_config.can_a2a_direct`'s CEO-target-side check consume, so the two enforcement points can't drift apart. AUDITOR/PR_REVIEWER carry `dm`/`read_a2a` (CEO-reachable, reply-only) but are not in this set — the auditor's peer-silence is enforced separately in `can_a2a_direct`'s `from_role == "auditor"` branch | | ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:80 | NotificationType→requires_ack mapping (action-required vs informational) | +| ReescalationPolicy | dataclass | roboco/foundation/policy/communications.py:113 | Bundled `base_seconds`/`max_reescalations` — kept as one dataclass so `reescalation_decision` stays under the 5-arg lint ceiling | +| reescalation_decision | function | roboco/foundation/policy/communications.py:123 | Pure due/wait/capped verdict for one notification's re-escalation attempt: first fire at expiry, then exponential doubling from `base_seconds` capped at 24h between attempts; past `max_reescalations` always "capped" regardless of elapsed time | | Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) | | SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping | | ReadTier | enum | roboco/foundation/policy/journaling.py:41 | Journal read-breadth StrEnum (own/cell/cell_and_pms/all_cells/all) | @@ -60,6 +62,8 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog | VERB_RETRY_LIMITS | constant | roboco/foundation/policy/agent_loop.py:54 | Per-verb retry caps over 60s window keyed by MCP-exposed verb names (pass/fail, i_am_done, open_pr=5, etc.) | | UNLIMITED_RETRY_VERBS | constant | roboco/foundation/policy/agent_loop.py:80 | frozenset of verbs exempt from the per-verb circuit breaker (discovery/claim verbs) | | retry_limit_for | function | roboco/foundation/policy/agent_loop.py:97 | Return per-verb retry cap or None for unlimited; falls back to default for unknown | +| default_budget_usd_for | function | roboco/foundation/policy/agent_loop.py:99 | Per-`TaskType` default $ cap consulted only when a task's own `budget_usd` is null AND `ROBOCO_TASK_BUDGETS_ENABLED` is on | +| effective_task_budget_usd | function | roboco/foundation/policy/agent_loop.py:111 | A task's effective $ cap: its own `budget_usd` if set, else the `TaskType` default; the single resolver the orchestrator's budget sweep and the panel's budget-input validation both consult | | Severity | enum | roboco/foundation/policy/content/enums.py:14 | PR-review finding severity ladder (blocker/major/minor/nit) | | Verdict | enum | roboco/foundation/policy/content/enums.py:23 | Review outcome StrEnum (approved/changes_requested/passed/failed) | | HasMarkers | protocol | roboco/foundation/policy/content/markers.py:19 | Protocol for anything carrying orchestration_markers column | @@ -263,6 +267,10 @@ foundation/policy (misc slice) > Post-snapshot updates (since 2026-06-29): a squash-merge commit landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING). No other symbols in this slice changed. > +> `1d5a8e84` (#652, "exponential backoff + CAS claim for expired-unacked re-escalation"): adds `ReescalationPolicy` + `reescalation_decision` (communications.py:113/123) — the pure due/wait/capped decision `NotificationDeliveryService._maybe_reescalate` (`docs/map/notification.md`) consults before ever attempting a re-escalation delivery. +> +> `7c8453e2` (#654, "per-task and per-project cost budgets"): adds `default_budget_usd_for`/`effective_task_budget_usd` (agent_loop.py:99/111) — the per-`TaskType` fallback + single resolver consumed by the orchestrator's budget sweep and `claim_guards.project_budget_exceeded_guard` (`docs/map/gateway-support.md`). +> > `56b6693e` ("security-hygiene-sweep"): adds `NO_COMMS_ROLES` (communications.py:66) — the four no-agent-comms-surface roles (auditor/pr_reviewer/prompter/secretary), extracted as the single canonical set two previously-independent hand-maintained literals now both derive from: `content_actions.py`'s `_NO_COMMS_ROLES` (the `dm()` sender-side guard) and a new `agents_config.py` helper, `_check_ceo_a2a` (called from `can_a2a_direct`'s CEO branch — previously an unconditional `True` for any CEO-initiated target; the "one asymmetric rule" now itself refuses a no-comms target). A downstream consequence with no separate code edit: `agents_config.py`'s statically-derived `A2A_ALLOWED_PAIRS` shrank 93→88 (`ceo` group 23→18). ## Regression Risks diff --git a/docs/map/gateway-support.md b/docs/map/gateway-support.md index 5c52a9f3..b00ff954 100644 --- a/docs/map/gateway-support.md +++ b/docs/map/gateway-support.md @@ -39,7 +39,8 @@ The support layer of the agent gateway: pure/cheap components the Choreographer | Envelope.as_dict | method | roboco/services/gateway/envelope.py:257 | Wire-format dict; drops None fields except error; always emits error/correlation_id/current_state/valid_next_verbs. | | already_active_guard | function | roboco/services/gateway/claim_guards.py:35 | Reject claim if agent has any active (claimed/in_progress/verifying/blocked) task other than target. | | paused_tasks_guard | function | roboco/services/gateway/claim_guards.py:61 | Reject claim if agent has a paused task other than target (PM re-entry on own paused umbrella exempt). | -| unmet_dependency_guard | function | roboco/services/gateway/claim_guards.py:86 | Reject claim while task has non-terminal depends_on; holds pre-assigned dev at claim verb. | +| project_budget_exceeded_guard | function | roboco/services/gateway/claim_guards.py:86 | `ROBOCO_TASK_BUDGETS_ENABLED`-gated: reject a WORK-STARTING claim (`i_will_work_on`/`i_will_plan` only — review/doc/gate/inbound-PR claims are exempt so in-flight work can always finish) once the project's `monthly_budget_usd` has been reached this calendar month; fires only when `monthly_budget_usd` is set (`None` = no cap). Spend counts closed sessions' `estimated_cost_usd` plus open sessions priced live from token snapshots. | +| unmet_dependency_guard | function | roboco/services/gateway/claim_guards.py:112 | Reject claim while task has non-terminal depends_on; holds pre-assigned dev at claim verb. | | ClaimDecision | enum | roboco/services/gateway/claimant_lock.py:18 | GRANTED / GRANTED_AFTER_STALE_RELEASE / BLOCKED_OTHER_ACTIVE. | | is_stale | function | roboco/services/gateway/claimant_lock.py:24 | True when no heartbeat or heartbeat older than threshold_seconds. | | try_acquire | function | roboco/services/gateway/claimant_lock.py:35 | Decide whether agent may acquire/refresh claim on task (pure; caller persists). | @@ -313,7 +314,7 @@ gateway-support ## Drift from CLAUDE.md - CLAUDE.md: 'pr_reviewer ... no agent comms' — content_actions._NO_COMMS_ROLES enforces this at the handler (defence-in-depth), matching the doc. No drift. -- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get (read-only inbox). This is an additive expansion beyond the doc's literal 'note + evidence' but is consistent with the doc's 'Wave 1 receivers get inbox read' footnote; arguably doc under-states the surface. Not a code drift. +- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get (read-only inbox), and — since `73c05cfa` (#623) — `dm`/`read_a2a` so the CEO can open a DM with a mid-flight auditor and it can reply in-thread (it still never INITIATES peer A2A; that refusal lives in `agents_config.can_a2a_direct`, not in the tool grant itself). `_PR_REVIEWER_DO` gained the identical `dm`/`read_a2a` pair in the same commit. CLAUDE.md's own prose already documents both additions; this file's Files/Key Symbols tables were the stale side and are now current. - CLAUDE.md: 'The note/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget'. content_actions._curate_playbook instead does an EXPLICIT await self.task.session.commit() before svc.index_approved / unindex_playbook — commit-before-index is intentional (gates the corpus). Not drift but a deliberate exception to the fire-and-forget pattern for the playbook curation path. - CLAUDE.md: claim-time concurrency guards 'are skipped for the coordinator PM roles (_COORDINATOR_ROLES = {main_pm, cell_pm}, consulted in _run_claim_guards)'. That skip lives in the Choreographer, NOT in claim_guards.py (this slice). The predicates in this slice have no PM exclusion — importing them directly elsewhere would re-introduce the PM self-block. By design, but the module boundary is easy to misread. diff --git a/docs/map/notification.md b/docs/map/notification.md index a952adec..59ec392d 100644 --- a/docs/map/notification.md +++ b/docs/map/notification.md @@ -13,6 +13,7 @@ This slice implements RoboCo's formal-notification backbone: NotificationService | roboco/services/telegram_inbound.py | V2: `TelegramInboundEngine` — getUpdates poll cycle (offset persisted as `telegram_last_update_id` in system_settings), chat-id AND sender-id authorization, `BOT_COMMANDS` registry driving both `/help` and a once-per-process `setMyCommands` sync, `/status`/`/queue`/`/task`/`/agents`/`/usage`/`/blocked`/`/secretary`/`/newtask`/`/end` command router, `apv|rej:<kind>:<id8>` callback codec, force_reply reject/approve-notes state machine (in-memory `_PENDING_REPLIES`, TTL), per-kind dispatch to the SAME service methods the CEO-gated HTTP routes call (task/release/xpost/video/roadmap), `via=telegram` audit rows | 1295 | | roboco/services/tg_cockpit.py | V4: `TgCockpitService` — DB-only, one-round-trip aggregate for the Mini App home screen (`today()`) and the bot's `/agents` command (`fleet()`); no live GitHub calls, no orchestrator singleton | 217 | | roboco/services/telegram_bridge.py | V4: bridges `/secretary`/`/newtask` Telegram chat into the SAME in-process live-chat runtimes the panel drives — a per-chat consumer task drains `PrompterLiveRegistry.stream`, forwards `turn_end`/`draft`/`batch`/`error` events as Telegram messages, and routes a `draft` event's Send-to-Board confirm through `PrompterService.confirm_live_draft` + registry `park` | 291 | +| roboco/services/notification_text.py | `task_display` (title-first, `#id8` fallback) / `agent_display` (identity-map slug first, DB lookup fallback) — feeds every notification producer so subjects/bodies read `Task <title>` / `<agent-slug>` instead of a raw UUID; tool-call examples (`unblock('<uuid>')`) keep the raw id on purpose, agents need it | 65 | ## Data Flow Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event. @@ -74,7 +75,7 @@ notification ├── EscalationError / EscalationOutcome / BlockerDetails ├── deliver (delivered_at in-tx, defer per-recipient events) ├── get_notification / _notification_is_fully_acked / _log_expired_notification - ├── sweep_expired_notifications (log stale unacked) + ├── sweep_expired_notifications / _maybe_reescalate / _claim_reescalation_slot / _re_escalate_unacked (exponential backoff, CAS claim before delivery) ├── get_pending_for_agent / get_unacknowledged_for_agent / get_notification_count ├── acknowledge / mark_read / bulk_acknowledge / get_ack_status / get_delivery_summary ├── Task-handoff / audit-bridge notifications @@ -110,15 +111,19 @@ telegram_inbound.py (TelegramInboundEngine, V2) | Name | File | Trigger | |---|---|---| | NotificationService.send_*_notification | roboco/services/notification.py | TaskService / orchestrator lifecycle transitions (blocker, qa-ready, docs, a2a, board-review) | -| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only) | +| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only); optional `db_session` param lets a caller whose OWN open transaction just created the referenced task (e.g. the release engine's just-originated proposal) join that transaction, so the `related_task_id` FK doesn't reject an insert made through a fresh session against an as-yet-uncommitted task | | NotificationDeliveryService.notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation / notify_auditor_of_rework | roboco/services/notification_delivery.py | api/routes/tasks.py i_am_blocked / escalate / ceo-approval routes; TaskService._alert_auditor_of_rework at QA-fail / rework chokepoints | | NotificationDeliveryService.acknowledge / list_for_agent / get_for_recipient_and_mark_read | roboco/services/notification_delivery.py | api/routes/notifications.py ACK + list endpoints | -| sweep_expired_notifications | roboco/services/notification_delivery.py | orchestrator periodic loop (orchestrator.py:5780) | +| `reescalation_decision` | roboco/foundation/policy/communications.py:123 | Pure due/wait/capped decision for one notification (`now`, `expires_at`, `count`, `last_reescalated_at`, `ReescalationPolicy`) — called by `_maybe_reescalate` before any delivery is attempted | +| `NotificationDeliveryService._claim_reescalation_slot` | roboco/services/notification_delivery.py:443 | Compare-and-set `UPDATE ... WHERE reescalation_count = :n` claimed BEFORE delivery — two sweep ticks racing the same row can never both deliver; the slot is consumed (count bumped) even on a subsequent delivery failure, so a permanently-broken chain still counts toward the cap instead of looping forever | +| sweep_expired_notifications | roboco/services/notification_delivery.py:367 | orchestrator periodic loop (orchestrator.py:5780); re-escalates a still-unacked row only when `reescalation_decision` says it's due, not every tick | | TelegramInboundEngine.run_cycle | roboco/services/telegram_inbound.py | orchestrator `_telegram_poll_loop` (default off, `telegram_enabled` AND `telegram_inbound_enabled`) | ## Config Flags - settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT) - `ROBOCO_NOTIFICATION_ACK_TTL_HOURS` (default `48`, `ge=0`) — hours until an ack-required notification's `expires_at` is stamped at creation (`_create_notification`, config.py:270); consumed by `NotificationDeliveryService.sweep_expired_notifications`'s re-escalation. `0` disables stamping entirely (`expires_at` stays `NULL`, legacy behavior — never expires). Only ack-required notifications (per `ACK_REQUIRED_BY_TYPE`) get a deadline; informational ones never do regardless of this setting. +- `ROBOCO_NOTIFICATION_REESCALATION_BASE_SECONDS` (default `3600`, `ge=1`) — base interval for the re-escalation backoff: the first re-escalation fires at expiry, each one after that doubles the wait from this base (1h, 2h, 4h, 8h, ...) capped at 24h between attempts. +- `ROBOCO_NOTIFICATION_MAX_REESCALATIONS` (default `5`, `ge=1`) — hard cap on re-escalations per notification; past this many a still-unacked row is logged once as permanently-unacked and never re-escalated again. - `telegram_enabled` (default off) — V1 master switch; `_notify_telegram` no-ops without it AND stored credentials. - `telegram_inbound_enabled` (default off, sub-switch on top of `telegram_enabled`) — V2: arms `TelegramInboundEngine.run_cycle` (the poll loop) and makes escalation DMs carry an actionable keyboard; with it off the bot only sends, never listens, and any inline button on an old message is inert. - `telegram_poll_interval_seconds` (5.0) / `telegram_poll_timeout_seconds` (25, Bot API long-poll `timeout`) / `telegram_max_updates_per_cycle` (50) / `telegram_pending_reply_ttl_seconds` (300) — V2 poll-loop tuning. @@ -159,6 +164,12 @@ telegram_inbound.py (TelegramInboundEngine, V2) > `cd978d11`+fixes (2026-07-18, wave-13): Telegram sends are HTML-styled — `_esc` (text nodes) / `_esc_attr` (href attributes) escaping discipline, balance-aware `_truncate`, `parse_mode`/`disable_link_preview` on the client; new `notify_ceo_of_queue_item` pushes a styled keyboard DM at each held-draft origination (release/x/video engines + `propose_roadmap`), sharing `telegram_inbound.render_queue_item_text`. > `3b9fd0e0`+`11915f36` (PR #551, Telegram V2): `3b9fd0e0` adds `telegram_inbound.py` (new file, `TelegramInboundEngine`), extends `telegram_client.py` with `get_updates`/`answer_callback_query`/`edit_message_reply_markup`/`edit_message_text`, adds `actionable=True` to `_notify_telegram` (escalation only) so the DM carries an Approve/Reject/Open keyboard, and wires the orchestrator's `_telegram_poll_loop`. `11915f36` closes a live-reproduced approve-after-reject hole reachable via a stale Telegram button (or the pre-existing HTTP routes for X/video): `ReleaseProposalService.approve()` now refuses CANCELLED (`already_rejected`) and COMPLETED (`already_published`) proposals via a new `_approve_precheck`, `.reject()` refuses COMPLETED by raising a new `TaskAlreadyCompletedError`, and `XPostService`/`VideoPostService.approve()` each add a CANCELLED pre-lock-and-under-lock guard returning `already_rejected`. Also adds `_authorized_sender` (chat-id auth is defense-in-depth'd with a sender-id check) and widens `_resolve_task`'s search limit 10→50 so a genuine id-prefix hit can't be pushed out by newer title/description matches. > `baa87d58`+`c7605b0d` (2026-07-19, PR #576 + #582, Telegram Mini App V4): new `tg_cockpit.py` — `TgCockpitService.today()` (tg_cockpit.py:59) assembles `needs_you`/`fleet`/`spend`/`velocity`/`ship` in one DB-only round trip backing `GET /api/telegram/today` (`api/routes/telegram.py:89`, `require_ceo_role` + 30/60s rate limit); `TgCockpitService.fleet()` (tg_cockpit.py:110) is shared verbatim by the bot's new `/agents` command. New `telegram_bridge.py` — `BridgeSession` (per-chat, in-memory) lifecycle via `start_secretary`/`start_intake`, a sole-consumer `_consume` task draining `PrompterLiveRegistry.stream`, `_forward_event` turning `turn_end`/`draft`/`batch`/`error` stream events into Telegram messages, and `mark_parked`/`discard_draft` routing a draft's Send-to-Board confirm through `PrompterService.confirm_live_draft(route="board")` + registry `park`; `sweep_idle()` reuses `settings.interactive_idle_reap_seconds` and skips parked sessions. `telegram_inbound.py` gains `BOT_COMMANDS` (a single registry driving both `/help`'s `_HELP_TEXT` and a once-per-process `client.set_my_commands` sync via `TelegramInboundEngine._ensure_commands_menu`, called from `run_cycle()`) plus `/agents` (`_render_agents`, calls `TgCockpitService.fleet()`), `/usage` (`_render_usage`, `UsageService.get_today_summary`), `/blocked` (`_render_blocked`, capped `awaiting_ceo_approval`+`blocked` lists with deep-linked rows), and `/secretary`/`/newtask`/`/end` (dispatch straight into `telegram_bridge.py`). `telegram_client.py` gains `set_my_commands` (`LiveTelegramClient`, best-effort `httpx.HTTPError`-suppressed) + a `NullTelegramClient` no-op. No orchestrator wiring changed — `_telegram_poll_loop`/`_run_telegram_poll_cycle` are byte-for-byte unchanged; the bridge's idle sweep and the commands sync both run *inside* the existing `run_cycle()` tick. New response schemas in `api/schemas/telegram.py`: `TodayTaskItem`/`TodayNeedsYou`/`TodayFleetAgent`/`TodayFleet`/`TodaySpend` (gains `series`/`delta_pct` in the `c7605b0d` follow-up)/`TodayVelocity` (new in `c7605b0d`)/`TodayShip`/`TelegramTodayResponse`. +> `1d5a8e84` (#652, "exponential backoff + CAS claim for expired-unacked re-escalation"): `sweep_expired_notifications` used to re-escalate EVERY expired unacked row on EVERY ~60s tick forever — the live incident was 3 fresh blocker escalations + Telegram DMs per minute from a static stale pile. Migration 079 adds `reescalation_count`/`last_reescalated_at`/`reescalation_delivered_count`; the due/wait/capped decision is the pure `reescalation_decision` (`docs/map/foundation-policy-misc.md`); `_claim_reescalation_slot`'s compare-and-set claims the attempt BEFORE delivery is even attempted (NOT backstopped by the loop-prone-type Redis dedup — `BLOCKER_ESCALATION`, the type every re-escalation fires as, is deliberately excluded from that dedup set) so two racing sweep ticks can never both deliver. Legacy rows (`count=0`) keep the original first-fire-at-expiry semantics. +> +> `d362858f` (#603, "ack notifications can join the caller's transaction"): `send_ack_notification` gains an optional `db_session` param forwarded to `_create_notification` — the release engine's bell notification for a just-originated proposal was inserting through a FRESH session while the proposal task still sat uncommitted in the engine's own transaction, so the `related_task_id` FK rejected the row and the ping was silently lost (the DB-free Telegram DM still went out, masking the loss). The release engine now passes its own session; the other five `send_ack_notification` callers pass no `task_id` or reference already-committed tasks, so they're unaffected. +> +> `c8f55be9` (#616, "task titles and agent slugs replace raw UUIDs"): new `notification_text.py` (`task_display`/`agent_display`) feeds every notification producer — all 13 `NotificationService` methods, the delivery-service task-handoff bodies, the substitute-PM ad-hoc insert, and the orchestrator/choreographer callers (threading the task row's title one call deeper) — so subjects/bodies read `Task <title>` (falling back to `#<id8>` when no title) and `<agent-slug>` instead of raw UUIDs. Also fixed a literal `'cell_pm'` role string that was being sent as an agent slug in the merge-conflict notification body. Tool-call examples in remediation hints (`unblock('<uuid>')`) keep the raw id on purpose — agents still need it to call the tool. +> > `56b6693e` ("security-hygiene-sweep"): root-causes a previously dead-on-arrival sweep — `NotificationDeliveryService.sweep_expired_notifications` already ran a real `expires_at < now()` query, but `NotificationService._create_notification` never WROTE `expires_at`, so the query always matched zero rows and every ack-required notification was effectively immortal. `_create_notification` now computes `requires_ack` up front (same derivation as before) and, when ack-required AND `settings.notification_ack_ttl_hours > 0`, stamps `expires_at = now() + timedelta(hours=notification_ack_ttl_hours)` (default 48h) on the `NotificationTable` row; `0` leaves `expires_at` `NULL` (never expires). Informational notifications never get a deadline regardless of the setting. ## Regression Risks diff --git a/docs/map/orchestrator.md b/docs/map/orchestrator.md index b3240cf8..dec830da 100644 --- a/docs/map/orchestrator.md +++ b/docs/map/orchestrator.md @@ -106,6 +106,15 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock | AgentOrchestrator._run_telegram_poll_cycle | method | roboco/runtime/orchestrator.py:8476 | One Telegram poll pass: `get_telegram_inbound_engine(db).run_cycle()` + commit; testable without the sleep. | | AgentOrchestrator._pending_claim_blocked | method | roboco/runtime/orchestrator.py:11586 | Dispatch-time probe reusing `TaskService.is_pending_claim_blocked` (the exact claim-gate predicate — dependency OR sequence) so `_route_unassigned_pm_task` (called from `_dispatch_pm_work` per PENDING coordination-root task) can filter a doomed later-wave/sequence-held task before attempting the raw claim, instead of one failed claim per tick. Fails open (False) on any lookup error — the claim attempt itself is the safety net. | | AgentOrchestrator._pm_respawn_should_gate | method | roboco/runtime/orchestrator.py:8915 | Per-(slug,task) respawn circuit breaker; tracing_gap rule-following resets (bounded) + durable persist; CEO notify once when tripped. | +| AgentOrchestrator._notification_spawn_cooled | method | roboco/runtime/orchestrator.py:4738 | Cross-tick cooldown for notification-triggered spawns (escalation/approval/audit/a2a): one spawn per (agent, notification) per `notification_spawn_cooldown_seconds`; `0` disables (legacy every-tick respawn). | +| AgentOrchestrator._notification_spawn_over_cap | method | roboco/runtime/orchestrator.py:4780 | Hard per-(agent, notification) attempt cap (`notification_spawn_max_attempts`, default 5) — the notification-driven analogue of the PM respawn breaker; these dispatchers carry no `task_id` so that breaker never sees them. Past the cap, stop respawning and log once; the count is id-scoped and survives map pruning. | +| AgentOrchestrator._prune_notification_spawn_maps | method | roboco/runtime/orchestrator.py:4811 | Evict `_notification_spawn_at` entries older than a cutoff so the in-memory cooldown map doesn't grow unbounded. | +| AgentOrchestrator._notification_has_live_work | coroutine | roboco/runtime/orchestrator.py:4848 | Live-work check before spawning a notification's recipient: skip when the notification has expired, is stale past `notification_spawn_max_age_seconds` (default 6h — wedged or reloaded from before a restart), or its related task is already terminal. | +| AgentOrchestrator._fetch_budget_status | coroutine | roboco/runtime/orchestrator.py:8021 | `ROBOCO_TASK_BUDGETS_ENABLED` sweep helper: live-priced spend snapshot for one active task (closed sessions' `estimated_cost_usd` + open sessions priced from token snapshots). | +| AgentOrchestrator._task_budget_breach | coroutine | roboco/runtime/orchestrator.py:8053 | `(spend, cap)` tuple when a task's own spend exceeds its `budget_usd` (falling back to the `TaskType` default via `effective_task_budget_usd`), else `None`. | +| AgentOrchestrator._handle_task_budget_breach | coroutine | roboco/runtime/orchestrator.py:8090 | BLOCKs a breaching task (HUMAN resolver, a budget marker) BEFORE the graceful stop so the ensuing unclaim no-ops and the dispatcher never respawns onto it; notifies the CEO naming both recovery steps (raise the cap or `unblock`). | +| AgentOrchestrator._sweep_budget_exceeded | coroutine | roboco/runtime/orchestrator.py:8152 | Iterates active agents past `_check_budget_for_agent`; gated entirely by `ROBOCO_TASK_BUDGETS_ENABLED` — off, neither cap is ever consulted. | +| AgentOrchestrator._check_budget_for_agent | coroutine | roboco/runtime/orchestrator.py:8187 | Per-agent budget check called from the sweep; resolves the active task, checks breach, and calls `_handle_task_budget_breach`. | | AgentOrchestrator._handle_pm_assigned_task | method | roboco/runtime/orchestrator.py:9084 | Spawn/respawn the PM for an assigned coordination root subject to the respawn gate. | | AgentOrchestrator._AUTO_SUBMIT_VERB_BY_ROLE | ClassVar[dict] | roboco/runtime/orchestrator.py:10650 | Wave-1 PR-gate turn cut: maps cell_pm -> (cell_pm, submit_up) and main_pm -> (main_pm, submit_root), the flow route+verb that assembles the parent's PR for each coordinator role. | | AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. Unconditional otherwise — no flag (the `pr_gate_auto_submit_enabled` kill-switch was removed post-0.19.0). | @@ -221,6 +230,7 @@ stateDiagram-v2 - No flag — the PR-gate turn cut is unconditional: when every child of an assembled parent is terminal, `_try_auto_submit` always runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity/AC-coverage/race) falls back to the classic PM closure spawn (the sole safety net), with the reason threaded into the PM's closure prompt. The `pr_gate_auto_submit_enabled` kill-switch that gated this through 0.19.0 has been removed. - `ROBOCO_OBSIDIAN_VAULT_ENABLED` (default off; both compose files set `true`) — gates `_dispatch_vault_curation_work` (the vault-curation Auditor-spawn trigger), `_vault_intake_loop`'s outer check, and (V2) `_vault_janitor_loop`'s sole gate. `ROBOCO_VAULT_INTAKE_ENABLED` (default off; both compose files set `true`) — the `_vault_intake_loop`'s own switch, consulted only when the vault master switch is also on. V2: `ROBOCO_VAULT_ARCHIVE_DAYS` (default 30) / `ROBOCO_VAULT_REPORT_ENABLED` (default true) tune the janitor's archival pass and weekly report, both folded into `_vault_janitor_loop` with no separate loop. `ROBOCO_VAULT_KB_ENABLED` (+ `_KB_DIRS` default `RoboCo/Notes` / `_KB_INTERVAL_SECONDS` default 900, default off; NAS compose sets it `true`, registry compose leaves it `false`) — the second, independent switch `_vault_kb_loop` checks alongside the vault master switch. - No flag — `_pending_claim_blocked` (the dependency/sequence claim-gate prefilter) is unconditional, mirroring `TaskService.claim`'s own always-on sequence gate; it is a pure dispatch-time optimization (fails open on lookup error) with no behavior change vs. attempting and failing the claim. +- `ROBOCO_TASK_BUDGETS_ENABLED` (default off) — arms `_sweep_budget_exceeded`; off = the sweep never runs and neither `tasks.budget_usd` nor `projects.monthly_budget_usd` is ever consulted regardless of field values. `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` / `_MAX_ATTEMPTS` (default 5) / `_MAX_AGE_SECONDS` (default 21600/6h) — the notification-driven-respawn breaker (`_notification_spawn_cooled`/`_over_cap`/`_notification_has_live_work`), independent of `ROBOCO_TASK_BUDGETS_ENABLED` and always on (`0` on any one of the three tunables disables just that guard). - `ROBOCO_TELEGRAM_ENABLED` (default off) — V1 master switch; `telegram_inbound_enabled` is a sub-switch on top of it, so both plus stored credentials are required before `_telegram_poll_loop` does anything. `ROBOCO_TELEGRAM_INBOUND_ENABLED` (default off, NAS compose arms it `true`) — gates `_telegram_poll_loop`; also makes escalation DMs (not completion DMs) carry an actionable Approve/Reject/Open keyboard. `ROBOCO_TELEGRAM_POLL_INTERVAL_SECONDS` (5.0) / `_POLL_TIMEOUT_SECONDS` (25) / `_MAX_UPDATES_PER_CYCLE` (50) / `_PENDING_REPLY_TTL_SECONDS` (300) tune the poll cadence and the force_reply prompt lifetime. ## Gotchas @@ -234,6 +244,8 @@ stateDiagram-v2 - Budget-kill (`_enforce_grok_cost_budget`) finalizes the spawn session BEFORE popping the instance so captured usage/cost isn't lost; the reaper then releases the freed claim. - `_should_skip_live_reap` short-circuits like the original `and`: when not live, none of the three kill checks is awaited. The three kill paths are: `_maybe_kill_wedged_grok` (grok idle TTL), `_maybe_kill_stuck_claude` (non-GROK agent stuck past `claude_stuck_kill_seconds`, default 3600s), and `_maybe_recover_broken_gateway`. A Claude agent stuck in a genuine verb loop (still firing gateway verbs, so heartbeat advances) remains spared — the stuck-claude TTL only catches heartbeat-stale containers. - Tests/helpers that construct `AgentOrchestrator` via `AgentOrchestrator.__new__(AgentOrchestrator)` to bypass `__init__` rely on `__new__` pre-initializing `_instances`, `_last_audit_spawn_at`, and `_notification_spawn_at`. Any refactor that moves those initializations out of `__new__` or adds new instance attributes read by `_dispatch_audit_work`/`_is_agent_active`/notification-cooldown paths without also pre-initializing them will re-break those helpers. `__init__` must continue to re-initialize `_instances` so normal construction is unaffected. +- The escalation/approval/audit/a2a dispatchers spawn a notification's recipient every cooldown window for as long as it stays pending, and these spawns carry no `task_id` — the PM respawn breaker (`_pm_respawn_should_gate`) never sees them, so before `_notification_spawn_over_cap`/`_notification_has_live_work` existed a single wedged alert whose recipient never resolved it respawned that recipient forever (observed live: 6+ hours of a ~2-3min respawn cycle). Both guards gate AFTER the existing cooldown, not instead of it. +- `_handle_task_budget_breach` blocks the task (HUMAN resolver) BEFORE the graceful stop specifically so the unclaim that follows is a no-op onto an already-`blocked` task — ordering it the other way would let the dispatcher immediately respawn onto the freshly-unclaimed, still-over-budget task. `unblock` on a budget-blocked task re-checks live spend and refuses while still over, closing the silent re-breach loop that ordering alone doesn't. - `_auto_submit_target` requires BOTH `branch_name` and `project_id` on the parent — a MegaTask umbrella (branchless coordination) always fails this check and falls through to the classic PM closure spawn, which is correct (an umbrella assembles no PR) but means the turn cut never applies to the top of a MegaTask tree, only its root-subtasks. ## Drift from CLAUDE.md @@ -255,6 +267,8 @@ stateDiagram-v2 > - `49b93248` (PR #504, 2026-07-13): Extended `AgentOrchestrator.__new__` to also pre-initialize `_last_audit_spawn_at` and `_notification_spawn_at`. Hardens the `__init__`-bypass construction path against `AttributeError` when the auditor-dispatch and notification-cooldown paths read those attributes; the existing `_fresh_orchestrator` helper's explicit initialization is now defensive rather than required. `__init__` still re-initializes the registries for normal construction. > - `fd621f0d` (PR #544, 2026-07-17): adds `_is_video_authoring_spawn` (a fail-closed role/team/task-source probe) and threads a new `task_id` param through `_generate_mcp_config` so `_append_role_scoped_mcp_servers` can register the `playwright` MCP for a ux-dev spawned onto a `source=video` task, alongside its existing fe-qa/ux-qa grant — see `docs/map/video-engine.md` for the composition-authoring side of this wire. > - `3b9fd0e0` (PR #551, Telegram V2, 2026-07-17): adds `_telegram_poll_task` + `_telegram_poll_loop`/`_run_telegram_poll_cycle` (mirrors the `_x_mentions_poll_loop` shape: gated on BOTH `telegram_enabled` AND `telegram_inbound_enabled`, started in `start()`, cancelled in `stop()`) driving the new `TelegramInboundEngine` (`roboco/services/telegram_inbound.py`) — long-polls Telegram `getUpdates` and dispatches `/status` `/queue` `/task` commands plus Approve/Reject button callbacks to the SAME CEO-gated service methods the HTTP routes call. +> - `b91229f4` (#643, "break the notification-driven respawn loop"): adds `_notification_spawn_cooled` (line 4738), `_notification_spawn_over_cap` (line 4780), `_prune_notification_spawn_maps` (line 4811), and `_notification_has_live_work` (line 4848) — both new guards gate AFTER the existing cross-tick cooldown. `_instances.__init__`/`__new__` gain `_notification_spawn_at: dict[tuple[str, str], float]` (existing cooldown map) plus a companion hard-cap counter; every notification-triggered spawn dispatcher (escalation/approval/audit/a2a) is threaded through both new checks. +> - `7c8453e2` (#654, "per-task and per-project cost budgets"): adds `_fetch_budget_status`/`_task_budget_breach`/`_handle_task_budget_breach`/`_sweep_budget_exceeded`/`_check_budget_for_agent` (lines 8021-8214) gated by `ROBOCO_TASK_BUDGETS_ENABLED`. The claim-time project-month-spend guard (`project_budget_exceeded_guard`) lives in `docs/map/gateway-support.md`; this is the sweep-side task-budget half. The same PR bundles `1d5a8e84` (#652's re-escalation backoff, see `docs/map/notification.md`) as a prerequisite commit in its branch history. ## Regression Risks diff --git a/docs/map/product-strategy-research-pitch.md b/docs/map/product-strategy-research-pitch.md index d32c7599..c15497a9 100644 --- a/docs/map/product-strategy-research-pitch.md +++ b/docs/map/product-strategy-research-pitch.md @@ -95,13 +95,16 @@ The product / strategy / research / pitch slice covers the "company layer" above | `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors | | `XEngine` | class | x_engine.py:230 | Dormant "engine 4": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape across THREE responsibilities — release posts, mention replies, feature spotlights | | `XEngine._voice_guide` | method | x_engine.py:259 | `_voice_guide(product_name)`: baseline house-voice constant (`_HOM_VOICE`) plus the CEO's `company_goals.brand_voice` sample when set — feeds release/reply prompts AND is the mechanism the HoM identity file points to for its own drafting; `product_name` is resolved once per call site via `CompanyGoalsService.resolve_product_name(project)` (project's own name → charter `company_name` → "RoboCo" literal), not hardcoded | +| `_hom_voice` / `_HOM_VOICE_GUIDE` | func / const | x_engine.py | The baseline voice fed into every local-model draft prompt (release/reply) — ported from the reasoning-backed Head-of-Marketing identity's own VOICE GUIDE (previously a one-sentence stub reaching only the off-by-default spotlight path) plus a slop-ban list (banned words, em-dash ban, exclamation-pileup ban, "X isn't just Y" ban, rule-of-three ban) adapted from the ux_ui team's "AI tells to avoid", and three fixed style exemplars. Both prompts now target well-under-240-chars so the 280 clamp never truncates mid-sentence. | +| `changelog_highlights` | func | x_engine.py | Pure extraction of the bold feature-lead lines (`- **Headline (#N).**`) from a curated CHANGELOG release entry, stripping PR refs and trailing periods, capped at `limit`; `approve()` prefers these over raw `change_summary` (per-commit subjects) for the release-post caption, falling back to `change_summary` only when the changelog yields nothing | +| `XEngine.redraft_from_rejection` | method | x_engine.py | Routes a CEO's non-blank rejection reason into a fresh held draft of the SAME source (release/reply/spotlight), mirroring `VideoEngine.reauthor_from_rejection` — reads the rejected draft's source-specific reference marker, asks the local model to revise the rejected body with the reason folded in as guidance via `_revision_prompt`; a local-model failure or empty output originates NOTHING (a degraded copy is worse than none). Deduped per underlying item (`_redraft_already_open`, excludes CANCELLED so a further redraft is allowed once THAT one is also rejected) under a per-identity Redis lock (`_acquire_redraft_lock`, mirroring `XPostService`'s lock style) so two racing rejects can't both originate. | | `XEngine.draft_release_post` | method | x_engine.py:279 | Event-driven hook (called from `ReleaseProposalService.approve`'s publish-success branch); local-model-drafted, deduped per version, capped by `x_max_open_posts`; resolves and threads `product_name` from the release's own project | | `XEngine.run_cycle` | method | x_engine.py:353 | Periodic mentions poll; no-op unless `x_engine_enabled` AND `x_replies_enabled`; filters bot-like/low-engagement mentions, dedupes by mention id (`XSeenMentionTable`); each mention's text is run through `screen_external_text` before the local-model reply prompt sees it; resolves `product_name` once per cycle and threads it through `_originate_reply`/`_draft_reply_body`/`_reply_prompt` | | `screen_external_text` | function | foundation/policy/injection_guard.py:95 | Shared screen-and-neutralize guard for unattended attacker-writable text feeds (X mentions, vault inbox notes): wraps the text in an untrusted-content envelope and flags any matched injection-pattern LINE inline — nothing is removed, so the CEO-facing draft still shows what the source really said | | `XEngine.open_feature_spotlight_exploration` | method | x_engine.py:487 | No-ops unless `x_engine_enabled` AND `x_feature_spotlight_enabled`, no creds, a cycle already open, the open-post cap reached, or project unresolvable; else opens ONE held PENDING exploration task for the Head of Marketing (`source=x_feature_exploration`) carrying a `x_seen_features` marker snapshot; the description is built by `_feature_exploration_description(product_name)`, no longer the fixed `_FEATURE_EXPLORATION_DESCRIPTION` string | | `XEngine.materialize_feature_spotlight` | method | x_engine.py:841 | Called from the `propose_feature_spotlight` do-tool: marks the feature slug seen (`XSeenFeatureTable`), creates the held draft (`source=x_feature`, identical shape to a release/reply draft), completes the exploration task | | `XPostService.approve` | method | x_post_service.py:92 | The ONLY caller of `x_client.post_tweet`; Redis single-flight lock, re-reads task under lock, idempotent on an already-posted draft (`already_posted`); a CANCELLED draft is refused both pre-lock and re-checked under lock (`already_rejected`) — a stale approve (e.g. a queued Telegram button) can't resurrect a draft the CEO already rejected | -| `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft | +| `XPostService.reject` | method | x_post_service.py:251 | Records the CEO's reason; cancels the held draft; a non-blank reason schedules `XEngine.redraft_from_rejection` via `defer_after_commit` (a fresh session, never blocks or fails the HTTP response) so the feedback re-enters the draft flow instead of dying with the cancel | | `XClient` / `NullXClient` / `LiveXClient` | ABC/class | x_client.py:150 / 166 / 186 | `NullXClient.configured` is False (no creds) — drafting still runs (content nobody can post is a no-op upstream), just never originates; `LiveXClient` signs OAuth 1.0a HMAC-SHA1 | | `build_x_client` | factory | x_client.py:306 | Returns `LiveXClient` when credentials decrypt, else `NullXClient` | | `XCredentialsService.set_credentials` / `.get_decrypted` | method | x_credentials.py:61 / 116 | All-or-nothing Fernet-encrypted singleton credential set/clear; decrypts server-side only, never exposed to agents | @@ -372,6 +375,9 @@ product-strategy-research-pitch > - `b3558d4e` ([chore] complexity: split 5 C-rank blocks to <=B, 2026-06-30): `kanban.py` `get_main_pm_board_flat` — refactored if/elif routing to a dict-dispatch (`status_col` + `team_col` maps) for xenon complexity gate; no functional change. > - **v0.18.0** (2026-07-04): the X feature-spotlight content in this slice (`XEngine` feature-spotlight methods, `_x_feature_spotlight_loop`/`_dispatch_feature_spotlight_exploration`, migration 061, `x_feature_spotlight_enabled`) was authored directly into this file's Files/Key Symbols/Data Flow/Mermaid/Logical Tree/Entry Points sections at implementation time rather than landing as a dated delta — noted here for changelog continuity; the body text above is current as of this date. (Config Flags is unchanged — the X-engine flags live in deployment-tooling.md's comprehensive list, not here.) > - `11915f36` (PR #551, Telegram V2 security follow-up, 2026-07-17): `x_post_service.py` — `XPostService.approve`/`_approve_locked` add a CANCELLED-task guard (pre-lock and re-checked under lock) returning a new `already_rejected` status, closing a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call). +> - `57b9e76b` (#607, "release caption uses curated CHANGELOG headlines, not commit subjects"): `draft_release_post` used to feed `highlights=list(report.change_summary)` — raw per-commit subjects — so the announcement model parroted the top commit's literal text. New pure `changelog_highlights()` extracts the bold feature leads from the curated release entry (`report.drafted_changelog`); `approve()` prefers those, falling back to `change_summary` only when the changelog yields nothing. The video pipeline's captions were already good (the authoring dev reads the changelog directly) — this brings X captions to the same source. +> - `16fa018a` (#615, "real voice guide, slop ban, and caption craft"): release/reply drafts previously ran on a one-sentence voice stub while the reasoning-backed Head-of-Marketing voice guide only reached the off-by-default spotlight path. `_hom_voice`/`_HOM_VOICE_GUIDE` port the full VOICE GUIDE (confident-not-hedgy, one idea per post, no emoji spam, plain text, never invent facts) plus a slop-ban list (em dashes, "game-changer"/"seamless"/etc., exclamation pileups, "X isn't just Y" constructions, rule-of-three chains) and three fixed style exemplars into every drafting prompt; both prompts target well-under-240-chars so the 280 clamp never truncates mid-sentence. An empty `brand_voice` now nudges the CEO exactly once (a durable `system_settings` marker, `_BRAND_VOICE_NUDGE_KEY`) instead of silently shipping baseline voice forever; a failed reply draft now skips origination instead of shipping a generic "Thanks for the mention!". +> - `4585a248` (#648, "redraft loop on CEO reject"): `XPostService.reject` with a non-blank reason now schedules `XEngine.redraft_from_rejection` after its commit (`defer_after_commit`, fresh session, never blocks/fails the reject) — mirroring `VideoEngine.reauthor_from_rejection`. Deduped via `_redraft_already_open` (excludes CANCELLED, so a further redraft is allowed once THAT one is itself rejected) under a per-identity Redis lock (`_acquire_redraft_lock`/`_REDRAFT_RELEASE_SCRIPT`, mirroring `XPostService`'s own lock style) so two racing rejects can't stack duplicate drafts. A local-model failure or empty revision output originates nothing — a degraded copy is worse than none. > - `7e01c0ce` (PR #570, "project-branded drafts + project badges", 2026-07-18): migration 075 adds `company_goals.company_name`; `CompanyGoalsService.resolve_product_name` (company_goals.py:79) is the new single fallback chain (project name → charter `company_name` → "RoboCo") consumed by both `XEngine._voice_guide`/`draft_release_post` and `VideoEngine` (see `docs/map/video-engine.md`) so their prompt builders stop hardcoding "RoboCo". New `roboco/api/schemas/project_fields.py`'s `task_project_fields` helper adds `project_slug`/`project_name` to the X and video post-queue API responses (`api/routes/x.py`, `api/routes/video.py`); the panel renders them via a shared `ProjectBadge` — see `docs/map/panel.md`. > - `461a6e1a`+`96401f4c`+`5f32d876` (Phases 1/2-3/4, 2026-07-18/19, #571/#575/#581) — Phase 4 makes `GitHubProvisioningService` provider-aware: `_build_provider` (github_provisioning.py:68) dispatches to `GitHubProvider`/`GiteaProvider`/`GitLabProvider` by `ROBOCO_PROVISIONING_PROVIDER`, `.enabled` additionally requires `ROBOCO_PROVISIONING_HOST` for gitlab/gitea, and `_is_already_exists` (github_provisioning.py:61) matches the "already exists" idempotency signal across all three forges' differing status codes/phrasing. The forge transport package itself (`GitProvider`/`ForgeRouter`/provider implementations) is documented in `docs/map/worksession-git.md` — this slice only covers the provisioning consumer. > - `a0baf94b` ("agnosticism-residue", agnosticism audit items B6/B8): `x_engine.py`'s remaining hardcoded `"RoboCo"` literals (the reply-prompt builder and the feature-spotlight exploration description — `draft_release_post`/`_voice_guide` were already fixed by `7e01c0ce` above) are threaded out: `_reply_prompt` gains a `product_name` param, `_FEATURE_EXPLORATION_DESCRIPTION` (a module constant) becomes `_feature_exploration_description(product_name)` (a function), and `run_cycle`/`open_feature_spotlight_exploration` each resolve `product_name` once via `resolve_product_name` and thread it through. diff --git a/docs/map/release-manager.md b/docs/map/release-manager.md index aa852815..b1ea5f5c 100644 --- a/docs/map/release-manager.md +++ b/docs/map/release-manager.md @@ -255,6 +255,7 @@ release-manager slice > - b3558d4e [chore] complexity: split 5 C-rank blocks to <=B for the xenon gate — refactored large methods in executor/proposal for xenon compliance; no behavior change. > - 8621d01d / fe9940de / d80dfb8b (#534, env-branches ladder) — replaces `default_branch`-keyed release targeting: `_ReleaseContext` gains `prod_branch` (renamed from `default_branch`) + `env_chain`; `get_release_executor` resolves the clone/commit/tag target via `roboco.models.env_branches.prod_branch` and computes `env_chain` via `promotion_chain`; `_GitReleaseOps` gains `promote_env_chain` (fetch + head-first merge of `env_chain` into the prod checkout, fail-closed `promotion_failed` on conflict), run as the first step of `_run_fresh_release`, before any version bump; `release_readiness.gather_snapshot` takes an optional `prod_branch` and diffs `prod..head` (falling back to `last_tag..HEAD` when unset) with a new `_tag_drift_gaps` check (last-tag sha vs. prod tip); `release_manager_engine._production_assess` best-effort fetches the prod rung into the head-pinned read clone before gathering the snapshot. A project with no declared ladder is unaffected (the shim resolves prod_branch/head_branch to the same `default_branch` value). > - `11915f36` (PR #551, Telegram V2 security follow-up) — `_approve_precheck` (new) makes `approve()` refuse a CANCELLED proposal (`already_rejected`) or a COMPLETED one (`already_published`) BEFORE touching the Redis lock/executor; `reject()` now raises the new `TaskAlreadyCompletedError` on a COMPLETED proposal instead of silently cancelling an already-public release. Closes a live-reproduced approve-after-reject hole reachable via a stale Telegram Approve button (or a replayed HTTP call). +> - `f74131a1` ("commit the approve() COMPLETED write under the release lock"): `approve()` used to `flush()` the published proposal's COMPLETED status and leave the durable commit to the background caller (`_run_approve_background`), which runs AFTER `approve()`'s `finally` has already released the Redis lock — in that window a concurrent `reject()` could acquire the freed lock, re-read a row whose COMPLETED write was only flushed (invisible to its own session under READ COMMITTED), pass its guard, and flip the just-published proposal to CANCELLED (last writer wins). The earlier bug-sweep (#638) fixed `reject()`'s side of this race but left `approve()`'s, so `reject()`'s "fails closed on a terminal proposal" guarantee didn't hold end to end. `approve()` now `commit()`s COMPLETED WHILE STILL HOLDING the lock (mirroring `XPostService._post`'s commit-under-lock), so it's durable before release and a racing reject sees it and refuses. ## Regression Risks diff --git a/docs/map/runtime-providers.md b/docs/map/runtime-providers.md index d40baf14..e63500ae 100644 --- a/docs/map/runtime-providers.md +++ b/docs/map/runtime-providers.md @@ -73,6 +73,28 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | GrokCliProvider._append_grok_auth_mount | staticmethod | roboco/llm/providers/grok.py:161 | Mount host ~/.grok DIRECTORY RO to /home/agent/.grok-auth-ro (dir, not file, so tmp+rename refresh propagates); warn if auth.json missing | | GrokCliProvider._append_usage_mount | staticmethod | roboco/llm/providers/grok.py:193 | Mount per-agent data dir so entrypoint writes usage.json orchestrator reads at finalize | | GrokCliProvider._append_grok_env | method | roboco/llm/providers/grok.py:205 | Append ROBOCO_AGENT_ID/MODEL/MCP_CONFIG/INITIAL_PROMPT/GROK_USAGE_FILE env to docker run | +| CodexCliProvider | class | roboco/llm/providers/codex.py:110 | Spawns roboco-agent-codex container: mounts host `~/.codex` (RO, DIRECTORY not single file — a bind-mounted single file pins the inode, same concern as grok's mount) with the entrypoint symlinking `~/.codex/auth.json` from it while codex's own writable state (config.toml, rules/, sessions/) lives in the image's own `~/.codex` | +| default_auth_path (codex) | function | roboco/llm/providers/codex_auth.py:73 | `CODEX_HOME`/`~/.codex/auth.json` path resolution, mirrors grok_auth's own | +| refresh_if_stale (codex) | function | roboco/llm/providers/codex_auth.py:251 | Orchestrator-side refresh loop for the JWT access token (the ONLY expiry signal is its `exp` claim — unlike grok's bundle there's no sibling `expires_at` field); the refresh-token grant against `auth.openai.com/oauth/token` is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern (`_recheck_or_refresh`, line 228) that protects grok's rotation from a concurrent double-burn | +| _exp_from_jwt | function | roboco/llm/providers/codex_auth.py:91 | Decode the access token's JWT `exp` claim — codex has no `expires_in`/`expires_at` sibling field at all, so this is the ONLY signal (grok has this only as a fallback) | +| codex_auth.main | function | roboco/llm/providers/codex_auth.py:282 | `--check` backstop CLI (entrypoint refuses to start on a missing/expired token) or bare orchestrator refresh-if-stale | +| sandbox_level_for_role | function | roboco/llm/providers/codex_cli_config.py:181 | Per-role `--sandbox` level: `workspace-write` for `developer`, `read-only` for every other role — codex's tool-scoping has no CLI-flag equivalent to grok's `--disallowed-tools` | +| render_execpolicy_rules / write_execpolicy_rules | functions | roboco/llm/providers/codex_cli_config.py:210 / 230 | One shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s denying git-mutation/destructive/raw-package-manager commands) rendered per role | +| render_combined_prompt / write_combined_prompt | functions | roboco/llm/providers/codex_cli_config.py:236 / 252 | Codex has no verified system-prompt-file mechanism, so the composed role blueprint is PREPENDED to the task prompt itself rather than mounted separately | +| codex_cli_config.main | function | roboco/llm/providers/codex_cli_config.py:282 | Entrypoint renderer: mcp-config → `~/.codex/config.toml`, execpolicy rules, combined prompt | +| aggregate_usage_from_jsonl | function | roboco/llm/providers/codex_cli_usage.py:80 | Sums `turn.completed` events' real input/output/cache-read/cache-write split — a genuine four-bucket split, unlike grok's output-only fallback | +| usage_and_cost (codex) | function | roboco/llm/providers/codex_cli_usage.py:113 | Prices the aggregated four-bucket usage into `usage.json` | +| capture_run_usage (codex) | function | roboco/llm/providers/codex_cli_usage.py:133 | Entrypoint: write usage.json for the run | +| extract_error_text / is_rate_limited / is_auth_failure / classify | functions | roboco/llm/providers/codex_cli_sniff.py:52-94 | Classifies a codex run's terminal state (`rate_limit`/`auth`/none) from ONLY the structured `error.message` JSONL field + stderr — NEVER the model's own transcript (which could false-positive on ordinary on-topic prose; this repo's own prompts use the phrase "quota-limited"). Codex has no exit-code taxonomy (every failure exits 1), so this sniff is the only signal the orchestrator has to park the provider. | +| GeminiCliProvider | class | roboco/llm/providers/gemini.py:135 | Spawns roboco-agent-gemini container: copies the RO-staged host `~/.gemini` OAuth creds into a container-local WRITABLE copy so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy — unlike grok/codex, Google's refresh token is reusable, so there is NO orchestrator refresh daemon for Gemini at all | +| policy_rules_for_role / render_policy_toml | functions | roboco/llm/providers/gemini_cli_config.py:158 / 189 | Per-role TOML Policy Engine deny rules (`~/.gemini/policies/roboco.toml`, keyed by `toolName`/`commandPrefix`) — gemini has no CLI-flag equivalent to grok's `--disallowed-tools` either | +| render_settings_json | function | roboco/llm/providers/gemini_cli_config.py:195 | `~/.gemini/settings.json`: `security.auth.selectedType` for headless OAuth, `experimental.enableAgents=false` (fleet-wide subagent ban), `advanced.autoConfigureMemory=false` | +| gemini_cli_args | function | roboco/llm/providers/gemini_cli_config.py:225 | `--approval-mode yolo` (universal headless auto-approval) + `--max-turns` cap | +| write_gemini_memory | function | roboco/llm/providers/gemini_cli_config.py:248 | Writes the composed role blueprint as `GEMINI.md` | +| gemini_cli_config.main | function | roboco/llm/providers/gemini_cli_config.py:276 | Entrypoint renderer: mcp-config → settings.json + per-role policy TOML + GEMINI.md | +| extract_model_stats / usage_and_cost (gemini) | functions | roboco/llm/providers/gemini_cli_usage.py:130 / 146 | Reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats — no session-file scraping — and prices each of the three GA models (`gemini-2.5-pro`/`-flash`/`-flash-lite`) at its own rate | +| classify_exit_code (gemini) | function | roboco/llm/providers/gemini_cli_usage.py:232 | Remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, mirroring codex/grok's park signal; exit 41 (the CLI's own auth-failure code) passes straight through | +| gemini_cli_usage.main | function | roboco/llm/providers/gemini_cli_usage.py:268 | Entrypoint: write usage.json for the run | | refresh_if_stale | function | roboco/llm/providers/grok_auth.py:307 | Mint fresh access token from refresh_token grant if expiry within skew; acquires _refresh_lock then delegates to _recheck_or_refresh to prevent concurrent double-rotation; returns fresh/refreshed/missing/no_refresh_token/failed (best-effort, never raises) | | _recheck_or_refresh | function | roboco/llm/providers/grok_auth.py:283 | Locked body of refresh_if_stale: re-load bundle + re-check staleness inside _refresh_lock, then call _do_refresh if still stale (prevents concurrent double-rotation of the single-use refresh grant, #94) | | _atomic_write | function | roboco/llm/providers/grok_auth.py:138 | Rewrite auth.json atomically (tmp+replace) with direct-write fallback so a rotated single-use refresh_token is never lost (F006) | @@ -147,6 +169,12 @@ Interactive intake (prompter) and secretary containers run intake_main/secretary The streaming.py callback is set once at bootstrap (websocket_bridge) and invoked by the agent reasoning path to broadcast chunks to /ws/agents/{id}. transcript_retention.select_prunable_transcripts is invoked by the orchestrator's cleanup loop against ~/.claude/projects to delete agent-owned old transcripts (never the operator's own session dirs). +**Codex spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.OPENAI` → `CodexCliProvider`. Spawn mounts the host `~/.codex` directory (from a one-time `codex login`, `ROBOCO_HOST_CODEX_DIR`) read-only; the entrypoint symlinks `~/.codex/auth.json` from that RO mount while codex's own writable state (`config.toml`, `rules/`, `sessions/`) lives in the image's own `~/.codex`, so the orchestrator's atomic tmp+rename refresh can still reach the credential without a single-file-mount inode pin. `roboco/llm/providers/codex_auth.py` runs the orchestrator-side refresh loop (`refresh_if_stale`, mirroring `grok_auth.py`): the access token is a JWT whose `exp` claim is the ONLY expiry signal, and the refresh-token grant is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern that protects grok's rotation from a concurrent double-burn. Tool scoping is a per-role `--sandbox` level (`workspace-write` for developer, `read-only` otherwise) plus one shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s) instead of a `--disallowed-tools` flag; the composed role blueprint is prepended to the task prompt itself (no verified system-prompt-file mechanism). Codex has no exit-code taxonomy (every failure exits 1) — `codex_cli_sniff.py` classifies a run's terminal state from ONLY the structured `error.message` JSONL field + stderr, never the model's own transcript. `codex_cli_usage.py` sums `turn.completed` events into a real four-bucket (input/output/cache-read/cache-write) usage split. + +**Gemini spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.GEMINI` → `GeminiCliProvider`. Spawn stages the host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) read-only, then the entrypoint COPIES it into a container-local writable `~/.gemini` so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy — Google's refresh token is REUSABLE (unlike grok's single-use one), so each container refreshing its own copy independently is safe with NO orchestrator refresh daemon for Gemini at all (a deliberate contrast the module docstring spells out against grok/codex). Tool scoping is expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`experimental.enableAgents=false` fleet-wide subagent ban); `--approval-mode yolo` is universal headless auto-approval. `gemini_cli_usage.py` reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats (no session-file scraping), prices each of the three GA models at its own rate, and remaps a quota/rate-limit error (parsed from the run's JSON `error.type`, no dedicated CLI exit code) to exit 75 — exit 41 (the CLI's own auth-failure code) passes straight through. Both Codex and Gemini are one-shot delivery-role runtimes only in this release — no interactive Intake/Secretary support (only Claude and Grok drive those chats). + +**Interactive-role exemption (post-finale sweep, #661).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex or Gemini, since neither provider supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. + ## Mermaid ```mermaid graph TD @@ -297,6 +325,11 @@ runtime-providers - ROBOCO_GROK_TURN_TIMEOUT_SECONDS (grok_cli_session per-turn watchdog, default 600) - ROBOCO_PROMPTER_SESSION_ID / ROBOCO_SECRETARY_SESSION_ID / ROBOCO_WORKSPACE / CLAUDE_CODE_SUBAGENT_MODEL (intake/secretary entrypoints) - ROBOCO_AGENT_ROLE / ROBOCO_AGENT_TOKEN (secretary_driver HMAC auth, server, grok_cli_session role resolve) +- ROBOCO_HOST_CODEX_DIR (codex.py host `~/.codex` mount source, from a one-time `codex login`) +- ROBOCO_CODEX_CLI_MODEL (default `gpt-5.3-codex` — codex has no reliable default) +- CODEX_HOME (codex_auth.default_auth_path) +- ROBOCO_HOST_GEMINI_DIR (gemini.py host `~/.gemini` staging mount source, from a one-time interactive `gemini` login) +- ROBOCO_GEMINI_CLI_MODEL (pins one of the three GA ids: `gemini-2.5-pro`/`-flash`/`-flash-lite`) ## Gotchas @@ -316,6 +349,11 @@ runtime-providers - grok_cli_config: --always-approve is REQUIRED for headless runs (without it grok cancels the first tool call). Safety holds via --disallowed-tools (removes tools) + --deny (hard-blocks patterns) regardless of approval. - grok_cli_config denies git mutation via native --deny (graceful, run continues) but exfil categories via the bash-guard PreToolUse hook (which CANCELS the run on deny) — deliberate split: a reflexive git op must not drop a task, but an exfil attempt should hard-cancel. - grok -p ignores -s (requested session id); the real session id is read back from the run log's end event (session_id_from_run_log) and reused via -r on subsequent turns to persist conversation context. +- codex's RO host mount is the DIRECTORY (like grok's, unlike a naive single-file mount) for the same reason: a single-file bind mount pins the inode, so the orchestrator's atomic tmp+rename refresh would never reach a running container. The Codex CLI's own writable state (config.toml, rules/, sessions/) is NOT on that RO mount — it lives in the image's own `~/.codex`, so only `auth.json` is symlinked from the RO side. +- codex's refresh token is single-use (like grok's) — `codex_auth.py`'s process-wide lock + re-check-inside-the-lock mirrors `grok_auth._recheck_or_refresh` exactly to prevent a concurrent double-burn of the same grant. +- gemini's refresh token is REUSABLE (unlike grok/codex) — this is the one structural asymmetry in the whole provider family: no orchestrator-side refresh daemon exists for Gemini at all, by design, because each container can safely refresh its own local copy independently without a single writer serializing rotation. +- codex has NO exit-code taxonomy — every failure exits 1, so `codex_cli_sniff.py` must classify from structured JSONL `error.message` fields only; it deliberately never inspects the model's own transcript text, since this repo's own prompts legitimately use phrases like "quota-limited" that would false-positive a transcript-text classifier. +- gemini's quota/rate-limit signal has no dedicated CLI exit code either — `gemini_cli_usage.classify_exit_code` parses the run's own JSON `error.type` and remaps to exit 75 (the same park signal grok's 429 and codex's rate-limit sniff produce), so the orchestrator's park-and-probe loop treats all three providers identically at that seam despite three different underlying signals. ## Drift from CLAUDE.md @@ -333,6 +371,12 @@ runtime-providers > Post-snapshot updates (since 2026-06-29): 536bbb64 (Chore/all/logical gaps sweep #286) — grok_auth.py: added module-level `_refresh_lock = threading.Lock()` (line 48) and new `_recheck_or_refresh` helper (line 283); `refresh_if_stale` now acquires `_refresh_lock` before the grant POST and delegates to `_recheck_or_refresh` inside the lock so a concurrent caller that waited finds the already-refreshed token and returns `fresh` instead of re-POSTing the now-dead single-use refresh grant (#94). All other commits touching roboco/runtime/ in this window only modified orchestrator.py (out-of-scope for this slice). > +> **Codex CLI provider (#659, `c70ff3cf` + npm-install fix `13abb2ec`).** New `codex.py`/`codex_auth.py`/`codex_cli_config.py`/`codex_cli_usage.py`/`codex_cli_sniff.py` add `ModelProvider.OPENAI` running OpenAI's official `codex` CLI on a ChatGPT subscription — `ProviderRegistry` now registers OPENAI alongside GROK. Migration 083 seeds the provider row. The install method itself needed a follow-up fix: the CDN-hosted `install.sh` the image first tried is blocked from RoboCo's build network, so the Dockerfile installs the CLI via `npm install -g` instead (no other behavior change). +> +> **Gemini CLI provider (#660, `21d67304`).** New `gemini.py`/`gemini_cli_config.py`/`gemini_cli_usage.py` add `ModelProvider.GEMINI` running Google's official `gemini` CLI on an OAuth login — `ProviderRegistry` now registers GEMINI alongside GROK/OPENAI. Migrations 084 (enum value) / 085 (seed row) / 086 (enable the row). +> +> **Post-finale completeness sweep (#661, `d4b7e1e7`).** Closes gaps found after the Codex/Gemini rollout: one-click `apply_mode` entries + panel mode cards for both new providers (`docs/map/support-services.md`); the interactive-role exemption (GLOBAL/ROLE rows on OPENAI/GEMINI are not-applicable to `intake-1`/`secretary-1`, an explicit AGENT_SLUG pin onto either is refused loudly by the spawn guard rather than silently breaking the interactive chat driver); compose env passthrough for `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` and `ROBOCO_TASK_BUDGETS_ENABLED` (the provider host-mount dirs were already wired by the provider PRs themselves); and a `gt=0` validation tightening on the task/project budget fields (`docs/map/gateway-support.md`, `docs/map/orchestrator.md`) so a `0` budget — which would silently block everything — is rejected outright. +> > **v0.18.0** (2026-07-04): Fable mode's grok side — `fable_honesty_nudge_hook_config`/`write_grok_fable_hooks` (grok_cli_config.py:308-345), gated by `fable_mode_enabled` (default off). Deliberately narrower than the Claude path's 5 hooks: only the never-denying PostToolUse honesty-nudge is ported, because a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) — the same asymmetry this file's Gotchas section already documents for the bash-guard's git-deny-vs-exfil-cancel split. ## Regression Risks @@ -345,6 +389,8 @@ runtime-providers | /usage/sync path guard may reject legit symlinked transcripts | roboco/agent_sdk/server.py:770 | _contained_transcript_path rejects any transcript whose resolved path is not under the resolved transcript root. A legitimately symlinked transcript dir under ~/.claude/projects pointing outside (e.g. a shared NAS location) now returns HTTP 400 and usage_sync silently returns the current snapshot — token usage for that agent stops updating without a loud failure. | low | | _coerce_draft drops null/non-array the_work to [] | roboco/agent_sdk/intake_driver.py:116 | New coercion wraps non-list the_work via _coerce_to_list, which returns [] for numbers/bools/None. A draft the agent emitted mid-spec with the_work: null now arrives downstream as the_work: [] (empty list) instead of null/absent. Downstream 'has work?' presence checks that distinguish null from empty may now see an empty list and behave differently (e.g. treat an incomplete draft as a zero-work draft). | low | | Missing auth.json now logs warning but still spawns doomed container | roboco/llm/providers/grok.py:185 | When ~/.grok/auth.json is absent, _append_grok_auth_mount now only logs a warning (was: silently skipped the mount). The spawn still proceeds and the container exits 78 at the entrypoint --check. Behavior of the spawn path is unchanged, but an operator who does not tail logs will still diagnose a later exit-78; the warning is only useful if someone reads it. | low | +| codex_cli_sniff classifies from error.message text only | roboco/llm/providers/codex_cli_sniff.py:94 | Codex has no exit-code taxonomy, so every quota/rate-limit/auth distinction rests entirely on the shape of a structured JSONL `error.message` field. If a future codex CLI version changes that field's wording or moves the signal elsewhere, the sniff silently falls through to "none" and the orchestrator crash-retries straight back into the same rate limit instead of parking the provider — the same failure mode the overload-break feature exists to prevent for the other providers. | medium | +| Gemini has no orchestrator-side refresh daemon at all | roboco/llm/providers/gemini.py:135 | The reusable-refresh-token design is safe under the CURRENT assumption (each container's local copy refreshes independently, no shared writer to serialize). If Google ever changes the refresh grant to single-use (matching grok/codex), every container refreshing concurrently would race to burn the same grant with no lock protecting it, unlike grok_auth/codex_auth's process-wide lock — this provider has no equivalent safety net because none was ever needed under the current contract. | low | ## Health This slice is coherent and well-factored: the provider ABC + registry cleanly isolates the Grok backend while the Anthropic/Ollama/LOCAL paths stay on the built-in spawn (additive seam, no destabilization), and the agent_sdk sidecar centralizes budget/loop/verb-circuit/token state that hooks share. The single baseline-to-HEAD commit (15effce0) landed three genuine hardening fixes — the F006 refresh_token-loss guard with direct-write fallback, the JWT-exp decode so a refreshed token isn't forever rejected, and the /usage/sync path-traversal guard — plus the grok directory-mount fix that resolves the inode-pinning hang. The main integrity concerns are operational rather than structural: the grok directory mount widens RO exposure to host grok state, the 6h expires_at default can burn the single-use refresh_token on the rare double-miss, the in-process SDK state is lost on every container restart (by design, but means verb-circuit/budget counters reset), and ClaudeCodeProvider is dead reference code whose 'default' label in CLAUDE.md is misleading. Interactive intake/secretary parity between Claude and Grok is real (shared IntakeDriver, only the SessionFactory differs). No obviously broken logic was introduced; the regression risks are edge-case behavior shifts, not holes. Recommend re-running the grok auth refresh test against a token that omits expires_in to confirm the JWT-exp path, and a /usage/sync test with a symlinked transcript to confirm the new guard fails loud where appropriate. diff --git a/docs/map/support-services.md b/docs/map/support-services.md index 9f609f29..bab1d42f 100644 --- a/docs/map/support-services.md +++ b/docs/map/support-services.md @@ -62,12 +62,19 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro | `get_decrypted_token` | method | `services/provider.py:211` | Decrypt provider token or None; raises `EncryptionError` on bad key | | `ModelRoutingService` | class | `services/llm.py:119` | Per-agent route resolution + assignment CRUD + mode apply | | `AgentRoute` | dataclass | `services/llm.py:95` | Frozen resolved route `(provider_id, type, base_url, auth_token, model_name)`; None base_url/token = Anthropic default | -| `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path | +| `resolve_for_agent` | method | `services/llm.py:194` | Precedence ladder AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL; optional `complexity` (a task's `estimated_complexity`, lowercased) enables the compound `ROLE:complexity` rung; never raises — downgrades to legacy Anthropic path | +| `_resolve_assignment` | method | `services/llm.py:248` | Tries AGENT_SLUG first, then (when `role` AND `complexity` both given) the compound `scope_value=f"{role}:{complexity}"` ROLE row, then the plain `role` ROLE row, then GLOBAL — the compound rung reuses the existing ROLE scope + `scope_value` column, no schema change | | `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` | | `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider | -| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix | -| `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments | +| `apply_mode` | method | `services/llm.py:567` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted/codex/gemini; per-agent map for mix. `apply_mode('cost_tiered')` is the one exception — it ADDITIVELY seeds a single day-1 `developer:low` → haiku override instead of wipe-then-seed, so it composes with whatever mode is already active. | +| `derive_mode` | method | `services/llm.py:443` | Settings UI label from current assignments | | `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row | +| `_interactive_exempt` | func | `services/llm.py:176` | True when `agent_slug` is `intake-1`/`secretary-1` AND the resolved assignment routes to OPENAI/GEMINI — neither provider supports the interactive chat driver, so these two agents stay on Anthropic regardless of a fleet-wide mode switch; an explicit AGENT_SLUG pin onto either provider for these two slugs is refused loudly at the spawn/save boundary rather than silently producing a broken interactive session | +| `list_routing_presets` / `get_routing_preset` | methods | `services/llm.py:837,845` | List / fetch one saved `RoutingPresetTable` row | +| `save_routing_preset` | method | `services/llm.py:848` | Snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) into a named `RoutingPresetTable.payload` (JSONB) | +| `apply_routing_preset` | method | `services/llm.py:931` | Restore a saved preset wholesale — a full swap, validating every entry via `_validate_preset_entry` FIRST so an invalid payload never triggers the wipe (unlike a mode switch's pin-preserving behavior) | +| `_validate_preset_entry` | method | `services/llm.py:896` | Per-entry validation for `apply_routing_preset` (scope/provider/model shape) run before any row is touched | +| `delete_routing_preset` | method | `services/llm.py:886` | Delete a saved preset by id | | `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session | | `ContextPackage` | dataclass | `services/proactive.py:27` | Aggregates similar_tasks/learnings/code_patterns/standards/decisions/known_issues + summary | | `on_task_claimed` / `on_session_started` | methods | `services/proactive.py:116,201` | Best-effort multi-index search; each source wrapped in try/except | @@ -98,7 +105,9 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro ## Data Flow -**Spawn routing.** The orchestrator (`runtime/orchestrator.py:3294`) opens a DB session, calls `get_model_routing_service(db).resolve_for_agent(agent_slug)`. `ModelRoutingService` walks `model_assignments` (AGENT_SLUG → ROLE → GLOBAL), joins the `provider_configs` row, decrypts any token via `ProviderService.get_decrypted_token` (Fernet from `utils/crypto`), probes LOCAL providers via `probe_ollama_tags`, and returns an `AgentRoute`. On any failure (decrypt, unreachable server, missing row) it falls back to `_legacy_route` (role → `MODEL_MAP` short name, Anthropic, mounted `~/.claude`). The orchestrator injects `ANTHROPIC_*`/base_url/auth env into the container only when the route is non-Anthropic. +**Spawn routing.** The orchestrator (`runtime/orchestrator.py:3294`) opens a DB session, calls `get_model_routing_service(db).resolve_for_agent(agent_slug, complexity=...)` — `complexity` threads through from the spawning task's `estimated_complexity` when one is bound, letting the compound `ROLE:complexity` rung (e.g. `developer:low`) pin a cheaper model at a given complexity without touching the plain ROLE row everything else still uses. `ModelRoutingService` walks `model_assignments` (AGENT_SLUG → ROLE:complexity → ROLE → GLOBAL via `_resolve_assignment`), joins the `provider_configs` row, decrypts any token via `ProviderService.get_decrypted_token` (Fernet from `utils/crypto`), probes LOCAL providers via `probe_ollama_tags`, and returns an `AgentRoute`. `_interactive_exempt` overrides the resolved route back to Anthropic for `intake-1`/`secretary-1` whenever it would otherwise land on OPENAI/GEMINI (neither provider supports the interactive chat driver). On any failure (decrypt, unreachable server, missing row) it falls back to `_legacy_route` (role → `MODEL_MAP` short name, Anthropic, mounted `~/.claude`). The orchestrator injects `ANTHROPIC_*`/base_url/auth env into the container only when the route is non-Anthropic. + +**Routing presets.** On top of modes, an operator can `save_routing_preset(name)` to snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) into a `RoutingPresetTable` row, then `apply_routing_preset(preset_id)` later to restore it wholesale — a full swap that validates every entry via `_validate_preset_entry` BEFORE the wipe, unlike a mode switch's pin-preserving, validate-as-you-go behavior. Panel: the AI routing settings card (`ai-routing-card.tsx`) exposes preset save/apply/delete alongside the existing mode buttons and the complexity-override editor. **Settings/flags.** At FastAPI lifespan (`api/app.py:117`), `apply_persisted_feature_flags(db)` reads each `FEATURE_FLAGS` key from `system_settings` and `setattr`s the live `roboco.config.settings` singleton so the rest of the app reads panel choices; an unset key keeps the env default. The Settings panel reads effective values via `feature_flag_effective_values` and writes via `SettingsService.set` (validates → upsert → flush; route commits). diff --git a/docs/map/task-service.md b/docs/map/task-service.md index d38abda5..393af0e1 100644 --- a/docs/map/task-service.md +++ b/docs/map/task-service.md @@ -36,7 +36,10 @@ | `is_pending_claim_blocked` | method | task.py:2864 | Read-only wrapper over `_claim_blocked_by_sequencing` (dependency OR sequence) so the orchestrator dispatcher can filter a doomed claim before attempting it (`_pending_claim_blocked` in orchestrator.py). | | `stamp_wave_sequence` | method | task.py:7452 | Stamps a freshly delegated subtask's `sequence` as `1 + max(sequence of each same-parent dependency target)`, or `0` when independent — so independent siblings tie (parallel under the sequence gate) while colliding/ordered work ascends. Runs POST-wiring (after the collision DAG / cross-cell edges land); PM-authored sequences are never rewritten. | | `_apply_dependency_lineage` / `_merge_one_dependency` | method | task.py:2308 / 2337 | Claim-time content assist (not a gate): merges each same-repo dependency's landed work into a freshly cut branch when it lies outside the branch's own ancestor chain (`GitService.merge_dependency_lineage`); a real conflict aborts the merge and stamps a `dependency_lineage_conflict` transition note instead of failing the claim. | -| `_finalize_claim` | method | task.py:2925 | Work-session create/inherit, branch cut, proactive-context injection. | +| `_finalize_claim` | method | task.py:3265 | Work-session create/inherit, branch cut, proactive-context injection; calls `_inherit_upstream_base` when `_should_inherit_base` says the reclaimed branch may need an upstream merge. | +| `_should_inherit_base` | function | task.py:404 | Pure double gate for base inheritance on a re-claim: role must WORK the branch (developer/cell_pm/main_pm — QA/documenter/gate claims review the branch as pushed and never move it) AND pre-claim status must be pending/needs_revision (never a PM's `i_will_plan` re-claim of its own `awaiting_pm_review` task, which already passed review on that branch). | +| `_inherit_upstream_base` | method | task.py:2640 | Reuses the dependency-lineage merge to fold the advanced parent/base branch into a pre-existing task branch on re-claim: already-ancestor is a no-op, a clean merge logs an audit trail, a real conflict aborts at the cut point and appends a `base_inheritance_conflict` transition note + dev-note steering the assignee to `sync_branch` — never fails the claim itself. | +| `_note_base_inheritance_conflict` / `_append_base_inheritance_dev_note` | methods | task.py:2708 / 2736 | Log + record the conflict as a transition note, then surface it to the assignee via `dev_notes`. | | `_inject_proactive_context` | method | task.py:3154 | Briefing injection at claim (institutional memory when `org_memory_enabled`). | | `_completion_learnings_for` | method | task.py:2798 | Distill one lesson (ON) vs legacy raw capture (OFF). | | `_extract_completion_learnings` | method | task.py:2837 | Fire-and-forget learning record + RAG indexing. | @@ -142,6 +145,7 @@ stateDiagram-v2 - `_apply_dependency_lineage` is scoped to SAME-REPO dependencies only (`dep_task.project_id != ctx.project.id` short-circuits) — a cross-repo dependency edge (e.g. a MegaTask root-subtask in another project) has no shared git history to merge and is silently skipped; the dependency TIMING gate still holds the claim regardless of repo. - `TaskTable.orchestration_markers` is generic `JSON`, not `JSONB`. Any SQL predicate on a marker key must use `.as_string()` (or the JSON dialect's generic comparator), not `.astext`, which is JSONB-only and raises `AttributeError` at compile time. `list_open_docs_sync_tasks(version=...)` at task.py:1596 is the current example; the inline comment records the rationale. - Both cancel and terminal-completion now force-delete (`-D`) the task's LOCAL branch ref in the assignee's clone alongside the worktree — a completed task's PR was squash-merged (its local ref is never an ancestor of base, so a "safe" `-d` refuses unconditionally) and a cancelled task's work is discarded by decision, so the ref is spent either way. Skipped when the branch name coincides with an environment-ladder rung (`effective_environments`), which outlives any one task. +- Upstream-base inheritance on re-claim (`_finalize_claim` → `_should_inherit_base` → `_inherit_upstream_base`) reuses the SAME merge primitive as `_apply_dependency_lineage` (`GitService.merge_dependency_lineage`), just pointed at the branch's own parent instead of a `dependency_ids` entry — it is a content assist, not a gate, so a merge conflict never fails the claim, only appends a note. It is double-gated (role AND pre-claim status) precisely so a QA/doc claim, or a PM re-claiming its own already-reviewed `awaiting_pm_review` task, never silently moves a branch that has already passed review. ## Drift from CLAUDE.md - CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale. @@ -158,6 +162,8 @@ stateDiagram-v2 > (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `_audit_events_for` (task.py:997) gains `task.request_changes` (agent_role `cell_pm`/`main_pm`) and `task.ceo_reject` (agent_role `ceo`) branches alongside the existing `task.qa_fail`/`task.pr_fail`; `ceo_reject` gains reason validation + a ledger `Finding` insert (see above); `qa_fail` and `request_changes` drop their raw `dev_notes` appends (the mirror-column data-loss bug) in favor of the ledger + a structured note. Full detail: `docs/map/review-findings.md`. > > (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Local branch refs stop leaking: `_delete_task_branch_best_effort`/`_remove_task_worktree_on_terminal` now also force-delete the assignee's local branch ref (via new `WorkspaceService.delete_local_branch`) and rmtree the task's `.previews/{task8}` video-preview dir, both skipped for environment-ladder rungs. See `docs/map/worksession-git.md` for the paired `GitService.cleanup_stale_branches` sweep. +> +> `2e889c70` (#644, "work re-claims inherit an advanced upstream base"): a re-claim (pool release / reaper unclaim / escalation redirect / a dev's own `i_will_work_on` re-entry) reused whatever branch had been cut at the EARLIER claim, so any work that landed on the parent/base branch since then (e.g. a sibling cell merging into root, or UX/UI landing before BE/FE re-claims) never reached the reclaimed branch — silent divergence, avoidable conflicts at PR time. `_finalize_claim` now calls `_inherit_upstream_base` after resolving the work session, gated by the double check above; a conflict never fails the claim, it steers the dev to `sync_branch` via a transition note + `dev_notes`. ## Regression Risks diff --git a/docs/map/tests.md b/docs/map/tests.md index da33cb11..d947051b 100644 --- a/docs/map/tests.md +++ b/docs/map/tests.md @@ -28,6 +28,7 @@ The pytest test suite for RoboCo: 571 test_*.py files across tests/foundation, t | tests/e2e_smoke/harness.py | E2E harness: E2EStack app + orchestrator client + per-test agent manifests; used by the e2e_smoke tier | ~520 | | tests/e2e_smoke/test_gitea_live.py | Live-Gitea contract suite for `GiteaProvider` — fully self-seeding (creates its own uniquely-named repo, pushes real commits) against a real Gitea instance; skipped unless `ROBOCO_GITEA_E2E_URL`/`ROBOCO_GITEA_E2E_TOKEN` are both set; exercises PR open → duplicate-409→422 reshape → list/filter → diff → comment review → commit-status CI reshape → squash merge → branch delete → release, plus the git-CLI Basic-auth extraheader claim | 250 | | tests/e2e_smoke/test_gitlab_live.py | Live-GitLab contract suite for `GitLabProvider` — mirrors `test_gitea_live.py`, self-seeding against a real GitLab instance (gitlab.com works, project deleted afterward best-effort); skipped unless `ROBOCO_GITLAB_E2E_URL`/`ROBOCO_GITLAB_E2E_TOKEN` are both set; exercises MR open → duplicate reshape → GitHub-shape adaptation → diff reassembly → note review → commit-status CI reshape → squash merge → branch delete → release → the oauth2 Basic-auth git-CLI claim | 265 | +| roboco/eval/ (`fixtures.py`/`runner.py`/`__main__.py`) | Golden-task offline CLI bench harness (source-checkout-only, NOT a pytest suite) — replays fixed `BenchTaskSpec` fixtures through the real delivery lifecycle in a disposable environment REUSED from `tests/e2e_smoke/harness.py` (fake GitHub REST, a real local git origin, a throwaway DB), scoring on deterministic metrics + a local-model judge. `python -m roboco.eval run` is wired but not functional this release — `OrchestratorStageSpawner` raises `NotImplementedError` at construction (a real spawn would resolve to the production orchestrator under real agent UUIDs, unsafe for a bench run); the only working path today is driving `EvalRunner` with an injected scripted `StageSpawner` from Python. Scoped to developer-role fixtures only; only runs from a source checkout. See `tests/e2e_smoke/test_eval_bench.py`. | — | ## E2E smoke harness @@ -244,6 +245,7 @@ tests/ > - **1f129199** auditor-trigger e2e smoke test: added `tests/e2e_smoke/test_auditor_triggers.py` exercising scheduled sweep and reactive QA-fail alert paths end-to-end, plus harness mount of `/api/notifications` so `_dispatch_audit_work` can poll ALERT rows. > - **babffe0a** fix(e2e_smoke): repair auditor-trigger smoke tests and harden harness (#498): fixed `tests/e2e_smoke/test_auditor_triggers.py` so scheduled/reactive auditor-trigger tests reach their spawn assertions, hardened `tests/conftest.py` to tolerate missing pgvector, and cleared leaked `ROBOCO_AGENT_TOKEN` in `tests/e2e_smoke/harness.py` before scripted agents load `flow_server`. See the E2E smoke harness section above for the exact patterns. > - **f081a574** (PR #502, 2026-07-13): Follow-up e2e lifecycle smoke fix. Restored the `ROBOCO_AGENT_TOKEN` pop in `tests/e2e_smoke/harness.py:ScriptedAgent._module` after it was accidentally removed, and clarified the `/api/notifications` mount comment so it no longer implies the router was newly added. The orchestrator `__new__` pre-init from `89b68786` means `_fresh_orchestrator` no longer needs to manually set `_instances`. +> - **`10f039c3`** (#655, "golden-task eval harness + doctrine cohort stamp"): adds `roboco/eval/` (see Files above), a bench harness reusing `tests/e2e_smoke/harness.py`'s disposable environment rather than a mock — real isolation. `agent_spawn_sessions.doctrine_version` (migration 081) is stamped at spawn-session finalize from the composed prompt layers, so a cohort's model+doctrine combination (e.g. Fable-mode on vs. off) is durably identifiable after the fact — see `docs/map/db-migrations.md`. Bench runs also patch every vault flag off so a bench task/note/journal write never lands in the operator's real Obsidian vault. > - **Forge providers, 2026-07-18/19 (#569/#571/#575/#579/#581)**: adds `tests/unit/services/forge/` (`test_gitea_provider.py` 297 lines/17 tests, `test_gitlab_provider.py` 713 lines/33 tests, `test_router.py` 90 lines/9 tests — all mocked-transport) + `tests/unit/foundation/policy/test_forge.py` (132 lines/23 tests, `extract_host`/`detect_provider`/`validate_project_forge`) + the live-forge contract suites `test_gitea_live.py`/`test_gitlab_live.py` (see the "Live-forge contract suites" section above). See `docs/map/worksession-git.md` for the forge package itself. ## Regression Risks diff --git a/docs/map/video-engine.md b/docs/map/video-engine.md index 04a20bc7..5c8a5ec1 100644 --- a/docs/map/video-engine.md +++ b/docs/map/video-engine.md @@ -17,6 +17,7 @@ The RoboCo video engine: a default-off subsystem that authors bespoke short mark | `roboco/services/gateway/content_actions.py` `propose_video` | Server-side action: team-gated (`_caller_team` rejects be-dev/fe-dev), resolves the caller's open video task, `markers.set_video_draft` with the metadata. | — | | `roboco/services/gateway/content_actions.py` `request_render` | Do-verb (developer/QA): renders the caller's ACTUAL composition to keyframe PNGs via the sidecar's frames mode and stamps the `render_preview` marker — dev renders their own tree (worktree-aware, `head_sha`/`dirty` stamped), QA a read-only branch export (`WorkspaceService.export_branch_motion`). Frames land at the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`. | — | | `roboco/foundation/policy/tracing.py` `RENDER_VERIFIED` | `i_am_done` requirement on `source=video` tasks: no stamped `render_preview` → tracing gap naming `render_preview` (hint: call `request_render`, Read every frame). Mirrored in the possibilities-matrix fast path. | — | +| `roboco/api/routes/video.py` `GET /video/preview-frames/{task_id}` / `.../{orientation}/{filename}` | CEO-gated routes serving the dev's already-rendered `request_render` preview frames — a `source=video` authoring task reaches `awaiting_ceo_approval` with no MP4 yet (rendering only happens post-completion), so the CEO otherwise has nothing to review. Frame listing is parsed from the self-describing `.previews/{task8}/{orientation}/frame-<idx>-of-<n>-at-<t>s.png` filenames (`_FRAME_NAME_RE`) rather than the `render_preview` marker (which only holds the LAST call's single orientation); the per-frame route streams a PNG behind the same `_resolve_preview_path` confinement guard the composition-HTML proxy already used. | — | | `alembic/versions/062_tiktok_credentials.py` | Migration 062 — the `tiktok_credentials` singleton row (Fernet-encrypted OAuth2 secrets, all-or-nothing set/clear, mirroring the git-token / `x_credentials` pattern). | 44 | | `video-renderer/` | The sidecar: `server.js` (HTTP+tarball boundary), `render.js` (`@hyperframes/producer` `createRenderJob` + `executeRenderJob`, system `ffmpeg`, headless Chromium). Credential-free and git-free — reads only what's POSTed. pnpm-managed (`pnpm-lock.yaml`, no npm `package-lock.json`); `@hyperframes/producer` pinned exact at `0.7.36` (`0.7.60` fails every render). | — | | `docker/video-renderer.Dockerfile` | Sidecar image (`roboco-video-renderer`): Node + Chromium + system `ffmpeg`; installs `@hyperframes/producer`. No RoboCo source, no creds. | — | @@ -45,6 +46,7 @@ CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET ## Changes Since Baseline +- **2026-07-20** (#608, `a5d8c6bd`, "CEO can preview a video authoring task's frames before approving"): two CEO-gated routes serve the `request_render` preview frames (see Files above) so the CEO has something concrete to review at `awaiting_ceo_approval` before any MP4 exists. The task-detail Overview gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame stepper with composition id, duration, and a dirty badge — shown for a video task with preview frames or awaiting CEO approval. - **2026-07-17** (PR #543, `3e801697`): Two renderer root causes fixed — `@hyperframes/producer` was floating (`^0.7.36`, no lockfile), so image builds silently picked up `0.7.60`, which fails EVERY render ("Cannot access 'rt' before initialization"); pinned exact (`0.7.36`, no caret) and committed a lockfile (regenerated as `pnpm-lock.yaml` by the immediate follow-up `a12fefcb`, not the npm `package-lock.json` this PR first wrote — this package is pnpm-managed). Second: the producer's per-clip visibility scheduler runs on a clock that lags ~50% behind the encoded timeline on a long cut, so tail scenes (past roughly the halfway mark) were silently missing from the MP4 regardless of authoring — fixed by treating `class="clip"` + `data-start`/`data-duration` as a structural-layer-only primitive and driving every beat with base-hidden styles + a delayed CSS animation instead (documented in `motion/README.md`'s "Clip windows are for structural layers only" rule). Also added the two choreography engines to `motion/kit/kit.js` (`choreographCursor` / `choreographCamera`, see Files above) plus a "Cinematography & rhythm" section in `motion/README.md` and a craft-bar block in the dev video spawn prompt (`roboco/runtime/orchestrator.py`) so a locked-off camera or a popping/freezing cursor reads as an automatic revision. - **2026-07-17** (PR #544, `fd621f0d`): The three craft capabilities wired one hop closer to the hands doing video work — vendored the vendor's own official HyperFrames agent skills (`hyperframes-core`/`-creative`/`-keyframes.md`, see Files above; supersedes the external-pointer-only version briefly added by the intervening `1416bd1d`); registered the `playwright` MCP for a ux-dev spawned onto a `source=video` task (`_is_video_authoring_spawn`, fail-closed role/team/task-source probe — gating-only, `agent-ux`'s image already bakes the browser); and added a video-mode override to the `ux_ui` team prompt's design bar ("video-authoring tasks are FILMS, not UI — these dials do not apply") so a video task no longer reads its own "dense product UI → motion 2-3" dial as license to ship a static slideshow. - **2026-07-17** (Wave 6, PR #550): Authoring craft, not engine code — `motion/README.md` gained `## Visual design bar (demo/kit register)` (spacing/hierarchy, beat pacing, `pk-chip`/`pk-pill` semantic discipline, camera+cursor+rhythm, anti-generic tells for the `kit/` register), four upstream HyperFrames craft references vendored verbatim under `motion/skills/references/` (fixing `hyperframes-creative.md`'s previously-dead `references/` pointers), and a new `motion/skills/hyperframes-catalog-index.md` (133-entry HyperFrames catalog vocabulary index, read-on-demand). No service/verb/schema change; the render/post pipeline documented above is untouched. diff --git a/docs/map/worksession-git.md b/docs/map/worksession-git.md index f530fe2c..65b716c2 100644 --- a/docs/map/worksession-git.md +++ b/docs/map/worksession-git.md @@ -70,7 +70,9 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService.create_branch` | method | git.py:989 | Build branch name, fetch base, `worktree add` (F123), push -u | | `GitService.create_branch_for_task` | method | git.py:1188 | Resolve workspace/team, create branch, commit DB | | `GitService.checkout_branch_for_agent` | method | git.py:1276 | Allowlist-bounded checkout for agent verb | -| `GitService.push_for_task` | method | git.py:1428 | Push the task's recorded branch by name (clone-checkout-independent) | +| `GitService.push_for_task` | method | git.py:1428 | Push the task's recorded branch by name (clone-checkout-independent); runs `_run_codegen_and_commit` first so any codegen drift lands in the SAME push | +| `GitService._codegen_command_for` | staticmethod | git.py:4397 | The project's `codegen_command` (nullable string column, migration 078), or `None` — a bare `MagicMock` test double auto-vivifies attributes, so the `isinstance` check also keeps unrelated tests inert | +| `GitService._run_codegen_and_commit` | method | git.py:4474 | Runs the project's `codegen_command` in the task's worktree right before push (hooked at both `push_for_task`/`push_branch` at git.py:1886 and `push_task_branch` at git.py:4689); fail-open (a broken/timeout command logs a warning and lets the push proceed — CI's own drift gate is the safety net) and a no-op for the common case of no declared command; a clean run with actual drift stages + commits it under the task's normal commit format before the push carries it | | `GitService.push_task_branch` | method | git.py:1467 | Gateway branch-keyed push | | `GitService.create_pull_request` | method | git.py:2132 | Open PR via GitHub API (legacy project-scoped) | | `GitService.create_pr_for_task` | method | git.py:2639 | Agent-facing open_pr verb | @@ -96,7 +98,9 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService.sync_task_branch` | method | git.py:3847 | Task-keyed rebase through dev `sync_branch` verb (pre-PR) | | `GitService.is_behind_base` | method | git.py:3889 | `(behind, ahead)` counts for i_am_done submit gate | | `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) | -| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) | +| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches, UNIONED with `_protected_branches_for_deletion(project_slug)` — the shared chokepoint every remote-delete path (task cleanup, PR-merge/close cleanup, the stale-branch sweep) routes through; returns `bool` (issued vs skipped/failed) | +| `GitService._protected_branches_for` | method | git.py:1183 | The project's own declared `protected_branches`, normalized (stripped, deduped into a `frozenset`) and unioned into the hardcoded `{master, main}` floor for rebase refusal (`rebase_onto_base`) and `sync_task_branch`'s force-push refusal; fails OPEN (empty set, logs a warning) on a project-lookup error — a wrongly-blocked rebase over a transient DB blip is the worse tradeoff | +| `GitService._protected_branches_for_deletion` | method | git.py:1219 | Deletion-only superset of `_protected_branches_for`: the declared field UNIONED with the project's env-ladder rung branches (`effective_environments` — a null ladder's synthesized single rung off `default_branch` is protected too, so a renamed trunk like `trunk` is covered even with no declared list); fails CLOSED (returns the hardcoded floor only, on a lookup error) since a skipped delete is free (next sweep retries) but silently deleting a real declared rung on an unresolvable project is not | | `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` | | `GitService.close_task_pr_best_effort` | method | git.py:3655 | No-clone-needed cancel-path cleanup: resolves the project token + `git_url`→`RepoRef`, fetches the PR via `self._forge.get_pr`, no-ops unless `state=="open"`, else `update_pr(payload={"state":"closed"})`; every failure path (missing token/project, unparseable URL, `httpx.HTTPError`) returns `False` rather than raising | | `GitService.cleanup_stale_branches` | method | git.py:3697 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable | @@ -307,6 +311,8 @@ Module-level tunables (not env): `_SLOW_GIT_OP_MS=5000`, `_CI_RUN_WINDOW=20`, `_ ## Gotchas +- **`protected_branches` is exact-match, case-sensitive, and additive-only**: an operator-declared list (panel: chips editor in the edit-project dialog) is UNIONED into — never replaces — the hardcoded `{master, main}` floor. The two consuming scopes deliberately fail differently on a project-lookup error: `_protected_branches_for` (rebase + `sync_task_branch` force-push refusal) fails OPEN to the hardcoded floor (a skipped rebase gets no free retry, so refusing wrongly is worse); `_protected_branches_for_deletion` (the shared remote-delete chokepoint) fails CLOSED (a skipped delete just retries at the next sweep, so silently proceeding on an unresolvable project is worse). An empty `protected_branches` list degrades to exactly the prior hardcoded-only behavior. +- **Token resolution is GitHub-App-first, PAT-fallback**: `ProjectService._resolve_token` (`roboco/services/project.py`, called from `GitService._token_for_project` → `get_decrypted_token_by_slug`) mints a short-lived installation token via `github_app_auth.mint_installation_token` when `project.github_installation_id` is set AND App credentials are stored; ANY minting failure (App not configured, installation revoked, network hiccup, the JWT/HTTP/parse path all wrapped so nothing escapes uncaught) falls back to the stored PAT instead of raising — a GitHub App outage never bricks git ops for an App-bound project that also has a PAT on file. `github_app_credentials.py` mirrors the `x_credentials`/`telegram_credentials` singleton-Fernet-row pattern (migration 077); the PEM is validated at credential-SET time (not first-mint) and the installation-token cache clears when credentials are deleted. - **Token transport split**: git-over-HTTPS uses HTTP **Basic** (`x-access-token:...`) via `http.extraheader` (git.py:289); the GitHub REST API uses **Bearer**. Swapping them causes silent credential-prompt failures. - **`pr_number` is NOT repo-scoped in `tasks.pr_number`** — every gateway merge/close path (`pr_merge`, `close_pull_request`, `pr_target`, `rebase_pr_for_task`) requires `project_id` to scope the task lookup. The route `merge_pr_for_task` instead verifies `data.pr_number == task.pr_number` (recorded PR is source of truth). - **`get_active_for_task` returns the most-recent ACTIVE row**, not `scalar_one_or_none` — the historical duplicate-ACTIVE defect would otherwise raise `MultipleResultsFound`. The invariant is also enforced at `create` and by a DB partial-unique index (migration 047). @@ -351,6 +357,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr > > **Forge providers — GitHub + Gitea + GitLab (2026-07-18/19, PRs #569/#571/#575/#579/#581).** A new `roboco/services/forge/` package (`base.py`/`github.py`/`gitea.py`/`gitlab.py`/`registry.py`/`router.py`/`shaping.py`) plus `roboco/foundation/policy/forge.py` route every REST call `GitService` makes (PRs/CI/reviews/labels/releases/provisioning) through a provider-agnostic transport. `388bab24` (Phase 0, #569): `projects.git_provider` column (migration 076, nullable, plain string not a pg enum — validated at the service layer, not the DB) + `validate_project_forge`/`detect_provider` (github.com auto-detects, self-hosted needs an explicit column value — the GHE/self-hosted escape hatch). `461a6e1a` (Phase 1, #571): the `GitProvider` ABC + `GitHubProvider` extracted byte-for-byte from `GitService`'s old inline `httpx` calls; `GitService._forge` (git.py:399) becomes the seam every call site routes through. `96401f4c` (Phases 2/2.1/3, #575): `GiteaProvider` + `GitLabProvider` + `ForgeRouter` (per-call transport dispatch off `RepoRef.host`) + the local-git `merge_branch` fallback for forges with no server-side merges API. `5f32d876` (Phase 4, #581): `roboco/services/github_provisioning.py` becomes provider-aware (`ROBOCO_PROVISIONING_PROVIDER`/`ROBOCO_PROVISIONING_HOST` — see `docs/map/product-strategy-research-pitch.md`) so pitch-driven repo creation works on all three forges. `d4cb5797` (#579) + the pre-existing `tests/e2e_smoke/test_gitea_live.py` are the live contract suites (self-seeding against a dockerized `gitea/gitea` / real `gitlab.com`, env-gated) that caught the slash-encoding and http-scheme gaps in the Gitea provider. Panel: the edit-project dialog's "Forge" `<Select>` (`edit-project-dialog.tsx`) offers Auto-detect/GitHub/Gitea/GitLab. > +> **Protected branches + env-ladder deletion protection (#649, #651).** `GitService._protected_branches_for`/`_protected_branches_for_deletion` (git.py:1183/1219) consult the new `projects.protected_branches` column end-to-end for the first time (the column previously existed with nothing reading it): the deletion scope additionally unions in the project's env-ladder rung branches (`effective_environments`), closing the gap where the post-merge PR-source cleanup and the stale-branch sweep could still delete a branch that IS a ladder rung — `delete_task_branch`'s own local rung check is now exactly subsumed and removed. Panel: a chips editor on the edit-project dialog. +> +> **GitHub App credentials + auto-regenerated codegen drift (#621/#633/5ca8a9c4, #632).** `roboco/services/github_app_auth.py`/`github_app_credentials.py` (new files) add a singleton Fernet-encrypted App id + private key (migration 077) and RS256 JWT + cached-installation-token minting; `ProjectTable.github_installation_id` lets a project bind to an installation (create-dialog repo picker, or an existing PAT project via the edit dialog's Unbind/re-bind section) with PAT fallback on any mint failure (see Gotchas). Independently, `GitService._run_codegen_and_commit` (git.py:4474, migration 078's `projects.codegen_command`) runs a per-project codegen command in the task's worktree right before every push and commits any resulting drift into the SAME push — RoboCo's own `codegen_command='make codegen'` keeps its checked-in lifecycle renders/verb tables from drifting invisibly past the agent's own gate (which omits `foundation-check`) until CI's separate drift gate catches it with no link back to the task. +> +> **Branch-list route fix (#610).** `roboco/api/routes/git.py`'s `list_branches` classified a remote-tracking ref by a literal `'remotes/'` prefix that `git for-each-ref --format=%(refname:short)` never emits, so every `origin/*` ref (including the symbolic `origin/HEAD`) rendered as a fake LOCAL branch. Listing now reads the full `%(refname)` and `_parse_branch_line` classifies on `refs/heads/` vs `refs/remotes/` (dropping `origin/HEAD`); the route also runs a best-effort `git remote prune origin` before listing so a branch already deleted upstream stops persisting in the panel's Git page forever, and the manual Fetch action now fetches with `--prune`. +> > **PR label base-branch agnosticism + cancel-path PR close (2026-07-18/19, "agnosticism-residue" + "cancel-pr-dependents-guard").** `derive_pr_labels` gained a required `base_branch: str` keyword-only param; the label is now `f"to {base_branch}"` (the PR's real resolved target) instead of `"to master" if is_root_pr else "to slave"` — a prerequisite for the env-branches ladder and non-GitHub forges, where "master"/"slave" aren't universal. All three `GitService` call sites (`_labels_for_pr_request`, the assembled cell→root/root→master PR opener) now thread the real resolved base branch through. Separately, `GitService.close_task_pr_best_effort` (git.py:3655) closes a task's forge PR on cancel — `TaskService._close_task_pr_best_effort` (task.py:6767) calls it once per cascaded descendant and once for the cancelled task itself, alongside the existing `_delete_task_branch_best_effort` — closing a gap where a cancelled task's branch was force-deleted but its open PR lingered on the forge forever. The same change adds `GitService._live_task_dependents` (git.py:3810) so `cleanup_stale_branches` never deletes a terminal task's branch while a non-terminal task still records it, or is a direct child of it (whose future PR base would resolve to that branch before it even opens a PR). ## Regression Risks diff --git a/docs/rag/architecture/http-security-guard.md b/docs/rag/architecture/http-security-guard.md index b781db43..6a7aa1b0 100644 --- a/docs/rag/architecture/http-security-guard.md +++ b/docs/rag/architecture/http-security-guard.md @@ -42,3 +42,9 @@ The three custom validators above are not covered by that exclusion — they sca ## Scanner Auto-Ban A separate layer targets automated scanners (not agents — agents run on Docker-internal IPs). Repeated probes to scanner fingerprints on `/api` paths (`recon`/`sensitive_file`/`cms_probing` categories) trip a per-IP auto-ban in active mode, and nginx drops the classic root scanner paths (`/.env`, `/wp-login.php`, `/.git/config`, …) at the edge with `444`. This does not affect legitimate agent traffic to the gateway verbs. + +## Internal Agent Mesh Exemption + +Agents reach the orchestrator DIRECTLY on the docker bridge (no nginx hop), HMAC-authenticated — the guard's WAF/IP-ban/rate-limit is meant for the EXTERNAL attack surface arriving through nginx, not for that already-authenticated internal traffic. A `whitelist` of loopback (`127.0.0.1`/`::1`) plus docker's default bridge address-pool range (`172.16.0.0/12`) skips WAF/ban/rate-limit checks entirely for requests from those addresses — without it, an ordinary journal/note body tripping a WAF signature would IP-ban the whole agent container, wedging every subsequent verb call (`dm`, `i_am_idle`, ...) behind it. + +This whitelist is deliberately narrow — NOT the full RFC1918 range. `10.0.0.0/8` and `192.168.0.0/16` are excluded on purpose: those also cover any real LAN client hitting nginx, not just the docker mesh, and with `trusted_proxy_depth=1` a genuine LAN browser's real IP survives the one XFF hop, so including them would let real external traffic skip the WAF right alongside agent traffic. A known ceiling remains: this can't distinguish a real docker-bridge peer from host-loopback/NAT'd traffic landing on the same address family, so a host-proxied chain (e.g. Tailscale Serve terminating on the host before nginx) can still resolve into this range and ride the exemption — see `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` above for the separate mechanism that scopes that specific shape. diff --git a/docs/rag/architecture/x-engine.md b/docs/rag/architecture/x-engine.md index 46acd55a..25661ea0 100644 --- a/docs/rag/architecture/x-engine.md +++ b/docs/rag/architecture/x-engine.md @@ -22,7 +22,7 @@ Both are bounded by `ROBOCO_X_MAX_OPEN_POSTS` (rolling cap on concurrently-open ## Drafting -Draft bodies are written by a **local-model** chat call (`_chat`, hitting `ROBOCO_LOCAL_LLM_BASE_URL` — never a cloud LLM in the hot path), in a fixed Head-of-Marketing voice prompt, then hard-clamped to 280 characters (`_clamp_tweet`). A local-model failure falls back to a plain template body rather than failing the draft outright. Drafting is **not** an agent spawn — no agent (including Head of Marketing) is spawned to write these; see `docs/rag/roles/head-marketing.md` for why the HoM's tool surface doesn't change. +Draft bodies are written by a **local-model** chat call (`_chat`, hitting `ROBOCO_LOCAL_LLM_BASE_URL` — never a cloud LLM in the hot path), in a full Head-of-Marketing voice prompt (the reasoning-backed VOICE GUIDE plus a banned-word/AI-slop list and style exemplars — no em dashes, no "game-changer"/"seamless"/etc., no exclamation pileups, aimed well under 240 characters so the 280 clamp never truncates mid-sentence), then hard-clamped to 280 characters (`_clamp_tweet`). A release-post local-model failure still falls back to a plain template body (a release announcement always has something real to say); a failed reply draft instead skips origination entirely rather than shipping a generic "Thanks for the mention!". Drafting is **not** an agent spawn — no agent (including Head of Marketing) is spawned to write these; see `docs/rag/roles/head-marketing.md` for why the HoM's tool surface doesn't change. ## Ownership and the CEO gate @@ -34,7 +34,7 @@ The CEO acts through panel-only REST, CEO-role-gated (`require_ceo_role`), never |----------|--------| | `GET /api/x/posts` | List every held draft (both sources) awaiting decision. | | `POST /api/x/posts/{task_id}/approve` | Post to X (optionally with an edited body, still 280-char clamped). Idempotent — approving an already-posted draft returns `already_posted` without a second API call. | -| `POST /api/x/posts/{task_id}/reject` | Cancel the draft with a reason. Terminal — a rejected draft is never posted: `approve` refuses a CANCELLED draft outright (returns `already_rejected` without calling the X API), so a stale approve can't resurrect it; a fresh one originates on the next cycle/release if still relevant. | +| `POST /api/x/posts/{task_id}/reject` | Cancel the draft with a reason. The cancelled draft itself is never posted — `approve` refuses a CANCELLED draft outright (returns `already_rejected` without calling the X API), so a stale approve can't resurrect it. A non-blank reason also schedules a redraft: the same source (release/reply/spotlight) is revised by the local model with the CEO's feedback folded in as guidance, and one fresh held draft appears in the queue — the feedback loop closes immediately rather than waiting on the next cycle/release. A local-model failure or empty revision originates nothing (no degraded copy). | | `GET /api/x/credentials` | Whether all four OAuth secrets are stored (`has_credentials` boolean — never the secrets). | | `POST /api/x/credentials` | Set (or, passing all four empty, clear) the four secrets. All-or-nothing — a partial set raises a validation error. | diff --git a/docs/rag/tools/git-tools.md b/docs/rag/tools/git-tools.md index 8188ef78..152069b4 100644 --- a/docs/rag/tools/git-tools.md +++ b/docs/rag/tools/git-tools.md @@ -46,6 +46,8 @@ commit(message="Add rate limiting endpoint", files=["roboco/api/routes/rate.py"] There is no separate `push` step and no separate `create_pr` step. Both are side-effects of the lifecycle transitions the verbs already drive. +On a project that checks in generated artifacts, the push step also regenerates them: right before your branch is pushed, the project's codegen command runs in your worktree, and any drift it produces is committed into the SAME push automatically — you don't need to run it yourself, and a broken/timing-out codegen command never blocks your push (it just logs and lets you proceed; a real drift then surfaces on CI's own drift gate instead). + ### PMs → `complete` (roboco-flow) ```python diff --git a/docs/rag/troubleshooting/git-errors.md b/docs/rag/troubleshooting/git-errors.md index 5e606099..96a5bfe7 100644 --- a/docs/rag/troubleshooting/git-errors.md +++ b/docs/rag/troubleshooting/git-errors.md @@ -108,6 +108,12 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool. Some low-level git error messages still say "GitHub API" in their text regardless of which forge the project actually uses (a known wording gap, not a routing bug) — the underlying failure is real even when the vendor name in the message is wrong. Diagnose from the actual symptom (missing token, PR-not-found, merge conflict, etc.), not from the vendor name in the message. +## Rebase/sync_branch refused on a protected branch + +**Cause:** `sync_branch`/rebase refuses to force-push a branch whose name matches the project's operator-declared `protected_branches` list, its environment-ladder rungs, or the hardcoded `master`/`main` floor. You should never legitimately be working on one of these directly — your own feature branch is never a protected name. + +**Fix:** If you see this, your task's recorded branch is misconfigured. `unclaim(task_id)` and re-claim to rebuild a proper branch; don't try to force through it. + ## FORCE_PUSH_FORBIDDEN **Cause:** Force-push is CEO-only. Anyone else attempting it (typically because their branch diverged) is denied. diff --git a/docs/rag/troubleshooting/task-errors.md b/docs/rag/troubleshooting/task-errors.md index ba536283..1c40835d 100644 --- a/docs/rag/troubleshooting/task-errors.md +++ b/docs/rag/troubleshooting/task-errors.md @@ -111,6 +111,14 @@ escalate_to_ceo(task_id=parent_id, reason="...") **Solution**: Verify with the repo-wide check (not a scoped one, since scoping is a no-op) and record the zero-diff finding in a `decision` journal entry. If the task's acceptance criteria are already met with nothing left to change in the named files, do not keep re-running the same check — escalate once with the concrete verification so a PM can either stamp the task as satisfied-by-upstream/cancel it, or direct a small verification commit outside the target file(s) to satisfy the commit gate. +## Task Auto-Blocked for Budget + +**Symptom**: Your in-progress task flips to `blocked` with a budget marker, and you weren't the one who blocked it. + +**Cause** (only when task budgets are armed): a periodic sweep prices the task's own spend against its `budget_usd` (or the `TaskType` default when unset) and blocks it before you get a chance to finish gracefully, so the dispatcher won't respawn onto an already-over-budget task. + +**Fix**: This is a PM/CEO decision, not yours to route around — `unblock` re-checks live spend and refuses again while still over the cap. Escalate (`i_am_blocked`/`escalate_up`) naming the budget breach; the CEO either raises the task's/project's cap or the PM redirects the remaining work. + ## Invalid Task Status for Operation **Error**: "Task is in [status], expected [expected_status]" diff --git a/docs/rag/workflows/task-claiming.md b/docs/rag/workflows/task-claiming.md index 23e6c276..a133bc96 100644 --- a/docs/rag/workflows/task-claiming.md +++ b/docs/rag/workflows/task-claiming.md @@ -27,7 +27,9 @@ claim_doc_task(task_id) # Documenter The claim verb both claims and starts the task — there is no separate `start` call. For developers, `i_will_work_on` also creates the `feature/{team}/{task-hierarchy}` branch and **adds a dedicated per-task worktree** at `{clone_root}/.worktrees/{task-id-first-8}/`, checking out the branch there. Your container is started with that worktree as its cwd, and the clone root's HEAD is never moved by the claim — so a second claim (or a coordinator PM's many parallel roots) never overwrites your first task's uncommitted work. See `docs/rag/architecture/workspaces.md` for the worktree model. -Exactly one active WorkSession exists per task at a time (enforced in the service layer and by a DB unique index). A re-claim — pool release, reaper unclaim, escalation redirect — supersedes any prior agent's stale active session for that task and re-points the worktree at the new claim. +Exactly one active WorkSession exists per task at a time (enforced in the service layer and by a DB unique index). A re-claim — pool release, reaper unclaim, escalation redirect, or your own `i_will_work_on`/`i_will_plan` re-entry — supersedes any prior agent's stale active session for that task and re-points the worktree at the new claim. + +If the branch you're reclaiming was cut a while ago, the base it branched from may have advanced since (a sibling merged into the parent, or a cell PR landed on root). The claim automatically folds that advance into your existing branch: an already-ancestor base is a no-op, a clean merge just happens silently, and a real conflict leaves your branch exactly where it was and appends a note plus a `dev_notes` line telling you to run `sync_branch(task_id)` to finish it by hand — the claim itself never fails over this. This only runs on developer/cell_pm/main_pm claims of a `pending`/`needs_revision` task; a QA/documenter claim or a PM gate claim reviews the branch exactly as pushed and never touches it. If your task has `dependency_ids` in the same repo, the fresh branch cut also backfills each dependency's already-landed work when it sits outside your branch's own ancestor chain (a same-parent sibling or a same-project wave usually doesn't need this — the shared base already has it; a cross-subtree or cross-cell dependency edge can). This is a content assist, not a gate: a clean merge is silent, and a real conflict aborts the merge (your branch is left exactly at its cut point) and appends a note to the task naming the conflicting branch and files — resolve it by hand (merge the named branch into yours) before assembling your PR. A cross-repo dependency has no shared git history and is skipped entirely. diff --git a/scripts/rebuild_map_concat.py b/scripts/rebuild_map_concat.py new file mode 100644 index 00000000..08e1f3f0 --- /dev/null +++ b/scripts/rebuild_map_concat.py @@ -0,0 +1,102 @@ +"""Rebuild docs/map/_complete_map.md from _front.md + the per-slice maps. + +The complete map is a literal concatenation: the front matter, then each +slice's body (everything from its "## Purpose" heading on — any per-file +title block above it is stripped) in the canonical order below, one blank +line between sections. It has drifted behind its own sources by hand-sync +three times; this script makes the rebuild mechanical. + +Usage: + python scripts/rebuild_map_concat.py # rewrite the concat + python scripts/rebuild_map_concat.py --check # exit 1 if drifted +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +MAP_DIR = Path(__file__).resolve().parent.parent / "docs" / "map" + +# The committed artifact's own section order (recovered from the file; not +# alphabetical and not the ToC order — keep appending new slices here). +SLICE_ORDER = [ + "a2a-audit-journal-permissions", + "deployment-tooling", + "engines-heal-ciwatch-depupdate", + "foundation-batch-sequencing", + "foundation-conventions-identity", + "foundation-lifecycle", + "foundation-policy-misc", + "gateway-support", + "intake-secretary", + "notification", + "orchestrator", + "org-memory-playbooks", + "pr-gate-review", + "prompts-roles-taxonomy", + "release-manager", + "runtime-providers", + "vault", + "video-engine", + "models", + "db-migrations", + "api-core-websocket", + "api-routes-schemas", + "mcp-servers", + "choreographer", + "task-service", + "worksession-git", + "workspace", + "support-services", + "review-findings", + "metrics-observability", + "conventions-service-validator", + "product-strategy-research-pitch", + "engine-docs-sync", + "panel", + "tests", +] + + +def _slice_body(path: Path) -> str: + text = path.read_text() + idx = text.find("## Purpose") + if idx < 0: + raise SystemExit(f"{path.name}: no '## Purpose' heading — cannot concat") + return text[idx:].rstrip("\n") + "\n" + + +def build() -> str: + on_disk = {p.stem for p in MAP_DIR.glob("*.md") if not p.name.startswith("_")} + listed = set(SLICE_ORDER) + if on_disk != listed: + raise SystemExit( + "slice set drifted — missing from SLICE_ORDER: " + f"{sorted(on_disk - listed)}, listed but absent: {sorted(listed - on_disk)}" + ) + front = (MAP_DIR / "_front.md").read_text() + return front + "".join( + "\n" + _slice_body(MAP_DIR / f"{name}.md") for name in SLICE_ORDER + ) + + +def main() -> int: + target = MAP_DIR / "_complete_map.md" + built = build() + if "--check" in sys.argv[1:]: + if target.read_text() != built: + print( + "_complete_map.md is behind its sources — " + "run scripts/rebuild_map_concat.py" + ) + return 1 + print("_complete_map.md is in sync.") + return 0 + target.write_text(built) + print(f"wrote {target} ({len(built)} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())