Skip to content

2.11.0: "Run without AI" boots ciris-server and the client, and nothing else (#1149) - #1150

Open
emooreatx wants to merge 53 commits into
mainfrom
feat/run-without-ai-node-only
Open

2.11.0: "Run without AI" boots ciris-server and the client, and nothing else (#1149)#1150
emooreatx wants to merge 53 commits into
mainfrom
feat/run-without-ai-node-only

Conversation

@emooreatx

@emooreatx emooreatx commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #1149.

What "Run without AI" now means

After a setup that chose it, the process is ciris-server and the client, and nothing else: no runtime, no API adapter, no node fold, no LLM. Zero CIRISAgent code beyond starting the node and the client.

  • Recorded at setup-complete as CIRIS_RUN_WITHOUT_AI=true next to the existing CIRIS_SERVICES_DISABLED=true, plus CIRIS_NODE_KEY_ID=<alias> so node-only boots serve the identity the wizard claimed (the wheel's desktop default ciris-client would mint a second one).
  • The post-setup restart becomes the node. Instead of resuming the runtime, the handler shuts the brain down and main.py's exit path execs python -m ciris_server --headless --home <home> --key-id <alias> in place — same pid, so ciris-agent keeps waiting on one process — and the node's read API comes up on :4243.
  • Every later boot hands off first. ciris_engine/node_only.py (imports nothing from the engine) reads <home>/.env and uses the ciris-server wheel's own launcher helpers: ciris-agent spawns the node and launches the desktop client against http://localhost:4243; ciris-agent --server, python main.py, Android mobile_main and iOS kmp_main serve the node in-process. CIRIS_RUN_WITHOUT_AI=false in the environment brings the brain back for one run; --help is never hijacked.
  • Setup-complete drops run_without_ai: 'Run without AI' is chosen, never recorded, and the next boot aborts on a critical llm_service #1149 ask 3: llm_provider / llm_api_key are optional when run_without_ai is true (model validator). A missing key without the choice still only degrades the brain.

Client contract (CIRISClient)

After a run-without-AI setup the backend serves only :4243: :8080 goes away when the brain exits. Desktop is handled by the launcher (CIRIS_API_URL=http://localhost:4243, as the wheel's own desktop mode does). On mobile the PythonRuntime health poll (localhost:8080/v1/system/health) must switch to the node's /health on 4243 when runWithoutAi was chosen, and the app must use the node base URL. Happy to pair on that; the flag is now the only signal, as the issue said.

Also here: the console-script collision, fixed

This package installed ciris-server and ciris-desktop console scripts, and so does the ciris-server wheel. ciris-agent depends on ciris-server, so pip wrote ours last and ours won: on any machine with the agent installed, ciris-server was the agent's headless API server. The desktop client spawns ciris-server --home <appdata>/ciris --key-id ciris-client and documents "started an agent and NEVER STARTED THE NODE" as the defect it fails fast on — with our script winning, that spawn hits Click's unknown-argument error on --home. A later pip install -U ciris-server flips it back and silently changes what ciris-desktop does.

setup.py now installs only ciris-agent. The agent's headless mode is ciris-agent --server; the desktop launcher is a bare ciris-agent. Nothing in this repo invoked either dropped name. This is a packaging change for anyone whose scripts call ciris-server expecting the agent — they want ciris-agent --server.

Troubleshooting

Every run-without-AI decision and step prints on stdout/stderr and to the ciris.node_only logger under one prefix, so grep RUN-WITHOUT-AI is the whole story:

[RUN-WITHOUT-AI] the owner chose to run without AI: this process is ciris-server and the client, nothing else (home=/home/u/ciris key_id=ciris-agent-bootstrap decided_by=/home/u/ciris/.env node_logs=/home/u/ciris/logs)
[RUN-WITHOUT-AI] starting the node as a child: ['/usr/bin/python3', '-m', 'ciris_server', '--headless', '--home', '/home/u/ciris', '--key-id', 'ciris-agent-bootstrap']
[RUN-WITHOUT-AI] node pid=12345; node logs in /home/u/ciris/logs
[RUN-WITHOUT-AI] waiting for the node's read API at http://localhost:4243/health (60s)
[RUN-WITHOUT-AI] node read API is up
[RUN-WITHOUT-AI] launching the desktop client against http://localhost:4243 (CIRIS_API_URL)

The unhappy paths are named rather than inferred: an unresolved key alias is an ERROR line saying the node will not carry the identity the wizard claimed; a missing ciris-server wheel exits 2 with the pip install line and the CIRIS_RUN_WITHOUT_AI=false escape hatch instead of an ImportError traceback; a node that dies within 2s reports its exit code, its log directory and the usual causes; a failed in-place exec says so and lets the process exit normally so the next boot hands off. Nothing is printed when node-only mode was never requested.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lymdxahy3PHqiXz4KEJJWN

… else (#1149) — 2.11.0

The wizard's choice was sent and read nowhere; the next boot aborted on a
critical llm_service. Main already records CIRIS_SERVICES_DISABLED for it,
which keeps the BRAIN degraded. The owner asked for no brain at all.

Setup-complete now also writes CIRIS_RUN_WITHOUT_AI=true and the node's
keystore alias (CIRIS_NODE_KEY_ID) beside it, and instead of resuming the
runtime it shuts the brain down; main.py's exit path execs
`python -m ciris_server --headless --home <home> --key-id <alias>` in place,
so the same pid keeps serving and the node comes up on :4243 with the
identity the wizard just claimed (the wheel's own desktop default,
`ciris-client`, would mint a second one).

Every later boot asks ciris_engine/node_only.py FIRST -- an import-light
module that reads <home>/.env and calls the ciris-server wheel's own
launcher helpers: `ciris-agent` spawns the node and launches the client
against :4243; `ciris-agent --server`, `python main.py`, the Android
mobile_main and the iOS kmp_main serve the node in-process. No runtime, no
API adapter, no node fold, no LLM. CIRIS_RUN_WITHOUT_AI=false in the
environment brings the brain back for one run; --help is never hijacked.

Also (#1149 ask 3): llm_provider / llm_api_key are optional when
run_without_ai is true, enforced by a model validator; an accidental
missing key still only degrades the brain -- it is not a decision to have
no brain.

Tests: env parsing and precedence; node-only config and command; run_headless
argv hand-off; exec argv; run_desktop spawn -> health -> client -> teardown
against fake wheel helpers; every CLI entry point hands off and --help does
not; the flag and alias are written (alias failure tolerated); the validator;
the restart task ends the brain after the response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lymdxahy3PHqiXz4KEJJWN
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

emooreatx and others added 3 commits September 6, 2026 10:05
… hand-off narrates itself

TWO NAMES THAT WERE NEVER OURS. This package installed `ciris-server` and
`ciris-desktop` console scripts alongside `ciris-agent`, and so does the
ciris-server wheel. ciris-agent DEPENDS on ciris-server, so pip wrote ours
last and won: on any machine with the agent installed, `ciris-server` was
the headless brain. The desktop client spawns
`ciris-server --home <appdata>/ciris --key-id ciris-client` and documents
"started an agent and NEVER STARTED THE NODE" as the defect it fails fast
on -- with our script winning, that spawn hits Click's unknown-argument
error on --home. A later `pip install -U ciris-server` flips it back and
silently changes what `ciris-desktop` does. Neither order is correct, so
the agent gives up both names: the headless brain is `ciris-agent --server`
and the desktop launcher is a bare `ciris-agent`. Nothing in this repo
invoked either name.

TROUBLESHOOTING THE HAND-OFF. Every run-without-AI decision and step is now
announced on stdout/stderr AND the `ciris.node_only` logger under one
greppable prefix, `[RUN-WITHOUT-AI]`: which file or environment variable
decided it, the home and key alias in use (an unresolved alias is an ERROR
line naming the consequence -- a different node identity than the wizard
claimed), the exact node command, the node's log directory, the health
wait, the client URL, and every exit code. A missing ciris-server wheel
exits 2 with the pip line and the escape hatch instead of an ImportError
traceback; a failed in-place exec says so and lets the process exit
normally so the next boot hands off instead of dying here. Silence is
reserved for the case where nobody asked for node-only mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lymdxahy3PHqiXz4KEJJWN
"Headless brain" reads like a third mode. There are two: the agent runtime
(with the desktop UI by default, or `--server` without it) and, after a
run-without-AI setup, the ciris-server node with no agent runtime at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lymdxahy3PHqiXz4KEJJWN
…se always lands first (#1149)

The client asked which of two things is true at the handover: (a) complete
responds fully and THEN the runtime stops, or (b) the shutdown races the
response and a dropped connection has to be read as success. They asked for
(a), because "the request failed, therefore it worked" is a rule that
eventually hides a real failure.

It was (a) with a 0.5s sleep, which is (a) by margin rather than by
construction. It is now (a) by construction: the shutdown is handed to
FastAPI's BackgroundTasks, which Starlette runs only after the response has
been sent. A dropped connection on /v1/setup/complete stays a real failure.

The test asserts the mechanism, not a timing: the handler takes
BackgroundTasks, registration runs nothing, and awaiting the task set is
what stops the runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
emooreatx and others added 2 commits September 6, 2026 10:54
…onment override (#1151)

CIRISClient 0.5.203 parses this key out of the same file and documented the
set it accepts: true / 1 / yes. Mine also accepted "on", which is a real
divergence, not a cosmetic one -- a value one side takes and the other does
not sends the agent to :4243 and the client to :8080, which presents as a
dead app with nothing in either log saying why. The set is now identical on
both sides, and it is the same one CIRIS_SERVICES_DISABLED has always used
(service_initializer.py, llm_providers.py): one convention, three readers.

The environment override had the same failure mode and I had shipped it as
a convenience. The client decides its endpoint from the FILE and cannot see
this process's environment, so an environment value that disagrees with the
file desynchronizes them in EITHER direction: env=false + file=true serves
:8080 while the client looks at :4243, and env=true + file absent does the
reverse. It stays -- it is genuinely useful headless -- but it now says so
on stderr, naming both ports and the fact that it is only safe with no
client attached.

Tests parametrize the shared vocabulary in both directions, including the
spellings that must NOT be truthy, so a future widening on one side fails
here instead of on a device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s parked when it fires

Found by running the feature end to end instead of trusting its unit tests,
which all passed against a design that could not work.

The handover asked the runtime to shut down and let main.py's exit path exec
into the node. That is correct only while the runtime is RUNNING, and the
moment this feature fires is exactly the moment it is not: during first-run
the runtime is parked waiting for the wizard, so nothing awaits its shutdown
event. Observed on a real boot: `RUNTIME SHUTDOWN REQUESTED` logged, and
:8080 still serving nine minutes later with the process still main.py. The
feature was inert in the only flow that reaches it.

The background task now performs the handover: it tells the API server to
stop accepting on :8080 -- so a client that has not re-pointed gets
connection-refused rather than a socket that accepts and never answers --
lets the response finish leaving the transport, and execs into the node.
main.py's exit path stays as the second door, for a setup re-run against a
live runtime. The server task is deliberately not awaited: this coroutine is
one of that server's own in-flight requests.

Proven end to end on 0.5.198, isolated home:
  POST /v1/setup/complete {"run_without_ai": true}  -> HTTP 200 in 0.06s
  t+2s: :8080 connection-refused, :4243 serving, SAME pid now `-m ciris_server`
  reboot on that home: node serving in 4s, zero runtime log lines, :8080 refused

The parked-runtime defect is broader than this feature and is filed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two move together because 0.5.199 pins `ciris-client>=0.5.203`, and
0.5.203 is the client half of run-without-AI: its wizard asks the AI
question first and a "no" sends it to the node on :4243 instead of :8080,
which is exactly what 2.11.0's hand-off requires. Adopting either alone
would pair a client that follows the switch with a node that has not made
it, or the reverse.

0.5.199 also answers the readiness contract the client asked for
(CIRISServer#548): a TCP accept on :4243 already means SERVING, there is no
`starting` state, and the launcher helper that claimed one was a bug -- now
tested against the contract. I had passed that helper's predicate to the
client as authoritative; the correction is on CIRISAgent#1149 and #548.

Verified on the published wheel: crossing composes (crossed ->
already_widened), attestation_promote is gone, the Edge surface is intact,
and `task_update_status(..., "rejected")` is accepted (#1077).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedpypi/​ciris-client@​0.5.202 ⏵ 0.5.20910010010010070
Updatedpypi/​ciris-server@​0.5.198 ⏵ 0.5.20399 +110010010070

View full report

…er 0.5.199

chore(ios): refresh iOS substrate binaries to ciris-server 0.5.199
@cla-assistant

cla-assistant Bot commented Sep 6, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ emooreatx
❌ ciris-ios-substrate-refresh[bot]


ciris-ios-substrate-refresh[bot] seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

1 similar comment
@cla-assistant

cla-assistant Bot commented Sep 6, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ emooreatx
❌ ciris-ios-substrate-refresh[bot]


ciris-ios-substrate-refresh[bot] seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

emooreatx and others added 13 commits September 6, 2026 18:29
…eract, reset, then the normal flow

The product has two shapes and CI only ever drove one. Every leg configured
a provider, so the run-without-AI path (CIRISAgent#1149) — the whole feature
2.11.0 adds — had no automated coverage on any platform. The only evidence
it worked was a curl against the API, which drives the agent and never the
client.

Each platform now runs, before its existing flow:

  desktop-setup --run-without-ai   YOU answers "without an AI assistant"
  desktop-login                    reach Interact against a NODE-only backend
  desktop-reset                    log out + factory reset through the client

The AI question is a control on YOU (client 0.5.203 AiPreferenceSection),
not a screen: it sets hasAiStep = hasAgent && !runWithoutAi, so answering it
decides whether the AI screen exists at all. Both options are clicked
explicitly rather than trusting the default, and a missing
`opt_run_without_ai` is fatal only in the without-AI direction — with-AI is
the default, so an older client still does the right thing, while
without-AI silently becoming with-AI would make the run prove nothing.

The reset goes through the CLIENT's own button, not `rm`. `factoryReset()`
is what deletes the .env, and the .env is where CIRIS_RUN_WITHOUT_AI lives:
skip it and every later boot keeps handing the process to the node, so the
with-AI pass would have no :8080 to talk to. Driving the real button is also
the only way this test notices if that reset ever stops clearing the flag.

Between the passes the node's ports are reaped. `--launch` tears down the
app and `main.py --adapter api`, but a node-only backend is
`python -m ciris_server` holding :4242/:4243 — which the agent's own node
fold binds next, so an orphan is EADDRINUSE (#1101/#1102). The per-platform
teardown list gains them too.

Each new phase names itself to the diagnoser (setup-noai / login-noai /
reset) so a red says which pass produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…he top of the screen

I checked the step sequence (YOU → JOIN_FEDERATION → [AI] → COMPLETE, the
same in 0.5.202 and 0.5.203) and concluded the layout had not changed. It
had, one level down: 0.5.203 renders AiPreferenceSection ABOVE age, account
and fed-ID on YOU, where 0.5.202 had no such section at all. My first cut
answered it after those three, mirroring the old screen.

Driving by testTag makes that survivable rather than correct, and the risk
is real in one direction: choosing "without an AI assistant" recomposes YOU,
so answering it AFTER filling the fields below means driving a screen that
is about to change under the automation. Answering it first is both the
order a person meets the screen in and the order that cannot race a
recomposition.

Behaviour is otherwise unchanged: still explicit for both options rather
than trusting the `runWithoutAi = false` default, still fatal only in the
without-AI direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
First real run of the run-without-AI pass (34067627803, windows). The
feature half worked: the wizard answered "without an AI assistant", skipped
the AI screen (hasAiStep=false), completed 6/6 with the node
federation-discoverable at 3/3 rows, and login then reached Interact 7/7.

My reset choreography was wrong. The nav has several categories, each with
its own opener and dropdown; `btn_menu` opens ADVANCED while `menu_logout`
is inside the GOVERNANCE one. Clicking the wrong opener still "succeeds" as
a click and then times out on an item that was never going to render — the
same shape of failure as clicking a button that does not advance a wizard.

It now opens `btn_governance_menu`, falls back to `btn_menu`, and only
fails after neither reveals the item — with a message that says the two
things it could be (not authenticated, or the item moved categories)
instead of just naming the missing tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
… just that a screen was absent

Its first real run (34067627803) went green on Windows and Linux while
testing nothing, and the agent's own log says why:

    [SETUP] No usable LLM provider (provider='openai', key_set=False) —
            writing CIRIS_SERVICES_DISABLED=true so the next boot degrades

That is the ACCIDENTAL branch. It runs only when run_without_ai is false, so
the flag never reached the agent: no CIRIS_RUN_WITHOUT_AI was written,
nothing handed off, :8080 kept serving, and the client logged in against the
brain it was supposed to have replaced.

The pass believed itself because it keyed on the AI screen being absent. But
hasAiStep is `hasAgent && !runWithoutAi`, so the screen is equally absent
when clientMode resolves NODE — which is what happens on a fresh home before
the node is folded and reachable. Absence of the screen was read as presence
of the choice: the same absence-means-success shape that has bitten this gate
before.

It now asserts the agent's recorded state instead. `CIRIS_RUN_WITHOUT_AI=true`
in the home's .env is written ONLY by the deliberate branch; the accidental
one writes CIRIS_SERVICES_DISABLED and nothing else, and that difference is
the entire point of keeping them apart. The failure names both candidates —
choice not recorded, or clientMode=NODE — so the next red says which.

This does not fix the underlying defect (the choice not arriving); it makes
the gate incapable of missing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…past them

macOS booted the with-AI backend straight into

    Edge runtime initialization failed (REQUIRED foundation dep):
    Edge transport ports are held by another process

which is CIRISAgent#1102 reproducing inside the gate: the previous backend's
Rust transport threads outlive a hard kill, and the graceful release added in
2.10.0 is bypassed when the launcher kills -9. My `sleep 3` was a guess where
evidence was available — the port being free is observable, so the gate now
polls for it (60s cap) and fails loudly, naming the ports and their holders,
if they never free.

That turns a failure that reads like a product fault into one that says what
it is. Worth noting the user-facing shape underneath: factory reset followed
by a fresh setup is the same sequence, so a real user can hit this too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…tion is desktop-only

Two of the Android failures in 34072801004 were mine, and both came from
treating mobile like desktop.

1. LOGOUT. The dumped tree at the failure says screen='Interact' with
   `menu_logout` AND `btn_login_reset_device` both present — while neither
   was reachable. That is the registry-never-forgets trap this repo already
   knows about: `/tree` reports every element ever composed (the registration
   has no DisposableEffect), so presence there is not drivability. Mobile puts
   the nav behind `btn_nav_drawer_open`, which has to be opened before the
   category menus exist to click. Desktop has no drawer, so it is a
   best-effort first move rather than a requirement.

2. THE .ENV ASSERTION. On Android and iOS the agent's home lives on the
   DEVICE, so `$CIRIS_HOME` on the runner is not it. Reading it there produced
   "cannot read /home/runner/work/_temp/ciris-android/.env", turning a real
   question into a harness error. The desktop legs carry the assertion; mobile
   now reports that it did NOT run and why. A check that cannot see the answer
   must not imply one in either direction — the whole reason this assertion
   exists is that an unasked question was reading as a pass.

The third Android symptom — the final `wait_for_setup_wizard` finding
screen='Startup' with 0 elements — is downstream of the failed reset, so it
should clear with (1). If it does not, it is a real app-recovery defect and
gets its own issue rather than a workaround here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
… chrome

Second and final Android RCA. Opening the nav drawer was necessary and not
sufficient: with the drawer open, the tree is 29 elements of
`nav_group_*` / `nav_epistemic_*` and contains no `menu_logout` at all.

The client says this itself, in EpistemicSidebar's header:

    Existing QA scripts that drove the old top-bar dropdown menu (`menu_*`
    testTags) will need updating to the new `nav_epistemic_*` testTags.
    This is expected scope for the 2.9.4 rewire — the old chrome is fully
    replaced.

So mobile logout is a different route entirely, not a differently-opened
version of the same one:

    btn_nav_drawer_open -> nav_epistemic_agent_settings -> btn_logout

Desktop keeps the dropdowns and is unchanged.

Why the first version looked plausible: the desktop tags DO appear in a
mobile /tree, because the registration has no DisposableEffect and the
registry reports every element ever composed. `menu_logout` and
`btn_login_reset_device` were both listed while neither was reachable. The
mobile route is now taken on its own terms rather than probed for, so a
stale registry entry cannot make it look available again. Recorded against
CIRISClient#33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
… field the wizard collected

CIRISAgent#1155. `completeSetup()` re-typed the request body through a
generated SDK model built from an openapi.json declaring 17 properties while
the request carries 36, so FIFTEEN fields were dropped at the wire with no
error and our pydantic defaults won. That is the whole of #1151: the client's
state was never wrong.

Not pinning 0.5.204 or 0.5.205 — both were tagged, built every wheel, then
failed the release gate on a vendoring digest and published nothing. Verified:
zero PyPI files for each. 0.5.206 is complete on all three channels (5 wheels,
.aar, .xcframework.zip) and resolves here.

CHECKED BEFORE BUMPING, because fourteen fields start arriving that never have:
every one type-checks against our model — three Booleans, eight Optional
scalars, Optional[int], and Optional[List[str]] against the client's
List<String>?. No 422 risk on the first 0.5.206 setup.

The consent consequence is not mechanical and is filed separately as #1156:
`trace_analyze` defaults True here, so during the window an owner who DECLINED
trace analysis had the be-scored dimension granted, and that grant is not
recoverable — the refusal never reached us. `share_location_in_traces` failed
the safe way (consent ignored rather than invented).

Also corrected the stale note in this pin: CIRISClient#30 was fixed in 0.5.203,
not still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…fuses them, correctly

First matrix run against ciris-client 0.5.206 (34121322364) failed on
Windows at the very first wizard step, and the new error message diagnosed
itself:

    age_band_adult is composed but off screen (inside a closed drawer or sheet?);
    on screen and drivable now: [age_band_declined, btn_next, opt_run_with_ai, …]

0.5.203 put the AI question above age / account / fed-ID on YOU, so the age
bands fell below the fold. Under the old semantics the wait passed on
position alone and the click was a coordinate gamble that happened to work.
0.5.206 refuses it. That is the correct answer — it is the same rule that
would have caught our Android menu_logout bug from the other end — and it
means anything below the fold needs scrolling before it can be driven.

`POST /scroll` exists for exactly this. The helper gains `scroll_into_view`,
`click()` scrolls once and retries when the app says "off screen" (anything
else goes straight up as a real failure), and `you_step` scrolls to the age
band proactively rather than waiting to be refused.

CAVEAT, reported upstream: /scroll is served by the desktop and iOS test
servers but NOT the Android one in 0.5.206, so on Android a long form stays
undrivable. Our fix cannot reach that platform; CIRISClient#33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
… at a time

The 0.5.206 iOS leg failed on `enter_username`, `enter_password` and
`click_login_button` as well as the wizard's age band — so `/input` refuses
off-screen elements exactly as `/click` does, and a login form below the fold
is as ordinary as a wizard step. Patching each call site as CI finds it would
mean learning the same lesson five more times.

`wait_for_element`, `click` and `input_text` now all do the same thing on the
one recoverable failure: if the app says "off screen", scroll once and ask
again; anything else is raised unchanged, carrying the app's own message,
which since 0.5.206 names what IS drivable.

The proactive scroll left in `you_step` is now belt-and-braces rather than
the mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…s reason

Ahead of ciris-client 0.5.207, whose matrix row states the contract: a
refused /scroll is a 404 carrying a reason, not a 200. My first version
returned False on 404 and otherwise fell through to "sleep, then look
again" — which for any other non-200 would have spent the whole six-attempt
budget re-asking a question the app had already answered.

Now only 200 continues; anything else returns False immediately and prints
the reason the app gave (unroutable on that platform, or a tag it will not
scroll to). That reason is the app telling us why, and the caller's own
failure message does not repeat it.

Also fixes a real defect in that first version: it called `logger.info`, and
this module has no logger — it prints. The line would have raised NameError
at exactly the moment it was trying to explain a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
The desktop server does route /scroll (client/desktopApp/.../TestAutomationServer.kt),
so on Windows the scroll ran and did not help. The drivable set in the refusal
is the tell:

    age_band_adult is composed but off screen;
    on screen and drivable now: [age_band_declined, btn_next,
                                 opt_run_with_ai, opt_run_without_ai]

A sibling band is reachable, and so are both AI options at the top AND
btn_next at the bottom. That is not a form scrolled past its bottom, so
scrolling down further was never going to reach it. Half the attempts now go
up.

If this still fails the element is clipped rather than merely off screen,
which no scroll can fix and which goes to the client rather than into more
retries here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…creen

0.5.206 and 0.5.207 both fail our gate at the wizard's first step, and it
was never a scroll problem. "Prefer not to say" sat inside the same Row as
the two age bands carrying fillMaxWidth(); Row measures unweighted children
first, so it took the whole row and both weight(1f) bands measured to zero.

Our own evidence said so and I read it too narrowly: the drivable set was
[age_band_declined, btn_next, opt_run_with_ai, opt_run_without_ai] — neither
band, not one hidden with its sibling reachable. Clipping or scroll position
would have left one of them. CIRISClient#42 has the full RCA.

This was user-facing, not a harness artifact: for four releases the only
selectable answer to a required question was "prefer not to say", which
routes to under-18 with stewardship. It stayed invisible because the controls
still composed and still registered click handlers, so the pre-0.5.206
coordinate fallback clicked a zero-size rect and automation looked green.
0.5.206's stricter /wait is what surfaced it.

0.5.207 is deliberately skipped: its publish run was cancelled after the
wheels landed, so v0.5.207 carries the .aar but no .xcframework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
emooreatx and others added 28 commits September 7, 2026 14:31
…ng the pin

CIRISClient can now cut a preview for a branch, and cut one for the /scroll
dispatcher fix (CIRISClient#44) — Android AAR plus wheels, ~30 minutes instead
of the four-hour full release. Testing it needs three things the gate could not
express:

  * the version is `0.5.208+preview.g7058419` — a PEP 440 local segment, which
    PyPI refuses by design, so `pip download ciris-client==` cannot reach it and
    the requirements.txt regex `[0-9.]+` will not match it;
  * the release tag is `preview-scroll-dispatch`, not `v<version>`;
  * the agent wheel Requires the pinned client, so installing our wheel pulls
    the pin from PyPI right back over the preview.

`client_preview_tag` (workflow_dispatch, default empty) handles all three: the
wheels come from the release, the version is derived from the asset name rather
than typed twice, CIRIS_CLIENT_VERSION / CIRIS_CLIENT_RELEASE_TAG steer
fetch_client_artifacts.py to the preview .aar, and the preview is reinstalled
--no-deps over the pin so pip does not "fix" the deliberate mismatch. The run
warns loudly that it is not testing the pin.

Empty input = today's behaviour exactly, and both env overrides default to the
pin, so nothing changes for a normal run. Verified locally: with the vars set
resolve_version() returns the preview and asset_url() resolves the real
preview .aar; with them unset it returns 0.5.208.

Also: scroll_into_view now prints what a 200 actually DID. 0.5.208's accepted-
but-inert scroll was indistinguishable from a working one over HTTP, which is
what made #44 cost a cycle; the fix reports "down:300 moved 0→300 of 1400", so
`moved 0→0 of 0` will now say so at the moment it happens rather than surfacing
three steps later as an unrelated-looking failure. Stale Android note replaced
with the actual 0.5.206→0.5.208 history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…with no work

Two bugs in 4f2f2b7, both found by the first preview dispatch (#34155903428).

`gh release download --pattern 'ciris_client-*.whl'` matched all three wheels
(any + manylinux + win_amd64), and vendor_desktop_jar.py requires exactly one --
it extracts a PLATFORM-SPECIFIC uber-jar, so "any of them" is not a valid
answer:

    expected exactly ONE client wheel in .../clientwheel, got [...]

The pip path being replaced only ever downloads the wheel matching the host.
The pattern is now chosen from RUNNER_OS, and a preview that ships no wheel for
this platform fails with that as the message rather than as an arity error.

Second: `platforms` was filtered per-leg INSIDE the run step, so a dispatch of
`linux android` still booted the macOS and Windows runners to do nothing. That
was merely wasteful before -- 45 minutes of macOS time a dispatch -- and became
a hard failure with previews, because this preview ships no macosx wheel and the
mac had no reason to have started. The job now skips a matrix entry when neither
of its platforms was requested. Each entry carries `first`/`second` for the
guard; windows duplicates its own name deliberately, since an undefined value
renders empty and contains(' linux android ', ' ') is TRUE -- the guard would
then never skip, which is backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…he step

0216ed4 invalidated the workflow file. GitHub answered the push with a run
carrying no jobs and conclusion=failure, which is what an unparseable workflow
looks like from the API: `jobs: []`, `event: push`, no annotations reachable
through `gh run view`.

The cause is a documented context restriction I did not check: `matrix` is NOT
in scope for `jobs.<job_id>.if`. It is available to STEP-level `if` and to
`env`, but a job-level condition is evaluated before matrix expansion, so
`matrix.first` there is not merely empty — it makes the file invalid.

Reverted to `if: ${{ !cancelled() }}` and moved the guard into the vendor step,
where `matrix.platforms` is legitimately in scope. Same effect for the case that
actually broke: a preview shipping no macosx wheel is no longer vendored on a
mac that was never going to run a macos or ios leg. The runner still boots — the
minutes saving needs a different mechanism and is not worth a second attempt at
this while a preview is waiting to be tested.

Inert without a preview: the guard is inside `[ -n "$PREVIEW" ]`, so an ordinary
partial dispatch vendors exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
The preview run got as far as vendoring and then refused it:

    CIRIS-linux-x64-1.5.208.jar does not carry the pinned client
    0.5.208+preview.g7058419 (looked for '5.208+preview.g7058419')
    — this is a STALE artifact, not a fresh download

The jar is correct and the check was wrong. `+preview.g7058419` identifies the
BUILD, not the release it is built from, so upstream rightly keeps naming the
jar for the product version — `1.5.208`. freshness_tail() compared against the
whole string and therefore called every preview stale.

Strips the local segment first. The check keeps doing its real job unchanged:
0.5.208 and 0.5.208+preview.g7058419 both yield `5.208`, while 0.5.207 still
yields `5.207`, so a jar left over from an earlier RELEASE is caught exactly as
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
Fourth and last pin site the preview has to reach. The flow got past vendoring
and died in the APK build:

    Could not find :ciris-client-0.5.208:
      - .../apps/android/libs/ciris-client-0.5.208.aar

fetch_client_artifacts.py had correctly landed the preview .aar --
`ciris-client-0.5.208+preview.g7058419.aar` -- but apps/android/build.gradle
derives the name from requirements.txt's pin, so it asked for a file that was
never going to be there.

Same CIRIS_CLIENT_VERSION the fetcher honours, read via System.getenv, and the
qa_runner's gradle subprocess passes no env= so it inherits it from $GITHUB_ENV.
Unset -- every ordinary build -- the pin is read exactly as before.

That is the whole set: requirements.txt (wheel), the release tag (.aar lookup),
the freshness assertion, and now the Gradle coordinate. Each assumed a version
with no PEP 440 local segment, published to PyPI, under a `v<version>` tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…d stopped

The preview proved CIRISClient#44's dispatcher fix works — scrolls now really
move, and the response says so:

    (scroll 'age_band_adult' down: down:300 moved 0→300 of 3118)
    (scroll 'input_username' down: down:300 moved 300→600 of 3142)

enter_username and enter_password PASS on Android for the first time. But
you_step still failed, and the numbers gave it away: a 3142px form, moved 300px,
one scroll per element. The remaining bug is ours, and it is two bugs.

FIRST: is_element_visible was `elem is not None` — "does the app remember
composing this". Under registry-never-forgets that is true forever, for anything
ever composed. scroll_into_view consults it after each scroll to decide whether
to stop, so it ALWAYS stopped after the first one and reported success; /input
then refused an element we had just declared visible. The `visible` field has
been in /tree since 0.5.206 and we were dropping it on parse — ElementInfo did
not even carry it. Now parsed and used, with a geometry fallback for older
clients rather than a fallback to presence: a zero-sized rect is not on screen
either, which is exactly the shape #42's bands had.

SECOND: the budget was split down/up up front, so a 6-attempt budget was 900px
of downward travel on a 3142px form — it turned around before reaching a field
near the bottom. The direction split was a leftover of the age-band theory the
client already disproved. Now one direction is exhausted before the other is
tried, bounded by the refusal contract: `already at the bottom` ends the phase
exactly when travel does, so the budget buys real movement instead of being
rationed against a direction that may not be needed.

Also skips scrolling entirely when the element is already on screen, which the
old presence check could never determine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
42e1d17 made is_element_visible mean ON SCREEN, which was right for the
scroller and wrong for the forty call sites written against its old body.
The first Android run after it typed no fed-ID label at all: the field was
composed below the fold, the guard `if is_element_visible("input_fedid_label")`
read "not on screen", and the wizard stopped on "Enter a name for your
federation ID to continue" (run #34162161554, android-setup-noai.png).

Those forty sites ask presence questions — does this build render the field,
is the login form shown or behind a button, are we still on YOU while the age
band exists — and a control that has merely scrolled off must still answer
yes. They now call is_element_present. Only scroll_into_view and the flow
runner keep is_element_visible, because only they need the on-screen answer.

flow_spec.py is the executable half of a CSD (FSD/CSD_STANDARD.md): the
`requires / do / expect` form CIRISClient specified in #39, strict at load
(an unknown key is an error, a step that asserts nothing is refused), a
version floor that refuses an older client loudly, per-step console line +
screenshot + JSON row, and a precondition failure reported as one — "this
flow cannot start here" is a different bug from "this element is broken".
`python -m tools.qa_runner.modules.web_ui flow --spec <yaml>` runs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
A CIRIS Specification Document is Mission Driven Development applied to one
feature: the MISSION a surface serves, the SCHEMAS it is made of (screens,
guaranteed tags, fields), the PROTOCOLS it reads (endpoints, version floors,
owners), and the LOGIC that drives it — as a flow in the UI DSL CIRISClient
specified in #39. Plus the one thing a feature has that an architecture does
not: a test that is the same document as the spec.

That last property is enforced, not hoped for. tools/dev/check_csd.py fails
CI when a CSD's embedded flow block differs from the file it names, when a
flow has no CSD or two, when the flow id and the CSD slug disagree, when the
client floor is missing, when a §3 source is neither a route nor the literal
word `unconfirmed`, or when the index omits a CSD. Negative-tested against
all four injected defects. `--embed CSD-NNN` makes "keep them identical" a
command. Wired into build.yml beside check_evidence.py.

CSD-001..004 cover Delegation, Environment, Constitutional and Capacity
Attestations. Two disciplines are demonstrated rather than described: CSD-002
leaves its return step as a comment because #45 wired the navigation but did
not name the tag — a guessed tag is the #39 defect exactly — and every source
that #45 did not name says `unconfirmed` with who is being asked. The gallery
renders each flow beside the platform tiles: one row per step, its failing
phase and predicate, and the screenshot it failed on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
On Android the agent is a thread of the client's process under Chaquopy, and
sys.executable is /system/bin/app_process64. exec_into_node exec'd that with
`-m ciris_server` — not a Python invocation — which replaced the app's process
image with a malformed launch; the app died and the client's runtime service
restarted it five seconds later into node-only mode (run #34165538262: pid
5883 exec'd app_process64, pid 6241 booted node-only). It worked as a crash.

There is no separate process to become on an embedded runtime, and the next
boot already does the right thing on its own — main.py reads the recorded
flag and serves the node in-process. So on a non-desktop platform the
hand-off now stops :8080, says exactly why in the [RUN-WITHOUT-AI] log, and
exits so the host restarts the runtime: the same observable outcome the
accident produced, deliberately and legibly. The clean contract — the client
restarts its own runtime once run_without_ai is recorded — is CIRISClient#43's
mobile half, posted there; when it lands this becomes "record, stop :8080,
return" on every platform. Uses the existing is_desktop() allow-list, which
fails closed: an unrecognized platform gets the embedded answer.

Harness: forward :4243 on Android (best-effort, like 8080) so the node is
observable from the host on a no-AI leg — without it the attribution read
"BOTH backends unreachable — the hand-off did not land" on a device where the
node was simply not forwarded — and pass the node URL at the second
attribution site too.

What the same log exposed but is NOT ours: the node-only boot on a split-key
home refuses to re-author the consent row the node key authored. Filed as
CIRISServer#563 with the full error; _resolve_key_id passes the engine alias
deliberately (CIRISServer#380 proves the alternative worse) and this commit
does not touch it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
918096c replaced a malformed execv of the host binary with a deliberate
os._exit(0) on embedded runtimes, on the reasoning that the observable outcome
was the same. It was the same on Android only. On iOS exit(0) is the app
vanishing mid-setup with no relaunch and no notification — the worst outcome
available (CIRISClient#43). And the host cannot restart the interpreter
either: Chaquopy initialises CPython once per process, so a service-level
restart would re-enter mobile_main in an interpreter still holding this
runtime's loop, threads and Edge transport — CIRISAgent#1152's shape.

So on every non-desktop platform the hand-off is now: the flag is recorded,
:8080 is told to stop, and the agent RETURNS. The node serves from the
runtime's next boot (main.py reads the flag -> run_headless). complete.py's
"exec FAILED" branch no longer fires on this path: it called
runtime.request_shutdown(), which a parked runtime ignores (#1152), and a log
line claiming a shutdown that never happens is the vacuous shape this feature
has produced once already.

What the client asked for — shape (1), the node starting in-process in the
same session — is not available today for two reasons that are both ours:
edge_runtime.py has initialize_edge_runtime and no release path, so :4242
cannot be handed over in-process; and the parked runtime never reaches its
shutdown sequence (#1152). Those are the work that unlocks it; this commit
only stops doing harm in the meantime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
d85bdd4 replaced the lsof teardown loop with free_ports.py, and
test_each_platform_starts_from_a_clean_host still asserted the old echo text
("teardown: killing"). The property it guards — the second platform on a
runner must not inherit the first's ports — is unchanged and still true; the
assertion just named the mechanism instead of the property.

Now asserts the mechanism that actually has the property (free_ports.py,
which proves a port free by binding it) and that the one without it stays
gone (no `lsof -ti`, which is absent on Windows and printed "free" having
checked nothing). Ports 8080 and 9091 still asserted in the slice. A
stronger test than before, not a loosened one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
…he property

3e22ea3 fixed one of two workflow-text tests keyed to the lsof loop that
d85bdd4 replaced, and it went out with the other still red — the commit was
gated on `pytest | grep | tail`, whose exit status is tail's. That is the
vacuous-green shape this branch has now catalogued five times; the gate here
reads pytest's own exit code.

test_each_platform_starts_from_a_clean_host sliced 700 characters after the
marker, which once held the whole loop and now holds only the rationale
comment above free_ports.py; it is bounded by the step's next structural line
instead. test_the_gate_waits_for_the_node_ports_rather_than_sleeping asserted
`lsof -ti` and `break` — the old mechanism — for the property "observe the
ports, don't sleep"; it now asserts free_ports.py with a --timeout bound (the
port is proved free by binding it) and that lsof stays out, since on Windows
it printed "free" having probed nothing. Both tests are stricter than before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
682fe46 split is_element_visible into is_element_present (composed) and
is_element_visible (on screen) and moved the YOU-step advance loop to the
former. test_the_you_step_advance_is_detected_by_the_next_steps_control still
asserted the old name in that loop's source and failed on shard 8.

Presence is the correct positive signal at that site, and the test now says
why: a control that has never been composed is absent, and the moment JOIN
FEDERATION composes it is present -- even below the fold on a phone, where an
on-screen check would wait out the budget on a wizard that had advanced.
Android passed join_federation on exactly this in run #34168683558. The test
additionally asserts the loop does NOT key on on-screen visibility, so the
false-timeout regression is kept out by name.

Gated on pytest's own exit status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HHdiFETLdwJdemhxDQUcr
… now lives

test_the_teardown_only_kills_listeners scanned the workflow for the
`lsof -ti … tcp:$port` line and required -sTCP:LISTEN on it. d85bdd4 moved
the teardown to free_ports.py, so the line is gone and the test raised
"port-teardown lsof line not found" — on a teardown that has the property.

The property (owning a port means listening on it; the iOS simulator app is a
CLIENT of :8080 and must not be killed for polling) now lives in
platform_procs.pids_listening_on, and the test asserts it there on both paths:
-sTCP:LISTEN on POSIX and the LISTENING state on the Windows netstat path —
where the old workflow line was never executed at all. It also asserts the
workflow goes through free_ports.py and that no raw lsof port kill returns.
Stricter than before on two counts. Found by running all of tests/workflows
locally instead of waiting for the shard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test_no_emoji_reaches_a_stream flagged the [WARN] line added in 918096c for
the Android node-port forward: on a Windows cp1252 console an emoji in
runtime output raises UnicodeEncodeError and kills the process. The warning
glyph is now the ASCII marker the test names, and the arrows in both new lines
are `->` — U+2192 is equally unencodable on cp1252 even though the test does
not flag it. The pre-existing 8080 forward lines carry the same glyphs and are
left as they are; they are not this change's to rewrite.

Found on shard 3 of the c29d33b run alongside the listeners test; both
tests/workflows and tests/logic/utils now run locally before a push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hand-off

CIRISClient#43, and bigger than the report: ActiveBackend.resolveFrom() had
no production caller since 0.5.203, so every platform kept polling :8080
after run-without-AI was recorded. The client now resolves the backend at
startup and at setup completion. With the agent half already record / stop
:8080 / return (e115d55), the three desktop no-AI legs should clear on this
pin. Android's second boot still dies on CIRISServer#563 until server 0.5.203
(PR #564) ships; that is not this pin's to fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ps its client log

Two findings from the first 0.5.209 run (#34177749800, Windows), both ours.

announce_bundle probed :4243 eighteen milliseconds after the agent exec'd
into the node; the node's read API bound 6.4 seconds later. The check
reported "unreachable" and passed as NOT asserted -- a green that asserted
nothing on the leg that exists to assert it. On a run-without-AI leg the
node is expected to be coming up, so the check now waits for its identity
endpoint (bounded, 60s) before logging in, and only then is "unreachable" a
finding.

The desktop client log was opened "w" under one fixed name on every launch,
and the gate launches the app several times per job, so the artifact carried
only the LAST session -- the with-AI pass -- and the no-AI hand-off, the one
session whose client behaviour we most needed, was overwritten every run.
Worse, a diagnosis I posted on CIRISClient#43 ("109 inits at :8080 after the
hand-off") was read from a with-AI client polling a backend that had died on
a port collision; corrected there. One timestamped log per launch now; the
collector already globs ciris_desktop*.log, and the claim_settled reader
takes the newest rather than silently skipping on a stale name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The macOS with-AI leg on ciris-client 0.5.209 (run #34177749800) died 0.8s
after boot on "Edge transport ports are held by another process" -- right
after free_ports.py had proved :4242/:4243/:8080 free by bind. The holder
arrived in that window: the no-AI pass's CIRIS_RUN_WITHOUT_AI was still in
the home's .env because the reset between the passes had failed, and 0.5.209
honours the flag where 0.5.208 ignored it. The client resolved :4243, found no
node, launched one of its own ("Launching local ciris-server node..."), and
its boot marker lands 0.7s after our agent's -- the collision.

The bring-up's first-run mode "skipped .env creation" and left whatever was
there. A first run has no recorded answer by definition, so it now strips
CIRIS_RUN_WITHOUT_AI and CIRIS_NODE_KEY_ID from an existing .env and says so.
Nothing else in the file is this mode's business.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he checkout

fee18b8's first-run strip looked for .env in project_root. start_env pins
CIRIS_HOME to project_root with setdefault, so when the gate exports
CIRIS_HOME=${RUNNER_TEMP}/ciris-<plat> -- as it does on every leg -- the agent
writes its .env there and the strip found nothing: run #34179671711 shows no
"Stripped" line. Windows' with-AI leg passed regardless (no port collision
this time); macOS, where the client's self-launched node took :4242 0.7s
before the agent, would not have. Resolve the effective home the way
start_env does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
macOS' with-AI leg failed ai_configuration with "Element not found:
input_api_key" while its own failure screenshot showed the AI Configuration
screen with the key field rendered and "API key is required" (run
#34181104589). The client log has the cause: "[AI] defaulting to on-device
inference (device is capable)". On a capable device the wizard opens on the
on-device option; choosing OpenRouter recomposes the form, and the key field
appears a beat after the click. The step slept 0.3s and typed into a field
that was not composed yet. Windows CI cannot run on-device inference, shows
the BYOK form immediately, and passed the same step -- a platform-conditional
race, ours. The step now waits for input_api_key (8s) and names the on-device
default in its failure if the field never comes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CIRISServer#563 → PR #564. peer.rs compared the signer's alias to the node's
derived id, never equal in production, so every consent re-author was refused
and a node-only boot on a split-key home died on its second boot. Fixed with a
guard that reads both signer conventions and a process-held node signer for
runtime emits; the boot re-author now moves the wizard's actor-authored grant
onto the node key. Nothing changes on our side — the engine alias is still
what _resolve_key_id passes, as CIRISServer#380 requires.

Expected on this pin: the Android and iOS second boot (reset / with-AI legs)
clears. Retest the Windows-only announce 409 after the exec here. The no-AI
login legs stay red on the two client behaviours reported on CIRISClient#43
until the client ships them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dles stop cancelling each other

Two ways run #34223901074 spent forty minutes learning what it could have
known in seconds.

requirements.txt is one of several ciris-server pin sites: apps/android/
build.gradle installs its own exact pin into the APK. Bumping requirements.txt
to 0.5.203 alone shipped 0.5.203 to the desktops and 0.5.199 to Android, and
the Android leg died on the bug 0.5.203 had fixed. check_version_alignment.py
already compares the two pins; it now runs as the gate's first step, so a
drift fails before a runner is booted, with the lockstep tool named in the
message.

The reusable iOS-substrate workflow's concurrency group was per branch and
per mode, but two upload-only callers share both -- the gate's bundle job and
compose-gate's ios-refresh -- and a push that bumps the pin fires the second
at the moment the first is dispatched. The later one cancelled the earlier
one's bundle, and the iOS leg failed as "no /health within 120s". Upload-only
callers each need their own bundle and have nothing to serialise, so the
group is keyed on the calling run for them; the commit path keeps its
per-branch mutex, which is a real one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…en proves the ports free

Linux's with-AI leg on run #34223901074 died 0.3s into boot on "Edge
transport ports are held by another process". A ciris-server had booted at
12:08:12 -- eight seconds before our agent and after the post-reset port
check -- and it was not ours: on the no-AI session ciris-client 0.5.209
resolves :4243, finds the exec'd node not yet bound, and launches a node of
its own, then keeps reviving it. The bring-up killed the JVM by name and left
that child alive holding :4242.

Both bring-up sites now kill ciris_server / ciris-server processes AFTER the
app that spawns them, then run free_ports.py so the ports are proved free by
binding rather than assumed free because a listing came back empty. A
bring-up that cannot free them fails there, in its own words, instead of
three steps later in the agent's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…de CI tests

apps/android/build.gradle installs its own exact ciris-server pin and the
wheels it resolves are checked in under apps/android/wheels (allowlisted in
the large-file hook; this is how every server bump lands). Bumping
requirements.txt alone (d40247e) shipped 0.5.203 to the desktops and left
the phones on 0.5.199, and the Android leg of run #34223901074 died on the
split-key re-author refusal 0.5.203 exists to fix.

tools/update_substrate_libs.py 0.5.203 --lib server --platform all: gradle
pin 0.5.199 -> 0.5.203; wheels 0.5.196 -> 0.5.203 for arm64-v8a,
armeabi-v7a, x86_64 (sha256-verified against the release). The gate now
refuses to run on a drift between these two pins (bd1198f).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e matches the pin

compose-gate's "iOS substrate currency" refused the branch after the server
pin moved: substrate.lock.json still said 0.5.199, so an Xcode build off this
branch would have shipped a node CI had not pinned. The check exists because
the vendored ciris_server declares no __version__ and the .so embeds no
version literal, so the lock is the only thing that can answer.

tools/update_substrate_libs.py 0.5.203 --lib server --platform ios:
substrate.lock.json server=0.5.203, Resources.zip rebuilt (5875 entries), the
device _native.abi3.so refreshed from the release -- all three tracked, as
every prior refresh left them. The simulator .so is gitignored and the gate
builds its own in-run. With requirements.txt, apps/android/build.gradle and
the Android wheels (484abd2) that is every ciris-server pin site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…not the automation server's

announce_bundle talks only to the ciris-server read API, yet a ReadError from
it escaped to run_test, whose classifier turns any transport exception into
"the client's automation server stopped answering". Matrix #5 said exactly
that for macOS and iOS while the peer that dropped the connection was the
exec'd node on :4243 -- wrong peer, wrong owner, and a diagnosis I had already
posted upstream once on that basis for Linux. Caught inside the step now and
named: which URL, which exception class, and where the evidence lives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d-recompose

macOS on matrix #5 reported the exec'd node NOT federation-discoverable on a
bare ReadError from the announce POST. The node's own log says otherwise:
the announce wrote its rows at 13:12:26 (owner_key, node_key, owner_binding,
all federation_visible=true) and the node re-composed at 13:12:27 -- the
write is durable, the response was lost in the re-compose. Announce is
idempotent, so after a dropped response the check now waits 5s for the
re-compose to settle and re-announces once; the second call returns the
bundle the first one produced. A second drop is still a failure, and still
named as the node's. Filed upstream as a question about whether the
immediate re-compose is the contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…uses a socket across it

CIRISServer#568 was ours, and the server team read three logs to say so:
the restart one second after the announce on macOS was the agent's own
complete_setup exec, not a server re-compose. The check had probed :4243,
been answered by the still-alive agent-hosted node fold, and announced
against it as the process was replaced; the pooled socket died with the
fold.

Two changes. On a desktop no-AI leg the check now watches for :4243 to be
REFUSED at least once (the fold dying) before it accepts an answer (the
exec'd node); if no transition appears within 4s the backend did not exec --
Android and iOS serve the fold in-process -- and the fold is the node to
announce against. And the announce client has no keep-alive pool, so a
socket opened to one peer is never reused against its successor; the one
idempotent retry stays as belt-and-braces and still names the node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup-complete drops run_without_ai: 'Run without AI' is chosen, never recorded, and the next boot aborts on a critical llm_service

2 participants