Skip to content

Add comprehensive testing guide documentation - #5

Merged
jdinalt merged 1 commit into
devfrom
claude/document-tests-0milZ
Feb 22, 2026
Merged

jdinalt merged 1 commit into
devfrom
claude/document-tests-0milZ

Conversation

@jdinalt

@jdinalt jdinalt commented Feb 22, 2026

Copy link
Copy Markdown
Owner

Summary

This PR adds a comprehensive testing guide (docs/development/testing.md) that documents how to run and work with the Forgather test suite. This is intended to help developers understand the test organization, how to run different test categories, and best practices for testing during development.

Key Changes

  • Test Organization Documentation: Detailed breakdown of the test directory structure, including unit tests, integration tests, distributed tests, and standalone scripts
  • Quick Reference Guide: Common pytest commands for running the full suite, specific test groups, and tests with coverage reporting
  • Unit Test Coverage: Comprehensive documentation of all unit test categories:
    • Core framework tests (configuration, preprocessing, code generation, YAML, templates)
    • ML subsystem tests (checkpointing, training, distributed coordination, model construction, optimization, loss functions, data handling)
    • Dataset tests (utilities, interleaving, HuggingFace loader)
    • DiLoCo distributed optimization tests
  • Distributed Test Documentation: Instructions for running tests that require torchrun, including checkpoint integration tests, dataloader dispatcher tests, and pipeline parallelism tests
  • CUDA Requirements: Clear documentation of which tests require CUDA and how they are automatically skipped on CPU-only machines
  • Shared Fixtures: Reference table of pytest fixtures available in conftest.py
  • Recommended Workflows: Practical guidance for different development scenarios (pre-PR submission, GPU access, focused development)
  • Configuration Reference: Documentation of pytest configuration in pytest.ini

Notable Details

  • Organized by test type and subsystem for easy navigation
  • Includes specific file-by-file breakdown of what each test covers
  • Provides both quick commands and detailed explanations
  • Distinguishes between tests that can run on CPU vs. those requiring CUDA
  • Documents both pytest-compatible tests and standalone scripts requiring torchrun
  • Includes marker-based test filtering guidance

https://claude.ai/code/session_017oY6i89L7mUfSTQ75hnra6

Document the test suite structure, how to run tests, and when to run
which groups. Covers unit tests, distributed tests requiring torchrun,
CUDA-gated tests, benchmarks, shared fixtures, and recommended workflows.

https://claude.ai/code/session_017oY6i89L7mUfSTQ75hnra6
@jdinalt
jdinalt merged commit 2f72635 into dev Feb 22, 2026
1 check passed
@jdinalt
jdinalt deleted the claude/document-tests-0milZ branch February 22, 2026 03:59
jdinalt added a commit that referenced this pull request Apr 19, 2026
Implemented fixes for issues identified in comprehensive code review:

1. **Removed batch buffer from length estimation** (lines 984-989)
   - Prevents race condition in multi-worker scenarios where
     _current_batch_buffer_size is not synchronized across processes
   - Simplifies logic while maintaining accurate progressive estimates

2. **Use full SHA256 hash for fingerprints** (lines 1145, 1193)
   - Changed from 16-char truncated hash to full 64-char hash
   - Eliminates collision risk for datasets with many configurations
   - Negligible storage cost for better safety

3. **Added shard index validation** (lines 358-363)
   - Validates index < num_shards before processing
   - Prevents silent empty shard creation from invalid parameters
   - Clear error messages for out-of-range indices

4. **Added batch map result validation** (lines 624-645)
   - Validates that batch map functions return consistent list lengths
   - Catches common error of returning scalars instead of lists
   - Prevents cryptic indexing errors during iteration
   - Allows intentional scalar broadcasting for metadata fields

Note: Length invalidation flag inheritance (#5) was already handled
correctly - both slice() and shard() reset _length_invalidated = False.

Issues documented but not fixed (require design changes):
- Map composition with different configurations (needs refactoring)
- State dict doesn't preserve map config (design limitation)
- Column names not updated after map (design limitation)

All 51 unit tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
jdinalt added a commit that referenced this pull request May 20, 2026
Backend (inference server)
- service.py: replace fastapi.HTTPException raised from _resolve_entry
  with a domain ModelNotFoundError; the service module stays
  transport-agnostic and the route layer translates to 404 via a new
  exception_handler in routes.py. (P3 #9)
- service.py: --eager-load loads every entry at startup so a broken
  checkpoint surfaces at boot instead of waiting for first request to
  land on it. (P3 #12)
- routes.py: every per-route catch-all now lets ModelNotFoundError
  bubble through to the app handler so a wrong "model" field returns
  404 rather than the catch-all's 500.

Backend (cluster picker)
- routes/cluster.py: rewrote the /inference_servers docstring — it
  used to claim "token-stripped" while the schema and serializer
  actually shipped the token. New docstring matches reality and
  points at the schema's rationale + follow-up note. (P1 #1)
- cluster_inference_inventory.py: token_for_url canonicalizes via
  urlparse before matching — lowercased scheme + host, default ports
  stripped, IPv6 brackets restored. URLs like "HTTP://Host:80" now
  find an entry stored as "http://host". (P3 #8)
- cluster_inference_inventory.py: wake_loops docstring updated to
  reflect the now-wired scheduler hook. (P1 #3)
- scheduler.py: new _wake_inference_inventory() helper called on
  inference-job launch and reap (regular + abort paths) so the
  cluster picker converges in ~1s rather than waiting for the 10s
  steady-state collect tick. (P1 #3)
- routes/inference_proxy.py: hoist cluster_inference_inventory import
  to module top and drop the bare-except wrapper. A typo in the
  inventory module now raises at import time instead of silently
  returning None for every off-host token lookup. (P2 #7)

WebUI
- InferenceModelPanel.tsx: gate jobsQ and clusterServersQ on
  clusterSelfQ.isFetched so the picker doesn't briefly show local-
  only state and then swap when the cluster gate resolves. Renders
  a "Loading…" placeholder for the brief gate-resolving window
  rather than the misleading "no live inference jobs" empty state.
  (P1 #2)
- InferenceModal.tsx: resetDefaults now also clears priority,
  matching the "back to first-open state" intent. (P3 #10)
- styles.css: .inference-model-rows-scroll uses max(240px, 30vh)
  so tall screens get more breathing room. (P3 #11)

Tests
- New tests/unit/forgather_server/test_routes_cluster_inference.py
  (9 tests): GET /inference_servers_local + tokens + 401 path; GET
  /inference_servers master path + non-master proxy fallback +
  pinned token-exposure assertion; POST /inference_servers/refresh
  wake-event firing + 401; _PEER_ALLOWED_PATHS membership for all
  three new paths. (P1 #4, P3 #13)
- New tests/unit/forgather_server/test_cluster_inference_inventory_
  master.py (12 tests): _collect_servers_tick local + peer-fanout +
  peer-error tolerance; _health_tick 200 / non-200 / connect-error
  / consecutive-failure-counter reset / zero-server pass-complete
  (also verifies the /health URL, not /v1/health); _sync_master_
  state across all three cluster states; wake_loops fans out to
  every registered event. (P2 #5)
- Extended tests/unit/forgather_server/test_inference_proxy.py with
  four cases for the new off-host branch: cluster-inventory hit,
  empty-inventory-returns-None, URL canonicalization, and a sanity
  test that the local loopback fast path is unaffected. (P2 #6)
- Updated tools/inference_server/tests/test_multi_model.py to
  assert ModelNotFoundError (with .requested / .available) instead
  of HTTPException after the decoupling.

All 164 backend tests green (66 cluster/inference + 33 proxy + 30
multi-model + 35 dataset-side); webui typecheck clean.

Live-smoke: --eager-load loads both models at startup; unknown
"model" field returns HTTP 404 with the new ModelNotFoundError
payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jdinalt added a commit that referenced this pull request May 20, 2026
* inference: multi-model server with CPU↔GPU swap

One inference-server process can now host multiple models. Requests
dispatch by the OpenAI 'model' field; models lazy-load on first use
and swap between CPU and GPU memory under an asyncio lock. Single-
model setups keep their existing eager-load behavior.

Backend (tools/inference_server/)
- service.py: new ModelEntry dataclass holds per-model state
  (tokenizer, weights, generation config, chat template, stop tokens,
  per-model utility objects). InferenceService becomes a registry
  with @Property shims so existing strategies still read
  service.model / service.tokenizer / etc. unchanged. New async
  acquire(name) context manager performs lazy-load + swap.
- routes.py: /v1/models lists all configured entries with an x_state
  field (unloaded / cpu / gpu). All generation routes (chat,
  completion, scoring fast-path, tokenize) wrap the strategy in
  acquire(); streaming holds the lock for the full SSE stream.
- server.py: -m / --model becomes repeatable, accepting PATH or
  NAME=PATH. Rejects -c <path> and device=auto with multi-model;
  warns on --compile with multi-model. New --keep-on-gpu flag
  disables CPU demotion so all models stay GPU-resident.
- config.py: YAML 'models:' list with per-model overrides (dtype,
  stop_sequences, chat_template, etc.). CLI -m takes precedence.
- core/generation_logger.py: tokenizer becomes a property reading
  through a factory so log decodes follow the active model.

Wire path
- src/forgather/cli/inference.py: forgather inf server --enqueue
  accepts -m repeatedly and --keep-on-gpu; packs 'models' list into
  job_params when more than one model is specified.
- tools/forgather_server/{inference_ops,launcher,scheduler}.py:
  thread 'models' and 'keep_on_gpu' through to argv.

WebUI (InferenceModal.tsx)
- Multi-model UX: "+ Add model" button in ad-hoc mode adds rows;
  each row has a path picker and optional name override. Hidden in
  project-backed flows (a project is a single model). The
  specific-checkpoint-path field is hidden when more than one
  model is configured.
- Scrollable row container so the modal doesn't outgrow the
  viewport when many models are added.
- "Keep all models on GPU" checkbox (visible in multi-model mode).
- "Create service…" suggested name: model basename for single model,
  host-port for multi-model (concatenating names doesn't scale).
- PathField gains a rememberKey prop that persists the parent of
  the last-picked path under a localStorage key; the next browse
  with an empty value lands at that parent — natural for "add
  another sibling" workflows like multi-model selection.

Tests
- tools/inference_server/tests/test_multi_model.py covers CLI arg
  parsing, YAML entry parsing, CLI/YAML reconciliation, service
  construction validation, name resolution, the acquire+swap
  protocol, and --keep-on-gpu. 30 tests, all green.

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

* cluster: aggregate inference servers across peers

The webui's "Running inference servers" picker only surfaced jobs
spawned on the local peer. In cluster mode, jobs running on other
peers were invisible — operators had to type the URL and bearer
token manually. This change mirrors the dataset-server inventory
pattern so the picker shows the full cluster.

Backend
- tools/forgather_server/cluster_inference_inventory.py (new):
  ``LocalInference`` + ``MasterInventory`` modeled on
  ``cluster_dataset_inventory``. Per-peer JobRecord scan with
  the multi-model-aware ``models[]`` extraction (single ``-m``
  path basename or per-entry names from ``-m NAME=PATH``), 0.0.0.0
  → cluster hostname rewriting, loopback flagging. Two async loops
  (collect from peers, /health probe) self-gate on master role.
- routes/cluster.py: GET /api/cluster/inference_servers_local
  (per-peer, tokens, peer-mTLS allowed), GET /api/cluster/
  inference_servers (master-aggregated, non-master proxies to
  master), POST /api/cluster/inference_servers/refresh (wake hook).
- routes/inference_proxy.py: ``_token_for`` consults the cluster
  inventory's ``token_for_url`` for off-host URLs. On master nodes
  this auto-attaches the upstream bearer; on non-master nodes the
  inventory is empty and the webui carries the token via the
  ``X-Inference-Auth-Token`` header.
- auth.py: adds the three new cluster paths to the mTLS peer
  allowlists.
- app.py: starts ``master_collect_servers_loop`` and
  ``master_health_loop`` on cluster-active boot; wires the
  membership listener so a master transition wakes both loops.

Tradeoff: ``/api/cluster/inference_servers`` includes the bearer
token in each entry. Stripping it would require either server-side
token attach via a non-master pull loop or per-request master
proxying — both substantially more work. The local /api/jobs
endpoint already exposes per-job tokens to the browser, so this
extension is consistent with today's surface, not a regression.
Comments at the new types flag the follow-up.

WebUI
- src/api.ts: new ``ClusterInferenceServer`` type +
  ``getClusterInferenceServers`` method.
- components/InferenceModelPanel.tsx: queries the cluster endpoint
  when in cluster mode and skips the local-jobs query; falls back
  to the local-jobs query otherwise. Picker rows unify both shapes
  through a new ``PickerRow`` interface. Header shows a "cluster"
  badge in cluster mode; rows get peer-id and health-failure
  indicators.

Tests
- tests/unit/forgather_server/test_cluster_inference_inventory.py:
  15 tests covering JobRecord scan (single + multi-model param
  shapes, loopback, 0.0.0.0 rewrite, scheme detection, non-running
  skip), ``models[]`` extraction across all four input shapes, and
  ``MasterInventory`` health-merge + ``token_for_url`` + role
  transitions. All green. Adjacent dataset-inventory tests still
  pass (50 total).

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

* review fixes: P1–P3 from inference multi-model + cluster picker review

Backend (inference server)
- service.py: replace fastapi.HTTPException raised from _resolve_entry
  with a domain ModelNotFoundError; the service module stays
  transport-agnostic and the route layer translates to 404 via a new
  exception_handler in routes.py. (P3 #9)
- service.py: --eager-load loads every entry at startup so a broken
  checkpoint surfaces at boot instead of waiting for first request to
  land on it. (P3 #12)
- routes.py: every per-route catch-all now lets ModelNotFoundError
  bubble through to the app handler so a wrong "model" field returns
  404 rather than the catch-all's 500.

Backend (cluster picker)
- routes/cluster.py: rewrote the /inference_servers docstring — it
  used to claim "token-stripped" while the schema and serializer
  actually shipped the token. New docstring matches reality and
  points at the schema's rationale + follow-up note. (P1 #1)
- cluster_inference_inventory.py: token_for_url canonicalizes via
  urlparse before matching — lowercased scheme + host, default ports
  stripped, IPv6 brackets restored. URLs like "HTTP://Host:80" now
  find an entry stored as "http://host". (P3 #8)
- cluster_inference_inventory.py: wake_loops docstring updated to
  reflect the now-wired scheduler hook. (P1 #3)
- scheduler.py: new _wake_inference_inventory() helper called on
  inference-job launch and reap (regular + abort paths) so the
  cluster picker converges in ~1s rather than waiting for the 10s
  steady-state collect tick. (P1 #3)
- routes/inference_proxy.py: hoist cluster_inference_inventory import
  to module top and drop the bare-except wrapper. A typo in the
  inventory module now raises at import time instead of silently
  returning None for every off-host token lookup. (P2 #7)

WebUI
- InferenceModelPanel.tsx: gate jobsQ and clusterServersQ on
  clusterSelfQ.isFetched so the picker doesn't briefly show local-
  only state and then swap when the cluster gate resolves. Renders
  a "Loading…" placeholder for the brief gate-resolving window
  rather than the misleading "no live inference jobs" empty state.
  (P1 #2)
- InferenceModal.tsx: resetDefaults now also clears priority,
  matching the "back to first-open state" intent. (P3 #10)
- styles.css: .inference-model-rows-scroll uses max(240px, 30vh)
  so tall screens get more breathing room. (P3 #11)

Tests
- New tests/unit/forgather_server/test_routes_cluster_inference.py
  (9 tests): GET /inference_servers_local + tokens + 401 path; GET
  /inference_servers master path + non-master proxy fallback +
  pinned token-exposure assertion; POST /inference_servers/refresh
  wake-event firing + 401; _PEER_ALLOWED_PATHS membership for all
  three new paths. (P1 #4, P3 #13)
- New tests/unit/forgather_server/test_cluster_inference_inventory_
  master.py (12 tests): _collect_servers_tick local + peer-fanout +
  peer-error tolerance; _health_tick 200 / non-200 / connect-error
  / consecutive-failure-counter reset / zero-server pass-complete
  (also verifies the /health URL, not /v1/health); _sync_master_
  state across all three cluster states; wake_loops fans out to
  every registered event. (P2 #5)
- Extended tests/unit/forgather_server/test_inference_proxy.py with
  four cases for the new off-host branch: cluster-inventory hit,
  empty-inventory-returns-None, URL canonicalization, and a sanity
  test that the local loopback fast path is unaffected. (P2 #6)
- Updated tools/inference_server/tests/test_multi_model.py to
  assert ModelNotFoundError (with .requested / .available) instead
  of HTTPException after the decoupling.

All 164 backend tests green (66 cluster/inference + 33 proxy + 30
multi-model + 35 dataset-side); webui typecheck clean.

Live-smoke: --eager-load loads both models at startup; unknown
"model" field returns HTTP 404 with the new ModelNotFoundError
payload.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jdinalt added a commit that referenced this pull request May 23, 2026
…xes (#69)

* Analyze tab: per-token scoring with histogram, plus dataset shortcuts (#55)

Adds an Analyze sub-tab to the webui Inference panel that visualizes per-token causal-LM loss / entropy with hover top-K alternatives, colormap choice (viridis / plasma / magma / inferno / turbo / green→red), auto or manual scale, EMA smoothing, SVG histogram, and drag-resizable splits.

Inference server: implements the previously-unimplemented logprobs field on POST /v1/completions using the OpenAI legacy "score the prompt" shape (echo=true, logprobs=K, max_tokens=0). Same request shape vLLM uses, so the same webui client works against vLLM once its Transformers v5 support lands. Adds a non-standard token_entropies field (Shannon entropy in nats of the full vocab distribution) that OpenAI/vLLM don't expose; clients fall back to loss when absent.

Dataset preview also got several quality-of-life improvements that pair with the analyze tab: drag-select inside a row no longer collapses it, right-click cell menu (Copy + Analyze in Inference… cross-section shortcut), and a 🎲 random-page-and-row pager button for sampling out of a large corpus.

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

* Multi-model inference + cluster-aware picker (#57)

* inference: multi-model server with CPU↔GPU swap

One inference-server process can now host multiple models. Requests
dispatch by the OpenAI 'model' field; models lazy-load on first use
and swap between CPU and GPU memory under an asyncio lock. Single-
model setups keep their existing eager-load behavior.

Backend (tools/inference_server/)
- service.py: new ModelEntry dataclass holds per-model state
  (tokenizer, weights, generation config, chat template, stop tokens,
  per-model utility objects). InferenceService becomes a registry
  with @property shims so existing strategies still read
  service.model / service.tokenizer / etc. unchanged. New async
  acquire(name) context manager performs lazy-load + swap.
- routes.py: /v1/models lists all configured entries with an x_state
  field (unloaded / cpu / gpu). All generation routes (chat,
  completion, scoring fast-path, tokenize) wrap the strategy in
  acquire(); streaming holds the lock for the full SSE stream.
- server.py: -m / --model becomes repeatable, accepting PATH or
  NAME=PATH. Rejects -c <path> and device=auto with multi-model;
  warns on --compile with multi-model. New --keep-on-gpu flag
  disables CPU demotion so all models stay GPU-resident.
- config.py: YAML 'models:' list with per-model overrides (dtype,
  stop_sequences, chat_template, etc.). CLI -m takes precedence.
- core/generation_logger.py: tokenizer becomes a property reading
  through a factory so log decodes follow the active model.

Wire path
- src/forgather/cli/inference.py: forgather inf server --enqueue
  accepts -m repeatedly and --keep-on-gpu; packs 'models' list into
  job_params when more than one model is specified.
- tools/forgather_server/{inference_ops,launcher,scheduler}.py:
  thread 'models' and 'keep_on_gpu' through to argv.

WebUI (InferenceModal.tsx)
- Multi-model UX: "+ Add model" button in ad-hoc mode adds rows;
  each row has a path picker and optional name override. Hidden in
  project-backed flows (a project is a single model). The
  specific-checkpoint-path field is hidden when more than one
  model is configured.
- Scrollable row container so the modal doesn't outgrow the
  viewport when many models are added.
- "Keep all models on GPU" checkbox (visible in multi-model mode).
- "Create service…" suggested name: model basename for single model,
  host-port for multi-model (concatenating names doesn't scale).
- PathField gains a rememberKey prop that persists the parent of
  the last-picked path under a localStorage key; the next browse
  with an empty value lands at that parent — natural for "add
  another sibling" workflows like multi-model selection.

Tests
- tools/inference_server/tests/test_multi_model.py covers CLI arg
  parsing, YAML entry parsing, CLI/YAML reconciliation, service
  construction validation, name resolution, the acquire+swap
  protocol, and --keep-on-gpu. 30 tests, all green.

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

* cluster: aggregate inference servers across peers

The webui's "Running inference servers" picker only surfaced jobs
spawned on the local peer. In cluster mode, jobs running on other
peers were invisible — operators had to type the URL and bearer
token manually. This change mirrors the dataset-server inventory
pattern so the picker shows the full cluster.

Backend
- tools/forgather_server/cluster_inference_inventory.py (new):
  ``LocalInference`` + ``MasterInventory`` modeled on
  ``cluster_dataset_inventory``. Per-peer JobRecord scan with
  the multi-model-aware ``models[]`` extraction (single ``-m``
  path basename or per-entry names from ``-m NAME=PATH``), 0.0.0.0
  → cluster hostname rewriting, loopback flagging. Two async loops
  (collect from peers, /health probe) self-gate on master role.
- routes/cluster.py: GET /api/cluster/inference_servers_local
  (per-peer, tokens, peer-mTLS allowed), GET /api/cluster/
  inference_servers (master-aggregated, non-master proxies to
  master), POST /api/cluster/inference_servers/refresh (wake hook).
- routes/inference_proxy.py: ``_token_for`` consults the cluster
  inventory's ``token_for_url`` for off-host URLs. On master nodes
  this auto-attaches the upstream bearer; on non-master nodes the
  inventory is empty and the webui carries the token via the
  ``X-Inference-Auth-Token`` header.
- auth.py: adds the three new cluster paths to the mTLS peer
  allowlists.
- app.py: starts ``master_collect_servers_loop`` and
  ``master_health_loop`` on cluster-active boot; wires the
  membership listener so a master transition wakes both loops.

Tradeoff: ``/api/cluster/inference_servers`` includes the bearer
token in each entry. Stripping it would require either server-side
token attach via a non-master pull loop or per-request master
proxying — both substantially more work. The local /api/jobs
endpoint already exposes per-job tokens to the browser, so this
extension is consistent with today's surface, not a regression.
Comments at the new types flag the follow-up.

WebUI
- src/api.ts: new ``ClusterInferenceServer`` type +
  ``getClusterInferenceServers`` method.
- components/InferenceModelPanel.tsx: queries the cluster endpoint
  when in cluster mode and skips the local-jobs query; falls back
  to the local-jobs query otherwise. Picker rows unify both shapes
  through a new ``PickerRow`` interface. Header shows a "cluster"
  badge in cluster mode; rows get peer-id and health-failure
  indicators.

Tests
- tests/unit/forgather_server/test_cluster_inference_inventory.py:
  15 tests covering JobRecord scan (single + multi-model param
  shapes, loopback, 0.0.0.0 rewrite, scheme detection, non-running
  skip), ``models[]`` extraction across all four input shapes, and
  ``MasterInventory`` health-merge + ``token_for_url`` + role
  transitions. All green. Adjacent dataset-inventory tests still
  pass (50 total).

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

* review fixes: P1–P3 from inference multi-model + cluster picker review

Backend (inference server)
- service.py: replace fastapi.HTTPException raised from _resolve_entry
  with a domain ModelNotFoundError; the service module stays
  transport-agnostic and the route layer translates to 404 via a new
  exception_handler in routes.py. (P3 #9)
- service.py: --eager-load loads every entry at startup so a broken
  checkpoint surfaces at boot instead of waiting for first request to
  land on it. (P3 #12)
- routes.py: every per-route catch-all now lets ModelNotFoundError
  bubble through to the app handler so a wrong "model" field returns
  404 rather than the catch-all's 500.

Backend (cluster picker)
- routes/cluster.py: rewrote the /inference_servers docstring — it
  used to claim "token-stripped" while the schema and serializer
  actually shipped the token. New docstring matches reality and
  points at the schema's rationale + follow-up note. (P1 #1)
- cluster_inference_inventory.py: token_for_url canonicalizes via
  urlparse before matching — lowercased scheme + host, default ports
  stripped, IPv6 brackets restored. URLs like "HTTP://Host:80" now
  find an entry stored as "http://host". (P3 #8)
- cluster_inference_inventory.py: wake_loops docstring updated to
  reflect the now-wired scheduler hook. (P1 #3)
- scheduler.py: new _wake_inference_inventory() helper called on
  inference-job launch and reap (regular + abort paths) so the
  cluster picker converges in ~1s rather than waiting for the 10s
  steady-state collect tick. (P1 #3)
- routes/inference_proxy.py: hoist cluster_inference_inventory import
  to module top and drop the bare-except wrapper. A typo in the
  inventory module now raises at import time instead of silently
  returning None for every off-host token lookup. (P2 #7)

WebUI
- InferenceModelPanel.tsx: gate jobsQ and clusterServersQ on
  clusterSelfQ.isFetched so the picker doesn't briefly show local-
  only state and then swap when the cluster gate resolves. Renders
  a "Loading…" placeholder for the brief gate-resolving window
  rather than the misleading "no live inference jobs" empty state.
  (P1 #2)
- InferenceModal.tsx: resetDefaults now also clears priority,
  matching the "back to first-open state" intent. (P3 #10)
- styles.css: .inference-model-rows-scroll uses max(240px, 30vh)
  so tall screens get more breathing room. (P3 #11)

Tests
- New tests/unit/forgather_server/test_routes_cluster_inference.py
  (9 tests): GET /inference_servers_local + tokens + 401 path; GET
  /inference_servers master path + non-master proxy fallback +
  pinned token-exposure assertion; POST /inference_servers/refresh
  wake-event firing + 401; _PEER_ALLOWED_PATHS membership for all
  three new paths. (P1 #4, P3 #13)
- New tests/unit/forgather_server/test_cluster_inference_inventory_
  master.py (12 tests): _collect_servers_tick local + peer-fanout +
  peer-error tolerance; _health_tick 200 / non-200 / connect-error
  / consecutive-failure-counter reset / zero-server pass-complete
  (also verifies the /health URL, not /v1/health); _sync_master_
  state across all three cluster states; wake_loops fans out to
  every registered event. (P2 #5)
- Extended tests/unit/forgather_server/test_inference_proxy.py with
  four cases for the new off-host branch: cluster-inventory hit,
  empty-inventory-returns-None, URL canonicalization, and a sanity
  test that the local loopback fast path is unaffected. (P2 #6)
- Updated tools/inference_server/tests/test_multi_model.py to
  assert ModelNotFoundError (with .requested / .available) instead
  of HTTPException after the decoupling.

All 164 backend tests green (66 cluster/inference + 33 proxy + 30
multi-model + 35 dataset-side); webui typecheck clean.

Live-smoke: --eager-load loads both models at startup; unknown
"model" field returns HTTP 404 with the new ModelNotFoundError
payload.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: multi-model inference + cluster picker (#58)

Cover the new surface across both technical and user-facing docs, and
add a CLAUDE.md reminder that docs ship in the same PR as the feature.

CLAUDE.md
- New "Documentation updates ship with the feature" note: both layers
  (technical docs in tools/<component>/, user-facing in docs/) update
  alongside the code, in the same PR by default.

Inference server technical docs (tools/inference_server/)
- README.md: -m repeatable PATH or NAME=PATH in Quick Start + Options;
  multi-model invariants; YAML "models:" list with per-entry overrides
  and global ``keep-on-gpu`` / ``eager-load``; /v1/models surfaces the
  ``x_state`` + ``x_model_path`` Forgather extensions; Limitations
  rewritten to document the per-registry lock and "no batching"
  reality; Features bullet added for multi-model hosting.
- ARCHITECTURE.md: new "Multi-Model Registry Pattern" section
  documenting ModelEntry / acquire() / lifecycle states /
  keep_on_gpu / eager_load. Service Layer rewritten to describe the
  registry + property shims + ModelNotFoundError translation. Request
  Flow diagrams updated to show acquire() wrapping the strategy in
  both non-streaming and streaming paths. InferenceService Core
  Components rewritten for the new constructor + methods. Known
  Limitations + Future Improvements updated for the post-multi-model
  state.

Forgather server technical docs (tools/forgather_server/README.md)
- Inference enqueue examples now show multi-model + --keep-on-gpu.
- Inference panel description notes the cluster-aware picker, the
  flicker-free cluster gate, peer-id badge, and unhealthy warning.
- InferenceModal multi-model UX documented: "+ Add model" button,
  scrollable row container, per-field rememberKey, host-port suggested
  service name, hidden specific-checkpoint field in multi-model mode.
- New "Cluster inference picker" subsection: cluster_inference_
  inventory.py structure, LocalInference / MasterInventory, the three
  endpoints + peer-allowlist carve-outs, scheduler wake hook, and the
  deliberate token-exposure tradeoff with the follow-up note.
- Background-loop table extended with the two new inference loops
  (collect + health) plus the dataset side now lists module-qualified
  names for clarity.

User-facing docs (docs/)
- README.md: index links to the inference server README + ARCHITECTURE
  under "Models and inference."
- guides/server-cli.md: multi-model enqueue examples, including
  --keep-on-gpu.
- guides/forgather-server-walkthrough.md: cluster-mode picker callout
  on the model-selection step + a tip on the sidebar Inference… modal
  for multi-model setups.

docs/tools/inference_server/{README,ARCHITECTURE}.md and
docs/forgather-server.md are symlinks to the canonical files in
tools/, so the mkdocs site picks up these updates automatically.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Single-spec passthrough for forgather eval test (#60)

* refactor: single-spec passthrough for forgather eval test

Previously a single eval CLI flag had to be declared in five places:
1. src/forgather/cli/eval_args.py        (the `test` subparser)
2. src/forgather/cli/eval.py             (cmd.extend forwarding)
3. src/forgather/cli/eval.py             (job_params for --enqueue)
4. tools/forgather_server/eval_ops.py    (build_eval_command kwargs)
5. tools/forgather_server/launcher.py
   + tools/forgather_server/scheduler.py (spawn_eval_process plumbing)
6. scripts/eval_script.py                (the script's own argparse)

Adding --fused-loss surfaced this — the user had to touch four of those
sites and still ended up with a subtle bug (typo'd parser.add_argument
on the wrong parser, so --fused-loss never appeared in --help).

Collapse all of it to a single spec table `_EVAL_SCRIPT_ARGS` in
eval_args.py. Three small helpers consume the spec:

  add_eval_script_args(parser)            — register on any parser
  forward_eval_script_args(args)          — emit CLI tokens from Namespace
  forward_eval_script_args_from_params(p) — emit CLI tokens from job_params

  eval_script_args_to_job_params(args)    — build the enqueue payload

The server-side trio (build_eval_command / spawn_eval_process /
_build_eval) collapses to a pass-through chain that funnels the
job_params dict into forward_eval_script_args_from_params, so the spec
is the only thing that has to change to add a new flag. scripts/
eval_script.py and the CLI's `test` subparser both call
add_eval_script_args() to declare the same args once.

To add a new passthrough flag now: append one entry to
_EVAL_SCRIPT_ARGS and use args.<dest> in eval_script.py. That's it.

Verified:
- `forgather eval test --help` shows --fused-loss
- Local dry-run forwards --fused-loss correctly
- End-to-end `forgather eval test --fused-loss ...` runs to completion
- Server-side cross-check: local cmd-tail and build_eval_command argv
  are byte-identical for the same logical input
- All 13 tests in tests/unit/forgather_server/test_eval_ops.py pass
  without modification (existing kwargs interface preserved via
  **passthrough)
- Added tests for --fused-loss and for unknown-kwarg drop semantics

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

* review: address spec-driven forwarding feedback

Address review feedback on PR #60:

- Local-CLI and enqueue paths disagreed on whether ``--batch-size 0`` was a
  legitimate value: the local path used ``is not None`` while the enqueue
  path used a truthy check, silently dropping 0. Introduce a third
  ``enqueue_when`` mode, ``"not_none"``, and switch the numeric value args
  (``--batch-size``, ``--max-length``, ``--stride``) to use it.

- Harden ``"always"`` forward mode against an explicit ``None`` in a future
  job_params payload (would have emitted the literal string ``"None"``).
  Fall back to the spec default in that case.

- Add ``_validate_spec()`` at import time. Catches enum-like typos
  (``forward``, ``enqueue_when``) and duplicate ``enqueue_key`` values at
  module load, instead of silently no-op'ing the first time the affected
  flag is used. This is the bug class the spec exists to prevent.

- Add ``passthrough_enqueue_keys()`` accessor and use it in
  ``scheduler._build_eval`` to whitelist forwarded keys. Defense-in-depth
  against future webui/queue fields (routing, accounting, etc.) leaking
  into the inner-script kwargs via ``**p``. The forwarder itself already
  ignores unknown keys; this keeps the kwarg surface honest too.

- Add tests:
  - Namespace-path ``--checkpoint`` rename: verifies the dest/enqueue_key
    divergence doesn't break the local CLI path.
  - ``--batch-size 0`` round-trips through both paths.
  - Cross-helper byte-equality: ``forward_eval_script_args(args) ==
    forward_eval_script_args_from_params(eval_script_args_to_job_params(args))``
    for a representative arg set. Locks the contract against future renames
    desyncing the two helpers.
  - ``_validate_spec()`` raises on bad ``forward`` mode and duplicate
    ``enqueue_key``.

All 581 tests under ``tests/unit/cli`` + ``tests/unit/forgather_server`` pass.
End-to-end dry-run output unchanged.

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

* webui: surface --fused-loss in EvalModal

The shared spec already exposes fused_loss through the enqueue payload
(``forgather.cli.eval_args._EVAL_SCRIPT_ARGS``); add a matching "fused loss"
checkbox to the EvalModal so webui-submitted eval jobs can opt into the
fused linear-cross-entropy path. Persists across ad-hoc invocations
alongside the other run-parameter toggles.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Handle HF-deduplicated tied weights in load_checkpoint (#59)

* fix: handle HF-deduplicated tied weights in load_checkpoint

HF safetensors cannot represent shared storage, so tied weight groups
(e.g. Gemma3's lm_head.weight tied to model.embed_tokens.weight) are
deduplicated on save — only the canonical name lands on disk. Strict
loads were rejecting these checkpoints for the missing aliases, breaking
forgather eval on tied-embedding models.

Detect tied groups in the in-memory module via create_sharing_metadata
and treat the missing aliases as already-covered: the in-place copy_ of
the canonical tensor updates the shared storage, and the caller's
tie_weights() step restores sharing if assign-mode rebinds parameters.

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

* review: document assign=True contract, expand test coverage

Address review feedback on PR #59:
- _synthesize_tied_aliases docstring now spells out the assign=True
  contract (storage shared, identity broken, caller must tie_weights()
  before any code path that depends on Parameter identity), and names
  the non-trainer callers (finalize.py, update_model/update.py) that
  carry the same responsibility.
- Add optional sharing_metadata parameter so callers that already have
  it don't re-walk the module.
- New tests: sharded-path negative case (untied missing key still fails
  strict), and assign=True load against a tied + deduped checkpoint
  (verifies storage sharing without identity, and that manual retie
  restores identity).

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

* docs: note pipeline-parallel caveat in _tied_aliases_in_module

Per-stage calls to ``create_sharing_metadata`` cannot see cross-stage
tied groups because ``id_to_fqn`` only walks the passed module. Not
reachable from current workflows but worth documenting for the next
maintainer who hits an HF-deduped checkpoint loaded into a pipeline-
split model.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add BPB / BPC to forgather eval test for cross-tokenizer comparison

Per-token perplexity isn't comparable across models with different
tokenizers: a coarser BPE drops perplexity mechanically because the
denominator (tokens) changes, not because the model is better.

This adds bits-per-byte (BPB) and bits-per-character (BPC) to every eval
run alongside the existing perplexity, plus the tokens_per_byte
conversion factor that makes the source of any past confusion explicit.
BPB is the de facto standard tokenizer-agnostic metric (Pile, GPT-3,
Karpathy's autoresearch). Byte/char counts come from a rank-0 pre-pass
that rebuilds the eval dataset (to avoid advancing an iterable dataset's
cursor) and decodes input_ids via tokenizer.decode(skip_special_tokens=True).
The example cap respects the trainer's effective dispatch_eval_batches
mode (default DDP dispatch caps at max_steps * batch_size; all-shards
DDP caps per-rank, so * world_size).

Downstream mirrors updated in lock-step:
- tools/forgather_server/routes/models.py EvalResultModel
- tools/forgather_server/webui/src/api.ts EvalResultData
- tools/forgather_server/webui/src/components/EvalResultTable.tsx
- tools/forgather_server/webui/src/components/EvalDetailPanel.tsx (bpb
  chip next to ppl)

Known approximation: bpb = eval_loss × tokens_per_byte / ln(2). The
trainer reports a mean of per-step-mean losses; this equals the true
token-mean exactly when batches have uniform valid-token counts (the
common case for eval with fixed max_length and full-length sequences)
and is otherwise <1% off. Making it exact would require accumulating
token-weighted loss inside the trainer's eval loops; deferred.

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

* Make defining (packed) datasets easier (#62)

* Make defining (packed) datasets easier

[Problems]
- Creating definitions for basic datasets, especially packed ones, is more
difficult that need-be.

- Our standard training template does not support easily using different
  datasets for train and validation.

[Solutions]

A new packed_load_dataset.yaml template has been created, which is
easily interchangeable with the base template, load_dataset.yaml. Just
change the name of the template to extend and you get a packed dataset.

- While doing so, I moved the common dynamic args into the base
  template, as these are now part of the protocol. This significantly
reduces the number of copies of those definitions.
- Simplified the library by making the defaults configs for most
  datasets of the "packed" type and deleted the now redundant packed
versions. Less code to maintain! And datasets using the fast-packed
format have proven to be reliable and a substantial performance win.
- Some of the legacy datasets, with references from examples to both the
  packed and non-packed versions still have both. Addressing this is a
larger refactor and I would like to limit the scope of this pass.
- Having eliminated the separate packed version of C4, the reference to
  the packed version in the evaluation projects was updated to point to
the now packed base version.

On the matter of split train / validation datasets, our training
template can now specify a separate "eval" dataset, but this defaults to
the train dataset.

A few other minor changes to meta-data was also made.

* Clean up loose ends from the packed-dataset refactor

- Update READMEs and a template comment to drop references to the
  packed/non-packed config pairs that were collapsed in e0e254cf
  (allenai, open-thoughts, ajibawa-2023, evaluation/common,
  packed_chat_template.yaml).
- Switch openthoughts.yaml to extend datasets/load_packed_dataset.yaml
  so it picks up the standard packed batch_size (250 vs the 32 it had
  been inheriting from the non-packed base, well below the 1000 the
  deleted openthoughts-packed.yaml used). Also restore
  stride: {{ stride | default(0) }} -- the previous commit accidentally
  hardcoded it to 0, silently breaking --stride.
- Drop the now-redundant ns.load_method overrides from the five
  ajibawa-2023 configs (load_packed_dataset.yaml -- and now even base
  load_dataset.yaml -- already set fast_load_iterable_dataset).
- Add trailing newlines to eight files touched by the previous commit
  (some pre-existing, some introduced).

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

* Remove broken docs symlinks for deleted packed dataset configs

The mkdocs CI job failed on the first broken symlink it encountered
(docs/examples/datasets/Open-Orca/templatelib/packed.yaml), because
mkdocs walks docs/ and tries to copy every file it finds as a static
asset -- including symlinks whose targets no longer exist.

Delete the eight symlinks under docs/ that point at *-packed.yaml /
packed.yaml dataset configs deleted in e0e254cf.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add float8 small-llm config stub (#63)

Minimal config for running the small-llm pretrain project with a
float8 model variant, so the path is available for testing on
hardware that supports it. Plan is to flesh this out with the
actual float8 settings and docs before merging back to main.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* CPU-only support: training, eval, inference end-to-end without a CUDA driver (#64)

* docker/run: clean up half-created container on docker-run failure

When `docker/run` is invoked on a host with no NVIDIA driver, the
default `--gpus all` fails with "failed to discover GPU vendor from
CDI" but leaves the container in a half-created state. The next
invocation (e.g. `GPUS=none docker/run`) would then take the
"existing container -> docker start" path and re-apply the bad
create-time GPU config, silently ignoring the new env var. Operators
had to know to run `docker/run --rm` first.

The wrapper now removes the partial container on failure and prints
a hint pointing at `GPUS=none` so `GPUS=none docker/run` Just Works
on the retry. Also documents `docker/build --cpu` and the
no-NVIDIA-driver path in the install guide, which previously only
mentioned `GPUS=none` as a "common override" without flagging it as
the required mode for GPU-less hosts.

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

* preprocess: cache get_peak_hardware_flops, silence warning on CPU-only hosts

Two follow-on issues with the fallback warning:

1. The function is called ~5 times per `forgather train` invocation
   (dynamic-arg schema scan, required-args check, bounds validation,
   final materialization, ...), so on a host without a recognized GPU
   the warning fires 5+ times back-to-back. Cache the resolved value
   at module scope so subsequent calls return immediately.

2. On a CPU-only host (no CUDA visible at all) the warning is just
   noise: MFU is meaningless on CPU regardless of the placeholder.
   Suppress the warning when `_detect_gpu_flops` returns a None
   device_name. An *unrecognized* GPU (device_name set, flops=None)
   still warns -- the operator can fix that by adding a
   hardware.yaml entry.

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

* train CLI: add --nproc override, auto-fall back from 'gpu' to 1 on CPU-only

The lm_training_project template defaults nproc_per_node to "gpu", which
torchrun resolves to the visible CUDA device count. On a host with no
CUDA device (CPU-only torch build, --gpus none under docker, etc.)
torchrun aborts immediately with:

    ValueError: invalid literal for int() with base 10: 'gpu'

Two changes in the `forgather ... train` CLI:

* `-n / --nproc N` lets the operator override --nproc-per-node from
  the command line (accepts the same values torchrun does -- integer,
  "gpu", or "auto"). Honored on both the local and --enqueue paths.

* When --nproc isn't passed and the config asks for "gpu" but no
  CUDA device is visible, fall back to --nproc-per-node 1 with a
  warning instead of letting torchrun crash. Useful for debugging
  training on a laptop / Chromebook without changing project configs.

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

* distributed: force gloo backend on CPU fallback, regardless of caller's spec

DistributedEnvironment honored whatever backend the caller passed in,
and the default template ships backend="cuda:nccl,cpu:gloo" (a compound
spec meant to pick the right backend per device). On a CPU-only torch
build the NCCL backend isn't compiled in at all, and
init_process_group aborts with "Distributed package doesn't have NCCL
built in" the moment NCCL is mentioned -- the device-aware dispatch
doesn't help because the failure happens during backend registration,
before any device is selected.

When the device falls back to CPU (no accelerator visible), unconditionally
override the backend to gloo. Gloo is the correct backend for CPU ranks
and is always available. Log at INFO if we're overriding a non-gloo
value the caller explicitly asked for so the substitution isn't silent.

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

* modelsrc/transformer: drop cache_position from create_causal_mask call

transformers 5.9 removed `cache_position` from the
create_causal_mask / create_sliding_window_causal_mask signature
("Deprecated and unused.") and shifted the 4th positional slot to
`past_key_values`. The existing call passed `cache_position`
positionally and `past_key_values=` as a kwarg, which on 5.9 collides:

    TypeError: create_causal_mask() got multiple values
               for argument 'past_key_values'

Drop the cache_position forward (the helper derives it from
past_key_values internally), and pass past_key_values by keyword so
the call works on both the pre-5.9 signature (where past_key_values
was kwarg-only) and the current one (where it's the 4th positional).

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

* inference server: default --device to 'auto' instead of 'cuda:0'

Previously the inference server hard-coded a CUDA device default, so
`forgather inf server -m MODEL` aborted on a host with no CUDA
device (CPU-only torch build, --gpus none under docker) unless the
operator passed an explicit `-d cpu`. The downstream loader already
handles `auto` correctly -- it forwards to HF's device_map='auto',
which picks CPU when no accelerator is visible.

Switch the default to `auto`. dtype resolution already returns
float32 on CPU when --dtype is unspecified, so the server now
starts and serves OpenAI-compatible requests on a CPU-only host
out of the box. (Operators on GPU hosts get the same behaviour as
before -- HF's auto picks cuda:0 when CUDA is available.)

Verified end-to-end on the CPU dev container with sshleifer/tiny-gpt2:
model loads on device cpu / torch.float32, /health responds,
/v1/chat/completions returns a real completion.

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

* inference server: detect a real default device, not 'auto' or hard-coded cuda:0

Walking back the previous commit's default of "auto". Two problems with it:

1. "auto" is not a real torch.device string. HF's from_pretrained
   accepts it (via accelerate's device_map='auto'), but the native
   Forgather checkpoint loader (forgather.ml.sharded_checkpoint,
   used by ``-c / --from-checkpoint``) feeds it to torch.device(...)
   and gets "Invalid device string: 'auto'".

2. device_map='auto' shards the model across every visible GPU.
   On a multi-GPU host the operator who passed ``-m model -p 8137``
   expecting a single-GPU server instead has every GPU on the box
   commandeered, blocking concurrent training jobs.

Replace with ``_default_device()``: probe ``torch.accelerator`` (the
accelerator-agnostic API, covers CUDA / XPU / MPS / ROCm) and return
``<acc>:0`` if any device is visible, else "cpu". Single device,
real torch.device string, falls back gracefully to CPU on hosts
without an accelerator. The hard-coded "cuda:0" of the previous
default was also wrong on non-CUDA accelerators.

Verified on the CPU dev container: ``forgather inf server -m
models/final -c`` (native loader, --from-checkpoint) loads on
device=cpu / float32 and /v1/chat/completions returns 20 tokens
in 7.5s.

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

* eval: auto-fall back from --nproc-per-node 'gpu' to 1 on CPU-only hosts

Mirror of the train-CLI fix in 3339eb3b. ``forgather eval test`` and
the server's enqueue path both hard-coded
``torchrun --standalone --nproc-per-node gpu`` for the ddp / pipeline
trainers. torchrun's "gpu" sentinel resolves to the visible CUDA
device count, which is 0 on a CPU-only torch build / --gpus none
docker / host without an NVIDIA driver -- the launcher aborts with
"invalid literal for int() with base 10: 'gpu'" before the eval
script starts.

When no CUDA device is visible, fall back to nproc-per-node 1 so
``forgather eval test -M MODEL`` keeps working for CPU debugging
on the user's existing default (``--trainer ddp``). The CLI emits a
one-line warning; the server-side path falls back silently (the
scheduler still gates which GPUs are visible via
CUDA_VISIBLE_DEVICES, so on GPU servers "gpu" picks the gated
subset as before).

Verified end-to-end on the CPU dev container against models/final:
both ``--trainer simple`` (no torchrun) and ``--trainer ddp``
(torchrun + nproc=1 fallback, gloo backend) return the same
eval_loss=1.57 / perplexity=4.80 in ~8-10s wall time.

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

* webui: allow 0-GPU inference jobs, pin spawned server to -d cpu

User report: the Inference modal would not let you submit a job with
0 GPUs (the GPUs field clamped to min=1), so on a CPU-only host the
form forced ``requested_gpus=1``, the scheduler couldn't place the
job (no GPU available), and the request lingered in the queue
forever without spawning anything.

Four-part fix end-to-end:

1. webui (InferenceModal.tsx): allow GPUs=0 in the input (min=0,
   updated clamp, dropped the Math.max(1, ...) baseline for
   maxGpus), and snap the persisted value into the available range
   once the GPU list resolves. Adds a "0 = run on CPU" hint when
   the user picks 0 on a host that has GPUs available, and a "CPU
   only" suffix when the host has no GPUs at all.

2. tools/forgather_server/routes/queue.py: add ``inference`` to
   ``_ZERO_GPU_JOB_TYPES``. The route was rejecting
   ``requested_gpus=0`` for inference jobs with HTTP 400
   "requested_gpus must be >= 1 for inference jobs", which is no
   longer true now that the inference server's _default_device()
   detects CPU and the launcher pins ``-d cpu`` on zero-GPU
   dispatches.

3. tools/forgather_server/inference_ops.py: change the
   ``build_inference_command(device=...)`` default from a hard-coded
   ``"cuda:0"`` to ``None``, which omits the ``-d`` flag entirely
   and lets the server's own _default_device() decide. Callers can
   still pin a specific device when they have one.

4. tools/forgather_server/launcher.py: in spawn_inference_process,
   compute ``device = "cpu" if not gpu_indices else None`` so a
   zero-GPU dispatch produces an explicit ``-d cpu`` in the argv
   (visible in the TTY log / process listing for triage) while
   GPU dispatches inherit the server's default-device behaviour
   against the CUDA_VISIBLE_DEVICES the launcher already sets.

Verified end-to-end inside the CPU dev container:
``POST /api/queue {requested_gpus: 0, job_type: "inference", ...}``
spawned ``server.py ... -d cpu -m models/final -T bfloat16 -a sdpa
-c ...`` against the user's local model, the health endpoint
returned healthy, and a chat completion returned 15 tokens.

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

* inference modal: expose explicit device override (incl. HF auto-shard)

Follow-up to 21d44e11. The previous fix let the launcher derive the
spawned server's ``-d`` flag from gpu_indices (cpu when 0 reserved,
else omit so the server self-detects), but offered no way for the
operator to pass anything else from the webui -- in particular the
"auto" sentinel that HF's device_map='auto' expects for sharding a
model across multiple GPUs (HF loader only, incompatible with
``--from-checkpoint`` because the native loader feeds device strings
directly to torch.device()).

Thread an optional ``device`` field through job_params:

* InferenceModal.tsx: add a Device select next to GPUs with options
  ``default`` (empty -> launcher decides), ``auto`` (HF
  device_map='auto'), ``cpu``, ``cuda:0``. Inline-warn when the
  operator picks ``auto`` while ``--from-checkpoint`` is on, and
  block the submit since the spawn would crash with "Cannot use
  'auto' device with checkpoint loading".

* scheduler.py:_build_inference: pull ``device`` from job_params
  (trimmed, blank -> None) and forward it to spawn_inference_process.

* launcher.py:spawn_inference_process: accept an optional ``device``
  kwarg. Caller's explicit value (e.g. ``"auto"``) wins; only when
  it's None do we fall back to the gpu_indices-derived choice
  (cpu / omit) from the prior commit.

Verified end-to-end against ``sshleifer/tiny-gpt2`` on the CPU dev
container: enqueue with ``{requested_gpus: 0, device: "auto",
from_checkpoint: false}`` -> spawned argv ``... -d auto -m
/tmp/tiny-gpt2 -T float32 ...``, /health -> healthy, chat
completion returned 5 tokens. (HF's device_map='auto' picks CPU
when no GPU is visible, so the path is correct on CPU hosts too.)

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

* webui: allow 0-GPU eval and training jobs, isolate zero-GPU subprocesses

Follow-on to 21d44e11 and fe18c5cd. The same min={1} GPUs lock that
blocked CPU-only inference from the webui also blocked eval and
training. Five-part fix end-to-end:

* routes/queue.py: add ``training`` and ``eval`` to
  _ZERO_GPU_JOB_TYPES so the POST /api/queue validation no longer
  rejects requested_gpus=0 for these types. (Now valid because the
  CLI / launcher paths fall back to nproc=1 on zero-GPU dispatch.)

* launcher.py:_spawn_subprocess: when gpu_indices is empty, set
  CUDA_VISIBLE_DEVICES="" on the subprocess env. Previously the
  branch was skipped, so a zero-GPU dispatch on a GPU host inherited
  the parent's (typically unset) CUDA_VISIBLE_DEVICES and torchrun's
  "gpu" sentinel grabbed every GPU on the box -- defeating the
  point of reserving 0. Now the subprocess truly sees no CUDA.

* launcher.py:build_command (training): accept ``gpu_indices`` and
  override nproc_per_node="gpu" -> 1 when the reservation is empty
  in single-node (standalone) mode. Mirrors the train CLI's fix in
  3339eb3b. Cluster dispatches still use rdzv_args's cluster_nproc.

* eval_ops.py:build_eval_command: take ``gpu_indices`` directly
  instead of guessing via torch.cuda.is_available() inside the
  server process. The server-process check was wrong on a GPU host
  that happened to dispatch a zero-GPU job. spawn_eval_process in
  launcher.py now passes gpu_indices through.

* EvalModal.tsx + SubmitModal.tsx: drop the Math.max(1, ...)
  baseline for maxGpus, switch input min to 0, fix the onChange
  clamp, add an inline "0 = run on CPU" hint. EvalModal also gains
  the useEffect that snaps the persisted value into [0, maxGpus]
  once the GPU list resolves (mirrors InferenceModal).

Verified inside the CPU dev container against the local forgather
server:
  POST /api/queue {requested_gpus: 0, job_type: "eval", ...} ->
    spawned ``torchrun --standalone --nproc-per-node 1
    .../eval_script.py ... --trainer ddp ...``
  POST /api/queue {requested_gpus: 0, job_type: "training", ...,
    config: "v2.yaml"} ->
    spawned ``torchrun --standalone --nproc-per-node 1
    .../train_script.py -p .../tiny_llama ... v2.yaml``

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

* SubmitModal: always clamp requestedGpus to [0, maxGpus] after GPU list loads

Follow-up to d8d6dc1e. Even after switching min={0}, the train modal
still showed (and submitted) requestedGpus=1 on a 0-GPU host because
the seed-from-cache useEffect only set requestedGpus when one of two
branches fired (cached override present, or config pinned a fixed
worker count). With neither -- the common case for "nproc_per_node:
gpu" configs and no prior submit -- the initial useState(1) stuck
through to submit.

Add a second useEffect that clamps the current value into
[0, maxGpus] whenever the GPU list resolves (mirroring EvalModal
and InferenceModal). It runs independent of gpusTouched / the seed
effect; clamping is idempotent so the ordering doesn't matter, and
on a 0-GPU host the form now correctly snaps to 0.

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

* SubmitModal: add nproc override field for CPU debugging

User report: training without GPUs only spawns 1 worker process
(because nproc_per_node='gpu' falls back to 1 when no GPU is
reserved), which underutilises a multi-core CPU. The dynamic-args
form can already drive ``nproc_per_node`` for configs that expose it,
but that's config-dependent and easy to miss for operators just
trying to run a CPU debug pass.

Add a dedicated single-node nproc override that's plumbed
independently of the config's dynamic args, mirroring the train
CLI's ``--nproc`` (3339eb3b):

* SubmitModal: text input next to GPUs, with the current config's
  ``nproc_per_node`` shown as placeholder. Accepts an integer or
  torchrun's ``gpu`` / ``cpu`` / ``auto`` sentinels. Blank = use
  the config (and the launcher's "gpu"->1 fallback when 0 GPUs are
  reserved). Forwarded as ``job_params.nproc`` only when non-empty.

* scheduler._build_training: read ``nproc`` from job_params, pass
  to spawn_training_process as ``nproc_override``.

* launcher.spawn_training_process / build_command: accept
  ``nproc_override`` and use it instead of the config's
  nproc_per_node. Only applied in single-node (standalone) mode;
  cluster dispatches still take per-peer nproc from rdzv_args.

Verified inside the CPU dev container against the local forgather
server:
  POST /api/queue {requested_gpus: 0, job_params: {nproc: "4"}, ...}
  -> spawned ``torchrun --standalone --nproc-per-node 4
     .../train_script.py ...`` with 4 worker processes.

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

* EvalModal: add nproc override field, mirroring SubmitModal

Match the train-modal change in d6c6e8fd. The eval CLI doesn't take
``--nproc`` (eval uses ``--trainer simple/ddp/pipeline`` and the
launcher derives nproc from gpu_indices), but for CPU debugging it's
useful to spawn more than one ddp worker (the launcher's "gpu"->1
fallback otherwise underutilises a multi-core host).

* EvalModal: text input next to GPUs. Disabled for --trainer simple
  (it bypasses torchrun entirely). Accepts an integer or torchrun's
  ``gpu`` / ``cpu`` / ``auto`` sentinels. Blank = use the
  gpu_indices-derived default. Forwarded via job_params.nproc only
  when non-empty and trainer != simple.

* scheduler._build_eval: extract ``nproc`` from job_params (it isn't
  a flag for eval_script.py so it doesn't belong in the
  _EVAL_SCRIPT_ARGS passthrough spec) and pass to
  spawn_eval_process as nproc_override.

* launcher.spawn_eval_process + eval_ops.build_eval_command: accept
  ``nproc_override`` and use it instead of the gpu_indices-derived
  default. Explicit override wins; the 0-GPU "gpu"->1 fallback is
  the next-priority default.

Verified inside the CPU dev container:
  POST /api/queue {requested_gpus: 0, job_type: "eval",
    job_params: {trainer: "ddp", nproc: "3", ...}}
  -> spawned ``torchrun --standalone --nproc-per-node 3
     .../eval_script.py ...`` with 3 worker processes.

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

* review: address PR #64 findings (CUDA-hide regression, UX clarity, dedup)

Acting on the backend / webui reviews of bug/fix-cpu-support.

Backend (block-merge candidate from review):
* launcher._spawn_subprocess no longer sets CUDA_VISIBLE_DEVICES=""
  on every empty-gpu_indices dispatch -- that regressed the
  ConvertModal / FinalizeModal escape hatch where requested_gpus=0
  paired with --device cuda:0 means "use host CUDA without a
  reservation" (ConvertModal even shows an inline hint about this:
  "0 = CPU only; raise if you set --device cuda…"). Move the
  isolation into a per-spawn helper _isolate_cuda_extra_env() and
  invoke it only from spawn_training_process / spawn_eval_process,
  not from spawn_inference_process (single-process, ``-d`` flag
  pins the device directly) or any *_ops spawn path that doesn't
  use torchrun. Operator-supplied CUDA_VISIBLE_DEVICES via
  extra_env still wins -- setdefault, not unconditional set.

Backend (nit-cleanup):
* scheduler: factor the duplicated nproc-coercion ladder into a
  single _coerce_nproc_override(raw) helper used by both
  _build_training and _build_eval.

Backend (observability):
* scheduler._build_training / _build_eval: log at INFO when a
  training / eval job dispatches with 0 GPUs reserved (single-node
  only -- cluster dispatches don't go through this path). Operators
  who accidentally enqueue at requested_gpus=0 through a non-modal
  path get a visible breadcrumb in server.log instead of silently
  running on CPU.

Webui (should-fix from review):
* EvalModal nproc placeholder was "auto", but the default the
  launcher uses for --trainer ddp / pipeline is torchrun's "gpu"
  sentinel (falls back to 1 on 0-GPU dispatch). Change placeholder
  to "gpu" so the hint matches reality.
* InferenceModal auto+from_checkpoint warning was jargony ("the
  native loader"). Rewrite in user-visible terms: names HF
  device_map sharding and the "Load via Forgather checkpoint"
  toggle by their UI labels.

Deferred / declined from review:
* distributed.py force-gloo override on CPU even for explicit
  backend="nccl": keep as-is. CPU + nccl is always a config error;
  current behaviour logs at INFO when overriding, which is the
  right loudness for "we substituted a working backend for a
  broken request".
* transformers 5.0-5.8 compat of the create_causal_mask fix:
  installed in the dev container is 5.9.0; pyproject pins ~=5.0
  but the prior commit (0499a06d) already established the
  forward-compat strategy with the same authors. Will add a
  version-aware shim if a 5.0-5.4 user reports breakage.
* Webui: limited device dropdown (no cuda:1 / xpu:0 / mps:0),
  free-form nproc validation, title-only help a11y, inline
  "width: 6em": all legitimate nits, none block CPU-only operation;
  defer to a separate UX pass.

Verified helpers behave as advertised:
  _coerce_nproc_override("  4  ") -> "4"
  _coerce_nproc_override("")      -> None
  _coerce_nproc_override(3)       -> "3"
  _coerce_nproc_override(None)    -> None
  _isolate_cuda_extra_env([], None)
    -> {"CUDA_VISIBLE_DEVICES": ""}
  _isolate_cuda_extra_env([0, 1], None) -> None
  _isolate_cuda_extra_env([], {"CUDA_VISIBLE_DEVICES": "0,1"})
    -> {"CUDA_VISIBLE_DEVICES": "0,1"}   (operator wins)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Inference client: surface vLLM reasoning trace separately from content (#65)

When a vLLM reasoning parser is active (e.g. --reasoning-parser qwen3
on Qwen3.6) the model's pre-answer thinking lands in
delta.reasoning / message.reasoning, not delta.content. Both the
Python CLI client and the TS webui client were reading only content,
so a thinking model that ran out of token budget mid-<think> appeared
to return nothing at all.

Both clients now consume the reasoning channel:

- Python CLI (tools/inference_server/client.py): _consume_chat_stream
  and _consume_chat_response helpers, shared by get_completion and
  single_shot, print reasoning in ANSI dim then content normal. ANSI
  codes are resolved per call (not at import time) so test harnesses
  that capture stdout still get plain text. A single streaming chunk
  carrying both reasoning and content is handled in full (no
  continue-after-reasoning that would silently drop the content).
  conversation_history stores only content, and an empty assistant
  turn is never pushed when the budget is exhausted inside <think>.
  Reads delta.reasoning via getattr + model_extra to stay compatible
  with older openai-python releases that don't type the field.

- TS webui: streamChatCompletion now yields a tagged ChatDelta
  ({ kind: "content" | "reasoning"; text }); runChatCompletion
  returns ChatResult ({ content, reasoning? }). streamSse is generic
  and its contract is documented — extract returning undefined skips
  the frame, any other value is yielded verbatim. The chat panel
  accumulates reasoning and content into separate fields on the
  trailing assistant turn (re-reading via index so future
  intermediate mutations cannot get overwritten by the spread) and
  renders reasoning in a dim italic block above the content.
  Reasoning is a UI-only field on ChatMessage and is stripped before
  any request body and any persistence write. Interleaved
  reasoning/content from the model is normalized into two contiguous
  blobs and rendered reasoning-above-content; parsers that genuinely
  interleave would need an ordered segment list, which qwen3 does
  not require in practice.

Co-authored-by: jdinalt <dinalt@kitt.dinalt.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add user-added inference server registry to webui (#66)

* Add user-added inference server registry to webui

The Inference → Model picker listed only spawned/cluster inference jobs;
external OpenAI-compatible servers (vLLM, remote inference, a teammate's
box) required retyping URL + token every session. Mirror the dataset_server
"User-added servers" pattern: a JSON-backed registry at <config>/server/
inference_server_registry.json (0600), CRUD'd via /api/inference-servers/
user, surfaced as a dedicated section in InferenceModelPanel. Token
resolution in the inference proxy gains a registry fallback after the
existing JobRecord + cluster-inventory lookups, so tokens never round-trip
back to the browser for saved entries. Per-entry verify_tls toggle lets
operators opt out of TLS chain validation for SSH-tunneled remotes.

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

* Disambiguate picker selection + clean up on user-server delete

Review caught two real bugs in the new Inference picker: (1) when a
user-registered URL coincides with a spawned/cluster server's URL, both
rows render selected because the picker compared URL strings — fixed by
pinning the last-picked row's {kind,id} in component state and falling
back to URL match only when nothing has been picked; (2) after deleting
the selected user-server, baseUrl stayed pointing at the orphaned URL —
added an effect to clear baseUrl/authToken when the picked user-entry
disappears, mirroring DatasetsPanel.

Also documented the intentional node-local scope of the inference
registry (no cluster wake-up call on mutation, unlike datasets).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Read-only demo mode for public webui exposure (#67)

* Add --demo read-only mode for public webui exposure

A new --demo CLI flag lets the Forgather server be safely shown to
anonymous visitors. The AuthMiddleware blocks every mutating HTTP
method outside a narrow allowlist (login/logout, inference-proxy
read-throughs) and returns 403; response serializers redact bearer
tokens from /api/jobs and /api/services so a curl can't exfiltrate
credentials minted for spawned inference / dataset jobs.

The webui learns the flag via /api/auth/status, shows an amber
DEMO MODE banner at all times, masks token fields, and disables
mutation controls on the most user-visible surfaces (Add modals,
Jobs cleanup, all .destructive buttons via CSS).

Independent of demo mode: the URL + auth-token inputs in the
inference panel are now readOnly so they can only be populated by
picking a server in the running- / user-registered lists or via
the Add modal — manual typing was never a real workflow.

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

* Fix demo-mode layout: keep sidebar+main side-by-side

The .app.demo-mode { flex-direction: column } override collapsed the
horizontal layout, stacking the main pane below the sidebar. Instead,
reserve 26px of padding-top on .app and absolutely-position the banner
into it so the row flex layout is preserved.

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

* Demo-mode 403 must not trigger a forced re-login

The webui's fetch wrapper dispatches AUTH_REQUIRED_EVENT on every 401
or 403 that isn't tagged as upstream-auth-failed or proxy-refused, so
a demo-mode policy 403 (e.g. clicking a GPU toggle) was bouncing the
user back to the login page. Tag the demo-blocked response with
X-Forgather-Demo-Blocked: 1 and add the matching skip to the fetch
wrapper, following the existing X-Upstream-Auth-Failed pattern.

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

* Move demo notice to sidebar chip; show Forgather version in header

The full-width amber banner was visually loud. Replace it with a
compact "DEMO MODE" chip in the sidebar header, next to "Forgather
Server". Also surface the server's Forgather package version as a
small "v<x.y.z>" label in the same row — visible in both demo and
non-demo mode so it's always discoverable. Both are sourced from
/api/auth/status so the existing useDemoMode + a new useServerVersion
hook share one query.

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

* Add --fs-root allowlist for path-accepting APIs

Jupyter-lab-style root restriction: pass --fs-root <path> (repeatable)
to scope every read/write that takes a client-supplied filesystem path
to descendants of one of the listed roots. Paths outside the allowlist
get 403 with X-Forgather-Fs-Root-Denied: 1 (so the webui fetch wrapper
doesn't mistake the policy refusal for a session-expiry and bounce to
login).

In --demo mode the allowlist defaults to {forgather repo root} ∪
{registered search roots} if --fs-root wasn't supplied — that's the
curated project content the operator already declared browsable.
Without --demo the default stays unrestricted, preserving historical
behaviour for local installs.

Wired into every path-accepting endpoint:
- fs.py: browse/path-exists/mkdir/new-file/rename/copy/move/delete-*
  (browse also filters out symlink targets that escape the roots, and
  the parent-link is hidden when it would point above a root)
- configs.py: every /config/* and /template/source endpoint
- docs.py: /docs/file, /docs/asset
- projects.py: /project*, workspace create/init/new-project

Frontend: hides the server-config gear in demo mode (the file it
opens isn't reachable through fs-root anyway), and disables the
scheduler / restart / shutdown gears with explanatory tooltips
(backend 403 still backs them up).

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

* Allowlist dataset-server load proxy in demo mode

POST /api/dataset-server/proxy/load is a read-through to the upstream
dataset_server's /v1/load — body carries a dataset spec, response is
the materialized handle the webui then pages through. No local state
mutation, so it belongs in the demo allowlist alongside the inference
proxy POSTs. Without this, the Datasets panel couldn't open any
dataset for browsing.

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

* Allowlist cluster dataset-server load proxy in demo mode

Add a small templated-pattern matcher to the demo allowlist so
/api/cluster/dataset_server_proxy/<server_id>/load — the cluster
variant of the dataset-load proxy already on the static list — gets
the same read-through treatment. The matcher requires the variable
middle segment to be a single path component, so a crafted
.../load/.. tunnel can't sneak through.

Without this, the Datasets cluster view 403'd when the user opened
any dataset for examination via a peer node, while the local-only
Datasets view (which hits /api/dataset-server/proxy/load directly)
worked fine.

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

* Address PR review: token-leak fixes, fs-root tightening, demo polish

Critical / High (token leak surfaces in demo mode):
- /api/dataset-servers/local/<id>/bundle now 403s in demo mode (the
  forgather-dataset:// URI it returns embeds the real bearer token
  under the key ``bundle``, which the key-name-based redactor can't
  catch). The webui's "Copy bundle" button is also hidden in demo.
- /api/queue _to_model now wraps job_params + dynamic_args through
  redact_sensitive_in_demo. Inference job_params carry auth_token
  directly, and extra_env can carry FORGATHER_DATASET_SERVER_TOKEN.
- /api/config/dynamic-args now enforces the fs-root allowlist on
  project_dir (missed in the original sweep — template preprocess
  follows ``-- include`` chains anywhere project_dir points).
- AddInferenceServerModal + AddDatasetServerModal mirror the panel's
  demo treatment: token field is masked + read-only in demo mode and
  the Show/Copy buttons are hidden, so a pre-filled value can't be
  revealed even if the submit button is greyed.
- The × remove buttons on user-added server rows pick up the
  ``destructive`` class so the global demo-mode CSS disables them.

Spawned-server TTY leak (new flag):
- --quiet-tokens on tools/inference_server/server.py and
  tools/dataset_server/server.py replaces the bearer-value-bearing
  launch banner with a one-line "value suppressed" message. Token
  still works; it's still cached to its per-port file for local
  client discovery; only the captured TTY pane is sanitized. Wired
  through inference_ops, dataset_server_ops, launcher.py, scheduler,
  and surfaced as a checkbox in the Inference and Dataset Server
  modals (start + create-service flows both pass it through).

Medium:
- paths.is_path_in_fs_root fails closed on empty/whitespace input
  (was silently passing when CWD happened to sit inside an fs-root).
- configure_fs_roots raises ValueError when the operator supplied
  --fs-root values but none resolved — refusing to silently fall
  back to unrestricted.
- /api/auth/login removed from the demo allowlist (dead entry; the
  middleware short-circuits on _OPEN_PATHS before the demo gate).
- cluster._forgather_version promoted to a public forgather_version()
  helper; old name preserved as an alias.
- Collapsed sidebar now shows a compact DEMO indicator (was invisible
  in collapsed state).
- "+ Add server" buttons in inference + dataset panels disabled with
  explanatory tooltip in demo (was a dead-end: modal opened then
  Add was greyed at the bottom).

Lower:
- _demo_path_allowed templated-pattern matcher rejects \\\\, \\x00,
  and % in the variable middle segment (defense vs. non-decoding ASGI
  servers).
- Sidebar demo chip gets role="status" + aria-label for screen readers.
- Sidebar version label gets ellipsis + max-width so a long version
  ("0.1.0+local.abc1234") can't push the gear icons off the edge.
- redact_sensitive_in_demo docstring spells out dict/list-only and
  key-name-based scoping so future callers know what slips through.

Docs:
- tools/forgather_server/README.md: new --demo and --fs-root rows in
  the CLI flag table, plus two full sections (Demo mode, Filesystem
  allowlist, Quiet-tokens) before Authentication overview. The
  user-facing docs/forgather-server.md is a symlink, so updated.
- tools/inference_server/README.md and tools/dataset_server/README.md:
  added --quiet-tokens documentation in their auth sections.

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

* Round-2 review fixes: fs-root on enqueue + CR/LF + disabled-button CSS

Second-pass agent review on commit 5d33317d turned up one HIGH and a
few NITs. All addressed.

HIGH — Security:
- POST /api/queue (enqueue) was calling config_ops.load_dynamic_args
  on req.project_dir without _enforce_fs_root. Demo mode hides this
  (POST /api/queue is mutation-blocked anyway), but outside demo with
  --fs-root the enqueue path follows ``-- include`` chains anywhere
  project_dir points — bypassing the allowlist. Added the gate at the
  top of enqueue().

LOW — Security:
- _demo_path_allowed templated-pattern middle-segment reject set now
  also rejects \\r and \\n. Uvicorn already rejects these upstream of
  ASGI, but the matcher shouldn't depend on the host server's
  normalization. Comment also flags the inherited "no %, no slashes,
  no control chars" constraint for future _DEMO_MUTATION_ALLOWLIST_PATTERNS
  additions.

NIT — UI:
- Disabled "+ Add server" buttons rendered fully primary-accent —
  looked live but blocked clicks. The global ``button:disabled`` rule
  only applied to .destructive / .secondary. Extended it to cover all
  disabled buttons.

Not addressed (intentional): the substring matcher in
_SENSITIVE_KEY_FRAGMENTS will false-positive on user-defined keys
like ``next_page_token`` or ``token_count`` in dynamic_args. Real
concern, but tightening the match (whole-word, exact, etc.) has its
own risks (might miss legitimate variants). The redactor's docstring
already calls out the key-name-based scoping; further tightening
can be a follow-up if a real false-positive turns up.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pre-rendered docs cache + configurable Docs landing page (#68)

* docs_build: pre-render mkdocstrings directives for the webui docs viewer

The webui's `/api/docs/file` endpoint serves raw markdown, so `:::`
mkdocstrings directives in `docs/api/*.md` appear unrendered. This
adds a build step that walks `docs/`, expands directives via griffe,
and writes a parallel tree at `docs/.built/<rel>.md`.

Incremental: a page rebuilds only when its source or any of the
Python files it resolved against are newer than the cached output.
A `.deps.json` sidecar tracks per-page dependencies relative to the
repo root.

This commit lands the library only — CLI, server-side fallback, and
build-pipeline integration follow in subsequent commits.

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

* docs_build: add `forgather docs` CLI

Wires the docs_build library into the CLI as `forgather docs build`
(plus `forgather docs clean`). `--check` makes it CI-friendly: exits
non-zero when any page would be rebuilt without writing anything.

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

* docs_build: gitignore docs/.built/ and exclude it from the mkdocs build

The previous commit accidentally added `docs/.built/` to the tree; this
removes it, gitignores the directory, and adds an `exclude_docs` entry
in `mkdocs.yml` so the real mkdocs build doesn't double-render API
pages whose `:::` directives mkdocstrings already expands at render
time.

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

* forgather-server: serve pre-rendered docs from docs/.built when available

When the docs view fetches a markdown file under <repo>/docs/, prefer
docs/.built/<rel>.md if it exists and is not older than the source.
Pages outside docs/ (e.g. the symlinked tools/forgather_server/README.md)
keep their existing behaviour, and any source edit since the last build
falls through to the raw markdown so users never see stale content.

The response's reported path is still the canonical source so the
frontend resolves relative asset references against the right directory.

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

* build-webui.sh: pre-render docs cache alongside the webui bundle

When the webui static bundle is built, also run `forgather docs build`
so the Docs panel can render `:::` API directives in `docs/api/*.md`
without falling back to raw markdown. The step is opt-out
(SKIP_DOCS_BUILD=1) and best-effo…
jdinalt added a commit that referenced this pull request Jun 8, 2026
…, reuse)

From the high-effort review of this branch:

- Event-loop blocking (#1/#2): run_train's advanced commit now awaits
  asyncio.to_thread(_submit_via_cli) instead of running subprocess.run on the
  loop; query_model / diloco_control commits and diloco_status / the inference
  & diloco list handlers are likewise offloaded; _inference/_diloco list_servers
  probe reachability concurrently (bounded pool) so it's O(timeout) not O(N*timeout).
- dynamic_args divergence (#3): the advanced (CLI) path now renders flags via
  the canonical forgather.cli.diloco._dynamic_cli_from_schema + a new
  config_ops.load_dynamic_args_schema, so store_true/store_false are honored and
  match `forgather submit` (was: False bools silently dropped).
- Reuse/drift (#6): _parse_dataset_source delegates to the shared CLI
  parse_dataset_source (fallback kept).
- _infer_requested_gpus (#5): coerce a numeric-string nproc_per_node via int()
  instead of rejecting it (was: silently reserved 1 GPU).
- cleanup_jobs (#4): catch non-HTTP errors per queue_id so one OSError doesn't
  abort the remaining removals.
- _DISCLOSURE_NOTE lookup (#8): .get() with an inline fallback (no KeyError if
  a future mode is added).
- tools_authoring docstring (#9): drop the stale propose_new_config_from_template
  reference.

Deferred (documented): routing advanced submit fully in-process instead of
shelling out (#1 altitude), and extracting a shared discover/pick helper for
the three _*_servers modules (#7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants