constitution · epochs · watch · epoch 3

comparison

c_9608dc0d38ab (tommy-mor) vs c_6f04dcb2e38c (tommy-mor)

download prompt · raw event · cmp_de0b2946719d15

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Side A fixes a real deadlock bug (nested RwLock read guard held across match, causing potential deadlock) with a concrete, correct restructuring plus a regression test, and hardens test infra with HTTP timeouts and safer process logging to avoid hangs. Side B is a larger cleanup that removes legacy projection code for clarity/consistency, which is valuable but is more refactor/simplification than a bugfix, and mostly trades one code path for another rather than fixing user-facing correctness issues.

~x-ai/grok-latest · winner A · 3:2 · permalink

A fixes a real tokio RwLock deadlock by scoping read guards so they drop before nested lock acquires in RoomCreate/RoomGrant, and backs that with a room-create RPC test plus harness fixes (log-file redirection to avoid pipe-buffer deadlocks, HTTP timeouts). B is worthwhile cleanup—dropping legacy GitDiscovery projection and forcing Evidence-only pages—but it is largely deletion of transitional UI/API paths and test rewrites after an intentional ledger wipe, so it adds less critical lasting correctness than the concurrency and reliability fixes.

openai/gpt-chat-latest · winner A · 4:1 · permalink

Side A fixes a concrete concurrency bug by ensuring Tokio RwLock read guards are dropped before nested read/write awaits, preventing deadlocks in RPC handlers for room creation and grants, and adds an integration test covering room creation. The remaining changes improve test reliability and align expectations with stored usernames. Side B is primarily a schema/UI cleanup that removes legacy GitDiscovery projection paths and requires evidence-only records, updating tests accordingly; while valuable for maintainability, it mostly deletes compatibility code rather than fixing a core runtime issue.

sides

A — c_9608dc0d38ab (tommy-mor)

message

[9ecc4e2e] nice

diff preview

diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 5675dcd2e28062acbe3c037b94b77c33adf32474..a18241f3ed7b4a248748fcefc799f9801ca62461 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -936,7 +936,15 @@ pub async fn handle_rpc_batch(
                 line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
             }
             RpcCommand::RoomCreate { slug, visibility } => {
-                match verify_bearer_principal(&headers, &*state.reduced.read().await) {
+                // Scope the first read so its guard drops before any nested `read().await` / `write().await`.
+                // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
+                // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
+                let principal = {
+                    let reduced = state.reduced.read().await;
+                    verify_bearer_principal(&headers, &*reduced)
+                };
+                match principal {
+                    Err((_, m)) => line_err(m, None),
                     Ok(principal) => {
                         let slug = slug.trim().to_lowercase();
                         if slug.is_empty() || slug.len() > 64 {
@@ -994,7 +1002,6 @@ pub async fn handle_rpc_batch(
                             }
                         }
                     }
-                    Err((_, m)) => line_err(m, None),
                 }
             }
             RpcCommand::RoomGrant {
@@ -1002,17 +1009,28 @@ pub async fn handle_rpc_batch(
                 username,
                 capability,
             } => {
-                match verify_bearer_principal(&headers, &*state.reduced.read().await) {
+                let principal = {
+                    let reduced = state.reduced.read().await;
+                    verify_bearer_principal(&headers, &*reduced)
+                };
+                match principal {
                     Err((_, m)) => line_err(m, None),
                     Ok(principal) => {
-                        let reduced = state.reduced.read().await;
-                        if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {
+                        let can_manage = {
+                            let reduced = state.reduced.read().await;
+                            reduced.user_has_cap(&room, &principal, ThreadCapability::Manage)
+                        };
+                        if !can_manage {
                             line_err("requires Manage capability", None)
                         } else {
                             match parse_username(&username) {
                                 Err(msg) => line_err("invalid username", Some(msg)),
                                 Ok(target) => {
-                                    if !reduced.users_by_provider.values().any(|u| u == &target) {
+                                    let user_exists = {
+                                        let reduced = state.reduced.read().await;
+                                        reduced.users_by_provider.values().any(|u| u == &target)
+                                    };
+                                    if !user_exists {
                                         line_err(format!("user @{target} not found"), None)
                                     } else {
                                         match parse_capability(&capability) {
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index 9162bd5bee84035e3908bfb9c8e201b7878ca339..b930120da09fe7d307f0411b84fb639fb8bd0b15 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -95,6 +95,23 @@ async fn test_healthz() {
     assert_eq!(response.text().await.unwrap(), "ok");
 }
 
+#[tokio::test]
+async fn test_room_create_private_rpc() {
+    let (addr, _tmp, _log, _handle) = create_test_server().await;
+    let client = reqwest::Client::new();
+    let batch = serde_json::json!([{
+        "RoomCreate": { "slug": "secret-project", "visibility": "private" }
+    }]);
+    let body = rpc_batch(&client, addr, Some(&test_bearer()), batch).await;
+    let line = &body["results"][0];
+    assert_eq!(line["ok"], true, "room create: {:?}", line);
+    let room_id = line["result"]["RoomCreated"]["room_id"].as_str().unwrap();
+    assert!(
+        room_id.contains("/secret-project"),
+        "expected room_id to contain slug, got {room_id}"
+    );
+}
+
 #[tokio::test]
 async fn test_index_page() {
     // HTML routes are offline during the auth-v3 refactor.
diff --git a/test/auth.bb b/test/auth.bb
index 611e04f1806ef81d678a91f499597fe55f691dd7..a67cc1de763434176d2f68e419dd37a8cd328395 100644
--- a/test/auth.bb
+++ b/test/auth.bb
@@ -176,7 +176,7 @@
          (assert! (= 200 (:status poll)) "pending-session poll returns 200")
          (let [poll-json (json/parse-string (:body poll) true)]
            (assert! (:complete poll-json) "pending session complete=true")
-           (assert! (= "@bbuser" (:user poll-json)) "poll returns @bbuser")
+           (assert! (= "bbuser" (:user poll-json)) "poll returns stored username bbuser")
            (assert! (clojure.string/starts-with? (:token poll-json) "slug_") "poll returns bearer token")
 
            (println "\nwhoami…")
@@ -184,7 +184,7 @@
                                :headers {"Authorization" (str "Bearer " (:token poll-json))})]
              (assert! (= 200 (:status who)) "whoami returns 200")
              (let [who-json (json/parse-string (:body who) true)]
-               (assert! (= "@bbuser" (:user who-json)) "whoami user is @bbuser"))))))
+               (assert! (= "bbuser" (:user who-json)) "whoami user is bbuser (stored form)"))))))
 
      (println "\nCLI: identity start → OAuth → identity poll → whoami…")
      (let [cli-home (str tmp-dir "/cli-home")
@@ -208,7 +208,7 @@
                       (str "identity poll exits 0 (stderr: " (:err poll-proc) ")"))
              (let [poll-cli (json/parse-string (:out poll-proc) true)]
                (assert! (= "complete" (:phase poll-cli)) "identity poll --json phase")
-               (assert! (= "@cliuser" (:user poll-cli)) "CLI poll user")
+               (assert! (= "cliuser" (:user poll-cli)) "CLI poll user (stored form)")
                (assert! (clojure.string/starts-with? (:token poll-cli) "slug_") "CLI poll token")
                (let [token-path (str cli-home "/.config/slugsocial/token")]
                  (assert! (fs/exists? token-path) "token written under isolated HOME")
@@ -219,7 +219,7 @@
                  (assert! (zero? (:exit who-proc))
                           (str "whoami exits 0 (stderr: " (:err who-proc) ")"))
                  (let [who-cli (json/parse-string (:out who-proc) true)]
-                   (assert! (= "@cliuser" (:user who-cli)) "CLI whoami uses saved token"))))))))
+                   (assert! (= "cliuser" (:user who-cli)) "CLI whoami uses saved token"))))))))
 
      (finally
        (when-some [s @!server] (common/kill-server s))
diff --git a/test/common.bb b/test/common.bb
index ebb16c615a8b40e8830b5d5765d1e935a45538d0..4ed450377cdbb020f5ff164a812dfbbfc23c0f47 100644
--- a/test/common.bb
+++ b/test/common.bb
@@ -78,9 +78,20 @@
 
 (defn start-server
   "Start the slugsocial-server binary with the given env map.
-   Returns the babashka.process map."
-  [server-bin env-map]
-  (p/process [server-bin] {:out :inherit :err :inherit :env env-map}))
+   Returns the babashka.process map.
+
+   When `log-file` (string path) is provided, stdout and stderr are appended there
+   instead of inheriting the parent descriptors. Inheriting shared pipes while the
+   parent blocks on HTTP I/O can fill the pipe buffer and deadlock the server on log writes."
+  ([server-bin env-map]
+   (start-server server-bin env-map nil))
+  ([server-bin env-map log-file]
+   (p/process [server-bin]
+              (if log-file
+                ;; Two string paths (same file): babashka.process can deref the process cleanly.
+                ;; :err :out + ProcessBuilder$Redirect breaks stream copying in deref/kill-server.
+                {:env env-map :out log-file :err log-file}
+                {:out :inherit :err :inherit :env env-map}))))
 
 (defn kill-server
   "Forcibly kill a server process (babashka.process map) and wait for it to exit."
diff --git a/test/grants.bb b/test/grants.bb
index 7066c1f2a57f39a362052f8524206dccf37bb7b1..793ba216f2de76a77eb20a76d08403db07ff411b 100644
--- a/test/grants.bb
+++ b/test/grants.bb
@@ -35,13 +35,14 @@
 (defn- http-client []
   (-> (java.net.http.HttpClient/newBuilder)
       (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)
+      (.connectTimeout (java.time.Duration/ofSeconds 15))
       (.build)))
 
 (defn- http-get [url & {:keys [headers]}]
   (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
     (doseq [[k v] (or headers {})]
       (.header b k v))
-    (let [req (-> b (.GET) (.build))
+    (let [req (-> b (.timeout (java.time.Duration/ofSeconds 60)) (.GET) (.build))
           resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
       {:status (.statusCode resp) :body (.body resp)})))
 
@@ -52,6 +53,7 @@
     (doseq [[k v] (or headers {})]
       (.header b k v))
     (let [req (-> b
+                  (.timeout (java.time.Duration/ofSeconds 60))
                   (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))
                   (.build))
           resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
@@ -67,6 +69,7 @@
         b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
     (.header b "Content-Type" "application/x-www-form-urlencoded")
     (let [req (-> b
+                  (.timeout (java.time.Duration/ofSeconds 60))
                   (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))
                   (.build))
           resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]

download full diff A

B — c_6f04dcb2e38c (tommy-mor)

message

[bda5f8aa] Remove legacy evidence projection; Evidence envelopes only.

Epoch and commit pages no longer invent history from bare GitDiscovery rows. Production ledger will be wiped to re-emit under the current schema.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/constitution.py b/constitution.py
index 4dd5b9dfbba231d46289c490f91b1dd5b1018bcf..26ba130e885e0e69fb7874ca5c3f07f42100a150 100644
--- a/constitution.py
+++ b/constitution.py
@@ -210,10 +210,10 @@ class Emission:
     distributions: dict   # author -> amount str
     ranking: dict         # author -> score str
     models_used: list
-    discovery_snapshot_id: str = ""  # empty only for pre-discovery ledger history
-    evidence_schema_version: int = 1
-    ranking_run_id: str = ""
-    ranking_event_id: str = ""
+    discovery_snapshot_id: str
+    evidence_schema_version: int
+    ranking_run_id: str
+    ranking_event_id: str
 
 
 @event
@@ -502,26 +502,6 @@ def _epochs_in_ledger() -> list[int]:
     return sorted(epochs)
 
 
-def _legacy_commit_row(commit_id: str) -> tuple[GitDiscovery | None, dict | None]:
-    for discovery in store.read():
-        if not isinstance(discovery, GitDiscovery):
-            continue
-        for commit in discovery.commits:
-            if commit_id_for_oid(commit["oid"]) == commit_id:
-                return discovery, commit
-    return None, None
-
-
-def _legacy_observation(commit_id: str) -> tuple[GitDiscovery | None, dict | None]:
-    for discovery in store.read():
-        if not isinstance(discovery, GitDiscovery):
-            continue
-        for obs in discovery.observations:
-            if commit_id_for_oid(obs["oid"]) == commit_id:
-                return discovery, obs
-    return None, None
-
-
 def build_pairwise_prompt(side_a: dict, side_b: dict) -> str:
     return f"""You are ranking contributions to an open source project.
 Compare these two sides (each may be one or more commits). Decide which side contributed more.
@@ -2040,7 +2020,7 @@ def _strip_heavy_fields(obj: dict) -> dict:
 
 @app.get("/api/ledger")
 async def get_ledger(offset: int = 0, limit: int = 100, full: int = 0):
-    """List of ledger dicts (backward-compatible). Heavy blobs stripped unless full=1."""
+    """List of ledger dicts. Heavy blobs stripped unless full=1."""
     limit = max(1, min(limit, 500))
     rows = []
     for e in store.read()[offset:offset + limit]:
@@ -2274,23 +2254,25 @@ async def epochs_index():
     epochs = _epochs_in_ledger()
     rows = []
     for epoch in epochs:
-        discovery = _discovery_for_epoch(epoch)
         emission = _emission_for_epoch(epoch)
-        evidence_n = sum(
-            1 for e in evidence_by_kind() if e.epoch == epoch
+        evidence_n = sum(1 for e in evidence_by_kind() if e.epoch == epoch)
+        disc = next(
+            (
+                e for e in evidence_by_kind("git.discovery_completed")
+                if e.epoch == epoch
+            ),
+            None,
         )
         detail = []
-        if discovery:
+        if disc:
             detail.append(
-                f"{len(discovery.commits)} eligible / "
-                f"{len(discovery.observations)} observed"
+                f"{disc.payload.get('eligible_count', 0)} eligible / "
+                f"{disc.payload.get('observation_count', 0)} observed"
             )
         if emission:
             detail.append(f"emitted {emission.total_emitted}")
         if evidence_n:
             detail.append(f"{evidence_n} evidence events")
-        elif discovery or emission:
-            detail.append("legacy (no Evidence envelopes)")
         rows.append(["li",
             _a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}"),
             " — ",
@@ -2307,37 +2289,37 @@ async def epochs_index():
 
 @app.get("/epochs/{epoch}")
 async def epoch_detail(epoch: int):
-    discovery = _discovery_for_epoch(epoch)
     emission = _emission_for_epoch(epoch)
     evidence_rows = [e for e in evidence_by_kind() if e.epoch == epoch]
+    if not evidence_rows and emission is None:
+        return _evidence_page(f"epoch {epoch}", [
+            _evidence_nav(),
+            ["h1", f"epoch {epoch}"],
+            ["p.note", "No evidence for this epoch."],
+        ])
+
     commit_evs = [e for e in evidence_rows if e.kind == "git.commit"]
     comparison_evs = [e for e in evidence_rows if e.kind == "comparison.input"]
     judgment_evs = [e for e in evidence_rows if e.kind == "llm.judgment"]
+    discovery_ev = next(
+        (e for e in evidence_rows if e.kind == "git.discovery_completed"), None
+    )
     ranking_started = next(
         (e for e in evidence_rows if e.kind == "ranking.started"), None
     )
     ranking_completed = next(
         (e for e in evidence_rows if e.kind == "ranking.completed"), None
     )
-    legacy = not evidence_rows and (discovery is not None or emission is not None)
-
-    commit_links: list[tuple[str, str]] = []
-    if commit_evs:
-        for e in commit_evs:
-            cid = e.payload.get("commit_id") or ""
-            label = (
-                f"{e.payload.get('oid', cid)[:24]} "
-                f"({e.payload.get('contributor', '?')})"
-            )
-            commit_links.append((label, _evidence_path("commit", cid)))
-    elif discovery:
-        for c in discovery.commits:
-            cid = commit_id_for_oid(c["oid"])
-            commit_links.append((
-                f"{c['oid'][:24]} ({c.get('contributor', '?')})",
-                _evidence_path("commit", cid),
-            ))
 
+    commit_links = [
+        (
+            f"{e.payload.get('oid', e.payload.get('commit_id', ''))[:24]} "
+            f"({e.payload.get('contributor', '?')})",
+            _evidence_path("commit", e.payload["commit_id"]),
+        )
+        for e in commit_evs
+        if e.payload.get("commit_id")
+    ]
     comparison_links = [
         (
             e.payload.get("summary") or e.payload.get("comparison_id", e.event_id),
@@ -2360,16 +2342,13 @@ async def epoch_detail(epoch: int):
     ]
 
     excluded = []
-    if discovery:
-        for obs in discovery.observations:
+    if discovery_ev:
+        for obs in discovery_ev.payload.get("observations") or []:
             if obs.get("eligible"):
                 continue
             oid = obs.get("oid", "?")
             reason = obs.get("exclusion_reason") or "excluded"
-            excluded.append(["li",
-                f"{oid[:28]} — {reason} — ",
-                ["span.note", "legacy evidence unavailable"],
-            ])
+            excluded.append(["li", f"{oid[:28]} — {reason}"])
 
     ranking_nodes: list = []
     if ranking_completed:
@@ -2388,21 +2367,10 @@ async def epoch_detail(epoch: int):
                 indent=2, sort_keys=True,
             )],
         ]
-    elif emission:
-        if legacy and len(emission.ranking or {}) <= 1:
-            ranking_nodes.append(["p.note",
-                "Single-contributor epoch — no LLM judgments."
-            ])
-        ranking_nodes.extend([
-            ["p", "Projected from Emission (no ranking Evidence event)."],
-            ["pre.blob", json.dumps(emission.ranking, indent=2, sort_keys=True)],
-        ])
+    elif ranking_started:
+        ranking_nodes = [["p.note", f"Ranking started: {ranking_started.event_id}"]]
     else:
-        ranking_nodes = [["p.note", "No ranking recorded."]]
-    if ranking_started and not ranking_completed:
-        ranking_nodes.insert(0, ["p.note",
-            f"Ranking started: {ranking_started.event_id}"
-        ])
+        ranking_nodes = [["p.note", "No ranking evidence."]]
 
     if emission:
         emission_node = _dl_rows([
@@ -2411,44 +2379,41 @@ async def epoch_detail(epoch: int):
             ("pool_after", emission.pool_after),
             ("discovery_snapshot_id", emission.discovery_snapshot_id),
             ("ranking_run_id", emission.ranking_run_id or None),
+            ("ranking_event_id", emission.ranking_event_id or None),
             ("models_used", ", ".join(emission.models_used or [])),
             ("distributions", json.dumps(emission.distributions, sort_keys=True)),
         ])
     else:
         emission_node = ["p.note", "No emission for this epoch."]
 
-    single_contributor = False
-    if discovery:
-        single_contributor = len({c.get("contributor") for c in discovery.commits}) <= 1
-    elif emission:
-        single_contributor = len(emission.ranking or {}) <= 1
+    contributors = {
+        e.payload.get("contributor")
+        for e in commit_evs
+        if e.payload.get("contributor")
+    }
+    no_comparisons_note = "No comparisons."
+    if len(contributors) <= 1:
+        no_comparisons_note += " Single-contributor — no LLM judgments."
 
     body = [
         _evidence_nav(),
         ["div.eyebrow", f"epoch {epoch}"],
         ["h1", f"epoch {epoch}"],
     ]
-    if legacy:
-        body.append(["p.note",
-            "Legacy epoch: projected from GitDiscovery/Emission without Evidence "
-            "envelopes. Eligible commits use discovery patches; discarded observation "
-            "metadata is marked legacy evidence unavailable. Single-contributor "
-            "epochs have no LLM judgments."
-        ])
-    if discovery:
+    if discovery_ev:
         body.extend([
             ["h2", "discovery"],
             _dl_rows([
-                ("snapshot_id", discovery.snapshot_id),
-                ("config_digest", discovery.config_digest),
-                ("initial_snapshot", discovery.initial_snapshot),
-                ("observations", len(discovery.observations)),
-                ("eligible", len(discovery.commits)),
+                ("snapshot_id", discovery_ev.payload.get("snapshot_id")),
+                ("config_digest", discovery_ev.payload.get("config_digest")),
+                ("observations", discovery_ev.payload.get("observation_count")),
+                ("eligible", discovery_ev.payload.get("eligible_count")),
+                ("event", _a(
+                    _evidence_path("event", discovery_ev.event_id),
+                    discovery_ev.event_id,
+                )),
             ]),
         ])
-    no_comparisons_note = "No comparisons."
-    if single_contributor:
-        no_comparisons_note += " Single-contributor — no LLM judgments."
     body.extend([
         ["h2", "commits"],
         _link_list(commit_links),
@@ -2471,92 +2436,42 @@ async def epoch_detail(epoch: int):
 @app.get("/commits/{commit_id}")
 async def commit_detail(commit_id: str):
     ev = find_evidence_payload("git.commit", "commit_id", commit_id)
-    discovery, legacy_row = (None, None)
     if not ev:
-        discovery, legacy_row = _legacy_commit_row(commit_id)
-    if not ev and not legacy_row:
-        discovery, obs = _legacy_observation(commit_id)
-        if obs is not None:
-            epoch = discovery.epoch if discovery else "?"
-            return _evidence_page(f"commit {commit_id[:24]}", [
-                _evidence_nav(
-                    _a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}")
-                ),
-                ["div.eyebrow", "commit"],
-                ["h1", commit_id],
-                ["p.note", "legacy evidence unavailable"],
-                _dl_rows([
-                    ("oid", obs.get("oid")),
-                    ("eligible", obs.get("eligible")),
-                    ("exclusion_reason", obs.get("exclusion_reason")),
-                    ("epoch", str(epoch)),
-                ]),
-            ])
         return _evidence_page("commit not found", [
             _evidence_nav(),
             ["h1", "commit not found"],
             ["p", commit_id],
         ])
-    if ev:
-        p = ev.payload
-        epoch = ev.epoch
-        oid = p.get("oid", "")
-        contributor = p.get("contributor", "")
-        message = _blob_text(p.get("message"))
-        patch = _blob_text(p.get("patch"))
-        meta = _dl_rows([
+    p = ev.payload
+    epoch = ev.epoch
+    return _evidence_page(f"commit {commit_id[:24]}", [
+        _evidence_nav(_a(_evidence_path("epoch", str(epoch)), f"epoch {epoch}")),
+        ["div.eyebrow", "commit"],
+        ["h1", commit_id],
+        _dl_rows([
             ("commit_id", commit

… preview truncated; 7,279 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.