Add branch disposition for lifecycle closure: integrated, abandoned, active

This commit is contained in:
Himanshu Dongre 2026-04-13 13:03:22 +05:30
parent cff02163c3
commit 405592da55
8 changed files with 409 additions and 6 deletions

View file

@ -0,0 +1,26 @@
"""Add branch_disposition column to chat_sessions.
Revision ID: f7a8b9cadbef
Revises: e6f7a8b9cadb
Create Date: 2026-04-13 01:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f7a8b9cadbef"
down_revision = "e6f7a8b9cadb"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"chat_sessions",
sa.Column("branch_disposition", sa.String(20), server_default="active"),
)
def downgrade() -> None:
op.drop_column("chat_sessions", "branch_disposition")

View file

@ -804,16 +804,34 @@ def get_head(repo_id: uuid.UUID, db: Session = Depends(get_db)):
def _get_active_branch_heads(
repo_id: uuid.UUID, db: Session, limit: int = ACTIVE_BRANCHES_CAP
) -> list[CommitModel]:
"""Return the most recent checkpoint on each non-main branch of this space,
ordered by `created_at` descending, capped at `limit`.
"""Return the most recent checkpoint on each non-main branch of this space
that has at least one session with branch_disposition == 'active'.
Branches marked 'integrated' or 'abandoned' are excluded they no
longer appear in ## Active branches or ## Divergence signal. They
remain in the lineage tree and in `smriti branch list` for history.
Implemented in two steps so the SQL stays portable across the
SQLAlchemy dialects we use in tests: first fetch every non-main
checkpoint (cheap there are dozens, not millions, in a real
project), then collapse per branch in Python. A Postgres-only
`DISTINCT ON (branch_name)` would be marginally faster but would
break sqlite-backed unit tests for no real gain.
checkpoint, then collapse per branch in Python and filter by
session disposition.
"""
# Step 1: find which branches have at least one active session.
active_branches_stmt = (
select(ChatSession.branch_name)
.where(
ChatSession.repo_id == repo_id,
ChatSession.branch_name != "main",
ChatSession.branch_disposition == "active",
)
.distinct()
)
active_branch_names = set(db.scalars(active_branches_stmt).all())
if not active_branch_names:
return []
# Step 2: find the latest checkpoint per active branch.
stmt = (
select(CommitModel)
.where(
@ -827,6 +845,9 @@ def _get_active_branch_heads(
for commit in db.scalars(stmt):
if commit.branch_name in seen:
continue
if commit.branch_name not in active_branch_names:
seen.add(commit.branch_name)
continue
seen.add(commit.branch_name)
heads.append(commit)
if len(heads) >= limit:

View file

@ -331,6 +331,87 @@ def fork_session(payload: ForkSessionRequest, db: Session = Depends(get_db)):
)
# ── Branch disposition endpoint ────────────────────────────────────────────────
VALID_DISPOSITIONS = {"active", "integrated", "abandoned"}
class BranchDispositionRequest(BaseModel):
space_id: str
branch_name: str
disposition: str = Field(
description="One of: active, integrated, abandoned",
)
class BranchDispositionResponse(BaseModel):
space_id: uuid.UUID
branch_name: str
disposition: str
sessions_updated: int
@router.patch("/branches/disposition", response_model=BranchDispositionResponse)
def set_branch_disposition(
payload: BranchDispositionRequest,
db: Session = Depends(get_db),
):
"""Set the disposition of a branch (all sessions on that branch).
Marks a branch as integrated, abandoned, or active. Sessions with
matching branch_name in the space have their branch_disposition
updated. This controls whether the branch appears in the
## Active branches section of smriti state.
Branch name is passed in the request body (not the URL) because
branch names frequently contain slashes (e.g. codex/config-reload)
which conflict with URL path routing.
Reversible: setting back to 'active' re-shows the branch.
"""
try:
space_id = uuid.UUID(payload.space_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid space_id")
_get_repo(space_id, db)
if payload.disposition not in VALID_DISPOSITIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid disposition '{payload.disposition}'. "
f"Must be one of: {', '.join(sorted(VALID_DISPOSITIONS))}",
)
# Find all sessions on this branch in this space.
stmt = (
select(ChatSession)
.where(
ChatSession.repo_id == space_id,
ChatSession.branch_name == payload.branch_name,
)
)
sessions = list(db.scalars(stmt).all())
if not sessions:
raise HTTPException(
status_code=404,
detail=f"No sessions found on branch '{payload.branch_name}' in this space.",
)
for session in sessions:
session.branch_disposition = payload.disposition
db.commit()
return BranchDispositionResponse(
space_id=space_id,
branch_name=payload.branch_name,
disposition=payload.disposition,
sessions_updated=len(sessions),
)
# ── Lineage (branch tree) endpoint ────────────────────────────────────────────
@router.get("/spaces/{space_id}", response_model=LineageResponse)

View file

@ -211,6 +211,11 @@ class ChatSession(Base):
)
# Branch identity — "main" for primary sessions, custom name for forks
branch_name: Mapped[str] = mapped_column(String(255), default="main")
# Branch lifecycle — explicit disposition signal so agents and humans
# can mark a branch as integrated or abandoned, removing it from the
# ## Active branches section of smriti state without deleting history.
# Values: "active" (default, shown), "integrated" (hidden), "abandoned" (hidden).
branch_disposition: Mapped[str] = mapped_column(String(20), default="active")
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(

View file

@ -0,0 +1,184 @@
"""Integration tests for branch disposition / lifecycle.
Covers:
- Setting disposition to integrated, abandoned, active
- Disposition filters branches from /state active_branches
- Disposition filters branches from /state divergence signal
- Reversibility: integrated active re-shows the branch
- Nonexistent branch returns 404
- Invalid disposition returns 400
"""
from __future__ import annotations
def _create_repo(client, name="Disposition Test"):
r = client.post("/api/v2/repos", json={"name": name})
assert r.status_code == 201, r.text
return r.json()["id"]
def _create_session(client, repo_id, title="test"):
r = client.post(
f"/api/v4/chat/spaces/{repo_id}/sessions",
json={"title": title, "provider": "openrouter", "model": "mock"},
)
assert r.status_code == 201, r.text
return r.json()["id"]
def _commit(client, repo_id, session_id, message="checkpoint", **kwargs):
payload = {"repo_id": repo_id, "session_id": session_id, "message": message, **kwargs}
r = client.post("/api/v4/chat/commit", json=payload)
assert r.status_code == 201, r.text
return r.json()
def _fork(client, space_id, checkpoint_id, branch_name=""):
r = client.post(
"/api/v5/lineage/sessions/fork",
json={
"space_id": space_id,
"checkpoint_id": checkpoint_id,
"branch_name": branch_name,
},
)
assert r.status_code == 201, r.text
return r.json()
def _set_disposition(client, space_id, branch_name, disposition):
r = client.patch(
"/api/v5/lineage/branches/disposition",
json={
"space_id": space_id,
"branch_name": branch_name,
"disposition": disposition,
},
)
return r
def _get_state(client, repo_id):
r = client.get(f"/api/v4/chat/spaces/{repo_id}/state")
assert r.status_code == 200, r.text
return r.json()
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_set_disposition_integrated(client):
repo_id = _create_repo(client, "Integrated")
session_id = _create_session(client, repo_id)
base = _commit(client, repo_id, session_id, "base", decisions=["D1"])
fork = _fork(client, repo_id, base["id"], branch_name="side-work")
_commit(client, repo_id, fork["session_id"], "fork commit", decisions=["D2"])
r = _set_disposition(client, repo_id, "side-work", "integrated")
assert r.status_code == 200
data = r.json()
assert data["disposition"] == "integrated"
assert data["sessions_updated"] == 1
def test_integrated_branch_hidden_from_state(client):
"""An integrated branch should not appear in active_branches or divergence."""
repo_id = _create_repo(client, "Hidden After Integrated")
session_id = _create_session(client, repo_id)
base = _commit(client, repo_id, session_id, "base", decisions=["Use Pydantic"])
fork = _fork(client, repo_id, base["id"], branch_name="experiment")
_commit(
client, repo_id, fork["session_id"],
"experiment commit", decisions=["Use dataclasses"],
)
# Before disposition: branch visible
state_before = _get_state(client, repo_id)
assert len(state_before["active_branches"]) == 1
assert state_before["active_branches"][0]["branch_name"] == "experiment"
# Mark integrated
_set_disposition(client, repo_id, "experiment", "integrated")
# After disposition: branch hidden
state_after = _get_state(client, repo_id)
assert len(state_after["active_branches"]) == 0
assert state_after["divergence"] is None
def test_abandoned_branch_hidden_from_state(client):
repo_id = _create_repo(client, "Hidden After Abandoned")
session_id = _create_session(client, repo_id)
base = _commit(client, repo_id, session_id, "base")
fork = _fork(client, repo_id, base["id"], branch_name="dead-end")
_commit(client, repo_id, fork["session_id"], "dead end commit")
_set_disposition(client, repo_id, "dead-end", "abandoned")
state = _get_state(client, repo_id)
assert len(state["active_branches"]) == 0
def test_reversibility_active_restores_branch(client):
"""Setting back to active re-shows the branch."""
repo_id = _create_repo(client, "Reversible")
session_id = _create_session(client, repo_id)
base = _commit(client, repo_id, session_id, "base")
fork = _fork(client, repo_id, base["id"], branch_name="temp")
_commit(client, repo_id, fork["session_id"], "temp commit")
_set_disposition(client, repo_id, "temp", "integrated")
state_hidden = _get_state(client, repo_id)
assert len(state_hidden["active_branches"]) == 0
_set_disposition(client, repo_id, "temp", "active")
state_restored = _get_state(client, repo_id)
assert len(state_restored["active_branches"]) == 1
assert state_restored["active_branches"][0]["branch_name"] == "temp"
def test_nonexistent_branch_returns_404(client):
repo_id = _create_repo(client, "No Branch")
r = _set_disposition(client, repo_id, "ghost-branch", "integrated")
assert r.status_code == 404
def test_invalid_disposition_returns_400(client):
repo_id = _create_repo(client, "Bad Disposition")
session_id = _create_session(client, repo_id)
base = _commit(client, repo_id, session_id, "base")
fork = _fork(client, repo_id, base["id"], branch_name="b1")
r = _set_disposition(client, repo_id, "b1", "archived")
assert r.status_code == 400
assert "disposition" in r.json()["detail"].lower()
def test_only_active_branches_generate_divergence(client):
"""Integrated branches should not trigger the divergence signal."""
repo_id = _create_repo(client, "Divergence Filter")
session_id = _create_session(client, repo_id)
base = _commit(
client, repo_id, session_id, "base",
decisions=["Main decision"],
)
fork = _fork(client, repo_id, base["id"], branch_name="alt")
_commit(
client, repo_id, fork["session_id"],
"alt commit", decisions=["Alt decision"],
)
# Divergence exists before disposition
state_before = _get_state(client, repo_id)
assert state_before["divergence"] is not None
_set_disposition(client, repo_id, "alt", "integrated")
# Divergence gone after disposition
state_after = _get_state(client, repo_id)
assert state_after["divergence"] is None

View file

@ -203,6 +203,20 @@ class SmritiClient:
params["include_expired"] = "true"
return self._request("GET", "/api/v5/claims", params=params)
# ── Branch disposition ────────────────────────────────────────────
def close_branch(self, space_id: str, branch_name: str, disposition: str) -> dict:
"""PATCH /api/v5/lineage/branches/disposition"""
return self._request(
"PATCH",
"/api/v5/lineage/branches/disposition",
json={
"space_id": space_id,
"branch_name": branch_name,
"disposition": disposition,
},
)
# ── Checkpoints ──────────────────────────────────────────────────
def create_chat_commit(self, payload: dict) -> dict:

View file

@ -555,6 +555,22 @@ def cmd_skills_install(client: SmritiClient, args: argparse.Namespace) -> None:
)
# ── branch subcommand handlers ──────────────────────────────────────────────
def cmd_branch_close(client: SmritiClient, args: argparse.Namespace) -> None:
"""Set the disposition of a branch (integrated, abandoned, or active)."""
space = client.resolve_space(args.space)
result = client.close_branch(space["id"], args.branch_name, args.disposition)
if args.json:
_print_json(result)
else:
print(
f"Branch `{result['branch_name']}` marked `{result['disposition']}` "
f"({result['sessions_updated']} session(s) updated)."
)
# ── claim subcommand handlers ───────────────────────────────────────────────
@ -851,6 +867,27 @@ def _build_parser() -> argparse.ArgumentParser:
compare_parser.add_argument("--json", action="store_true", help="Output structured JSON")
compare_parser.set_defaults(func=cmd_compare)
# branch — branch lifecycle / disposition
branch_parser = subparsers.add_parser(
"branch",
help="Manage branch lifecycle (mark branches as integrated or abandoned)",
)
branch_sub = branch_parser.add_subparsers(dest="subcommand", required=True)
br_close = branch_sub.add_parser(
"close",
help="Set the disposition of a branch (integrated, abandoned, or active)",
)
br_close.add_argument("space", help="Space name or UUID")
br_close.add_argument("branch_name", help="Branch name to update")
br_close.add_argument(
"--disposition", required=True,
choices=["integrated", "abandoned", "active"],
help="New disposition for the branch",
)
br_close.add_argument("--json", action="store_true")
br_close.set_defaults(func=cmd_branch_close)
# claim — work claims for pre-work intent visibility
claim_parser = subparsers.add_parser(
"claim",

View file

@ -455,6 +455,41 @@ def smriti_delete_space(space: str) -> str:
return f"Deleted space '{s['name']}' and its {len(commits)} checkpoint(s).\n"
@mcp.tool()
def smriti_close_branch(
space: str,
branch: str,
disposition: str = "integrated",
) -> str:
"""Mark a branch as integrated, abandoned, or active.
Branches marked integrated or abandoned stop appearing in the
## Active branches and ## Divergence signal sections of
smriti_state. Their checkpoints remain in the lineage tree for
history nothing is deleted.
Setting back to active re-shows the branch. Fully reversible.
Call this as part of your clean-finish workflow after a branch's
work has been merged or intentionally stopped.
Args:
space: Space name or UUID.
branch: Branch name to update.
disposition: "integrated", "abandoned", or "active".
"""
client = _client()
try:
s = client.resolve_space(space)
result = client.close_branch(s["id"], branch, disposition)
except SmritiError as e:
_raise_from(e)
return (
f"Branch `{result['branch_name']}` marked `{result['disposition']}` "
f"({result['sessions_updated']} session(s) updated)."
)
@mcp.tool()
def smriti_claim(
space: str,