Windows worker first-run onboarding: packaged setup dialog + working scarcity-worker.exe pair (issue #113) #114

Merged
adrian.tkacz merged 3 commits from fix/windows-first-run-onboarding into develop 2026-09-24 13:26:07 +00:00
Member

Closes #113 (stays open for human review; do not merge before live Windows acceptance).

Problem

The v0.1.0 Windows ZIP (scarcity-worker.exe, candidate run 35780744775) dead-ended on first launch: it instructed users to run scarcity-router-worker pair … — a Python console script the standalone package does not contain. docs/m10-acceptance.md already claimed the Windows package asks for pairing information "in its first-run dialog"; this PR makes that statement true.

What this changes

  • scarcity_router/worker_setup.py (new, GUI-independent core): typed/versioned non-secret local settings (WorkerLocalSettings: optional control-UI origin + optional loopback Ollama resource/host/port) persisted as one strictly-parsed JSON value in the existing WorkerLocalStore (schema v1, exact key set, bounded, atomic, fail-closed); pairing orchestration (pair_worker → the EXISTING WorkerRuntime.pair); deterministic error mapping (safe, actionable, code-never-echoed); complete_setup with defined partial-failure semantics (failed pairing persists nothing; settings-save failure after pairing stays recoverable); documented precedence (effective_arguments: explicit CLI --allow-* flags replace the stored selection, otherwise stored settings drive); launch routing (resolve_launch).
  • windows_tray.py (packaged entry router): pair … → packaged CLI pairing (same path as the dialog; best-effort parent-console attach via CreateFileW + fd so output is visible from PowerShell/cmd despite the windowed build); no-arg launch → first-run dialog when unpaired, tray when paired; explicit run flags keep pre-#113 CLI semantics exactly. The tray runtime factory rebuilds the adapter registry from stored settings on every (re)connect through the EXISTING build_local_adapter_registry; a new "Worker settings…" tray action reopens the SAME dialog and a save triggers the controlled restart machinery. Remediation text names scarcity-worker.exe, never the console script.
  • Packaging tree: scarcity_worker_setup_view.py (new, compact tkinter dialog — first-run and settings modes in ONE window; lazy imports; no wizard, no second application); tray view gains the settings item; launcher wires both seams; worker.spec pins the tkinter hidden import (PyInstaller's hook bundles the tcl/tk runtime — verified present in the produced ZIP).
  • Security boundaries preserved: worker-local allowlist stays local (non-loopback Ollama refused in the settings layer BEFORE pairing, re-validated by LoopbackOllamaAdapter); server can never widen the allowlist; pairing code never persisted/logged/echoed; verified TLS unchanged; settings dialog holds no admin credentials and mutates no server configuration; Windows-native Codex NOT advertised (the dialog source contains no codex token — asserted by test; the Linux/WSL CLI path stays fail-closed). The library writes no files — the console attach uses a Win32 handle, keeping the M08 write-surface guardrail green.
  • Docs: README (standalone Windows instructions with scarcity-worker.exe), M10 acceptance record (worker sequence + new platform-table row), decision D-051 in docs/decisions.md.

Tests

New tests/test_worker_first_run.py (44 tests, all failing on pre-#113 code where applicable) drives the production seams — the packaged entry router, the real pairing protocol over in-memory transports, the real store — with fake views (no GUI pixels): entry routing, cancel/partial-failure semantics, code non-persistence (raw DB bytes), safe error mapping, strict settings parsing/secret smuggling, restart persistence, registry reuse, pair-only validity, packaged pair command, non-loopback refusal, no-Codex advertising. Updated tests/test_windows_packaging.py (spec shape incl. tkinter; launcher delegation; setup-view adapter lazy-import discipline).

Local gates: make check (1930 tests) green, uv run basedpyright 0/0/0, make package-check green, make guardrails green, git diff --check clean.

Windows candidate build (exact PR HEAD 91982ae)

GitHub manual release-candidate run 35791518999 (workflow_dispatch on fix/windows-first-run-onboarding @ 91982aecdb4120ac96a9a014f29cd67ea3001209): verify-release-contract success, build-and-verify-artifacts success, build-windows-worker-package success, finalize-release-bundle success; publish-github-release skipped, publish-pypi skipped. New ZIP downloaded and inspected: scarcity-worker-0.1.0-windows-x64.zip, 20,775,270 bytes, SHA256 5b977a39f1782b7830824ce8d138722c276ebeab3e393ae6ea77b4a89ee431c5, contains scarcity-worker.exe + _internal/ incl. _tkinter.pyd, tcl86t.dll, tk86t.dll and the tcl/tk data tree (927 entries).

Review

A fresh independent GLM-5.3-Flash (thinking MAX) review runs against this PR per the bounded review lifecycle; findings will be remediated (≤ 2 rounds) here. Live Windows acceptance remains with the owner (checklist in the task report).

Closes #113 (stays open for human review; do not merge before live Windows acceptance). ## Problem The v0.1.0 Windows ZIP (`scarcity-worker.exe`, candidate run `35780744775`) dead-ended on first launch: it instructed users to run `scarcity-router-worker pair …` — a Python console script the standalone package does not contain. `docs/m10-acceptance.md` already claimed the Windows package asks for pairing information "in its first-run dialog"; this PR makes that statement true. ## What this changes - **`scarcity_router/worker_setup.py` (new, GUI-independent core):** typed/versioned non-secret local settings (`WorkerLocalSettings`: optional control-UI origin + optional loopback Ollama resource/host/port) persisted as one strictly-parsed JSON value in the existing `WorkerLocalStore` (schema v1, exact key set, bounded, atomic, fail-closed); pairing orchestration (`pair_worker` → the EXISTING `WorkerRuntime.pair`); deterministic error mapping (safe, actionable, code-never-echoed); `complete_setup` with defined partial-failure semantics (failed pairing persists nothing; settings-save failure after pairing stays recoverable); documented precedence (`effective_arguments`: explicit CLI `--allow-*` flags replace the stored selection, otherwise stored settings drive); launch routing (`resolve_launch`). - **`windows_tray.py` (packaged entry router):** `pair …` → packaged CLI pairing (same path as the dialog; best-effort parent-console attach via CreateFileW + fd so output is visible from PowerShell/cmd despite the windowed build); no-arg launch → first-run dialog when unpaired, tray when paired; explicit run flags keep pre-#113 CLI semantics exactly. The tray runtime factory rebuilds the adapter registry from stored settings on every (re)connect through the EXISTING `build_local_adapter_registry`; a new "Worker settings…" tray action reopens the SAME dialog and a save triggers the controlled restart machinery. Remediation text names `scarcity-worker.exe`, never the console script. - **Packaging tree:** `scarcity_worker_setup_view.py` (new, compact tkinter dialog — first-run and settings modes in ONE window; lazy imports; no wizard, no second application); tray view gains the settings item; launcher wires both seams; `worker.spec` pins the `tkinter` hidden import (PyInstaller's hook bundles the tcl/tk runtime — verified present in the produced ZIP). - **Security boundaries preserved:** worker-local allowlist stays local (non-loopback Ollama refused in the settings layer BEFORE pairing, re-validated by `LoopbackOllamaAdapter`); server can never widen the allowlist; pairing code never persisted/logged/echoed; verified TLS unchanged; settings dialog holds no admin credentials and mutates no server configuration; Windows-native Codex NOT advertised (the dialog source contains no codex token — asserted by test; the Linux/WSL CLI path stays fail-closed). The library writes no files — the console attach uses a Win32 handle, keeping the M08 write-surface guardrail green. - **Docs:** README (standalone Windows instructions with `scarcity-worker.exe`), M10 acceptance record (worker sequence + new platform-table row), decision **D-051** in `docs/decisions.md`. ## Tests New `tests/test_worker_first_run.py` (44 tests, all failing on pre-#113 code where applicable) drives the production seams — the packaged entry router, the real pairing protocol over in-memory transports, the real store — with fake views (no GUI pixels): entry routing, cancel/partial-failure semantics, code non-persistence (raw DB bytes), safe error mapping, strict settings parsing/secret smuggling, restart persistence, registry reuse, pair-only validity, packaged `pair` command, non-loopback refusal, no-Codex advertising. Updated `tests/test_windows_packaging.py` (spec shape incl. tkinter; launcher delegation; setup-view adapter lazy-import discipline). Local gates: `make check` (1930 tests) green, `uv run basedpyright` 0/0/0, `make package-check` green, `make guardrails` green, `git diff --check` clean. ## Windows candidate build (exact PR HEAD `91982ae`) GitHub manual release-candidate run **`35791518999`** (`workflow_dispatch` on `fix/windows-first-run-onboarding` @ `91982aecdb4120ac96a9a014f29cd67ea3001209`): `verify-release-contract` success, `build-and-verify-artifacts` success, `build-windows-worker-package` success, `finalize-release-bundle` success; `publish-github-release` skipped, `publish-pypi` skipped. New ZIP downloaded and inspected: `scarcity-worker-0.1.0-windows-x64.zip`, 20,775,270 bytes, SHA256 `5b977a39f1782b7830824ce8d138722c276ebeab3e393ae6ea77b4a89ee431c5`, contains `scarcity-worker.exe` + `_internal/` incl. `_tkinter.pyd`, `tcl86t.dll`, `tk86t.dll` and the tcl/tk data tree (927 entries). ## Review A fresh independent GLM-5.3-Flash (thinking MAX) review runs against this PR per the bounded review lifecycle; findings will be remediated (≤ 2 rounds) here. Live Windows acceptance remains with the owner (checklist in the task report).
feat(worker): compact first-run setup for the packaged Windows executable (issue #113)
All checks were successful
ci / check (pull_request) Successful in 6m51s
91982aecdb
The v0.1.0 Windows ZIP dead-ended: scarcity-worker.exe told users to run
the Python console script the package does not contain. This makes the
documented first-run dialog claim (docs/m10-acceptance.md) true:

- worker_setup (library, GUI-independent): typed versioned non-secret
  local settings over the existing WorkerLocalStore (strict parse,
  fail closed, atomic); pairing orchestration over the EXISTING
  WorkerRuntime.pair shared by dialog and CLI; deterministic error
  mapping; loopback-only Ollama settings; documented CLI-vs-settings
  precedence; first-run/tray launch routing.
- windows_tray: entry router (pair / run / no-arg first-run), tray
  runtime factory rebuilt from stored settings on every reconnect,
  Worker settings action wired to the same dialog, best-effort parent
  console attach for packaged CLI output (CreateFileW handle, never a
  filesystem write), exe-named remediation text.
- packaging: tkinter setup dialog adapter beside the pystray tray view
  (lazy imports; first-run + settings modes in one compact dialog);
  spec pins the tkinter hidden import; launcher wires both seams.
- tests: discriminating first-run suite (entry routing, cancel and
  partial-failure semantics, real pairing path, code non-persistence,
  safe errors, settings strictness/precedence/restart, pair-only
  validity, loopback-only Ollama, packaged pair command, no Windows
  Codex advertising) + packaging spec/adapter shape updates.
- docs: README Windows package instructions, M10 acceptance record,
  D-051 decision record.
Author
Member

Independent review verdict: APPROVE

Fresh independent GLM-5.3-Flash (thinking MAX) review session over the full diff a7ea86d..91982ae, the governing docs (AGENTS.md, docs/security.md, docs/decisions.md D-042/D-044/D-049/D-050/D-051) and targeted test re-runs (61 tests green). No MERGE_BLOCKER finding stands; per the bounded review lifecycle no remediation round is triggered and the reviewed head stays 91982ae (the exact head of candidate run 35791518999).

All 15 required review questions answered YES/SUFFICIENT with file:line evidence, notably: complete ZIP-only first-run path; one pairing implementation across dialog + packaged CLI + console script; no-arg launch routing; allowlist stays worker-local (non-loopback Ollama refused before pairing, server cannot expand it); secrets out of the settings document (strict schema, raw-DB-byte assertion); pairing code never persisted/logged/echoed; TLS verification unchanged (grep-verified no bypass); deterministic persist/reload + documented CLI-vs-settings precedence; fail-closed malformed-config recovery; one compact dialog, not an application; Windows Codex honestly absent (dialog source contains no codex token, adapter gate fail-closed); docs match the artifact; windows-latest evidence correctly tied to this HEAD; nothing added as release theatre.

Recorded DEFER items (non-blocking follow-ups, not fixed in this PR to keep the reviewed head and the candidate-build evidence identical):

  1. _packaged_pair closes the store only on the success path (scarcity_router/windows_tray.py:677-700) — try/finally would match the zero-leak discipline; harmless today (process exits immediately after the failure).
  2. First-run status text says "Pairing over verified TLS…" even for loopback srw:// origins (packaging/windows/scarcity_worker_setup_view.py:381) — cosmetic; transport selection is correct.
  3. Tray error for a malformed stored settings document renders only the exception type name (scarcity_router/windows_tray.py:357-358) — full remediation text is visible in the Worker settings dialog, the documented recovery path.
  4. Settings-mode "Open server web UI" does not derive the origin from the paired origin when the control-UI field is empty (packaging/windows/scarcity_worker_setup_view.py:433-452) — not a dead end; the origin is displayed in the dialog header.
  5. pair --code CODE passes the transient one-time code as an argv — mirrors the frozen pre-existing console-script contract; future hardening (prompt fallback) needs its own interface decision.
  6. is_setup_available() is exported without a production caller — test seam only.

Remaining gates are all external/owner-side: EXTERNAL_ACCEPTANCE_GATE: LIVE_WINDOWS_ACCEPTANCE (double-click flow on Windows 11).

## Independent review verdict: APPROVE Fresh independent GLM-5.3-Flash (thinking MAX) review session over the full diff `a7ea86d..91982ae`, the governing docs (AGENTS.md, docs/security.md, docs/decisions.md D-042/D-044/D-049/D-050/D-051) and targeted test re-runs (61 tests green). **No MERGE_BLOCKER finding stands**; per the bounded review lifecycle no remediation round is triggered and the reviewed head stays `91982ae` (the exact head of candidate run `35791518999`). All 15 required review questions answered YES/SUFFICIENT with file:line evidence, notably: complete ZIP-only first-run path; one pairing implementation across dialog + packaged CLI + console script; no-arg launch routing; allowlist stays worker-local (non-loopback Ollama refused before pairing, server cannot expand it); secrets out of the settings document (strict schema, raw-DB-byte assertion); pairing code never persisted/logged/echoed; TLS verification unchanged (grep-verified no bypass); deterministic persist/reload + documented CLI-vs-settings precedence; fail-closed malformed-config recovery; one compact dialog, not an application; Windows Codex honestly absent (dialog source contains no codex token, adapter gate fail-closed); docs match the artifact; windows-latest evidence correctly tied to this HEAD; nothing added as release theatre. Recorded DEFER items (non-blocking follow-ups, not fixed in this PR to keep the reviewed head and the candidate-build evidence identical): 1. `_packaged_pair` closes the store only on the success path (`scarcity_router/windows_tray.py:677-700`) — try/finally would match the zero-leak discipline; harmless today (process exits immediately after the failure). 2. First-run status text says "Pairing over verified TLS…" even for loopback `srw://` origins (`packaging/windows/scarcity_worker_setup_view.py:381`) — cosmetic; transport selection is correct. 3. Tray error for a malformed stored settings document renders only the exception type name (`scarcity_router/windows_tray.py:357-358`) — full remediation text is visible in the Worker settings dialog, the documented recovery path. 4. Settings-mode "Open server web UI" does not derive the origin from the paired origin when the control-UI field is empty (`packaging/windows/scarcity_worker_setup_view.py:433-452`) — not a dead end; the origin is displayed in the dialog header. 5. `pair --code CODE` passes the transient one-time code as an argv — mirrors the frozen pre-existing console-script contract; future hardening (prompt fallback) needs its own interface decision. 6. `is_setup_available()` is exported without a production caller — test seam only. Remaining gates are all external/owner-side: `EXTERNAL_ACCEPTANCE_GATE: LIVE_WINDOWS_ACCEPTANCE` (double-click flow on Windows 11).
fix(worker): close the worker store exactly once on every exit path (issue #113)
All checks were successful
ci / check (pull_request) Successful in 6m56s
3aea980efb
The packaged Windows pairing path returned from typed pairing failures
without closing the WorkerLocalStore it opened - invalid/expired/used
codes, TLS and transport failures, origin validation all leaked. The
same inspection proved the identical defect in the analogous paths:
tray_main's tray-runtime store (leaked on config errors, never closed
on the success path) and worker_client.main (pair failures, run
configuration errors and raising run() leaked past the outer except).

All three sites now own their store explicitly: open -> try -> finally
close, exactly once on success, normal failure and exceptional failure,
never via GC or process exit, and never before the runtime that uses
the store has finished. Pairing semantics, messages, exit codes,
redaction and TLS verification are unchanged.

Tests: CloseCountingStore records close() calls; 21 new ownership
tests across the packaged pair command, the tray body and
worker_client.main assert close_count == 1 per path (16 of them
verified to fail against the pre-remediation code), and assert the
store is still open while the runtime runs. Focused suites emit zero
ResourceWarnings with PYTHONWARNINGS=error::ResourceWarning.
Author
Member

Lifecycle remediation: deterministic WorkerLocalStore ownership (commit 3aea980)

Defect. _packaged_pair() returned on every typed pairing failure (invalid/expired/used code, TLS failure, transport OSError, origin validation) without closing the WorkerLocalStore it opened — the success path was the only close. A failed first-run pairing is a normal user path, not an exotic edge. The same inspection proved the identical ownership defect in the directly analogous paths, fixed in the same commit:

  • tray_main() tray-runtime store: leaked on WorkerOrigin.parse/control_ui_origin/TrayNotAvailableError/build_runtime failures and was never closed on the success path;
  • worker_client.main(): pair failures, run configuration errors (WorkerOrigin.parse, build_registry), a raising runtime.run() (past the diagnostics finally) all leaked past the outer except.

Remediation. Explicit ownership at all three sites: open → try → finally: store.close() — exactly once on success, normal failure and exceptional failure; never via GC, interpreter shutdown or process exit; never before the runtime that uses the store finishes (run_tray_worker joins the session thread before returning; runtime.run() completes before the close, including when it raises). Pairing semantics, user-facing messages, exit codes (0/1/2/3), code redaction and TLS verification are unchanged.

Tests. New CloseCountingStore fixture (records close() calls) drives 21 new ownership tests: packaged pair success / invalid / expired / used / TLS / transport / config-after-open / unexpected-exception / code-never-persisted; tray paired launch, config failure and runtime failure (each also asserts the store is still open when the runtime starts — no early close); worker_client.main pair success, origin failure, transport failure, run not-paired, run config error, run fatal, budget-exhausted, run exception. 16 of the 21 tests fail against the pre-remediation code (independently re-verified by the reviewer against an extracted HEAD tree). Focused suites pass with PYTHONWARNINGS=error::ResourceWarning — zero project-owned resource warnings, no suppression added.

Gates. make check (1951 tests OK; basedpyright 0 errors / 0 warnings / 0 notes), make package-check PASS, make guardrails PASS, git diff --check clean. Forgejo CI run #1910 SUCCESS on 3aea980.

Review. One fresh independent review on GLM-5.3-Flash (thinking MAX): APPROVE, 0 MERGE_BLOCKER, 2 DEFER (a pre-existing 10 s join cap in run_tray_worker — residual, out of scope for this remediation; and a wording imprecision in the new fixture docstring, corrected in 3aea980).

New Windows candidate — run 35791518999 is superseded. GitHub manual run 35881326806 (workflow_dispatch on fix/windows-first-run-onboarding @ 3aea980efb8c90db0fa720623f157d052b0288d7, mirror SHA verified equal to this PR head): verify-release-contract success, build-and-verify-artifacts success, build-windows-worker-package success, finalize-release-bundle success; publish-github-release skipped, publish-pypi skipped. New ZIP downloaded and inspected: scarcity-worker-0.1.0-windows-x64.zip, 20,775,846 bytes, SHA256 bedb490ff1f6b916c2c40ae1334afa19d3b4f9dcda608f7f12f18ccdba320897, contains scarcity-worker.exe + _internal/ (1043 entries incl. _tkinter.pyd, tcl86t.dll, tk86t.dll, tcl/tk tree). Nothing was published.

The old candidate run 35791518999 (built from 91982ae) is obsolete and MUST NOT be used for final live acceptance. EXTERNAL_ACCEPTANCE_GATE (LIVE_WINDOWS_ACCEPTANCE: unzip → double-click → pair → tray → relaunch → settings) remains with the owner; the PR stays open and unmerged.

## Lifecycle remediation: deterministic `WorkerLocalStore` ownership (commit `3aea980`) **Defect.** `_packaged_pair()` returned on every typed pairing failure (invalid/expired/used code, TLS failure, transport `OSError`, origin validation) without closing the `WorkerLocalStore` it opened — the success path was the only close. A failed first-run pairing is a normal user path, not an exotic edge. The same inspection proved the identical ownership defect in the directly analogous paths, fixed in the same commit: - `tray_main()` tray-runtime store: leaked on `WorkerOrigin.parse`/`control_ui_origin`/`TrayNotAvailableError`/`build_runtime` failures and was never closed on the success path; - `worker_client.main()`: `pair` failures, `run` configuration errors (`WorkerOrigin.parse`, `build_registry`), a raising `runtime.run()` (past the diagnostics `finally`) all leaked past the outer `except`. **Remediation.** Explicit ownership at all three sites: `open → try → finally: store.close()` — exactly once on success, normal failure and exceptional failure; never via GC, interpreter shutdown or process exit; never before the runtime that uses the store finishes (`run_tray_worker` joins the session thread before returning; `runtime.run()` completes before the close, including when it raises). Pairing semantics, user-facing messages, exit codes (0/1/2/3), code redaction and TLS verification are unchanged. **Tests.** New `CloseCountingStore` fixture (records `close()` calls) drives 21 new ownership tests: packaged `pair` success / invalid / expired / used / TLS / transport / config-after-open / unexpected-exception / code-never-persisted; tray paired launch, config failure and runtime failure (each also asserts the store is still open when the runtime starts — no early close); `worker_client.main` pair success, origin failure, transport failure, run not-paired, run config error, run fatal, budget-exhausted, run exception. **16 of the 21 tests fail against the pre-remediation code** (independently re-verified by the reviewer against an extracted HEAD tree). Focused suites pass with `PYTHONWARNINGS=error::ResourceWarning` — zero project-owned resource warnings, no suppression added. **Gates.** `make check` (1951 tests OK; basedpyright 0 errors / 0 warnings / 0 notes), `make package-check` PASS, `make guardrails` PASS, `git diff --check` clean. Forgejo CI run **#1910 SUCCESS** on `3aea980`. **Review.** One fresh independent review on GLM-5.3-Flash (thinking MAX): **APPROVE**, 0 `MERGE_BLOCKER`, 2 `DEFER` (a pre-existing 10 s join cap in `run_tray_worker` — residual, out of scope for this remediation; and a wording imprecision in the new fixture docstring, corrected in `3aea980`). **New Windows candidate — run `35791518999` is superseded.** GitHub manual run **35881326806** (`workflow_dispatch` on `fix/windows-first-run-onboarding` @ `3aea980efb8c90db0fa720623f157d052b0288d7`, mirror SHA verified equal to this PR head): `verify-release-contract` success, `build-and-verify-artifacts` success, `build-windows-worker-package` success, `finalize-release-bundle` success; `publish-github-release` skipped, `publish-pypi` skipped. New ZIP downloaded and inspected: `scarcity-worker-0.1.0-windows-x64.zip`, 20,775,846 bytes, SHA256 `bedb490ff1f6b916c2c40ae1334afa19d3b4f9dcda608f7f12f18ccdba320897`, contains `scarcity-worker.exe` + `_internal/` (1043 entries incl. `_tkinter.pyd`, `tcl86t.dll`, `tk86t.dll`, tcl/tk tree). Nothing was published. **The old candidate run `35791518999` (built from `91982ae`) is obsolete and MUST NOT be used for final live acceptance.** EXTERNAL_ACCEPTANCE_GATE (LIVE_WINDOWS_ACCEPTANCE: unzip → double-click → pair → tray → relaunch → settings) remains with the owner; the PR stays open and unmerged.
fix(worker): one deliberate owner per Windows GUI event loop (D-052, issue #113)
All checks were successful
ci / check (pull_request) Successful in 6m55s
bc2cb15639
Live Windows retest: the settings dialog froze, lost focus transitions
and occupied the tray callback. Root cause: the dialog's tkinter
mainloop ran synchronously inside the pystray menu callback - one
toolkit's event loop nested inside another toolkit's message loop - so
the tray's Win32 loop could not service any action while the dialog
was open and Quit could not tear the process down deterministically.

New ownership model (D-052, docs/decisions.md): every long-lived event
loop has exactly one owner. The tray thread owns the Win32 message
loop and its menu callbacks only marshal or signal; ONE UI-owner
thread (windows_tray.UiDispatcher) owns every tkinter root, created,
mainlooped and destroyed strictly serially; the runtime keeps its
session thread; the process main thread coordinates restart/quit.
Crossings are asynchronous: dispatcher posts, threading events, the
store's own lock. A non-blocking settings slot taken at post time
makes repeat/rapid requests unable to open a second window; a close
signal plus the view's arm_close_request capability lets Quit unwind
an open dialog and join the UI thread with a bound before tray_main
returns - no orphan GUI threads, no daemon-kill reliance.

Shell/browser/folder launches and status notifications never run on
the tray loop. Onboarding semantics, pairing, messages, exit codes,
redaction and TLS verification unchanged; no new framework, process
or IPC layer; the library stays GUI-toolkit-free.

Tests: TrayUiOwnershipTests pins the model - the settings action
returns immediately, dialogs run on the UI-owner thread, five rapid
requests open exactly one window, Quit/Reconnect with the dialog open
are deterministic, Settings->Cancel->Settings->Save->restart and
Settings open->Quit sequences hold, and no UI/session thread survives
tray_main. Reviewer: independent GLM-5.3-Flash (thinking MAX) APPROVE
with hardening defers (slot/release order, enqueue-after-poison race)
applied in this commit.
Author
Member

Windows GUI ownership rework: D-052 (commit bc2cb15)

Live defect. On the Windows 11 acceptance host, the "Worker settings..." dialog froze, failed focus/foreground transitions, and occupied the tray callback. Root cause (architecture, not the Ollama fields): _open_settings_dialog ran the dialog's whole tkinter mainloop synchronously inside the pystray menu callback. pystray invokes menu handlers on its Win32 message-loop thread, so the tray loop was blocked for the dialog's lifetime — no menu action could run, focus behavior broke, and Quit could not deterministically tear the process down.

New model — D-052 (docs/decisions.md): one deliberate owner per long-lived event loop.

  • Tray/Win32 loop → the packaging adapter's tray thread; every menu handler now only marshals or signals (status → notify thread; control UI → worker thread; diagnostics folder → worker thread; Reconnect/Quit → events; Settings → dispatcher post).
  • Tkinter → ONE UI-owner thread (windows_tray.UiDispatcher, scarcity-router-ui), lazy-started, strictly serial; the first-run dialog runs there too while the coordination thread waits on the result future (a plain sequential wait, no nesting).
  • Worker runtime → its session thread (unchanged); UI → session restart stays the existing threading-event hook; settings persistence stays solely in WorkerLocalStore (locked, atomic single-row writes).
  • One settings window: a non-blocking slot is taken at POST time on the tray thread — rapid/repeat requests are dropped, never queued behind the dialog.
  • Deterministic teardown: dispatcher.close_request + the view's arm_close_request capability (packaged tkinter view polls it via after); Quit unwinds an open dialog, drains and joins the UI thread bounded before tray_main returns. No orphan GUI threads, no daemon-kill reliance.

No new framework, process or IPC layer; the library stays GUI-toolkit-free; onboarding semantics, pairing, messages, exit codes, redaction and TLS verification unchanged.

Tests. New TrayUiOwnershipTests (6): the settings action returns immediately while the dialog blocks on the UI-owner thread (different thread asserted); 5 rapid requests open exactly 1 window and a post-close request opens the next; Quit with the dialog open unwinds it via the close signal (armed, no restart, exit 0, no scarcity-router-ui/scarcity-router-worker thread left); Reconnect under an open dialog rebuilds the runtime deterministically; Settings→Cancel→Settings→Save→restart; "Open server control UI" never blocks its caller. Focused suites green, zero ResourceWarnings with PYTHONWARNINGS=error::ResourceWarning.

Gates. make check (1957 tests OK; basedpyright 0/0/0), make package-check PASS, make guardrails PASS, git diff --check clean. Forgejo CI run #1913 SUCCESS on bc2cb15.

Review. Fresh independent GLM-5.3-Flash (thinking MAX) review with the explicit instruction that an unchanged unsafe event-loop ownership is CHANGES_REQUESTED: APPROVE, 0 MERGE_BLOCKER, 5 DEFER. Two hardening defers were applied in the reviewed commit (slot acquire/release ordering on the view-less path; enqueue moved under the dispatcher lock so no task can land behind the shutdown pill). Remaining defers are documented semantics (Quit during an in-flight save discards the dialog; the atomic row applies at next restart — added to the live retest checklist), a cosmetic message tense change, and the live-Windows gate itself.

New Windows candidate — runs 35791518999 (old) and 35881326806 (store-ownership build) are both superseded. GitHub manual run 35996303779 (workflow_dispatch @ bc2cb156397aca658395f4bdf0bfd6ab28b7ed0a, mirror SHA verified equal to this PR head): verify-release-contract, build-and-verify-artifacts, build-windows-worker-package, finalize-release-bundle all success; publish-github-release and publish-pypi skipped. ZIP downloaded and inspected: scarcity-worker-0.1.0-windows-x64.zip, SHA256 1fc592f6c5a96bc8c9e74a0c220330272892e7d319d7bb93c7e82ce1916c549c (matches bundle SHA256SUMS), 1044 entries incl. scarcity-worker.exe, _internal/_tkinter.pyd, tcl86t.dll, tk86t.dll. Nothing published.

The live retest must exercise the tray as a whole (D-052 checklist in docs/m10-acceptance.md), not only Ollama field enablement: settings open → tray responsive (status / control UI / diagnostics / reconnect all work), second settings request opens no second window, Settings→Cancel→Settings, Settings→Save→restart→Settings, Settings open→Quit, Settings open→Reconnect, rapid repeated Settings, no freeze/focus loss, clean process exit. PR stays open and unmerged for EXTERNAL_ACCEPTANCE_GATE: LIVE_WINDOWS_ACCEPTANCE.

## Windows GUI ownership rework: D-052 (commit `bc2cb15`) **Live defect.** On the Windows 11 acceptance host, the "Worker settings..." dialog froze, failed focus/foreground transitions, and occupied the tray callback. Root cause (architecture, not the Ollama fields): `_open_settings_dialog` ran the dialog's whole tkinter mainloop **synchronously inside the pystray menu callback**. pystray invokes menu handlers on its Win32 message-loop thread, so the tray loop was blocked for the dialog's lifetime — no menu action could run, focus behavior broke, and Quit could not deterministically tear the process down. **New model — D-052 (`docs/decisions.md`): one deliberate owner per long-lived event loop.** - Tray/Win32 loop → the packaging adapter's tray thread; every menu handler now only marshals or signals (status → notify thread; control UI → worker thread; diagnostics folder → worker thread; Reconnect/Quit → events; Settings → dispatcher post). - Tkinter → ONE UI-owner thread (`windows_tray.UiDispatcher`, `scarcity-router-ui`), lazy-started, strictly serial; the first-run dialog runs there too while the coordination thread waits on the result future (a plain sequential wait, no nesting). - Worker runtime → its session thread (unchanged); UI → session restart stays the existing threading-event hook; settings persistence stays solely in `WorkerLocalStore` (locked, atomic single-row writes). - **One settings window**: a non-blocking slot is taken at POST time on the tray thread — rapid/repeat requests are dropped, never queued behind the dialog. - **Deterministic teardown**: `dispatcher.close_request` + the view's `arm_close_request` capability (packaged tkinter view polls it via `after`); Quit unwinds an open dialog, drains and joins the UI thread bounded before `tray_main` returns. No orphan GUI threads, no daemon-kill reliance. No new framework, process or IPC layer; the library stays GUI-toolkit-free; onboarding semantics, pairing, messages, exit codes, redaction and TLS verification unchanged. **Tests.** New `TrayUiOwnershipTests` (6): the settings action returns immediately while the dialog blocks on the UI-owner thread (different thread asserted); 5 rapid requests open exactly 1 window and a post-close request opens the next; Quit with the dialog open unwinds it via the close signal (armed, no restart, exit 0, no `scarcity-router-ui`/`scarcity-router-worker` thread left); Reconnect under an open dialog rebuilds the runtime deterministically; Settings→Cancel→Settings→Save→restart; "Open server control UI" never blocks its caller. Focused suites green, zero ResourceWarnings with `PYTHONWARNINGS=error::ResourceWarning`. **Gates.** `make check` (1957 tests OK; basedpyright 0/0/0), `make package-check` PASS, `make guardrails` PASS, `git diff --check` clean. Forgejo CI run **#1913 SUCCESS** on `bc2cb15`. **Review.** Fresh independent GLM-5.3-Flash (thinking MAX) review with the explicit instruction that an unchanged unsafe event-loop ownership is CHANGES_REQUESTED: **APPROVE**, 0 `MERGE_BLOCKER`, 5 `DEFER`. Two hardening defers were applied in the reviewed commit (slot acquire/release ordering on the view-less path; enqueue moved under the dispatcher lock so no task can land behind the shutdown pill). Remaining defers are documented semantics (Quit during an in-flight save discards the dialog; the atomic row applies at next restart — added to the live retest checklist), a cosmetic message tense change, and the live-Windows gate itself. **New Windows candidate — runs `35791518999` (old) and `35881326806` (store-ownership build) are both superseded.** GitHub manual run **35996303779** (`workflow_dispatch` @ `bc2cb156397aca658395f4bdf0bfd6ab28b7ed0a`, mirror SHA verified equal to this PR head): `verify-release-contract`, `build-and-verify-artifacts`, `build-windows-worker-package`, `finalize-release-bundle` all success; `publish-github-release` and `publish-pypi` skipped. ZIP downloaded and inspected: `scarcity-worker-0.1.0-windows-x64.zip`, SHA256 `1fc592f6c5a96bc8c9e74a0c220330272892e7d319d7bb93c7e82ce1916c549c` (matches bundle `SHA256SUMS`), 1044 entries incl. `scarcity-worker.exe`, `_internal/_tkinter.pyd`, `tcl86t.dll`, `tk86t.dll`. Nothing published. **The live retest must exercise the tray as a whole (D-052 checklist in `docs/m10-acceptance.md`), not only Ollama field enablement**: settings open → tray responsive (status / control UI / diagnostics / reconnect all work), second settings request opens no second window, Settings→Cancel→Settings, Settings→Save→restart→Settings, Settings open→Quit, Settings open→Reconnect, rapid repeated Settings, no freeze/focus loss, clean process exit. PR stays open and unmerged for EXTERNAL_ACCEPTANCE_GATE: LIVE_WINDOWS_ACCEPTANCE.
adrian.tkacz deleted branch fix/windows-first-run-onboarding 2026-09-24 13:26:07 +00:00
Sign in to join this conversation.
No description provided.