7322c9bb
Abbreviate issue ids in lease output and document claiming work
a73x 2026-09-06 08:14
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -242,7 +242,7 @@ reader folds it. | |||
| 242 | 242 | ||
| 243 | | | | | 243 | | | | |
| 244 | |---|---| | 244 | |---|---| |
| 245 | | `issue` | open, list, show, comment, edit, edit-comment, delete-comment, label, assign, close | | 245 | | `issue` | open, list, show, comment, edit, edit-comment, delete-comment, label, assign, close, claim, unclaim, renew, claims | |
| 246 | | `patch` | create, list, show, diff, comment, review, revise, edit-comment, delete-comment, edit-revision, log, checkout, merge, close | | 246 | | `patch` | create, list, show, diff, comment, review, revise, edit-comment, delete-comment, edit-revision, log, checkout, merge, close | |
| 247 | | `sync` | fetch, reconcile and push collab refs | | 247 | | `sync` | fetch, reconcile and push collab refs | |
| 248 | | `hooks` | install and inspect the `commit-msg` trailer hook | | 248 | | `hooks` | install and inspect the `commit-msg` trailer hook | |
| @@ -359,6 +359,53 @@ auto-sync: your changes are recorded locally; run 'git-collab sync' to publish t | |||
| 359 | Because the narration stays on stderr, stdout carries the command's result and | 359 | Because the narration stays on stderr, stdout carries the command's result and |
| 360 | nothing else, whether or not a sync happened. | 360 | nothing else, whether or not a sync happened. |
| 361 | 361 | ||
| 362 | ## Claiming work | ||
| 363 | |||
| 364 | Two people — or twenty agents — working the same backlog need to know who has | ||
| 365 | picked up what before they start. That is the one thing a repository cannot | ||
| 366 | answer: git's only atomic operation is a ref update on push, so a claim made | ||
| 367 | in the repository is a race whose loser finds out after the fact. Claims | ||
| 368 | therefore live on the server, and the commands need one round trip each: | ||
| 369 | |||
| 370 | ```console | ||
| 371 | $ git-collab issue claim a1b2c3d4 # yours until you say otherwise | ||
| 372 | Claimed issue a1b2c3d4 | ||
| 373 | $ git-collab issue claims # who holds what | ||
| 374 | a1b2c3d4 key:SHA256:… assigned | ||
| 375 | $ git-collab issue unclaim a1b2c3d4 | ||
| 376 | Released the claim on a1b2c3d4 | ||
| 377 | ``` | ||
| 378 | |||
| 379 | Ids are abbreviated here as everywhere else, and `--json` carries the full | ||
| 380 | forty characters — the identifier a script should hold on to. | ||
| 381 | |||
| 382 | With no `--ttl` a claim is an *assignment*: open-ended, released when you say | ||
| 383 | so. With one it is a *lease* that lapses unless renewed, which is what an | ||
| 384 | unattended worker wants — if the machine dies, the work returns to the pool on | ||
| 385 | its own rather than staying claimed by a process that no longer exists: | ||
| 386 | |||
| 387 | ```console | ||
| 388 | $ git-collab issue claim a1b2c3d4 --ttl 900 # 15 minutes | ||
| 389 | $ git-collab issue renew a1b2c3d4 --ttl 900 # before it lapses | ||
| 390 | ``` | ||
| 391 | |||
| 392 | Losing a race exits **4**, distinct from the 1 any other failure exits with, | ||
| 393 | so a script can tell "someone else got it" from "the command was wrong" | ||
| 394 | without reading prose: | ||
| 395 | |||
| 396 | ```console | ||
| 397 | $ git-collab issue claim a1b2c3d4 --json; echo "exit $?" | ||
| 398 | {"status":"held","holder":"key:SHA256:…","expires_at":"2026-09-05T12:15:00Z",…} | ||
| 399 | exit 4 | ||
| 400 | ``` | ||
| 401 | |||
| 402 | The server is the arbiter, so these commands need a reachable SSH remote and | ||
| 403 | authenticate with the same key that clones the repository — there is no token | ||
| 404 | to issue and nothing to configure. They write no events and never sync, so a | ||
| 405 | claim leaves no trace in `refs/collab/*`; the web UI shows the holder on the | ||
| 406 | issue list and its detail page. A claim is a coordination hint with a clock on | ||
| 407 | it, not part of the record. | ||
| 408 | |||
| 362 | ## Trust | 409 | ## Trust |
| 363 | 410 | ||
| 364 | Sync verifies every signature it fetches. Until you add a trusted key, valid | 411 | Sync verifies every signature it fetches. Until you add a trusted key, valid |
docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md
| Old | New | ||
|---|---|---|---|
| @@ -248,11 +248,13 @@ Each phase is independently shippable and gets its own implementation plan | |||
| 248 | (in `docs/superpowers/plans/`) when picked up. Order matters: every phase is | 248 | (in `docs/superpowers/plans/`) when picked up. Order matters: every phase is |
| 249 | useful the day it lands, and none blocks on the companions. | 249 | useful the day it lands, and none blocks on the companions. |
| 250 | 250 | ||
| 251 | 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks. | 251 | 1. **Leases.** ✅ **Done** (`docs/superpowers/plans/2026-09-05-issue-leases.md`). |
| 252 | The agent loop works against the forge as it exists today; humans get | 252 | SQLite lease store, `collab-lease` SSH exec verb (acquire/renew/release/ |
| 253 | `issue claim`. Transport is the `collab-lease` SSH exec verb; no new | 253 | list), `issue claim|unclaim|renew|claims` on the CLI, claims shown in the |
| 254 | auth machinery. (The seam the whole agent story hangs on, and the one | 254 | web UI. Transport is the SSH verb; no new auth machinery. Fencing tokens |
| 255 | thing git structurally cannot express.) | 255 | are stored and reported but not yet *enforced* — nothing server-mediated |
| 256 | exists to fence until phase 3. (The seam the whole agent story hangs on, | ||
| 257 | and the one thing git structurally cannot express.) | ||
| 256 | 2. **Issues and comments to SQLite.** Exec verbs + web UI read/write the | 258 | 2. **Issues and comments to SQLite.** Exec verbs + web UI read/write the |
| 257 | DB; a comment becomes one round trip. Kills the review burden. Includes | 259 | DB; a comment becomes one round trip. Kills the review burden. Includes |
| 258 | a one-time importer that replays existing `refs/collab/*` DAGs into the | 260 | a one-time importer that replays existing `refs/collab/*` DAGs into the |
src/lease.rs
| Old | New | ||
|---|---|---|---|
| @@ -58,6 +58,16 @@ fn emit(value: &serde_json::Value, json: bool, prose: impl FnOnce() -> String) { | |||
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | /// The id to show a human: abbreviated against this repository's issues, as | ||
| 62 | /// every other command does. The server always answers with the full id — | ||
| 63 | /// that is what `--json` passes through, and what a script should hold — but | ||
| 64 | /// prose that printed 40 characters here would be the only place in the CLI | ||
| 65 | /// that did. | ||
| 66 | fn short(repo: &Repository, value: &serde_json::Value, fallback: &str) -> String { | ||
| 67 | let full = value["issue"].as_str().unwrap_or(fallback); | ||
| 68 | crate::abbrev::for_issues(repo).of(full).to_string() | ||
| 69 | } | ||
| 70 | |||
| 61 | fn expiry_note(value: &serde_json::Value) -> String { | 71 | fn expiry_note(value: &serde_json::Value) -> String { |
| 62 | match value["expires_at"].as_str() { | 72 | match value["expires_at"].as_str() { |
| 63 | Some(at) => format!(" (expires {})", at), | 73 | Some(at) => format!(" (expires {})", at), |
| @@ -99,7 +109,7 @@ pub fn claim( | |||
| 99 | emit(&value, json, || { | 109 | emit(&value, json, || { |
| 100 | format!( | 110 | format!( |
| 101 | "Claimed issue {}{}", | 111 | "Claimed issue {}{}", |
| 102 | value["issue"].as_str().unwrap_or(id), | 112 | short(repo, &value, id), |
| 103 | expiry_note(&value) | 113 | expiry_note(&value) |
| 104 | ) | 114 | ) |
| 105 | }); | 115 | }); |
| @@ -162,10 +172,11 @@ pub fn claims(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Er | |||
| 162 | println!("No claims."); | 172 | println!("No claims."); |
| 163 | return Ok(()); | 173 | return Ok(()); |
| 164 | } | 174 | } |
| 175 | let abbrev = crate::abbrev::for_issues(repo); | ||
| 165 | for row in &rows { | 176 | for row in &rows { |
| 166 | println!( | 177 | println!( |
| 167 | "{} {}{}", | 178 | "{} {}{}", |
| 168 | row["issue"].as_str().unwrap_or("?"), | 179 | abbrev.of(row["issue"].as_str().unwrap_or("?")), |
| 169 | row["holder"].as_str().unwrap_or("?"), | 180 | row["holder"].as_str().unwrap_or("?"), |
| 170 | match row["expires_at"].as_str() { | 181 | match row["expires_at"].as_str() { |
| 171 | Some(at) => format!(" expires {}", at), | 182 | Some(at) => format!(" expires {}", at), |
tests/lease_cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -68,6 +68,20 @@ fn claim_then_claims_lists_it() { | |||
| 68 | ); | 68 | ); |
| 69 | // No --ttl, so the claim is an assignment rather than a lease. | 69 | // No --ttl, so the claim is an assignment rather than a lease. |
| 70 | assert!(out(&claims).contains("assigned"), "{}", out(&claims)); | 70 | assert!(out(&claims).contains("assigned"), "{}", out(&claims)); |
| 71 | |||
| 72 | // Prose abbreviates like every other command, even though the server | ||
| 73 | // answers with the full id — which `--json` still carries. This is the | ||
| 74 | // house convention, not an accident of formatting. | ||
| 75 | assert!( | ||
| 76 | !out(&claim).contains(&id), | ||
| 77 | "prose should abbreviate the id, not print all 40 characters: {}", | ||
| 78 | out(&claim) | ||
| 79 | ); | ||
| 80 | assert!( | ||
| 81 | !out(&claims).contains(&id), | ||
| 82 | "prose should abbreviate the id in the claims list: {}", | ||
| 83 | out(&claims) | ||
| 84 | ); | ||
| 71 | } | 85 | } |
| 72 | 86 | ||
| 73 | #[test] | 87 | #[test] |