a73x

7322c9bb

Abbreviate issue ids in lease output and document claiming work

a73x   2026-09-06 08:14

Commit message
Abbreviate issue ids in lease output and document claiming work

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]