Skip to content

Commit 475807e

Browse files
authored
Merge pull request #171 from compoundingtech/fix/transition-companion-doctor
Treat the transition companion absence as healthy
2 parents a2f8a73 + a007f04 commit 475807e

2 files changed

Lines changed: 202 additions & 29 deletions

File tree

now.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ The living handoff for whoever owns fabric next (there was none before; keep thi
44
current). This records what is DONE, what is IN FLIGHT, and what is NEXT — the
55
things the repo history alone does not carry.
66

7-
_Last updated: 2026-09-04 by Silber.fabric-codex. Main is ahead of the fleet.
8-
Ask Silber.cos for the current fleet build. Do not cut or deploy a release
9-
without Silber.cos._
7+
_Last updated: 2026-09-05 by Silber.fabric-codex. Silber and hetz run Release A.
8+
Bluey was away and last reported the earlier fleet build. Ask Silber.cos before
9+
each release or deployment._
1010

1111
For extraction steps 6 and 8, merge on green without asking for a separate
1212
Silber.cos approval. Silber.cos holds the step 7 activation gate and every
1313
release or deployment gate. Before any deployment, prove matched-pair rollback
1414
on hetz. macOS needs a detached update supervisor; the current change adds it.
1515

16-
## Current main — 2026-09-04
16+
## Current main — 2026-09-05
1717

1818
PR #154 made the current-connection fault class visible. PR #155 froze the
1919
sync-process extraction plan. PR #156 made `peers.toml` formatting stable.
@@ -46,7 +46,7 @@ PR #164 added a detached macOS update supervisor. PR #165 added pair-aware,
4646
fail-closed rollback. PR #166 made real launchd and systemd tests exercise that
4747
reader. All three changes are merged.
4848

49-
The fleet build `0.2.1+48208e4` has only single-binary supervisor logic. The
49+
The pre-transition fleet build `0.2.1+48208e4` has only single-binary supervisor logic. The
5050
rollback runs this old binary on purpose because it is the machine's known-good
5151
copy. New rollback logic therefore cannot protect the release that introduces
5252
it.
@@ -79,7 +79,33 @@ exact status and manual recovery commands. PR #169 keeps expected companion
7979
absence silent on launchd and systemd. Step 7 restores paired archives after
8080
Release A reaches every machine.
8181

82-
The local library suite passed 499 tests with five measurements ignored. The
82+
PR #170 set the operator-visible version boundary. Release A
83+
`v0.2.2+a2f8a73` is published from exact commit `a2f8a73`. Its three archives
84+
passed checksum and one-member checks. Each archive contains only `fabric`.
85+
86+
Hetz and Silber now run `0.2.2+a2f8a73`. Hetz restarted through systemd and its
87+
detached supervisor confirmed the new version. A hetz-side observer sent 360
88+
pings to Silber over 451.5 seconds. No ping failed. The restart-crossing ping
89+
returned through the relay in 835 ms, so observed unreachability was zero.
90+
91+
The same observer disproved the tentative sync-starvation explanation for slow
92+
pings. After both machines ran Release A, 24 of 341 pings exceeded one second,
93+
seven exceeded three seconds, and the maximum was 9.212 seconds. The connection
94+
stayed alive with zero attach failures. This remains unassigned work.
95+
96+
The old running updater performs the transition install. It therefore prints
97+
the old premature `control-socket ready` line. On macOS, it also cannot schedule
98+
the detached supervisor that Release A first introduces. Bluey's instructions
99+
must tell Nathan to ignore that line, wait two minutes, and use manual recovery
100+
if the new version does not answer.
101+
102+
Release A intentionally has no companion binary or service while embedded sync
103+
remains active. Doctor incorrectly reports both absences as problems and tells
104+
the operator to run `fabric service install`. This change makes that exact
105+
transition green. A present companion binary with a missing service remains a
106+
problem. An enabled service with a missing binary now asks for a matched pair.
107+
108+
The local library suite passed 513 tests with five measurements ignored. The
83109
binary suite passed 19 tests, and the update contract passed eight tests. The
84110
five-second latency property measured 502 records, seven scans, a 10.001166 ms
85111
source maximum, and a 20.102959 ms delivery maximum. The explicit live-launchd

src/doctor.rs

Lines changed: 170 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ pub struct Facts {
152152
/// unit file exists. Presence is not enablement.
153153
pub service: ServiceEnablement,
154154
pub sync_service: ServiceEnablement,
155+
/// Whether the managed install path has a sibling `fabric-sync` binary.
156+
/// `None` means the path could not be established.
157+
pub sync_companion_binary: Option<bool>,
155158
pub sync_runtime: crate::control::SyncRuntimeStatus,
156159
pub own_version: String,
157160
pub peers: Vec<PeerFact>,
@@ -175,9 +178,19 @@ impl Facts {
175178
}
176179
}
177180

181+
fn is_fabric_only_transition(facts: &Facts) -> bool {
182+
facts.manages_service
183+
&& facts.daemon_running
184+
&& facts.sync_service == ServiceEnablement::NotInstalled
185+
&& facts.sync_companion_binary == Some(false)
186+
&& facts.sync_runtime.owner == "embedded"
187+
&& facts.sync_runtime.companion == "absent"
188+
}
189+
178190
/// Turn facts into findings. Pure, so every outcome is testable.
179191
pub fn diagnose(facts: &Facts) -> Vec<Finding> {
180192
let fresh = facts.never_configured();
193+
let fabric_only_transition = is_fabric_only_transition(facts);
181194
let mut out = Vec::new();
182195

183196
out.push(if facts.has_identity {
@@ -239,29 +252,65 @@ pub fn diagnose(facts: &Facts) -> Vec<Finding> {
239252
});
240253

241254
if facts.manages_service {
242-
out.push(match facts.sync_service {
243-
ServiceEnablement::Enabled => Finding::new(
255+
out.push(if fabric_only_transition {
256+
Finding::new(
244257
"sync service",
245258
Verdict::Ok,
246-
"the sync companion is installed and enabled",
247-
),
248-
ServiceEnablement::PresentNotEnabled => Finding::new(
249-
"sync service",
250-
if fresh { Verdict::Setup } else { Verdict::Problem },
251-
"the sync companion is installed but not enabled",
252-
)
253-
.with_action("fabric service install"),
254-
ServiceEnablement::NotInstalled => Finding::new(
255-
"sync service",
256-
if fresh { Verdict::Setup } else { Verdict::Problem },
257-
"the sync companion service is absent",
259+
"the fabric-only transition does not install a sync companion service",
258260
)
259-
.with_action("fabric service install"),
260-
ServiceEnablement::Unknown => Finding::new(
261-
"sync service",
262-
Verdict::Unknown,
263-
"could not tell whether the sync companion is enabled",
264-
),
261+
} else {
262+
match (facts.sync_service, facts.sync_companion_binary) {
263+
(ServiceEnablement::Enabled, Some(true)) => Finding::new(
264+
"sync service",
265+
Verdict::Ok,
266+
"the sync companion is installed and enabled",
267+
),
268+
(ServiceEnablement::Enabled, Some(false)) => Finding::new(
269+
"sync service",
270+
Verdict::Problem,
271+
"the sync companion service is enabled, but its binary is absent",
272+
)
273+
.with_action("install a matched fabric and fabric-sync pair"),
274+
(ServiceEnablement::Enabled, None) => Finding::new(
275+
"sync service",
276+
Verdict::Unknown,
277+
"could not verify the binary used by the sync companion service",
278+
),
279+
(ServiceEnablement::PresentNotEnabled, Some(false)) => Finding::new(
280+
"sync service",
281+
if fresh { Verdict::Setup } else { Verdict::Problem },
282+
"the sync companion service is not enabled, and its binary is absent",
283+
)
284+
.with_action("install a matched fabric and fabric-sync pair"),
285+
(ServiceEnablement::PresentNotEnabled, _) => Finding::new(
286+
"sync service",
287+
if fresh { Verdict::Setup } else { Verdict::Problem },
288+
"the sync companion is installed but not enabled",
289+
)
290+
.with_action("fabric service install"),
291+
(ServiceEnablement::NotInstalled, Some(true)) => Finding::new(
292+
"sync service",
293+
if fresh { Verdict::Setup } else { Verdict::Problem },
294+
"the sync companion service is absent",
295+
)
296+
.with_action("fabric service install"),
297+
(ServiceEnablement::NotInstalled, Some(false)) => Finding::new(
298+
"sync service",
299+
if fresh { Verdict::Setup } else { Verdict::Problem },
300+
"the sync companion binary and service are absent",
301+
)
302+
.with_action("install a matched fabric and fabric-sync pair"),
303+
(ServiceEnablement::NotInstalled, None) => Finding::new(
304+
"sync service",
305+
Verdict::Unknown,
306+
"could not tell whether the absent sync service has a companion binary",
307+
),
308+
(ServiceEnablement::Unknown, _) => Finding::new(
309+
"sync service",
310+
Verdict::Unknown,
311+
"could not tell whether the sync companion is enabled",
312+
),
313+
}
265314
});
266315
}
267316

@@ -272,16 +321,30 @@ pub fn diagnose(facts: &Facts) -> Vec<Finding> {
272321
"the sync runtime is unavailable",
273322
)
274323
.with_action("start the fabric daemon and sync companion")
275-
} else if facts.manages_service && facts.sync_runtime.companion == "absent" {
324+
} else if fabric_only_transition {
276325
Finding::new(
326+
"sync runtime",
327+
Verdict::Ok,
328+
"embedded sync is active; this fabric-only transition has no companion",
329+
)
330+
} else if facts.manages_service && facts.sync_runtime.companion == "absent" {
331+
let finding = Finding::new(
277332
"sync runtime",
278333
Verdict::Problem,
279334
format!(
280335
"{} sync is active, but the supervised companion is absent",
281336
facts.sync_runtime.owner
282337
),
283-
)
284-
.with_action("fabric service install")
338+
);
339+
match facts.sync_companion_binary {
340+
Some(true) => finding.with_action("fabric service install"),
341+
Some(false) => finding.with_action("install a matched fabric and fabric-sync pair"),
342+
None => Finding::new(
343+
"sync runtime",
344+
Verdict::Unknown,
345+
"the sync companion is absent, and its binary could not be checked",
346+
),
347+
}
285348
} else if facts.sync_runtime.companion == "incompatible" {
286349
Finding::new(
287350
"sync runtime",
@@ -739,6 +802,7 @@ mod tests {
739802
daemon_running: true,
740803
service: ServiceEnablement::Enabled,
741804
sync_service: ServiceEnablement::Enabled,
805+
sync_companion_binary: Some(true),
742806
sync_runtime: crate::control::SyncRuntimeStatus {
743807
owner: "embedded".to_string(),
744808
companion: "standby".to_string(),
@@ -790,6 +854,82 @@ mod tests {
790854
assert!(opening(&configured()).is_none());
791855
}
792856

857+
#[test]
858+
fn a_fabric_only_transition_does_not_offer_a_stranding_action() {
859+
let mut facts = configured();
860+
facts.sync_service = ServiceEnablement::NotInstalled;
861+
facts.sync_companion_binary = Some(false);
862+
facts.sync_runtime.companion = "absent".to_string();
863+
864+
let findings = diagnose(&facts);
865+
for finding in find(&findings, "sync service")
866+
.into_iter()
867+
.chain(find(&findings, "sync runtime"))
868+
{
869+
assert_eq!(finding.verdict, Verdict::Ok, "wrong finding: {finding:?}");
870+
assert!(
871+
finding.action.is_none(),
872+
"the transition offered an unsafe action: {finding:?}"
873+
);
874+
}
875+
assert_eq!(exit_code(&findings), 0);
876+
}
877+
878+
#[test]
879+
fn a_paired_binary_without_its_service_stays_loud() {
880+
let mut facts = configured();
881+
facts.sync_service = ServiceEnablement::NotInstalled;
882+
facts.sync_runtime.companion = "absent".to_string();
883+
884+
let findings = diagnose(&facts);
885+
for finding in find(&findings, "sync service")
886+
.into_iter()
887+
.chain(find(&findings, "sync runtime"))
888+
{
889+
assert_eq!(finding.verdict, Verdict::Problem);
890+
assert_eq!(finding.action.as_deref(), Some("fabric service install"));
891+
}
892+
}
893+
894+
#[test]
895+
fn an_enabled_companion_service_without_its_binary_stays_loud() {
896+
let mut facts = configured();
897+
facts.sync_companion_binary = Some(false);
898+
facts.sync_runtime.companion = "absent".to_string();
899+
900+
let findings = diagnose(&facts);
901+
for finding in find(&findings, "sync service")
902+
.into_iter()
903+
.chain(find(&findings, "sync runtime"))
904+
{
905+
assert_eq!(finding.verdict, Verdict::Problem);
906+
assert!(
907+
finding
908+
.action
909+
.as_deref()
910+
.is_some_and(|action| action.contains("matched")),
911+
"the missing binary got the wrong action: {finding:?}"
912+
);
913+
assert_ne!(finding.action.as_deref(), Some("fabric service install"));
914+
}
915+
}
916+
917+
#[test]
918+
fn an_unknown_companion_binary_offers_no_action() {
919+
let mut facts = configured();
920+
facts.sync_companion_binary = None;
921+
facts.sync_runtime.companion = "absent".to_string();
922+
923+
let findings = diagnose(&facts);
924+
for finding in find(&findings, "sync service")
925+
.into_iter()
926+
.chain(find(&findings, "sync runtime"))
927+
{
928+
assert_eq!(finding.verdict, Verdict::Unknown);
929+
assert!(finding.action.is_none(), "unknown state offered an action");
930+
}
931+
}
932+
793933
#[test]
794934
fn a_peer_with_no_grants_is_an_actionable_problem() {
795935
let mut facts = configured();
@@ -820,6 +960,7 @@ mod tests {
820960
daemon_running: false,
821961
service: ServiceEnablement::NotInstalled,
822962
sync_service: ServiceEnablement::NotInstalled,
963+
sync_companion_binary: Some(false),
823964
sync_runtime: crate::control::SyncRuntimeStatus {
824965
owner: "unavailable".to_string(),
825966
companion: "unknown".to_string(),
@@ -871,6 +1012,7 @@ mod tests {
8711012
// what the gatherer sees.
8721013
service: ServiceEnablement::Enabled,
8731014
sync_service: ServiceEnablement::Enabled,
1015+
sync_companion_binary: Some(true),
8741016
sync_runtime: crate::control::SyncRuntimeStatus {
8751017
owner: "unavailable".to_string(),
8761018
companion: "unknown".to_string(),
@@ -1438,6 +1580,10 @@ where
14381580

14391581
let service = crate::service::service_enablement();
14401582
let sync_service = crate::service::sync_service_enablement();
1583+
let sync_companion_binary = crate::update::managed_binary_path()
1584+
.and_then(|path| crate::update::companion_binary_path(&path))
1585+
.map(|path| path.exists())
1586+
.ok();
14411587

14421588
let mut own_version = env!("CARGO_PKG_VERSION").to_string();
14431589
let mut daemon_running = false;
@@ -1535,6 +1681,7 @@ where
15351681
daemon_running,
15361682
service,
15371683
sync_service,
1684+
sync_companion_binary,
15381685
sync_runtime,
15391686
own_version,
15401687
peers,

0 commit comments

Comments
 (0)