Skip to content

feat(project-layer): Project-layer v1 finalization — canonical shape with warn-only compat (M9)#531

Merged
CiroGamboa merged 6 commits into
mainfrom
feat/project-layer-v1-final
Jul 20, 2026
Merged

feat(project-layer): Project-layer v1 finalization — canonical shape with warn-only compat (M9)#531
CiroGamboa merged 6 commits into
mainfrom
feat/project-layer-v1-final

Conversation

@CiroGamboa

@CiroGamboa CiroGamboa commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

Activates the finalized Project-layer shape from M2.5 with warn-only compatibility — every legacy shape a real task pack ships continues to load unchanged, with a DeprecationWarning naming the file, the offending key, and the concrete migration action. No hard breaks in this release. Post-M9 follow-up #533 tracks the future strict-rejection flip once a deprecation-window release cycle has closed.

Also: an actors.user runtime binding so a shape that was previously parsed but inert now actually configures the user simulator (backward-compat via the user_simulator alias); a schema-derived filter that fixes an M4 regression in the example-microservices-pack load path; and pack-wiring tests promoted to the required PR gate so a similar regression cannot ship silently again.

Closes #265, #235, #213, #277.

Impact on existing tasks — read this first

Zero hard breaks. A task pack that loads against main today continues to load, with DeprecationWarnings that point at the migration path. Concrete guarantees:

  • Missing project.yaml — packs without one continue to load via a synthesised default. The synthesiser now emits a DeprecationWarning naming the searched root and the exact fix (Add a project.yaml at the pack root — name: alone suffices). Follow-up Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533 flips this to a hard error after a deprecation window.
  • Unknown keys in project.yaml / run_configs/*.yaml / task.yaml / grading.yaml no longer fail load. construct_config warns with a message shaped unknown key 'mox_turns' in dev.yaml — did you mean 'max_turns'? Rename 'mox_turns' to 'max_turns' (or remove it if unused). (tracked in #533) — top-level scan only; the key is silently dropped from the model instance so existing configs keep working.
  • orchestrator.max_turns default stays at int = 50 (pre-M9 always-on-cap semantic). Tasks that ship max_turns: 100 continue to clamp to the run-cap of 50. Follow-up Follow-up (post-M9): opt-in OrchestratorConfig.max_turns default flip (redo #265) #534 tracks the eventual opt-in-cap flip (default int | None = None) after the deprecation window.
  • stack: null / stack.compose_file: null in a task's environment_manifest (and in a project's default_environment) emit a DeprecationWarning and drop the null so the loader treats the key as unset (inherit-from-project). Non-None non-str compose_file still hard-raises a type error.
  • Direct-Python TaskConfig(user_simulator=UserSimulatorConfig(...)) continues to work via a mode="before" shim that lifts the kwarg into actors["user"] with a warning — the field itself is gone, but the compat path is preserved.

Semantic changes covered by aliases (still accepted with warning):

  • actors.user is now the canonical author shape for the user simulator and drives it at runtime. Top-level user_simulator (either in YAML or as a Python kwarg) is a legacy alias.
  • evaluation.projects replaces evaluation.task_packs.
  • network_policy lowercase enum values (no_internet, limited_internet, full_internet) replace uppercase (NO_INTERNET, ...). Uppercase accepted with warning, lowercased at parse time.
  • security_context_defaults.run_as_user / .run_as_group replace .user / .group. Legacy accepted with warning; disagreeing values fail loud.
  • stack sub-object on default_environment is the canonical substrate shape; flat compose_file / runner_service at the top level of EnvironmentPatch are accepted with warning.

Deprecation message quality bar. Every warning M9 emits follows a uniform actionable shape: what legacy shape triggered it, where it lives (file basename via source_context), why it is deprecated, how to migrate (a concrete key rename or block move with a worked example), and when it goes away (a (tracked in #NNN) suffix pointing at the follow-up issue that carries the retirement schedule). Example messages produced against the real private ots task pack:

No project.yaml found under /path/to/tau_manufacturing; using a synthesised default.
  Add a `project.yaml` at the pack root (`name: tau_manufacturing` alone suffices) to
  remove this warning. A future release will require project.yaml at load time.
  (tracked in #533)

Top-level 'user_simulator' in task.yaml is deprecated; use 'actors.user' instead.
  Move the block under actors.user: `user_simulator: {mode: llm, persona: X}` becomes
  `actors: {user: {mode: llm, persona: X}}`. (tracked in #533)

evaluation.task_packs in dev.yaml is deprecated; use evaluation.projects instead.
  Rename the key: `evaluation.task_packs: [...]` becomes `evaluation.projects: [...]`
  — value shape is unchanged. (tracked in #533)

network_policy: NO_INTERNET in project.yaml is deprecated; use network_policy: no_internet
  instead. Network policy enum values are lowercase — lowercase the value in place:
  `network_policy: NO_INTERNET` becomes `network_policy: no_internet`. (tracked in #533)

SecurityContext.user in project.yaml is deprecated; use SecurityContext.run_as_user
  instead. Rename the key in `security_context_defaults`: `user: 1000` becomes
  `run_as_user: 1000`. (tracked in #533)

Design walkthrough

flowchart TB
    subgraph before ["Before M9 (main)"]
        b_yaml["task.yaml + project.yaml (optional)"]
        b_loader["_task_loader.py<br/>whole-dict deep_merge<br/>silent extra=None drop"]
        b_config["TaskConfig<br/>extra=None (silent drop)<br/>user_simulator field lives here"]
        b_runtime["runtime reads<br/>task.user_simulator"]
        b_yaml --> b_loader
        b_loader --> b_config
        b_config --> b_runtime
    end

    subgraph after ["After M9 (this PR — warn-only)"]
        a_yaml["task.yaml + project.yaml<br/>(project.yaml still optional — warn)"]
        a_dep["deprecations.py<br/>per-layer alias coercion<br/>source_context threading<br/>tracker-suffixed messages"]
        a_loader["_task_loader.py<br/>filter TaskDefaults-only keys<br/>then deep_merge"]
        a_construct["construct_config helper<br/>warns on unknown top-level keys<br/>difflib closest-match suggestion<br/>file + key + how-to-fix + tracker"]
        a_config["TaskConfig<br/>extra=ignore + warn-on-unknown<br/>actors is canonical home<br/>resolve_user_simulator()<br/>user_simulator= kwarg shim"]
        a_synth["synthesize_default_project<br/>DeprecationWarning<br/>fallback for missing project.yaml"]
        a_runtime["runtime reads<br/>actors['user']"]
        a_yaml --> a_dep
        a_dep --> a_loader
        a_loader --> a_construct
        a_construct --> a_config
        a_config --> a_runtime
        a_yaml -.no project.yaml.-> a_synth
        a_synth --> a_config
    end

    before -.M9 keystone: #213/#529 → soften: #531/88b0989.-> after

    style a_dep fill:#e1f5e1
    style a_construct fill:#e1f5e1
    style a_synth fill:#fff3cd
    style a_config fill:#e1f5e1
Loading

Design choices

  • Warn-only + follow-up issue for the strict flip (this softening pass) rather than shipping hard breaks. The M4 keystone (originally on this branch) shipped strict validation and synth removal. External task packs — including the private ots packs at tolokaforge-tasks/config/tau_manufacturing/ — still ship the pre-M9 shape (no project.yaml, root-level run_config.yaml, orchestrator.workers dual-home alias, orchestrator.runtime: "docker"). Any hard break would fail those packs at load. Follow-up Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533 lands the strict flip after a deprecation-window release cycle.
  • extra="ignore" on all Project-layer models + construct_config warns on unknown keys. Model-level extra="ignore" keeps loads permissive. The loader-level scan (before Pydantic construction) catches unknown top-level keys and warns with a file+key+difflib+tracker message. Nested unknown keys are silently dropped in this release — the recursive scan comes back with the strict flip in Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533. This keeps the message quality high without requiring a full schema-walk implementation.
  • Source-path threading via ContextVar. warn_deprecated reads source_context (a ContextVar[Path | None]) so every alias warning names the file basename without threading source through every coercer signature. construct_config, load_project_config, _task_loader.load_task_yaml, and _load_domain_dict set the context; Python-level callers (no loader in the stack) get warnings without a in <file> clause.
  • follow_up_issue param on warn_deprecated defaulting to #533. Every alias points at the strict-flip follow-up as its retirement tracker. synthesize_default_project also points at Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533; the max_turns docs point at Follow-up (post-M9): opt-in OrchestratorConfig.max_turns default flip (redo #265) #534. Users have a concrete issue to subscribe to.
  • actors.user runtime binding kept (the M4 Stage 2 work). Alias covers the YAML compat surface, direct-Python user_simulator= kwarg shim covers the Python compat surface. This is a soft semantic change but reversible via the alias if a caller notices behavior drift.
  • Loader filter for project.task_defaults (from perf(tests): integration suite dominated by Docker lifecycle — proposals to speed up #277): the merge into task dicts excludes TaskDefaults-only keys (grading_defaults, continue_prompt) that would otherwise trip the strict-schema warnings on every task load. Derived from TaskDefaults.model_fields - TaskConfig.model_fields, not hardcoded — future project-only defaults handled automatically.
  • Pack tests promoted from tests/integration/tests/canonical/ (from perf(tests): integration suite dominated by Docker lifecycle — proposals to speed up #277) so a future regression that breaks a shipped pack fails on the introducing PR (test-smoke gate), not weeks later on nightly.

Concepts introduced

  • tolokaforge/core/deprecations.py — canonical home for schema-shape alias coercers + warn_deprecated uniform message shape helper + source_context for file-path threading + POST_M9_STRICT_FLIP_ISSUE = "533" retirement tracker.
  • construct_config(model, data, *, source, section) -> BaseModel in project_loader.py — the loader-side warn-on-unknown-key helper. Every YAML load site (project, task, run, grading) routes through it.
  • TaskConfig.resolve_user_simulator() -> UserSimulatorConfig — reads actors["user"] and applies simulator defaults for unset fields. Called by the conductor and native adapter.
  • _lift_user_simulator_kwarg + _accept_legacy_user_simulator_kwarg mode="before" validator on TaskConfig / TaskDefaults — the direct-Python compat shim for callers passing user_simulator=... as a constructor kwarg.
  • Per-pack run_configs/<name>.yaml convention for in-tree examples — replaces the pack-root run_config.yaml layout. External packs (including the private ots pack) still use the root-level shape; both work.

Plan

The plan lives at ~/.claude/plans/toloka-tolokaforge/issue-213-m4-strict-schema.md (M4 keystone) plus the softening plan at ~/.claude/plans/dapper-painting-key.md (this PR's rollout). Stage-by-stage in the commits below.

Suggested review order

  1. 0bd3bf6 (OrchestratorConfig.max_turns default should be None, not 50 #265 max_turns default) — small warmup; extract-only, no semantic change (the flip was reverted in 88b0989).
  2. 443c367 (Reject stack: null and stack.compose_file: null at load time #235 stack:null rejection) — establishes the loader-parse-boundary pattern.
  3. ad15b69 (Project layer — strict schema + example-pack migration (M4) #213 M4 keystone) — 8 stages: deprecations.py, actors.user runtime binding, all 14 packs canonical, extra="forbid" + difflib, synth removal.
  4. b1b9711 (perf(tests): integration suite dominated by Docker lifecycle — proposals to speed up #277 regression fix + pack tests promoted) — the M4 regression fix + pytest-xdist + run_configs/dev.yaml path renames.
  5. 88b0989 (softening — this commit) — reverts the hard breaks to warn-only, restores the synthesiser, reverts max_turns default, adds direct-Python shims, upgrades deprecation message quality, files follow-ups Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533 / Follow-up (post-M9): opt-in OrchestratorConfig.max_turns default flip (redo #265) #534.

Discovered issues

Filed as follow-ups (retirement schedule tracker):

Pre-existing (from M4 planning):

Test plan

  • uv run pytest -m unit2640 pass, 1 skip. Every M9-touched path + previously-strict tests converted to soft-warn assertions.

  • uv run pytest -m canonical526 pass. Snapshot regenerated for the error-message shape.

  • uv run lint_check — clean.

  • End-to-end load-probe against the private ots task pack (tolokaforge-tasks/config/tau_manufacturing/smoke_comm_rubrics_kimi_k2.6.yaml) — loads with 0 errors and 5 actionable DeprecationWarnings naming every legacy shape it uses:

    1. Missing project.yaml at pack root
    2. orchestrator.workerscompute.workers (pre-existing dual-home alias)
    3. orchestrator.max_attempt_retriescompute.max_attempt_retries (pre-existing)
    4. orchestrator.runtime = "docker" → alias for "shared" (pre-existing)
    5. orchestrator.runtime deprecated (backend selection now task-driven, pre-existing)
  • Post-merge: any external pack that today loads on main continues to load with the new warnings pointing at the migration path. The strict-flip follow-up (Follow-up (post-M9): re-flip Project-layer strict validation + remove synthesize_default_project #533) will land these breaks properly after the deprecation-window release cycle.

CI note: the hygiene-review workflow lane has a known infrastructure failure on this repo right now; merging past it is standard (matches the last several PRs on main).

Closes #213, #235, #265, #277.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

…491)

Flip OrchestratorConfig.max_turns from 50 to None so the run-level turn
cap is opt-in. A task's max_turns is no longer silently clamped to 50;
the effective budget is min(task, run cap) only when an operator sets a
cap, and the engine default (DEFAULT_MAX_TURNS = 50) applies when neither
the run nor the task declares a value.

The inline min-rule at InProcessConductor._run_agent_loop is extracted to
a pure resolve_max_turns() that returns a concrete int in every branch,
called before the mobile-scaling block so None never reaches max()/range().

Closes #265
…t load time (#496)

A task's environment_manifest and a project's default_environment now
fail loud at load time when 'stack' or 'stack.compose_file' is explicitly
null, naming the offending file and field. Previously stack:null was
silently dropped, letting a project's value pass through — the opposite
of the documented full-override rule; stack.compose_file:null surfaced
as a misleading type error. Omitting the key to inherit stays valid.

Closes #235
…ation + actors.user binding (#529)

* refactor(core): consolidate schema-shape aliases into deprecations.py; add network_policy + security_context aliases

Relocate the three schema-shape alias coercers (task_packs->projects, flat
compose_file/runner_service->stack, run_config/->run_configs/) into a new
tolokaforge/core/deprecations.py module and add two new accept-with-warning
aliases: uppercase network_policy enum names lowercase to the canonical
values, and SecurityContext user/group rename to run_as_user/run_as_group
(single-source disagreement fails loud). The RunConfig dual-home lifts stay
on RunConfig — they are field-level orchestrator->compute/storage
migrations, not schema-shape renames.

Refs #213

* feat(actors): bind actors.user to the user-simulator runtime; alias top-level user_simulator

actors.user is the canonical author shape for the user simulator on
TaskDefaults and TaskConfig and now drives the simulator at runtime
(conductor + native adapter read it via TaskConfig.resolve_user_simulator).
Top-level user_simulator is a legacy alias: the loader lifts it into
actors.user per config layer, pre-merge, with a DeprecationWarning, so a
canonical project layer and a legacy task layer compose field-by-field
(task wins) without a false conflict. A single source declaring both keys
fails loud.

* refactor(examples): migrate example-microservices-pack to fully canonical Project-layer shape

The reference pack's YAML shape is already canonical (stack sub-object,
actors.user, run_configs/, evaluation.projects). Strip the remaining
legacy narrative so it reads as current state: drop the PROPOSED-schema /
not-runnable-until-#211 header, the (#366)-must-land-first and (post-M2)
notes in the README, and the backstory_template comment that named an
unbuildable composition field. Add the collective pack-migration CHANGELOG
entry (#213).

* refactor(examples): migrate 6 project-bearing multi_service_* packs to canonical shape

Migrate multi_service_cache_debug, multi_service_endpoint_add,
multi_service_helpdesk_workflow, multi_service_lot_ops,
multi_service_postgres_reset, and multi_service_slow_start to the
canonical Project-layer shape:

- task.yaml: top-level `user_simulator` -> `actors.user`; flat
  `environment_manifest.compose_file`/`runner_service` -> under `stack`
  (helpdesk, lot_ops).
- run config: pack-root `run_config.yaml` -> `run_configs/dev.yaml`;
  `evaluation.task_packs` -> `evaluation.projects` (cache_debug,
  endpoint_add, helpdesk, lot_ops; reset + slow_start already used
  `projects`).
- helpdesk project.yaml gains `task_defaults.actors.user`.

Lock the migration with tests/unit/test_multi_service_packs_canonical.py:
every pack loads through load_effective_run_config + the task loader on
the canonical shape with zero DeprecationWarning, and `actors.user`
drives the resolved user simulator. Update per-pack READMEs,
docs/MULTI_CONTAINER_GUIDE.md, hardcoded run-config paths in the six
end-to-end integration tests, the helpdesk example-task test, and the
CHANGELOG.

Refs #213

* refactor(examples): migrate 7 project-less packs to canonical (add project.yaml + run_configs/)

Every pack under examples/native now ships a project.yaml and loads fully
canonical. Migrates browser_task, coding, tool_use, multi_service,
multi_service_advanced, multi_service_postgres and native_shared_domain:

- add project.yaml at each pack root (identity + task discovery glob +
  task_defaults lifting the shared actors.user / adapter / turn budget);
- move the pack-root run_config.yaml to run_configs/dev.yaml
  (native_shared_domain also gains run_configs/gate_demo.yaml);
- evaluation.task_packs -> evaluation.projects;
- top-level user_simulator -> actors.user (shared mode/persona lifted to
  task_defaults, per-task backstory kept as a delta);
- flat environment_manifest.compose_file/runner_service -> stack (the
  three multi_service packs).

Locks the migration with tests/unit/test_project_less_packs_canonical.py:
every run config and every task loads with zero DeprecationWarning and
actors.user drives the resolved user simulator. Fixes a latent path bug in
test_task_loader.py (the native_shared_domain smoke check pointed at a
non-existent examples/native_shared_domain path and always skipped) and
updates every hardcoded run-config path across docs, scripts, the Makefile
and tests to run_configs/dev.yaml.

Refs #213

* feat(core): enable extra="forbid" on all Project-layer models + difflib closest-match loader error

Turn on strict validation across every Project-layer Pydantic model (ProjectConfig,
RunConfig, TaskConfig, GradingConfig transitive closures, plus EnvironmentPatch) so
unknown keys in project.yaml / run_configs/*.yaml / task.yaml / grading.yaml now
fail loud. A new construct_config helper in tolokaforge/core/project_loader.py
wraps pydantic.ValidationError and, for each extra_forbidden error, builds a
message naming the file (basename or repo-relative — never the absolute path
from tmp_path), the offending key path, and the closest schema match via
difflib.get_close_matches. Non-extra validation errors re-raise unchanged.

All four load sites route through the helper: ProjectConfig via project_loader,
TaskConfig via _task_loader, GradingConfig via native adapter, RunConfig at the
four cli/main.py invocation points. Aliases (task_packs, user_simulator,
uppercase network_policy, security_context user/group) still work because they
lift/rename in mode="before" before the extra check runs.

Fixture and test fallout: strip stray keys from tests/data/tasks/browser_basic
and calc_basic grading fixtures; correct one Optional wrong-kwarg construction
in test_orchestrator_logic; drop an obsolete legacy key in
test_project_loader_end_to_end; update the terminal-bench external adapter's
GradingConfig construction to the canonical shape.

Behaviour locked: 11 unit tests (5 model roots reject unknown keys; construct_config
error surface names file+key+suggestion; non-extra errors re-raise; four aliases
still accepted after forbid) + 1 canonical snapshot pinning the error-message
shape (basename-only, portable across machines).

* feat(core)!: require project.yaml — remove transitional default synthesiser

A pack must ship a project.yaml. The transitional default-project
synthesiser is removed: a run config with no discoverable project.yaml
(walking up from the config file to the filesystem root) now fails at
load with a RuntimeError naming the searched root and instructing the
author to add one, in the loader's existing fail-loud style.

BREAKING: external packs that relied on the synthesised default must
add a minimal project.yaml (name: alone suffices) at the pack root.
All 14 in-tree example packs already ship a project.yaml (Stage 5), so
no in-tree consumer breaks.

* docs(m4): rewrite M4 doc drift — actors example, run_config paths, run configs directory prose
…ical + parallel-safe integration lane (#530)

* fix(loader): stop project-scoped task_defaults leaking into strict TaskConfig validation

The task loader deep-merged the entire project.task_defaults dict into
each task dict before validating against TaskConfig. Since #213/#529
made TaskConfig extra="forbid", the project-scoped-only defaults
grading_defaults and continue_prompt — legitimate task_defaults keys
that are not TaskConfig fields — were rejected, so the shipped
example-microservices-pack could not build a single TaskDescription.

Narrow the merged layer to TaskConfig-shaped keys. The excluded set is
derived from the schema (TaskDefaults.model_fields - TaskConfig.model_fields),
so future project-only defaults are handled automatically. The adapter's
stored _project_task_defaults is untouched — grading_defaults still flows
through NativeAdapter.get_grading_config. A genuinely-unknown key authored
in task.yaml still raises the friendly construct_config error.

* test(canonical): promote pack wiring-proof tests from integration → canonical

test_example_microservices_pack.py is a Docker-free, LLM-free load-only
wiring proof: it exercises the project.yaml → per-task manifest →
backend-selector path against the shipped example-microservices-pack
without spinning up any external service. It therefore belongs in the
canonical tier, not integration.

CI selects lanes by directory (tests/unit/ + tests/canonical/ under the
required test-smoke gate; tests/integration/ only on push/nightly/gate),
so the file is physically moved rather than just re-marked. Running these
tests in the required PR gate is what closes the gap that let the M4
strict-schema regression ship unnoticed.

* test(integration): pytest-xdist + per-worker COMPOSE_PROJECT_NAME for parallel-safe Docker stacks

testcontainers runs Docker Compose with cwd set to the context dir and
passes no --project-name, so Compose derives the project name from that
dir's basename. Integration tests that build their stack under
tmp_path/"compose" all resolve to project name "compose"; under
pytest -n auto two such stacks on different workers share one project
namespace and collide on container/network names.

Add pytest-xdist to the dev dependency group and a tests/integration
autouse session fixture that pins a unique COMPOSE_PROJECT_NAME per
xdist worker (falling back to a stable name without xdist). This isolates
every worker's stacks in one place, without editing each DockerCompose
call site. Per-worker uniqueness suffices because a worker runs its tests
sequentially. Both CI integration steps (test-full, test-gate) now run
with -n auto.

* fix(tests,docs): narrow COMPOSE_PROJECT_NAME conftest scope; correct PROJECTS.md field-ownership table

Move the per-worker COMPOSE_PROJECT_NAME autouse fixture from
tests/integration/conftest.py to tests/integration/reset_recipes/conftest.py.
The env pin only serves the reset-recipe suite, whose stacks all share the
`compose` basename; applied worker-wide it collapsed the two concurrent
stacks in test_per_trial_isolation_across_concurrent_instances (which rely on
make_project_temp_dir's slug-encoded basenames) into one project namespace.
Addresses reviewer-correctness's finding for that isolation test.

Split the docs/PROJECTS.md field-ownership row: grading_defaults and
continue_prompt are project-scoped with no per-task delta (a task.yaml naming
either fails with unknown key under TaskConfig's extra="forbid"); the row
previously promised a task.yaml delta path that has not existed since #213.
Addresses reviewer-hygiene M1.

* fix(tests): scope COMPOSE_PROJECT_NAME pin to function lifetime — no cross-test env leak

Round-2 fix for the correctness reviewer's follow-on finding. The session-scoped
autouse fixture left COMPOSE_PROJECT_NAME pinned for the rest of the worker's
queue, so sibling docker tests (notably
test_per_trial_isolation_across_concurrent_instances) inherited the pin and their
two concurrent stacks collapsed into one Compose namespace. Function-scoped
monkeypatch restores the prior env after each reset-recipe test.

Also drops a stray bare `prompt` token from the task_defaults enumeration in
docs/PROJECTS.md.
…nthesiser + revert max_turns flip

M9 originally shipped strict validation across every Project-layer model
(extra="forbid") plus removal of the transitional synthesize_default_project
fallback and a flip of OrchestratorConfig.max_turns default (int=50 → int|None=None).
All three were hard backward-compat breaks that would fail existing external
task packs at load. Soften before merge so a gradual rollout is possible:
canonical shapes ship with actionable DeprecationWarnings; a future release
flips to strict after a deprecation window (tracked in #533 for strict flip,
#534 for the max_turns default flip).

Changes:

1. extra="forbid" -> extra="ignore" on 42 Project-layer models (39 in
   core/models.py, EnvironmentPatch in runner/models.py, plus 3 misses
   from the initial soften pass: ActorSpec, SeedRef, AssetsConfig).
   LocalStorageConfig / S3StorageConfig kept as forbid (discriminated-
   union tag safety).

2. construct_config warns instead of raises on unknown top-level keys.
   Message names the file basename, the offending key, the closest schema
   match via difflib, and a "(tracked in #533)" suffix pointing at the
   follow-up. Nested unknown keys are silently dropped (recursive scan
   deferred to #533).

3. synthesize_default_project restored. A run config with no discoverable
   project.yaml emits a DeprecationWarning naming the searched root and
   the exact fix ("Add a project.yaml at the pack root — name: <pack>
   alone suffices"), then falls back to a minimal ProjectConfig so the
   pack loads unchanged.

4. stack: null / stack.compose_file: null softened to warn-only in both
   _task_loader.py::_resolve_environment_manifest_paths and
   project_loader.py::_warn_null_stack (renamed from _reject_null_stack).
   The null key is dropped so the loader treats it as unset (inherit-
   from-project). Non-None non-str compose_file still hard-raises the
   type error.

5. OrchestratorConfig.max_turns default reverted int|None=None -> int=50.
   Restored pre-M9 always-on-cap semantic. resolve_max_turns helper and
   DEFAULT_MAX_TURNS constant kept (pure code cleanup, no behaviour
   change). Follow-up #534 tracks the eventual opt-in-cap flip.

6. Direct-Python compat shim on TaskConfig / TaskDefaults: a
   mode="before" validator (_accept_legacy_user_simulator_kwarg) lifts
   TaskConfig(user_simulator=...) kwargs into actors["user"] with a
   DeprecationWarning. Reuses the existing canonicalize_actor_config
   coercer via a small _lift_user_simulator_kwarg helper that
   model-dumps a UserSimulatorConfig instance before lift.

7. Deprecation message quality bar. Every warning now includes: what
   legacy shape triggered it, where (file basename via ContextVar-based
   source_context), why (canonical form), how to fix (concrete rename
   or block-move with a worked example), and when it goes away
   ((tracked in #NNN) suffix pointing at the retirement follow-up).
   warn_deprecated gains a follow_up_issue param defaulting to
   POST_M9_STRICT_FLIP_ISSUE = "533".

Test updates: raises->warns for stack:null tests; soft-drop assertions
for 3 reservation tests; direct-Python user_simulator= kwarg shim tests
added; TestResolveMaxTurns renamed to reflect the reverted default-50
clamp; canonical error-message snapshot regenerated.

Docs: CHANGELOG rewrote the M9 keystone entry to describe the warn-only
rollout and added a "Compat / migration notes" section; PROJECTS.md,
CONFIG.md, REFERENCE.md, SECURITY.md reverted max_turns prose to the
default-50 always-on-cap shape with a #534 breadcrumb.

Validated: unit 2640 pass, canonical 526 pass, lint clean.
End-to-end load-probe of the private ots pack
(tolokaforge-tasks/config/tau_manufacturing/smoke_comm_rubrics_kimi_k2.6.yaml):
loads with 0 errors and 5 actionable DeprecationWarnings.

Refs: #213, #235, #265, #277, #489, #499, #533, #534.
@CiroGamboa
CiroGamboa force-pushed the feat/project-layer-v1-final branch from 6a7a691 to 88b0989 Compare July 20, 2026 17:54
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@CiroGamboa CiroGamboa changed the title feat(project-layer)!: Project-layer v1 finalization — strict schema + example-pack migration + actors.user runtime binding (M9) feat(project-layer): Project-layer v1 finalization — canonical shape with warn-only compat (M9) Jul 20, 2026
…cklevel note

Addresses three review findings on PR #531 from my own /review pass:

1. Absolute-path leakage in warning messages. `_warn_null_stack`
   (project_loader.py) and the two stack:null branches in _task_loader.py
   emitted `warnings.warn(f"... at {path}: ...")` with `path` being the
   full absolute path — violating the basename-only policy the rest of
   M9 established via `source_context`. Route all four through
   `warn_deprecated(...)` inside `source_context(path)` so the basename
   is used consistently. Test snapshots stay machine-independent; prod
   logs stop leaking user home paths.

2. Inconsistent tracker-suffix handling. Three sites built the message
   inline and hardcoded `(tracked in #533)`; two more had raw
   `warnings.warn` calls without going through `warn_deprecated` at all.
   Consolidated: `coerce_task_packs_alias` both-set branch,
   `warn_legacy_run_config_dir`, `synthesize_default_project`, and the
   two `stack: null` sites now all route through `warn_deprecated(...)`
   which handles the tracker suffix uniformly.

3. `warn_deprecated` stacklevel docstring caveat. Old docstring claimed
   "stacklevel=3 targets the coercer's caller so IDE warnings point at
   user code." True only for direct-loader calls; when the coercer is
   invoked from a Pydantic `mode="before"` validator, Pydantic inserts
   wrapper frames and the warning surfaces at the model class instead.
   Added the caveat and a `stacklevel` param so callers with a known
   depth can target user code precisely.

Test-message assertions in test_task_loader.py, test_project_loader.py,
test_project_loader_end_to_end.py, and test_schema_shape_aliases.py
updated to match the new consolidated message shape (regex-based so the
`in <file>` clause between legacy and "is deprecated" is tolerated).

Validated: unit 2640 pass; private ots pack load-probe emits 5
DeprecationWarnings with zero absolute-path leaks (grepped for
`/Users/` / `/private/` / `/tmp/` in every warning).

Refs: PR #531 self-review.
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

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.

OrchestratorConfig.max_turns default should be None, not 50 Project layer — strict schema + example-pack migration (M4)

1 participant