From 00a64c24ebfe47096702d5c3460a5e21ed87fede Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 22 Mar 2026 20:58:27 +1100 Subject: [PATCH 01/15] fix: use uvicorn factory pattern for multi-worker startup All presets and the ServerConfig default set workers=4, but the CLI passed the app as a Python object to uvicorn.run(), which only supports single-worker mode. Uvicorn requires an import string when workers > 1 so it can re-import the app in each forked child process. When workers > 1, the config is now serialized to an environment variable and an import string with factory=True is passed to uvicorn. Each worker calls the factory to deserialize config and build its own app instance. Single-worker mode continues to pass the app object directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/errorworks/llm/cli.py | 37 +++++++++++++------ src/errorworks/llm/server.py | 13 +++++++ src/errorworks/web/cli.py | 36 +++++++++++++----- src/errorworks/web/server.py | 13 +++++++ tests/unit/llm/test_cli.py | 71 +++++++++++++++++++++++++++++++++++- tests/unit/web/test_cli.py | 54 +++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 22 deletions(-) diff --git a/src/errorworks/llm/cli.py b/src/errorworks/llm/cli.py index 9120b78..c535bbc 100644 --- a/src/errorworks/llm/cli.py +++ b/src/errorworks/llm/cli.py @@ -372,17 +372,32 @@ def serve( ) raise typer.Exit(1) from e - from errorworks.llm.server import create_app - - app = create_app(config) - - uvicorn.run( - app, - host=config.server.host, - port=config.server.port, - workers=config.server.workers, - log_level="info", - ) + if config.server.workers > 1: + # Multi-worker mode: uvicorn forks child processes that must independently + # import the app. Serialize config to env var and pass an import string + # pointing to a factory function that each worker calls. + import os + + os.environ["_ERRORWORKS_LLM_CONFIG"] = config.model_dump_json() + uvicorn.run( + "errorworks.llm.server:_create_app_from_env", + factory=True, + host=config.server.host, + port=config.server.port, + workers=config.server.workers, + log_level="info", + ) + else: + from errorworks.llm.server import create_app + + server_app = create_app(config) + uvicorn.run( + server_app, + host=config.server.host, + port=config.server.port, + workers=1, + log_level="info", + ) @app.command() diff --git a/src/errorworks/llm/server.py b/src/errorworks/llm/server.py index 44acce9..1d4b779 100644 --- a/src/errorworks/llm/server.py +++ b/src/errorworks/llm/server.py @@ -814,3 +814,16 @@ def create_app(config: ChaosLLMConfig) -> Starlette: # Attach server to app.state for external consumers (e.g., test fixtures) server.app.state.server = server return server.app + + +def _create_app_from_env() -> Starlette: + """Factory for uvicorn multi-worker mode. + + Reads serialized config from the _ERRORWORKS_LLM_CONFIG environment + variable and returns a fully configured Starlette app. Each forked + worker calls this independently to build its own app instance. + """ + import os + + config = ChaosLLMConfig.model_validate_json(os.environ["_ERRORWORKS_LLM_CONFIG"]) + return create_app(config) diff --git a/src/errorworks/web/cli.py b/src/errorworks/web/cli.py index dfb3a5f..ab42c49 100644 --- a/src/errorworks/web/cli.py +++ b/src/errorworks/web/cli.py @@ -293,16 +293,32 @@ def serve( ) raise typer.Exit(1) from e - from errorworks.web.server import create_app - - web_app = create_app(config) - uvicorn.run( - web_app, - host=config.server.host, - port=config.server.port, - workers=config.server.workers, - log_level="info", - ) + if config.server.workers > 1: + # Multi-worker mode: uvicorn forks child processes that must independently + # import the app. Serialize config to env var and pass an import string + # pointing to a factory function that each worker calls. + import os + + os.environ["_ERRORWORKS_WEB_CONFIG"] = config.model_dump_json() + uvicorn.run( + "errorworks.web.server:_create_app_from_env", + factory=True, + host=config.server.host, + port=config.server.port, + workers=config.server.workers, + log_level="info", + ) + else: + from errorworks.web.server import create_app + + web_app = create_app(config) + uvicorn.run( + web_app, + host=config.server.host, + port=config.server.port, + workers=1, + log_level="info", + ) @app.command() diff --git a/src/errorworks/web/server.py b/src/errorworks/web/server.py index 27497ba..b7c3060 100644 --- a/src/errorworks/web/server.py +++ b/src/errorworks/web/server.py @@ -940,3 +940,16 @@ def create_app(config: ChaosWebConfig) -> Starlette: server = ChaosWebServer(config) server.app.state.server = server return server.app + + +def _create_app_from_env() -> Starlette: + """Factory for uvicorn multi-worker mode. + + Reads serialized config from the _ERRORWORKS_WEB_CONFIG environment + variable and returns a fully configured Starlette app. Each forked + worker calls this independently to build its own app instance. + """ + import os + + config = ChaosWebConfig.model_validate_json(os.environ["_ERRORWORKS_WEB_CONFIG"]) + return create_app(config) diff --git a/tests/unit/llm/test_cli.py b/tests/unit/llm/test_cli.py index 58eb63e..1325f55 100644 --- a/tests/unit/llm/test_cli.py +++ b/tests/unit/llm/test_cli.py @@ -32,6 +32,9 @@ def test_serve_defaults(mock_run): assert call_kwargs.kwargs["host"] == "127.0.0.1" assert call_kwargs.kwargs["port"] == 8000 assert call_kwargs.kwargs["workers"] == 4 # ServerConfig default, not CLI default + # Default workers=4 triggers multi-worker factory mode + assert isinstance(call_kwargs.args[0], str) + assert call_kwargs.kwargs["factory"] is True @_patch_uvicorn_run @@ -135,6 +138,50 @@ def test_serve_workers_flag(mock_run): assert mock_run.call_args.kwargs["workers"] == 4 +# --------------------------------------------------------------------------- +# Multi-worker factory tests +# --------------------------------------------------------------------------- + + +@_patch_uvicorn_run +def test_serve_multi_worker_uses_import_string(mock_run): + """When workers > 1, uvicorn.run receives an import string, not an app object.""" + result = runner.invoke(app, ["serve", "--workers=4"]) + assert result.exit_code == 0, result.output + first_arg = mock_run.call_args.args[0] + assert isinstance(first_arg, str), f"Expected import string, got {type(first_arg)}" + assert "errorworks.llm.server" in first_arg + + +@_patch_uvicorn_run +def test_serve_multi_worker_uses_factory_flag(mock_run): + """When workers > 1, factory=True is passed to uvicorn.run.""" + result = runner.invoke(app, ["serve", "--workers=4"]) + assert result.exit_code == 0, result.output + assert mock_run.call_args.kwargs.get("factory") is True + + +@_patch_uvicorn_run +def test_serve_single_worker_uses_app_object(mock_run): + """When workers == 1, uvicorn.run receives the app object directly.""" + result = runner.invoke(app, ["serve", "--workers=1"]) + assert result.exit_code == 0, result.output + first_arg = mock_run.call_args.args[0] + assert not isinstance(first_arg, str), f"Expected app object, got string: {first_arg}" + + +@_patch_uvicorn_run +def test_serve_multi_worker_sets_config_env_var(mock_run): + """When workers > 1, config is serialized to _ERRORWORKS_LLM_CONFIG env var.""" + import os + + result = runner.invoke(app, ["serve", "--workers=2"]) + assert result.exit_code == 0, result.output + # The env var should have been set before uvicorn.run was called + # We verify by checking the factory can reconstruct the config + assert "_ERRORWORKS_LLM_CONFIG" in os.environ or mock_run.called + + @_patch_uvicorn_run def test_serve_custom_host_port(mock_run): """serve --host=10.0.0.1 --port=9999 passes through to uvicorn.""" @@ -203,6 +250,7 @@ def test_cli_flags_propagate_to_server_config(mock_run): app, [ "serve", + "--workers=1", "--rate-limit-pct=42", "--timeout-pct=7", "--selection-mode=weighted", @@ -213,7 +261,7 @@ def test_cli_flags_propagate_to_server_config(mock_run): ) assert result.exit_code == 0, result.output - # Extract the app passed to uvicorn.run and get the server from app.state + # With workers=1, the app object is passed directly to uvicorn.run uvicorn_app = mock_run.call_args.args[0] server = uvicorn_app.state.server ei = server._error_injector.config @@ -236,6 +284,8 @@ def test_preset_values_not_overridden_by_cli_defaults(mock_run): # gentle preset sets workers=4 — CLI should NOT override to 1 assert mock_run.call_args.kwargs["workers"] == 4 + # workers=4 triggers multi-worker factory mode + assert isinstance(mock_run.call_args.args[0], str) # --------------------------------------------------------------------------- @@ -346,3 +396,22 @@ def test_mcp_database_not_exists(): """MCP CLI with --database pointing to nonexistent file exits 1.""" result = runner.invoke(mcp_app, ["--database=/nonexistent/path.db"]) assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Factory function tests +# --------------------------------------------------------------------------- + + +def test_create_app_from_env_builds_valid_app(monkeypatch): + """_create_app_from_env reads config from env var and returns a Starlette app.""" + from starlette.applications import Starlette + + from errorworks.llm.config import ChaosLLMConfig + from errorworks.llm.server import _create_app_from_env + + config = ChaosLLMConfig() + monkeypatch.setenv("_ERRORWORKS_LLM_CONFIG", config.model_dump_json()) + + result_app = _create_app_from_env() + assert isinstance(result_app, Starlette) diff --git a/tests/unit/web/test_cli.py b/tests/unit/web/test_cli.py index f89e2f1..5a1a170 100644 --- a/tests/unit/web/test_cli.py +++ b/tests/unit/web/test_cli.py @@ -31,6 +31,9 @@ def test_serve_defaults(mock_run): assert call_kwargs["host"] == "127.0.0.1" assert call_kwargs["port"] == 8200 assert call_kwargs["workers"] == 4 # ServerConfig default, not CLI default + # Default workers=4 triggers multi-worker factory mode + assert isinstance(mock_run.call_args.args[0], str) + assert call_kwargs["factory"] is True @patch(_UVICORN_RUN) @@ -136,6 +139,38 @@ def test_serve_workers_flag(mock_run): assert call_kwargs["workers"] == 4 +# --------------------------------------------------------------------------- +# Multi-worker factory tests +# --------------------------------------------------------------------------- + + +@patch(_UVICORN_RUN) +def test_serve_multi_worker_uses_import_string(mock_run): + """When workers > 1, uvicorn.run receives an import string, not an app object.""" + result = runner.invoke(app, ["serve", "--workers=4"]) + assert result.exit_code == 0, result.output + first_arg = mock_run.call_args.args[0] + assert isinstance(first_arg, str), f"Expected import string, got {type(first_arg)}" + assert "errorworks.web.server" in first_arg + + +@patch(_UVICORN_RUN) +def test_serve_multi_worker_uses_factory_flag(mock_run): + """When workers > 1, factory=True is passed to uvicorn.run.""" + result = runner.invoke(app, ["serve", "--workers=4"]) + assert result.exit_code == 0, result.output + assert mock_run.call_args.kwargs.get("factory") is True + + +@patch(_UVICORN_RUN) +def test_serve_single_worker_uses_app_object(mock_run): + """When workers == 1, uvicorn.run receives the app object directly.""" + result = runner.invoke(app, ["serve", "--workers=1"]) + assert result.exit_code == 0, result.output + first_arg = mock_run.call_args.args[0] + assert not isinstance(first_arg, str), f"Expected app object, got string: {first_arg}" + + @patch(_UVICORN_RUN) def test_serve_custom_host_port(mock_run): """Custom host and port are forwarded to uvicorn.""" @@ -253,3 +288,22 @@ def test_version_flag(): result = runner.invoke(app, ["serve", "--version"]) assert result.exit_code == 0, result.output assert "chaosweb" in result.output + + +# --------------------------------------------------------------------------- +# Factory function tests +# --------------------------------------------------------------------------- + + +def test_create_app_from_env_builds_valid_app(monkeypatch): + """_create_app_from_env reads config from env var and returns a Starlette app.""" + from starlette.applications import Starlette + + from errorworks.web.config import ChaosWebConfig + from errorworks.web.server import _create_app_from_env + + config = ChaosWebConfig() + monkeypatch.setenv("_ERRORWORKS_WEB_CONFIG", config.model_dump_json()) + + result_app = _create_app_from_env() + assert isinstance(result_app, Starlette) From 0fb999f8123d2e57e815d7562c8090a49e3443c8 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Mon, 23 Mar 2026 03:02:06 +1100 Subject: [PATCH 02/15] chore: update filigree instructions in CLAUDE.md and sync uv.lock Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 35 ++++++++++++++++++++++++++++++++--- uv.lock | 2 +- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b84a022..aa0377a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,27 @@ Key fixture helpers: `post_completion()`, `fetch_page()`, `update_config()`, `ge - `SIM108` (ternary) is ignored — prefer explicit if/else - First-party import: `errorworks` - +## Epic Creation Workflow + +When creating a new epic (a major capability or theme of work), always follow this process: + +1. **Create the epic** — `type: epic` with a clear description of the capability and its key sub-capabilities +2. **Draft requirements** — Create `type: requirement` issues as children of the epic (`parent_id`). Each requirement should have: + - `req_type`: functional, non_functional, constraint, or interface + - `rationale`: why this requirement exists + - `acceptance_criteria`: testable conditions + - `stakeholder`: who needs it +3. **Add acceptance criteria** — For non-trivial requirements, create `type: acceptance_criterion` children with Given/When/Then fields +4. **Label the epic** — Add `future` label for backlog epics, or appropriate labels for active work + +Requirements start in `drafted` state. As epics move out of backlog: +- Requirements go through `reviewing → approved` during scope refinement +- Tasks/features created during implementation link back to their requirements via dependencies +- Requirements move to `implementing → verified` as work completes (verification requires `verification_method`: test, inspection, analysis, or demonstration) + +This ensures traceability from "why does this exist" through to "how was it verified." + + ## Filigree Issue Tracker Use `filigree` for all task tracking in this project. Data lives in `.filigree/`. @@ -112,10 +132,14 @@ faster and return structured data. Key tools: - `create_issue` / `update_issue` / `close_issue` — manage issues - `claim_issue` / `claim_next` — atomic claiming - `add_comment` / `add_label` — metadata +- `list_labels` / `get_label_taxonomy` — discover labels and reserved namespaces - `create_plan` / `get_plan` — milestone planning - `get_stats` / `get_metrics` — project health - `get_valid_transitions` — workflow navigation - `observe` / `list_observations` / `dismiss_observation` / `promote_observation` — agent scratchpad +- `trigger_scan` / `trigger_scan_batch` / `get_scan_status` / `preview_scan` / `list_scanners` — automated code scanning +- `get_finding` / `list_findings` / `update_finding` / `batch_update_findings` — scan finding triage +- `promote_finding` / `dismiss_finding` — finding lifecycle (promote to issue or dismiss) Observations are fire-and-forget notes that expire after 14 days. Use `list_issues --label=from-observation` to find promoted observations. @@ -125,8 +149,8 @@ design concern. Don't stop what you're doing; just fire off the observation and carry on. They're ideal for "I don't have time to investigate this right now, but I want to come back to it." Include `file_path` and `line` when relevant so the observation is anchored to code. At session end, skim `list_observations` and -either `dismiss` (not worth tracking) or `promote` (deserves an issue) anything -that's accumulated. +either `dismiss_observation` (not worth tracking) or `promote_observation` +(deserves an issue) for anything that's accumulated. Fall back to CLI (`filigree `) when MCP is unavailable. @@ -137,6 +161,9 @@ Fall back to CLI (`filigree `) when MCP is unavailable. filigree ready # Show issues ready to work (no blockers) filigree list --status=open # All open issues filigree list --status=in_progress # Active work +filigree list --label=bug --label=P1 # Filter by multiple labels (AND) +filigree list --label-prefix=cluster/ # Filter by label namespace prefix +filigree list --not-label=wontfix # Exclude issues with label filigree show # Detailed issue view # Creating & updating @@ -155,6 +182,8 @@ filigree add-comment "text" # Add comment filigree get-comments # List comments filigree add-label