99c71094
Plan phase 1: issue leases over a collab-lease SSH verb
a73x 2026-09-05 19:37
Commit message
docs/superpowers/plans/2026-09-05-issue-leases.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,464 @@ | |||
| 1 | # Issue Leases Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Atomic, TTL-based work claims ("leases") on issues, held server-side in SQLite and operated over an authenticated `collab-lease` SSH exec verb. Phase 1 of `docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md`. | ||
| 6 | |||
| 7 | **Architecture:** A new `collab-lease` SSH exec verb (alongside `git-upload-pack`/`git-receive-pack`/`collab-release`) with subverbs `acquire`/`renew`/`release`/`list`, gated by the existing per-repo policies (mutations need write, list needs read). Leases live in one SQLite database at `{repos_dir}/collab.db` (config-overridable), keyed `(repo, issue_id)`. The holder is the connection's SSH principal — governed name when governance is active, key fingerprint otherwise — so **the SSH key is the credential; there are no API tokens**. The CLI gains `git-collab issue claim/unclaim/renew/claims`, which shell out to `ssh` exactly like `git-collab release` does. The web UI shows a live claim on the issues list and detail pages. | ||
| 8 | |||
| 9 | **Semantics (from the design doc):** acquire is one conditional write — the DB's serialization is the arbiter. TTL leases expire lazily; a missing `--ttl` means open-ended (a human "assignment"). Re-acquire/renew by the current holder is idempotent. `lease_token` increments per tenure change and is *stored and reported* now; token *enforcement* on write paths (fencing) belongs to later phases — there are no server-mediated writes to fence yet. | ||
| 10 | |||
| 11 | **Tech Stack:** Rust 2021, russh (SSH server), rusqlite (new dep, `bundled` feature), git2, serde_json, chrono. | ||
| 12 | |||
| 13 | **Spec:** `docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md` | ||
| 14 | |||
| 15 | **Codebase facts the implementer needs:** | ||
| 16 | |||
| 17 | - TWO crate roots: the lib (`src/lib.rs`, used by the `git-collab` CLI bin) and the server bin (`src/server/main.rs`, which declares `mod config; mod governance; mod http; mod refs; mod releases; mod repos; mod setup; mod ssh;`). Server modules reference each other as `crate::…` within the server bin and use the lib as `git_collab::…`. | ||
| 18 | - SSH exec requests are parsed by `src/server/ssh/session.rs::parse_exec_command` into `ExecCommand` (`Git` | `Release`); `collab-release` shows the token-parsing pattern (`shell_tokens`, empty-token rejection). Follow it exactly. | ||
| 19 | - Dispatch + authorization pattern: `handle_release_command` in `session.rs`. Unknown repo and unauthorized get the SAME `error: repository not found\n` reply (exit 1) so errors can't probe private repos — leases must mirror this. Authorization switches on `Regime` (`Closed` → reject; `Ungoverned` → `entry.policy.allows_read/allows_write(principal)`; `Governed` → `governance.conf.allows_repo(&key, &subject, needed)` with `governance::conf::Access`). `Access::Write` is "fast-forward a ref, create a ref" — the right level for lease mutations (they are routine collaboration, not history rewrites; releases use `Rewind` because they are administrative). | ||
| 20 | - Replies go through `reply_and_close(session, channel, message, exit_code)` (`session.rs:1104`). | ||
| 21 | - The governed display name: `Regime::Governed { name, .. }` carries it; ungoverned connections have only the fingerprint `principal` (`ssh_key_principal` form). Holder string = governed name if governed, else `principal`. | ||
| 22 | - Repo path safety: `resolve_repo_path(repos_dir, requested)`; repo entry + policy via `crate::repos::entry_for_path(&self.config.repos_dir, resolved_path)`. | ||
| 23 | - Issue resolution server-side: open the bare repo with `git2::Repository::open(resolved_path)`, then `git_collab::state::resolve_issue_ref(&repo, id_or_prefix)` → `(ref_name, full_id)` and `git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id)` → `.status` (`git_collab::state::IssueStatus::Open`). This is exactly what `src/server/http/repo/issues.rs` does. | ||
| 24 | - Server config: `src/server/config.rs::ServerConfig` (serde `Deserialize` from TOML, `#[serde(default = "…")]` pattern per field; see `max_release_size`). | ||
| 25 | - HTTP state: `src/server/http/mod.rs::AppState { repos_dir, site_title }`; handlers get `State(Arc<AppState>)`. Issues templates: `IssueListItem` / `IssueDetailView` in `src/server/http/repo/issues.rs`, templates `issues.html` / `issue_detail.html` under `src/server/http/templates/`. Every template extending `repo_base.html` must provide `site_title`, `repo_name`, `active_section`, `open_patches`, `open_issues`. | ||
| 26 | - CLI SSH client pattern: `src/release.rs` — `parse_ssh_remote` (pub), `ssh_remote(repo, remote_name)` and `run_remote(repo, &remote, &remote_cmd, stdin)` (private today; Task 5 extracts them). SSH command resolution honors `GIT_COLLAB_SSH_COMMAND` > `GIT_SSH_COMMAND` > git config. | ||
| 27 | - CLI subcommands: `src/cli.rs` (`clap` derive; `IssueCmd` exists). Dispatch lives in `src/main.rs`. | ||
| 28 | - Test harness: `tests/common/mod.rs::ServerHarness` starts a real `git-collab-server`; `ssh_client_key()` generates + authorizes a client key; `ssh_exec_with_stdin(remote_cmd, stdin)` / `ssh_exec_as(key, remote_cmd, stdin)` run exec verbs; `named_key(name)` makes keys enrollable through governance. `open_issue(...)` in `tests/common/mod.rs` writes issue events; push them to the harness bare repo over SSH (`ssh_push`) so the server can resolve them. | ||
| 29 | - Time: store unix epoch seconds (`i64`) in SQLite; render RFC3339 in JSON via `chrono::DateTime::from_timestamp`. | ||
| 30 | - Run tests with `cargo test`, lints with `cargo clippy --all-targets`. | ||
| 31 | - Commit messages: plain imperative style ("Enforce one review vote per author per revision"). | ||
| 32 | |||
| 33 | **Exit-code contract for `collab-lease` (used by CLI and agents):** | ||
| 34 | |||
| 35 | | Code | Meaning | | ||
| 36 | |---|---| | ||
| 37 | | 0 | success (including idempotent re-acquire/release) | | ||
| 38 | | 1 | error: bad arguments, unknown/unauthorized repo, unknown issue, closed issue | | ||
| 39 | | 4 | lease conflict: held by someone else (acquire), or not the holder (renew) | | ||
| 40 | |||
| 41 | All success output is one JSON object on stdout. Errors are one `error: …` line. | ||
| 42 | |||
| 43 | --- | ||
| 44 | |||
| 45 | ### Task 1: Lease store in the server bin | ||
| 46 | |||
| 47 | A `leases` module owning the SQLite schema and the four operations. All decisions (who wins, idempotency, expiry) live here, in plain functions over a `rusqlite::Connection`, unit-testable without SSH. | ||
| 48 | |||
| 49 | **Files:** | ||
| 50 | - Create: `src/server/leases.rs` | ||
| 51 | - Modify: `src/server/main.rs` (add `mod leases;`), `Cargo.toml` | ||
| 52 | |||
| 53 | - [ ] **Step 1: Add the dependency** | ||
| 54 | |||
| 55 | Run: `cargo add rusqlite --features bundled` | ||
| 56 | |||
| 57 | - [ ] **Step 2: Write the module skeleton and failing tests** | ||
| 58 | |||
| 59 | Create `src/server/leases.rs`: | ||
| 60 | |||
| 61 | ```rust | ||
| 62 | //! Work leases on issues: the one collaboration primitive git cannot | ||
| 63 | //! express (atomic claim with TTL). One SQLite database per server, | ||
| 64 | //! `(repo, issue_id)` primary key, lazy expiry. See | ||
| 65 | //! docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md. | ||
| 66 | |||
| 67 | use std::path::Path; | ||
| 68 | |||
| 69 | use rusqlite::Connection; | ||
| 70 | |||
| 71 | /// A live or expired lease row. | ||
| 72 | #[derive(Debug, Clone, PartialEq)] | ||
| 73 | pub struct Lease { | ||
| 74 | pub repo: String, | ||
| 75 | pub issue_id: String, | ||
| 76 | pub holder: String, | ||
| 77 | pub token: i64, | ||
| 78 | pub acquired_at: i64, | ||
| 79 | /// None = open-ended (human assignment). | ||
| 80 | pub expires_at: Option<i64>, | ||
| 81 | } | ||
| 82 | |||
| 83 | impl Lease { | ||
| 84 | pub fn live(&self, now: i64) -> bool { | ||
| 85 | self.expires_at.map(|e| e > now).unwrap_or(true) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | #[derive(Debug, PartialEq)] | ||
| 90 | pub enum Acquire { | ||
| 91 | /// Caller now holds the lease (fresh tenure, or idempotent re-acquire). | ||
| 92 | Acquired { token: i64, expires_at: Option<i64> }, | ||
| 93 | /// A different holder has a live lease. | ||
| 94 | Held { holder: String, expires_at: Option<i64> }, | ||
| 95 | } | ||
| 96 | |||
| 97 | #[derive(Debug, PartialEq)] | ||
| 98 | pub enum Renew { | ||
| 99 | Renewed { token: i64, expires_at: Option<i64> }, | ||
| 100 | /// No live lease held by caller (expired, released, or someone else's). | ||
| 101 | NotHolder { holder: Option<String> }, | ||
| 102 | } | ||
| 103 | |||
| 104 | #[derive(Debug, PartialEq)] | ||
| 105 | pub enum Release { | ||
| 106 | /// Row deleted, or there was nothing to release (idempotent success). | ||
| 107 | Released, | ||
| 108 | /// A different holder has a live lease; refuse. | ||
| 109 | NotHolder { holder: String }, | ||
| 110 | } | ||
| 111 | |||
| 112 | /// Open (creating if needed) the lease database and ensure the schema. | ||
| 113 | pub fn open(path: &Path) -> rusqlite::Result<Connection> { | ||
| 114 | todo!() | ||
| 115 | } | ||
| 116 | |||
| 117 | pub fn acquire( | ||
| 118 | conn: &Connection, | ||
| 119 | repo: &str, | ||
| 120 | issue_id: &str, | ||
| 121 | holder: &str, | ||
| 122 | ttl_secs: Option<i64>, | ||
| 123 | now: i64, | ||
| 124 | ) -> rusqlite::Result<Acquire> { | ||
| 125 | todo!() | ||
| 126 | } | ||
| 127 | |||
| 128 | pub fn renew( | ||
| 129 | conn: &Connection, | ||
| 130 | repo: &str, | ||
| 131 | issue_id: &str, | ||
| 132 | holder: &str, | ||
| 133 | ttl_secs: Option<i64>, | ||
| 134 | now: i64, | ||
| 135 | ) -> rusqlite::Result<Renew> { | ||
| 136 | todo!() | ||
| 137 | } | ||
| 138 | |||
| 139 | pub fn release( | ||
| 140 | conn: &Connection, | ||
| 141 | repo: &str, | ||
| 142 | issue_id: &str, | ||
| 143 | holder: &str, | ||
| 144 | now: i64, | ||
| 145 | ) -> rusqlite::Result<Release> { | ||
| 146 | todo!() | ||
| 147 | } | ||
| 148 | |||
| 149 | /// A live lease on the issue, if any. | ||
| 150 | pub fn current( | ||
| 151 | conn: &Connection, | ||
| 152 | repo: &str, | ||
| 153 | issue_id: &str, | ||
| 154 | now: i64, | ||
| 155 | ) -> rusqlite::Result<Option<Lease>> { | ||
| 156 | todo!() | ||
| 157 | } | ||
| 158 | |||
| 159 | /// All live leases in a repo, oldest first. | ||
| 160 | pub fn list(conn: &Connection, repo: &str, now: i64) -> rusqlite::Result<Vec<Lease>> { | ||
| 161 | todo!() | ||
| 162 | } | ||
| 163 | ``` | ||
| 164 | |||
| 165 | Schema (inside `open`, `CREATE TABLE IF NOT EXISTS`; also set `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`): | ||
| 166 | |||
| 167 | ```sql | ||
| 168 | CREATE TABLE IF NOT EXISTS leases ( | ||
| 169 | repo TEXT NOT NULL, | ||
| 170 | issue_id TEXT NOT NULL, | ||
| 171 | holder TEXT NOT NULL, | ||
| 172 | token INTEGER NOT NULL, | ||
| 173 | acquired_at INTEGER NOT NULL, | ||
| 174 | expires_at INTEGER, | ||
| 175 | PRIMARY KEY (repo, issue_id) | ||
| 176 | ); | ||
| 177 | ``` | ||
| 178 | |||
| 179 | Semantics to implement (each inside ONE transaction — `BEGIN IMMEDIATE` via `conn.transaction_with_behavior(TransactionBehavior::Immediate)` — so concurrent connections serialize): | ||
| 180 | |||
| 181 | - `acquire`: read the row. No row, or row not `live(now)` → take tenure: `INSERT OR REPLACE` with `token = old_token + 1` (1 if no row), `acquired_at = now`, `expires_at = ttl_secs.map(|t| now + t)` → `Acquired`. Live row held by caller → idempotent: keep token, update `expires_at` from this call's `ttl_secs` → `Acquired`. Live row held by another → `Held`. | ||
| 182 | - `renew`: live row held by caller → update `expires_at` (same rule), keep token → `Renewed`. Anything else → `NotHolder` (with the live holder's name if there is one). | ||
| 183 | - `release`: live row held by another → `NotHolder`. Otherwise delete any row → `Released` (idempotent: releasing nothing succeeds — a client retrying after a dropped connection must not fail). | ||
| 184 | - `current`/`list`: return only live rows; opportunistically `DELETE` expired rows they encounter (lazy reaping). | ||
| 185 | |||
| 186 | Unit tests in the same file (`#[cfg(test)]`, DB via `Connection::open_in_memory()` piped through the same schema init — factor `open` so tests can reuse the schema function): | ||
| 187 | |||
| 188 | ```text | ||
| 189 | acquire_free_issue_returns_token_1 | ||
| 190 | acquire_sets_expiry_from_ttl / acquire_without_ttl_is_open_ended | ||
| 191 | second_acquire_by_other_holder_returns_held | ||
| 192 | reacquire_by_holder_is_idempotent_same_token_new_expiry | ||
| 193 | acquire_after_expiry_takes_over_and_bumps_token | ||
| 194 | renew_extends_expiry_keeps_token | ||
| 195 | renew_by_non_holder_returns_not_holder | ||
| 196 | renew_after_expiry_returns_not_holder | ||
| 197 | release_by_holder_deletes / release_idempotent_when_absent | ||
| 198 | release_by_non_holder_refused | ||
| 199 | list_shows_only_live_leases / current_none_after_expiry | ||
| 200 | open_ended_lease_never_expires (large `now`) | ||
| 201 | tenure_token_monotonic_across_holders (a→expire→b→expire→a: tokens 1,2,3) | ||
| 202 | ``` | ||
| 203 | |||
| 204 | - [ ] **Step 3: Run tests to verify they fail** | ||
| 205 | |||
| 206 | Run: `cargo test leases::` | ||
| 207 | Expected: FAIL (panic at `todo!()`) | ||
| 208 | |||
| 209 | - [ ] **Step 4: Implement** | ||
| 210 | |||
| 211 | Fill in the five functions per the semantics above. Keep every branch decision in SQL-adjacent Rust (read row → decide → write); no clever single-statement upserts — the transaction provides atomicity and the explicit branches keep `Held`/`NotHolder` reporting exact. | ||
| 212 | |||
| 213 | - [ ] **Step 5: Run tests to verify they pass** | ||
| 214 | |||
| 215 | Run: `cargo test leases::` | ||
| 216 | |||
| 217 | - [ ] **Step 6: Commit** | ||
| 218 | |||
| 219 | `Add a server-side lease store for issue claims` | ||
| 220 | |||
| 221 | --- | ||
| 222 | |||
| 223 | ### Task 2: `collab_db` server config | ||
| 224 | |||
| 225 | **Files:** | ||
| 226 | - Modify: `src/server/config.rs` | ||
| 227 | |||
| 228 | - [ ] **Step 1: Write the failing tests** | ||
| 229 | |||
| 230 | In `config.rs` tests: `parse_minimal_config_uses_defaults` asserts `config.collab_db(&config.repos_dir)` — add a test that the default is `{repos_dir}/collab.db` and an explicit `collab_db = "/elsewhere/x.db"` wins. | ||
| 231 | |||
| 232 | Design: an `Option<PathBuf>` field (serde default `None`) plus an accessor, because the default depends on another field: | ||
| 233 | |||
| 234 | ```rust | ||
| 235 | #[serde(default)] | ||
| 236 | pub collab_db: Option<PathBuf>, | ||
| 237 | |||
| 238 | pub fn collab_db_path(&self) -> PathBuf { | ||
| 239 | self.collab_db | ||
| 240 | .clone() | ||
| 241 | .unwrap_or_else(|| self.repos_dir.join("collab.db")) | ||
| 242 | } | ||
| 243 | ``` | ||
| 244 | |||
| 245 | - [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server config::` | ||
| 246 | |||
| 247 | - [ ] **Step 3: Implement** (as above) | ||
| 248 | |||
| 249 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 250 | |||
| 251 | - [ ] **Step 5: Commit** | ||
| 252 | |||
| 253 | `Add a collab_db server config option defaulting beside the repos` | ||
| 254 | |||
| 255 | --- | ||
| 256 | |||
| 257 | ### Task 3: Parse `collab-lease` exec commands | ||
| 258 | |||
| 259 | **Files:** | ||
| 260 | - Modify: `src/server/ssh/session.rs` (`ExecCommand`, `parse_exec_command`, tests) | ||
| 261 | |||
| 262 | - [ ] **Step 1: Write the failing tests** | ||
| 263 | |||
| 264 | Next to the `collab-release` parser tests, following their shape (quoting, empty tokens, unknown verbs, extra args → `None`): | ||
| 265 | |||
| 266 | ```text | ||
| 267 | parse_lease_acquire collab-lease acquire 'r.git' 'a1b2c3d4' | ||
| 268 | parse_lease_acquire_with_ttl collab-lease acquire 'r.git' 'a1b2c3d4' --ttl 300 | ||
| 269 | parse_lease_renew_requires_ttl collab-lease renew 'r.git' 'a1b2c3d4' --ttl 300 (without --ttl → None) | ||
| 270 | parse_lease_release collab-lease release 'r.git' 'a1b2c3d4' | ||
| 271 | parse_lease_list collab-lease list 'r.git' | ||
| 272 | rejects: unknown subverb, empty repo/issue, non-numeric or zero/negative ttl, | ||
| 273 | trailing args, unclosed quotes | ||
| 274 | ``` | ||
| 275 | |||
| 276 | New variants: | ||
| 277 | |||
| 278 | ```rust | ||
| 279 | #[derive(Debug, Clone, PartialEq)] | ||
| 280 | pub enum LeaseCmd { | ||
| 281 | Acquire { repo: String, issue: String, ttl_secs: Option<i64> }, | ||
| 282 | Renew { repo: String, issue: String, ttl_secs: i64 }, | ||
| 283 | Release { repo: String, issue: String }, | ||
| 284 | List { repo: String }, | ||
| 285 | } | ||
| 286 | ``` | ||
| 287 | |||
| 288 | `ExecCommand` gains `Lease(LeaseCmd)`; its `repo()` accessor covers it. `--ttl <n>` parses as `i64`, must be `> 0`. | ||
| 289 | |||
| 290 | - [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server parse_lease` | ||
| 291 | |||
| 292 | - [ ] **Step 3: Implement** — extend `parse_exec_command` after the `collab-release` block, same `shell_tokens` path. | ||
| 293 | |||
| 294 | - [ ] **Step 4: Run tests to verify they pass** (including the existing release parser suite — no regressions) | ||
| 295 | |||
| 296 | - [ ] **Step 5: Commit** | ||
| 297 | |||
| 298 | `Parse collab-lease exec commands` | ||
| 299 | |||
| 300 | --- | ||
| 301 | |||
| 302 | ### Task 4: SSH dispatch, authorization, and end-to-end server tests | ||
| 303 | |||
| 304 | **Files:** | ||
| 305 | - Modify: `src/server/ssh/session.rs` (dispatch), `src/server/main.rs`/wherever `SshHandler` gets config (it already holds `self.config`) | ||
| 306 | - Create: `tests/lease_server_test.rs` | ||
| 307 | |||
| 308 | - [ ] **Step 1: Write the failing e2e tests** | ||
| 309 | |||
| 310 | `tests/lease_server_test.rs`, using `ServerHarness`. Setup per test: create an issue locally (`common::open_issue`), push collab refs to the harness repo over SSH (`ssh_push` with the collab refspec the sync tests use), then drive `collab-lease` via `harness.ssh_exec_with_stdin(...)` / `ssh_exec_as(...)` with a second key where two principals are needed. Parse stdout as JSON; assert exit codes per the contract table. | ||
| 311 | |||
| 312 | ```text | ||
| 313 | acquire_open_issue_succeeds status=acquired, token=1, expires_at=null | ||
| 314 | acquire_with_ttl_reports_expiry RFC3339 expires_at | ||
| 315 | acquire_conflict_reports_holder second key → exit 4, status=held, holder names key 1 | ||
| 316 | reacquire_same_key_is_idempotent exit 0, same token | ||
| 317 | renew_extends / renew_wrong_key_exit_4 | ||
| 318 | release_then_other_key_can_acquire token increments | ||
| 319 | acquire_by_issue_id_prefix short prefix resolves; JSON carries the full id | ||
| 320 | acquire_unknown_issue_exit_1 | ||
| 321 | acquire_closed_issue_exit_1 (close_issue + push first) | ||
| 322 | unknown_repo_and_unauthorized_are_identical same NOT_FOUND string, exit 1 (mirror release test) | ||
| 323 | list_shows_live_leases_json | ||
| 324 | read_only_principal_cannot_acquire policy write required (exit 1, NOT_FOUND) | ||
| 325 | ``` | ||
| 326 | |||
| 327 | - [ ] **Step 2: Run tests to verify they fail** — `cargo test --test lease_server_test` (dispatch missing: server replies as if the verb is unknown) | ||
| 328 | |||
| 329 | - [ ] **Step 3: Implement the dispatch** | ||
| 330 | |||
| 331 | `handle_lease_command(&mut self, channel, session, cmd: LeaseCmd, resolved_path, principal, regime)` modeled line-for-line on `handle_release_command`: | ||
| 332 | |||
| 333 | 1. `entry_for_path` → unknown repo → `NOT_FOUND`, exit 1. | ||
| 334 | 2. `needed = Access::Read` for `List`, `Access::Write` otherwise; authorize per regime exactly as releases do; failure → same `NOT_FOUND`, exit 1. | ||
| 335 | 3. Holder string: `Regime::Governed { name, .. }` → `name`, else `principal`. | ||
| 336 | 4. For `Acquire`: open the bare repo, `resolve_issue_ref` (unknown → `error: issue not found\n`, exit 1), `IssueState::from_ref`, require `IssueStatus::Open` (`error: issue is closed\n`, exit 1). `Renew`/`Release` operate on the lease row alone (the issue's continued existence is not their business); `List` needs no issue. | ||
| 337 | 5. `leases::open(&self.config.collab_db_path())`, call the store with `now = chrono::Utc::now().timestamp()`, map outcomes: | ||
| 338 | - `Acquired`/`Renewed`/`Released` → JSON on stdout, exit 0. | ||
| 339 | - `Held`/`NotHolder` → JSON (`status: "held"`, holder, expires_at) — exit 4. | ||
| 340 | 6. Reply via `reply_and_close`. The store call is synchronous I/O on the async runtime — same accepted tradeoff as the release path, same caveat comment. | ||
| 341 | |||
| 342 | JSON shapes (all include `"issue"` as the FULL id, and RFC3339 timestamps or `null`): | ||
| 343 | |||
| 344 | ```json | ||
| 345 | {"status":"acquired","repo":"r.git","issue":"<full>","holder":"<principal>","token":1,"expires_at":null} | ||
| 346 | {"status":"held","repo":"r.git","issue":"<full>","holder":"<other>","expires_at":"2026-09-05T12:00:00Z"} | ||
| 347 | {"status":"renewed", …} {"status":"released","repo":"r.git","issue":"<full>"} | ||
| 348 | {"leases":[{"issue":"<full>","holder":"…","token":2,"acquired_at":"…","expires_at":null}]} | ||
| 349 | ``` | ||
| 350 | |||
| 351 | - [ ] **Step 4: Run the e2e tests** — `cargo test --test lease_server_test` | ||
| 352 | |||
| 353 | - [ ] **Step 5: Run the full suite** — `cargo test` (release + governance suites must be untouched) | ||
| 354 | |||
| 355 | - [ ] **Step 6: Commit** | ||
| 356 | |||
| 357 | `Serve issue leases over a collab-lease SSH verb` | ||
| 358 | |||
| 359 | --- | ||
| 360 | |||
| 361 | ### Task 5: Extract the shared SSH client helpers | ||
| 362 | |||
| 363 | `release.rs` owns `SshRemote`, `parse_ssh_remote`, `ssh_remote`, `run_remote`; the lease client needs them too. Shared code moves to its own lib module rather than one feature reaching into another's. | ||
| 364 | |||
| 365 | **Files:** | ||
| 366 | - Create: `src/remote_ssh.rs` | ||
| 367 | - Modify: `src/lib.rs` (add `pub mod remote_ssh;`), `src/release.rs` | ||
| 368 | |||
| 369 | - [ ] **Step 1: Move** `SshRemote`, `parse_ssh_remote`, `ssh_remote`, `run_remote` (and their unit tests) into `src/remote_ssh.rs`, all `pub`. In `release.rs`, `use crate::remote_ssh::{ssh_remote, run_remote};` and re-export `pub use crate::remote_ssh::{parse_ssh_remote, SshRemote};` so existing callers and tests keep compiling. | ||
| 370 | |||
| 371 | - [ ] **Step 2: Verify** — `cargo test` (pure refactor: zero behavior change, zero test edits beyond module paths) | ||
| 372 | |||
| 373 | - [ ] **Step 3: Commit** | ||
| 374 | |||
| 375 | `Extract the SSH remote client helpers from release into remote_ssh` | ||
| 376 | |||
| 377 | --- | ||
| 378 | |||
| 379 | ### Task 6: CLI `issue claim` / `unclaim` / `renew` / `claims` | ||
| 380 | |||
| 381 | **Files:** | ||
| 382 | - Create: `src/lease.rs` (lib module: client side) | ||
| 383 | - Modify: `src/lib.rs`, `src/cli.rs` (`IssueCmd` variants), `src/main.rs` (dispatch) | ||
| 384 | - Create: `tests/lease_cli_test.rs` | ||
| 385 | |||
| 386 | - [ ] **Step 1: Add the CLI surface** | ||
| 387 | |||
| 388 | `IssueCmd` gains: | ||
| 389 | |||
| 390 | ```text | ||
| 391 | claim <id> [--ttl <secs>] [--remote <name>=origin] [--json] | ||
| 392 | unclaim <id> [--remote] [--json] | ||
| 393 | renew <id> --ttl <secs> [--remote] [--json] | ||
| 394 | claims [--remote] [--json] | ||
| 395 | ``` | ||
| 396 | |||
| 397 | Note `tests/cli_surface_test.rs` snapshots the CLI surface — update it deliberately, not incidentally. | ||
| 398 | |||
| 399 | - [ ] **Step 2: Implement the client module** | ||
| 400 | |||
| 401 | `src/lease.rs` follows `release.rs::list` verbatim in shape: `ssh_remote(repo, remote_name)` → format the `collab-lease …` command (single-quoted args; ids validated as hex prefixes client-side before quoting) → `run_remote` → forward stdout when `--json`, otherwise render: | ||
| 402 | |||
| 403 | ```text | ||
| 404 | claimed a1b2c3d4 (expires 2026-09-05T12:05:00Z) exit 0 | ||
| 405 | issue a1b2c3d4 is claimed by <holder> exit 4 (message on stderr) | ||
| 406 | ``` | ||
| 407 | |||
| 408 | Exit codes pass through from the remote command (russh delivers the exit status; `run_remote` already surfaces it for releases — keep that behavior). | ||
| 409 | |||
| 410 | - [ ] **Step 3: Write failing CLI e2e tests** | ||
| 411 | |||
| 412 | `tests/lease_cli_test.rs` against `ServerHarness`: clone over SSH with `GIT_COLLAB_SSH_COMMAND` set (the release CLI tests show the incantation), then: | ||
| 413 | |||
| 414 | ```text | ||
| 415 | claim_then_claims_lists_it | ||
| 416 | claim_conflict_exit_code_4_names_holder (second key) | ||
| 417 | unclaim_frees / renew_updates_expiry | ||
| 418 | claim_json_passthrough_is_server_json | ||
| 419 | claim_against_repo_without_issue_fails_cleanly | ||
| 420 | ``` | ||
| 421 | |||
| 422 | - [ ] **Step 4: Run** — `cargo test --test lease_cli_test`, then the full suite. | ||
| 423 | |||
| 424 | - [ ] **Step 5: Commit** | ||
| 425 | |||
| 426 | `Add issue claim, unclaim, renew and claims CLI commands` | ||
| 427 | |||
| 428 | --- | ||
| 429 | |||
| 430 | ### Task 7: Show claims in the web UI | ||
| 431 | |||
| 432 | A claim that is invisible gets double-worked around; the issues list and detail pages must show it. | ||
| 433 | |||
| 434 | **Files:** | ||
| 435 | - Modify: `src/server/http/mod.rs` (`AppState` gains `collab_db: PathBuf`; `main.rs` passes `config.collab_db_path()`), `src/server/http/repo/issues.rs`, `src/server/http/templates/issues.html`, `src/server/http/templates/issue_detail.html` | ||
| 436 | - Modify: `tests/web_rendering_test.rs` (or the closest existing web test file) | ||
| 437 | |||
| 438 | - [ ] **Step 1: Write the failing tests** — acquire a lease over SSH in a harness, `GET` the issues page and detail page, assert the holder appears; assert an expired lease does NOT appear. | ||
| 439 | |||
| 440 | - [ ] **Step 2: Implement** — `IssueListItem` and `IssueDetailView` gain `claimed_by: String` (empty = unclaimed, matching the existing `labels: String` convention). Handlers open the lease DB read-only per request (`leases::open` on a path that may not exist yet must still succeed — it creates the schema; that is fine, it is the server's own file). Render as `claimed by <holder>` next to the status badge. | ||
| 441 | |||
| 442 | - [ ] **Step 3: Run the web tests, then the full suite.** | ||
| 443 | |||
| 444 | - [ ] **Step 4: Commit** | ||
| 445 | |||
| 446 | `Show live issue claims in the web UI` | ||
| 447 | |||
| 448 | --- | ||
| 449 | |||
| 450 | ### Task 8: Final verification | ||
| 451 | |||
| 452 | - [ ] **Step 1: Full test suite** — `cargo test` | ||
| 453 | - [ ] **Step 2: Clippy** — `cargo clippy --all-targets` | ||
| 454 | - [ ] **Step 3: Docs** — README gains a short "Claiming work" subsection under the issues material: the three commands, the TTL/assignment distinction, one sentence that leases live on the server and require no sync. Mark phase 1 done in the design doc's roadmap. | ||
| 455 | - [ ] **Step 4: Commit any remaining fixes** | ||
| 456 | |||
| 457 | --- | ||
| 458 | |||
| 459 | ## Out of scope for this plan (deliberately) | ||
| 460 | |||
| 461 | - **Fencing enforcement** — tokens are stored and reported, but nothing checks them yet: pushes and events don't flow through lease-aware server paths until later phases. | ||
| 462 | - **Auto-release on merge/close** — needs the push-time merge scanning of phase 3. | ||
| 463 | - **Any HTTP mutation surface or API tokens** — mutations ride SSH, per the design doc. | ||
| 464 | - **IRC bot / foreman / eitri integration** — external subscribers; they consume this verb, they don't live here. | ||
docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md
| Old | New | ||
|---|---|---|---|
| @@ -69,9 +69,9 @@ interdiffs, timelines, trailer-recorded merges) and relocates authority. | |||
| 69 | | Code, branches, tags | git | git (unchanged) | | 69 | | Code, branches, tags | git | git (unchanged) | |
| 70 | | Patch revision commits | git, refs written by clients + sync | git, refs written **by the server** on push | | 70 | | Patch revision commits | git, refs written by clients + sync | git, refs written **by the server** on push | |
| 71 | | Issues, comments, reviews, status | signed event DAG in `refs/collab/*` | SQLite on the server | | 71 | | Issues, comments, reviews, status | signed event DAG in `refs/collab/*` | SQLite on the server | |
| 72 | | Work claims | (impossible) | SQLite lease rows, atomic HTTP endpoint | | 72 | | Work claims | (impossible) | SQLite lease rows, atomic server operation over SSH exec | |
| 73 | | Merge recording | sync-time scan on every clone | push-time scan on the server | | 73 | | Merge recording | sync-time scan on every clone | push-time scan on the server | |
| 74 | | Auth / identity | trust allowlist file, distributed by hand | server-side authorized keys (SSH + HTTP); agents are keypairs authorized once | | 74 | | Auth / identity | trust allowlist file, distributed by hand | server-side `authorized_keys`; agents are keypairs authorized once; SSH principal = identity | |
| 75 | | Conflict resolution | timestamp-wins DAG replay | none needed — the DB serializes writes | | 75 | | Conflict resolution | timestamp-wins DAG replay | none needed — the DB serializes writes | |
| 76 | | Event schema | frozen forever (signed) | migratable (DB), versioned (webhooks) | | 76 | | Event schema | frozen forever (signed) | migratable (DB), versioned (webhooks) | |
| 77 | | Portability of the record | live sync of `refs/collab/*` | optional one-way `export` to `refs/collab/*` as an archival artifact | | 77 | | Portability of the record | live sync of `refs/collab/*` | optional one-way `export` to `refs/collab/*` as an archival artifact | |
| @@ -94,7 +94,8 @@ Every mutation on the forge emits one internal event — `issue.filed`, | |||
| 94 | 3. **IRC bridge** — the human feed. Announcements mirrored into channels; | 94 | 3. **IRC bridge** — the human feed. Announcements mirrored into channels; |
| 95 | scrollback becomes an ambient audit trail. | 95 | scrollback becomes an ambient audit trail. |
| 96 | 96 | ||
| 97 | Rule that keeps the seams clean: **machines talk HTTP, humans talk IRC, and | 97 | Rule that keeps the seams clean: **machines talk to the forge directly |
| 98 | (SSH exec in, webhooks out), humans talk IRC, and | ||
| 98 | the bridge translates only at the edges.** IRC carries intent only when a | 99 | the bridge translates only at the edges.** IRC carries intent only when a |
| 99 | human is speaking (DM intake, explicit commands like `claim`). No | 100 | human is speaking (DM intake, explicit commands like `claim`). No |
| 100 | machine-originated action ever rides the channel; automation consumes | 101 | machine-originated action ever rides the channel; automation consumes |
| @@ -113,11 +114,23 @@ WHERE id = :task | |||
| 113 | AND (lease_holder IS NULL OR lease_expires < :now); | 114 | AND (lease_holder IS NULL OR lease_expires < :now); |
| 114 | ``` | 115 | ``` |
| 115 | 116 | ||
| 116 | One row affected = claimed (HTTP 200); zero = lost the race (HTTP 409). The | 117 | One row affected = claimed; zero = lost the race. The database's write |
| 117 | database's write serialization is the arbiter; no lock service. | 118 | serialization is the arbiter; no lock service. |
| 119 | |||
| 120 | **Transport and auth: SSH exec verbs, not HTTP.** The server already has an | ||
| 121 | authenticated command channel — the `collab-release` pattern: the client | ||
| 122 | shells out to `ssh`, `auth_publickey` maps the key to a principal, and | ||
| 123 | per-repo policies authorize the verb. Leases follow it (`collab-lease | ||
| 124 | acquire '<repo>' '<issue>' [--ttl <secs>]`), and `lease_holder` is the SSH | ||
| 125 | principal. This sets the rule for every later phase: **mutations ride SSH | ||
| 126 | (authenticated by key), HTTP stays read-only (web UI + git data), webhooks | ||
| 127 | are outbound and signed.** One auth system for humans and agents alike — an | ||
| 128 | agent's keypair is an `authorized_keys` line, and it already needs that key | ||
| 129 | to clone. If a non-SSH client ever matters, mint bearer tokens over SSH | ||
| 130 | (`collab-token new`); not before. | ||
| 118 | 131 | ||
| 119 | - **TTL + heartbeat.** Agents die mid-task (VM reaped, OOM, wedged). Leases | 132 | - **TTL + heartbeat.** Agents die mid-task (VM reaped, OOM, wedged). Leases |
| 120 | expire on their own; live holders renew via `PUT /tasks/:id/lease`. Humans | 133 | expire on their own; live holders renew (`collab-lease renew`). Humans |
| 121 | get an open-ended lease that renders as "assigned" — one mechanism, two | 134 | get an open-ended lease that renders as "assigned" — one mechanism, two |
| 122 | tenure policies. | 135 | tenure policies. |
| 123 | - **Fencing tokens.** `lease_token` increments on every acquire. Every write | 136 | - **Fencing tokens.** `lease_token` increments on every acquire. Every write |
| @@ -125,7 +138,8 @@ database's write serialization is the arbiter; no lock service. | |||
| 125 | token; the forge rejects stale tokens. Expiry alone protects liveness; | 138 | token; the forge rejects stale tokens. Expiry alone protects liveness; |
| 126 | fencing protects correctness against zombie workers waking up post-expiry. | 139 | fencing protects correctness against zombie workers waking up post-expiry. |
| 127 | - **Idempotent acquire.** Re-claiming a lease you already hold succeeds, so a | 140 | - **Idempotent acquire.** Re-claiming a lease you already hold succeeds, so a |
| 128 | client retrying a lost HTTP response does not deadlock against itself. | 141 | client retrying after a dropped connection does not deadlock against |
| 142 | itself. | ||
| 129 | 143 | ||
| 130 | Scale is a non-issue by construction: a lease op costs ~1ms of forge time | 144 | Scale is a non-issue by construction: a lease op costs ~1ms of forge time |
| 131 | against tasks costing minutes of VM work, and contention is per-task (N racers | 145 | against tasks costing minutes of VM work, and contention is per-task (N racers |
| @@ -168,7 +182,7 @@ on behalf of alex, via DM*. | |||
| 168 | 182 | ||
| 169 | Issues belong to a repo; references are qualified (`mux#4f2a91`). `relates_to` | 183 | Issues belong to a repo; references are qualified (`mux#4f2a91`). `relates_to` |
| 170 | generalizes to cross-repo foreign keys in the one database. A worker that | 184 | generalizes to cross-repo foreign keys in the one database. A worker that |
| 171 | finds a side-issue in another repo files it (`POST /repos/<repo>/issues`), | 185 | finds a side-issue in another repo files it (one exec verb / API call), |
| 172 | links it, and stays on task — the default policy is **file, link, continue**; | 186 | links it, and stays on task — the default policy is **file, link, continue**; |
| 173 | the lease system keeps workers honest about what they claimed. | 187 | the lease system keeps workers honest about what they claimed. |
| 174 | 188 | ||
| @@ -199,7 +213,8 @@ Nothing in this repo may depend on any of these existing. | |||
| 199 | - The Gerrit reconstruction: `patch.rs` diff/interdiff, `timeline.rs`, | 213 | - The Gerrit reconstruction: `patch.rs` diff/interdiff, `timeline.rs`, |
| 200 | revision refs (now server-written), TUI and web rendering. | 214 | revision refs (now server-written), TUI and web rendering. |
| 201 | - `merge_scan.rs` (relocated to push time), `hooks.rs` (unchanged role). | 215 | - `merge_scan.rs` (relocated to push time), `hooks.rs` (unchanged role). |
| 202 | - The CLI: same verbs, thin client over HTTP instead of a local ref-writer. | 216 | - The CLI: same verbs, thin client over SSH exec (the `release.rs` pattern) |
| 217 | instead of a local ref-writer. | ||
| 203 | - `event.rs` vocabulary: becomes the webhook payload schema. | 218 | - `event.rs` vocabulary: becomes the webhook payload schema. |
| 204 | - `signing.rs`: signs webhook payloads; agent keys remain Ed25519. | 219 | - `signing.rs`: signs webhook payloads; agent keys remain Ed25519. |
| 205 | 220 | ||
| @@ -216,7 +231,7 @@ Nothing in this repo may depend on any of these existing. | |||
| 216 | - SQLite schema: issues, comments, patches, revisions, reviews, leases, | 231 | - SQLite schema: issues, comments, patches, revisions, reviews, leases, |
| 217 | webhook subscriptions. | 232 | webhook subscriptions. |
| 218 | - The lease endpoints (~50 lines + fencing checks on write paths). | 233 | - The lease endpoints (~50 lines + fencing checks on write paths). |
| 219 | - The HTTP API surface for issues/comments/reviews. | 234 | - The SSH exec verbs for issues/comments/reviews/leases (the mutation API). |
| 220 | - `refs/for/<base>` receive-hook handling. | 235 | - `refs/for/<base>` receive-hook handling. |
| 221 | - The event bus + webhook dispatcher. | 236 | - The event bus + webhook dispatcher. |
| 222 | - `git-collab export`: one-way materialization of the record into | 237 | - `git-collab export`: one-way materialization of the record into |
| @@ -231,10 +246,11 @@ useful the day it lands, and none blocks on the companions. | |||
| 231 | 246 | ||
| 232 | 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks. | 247 | 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks. |
| 233 | The agent loop works against the forge as it exists today; humans get | 248 | The agent loop works against the forge as it exists today; humans get |
| 234 | `issue claim`. (The seam the whole agent story hangs on, and the one | 249 | `issue claim`. Transport is the `collab-lease` SSH exec verb; no new |
| 250 | auth machinery. (The seam the whole agent story hangs on, and the one | ||
| 235 | thing git structurally cannot express.) | 251 | thing git structurally cannot express.) |
| 236 | 2. **Issues and comments to SQLite.** The API + web UI read/write the DB; | 252 | 2. **Issues and comments to SQLite.** Exec verbs + web UI read/write the |
| 237 | a comment becomes one `POST`. Kills the review round-trip burden. Includes | 253 | DB; a comment becomes one round trip. Kills the review burden. Includes |
| 238 | a one-time importer that replays existing `refs/collab/*` DAGs into the | 254 | a one-time importer that replays existing `refs/collab/*` DAGs into the |
| 239 | DB (the current `state.rs` materializer, run once, then retired). | 255 | DB (the current `state.rs` materializer, run once, then retired). |
| 240 | 3. **Server-maintained revision refs + `refs/for/<base>`.** The receive hook | 256 | 3. **Server-maintained revision refs + `refs/for/<base>`.** The receive hook |