1use anyhow::{bail, Result};
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16
17use crate::ballot_sig;
18use crate::proc_tree;
19
20pub const READS_FILE: &str = ".ljos-reads";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct BallotTrace {
29 pub voter: String,
30 pub harness: String,
31 pub nonce: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CandidateTrace {
38 pub id: String,
39 pub harness: String,
40 pub nonce: String,
41 pub saw: Vec<String>,
42}
43
44#[must_use]
49pub fn session_key(harness: &str, nonce: &str) -> String {
50 let h = harness.trim();
51 if h.is_empty() || h == "-" {
52 let n = nonce.trim();
53 if n.is_empty() || n == "-" {
54 String::new()
55 } else {
56 format!("nonce:{n}")
57 }
58 } else {
59 h.to_string()
60 }
61}
62
63#[must_use]
67pub fn refusal(
68 issue: &str,
69 ballots: &[BallotTrace],
70 candidates: &[CandidateTrace],
71) -> Option<String> {
72 let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
73 for b in ballots {
74 let key = session_key(&b.harness, &b.nonce);
75 if key.is_empty() {
76 continue;
77 }
78 groups.entry(key).or_default().push(b.voter.clone());
79 }
80 let mut authored = Vec::new();
81 for c in candidates {
82 let key = session_key(&c.harness, &c.nonce);
83 if key.is_empty() || !groups.contains_key(&key) {
84 continue;
85 }
86 authored.push(format!(
87 "session {key} authored candidate {} and voted on {issue}",
88 c.id
89 ));
90 }
91 authored.sort();
92 if authored.is_empty() {
93 return None;
94 }
95 Some(format!(
96 "refuses to settle {issue}: {}",
97 authored.join("; ")
98 ))
99}
100
101#[must_use]
103pub fn blind_sentence(id: &str, session: &str, saw: &[String]) -> String {
104 format!(
105 "design {id} is not blind: session {session} read {} before filing it",
106 saw.join(", ")
107 )
108}
109
110#[must_use]
113pub fn blind_lines(candidates: &[CandidateTrace]) -> Vec<String> {
114 let mut out = Vec::new();
115 for c in candidates {
116 if c.saw.is_empty() {
117 continue;
118 }
119 let session = session_key(&c.harness, &c.nonce);
120 let session = if session.is_empty() {
121 c.harness.clone()
122 } else {
123 session
124 };
125 out.push(blind_sentence(&c.id, &session, &c.saw));
126 }
127 out
128}
129
130#[must_use]
132pub fn reads_that_spoil(
133 session: &str,
134 reads: &[(String, String)],
135 others: &[String],
136) -> Vec<String> {
137 let mut saw: Vec<String> = reads
138 .iter()
139 .filter(|(key, issue)| key == session && others.iter().any(|o| o == issue))
140 .map(|(_, issue)| issue.clone())
141 .collect();
142 saw.sort();
143 saw.dedup();
144 saw
145}
146
147fn newest_sig(notes: &[String], voter: &str) -> Option<ballot_sig::SigNote> {
148 notes.iter().find_map(|n| {
149 let note = ballot_sig::parse_note(n)?;
150 (note.voter == voter).then_some(note)
151 })
152}
153
154#[must_use]
157pub fn ballot_traces(ballots: &[Value], notes: &[String]) -> Vec<BallotTrace> {
158 ballots
159 .iter()
160 .filter_map(|b| {
161 let voter = b["agent"].as_str()?.trim().to_string();
162 if voter.is_empty() {
163 return None;
164 }
165 let (harness, nonce) = newest_sig(notes, &voter)
166 .map(|n| (n.harness.unwrap_or_default(), n.nonce.unwrap_or_default()))
167 .unwrap_or_default();
168 Some(BallotTrace {
169 voter,
170 harness,
171 nonce,
172 })
173 })
174 .collect()
175}
176
177fn verified_candidate(note: &str) -> Option<CandidateTrace> {
178 let parsed = ballot_sig::parse_candidate(note)?;
179 if !ballot_sig::candidate_verifies(&parsed) {
180 return None;
181 }
182 Some(CandidateTrace {
183 id: parsed.issue,
184 harness: parsed.harness,
185 nonce: parsed.nonce,
186 saw: parsed.saw,
187 })
188}
189
190#[must_use]
192pub fn candidates_in(notes: &[String]) -> Vec<CandidateTrace> {
193 let mut out = Vec::new();
194 let mut seen = std::collections::BTreeSet::new();
195 for note in notes {
196 if let Some(c) = verified_candidate(note) {
197 if seen.insert(c.id.clone()) {
198 out.push(c);
199 }
200 }
201 }
202 out
203}
204
205fn parent_of(v: &Value) -> Option<String> {
206 v.get("parent")
207 .or_else(|| v.get("PARENT"))
208 .and_then(Value::as_str)
209 .map(str::trim)
210 .filter(|s| !s.is_empty())
211 .map(str::to_string)
212}
213
214fn children_of(issue: &str) -> Vec<String> {
215 let Ok(said) = crate::tracker_listing(&["children", issue, "--json"]) else {
216 return Vec::new();
217 };
218 serde_json::from_str::<Vec<Value>>(&said)
219 .ok()
220 .into_iter()
221 .flatten()
222 .filter_map(|row| row["id"].as_str().map(str::to_string))
223 .collect()
224}
225
226fn family_issue_ids(issue: &str) -> Vec<String> {
227 let mut ids = vec![issue.to_string()];
228 if let Ok(v) = crate::issue_record(issue) {
229 if let Some(p) = parent_of(&v) {
230 ids.push(p);
231 }
232 }
233 let mut extra = Vec::new();
234 for id in &ids {
235 extra.extend(children_of(id));
236 }
237 ids.extend(extra);
238 ids.sort();
239 ids.dedup();
240 ids
241}
242
243fn notes_on(issue: &str) -> Vec<String> {
244 crate::issue_record(issue)
245 .map(|v| ballot_sig::notes_of(&v))
246 .unwrap_or_default()
247}
248
249fn candidate_notes(issue: &str) -> Vec<String> {
252 let mut notes = Vec::new();
253 for id in family_issue_ids(issue) {
254 notes.extend(notes_on(&id));
255 }
256 notes
257}
258
259fn family_notes(issue: &str) -> Vec<(String, String)> {
260 family_issue_ids(issue)
261 .into_iter()
262 .filter(|id| id != issue)
263 .flat_map(|id| {
264 notes_on(&id)
265 .into_iter()
266 .map(move |note| (id.clone(), note))
267 })
268 .collect()
269}
270
271fn withdrawal_cuts(notes: &[String], home: &str) -> BTreeMap<(String, String), usize> {
275 let mut cuts = BTreeMap::new();
276 for (at, note) in notes.iter().enumerate() {
277 if let Some(w) = ballot_sig::parse_withdrawn(note) {
278 let issue = w.issue.unwrap_or_else(|| home.to_string());
279 cuts.entry((issue, w.voter)).or_insert(at);
280 }
281 }
282 cuts
283}
284
285fn withdrawn_at(
286 cuts: &BTreeMap<(String, String), usize>,
287 issue: &str,
288 voter: &str,
289) -> Option<usize> {
290 if issue.is_empty() {
291 let field = ballot_sig::field(voter);
292 return cuts
293 .iter()
294 .find_map(|((_issue, v), at)| (v == voter || v == &field).then_some(*at));
295 }
296 let named = (issue.to_string(), voter.to_string());
297 let field = (issue.to_string(), ballot_sig::field(voter));
298 cuts.get(&named).or_else(|| cuts.get(&field)).copied()
299}
300
301fn same_voter(signed: &str, voter: &str) -> bool {
302 signed == voter.trim() || signed == ballot_sig::field(voter)
303}
304
305fn matching_ballot_issue(
306 issue: &str,
307 voter: &str,
308 own: &[String],
309 family: &[(String, String)],
310) -> Option<String> {
311 if voter.trim().is_empty() {
312 return None;
313 }
314 let mut by_issue: BTreeMap<String, Vec<String>> = BTreeMap::new();
315 for (id, note) in family {
316 by_issue.entry(id.clone()).or_default().push(note.clone());
317 }
318 for (home, notes) in std::iter::once((issue.to_string(), own.to_vec())).chain(by_issue) {
319 let cuts = withdrawal_cuts(¬es, &home);
320 for (at, note) in notes.iter().enumerate() {
321 let Some(sig) = ballot_sig::parse_note(note) else {
322 continue;
323 };
324 if !ballot_sig::verifies(issue, &sig) {
325 continue;
326 }
327 if !same_voter(&sig.voter, voter) {
328 continue;
329 }
330 if withdrawn_at(&cuts, issue, &sig.voter).is_some_and(|cut| cut < at) {
331 continue;
332 }
333 return Some(home);
334 }
335 }
336 None
337}
338
339#[must_use]
341pub fn replace_hint(issue: &str, voter: &str) -> String {
342 let voter = ballot_sig::field(voter);
343 format!(
344 "To replace it, withdraw it and cast again: `ljos vote {issue} --withdraw --as {voter}` (ljos_vote with withdraw), then `ljos vote {issue} --for OPTION --used ... --as {voter}`."
345 )
346}
347
348#[must_use]
356fn session_cast_refusal_on(
357 issue: &str,
358 key: &str,
359 voter: &str,
360 own: &[String],
361 family: &[(String, String)],
362) -> Option<String> {
363 let key = key.trim();
364 if key.is_empty() {
365 return Some(format!(
366 "a ballot on {issue} needs this connection's session. Nothing was cast"
367 ));
368 }
369 let mut family_by_issue: BTreeMap<String, Vec<String>> = BTreeMap::new();
370 for (id, note) in family {
371 family_by_issue
372 .entry(id.clone())
373 .or_default()
374 .push(note.clone());
375 }
376 let signed = own
377 .iter()
378 .enumerate()
379 .map(|(at, note)| (issue, Some(at), note));
380 let elsewhere = family_by_issue.iter().flat_map(|(home, notes)| {
381 notes
382 .iter()
383 .enumerate()
384 .map(move |(at, note)| (home.as_str(), Some(at), note))
385 });
386 for (home, at, note) in signed.chain(elsewhere) {
387 let Some(sig) = ballot_sig::parse_note(note) else {
388 continue;
389 };
390 if !ballot_sig::verifies(issue, &sig) {
391 continue;
392 }
393 let other = session_key(
394 sig.harness.as_deref().unwrap_or(""),
395 sig.nonce.as_deref().unwrap_or(""),
396 );
397 if other != key || !same_voter(&sig.voter, voter) {
400 continue;
401 }
402 let session = if sig.seat_only() {
403 format!("{key}, {}", ballot_sig::unverified_sentence(key))
404 } else {
405 key.to_string()
406 };
407 let cuts = if home == issue {
408 withdrawal_cuts(own, issue)
409 } else {
410 let notes = family_by_issue.get(home).cloned().unwrap_or_default();
411 withdrawal_cuts(¬es, home)
412 };
413 if at.is_some_and(|at| withdrawn_at(&cuts, issue, &sig.voter).is_some_and(|cut| cut < at)) {
414 continue;
415 }
416 return Some(format!(
417 "this session already cast a ballot on {issue} ({session}): {}'s, for {}. Nothing was cast. {}",
418 sig.voter,
419 sig.choice,
420 replace_hint(issue, voter)
421 ));
422 }
423 for note in own.iter().chain(family.iter().map(|(_, note)| note)) {
424 let Some(candidate) = ballot_sig::parse_candidate(note) else {
425 continue;
426 };
427 let other = session_key(&candidate.harness, &candidate.nonce);
428 if other == key {
429 return Some(format!(
430 "this session authored candidate {} and its vote on {issue} is refused. Nothing was cast.",
431 candidate.issue
432 ));
433 }
434 }
435 None
436}
437
438#[must_use]
441pub fn session_cast_refusal(
442 issue: &str,
443 key: &str,
444 voter: &str,
445 own: &[String],
446 family: &[(String, String)],
447) -> Option<String> {
448 let family: Vec<(String, String)> = family
449 .iter()
450 .filter(|(_, note)| !own.contains(note))
451 .cloned()
452 .collect();
453 session_cast_refusal_on(issue, key, voter, own, &family)
454}
455
456#[must_use]
459pub fn standing_ballot_issue(issue: &str, voter: &str) -> Option<String> {
460 let own = notes_on(issue);
461 let family = family_notes(issue);
462 matching_ballot_issue(issue, voter, &own, &family)
463}
464
465static CAST: std::sync::Mutex<std::collections::BTreeSet<String>> =
466 std::sync::Mutex::new(std::collections::BTreeSet::new());
467
468#[derive(Debug, Default, Clone, PartialEq, Eq)]
473struct CastTree {
474 shell: Option<String>,
475 harness: Option<String>,
476}
477
478impl CastTree {
479 fn here() -> Self {
484 let mine = proc_tree::exe(std::process::id());
485 if !mine
486 .as_ref()
487 .is_some_and(|path| proc_tree::is_ljos_exe(path))
488 {
489 return Self::default();
490 }
491 let chain = proc_tree::ancestors();
492 Self {
493 shell: proc_tree::cast_shell(&chain, mine.as_deref()).map(proc_tree::token),
494 harness: crate::subagent::taken_harness().map(|p| proc_tree::token(&p)),
495 }
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
504struct TreeRow {
505 shell: String,
506 harness: String,
507 session: String,
508 state: String,
509}
510
511const CAST_STATE: &str = "cast";
512
513impl TreeRow {
514 fn parse(line: &str) -> Option<Self> {
515 let mut f = line.split('\t');
516 let row = Self {
517 shell: f.next()?.to_string(),
518 harness: f.next()?.to_string(),
519 session: f.next()?.to_string(),
520 state: f.next()?.to_string(),
521 };
522 f.next().is_none().then_some(row)
523 }
524
525 fn line(&self) -> String {
526 format!(
527 "{}\t{}\t{}\t{}",
528 self.shell, self.harness, self.session, self.state
529 )
530 }
531
532 fn live(&self) -> bool {
535 let runs = |t: &str| t != "-" && proc_tree::token_alive(t);
536 (runs(&self.shell) || runs(&self.harness))
537 && (self.state == CAST_STATE || runs(&self.state))
538 }
539
540 fn binds(&self, key: &str, tree: &CastTree) -> Option<(&'static str, String)> {
547 let same = |mine: &Option<String>, theirs: &str| mine.as_deref() == Some(theirs);
548 if same(&tree.shell, &self.shell) && self.session != key {
549 Some(("shell", self.session.clone()))
550 } else if same(&tree.harness, &self.harness)
551 && session_root(&self.session) != session_root(key)
552 {
553 Some(("harness", self.session.clone()))
554 } else {
555 None
556 }
557 }
558}
559
560fn session_root(key: &str) -> &str {
561 key.split(':').next().unwrap_or(key)
562}
563
564fn voice_of(
565 why: &str,
566 session: &str,
567 claimed: &ballot_sig::SessionIdentity,
568) -> ballot_sig::VoiceBind {
569 let (harness, nonce) = session
570 .strip_prefix("nonce:")
571 .map(|nonce| ("-".to_string(), nonce.to_string()))
572 .unwrap_or_else(|| (session.to_string(), "-".to_string()));
573 ballot_sig::VoiceBind {
574 harness,
575 nonce,
576 claimed_harness: claimed.harness.clone(),
577 claimed_nonce: claimed.nonce.clone(),
578 why: why.to_string(),
579 }
580}
581
582fn bound_of(
583 rows: &[TreeRow],
584 key: &str,
585 tree: &CastTree,
586 claimed: &ballot_sig::SessionIdentity,
587) -> Option<ballot_sig::VoiceBind> {
588 let (why, session) = rows.iter().find_map(|row| row.binds(key, tree))?;
589 Some(voice_of(why, &session, claimed))
590}
591
592pub(crate) fn peek_bound(
596 issue: &str,
597 claimed: &ballot_sig::SessionIdentity,
598) -> Option<ballot_sig::VoiceBind> {
599 let tree = CastTree::here();
600 if tree.shell.is_none() && tree.harness.is_none() {
601 return None;
602 }
603 let key = session_key(&claimed.harness, &claimed.nonce);
604 let text = std::fs::read_to_string(tree_path(issue)).unwrap_or_default();
605 let rows: Vec<TreeRow> = text
606 .lines()
607 .filter_map(TreeRow::parse)
608 .filter(TreeRow::live)
609 .collect();
610 bound_of(&rows, &key, &tree, claimed)
611}
612
613fn tree_path(issue: &str) -> PathBuf {
616 use sha2::{Digest, Sha256};
617 let tracker = std::env::var_os("VISSUE_ROOT").unwrap_or_default();
618 let tracker = hex::encode(Sha256::digest(tracker.as_encoded_bytes()));
619 crate::runtime_dir()
620 .join("ballot-trees")
621 .join(&tracker[..16])
622 .join(crate::subagent::token(issue))
623}
624
625fn edit_rows<R>(path: &Path, edit: impl FnOnce(&mut Vec<TreeRow>) -> Result<R>) -> Result<R> {
628 let _durable = crate::bookkeep::durable_write();
629 if let Some(dir) = path.parent() {
630 std::fs::create_dir_all(dir)?;
631 }
632 let _lock = crate::CommitLock::acquire(&path.with_extension("lock"));
633 let text = std::fs::read_to_string(path).unwrap_or_default();
634 let mut rows: Vec<TreeRow> = text
635 .lines()
636 .filter_map(TreeRow::parse)
637 .filter(TreeRow::live)
638 .collect();
639 let out = edit(&mut rows)?;
640 let body: String = rows.iter().map(|r| r.line() + "\n").collect();
641 let tmp = path.with_extension(format!("tmp{}", std::process::id()));
642 std::fs::write(&tmp, body)?;
643 std::fs::rename(&tmp, path)?;
644 Ok(out)
645}
646
647#[derive(Debug)]
649struct TreeHold {
650 path: PathBuf,
651 row: TreeRow,
652}
653
654fn hold_tree(
658 issue: &str,
659 claimed: &ballot_sig::SessionIdentity,
660 tree: &CastTree,
661) -> Result<Option<TreeHold>> {
662 if tree.shell.is_none() && tree.harness.is_none() {
663 ballot_sig::clear_voice_bind();
664 return Ok(None);
665 }
666 let key = session_key(&claimed.harness, &claimed.nonce);
667 let me =
668 proc_tree::of(std::process::id()).map_or_else(|| "-".to_string(), |p| proc_tree::token(&p));
669 let path = tree_path(issue);
670 let row = edit_rows(&path, |rows| {
671 let bound = bound_of(rows, &key, tree, claimed);
672 let session = bound
673 .as_ref()
674 .map(|bind| session_key(&bind.harness, &bind.nonce))
675 .filter(|session| !session.is_empty())
676 .unwrap_or_else(|| key.clone());
677 if let Some(bound) = bound {
678 ballot_sig::set_voice_bind(bound);
679 } else {
680 ballot_sig::clear_voice_bind();
681 }
682 let row = TreeRow {
683 shell: tree.shell.clone().unwrap_or_else(|| "-".into()),
684 harness: tree.harness.clone().unwrap_or_else(|| "-".into()),
685 session,
686 state: me,
687 };
688 rows.push(row.clone());
689 Ok(row)
690 })?;
691 Ok(Some(TreeHold { path, row }))
692}
693
694impl TreeHold {
695 fn settle(&self, kept: bool) {
697 let _ = edit_rows(&self.path, |rows| {
698 if let Some(at) = rows.iter().position(|r| *r == self.row) {
699 if kept {
700 rows[at].state = CAST_STATE.into();
701 } else {
702 rows.remove(at);
703 }
704 }
705 Ok(())
706 });
707 }
708}
709
710#[derive(Debug)]
714pub struct SessionBallot {
715 mark: String,
716 kept: bool,
717 tree: Option<TreeHold>,
718}
719
720impl SessionBallot {
721 pub fn keep(mut self) {
723 self.kept = true;
724 }
725}
726
727impl Drop for SessionBallot {
728 fn drop(&mut self) {
729 if let Some(tree) = &self.tree {
730 tree.settle(self.kept);
731 }
732 if !self.kept {
735 ballot_sig::end_cast();
736 }
737 if self.kept {
738 return;
739 }
740 if let Ok(mut held) = CAST.lock() {
741 held.remove(&self.mark);
742 }
743 }
744}
745
746fn held_choice(issue: &str, voter: &str) -> Option<String> {
748 crate::raw_ballot_rows(issue)
749 .ok()?
750 .iter()
751 .find(|row| {
752 row["agent"]
753 .as_str()
754 .is_some_and(|agent| same_voter(agent.trim(), voter))
755 })
756 .map(|row| row["choice"].as_str().unwrap_or("").trim().to_string())
757}
758
759pub fn claim_session_ballot(issue: &str, voter: &str) -> Result<SessionBallot> {
769 ballot_sig::begin_cast(issue);
770 let claimed = ballot_sig::claimed_identity();
771 let id = ballot_sig::identity();
772 let key = session_key(&id.harness, &id.nonce);
773 let own = notes_on(issue);
774 let family = family_notes(issue);
775 if let Some(why) = session_cast_refusal_on(issue, &key, voter, &own, &family) {
776 ballot_sig::end_cast();
777 bail!("{why}");
778 }
779 if let Some(choice) = held_choice(issue, voter) {
780 ballot_sig::end_cast();
781 bail!(
782 "{voter} already holds a ballot on {issue} for {choice}. Nothing was cast. {}",
783 replace_hint(issue, voter)
784 );
785 }
786 let mark = format!("{}\n{voter}", issue.trim());
787 let mut held = CAST.lock().unwrap_or_else(|e| e.into_inner());
788 if !held.insert(mark.clone()) {
789 ballot_sig::end_cast();
790 bail!(
791 "{voter} already cast a ballot on {issue} in this process. Nothing was cast. {}",
792 replace_hint(issue, voter)
793 );
794 }
795 drop(held);
796 let mut ballot = SessionBallot {
797 mark,
798 kept: false,
799 tree: None,
800 };
801 ballot.tree = hold_tree(issue, &claimed, &CastTree::here())?;
802 Ok(ballot)
803}
804
805#[must_use]
809pub fn settle_refusal(issue: &str) -> Option<String> {
810 let ballots = crate::ballot_rows(issue);
811 if ballots.is_empty() {
812 return None;
813 }
814 let notes = crate::issue_record(issue)
815 .map(|v| ballot_sig::notes_of(&v))
816 .unwrap_or_default();
817 let traces = ballot_traces(&ballots, ¬es);
818 if traces
819 .iter()
820 .all(|t| session_key(&t.harness, &t.nonce).is_empty())
821 {
822 return None;
823 }
824 let candidates = candidates_in(&candidate_notes(issue));
825 refusal(issue, &traces, &candidates)
826}
827
828#[must_use]
830pub fn blind_lines_for(issue: &str) -> Vec<String> {
831 blind_lines(&candidates_in(&candidate_notes(issue)))
832}
833
834fn author_sessions(issue: &str) -> std::collections::BTreeSet<String> {
837 candidates_in(&candidate_notes(issue))
838 .into_iter()
839 .map(|c| session_key(&c.harness, &c.nonce))
840 .filter(|key| !key.is_empty())
841 .collect()
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct IgnoredBallot {
847 pub voter: String,
848 pub session: String,
849 pub why: &'static str,
851 pub unverified: bool,
853}
854
855struct Tally {
858 counted: Vec<Value>,
859 ignored: Vec<IgnoredBallot>,
860 folds: Vec<String>,
861}
862
863struct VoiceLink {
866 keys: Vec<String>,
867 parents: Vec<String>,
868 links: Vec<(String, String)>,
871 newest: usize,
872}
873
874fn voice_links(sigs: &[(bool, ballot_sig::SigNote)]) -> BTreeMap<String, VoiceLink> {
875 let mut links = BTreeMap::new();
876 for (at, (standing, sig)) in sigs.iter().enumerate() {
877 if !standing {
878 continue;
879 }
880 let harness = sig.harness.clone().unwrap_or_default();
881 let nonce = sig.nonce.clone().unwrap_or_default();
882 let key = session_key(&harness, &nonce);
883 let parent = ballot_sig::parent_session(&harness);
884 let link = links.entry(sig.voter.clone()).or_insert_with(|| VoiceLink {
885 keys: Vec::new(),
886 parents: Vec::new(),
887 links: Vec::new(),
888 newest: at,
889 });
890 if !key.is_empty() && !link.keys.contains(&key) {
891 link.keys.push(key);
892 }
893 if !parent.is_empty() && !link.parents.contains(&parent) {
894 link.parents.push(parent);
895 }
896 if let Some(why) = sig
897 .link
898 .clone()
899 .filter(|why| why == "shell" || why == "harness")
900 {
901 let voice = sig.harness.clone().unwrap_or_default();
902 let pair = (why, voice);
903 if !pair.1.is_empty() && !link.links.contains(&pair) {
904 link.links.push(pair);
905 }
906 }
907 }
908 links
909}
910
911fn find_voice(uf: &mut [usize], mut i: usize) -> usize {
912 while uf[i] != i {
913 uf[i] = uf[uf[i]];
914 i = uf[i];
915 }
916 i
917}
918
919fn unite_voice(uf: &mut [usize], a: usize, b: usize) {
920 let a = find_voice(uf, a);
921 let b = find_voice(uf, b);
922 if a != b {
923 uf[b] = a;
924 }
925}
926
927fn remember_link(at: &mut BTreeMap<String, usize>, uf: &mut [usize], i: usize, name: &str) {
928 if let Some(j) = at.get(name).copied() {
929 unite_voice(uf, i, j);
930 } else {
931 at.insert(name.to_string(), i);
932 }
933}
934
935fn fold_why(members: &[usize], rows: &[Value], links: &BTreeMap<String, VoiceLink>) -> String {
938 let mut keys = std::collections::BTreeSet::new();
939 let mut parents = std::collections::BTreeSet::new();
940 let mut shells = std::collections::BTreeSet::new();
941 let mut harnesses = std::collections::BTreeSet::new();
942 for &i in members {
943 let voter = rows[i]["agent"].as_str().unwrap_or("").trim();
944 if let Some(link) = links.get(voter) {
945 keys.extend(link.keys.iter().cloned());
946 parents.extend(link.parents.iter().cloned());
947 for (why, voice) in &link.links {
948 if why == "shell" {
949 shells.insert(voice.clone());
950 } else if why == "harness" {
951 harnesses.insert(voice.clone());
952 }
953 }
954 }
955 }
956 if !shells.is_empty() {
957 return format!(
958 "same shell {}",
959 shells.into_iter().collect::<Vec<_>>().join(", ")
960 );
961 }
962 if !harnesses.is_empty() {
963 return format!(
964 "same harness {}",
965 harnesses.into_iter().collect::<Vec<_>>().join(", ")
966 );
967 }
968 if keys.len() == 1 {
969 let key = keys.iter().next().map(String::as_str).unwrap_or("");
970 format!("same session {key}")
971 } else if !parents.is_empty() {
972 format!(
973 "same parent {}",
974 parents.into_iter().collect::<Vec<_>>().join(", ")
975 )
976 } else {
977 format!(
978 "same session {}",
979 keys.into_iter().collect::<Vec<_>>().join(", ")
980 )
981 }
982}
983
984fn fold_one_voice(
988 rows: Vec<Value>,
989 links: &BTreeMap<String, VoiceLink>,
990) -> (Vec<Value>, Vec<String>) {
991 let n = rows.len();
992 let mut uf: Vec<usize> = (0..n).collect();
993 let mut key_at = BTreeMap::new();
994 let mut parent_at = BTreeMap::new();
995 for (i, row) in rows.iter().enumerate() {
996 let voter = row["agent"].as_str().unwrap_or("").trim();
997 let Some(link) = links.get(voter) else {
998 continue;
999 };
1000 for key in &link.keys {
1001 remember_link(&mut key_at, &mut uf, i, key);
1002 }
1003 for parent in &link.parents {
1004 remember_link(&mut parent_at, &mut uf, i, parent);
1005 }
1006 }
1007 let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1008 for i in 0..n {
1009 groups.entry(find_voice(&mut uf, i)).or_default().push(i);
1010 }
1011 let mut drop = std::collections::BTreeSet::new();
1012 let mut lines = Vec::new();
1013 for members in groups.values() {
1014 if members.len() < 2 {
1015 continue;
1016 }
1017 let keeper = members
1018 .iter()
1019 .copied()
1020 .min_by_key(|&i| {
1021 let voter = rows[i]["agent"].as_str().unwrap_or("").trim();
1022 links
1023 .get(voter)
1024 .map(|link| link.newest)
1025 .unwrap_or(usize::MAX)
1026 })
1027 .unwrap_or(members[0]);
1028 let mut names = Vec::new();
1029 for &i in members {
1030 let voter = rows[i]["agent"].as_str().unwrap_or("").trim().to_string();
1031 if i != keeper {
1032 drop.insert(i);
1033 }
1034 names.push(voter);
1035 }
1036 names.sort();
1037 let counted = rows[keeper]["agent"].as_str().unwrap_or("").trim();
1038 let why = fold_why(members, &rows, links);
1039 lines.push(format!(
1040 "folded into one voice ({why}): {}",
1041 names.join(", ")
1042 ));
1043 lines.push(format!("counted {counted}"));
1044 }
1045 let counted = rows
1046 .into_iter()
1047 .enumerate()
1048 .filter(|(i, _)| !drop.contains(i))
1049 .map(|(_, row)| row)
1050 .collect();
1051 (counted, lines)
1052}
1053
1054#[must_use]
1062pub fn tally_of(
1063 issue: &str,
1064 rows: &[Value],
1065 notes: &[String],
1066 authors: &std::collections::BTreeSet<String>,
1067) -> (Vec<Value>, Vec<IgnoredBallot>) {
1068 let tally = tally_voices(issue, rows, notes, authors);
1069 (tally.counted, tally.ignored)
1070}
1071
1072fn tally_voices(
1073 issue: &str,
1074 rows: &[Value],
1075 notes: &[String],
1076 authors: &std::collections::BTreeSet<String>,
1077) -> Tally {
1078 let cuts = withdrawal_cuts(notes, issue);
1079 let sigs: Vec<(bool, ballot_sig::SigNote)> = notes
1080 .iter()
1081 .enumerate()
1082 .filter_map(|(at, n)| {
1083 let sig = ballot_sig::parse_note(n)?;
1084 let standing = withdrawn_at(&cuts, issue, &sig.voter).is_none_or(|cut| at < cut);
1085 Some((standing, sig))
1086 })
1087 .collect();
1088 let mut bound: BTreeMap<String, (String, Option<String>, bool)> = BTreeMap::new();
1090 for (standing, sig) in sigs.iter().rev() {
1091 let key = session_key(
1092 sig.harness.as_deref().unwrap_or(""),
1093 sig.nonce.as_deref().unwrap_or(""),
1094 );
1095 if key.is_empty() {
1096 continue;
1097 }
1098 let entry = bound
1099 .entry(sig.voter.clone())
1100 .or_insert_with(|| (key, None, sig.seat_only()));
1101 if entry.1.is_none() && *standing {
1102 entry.1 = Some(sig.choice.clone());
1103 }
1104 }
1105 let mut counted = Vec::new();
1106 let mut ignored = Vec::new();
1107 let mut seen = std::collections::BTreeSet::new();
1108 for row in rows {
1109 let voter = row["agent"].as_str().unwrap_or("").trim();
1110 if voter.is_empty() || !seen.insert(voter.to_string()) {
1111 continue;
1112 }
1113 let any_standing = sigs
1114 .iter()
1115 .any(|(standing, sig)| *standing && sig.voter == voter);
1116 if withdrawn_at(&cuts, issue, voter).is_some() && !any_standing {
1117 continue;
1118 }
1119 if let Some((key, oldest, seat_only)) = bound.get(voter) {
1120 if authors.contains(key) {
1121 ignored.push(IgnoredBallot {
1122 voter: voter.to_string(),
1123 session: key.clone(),
1124 why: "authored a candidate",
1125 unverified: *seat_only,
1126 });
1127 continue;
1128 }
1129 let Some(choice) = oldest.clone() else {
1130 continue;
1131 };
1132 let drawer = row["choice"].as_str().unwrap_or("").trim();
1133 let signed = sigs
1134 .iter()
1135 .any(|(standing, sig)| *standing && sig.voter == voter && sig.choice == drawer);
1136 if !signed {
1137 continue;
1138 }
1139 let mut row = row.clone();
1140 if let Some(obj) = row.as_object_mut() {
1141 obj.insert("choice".into(), Value::String(choice));
1142 }
1143 counted.push(row);
1144 continue;
1145 }
1146 counted.push(row.clone());
1147 }
1148 let links = voice_links(&sigs);
1149 let (counted, folds) = fold_one_voice(counted, &links);
1150 Tally {
1151 counted,
1152 ignored,
1153 folds,
1154 }
1155}
1156
1157#[must_use]
1159pub fn counted_ballots(issue: &str, rows: &[Value]) -> Vec<Value> {
1160 let notes = crate::issue_record(issue)
1161 .map(|v| ballot_sig::notes_of(&v))
1162 .unwrap_or_default();
1163 tally_of(issue, rows, ¬es, &author_sessions(issue)).0
1164}
1165
1166fn seat_only_voters(notes: &[String]) -> BTreeMap<String, String> {
1169 let mut first = BTreeMap::new();
1170 for sig in notes.iter().rev().filter_map(|n| ballot_sig::parse_note(n)) {
1171 if sig.harness.is_none() {
1172 continue;
1173 }
1174 let seat = sig
1175 .seat_only()
1176 .then(|| sig.harness.clone().unwrap_or_default());
1177 first.entry(sig.voter).or_insert(seat);
1178 }
1179 first
1180 .into_iter()
1181 .filter_map(|(voter, seat)| Some((voter, seat?)))
1182 .collect()
1183}
1184
1185pub fn tally_report(issue: &str) -> Result<String> {
1193 let raw = crate::raw_ballot_rows(issue)?;
1194 let notes = crate::issue_record(issue)
1195 .map(|v| ballot_sig::notes_of(&v))
1196 .unwrap_or_default();
1197 let tally = tally_voices(issue, &raw, ¬es, &author_sessions(issue));
1198 let counted = &tally.counted;
1199 let ignored = &tally.ignored;
1200 let seat_only = seat_only_voters(¬es);
1201 let mut unverified = std::collections::BTreeSet::new();
1202 let mut out = String::new();
1203 let n = counted.len();
1204 let word = if n == 1 {
1205 "ballot counts"
1206 } else {
1207 "ballots count"
1208 };
1209 out.push_str(&format!("{issue}: {n} {word}\n"));
1210 for row in counted {
1211 let agent = row["agent"].as_str().unwrap_or("").trim();
1212 let choice = row["choice"].as_str().unwrap_or("").trim();
1213 let raw_choice = raw
1214 .iter()
1215 .find(|r| r["agent"].as_str().map(str::trim) == Some(agent))
1216 .and_then(|r| r["choice"].as_str())
1217 .unwrap_or("");
1218 let mut tags = Vec::new();
1219 if !raw_choice.is_empty() && raw_choice != choice {
1220 tags.push("a later ballot on this session is ignored");
1221 }
1222 if let Some(seat) = seat_only.get(agent) {
1223 unverified.insert(seat.clone());
1224 tags.push("unverified session");
1225 }
1226 if tags.is_empty() {
1227 out.push_str(&format!(" {agent} {choice}\n"));
1228 } else {
1229 out.push_str(&format!(" {agent} {choice} ({})\n", tags.join("; ")));
1230 }
1231 }
1232 if !ignored.is_empty() {
1233 out.push_str("ignored, the session filed a candidate:\n");
1234 for item in ignored {
1235 let unverified_tag = if item.unverified {
1236 unverified.insert(item.session.clone());
1237 ", unverified"
1238 } else {
1239 ""
1240 };
1241 out.push_str(&format!(
1242 " {} {} ({}{unverified_tag})\n",
1243 item.voter, item.session, item.why
1244 ));
1245 }
1246 }
1247 for seat in &unverified {
1248 out.push_str(&format!("{}\n", ballot_sig::unverified_sentence(seat)));
1249 }
1250 out.push_str(&provenance_text(¬es, &raw));
1251 for line in &tally.folds {
1252 out.push_str(line);
1253 out.push('\n');
1254 }
1255 Ok(out)
1256}
1257
1258fn provenance_of(notes: &[String], voter: &str) -> Option<(String, String)> {
1261 if let Some((caster, persona)) = ballot_sig::parse_caster_persona_voter(voter) {
1262 for note in notes {
1263 if let Some((named, from, parent)) = ballot_sig::parse_from(note) {
1264 if named == persona && from == caster && parent != "-" {
1265 return Some((from, parent));
1266 }
1267 }
1268 }
1269 }
1270 for note in notes {
1271 if let Some((persona, caster, parent)) = ballot_sig::parse_from(note) {
1272 if persona == voter && parent != "-" {
1273 return Some((caster, parent));
1274 }
1275 }
1276 }
1277 let mut found = None;
1278 for note in notes.iter().rev() {
1279 let Some(sig) = ballot_sig::parse_note(note) else {
1280 continue;
1281 };
1282 if sig.voter != voter {
1283 continue;
1284 }
1285 if found.is_some() {
1286 continue;
1287 }
1288 let harness = sig.harness.clone().unwrap_or_default();
1289 let parent = ballot_sig::parent_session(&harness);
1290 if parent.is_empty() {
1291 continue;
1292 }
1293 let caster = if harness.is_empty() {
1294 parent.clone()
1295 } else {
1296 harness
1297 };
1298 found = Some((caster, parent));
1299 }
1300 found
1301}
1302
1303#[must_use]
1306pub fn provenance_text(notes: &[String], rows: &[Value]) -> String {
1307 let mut lines = Vec::new();
1308 let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
1309 for row in rows {
1310 let voter = row["agent"].as_str().unwrap_or("").trim();
1311 if voter.is_empty() {
1312 continue;
1313 }
1314 let Some((caster, parent)) = provenance_of(notes, voter) else {
1315 continue;
1316 };
1317 let label = ballot_sig::parse_caster_persona_voter(voter)
1318 .map(|(_, persona)| persona)
1319 .unwrap_or_else(|| voter.to_string());
1320 lines.push(format!(" {label} cast by {caster}, parent {parent}"));
1321 groups.entry(parent).or_default().push(label);
1322 }
1323 if lines.is_empty() {
1324 return String::new();
1325 }
1326 let mut out = String::from("provenance:\n");
1327 for line in lines {
1328 out.push_str(&line);
1329 out.push('\n');
1330 }
1331 for (parent, mut names) in groups {
1332 if names.len() < 2 {
1333 continue;
1334 }
1335 names.sort();
1336 names.dedup();
1337 out.push_str(&format!("shared parent {parent}: {}\n", names.join(", ")));
1338 }
1339 out
1340}
1341
1342#[must_use]
1345pub fn panel_provenance(issue: &str) -> String {
1346 let notes = crate::issue_record(issue)
1347 .map(|v| ballot_sig::notes_of(&v))
1348 .unwrap_or_default();
1349 let raw = crate::raw_ballot_rows(issue).unwrap_or_default();
1350 let mut out = provenance_text(¬es, &raw);
1351 let tally = tally_voices(issue, &raw, ¬es, &author_sessions(issue));
1352 for line in tally.folds {
1353 out.push_str(&line);
1354 out.push('\n');
1355 }
1356 out
1357}
1358
1359fn reads_path() -> Option<PathBuf> {
1360 let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
1361 Some(PathBuf::from(root).join(READS_FILE))
1362}
1363
1364pub fn note_tracker_read(issue: &str) {
1367 let issue = ballot_sig::field(issue);
1368 if issue == "-" {
1369 return;
1370 }
1371 let Some(path) = reads_path() else {
1372 return;
1373 };
1374 let id = ballot_sig::identity();
1375 let line = format!(
1376 "{} {} {} {issue}\n",
1377 crate::now_utc(),
1378 ballot_sig::field(&id.harness),
1379 ballot_sig::field(&id.nonce)
1380 );
1381 if let Some(dir) = path.parent() {
1382 let _ = std::fs::create_dir_all(dir);
1383 }
1384 use std::io::Write;
1385 let _ = std::fs::OpenOptions::new()
1386 .create(true)
1387 .append(true)
1388 .open(path)
1389 .and_then(|mut f| f.write_all(line.as_bytes()));
1390}
1391
1392#[must_use]
1394pub fn read_log(path: &Path) -> Vec<(String, String)> {
1395 let Ok(text) = std::fs::read_to_string(path) else {
1396 return Vec::new();
1397 };
1398 text.lines()
1399 .filter_map(|line| {
1400 let mut parts = line.split_whitespace();
1401 let _time = parts.next()?;
1402 let harness = parts.next()?;
1403 let nonce = parts.next()?;
1404 let issue = parts.next()?;
1405 Some((session_key(harness, nonce), issue.to_string()))
1406 })
1407 .collect()
1408}
1409
1410fn tag_list(v: &Value, key: &str) -> Vec<String> {
1411 match &v[key] {
1412 Value::Array(items) => items
1413 .iter()
1414 .filter_map(|t| t.as_str().map(str::to_string))
1415 .collect(),
1416 Value::String(s) => s.split(',').map(|t| t.trim().to_string()).collect(),
1417 _ => Vec::new(),
1418 }
1419}
1420
1421fn issue_title_and_tags(issue: &str) -> (String, String) {
1422 let Ok(v) = crate::issue_record(issue) else {
1423 return (String::new(), String::new());
1424 };
1425 let title = v["title"].as_str().unwrap_or("").to_string();
1426 let mut tags = tag_list(&v, "tags");
1427 tags.extend(tag_list(&v, "org_tags"));
1428 (title, tags.join(","))
1429}
1430
1431#[must_use]
1434pub fn is_design_candidate(title: &str, tags: Option<&str>) -> bool {
1435 let t = title.trim().to_ascii_lowercase();
1436 t.starts_with("design:")
1437 || t.starts_with("candidate:")
1438 || tags
1439 .unwrap_or("")
1440 .split(',')
1441 .any(|tag| tag.trim() == "stage-design")
1442}
1443
1444fn sibling_designs(parent: Option<&str>, self_id: &str) -> Vec<String> {
1445 let Some(parent) = parent.map(str::trim).filter(|s| !s.is_empty()) else {
1446 return Vec::new();
1447 };
1448 children_of(parent)
1449 .into_iter()
1450 .filter(|id| {
1451 if id == self_id {
1452 return false;
1453 }
1454 let (title, tags) = issue_title_and_tags(id);
1455 is_design_candidate(&title, Some(tags.as_str()))
1456 })
1457 .collect()
1458}
1459
1460pub fn record_candidate(
1468 id: &str,
1469 title: &str,
1470 parent: Option<&str>,
1471 tags: Option<&str>,
1472) -> Result<String> {
1473 if !is_design_candidate(title, tags) {
1474 return Ok(String::new());
1475 }
1476 let ident = ballot_sig::identity();
1477 let key = session_key(&ident.harness, &ident.nonce);
1478 let others = sibling_designs(parent, id);
1479 let reads = reads_path().map(|p| read_log(&p)).unwrap_or_default();
1480 let saw = reads_that_spoil(&key, &reads, &others);
1481 let seat = crate::seat_name();
1482 let note = ballot_sig::sign_candidate(id, &seat, &ident, &saw)?;
1483 crate::run_captured("vissue", &["note", id, ¬e])?;
1484 let mut out = format!("candidate {id} bound to session {key}\n");
1485 if !saw.is_empty() {
1486 let line = blind_sentence(id, &key, &saw);
1487 let _ = crate::run_captured(
1488 "vissue",
1489 &["note", id, &format!("design-seen: {id} {}", saw.join(" "))],
1490 );
1491 out.push_str(&format!("blind design: {line}\n"));
1492 }
1493 Ok(out)
1494}
1495
1496#[must_use]
1498pub fn harness_argv(harness: &str, prompt: &str) -> Vec<String> {
1499 let trimmed = harness.trim();
1500 if trimmed.is_empty() || trimmed == "cursor" || trimmed == "cursor-agent" {
1501 return vec!["cursor-agent".into(), "-p".into(), prompt.into()];
1502 }
1503 if let Some(template) = crate::panel_runner::template_for_runner(trimmed, false, false) {
1504 let vars = crate::tools::Vars::default()
1505 .with("prompt", prompt)
1506 .with("cwd", ".")
1507 .with("persona", "member");
1508 return crate::tools::fill(&template, &vars);
1509 }
1510 vec![trimmed.into(), "-p".into(), prompt.into()]
1511}
1512
1513#[must_use]
1515pub fn member_argv(harness: &str, prompt: &str) -> Vec<String> {
1516 crate::panel_runner::run_argv_for(harness, prompt, "member")
1517}
1518
1519#[must_use]
1521pub fn designer_prompt(issue: &str, n: usize, of: usize, brief: &str) -> String {
1522 format!(
1523 "You are designer {n} of {of} on {issue}, in your own harness session.\n\
1524 The brief below is the only description you are given. Do not read another design, \
1525 `vissue children`, `vissue vote`, or `ljos consensus`. Do not vote.\n\
1526 File one candidate and stop:\n\
1527 `ljos file \"design: TITLE\" --parent {issue} -t task --body \"YOUR DESIGN\"`\n\
1528 \n\
1529 == brief\n\
1530 {brief}\n"
1531 )
1532}
1533
1534fn issue_brief(issue: &str) -> String {
1535 match crate::issue_record(issue) {
1536 Ok(v) => {
1537 let title = v["title"].as_str().unwrap_or("");
1538 let body = v["body"].as_str().unwrap_or("");
1539 let text = format!("{title}\n{body}");
1540 let text = text.trim();
1541 if text.is_empty() {
1542 format!("Issue {issue}.")
1543 } else {
1544 text.to_string()
1545 }
1546 }
1547 Err(_) => format!("Issue {issue}."),
1548 }
1549}
1550
1551fn persona_names(issue: &str) -> Vec<String> {
1552 if let Ok(raw) = std::env::var("LJOS_PANEL_PERSONAS") {
1553 let names: Vec<String> = raw
1554 .split(',')
1555 .map(str::trim)
1556 .filter(|s| !s.is_empty())
1557 .map(str::to_string)
1558 .collect();
1559 if !names.is_empty() {
1560 return names;
1561 }
1562 }
1563 if crate::bound_playbook(issue).is_none() {
1564 return Vec::new();
1565 }
1566 let all = crate::personas_from_pack().unwrap_or_default();
1567 crate::panel_personas(issue, &all)
1568 .into_iter()
1569 .map(|p| p.name)
1570 .collect()
1571}
1572
1573fn fresh_session(issue: &str, role: &str) -> String {
1574 use std::io::Read;
1575 let mut buf = [0u8; 4];
1576 let _ = std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf));
1577 format!(
1578 "panel-{}-{}-{}",
1579 ballot_sig::field(issue),
1580 ballot_sig::field(role),
1581 hex::encode(buf)
1582 )
1583}
1584
1585#[derive(Clone)]
1586struct Job {
1587 role: String,
1588 session: String,
1589 prompt: String,
1590 argv: Vec<String>,
1591}
1592
1593fn plan(issue: &str, harness: &str, designers: usize, personas: &[String]) -> Vec<Job> {
1594 let brief = issue_brief(issue);
1595 let mut jobs = Vec::new();
1596 for n in 1..=designers {
1597 let role = format!("designer-{n}");
1598 let prompt = designer_prompt(issue, n, designers, &brief);
1599 jobs.push(Job {
1600 session: fresh_session(issue, &role),
1601 argv: crate::panel_runner::run_argv_for(harness, &prompt, &role),
1602 role,
1603 prompt,
1604 });
1605 }
1606 for name in personas {
1607 let prompt = match crate::brief(name, issue) {
1608 Ok(text) => crate::decision_member_task(&text, name, issue),
1609 Err(_) => format!(
1610 "You are {name}, in your own harness session. Cast exactly one ballot on {issue} \
1611 and stop. Do not read `vissue vote {issue}` or another persona's ballot first.\n\
1612 `ljos vote {issue} --for OPTION --expect OPTION --as {name} --confidence 0.7 --used none`.\n"
1613 ),
1614 };
1615 jobs.push(Job {
1616 session: fresh_session(issue, name),
1617 argv: crate::panel_runner::run_argv_for(harness, &prompt, name),
1618 role: name.clone(),
1619 prompt,
1620 });
1621 }
1622 jobs
1623}
1624
1625fn format_plan(issue: &str, harness: &str, jobs: &[Job], dry_run: bool) -> String {
1626 let mut out = format!(
1627 "panel run {issue}: {} harness sessions ({harness})\n",
1628 jobs.len()
1629 );
1630 for job in jobs {
1631 let kind = if job.role.starts_with("designer-") {
1632 "designer, brief only"
1633 } else {
1634 "persona"
1635 };
1636 out.push_str(&format!(" {} session {} {kind}\n", job.role, job.session));
1637 if dry_run {
1638 out.push_str(&format!(
1639 " argv: {}\n",
1640 job.argv[..job.argv.len().min(2)].join(" ")
1641 ));
1642 out.push_str(&job.prompt);
1643 if !job.prompt.ends_with('\n') {
1644 out.push('\n');
1645 }
1646 }
1647 }
1648 out
1649}
1650
1651fn run_job(job: &Job) -> Result<String> {
1654 if job.argv.is_empty() {
1655 anyhow::bail!("{} did not start: no argv", job.role);
1656 }
1657 let mut cmd = Command::new(&job.argv[0]);
1658 cmd.args(&job.argv[1..])
1659 .env("LJOS_HARNESS_SESSION", &job.session)
1660 .env("LJOS_SESSION_ID", &job.session)
1661 .env("SEAT_SESSION_ID", &job.session)
1662 .env("LJOS_PANEL_CHILD", "1")
1663 .env("LJOS_SEAT", &job.role)
1664 .env("VISSUE_AGENT", &job.role)
1665 .env_remove("LJOS_PROCESS_NONCE")
1666 .stdin(Stdio::null())
1667 .stdout(Stdio::piped())
1668 .stderr(Stdio::piped());
1669 match cmd.output() {
1670 Ok(out) if out.status.success() => {
1671 Ok(format!("{} session {} exited 0\n", job.role, job.session))
1672 }
1673 Ok(out) => anyhow::bail!(
1674 "{} session {} exited {}",
1675 job.role,
1676 job.session,
1677 out.status.code().unwrap_or(1)
1678 ),
1679 Err(e) => anyhow::bail!(
1680 "{} session {} did not start ({}): {e}",
1681 job.role,
1682 job.session,
1683 job.argv[0]
1684 ),
1685 }
1686}
1687
1688pub fn panel_run(
1698 issue: &str,
1699 harness: Option<&str>,
1700 designers: usize,
1701 dry_run: bool,
1702) -> Result<String> {
1703 if std::env::var_os("LJOS_PANEL_CHILD").is_some() {
1704 return Ok(format!(
1705 "panel run: {issue} is already inside a panel member; nothing spawned\n"
1706 ));
1707 }
1708 let personas = persona_names(issue);
1709 if designers == 0 && personas.is_empty() {
1710 anyhow::bail!(
1711 "panel run: {issue} has no designers and no personas; pass --designers N or seat a persona"
1712 );
1713 }
1714 let harness =
1715 crate::panel_runner::resolve_panel_run_harness_with(harness, dry_run, &crate::on_path)?;
1716 let jobs = plan(issue, &harness, designers, &personas);
1717 let mut out = format_plan(issue, &harness, &jobs, dry_run);
1718 if dry_run {
1719 out.push_str("dry run: nothing spawned\n");
1720 return Ok(out);
1721 }
1722 let at_once = crate::panel_concurrency().max(1);
1723 let mut failed = 0;
1724 for chunk in jobs.chunks(at_once) {
1725 let mut handles = Vec::new();
1726 for job in chunk {
1727 let job = job.clone();
1728 handles.push(std::thread::spawn(move || run_job(&job)));
1729 }
1730 for handle in handles {
1731 match handle.join() {
1732 Ok(Ok(line)) => out.push_str(&line),
1733 Ok(Err(err)) => {
1734 failed += 1;
1735 out.push_str(&format!("{err:#}\n"));
1736 }
1737 Err(_) => {
1738 failed += 1;
1739 out.push_str("a panel member thread stopped\n");
1740 }
1741 }
1742 }
1743 }
1744 let ran = jobs.len() - failed;
1745 if failed > 0 {
1746 anyhow::bail!(
1747 "{out}panel run {issue}: opened {ran} of {} sessions",
1748 jobs.len()
1749 );
1750 }
1751 out.push_str(&format!(
1752 "panel run {issue}: opened {ran} of {} sessions\n",
1753 jobs.len()
1754 ));
1755 Ok(out)
1756}
1757
1758#[must_use]
1761pub fn designer_prompt_for(issue: &str, n: usize, of: usize) -> String {
1762 designer_prompt(issue, n, of, &issue_brief(issue))
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768
1769 #[test]
1770 fn a_shell_casts_as_one_session_and_a_harness_as_one_root() {
1771 let row = TreeRow {
1772 shell: "b:10:1".into(),
1773 harness: "b:20:2".into(),
1774 session: "sess-root:agent-a".into(),
1775 state: CAST_STATE.into(),
1776 };
1777 assert_eq!(TreeRow::parse(&row.line()), Some(row.clone()));
1778 let tree = |shell: &str, harness: &str| CastTree {
1779 shell: Some(shell.into()),
1780 harness: Some(harness.into()),
1781 };
1782 assert_eq!(
1783 row.binds("sess-root:agent-b", &tree("b:10:1", "b:30:3")),
1784 Some(("shell", "sess-root:agent-a".to_string())),
1785 "the same shell binds the later session to the earlier voice"
1786 );
1787 assert_eq!(
1788 row.binds("sess-root:agent-a", &tree("b:10:1", "b:20:2")),
1789 None,
1790 "the same session is the cast gate's to judge"
1791 );
1792 assert_eq!(
1793 row.binds("sess-root:agent-b", &tree("b:11:1", "b:20:2")),
1794 None,
1795 "a subagent of the same root counts with its parent"
1796 );
1797 assert_eq!(
1798 row.binds("sess-other", &tree("b:11:1", "b:20:2")),
1799 Some(("harness", "sess-root:agent-a".to_string())),
1800 "another root session on the same harness is the earlier voice"
1801 );
1802 assert_eq!(row.binds("sess-other", &CastTree::default()), None);
1803 assert!(!row.live(), "a row naming no running process is dropped");
1804 }
1805
1806 #[test]
1807 fn a_linked_ballot_folds_into_the_earlier_voice() {
1808 let k = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
1809 let first = ballot_sig::sign_bound(&k, "demo-tree", "alice", "ship", "sess-a", "-");
1810 let second = ballot_sig::sign_linked(
1811 &k,
1812 "demo-tree",
1813 "bea",
1814 "hold",
1815 ballot_sig::LinkedFields {
1816 harness: "sess-a",
1817 nonce: "-",
1818 claimed_harness: "sess-b",
1819 claimed_nonce: "-",
1820 link: "shell",
1821 },
1822 );
1823 let rows = [
1824 serde_json::json!({"agent": "alice", "choice": "ship"}),
1825 serde_json::json!({"agent": "bea", "choice": "hold"}),
1826 ];
1827 let tally = tally_voices(
1828 "demo-tree",
1829 &rows,
1830 &[second, first],
1831 &std::collections::BTreeSet::new(),
1832 );
1833 assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
1834 assert!(
1835 tally
1836 .folds
1837 .iter()
1838 .any(|line| line.contains("folded into one voice (same shell sess-a)")),
1839 "{:?}",
1840 tally.folds
1841 );
1842 let apart = [
1843 ballot_sig::sign_bound(&k, "demo-tree", "carol", "ship", "sess-c", "-"),
1844 ballot_sig::sign_bound(&k, "demo-tree", "dora", "ship", "sess-d", "-"),
1845 ];
1846 let rows = [
1847 serde_json::json!({"agent": "carol", "choice": "ship"}),
1848 serde_json::json!({"agent": "dora", "choice": "ship"}),
1849 ];
1850 let (counted, _) = tally_of(
1851 "demo-tree",
1852 &rows,
1853 &apart,
1854 &std::collections::BTreeSet::new(),
1855 );
1856 assert_eq!(counted.len(), 2, "{counted:?}");
1857 }
1858
1859 fn seat(voter: &str, harness: &str) -> BallotTrace {
1860 BallotTrace {
1861 voter: voter.into(),
1862 harness: harness.into(),
1863 nonce: format!("n-{voter}"),
1864 }
1865 }
1866
1867 #[test]
1868 fn one_connection_cannot_cast_again_by_naming_another_seat() {
1869 let key = ed25519_dalek::SigningKey::from_bytes(&[3; 32]);
1870 let ballot = ballot_sig::sign_bound(
1871 &key,
1872 "demo-ind1",
1873 "alice",
1874 "ship",
1875 "sess-shared-0001",
1876 "n-alice",
1877 );
1878 let own = [ballot];
1879 let family = [(
1880 "demo-vote".to_string(),
1881 "candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string(),
1882 )];
1883 assert!(
1884 session_cast_refusal("demo-ind1", "sess-shared-0001", "bea", &own, &family).is_none(),
1885 "another persona from this session is recorded"
1886 );
1887 let recast =
1888 session_cast_refusal("demo-ind1", "sess-shared-0001", "alice", &own, &family).unwrap();
1889 assert!(
1890 recast.contains("already cast") && recast.contains("Nothing was cast"),
1891 "{recast}"
1892 );
1893 let author =
1894 session_cast_refusal("demo-vote", "sess-author-0001", "alice", &[], &family).unwrap();
1895 assert!(author.contains("authored candidate demo-d001"), "{author}");
1896 let own_candidate =
1897 ["candidate-sig: demo-d001 alice sess-author-0001 n-author - ab cd".to_string()];
1898 let on_own = session_cast_refusal(
1899 "demo-vote",
1900 "sess-author-0001",
1901 "alice",
1902 &own_candidate,
1903 &[],
1904 )
1905 .unwrap();
1906 assert!(on_own.contains("authored candidate demo-d001"), "{on_own}");
1907 assert!(session_cast_refusal("demo-ind1", "sess-bob-0001", "bob", &own, &family).is_none());
1908 let _g = crate::test_env_lock();
1909 let dir = tempfile::tempdir().unwrap();
1910 std::env::set_var("VISSUE_ROOT", dir.path());
1911 std::env::set_var("LJOS_HARNESS_SESSION", "sess-live-0001");
1912 std::env::set_var("LJOS_PROCESS_NONCE", "nonce-live-0001");
1913 let first = claim_session_ballot("probe-seat-1", "saddle").unwrap();
1914 let second = claim_session_ballot("probe-seat-1", "other");
1915 assert!(second.is_ok(), "{second:?}");
1916 let again = claim_session_ballot("probe-seat-1", "saddle").unwrap_err();
1917 assert!(again.to_string().contains("already cast"), "{again:#}");
1918 first.keep();
1919 std::env::remove_var("VISSUE_ROOT");
1920 std::env::remove_var("LJOS_HARNESS_SESSION");
1921 std::env::remove_var("LJOS_PROCESS_NONCE");
1922 }
1923
1924 #[test]
1925 fn a_signature_for_this_issue_on_its_family_still_counts() {
1926 let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
1927 let on_b = ballot_sig::sign_bound(&key, "demo-b", "saddle", "ship", "sess-one", "-");
1928 let stray = [("parent-b".to_string(), on_b.clone())];
1929 assert!(
1930 session_cast_refusal("demo-b", "sess-one", "bea", &[], &stray).is_none(),
1931 "another persona is recorded beside saddle"
1932 );
1933 let withdrawn = ballot_sig::withdrawn_line("saddle", "sess-one", "-");
1934 let own = [withdrawn, on_b.clone()];
1935 assert!(
1936 session_cast_refusal("demo-b", "sess-one", "saddle", &own, &stray).is_none(),
1937 "the issue's own copy in the family is read once, with its withdrawal"
1938 );
1939 }
1940
1941 #[test]
1942 fn a_parent_cut_for_a_child_issue_is_read_on_the_parent_index() {
1943 let key = ed25519_dalek::SigningKey::from_bytes(&[6; 32]);
1944 let issue = "child-a";
1945 let parent = "parent-1";
1946 let own = [ballot_sig::sign_bound(
1947 &key, issue, "bea", "ship", "sess-bea", "-",
1948 )];
1949 let sig = ballot_sig::sign_bound(&key, issue, "saddle", "ship", "sess-saddle", "-");
1950 let cut = ballot_sig::withdrawn_line_on("saddle", "sess-saddle", "-", Some(issue));
1951 let family = [(parent.to_string(), cut), (parent.to_string(), sig)];
1952 assert_eq!(
1953 session_cast_refusal(issue, "sess-saddle", "saddle", &own, &family),
1954 None,
1955 "a parent cut for child-a must not be indexed against child-a's own notes"
1956 );
1957 }
1958
1959 #[test]
1960 fn a_choice_with_a_space_is_still_this_sessions_one_ballot() {
1961 let k = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
1964 let first =
1965 ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1966 let again = session_cast_refusal(
1967 "panel-spcd",
1968 "spaced-1",
1969 "seat-a",
1970 std::slice::from_ref(&first),
1971 &[],
1972 )
1973 .expect("a second cast from spaced-1 was let through");
1974 assert!(
1975 again.contains("already cast") && again.contains("Nothing was cast"),
1976 "{again}"
1977 );
1978 assert!(session_cast_refusal(
1979 "panel-spcd",
1980 "other-sess",
1981 "seat-b",
1982 std::slice::from_ref(&first),
1983 &[],
1984 )
1985 .is_none());
1986 let later = ballot_sig::sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
1989 let rows = [serde_json::json!({"agent": "seat-a", "choice": "xdg home"})];
1990 let (counted, _) = tally_of(
1991 "panel-spcd",
1992 &rows,
1993 &[later, first],
1994 &std::collections::BTreeSet::new(),
1995 );
1996 assert_eq!(counted.len(), 1, "{counted:?}");
1997 assert_eq!(counted[0]["choice"], "beside binary");
1998 let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -".to_string();
2000 assert!(session_cast_refusal("panel-spcd", "spaced-1", "seat-a", &[odd], &[]).is_none());
2001 }
2002
2003 #[test]
2004 fn five_personas_one_sand_session_count_as_the_first_ballot() {
2005 let session = "sand-subagent-fcdb9c57-9988-7d91-4311-9fc5e42f7529";
2006 let personas = ["academic", "transit", "safety", "cat-owner", "finance"];
2007 let mut notes = Vec::new();
2008 for persona in personas.iter().rev() {
2011 notes.push(format!(
2012 "ballot-sig: {persona} bus ab cd {session} n-{persona}-2"
2013 ));
2014 }
2015 for persona in personas.iter().rev() {
2016 notes.push(format!(
2017 "ballot-sig: {persona} rail ab cd {session} n-{persona}-1"
2018 ));
2019 }
2020 let rows: Vec<Value> = personas
2021 .iter()
2022 .map(|persona| serde_json::json!({"agent": persona, "choice": "bus"}))
2023 .collect();
2024 let tally = tally_voices(
2025 "travel-rx1d",
2026 &rows,
2027 ¬es,
2028 &std::collections::BTreeSet::new(),
2029 );
2030 assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2031 assert_eq!(tally.counted[0]["agent"], "finance");
2032 assert_eq!(tally.counted[0]["choice"], "rail");
2033 assert!(tally.ignored.is_empty(), "{:?}", tally.ignored);
2034 let said = provenance_text(¬es, &rows);
2035 assert!(
2036 said.contains(&format!("shared parent {session}:")),
2037 "{said}"
2038 );
2039 let folded = tally.folds.join("\n");
2040 assert!(
2041 folded.contains(&format!("same session {session}"))
2042 && personas.iter().all(|persona| folded.contains(persona)),
2043 "{folded}"
2044 );
2045 let mut authors = std::collections::BTreeSet::new();
2046 authors.insert(session.to_string());
2047 let (none, dropped) = tally_of("travel-rx1d", &rows, ¬es, &authors);
2048 assert!(none.is_empty(), "{none:?}");
2049 assert_eq!(dropped.len(), 5, "{dropped:?}");
2050 assert!(dropped
2051 .iter()
2052 .all(|item| item.why == "authored a candidate"));
2053 }
2054
2055 fn note_of(voter: &str, choice: &str, harness: &str) -> String {
2056 format!("ballot-sig: {voter} {choice} ab cd {harness} -")
2057 }
2058
2059 #[test]
2060 fn one_session_that_switches_seats_counts_the_latest_ballot() {
2061 let session = "sess-switch-0001";
2062 let notes = vec![
2063 note_of("bob", "hold", session),
2064 note_of("alice", "ship", session),
2065 ];
2066 let rows = [
2067 serde_json::json!({"agent": "alice", "choice": "ship"}),
2068 serde_json::json!({"agent": "bob", "choice": "hold"}),
2069 ];
2070 let tally = tally_voices(
2071 "demo-switch",
2072 &rows,
2073 ¬es,
2074 &std::collections::BTreeSet::new(),
2075 );
2076 assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2077 assert_eq!(tally.counted[0]["agent"], "bob");
2078 assert_eq!(tally.counted[0]["choice"], "hold");
2079 let folded = tally.folds.join("\n");
2080 assert!(
2081 folded.contains(&format!("same session {session}"))
2082 && folded.contains("alice")
2083 && folded.contains("bob")
2084 && folded.contains("counted bob"),
2085 "{folded}"
2086 );
2087 }
2088
2089 #[test]
2090 fn three_subagents_of_one_parent_are_one_voice() {
2091 let parent = "sess-parent-0001";
2092 let notes = vec![
2093 note_of("cee", "ship", &format!("{parent}:agent-c")),
2094 note_of("bee", "hold", &format!("{parent}:agent-b")),
2095 note_of("aye", "ship", &format!("{parent}:agent-a")),
2096 ];
2097 let rows = [
2098 serde_json::json!({"agent": "aye", "choice": "ship"}),
2099 serde_json::json!({"agent": "bee", "choice": "hold"}),
2100 serde_json::json!({"agent": "cee", "choice": "ship"}),
2101 ];
2102 let tally = tally_voices(
2103 "demo-three",
2104 &rows,
2105 ¬es,
2106 &std::collections::BTreeSet::new(),
2107 );
2108 assert_eq!(tally.counted.len(), 1, "{:?}", tally.counted);
2109 assert_eq!(tally.counted[0]["agent"], "cee");
2110 assert_eq!(tally.counted[0]["choice"], "ship");
2111 let folded = tally.folds.join("\n");
2112 assert!(
2113 folded.contains(&format!("same parent {parent}"))
2114 && folded.contains("aye")
2115 && folded.contains("bee")
2116 && folded.contains("cee"),
2117 "{folded}"
2118 );
2119 }
2120
2121 #[test]
2122 fn two_sessions_count_as_two_voices() {
2123 let notes = vec![
2124 note_of("bob", "hold", "sess-bob-0001"),
2125 note_of("alice", "ship", "sess-alice-0001"),
2126 ];
2127 let rows = [
2128 serde_json::json!({"agent": "alice", "choice": "ship"}),
2129 serde_json::json!({"agent": "bob", "choice": "hold"}),
2130 ];
2131 let tally = tally_voices(
2132 "demo-two",
2133 &rows,
2134 ¬es,
2135 &std::collections::BTreeSet::new(),
2136 );
2137 assert_eq!(tally.counted.len(), 2, "{:?}", tally.counted);
2138 assert!(tally.folds.is_empty(), "{:?}", tally.folds);
2139 }
2140
2141 #[test]
2142 fn one_session_of_n_seats_is_weighed() {
2143 let ballots = vec![
2144 seat("carol", "sess-shared-0001"),
2145 seat("alice", "sess-shared-0001"),
2146 seat("bob", "sess-shared-0001"),
2147 ];
2148 assert_eq!(refusal("demo-ind1", &ballots, &[]), None);
2149 }
2150
2151 #[test]
2152 fn n_sessions_settle() {
2153 let ballots = vec![
2154 seat("alice", "sess-alice-0001"),
2155 seat("bob", "sess-bob-0001"),
2156 seat("carol", "sess-carol-0001"),
2157 ];
2158 assert_eq!(refusal("demo-ind2", &ballots, &[]), None);
2159 }
2160
2161 #[test]
2162 fn an_author_who_votes_is_refused_even_when_the_other_ballots_are_not_theirs() {
2163 let ballots = vec![
2164 seat("alice", "sess-alice-0001"),
2165 seat("bob", "sess-bob-0001"),
2166 ];
2167 let candidates = vec![CandidateTrace {
2168 id: "demo-d001".into(),
2169 harness: "sess-alice-0001".into(),
2170 nonce: "n-alice".into(),
2171 saw: vec![],
2172 }];
2173 let msg = refusal("demo-vote", &ballots, &candidates).unwrap();
2174 assert_eq!(
2175 msg,
2176 "refuses to settle demo-vote: session sess-alice-0001 authored candidate demo-d001 \
2177 and voted on demo-vote"
2178 .to_string()
2179 );
2180 }
2181
2182 #[test]
2183 fn a_session_that_read_another_candidate_is_flagged() {
2184 let reads = vec![
2185 ("sess-1".into(), "demo-b".into()),
2186 ("sess-2".into(), "demo-a".into()),
2187 ];
2188 let saw = reads_that_spoil("sess-1", &reads, &["demo-a".into(), "demo-b".into()]);
2189 assert_eq!(saw, vec!["demo-b".to_string()]);
2190 let line = blind_sentence("demo-a", "sess-1", &saw);
2191 assert_eq!(
2192 line,
2193 "design demo-a is not blind: session sess-1 read demo-b before filing it"
2194 );
2195 let quiet = reads_that_spoil("sess-2", &reads, &["demo-b".into()]);
2196 assert!(quiet.is_empty(), "{quiet:?}");
2197 }
2198
2199 #[test]
2200 fn a_designer_prompt_is_only_the_brief() {
2201 let prompt = designer_prompt("demo-1", 1, 3, "Ship the cache.\nKeep the ledger.");
2202 assert!(prompt.contains("== brief"), "{prompt}");
2203 assert!(prompt.contains("Ship the cache."), "{prompt}");
2204 assert!(prompt.contains("Do not read another design"), "{prompt}");
2205 assert!(prompt.contains("Do not vote"), "{prompt}");
2206 assert!(!prompt.contains("OTHER-CANDIDATE-BODY"), "{prompt}");
2207 let argv = harness_argv("cursor-agent", "hello");
2208 assert_eq!(argv, vec!["cursor-agent", "-p", "hello"]);
2209 let claude = harness_argv("claude", "hello");
2210 assert_eq!(&claude[..3], ["claude", "-p", "hello"]);
2211 }
2212
2213 #[test]
2214 fn unbound_ballots_are_left_alone() {
2215 let ballots = vec![
2216 BallotTrace {
2217 voter: "alice".into(),
2218 harness: String::new(),
2219 nonce: String::new(),
2220 },
2221 BallotTrace {
2222 voter: "bob".into(),
2223 harness: "-".into(),
2224 nonce: "-".into(),
2225 },
2226 ];
2227 assert_eq!(refusal("old-tracker", &ballots, &[]), None);
2228 }
2229
2230 #[test]
2231 fn the_same_nonce_without_a_harness_is_one_session() {
2232 let ballots = vec![
2233 BallotTrace {
2234 voter: "alice".into(),
2235 harness: "-".into(),
2236 nonce: "same-nonce".into(),
2237 },
2238 BallotTrace {
2239 voter: "bob".into(),
2240 harness: "-".into(),
2241 nonce: "same-nonce".into(),
2242 },
2243 ];
2244 assert_eq!(session_key("-", "same-nonce"), "nonce:same-nonce");
2245 assert_eq!(refusal("demo-n", &ballots, &[]), None);
2246 }
2247
2248 #[test]
2249 fn a_seat_only_session_keeps_the_seat_as_its_key() {
2250 assert_eq!(
2251 session_key("ljosbot", ballot_sig::SEAT_NONCE),
2252 session_key("ljosbot", "-")
2253 );
2254 let key = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
2255 let old = ballot_sig::sign_bound(&key, "demo-s", "alice", "ship", "ljosbot", "-");
2257 assert!(
2258 session_cast_refusal("demo-s", "ljosbot", "bea", std::slice::from_ref(&old), &[])
2259 .is_none()
2260 );
2261 let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[old], &[]).unwrap();
2262 assert!(again.contains("already cast"), "{again}");
2263 assert!(!again.contains("unverified"), "{again}");
2264 let marked = ballot_sig::sign_bound(
2265 &key,
2266 "demo-s",
2267 "alice",
2268 "ship",
2269 "ljosbot",
2270 ballot_sig::SEAT_NONCE,
2271 );
2272 assert!(session_cast_refusal(
2273 "demo-s",
2274 "ljosbot",
2275 "bea",
2276 std::slice::from_ref(&marked),
2277 &[],
2278 )
2279 .is_none());
2280 let again = session_cast_refusal("demo-s", "ljosbot", "alice", &[marked], &[]).unwrap();
2281 assert!(
2282 again.contains("already cast")
2283 && again.contains(&ballot_sig::unverified_sentence("ljosbot")),
2284 "{again}"
2285 );
2286 }
2287
2288 #[test]
2289 fn a_siblings_signature_does_not_block_this_issue() {
2290 let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
2291 let sibling =
2292 ballot_sig::sign_bound(&key, "rgpot-9ot3", "saddle", "ship", "sess-saddle", "-");
2293 assert!(session_cast_refusal(
2294 "eOn-1stn",
2295 "sess-saddle",
2296 "saddle",
2297 &[],
2298 &[("rgpot-9ot3".to_string(), sibling.clone())],
2299 )
2300 .is_none());
2301 assert!(
2302 session_cast_refusal(
2303 "eOn-1stn",
2304 "sess-saddle",
2305 "saddle",
2306 std::slice::from_ref(&sibling),
2307 &[],
2308 )
2309 .is_none(),
2310 "a signature for another issue does not verify here"
2311 );
2312 let here = ballot_sig::sign_bound(&key, "eOn-1stn", "saddle", "ship", "sess-saddle", "-");
2313 let blocked = session_cast_refusal(
2314 "eOn-1stn",
2315 "sess-saddle",
2316 "saddle",
2317 std::slice::from_ref(&here),
2318 &[],
2319 )
2320 .unwrap();
2321 assert!(
2322 blocked.contains("already cast") && blocked.contains("Nothing was cast"),
2323 "{blocked}"
2324 );
2325 }
2326
2327 #[test]
2328 fn a_withdrawn_ballot_drops_out_of_the_tally_until_its_voter_casts_again() {
2329 let key = ed25519_dalek::SigningKey::from_bytes(&[8; 32]);
2330 let sig = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "ship", "sess-r", "-");
2331 let authors = std::collections::BTreeSet::new();
2332 let (gone, _) = tally_of("demo-w", &[], std::slice::from_ref(&sig), &authors);
2333 assert!(gone.is_empty(), "{gone:?}");
2334 let rows = [serde_json::json!({"agent": "reviewer", "choice": "ship"})];
2335 let note = ballot_sig::withdrawn_line("reviewer", "sess-r", "-");
2336 let taken_back = [note.clone(), sig.clone()];
2337 let (counted, ignored) = tally_of("demo-w", &rows, &taken_back, &authors);
2338 assert!(counted.is_empty(), "{counted:?}");
2339 assert!(ignored.is_empty(), "{ignored:?}");
2340 assert!(
2341 session_cast_refusal("demo-w", "sess-r", "editor", &taken_back, &[]).is_none(),
2342 "another persona is recorded after a withdrawal"
2343 );
2344 assert_eq!(
2346 session_cast_refusal("demo-w", "sess-r", "reviewer", &taken_back, &[]),
2347 None
2348 );
2349 let recast = ballot_sig::sign_bound(&key, "demo-w", "reviewer", "hold", "sess-r", "-");
2350 let notes = [recast, note, sig];
2351 let rows = [serde_json::json!({"agent": "reviewer", "choice": "hold"})];
2352 let (counted, _) = tally_of("demo-w", &rows, ¬es, &authors);
2353 assert_eq!(counted.len(), 1, "{counted:?}");
2354 assert_eq!(counted[0]["choice"], "hold");
2355 let again = session_cast_refusal("demo-w", "sess-r", "reviewer", ¬es, &[]).unwrap();
2357 assert!(
2358 again.contains("already cast a ballot on demo-w (sess-r): reviewer's, for hold.")
2359 && again.contains("`ljos vote demo-w --withdraw --as reviewer`")
2360 && again.contains("Nothing was cast"),
2361 "{again}"
2362 );
2363 }
2364
2365 #[test]
2366 fn a_withdrawn_seat_ballot_does_not_decide_a_push() {
2367 let key = ed25519_dalek::SigningKey::from_bytes(&[4; 32]);
2368 let ship = ballot_sig::sign_bound(&key, "cite-1", "seat-a", "ship", "sess-a", "-");
2369 let hold = ballot_sig::sign_bound(&key, "cite-1", "seat-b", "hold", "sess-b", "-");
2370 let withdrawn = ballot_sig::withdrawn_line("seat-a", "sess-a", "-");
2371 let rows = [
2372 serde_json::json!({"agent": "seat-a", "choice": "ship"}),
2373 serde_json::json!({"agent": "seat-b", "choice": "hold"}),
2374 ];
2375 let notes = vec![withdrawn, hold, ship];
2376 let authors = std::collections::BTreeSet::new();
2377 let (counted, _) = tally_of("cite-1", &rows, ¬es, &authors);
2378 assert_eq!(counted.len(), 1, "{counted:?}");
2379 assert_eq!(counted[0]["agent"], "seat-b");
2380 assert_eq!(counted[0]["choice"], "hold");
2381 let personas = std::collections::BTreeSet::new();
2382 let (words, json) = crate::push_cite_reading_signed(
2383 "cite-1",
2384 &counted,
2385 &personas,
2386 "seat-c",
2387 Ok(Default::default()),
2388 );
2389 assert!(words.contains("decides hold"), "{words}");
2390 assert!(!words.contains("split"), "{words}");
2391 assert_eq!(json["push_cite"]["stands"].as_bool(), Some(true), "{json}");
2392 let (only, _) = tally_of("cite-1", &rows[..1], ¬es, &authors);
2393 assert!(only.is_empty(), "{only:?}");
2394 let (words, json) = crate::push_cite_reading_signed(
2395 "cite-1",
2396 &only,
2397 &personas,
2398 "seat-c",
2399 Ok(Default::default()),
2400 );
2401 assert!(
2402 words.contains("does not stand") && words.contains("no seat voted"),
2403 "{words}"
2404 );
2405 assert_eq!(json["push_cite"]["stands"].as_bool(), Some(false), "{json}");
2406 }
2407}