From eae141786fbfc913d0ed4d9367027efd7eb233a0 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Sat, 29 Aug 2026 11:06:30 -0500 Subject: [PATCH] fix(setup): the wizard was asking the poller gate a question it does not answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIRISClient#21. On a folded agent's first run the wizard drops the AI step, so setup completes with no LLM and the user reaches chat to silence. NOT A RACE — OR NOT ONLY ONE. The issue reads the 80-line gap between `clientMode=NODE` and `clientMode=AGENT` as the wizard finalising on a provisional value, and suggests waiting for the probe. Waiting alone would not have fixed it: at the first gate line the mode was NODE **correctly**, and it would still be NODE however long we waited, because the brain reports `setup_required: true` and `clientModeFrom` demotes an unconfigured brain to NODE (CIRISAgent#1075). THE CIRCULARITY IS NAMED IN THE ARM'S OWN COMMENT. That demotion was added because an unconfigured brain 503s every AGENT poller, and the list of victims includes: addLlmProvider 503 <- so the user could not configure an escape So: the brain is unconfigured, therefore the gate says NODE, therefore the wizard drops the AI step, therefore the brain stays unconfigured. The fix that stopped the 503s became the reason configuration never happens. `clientMode` answers "should the agent surfaces and AGENT-only pollers run", and demoting there is right. The wizard asks "is there a brain here that needs configuring", where being unconfigured is the REASON TO OFFER THE STEP. Using one for the other is a category error, not a timing bug. `ModeProbe.brainPresent` = `folded && reachable`, independent of readiness — because readiness must not gate the step that produces readiness. The poller gate is unchanged. AND THE PROVISIONAL-VALUE HALF IS REAL TOO. `setupHasAgent` was `clientMode?.isAgent ?: false`, so "not probed yet" became "no brain", and `SetupViewModel` is keyed on it while `isFinalSetupStep` reads it — the step list is FIXED at composition. `Screen.Setup` now holds on StartupScreen until the probe answers rather than composing the shape on a default. Same distinct-zeroes rule as CapabilityWire's null-versus-empty: could-not-determine must not become a definite answer. Tested against the exact gate line from the issue's log — SETUP / 0 services / folded / reachable / unconfigured / fabric-node. The poller gate still returns NODE there, and `brainPresent` is true at the same instant. Mutation: let readiness back into `brainPresent` and the two decisive cases fail. Closes #21. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- client/VENDORING.md | 2 +- .../kotlin/ai/ciris/mobile/shared/CIRISApp.kt | 52 +++++++++++- .../ciris/mobile/shared/models/ClientMode.kt | 27 +++++- .../mobile/shared/models/WizardShapeTest.kt | 84 +++++++++++++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index 4f1f59c..0b72449 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `c6c17a6c3a83b6d110a3b94fc6ac3aaf74ceb0c567000ad251048d1c82cf79a4` +**state digest:** `95272e65abc8c422702e70314997e2cbff5fd10866af5ecc8e3b873b30bd3513` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 2df14de..80d84ca 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -427,6 +427,11 @@ fun CIRISApp( // lights, "agent" wording, the WORK-state wait). null = not probed yet. // nodeVersion drives the non-blocking version-mismatch banner. var clientMode by remember { mutableStateOf(null) } + /** + * Is a brain folded here and answering — regardless of whether it is + * configured yet. `null` until the first probe answers; see [setupHasAgent]. + */ + var brainPresent by remember { mutableStateOf(null) } var nodeVersion by remember { mutableStateOf(null) } val isAgentMode = clientMode?.isAgent ?: true // default to agent wording until probed // The landing surface, from the PROBE (CIRISServer#479). Deliberately NOT @@ -725,7 +730,26 @@ fun CIRISApp( // KEYED on the probe (CIRISServer#479): `viewModel {}` caches its instance, // so a VM built before the mode landed would keep the pre-probe answer for // the app's whole life — the exact staleness this conversion exists to end. - val setupHasAgent = clientMode?.isAgent ?: false + // THE WIZARD'S QUESTION IS NOT THE POLLER GATE'S QUESTION. + // + // This was `clientMode?.isAgent ?: false`, and both halves were wrong for + // setup (CIRISClient#21): + // + // isAgent demotes a folded brain that reports itself unconfigured — and + // on a first run it IS unconfigured, which is what the wizard + // exists to fix. So the gate said NODE, the wizard dropped the + // AI step, the brain stayed unconfigured, and the user reached + // chat to silence. The arm's own comment names the circularity: + // it was added because such a brain 503s `addLlmProvider`, "so + // the user could not configure an escape". + // ?: false collapses "not probed yet" into "no brain", so the wizard + // composed its step list on a value that meant unknown. Same + // distinct-zeroes rule as CapabilityWire's null-versus-empty: + // could-not-determine must not become a definite answer. + // + // `brainPresent` is folded && reachable — is there a brain here to + // configure — and null still means nobody has asked. + val setupHasAgent = brainPresent == true val setupViewModel: SetupViewModel = viewModel(key = "setup-$setupHasAgent") { SetupViewModel(apiClient, setupHasAgent) } // Set HA addon mode if running in Home Assistant addon context @@ -1147,6 +1171,9 @@ fun CIRISApp( } } clientMode = mode + // The wizard's question, from the same probe. See setupHasAgent. + brainPresent = probe.brainPresent + platformLog(TAG, "[INFO][gate] brainPresent=${probe.brainPresent} (folded/reachable, independent of readiness)") // Node mode has no 22 cognitive service lights — drive the count // from the gate rather than the hardcoded agent default. startupViewModel.setClientMode(mode) @@ -2349,7 +2376,28 @@ fun CIRISApp( } Screen.Setup -> { - platformLog(TAG, "[DEBUG][Screen.Setup] Rendering setup screen") + // DO NOT COMPOSE THE WIZARD ON AN UNANSWERED PROBE. + // + // `SetupViewModel` is keyed on `setupHasAgent` and + // `isFinalSetupStep` reads it, so the step list is FIXED at + // composition. Composing while the probe is outstanding fixes + // that shape on a default, and the shape is what decides whether + // the AI step exists at all — the log in CIRISClient#21 shows + // the wizard declaring JOIN_FEDERATION final 80 lines before the + // gate resolved. + // + // It is a race, so it hits slow machines and not fast ones, + // which is the worst way for it to reach a user: a finished + // install with no LLM and a chat screen that answers nothing. + // + // Waiting is safe — the probe is bounded by the startup budget + // and StartupScreen is what the user is already looking at. + if (brainPresent == null) { + platformLog(TAG, "[INFO][Screen.Setup] holding — brainPresent not yet probed") + StartupScreen(viewModel = startupViewModel) + return@Box + } + platformLog(TAG, "[DEBUG][Screen.Setup] Rendering setup screen (hasAgent=$setupHasAgent)") SetupScreen( hasAgent = setupHasAgent, viewModel = setupViewModel, diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt index 331cd29..9d8ed5d 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt @@ -51,7 +51,30 @@ enum class ClientMode { * cannot. [undetermined] is a RETRY signal, not a verdict: the caller must * re-probe (bounded) and must NOT latch [mode] as final while it is set. */ -data class ModeProbe(val mode: ClientMode, val undetermined: Boolean) +data class ModeProbe( + val mode: ClientMode, + val undetermined: Boolean, + /** + * IS THERE A BRAIN HERE AT ALL — regardless of whether it is configured yet. + * + * [mode] answers a NARROWER question than it looks like it answers: "should + * the agent surfaces and the AGENT-only pollers run". It deliberately + * demotes a folded brain that reports itself unconfigured (CIRISAgent#1075) + * because such a brain 503s every agent poller — including, per the comment + * on that arm, `addLlmProvider`, "so the user could not configure an escape". + * + * The SETUP WIZARD asks a different question: is there a brain that needs + * configuring? For that, being unconfigured is the REASON TO OFFER THE STEP, + * not to hide it — and using [mode] made it circular. On a folded agent's + * first run the brain is unconfigured by definition, so the gate said NODE, + * so the wizard dropped the AI step, so the brain stayed unconfigured and + * the user reached chat to silence (CIRISClient#21). + * + * `folded && reachable` — a brain that exists and is answering. Nothing + * about its readiness, which is the wizard's job to fix. + */ + val brainPresent: Boolean = false, +) /** The runtime's own answer to "what am I" — `data.role` in `/v1/system/health`. */ const val ROLE_AGENT = "agent" @@ -207,6 +230,8 @@ fun clientModeFrom( clientModeFrom(cognitiveState, serviceCount, brainUnconfigured, role) }, undetermined = agentFolded && !agentReachable && !brainUnconfigured && !declaredAgent, + // Independent of readiness on purpose — see the field doc. + brainPresent = agentFolded && agentReachable, ) } diff --git a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt new file mode 100644 index 0000000..9d18b87 --- /dev/null +++ b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/WizardShapeTest.kt @@ -0,0 +1,84 @@ +package ai.ciris.mobile.shared.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * CIRISClient#21 — the wizard's question is not the poller gate's question. + * + * The reproduction from the field log: a folded agent's first run reports + * `folded=true, reachable=true` and `setup_required=true`, so the brain is + * unconfigured, so `clientMode` is NODE — and the wizard used that to drop the + * step that configures it. + */ +class WizardShapeTest { + + /** Exactly the first gate line in the issue's log. */ + private fun firstRunFoldedAgent() = clientModeFrom( + cognitiveState = "SETUP", + serviceCount = 0, + agentFolded = true, + agentReachable = true, + brainUnconfigured = true, + role = ROLE_FABRIC_NODE, + ) + + @Test + fun the_poller_gate_still_says_node_and_that_is_correct() { + // Unchanged on purpose: an unconfigured brain 503s every agent poller, + // which is why the demotion exists (CIRISAgent#1075). + assertEquals(ClientMode.NODE, firstRunFoldedAgent().mode) + } + + @Test + fun but_the_wizard_can_see_there_is_a_brain_to_configure() { + // The same probe, the same instant, the answer the wizard needs. + assertTrue( + firstRunFoldedAgent().brainPresent, + "folded && reachable — being unconfigured is why the AI step must be OFFERED", + ) + } + + @Test + fun brain_presence_does_not_depend_on_readiness() { + // The circularity in one assertion: readiness must not gate the step + // that produces readiness. + val unconfigured = firstRunFoldedAgent() + val configured = clientModeFrom( + cognitiveState = "WORK", serviceCount = 22, + agentFolded = true, agentReachable = true, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertEquals(unconfigured.brainPresent, configured.brainPresent) + assertTrue(configured.mode == ClientMode.AGENT && unconfigured.mode == ClientMode.NODE, + "the poller gate differs across these two; brainPresent does not") + } + + @Test + fun a_bare_node_has_no_brain_to_configure() { + // The other direction: no false AI step on a node that carries none. + val bare = clientModeFrom( + cognitiveState = null, serviceCount = 0, + agentFolded = false, agentReachable = false, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertFalse(bare.brainPresent) + assertEquals(ClientMode.NODE, bare.mode) + } + + @Test + fun a_folded_brain_that_is_not_answering_is_not_yet_present() { + // Undetermined: a brain exists but has not spoken. Not a licence to + // compose the agent wizard, and not a claim there is no brain either — + // the caller waits, which is what Screen.Setup now does. + val booting = clientModeFrom( + cognitiveState = null, serviceCount = 0, + agentFolded = true, agentReachable = false, + brainUnconfigured = false, role = ROLE_FABRIC_NODE, + ) + assertTrue(booting.undetermined) + assertFalse(booting.brainPresent, "not answering yet is not present") + } +}