feat: worktree-aware session lifecycle in SessionService

Add worktree support to SessionService via the hqt.git.worktree module:

- create_session(worktree_branch=...): creates the worktree before the row
  (ServiceError → no row, no spawn); spawn/capture use the worktree path;
  blank nickname defaults to the branch name.
- _session_path / _repo_path_for_worktree helpers route every harness-facing
  path through the worktree when set, with a parent.parent repo fallback when
  the project row is gone.
- attach_session rung 0: recreate a missing worktree from its branch, else
  raise "delete the session".
- delete_session(remove_worktree=, force=): remove worktree before deleting
  the row; removal failure keeps the row.
- worktree_state_for(session_id): None for plain/missing sessions, else the
  module's WorktreeState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:21:37 -04:00
parent 860f1b59bd
commit 61f1a07ba7
2 changed files with 441 additions and 12 deletions
+327
View File
@@ -1106,3 +1106,330 @@ async def test_list_sessions_skips_capture_for_deleted_project(
harnesses_capture["claude-code"].capture_session_id.return_value = "would-capture"
infos = await service.list_sessions(1)
assert len(infos) == 1 # no ServiceError escaped
# ---------------------------------------------------------------------------
# Task 3: worktree-aware session lifecycle
# ---------------------------------------------------------------------------
WT_PATH = Path("/tmp/myproj/.worktrees/feature-x")
@pytest.fixture
def fake_worktree(monkeypatch):
"""Monkeypatch hqt.git.worktree functions with recording async fakes.
Returns a namespace exposing the recorded call args and the WorktreeState
class so tests can assert ordering and arguments.
"""
import hqt.git.worktree as wt_mod
class Recorder:
def __init__(self):
self.create_calls = []
self.add_calls = []
self.remove_calls = []
self.state_calls = []
self.create_path = WT_PATH
self.create_error: Exception | None = None
self.add_error: Exception | None = None
self.remove_error: Exception | None = None
self.state_result = wt_mod.WorktreeState(
exists=True, dirty=False, unique_commits=2
)
async def create_worktree(self, repo, branch):
self.create_calls.append((repo, branch))
if self.create_error is not None:
raise self.create_error
return self.create_path
async def add_worktree_for_branch(self, repo, branch):
self.add_calls.append((repo, branch))
if self.add_error is not None:
raise self.add_error
return self.create_path
async def remove_worktree(self, repo, path, branch, force):
self.remove_calls.append((repo, path, branch, force))
if self.remove_error is not None:
raise self.remove_error
async def worktree_state(self, repo, path, branch):
self.state_calls.append((repo, path, branch))
return self.state_result
rec = Recorder()
monkeypatch.setattr(wt_mod, "create_worktree", rec.create_worktree)
monkeypatch.setattr(wt_mod, "add_worktree_for_branch", rec.add_worktree_for_branch)
monkeypatch.setattr(wt_mod, "remove_worktree", rec.remove_worktree)
monkeypatch.setattr(wt_mod, "worktree_state", rec.worktree_state)
return rec
@pytest.mark.asyncio
async def test_create_session_worktree_branch(
service, db, tmux, harnesses, fake_worktree
):
"""worktree_branch set → create_worktree called with (project_path, branch);
spawn cwd and capture use the worktree path; row stores both fields."""
harnesses["claude-code"].captures_session_id = True
harnesses["claude-code"].capture_session_id.return_value = "cap-id"
harnesses["claude-code"].build_spawn_config.return_value = MagicMock(
command=["claude"], env={}, cwd=WT_PATH
)
result = await service.create_session(
project_id=1, harness_name="claude-code", worktree_branch="feature-x"
)
# create_worktree called with project path + branch
assert fake_worktree.create_calls == [(Path("/tmp/myproj"), "feature-x")]
# spawn request cwd is the worktree path
spawn_req = tmux.spawn.call_args.args[0]
assert spawn_req.cwd == str(WT_PATH)
# build_spawn_config used the worktree path, not the project path
assert harnesses["claude-code"].build_spawn_config.call_args.args[0] == WT_PATH
# capture used the worktree path
assert harnesses["claude-code"].capture_session_id.call_args.args[0] == WT_PATH
# row carries both worktree fields
sess = result.session
assert sess.worktree_path == str(WT_PATH)
assert sess.worktree_branch == "feature-x"
@pytest.mark.asyncio
async def test_create_session_worktree_blank_nickname_defaults_to_branch(
service, db, tmux, harnesses, fake_worktree
):
"""Blank nickname + worktree_branch → nickname defaults to branch name."""
result = await service.create_session(
project_id=1,
harness_name="claude-code",
nickname="",
worktree_branch="feature-x",
)
assert result.session.nickname == "feature-x"
@pytest.mark.asyncio
async def test_create_session_worktree_explicit_nickname_preserved(
service, db, tmux, harnesses, fake_worktree
):
"""Explicit nickname + worktree_branch → nickname preserved."""
result = await service.create_session(
project_id=1,
harness_name="claude-code",
nickname="my-work",
worktree_branch="feature-x",
)
assert result.session.nickname == "my-work"
@pytest.mark.asyncio
async def test_create_session_worktree_create_fails_no_row_no_spawn(
factory, db, tmux, harnesses, fake_worktree
):
"""create_worktree raising ServiceError → propagates, no Session row, no spawn."""
from hqt.db.models import Session
fake_worktree.create_error = ServiceError("git worktree add failed")
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
with pytest.raises(ServiceError, match="worktree add failed"):
await service.create_session(
project_id=1, harness_name="claude-code", worktree_branch="feature-x"
)
# No Session row committed
db.expire_all()
assert db.query(Session).count() == 0
# tmux spawn never called
tmux.spawn.assert_not_called()
@pytest.mark.asyncio
async def test_create_session_without_worktree_branch_regression(
service, db, tmux, harnesses, fake_worktree
):
"""No worktree_branch → create_worktree not called, row worktree fields None."""
result = await service.create_session(project_id=1, harness_name="claude-code")
assert fake_worktree.create_calls == []
assert result.session.worktree_path is None
assert result.session.worktree_branch is None
def _seed_worktree_session(db, branch="feature-x", path=str(WT_PATH)):
"""Seed a worktree session for project 1 / harness claude-code."""
from hqt.db.models import Harness, Session
harness = db.query(Harness).filter_by(name="claude-code").first()
sess = Session(
project_id=1,
harness_id=harness.id,
tmux_session_name="hqt-wt",
harness_session_id="sess-1",
worktree_path=path,
worktree_branch=branch,
archived=False,
)
db.add(sess)
db.commit()
return sess
@pytest.mark.asyncio
async def test_attach_session_recreates_missing_worktree(
factory, db, tmux, harnesses, fake_worktree
):
"""attach_session: worktree dir missing on disk → add_worktree_for_branch called."""
# Use a path that does not exist on disk.
missing = "/tmp/does-not-exist/.worktrees/feature-x"
sess = _seed_worktree_session(db, path=missing)
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing))
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=True, window_id=None, error="")
)
tmux.attach = AsyncMock(return_value=True)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.attach_session(session_id=sess.id)
assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")]
@pytest.mark.asyncio
async def test_attach_session_missing_worktree_recreate_fails(
factory, db, tmux, harnesses, fake_worktree
):
"""attach_session: recreate raises → ServiceError mentioning deleting the session."""
missing = "/tmp/does-not-exist/.worktrees/feature-x"
sess = _seed_worktree_session(db, path=missing)
fake_worktree.add_error = ServiceError("branch gone")
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
with pytest.raises(ServiceError, match="delete the session"):
await service.attach_session(session_id=sess.id)
@pytest.mark.asyncio
async def test_attach_session_worktree_resume_uses_worktree_path(
factory, db, tmux, harnesses, fake_worktree
):
"""Resume config for a worktree session uses the worktree path as cwd."""
# WT_PATH parent exists (/tmp/myproj/.worktrees won't, but use a real existing dir)
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
sess = _seed_worktree_session(db, path=tmpdir)
resume_cfg = MagicMock(command=["claude", "--resume"], env={}, cwd=Path(tmpdir))
harnesses["claude-code"].build_resume_config = MagicMock(
return_value=resume_cfg
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(
return_value=SpawnResult(ok=True, window_id=None, error="")
)
tmux.attach = AsyncMock(return_value=True)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.attach_session(session_id=sess.id)
# build_resume_config received the worktree path as the project path arg
assert harnesses["claude-code"].build_resume_config.call_args.args[0] == Path(
tmpdir
)
# since the worktree exists on disk, recreate must not have been attempted
assert fake_worktree.add_calls == []
@pytest.mark.asyncio
async def test_delete_session_remove_worktree_true(
factory, db, tmux, harnesses, fake_worktree
):
"""delete_session(remove_worktree=True) calls remove_worktree and deletes the row."""
from hqt.db.models import Session
sess = _seed_worktree_session(db)
sess_id = sess.id
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.delete_session(sess_id, remove_worktree=True, force=True)
assert fake_worktree.remove_calls == [
(Path("/tmp/myproj"), WT_PATH, "feature-x", True)
]
db.expire_all()
assert db.get(Session, sess_id) is None
@pytest.mark.asyncio
async def test_delete_session_remove_worktree_raises_keeps_row(
factory, db, tmux, harnesses, fake_worktree
):
"""remove_worktree raising → ServiceError propagates, row still present."""
from hqt.db.models import Session
sess = _seed_worktree_session(db)
sess_id = sess.id
fake_worktree.remove_error = ServiceError("worktree dirty")
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
with pytest.raises(ServiceError, match="worktree dirty"):
await service.delete_session(sess_id, remove_worktree=True)
db.expire_all()
assert db.get(Session, sess_id) is not None
@pytest.mark.asyncio
async def test_delete_session_remove_worktree_false_skips_removal(
factory, db, tmux, harnesses, fake_worktree
):
"""remove_worktree=False → remove_worktree not called, row deleted."""
from hqt.db.models import Session
sess = _seed_worktree_session(db)
sess_id = sess.id
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.delete_session(sess_id, remove_worktree=False)
assert fake_worktree.remove_calls == []
db.expire_all()
assert db.get(Session, sess_id) is None
@pytest.mark.asyncio
async def test_worktree_state_for_plain_session_returns_none(
service, db, tmux, harnesses, fake_worktree
):
"""worktree_state_for: plain session → None."""
sess = _seed_session(db)
assert await service.worktree_state_for(sess.id) is None
@pytest.mark.asyncio
async def test_worktree_state_for_missing_id_returns_none(service, db, fake_worktree):
"""worktree_state_for: unknown session id → None."""
assert await service.worktree_state_for(99999) is None
@pytest.mark.asyncio
async def test_worktree_state_for_worktree_session_returns_state(
factory, db, tmux, harnesses, fake_worktree
):
"""worktree_state_for: worktree session → returns the module's WorktreeState."""
sess = _seed_worktree_session(db)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
state = await service.worktree_state_for(sess.id)
assert state is fake_worktree.state_result
assert fake_worktree.state_calls == [(Path("/tmp/myproj"), WT_PATH, "feature-x")]