diff --git a/CHANGELOG.md b/CHANGELOG.md index 921c812a..743cd85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **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. +- **Panel consistency & UX wave (#694).** A global Cmd+K command palette (`components/layout/command-palette.tsx`) searches tasks/agents/projects/pages with recents + fuzzy match, wired into the header search box. The Tasks page gains top-level List\|Kanban tabs sharing the page's URL filter state, and the standalone `/kanban` route now redirects there — its sidebar entry is gone and the bottom tab bar's kanban slot swaps to Agents. A forms-consistency audit synced the create/edit project and task dialogs against their backend schemas (`docs/frontend/forms/forms-audit.md`), and a touch-target/responsiveness sweep bumped every sub-36px icon control across the panel to the accessible floor, fixed `AlertDialog` clipping at short viewport heights, and wrapped the Scorecards Members table in `ResponsiveTable`. +- **Project settings is a full page, not a dialog.** The edit-project dialog carried ~30 fields across 7 concerns in one flat scroll with a per-tab width swap — outgrown. Project settings now live at `/projects/[id]/settings` as a card-per-concern grid (the settings page's own pattern) with per-card save and Conventions as a page-level tab at natural width; the list Edit action routes there, and a slim quick-edit dialog (name/cell/active) replaces the kitchen-sink (promote project settings to a full page). + +### Changed + +- **Panel dialogs converge on one shape: one disclosure primitive, DialogFooter everywhere, three dialog widths.** `collapsible-section` becomes the single sectioned-disclosure primitive — task dialogs' raw `Collapsible` and create-project's ad-hoc `showAdvanced` both move onto it; every hand-rolled dialog footer becomes `DialogFooter`; dialog widths collapse from ten ad-hoc classes to three named sizes, with deliberate outliers annotated. No behavioral change. ### Fixed @@ -36,6 +42,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **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. +- **Intake/Secretary chat replies survive a gated CLI (#665).** The chat driver treated stream-event text deltas as the only text channel, so when the CLI's partial-message emission is server-side gated (observed live — identical binary/flags/model, zero `stream_event` lines), the reply was generated but never rendered and the CEO saw nothing. `SdkIntakeSession` now emits an `AssistantMessage`'s complete text whenever no delta arrived during the turn; streaming mode is unaffected. +- **Telegram cockpit reported wrong numbers three ways (#666).** GLM 5.2 now gets a grounded per-token rate instead of a silent $0.00 for a full fleet day of usage; the cockpit's spend/velocity series bucket by a configurable `ROBOCO_DISPLAY_TIMEZONE` instead of UTC; and agent activity ("active: 0, working: []") is finally real — every claim path now marks the claimant `ACTIVE` with a `current_task_id`, and every release path clears it. Task budgets also became explicit-input only: the per-`TaskType` default cap table (which blocked an unbudgeted coordination root one opus planning turn in) is gone — a null `budget_usd` is genuinely uncapped. +- **Fleet PRs authored by a GitHub App bound as `[bot]` were reviewed as external (#668).** A bound GitHub App authors fleet PRs as `[bot]` with `author_association=NONE`, which the inbound classifier's author heuristics read as an outsider — the org's own dev-stream PR was ingested for adversarial external-PR review. Ownership now classifies BEFORE the author check: a same-repo PR whose head branch an active task owns is never treated as external, and the ownership scope widens to every project sharing the polled project's `git_url` (a monorepo's sibling cell-projects). A deleted-fork head now fails closed to review instead of risking a silent skip. +- **Tool-call budget caps reached no compose file (#672).** `ROBOCO_AGENT_TOOL_CALL_HALT`/`_WARN` were read by the SDK server and defined in config but wired into no compose `environment:` stanza — the code default (halt at 300) repeatedly killed and respawned a legitimate multi-file task in 300-call slices. The build composes now default to halt=600/warn=200, matching the already-patched NAS; the registry compose passes them through unset. +- **Board wiring, revived: research key, pitch flow, auditor playbooks (#684).** `ROBOCO_RESEARCH_API_KEY`/`_PROVIDER` and `ROBOCO_PROVISIONING_TOKEN`/`_ORG` reached no compose environment stanza, so research silently ran on the NullProvider and an approved pitch always died on `ProvisioningDisabledError`; both now pass through. A proposed pitch nudges the CEO (notification + Telegram link to the Pitches tab); `auditor_triage` now surfaces the oldest pending playbook draft once anomalies are clear, closing the loop on a curation queue nothing ever pointed the Auditor at. +- **A haiku-pinned QA/PM looped instead of reviewing (#680).** Haiku can't reliably emit the structured envelopes the lifecycle now runs on (`pass_review`'s per-AC `criteria_verified`, `delegate`'s `covers_parent_criteria`, the findings ledger) — a haiku QA claims, gets validation-rejected, idles, and respawns without ever passing a review. `ROLE_MODEL_MAP`'s qa/documenter defaults move to sonnet, the `cost_tiered` mode's `developer:low`→haiku seed retires to empty, and a structured-verb capability floor now upgrades any below-floor Anthropic assignment to sonnet at resolution regardless of source. +- **The sequence claim bar could phantom-hold an unrelated sibling (#681).** `stamp_wave_sequence` stamps each sibling's wave from a partial, per-task view of the graph at delegate time, so two independent same-parent dev-task streams could coincidentally land on the same raw sequence number — and the old edge-agnostic bar held a wave-N sibling behind a totally unrelated wave-(N-1) sibling from a different stream. Outside MegaTask batch root-subtasks (which keep the original strict, staged-release rule), the bar now only lets a lower-sequence sibling block when it's a real dependency-graph predecessor. A held claim also returns a dedicated `sequence_held` error naming the blocker instead of a bare rejection the verb runner misdiagnosed as a concurrent-transition error. +- **An acceptance-criteria rewrite could silently waive the whole subtree's coverage gate (#682).** Every post-create rewrite of `acceptance_criteria` (the task PATCH route, the prompter's draft/batch update paths) overwrote the criteria text without touching `acceptance_criteria_ids`, leaving them mismatched or empty — and an empty id list disabled the parent-coverage gate entirely. `create`/`update` now reconcile ids through one pure helper (text-unchanged criteria keep their id so live child/finding references never orphan); the coverage gate also self-heals an already-broken id list in place instead of silently waiving coverage. +- **A rebase could silently discard a developer's committed-but-unpushed work (#683).** The shared rebase primitive (`sync_branch`, the PM/CEO rebase route, submit-time freshening, the merge-conflict resolver) opened with an unconditional `reset --hard origin/` — since the `commit` do-verb never pushes, a dev routinely has commits that reset would rewind past, and the follow-on force-push then republished the truncated branch as authoritative. The primitive now classifies local vs. origin post-fetch: behind/equal resets as before, strictly-ahead skips the reset and rebases from the local tip, and a genuine two-sided divergence (checked for patch-equivalence first, so a prior rebase's failed force-push self-heals on retry) refuses outright with neither side touched. +- **An escalate/unblock round trip could oscillate a task forever (#685).** A cell PM's `escalate_up` → BLOCKED → a main PM's `unblock` → respawn → `escalate_up` again loop had no fuel limit: the restored side dispatches through a path with no respawn counter at all. `unblock()` now counts strikes task-scoped (a cheap commit-count/`revision_count`/terminal-children progress fingerprint resets the count on real forward motion); past threshold the task is blocked for a human with both agents and the cycle count named in the CEO notification, and the dispatchers actually see the block now that `blocker_resolver_type` serializes over the wire. +- **A rejected `delegate` call named the missing field but never showed its shape (#686).** A child draft rejected for a `covers_parent_criteria` ref that doesn't resolve now gets a copy-pasteable corrected `delegate(...)` skeleton with the parent's real criteria inlined — a PM no longer loops on a rejection it can't act on. The coverage-reject path also self-heals a legacy parent whose criteria ids are empty or out of length before rendering the hint, and the plain `pause` route now authorizes the CEO tier like its sibling lifecycle routes. +- **A finding's `file` could be prose instead of a path (#687).** A finding with `file` set to something like a PR reference validated cleanly and then doomed the panel's code-snippet fetch. `file` is now shape-gated to look like a repo-relative path (narrative belongs in `evidence`, and the remediate says so); the Findings tab groups findings into per-round collapsible sections and only attempts a snippet fetch for a path-shaped ref. +- **A reviewer's clone could stay frozen on pre-rebase history across every review round (#690).** `diff`/`list_changed_files`/`read_file_at_branch` (and the read-only `roboco_git_log` route) kept local-ref priority on any divergence from origin, real or rewritten — a QA/PM/reviewer clone parked before a routine force-push sync stayed there forever, and QA repeatedly bounced work that had already landed. Every caller here is a reader, never the branch's own author mid-write, so origin now wins whenever it carries anything the local ref lacks. +- **A per-task worktree was created once and never refreshed again (#692).** A worktree from a reviewer's first `claim_review`/`claim_gate_review` stayed frozen at that commit across every later respawn even as new commits landed on origin — the mechanism behind a live multi-round QA/PR-gate bounce loop. `ensure_worktree_self_heal` now refreshes an already-present (or re-added) worktree against origin at every spawn, role-aware: an author's (developer/documenter) dirty uncommitted edits are never discarded to fast-forward, and only a pure reader's diverged copy gets hard-reset. ## [0.26.0] - 2026-07-20 diff --git a/CLAUDE.md b/CLAUDE.md index 3dc95bab..538b5050 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,8 @@ A task has at most **one active WorkSession**: re-claiming a task (pool release, A developer's clone is shared across all their tasks, so push and PR-head operate on the task's **recorded branch by name**, independent of the clone's current checkout — fixing the `BRANCH_MISMATCH` / "No commits between" failures when the clone was parked on a later task's branch. A missing local task-branch ref is first recovered from `origin/` before the push-by-name. +**Stale-state family fixes (2026-07-24, #683/#690/#692).** Three independent chokepoints used to trust whatever a clone/worktree happened to have on disk instead of checking origin, all closed the same week off one live incident: `GitService.rebase_onto_base` no longer opens with an unconditional `reset --hard origin/` (#683) — it classifies local vs origin post-fetch first, so a committed-but-unpushed dev tip (routine mid-rework, since `commit` never pushes) is never silently discarded, and a genuine two-sided divergence returns `{"status": "diverged", ...}` untouched rather than guessing a side to keep. The reader-side twin, `GitService._resolve_head_ref` (backing `diff`/`list_changed_files`/`read_file_at_branch`/`roboco_git_log`), now prefers `origin/` over a local ref whenever origin carries commits the local ref lacks (#690) — a QA/PM/reviewer clone parked on pre-rebase history no longer stays frozen there across every later review round. And `WorkspaceService.ensure_worktree_self_heal` stops treating an ALREADY-PRESENT per-task worktree as a pure venv+chown no-op (#692): every spawn now runs it through `_refresh_present_worktree`, role-aware via `can_author` (`foundation.identity.WORKTREE_AUTHOR_ROLES`) — fast-forward when behind for every role without ever discarding an author's dirty uncommitted edits, leave alone when strictly ahead, hard-reset only for a non-author reader when diverged. Together these close the "reviewer keeps bouncing a task back for fixes that already landed, because it's still looking at its own frozen round-1 checkout" incident class. + ### Git Credentials Git authentication is managed **per-project** through encrypted GitHub PATs: @@ -188,11 +190,11 @@ Git authentication is managed **per-project** through encrypted GitHub PATs: ### Forge providers (GitHub + Gitea + GitLab) -The REST surface (PRs, CI status, reviews, labels, releases) is provider-routed (`roboco/services/forge/`): `GitProvider` is the ~20-method transport contract, `GitHubProvider`, `GiteaProvider`, and `GitLabProvider` implement it, and `GitService._forge` returns a `ForgeRouter` that picks the transport per call from `RepoRef.host` — `None` (github.com/GHE) rides GitHub, a registered Gitea/GitLab host rides that instance's provider, so `GitService`'s call sites never know which forge they're on. A project opts in via `projects.git_provider` (gitlab.com auto-detects like github.com; self-hosted instances set it explicitly; `"github"` doubles as the GHE escape hatch with `ROBOCO_GITHUB_API_BASE_URL`) — panel: the Forge select in the edit-project dialog. The host→provider(+scheme — plain-http LAN instances are supported) map is in-memory per process, self-healing: `ProjectService.get`/`get_by_slug` re-register on every read. Both non-GitHub providers adapt their wire contracts back into the GitHub shapes `GitService` classifies (`forge/shaping.py` `ShapedResponse`): Gitea — `token` auth scheme, duplicate-PR 409→422, commit statuses reshaped into `check_runs`/`workflow_runs`, `Do`-keyed POST merge, slash-encoded refs; GitLab — MR iid→`number`, source/target_branch→`head`/`base`, per-file diffs reassembled into unified-diff text, approve-vs-note review routing (no request-changes verb exists), pipelines/statuses CI reshapes, reviewer-request skipped (needs numeric ids). Neither has GitHub's server-side merges API: their `merge_branch` returns a shaped 501 and `GitService.sync_env_branch` runs the shared local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; conflict aborts with the remote untouched, same status vocabulary). Plain git (clone/fetch/push) is forge-agnostic — the Basic-auth `x-access-token:` extraheader works on Gitea/GitLab unchanged (verified live on Gitea). The env-gated `tests/e2e_smoke/test_gitea_live.py` is the live contract suite (self-seeding against a dockerized `gitea/gitea`; it caught the slash-encoding and http-scheme gaps). +The REST surface (PRs, CI status, reviews, labels, releases) is provider-routed (`roboco/services/forge/`): `GitProvider` is the ~20-method transport contract, `GitHubProvider`, `GiteaProvider`, and `GitLabProvider` implement it, and `GitService._forge` returns a `ForgeRouter` that picks the transport per call from `RepoRef.host` — `None` (github.com/GHE) rides GitHub, a registered Gitea/GitLab host rides that instance's provider, so `GitService`'s call sites never know which forge they're on. A project opts in via `projects.git_provider` (gitlab.com auto-detects like github.com; self-hosted instances set it explicitly; `"github"` doubles as the GHE escape hatch with `ROBOCO_GITHUB_API_BASE_URL`) — panel: the Forge select on the project settings page's identity card (`/projects/[id]/settings`, Wave C — was the edit-project dialog, now deleted). The host→provider(+scheme — plain-http LAN instances are supported) map is in-memory per process, self-healing: `ProjectService.get`/`get_by_slug` re-register on every read. Both non-GitHub providers adapt their wire contracts back into the GitHub shapes `GitService` classifies (`forge/shaping.py` `ShapedResponse`): Gitea — `token` auth scheme, duplicate-PR 409→422, commit statuses reshaped into `check_runs`/`workflow_runs`, `Do`-keyed POST merge, slash-encoded refs; GitLab — MR iid→`number`, source/target_branch→`head`/`base`, per-file diffs reassembled into unified-diff text, approve-vs-note review routing (no request-changes verb exists), pipelines/statuses CI reshapes, reviewer-request skipped (needs numeric ids). Neither has GitHub's server-side merges API: their `merge_branch` returns a shaped 501 and `GitService.sync_env_branch` runs the shared local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; conflict aborts with the remote untouched, same status vocabulary). Plain git (clone/fetch/push) is forge-agnostic — the Basic-auth `x-access-token:` extraheader works on Gitea/GitLab unchanged (verified live on Gitea). The env-gated `tests/e2e_smoke/test_gitea_live.py` is the live contract suite (self-seeding against a dockerized `gitea/gitea`; it caught the slash-encoding and http-scheme gaps). ### Protected Branches -`projects.protected_branches` (operator-declared, panel: a chips editor in the edit-project dialog) is unioned — never replacing, only tightening — into `GitService`'s hardcoded safety floor. Two scopes, deliberately different: `_protected_branches_for` (rebase refusal + `sync_task_branch`'s force-push refusal) is the field alone unioned with the hardcoded `{master, main}` floor and fails OPEN on a lookup error (a wrongly-blocked rebase over a transient DB blip is the worse tradeoff, and a skipped rebase gets no free retry); `_protected_branches_for_deletion` — consulted ONLY by the shared `_delete_remote_branch_best_effort` chokepoint every remote-delete path routes through (task-branch cleanup on cancel, the stale-branch sweep, and post-merge PR-source cleanup) — additionally unions in the project's environment-ladder rung branches (`effective_environments`, so a null ladder's synthesized single rung off `default_branch` is protected too, e.g. a renamed trunk like `trunk`) and fails CLOSED on a lookup error (skip the delete entirely; deletion is best-effort so a skipped one just retries at the next sweep, whereas silently proceeding on an unresolvable project could delete a real declared rung for good). Matching is exact and case-sensitive; an empty `protected_branches` list degrades to exactly the prior hardcoded-only behavior. +`projects.protected_branches` (operator-declared, panel: a chips editor on the project settings page's placement card) is unioned — never replacing, only tightening — into `GitService`'s hardcoded safety floor. Two scopes, deliberately different: `_protected_branches_for` (rebase refusal + `sync_task_branch`'s force-push refusal) is the field alone unioned with the hardcoded `{master, main}` floor and fails OPEN on a lookup error (a wrongly-blocked rebase over a transient DB blip is the worse tradeoff, and a skipped rebase gets no free retry); `_protected_branches_for_deletion` — consulted ONLY by the shared `_delete_remote_branch_best_effort` chokepoint every remote-delete path routes through (task-branch cleanup on cancel, the stale-branch sweep, and post-merge PR-source cleanup) — additionally unions in the project's environment-ladder rung branches (`effective_environments`, so a null ladder's synthesized single rung off `default_branch` is protected too, e.g. a renamed trunk like `trunk`) and fails CLOSED on a lookup error (skip the delete entirely; deletion is best-effort so a skipped one just retries at the next sweep, whereas silently proceeding on an unresolvable project could delete a real declared rung for good). Matching is exact and case-sensitive; an empty `protected_branches` list degrades to exactly the prior hardcoded-only behavior. ## Task Lifecycle @@ -390,7 +392,9 @@ The `next` field tells the agent what to call next; the `remediate` field on err Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` lifecycle ABC (`base.py`) and a `ProviderRegistry` keyed by `ModelProvider` (`registry.py`), with `ClaudeCodeProvider` (default), `GrokCliProvider`, `GeminiCliProvider`, and `CodexCliProvider`. The orchestrator resolves a provider at spawn from the agent's `ModelProvider`; when no dedicated provider is registered it falls back to the built-in Claude Code spawn. `ModelProvider` (`roboco/models/base.py`) is `ANTHROPIC` (default), `GROK`, `GEMINI`, `OPENAI`, `LOCAL`, `OLLAMA_CLOUD` — `OPENAI` routes through the official Codex CLI on a ChatGPT subscription (`CodexCliProvider`), not a reserved/unimplemented value. The seam is additive: only `GROK`/`GEMINI`/`OPENAI` route through their dedicated providers; Anthropic / Ollama Cloud / self-hosted spawns are unchanged, and every provider gets the same MCP gateway + tool-manifest wiring by construction. -**Cost-tiered routing + routing presets.** `ModelRoutingService` (`roboco/services/llm.py`) resolves `(provider, model)` per agent at spawn from `model_assignments` with precedence `AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL`; the compound `ROLE:complexity` rung (e.g. `developer:low`) reuses the existing ROLE scope + `scope_value` column — no schema change — to pin a role to a cheaper model at a given task's `estimated_complexity` without touching the plain ROLE row everything else still uses. `apply_mode('cost_tiered')` additively seeds one day-1 override (`developer:low` → `haiku`), unlike every other mode's wipe-then-seed. On top of modes, **routing presets** (`RoutingPresetTable`) let an operator name-and-snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) via `save_routing_preset`, then restore it wholesale later with `apply_routing_preset` — a full swap, validate-every-entry-first so an invalid payload never triggers the wipe, unlike a mode's pin-preserving 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. +**Cost-tiered routing + routing presets.** `ModelRoutingService` (`roboco/services/llm.py`) resolves `(provider, model)` per agent at spawn from `model_assignments` with precedence `AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL`; the compound `ROLE:complexity` rung (e.g. `developer:low`) reuses the existing ROLE scope + `scope_value` column — no schema change — to pin a role to a cheaper model at a given task's `estimated_complexity` without touching the plain ROLE row everything else still uses. `apply_mode('cost_tiered')` additively seeds `_COST_TIERED_SEED`, unlike every other mode's wipe-then-seed; the seed is now an empty tuple (see "haiku retired from delivery-lifecycle roles" below) — the mode stays wired for a future above-floor re-seed, it just ships inert. On top of modes, **routing presets** (`RoutingPresetTable`) let an operator name-and-snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) via `save_routing_preset`, then restore it wholesale later with `apply_routing_preset` — a full swap, validate-every-entry-first so an invalid payload never triggers the wipe, unlike a mode's pin-preserving 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. + +**Haiku retired from delivery-lifecycle roles (2026-07-24).** Haiku can't reliably emit the structured envelopes the lifecycle now runs on — `pass_review`'s per-AC `criteria_verified`, `delegate`'s `covers_parent_criteria`, the findings ledger — so a haiku QA/PM claims, gets validation-rejected, idles, respawns, and loops without progress (a live incident: an fe-qa on haiku looped four `awaiting_qa` tasks to zero progress). `ROLE_MODEL_MAP`'s `qa`/`documenter` defaults move from haiku to sonnet; the `cost_tiered` seed's `developer:low` → haiku entry retires to empty (the floor below would upgrade it anyway); and a structured-verb capability floor (`_below_capability_floor`/`_floor_below_capability`, `roboco/services/llm.py`) upgrades ANY below-floor Anthropic assignment to sonnet at resolution — from a pin, a ROLE row, or a future map edit — in both the assignment and legacy paths. Non-Anthropic providers are untouched (an Anthropic-tier floor, not a provider policy); `pr_reviewer`/`auditor` stay on opus. **Grok runtime.** `GROK` agents run xAI's official `grok` CLI (model `grok-build`) authenticated by a **SuperGrok subscription**, not a metered API key — so a Grok workforce can't stall mid-task on out-of-credits. The host `~/.grok/auth.json` is mounted **read-only** into each agent (`GrokCliProvider._append_grok_auth_mount`; `ROBOCO_HOST_GROK_DIR` is the host mount source, set up once with `grok login`). It reaches parity with the Claude path by construction: same MCP gateway + manifest, per-role tool-removal and git-operation deny rules, a prompt-injection guard on the task prompt, headless tool auto-approval, and per-agent token/cost capture from the grok session store. It covers both one-shot delivery roles and the interactive Intake (Prompter) and Secretary chats (per-turn `grok -p` with session resume). @@ -420,7 +424,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` **RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes, drafts replies to meaningful mentions, and — a third, independent capability — periodically investigates RoboCo's own shipped features and drafts a spotlight for an under-publicized one. NOTHING auto-posts across any of the three; every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply` / `x_feature`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a body clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps — both are **local-model-drafted** (never a cloud LLM in the hot path). The spotlight half is the one exception to "no agent spawn": gated by its own sub-switch `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_INTERVAL_SECONDS`, default 3 days) on top of `x_engine_enabled`, `_x_feature_spotlight_loop` opens a held PENDING exploration task (`source=x_feature_exploration`, team=Board, assigned to Head of Marketing, carrying a `x_seen_features` dedup-ledger snapshot marker) that `_dispatch_pm_work` routes (mirroring `ROADMAP_SOURCE`) to a one-shot real cloud-LLM spawn of the Head of Marketing — full read tools, investigates CHANGELOG.md/feature-flags/docs/map/charter/KB, calls the Head-of-Marketing-only `propose_feature_spotlight` do-tool exactly once, which marks the feature slug seen (`x_seen_features` table, migration 061) and materializes a brand-new `source=x_feature` held draft (completing the exploration task as a side effect — a deliberate asymmetry from `propose_roadmap`, which instead leaves its own task open). The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). All three draft kinds share one voice: `XEngine._voice_guide` reads the CEO-editable `company_goals.brand_voice` charter field (migration 061, panel-editable in Business → Goals) and appends it to a generic baseline (`_HOM_VOICE`) — the baseline alone until the CEO supplies a real sample. Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject, a `sourceMeta`-driven label/icon per source including "Feature spotlight") + `x-credentials-card.tsx` (4 write-only secret inputs). -**RoboCo video engine (default-off).** Bespoke motion-graphics videos (release announcements, feature spotlights, on-demand CEO briefs) authored by a UX/UI dev and distributed to X/TikTok — nothing renders or posts without the flags on, and nothing posts without an explicit CEO approval. Gated by `ROBOCO_VIDEO_ENGINE_ENABLED` (+ sub-switches `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT`, and `_MAX_OPEN_POSTS` / `_RENDER_INTERVAL_SECONDS` / `_RENDER_TIMEOUT_SECONDS` / `_REQUEST_TIMEOUT_SECONDS` / `_OUTPUT_DIR`); a CEO on-demand brief rides `POST /video/request` regardless of the release/spotlight sub-switches. A project opts in via `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/` — `VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it in the panel's edit-project dialog. Two task kinds mirror the XEngine/ReleaseManagerEngine "originate a CEO-scoped artifact" shape but split across the real delivery lifecycle: `VideoEngine.open_video_task` (`roboco/services/video_engine.py`) opens a normal, ASSIGNED **authoring task** (`source=video`, `confirmed_by_human=True`, team=UX/UI, balanced across `ux-dev-1`/`ux-dev-2` by open-task count) that dispatches like any other pre-assigned code task — NOT held, NOT in any dispatcher's skip bucket. The assigned dev builds a HyperFrames HTML composition under `motion/compositions//` and calls the UX/UI-team-gated `propose_video` do-tool (metadata-only: composition id, input props, per-platform captions — every developer role carries the tool on their manifest, but the runtime `_caller_team` check rejects a be-dev/fe-dev) to stamp the task's `video_draft` marker, then commits + `open_pr` through the normal PR-review gate. Once that authoring task reaches `completed`, the orchestrator's `_video_render_loop` (bounded retry, `_MAX_VIDEO_RENDER_ATTEMPTS`) tars the merged `motion/` dir from the project's read-clone and POSTs it to the credential-free **video-renderer sidecar** (`VideoRenderer` in `roboco/services/video_renderer_client.py`, `ROBOCO_VIDEO_RENDERER_BASE_URL`) to render both the 9:16 and 1:1 cuts to MP4 (`video_output_dir`); on success `VideoEngine._originate_video_post` materializes a held **video-post draft** (`source=video_post`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) carrying `mp4_paths` (`{vertical, square}` absolute paths) + the per-platform captions. The CEO reviews it in the panel's video queue (`video-post-queue.tsx`; `GET /video/posts` lists drafts including `mp4_paths` so the panel knows which cuts exist, `GET /video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player, CEO-gated throughout) and edits captions / approves / rejects. `VideoPostService.approve` (`roboco/services/video_post_service.py`) is the ONLY caller of the X-v2 (`XVideoPoster` in `x_video_client.py`) and TikTok inbox-upload (`TikTokPoster` in `tiktok_client.py`) posters; because a video upload + transcode/poll can run well past a minute, the critical section runs under a heartbeat-renewed Redis mutex (`heartbeat_mutex.py`, mirroring `ReleaseProposalService`'s release-execute lock shape) rather than a flat lock, commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster again). TikTok's four OAuth2 secrets live Fernet-encrypted in a singleton `tiktok_credentials` row (mirroring the git-token / `x_credentials` pattern; the API only ever returns `has_credentials`) — set via the panel's TikTok credentials card. `NullVideoRenderer` / `NullXVideoPoster` / `NullTikTokPoster` make every unconfigured leg a graceful no-op rather than a crash. **Artifact verification (2026-07-16):** authoring is gated on the RENDERED clip, not its source — the `request_render` do-verb (developer/QA, mirroring `request_sandbox`'s shape) renders the caller's actual composition via the sidecar's new frames mode (`POST /render` with `frames=N` → ffprobe'd duration + N midpoint-sampled PNGs, tar.gz back) and extracts them to the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`; the dev renders their own working tree (worktree-aware, `head_sha`/`dirty` provenance), QA a read-only `git archive` export of the assembled branch (`WorkspaceService.export_branch_motion` — the read clone's pinned HEAD undisturbed). Success stamps the `render_preview` marker; `i_am_done` on a `source=video` task refuses without it (`Requirement.RENDER_VERIFIED` in the tracing gate, mirrored in the possibilities-matrix fast path — the canonical source string lives in foundation as `markers.VIDEO_TASK_SOURCE`), the dev spawn prompt orders Read-every-frame verification, and QA's `claim_review` evidence carries a `video_context` block so the reviewer checks output, not source. A CEO reject of a held video-post draft with a non-empty reason now also re-enters the flow: `VideoPostService.reject` → `VideoEngine.reauthor_from_rejection` opens a fresh authoring task carrying the verbatim feedback + a revise-in-place pointer at the existing composition (best-effort, never fails the reject). +**RoboCo video engine (default-off).** Bespoke motion-graphics videos (release announcements, feature spotlights, on-demand CEO briefs) authored by a UX/UI dev and distributed to X/TikTok — nothing renders or posts without the flags on, and nothing posts without an explicit CEO approval. Gated by `ROBOCO_VIDEO_ENGINE_ENABLED` (+ sub-switches `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT`, and `_MAX_OPEN_POSTS` / `_RENDER_INTERVAL_SECONDS` / `_RENDER_TIMEOUT_SECONDS` / `_REQUEST_TIMEOUT_SECONDS` / `_OUTPUT_DIR`); a CEO on-demand brief rides `POST /video/request` regardless of the release/spotlight sub-switches. A project opts in via `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/` — `VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it on the project settings page's budget/ops card (Wave C — was the edit-project dialog). Two task kinds mirror the XEngine/ReleaseManagerEngine "originate a CEO-scoped artifact" shape but split across the real delivery lifecycle: `VideoEngine.open_video_task` (`roboco/services/video_engine.py`) opens a normal, ASSIGNED **authoring task** (`source=video`, `confirmed_by_human=True`, team=UX/UI, balanced across `ux-dev-1`/`ux-dev-2` by open-task count) that dispatches like any other pre-assigned code task — NOT held, NOT in any dispatcher's skip bucket. The assigned dev builds a HyperFrames HTML composition under `motion/compositions//` and calls the UX/UI-team-gated `propose_video` do-tool (metadata-only: composition id, input props, per-platform captions — every developer role carries the tool on their manifest, but the runtime `_caller_team` check rejects a be-dev/fe-dev) to stamp the task's `video_draft` marker, then commits + `open_pr` through the normal PR-review gate. Once that authoring task reaches `completed`, the orchestrator's `_video_render_loop` (bounded retry, `_MAX_VIDEO_RENDER_ATTEMPTS`) tars the merged `motion/` dir from the project's read-clone and POSTs it to the credential-free **video-renderer sidecar** (`VideoRenderer` in `roboco/services/video_renderer_client.py`, `ROBOCO_VIDEO_RENDERER_BASE_URL`) to render both the 9:16 and 1:1 cuts to MP4 (`video_output_dir`); on success `VideoEngine._originate_video_post` materializes a held **video-post draft** (`source=video_post`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) carrying `mp4_paths` (`{vertical, square}` absolute paths) + the per-platform captions. The CEO reviews it in the panel's video queue (`video-post-queue.tsx`; `GET /video/posts` lists drafts including `mp4_paths` so the panel knows which cuts exist, `GET /video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player, CEO-gated throughout) and edits captions / approves / rejects. `VideoPostService.approve` (`roboco/services/video_post_service.py`) is the ONLY caller of the X-v2 (`XVideoPoster` in `x_video_client.py`) and TikTok inbox-upload (`TikTokPoster` in `tiktok_client.py`) posters; because a video upload + transcode/poll can run well past a minute, the critical section runs under a heartbeat-renewed Redis mutex (`heartbeat_mutex.py`, mirroring `ReleaseProposalService`'s release-execute lock shape) rather than a flat lock, commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster again). TikTok's four OAuth2 secrets live Fernet-encrypted in a singleton `tiktok_credentials` row (mirroring the git-token / `x_credentials` pattern; the API only ever returns `has_credentials`) — set via the panel's TikTok credentials card. `NullVideoRenderer` / `NullXVideoPoster` / `NullTikTokPoster` make every unconfigured leg a graceful no-op rather than a crash. **Artifact verification (2026-07-16):** authoring is gated on the RENDERED clip, not its source — the `request_render` do-verb (developer/QA, mirroring `request_sandbox`'s shape) renders the caller's actual composition via the sidecar's new frames mode (`POST /render` with `frames=N` → ffprobe'd duration + N midpoint-sampled PNGs, tar.gz back) and extracts them to the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`; the dev renders their own working tree (worktree-aware, `head_sha`/`dirty` provenance), QA a read-only `git archive` export of the assembled branch (`WorkspaceService.export_branch_motion` — the read clone's pinned HEAD undisturbed). Success stamps the `render_preview` marker; `i_am_done` on a `source=video` task refuses without it (`Requirement.RENDER_VERIFIED` in the tracing gate, mirrored in the possibilities-matrix fast path — the canonical source string lives in foundation as `markers.VIDEO_TASK_SOURCE`), the dev spawn prompt orders Read-every-frame verification, and QA's `claim_review` evidence carries a `video_context` block so the reviewer checks output, not source. A CEO reject of a held video-post draft with a non-empty reason now also re-enters the flow: `VideoPostService.reject` → `VideoEngine.reauthor_from_rejection` opens a fresh authoring task carrying the verbatim feedback + a revise-in-place pointer at the existing composition (best-effort, never fails the reject). **Board roadmap engine (default-off).** The Board originating strategic work: on a weekly interval (`ROBOCO_ROADMAP_ENGINE_ENABLED` + `_INTERVAL_SECONDS` / `_MIN_ITEMS_PER_CYCLE` / `_MAX_ITEMS_PER_CYCLE`) `RoadmapEngine` (`roboco/services/roadmap_engine.py`) opens ONE held **exploration** task (`source="board_roadmap"`, `confirmed_by_human=False`, PENDING, Product-Owner-assigned, `Team.BOARD`), deduped to one open cycle at a time. A dedicated one-shot `_dispatch_roadmap_exploration` spawns the Product Owner **solo** — deliberately NOT `_handle_board_assigned_task` (which would also spawn Head of Marketing and fire the Approve-&-Start handoff, both wrong for a PO-authored cycle) — reusing the `_board_dispatched` one-shot tracker + respawn breaker, and short-circuiting once the cycle is authored. The PO explores (read-only git, KB/RAG, metrics, releases, charter, optional web research) and makes ONE `propose_roadmap` call (a content verb gated to `product_owner` only, `_ROADMAP_ROLES`; wired through the do_server/Choreographer like `pitch`) authoring a **themed cycle** — a one-line goal + 3-7 item drafts — persisted as a `roadmap_cycle` marker on the exploration task (no table/migration). The CEO acts per-item in the panel roadmap queue (`roadmap-review-queue.tsx`; `/api/roadmap/cycles{,/items/{id}/approve,/items/{id}/reject}`, CEO-only): approve materializes that item as a BACKLOG task (`source="roadmap"`, no assignee — never auto-starts; normal PM activation picks it up) via `PrompterService.create_task_from_draft`, reject records a reason; when every item is terminal the exploration task completes (`RoadmapService`, idempotent per item). Dispatchers skip `board_roadmap` (never delivery work). `create_task_from_draft` honors a draft-declared `source` only from a `{prompter, roadmap}` whitelist — an LLM-authored draft can't impersonate a privileged origin. @@ -456,7 +460,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` **Validator.** A single Python CLI, `python -m roboco.conventions check --root --files ...` (`roboco/conventions/`), uses tree-sitter (Python + TypeScript grammars, shipped in the agent image) to classify each changed definition and flag forbidden placements + hygiene + custom-rule matches as JSONL findings, after waiver filtering. Precision over recall (it abstains when uncertain so a `block` gate can't false-positive-strand a task) and fail-loud (a validator that cannot run exits 3 so the gate blocks, never silently passes). -**Threading + enforcement.** The standard reaches the work two ways: an ambient "Architectural Standard" block injected at spawn (`compose_prompt`) and an auto-attached `## Constraints` section on every project task (`TaskService.create`). Enforcement is deterministic: a `block`-level finding refuses `i_am_done` (dev pre-submit) and `pr_pass` (the in-path PR gate) with the offending `file:line` + fix hint; findings also surface in QA's `claim_review` evidence (`convention_findings`). A false positive is relieved by a `waiver` the dev commits in their branch — accountable, reviewed in the PR. The panel's per-project Conventions tab (in the edit-project dialog) shows the map + health and offers Save / Restore. +**Threading + enforcement.** The standard reaches the work two ways: an ambient "Architectural Standard" block injected at spawn (`compose_prompt`) and an auto-attached `## Constraints` section on every project task (`TaskService.create`). Enforcement is deterministic: a `block`-level finding refuses `i_am_done` (dev pre-submit) and `pr_pass` (the in-path PR gate) with the offending `file:line` + fix hint; findings also surface in QA's `claim_review` evidence (`convention_findings`). A false positive is relieved by a `waiver` the dev commits in their branch — accountable, reviewed in the PR. The panel's per-project Conventions tab (a page-level tab on `/projects/[id]/settings`, Wave C — was a tab inside the edit-project dialog) shows the map + health and offers Save / Restore. ## Design Bar diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index 611bc4d6..fbca1a25 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -2527,6 +2527,7 @@ The support layer of the agent gateway: pure/cheap components the Choreographer | hint_for_missing_progress | function | roboco/services/gateway/remediation.py:11 | Hint: make a commit before i_am_done. | | hint_for_missing_reflect | function | roboco/services/gateway/remediation.py:18 | Hint: call note(scope='reflect',...). | | hint_for_unaddressed_acceptance_criteria | function | roboco/services/gateway/remediation.py:25 | Hint: every AC needs a referencing artifact. | +| hint_for_missing_ac_coverage | function | roboco/services/gateway/remediation.py | #686: a `delegate` child draft rejected for a `covers_parent_criteria` ref that doesn't resolve now gets a copy-pasteable corrected skeleton with the parent's REAL criteria inlined (id when present, quoted exact text otherwise — never a bare `''` placeholder), instead of a rejection that named the field but never showed its shape. The coverage-reject path self-heals an empty/out-of-length id list on the parent before rendering so the skeleton always has real references; `cell_pm.md`/`main_pm.md` also show the field on every delegation-guidance child draft now. | | hint_for_missing_journal_decision | function | roboco/services/gateway/remediation.py:36 | Hint: call note(scope='decision',...) before complete. | | hint_for_missing_journal_learning | function | roboco/services/gateway/remediation.py:43 | Hint: call note(scope='learning',...) before pass/fail. | | hint_for_missing_qa_notes | function | roboco/services/gateway/remediation.py:50 | Hint: qa_notes must be >=80 chars. | @@ -3324,9 +3325,11 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock | 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._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), its related task is already terminal, **or (since #685) the related task is HITL-blocked** (`_is_hitl_blocked`) — closes the admin-route escalation side of the oscillation loop the same way the dev/PM dispatchers' own HITL skip does. | +| AgentOrchestrator._is_hitl_blocked | staticmethod | roboco/runtime/orchestrator.py:13796 | `status == "blocked" and blocker_resolver_type == "human"` over the WIRE task dict; consulted by `_dev_dispatch_one`, `_dispatch_claimed_without_agent`, and `_notification_has_live_work`. Only actually fires over the wire since #685 stamped `TaskResponse.blocker_resolver_type` — before that the field never serialized, so every wire-dict HITL check silently no-op'd regardless of the in-process row. Budget-breach (#654) and the task-scoped oscillation breaker (#685) both set this same resolver+status pair, so one skip covers both trip conditions. | +| AgentOrchestrator._dispatch_claimed_without_agent | coroutine | roboco/runtime/orchestrator.py:14559 | Re-dispatches a `claimed` task whose owning agent isn't running; the RESTORED side of an escalate/unblock round trip (no per-(agent,task) respawn counter of its own) — gated by `_is_hitl_blocked` (#685) so an oscillation-tripped or budget-blocked task stops being re-dispatched here too. | | 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._task_budget_breach | coroutine | roboco/runtime/orchestrator.py:8053 | `(spend, cap)` tuple when a task's own spend exceeds its `budget_usd` via `effective_task_budget_usd`, else `None`. **Explicit-input only since #666**: a null `budget_usd` resolves to `None` (no cap) — the earlier per-`TaskType` default table (which blocked an unbudgeted coordination root one opus planning turn in) was removed. | | 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`. | @@ -3484,6 +3487,9 @@ stateDiagram-v2 > - `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. +> - `a036c979` (#666, "cockpit data correctness", 2026-07-23) `effective_task_budget_usd` drops its per-`TaskType` default fallback — a budget-less task is now genuinely uncapped (`None`), not silently capped at a `TaskType`-keyed dollar figure that blocked an unbudgeted coordination root one opus planning turn in. `_task_budget_breach`'s description above reflects this; the `TaskType` default table + its resolver are deleted outright. +> - `eb0dcb6e` (#685, "task-scoped oscillation breaker", 2026-07-24) A escalate_up→BLOCKED→unblock→respawn→escalate_up round trip had no fuel limit: the RESTORED side dispatches via `_dispatch_claimed_without_agent`, which consulted no respawn counter at all. `unblock()` now counts strikes task-scoped (durable in `orchestration_markers`, no migration) via a cheap progress fingerprint (commit count + `revision_count` + `terminal_children_count`); past threshold the task is BLOCKED with `blocker_resolver_type=HUMAN` (the same posture budget-breach uses) and the CEO is notified naming both agents + the cycle count. `TaskResponse.blocker_resolver_type` now actually serializes over the wire, so `_is_hitl_blocked`'s skip (already used by `_dev_dispatch_one`) now really fires for `_dispatch_claimed_without_agent` and `_notification_has_live_work` too — previously the wire dict never carried the field, so those two HITL checks silently no-op'd. +> - `987eb09c` (#692, "role-aware worktree refresh at every spawn", 2026-07-24) `_ensure_worktree_before_spawn` now passes `can_author` (from the spawning agent's role, `foundation.identity.WORKTREE_AUTHOR_ROLES`) into `ensure_worktree_self_heal`, which refreshes an ALREADY-PRESENT per-task worktree against origin at every respawn instead of treating it as a pure venv+chown no-op — see `docs/map/workspace.md` for the full classification. ## Regression Risks @@ -5129,7 +5135,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, | `AgentInstance` | dataclass | runtime.py:70 | Running Claude Code container record + `usage_session_id` | | `SpawnGitContext` | dataclass | runtime.py:28 | Git context for spawn (project_slug, branch, `task_short_id` for worktree) | | `MODEL_MAP` | dict | runtime.py:106 | short-name→full Claude id (opus→claude-opus-4-6, sonnet→claude-sonnet-5, haiku→…) | -| `ROLE_MODEL_MAP` | dict | runtime.py:114 | per-role default tier: developer/cell_pm/main_pm→sonnet, qa/documenter→haiku, pr_reviewer/auditor/board/ceo→opus (qa→haiku, main_pm→sonnet, pr_reviewer→opus are the cost-tuned defaults) | +| `ROLE_MODEL_MAP` | dict | runtime.py:120 | per-role default tier: developer/qa/documenter/cell_pm/main_pm→sonnet, pr_reviewer/auditor/board/ceo/prompter/secretary→opus. **Haiku retired from every delivery-lifecycle role (#680, 2026-07-24)**: qa/documenter moved haiku→sonnet — haiku can't reliably emit the structured envelopes the lifecycle runs on (`pass_review`'s per-AC `criteria_verified`, `delegate`'s `covers_parent_criteria`, the findings ledger), so a haiku QA/PM claims, gets validation-rejected, idles, respawns, and loops without progress. A structured-verb capability floor (`_below_capability_floor`/`_floor_below_capability`, `roboco/services/llm.py`) additionally upgrades ANY below-floor Anthropic assignment to sonnet at resolution time — from a pin, a plain ROLE row, or a future map edit — so the floor can't be silently reintroduced. Non-Anthropic providers are untouched (an Anthropic-tier floor, not a provider policy). | | `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) | | `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` | | `PermissionLevel` | IntEnum | permissions.py:15 | CEO=0/BOARD=1/MAIN_PM=2/CELL_PM=3/CELL_MEMBER=4/AUDITOR=99 | @@ -6565,7 +6571,8 @@ The Choreographer is the server-side composition layer that turns agent intent-v | `_briefing_for` | async helper | `_impl.py:813` | Builds `context_briefing`. Claim-scoped: `full=True` (context-acquisition verbs only — give_me_work/claims/plan/resume/triage) carries the heavy sections via `_heavy_briefing_sections` (company_goals, recent_team_activity, blockers, task_handoff, institutional_memory); every other verb gets slim signals-only (unread a2a/mentions/notifications + metadata gaps). `include_company_goals=True` is a narrower, cheap-only opt-in (`_resolve_company_goals`) that fetches just the company_goals singleton without the rest of `full`'s heavy sections — used by `board_triage`'s idle branch so the CEO's charter (brand_voice/north_star) still reaches the Product Owner's roadmap-exploration / Head of Marketing's feature-spotlight-exploration one-shot spawns, whose directly-assigned exploration task is never itself a strategic root awaiting PM review (so the `full=True` branch never fires for it). AC coverage stays independent of `full`. | | `_run_claim_guards` | async helper | `_impl.py:916` | already_active / paused / unmet_dependency (with re-check race narrowing) + `_lane_claim_guard`; `skip_dev_guards=False` param skips dev-only guards for pr_reviewer gate claims (claim_gate_review). | | `_lane_claim_guard` | async helper | `_impl.py:977` | Out-of-order-start barrier: refuse code leaf behind an earlier open same-assignee sibling. Fail-closed on lookup error. | -| `_claim_plan_start_gate` | async helper | `_impl.py:1179` | spec gate → advisory claim lock (non-PM) → behavioral guards. | +| `_sequencing_claim_guard` | async helper | `_impl.py:1230` | Proactive claim-time check (#681): consults `TaskService.sequence_hold_reason` and, when held, returns `Envelope.sequence_held` (naming the blocking sibling) BEFORE the claim even reaches `TaskService.claim`'s bare-`None`-on-reject path — closes the misdiagnosed `INVALID_STATE` a held PENDING/NEEDS_REVISION claim used to surface as. | +| `_claim_plan_start_gate` | async helper | `_impl.py:1179` | spec gate → advisory claim lock (non-PM) → behavioral guards → `_sequencing_claim_guard`. | | `_claim_plan_start_run` | async helper | `_impl.py:1251` | `runner.run_intent(verb)` + ensure_work_session + `_touch`. | | `i_will_work_on` | async verb | `_impl.py:1326` | Dev claim+plan+start path; routes re-entry vs fresh claim. | | `open_pr` | async verb | `_impl.py:1571` | Pre-flight + `run_intent("open_pr")` (push_branch + create_pr side effects). | @@ -6591,6 +6598,9 @@ The Choreographer is the server-side composition layer that turns agent intent-v | `complete` | async verb | `_impl.py:6599` | Role-dispatch to `cell_pm_complete` / `main_pm_complete`; umbrella-in-progress bypasses spec gate. | | `main_pm_complete` | async verb | `_impl.py:6496` | Main PM merge + escalate to CEO (never merges master itself). | | `escalate_to_ceo` | async verb | `_impl.py:6844` | Escalate to `awaiting_ceo_approval`. | +| `unblock` | async verb | `_impl.py:7041` | Human/PM restores a BLOCKED task; gated by `_oscillation_unblock_guard` (refuses while oscillation-tripped) then, on a real restore, runs `_maybe_trip_oscillation_breaker` (#685). | +| `_oscillation_unblock_guard` | async helper | `_impl.py:7184` | Refuses `unblock` once `markers.is_oscillation_tripped(t)` — an admin override must clear the marker first. | +| `_maybe_trip_oscillation_breaker` | async helper | `_impl.py:7209` | Task-scoped strike counter (#685) at the `unblock()` chokepoint (agent-agnostic — legitimate `needs_revision` rework never calls `unblock`, so it structurally can't trip this): `markers.bump_oscillation_strikes(t, progress_fp)` where the progress fingerprint is commit count + `revision_count` + `terminal_children_count` (a coordination root's children advancing between escalations resets the count); past threshold, sets `blocker_resolver_type=HUMAN`, marks the task oscillation-tripped, and best-effort notifies the CEO (`_notify_ceo_oscillation`) naming both agents + the cycle count. | | `VerbRunner.run_intent` | async method | `_verb_runner.py:37` | pre_side_effects → SAVEPOINT(composes) → side_effects; intermediate-None raises INVALID_STATE. | | `VerbRunner._do_pr_merge` | async handler | `_verb_runner.py:257` | `pr_merge` with `project_id` scoping (cross-repo collision fix) + `resolve_parent_branch`. | | `ChoreographerHelpers` | stub class | `_protocol.py:31` | TYPE_CHECKING-only typed view of `self` helpers for role mixins. | @@ -6734,7 +6744,9 @@ The slice is structurally sound: the SAVEPOINT boundary, intermediate-None INVAL | `_validate_and_set_status` | method | task.py:548 | Single chokepoint: validate transition + git requirements, set status, poke dispatcher, emit audit. | | `_emit_status_transition_audit` | method | task.py:652 | Write `task.` audit row in caller session; bump `revision_count` on entry into `needs_revision`. | | `_alert_auditor_of_rework` | method | task.py:1019 | Best-effort helper that asks `NotificationDeliveryService` to send a HIGH `ALERT` to the auditor when a task enters `needs_revision`. Called from `fail_qa`, `pr_fail`, and `request_changes` immediately after `await self.session.flush()` so the transition row is visible before the alert is dispatched. | -| `create` | method | task.py:864 | New task; depth/batch/AC validation; branchless/umbrella flags; baseline constraints attachment; (V2) vault materialize-on-create. | +| `create` | method | task.py:864 | New task; depth/batch/AC validation; branchless/umbrella flags; baseline constraints attachment; (V2) vault materialize-on-create; stamps `acceptance_criteria_ids` through `_reconcile_ac_ids` (#682, explicitly supplied ids still win). | +| `update` | method | task.py:2928 | Generic PATCH field loop (task route, prompter `update_live_draft`/`_patch_batch_child`/`update_live_batch`) — derives `acceptance_criteria_ids` via `_reconcile_ac_ids` whenever `acceptance_criteria` is rewritten with no explicit id list (#682; previously overwrote the criteria text without touching ids, leaving them mismatched or empty and silently disabling the parent-coverage gate). | +| `_reconcile_ac_ids` | function | task.py | Pure helper (#682): one id per new criterion; text-unchanged criteria KEEP their existing id (children/findings reference criteria by id or exact text — a blanket re-mint would orphan every live reference); new/reworded text mints a fresh id; a dropped criterion drops its id. | | `_attach_baseline_constraints` | method | task.py:971 | Append conventions baseline constraints to task prompt (gated `conventions_enabled`). | | `_materialize_vault_note` | method | task.py:910 | V2: best-effort vault seam called from `create` — assembles + writes a deterministic task note (narrative placeholder) so a task is visible in the vault from the moment it exists, not just at Auditor curation/rebuild. Gated `obsidian_vault_enabled`; swallows + logs any failure. | | `list_updated_since` | method | task.py:7101 | V2: tasks touched (`COALESCE(updated_at, created_at)`) since a timestamp, ascending, paged — the vault janitor's changed-task re-projection set. | @@ -6749,9 +6761,12 @@ The slice is structurally sound: the SAVEPOINT boundary, intermediate-None INVAL | `_emit_admin_override_audit` | method | task.py:8555 | Factored out of `_apply_pre_block_restore`: writes `task.admin_override` audit row for admin-triggered blocked restores (`forced=False, restore=True`). | | `claim` | method | task.py:3112 | `FOR UPDATE` lock + `_validate_claim_preconditions` + `_finalize_claim`; calls `_validate_and_set_status(claimed)`. | | `_validate_claim_preconditions` | method | task.py:2883 | Per-claim validator chain: status, `_claim_blocked_by_sequencing` (dependency + sequence), team, pre-assignment theft, self-review. | -| `_claim_blocked_by_sequence` | method | task.py:2805 | Strict sibling-sequence gate: a PENDING/`needs_revision` task with parent + effective `sequence` (`COALESCE(sequence, 0)`) N is held while any same-parent sibling with a strictly lower effective sequence is non-terminal — assignee-blind, independent of `dependency_ids`. Ties run parallel; cancelled siblings never block. | +| `_claim_blocked_by_sequence` | method | task.py:2805 | Sequence gate for a PENDING/`needs_revision` task with a parent + effective `sequence` (`COALESCE(sequence, 0)`) N — **reachability-aware since #681**: it branches on `is_batch_root_subtask(task.batch_id, task.parent_task_id)`. A MegaTask root-subtask keeps the ORIGINAL strict rule unchanged (held while ANY same-parent sibling with a strictly lower sequence is non-terminal — assignee-blind, edge-agnostic; `sequence` there is a one-shot globally-computed Kahn wave, a deliberate staged-release barrier). Every other same-parent context instead routes through the pure `sequence_blocker_id` (`roboco/services/sequencing.py`), which only lets a lower-sequence sibling block when it is a real transitive predecessor via `dependency_ids` UNIONED with `completed_dependency_ids` — a task with NO dependency edge onto any same-parent sibling still falls back to the raw edge-agnostic bar (preserves the pre-#681 #452 edge-less-PM-delegation scenario). Ties run parallel; cancelled siblings never block. | +| `sequence_blocker_id` | function | services/sequencing.py | Pure reachability check backing the non-batch branch of `_claim_blocked_by_sequence` (#681): returns the blocking sibling's id, or `None`, from `dependency_ids ∪ completed_dependency_ids` — the union matters because `_unblock_dependents` prunes a completed dependency's edge into `completed_dependency_ids` the moment it lands, almost always before the dependent is ever claimed. | +| `sequence_hold_reason` | method | task.py | Names the blocking sibling for the caller instead of a bare `None`; feeds the gateway's `Envelope.sequence_held` (#681) so a held claim on PENDING or NEEDS_REVISION surfaces cleanly instead of `claim()`'s bare `None` return being misdiagnosed by the verb runner as a concurrent-transition `INVALID_STATE`. | | `_claim_blocked_by_dependencies` | method | task.py:2781 | `unmet_dependency` TIMING gate: refuses claim while any `dependency_ids` entry is non-terminal. | -| `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). | +| `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); extended to NEEDS_REVISION so `give_me_work`'s two offer paths (`list_pending_for_agent`, the Choreographer's `_drop_dependency_held`) never offer a task the reachability-aware bar (#681) is about to reject. | +| `terminal_children_count` | method | task.py:9711 | Count of a task's terminal (completed/cancelled) direct children; feeds the orchestrator oscillation breaker's progress fingerprint (#685, `roboco/services/gateway/choreographer/_impl.py`) so a coordination root whose children advance between escalate/unblock round-trips resets its strike count instead of accruing toward a false trip. | | `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: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. | @@ -6860,6 +6875,8 @@ stateDiagram-v2 - `complete()` requires PR merged (`_assert_pr_merged_for_complete`) EXCEPT branchless roots; `ceo_approve` separately checks `work_session.pr_status=="merged"` and refuses otherwise. - Background indexing/learning/cleanup tasks are tracked on `self._background_tasks` and are best-effort — a failure never blocks the transition. - The sequence gate (`_claim_blocked_by_sequence`) is enforced ONLY in `_validate_claim_preconditions`, i.e. inside `claim` itself — both the gateway claim verbs AND the orchestrator's raw dispatch claim cross it because they both funnel through `TaskService.claim`, unlike the pre-#382 dependency gate which briefly lived only on the gateway side. Any future claim path that bypasses `TaskService.claim` (a raw `admin_set_status`, for instance) does NOT get sequence enforcement. +- Since #681, the sequence gate is NOT one rule everywhere: `stamp_wave_sequence` stamps a sibling's wave from a partial, per-task view of the graph at delegate time, which is fine within one connected chain but can coincidentally collide two INDEPENDENT same-parent dev-task streams onto the same raw sequence number. The strict edge-agnostic rule survives ONLY for `is_batch_root_subtask` rows (a MegaTask's globally-computed Kahn wave is a deliberate barrier); every other same-parent context resolves via `sequence_blocker_id`'s real-predecessor check. A task with an empty `dependency_ids`/`completed_dependency_ids` union onto its same-parent siblings still falls back to the raw bar unchanged either way. +- An `acceptance_criteria` rewrite through `update()` with NO explicit `acceptance_criteria_ids` used to silently desync ids from text (or leave an empty id list, which the parent-coverage gate then reads as "no criteria to cover" — waiving coverage for the whole subtree). `_reconcile_ac_ids` (#682) closes this at the one chokepoint every rewrite path shares; the parent-coverage gate also self-heals an out-of-length/empty id list in place rather than returning early. - `_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. @@ -6882,6 +6899,10 @@ stateDiagram-v2 > (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`. +> +> `c4ba351a` (#681, "reachability-aware claim bar + sequence_held surfacing", 2026-07-24) The sequence-drift fix: `_claim_blocked_by_sequence` branches on `is_batch_root_subtask` so an independent same-parent dev-task stream can no longer phantom-hold behind an unconnected sibling that coincidentally shares a lower raw `stamp_wave_sequence` number; non-batch contexts route through the new pure `sequence_blocker_id` (real transitive predecessor via `dependency_ids ∪ completed_dependency_ids`). New `sequence_hold_reason` + a proactive gateway guard return `Envelope.sequence_held` instead of a bare `None` reaching the verb runner as a misdiagnosed `INVALID_STATE`. `is_pending_claim_blocked` extended to NEEDS_REVISION so both `give_me_work` offer paths never dangle a task the claim gate would reject. +> +> `3516d925` (#682, "reconcile acceptance_criteria_ids at the update chokepoint", same day) `create()` and `update()` both now stamp `acceptance_criteria_ids` through the new pure `_reconcile_ac_ids` (id-preserving for text-unchanged criteria, fresh id for new/reworded text, dropped id for a removed criterion) — every prior post-create rewrite path (task PATCH, prompter `update_live_draft`/`_patch_batch_child`/`update_live_batch`) had silently desynced or emptied the ids, and an empty id list silently waived the whole subtree's parent-coverage gate. The gate itself now self-heals an empty/out-of-length id list in place instead of returning early. ## Regression Risks @@ -6986,7 +7007,7 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService._merge_with_retry` | method | git.py:3555 | Single 409 retry + already-merged disambiguation → MergeConflictError | | `GitService._lock_parent_task_for_merge` | method | git.py:3503 | SELECT FOR UPDATE on parent task (sibling merge serialization) | | `GitService._resolve_merger_id` | static | git.py:3530 | merged_by attribution: actor→assigned→created→UUID(0) | -| `GitService.rebase_onto_base` | method | git.py:3733 | Rebase primitive: rebased/superseded/conflicts classification | +| `GitService.rebase_onto_base` | method | git.py:5158 | Rebase primitive: rebased/superseded/conflicts/**diverged** classification. Post-fetch, `_reset_head_or_diverged` classifies local vs `origin/` BEFORE the old unconditional `reset --hard`: behind-or-equal resets to origin (unchanged); strictly ahead skips the reset and rebases from the local tip (the committed-but-unpushed-work case `commit` leaves behind); a real divergence returns `{"status": "diverged", "local_only", "origin_only"}` untouched — no reset, no rebase, no push. A patch-equivalence probe (`rev-list --right-only --cherry-pick`) first tries to recognize the diverged shape as a prior rebase whose force-push failed (self-heals as "ahead" on retry) before declaring a genuine two-sided divergence (#683). | | `GitService.rebase_pr_for_task` | method | git.py:3792 | PR-keyed rebase via PR refs (project_id scoped) | | `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 | @@ -7007,7 +7028,8 @@ This slice is the git substrate every delivery agent works on. `GitService` runs | `GitService.conventions_check_for_task` | method | git.py:4368 | Run conventions validator on changed files (fail-closed) | | `GitService._run_conventions_validator` | method | git.py:4408 | Subprocess `python -m roboco.conventions` with 120s cap | | `GitService.open_conventions_pr` | method | git.py:4456 | Scaffold `.roboco/conventions.yml` on a branch + open PR | -| `GitService.diff` / `list_changed_files` / `read_file_at_branch` | methods | git.py:4192/4225/4259 | Read-only git queries (gateway + routes) | +| `GitService.diff` / `list_changed_files` / `read_file_at_branch` | methods | git.py:5943/5984/6021 | Read-only git queries (gateway + routes); each resolves its head ref through `_resolve_head_ref` | +| `GitService._resolve_head_ref` | method | git.py:5893 | Shared reader-ref resolution for `diff`/`list_changed_files`/`read_file_at_branch`/`/api/git/log`. Fetches `branch_name`, then prefers `origin/` over the bare local ref whenever origin carries commits local lacks (`origin_only > 0` via `rev-list`) — a reviewer's clone parked on pre-rebase history no longer stays frozen there across every later round; local wins only when it strictly contains everything origin has. Unlike the write-path's `_reset_head_or_diverged`, a read never needs to tell genuine divergence apart from a rewritten history — both resolve to "serve origin" (#690). | | `GitService.commit` | method | git.py:4286 | Gateway content-verb commit (branch-keyed) | | `get_git_service` | factory | git.py:4594 | Construct GitService from AsyncSession | | `build_branch_name` | func | templates/git/branch.py:37 | `{type}/{team}/{root}--{sub}--...` up to MAX_TASK_DEPTH | @@ -7217,7 +7239,8 @@ Module-level tunables (not env): `_SLOW_GIT_OP_MS=5000`, `_CI_RUN_WINDOW=20`, `_ - **`get_current_branch` raises on detached HEAD** instead of returning `""` — the empty string used to leak "(HEAD detached at ...)" into `checkout -b`. - **`MAX_TASK_DEPTH=4`** (was 3) — MegaTask's umbrella→root→cell→dev needs 4; validator rejects a child whose depth would *reach* MAX_TASK_DEPTH, so 4 permits dev at depth 3. - **Branch name uses 8-char UUID prefix** (`_SHORT_ID_LEN=8`), not full UUID — full UUIDs produced 140-char branch names. -- **`rebase_onto_base` force-pushes with `--force-with-lease`** only the head branch; never touches base. `superseded` (unique==0) means close-without-merge. +- **`rebase_onto_base` force-pushes with `--force-with-lease`** only the head branch; never touches base. `superseded` (unique==0) means close-without-merge. It no longer resets a local tip that is ahead of origin (a committed-but-unpushed dev tip used to be silently discarded before the rebase); a `diverged` result leaves BOTH sides untouched — the sync_branch verb maps it to `i_am_blocked` with a stash-preserved note (#683). +- **Reader git queries prefer origin over a diverged local ref** (`_resolve_head_ref`, #690): a QA/PM/reviewer clone that never pushed can go stale relative to origin after a force-push sync; `diff`/`list_changed_files`/`read_file_at_branch` and `roboco_git_log` all resolve through the same fixed helper now, so a repeated review round no longer re-examines a frozen pre-rebase checkout. - **`is_behind_base` raises on git failure**; the i_am_done gate fail-opens on the raised error so a flaky fetch can't strand the task. - **Conventions validator fails closed** (`could_not_run=True` blocks submit) on resolution error / timeout / non-zero exit; branchless + no-changed-files fail open. - **`_assert_on_task_branch` never discards work** — it does `checkout`, not `reset --hard`, to preserve a resumed agent's unpushed commits. @@ -7248,6 +7271,8 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr > > `496c24d1` (PR #548, "git hygiene", 2026-07-17) Local branch refs stop leaking alongside remote ones: `delete_task_branch` now also skips environment-ladder rungs (previously only the remote-delete's own main/master/develop guard existed) and returns `bool`; new `cleanup_stale_branches` + `_stale_branch_window` + `_cleanup_one_stale_branch` back a PM/CEO-only `POST /git/branches/cleanup` sweep of terminal tasks' remote+local branches, exposed as a confirm-dialog button on the panel Git page. See `docs/map/task-service.md` for the paired per-task reap at cancel/completion and `docs/map/workspace.md` for the new `WorkspaceService.delete_local_branch` primitive both routes share. > +> `71f5426e` (PR #683, "never discard committed local work in rebase_onto_base", 2026-07-24) The stale-state family fix for the write path: `rebase_onto_base` classifies local vs `origin/` post-fetch instead of an unconditional `reset --hard` — behind/equal resets as before, strictly-ahead skips the reset and rebases from the local tip, and a genuine two-sided divergence (patch-equivalence-checked first, so a prior failed force-push self-heals as "ahead" on retry) returns `{"status": "diverged", ...}` with neither side touched. `sync_branch`/submit-freshen/the merge-conflict resolver all gained a `diverged` handling branch. `e97f46af` (PR #690, "reviewer reads must prefer origin over a diverged local ref", same day) is the read-path sibling: `_resolve_head_ref` (backing `diff`/`list_changed_files`/`read_file_at_branch`/`roboco_git_log`) now prefers origin whenever it carries commits the local ref lacks, closing the QA/PM-clone-frozen-on-pre-rebase-history bounce loop. +> > **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" `` (Auto-detect/GitHub/Gitea/GitLab, `project.git_provider`) — see `docs/map/worksession-git.md` | +| Project Settings / Conventions | `app/(dashboard)/projects/[projectId]/settings/page.tsx` + `components/projects/settings/*.tsx` + `components/conventions/conventions-tab.tsx` | Wave C full-page project settings (card-per-concern, per-card save) — was the ~30-field `edit-project-dialog.tsx` (deleted). The list's Edit action routes here; a slim `quick-edit-project-dialog.tsx` (name/cell/active) covers the fast path. Conventions is a page-level tab: per-project `.roboco/conventions.yml` map + health, Save/Restore via PR. The "Forge" `` (Auto-detect/GitHub/Gitea/GitLab, `project.git_provider`) — see `docs/map/worksession-git.md` | +| Project Settings / Conventions | `app/(dashboard)/projects/[projectId]/settings/page.tsx` + `components/projects/settings/*.tsx` + `components/conventions/conventions-tab.tsx` | Wave C full-page project settings (card-per-concern, per-card save) — was the ~30-field `edit-project-dialog.tsx` (deleted). The list's Edit action routes here; a slim `quick-edit-project-dialog.tsx` (name/cell/active) covers the fast path. Conventions is a page-level tab: per-project `.roboco/conventions.yml` map + health, Save/Restore via PR. The "Forge" `` (`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. diff --git a/docs/map/workspace.md b/docs/map/workspace.md index fcd640d7..057b54e1 100644 --- a/docs/map/workspace.md +++ b/docs/map/workspace.md @@ -37,7 +37,9 @@ WorkspaceService manages the per-agent git clone layout under {workspaces_root}/ | WorkspaceService.ensure_worktree | method | roboco/services/workspace.py:555 | git worktree add -b (or reuse existing branch); calls _park_clone_root_off_branch first; link venv; chown worktree + clone root | | WorkspaceService.ensure_worktree_for_resume | method | roboco/services/workspace.py:591 | Re-add a pruned worktree on resume (no -b; branch ref survives); calls _park_clone_root_off_branch first; idempotent; link venv + chown | | WorkspaceService._fetch_branch_ref | method | roboco/services/workspace.py:613 | Token-aware git fetch origin into clone_root; best-effort (never raises); used by ensure_worktree_self_heal (536bbb64) | -| WorkspaceService.ensure_worktree_self_heal | method | roboco/services/workspace.py:671 | Orchestrator spawn-time chokepoint: re-attaches a per-task worktree after clone vanished (redeploy/disk loss); fetches branch ref from origin when the local ref is absent after a re-clone, then delegates to ensure_worktree (536bbb64) | +| WorkspaceService.ensure_worktree_self_heal | method | roboco/services/workspace.py:716 | Orchestrator spawn-time chokepoint, now role-aware (`can_author`, #692): a PRESENT worktree is no longer a pure venv+chown no-op — it runs through `_refresh_present_worktree` first, so a worktree created once at first claim/`claim_review` no longer stays frozen at that commit across every later respawn. A vanished-clone re-attach still fetches the branch ref from origin when the local ref is absent (536bbb64); a surviving-ref-but-pruned-worktree re-add now also runs the same refresh classification instead of trusting the stale ref's commit. | +| WorkspaceService._refresh_present_worktree | method | roboco/services/workspace.py:869 | Compares local `branch` HEAD against `origin/` (rev-list ahead/behind): behind-or-equal fast-forwards for every role, but never discards an author-capable role's (`can_author`) dirty uncommitted edits to do it; strictly ahead is always left alone (real unpushed work); diverged only hard-resets for a pure reader (QA/PR-gate/PM), whose local history can only ever be a stale prior round's checkout — never an author, since only developer/documenter hold the gateway `commit` tool. Every reset is additionally guarded by `_safe_to_reset` (#692). | +| WorkspaceService._worktree_is_dirty / `_worktree_on_task_branch` / `_safe_to_reset` | methods | roboco/services/workspace.py | Dirty-guard trio backing `_refresh_present_worktree`: a failed `git status` reads as dirty (fail toward preservation, never a false-clean reset); a reset only ever fires when the worktree is actually still checked out on `branch` (a detached/drifted worktree is left alone with a warning) (#692) | | WorkspaceService.remove_worktree | method | roboco/services/workspace.py:733 | Best-effort git worktree remove --force + prune; no-op if gone (cancel/terminal/reaper evict) | | WorkspaceService.delete_local_branch | method | roboco/services/workspace.py:787 | Best-effort `git branch -d/-D ` in a clone; never raises; skips main/master/develop/empty (mirrors GitService._delete_remote_branch_best_effort); callers run it AFTER remove_worktree (a still-checked-out branch refuses) | | WorkspaceService.resolve_workspace | method | roboco/services/workspace.py:745 | Look up agent (UUID or slug) -> team+slug -> workspace path; default team BACKEND | @@ -70,7 +72,7 @@ WorkspaceService manages the per-agent git clone layout under {workspaces_root}/ | get_workspace_service | function | roboco/services/workspace.py:1930 | Factory: WorkspaceService(session) | ## Data Flow -Inputs: an AsyncSession, a project_slug, an agent_id (UUID or slug), optionally a git_url/default_branch/force. The orchestrator, GitService, TaskService, conventions service, dep_update_engine, and gateway content_actions all obtain a WorkspaceService via get_workspace_service(session) (or WorkspaceService(db) directly in the spawn path). Control flow on ensure_workspace: _lookup_agent_or_raise -> get_workspace_path -> acquire per-(project,agent) asyncio.Lock -> if _is_workspace_healthy (.git+HEAD+objects): _ensure_agent_owned (to_thread), prune broken refs, scoped _fetch_origin_best_effort (30s TTL, force override), re-chown, install_dev_deps (digest-cache hit short-circuits), return. Else: rmtree any partial/stub dir, ProjectService.get_by_slug, resolve the clone target branch via `head_branch(project)` (the env-ladder head rung — `roboco.models.env_branches`, shimmed from `default_branch` when no ladder is declared), _resolve_git_token (decrypt PAT; raise on HTTPS-with-no-token), _clone_repo (git clone --branch --no-tags, configure identity/fileMode, scrub PAT from remote URL, _assert_no_pat_leak scanning .git/** for ghp_/github_pat_/x-access-token, chown, install_dev_deps), _maybe_scaffold_conventions (once-per-process, flag-gated). Per-task worktree path: get_clone_root_path + get_worktree_path (.worktrees/{task_short_id}); ensure_worktree runs git worktree add -b (or reuses an existing branch ref), _link_shared_venv (symlink to clone-root .venv only if it exists), chowns both worktree and clone root. GitService.create_branch calls ensure_worktree; commit/rebase paths call ensure_worktree_for_resume via GitService._ensure_worktree_for_commit; the orchestrator's _ensure_worktree_before_spawn calls ensure_worktree_self_heal (post-536bbb64) which first fetches the branch ref from origin if the local ref is absent after a re-clone, then delegates to ensure_worktree; TaskService.complete/cancel call remove_worktree. ensure_read_clone is called by ConventionsService for the project-level read clone at _meta/conventions, hard-reset to origin/default. dry_upgrade_changes_lockfile (dep_update_engine) clones the read clone --local --no-hardlinks into a throwaway under the read-clone lock, runs dep_update_command, and checks git status --porcelain on lockfile paths. Outputs: workspace Path (and side effects: on-disk clone/worktree, .venv symlink, .git/.roboco-dep-install + .git/.roboco-toolchain markers, root-owned refs re-chowned to agent uid). All git/subprocess work runs via asyncio.to_thread; tokens are injected only transiently into argv (never written to .git/config) and scrubbed post-clone. +Inputs: an AsyncSession, a project_slug, an agent_id (UUID or slug), optionally a git_url/default_branch/force. The orchestrator, GitService, TaskService, conventions service, dep_update_engine, and gateway content_actions all obtain a WorkspaceService via get_workspace_service(session) (or WorkspaceService(db) directly in the spawn path). Control flow on ensure_workspace: _lookup_agent_or_raise -> get_workspace_path -> acquire per-(project,agent) asyncio.Lock -> if _is_workspace_healthy (.git+HEAD+objects): _ensure_agent_owned (to_thread), prune broken refs, scoped _fetch_origin_best_effort (30s TTL, force override), re-chown, install_dev_deps (digest-cache hit short-circuits), return. Else: rmtree any partial/stub dir, ProjectService.get_by_slug, resolve the clone target branch via `head_branch(project)` (the env-ladder head rung — `roboco.models.env_branches`, shimmed from `default_branch` when no ladder is declared), _resolve_git_token (decrypt PAT; raise on HTTPS-with-no-token), _clone_repo (git clone --branch --no-tags, configure identity/fileMode, scrub PAT from remote URL, _assert_no_pat_leak scanning .git/** for ghp_/github_pat_/x-access-token, chown, install_dev_deps), _maybe_scaffold_conventions (once-per-process, flag-gated). Per-task worktree path: get_clone_root_path + get_worktree_path (.worktrees/{task_short_id}); ensure_worktree runs git worktree add -b (or reuses an existing branch ref), _link_shared_venv (symlink to clone-root .venv only if it exists), chowns both worktree and clone root. GitService.create_branch calls ensure_worktree; commit/rebase paths call ensure_worktree_for_resume via GitService._ensure_worktree_for_commit; the orchestrator's _ensure_worktree_before_spawn calls ensure_worktree_self_heal at EVERY spawn (not just after a vanished clone, post-#692), passing `can_author` from the spawning agent's role (`WORKTREE_AUTHOR_ROLES` — developer/documenter): a present worktree now runs through `_refresh_present_worktree` (fast-forward when behind, leave alone when ahead, hard-reset only for a non-author reader when diverged) before venv-link + chown; a vanished clone still fetches the branch ref from origin if the local ref is absent, and a surviving-ref-but-pruned worktree re-adds then runs the identical refresh; TaskService.complete/cancel call remove_worktree. ensure_read_clone is called by ConventionsService for the project-level read clone at _meta/conventions, hard-reset to origin/default. dry_upgrade_changes_lockfile (dep_update_engine) clones the read clone --local --no-hardlinks into a throwaway under the read-clone lock, runs dep_update_command, and checks git status --porcelain on lockfile paths. Outputs: workspace Path (and side effects: on-disk clone/worktree, .venv symlink, .git/.roboco-dep-install + .git/.roboco-toolchain markers, root-owned refs re-chowned to agent uid). All git/subprocess work runs via asyncio.to_thread; tokens are injected only transiently into argv (never written to .git/config) and scrubbed post-clone. ## Mermaid ```mermaid @@ -159,7 +161,7 @@ WorkspaceService slice | ensure_workspace | roboco/services/workspace.py | GitService.create_branch_for_task / push / PR ops; orchestrator spawn ensure; gateway content_actions; called transitively by many verbs | | ensure_worktree | roboco/services/workspace.py | GitService.create_branch_for_task on fresh claim (worktree add -b ) | | ensure_worktree_for_resume | roboco/services/workspace.py | GitService._ensure_worktree_for_commit (commit/rebase paths) | -| ensure_worktree_self_heal | roboco/services/workspace.py | orchestrator._ensure_worktree_before_spawn before -w container launch (replaces the former ensure_worktree_for_resume call there; handles vanished clones + missing branch refs) | +| ensure_worktree_self_heal | roboco/services/workspace.py | orchestrator._ensure_worktree_before_spawn before -w container launch (replaces the former ensure_worktree_for_resume call there; handles vanished clones + missing branch refs, and — post-#692 — refreshes an already-present worktree against origin at every respawn, role-aware via `can_author`) | | remove_worktree | roboco/services/workspace.py | TaskService terminal/cancel paths + claim-rollback (mid-claim failure) | | delete_local_branch | roboco/services/workspace.py | TaskService terminal/cancel paths (right after remove_worktree) + GitService.cleanup_stale_branches sweep | | ensure_read_clone | roboco/services/workspace.py | ConventionsService.scaffold/effective-map reads (project-level conventions metadata) | @@ -222,18 +224,20 @@ WorkspaceService slice > Further post-snapshot update (#534, env-branches ladder): `ensure_workspace`'s fresh-clone branch and `ensure_read_clone` both resolve their target branch via `roboco.models.env_branches.head_branch(project)` — the env-ladder's head rung — instead of reading `project.default_branch` directly. A project with no declared ladder resolves to the identical `default_branch` value via the read-time shim, so this is behavior-preserving until the CEO declares a real ladder in the panel. > > (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Added `delete_local_branch` (line 787) so `TaskService`'s cancel/terminal-completion cleanup and `GitService.cleanup_stale_branches` can reap a spent local branch ref, not just the worktree — previously every task an agent ever claimed leaked a permanent `refs/heads/{branch}` in that agent's clone. +> +> `987eb09c` (PR #692, "role-aware worktree refresh at every spawn", 2026-07-24) Closed the frozen-worktree-across-respawns bug behind a live multi-round QA/PR-gate bounce loop: `ensure_worktree_self_heal` gained a `can_author` parameter (from `foundation.identity.WORKTREE_AUTHOR_ROLES` — developer/documenter) and now runs every PRESENT or re-added worktree through the new `_refresh_present_worktree` before venv-link + chown, not just a vanished-clone re-attach. Behind-or-equal fast-forwards for every role (an author's dirty tree is left untouched to do it); strictly ahead is always left alone; diverged only hard-resets for a pure reader. `conventions_check_for_task`'s list-vs-content gap closes as a side effect — a reviewer's worktree is current as of spawn. ## Regression Risks | Title | File:Line | Claim | Severity | |---|---|---|---| | Worktree .venv symlink self-heal depends on a later ensure firing | roboco/services/workspace.py:534 | ensure_worktree (fresh claim) runs _link_shared_venv BEFORE install_dev_deps provisions clone_root/.venv, so the symlink is skipped on the first claim. The shared-venv optimization only self-heals if a later ensure (resume/commit via _ensure_worktree_for_commit) re-runs _link_shared_venv. If the agent commits via a path that does not re-invoke ensure and uv re-syncs a worktree-local .venv first, the lexists guard prevents replacing the real dir and the worktree is stuck with a duplicated venv. The F-fix mitigated the dangling-symlink case but did not close the already-clobbered-venv recovery (explicitly out of scope per commit msg). | medium | -| ensure_worktree reuses an existing branch ref without validating it points at base | roboco/services/workspace.py:570 | When branch_exists is True (re-claim after rollback) ensure_worktree runs `worktree add ` with no -b and no base. If the surviving branch ref was left at an unexpected commit (e.g. a prior partial rebase, or a force-pushed-and-locally-stale ref), the worktree is created at that commit, not at the intended base. The caller (GitService.create_branch_for_task) assumes a fresh branch at base; a stale ref could spawn the agent on the wrong HEAD. | medium | -| ensure_worktree_for_resume silently re-adds a worktree whose branch was force-updated remotely | roboco/services/workspace.py:591 | On resume via GitService._ensure_worktree_for_commit, ensure_worktree_for_resume re-adds the worktree from the surviving local branch ref (no fetch, no base). If the branch was force-pushed remotely while the agent was down and the local ref is stale, the agent resumes on the old commits with no warning. NOTE: the orchestrator spawn path (cfe725da/536bbb64) now calls ensure_worktree_self_heal instead, which fetches the branch ref from origin before re-attaching — the spawn path is resolved. The GitService commit path still uses ensure_worktree_for_resume without a fetch. | medium | +| ensure_worktree reuses an existing branch ref without validating it points at base | roboco/services/workspace.py:570 | When branch_exists is True (re-claim after rollback) ensure_worktree runs `worktree add ` with no -b and no base. If the surviving branch ref was left at an unexpected commit (e.g. a prior partial rebase, or a force-pushed-and-locally-stale ref), the worktree is created at that commit, not at the intended base. RESOLVED for the orchestrator spawn path (#692): `ensure_worktree_self_heal` now runs every re-add through `_refresh_present_worktree`, which fetches origin and reconciles the ref before the agent starts working. The GitService commit path (`_ensure_worktree_for_commit` → `ensure_worktree_for_resume`) still calls the bare, unrefreshed re-add — residual risk there only. | low | +| ensure_worktree_for_resume silently re-adds a worktree whose branch was force-updated remotely | roboco/services/workspace.py:591 | On resume via GitService._ensure_worktree_for_commit, ensure_worktree_for_resume re-adds the worktree from the surviving local branch ref (no fetch, no base). If the branch was force-pushed remotely while the agent was down and the local ref is stale, the agent resumes on the old commits with no warning. RESOLVED for the orchestrator spawn path: `ensure_worktree_self_heal` (#692) now runs a PRESENT worktree — and a re-added one recovered from a surviving ref — through the same `_refresh_present_worktree` classification at every respawn (fast-forward when behind, reset only for a non-author role when diverged), not just after a vanished clone. The GitService commit path still uses the bare, unrefreshed `ensure_worktree_for_resume`. | low | | PAT-leak scan cannot run if .git was wiped by a prior failed rmtree | roboco/services/workspace.py:1391 | _assert_no_pat_leak (line 1352) guards on `git_dir.exists()` and returns early if not. If a catastrophic clone left .git partially absent but the auth URL written elsewhere (e.g. into .git/config before .git/objects was created), the early return means the leak check is skipped. Combined with the F063 rmtree-on-failure this is low risk, but a rmtree that fails silently (ignore_errors=True at line 1382 only triggers on leak detection, not on the failure branches) could leave a tokenized .git/config. | low | | dry_upgrade probe lock gap could race a future interleaved sync | roboco/services/workspace.py:1673 | The dep-update probe acquires the _meta-conventions lock only around _clone_local_into (line 1676) and releases it before _probe_lockfile_on_clone (line 1679). The commit msg argues this is safe because any concurrent _sync_read_clone completes under the lock first. This holds ONLY because _sync_read_clone is the sole other holder; if a future change adds a third concurrent mutator of the read clone that interleaves between the release and re-acquire (none today), the local clone could read a half-mutated source. Fragile invariant documented only in the commit, not enforced. | low | | _fetch_origin_best_effort TTL cache not shared between clone root and worktree paths | roboco/services/workspace.py:1038 | _fetch_cache is keyed by str(workspace) on the instance. ensure_workspace is called with the clone-root path, but a worktree-path caller (none currently call ensure_workspace directly with a worktree path, but _resolve_clone_root exists to support worktree-aware uv env) would get a separate cache entry. Not a current bug, but a future worktree-aware ensure_workspace call could double-fetch. | low | | _ensure_agent_owned walk excludes .venv/node_modules but agent may need to write them | roboco/services/workspace.py:67 | _PRUNE_DIRS skips .venv, node_modules, .next etc. from the chown walk for speed. The agent normally owns these (it created them) and the symlinked worktree .venv points to the clone-root .venv which IS walked (it is not under a pruned name at clone root). But a worktree-local .venv created by uv when the symlink was missing (regression risk #1) would NOT be chowned, leaving the agent unable to write into it. Edge case, low severity. | low | ## 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. +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 GitService commit path's resume re-add still reusing a possibly-stale local branch ref without a fetch (the orchestrator spawn path's own version of this is now resolved by #692's role-aware `_refresh_present_worktree`), 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. diff --git a/docs/rag/architecture/review-findings.md b/docs/rag/architecture/review-findings.md index ba218433..9193751a 100644 --- a/docs/rag/architecture/review-findings.md +++ b/docs/rag/architecture/review-findings.md @@ -30,6 +30,7 @@ Each takes `findings: list[dict]` — a list of structured findings. The legacy - `severity`: `blocker` (must fix before merge/pass) → `major` (significant defect) → `minor` (small defect, fix advised) → `nit` (cosmetic). - `criterion`, if supplied, must match one of the task's acceptance-criterion ids or exact text — a criterion that matches neither is rejected outright, so a typo doesn't silently detach the finding from what it's actually about. +- `file`, if supplied, must actually LOOK like a repo-relative path (letters/digits/`._-/()[]+@`, no spaces) — narrative like "PR #676 description" is rejected outright instead of silently accepted and then dooming the panel's code-snippet fetch. Cross-cutting or narrative feedback that has no single file:line home should omit `file` entirely and put the detail in `evidence` instead — that's the legal file-less option, not a workaround. - **Count guard**: a soft nudge appears (non-blocking) above 5 findings in one call; more than 10 is a hard reject — split across calls or prioritize the blocking ones first. An oversized findings list is as unreviewable as an oversized task. ## What happens when you file one diff --git a/docs/rag/architecture/workspaces.md b/docs/rag/architecture/workspaces.md index 0b3f4b4c..09a4cc38 100644 --- a/docs/rag/architecture/workspaces.md +++ b/docs/rag/architecture/workspaces.md @@ -71,7 +71,7 @@ Your agent clone is **shared across all your tasks**, but each **claimed task** ``` - **Claim** (`i_will_work_on` / `claim_review` / `claim_doc_task`) adds a worktree at `.worktrees/{task-id-first-8}/` and checks out the task's branch there. The clone root's HEAD is **never moved** by a claim. -- **Your container is started with `-w` pointing at the worktree** for your current task, so `commit`, edits, and `uv run` all resolve there automatically. Spawn resolves the worktree from your `current_task_id` on every spawn (never cached), so a resume/respawn re-attaches a pruned worktree before launch. +- **Your container is started with `-w` pointing at the worktree** for your current task, so `commit`, edits, and `uv run` all resolve there automatically. Spawn resolves the worktree from your `current_task_id` on every spawn (never cached), so a resume/respawn re-attaches a pruned worktree before launch. Every spawn also RE-SYNCS an already-present worktree against origin (role-aware): if you're a developer/documenter, your own dirty uncommitted edits are never discarded to do it — a behind-or-equal worktree fast-forwards, a strictly-ahead one (your own unpushed commits) is left exactly alone; if you're QA/PR-gate/PM reviewing someone else's branch, a diverged worktree hard-resets to origin, since your local history there can only ever be a stale prior-round checkout. This closes the "reviewer keeps re-examining its own frozen round-1 checkout across a multi-round bounce" class — you're never respawned onto commits older than what's actually on the branch. - **The clone-root `.venv` is shared** — each worktree's `.venv` is a symlink to `../../.venv`, so `uv run` from a worktree resolves the clone-root venv. No per-worktree re-sync. - **Git ops split by kind**: checkout/HEAD-moving ops (`create_branch`, `commit`, `rebase`, `checkout`) target the worktree; branch-by-name ops (`push`, `pull`, `fetch`, `pr_merge`, `diff`) run from the clone root. You never do either by hand — the verbs resolve the worktree for you. - **One active WorkSession per task** is enforced both 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 session for that task. diff --git a/docs/rag/tools/task-tools.md b/docs/rag/tools/task-tools.md index 93de6c0e..069588ad 100644 --- a/docs/rag/tools/task-tools.md +++ b/docs/rag/tools/task-tools.md @@ -89,7 +89,7 @@ request_changes(task_id, findings=[...]) escalate_up(task_id, reason) # escalate to your escalation target ``` -After `i_will_plan` and each `delegate`, the envelope includes a coverage view of the parent — `parent_ac_coverage` (per-criterion `id` / `text` / `claimed` / `verified`) and `unclaimed_parent_acs` (criteria no subtask covers yet). A parent cannot idle with unclaimed criteria, nor `complete` / `submit_up` / `escalate_to_ceo` until every criterion traces to a child that passed QA. `delegate` refusing a child with no `covers_parent_criteria` (above) is what puts every parent with acceptance criteria under this coverage discipline from its first subtask on — a decomposition can no longer opt out by never declaring. `i_will_plan`'s planning briefing also carries `collision_context` (in `context_briefing`, not `evidence`) surfacing any same-parent siblings that already collide on file globs or migrations, so you can sequence your delegation before you commit to it. See `docs/rag/workflows/task-planning.md`. +After `i_will_plan` and each `delegate`, the envelope includes a coverage view of the parent — `parent_ac_coverage` (per-criterion `id` / `text` / `claimed` / `verified`) and `unclaimed_parent_acs` (criteria no subtask covers yet). A parent cannot idle with unclaimed criteria, nor `complete` / `submit_up` / `escalate_to_ceo` until every criterion traces to a child that passed QA. `delegate` refusing a child with no `covers_parent_criteria` (above) is what puts every parent with acceptance criteria under this coverage discipline from its first subtask on — a decomposition can no longer opt out by never declaring. A rejection now includes a copy-pasteable corrected `delegate(...)` skeleton with the parent's real criteria inlined (an id when the parent has one, its exact quoted text otherwise) — retry with that shape verbatim rather than re-deriving the field's syntax. `i_will_plan`'s planning briefing also carries `collision_context` (in `context_briefing`, not `evidence`) surfacing any same-parent siblings that already collide on file globs or migrations, so you can sequence your delegation before you commit to it. See `docs/rag/workflows/task-planning.md`. **Delegation rules** (enforced): `main_pm -> cell_pm`; `cell_pm -> its team's devs`. Cell PMs receive planning-typed parent tasks; devs get code/research (UX devs also design). Always create subtasks via `delegate` with `parent_task_id` set — there is no standalone task-create verb for agents. diff --git a/docs/rag/troubleshooting/git-errors.md b/docs/rag/troubleshooting/git-errors.md index 96a5bfe7..c87b67a8 100644 --- a/docs/rag/troubleshooting/git-errors.md +++ b/docs/rag/troubleshooting/git-errors.md @@ -69,6 +69,14 @@ Don't checkout by hand — there is no `roboco_git_checkout` tool. **Fix:** Either `commit(message=...)` (omit `files` to stage everything) then `sync_branch(task_id)` again, OR call `sync_branch(task_id, stash=True)` to auto-stash (tracked + untracked), rebase, and restore your changes in one call. If the stash pop conflicts, the envelope's `next` tells you so — your stash is preserved (never dropped); resolve by hand and `commit(...)`. Still stuck after that? `unclaim(task_id)` releases the claim back to the pool rather than looping. +## sync_branch refused: branch DIVERGED from its origin copy + +**Error:** `sync_branch` returns `next` telling you your branch and its origin copy have DIVERGED (both sides carry commits the other lacks). + +**Cause:** Your workspace and `origin/` each hold real work the other doesn't — not the routine "behind base" case. `sync_branch` first checks whether this is actually the harmless residue of an EARLIER `sync_branch` call whose rebase succeeded locally but whose force-push then failed (a network blip, a container reap) — that self-heals silently as an ordinary rebase on this retry. What's left after that check is a genuine divergence, most often your task bounced to a different agent's clone that pushed meanwhile. + +**Fix:** Neither side is touched or lost — nothing was reset, nothing was rebased, nothing was pushed. `i_am_blocked(reason='...')` naming the divergence so a human can reconcile the two histories by hand (fetch, inspect both tips, merge or rebase deliberately). If `stash=True` was passed and the stash pop ALSO conflicted, your stash is preserved (never dropped) — resolve it by hand alongside the divergence. + ## src refspec does not match any (during open_pr) **Error:** `src refspec '' does not match any` diff --git a/docs/rag/troubleshooting/task-errors.md b/docs/rag/troubleshooting/task-errors.md index 1c40835d..f7a6827c 100644 --- a/docs/rag/troubleshooting/task-errors.md +++ b/docs/rag/troubleshooting/task-errors.md @@ -115,7 +115,7 @@ escalate_to_ceo(task_id=parent_id, reason="...") **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. +**Cause** (only when task budgets are armed): a periodic sweep prices the task's own spend against its explicit `budget_usd` and blocks it before you get a chance to finish gracefully, so the dispatcher won't respawn onto an already-over-budget task. A task with no `budget_usd` set is uncapped at this check (explicit-input only — there is no `TaskType`-keyed default anymore); the project's `monthly_budget_usd` is the separate fleet-wide backstop. **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. diff --git a/docs/rag/workflows/task-claiming.md b/docs/rag/workflows/task-claiming.md index a133bc96..665f034d 100644 --- a/docs/rag/workflows/task-claiming.md +++ b/docs/rag/workflows/task-claiming.md @@ -51,7 +51,7 @@ If your task has `dependency_ids` in the same repo, the fresh branch cut also ba - **Self-review prevention**: QA cannot `claim_review` tasks they developed - **Self-documentation prevention**: Documenter cannot claim tasks they developed - **Branch requirement**: Branch auto-created on `i_will_work_on` -- **Sequence order (strict, assignee-blind)**: if a task has a parent and a `sequence` number, it cannot be claimed while any sibling with a strictly lower sequence is still non-terminal — regardless of who owns which task. Siblings on the SAME sequence run in parallel (independent work ties at 0, or at the wave a delegating PM stamped from the collision graph). This is independent of, and stricter than, `dependency_ids`: a claim attempt on a sequence-held task fails even with no unmet dependency. The error names the blocking sibling by title — `unclaim`/wait is the only remedy, there is no override verb. The dispatcher pre-filters sequence-held (and dependency-held) tasks before attempting a claim, so you should rarely see this in practice — but a claim you make directly (rather than via `give_me_work`) can still hit it. +- **Sequence order (assignee-blind, reachability-aware outside MegaTask batches)**: if a task has a parent and a `sequence` number, a lower-sequence sibling can hold it — but whether ANY lower-sequence sibling blocks, or only a REAL one, depends on context. Inside a MegaTask batch (a root-subtask under the umbrella), the rule is strict and edge-agnostic exactly as before: held while ANY same-parent sibling with a strictly lower sequence is non-terminal, regardless of whether the two tasks are actually connected (the batch's `sequence` is a one-shot, globally-computed staged-release wave). Everywhere else, a lower-sequence sibling only blocks when it's a real (transitive) predecessor via `dependency_ids` — a task with no dependency edge onto any same-parent sibling still falls back to the strict raw bar. Siblings on the SAME sequence always run in parallel; cancelled siblings never block. This is independent of, and stricter than (in the batch case), `dependency_ids`: a claim attempt on a sequence-held task fails even with no unmet dependency. A held claim now returns a dedicated `sequence_held` error naming the blocking sibling — `unclaim`/wait is the only remedy, there is no override verb. The dispatcher pre-filters sequence-held (and dependency-held) tasks before attempting a claim (including on `needs_revision` reclaims), so you should rarely see this in practice — but a claim you make directly (rather than via `give_me_work`) can still hit it. - **Project budget cap** (when task budgets are armed): `i_will_work_on` / `i_will_plan` are refused once the project's `monthly_budget_usd` has been reached this calendar month — a WORK-STARTING claim only, so a QA/doc/PR-review/PM-merge claim on already-in-flight work is never blocked by this. There is no override; wait for the next month or ask the CEO to raise the cap. ## Releasing a Claimed Task