Design: clone-and-worktree workspace for spawned sessions
Phase 2 of 3 (requirements → design → tasks). Derives from
docs/specs/issue-76/requirements.md. Decision:docs/decisions/decision-034.md.
Overview
A new provider-neutral the_loop.workspace module owns the daemon's checkouts, in one of two strategies. The dispatcher calls it on the spawn path (to resolve a session's cwd) and on the PR-close path (to clean up), gated by routing.workspace.root being set. Everything else — the session registry, resume, per-session FIFO dispatch, tmux runner, poller — is unchanged: a checkout is just a directory, recorded as the session's cwd, so the existing "resume runs in cwd" contract (decision-016) carries redelivered events back into the same checkout for free.
Components
the_loop.workspace (new)
RepoTarget—(host, owner, repo, clone_url);rel_path=host/owner/repo.repo_target_from_payload(payload, *, protocol, default_host)— resolves aRepoTargetfrom a webhook or synthesised payload. Usesclone_url/ssh_url/html_urlwhen present (real webhook) and reconstructs them fromfull_name+default_hostotherwise (poller). ReturnsNonewhen no repository is named. Validates each path segment (_safe_component) — R5.1.Workspace(root, *, strategy, git_binary)—strategyisworktree(default) orclone(unknown values fall back toworktree):prepare(target, slug, *, branch)/cleanup(target, slug)— the strategy-dispatch entry points the dispatcher calls; return the sessioncwd/ remove the checkout.repo_dir/worktree_dir(worktree) andworkitem_dir(slug)/clone_dir(target, slug)(clone) — the layouts (R2.1, R3.1/R3.2).is_available()—giton PATH (verified before use, like the tmux runner).ensure_clone+default_branch+ensure_worktree(target, slug, *, branch)/remove_worktree— the worktree impl: shared clone (fetch if present), PR head viagit worktree add -B <ref> origin/<ref>else--detachat the default branch; removal viagit worktree remove --force+prune, shared clone kept (R2.2, R3.3, R4.1/R4.2). Idempotent.ensure_workitem_clone(target, slug, *, branch)/remove_workitem_clone(slug)— the clone impl: full clone into<root>/.work-items/<slug>/…(default branch in place, or PR head viacheckout -B), removal via a singlermtreeof the folder (every repo the work item cloned) (R3.2/R3.3, R4.1). Idempotent.
WorkspaceError— raised on a git failure so the caller can fail+retry (R5.2).
Dispatcher wiring (the_loop.webhook.dispatcher)
WorkspaceConfigmirrorsrouting.workspace(root,strategy,cloneProtocol,defaultHost,keepCheckoutOnClose,gitBinary);enabled⇔rootnon-empty. Added toRoutingConfig.Dispatcherbuilds aWorkspacewhenworkspace.enabled, elseNone(legacy). It is rebuilt on hot-reload (a caller-supplied override is preserved for tests/embedding)._prepare_workspace(work_item, routed)— returnsspawnWorkdirwhen disabled or the payload names no repo; otherwiseworkspace.prepare(...)and returns the checkout path._spawn_for/_spawn_tmuxuse this forcwdand record it on theSession; aWorkspaceErrorbecomessession.spawn_failed+ delivery release (R5.2)._cleanup_workspace(session, routed)— called in the existing PR-close branch ofhandle, afterregistry.close;workspace.cleanup(...)unlesskeepCheckoutOnClose(R4). Best-effort — never raises out of close._pr_head_ref(routed)— the PR head branch to seed a PR-triggered checkout.
Event log (the_loop.eventlog)
Two new EVENT_TYPES: workspace.prepared (work_item, strategy, checkout, branch) and workspace.cleaned (work_item, strategy) — R5.3.
Config schema / templates
.the-loop/cli-config.schema.json gains routing.workspace; the shipped cli-config.yaml template and the automation.md reference document it.
Data / layout
<root>/ # strategy: worktree
github.com/ # or the enterprise domain
<owner>/<repo>/ # shared clone — default branch, fetched, never worked in
.worktrees/
github.com/<owner>/<repo>/
<work-item-slug>/ # one worktree per work item (session cwd)
<root>/ # strategy: clone
.work-items/
<work-item-slug>/ # one folder per work item (removed whole on close)
github.com/<owner>/<repo>/ # full clone of each repo it touches (session cwd)Testing strategy
tests/test_workspace.py(skipped whengitis absent): a bare local repo stands in for the origin. Coversrepo_target_from_payload(full webhook, ssh, lean poller payload, traversal rejection), layout,ensure_clone(create + reuse + bad-URL raise),ensure_worktree(detached default, PR branch checkout, unknown-branch fallback, idempotency),remove_worktree, and the clone strategy (work-item-centric layout, full clone on default/PR branch, idempotency, and cleanup removing the whole multi-repo work-item folder; unknown strategy falls back toworktree).- Dispatcher integration (same file): a real
Workspaceover a local origin + recording adapter proves a spawn runs in the checkout cwd, PR-close removes it (and keeps it underkeepCheckoutOnClose) — for both strategies — and the disabled path still usesspawnWorkdir. - Config parsing:
WorkspaceConfigdefaults + overrides (incl.strategy) viaRoutingConfig.
Re-evaluation triggers
- Auth beyond git credentials (per-repo tokens, app installs) — revisit if operators need it; today
gh auth setup-git/ ssh keys cover it. - Checkout GC / disk pressure — merged-PR cleanup handles the common case; a periodic prune of abandoned worktrees/work-item folders could follow if long-lived daemons accumulate them (the
clonestrategy is heavier on disk, one full clone per work item). - Non-GitHub providers (the reserved
jira:future) —RepoTarget/Workspaceare already provider-neutral; only payload→target resolution would extend.