feat: git hygiene (branch/preview reaping + cleanup sweep) and panel charts; work sessions under Git (#548)

* feat(panel): session-start, 7d overview spend, and 30d business spend charts

* feat(panel): surface work sessions as a Git page tab (route was orphaned)

* feat(git): reap spent task branches and render previews at lifecycle chokepoints; guarded stale-branch sweep

* feat(panel): stale-branch cleanup button on the Git page

* fix(git,panel): cursor-resumable sweep, force-delete spent refs, local filter state

* docs(map,rag): branch/preview reaping, cleanup sweep, git-tab work sessions, wave-2 charts

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:44:48 +02:00
committed by GitHub
co-authored by Renn F
parent 885d6bbe83
commit 496c24d186
39 changed files with 1841 additions and 222 deletions
+2
View File
@@ -120,6 +120,8 @@ On a Python workspace, `WorkspaceService` runs `uv sync --extra dev` (not plain
Because the clone is shared across a dev's tasks, a **fresh claim** git-resets the workspace to a clean tree (`git reset --hard`) before checking out the new task's branch — discarding abandoned uncommitted cruft from a finished task while preserving all commits and the gitignored `.venv`. A resume short-circuits before this, so committed work is never reset.
Terminal completion and cancellation also force-delete the task's local branch ref and its `.previews/` video-render dir in the assignee's clone (alongside the existing worktree removal), skipping any branch that coincides with an environment-ladder rung; a PM/CEO can additionally sweep older backlog branches project-wide via `POST /git/branches/cleanup` or the Git page's "Clean Up Stale Branches" button.
## Git Workflow
### Branch Naming Convention
+36 -5
View File
@@ -2446,7 +2446,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| roboco/api/routes/dashboard.py | CEO/auditor/kanban/metrics/agents/activity dashboards. |
| roboco/api/routes/tasks.py | Task CRUD + lifecycle transitions (claim/start/verify/qa/complete...). |
| roboco/api/routes/work_session.py | Work-session list/commit/files/PR/merge/complete/abandon. |
| roboco/api/routes/git.py | Per-project git status/log/diff/commit/push/PR/rebase. |
| roboco/api/routes/git.py | Per-project git status/log/diff/commit/push/PR/rebase/branch-cleanup sweep. |
| roboco/api/routes/project.py | Project CRUD + workspace/sync/access + conventions. |
| roboco/api/routes/product.py | Product CRUD. |
| roboco/api/routes/optimal.py | RAG: kb/search, rag/query, mentor/ask, learnings, decisions, review. |
@@ -2502,6 +2502,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
| GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context |
| POST | /api/git/branches/cleanup | git.py | agent context, PM/CEO role-gated like `/rebase`; rate-limit 5/60 — cursor-resumable stale-branch sweep, `GitBranchCleanupRequest`/`Response` (wave 2, open PR #548) |
| POST | /api/v1/flow/developer/{give_me_work,i_will_work_on,open_pr,i_am_done,unclaim,resume,sync_branch} | flow_dev.py | `require_dev` (role + HMAC) |
| POST | /api/v1/flow/qa/{claim_review,pass_review,fail_review} | flow_qa.py | `require_qa` |
| POST | /api/v1/flow/cell_pm/{delegate,submit_up,complete,triage,unblock,reassign} | flow_cell_pm.py | `require_cell_pm` |
@@ -2666,6 +2667,7 @@ roboco/api/
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) adds `POST /api/git/branches/cleanup` (PM/CEO role-gated like `/rebase`, rate-limit 5/60) + `GitBranchCleanupRequest`/`GitBranchCleanupResponse` schemas — cursor-resumable sweep of terminal tasks' remote+local branches, backing a confirm-dialog button on the panel Git page.
## Regression Risks
@@ -3605,7 +3607,10 @@ The slice is structurally sound: the SAVEPOINT boundary, intermediate-None INVAL
| `escalate_to_ceo` | method | task.py:5064 | `awaiting_pm_review→awaiting_ceo_approval`; gained `actor_agent_id: UUID | None = None` param (stamped as `audit_agent_id` so the transition row attributes to the specific PM/Board agent, not just the role). |
| `ceo_approve` | method | task.py:5146 | CEO merges then approves; `awaiting_ceo_approval→completed`. |
| `ceo_reject` | method | task.py:5414 | Reject → `needs_revision` (dev) or `pending` (branchless root via admin_set_status). |
| `_remove_task_worktree_on_terminal` | method | task.py:5601 | Best-effort worktree cleanup on complete/ceo_approve; no-op for branchless. |
| `_delete_task_branch_best_effort` | method | task.py:6726 | Cancel-path cleanup: remote branch delete + `_remove_task_worktree_best_effort(force_branch_delete=True)`; skipped once branch is unset. |
| `_remove_task_worktree_best_effort` | method | task.py:6767 | Shared worktree+local-branch+previews cleanup called by both cancel and terminal paths; force-deletes the local branch ref unless it's an environment-ladder rung (`effective_environments`). |
| `_cleanup_task_previews_best_effort` | method | task.py:6804 | `rmtree` the task's `.previews/{task8}` video-render dir; path-containment-checked against the project workspace dir before deleting. |
| `_remove_task_worktree_on_terminal` | method | task.py:6829 | Best-effort worktree + local-branch (force `-D`, squash-merge is never an ancestor) + previews cleanup on complete/ceo_approve; no-op for branchless. |
| `cancel` | method | task.py:5644 | Cascade-cancel descendants through the validator. |
| `reassign` / `reassign_active_claim` | method | task.py:7657 / 7807 | Reassignment with Board/Main-PM diversion guards. |
| `pr_pass` / `pr_fail` | method | task.py:8100 / 8137 | In-path PR-review gate verdicts. |
@@ -3645,7 +3650,7 @@ stateDiagram-v2
- TaskService
- State core: `_validate_and_set_status`, `_emit_status_transition_audit`, `admin_set_status`, `_restore_block_ownership`, `_emit_admin_override_audit`
- Create/shape: `create`, `_validate_parent_depth`, `_validate_batch_membership`, `activate`
- Branch/worktree: `_ensure_branch_for_task`, `_auto_create_branch`, `_remove_task_worktree*`
- Branch/worktree: `_ensure_branch_for_task`, `_auto_create_branch`, `_delete_task_branch_best_effort`, `_remove_task_worktree*`, `_cleanup_task_previews_best_effort`
- Claim: `claim`, `_finalize_claim`, `_inject_proactive_context`, `acquire_*_lock`
- Lifecycle verbs: `start`, `block*`, `unblock`, `pause`, `resume`, `submit_for_qa`, `pass_qa`, `fail_qa`, `docs_complete`, `submit_for_pm_review`
- Completion: `complete`, `_apply_complete_approval_chain`, `ceo_approve`, `ceo_reject`, `cancel`
@@ -3684,6 +3689,7 @@ stateDiagram-v2
- Branchless/umbrella/external-review tasks are exempt from the branch gate inside `GitContext` (task.py:597-611); umbrella is also exempt from the `awaiting_pm_review→awaiting_ceo_approval` pr_number gate.
- `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.
- 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.
## Drift from CLAUDE.md
- CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale.
@@ -3696,6 +3702,8 @@ stateDiagram-v2
- `3aff6e04` Chore: Close gaps (#285) — follow-on gap close (worktree-on-terminal cleanup F123 Phase C, escalation audit emit, rework routing hardening).
> Post-snapshot updates (since 2026-06-29): `20f1f9ba` admin_set_status: thread actor_id/actor_role into `_apply_pre_block_restore`; blocked→pending/in_progress restore now attributes the audit row to the admin actor (not the restored owner) and emits a `task.admin_override` row (forced=False, restore=True) independent of the force flag. `b3558d4e` complexity: extract `_restore_block_ownership` (line 8526) + `_emit_admin_override_audit` (line 8555) from `_apply_pre_block_restore` — no behavior change, splits a C-rank block for the xenon gate. `0e7674af` escalate_to_ceo gains `actor_agent_id: UUID | None = None` param stamped as audit_agent_id; push_branch / create_pr / create_root_pr / escalate_to_ceo side-effect handlers in the verb runner now forward actor_agent_id (was dropped, causing wrong workspace or role-only audit attribution).
>
> (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 the `worksession-git` section for the paired `GitService.cleanup_stale_branches` sweep.
## Regression Risks
@@ -3795,6 +3803,11 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `GitService.sync_task_branch` | method | git.py:3847 | Task-keyed rebase through dev `sync_branch` verb (pre-PR) |
| `GitService.is_behind_base` | method | git.py:3889 | `(behind, ahead)` counts for i_am_done submit gate |
| `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) |
| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) |
| `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` |
| `GitService.cleanup_stale_branches` | method | git.py:3711 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable |
| `GitService._stale_branch_window` | method | git.py:3777 | One deterministic `ORDER BY id` window of sweep candidates; ladder rungs excluded from results but still advance the cursor |
| `GitService._cleanup_one_stale_branch` | method | git.py:3810 | Per-branch remote+local delete for one sweep candidate; raises on unexpected failure so the caller's try/except counts it as an error |
| `GitService.pr_target` | method | git.py:4021 | Return PR base branch (project_id scoped) |
| `GitService.create_pr` | method | git.py:3418 | Branch-keyed open PR (gateway path; ensures base on remote) |
| `GitService._record_pr_atomically` | method | git.py:2601 | Atomic pr_number/url write to task |
@@ -3915,6 +3928,7 @@ roboco/
│ │ _pr_is_merged / _auto_complete_on_merge / _first_allowed_merge_method
│ ├── branch cleanup: _delete_remote_branch_best_effort / _delete_pr_branch_best_effort
│ │ delete_task_branch / _branch_has_open_dependents
│ │ cleanup_stale_branches / _stale_branch_window / _cleanup_one_stale_branch (sweep)
│ ├── rebase/sync: rebase_onto_base / rebase_pr_for_task / sync_task_branch / is_behind_base
│ ├── close: close_pull_request
│ ├── quality: run_pre_submit_quality_gate / toolchain_status_for_task / _fast_gate_commands
@@ -3954,7 +3968,7 @@ External:
## Entry Points
- **HTTP routes** (`roboco/api/routes/git.py`): `get_status`, `log`, `diff`, `commit_for_task`, `push_for_task`, `create_branch_for_task`, `checkout_branch_for_agent`, `create_pr_for_task`, `merge_pr_for_task`, `pull`, `fetch`, `rebase` — all construct via `get_git_service(db)`.
- **HTTP routes** (`roboco/api/routes/git.py`): `get_status`, `log`, `diff`, `commit_for_task`, `push_for_task`, `create_branch_for_task`, `checkout_branch_for_agent`, `create_pr_for_task`, `merge_pr_for_task`, `pull`, `fetch`, `rebase`, `cleanup_stale_branches` (`POST /git/branches/cleanup`, PM/CEO role-gated like `/rebase`, rate-limit 5/60) — all construct via `get_git_service(db)`.
- **HTTP routes** (`roboco/api/routes/tasks.py:253`): `get_git_service` for task-scoped git.
- **Gateway Choreographer** (`roboco/services/gateway/choreographer/`):
- `_verb_runner._do_pr_merge``pr_merge`
@@ -3999,6 +4013,8 @@ Module-level tunables (not env): `_SLOW_GIT_OP_MS=5000`, `_CI_RUN_WINDOW=20`, `_
- **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.
- **CEO-only master merge**: `pr_merge` refuses `target == default_branch` for agents; only `merge_pr_for_task` (CEO role-gated from `awaiting_ceo_approval`) may merge to master.
- **`cleanup_stale_branches` cursor is required, not optional**: task rows never change as a side effect of the sweep (unlike, say, a queue that drains), so a repeat call with no `after_cursor` re-scans the identical first 200-row window forever instead of progressing. `_stale_branch_window` still advances the cursor past ladder-rung rows even though they're excluded from `candidates`, so `truncated` can't false-negative when a rung lands inside the window.
- **Local branch delete in the sweep is always `force=True`** (`-D`) regardless of completed vs cancelled — a completed task's PR was squash-merged, so its local ref is never an ancestor of base and a "safe" `-d` would refuse every single candidate.
## Drift from CLAUDE.md
@@ -4020,6 +4036,8 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr
| `3aff6e04` (Close gaps) | Same mega-commit (the PR body is identical — #285 is the merge closure of the #283 batch); the in-scope file deltas are the same set of additions. No additional logic change to these files beyond what #283 listed. |
> Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286 — closed regression risks #108 and #109 in this slice: `_merge_with_retry` now falls back to a permitted merge method on 405 via `_first_allowed_merge_method` before raising `MergeConflictError`; `close_pull_request` default flipped to `delete_branch=False`, choreographer caller now passes `delete_branch=True` explicitly). `00513399` ([bug] push_branch — `push_branch(branch_name)` now passes `branch=branch_name` to `self.push()` so the gateway `open_pr` path pushes the actual named task branch rather than the clone root's current checkout; fixes the "No commits between" 422 → `i_am_blocked` wedge observed in the F123 per-worktree model). `2759edf7` ([B-REL] release executor — added `_CiRunQuery` dataclass at git.py:241 to bundle per-project CI-fetch inputs; `get_latest_ci_conclusion` and `_fetch_latest_ci_run` now accept an optional `head_sha` so the release CI gate polls a specific release commit's own run rather than branch-latest; `settings.release_ci_workflow` config flag added). `69071030` ([chore] work-session-routes — added `WorkSessionService.task_team_for_session` helper (route layer PM cell-ownership check for `merge_pr`); route layer now stamps `merged_by` from the authenticated caller rather than the request body — `WorkSessionService.merge_pr` signature is unchanged, but `MergePRRequest` schema dropped `merged_by` field).
>
> (open PR #548, branch `feature/wave-2-hygiene-charts`, 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 the `task-service` section for the paired per-task reap at cancel/completion and the `workspace` section for the new `WorkspaceService.delete_local_branch` primitive both routes share.
## Regression Risks
@@ -4082,6 +4100,7 @@ WorkspaceService manages the per-agent git clone layout under {workspaces_root}/
| WorkspaceService._fetch_branch_ref | method | roboco/services/workspace.py:613 | Token-aware git fetch origin <branch> 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.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 <branch>` 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 |
| WorkspaceService._lookup_agent_or_raise | method | roboco/services/workspace.py:787 | Find agent by UUID or slug; raise WorkspaceError if missing |
| WorkspaceService._is_workspace_healthy | staticmethod | roboco/services/workspace.py:806 | True only if .git exists AND has HEAD + objects/ (rejects stub clones) |
@@ -4178,7 +4197,7 @@ WorkspaceService slice
+-- WorkspaceError
+-- WorkspaceService
| +-- Path math: get_workspace_path / get_clone_root_path / get_worktree_path
| +-- Worktree ops: _clone_root_default_branch / _park_clone_root_off_branch / _worktree_git / _link_shared_venv / ensure_worktree / ensure_worktree_for_resume / _fetch_branch_ref / ensure_worktree_self_heal / remove_worktree
| +-- Worktree ops: _clone_root_default_branch / _park_clone_root_off_branch / _worktree_git / _link_shared_venv / ensure_worktree / ensure_worktree_for_resume / _fetch_branch_ref / ensure_worktree_self_heal / remove_worktree / delete_local_branch
| +-- Agent lookup: resolve_workspace / _lookup_agent_or_raise
| +-- Health + refs: _is_workspace_healthy / _prune_broken_refs / _fetch_origin_best_effort
| +-- Token: _resolve_git_token / _read_clone_token
@@ -4203,6 +4222,7 @@ WorkspaceService slice
| 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) |
| 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) |
| dry_upgrade_changes_lockfile | roboco/services/workspace.py | DepUpdateEngine periodic probe loop |
| fetch_branch_for_inspection | roboco/services/workspace.py | gateway content_actions (QA/Documenter/PM need to read a dev branch) |
@@ -4236,6 +4256,7 @@ WorkspaceService slice
- dry_upgrade_changes_lockfile holds the read-clone lock only for the local clone step, then releases it before the upgrade runs. The tiny gap between ensure_read_clone releasing and the probe re-acquiring is safe only because any concurrent _sync_read_clone completes under the lock first — a future change that interleaves could race.
- get_workspace_path raises WorkspaceError if team is None rather than producing a literal 'None' segment; resolve_workspace falls back to Team.BACKEND when agent.team is falsy — agents missing a team silently land under backend/.
- fetch_branch_for_inspection reuses workspace_clone_timeout (300s) for a single-branch fetch, not the shorter refresh timeout — a hung remote blocks the QA/Doc verb for 5 minutes.
- delete_local_branch only detaches the ref; remove_worktree only detaches the worktree. Callers MUST run remove_worktree first — `git branch -d/-D` refuses a branch still checked out elsewhere in the clone (the worktree). Skipping the order silently no-ops the branch delete (check=False swallows the refusal).
## Drift from CLAUDE.md
@@ -4258,6 +4279,8 @@ WorkspaceService slice
| 0f7d6929 | [F-fix] gate the worktree .venv symlink on the clone-root venv existing | _link_shared_venv now no-ops when clone_root/.venv does not yet exist (instead of dangling a symlink), so uv no longer errors or silently re-syncs a worktree-local venv in the near-zero gap before install_dev_deps provisions the clone-root venv. A later ensure self-heals the link. |
> Post-snapshot updates (since 2026-06-29): 5 commits touched workspace.py. (1) 9faf2763 [hotfix] strip VIRTUAL_ENV + UV_PROJECT_ENVIRONMENT from _uv_subprocess_env so workspace uv calls stop warning about the image-baked /app/.venv pin. (2) cfe725da [hotfix] worktree: clone root left on the task branch caused fatal "already checked out" on every worktree add re-dispatch — added _clone_root_default_branch + _park_clone_root_off_branch; ensure_worktree and ensure_worktree_for_resume now call _park_clone_root_off_branch before the add to restore the F123 invariant. (3) 536bbb64 (logical-gap sweep PR#286) added _fetch_branch_ref + ensure_worktree_self_heal: the orchestrator's _ensure_worktree_before_spawn now calls ensure_worktree_self_heal instead of bare ensure_worktree_for_resume so a vanished clone (redeploy/disk loss) that left no local branch ref recovers the pushed commits from origin before re-attaching. (4) 3aff6e04 and 15effce0 (gap-fill PRs #285/#283) contributed earlier worktree + dep-probe plumbing (the _clone_local_into / _probe_lockfile_on_clone split already captured in the baseline).
>
> (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.
## Regression Risks
@@ -8037,6 +8060,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` |
| Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt |
| Task Detail | `components/tasks/task-detail/*` | Tabbed: overview, plan, progress, commits, sessions, notes, dependencies, AC, action dialogs |
| AI Providers | `app/(dashboard)/settings/ai-providers/page.tsx` + `components/settings/ai-routing-card.tsx` | Per-slug/role/global model routing |
@@ -8075,6 +8099,11 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `RequiredNotesDialog` | comp | `components/ui/required-notes-dialog.tsx` | Reusable notes-gated confirm; submit disabled on empty/whitespace |
| `CommandCenter` | comp | `components/dashboard/command-center.tsx` | Overview page body; composes all dashboard cards |
| `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels |
| `GitActionsPanel` | comp | `components/git/git-actions-panel.tsx` | Commit/push/PR/rebase actions + destructive-confirm "Clean Up Stale Branches" (`AlertDialog`) |
| `useCleanupBranches` / `handleCleanupBranches` | hook | `hooks/use-git.ts` / `hooks/use-git-browser.ts` | Mutation over `POST /git/branches/cleanup`; the browser hook tracks a per-project cursor ref so a repeat click resumes a truncated sweep instead of re-scanning the first window |
| `WorkSessionsView` | comp | `components/work-sessions/work-sessions-view.tsx` | Git page's "Work Sessions" tab body; search/status filters are LOCAL `useState`, not URL params |
| `SessionTrendChart` | comp | `components/work-sessions/session-trend-chart.tsx` | Active-session start-time histogram (hourly/daily bucketing); honestly labeled active-only, no history beyond `GET /work-sessions` |
| `CostTrendChart` / `SpendTrendChart` | comp | `components/dashboard/cost-trend-chart.tsx` / `components/business/spend-trend-chart.tsx` | Daily-spend area charts off `GET /usage/time-series`; 7d on Overview (`CommandCenter`), 30d on the Business scorecard (`CompanyScorecardCard`) |
## Data Flow
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
@@ -8208,6 +8237,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `page.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top**`WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
## Drift from CLAUDE.md
- CLAUDE.md says panel lives at `roboco/panel/` inside this repo — confirmed (no longer a separate `roboco-panel` project). No drift.
@@ -8237,6 +8267,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events.
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO New-DM composer: `a2a-new-dm-dialog.tsx` (opens a fresh CEO-owned 1:1, `AgentSelector`'s new `excludeRoles` prop) + `a2a-direct-composer.tsx` (posts in a CEO-owned thread, no task link needed) wired into `page.tsx`'s composer-selection branch (CEO-owned thread → direct composer; task-linked watched thread → reply composer; else read-only). `use-a2a-live.ts` adds `useCreateCeoConversation`/`useSendCeoMessage`; `lib/api/a2a.ts` adds `createConversation`/`sendCeoMessage` (both force `X-Agent-ID: "ceo"` per-call). `client.ts`'s header injection changed from an unconditional overwrite to a `has()`/`set()` default so a per-call override survives. Backend: `A2AService._maybe_wake_ceo_recipient` wakes an offline `read_a2a`-capable recipient of a CEO DM via the `a2a_request` dispatch path — see `docs/map/a2a-audit-journal-permissions.md`. Same branch also scrubbed "message the CEO" recipes from `docs/rag`/`agents/prompts` (agents are never taught to DM the CEO — reply-only).
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Wave 2 hygiene + charts: `/work-sessions` route now redirects to `/git?tab=sessions` (moved under Git as a "Work Sessions" tab, `git-page.tsx` gains a `Tabs`); `GitActionsPanel` gains a confirm-gated "Clean Up Stale Branches" button (`useCleanupBranches`, cursor-resumable); `WorkSessionsView`'s filters moved from URL params to local state (ScrollRestoration bounce fix); new `SessionTrendChart` / `CostTrendChart` / `SpendTrendChart`.
## Regression Risks
+3 -1
View File
@@ -19,7 +19,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| roboco/api/routes/dashboard.py | CEO/auditor/kanban/metrics/agents/activity dashboards. |
| roboco/api/routes/tasks.py | Task CRUD + lifecycle transitions (claim/start/verify/qa/complete...). |
| roboco/api/routes/work_session.py | Work-session list/commit/files/PR/merge/complete/abandon. |
| roboco/api/routes/git.py | Per-project git status/log/diff/commit/push/PR/rebase. |
| roboco/api/routes/git.py | Per-project git status/log/diff/commit/push/PR/rebase/branch-cleanup sweep. |
| roboco/api/routes/project.py | Project CRUD + workspace/sync/access + conventions. |
| roboco/api/routes/product.py | Product CRUD. |
| roboco/api/routes/optimal.py | RAG: kb/search, rag/query, mentor/ask, learnings, decisions, review. |
@@ -75,6 +75,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
| GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context |
| POST | /api/git/branches/cleanup | git.py | agent context, PM/CEO role-gated like `/rebase`; rate-limit 5/60 — cursor-resumable stale-branch sweep, `GitBranchCleanupRequest`/`Response` (wave 2, open PR #548) |
| POST | /api/v1/flow/developer/{give_me_work,i_will_work_on,open_pr,i_am_done,unclaim,resume,sync_branch} | flow_dev.py | `require_dev` (role + HMAC) |
| POST | /api/v1/flow/qa/{claim_review,pass_review,fail_review} | flow_qa.py | `require_qa` |
| POST | /api/v1/flow/cell_pm/{delegate,submit_up,complete,triage,unblock,reassign} | flow_cell_pm.py | `require_cell_pm` |
@@ -239,6 +240,7 @@ roboco/api/
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) adds `POST /api/git/branches/cleanup` (PM/CEO role-gated like `/rebase`, rate-limit 5/60) + `GitBranchCleanupRequest`/`GitBranchCleanupResponse` schemas — cursor-resumable sweep of terminal tasks' remote+local branches, backing a confirm-dialog button on the panel Git page.
## Regression Risks
+8
View File
@@ -58,6 +58,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` |
| Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt |
| Task Detail | `components/tasks/task-detail/*` | Tabbed: overview, plan, progress, commits, sessions, notes, dependencies, **findings**, AC, action dialogs |
| AI Providers | `app/(dashboard)/settings/ai-providers/page.tsx` + `components/settings/ai-routing-card.tsx` | Per-slug/role/global model routing |
@@ -96,6 +97,11 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `RequiredNotesDialog` | comp | `components/ui/required-notes-dialog.tsx` | Reusable notes-gated confirm; submit disabled on empty/whitespace |
| `CommandCenter` | comp | `components/dashboard/command-center.tsx` | Overview page body; composes all dashboard cards |
| `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels |
| `GitActionsPanel` | comp | `components/git/git-actions-panel.tsx` | Commit/push/PR/rebase actions + destructive-confirm "Clean Up Stale Branches" (`AlertDialog`) |
| `useCleanupBranches` / `handleCleanupBranches` | hook | `hooks/use-git.ts` / `hooks/use-git-browser.ts` | Mutation over `POST /git/branches/cleanup`; the browser hook tracks a per-project cursor ref so a repeat click resumes a truncated sweep instead of re-scanning the first window |
| `WorkSessionsView` | comp | `components/work-sessions/work-sessions-view.tsx` | Git page's "Work Sessions" tab body; search/status filters are LOCAL `useState`, not URL params |
| `SessionTrendChart` | comp | `components/work-sessions/session-trend-chart.tsx` | Active-session start-time histogram (hourly/daily bucketing); honestly labeled active-only, no history beyond `GET /work-sessions` |
| `CostTrendChart` / `SpendTrendChart` | comp | `components/dashboard/cost-trend-chart.tsx` / `components/business/spend-trend-chart.tsx` | Daily-spend area charts off `GET /usage/time-series`; 7d on Overview (`CommandCenter`), 30d on the Business scorecard (`CompanyScorecardCard`) |
## Data Flow
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
@@ -229,6 +235,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `page.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top**`WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
## Drift from CLAUDE.md
- CLAUDE.md says panel lives at `roboco/panel/` inside this repo — confirmed (no longer a separate `roboco-panel` project). No drift.
@@ -258,6 +265,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events.
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO New-DM composer: `a2a-new-dm-dialog.tsx` (opens a fresh CEO-owned 1:1, `AgentSelector`'s new `excludeRoles` prop) + `a2a-direct-composer.tsx` (posts in a CEO-owned thread, no task link needed) wired into `page.tsx`'s composer-selection branch (CEO-owned thread → direct composer; task-linked watched thread → reply composer; else read-only). `use-a2a-live.ts` adds `useCreateCeoConversation`/`useSendCeoMessage`; `lib/api/a2a.ts` adds `createConversation`/`sendCeoMessage` (both force `X-Agent-ID: "ceo"` per-call). `client.ts`'s header injection changed from an unconditional overwrite to a `has()`/`set()` default so a per-call override survives. Backend: `A2AService._maybe_wake_ceo_recipient` wakes an offline `read_a2a`-capable recipient of a CEO DM via the `a2a_request` dispatch path — see `docs/map/a2a-audit-journal-permissions.md`. Same branch also scrubbed "message the CEO" recipes from `docs/rag`/`agents/prompts` (agents are never taught to DM the CEO — reply-only).
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Wave 2 hygiene + charts: `/work-sessions` route now redirects to `/git?tab=sessions` (moved under Git as a "Work Sessions" tab, `git-page.tsx` gains a `Tabs`); `GitActionsPanel` gains a confirm-gated "Clean Up Stale Branches" button (`useCleanupBranches`, cursor-resumable); `WorkSessionsView`'s filters moved from URL params to local state (ScrollRestoration bounce fix); new `SessionTrendChart` / `CostTrendChart` / `SpendTrendChart`.
## Regression Risks
+8 -2
View File
@@ -55,7 +55,10 @@
| `escalate_to_ceo` | method | task.py:5064 | `awaiting_pm_review→awaiting_ceo_approval`; gained `actor_agent_id: UUID | None = None` param (stamped as `audit_agent_id` so the transition row attributes to the specific PM/Board agent, not just the role). |
| `ceo_approve` | method | task.py:5146 | CEO merges then approves; `awaiting_ceo_approval→completed`. |
| `ceo_reject` | method | task.py:5414 | Reject → `needs_revision` (dev) or `pending` (branchless root via admin_set_status); now validates `reason` (`reject_trivial` — previously an uncaught Pydantic error could 500 on empty/trivial input) and inserts one `origin=ceo` Finding onto the revision-findings ledger; the branchless-root path manually bumps `revision_count` + emits `task.ceo_reject` since it skips `_emit_status_transition_audit`. See `docs/map/review-findings.md`. |
| `_remove_task_worktree_on_terminal` | method | task.py:5601 | Best-effort worktree cleanup on complete/ceo_approve; no-op for branchless. |
| `_delete_task_branch_best_effort` | method | task.py:6726 | Cancel-path cleanup: remote branch delete + `_remove_task_worktree_best_effort(force_branch_delete=True)`; skipped once branch is unset. |
| `_remove_task_worktree_best_effort` | method | task.py:6767 | Shared worktree+local-branch+previews cleanup called by both cancel and terminal paths; force-deletes the local branch ref unless it's an environment-ladder rung (`effective_environments`). |
| `_cleanup_task_previews_best_effort` | method | task.py:6804 | `rmtree` the task's `.previews/{task8}` video-render dir; path-containment-checked against the project workspace dir before deleting. |
| `_remove_task_worktree_on_terminal` | method | task.py:6829 | Best-effort worktree + local-branch (force `-D`, squash-merge is never an ancestor) + previews cleanup on complete/ceo_approve; no-op for branchless. |
| `cancel` | method | task.py:5644 | Cascade-cancel descendants through the validator. |
| `reassign` / `reassign_active_claim` | method | task.py:7657 / 7807 | Reassignment with Board/Main-PM diversion guards. |
| `pr_pass` / `pr_fail` | method | task.py:8100 / 8137 | In-path PR-review gate verdicts; `pr_fail` transitions to `needs_revision`, then calls `_alert_auditor_of_rework` after flush to emit a best-effort auditor rework ALERT. |
@@ -96,7 +99,7 @@ stateDiagram-v2
- TaskService
- State core: `_validate_and_set_status`, `_emit_status_transition_audit`, `admin_set_status`, `_restore_block_ownership`, `_emit_admin_override_audit`
- Create/shape: `create`, `_validate_parent_depth`, `_validate_batch_membership`, `activate`
- Branch/worktree: `_ensure_branch_for_task`, `_auto_create_branch`, `_remove_task_worktree*`
- Branch/worktree: `_ensure_branch_for_task`, `_auto_create_branch`, `_delete_task_branch_best_effort`, `_remove_task_worktree*`, `_cleanup_task_previews_best_effort`
- Claim: `claim`, `_validate_claim_preconditions`, `_claim_blocked_by_sequence`, `_claim_blocked_by_dependencies`, `_finalize_claim`, `_apply_dependency_lineage`, `_inject_proactive_context`, `acquire_*_lock`
- Lifecycle verbs: `start`, `block*`, `unblock`, `pause`, `resume`, `submit_for_qa`, `pass_qa`, `fail_qa`, `docs_complete`, `submit_for_pm_review`
- Completion: `complete`, `_apply_complete_approval_chain`, `ceo_approve`, `ceo_reject`, `cancel`
@@ -138,6 +141,7 @@ stateDiagram-v2
- 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.
- `_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.
## Drift from CLAUDE.md
- CLAUDE.md states ceo_reject "~4779 skips _validate_and_set_status in branchless path". Actual: branchless branch of `ceo_reject` is at task.py:5488 and routes through `admin_set_status` (which DOES emit audit at task.py:2100). The non-branchless branch DOES call `_validate_and_set_status` (task.py:5461). No audit gap — the line reference is stale.
@@ -152,6 +156,8 @@ stateDiagram-v2
> Post-snapshot updates (since 2026-06-29): `20f1f9ba` admin_set_status: thread actor_id/actor_role into `_apply_pre_block_restore`; blocked→pending/in_progress restore now attributes the audit row to the admin actor (not the restored owner) and emits a `task.admin_override` row (forced=False, restore=True) independent of the force flag. `b3558d4e` complexity: extract `_restore_block_ownership` (line 8526) + `_emit_admin_override_audit` (line 8555) from `_apply_pre_block_restore` — no behavior change, splits a C-rank block for the xenon gate. `0e7674af` escalate_to_ceo gains `actor_agent_id: UUID | None = None` param stamped as audit_agent_id; push_branch / create_pr / create_root_pr / escalate_to_ceo side-effect handlers in the verb runner now forward actor_agent_id (was dropped, causing wrong workspace or role-only audit attribution). `8f3f4236` (#452) "sequence is the bar" — adds `_claim_blocked_by_sequence` + `_validate_claim_preconditions` wiring, `stamp_wave_sequence` (replacing a raw per-sibling delegation ordinal), and migration 069 (`tasks.parent_task_id` index, the sibling probe's hot path). `f2834cf5` (#466) adds `_apply_dependency_lineage`/`_merge_one_dependency`, called from `_create_branch_in_project` right after a fresh branch cut. `61e00832` (PR #492) added `_alert_auditor_of_rework()` and invoked it from `fail_qa`, `pr_fail`, and `request_changes` after each transition to `needs_revision`, wiring the reactive auditor ALERT path. `f6c75237` (PR #509) restored those `_alert_auditor_of_rework()` calls after they were accidentally deleted by the docs-sync PR: all three call sites now dispatch the alert immediately after `await self.session.flush()` so the `needs_revision` transition row is committed before the auditor notification is created. The same commit also changed the descendant-traversal casts in `_supersede_replacement_landed` and `get_all_descendants`, but it used `cast(UUID, child.id)` with a scoped `# noqa: TC006` and `child.id` with a `# type: ignore[arg-type]`, respectively. `e4b7dd0f` / PR #511 reverted those two cast regressions to the preferred string-literal form `cast('UUID', child.id)` with no lint or type suppression, leaving `DOCS_SYNC_SOURCE` and `list_open_docs_sync_tasks` untouched.
>
> (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: `_audit_events_for` (task.py:997) gains `task.request_changes` (agent_role `cell_pm`/`main_pm`) and `task.ceo_reject` (agent_role `ceo`) branches alongside the existing `task.qa_fail`/`task.pr_fail`; `ceo_reject` gains reason validation + a ledger `Finding` insert (see above); `qa_fail` and `request_changes` drop their raw `dev_notes` appends (the mirror-column data-loss bug) in favor of the ledger + a structured note. Full detail: `docs/map/review-findings.md`.
>
> (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Local branch refs stop leaking: `_delete_task_branch_best_effort`/`_remove_task_worktree_on_terminal` now also force-delete the assignee's local branch ref (via new `WorkspaceService.delete_local_branch`) and rmtree the task's `.previews/{task8}` video-preview dir, both skipped for environment-ladder rungs. See `docs/map/worksession-git.md` for the paired `GitService.cleanup_stale_branches` sweep.
## Regression Risks
+11 -1
View File
@@ -83,6 +83,11 @@ This slice is the git substrate every delivery agent works on. `GitService` runs
| `GitService.sync_task_branch` | method | git.py:3847 | Task-keyed rebase through dev `sync_branch` verb (pre-PR) |
| `GitService.is_behind_base` | method | git.py:3889 | `(behind, ahead)` counts for i_am_done submit gate |
| `GitService.close_pull_request` | method | git.py:3940 | Close superseded PR + optional comment + branch cleanup (idempotent) |
| `GitService._delete_remote_branch_best_effort` | method | git.py:3608 | Best-effort remote delete; skips main/master/develop + open-dependent-PR branches; returns `bool` (issued vs skipped/failed) |
| `GitService.delete_task_branch` | method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (`effective_environments`) so a task's `branch_name` can never collide-delete a ladder rung; returns `bool` |
| `GitService.cleanup_stale_branches` | method | git.py:3711 | `POST /git/branches/cleanup` backing sweep: terminal (completed/cancelled) tasks' branches, remote (`delete_task_branch`) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable |
| `GitService._stale_branch_window` | method | git.py:3777 | One deterministic `ORDER BY id` window of sweep candidates; ladder rungs excluded from results but still advance the cursor |
| `GitService._cleanup_one_stale_branch` | method | git.py:3810 | Per-branch remote+local delete for one sweep candidate; raises on unexpected failure so the caller's try/except counts it as an error |
| `GitService.pr_target` | method | git.py:4021 | Return PR base branch (project_id scoped) |
| `GitService.create_pr` | method | git.py:3418 | Branch-keyed open PR (gateway path; ensures base on remote) |
| `GitService._record_pr_atomically` | method | git.py:2601 | Atomic pr_number/url write to task |
@@ -203,6 +208,7 @@ roboco/
│ │ _pr_is_merged / _auto_complete_on_merge / _first_allowed_merge_method
│ ├── branch cleanup: _delete_remote_branch_best_effort / _delete_pr_branch_best_effort
│ │ delete_task_branch / _branch_has_open_dependents
│ │ cleanup_stale_branches / _stale_branch_window / _cleanup_one_stale_branch (sweep)
│ ├── rebase/sync: rebase_onto_base / rebase_pr_for_task / sync_task_branch / is_behind_base
│ ├── close: close_pull_request
│ ├── quality: run_pre_submit_quality_gate / toolchain_status_for_task / _fast_gate_commands
@@ -242,7 +248,7 @@ External:
## Entry Points
- **HTTP routes** (`roboco/api/routes/git.py`): `get_status`, `log`, `diff`, `commit_for_task`, `push_for_task`, `create_branch_for_task`, `checkout_branch_for_agent`, `create_pr_for_task`, `merge_pr_for_task`, `pull`, `fetch`, `rebase` — all construct via `get_git_service(db)`.
- **HTTP routes** (`roboco/api/routes/git.py`): `get_status`, `log`, `diff`, `commit_for_task`, `push_for_task`, `create_branch_for_task`, `checkout_branch_for_agent`, `create_pr_for_task`, `merge_pr_for_task`, `pull`, `fetch`, `rebase`, `cleanup_stale_branches` (`POST /git/branches/cleanup`, PM/CEO role-gated like `/rebase`, rate-limit 5/60) — all construct via `get_git_service(db)`.
- **HTTP routes** (`roboco/api/routes/tasks.py:253`): `get_git_service` for task-scoped git.
- **Gateway Choreographer** (`roboco/services/gateway/choreographer/`):
- `_verb_runner._do_pr_merge``pr_merge`
@@ -287,6 +293,8 @@ Module-level tunables (not env): `_SLOW_GIT_OP_MS=5000`, `_CI_RUN_WINDOW=20`, `_
- **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.
- **CEO-only master merge**: `pr_merge` refuses `target == default_branch` for agents; only `merge_pr_for_task` (CEO role-gated from `awaiting_ceo_approval`) may merge to master. `default_branch` resolves through the env-ladder head rung (`_project_default_branch``head_branch(project)`), not the raw `projects.default_branch` column.
- **`cleanup_stale_branches` cursor is required, not optional**: task rows never change as a side effect of the sweep (unlike, say, a queue that drains), so a repeat call with no `after_cursor` re-scans the identical first 200-row window forever instead of progressing. `_stale_branch_window` still advances the cursor past ladder-rung rows even though they're excluded from `candidates`, so `truncated` can't false-negative when a rung lands inside the window.
- **Local branch delete in the sweep is always `force=True`** (`-D`) regardless of completed vs cancelled — a completed task's PR was squash-merged, so its local ref is never an ancestor of base and a "safe" `-d` would refuse every single candidate.
## Drift from CLAUDE.md
@@ -308,6 +316,8 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441` (master tip before the metr
| `3aff6e04` (Close gaps) | Same mega-commit (the PR body is identical — #285 is the merge closure of the #283 batch); the in-scope file deltas are the same set of additions. No additional logic change to these files beyond what #283 listed. |
> Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286 — closed regression risks #108 and #109 in this slice: `_merge_with_retry` now falls back to a permitted merge method on 405 via `_first_allowed_merge_method` before raising `MergeConflictError`; `close_pull_request` default flipped to `delete_branch=False`, choreographer caller now passes `delete_branch=True` explicitly). `00513399` ([bug] push_branch — `push_branch(branch_name)` now passes `branch=branch_name` to `self.push()` so the gateway `open_pr` path pushes the actual named task branch rather than the clone root's current checkout; fixes the "No commits between" 422 → `i_am_blocked` wedge observed in the F123 per-worktree model). `2759edf7` ([B-REL] release executor — added `_CiRunQuery` dataclass at git.py:241 to bundle per-project CI-fetch inputs; `get_latest_ci_conclusion` and `_fetch_latest_ci_run` now accept an optional `head_sha` so the release CI gate polls a specific release commit's own run rather than branch-latest; `settings.release_ci_workflow` config flag added). `69071030` ([chore] work-session-routes — added `WorkSessionService.task_team_for_session` helper (route layer PM cell-ownership check for `merge_pr`); route layer now stamps `merged_by` from the authenticated caller rather than the request body — `WorkSessionService.merge_pr` signature is unchanged, but `MergePRRequest` schema dropped `merged_by` field).
>
> (open PR #548, branch `feature/wave-2-hygiene-charts`, 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.
## Regression Risks
+6 -1
View File
@@ -39,6 +39,7 @@ WorkspaceService manages the per-agent git clone layout under {workspaces_root}/
| WorkspaceService._fetch_branch_ref | method | roboco/services/workspace.py:613 | Token-aware git fetch origin <branch> 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.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 <branch>` 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 |
| WorkspaceService._lookup_agent_or_raise | method | roboco/services/workspace.py:787 | Find agent by UUID or slug; raise WorkspaceError if missing |
| WorkspaceService._is_workspace_healthy | staticmethod | roboco/services/workspace.py:806 | True only if .git exists AND has HEAD + objects/ (rejects stub clones) |
@@ -135,7 +136,7 @@ WorkspaceService slice
+-- WorkspaceError
+-- WorkspaceService
| +-- Path math: get_workspace_path / get_clone_root_path / get_worktree_path
| +-- Worktree ops: _clone_root_default_branch / _park_clone_root_off_branch / _worktree_git / _link_shared_venv / ensure_worktree / ensure_worktree_for_resume / _fetch_branch_ref / ensure_worktree_self_heal / remove_worktree
| +-- Worktree ops: _clone_root_default_branch / _park_clone_root_off_branch / _worktree_git / _link_shared_venv / ensure_worktree / ensure_worktree_for_resume / _fetch_branch_ref / ensure_worktree_self_heal / remove_worktree / delete_local_branch
| +-- Agent lookup: resolve_workspace / _lookup_agent_or_raise
| +-- Health + refs: _is_workspace_healthy / _prune_broken_refs / _fetch_origin_best_effort
| +-- Token: _resolve_git_token / _read_clone_token
@@ -160,6 +161,7 @@ WorkspaceService slice
| 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) |
| 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) |
| dry_upgrade_changes_lockfile | roboco/services/workspace.py | DepUpdateEngine periodic probe loop |
| fetch_branch_for_inspection | roboco/services/workspace.py | gateway content_actions (QA/Documenter/PM need to read a dev branch) |
@@ -193,6 +195,7 @@ WorkspaceService slice
- dry_upgrade_changes_lockfile holds the read-clone lock only for the local clone step, then releases it before the upgrade runs. The tiny gap between ensure_read_clone releasing and the probe re-acquiring is safe only because any concurrent _sync_read_clone completes under the lock first — a future change that interleaves could race.
- get_workspace_path raises WorkspaceError if team is None rather than producing a literal 'None' segment; resolve_workspace falls back to Team.BACKEND when agent.team is falsy — agents missing a team silently land under backend/.
- fetch_branch_for_inspection reuses workspace_clone_timeout (300s) for a single-branch fetch, not the shorter refresh timeout — a hung remote blocks the QA/Doc verb for 5 minutes.
- delete_local_branch only detaches the ref; remove_worktree only detaches the worktree. Callers MUST run remove_worktree first — `git branch -d/-D` refuses a branch still checked out elsewhere in the clone (the worktree). Skipping the order silently no-ops the branch delete (check=False swallows the refusal).
## Drift from CLAUDE.md
@@ -217,6 +220,8 @@ WorkspaceService slice
> Post-snapshot updates (since 2026-06-29): 5 commits touched workspace.py. (1) 9faf2763 [hotfix] strip VIRTUAL_ENV + UV_PROJECT_ENVIRONMENT from _uv_subprocess_env so workspace uv calls stop warning about the image-baked /app/.venv pin. (2) cfe725da [hotfix] worktree: clone root left on the task branch caused fatal "already checked out" on every worktree add re-dispatch — added _clone_root_default_branch + _park_clone_root_off_branch; ensure_worktree and ensure_worktree_for_resume now call _park_clone_root_off_branch before the add to restore the F123 invariant. (3) 536bbb64 (logical-gap sweep PR#286) added _fetch_branch_ref + ensure_worktree_self_heal: the orchestrator's _ensure_worktree_before_spawn now calls ensure_worktree_self_heal instead of bare ensure_worktree_for_resume so a vanished clone (redeploy/disk loss) that left no local branch ref recovers the pushed commits from origin before re-attaching. (4) 3aff6e04 and 15effce0 (gap-fill PRs #285/#283) contributed earlier worktree + dep-probe plumbing (the _clone_local_into / _probe_lockfile_on_clone split already captured in the baseline).
>
> 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.
## Regression Risks
+1 -1
View File
@@ -76,7 +76,7 @@ Your agent clone is **shared across all your tasks**, but each **claimed task**
- **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.
- **Claim rollback** (a mid-claim failure) `worktree remove --force`s the worktree so a retry doesn't collide with a stale one.
- **Terminal completion** (`complete` / `ceo_approve`) removes the assignee's worktree best-effort, so finished tasks don't accumulate. A `needs_revision` bounce keeps the worktree — you need it back. The stale-claim reaper does **not** remove the worktree; it routes the task to `pending` for a re-claim that reuses it.
- **Terminal completion** (`complete` / `ceo_approve`) and **cancellation** remove the assignee's worktree AND force-delete the now-spent local branch ref in the clone, so finished/cancelled tasks don't leak either on disk. A `needs_revision` bounce keeps both — you need the branch back. The stale-claim reaper does **not** remove the worktree or branch; it routes the task to `pending` for a re-claim that reuses them. A PM/CEO can also run a backlog stale-branch sweep from the panel's Git page for older completed/cancelled tasks whose ref survived from before this reaping existed.
You do not manage any of this. The verbs do. The only thing you must know: **your cwd is the worktree for your current task, not the clone root** — so relative paths and `uv run` resolve against your task's checkout.
+96 -2
View File
@@ -1,5 +1,99 @@
import { GitBrowser } from "@/components/git";
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { GitBrowser } from "@/components/git";
import { WorkSessionsView } from "@/components/work-sessions";
interface TabDef {
value: "repository" | "sessions";
label: string;
hint: string;
}
const TAB_DEFS: TabDef[] = [
{
value: "repository",
label: "Repository",
hint: "Browse status, branches, log, and diffs; run git actions",
},
{
value: "sessions",
label: "Work Sessions",
hint: "Active agent work sessions — branch, commits, and PR per task",
},
];
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(value: string | null): value is TabValue {
return TAB_VALUES.includes(value as TabValue);
}
function GitPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const rawTab = searchParams.get("tab");
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "repository";
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`/git?${params.toString()}`);
};
return (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{TAB_DEFS.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
own data-state; re-stamp it so the active style survives. */}
<TabsTrigger
value={tab.value}
data-state={tab.value === activeTab ? "active" : "inactive"}
>
{tab.label}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList>
<TabsContent value="repository" className="mt-4">
<GitBrowser />
</TabsContent>
<TabsContent value="sessions" className="mt-4">
<WorkSessionsView />
</TabsContent>
</Tabs>
);
}
// Wrap in Suspense for useSearchParams
export default function GitPage() {
return <GitBrowser />;
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<GitPageContent />
</Suspense>
);
}
@@ -1,157 +1,6 @@
"use client";
import { redirect } from "next/navigation";
import { Suspense, useMemo, useCallback, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { WorkSessionStatus } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import {
WorkSessionTable,
WorkSessionFilters,
} from "@/components/work-sessions";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageRefresh } from "@/hooks";
function WorkSessionsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const searchQuery = searchParams.get("q") || "";
const statusParam = searchParams.get("status");
const statusFilter = useMemo(
() =>
(statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
[statusParam],
);
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/work-sessions?${query}` : "/work-sessions");
},
[router, searchParams],
);
const handleSearchChange = useCallback(
(value: string) => {
updateParams({ q: value || null });
},
[updateParams],
);
const handleStatusChange = useCallback(
(value: WorkSessionStatus[]) => {
updateParams({ status: value.length > 0 ? value.join(",") : null });
},
[updateParams],
);
// Fetch work sessions
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter sessions client-side for search and multi-select status filter
const filteredSessions = useMemo(() => {
if (!sessions) return [];
return sessions.filter((session) => {
// Search filter - match branch name
if (
searchQuery &&
!session.branch_name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Status filter (if any selected, session must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(session.status)) {
return false;
}
return true;
});
}, [sessions, searchQuery, statusFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Work Sessions</h1>
<p className="text-muted-foreground">
Track git branches and pull requests for active work
</p>
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<WorkSessionFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusChange={handleStatusChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Work Sessions"
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
onRetry={() => void refresh()}
/>
) : (
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
// The work-sessions surface moved under /git as its "Work Sessions" tab.
export default function WorkSessionsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-72" />
</div>
</div>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<WorkSessionsPageContent />
</Suspense>
);
redirect("/git?tab=sessions");
}
@@ -26,6 +26,17 @@ vi.mock("@/lib/api/cockpit", () => ({
},
}));
// The spend trend chart pulls its own series via useUsageTimeSeries — a
// hook-level mock (not the raw react-query one above, which only controls
// the single cockpit-summary useQuery call) so SpendTrendChart never sees
// the mocked CockpitSummary object where it expects an array.
vi.mock("@/hooks/use-usage", () => ({
useUsageTimeSeries: () => ({
data: [],
isLoading: false,
}),
}));
// ---------------------------------------------------------------------------
// Import component AFTER mocks are set up
// ---------------------------------------------------------------------------
@@ -160,6 +171,17 @@ describe("CompanyScorecardCard", () => {
expect(screen.getByText("Done (30 d)")).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// The spend-trend chart is wired into the Spend section
// -------------------------------------------------------------------------
it("renders the daily spend trend chart alongside the spend summary", () => {
setQueryState({ data: buildSummary() });
render(<CompanyScorecardCard />);
expect(screen.getByText("Daily Spend (30d)")).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// AC2 Scenario 4: Spend — 'No budget cap set' when cap is null
// -------------------------------------------------------------------------
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SpendTrendChart } from "../spend-trend-chart";
import type { UsageTimePoint } from "@/types";
function buildPoint(overrides: Partial<UsageTimePoint> = {}): UsageTimePoint {
return {
bucket: new Date().toISOString(),
tokens_input: 1000,
tokens_output: 500,
total_tokens: 1500,
cost_usd: 4.56,
...overrides,
};
}
describe("SpendTrendChart", () => {
it("renders the card title", () => {
render(<SpendTrendChart data={[buildPoint()]} isLoading={false} />);
expect(screen.getByText("Daily Spend (30d)")).toBeInTheDocument();
});
it("shows an empty state when there is no data", () => {
render(<SpendTrendChart data={[]} isLoading={false} />);
expect(screen.getByText("No spend data")).toBeInTheDocument();
});
it("shows an empty state when data is undefined", () => {
render(<SpendTrendChart data={undefined} isLoading={false} />);
expect(screen.getByText("No spend data")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<SpendTrendChart data={[]} isLoading />);
expect(screen.queryByText("No spend data")).not.toBeInTheDocument();
});
});
@@ -12,6 +12,9 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { useUsageTimeSeries } from "@/hooks/use-usage";
import { SpendTrendChart } from "./spend-trend-chart";
import type { UsageTimePoint } from "@/types";
// ---------------------------------------------------------------------------
// Loading skeleton — three grouped skeleton blocks
@@ -131,9 +134,15 @@ function DeliverySection({ delivery }: DeliverySectionProps) {
interface SpendSectionProps {
spend: CockpitSummary["spend"];
spendTrend: UsageTimePoint[] | undefined;
spendTrendLoading: boolean;
}
function SpendSection({ spend }: SpendSectionProps) {
function SpendSection({
spend,
spendTrend,
spendTrendLoading,
}: SpendSectionProps) {
const {
monthly_budget_cap_usd,
spend_30d_usd,
@@ -190,6 +199,7 @@ function SpendSection({ spend }: SpendSectionProps) {
)}
</div>
</div>
<SpendTrendChart data={spendTrend} isLoading={spendTrendLoading} />
</div>
);
}
@@ -266,9 +276,15 @@ function StubObjectivesSection() {
interface ScorecardBodyProps {
data: CockpitSummary;
spendTrend: UsageTimePoint[] | undefined;
spendTrendLoading: boolean;
}
function ScorecardBody({ data }: ScorecardBodyProps) {
function ScorecardBody({
data,
spendTrend,
spendTrendLoading,
}: ScorecardBodyProps) {
return (
<Card>
<CardHeader>
@@ -277,7 +293,11 @@ function ScorecardBody({ data }: ScorecardBodyProps) {
</CardHeader>
<CardContent className="space-y-6">
<DeliverySection delivery={data.delivery} />
<SpendSection spend={data.spend} />
<SpendSection
spend={data.spend}
spendTrend={spendTrend}
spendTrendLoading={spendTrendLoading}
/>
<SpeedSection medianLeadTimeHours={data.median_lead_time_hours} />
<StubObjectivesSection />
</CardContent>
@@ -294,6 +314,8 @@ export function CompanyScorecardCard() {
queryKey: ["cockpit-summary"],
queryFn: cockpitApi.summary,
});
const { data: spendTrend, isLoading: spendTrendLoading } =
useUsageTimeSeries("30d");
if (isLoading) return <ScorecardSkeleton />;
@@ -307,5 +329,11 @@ export function CompanyScorecardCard() {
);
}
return <ScorecardBody data={data} />;
return (
<ScorecardBody
data={data}
spendTrend={spendTrend}
spendTrendLoading={spendTrendLoading}
/>
);
}
@@ -0,0 +1,91 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import type { UsageTimePoint } from "@/types";
interface SpendTrendChartProps {
data: UsageTimePoint[] | undefined;
isLoading: boolean;
}
function formatBucket(bucket: string): string {
const d = new Date(bucket);
return d.getMonth() + 1 + "/" + d.getDate();
}
function fmtCost(n: number): string {
return "$" + n.toFixed(2);
}
/**
* Daily-spend breakdown behind the Scorecard's "30-day spend" figure
* reuses GET /usage/time-series (period=30d, daily buckets), the same
* series-shaped endpoint the Overview page's CostTrendChart draws from.
*/
export function SpendTrendChart({ data, isLoading }: SpendTrendChartProps) {
const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket),
Spend: p.cost_usd,
}));
return (
<Card>
<CardHeader className="pb-2">
<HelpTip label="Provider-priced agent-session cost per day over the trailing 30 days — the daily breakdown behind the 30-day spend total above">
<CardTitle className="text-base">Daily Spend (30d)</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No spend data
</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="day"
tick={{ fontSize: 9 }}
interval={4}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtCost}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={44}
/>
<Tooltip
formatter={(value) => [
fmtCost(typeof value === "number" ? value : 0),
"Spend",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Spend" fill="var(--chart-3)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -32,6 +32,13 @@ vi.mock("@/hooks/use-tasks", () => ({
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-usage", () => ({
useUsageTimeSeries: () => ({
data: undefined,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../team-health-cards", () => ({
TeamHealthCards: () => <div>TeamHealthCardsStub</div>,
@@ -87,6 +94,9 @@ vi.mock("../usage-overview-panel", () => ({
vi.mock("../scorecard-overview-panel", () => ({
ScorecardOverviewPanel: () => <div>ScorecardOverviewPanelStub</div>,
}));
vi.mock("../cost-trend-chart", () => ({
CostTrendChart: () => <div>CostTrendChartStub</div>,
}));
import { CommandCenter } from "../command-center";
@@ -106,6 +116,7 @@ describe("CommandCenter", () => {
"AuditorAlertsPanelStub",
"UsageOverviewPanelStub",
"ScorecardOverviewPanelStub",
"CostTrendChartStub",
"TeamHealthCardsStub",
"CeoApprovalQueueStub",
"StrategySignalsPanelStub",
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { CostTrendChart } from "../cost-trend-chart";
import type { UsageTimePoint } from "@/types";
function buildPoint(overrides: Partial<UsageTimePoint> = {}): UsageTimePoint {
return {
bucket: new Date().toISOString(),
tokens_input: 1000,
tokens_output: 500,
total_tokens: 1500,
cost_usd: 1.23,
...overrides,
};
}
describe("CostTrendChart", () => {
it("renders the card title", () => {
render(<CostTrendChart data={[buildPoint()]} isLoading={false} />);
expect(screen.getByText("Spend Trend (7d)")).toBeInTheDocument();
});
it("shows an empty state when there is no data", () => {
render(<CostTrendChart data={[]} isLoading={false} />);
expect(screen.getByText("No usage data")).toBeInTheDocument();
});
it("shows an empty state when data is undefined", () => {
render(<CostTrendChart data={undefined} isLoading={false} />);
expect(screen.getByText("No usage data")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<CostTrendChart data={[]} isLoading />);
expect(screen.queryByText("No usage data")).not.toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import {
useRecentActivity,
} from "@/hooks/use-dashboard";
import { useTasks } from "@/hooks/use-tasks";
import { useUsageTimeSeries } from "@/hooks/use-usage";
import { usePageRefresh } from "@/hooks";
import { TeamHealthCards } from "./team-health-cards";
import { KeyMetricsPanel } from "./key-metrics-panel";
@@ -25,6 +26,7 @@ import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
import { CostTrendChart } from "./cost-trend-chart";
import {
Tooltip,
TooltipContent,
@@ -62,6 +64,11 @@ export function CommandCenter() {
isError: errorActivity,
refetch: refetchActivity,
} = useRecentActivity(24);
const {
data: costTrend,
isLoading: loadingCostTrend,
refetch: refetchCostTrend,
} = useUsageTimeSeries("7d");
const { register, unregister } = usePageRefresh();
@@ -79,6 +86,9 @@ export function CommandCenter() {
() => {
void refetchActivity();
},
() => {
void refetchCostTrend();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
@@ -91,6 +101,7 @@ export function CommandCenter() {
refetchFlags,
refetchTasks,
refetchActivity,
refetchCostTrend,
]);
const hasError = errorOverview || errorFlags || errorTasks || errorActivity;
@@ -156,6 +167,8 @@ export function CommandCenter() {
<ScorecardOverviewPanel />
</div>
<CostTrendChart data={costTrend} isLoading={loadingCostTrend} />
{/* Section 2: Team Health (team cards + Task Intake + Secretary) */}
<section>
<HelpTip label="Per-team blocked ratio and throughput, plus the on-demand agents">
@@ -0,0 +1,109 @@
"use client";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import type { UsageTimePoint } from "@/types";
interface CostTrendChartProps {
data: UsageTimePoint[] | undefined;
isLoading: boolean;
}
function formatBucket(bucket: string): string {
const d = new Date(bucket);
return d.getMonth() + 1 + "/" + d.getDate();
}
function fmtCost(n: number): string {
return "$" + n.toFixed(2);
}
/**
* Compact daily spend trend for the Command Center landing page reuses
* GET /usage/time-series (period=7d, daily buckets) so the CEO sees where
* the current-period totals in UsageOverviewPanel came from at a glance.
*/
export function CostTrendChart({ data, isLoading }: CostTrendChartProps) {
const chartData = (data ?? []).map((p) => ({
day: formatBucket(p.bucket),
Cost: p.cost_usd,
}));
return (
<Card>
<CardHeader className="pb-2">
<HelpTip label="Provider-priced agent-session cost per day over the last 7 days">
<CardTitle className="text-base">Spend Trend (7d)</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No usage data
</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<AreaChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillCost" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-3)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-3)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="day"
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtCost}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={44}
/>
<Tooltip
formatter={(value) => [
fmtCost(typeof value === "number" ? value : 0),
"Cost",
]}
contentStyle={{ fontSize: 12 }}
/>
<Area
type="monotone"
dataKey="Cost"
stroke="var(--chart-3)"
fill="url(#fillCost)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -36,6 +36,7 @@ import {
Download,
RefreshCcw,
GitGraph,
Trash2,
} from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
@@ -51,6 +52,7 @@ interface GitActionsPanelProps {
onPull: () => void;
onFetch: () => void;
onRebase: (targetBranch: string) => void;
onCleanupBranches: () => void;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
@@ -58,6 +60,7 @@ interface GitActionsPanelProps {
isPulling: boolean;
isFetching: boolean;
isRebasing: boolean;
isCleaningUpBranches: boolean;
}
export function GitActionsPanel({
@@ -72,6 +75,7 @@ export function GitActionsPanel({
onPull,
onFetch,
onRebase,
onCleanupBranches,
isCommitting,
isPushing,
isCreatingPR,
@@ -79,6 +83,7 @@ export function GitActionsPanel({
isPulling,
isFetching,
isRebasing,
isCleaningUpBranches,
}: GitActionsPanelProps) {
void _agentId; // Reserved for future use
const [showCommitDialog, setShowCommitDialog] = useState(false);
@@ -513,6 +518,48 @@ export function GitActionsPanel({
</AlertDialogContent>
</AlertDialog>
{/* Cleanup Stale Branches — destructive, requires confirmation */}
<AlertDialog>
<HelpTip label="Deletes the remote + local branch of every completed/cancelled task in this project. Never touches the default branch or an environment-ladder rung (head/qa/stag/prod).">
<span className="block w-full">
<AlertDialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={isCleaningUpBranches}
>
{isCleaningUpBranches ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Clean Up Stale Branches
</Button>
</AlertDialogTrigger>
</span>
</HelpTip>
<AlertDialogContent className="border-destructive bg-destructive/5">
<AlertDialogHeader>
<AlertDialogTitle>Clean up stale branches?</AlertDialogTitle>
<AlertDialogDescription>
Deletes the remote + local branch of every completed or
cancelled task in <strong>{projectSlug}</strong>. The default
branch and every environment-ladder rung are always skipped.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onCleanupBranches}
>
Clean Up
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Status Summary */}
{status && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
+4
View File
@@ -48,6 +48,7 @@ function GitBrowserContent() {
handlePull,
handleFetch,
handleRebase,
handleCleanupBranches,
isCommitting,
isPushing,
isCreatingPR,
@@ -57,6 +58,7 @@ function GitBrowserContent() {
isRebasing,
isCheckingOut,
isCreatingBranch,
isCleaningUpBranches,
} = useGitBrowser();
if (isOffline) {
@@ -139,6 +141,7 @@ function GitBrowserContent() {
onPull={handlePull}
onFetch={handleFetch}
onRebase={handleRebase}
onCleanupBranches={handleCleanupBranches}
isCommitting={isCommitting}
isPushing={isPushing}
isCreatingPR={isCreatingPR}
@@ -146,6 +149,7 @@ function GitBrowserContent() {
isPulling={isPulling}
isFetching={isFetching}
isRebasing={isRebasing}
isCleaningUpBranches={isCleaningUpBranches}
/>
</div>
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SessionTrendChart } from "../session-trend-chart";
import { WorkSessionStatus } from "@/types";
import type { WorkSessionSummary } from "@/types";
function buildSession(overrides: Partial<WorkSessionSummary> = {}): WorkSessionSummary {
return {
id: "session-1",
task_id: "11111111-1111-1111-1111-111111111111",
branch_name: "feature/backend/ABC12345",
status: WorkSessionStatus.ACTIVE,
started_at: new Date().toISOString(),
has_pr: false,
...overrides,
};
}
describe("SessionTrendChart", () => {
it("renders the card title", () => {
render(<SessionTrendChart sessions={[buildSession()]} isLoading={false} />);
expect(screen.getByText("Active Session Starts")).toBeInTheDocument();
});
it("shows an empty state when there are no sessions", () => {
render(<SessionTrendChart sessions={[]} isLoading={false} />);
expect(screen.getByText("No active sessions")).toBeInTheDocument();
});
it("shows an empty state when sessions is undefined", () => {
render(<SessionTrendChart sessions={undefined} isLoading={false} />);
expect(screen.getByText("No active sessions")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
render(<SessionTrendChart sessions={[]} isLoading />);
expect(screen.queryByText("No active sessions")).not.toBeInTheDocument();
});
});
@@ -1,2 +1,4 @@
export { WorkSessionTable } from "./work-session-table";
export { WorkSessionFilters } from "./work-session-filters";
export { SessionTrendChart } from "./session-trend-chart";
export { WorkSessionsView } from "./work-sessions-view";
@@ -0,0 +1,128 @@
"use client";
import { useMemo } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import type { WorkSessionSummary } from "@/types";
interface SessionTrendChartProps {
sessions: WorkSessionSummary[] | undefined;
isLoading: boolean;
}
const HOURLY_SPAN_MS = 36 * 60 * 60 * 1000;
interface Bucket {
key: string;
label: string;
count: number;
}
/**
* Buckets session `started_at` timestamps by hour (span <= 36h) or by day
* (wider span), mirroring the hourly/daily switch usage-time-series-chart
* applies for its period-selected data.
*/
function bucketSessions(sessions: WorkSessionSummary[]): Bucket[] {
if (sessions.length === 0) return [];
const times = sessions.map((s) => new Date(s.started_at).getTime());
const spanMs = Math.max(...times) - Math.min(...times);
const hourly = spanMs <= HOURLY_SPAN_MS;
const counts = new Map<string, number>();
for (const s of sessions) {
const d = new Date(s.started_at);
const key = hourly
? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}`
: `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const buckets: Bucket[] = Array.from(counts.entries()).map(([key, count]) => {
const [y, m, day, hour] = key.split("-").map(Number);
const d = new Date(y, m, day, hour ?? 0);
const label = hourly
? d.getHours().toString().padStart(2, "0") + ":00"
: d.getMonth() + 1 + "/" + d.getDate();
return { key, label, count };
});
buckets.sort((a, b) => a.key.localeCompare(b.key));
return buckets;
}
/**
* Session-start volume trend for the Work Sessions page. `GET /work-sessions`
* (unfiltered, as this page calls it) returns only currently ACTIVE sessions
* there is no tokens/cost/duration field on WorkSessionSummary (that data
* lives in agent_spawn_sessions, a different table) and no historical depth
* beyond whatever is active right now. So this charts what's honestly here:
* a start-time distribution of the active sessions already on the page,
* labeled accordingly rather than presented as a full history.
*/
export function SessionTrendChart({
sessions,
isLoading,
}: SessionTrendChartProps) {
const buckets = useMemo(() => bucketSessions(sessions ?? []), [sessions]);
return (
<Card>
<CardHeader className="pb-2">
<HelpTip label="When today's currently active work sessions began, bucketed by hour or day — this list only ever shows active sessions, not full session history">
<CardTitle className="text-base">Active Session Starts</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : buckets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No active sessions
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={buckets}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="label"
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={28}
/>
<Tooltip
formatter={(value) => [
typeof value === "number" ? value : 0,
"Sessions started",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="count" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { WorkSessionStatus } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { WorkSessionTable } from "./work-session-table";
import { WorkSessionFilters } from "./work-session-filters";
import { SessionTrendChart } from "./session-trend-chart";
import { usePageRefresh } from "@/hooks";
/**
* Work-sessions content, rendered as the "Work Sessions" tab of /git.
* Filter state is LOCAL, deliberately not URL params: every URL write forks
* ScrollRestoration's route key and force-scrolls <main> to top, so a
* per-keystroke q= param made typing in the search box bounce the page.
*/
export function WorkSessionsView() {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<WorkSessionStatus[]>([]);
const handleSearchChange = setSearchQuery;
const handleStatusChange = setStatusFilter;
// Fetch work sessions
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter sessions client-side for search and multi-select status filter
const filteredSessions = useMemo(() => {
if (!sessions) return [];
return sessions.filter((session) => {
// Search filter - match branch name
if (
searchQuery &&
!session.branch_name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Status filter (if any selected, session must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(session.status)) {
return false;
}
return true;
});
}, [sessions, searchQuery, statusFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Work Sessions</h1>
<p className="text-muted-foreground">
Track git branches and pull requests for active work
</p>
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<WorkSessionFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusChange={handleStatusChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Work Sessions"
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
onRetry={() => void refresh()}
/>
) : (
<>
<SessionTrendChart sessions={filteredSessions} isLoading={isLoading} />
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
</>
)}
</div>
);
}
@@ -73,6 +73,7 @@ function buildMutations(overrides: Record<string, unknown> = {}) {
pull: { mutateAsync: vi.fn(), isPending: false },
fetch: { mutateAsync: vi.fn(), isPending: false },
rebase: { mutateAsync: vi.fn(), isPending: false },
cleanupBranches: { mutateAsync: vi.fn(), isPending: false },
...overrides,
};
}
@@ -312,4 +313,41 @@ describe("useGitBrowser", () => {
expect(result.current.isCommitting).toBe(true);
expect(result.current.isPushing).toBe(false);
});
it("cleans up branches for the current project and shows a count toast", async () => {
const mutateAsync = vi.fn(() =>
Promise.resolve({
remote_deleted: 3,
local_deleted: 2,
skipped: 1,
errors: 0,
truncated: false,
}),
);
mockUseGitOperations.mockReturnValue(
buildMutations({ cleanupBranches: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handleCleanupBranches();
await waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({ project_slug: "roboco" }),
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"Cleaned up branches: 3 remote, 2 local, 1 skipped, 0 errors",
);
});
it("shows an error toast when branch cleanup fails", async () => {
const mutateAsync = vi.fn(() => Promise.reject(new Error("boom")));
mockUseGitOperations.mockReturnValue(
buildMutations({ cleanupBranches: { mutateAsync, isPending: false } }),
);
const { result } = renderHook(() => useGitBrowser());
await result.current.handleCleanupBranches();
await waitFor(() => expect(mockToastError).toHaveBeenCalledWith("boom"));
});
});
+38 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useProjects } from "@/hooks/use-projects";
@@ -45,6 +45,7 @@ export interface UseGitBrowserResult {
handlePull: () => Promise<void>;
handleFetch: () => Promise<void>;
handleRebase: (targetBranch: string) => Promise<void>;
handleCleanupBranches: () => Promise<void>;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
@@ -54,6 +55,7 @@ export interface UseGitBrowserResult {
isRebasing: boolean;
isCheckingOut: boolean;
isCreatingBranch: boolean;
isCleaningUpBranches: boolean;
}
/**
@@ -162,8 +164,12 @@ export function useGitBrowser(): UseGitBrowserResult {
pull,
fetch,
rebase,
cleanupBranches,
} = useGitOperations();
// Resume point for a capped stale-branch sweep, per project.
const cleanupCursorRef = useRef<{ slug: string; cursor: string } | null>(null);
const handleCheckout = useCallback(
async (branch: string) => {
try {
@@ -317,6 +323,35 @@ export function useGitBrowser(): UseGitBrowserResult {
[projectSlug, taskId, rebase],
);
const handleCleanupBranches = useCallback(async () => {
try {
// Resume a capped sweep from where the last click stopped — without
// the cursor the backend re-scans the identical first window forever.
const cursor =
cleanupCursorRef.current?.slug === projectSlug
? cleanupCursorRef.current.cursor
: undefined;
const result = await cleanupBranches.mutateAsync({
project_slug: projectSlug,
...(cursor ? { after_cursor: cursor } : {}),
});
cleanupCursorRef.current =
result.truncated && result.next_cursor
? { slug: projectSlug, cursor: result.next_cursor }
: null;
const truncatedNote = result.truncated
? " (cap reached — click again to continue where it stopped)"
: "";
toast.success(
`Cleaned up branches: ${result.remote_deleted} remote, ` +
`${result.local_deleted} local, ${result.skipped} skipped, ` +
`${result.errors} errors${truncatedNote}`,
);
} catch (error) {
toast.error(getErrorMessage(error));
}
}, [projectSlug, cleanupBranches]);
const isOffline =
!!projectsError &&
(projectsError.message?.includes("Network Error") ||
@@ -349,6 +384,7 @@ export function useGitBrowser(): UseGitBrowserResult {
handlePull,
handleFetch,
handleRebase,
handleCleanupBranches,
isCommitting: commit.isPending,
isPushing: push.isPending,
isCreatingPR: createPR.isPending,
@@ -358,5 +394,6 @@ export function useGitBrowser(): UseGitBrowserResult {
isRebasing: rebase.isPending,
isCheckingOut: checkout.isPending,
isCreatingBranch: createBranch.isPending,
isCleaningUpBranches: cleanupBranches.isPending,
};
}
+25
View File
@@ -30,6 +30,8 @@ import type {
GitFetchResponse,
GitRebaseRequest,
GitRebaseResponse,
GitBranchCleanupRequest,
GitBranchCleanupResponse,
} from "@/types/git";
// =============================================================================
@@ -331,6 +333,27 @@ export function useGitRebase() {
});
}
/**
* Sweep a project's terminal-task branches (remote + local, PM/CEO only)
*/
export function useCleanupBranches() {
const queryClient = useQueryClient();
return useMutation<
GitBranchCleanupResponse,
Error,
GitBranchCleanupRequest
>({
mutationFn: (request) => gitApi.cleanupBranches(request),
onSuccess: (_, variables) => {
// Invalidate branches — the sweep may have deleted several.
queryClient.invalidateQueries({
queryKey: [...gitKeys.all, "branches", variables.project_slug],
});
},
});
}
// =============================================================================
// Bundled Hook for Git Operations
// =============================================================================
@@ -348,6 +371,7 @@ export function useGitOperations() {
const pull = useGitPull();
const fetch = useGitFetch();
const rebase = useGitRebase();
const cleanupBranches = useCleanupBranches();
return {
commit,
@@ -359,5 +383,6 @@ export function useGitOperations() {
pull,
fetch,
rebase,
cleanupBranches,
};
}
+26
View File
@@ -30,6 +30,8 @@ import type {
GitFetchResponse,
GitRebaseRequest,
GitRebaseResponse,
GitBranchCleanupRequest,
GitBranchCleanupResponse,
} from "@/types/git";
// =============================================================================
@@ -358,4 +360,28 @@ export const gitApi = {
const { data } = await api.post<GitRebaseResponse>("/git/rebase", request);
return data;
},
/**
* Sweep a project's terminal-task branches (remote + local, PM/CEO only)
*/
cleanupBranches: async (
request: GitBranchCleanupRequest,
): Promise<GitBranchCleanupResponse> => {
if (isMockMode()) {
return {
project_slug: request.project_slug,
remote_deleted: 3,
local_deleted: 3,
skipped: 0,
errors: 0,
truncated: false,
next_cursor: null,
};
}
const { data } = await api.post<GitBranchCleanupResponse>(
"/git/branches/cleanup",
request,
);
return data;
},
};
+16
View File
@@ -198,3 +198,19 @@ export interface GitRebaseResponse {
conflict: boolean;
conflicted_files: string[];
}
export interface GitBranchCleanupRequest {
project_slug: string;
/** Resume point from a prior truncated sweep's next_cursor. */
after_cursor?: string;
}
export interface GitBranchCleanupResponse {
project_slug: string;
remote_deleted: number;
local_deleted: number;
skipped: number;
errors: number;
truncated: boolean;
next_cursor: string | null;
}
+59
View File
@@ -31,6 +31,8 @@ from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.git import (
BranchInfo,
CommitInfo,
GitBranchCleanupRequest,
GitBranchCleanupResponse,
GitBranchListResponse,
GitCheckoutRequest,
GitCheckoutResponse,
@@ -763,3 +765,60 @@ async def rebase_branch(
conflict=conflict,
conflicted_files=conflicted_files,
)
@router.post("/branches/cleanup", response_model=GitBranchCleanupResponse)
@guard_deco.rate_limit(requests=5, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
async def cleanup_stale_branches(
data: GitBranchCleanupRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitBranchCleanupResponse:
"""Sweep a project's terminal-task branches (PM/CEO only).
Deletes the remote + local branch of every completed/cancelled task in
the project (capped per call see ``GitService.cleanup_stale_branches``),
skipping the default branch and any environment-ladder rung so a live
integration/prod branch is never touched. Role-gated identically to
``/rebase`` a history-affecting bulk operation shouldn't be open to
developers either. Purely a read + external-git-op endpoint: no task rows
are mutated, so there's nothing for this request to commit.
"""
if agent.role not in _REBASE_ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"BRANCH_CLEANUP_ROLE_RESTRICTED: Role '{agent.role}' is not "
"permitted to sweep branches. Only CEO and PM roles (cell_pm, "
"main_pm) may use this endpoint."
),
)
project_slug = await _resolve_project_slug(data.project_slug, db)
git_service = get_git_service(db)
try:
(
remote_deleted,
local_deleted,
skipped,
errors,
truncated,
next_cursor,
) = await git_service.cleanup_stale_branches(
project_slug, after_task_id=data.after_cursor
)
except _TranslatableError as e:
raise _translate_error(e) from e
return GitBranchCleanupResponse(
project_slug=project_slug,
remote_deleted=remote_deleted,
local_deleted=local_deleted,
skipped=skipped,
errors=errors,
truncated=truncated,
next_cursor=next_cursor,
)
+36
View File
@@ -334,6 +334,42 @@ class GitRebaseResponse(BaseModel):
conflicted_files: list[str] = []
# =============================================================================
# BRANCH CLEANUP
# =============================================================================
class GitBranchCleanupRequest(BaseModel):
"""Request to sweep a project's terminal-task branches.
``after_cursor`` resumes a capped sweep from a prior response's
``next_cursor`` task rows never change as a sweep side effect, so
without it a repeat call re-scans the same first window forever.
"""
project_slug: str
after_cursor: UUID | None = None
class GitBranchCleanupResponse(BaseModel):
"""Counts from a stale-branch cleanup sweep.
``local_deleted`` counts an attempted local delete (assignee/clone
resolved), not a confirmed one the underlying ``git branch -D`` is
itself best-effort. ``truncated`` is True when more terminal-task
branches existed than the per-call cap; ``next_cursor`` is then the
resume point to pass back as ``after_cursor``.
"""
project_slug: str
remote_deleted: int = 0
local_deleted: int = 0
skipped: int = 0
errors: int = 0
truncated: bool = False
next_cursor: str | None = None
# =============================================================================
# GATEWAY-LAYER LIGHTWEIGHT SCHEMAS
#
+161 -13
View File
@@ -42,7 +42,7 @@ if TYPE_CHECKING:
GitCreatePRRequest,
GitMergePRRequest,
)
from roboco.db.tables import TaskTable
from roboco.db.tables import ProjectTable, TaskTable
from roboco.config import settings
from roboco.exceptions import (
GitCommandError,
@@ -53,7 +53,7 @@ from roboco.exceptions import (
from roboco.foundation.policy import lifecycle
from roboco.foundation.policy.pr_labels import CONVENTIONS_PR_LABELS, derive_pr_labels
from roboco.models.base import AgentRole, TaskStatus
from roboco.models.env_branches import head_branch
from roboco.models.env_branches import effective_environments, head_branch
from roboco.services.base import (
BaseService,
NotFoundError,
@@ -3607,16 +3607,19 @@ class GitService(BaseService):
async def _delete_remote_branch_best_effort(
self, owner: str, repo: str, branch: str, git_token: str
) -> None:
) -> bool:
"""Best-effort: delete a remote branch by name.
Silently swallows errors cleanup is not critical. Skips branches that
look like project defaults (main / master / develop) and any branch that
still has open dependent PRs (an active integration target deleting it
would strand in-flight child work).
would strand in-flight child work). Returns True if the delete request
was issued with no transport error, False on any skip/failure callers
that only fire-and-forget can ignore it; the branch-cleanup sweep uses
it to report counts.
"""
if branch in ("main", "master", "develop", ""):
return
return False
if await self._branch_has_open_dependents(owner, repo, branch, git_token):
self.log.info(
"branch delete skipped: open dependent PRs target it as base",
@@ -3624,7 +3627,7 @@ class GitService(BaseService):
owner=owner,
repo=repo,
)
return
return False
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(
@@ -3635,8 +3638,9 @@ class GitService(BaseService):
"X-GitHub-Api-Version": "2022-11-28",
},
)
return True
except httpx.HTTPError:
return
return False
async def _delete_pr_branch_best_effort(
self, owner: str, repo: str, pr_number: int, git_token: str
@@ -3664,15 +3668,25 @@ class GitService(BaseService):
except httpx.HTTPError:
return
async def delete_task_branch(self, project_slug: str, branch_name: str) -> None:
async def delete_task_branch(self, project_slug: str, branch_name: str) -> bool:
"""Delete a remote task branch after cancel/discard. Best-effort.
Called by `TaskService` on cancellation so abandoned task
branches don't accumulate on the remote.
branches don't accumulate on the remote. Returns whether the delete
was actually issued (see ``_delete_remote_branch_best_effort``).
This is the chokepoint every task-scoped remote-delete call routes
through, so the environment-ladder guard lives here rather than only
at each caller: ``_delete_remote_branch_best_effort``'s own
main/master/develop skip predates the env-ladder model and doesn't
know about it (it's a generic branch-delete primitive also used by
the merged-PR source-branch cleanup, which never targets a ladder
branch by construction) a task's ``branch_name`` could otherwise
coincide with a ladder rung and get deleted out from under it.
"""
git_token = await self._token_for_project(project_slug)
if not git_token:
return
return False
# Resolve remote from any workspace — branch deletion only needs
# the owner/repo, not a checkout. Use a service-root probe path
# if no agent workspace is available.
@@ -3680,14 +3694,148 @@ class GitService(BaseService):
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project or not project.git_url:
return
return False
if branch_name in {r.branch for r in effective_environments(project)}:
return False
owner, repo = self._parse_git_url(project.git_url)
except Exception:
return
await self._delete_remote_branch_best_effort(
return False
return await self._delete_remote_branch_best_effort(
owner, repo, branch_name, git_token
)
# Per-call cap on the stale-branch sweep so one request can't hang on an
# unbounded fan-out of remote-delete calls.
_CLEANUP_BRANCH_LIMIT = 200
async def cleanup_stale_branches(
self, project_slug: str, after_task_id: UUID | None = None
) -> tuple[int, int, int, int, bool, str | None]:
"""Sweep a project's terminal tasks and delete their spent branches.
Candidates are TERMINAL (completed/cancelled) tasks with a
``branch_name`` that isn't an environment-ladder rung (a ladder branch
outlives any one task see ``roboco.models.env_branches``). Capped at
``_CLEANUP_BRANCH_LIMIT`` per call; the window is deterministic
(``ORDER BY id``) and cursor-resumable via ``after_task_id`` task
rows never change as a side effect of the sweep, so without a cursor a
repeat call would re-scan the identical first window forever instead
of progressing past the cap. Ladder-branch rows still advance the
cursor (processed-as-excluded), so ``truncated`` can't go false-
negative when rungs land inside the window. Per branch, best-effort:
remote delete (the same guarded ``delete_task_branch`` cancel already
uses main/master/develop and open-dependent-PR branches are skipped
there too) and, in the assignee's clone, a force local delete (a
completed task's branch was squash-merged, so a safe ``-d`` would
refuse unconditionally; a cancelled one's work is discarded by
decision).
Returns ``(remote_deleted, local_deleted, skipped, errors, truncated,
next_cursor)`` ``next_cursor`` is the last processed task id when
truncated, to pass back as ``after_task_id``. ``local_deleted`` counts
a local delete as ATTEMPTED (assignee/clone resolved), not confirmed
the underlying ``git branch -D`` is itself best-effort and reports no
outcome. ``skipped`` counts branches with no resolvable assignee/clone
(nothing to locally clean up, though the remote delete may still have
run); ``errors`` counts branches that raised unexpectedly while
resolving the assignee's workspace.
"""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
return (0, 0, 0, 0, False, None)
candidates, truncated, next_cursor = await self._stale_branch_window(
project, after_task_id
)
remote_deleted = local_deleted = skipped = errors = 0
workspace_service = get_workspace_service(self.session)
for task in candidates:
branch = str(task.branch_name)
try:
remote_ok, local_attempted = await self._cleanup_one_stale_branch(
project_slug, task, branch, workspace_service
)
except Exception as e:
errors += 1
self.log.warning(
"Stale-branch cleanup skipped for branch",
project_slug=project_slug,
branch=branch,
error=str(e),
)
continue
remote_deleted += int(remote_ok)
if local_attempted:
local_deleted += 1
else:
skipped += 1
return (remote_deleted, local_deleted, skipped, errors, truncated, next_cursor)
async def _stale_branch_window(
self, project: ProjectTable, after_task_id: UUID | None
) -> tuple[list[TaskTable], bool, str | None]:
"""Fetch one deterministic, cursor-resumable window of terminal-task
branch-cleanup candidates for ``cleanup_stale_branches``.
Returns ``(candidates, truncated, next_cursor)`` ladder-branch rows
stay in the window (and so still advance the cursor) but are excluded
from ``candidates``, matching the caller's docstring.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable
ladder_branches = {rung.branch for rung in effective_environments(project)}
query = (
select(TaskTable)
.where(TaskTable.project_id == project.id)
.where(TaskTable.branch_name.is_not(None))
.where(TaskTable.status.in_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]))
.order_by(TaskTable.id)
.limit(self._CLEANUP_BRANCH_LIMIT + 1)
)
if after_task_id is not None:
query = query.where(TaskTable.id > after_task_id)
result = await self.session.execute(query)
window = list(result.scalars().all())
truncated = len(window) > self._CLEANUP_BRANCH_LIMIT
window = window[: self._CLEANUP_BRANCH_LIMIT]
next_cursor = str(window[-1].id) if truncated and window else None
candidates = [t for t in window if str(t.branch_name) not in ladder_branches]
return candidates, truncated, next_cursor
async def _cleanup_one_stale_branch(
self,
project_slug: str,
task: TaskTable,
branch: str,
workspace_service: WorkspaceService,
) -> tuple[bool, bool]:
"""Delete one candidate's remote + local branch.
Returns ``(remote_deleted, local_attempted)`` see
``cleanup_stale_branches`` for what each means. Raises on an
unexpected failure so the caller's per-branch try/except counts it.
"""
remote_deleted = await self.delete_task_branch(project_slug, branch)
assignee = task.assignee
if assignee is None or assignee.team is None or assignee.slug is None:
return remote_deleted, False
clone_root = workspace_service.get_clone_root_path(
project_slug, assignee.team, assignee.slug
)
# force for every terminal candidate: a completed task's PR was
# squash-merged (its local ref is never an ancestor of the base, so
# -d refuses unconditionally), a cancelled one's work is discarded
# by decision — the ref is spent either way.
await workspace_service.delete_local_branch(clone_root, branch, force=True)
return remote_deleted, True
async def _first_allowed_merge_method(
self,
owner: str,
+90 -34
View File
@@ -6,6 +6,7 @@ Handles status transitions, assignments, and queries.
"""
import asyncio
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -60,7 +61,7 @@ from roboco.models.base import (
TaskType,
Team,
)
from roboco.models.env_branches import head_branch
from roboco.models.env_branches import effective_environments, head_branch
from roboco.models.permissions import AgentContext, TaskAction
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionCreate
@@ -6723,30 +6724,36 @@ class TaskService(BaseService):
await ws_service.abandon(require_uuid(task.work_session_id), reason=reason)
async def _delete_task_branch_best_effort(self, task: TaskTable) -> None:
"""Delete the task's remote branch + per-task worktree on cancel.
"""Delete the task's remote + local branch and per-task worktree on
cancel.
Best-effort, never raises. Skipped for tasks that didn't make it to a
branch yet, or whose PR already merged (merge path deletes the source
branch). The worktree at ``{clone_root}/.worktrees/{task-short}/`` is
removed from the assignee's clone so cancelled tasks don't leak full
working trees on disk (F123). The stale-claim reaper must NOT call this
it routes to ``pending`` for a re-claim that reuses the worktree.
working trees on disk (F123); the local branch ref (force-deleted
cancelled work is discarded on purpose) and the task's video-preview
dir are cleaned up alongside it. The stale-claim reaper must NOT call
this it routes to ``pending`` for a re-claim that reuses the
worktree.
"""
branch = task.branch_name
if not branch:
return
try:
project_result = await self.session.execute(
select(ProjectTable.slug).where(ProjectTable.id == task.project_id)
select(ProjectTable).where(ProjectTable.id == task.project_id)
)
project_slug = project_result.scalar_one_or_none()
if not project_slug:
project = project_result.scalar_one_or_none()
if not project:
return
from roboco.services.git import get_git_service
git_service = get_git_service(self.session)
await git_service.delete_task_branch(project_slug, str(branch))
await self._remove_task_worktree_best_effort(task, project_slug)
await git_service.delete_task_branch(project.slug, str(branch))
await self._remove_task_worktree_best_effort(
task, project, force_branch_delete=True
)
except Exception as e:
# Cleanup is best-effort — don't fail the cancel if the
# remote is unreachable or the branch is already gone.
@@ -6758,46 +6765,95 @@ class TaskService(BaseService):
)
async def _remove_task_worktree_best_effort(
self, task: TaskTable, project_slug: str
self, task: TaskTable, project: ProjectTable, *, force_branch_delete: bool
) -> None:
"""Remove the per-task worktree from the assignee's clone. Never raises.
"""Remove the per-task worktree + local branch ref, and rmtree the
task's video-preview dir. Never raises.
No-op when the task has no resolvable assignee (pooled/unassigned at
cancel) or the assignee carries no team (can't form a clone path).
The worktree/local-branch step no-ops when the task has no resolvable
assignee (pooled/unassigned at cancel) or the assignee carries no team
(can't form a clone path); previews cleanup still runs regardless
(project-scoped, not clone-scoped). The local branch is skipped
entirely when it's still an environment-ladder rung — a ladder branch
outlives any one task.
"""
assignee = task.assignee
if assignee is None or assignee.team is None or assignee.slug is None:
return
from roboco.services.workspace import get_workspace_service
if (
assignee is not None
and assignee.team is not None
and assignee.slug is not None
):
from roboco.services.workspace import get_workspace_service
ws_service = get_workspace_service(self.session)
clone_root = ws_service.get_clone_root_path(
project_slug, assignee.team, assignee.slug
)
worktree = clone_root / ".worktrees" / str(task.id)[:8]
await ws_service.remove_worktree(clone_root, worktree)
ws_service = get_workspace_service(self.session)
clone_root = ws_service.get_clone_root_path(
project.slug, assignee.team, assignee.slug
)
worktree = clone_root / ".worktrees" / str(task.id)[:8]
await ws_service.remove_worktree(clone_root, worktree)
branch = task.branch_name
ladder_branches = {r.branch for r in effective_environments(project)}
if branch and str(branch) not in ladder_branches:
await ws_service.delete_local_branch(
clone_root, str(branch), force=force_branch_delete
)
self._cleanup_task_previews_best_effort(task, project.slug)
def _cleanup_task_previews_best_effort(
self, task: TaskTable, project_slug: str
) -> None:
"""Best-effort rmtree of the task's video-render preview dir.
Nothing else prunes it (``_render_extract_frames`` writes frames but
no cleanup runs on task end), so a video-authoring task's previews
would otherwise leak on disk forever. Guards the resolved path stays
under the project's workspace dir before deleting anything.
"""
from roboco.config import settings
project_dir = Path(settings.workspaces_root) / project_slug
previews_dir = project_dir / ".previews" / str(task.id)[:8]
try:
if not previews_dir.exists():
return
if not previews_dir.resolve().is_relative_to(project_dir.resolve()):
return
shutil.rmtree(previews_dir)
except Exception as e:
self.log.warning(
"Preview cleanup skipped", task_id=str(task.id), error=str(e)
)
async def _remove_task_worktree_on_terminal(self, task: TaskTable) -> None:
"""Best-effort per-task worktree removal on terminal completion.
"""Best-effort per-task worktree + local-branch removal on terminal
completion.
Mirrors the cancel-path cleanup but WITHOUT deleting the remote branch
(the merge path already deleted it). A completed/merged task would
otherwise leak its worktree on disk until the whole agent is deleted
(F123). Best-effort: never raises, so a cleanup failure can't block
completion. No-op for branchless tasks (no worktree was ever cut).
Terminal-only by call site earlier review states may bounce
``needs_revision`` and need the worktree back.
Force-deletes the local branch ref (``-D``), same as the cancel path:
completion implies the PR already merged remotely (the merge path
deleted the remote branch), and the default merge method is SQUASH
the local ref's commits are never ancestors of the base, so a "safe"
``-d`` refuses every time and the ref would leak forever. The ref is
spent either way once the task is terminal. A completed task would
otherwise leak its worktree + branch ref on disk until the whole
agent is deleted (F123). Best-effort: never raises, so a cleanup
failure can't block completion. No-op for branchless tasks (no
worktree was ever cut). Terminal-only by call site earlier review
states may bounce ``needs_revision`` and need the worktree back.
"""
if not task.branch_name:
return
try:
result = await self.session.execute(
select(ProjectTable.slug).where(ProjectTable.id == task.project_id)
select(ProjectTable).where(ProjectTable.id == task.project_id)
)
project_slug = result.scalar_one_or_none()
if not project_slug:
project = result.scalar_one_or_none()
if not project:
return
await self._remove_task_worktree_best_effort(task, project_slug)
await self._remove_task_worktree_best_effort(
task, project, force_branch_delete=True
)
except OSError as e:
# FS/permission failure (stuck mount, perms) — systemic, not
# task-specific. Track the streak and escalate a CEO alert once
+23
View File
@@ -784,6 +784,29 @@ class WorkspaceService:
)
self._worktree_git(clone_root, ["worktree", "prune"], check=False)
async def delete_local_branch(
self, clone_root: Path, branch: str, *, force: bool
) -> None:
"""Delete a local branch ref in a clone. Best-effort, never raises.
``remove_worktree`` only detaches the worktree the local
``refs/heads/{branch}`` ref survives, so every task an agent ever
claimed leaks a permanent branch ref in that agent's clone. Callers
run this right after ``remove_worktree`` (the worktree must be gone
first, or ``git branch -d/-D`` refuses a branch still checked out
elsewhere in the clone).
``force=False`` uses ``-d`` (refuses an unmerged branch a clean skip,
not an error, via ``check=False``); ``force=True`` uses ``-D``. A
missing branch is likewise a clean skip. Never touches main/master/
develop or an empty branch name mirrors
``GitService._delete_remote_branch_best_effort``'s default-branch guard.
"""
if branch in ("main", "master", "develop", ""):
return
flag = "-D" if force else "-d"
self._worktree_git(clone_root, ["branch", flag, branch], check=False)
async def resolve_workspace(
self,
project_slug: str,
@@ -0,0 +1,302 @@
"""GitService.cleanup_stale_branches — the branch-cleanup sweep's selection
+ per-branch delete logic (backs the panel's "Clean up stale branches"
button / POST /git/branches/cleanup).
Real DB (ProjectTable/TaskTable/AgentTable) so the terminal-only + env-ladder
selection runs for real; the two external-I/O boundaries are mocked so the
test never makes a real GitHub API call or runs a real git subprocess:
``_delete_remote_branch_best_effort`` (network) and
``roboco.services.git.get_workspace_service`` (local clone/subprocess).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.services.git import GitService
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def cleanup_setup(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> AsyncIterator[dict[str, Any]]:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="P",
slug=f"p-{uuid4().hex[:8]}",
git_url="https://github.com/acme/repo.git",
default_branch="master",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
svc = GitService(db_session)
monkeypatch.setattr(svc, "_token_for_project", AsyncMock(return_value="tok"))
# Remote delete: no real HTTP — a bare True/False signal is all
# cleanup_stale_branches consumes from it.
monkeypatch.setattr(
svc, "_delete_remote_branch_best_effort", AsyncMock(return_value=True)
)
ws_svc = MagicMock()
ws_svc.get_clone_root_path = MagicMock(
return_value=f"/data/workspaces/{project.slug}/backend/{agent.slug}"
)
ws_svc.delete_local_branch = AsyncMock()
monkeypatch.setattr("roboco.services.git.get_workspace_service", lambda _s: ws_svc)
yield {
"svc": svc,
"db": db_session,
"agent": agent,
"project": project,
"ws_svc": ws_svc,
}
def _task(
setup: dict[str, Any],
*,
branch: str,
status: TaskStatus,
assigned: bool = True,
) -> TaskTable:
task = TaskTable(
id=uuid4(),
title="t",
description="d",
status=status,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
team=Team.BACKEND,
project_id=setup["project"].id,
created_by=setup["agent"].id,
assigned_to=setup["agent"].id if assigned else None,
acceptance_criteria=["ac"],
branch_name=branch,
)
setup["db"].add(task)
return task
@pytest.mark.asyncio
async def test_only_terminal_tasks_are_candidates(
cleanup_setup: dict[str, Any],
) -> None:
_task(cleanup_setup, branch="feature/backend/pending", status=TaskStatus.PENDING)
_task(
cleanup_setup,
branch="feature/backend/inprogress",
status=TaskStatus.IN_PROGRESS,
)
_task(
cleanup_setup, branch="feature/backend/completed", status=TaskStatus.COMPLETED
)
_task(
cleanup_setup, branch="feature/backend/cancelled", status=TaskStatus.CANCELLED
)
await cleanup_setup["db"].flush()
result = await cleanup_setup["svc"].cleanup_stale_branches(
cleanup_setup["project"].slug
)
# Only the 2 terminal tasks are candidates — pending/in_progress untouched.
assert result == (2, 2, 0, 0, False, None)
@pytest.mark.asyncio
async def test_env_ladder_branch_is_excluded(cleanup_setup: dict[str, Any]) -> None:
project = cleanup_setup["project"]
project.environments = [
{"name": "head", "branch": "develop"},
{"name": "prod", "branch": "master"},
]
await cleanup_setup["db"].flush()
# A terminal task whose branch happens to equal a ladder rung must never
# be touched — the ladder outlives any one task.
_task(cleanup_setup, branch="develop", status=TaskStatus.COMPLETED)
_task(
cleanup_setup, branch="feature/backend/real-task", status=TaskStatus.COMPLETED
)
await cleanup_setup["db"].flush()
result = await cleanup_setup["svc"].cleanup_stale_branches(project.slug)
assert result == (1, 1, 0, 0, False, None)
cleanup_setup["ws_svc"].delete_local_branch.assert_awaited_once()
call = cleanup_setup["ws_svc"].delete_local_branch.await_args
assert call is not None
assert call.args[1] == "feature/backend/real-task"
@pytest.mark.asyncio
async def test_cancelled_task_force_deletes_local_branch(
cleanup_setup: dict[str, Any],
) -> None:
_task(cleanup_setup, branch="feature/backend/x", status=TaskStatus.CANCELLED)
await cleanup_setup["db"].flush()
await cleanup_setup["svc"].cleanup_stale_branches(cleanup_setup["project"].slug)
cleanup_setup["ws_svc"].delete_local_branch.assert_awaited_once()
call = cleanup_setup["ws_svc"].delete_local_branch.await_args
assert call is not None
assert call.kwargs["force"] is True
@pytest.mark.asyncio
async def test_completed_task_also_force_deletes_local_branch(
cleanup_setup: dict[str, Any],
) -> None:
# Completed ⇒ the PR already squash-merged, so the local ref is spent but
# never an ancestor of the base — a "safe" -d would refuse every time.
_task(cleanup_setup, branch="feature/backend/x", status=TaskStatus.COMPLETED)
await cleanup_setup["db"].flush()
await cleanup_setup["svc"].cleanup_stale_branches(cleanup_setup["project"].slug)
cleanup_setup["ws_svc"].delete_local_branch.assert_awaited_once()
call = cleanup_setup["ws_svc"].delete_local_branch.await_args
assert call is not None
assert call.kwargs["force"] is True
@pytest.mark.asyncio
async def test_no_assignee_skips_local_delete_but_still_attempts_remote(
cleanup_setup: dict[str, Any],
) -> None:
_task(
cleanup_setup,
branch="feature/backend/orphan",
status=TaskStatus.COMPLETED,
assigned=False,
)
await cleanup_setup["db"].flush()
result = await cleanup_setup["svc"].cleanup_stale_branches(
cleanup_setup["project"].slug
)
assert result == (1, 0, 1, 0, False, None)
cleanup_setup["ws_svc"].delete_local_branch.assert_not_awaited()
@pytest.mark.asyncio
async def test_candidate_set_truncated_past_the_cap(
cleanup_setup: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(GitService, "_CLEANUP_BRANCH_LIMIT", 2)
for i in range(3):
_task(
cleanup_setup,
branch=f"feature/backend/task-{i}",
status=TaskStatus.COMPLETED,
)
await cleanup_setup["db"].flush()
result = await cleanup_setup["svc"].cleanup_stale_branches(
cleanup_setup["project"].slug
)
assert result[:5] == (2, 2, 0, 0, True)
assert result[5] is not None # resume cursor for the next call
@pytest.mark.asyncio
async def test_capped_sweep_progresses_with_cursor(
cleanup_setup: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Repeated sweeps must cover NEW branches, not re-scan the same window —
task rows never change as a sweep side effect, so only the cursor moves
the window forward."""
monkeypatch.setattr(GitService, "_CLEANUP_BRANCH_LIMIT", 2)
for i in range(5):
_task(
cleanup_setup,
branch=f"feature/backend/task-{i}",
status=TaskStatus.COMPLETED,
)
await cleanup_setup["db"].flush()
svc = cleanup_setup["svc"]
slug = cleanup_setup["project"].slug
ws = cleanup_setup["ws_svc"]
first = await svc.cleanup_stale_branches(slug)
assert first[4] is True and first[5] is not None
first_branches = {call.args[1] for call in ws.delete_local_branch.await_args_list}
ws.delete_local_branch.reset_mock()
await svc.cleanup_stale_branches(slug, after_task_id=UUID(first[5]))
second_branches = {call.args[1] for call in ws.delete_local_branch.await_args_list}
assert second_branches, "second sweep processed nothing"
assert first_branches.isdisjoint(second_branches), (
f"second sweep re-touched {first_branches & second_branches}"
)
@pytest.mark.asyncio
async def test_unknown_project_returns_zeroed_result(
cleanup_setup: dict[str, Any],
) -> None:
result = await cleanup_setup["svc"].cleanup_stale_branches("does-not-exist")
assert result == (0, 0, 0, 0, False, None)
@pytest.mark.asyncio
async def test_delete_task_branch_refuses_env_ladder_rung_directly(
cleanup_setup: dict[str, Any],
) -> None:
"""The chokepoint guard, not just the sweep's candidate filter: even a
direct ``delete_task_branch`` call (e.g. the cancel-path caller in
task.py) must refuse a branch that is an environment-ladder rung the
generic ``_delete_remote_branch_best_effort`` primitive's own
main/master/develop skip predates the ladder model and doesn't know it."""
project = cleanup_setup["project"]
project.environments = [
{"name": "head", "branch": "develop"},
{"name": "prod", "branch": "master"},
]
await cleanup_setup["db"].flush()
ok = await cleanup_setup["svc"].delete_task_branch(project.slug, "develop")
assert ok is False
remote_delete = cleanup_setup["svc"]._delete_remote_branch_best_effort
remote_delete.assert_not_awaited()
+73
View File
@@ -1002,5 +1002,78 @@ async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# branches/cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cleanup_branches_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(return_value=(3, 2, 1, 0, False, None))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert (
data["remote_deleted"],
data["local_deleted"],
data["skipped"],
data["errors"],
data["truncated"],
) == (3, 2, 1, 0, False)
svc.cleanup_stale_branches.assert_awaited_once_with(
pm_git_client["project"].slug, after_task_id=None
)
@pytest.mark.asyncio
async def test_cleanup_branches_reports_truncation(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(
return_value=(200, 190, 0, 0, True, "0" * 32)
)
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["truncated"] is True
@pytest.mark.asyncio
async def test_cleanup_branches_developer_gets_403(git_client: dict) -> None:
"""git_client carries a DEVELOPER-role agent — same role gate as /rebase."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "BRANCH_CLEANUP_ROLE_RESTRICTED" in response.json()["detail"]
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_cleanup_branches_project_not_found(pm_git_client: dict) -> None:
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": "does-not-exist"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# Re-export to keep import alive (TC reorders imports)
_ = SimpleNamespace
@@ -50,7 +50,17 @@ def _task(*, branch: str | None, assignee: MagicMock | None) -> MagicMock:
def _project_result(slug: str | None) -> MagicMock:
result = MagicMock()
result.scalar_one_or_none.return_value = slug
# `_delete_task_branch_best_effort` now fetches the full project row (not
# just the slug column) so it can resolve the environment ladder before
# deleting a local branch. `environments=None` degenerates to a
# single-branch ladder off `default_branch` (never equal to a task branch
# in these tests, so the ladder-exclusion guard is a no-op here).
project = (
MagicMock(slug=slug, environments=None, default_branch="master")
if slug
else None
)
result.scalar_one_or_none.return_value = project
return result
@@ -71,6 +81,7 @@ async def test_cancel_removes_worktree_for_assignee() -> None:
ws_svc = MagicMock()
ws_svc.get_clone_root_path = MagicMock(return_value=clone)
ws_svc.remove_worktree = AsyncMock()
ws_svc.delete_local_branch = AsyncMock()
with (
patch(
@@ -97,6 +108,10 @@ async def test_cancel_removes_worktree_for_assignee() -> None:
f"remove must target the task worktree {clone}/.worktrees/{short}; "
f"got {args[1]}"
)
# Cancel force-deletes the local branch ref (-D) — the work is discarded.
ws_svc.delete_local_branch.assert_awaited_once_with(
clone, "feature/backend/abc12345", force=True
)
@pytest.mark.asyncio
@@ -485,3 +485,89 @@ async def test_self_heal_recovers_clone_root_left_on_task_branch(clone: Path) ->
assert (wt / ".git").is_file()
assert _git(wt, "rev-parse", "--abbrev-ref", "HEAD").strip() == branch
assert _git(clone, "rev-parse", "--abbrev-ref", "HEAD").strip() == "main"
# ---------------------------------------------------------------------------
# delete_local_branch — cleans up the branch ref remove_worktree leaves behind
# (worktree removal never deletes refs/heads/{branch}; every claimed task
# leaked a permanent local branch ref until this).
# ---------------------------------------------------------------------------
async def test_delete_local_branch_skips_default_branches(clone: Path) -> None:
svc = _service()
for branch in ("main", "master", "develop", ""):
await svc.delete_local_branch(clone, branch, force=True)
# "main" is the clone's actual current branch — still checked out, untouched.
assert _git(clone, "rev-parse", "--abbrev-ref", "HEAD").strip() == "main"
async def test_delete_local_branch_removes_merged_branch(clone: Path) -> None:
svc = _service()
_git(clone, "branch", "feature/merged") # no new commits -> already merged
assert _ref_exists(clone, "refs/heads/feature/merged")
await svc.delete_local_branch(clone, "feature/merged", force=False)
assert not _ref_exists(clone, "refs/heads/feature/merged")
async def test_delete_local_branch_not_merged_skips_without_force(clone: Path) -> None:
svc = _service()
_git(clone, "checkout", "-b", "feature/unmerged")
(clone / "work.txt").write_text("x")
_git(clone, "add", "work.txt")
_git(clone, "commit", "-m", "unmerged work")
_git(clone, "checkout", "main")
assert _ref_exists(clone, "refs/heads/feature/unmerged")
await svc.delete_local_branch(clone, "feature/unmerged", force=False)
# `-d` refuses an unmerged branch — a clean skip, not an error, ref stays.
assert _ref_exists(clone, "refs/heads/feature/unmerged")
async def test_delete_local_branch_force_deletes_unmerged(clone: Path) -> None:
svc = _service()
_git(clone, "checkout", "-b", "feature/unmerged")
(clone / "work.txt").write_text("x")
_git(clone, "add", "work.txt")
_git(clone, "commit", "-m", "unmerged work")
_git(clone, "checkout", "main")
assert _ref_exists(clone, "refs/heads/feature/unmerged")
await svc.delete_local_branch(clone, "feature/unmerged", force=True)
assert not _ref_exists(clone, "refs/heads/feature/unmerged")
async def test_delete_local_branch_squash_merged_needs_force(clone: Path) -> None:
# RoboCo's default merge method is SQUASH: the branch's commits are never
# ancestors of the base afterwards, so `-d` refuses even though the work
# fully landed. This is why every terminal-path caller passes force=True —
# with -d the completed-task ref would leak forever.
svc = _service()
_git(clone, "checkout", "-b", "feature/squashed")
(clone / "work.txt").write_text("x")
_git(clone, "add", "work.txt")
_git(clone, "commit", "-m", "feature work")
_git(clone, "checkout", "main")
_git(clone, "merge", "--squash", "feature/squashed")
_git(clone, "commit", "-m", "squash-merge feature")
assert (clone / "work.txt").exists() # the work landed on main
await svc.delete_local_branch(clone, "feature/squashed", force=False)
assert _ref_exists(clone, "refs/heads/feature/squashed") # -d refused
await svc.delete_local_branch(clone, "feature/squashed", force=True)
assert not _ref_exists(clone, "refs/heads/feature/squashed")
async def test_delete_local_branch_missing_branch_is_clean_skip(clone: Path) -> None:
svc = _service()
await svc.delete_local_branch(
clone, "feature/never-existed", force=False
) # no error
await svc.delete_local_branch(
clone, "feature/never-existed", force=True
) # no error
@@ -75,7 +75,7 @@ async def test_complete_removes_assignee_worktree_best_effort() -> None:
result = await svc.complete(task.id)
assert result is task
remove.assert_awaited_once_with(task, "roboco-api")
remove.assert_awaited_once_with(task, "roboco-api", force_branch_delete=True)
@pytest.mark.asyncio
@@ -149,7 +149,7 @@ async def test_ceo_approve_removes_assignee_worktree_best_effort() -> None:
result = await svc.ceo_approve(task.id)
assert result is task
remove.assert_awaited_once_with(task, "roboco-api")
remove.assert_awaited_once_with(task, "roboco-api", force_branch_delete=True)
@pytest.mark.asyncio