60 KiB
RoboCo Slice Map — worksession-git
Scope key: worksession-git Repo root: /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco Files in scope:
roboco/services/work_session.pyroboco/services/git.pyroboco/templates/git/(__init__.py,branch.py,commit.py,constants.py,pr_internal.py,pr_root.py)roboco/services/forge/(__init__.py,base.py,github.py,gitea.py,gitlab.py,registry.py,router.py,shaping.py)roboco/foundation/policy/forge.pyroboco/foundation/policy/pr_labels.py
Purpose
This slice is the git substrate every delivery agent works on. GitService runs all git subprocesses (status/commit/branch/push/rebase), mints branches + commit messages + PR bodies from templates, and drives the REST API for PR create/merge/close — now routed through a multi-forge layer (roboco/services/forge/) instead of talking to GitHub directly. WorkSessionService persists the per-claim row that links an agent to a task's branch/commits/PR and enforces the single-active-per-task invariant. The roboco/templates/git/ package is the pure rendering layer for branch names, commit messages, and internal/root PR bodies. roboco/foundation/policy/pr_labels.py derives the org-structure label vocabulary (to master/to slave, root, MegaTask, layer labels) applied at PR-open. Together they are the boundary between the task lifecycle and the actual git history on disk + the configured forge (GitHub, Gitea, or GitLab).
Files
| Path | Role | approx LOC |
|---|---|---|
roboco/services/work_session.py |
WorkSession CRUD, commit/file tracking, PR-lifecycle record, single-active invariant | 685 |
roboco/services/git.py |
Git subprocess execution, branch/commit/PR/rebase/merge/sync, GitHub REST API, conventions validator runner | 4596 |
roboco/templates/git/__init__.py |
Package re-exports for branch/commit/PR templates | 48 |
roboco/templates/git/branch.py |
Hierarchical branch name builder + root-task resolver | 131 |
roboco/templates/git/constants.py |
BRANCH_TYPES, COMMIT_TYPES, MAX_TASK_DEPTH, length constants |
52 |
roboco/templates/git/commit.py |
CommitContext + build_commit_message (traceability links) |
114 |
roboco/templates/git/pr_internal.py |
Internal (subtask→parent) PR title/body builder | 140 |
roboco/templates/git/pr_root.py |
Root (→master, CEO-level) PR title/body builder with task tree | 245 |
roboco/services/forge/__init__.py |
Package re-exports (ForgeRouter, GitProvider, GitHubProvider, GiteaProvider, GitLabProvider, RepoRef, provider_for, register_project_forge) |
32 |
roboco/services/forge/base.py |
Pure contract: RepoRef dataclass + the GitProvider ABC (~20 abstract methods every forge implements) |
208 |
roboco/services/forge/github.py |
GitHub.com/GHE REST transport — the pre-existing inline httpx logic pulled out unchanged; owns the sole retry policy in the package (list_ci_runs) |
432 |
roboco/services/forge/gitea.py |
Self-hosted Gitea REST transport, adapting the wire contract back into GitHub shapes | 471 |
roboco/services/forge/gitlab.py |
GitLab REST v4 transport — heaviest adaptation (MR iid, diff reassembly, pipelines) |
679 |
roboco/services/forge/registry.py |
Host↔provider(+scheme) map + provider_for(project) resolution, self-healing per-process |
104 |
roboco/services/forge/router.py |
ForgeRouter — implements GitProvider by picking a transport per call from RepoRef.host |
195 |
roboco/services/forge/shaping.py |
ShapedResponse — an httpx.Response-compatible stand-in a non-GitHub provider returns when it must synthesize a status the wire call didn't produce (e.g. a shaped 501 for merge_branch) |
51 |
roboco/foundation/policy/forge.py |
Pure host/provider detection (extract_host, detect_provider) + registration-time validation (validate_project_forge) |
99 |
roboco/foundation/policy/pr_labels.py |
derive_pr_labels — the org-structure label vocabulary (to <base_branch>, root, MegaTask, layer labels) applied at every PR-open site |
~90 |
Key Symbols
| Name | Kind | File:Line | Responsibility |
|---|---|---|---|
WorkSessionService |
class | work_session.py:29 | Session lifecycle + single-active invariant |
WorkSessionService.create |
method | work_session.py:50 | Validate project/task, refuse duplicate, supersede stale ACTIVE, insert row |
WorkSessionService.get_active_for_task |
method | work_session.py:184 | Most-recent ACTIVE row (resilient to dup-rows defect) |
WorkSessionService.supersede_active_sessions_for_task |
method | work_session.py:247 | ABANDON every other ACTIVE session for a task (single-active) |
WorkSessionService.add_commit |
method | work_session.py:363 | Append dedup'd commit SHA to session.commits |
WorkSessionService.create_pr |
method | work_session.py:427 | Record pr_number/pr_url/pr_created_at |
WorkSessionService.merge_pr |
method | work_session.py:488 | Record merge + COMPLETED; idempotent active-guard (F062) |
WorkSessionService.close |
method | work_session.py:618 | Idempotent COMPLETED on task completion |
WorkSessionService.abandon |
method | work_session.py:577 | ABANDONED + ended_at (non-active → warning + None) |
WorkSessionService.has_unpushed_commits |
method | work_session.py:662 | PR-existence proxy for unpushed commit detection |
WorkSessionService.task_team_for_session |
method | work_session.py:147 | Return the task's team (cell) for a given session; used by route layer's PM cell-ownership check on merge_pr |
get_work_session_service |
factory | work_session.py:682 | Construct service from AsyncSession |
GitService |
class | git.py:234 | All git operations + GitHub API |
_GIT_EXECUTOR |
module const | git.py:116 | Dedicated ThreadPoolExecutor for git subprocesses (16 workers) |
resolve_git_dir |
func | git.py:129 | Resolve .git dir for clone OR linked worktree |
_remove_stale_git_locks |
func | git.py:156 | Best-effort clear orphaned .git/**/*.lock after timeout SIGKILL |
_select_ci_head_run |
func | git.py:220 | Pick CI run matching current HEAD (anti-stale-green) |
GitService._run_git |
method | git.py:247 | Run git subprocess in dedicated pool, token header, chown-back, lock cleanup |
GitService._token_for_project |
method | git.py:344 | Decrypted project PAT (logs loudly on key-rotation failure) |
GitService.get_workspace |
method | git.py:387 | Resolve/clone agent workspace (auto_clone aware) |
GitService.get_status |
method | git.py:499 | Porcelain status + ahead/behind |
GitService._classify_porcelain |
static | git.py:439 | Split porcelain into staged/unstaged/untracked (column-safe) |
GitService._parse_git_url |
static | git.py:557 | Extract (owner,repo) from tokened/https/ssh GitHub URL |
GitService.create_commit |
method | git.py:640 | Stage + commit with template message + worktree ensure |
GitService._worktree_for_task |
static | git.py:747 | Per-task worktree path {clone_root}/.worktrees/{task_id[:8]} (F123) |
GitService._ensure_worktree_for_commit |
method | git.py:756 | Re-attach a pruned worktree before cwd-dependent op |
GitService._assert_on_task_branch |
method | git.py:771 | Recover drifted clone onto task branch (never discards work) |
GitService.commit_for_task |
method | git.py:875 | Agent-facing commit verb backing the commit content tool |
GitService.create_branch |
method | git.py:989 | Build branch name, fetch base, worktree add (F123), push -u |
GitService.create_branch_for_task |
method | git.py:1188 | Resolve workspace/team, create branch, commit DB |
GitService.checkout_branch_for_agent |
method | git.py:1276 | Allowlist-bounded checkout for agent verb |
GitService.push_for_task |
method | git.py:1428 | Push the task's recorded branch by name (clone-checkout-independent); runs _run_codegen_and_commit first so any codegen drift lands in the SAME push |
GitService._codegen_command_for |
staticmethod | git.py:4397 | The project's codegen_command (nullable string column, migration 078), or None — a bare MagicMock test double auto-vivifies attributes, so the isinstance check also keeps unrelated tests inert |
GitService._run_codegen_and_commit |
method | git.py:4474 | Runs the project's codegen_command in the task's worktree right before push (hooked at both push_for_task/push_branch at git.py:1886 and push_task_branch at git.py:4689); fail-open (a broken/timeout command logs a warning and lets the push proceed — CI's own drift gate is the safety net) and a no-op for the common case of no declared command; a clean run with actual drift stages + commits it under the task's normal commit format before the push carries it |
GitService.push_task_branch |
method | git.py:1467 | Gateway branch-keyed push |
GitService.create_pull_request |
method | git.py:2132 | Open PR via GitHub API (legacy project-scoped) |
GitService.create_pr_for_task |
method | git.py:2639 | Agent-facing open_pr verb |
GitService.update_pr_for_task |
method | git.py:2506 | Patch PR title/body; 404→typed GitError |
GitService.get_pr_head_sha |
method | git.py:2451 | PR head SHA for pr_fail re-submit loop guard (fail-open) |
GitService.get_latest_ci_conclusion |
method | git.py:1929 | Per-project CI signal (unknown never false-green) |
GitService.get_pr_ci_status |
method | git.py:2662 | CI status of a PR's current head commit for the in-path pr_pass gate; returns {state, failing_checks?, head_sha} or None on config gaps (fail-open) |
GitService._ci_status_prereqs |
method | git.py:2698 | Resolve (owner, repo, auth headers, head_sha) for CI-status lookup or None on any gap |
GitService._fetch_check_runs |
method | git.py:2730 | GET check-runs for head_sha; None on any API failure |
GitService._classify_check_runs |
method | git.py:2770 | State classification: success/failure/pending from check-run conclusions list |
GitService._classify_zero_check_runs |
method | git.py:2790 | State classification when zero check-runs exist: pending_not_scheduled or no_ci_configured (depends on workflow count) |
GitService.list_open_prs |
method | git.py:1844 | Normalized open-PR list |
GitService.post_pr_review |
method | git.py:2329 | Post reviewer comments via GitHub API |
GitService.merge_pull_request |
method | git.py:2914 | GitHub merge API + method fallback + already-merged disambiguation |
GitService.merge_pr_for_task |
method | git.py:3046 | Role-gated merge + recorded-PR verification + auto-complete |
GitService._assert_merge_role |
method | git.py:2989 | PM/CEO approval-chain role gate |
GitService.pr_merge |
method | git.py:3616 | Gateway merge: project_id-scoped, parent row lock, 409 retry, CEO-only default guard |
GitService._merge_with_retry |
method | git.py:3555 | Single 409 retry + already-merged disambiguation → MergeConflictError |
GitService._lock_parent_task_for_merge |
method | git.py:3503 | SELECT FOR UPDATE on parent task (sibling merge serialization) |
GitService._resolve_merger_id |
static | git.py:3530 | merged_by attribution: actor→assigned→created→UUID(0) |
GitService.rebase_onto_base |
method | git.py:5158 | Rebase primitive: rebased/superseded/conflicts/diverged classification. Post-fetch, _reset_head_or_diverged classifies local vs origin/<head_branch> BEFORE the old unconditional reset --hard: behind-or-equal resets to origin (unchanged); strictly ahead skips the reset and rebases from the local tip (the committed-but-unpushed-work case commit leaves behind); a real divergence returns {"status": "diverged", "local_only", "origin_only"} untouched — no reset, no rebase, no push. A patch-equivalence probe (rev-list --right-only --cherry-pick) first tries to recognize the diverged shape as a prior rebase whose force-push failed (self-heals as "ahead" on retry) before declaring a genuine two-sided divergence (#683). |
GitService.rebase_pr_for_task |
method | git.py:3792 | PR-keyed rebase via PR refs (project_id scoped) |
GitService.sync_task_branch |
method | git.py:3847 | Task-keyed rebase through dev sync_branch verb (pre-PR) |
GitService.is_behind_base |
method | git.py:3889 | (behind, ahead) counts for i_am_done submit gate |
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, UNIONED with _protected_branches_for_deletion(project_slug) — the shared chokepoint every remote-delete path (task cleanup, PR-merge/close cleanup, the stale-branch sweep) routes through; returns bool (issued vs skipped/failed) |
GitService._protected_branches_for |
method | git.py:1183 | The project's own declared protected_branches, normalized (stripped, deduped into a frozenset) and unioned into the hardcoded {master, main} floor for rebase refusal (rebase_onto_base) and sync_task_branch's force-push refusal; fails OPEN (empty set, logs a warning) on a project-lookup error — a wrongly-blocked rebase over a transient DB blip is the worse tradeoff |
GitService._protected_branches_for_deletion |
method | git.py:1219 | Deletion-only superset of _protected_branches_for: the declared field UNIONED with the project's env-ladder rung branches (effective_environments — a null ladder's synthesized single rung off default_branch is protected too, so a renamed trunk like trunk is covered even with no declared list); fails CLOSED (returns the hardcoded floor only, on a lookup error) since a skipped delete is free (next sweep retries) but silently deleting a real declared rung on an unresolvable project is not |
GitService.delete_task_branch |
method | git.py:3671 | Cancel-path remote branch delete; chokepoint for the environment-ladder skip (effective_environments) so a task's branch_name can never collide-delete a ladder rung; returns bool |
GitService.close_task_pr_best_effort |
method | git.py:3655 | No-clone-needed cancel-path cleanup: resolves the project token + git_url→RepoRef, fetches the PR via self._forge.get_pr, no-ops unless state=="open", else update_pr(payload={"state":"closed"}); every failure path (missing token/project, unparseable URL, httpx.HTTPError) returns False rather than raising |
GitService.cleanup_stale_branches |
method | git.py:3697 | POST /git/branches/cleanup backing sweep: terminal (completed/cancelled) tasks' branches, remote (delete_task_branch) + local force-delete in the assignee's clone; capped 200/call, cursor-resumable |
GitService._stale_branch_window |
method | git.py:3767 | One deterministic ORDER BY id window of sweep candidates; ladder rungs excluded from results but still advance the cursor |
GitService._live_task_dependents |
method | git.py:3810 | Companion sweep guard: excludes a terminal candidate's branch when a non-terminal task still records that exact branch_name, or when a non-terminal task is a direct child of the candidate (the child's future PR base would resolve to the parent's branch via resolve_parent_branch even before it has opened a PR — catches what the OPEN-PR-only _branch_has_open_dependents can't see) |
GitService._cleanup_one_stale_branch |
method | git.py:3841 | 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 |
GitService.run_pre_submit_quality_gate |
method | git.py:3208 | make quality gate before submit |
GitService.conventions_check_for_task |
method | git.py:4368 | Run conventions validator on changed files (fail-closed) |
GitService._run_conventions_validator |
method | git.py:4408 | Subprocess python -m roboco.conventions with 120s cap |
GitService.open_conventions_pr |
method | git.py:4456 | Scaffold .roboco/conventions.yml on a branch + open PR |
GitService.diff / list_changed_files / read_file_at_branch |
methods | git.py:5943/5984/6021 | Read-only git queries (gateway + routes); each resolves its head ref through _resolve_head_ref |
GitService._resolve_head_ref |
method | git.py:5893 | Shared reader-ref resolution for diff/list_changed_files/read_file_at_branch//api/git/log. Fetches branch_name, then prefers origin/<branch_name> over the bare local ref whenever origin carries commits local lacks (origin_only > 0 via rev-list) — a reviewer's clone parked on pre-rebase history no longer stays frozen there across every later round; local wins only when it strictly contains everything origin has. Unlike the write-path's _reset_head_or_diverged, a read never needs to tell genuine divergence apart from a rewritten history — both resolve to "serve origin" (#690). |
GitService.commit |
method | git.py:4286 | Gateway content-verb commit (branch-keyed) |
get_git_service |
factory | git.py:4594 | Construct GitService from AsyncSession |
build_branch_name |
func | templates/git/branch.py:37 | {type}/{team}/{root}--{sub}--... up to MAX_TASK_DEPTH |
get_root_task_id |
func | templates/git/branch.py:97 | Walk parent chain to root |
BranchNameError |
exc | templates/git/branch.py:33 | Bad type / missing task / over-depth |
build_commit_message |
func | templates/git/commit.py:63 | Rich commit msg with task/root/agent/session links |
CommitContext |
dataclass | templates/git/commit.py:34 | Validated commit-message input |
build_pr_body_internal / build_pr_title_internal |
funcs | templates/git/pr_internal.py:75/130 | Subtask→parent PR rendering |
build_pr_body_root / build_pr_title_root |
funcs | templates/git/pr_root.py:167/235 | Root PR rendering with task tree + AC checklist |
MAX_TASK_DEPTH |
const | templates/git/constants.py:45 | 4 (umbrella→root→cell→dev) |
BRANCH_TYPES / COMMIT_TYPES |
consts | templates/git/constants.py:10/21 | Allowed prefixes |
RepoRef |
dataclass | forge/base.py:26 | Provider-opaque repo identity (owner, repo, host); GitLab packs the full URL-encoded namespace path into owner, leaving repo empty |
GitProvider |
ABC | forge/base.py:49 | The ~20-method transport contract every forge implements (PR CRUD, review flow, merge_branch, CI signal surface, repo/label/branch/release/provisioning) |
GitService._forge |
property | git.py:399 | A fresh ForgeRouter() per access (cheap, no I/O — built this way, not __init__-cached, so tests can GitService.__new__ bypass the constructor) |
ForgeRouter |
class | forge/router.py:37 | Implements GitProvider by picking a transport per call from RepoRef.host |
ForgeRouter._provider_for_ref |
staticmethod | forge/router.py:40 | None host → GitHubProvider(); a registered "gitea"/"gitlab" host → that provider constructed with its remembered scheme; unregistered → GitError naming the fix |
register_project_forge |
func | forge/registry.py:50 | Records a project's host→provider(+scheme) mapping; called from ProjectService.create/update/get/get_by_slug — in-memory, per-process, self-healing (a restart forgets it; the next project read re-registers) |
provider_for |
func | forge/registry.py:77 | Resolve a GitProvider for a project (duck-typed on .git_provider/.git_url) |
GitHubProvider |
class | forge/github.py:71 | GitHub.com/GHE REST transport — the pre-existing inline httpx logic, byte-for-byte; owns the sole retry policy in the package (list_ci_runs, 3 attempts/0.5s backoff, retryable on 429+5xx) |
GiteaProvider |
class | forge/gitea.py:55 | Self-hosted Gitea transport: token-scheme auth (Bearer is rejected by classic PATs); duplicate-PR 409→422 reshape on create_pr; merge_pr POSTs (not PUTs) with the method under a "Do" key; commit-status → synthetic check_runs/workflow_runs; branch names with slashes URL-quote()-encoded before hitting Gitea's router (caught live by the e2e suite) |
GitLabProvider |
class | forge/gitlab.py:85 | GitLab REST v4 transport — the most semantically divergent: MR iid→number, source_branch/target_branch→head.ref/base.ref; get_pr_diff reassembles a unified-diff string from up to 3 pages of per-file JSON diffs (no raw-diff media type); post_review routes APPROVE to /approve and everything else to a plain note (no request-changes verb exists); request_reviewers synthetic-skips (needs numeric user ids RoboCo doesn't store); create_org_repo resolves a group path to a numeric namespace id, falling back to the token's personal namespace on 404 |
ShapedResponse |
class | forge/shaping.py:17 | httpx.Response-compatible stand-in a non-GitHub provider returns to synthesize a status the wire call didn't produce (e.g. merge_branch's shaped 501) |
extract_host / detect_provider / validate_project_forge |
funcs | foundation/policy/forge.py:27/45/62 | Pure host extraction (https/ssh/scp-like URLs), github.com/gitlab.com auto-detection, and registration-time validation (a self-hosted host with no explicit git_provider is a registration-time rejection — the GHE/self-hosted escape hatch requires the operator to set the column) |
derive_pr_labels |
func | foundation/policy/pr_labels.py | Org-structure label vocabulary: base_branch (required kwarg, the PR's real resolved target — never assumed from is_root_pr) → f"to {base_branch}", plus root/MegaTask/layer labels (main-pm, cell/{team}, subtask/{team}) |
Neither Gitea nor GitLab has GitHub's server-side merges API for the env-sync cascade: both return a shaped 501 from merge_branch, landing GitService.sync_env_branch on the local-git fallback (_local_merge_branch: throwaway clone → merge → push; a conflict aborts leaving the remote untouched, same status vocabulary as the GitHub server-side path) — this fallback lives entirely in GitService, not the forge package. Plain git (clone/fetch/push) needed zero forge-specific work: all three forges accept a PAT as the Basic-auth password with username ignored, so the existing x-access-token:<token> extraheader works unchanged (verified live against a dockerized Gitea).
Data Flow
A developer claims a task → the orchestrator/choreographer calls create_branch_for_task → build_branch_name walks the task parent chain (TaskService.get) up to MAX_TASK_DEPTH=4, joins ---separated 8-char UUID prefixes, and yields {type}/{team}/{root}--{sub}--.... create_branch fetches only the needed refs from origin, runs git worktree add under {clone_root}/.worktrees/{task_id[:8]} (F123 per-task isolation), force-pushes the branch with -u, and stores branch_name on the task. A WorkSession row is created (WorkSessionService.create), first superseding any other agent's stale ACTIVE session on that task.
The agent commits via the commit content verb → GitService.commit (or commit_for_task route), which ensures the worktree is attached, asserts the workspace is on the task branch (recovering a drifted clone), stages, runs build_commit_message (CommitContext → header + metadata + links), commits, then best-effort links the SHA to the task + work session (_link_commit_to_task). Every _run_git call re-chowns the tree to the agent uid and clears orphaned lock files on timeout.
open_pr → create_pr resolves the task by branch name, ensures the parent branch exists on origin, POSTs the PR via GitHub REST, and atomically records pr_number/pr_url on the task (_record_pr_atomically) and work session (create_pr). PR title/body come from task.title/task.description for gateway PRs; the rich build_pr_body_root/_internal templates are used by the older create_pull_request path.
Merge: a cell PM complete/submit_up → pr_merge (gateway) scopes the task lookup by project_id (cross-repo PR-number collision guard), takes a SELECT FOR UPDATE lock on the parent task, calls _merge_with_retry (squash; on 409 re-syncs target + retries once; on 405 disambiguates already-merged vs real conflict → MergeConflictError), deletes the PR branch, syncs the local target, and records the merge on the work session (merge_pr, idempotent). The CEO-only root→master merge goes through merge_pr_for_task (role-gated, recorded-PR verification) → merge_pull_request. The CEO-merge never targets the default branch via pr_merge (the target == default_branch guard refuses it) — default_branch here is _project_default_branch, which now resolves via roboco.models.env_branches.head_branch(project) (the env-ladder head rung) rather than reading project.default_branch directly; a project with no declared ladder resolves to the same value via the read-time shim.
Behind-base recovery: is_behind_base feeds the i_am_done submit gate; on a non-zero behind, the dev calls sync_branch → sync_task_branch → rebase_onto_base (rebased/superseded/conflicts). On a merge conflict, the choreographer calls rebase_pr_for_task, then either re-merges or close_pull_requests a superseded PR. get_pr_head_sha feeds the submit_root re-submit loop guard.
Mermaid
stateDiagram-v2
[*] --> Active: create() (supersede stale)
Active --> Active: add_commit / add_files_modified
Active --> Active: create_pr (pr_number set)
Active --> Completed: merge_pr (idempotent active-guard)
Active --> Completed: close() (task completion)
Active --> Abandoned: abandon() / supersede_active_sessions_for_task
Completed --> [*]: terminal
Abandoned --> [*]: terminal
note right of Active
single-active per task enforced at
create + DB partial-unique index (mig 047)
end note
sequenceDiagram
participant G as Choreographer/Gateway
participant GS as GitService
participant GH as GitHub REST API
participant WS as WorkSessionService
participant DB as DB (TaskTable)
G->>GS: pr_merge(pr_number, target, project_id)
GS->>DB: SELECT task WHERE pr_number AND project_id (scoped)
GS->>DB: SELECT FOR UPDATE parent_task (serialize siblings)
GS->>GH: PUT /pulls/{n}/merge (squash)
alt 409 conflict
GS->>GS: _sync_target_branch (re-pull)
GS->>GH: PUT /pulls/{n}/merge (retry once)
end
alt non-success
GS->>GH: GET /pulls/{n} (already-merged?)
opt already merged
GS-->>G: idempotent success
end
opt real conflict
GS-->>G: raise MergeConflictError
end
end
GS->>GH: DELETE PR branch (best-effort)
GS->>GS: _sync_target_branch_best_effort
GS->>WS: merge_pr(session_id, merger_id)
WS->>WS: guard status==ACTIVE else return unchanged
WS-->>GS: COMPLETED + merged_by
GS-->>G: {"merge_commit_sha": ...}
Logical Tree
roboco/
├── services/
│ ├── work_session.py
│ │ └── WorkSessionService (BaseService)
│ │ ├── create / get / get_or_raise / update
│ │ ├── get_active_for_task(_and_agent)
│ │ ├── supersede_active_sessions_for_task # single-active invariant
│ │ ├── list_by_agent / list_by_project / list_active_sessions
│ │ ├── add_commit / add_files_modified
│ │ ├── create_pr / update_pr_status / merge_pr
│ │ ├── complete / abandon / close
│ │ └── files_changed / has_unpushed_commits # gateway backfill
│ └── git.py
│ ├── _GIT_EXECUTOR (ThreadPoolExecutor, 16)
│ ├── resolve_git_dir / _remove_stale_git_locks / _select_ci_head_run
│ └── GitService (BaseService)
│ ├── _run_git (token, timeout, chown, lock-cleanup)
│ ├── _token_for_project / _token_for_workspace / get_workspace
│ ├── status: get_status / get_current_branch / _classify_porcelain / _ahead_behind
│ ├── commit: create_commit / commit_for_task / commit (gateway) / _link_commit_to_task
│ ├── worktree (F123): _worktree_for_task / _ensure_worktree_for_commit / _assert_on_task_branch
│ ├── branch: create_branch / create_branch_for_task / create_branch_from_pr_head / checkout*
│ ├── push/pull/fetch/rebase: push_for_task / push_task_branch / pull / fetch / rebase
│ ├── PR context: _build_root_pr_context / _build_internal_pr_context / _generate_pr_title_body
│ ├── PR list/find: _find_existing_pr / list_open_prs / _fetch_open_prs / _normalize_open_pr
│ ├── PR create: create_pull_request / create_pr_for_task / create_pr (branch-keyed)
│ ├── PR update/review: update_pr_for_task / post_pr_review / _patch_pr_title_body
│ ├── PR read: get_pr_diff / get_pr_head_sha / pr_target
│ ├── CI: get_latest_ci_conclusion / _get_ci_runs_response / _fetch_latest_ci_run
│ ├── merge: merge_pull_request / merge_pr_for_task / pr_merge / _merge_with_retry
│ │ _assert_merge_role / _lock_parent_task_for_merge / _resolve_merger_id
│ │ _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
│ ├── conventions: conventions_check_for_task / _run_conventions_validator / open_conventions_pr
│ ├── read-only: diff / list_changed_files / read_file_at_branch / _ref_exists / _resolve_diff_base
│ └── helpers: _task_for_branch / _project_for_task / _workspace_for_branch / _token_for_branch ...
└── templates/git/
├── __init__.py # re-exports
├── constants.py # BRANCH_TYPES / COMMIT_TYPES / MAX_TASK_DEPTH=4
├── branch.py # build_branch_name / get_root_task_id / BranchNameError
├── commit.py # CommitContext / build_commit_message / CommitMessageError
├── pr_internal.py # InternalPRContext / build_pr_body_internal / build_pr_title_internal
└── pr_root.py # RootPRContext / SubtaskInfo / build_pr_body_root / build_pr_title_root
Dependencies
Internal:
roboco.config.settings(timeouts, URLs, workspace root, auto_clone)roboco.exceptions(GitCommandError,GitError,GitTimeoutError,MergeConflictError)roboco.foundation.policy.lifecycle(role/intent parity)roboco.models.base(AgentRole,TaskStatus)roboco.models.work_session(WorkSessionCreate/Update/Status)roboco.db.tables(ProjectTable,TaskTable,WorkSessionTable)roboco.services.base(BaseService,NotFoundError,ConflictError,ValidationError,UnauthorizedError,ServiceError)roboco.services.project/roboco.services.task/roboco.services.workspace(composed for clone/workspace/branch resolution)roboco.services.gateway.quality_gate(GateResult,run_quality_commands)roboco.templates.git(all template builders)roboco.utils.converters.require_uuid,roboco.utils.crypto.EncryptionErrorroboco.api.schemas.git(TYPE_CHECKING only — runtime duck-typed)
External:
sqlalchemy/sqlalchemy.ext.asynciohttpx(GitHub REST API)asyncio,subprocess,concurrent.futures.ThreadPoolExecutordataclasses,pathlib,uuid,base64,re,json,time,os,sys
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,cleanup_stale_branches(POST /git/branches/cleanup, PM/CEO role-gated like/rebase, rate-limit 5/60) — all construct viaget_git_service(db). - HTTP routes (
roboco/api/routes/tasks.py:253):get_git_servicefor task-scoped git. - Gateway Choreographer (
roboco/services/gateway/choreographer/):_verb_runner._do_pr_merge→pr_merge_impl→conventions_check_for_task(i_am_done + pr_pass gates),is_behind_base(submit gate),sync_task_branch(sync_branch verb),pr_merge/rebase_pr_for_task/close_pull_request(merge-conflict resolution),get_pr_head_sha(submit_root re-submit guard)pr_gate.py→get_pr_head_shaqa.py→conventions_check_for_task
- WorkspaceService calls
ensure_worktree/ensure_worktree_for_resume(F123). - Lifespan/CLI: none direct;
git_serviceis constructed per-request viadeps.py(git=GitService(db_session)).
Config Flags
| Flag / setting | Source | Used for |
|---|---|---|
ROBOCO_GIT_EXECUTOR_WORKERS (env, default 16) |
os.environ at git.py:117 |
Dedicated git subprocess pool size |
settings.git_command_timeout_seconds |
config.py:728 | Default _run_git timeout |
settings.git_commit_timeout_seconds |
config.py:737 | Staging/commit large changeset timeout |
settings.git_network_timeout_seconds |
config.py:748 | fetch/pull/push/ls-remote timeout |
settings.workspaces_root |
config.py:603 | Workspace path root (token derivation) |
settings.workspace_auto_clone |
config.py:607 | get_workspace auto-clone branch |
settings.public_base_url |
config.py:827 | Commit/PR template link base (+ /api) |
settings.internal_api_url |
config.py:57 | Internal PR body link base |
settings.github_api_base_url |
config.py:327 | GitHub REST API base (PR/CI/review) |
settings.release_ci_workflow |
config.py:597 | Named workflow file (e.g. ci.yml) used by the release CI gate in get_latest_ci_conclusion; always resolves a named workflow so the gate never degrades to the imprecise all-workflows mode |
Module-level tunables (not env): _SLOW_GIT_OP_MS=5000, _CI_RUN_WINDOW=20, _CI_FETCH_ATTEMPTS=3, _CI_FETCH_BACKOFF_SECONDS=0.5, _CI_RETRYABLE_STATUS, _CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS=120, _GH_UNPROCESSABLE=422, _HTTP_NOT_FOUND=404, _HTTP_CONFLICT=409.
Gotchas
protected_branchesis exact-match, case-sensitive, and additive-only: an operator-declared list (panel: chips editor in the edit-project dialog) is UNIONED into — never replaces — the hardcoded{master, main}floor. The two consuming scopes deliberately fail differently on a project-lookup error:_protected_branches_for(rebase +sync_task_branchforce-push refusal) fails OPEN to the hardcoded floor (a skipped rebase gets no free retry, so refusing wrongly is worse);_protected_branches_for_deletion(the shared remote-delete chokepoint) fails CLOSED (a skipped delete just retries at the next sweep, so silently proceeding on an unresolvable project is worse). An emptyprotected_brancheslist degrades to exactly the prior hardcoded-only behavior.- Token resolution is GitHub-App-first, PAT-fallback:
ProjectService._resolve_token(roboco/services/project.py, called fromGitService._token_for_project→get_decrypted_token_by_slug) mints a short-lived installation token viagithub_app_auth.mint_installation_tokenwhenproject.github_installation_idis set AND App credentials are stored; ANY minting failure (App not configured, installation revoked, network hiccup, the JWT/HTTP/parse path all wrapped so nothing escapes uncaught) falls back to the stored PAT instead of raising — a GitHub App outage never bricks git ops for an App-bound project that also has a PAT on file.github_app_credentials.pymirrors thex_credentials/telegram_credentialssingleton-Fernet-row pattern (migration 077); the PEM is validated at credential-SET time (not first-mint) and the installation-token cache clears when credentials are deleted. - Token transport split: git-over-HTTPS uses HTTP Basic (
x-access-token:...) viahttp.extraheader(git.py:289); the GitHub REST API uses Bearer. Swapping them causes silent credential-prompt failures. pr_numberis NOT repo-scoped intasks.pr_number— every gateway merge/close path (pr_merge,close_pull_request,pr_target,rebase_pr_for_task) requiresproject_idto scope the task lookup. The routemerge_pr_for_taskinstead verifiesdata.pr_number == task.pr_number(recorded PR is source of truth).get_active_for_taskreturns the most-recent ACTIVE row, notscalar_one_or_none— the historical duplicate-ACTIVE defect would otherwise raiseMultipleResultsFound. The invariant is also enforced atcreateand by a DB partial-unique index (migration 047).merge_pridempotency guard (F062): a terminal session is returned unchanged;complete/abandonon a non-active session returnNone(warning).closereturns the session unchanged. Behavior differs between these three on terminal state —merge_pr/closeare silent,complete/abandonwarn-and-None.- F123 per-task worktrees: commit/checkout/rebase/conventions MUST run inside
{clone_root}/.worktrees/{task_id[:8]}, not the clone root (which sits on the default branch)._worktree_for_task+_ensure_worktree_for_commitare the seam; forgetting them makes checkout fail with "already checked out at ''" or false-passes the conventions validator. create_branchrunsreset --hardon a no-commit branch in the worktree (git.py:1104) — safe becauseunique == 0, but a branch carrying real work is left as-is. The fresh-claim path only; resume short-circuits before it._run_gitre-chowns the tree after every op (root → agent uid 1000); without it the agent's next commit fails with "unable to append to .git/logs/refs/...".- Porcelain parsing uses
splitlines()notstrip().split("\n")— strip eats the leading space onD fileand false-stages deletions. get_current_branchraises on detached HEAD instead of returning""— the empty string used to leak "(HEAD detached at ...)" intocheckout -b.MAX_TASK_DEPTH=4(was 3) — MegaTask's umbrella→root→cell→dev needs 4; validator rejects a child whose depth would reach MAX_TASK_DEPTH, so 4 permits dev at depth 3.- Branch name uses 8-char UUID prefix (
_SHORT_ID_LEN=8), not full UUID — full UUIDs produced 140-char branch names. rebase_onto_baseforce-pushes with--force-with-leaseonly the head branch; never touches base.superseded(unique==0) means close-without-merge. It no longer resets a local tip that is ahead of origin (a committed-but-unpushed dev tip used to be silently discarded before the rebase); adivergedresult leaves BOTH sides untouched — the sync_branch verb maps it toi_am_blockedwith a stash-preserved note (#683).- Reader git queries prefer origin over a diverged local ref (
_resolve_head_ref, #690): a QA/PM/reviewer clone that never pushed can go stale relative to origin after a force-push sync;diff/list_changed_files/read_file_at_branchandroboco_git_logall resolve through the same fixed helper now, so a repeated review round no longer re-examines a frozen pre-rebase checkout. is_behind_baseraises on git failure; the i_am_done gate fail-opens on the raised error so a flaky fetch can't strand the task.- Conventions validator fails closed (
could_not_run=Trueblocks submit) on resolution error / timeout / non-zero exit; branchless + no-changed-files fail open. _assert_on_task_branchnever discards work — it doescheckout, notreset --hard, to preserve a resumed agent's unpushed commits.- CEO-only master merge:
pr_mergerefusestarget == default_branchfor agents; onlymerge_pr_for_task(CEO role-gated fromawaiting_ceo_approval) may merge to master.default_branchresolves through the env-ladder head rung (_project_default_branch→head_branch(project)), not the rawprojects.default_branchcolumn. cleanup_stale_branchescursor 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 noafter_cursorre-scans the identical first 200-row window forever instead of progressing._stale_branch_windowstill advances the cursor past ladder-rung rows even though they're excluded fromcandidates, sotruncatedcan'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"-dwould refuse every single candidate.
Drift from CLAUDE.md
- CLAUDE.md "WorkSessionService" table claims the service handles "Git session management, PR lifecycle" — accurate. No drift.
- CLAUDE.md says
ROBOCO_WORKSPACE_CLONE_TIMEOUT=300is a WorkspaceService config; not referenced in this slice (lives inworkspace.py). No drift in scope. - CLAUDE.md verb table lists
sync_branchfor developers and/rebasefor PM/CEO. The code matches:sync_task_branchis the dev path,rebase_pr_for_taskthe PR-keyed path. No drift. - CLAUDE.md states "A task has at most one active WorkSession ... enforced both at the service layer and by a DB partial-unique index (migration 047)." Code matches (
supersede_active_sessions_for_task+get_active_for_taskresilient return). No drift. - CLAUDE.md "PR is created BEFORE QA review" —
create_pr/create_pr_for_taskonly setspr_number; QA pass requires it. Matches. - Minor doc vs code: CLAUDE.md commit-format example is
[{task-id[:8]}] {message}(single ID), butbuild_commit_message(templates/git/commit.py:80) emits[{root_short}:{task_short}] {type}({scope}): {desc}— a richer two-ID header. The doc undersells the actual format; not a bug, but the template header is not the literal[{task-id[:8]}]the doc shows. - CLAUDE.md lists
merge_pull_request-style PM merges; the agent-facing path is nowpr_merge(gateway) with parent-row locking + 409 retry, which the doc does not describe. Additive (the routemerge_pr_for_taskstill exists) — doc is incomplete rather than wrong.
Changes Since Baseline
Baseline: fd10cc862c2020b3f639cdb686d427b0198a2441 (master tip before the metrics-granularity branch). Diff stat: git.py +614/-107-ish, work_session.py +11, branch.py +9/-, constants.py +12/-. Commits touching these files: 15effce0 (#283 "141 Gaps fill-in"), 3aff6e04 (#285 "Close gaps").
| Commit | IMPACT (one line) |
|---|---|
15effce0 (141 Gaps fill-in) |
Added resolve_git_dir + worktree-aware _remove_stale_git_locks; added F123 _worktree_for_task/_ensure_worktree_for_commit and routed commit/checkout/rebase/conventions into the per-task worktree; added get_pr_head_sha (pr_fail re-submit guard); added sync_task_branch + is_behind_base (dev sync_branch verb + i_am_done behind gate); added rebase_onto_base/rebase_pr_for_task (merge-conflict resolver); added close_pull_request (superseded-PR close); added pr_merge with project_id scoping + parent-row lock + 409 retry + CEO-only default-branch guard + already-merged disambiguation; added _merge_with_retry/_pr_is_merged/_resolve_merger_id/_lock_parent_task_for_merge; added conventions validator runner (conventions_check_for_task/_run_conventions_validator/open_conventions_pr); raised MAX_TASK_DEPTH 3→4 (MegaTask depth-cap fix); WorkSessionService.merge_pr idempotent active-guard (F062). |
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_retrynow falls back to a permitted merge method on 405 via_first_allowed_merge_methodbefore raisingMergeConflictError;close_pull_requestdefault flipped todelete_branch=False, choreographer caller now passesdelete_branch=Trueexplicitly).00513399([bug] push_branch —push_branch(branch_name)now passesbranch=branch_nametoself.push()so the gatewayopen_prpath pushes the actual named task branch rather than the clone root's current checkout; fixes the "No commits between" 422 →i_am_blockedwedge observed in the F123 per-worktree model).2759edf7([B-REL] release executor — added_CiRunQuerydataclass at git.py:241 to bundle per-project CI-fetch inputs;get_latest_ci_conclusionand_fetch_latest_ci_runnow accept an optionalhead_shaso the release CI gate polls a specific release commit's own run rather than branch-latest;settings.release_ci_workflowconfig flag added).69071030([chore] work-session-routes — addedWorkSessionService.task_team_for_sessionhelper (route layer PM cell-ownership check formerge_pr); route layer now stampsmerged_byfrom the authenticated caller rather than the request body —WorkSessionService.merge_prsignature is unchanged, butMergePRRequestschema droppedmerged_byfield).
496c24d1(PR #548, "git hygiene", 2026-07-17) Local branch refs stop leaking alongside remote ones:delete_task_branchnow also skips environment-ladder rungs (previously only the remote-delete's own main/master/develop guard existed) and returnsbool; newcleanup_stale_branches+_stale_branch_window+_cleanup_one_stale_branchback a PM/CEO-onlyPOST /git/branches/cleanupsweep of terminal tasks' remote+local branches, exposed as a confirm-dialog button on the panel Git page. Seedocs/map/task-service.mdfor the paired per-task reap at cancel/completion anddocs/map/workspace.mdfor the newWorkspaceService.delete_local_branchprimitive both routes share.
71f5426e(PR #683, "never discard committed local work in rebase_onto_base", 2026-07-24) The stale-state family fix for the write path:rebase_onto_baseclassifies local vsorigin/<head_branch>post-fetch instead of an unconditionalreset --hard— behind/equal resets as before, strictly-ahead skips the reset and rebases from the local tip, and a genuine two-sided divergence (patch-equivalence-checked first, so a prior failed force-push self-heals as "ahead" on retry) returns{"status": "diverged", ...}with neither side touched.sync_branch/submit-freshen/the merge-conflict resolver all gained adivergedhandling branch.e97f46af(PR #690, "reviewer reads must prefer origin over a diverged local ref", same day) is the read-path sibling:_resolve_head_ref(backingdiff/list_changed_files/read_file_at_branch/roboco_git_log) now prefers origin whenever it carries commits the local ref lacks, closing the QA/PM-clone-frozen-on-pre-rebase-history bounce loop.Forge providers — GitHub + Gitea + GitLab (2026-07-18/19, PRs #569/#571/#575/#579/#581). A new
roboco/services/forge/package (base.py/github.py/gitea.py/gitlab.py/registry.py/router.py/shaping.py) plusroboco/foundation/policy/forge.pyroute every REST callGitServicemakes (PRs/CI/reviews/labels/releases/provisioning) through a provider-agnostic transport.388bab24(Phase 0, #569):projects.git_providercolumn (migration 076, nullable, plain string not a pg enum — validated at the service layer, not the DB) +validate_project_forge/detect_provider(github.com auto-detects, self-hosted needs an explicit column value — the GHE/self-hosted escape hatch).461a6e1a(Phase 1, #571): theGitProviderABC +GitHubProviderextracted byte-for-byte fromGitService's old inlinehttpxcalls;GitService._forge(git.py:399) becomes the seam every call site routes through.96401f4c(Phases 2/2.1/3, #575):GiteaProvider+GitLabProvider+ForgeRouter(per-call transport dispatch offRepoRef.host) + the local-gitmerge_branchfallback for forges with no server-side merges API.5f32d876(Phase 4, #581):roboco/services/github_provisioning.pybecomes provider-aware (ROBOCO_PROVISIONING_PROVIDER/ROBOCO_PROVISIONING_HOST— seedocs/map/product-strategy-research-pitch.md) so pitch-driven repo creation works on all three forges.d4cb5797(#579) + the pre-existingtests/e2e_smoke/test_gitea_live.pyare the live contract suites (self-seeding against a dockerizedgitea/gitea/ realgitlab.com, env-gated) that caught the slash-encoding and http-scheme gaps in the Gitea provider. Panel: the edit-project dialog's "Forge"<Select>(edit-project-dialog.tsx) offers Auto-detect/GitHub/Gitea/GitLab.Protected branches + env-ladder deletion protection (#649, #651).
GitService._protected_branches_for/_protected_branches_for_deletion(git.py:1183/1219) consult the newprojects.protected_branchescolumn end-to-end for the first time (the column previously existed with nothing reading it): the deletion scope additionally unions in the project's env-ladder rung branches (effective_environments), closing the gap where the post-merge PR-source cleanup and the stale-branch sweep could still delete a branch that IS a ladder rung —delete_task_branch's own local rung check is now exactly subsumed and removed. Panel: a chips editor on the edit-project dialog.GitHub App credentials + auto-regenerated codegen drift (#621/#633/5ca8a9c4, #632).
roboco/services/github_app_auth.py/github_app_credentials.py(new files) add a singleton Fernet-encrypted App id + private key (migration 077) and RS256 JWT + cached-installation-token minting;ProjectTable.github_installation_idlets a project bind to an installation (create-dialog repo picker, or an existing PAT project via the edit dialog's Unbind/re-bind section) with PAT fallback on any mint failure (see Gotchas). Independently,GitService._run_codegen_and_commit(git.py:4474, migration 078'sprojects.codegen_command) runs a per-project codegen command in the task's worktree right before every push and commits any resulting drift into the SAME push — RoboCo's owncodegen_command='make codegen'keeps its checked-in lifecycle renders/verb tables from drifting invisibly past the agent's own gate (which omitsfoundation-check) until CI's separate drift gate catches it with no link back to the task.Branch-list route fix (#610).
roboco/api/routes/git.py'slist_branchesclassified a remote-tracking ref by a literal'remotes/'prefix thatgit for-each-ref --format=%(refname:short)never emits, so everyorigin/*ref (including the symbolicorigin/HEAD) rendered as a fake LOCAL branch. Listing now reads the full%(refname)and_parse_branch_lineclassifies onrefs/heads/vsrefs/remotes/(droppingorigin/HEAD); the route also runs a best-effortgit remote prune originbefore listing so a branch already deleted upstream stops persisting in the panel's Git page forever, and the manual Fetch action now fetches with--prune.PR label base-branch agnosticism + cancel-path PR close (2026-07-18/19, "agnosticism-residue" + "cancel-pr-dependents-guard").
derive_pr_labelsgained a requiredbase_branch: strkeyword-only param; the label is nowf"to {base_branch}"(the PR's real resolved target) instead of"to master" if is_root_pr else "to slave"— a prerequisite for the env-branches ladder and non-GitHub forges, where "master"/"slave" aren't universal. All threeGitServicecall sites (_labels_for_pr_request, the assembled cell→root/root→master PR opener) now thread the real resolved base branch through. Separately,GitService.close_task_pr_best_effort(git.py:3655) closes a task's forge PR on cancel —TaskService._close_task_pr_best_effort(task.py:6767) calls it once per cascaded descendant and once for the cancelled task itself, alongside the existing_delete_task_branch_best_effort— closing a gap where a cancelled task's branch was force-deleted but its open PR lingered on the forge forever. The same change addsGitService._live_task_dependents(git.py:3810) socleanup_stale_branchesnever deletes a terminal task's branch while a non-terminal task still records it, or is a direct child of it (whose future PR base would resolve to that branch before it even opens a PR).
Regression Risks
| Title | File:Line | Claim | Severity |
|---|---|---|---|
merge_pr idempotent guard silently drops retried merge attribution |
work_session.py:517 | A retried merge after a successful-but-unconfirmed GitHub merge returns the existing COMPLETED row unchanged, so the retry's merged_by/pr_merged_at are NOT updated. Correct for audit-trail integrity, but a caller that relied on merge_pr returning a freshly-updated row (e.g. reading pr_merged_at as "now") now gets the original timestamp. Low risk but a behavior change worth a regression test. |
low |
pr_merge CEO-only guard refuses default-branch target |
git.py:3671 | If a non-root PR's target legitimately resolves to the repo default branch (e.g. a mis-configured default_branch or a single-branch repo), pr_merge now hard-fails with UnauthorizedError. Any cell that previously merged a leaf PR into master via this path (shouldn't happen per policy, but the route existed) is now blocked. Since the env-branches ladder (#534) the comparison target is the env-ladder head rung (_project_default_branch → head_branch(project)), not necessarily prod — a project that declares head != prod has this guard protect the head rung, not the CEO's actual prod branch. |
medium |
pr_merge parent-row SELECT FOR UPDATE can deadlock |
git.py:3503/3683 | Two PMs merging sibling subtasks of different parents that share an ancestor, under并发 lock ordering, could deadlock if lock acquisition order diverges. Lock is only on the immediate parent, so risk is bounded, but a deadlock surfaces as a rollback (not a clean 409 retry). | medium |
create_branch reset --hard on zero-commit branch inside worktree |
git.py:1104 | If rev-list --count {base_ref}..{branch_name} returns 0 for a branch that actually carries work (e.g. base_ref mis-resolved to a ref that already contains the work), the worktree is hard-reset and uncommitted work in the worktree is lost. The unique==0 check is the only guard; a stale base_ref could trip it. |
medium |
| Panel Forge-select help text is stale vs. the shipped GitLab provider | panel/src/components/projects/edit-project-dialog.tsx:315 |
The HelpTip copy next to the Forge <Select> still reads "...GitLab support is planned" even though gitlab is a live SelectItem one line below and the backend has full GitLab Phase 3 support (GitLabProvider, ForgeRouter) — cosmetic only, no functional gap, but confusing to an operator reading the tooltip before picking GitLab. |
low |
| F123 worktree routing — commit/conventions/rebase run in worktree, merge sync runs in clone root | git.py:3696/3785 | pr_merge calls _sync_target_branch_best_effort(workspace,...) with the clone-root workspace (from get_workspace), not the per-task worktree. If the target branch is checked out in a worktree, the sync's checkout of target in the clone root fails ("already checked out"). Best-effort swallows it, but the local target ref may stay stale for the next sibling merge. |
medium |
_merge_with_retry retries on 409 only; 405 falls through to already-merged check then MergeConflictError |
git.py:3597 | FIXED (536bbb64 #108) — _merge_with_retry now falls back to a permitted merge method (via _first_allowed_merge_method, exclude='squash') on 405 before raising MergeConflictError, mirroring the CEO merge_pull_request path. A 405 with no permitted fallback or a second 405 still falls through to disambiguation/MergeConflictError. |
|
is_behind_base raises on fetch failure; gate fail-opens |
git.py:3922/3938 | A flaky origin fetch makes is_behind_base raise; the i_am_done gate catches it and fail-opens, letting a behind branch submit. The merge layer's own behind check is the backstop, but a genuinely-behind branch can reach QA. Documented, but a regression in the "gate is authoritative" expectation. |
low |
MAX_TASK_DEPTH 3→4 changes branch-name length + validation |
constants.py:45 / branch.py:71 | Any pre-existing task hierarchy at depth 4 that was previously rejected now builds a 4-segment branch name; tasks created under the old cap that stored a shorter branch are unaffected, but new subtasks of a previously-maxed tree now cut branches where they couldn't before — could surface latent assumptions in downstream consumers parsing branch names. | low |
close_pull_request deletes branch on close by default |
git.py:4005 | FIXED (536bbb64 #109) — default flipped to delete_branch=False (opt-in deletion); the choreographer supersede caller now passes delete_branch=True explicitly when it wants deletion, matching the orchestrator supersede path. A superseded PR's branch is preserved by default. |
|
| Conventions validator fail-closed on resolution error | git.py:4392 | A workspace resolution failure (missing clone, diff error) returns could_not_run=True, which the block-gate treats as a hard refuse. A transient workspace/clone issue can now block i_am_done/pr_pass where previously the gate would have passed. Intentional but a new stranding vector. |
low |
get_pr_head_sha fail-open returns None on any error |
git.py:2496 | The submit_root re-submit loop guard only hard-blocks on an exact unchanged head SHA; any GitHub error / closed PR returns None and the guard passes, so a flaky API call lets a weak coordinator re-submit the same failed PR. Documented fail-open, but a regression vs. a strict gate. |
low |
Health
Integrity is good and actively hardened. The slice carries the scars of multiple live meltdowns (single-active work-session defect, pr_fail re-submit loop, cell_pm merge block<->unblock, MegaTask depth cap, cross-repo PR-number collision) and each is closed with a deterministic guard plus a comment explaining the failure mode. The F123 per-task-worktree routing is consistently threaded through commit/checkout/rebase/conventions, and the merge path has layered defenses (parent-row lock, 409 retry, already-merged disambiguation, CEO-only master guard). Two formerly-medium risks in the merge path have since been closed: _merge_with_retry now has the 405 method-fallback that merge_pull_request has (536bbb64), and close_pull_request now defaults to delete_branch=False (536bbb64). The remaining residual risk is pr_merge's post-merge target sync running in the clone root (not the worktree) and best-effort-swallowing a checkout conflict — the local target ref may stay stale for the next sibling merge. Test coverage of the work-session lifecycle is solid; the newer pr_merge/sync_task_branch/rebase_onto_base/close_pull_request quartet deserves the most scrutiny on any future change. No outright bugs found; the drift vs CLAUDE.md is documentation undersell (commit header format, gateway merge-path description), not behavioral mismatch. The forge-providers rollout (GitHub/Gitea/GitLab) added real breadth without touching the merge-path defenses above — GitService's callers still reason in GitHub-shaped responses, and ForgeRouter/GiteaProvider/GitLabProvider carry the entire adaptation burden behind that seam; the live-forge e2e suites (tests/e2e_smoke/test_gitea_live.py/test_gitlab_live.py, both env-gated and self-seeding) are the only coverage that exercises a real forge over the wire rather than a mocked transport, and they already caught two real gaps (slash-encoding, http scheme) the mocked unit tests couldn't have found.