Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
- **feat(sidebar): department cards show each person's inbox count.** A new right-aligned `inbox` column reports zero and multi-digit counts without moving the model column between rows. The count follows the product's durable inbox view: `pending` and fence-archived `delivered` messages are visible, while the four pane-drain states are not.
**The card stays current without a second reader.** The launch-catalog route already reads the whole company mailbox once per converge pass, so it now publishes one exact count for every roster person, including a person whose launch gate is refused. The actuator subscribes to the existing `mailbox/` changefeed prefix, then hands the count one way through the session brain into the card snapshot; the card never reads chiefd or a pane itself. Launch demand stays unchanged and continues to use only the existing `pendingMail` Boolean.

- **fix(sidebar): one failed focus-window census can no longer freeze every company action.** The permanent focus window was created after a client-side read: two sidebar owners could both observe absence, and a tmux error was also indistinguishable from absence. Both paths could mint `__focus__` twice. The actuator then correctly failed closed on the ambiguous topology before step zero, which left new people at `starting` and kept settled people on the glass.
**Creation is now decided inside tmux's serialized command queue.** The create, ownership tags, and parked-notice tag are one guarded batch. A stale or empty client read therefore resolves to the winner instead of creating a second window. Sessions already in the broken state self-repair only by removing an inactive window proved, again at the mutation boundary, to contain exactly Chief's rail and parked notice; an active, person-owned, unknown, or changed window is never deleted. Live-tmux tests pin simultaneous creation, a failed census, the observed two-inactive-window incident, active-window choice, unknown state, and both sides of the delete race.

Expand Down
1 change: 1 addition & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,4 @@
- 2026-08-25 — CHANGELOG.md and DECISIONS.md start FRESH in the public tree, and the private ledger is archived rather than published. Those two files had grown to about 16,800 lines of internal narrative — detailed incident write-ups, named hosts, and roughly thirty-six leaked machine names — written for the people who were in the room. The rejected alternative was a mass rewording of shipped entries through the `doc-append-only` guard's per-entry exception registry, which would have taught every future contributor that an append-only file may be reworded when the reason seems good enough. That is a repeal of the rule, not an exception to it. The same philosophy as the `plans/` ruling, applied to the same kind of content: the history is kept, and it is kept private. What still BINDS the code is carried forward as new dated entries below; `CLAUDE.md` and `AGENTS.md` already carry the organization model, the operator wake lease and the placement rule, and duplicating them here would create two sources for one rule. The guard treats this as a ONE-TIME reset, detected by the first line of this entry and of the changelog's, so ordinary append-only resumes from the very next commit with no exception left behind to rot.
- 2026-09-03 — The sidebar rail is furniture and never takes the operator's cursor. It is minted active on purpose — the tag and the resize that follow the split name a WINDOW, which tmux resolves to the active pane — so every mint frame ends with `select-pane -l`, restoring the pane that was active before the split. The rejected alternative was `split-window -d`, which cannot work without a second tmux invocation to name the pane the split has not reported yet, and a rail that is observable untagged is the gap that once put two rails in one window. Pinned by `the_rail_frame_hands_the_cursor_back_after_every_other_write` and `a_real_rail_mint_leaves_the_person_pane_active` (`attach.rs`) and by `a_repaired_rail_gives_the_cursor_back_after_it_is_tagged` and `real_rail_repair_leaves_the_person_pane_active_and_not_the_rail` (`actuate/interpret/tests.rs`).
- 2026-09-04 — A session has one permanent focus window, and its creation decision belongs to tmux's serialized command queue; an unreadable client census is never authority to create a duplicate. Recovery may delete only an inactive focus window proved at the mutation boundary to contain Chief's exact parked rail and notice, while active, person-owned, unknown, or changed content remains fail-closed. Pinned by `concurrent_real_tmux_focus_ensures_mint_one_window`, `an_empty_client_census_cannot_duplicate_an_existing_real_focus_window`, `a_real_inactive_duplicate_made_only_of_focus_furniture_is_repaired`, `a_real_duplicate_with_unknown_local_state_is_left_for_fail_closed_planning`, `a_duplicate_that_changes_after_snapshot_is_not_deleted`, and `a_keeper_that_changes_after_snapshot_preserves_the_other_focus_window` (`sidebar/tests.rs`).
- 2026-09-04 — A department card's `inbox` count is the same durable view the person's footer uses: `pending` plus fence-archived `delivered`, with the four pane-drain states excluded. It is a roster fact published from chiefd's existing whole-mailbox read and projected one way through the client; it never comes from a pane, and it does not change the separate pending-only launch-demand rule. Pinned by `the_launch_catalog_route_keeps_inbox_visibility_separate_from_launch_demand` (`docstore/desired.rs`), `inbox_counts_name_every_roster_person_including_a_refusal` (`actuate/launch_catalog.rs`), and `a_rendered_inbox_count_is_whole_or_hidden_at_its_exact_width_boundary` (`sidebar/department_card/tests.rs`).
9 changes: 6 additions & 3 deletions apps/chiefd/crates/chief-cli/src/actuate/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ pub const LAUNCH_CATALOG_BUDGET: Duration = Duration::from_secs(20);
/// runtime identity a restart is fenced on.
/// * `converge-safety` — shadow/apply, the breaker, the budgets.
/// * `org-manifest` — the structural authority: people, departments, the tree.
pub const WAKE_STORES: [&str; 4] = ["activity", "supervision", "converge-safety", "org-manifest"];
/// * `mailbox/` — every per-person inbox; a drain changes the card count even
/// when no runtime authority changes with it.
pub const WAKE_STORES: [&str; 5] =
["activity", "supervision", "converge-safety", "org-manifest", "mailbox/"];

/// How long a wake waits for the rest of its burst before it is answered.
///
Expand Down Expand Up @@ -1072,7 +1075,7 @@ mod tests {
!WAKE_STORES.contains(&"runtime-actuation"),
"the actuation store is deleted; nothing may subscribe to it"
);
for expected in ["activity", "supervision", "converge-safety", "org-manifest"] {
for expected in ["activity", "supervision", "converge-safety", "org-manifest", "mailbox/"] {
assert!(WAKE_STORES.contains(&expected), "{expected} is work somebody else commits");
}
}
Expand Down Expand Up @@ -1251,7 +1254,7 @@ mod tests {
assert_eq!(client.document_key(), "acme@abc123");
assert_eq!(
client.watch_url(Some(3)),
"http://127.0.0.1:8791/v1/docs/watch?slug=acme@abc123&stores=activity,supervision,converge-safety,org-manifest&after=3",
"http://127.0.0.1:8791/v1/docs/watch?slug=acme@abc123&stores=activity,supervision,converge-safety,org-manifest,mailbox/&after=3",
"a trailing slash on the base must never double the separator"
);
// A3: this actuator is its OWN principal. It never borrows the
Expand Down
50 changes: 48 additions & 2 deletions apps/chiefd/crates/chief-cli/src/actuate/launch_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ pub struct LaunchCatalog {
pub roster: Vec<String>,
/// Current model facts for every validated roster person.
pub models: BTreeMap<String, PersonModel>,
/// Messages in the durable inbox view, for every roster person.
pub inbox_counts: BTreeMap<String, usize>,
/// The people the on-disk gate ADMITTED.
pub people: BTreeMap<String, LaunchEntry>,
/// Why each person in `roster` but not in `people` was declined.
Expand All @@ -232,10 +234,22 @@ impl LaunchCatalog {
catalog.schema_version
)));
}
let roster: BTreeSet<&str> = catalog.roster.iter().map(String::as_str).collect();
if roster.len() != catalog.roster.len() {
return Err(<serde_json::Error as serde::de::Error>::custom(
"launch catalog roster contains duplicate person ids",
));
}
let counted: BTreeSet<&str> = catalog.inbox_counts.keys().map(String::as_str).collect();
if counted != roster {
return Err(<serde_json::Error as serde::de::Error>::custom(format!(
"launch catalog inboxCounts must name every roster person exactly; roster={roster:?}, counts={counted:?}"
)));
}
Ok(catalog)
}

/// Turn the wire body into the three values the interpreter wants.
/// Turn the wire body into the facts the interpreter wants.
///
/// Done ONCE per pass rather than per step: `LaunchSpec` owns its strings,
/// and rebuilding the whole map for every start in a plan would re-clone
Expand Down Expand Up @@ -275,6 +289,7 @@ impl LaunchCatalog {
specs,
roster: self.roster.iter().cloned().collect(),
models: self.models.clone(),
inbox_counts: self.inbox_counts.clone(),
refusals,
}
}
Expand All @@ -295,6 +310,8 @@ pub struct ResolvedCatalog {
pub roster: BTreeSet<String>,
/// Backend-owned current model facts by person id.
pub models: BTreeMap<String, PersonModel>,
/// Durable inbox-message counts by person id, including refused people.
pub inbox_counts: BTreeMap<String, usize>,
/// chiefd's own re-derived reason for each declined person.
pub refusals: BTreeMap<String, String>,
}
Expand All @@ -306,7 +323,7 @@ mod tests {
#[test]
fn the_retired_launch_catalog_schema_is_refused_without_a_compatibility_arm() {
let error = LaunchCatalog::from_json(
r#"{"schemaVersion":1,"company":"acme","roster":[],"people":{},"models":{},"refusals":{}}"#,
r#"{"schemaVersion":1,"company":"acme","roster":[],"people":{},"models":{},"inboxCounts":{},"refusals":{}}"#,
)
.expect_err("schema 1 is retired");
assert!(error.to_string().contains("expected 2"));
Expand All @@ -332,6 +349,7 @@ mod tests {
"vera": {"state":"selected","provider":"openai","model":"gpt-5.6"},
"nolan": {"state":"unavailable","provider":null,"model":null}
},
"inboxCounts": {"vera":12,"nolan":0},
"people": {
"vera": {
"piBinary": "/opt/pi/bin/pi",
Expand Down Expand Up @@ -373,10 +391,38 @@ mod tests {
assert_eq!(catalog.models["vera"].state, PersonModelState::Selected);
assert_eq!(catalog.models["vera"].provider.as_deref(), Some("openai"));
assert_eq!(catalog.models["vera"].model.as_deref(), Some("gpt-5.6"));
assert_eq!(catalog.inbox_counts["vera"], 12);
assert_eq!(catalog.inbox_counts["nolan"], 0);
assert_eq!(catalog.people.len(), 1, "only the admitted person carries an entry");
assert_eq!(catalog.refusals["nolan"], "required directory 'workspace' is missing");
}

#[test]
fn inbox_counts_name_every_roster_person_including_a_refusal() {
let mut missing: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON");
missing["inboxCounts"].as_object_mut().expect("count map").remove("nolan");
let error = LaunchCatalog::from_json(&serde_json::to_string(&missing).expect("JSON"))
.expect_err("a refused person still needs an inbox count");
assert!(error.to_string().contains("inboxCounts must name every roster person exactly"));

let mut unknown: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON");
unknown["inboxCounts"]["stranger"] = serde_json::json!(1);
let error = LaunchCatalog::from_json(&serde_json::to_string(&unknown).expect("JSON"))
.expect_err("an unknown person cannot enter the card through the count map");
assert!(error.to_string().contains("inboxCounts must name every roster person exactly"));
}

#[test]
fn duplicate_roster_ids_are_refused_before_they_collapse_into_a_set() {
let mut duplicate: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON");
duplicate["roster"] = serde_json::json!(["vera", "nolan", "vera"]);

let error = LaunchCatalog::from_json(&serde_json::to_string(&duplicate).expect("JSON"))
.expect_err("one person cannot occupy two roster positions");

assert!(error.to_string().contains("roster contains duplicate person ids"));
}

/// Every field, because a field this client silently dropped would be a
/// launch input chiefd authorized and the pane never received — and the
/// symptom would be a Pi that starts with the wrong tools, or a rail whose
Expand Down
81 changes: 81 additions & 0 deletions apps/chiefd/crates/chief-cli/src/actuate/resident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,29 @@ fn spawn_person(step: &plan::Step) -> Option<String> {
}
}

/// Missing and unknown inbox-count owners at the display handoff.
///
/// The roster and launch catalog are separate HTTP reads. The launch catalog
/// validates its count map against its OWN roster, but the brain draws the
/// roster read by this pass. Keep that boundary explicit: a mismatch is not an
/// empty inbox and is not launch authority.
fn inbox_count_roster_mismatch<'a>(
roster_people: impl IntoIterator<Item = &'a str>,
inbox_counts: &BTreeMap<String, usize>,
) -> Option<(Vec<String>, Vec<String>)> {
let roster: BTreeSet<&str> = roster_people.into_iter().collect();
let counted: BTreeSet<&str> = inbox_counts.keys().map(String::as_str).collect();
let missing: Vec<String> =
roster.difference(&counted).map(|person| (*person).to_owned()).collect();
let unknown: Vec<String> =
counted.difference(&roster).map(|person| (*person).to_owned()).collect();
if missing.is_empty() && unknown.is_empty() {
None
} else {
Some((missing, unknown))
}
}

impl TmuxActuator {
/// Hand the session brain the company this pass just read.
///
Expand Down Expand Up @@ -925,6 +948,20 @@ impl TmuxActuator {
launch: &ResolvedCatalog,
crashing: BTreeMap<String, CrashReport>,
) {
if let Some((missing, unknown)) = inbox_count_roster_mismatch(
roster.people.iter().map(|person| person.id.as_str()),
&launch.inbox_counts,
) {
self.brain.unreadable();
tracing::warn!(
event = "sidebar.company.inbox-counts-inconsistent",
company = %self.company,
?missing,
?unknown,
"the launch catalog's inbox counts do not exactly cover this pass's roster; the display was not updated"
);
return;
}
let Ok(board) = self.client.lifecycle_status().await else {
tracing::debug!(
event = "sidebar.company.lifecycle-unreadable",
Expand All @@ -947,6 +984,7 @@ impl TmuxActuator {
hashes: desired.hashes(),
accents,
models: launch.models.clone(),
inbox_counts: launch.inbox_counts.clone(),
// THE ACTUATOR'S OWN CRASH REPORT, HANDED TO THE GLASS. This
// process is the only one that knows a person's boot keeps dying,
// how many times, since when, and what tmux said about it. Until it
Expand Down Expand Up @@ -1389,6 +1427,49 @@ mod tests {
use super::*;
use crate::actuate::desired::DesiredPerson;

fn inbox_counts(people: &[&str]) -> BTreeMap<String, usize> {
people.iter().enumerate().map(|(count, person)| ((*person).to_owned(), count)).collect()
}

#[test]
fn exact_inbox_count_keys_cover_the_display_roster() {
let counts = inbox_counts(&["chief", "vera"]);
assert_eq!(inbox_count_roster_mismatch(["chief", "vera"], &counts), None);
}

#[test]
fn a_missing_inbox_count_is_not_an_empty_inbox() {
let counts = inbox_counts(&["chief"]);
assert_eq!(
inbox_count_roster_mismatch(["chief", "vera"], &counts),
Some((vec!["vera".to_owned()], Vec::new()))
);
}

#[test]
fn an_unknown_inbox_count_cannot_enter_the_display() {
let counts = inbox_counts(&["chief", "stranger"]);
assert_eq!(
inbox_count_roster_mismatch(["chief"], &counts),
Some((Vec::new(), vec!["stranger".to_owned()]))
);
}

#[test]
fn inbox_count_validation_is_display_only_and_placement_still_runs() {
let source = include_str!("resident.rs");
let handoff = source
.find("self.feed_brain(&roster, desired, &launch, crashing.clone()).await;")
.expect("the display handoff");
let placement = source[handoff..]
.find("crate::placement::desired_topology(&roster, &hashes, &self.session)")
.expect("placement follows the display handoff");
assert!(
placement > 0,
"the handoff returns unit to converge; an unreadable display does not block placement"
);
}

fn observed_person(person: &str, organization: &str, hash: &str) -> ObservedTopology {
ObservedTopology {
session_exists: true,
Expand Down
Loading