refactor(config): single pydantic-settings model (closes #84)#98
Merged
Conversation
….environ.get sprawl (closes #84) Before: config.py + 35 ad-hoc os.environ.get() callsites across 15 modules. Inconsistent precedence (some env vars beat yaml, some didn't), no schema validation at load, no `forge-loop config` output worth trusting. After: - src/forge_loop/settings.py: one pydantic-settings Settings tree with nested groups (RepoSettings, WorkerSettings, OperatorSettings, ...) mirroring the legacy Config shape. Uniform precedence: env > yaml > defaults. Validation errors raise ConfigError at startup with the field name + source hint so operators can fix typos without grepping. - ENV_MAP is data-driven (env_var → dotted_path → coercer) so a future `forge-loop config --show-env` can dump the whole mapping. - config.py: kept as a thin backwards-compat shim. Config dataclass is materialised from Settings via _from_settings(); existing 80+ call sites (cfg.worker.model, cfg.critic.timeout_s, ...) work unchanged. ModelConfigError aliased to ConfigError. - Ported standalone env reads (operator.py, attempts.py, state.py, cli.py drift_halt/dashboard/repos_dir, mcp_server cap default, drift.py, boot.py queue_url, _pipeline_driver.py, _extras.py experimental, dashboard/app.py token). - `forge-loop config` defaults to dumping resolved Settings as YAML (round-trippable). --json preserves the legacy summary surface. - Architectural regression gate: tests/test_settings.py asserts no os.environ.get sites outside settings.py + config.py (modulo a small allowlist of dynamic-key callers documented inline). Tests: - +12 new tests in test_settings.py (precedence matrix, validation, yaml round-trip, codex provider defaults, regression gate). - test_config.py: monkeypatches repointed to forge_loop.settings._repo_root; all 22 existing assertions still pass. - Full suite: 613 passed, baseline 10 pre-existing failures unchanged. - 0 regressions introduced. Legitimate exceptions (NOT ported, listed in test allowlist): - briefs/__init__.py: env_var name passed as function arg (dynamic key) - mcp_server.py per-tool cap (LOOP_MCP_CAP_<TOOL_NAME> — dynamic key) - observability/__init__.py: experimental-gated, separate cleanup pass - runner_async.py: dynamic env-keyed helper - cli_tui.py: FORGE_LOOP_TUI_FORCE single op-mode flag
This was referenced May 28, 2026
hadamrd
added a commit
that referenced
this pull request
May 28, 2026
… (#139) Dogfood the manifestos system on forge-loop itself by writing the seed quality and testing manifestos that every future forge-loop change is gated against. quality-manifesto.md codifies five rules drawn from this week's persistent-worker work: no shared module-level state (#100), typed Protocol+Fake at every I/O boundary (#104), single Settings source of truth (#98), typed events instead of untyped **fields (#99), and no subprocess.run for SDK-able services (#103, #105). Each rule names the concrete issue it came from so future contributors know the *why*. testing-manifesto.md codifies six rules drawn from this week's iteration-probe bugs: one test per state-machine edge plus a fallthrough adversarial (would have caught #97/#120/#128), an adversarial test for the false case of every external-dep assumption, both ==0 and !=0 branches for every subprocess.returncode (specifically #128), a contract test pinning every Fake to its Real, hypothesis property tests on >4-branch / user-input functions (#102), and an adversarial test that every infinite-loop guard actually fires. tests/test_manifestos_discovery.py is the meta-validation gate: it discovers and parses both files, asserts each rule has a rationale, asserts the spec-mandated issue references are present, and includes adversarial tests that stubs and missing files are detectable. 22 tests, all pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
config.py+ 35 ad-hocos.environ.get(...)callsites with onepydantic-settingsSettings tree (src/forge_loop/settings.py).ConfigErrorat load time with field name + source hint.Configdataclass kept as a thin shim built fromSettings— 80+ existing call sites (cfg.worker.model,cfg.critic.timeout_s, etc) work unchanged.forge-loop confignow dumps resolved Settings as round-trippable YAML;--jsonpreserves the legacy summary surface.os.environ.getsites outsidesettings.py/config.py(modulo a small documented allowlist for dynamic-key callers).Env-var → settings field mapping
LOOP_GH_REPOrepo.githubLOOP_BASE_BRANCHrepo.base_branchLOOP_REPOS_DIRrepo.repos_dirLOOP_PARALLELscheduling.parallelLOOP_TICK_INTERVAL_Sscheduling.tick_interval_sLOOP_MAX_TICKSscheduling.max_ticksLOOP_WORKER_TIMEOUT_Sscheduling.worker_timeout_sLOOP_MAINTENANCE_EVERY_Nscheduling.maintenance_every_n_ticksLOOP_DEPLOY_TASKdeploy.taskLOOP_DEPLOY_DRIFT_HALTdeploy.drift_haltLOOP_QUERY_LABELlabels.readyLOOP_CRITIC_*(5 knobs)critic.*LOOP_PO_*(3 knobs)po.*LOOP_WORKER_*(7 knobs)worker.*LOOP_LUMEN_TOP_Klumen.top_kLOOP_RETRY_COOLDOWN_Sattempts.cooldown_sLOOP_WORKER_MAX_ITERATIONSiteration.max_iterationsLOOP_PIPELINE_DRIVENiteration.pipeline_drivenLOOP_OPERATOR_*(4 knobs)operator.*LOOP_DASHBOARD_PORT/TOKENdashboard.port/tokenLOOP_COAUTHORmisc.coauthorLOOP_EVENTS_ROTATE_BYTESmisc.events_rotate_bytesLOOP_MCP_CAP_DEFAULTmisc.mcp_cap_defaultLOOP_QUEUE_URLmisc.queue_urlFORGE_LOOP_EXPERIMENTALmisc.experimental_enabledDocumented exceptions (kept reading env directly — dynamic key or out-of-scope)
briefs/__init__.py— env-var name is a function arg (dynamic)mcp_server.py— per-tool capLOOP_MCP_CAP_<TOOL_NAME>(dynamic)observability/__init__.py— experimental extras, separate cleanup passrunner_async.py— dynamic env-keyed helpercli_tui.py—FORGE_LOOP_TUI_FORCEsingle op-mode flagTest plan
pytest tests/test_settings.py -v— 12 new tests (precedence matrix, validation, yaml round-trip, codex defaults, regression gate)pytest tests/test_config.py— 22 existing assertions still green (monkeypatches repointed toforge_loop.settings._repo_root)pytest tests/— 613 passed, 0 regressions vs trunk baseline (10 pre-existing failures unchanged)forge-loop configround-trips: write yaml → load → re-dump = same shape