From a4b0cda614cb9640b3ce09a976e848d81a7e7c86 Mon Sep 17 00:00:00 2001 From: Luka Skugor Date: Fri, 6 Nov 2026 09:34:32 +0100 Subject: [PATCH 1/2] factory: seed auto-merge targets from deployment config OMNIGENT_FACTORY_AUTO_MERGE_SEED={"": [{"base": ""}]} gives a factory on that repo its product.delivery.auto_merge targets: in v1, or once at boot as a seed version (by server, created_via seed) while no version ever set targets. A factory whose owners set or cleared them is never touched; each target names its base, so main is never added implicitly. Live PLs get the CHARTER UPDATE for the new merge policy line. Co-Authored-By: Claude Opus 5.5 --- docs/FACTORY.md | 19 +++ .../factory/background/delivery_seed.py | 12 +- omnigent/server/factory/product_profile.py | 46 +++++- tests/server/factory/test_auto_merge.py | 142 ++++++++++++++++++ 4 files changed, 213 insertions(+), 6 deletions(-) diff --git a/docs/FACTORY.md b/docs/FACTORY.md index cbb2bd32db7..edf001a2535 100644 --- a/docs/FACTORY.md +++ b/docs/FACTORY.md @@ -857,6 +857,7 @@ transcript folds. | `OMNIGENT_FACTORY_PREVIEW_DOMAIN` | unset | The deployment's env domain: the only domain env and preview URLs may use (SSRF guard) for a factory whose product profile sets no `product.delivery.preview_domain`. `OMNIGENT_ENV_DOMAIN` (the sandboxes' env domain) is read when it is unset. With neither, no env URL is accepted. | | `OMNIGENT_FACTORY_PREVIEW_DOMAINS` | the env domain | Tier 0: comma-separated domains a product profile may name as its `product.delivery.preview_domain` (the env domain is always one). A definition change to any other domain is refused (422 `tier0`); a stored one the deployment no longer lists is ignored with a warning, and the env domain applies. | | `OMNIGENT_FACTORY_DELIVERY_SEED` | unset | JSON keyed by repo, `{"": {}}` (`merge_policy`, `auto_merge`, `protected_repos`, `demo_paths`, `preview_domain`, `release`, `flags`): the profile a factory on that repo starts from. A new factory whose default repo is listed gets it in v1; a factory from before the product profile whose default or project repo is listed gets it once as a seed version at boot. A factory on another repo starts with an empty profile. Owners edit it afterwards; a factory whose profile was already seeded or set takes new `auto_merge` targets only from an owner in Steer. Example: `{"sample-org/sample-app": {"auto_merge": [{"repo": "sample-org/sample-app", "base": "staging"}]}}`. | +| `OMNIGENT_FACTORY_AUTO_MERGE_SEED` | unset | JSON keyed by repo, `{"": [{"base": "", "repo"?: ""}]}`: the auto-merge targets (`product.delivery.auto_merge`) a factory on that repo starts with, or gets once at boot as a seed version while no version of its definition ever set targets (one that set or cleared them is never touched). Every target names its base, so `main` is a target only when named; an invalid value seeds nothing (logged). See "Auto-merge targets". Example: `{"sample-org/sample-app": [{"base": "staging"}]}`. | | `OMNIGENT_FACTORY_ENVIRONMENT_SEED` | unset | JSON keyed by repo, `{"": {}}` (`summary`, `services`, `tooling`, `registry`, `base_image`, `pull_secret`, `notes`, `conventions`, `protected_branches`, `demo`): the environment profile a factory on that repo starts from, seeded like `OMNIGENT_FACTORY_DELIVERY_SEED` (in v1, or once at boot for a factory from before it). The seed's `repo` is its key unless it names one, so it reaches only that repo's projects. An invalid value seeds nothing (logged). | | `OMNIGENT_FACTORY_ALWAYS_DENY_REPOS` | unset | Tier 0: comma-separated `owner/repo` values no factory agent ever writes, whatever a spec, a project or a profile says. Read once at boot. Unset, the server logs an error at start and the cockpit health strip shows "Config: no always-deny repos configured". | | `OMNIGENT_DOMAIN` | unset | Public host of this server, e.g. `omnigent.example.com`. Demo PR comments and `demo_url` use it for absolute links; unset, they use the address the runner called. | @@ -2297,6 +2298,24 @@ ci, claim, plan, intake, improve); `factory_loops` keeps one row per targets under their own top-level key (`auto_merge_targets`), so a server from before them drops only the targets on a rollback and keeps the rest of the profile (`protected_repos` included). + + A deployment can set the targets so no one has to click: + `OMNIGENT_FACTORY_AUTO_MERGE_SEED`, e.g. + `{"sample-org/sample-app": [{"base": "staging"}]}` (each target's `repo` is + the key unless it names another). A new factory whose default repo is a key + starts with those targets in v1. A factory that already exists gets them + once, at boot, as a recorded definition version (`created_via: "seed"`, by + `server`: the deployment, not an agent), and only when its default repo or + a project repo a person gave it is a key and no version of its definition + ever set `product.delivery.auto_merge` (a factory whose owners set targets, + or set and later cleared them, is never touched). The root's + `omnigent.factory.auto_merge_seed` label (`v` or `set`) keeps it from + running twice. Every target names its base: an entry without one makes the + whole value invalid (logged, nothing seeded), so a human-merged default + branch (`main`, `master`) becomes a target only when the seed names it. + Afterwards owners change the targets in Steer like any other; agents never + can. `auto_merge` inside `OMNIGENT_FACTORY_DELIVERY_SEED` still works for a + factory's first delivery seed; this seed wins in v1 when both name targets. - **Autonomy and auto-merge per subtree (owners only).** An owner scopes either setting to one stored node of the product tree (a goal `G-n`, a need `O-n`, a feature `I-n` or a project slug) with the node's "Autonomy & diff --git a/omnigent/server/factory/background/delivery_seed.py b/omnigent/server/factory/background/delivery_seed.py index edbf386789d..5c2805442a7 100644 --- a/omnigent/server/factory/background/delivery_seed.py +++ b/omnigent/server/factory/background/delivery_seed.py @@ -6,11 +6,14 @@ :data:`~omnigent.server.factory.product_profile.ENVIRONMENT_SEED_ENV`) as one seed version each, so what the server, the cockpit and the agents read from the profile matches what they did before -(:func:`.product_profile.seed_existing`). A factory on a repo the seed does +(:func:`.product_profile.seed_existing`). The autonomy level and the +auto-merge targets (:data:`~omnigent.server.factory.product_profile.AUTO_MERGE_SEED_ENV`) +are seeded the same way while no version ever set them. A factory on a repo the seed does not name is left alone. It runs in the background, one factory at a time under its definition lock, and never again for a factory it marked. Each live PL whose charter gains ``Environment`` lines gets them as a CHARTER -UPDATE, since its playbook now points there. +UPDATE, since its playbook now points there; so does each PL whose merge +policy line now says the server merges its PRs. """ from __future__ import annotations @@ -59,7 +62,8 @@ async def seed_all(app_state: Any) -> list[str]: continue if root.id not in seeded: seeded.append(root.id) - if seed is product_profile.ENVIRONMENT: + if seed in (product_profile.ENVIRONMENT, product_profile.AUTO_MERGE): + # Charter lines: the environment, and who merges PRs into a target base. await _announce(app_state, deps, root, version) return seeded @@ -83,7 +87,7 @@ async def _announce(app_state: Any, deps: FactoryDeps, root: Any, version: int) ) except Exception: # noqa: BLE001 - the seed stays; the PLs read it on their next brief _logger.warning( - "factory %s: could not announce the seeded environment", root.id, exc_info=True + "factory %s: could not announce the seeded profile", root.id, exc_info=True ) diff --git a/omnigent/server/factory/product_profile.py b/omnigent/server/factory/product_profile.py index 225d65b1a3f..76eda91faab 100644 --- a/omnigent/server/factory/product_profile.py +++ b/omnigent/server/factory/product_profile.py @@ -36,6 +36,17 @@ (:data:`AUTONOMY_SEED_ENV`, ``{"": {"level": "standard"}}``): a factory on that repo starts at that level, or gets it once as a seed version while no version ever set ``autonomy.level``; owners change it in Steer. + +The auto-merge targets have their own seed (:data:`AUTO_MERGE_SEED_ENV`, +``{"": [{"base": "staging"}]}``), so a deployment can hand them to +a factory whose delivery profile was seeded or set long ago: a factory on that +repo starts with them, or gets them once as a seed version (by the server, +never an agent) while no version ever set ``product.delivery.auto_merge``. A +factory whose owners set or cleared targets is never touched. Each target +names its base (a target without one seeds nothing), so a human-merged +default branch such as ``main`` is a target only when the seed says so; its +``repo`` is the key unless it names another. Owners change the targets in +Steer afterwards; agents never do. """ from __future__ import annotations @@ -72,6 +83,10 @@ """JSON ``{"": {"level": "standard"}}``: the level that repo's factories start at.""" LABEL_AUTONOMY_SEED: Final = f"{LABEL_PREFIX}autonomy_seed" """On a factory root once its autonomy level was seeded (``v``) or found set (``set``).""" +AUTO_MERGE_SEED_ENV: Final = "OMNIGENT_FACTORY_AUTO_MERGE_SEED" +"""JSON ``{"": [{"base": "staging"}]}``: that repo's factories' merge targets.""" +LABEL_AUTO_MERGE_SEED: Final = f"{LABEL_PREFIX}auto_merge_seed" +"""On a factory root once its auto-merge targets were seeded (``v``) or found set (``set``).""" _HISTORY_LIMIT: Final = 1000 @@ -87,6 +102,9 @@ class ProfileSeed: :param repo_field: A field set to the seed's repo when the seed leaves it out. :param fields: Only these fields of the block are seeded and checked for "ever set" (``None``: the whole block). + :param list_field: A repo's value is this field's list (a list of objects, + each given the seed's repo as ``repo`` when it names none); an empty + list seeds nothing. """ env: str @@ -95,6 +113,7 @@ class ProfileSeed: label: str repo_field: str | None = None fields: tuple[str, ...] | None = None + list_field: str | None = None def default(self) -> dict[str, Any]: """:returns: The block's defaults.""" @@ -108,7 +127,16 @@ def default(self) -> dict[str, Any]: AUTONOMY: Final = ProfileSeed( AUTONOMY_SEED_ENV, "autonomy", AutonomyDef, LABEL_AUTONOMY_SEED, fields=("level",) ) -SEEDS: Final = (DELIVERY, ENVIRONMENT, AUTONOMY) +AUTO_MERGE: Final = ProfileSeed( + AUTO_MERGE_SEED_ENV, + SEED_PATH, + DeliveryDef, + LABEL_AUTO_MERGE_SEED, + fields=("auto_merge",), + list_field="auto_merge", +) +SEEDS: Final = (DELIVERY, ENVIRONMENT, AUTONOMY, AUTO_MERGE) +"""Applied in this order: a later seed's fields win in v1 (auto-merge over delivery).""" def _default_delivery() -> dict[str, Any]: @@ -140,12 +168,16 @@ def deployment_seeds(seed: ProfileSeed = DELIVERY) -> dict[str, dict[str, Any]]: seeds: dict[str, dict[str, Any]] = {} for key, fields in parsed.items(): repo = _norm_repo(key) + if seed.list_field is not None: + fields = _listed(seed.list_field, key, repo, fields) if not repo or not isinstance(fields, Mapping): raise ValueError(f"{key!r}: keys are owner/repo, values {seed.path} objects") found = seed.model.model_validate(dict(fields)) values = found.model_dump(mode="json", exclude_unset=True) if seed.fields is not None: values = {k: v for k, v in values.items() if k in seed.fields} + if seed.list_field is not None and not values.get(seed.list_field): + continue if values: if seed.repo_field and seed.repo_field not in values: values[seed.repo_field] = repo @@ -161,6 +193,13 @@ def deployment_seeds(seed: ProfileSeed = DELIVERY) -> dict[str, dict[str, Any]]: return {} +def _listed(field: str, key: object, repo: str, value: object) -> dict[str, Any]: + """:returns: ``{field: value}``, each item given *key* as its ``repo`` when it names none.""" + if not isinstance(value, list) or not all(isinstance(i, Mapping) for i in value): + raise ValueError(f"{key!r}: values are lists of {field} objects") + return {field: [{"repo": str(key).strip(), **item} if repo else dict(item) for item in value]} + + def seed_values(*repos: str, seed: ProfileSeed = DELIVERY) -> dict[str, Any]: """ :param repos: The factory's repos, its default repo first, e.g. ``("acme/app",)``. @@ -231,7 +270,7 @@ def seed_existing( :param deps: The factory stores. :param root: The factory root. - :param seed: The block to seed (:data:`DELIVERY` or :data:`ENVIRONMENT`). + :param seed: The block to seed (one of :data:`SEEDS`). :returns: The seed version, or ``None`` when nothing was stored. """ from omnigent.factory import LABEL_DEFAULT_REPO @@ -321,11 +360,14 @@ def preview_domain(deps: FactoryDeps | None, factory_id: str | None) -> str | No __all__ = [ "AUTONOMY", "AUTONOMY_SEED_ENV", + "AUTO_MERGE", + "AUTO_MERGE_SEED_ENV", "DELIVERY", "DELIVERY_SEED_ENV", "ENVIRONMENT", "ENVIRONMENT_SEED_ENV", "LABEL_AUTONOMY_SEED", + "LABEL_AUTO_MERGE_SEED", "LABEL_DELIVERY_SEED", "LABEL_ENVIRONMENT_SEED", "SEEDS", diff --git a/tests/server/factory/test_auto_merge.py b/tests/server/factory/test_auto_merge.py index b5aee04410c..762d2922e14 100644 --- a/tests/server/factory/test_auto_merge.py +++ b/tests/server/factory/test_auto_merge.py @@ -220,6 +220,148 @@ async def test_an_agent_never_proposes_targets( assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid) +# ── The deployment's auto-merge seed ───────────────────────────────────────── + + +def _seed_targets(monkeypatch: pytest.MonkeyPatch, value: Any) -> None: + monkeypatch.setenv(product_profile.AUTO_MERGE_SEED_ENV, json.dumps(value)) + + +def _seed_now(cockpit: Cockpit) -> int | None: + root = cockpit.world.store.get_conversation(cockpit.fid) + assert root is not None + found = product_profile.seed_existing(cockpit.deps, root, product_profile.AUTO_MERGE) + definition.invalidate() + return found + + +def test_the_auto_merge_seed_names_each_base(monkeypatch: pytest.MonkeyPatch) -> None: + other = "sample-org/sample-docs" + _seed_targets(monkeypatch, {REPO: [{"base": BASE}, {"repo": other, "base": "main"}]}) + assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == { + REPO: {"auto_merge": [*TARGETS, {"repo": other, "base": "main"}]} + } + # A target without a base is never read as the default branch: nothing is seeded. + _seed_targets(monkeypatch, {REPO: [{"base": BASE}, {}]}) + assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {} + _seed_targets(monkeypatch, {REPO: {"base": BASE}}) + assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {} + # An empty list seeds nothing (never an empty version). + _seed_targets(monkeypatch, {REPO: []}) + assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {} + + +def test_a_new_factory_starts_with_the_seeded_targets(monkeypatch: pytest.MonkeyPatch) -> None: + from omnigent import factory as F + + _seed_targets(monkeypatch, {REPO: [{"base": BASE}]}) + + doc = definition.seed_doc({F.LABEL_DEFAULT_REPO: REPO}) + other = definition.seed_doc({F.LABEL_DEFAULT_REPO: "sample-org/other"}) + + assert doc["product"]["delivery"]["auto_merge"] == TARGETS + assert other["product"]["delivery"]["auto_merge"] == [] + + +async def test_the_auto_merge_seed_applies_once_as_the_server( + cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch +) -> None: + # A factory from before the seed: its v1 exists already, its profile set otherwise. + resp = await put( + cockpit, + {"op": "replace", "path": "product.delivery.merge_policy", "value": "Owners (sample)"}, + ) + assert resp.status_code == 200, resp.text + _seed_targets(monkeypatch, {REPO: [{"base": BASE}]}) + + version = _seed_now(cockpit) + again = _seed_now(cockpit) + + assert version is not None and again is None + row = cockpit.deps.factory_store.get_definition(cockpit.fid, version) + assert row is not None and (row.created_by, row.created_via) == ("server", "seed") + assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)} + # Only the targets are seeded; the rest of the profile stays the owners'. + assert definition.value(cockpit.deps, cockpit.fid, "product.delivery.merge_policy") == ( + "Owners (sample)" + ) + root = cockpit.world.store.get_conversation(cockpit.fid) + assert root is not None + assert root.labels[product_profile.LABEL_AUTO_MERGE_SEED] == f"v{version}" + # Idempotent across restarts too: the label stops the boot pass, and a + # changed seed never reaches a seeded factory. + _seed_targets(monkeypatch, {REPO: [{"base": "main"}]}) + assert _seed_now(cockpit) is None + assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)} + + +async def test_the_auto_merge_seed_skips_a_factory_that_set_targets( + cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch +) -> None: + resp = await put(cockpit, {"op": "replace", "path": PATH, "value": TARGETS}) + assert resp.status_code == 200, resp.text + _seed_targets(monkeypatch, {REPO: [{"base": "main"}]}) + + assert _seed_now(cockpit) is None + + assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)} + root = cockpit.world.store.get_conversation(cockpit.fid) + assert root is not None and root.labels[product_profile.LABEL_AUTO_MERGE_SEED] == "set" + + +async def test_the_auto_merge_seed_skips_a_factory_that_cleared_targets( + cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch +) -> None: + for value in (TARGETS, []): + resp = await put(cockpit, {"op": "replace", "path": PATH, "value": value}) + assert resp.status_code == 200, resp.text + definition.invalidate() + assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid) + _seed_targets(monkeypatch, {REPO: [{"base": BASE}]}) + + assert _seed_now(cockpit) is None + + assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid) + + +async def test_the_auto_merge_seed_skips_another_repo_and_adds_no_main( + cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch +) -> None: + _seed_targets(monkeypatch, {"sample-org/other": [{"base": BASE}]}) + assert _seed_now(cockpit) is None + assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid) + root = cockpit.world.store.get_conversation(cockpit.fid) + assert root is not None and product_profile.LABEL_AUTO_MERGE_SEED not in root.labels + + # The seed names a test base only: the repo's default branch is never added with it. + _seed_targets(monkeypatch, {REPO: [{"base": "temp-main"}]}) + assert _seed_now(cockpit) is not None + targets = auto_merge.factory_targets(cockpit.deps, cockpit.fid) + assert targets.pairs == {(REPO, "temp-main")} + assert not auto_merge.is_target(targets, REPO, "main") + + +async def test_the_boot_seed_tells_each_live_pl_the_server_merges( + cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch +) -> None: + from omnigent.server.factory.background import delivery_seed + from tests.server.factory.projects_helpers import BASE as PROJECT_BASE + from tests.server.factory.projects_helpers import seed as seed_cockpit + + seed_cockpit(cockpit) + # A factory from before the seed: its v1 exists already. + assert definition.definition_for(cockpit.deps, cockpit.fid, fresh=True) is not None + _seed_targets(monkeypatch, {REPO: [{"base": PROJECT_BASE}]}) + + assert await delivery_seed.seed_all(cockpit.app.state) == [cockpit.fid] + assert await delivery_seed.seed_all(cockpit.app.state) == [] + + pl = cockpit.world.id("pl") + [text] = [t for t in cockpit.mailer.texts_to(pl) if "CHARTER UPDATE" in t] + assert "by server (seed v" in text + assert f"PRs into {REPO}#{PROJECT_BASE} are merged by the factory's server" in text + + async def test_approving_an_agent_proposal_never_widens_targets(cockpit: Cockpit) -> None: # A proposal stored past the propose check (an older server filed it) still never applies. row, _ = cockpit.store.create_proposal( From 426176a9c97efd424fd1be984c2d4edc847239db Mon Sep 17 00:00:00 2001 From: Luka Skugor Date: Fri, 6 Nov 2026 09:46:39 +0100 Subject: [PATCH 2/2] factory: sample-only branch name in the auto-merge seed test Co-Authored-By: Claude Opus 5.5 --- tests/server/factory/test_auto_merge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/server/factory/test_auto_merge.py b/tests/server/factory/test_auto_merge.py index 762d2922e14..c00f6ff470d 100644 --- a/tests/server/factory/test_auto_merge.py +++ b/tests/server/factory/test_auto_merge.py @@ -334,10 +334,10 @@ async def test_the_auto_merge_seed_skips_another_repo_and_adds_no_main( assert root is not None and product_profile.LABEL_AUTO_MERGE_SEED not in root.labels # The seed names a test base only: the repo's default branch is never added with it. - _seed_targets(monkeypatch, {REPO: [{"base": "temp-main"}]}) + _seed_targets(monkeypatch, {REPO: [{"base": "preview-main"}]}) assert _seed_now(cockpit) is not None targets = auto_merge.factory_targets(cockpit.deps, cockpit.fid) - assert targets.pairs == {(REPO, "temp-main")} + assert targets.pairs == {(REPO, "preview-main")} assert not auto_merge.is_target(targets, REPO, "main")