Skip to content

cockpit v3: autonomy and auto-merge per subtree (S13) - #217

Merged
LittleChimera merged 2 commits into
stagingfrom
factory/v3-m2-subtree
Nov 4, 2026
Merged

LittleChimera merged 2 commits into
stagingfrom
factory/v3-m2-subtree

Conversation

@LittleChimera

@LittleChimera LittleChimera commented Nov 4, 2026 •

Copy link
Copy Markdown

Related issue

Closes # (cockpit v3 milestone 2, plan item S13; notes/factory-v3/final/plan.md)

Summary

The owner asked for the temp-main tryout factory to "automatically merge, not wait for me", and said "i don't know how i'm supposed to steer it". This PR lets an owner set autonomy and auto-merge on one node, and the setting covers everything under that node, including work added later.

  • Scope lists in the definition.
    • product.delivery.auto_merge_scopes takes entries [{ref, repo, base}] or {ref, merge: false}.
    • autonomy.scopes takes entries [{ref, level}].
    • A ref is a stored goal G-n, need O-n, feature I-n or project slug.
    • The nearest scoped ancestor decides. The node's own entry comes first.
  • The merger (factory: owner-set auto-merge targets, merged by the server #202) and the autonomy decision (factory: no double approvals, an owner-set autonomy level, stale proposals close themselves #204) look up that ancestor.
    • A scope that merges adds its repo#base to the factory-wide targets for the projects under it.
    • A merge: false scope keeps everything under it merged by people.
    • A proposal's ops are decided at the level of the subtree they touch. When an op touches several subtrees, the strictest level wins.
  • Owners only, as a person. Both lists are class B and in AGENT_NEVER, so no agent proposes them, adds a scope or widens one. The verb routes are HUMAN_ORIGIN and need MANAGE.
  • "Autonomy & merge" verb (u) on a node.
    • Preview shows what it covers now and later, the first effect, what it does not touch ("main stays merged by people (… is outside this subtree)") and who approves.
    • Apply writes a definition version.
    • Undo (10 min) re-pins the version before.
  • main is never inferred. A scope always names its base. The verb proposes the subtree's one base (or the factory's), but never main, master or a protected branch unless the owner names it in args.base.

v1 and v2 behave as before. Factory-wide targets and autonomy.level work as today. Nothing runner-side changed (omnigent/runner, tools, policies and omnigent/factory.py are untouched).

ELI5: on the "Agent helper" outcome page, press u and turn on "Auto-merge". The preview says: "Merges into: org/app#temp-main … Covers: 4 projects now (…) and any added under it later … Not affected: main stays merged by people (build-tier-drafts-3 is outside this subtree)". Apply it. From then on, every ready, green PR of those projects is merged by the server, including projects Atlas adds to that outcome later. The project on main still waits for you. Every page under the outcome shows the chip "auto-merge → temp-main · from Agent helper".

definition (owners only; stored under their own top-level keys for rollback)
  product.delivery.auto_merge_scopes: [{ref: "I-1", repo: "org/app", base: "temp-main"}, {ref: "panel", merge: false}]
  autonomy.scopes:                    [{ref: "I-1", level: "tryout"}]

PR #9630 of project "core" ──► chain: core → I-1 (home feature) → O-3 (its need) → G-1 (goal)
                                nearest scoped: I-1 → targets = factory pairs ∪ {(org/app, temp-main)}
PR of "panel"              ──► chain: panel → I-1 …  nearest scoped: panel (merge: false) → no target
PR of "drafts" (main)      ──► chain: drafts         no scope → factory pairs (none) → a person merges

Plan bullets and what this PR does

S13. scope lists on autonomy and delivery.auto_merge: {ref, autonomy?, auto_merge_base?}, inherited by descendants

  • subtrees.py (new):
    • The scope ref grammar: G-n, O-n, I-n or a project slug. Implicit parents and the roots are refused.
    • nearest(chain, scopes).
    • Lineage, which places a project the way the node tree files it:
      • its home feature (features.homes, one HomeFinder per lineage);
      • that feature's live need: its plan.opportunity, else the opportunity of the proposal that started it (unless it sits under a person's ask);
      • then the need's goal (from strategy.goals);
      • a project with no home feature sits in the implicit feature of the proposal that created it, under that proposal's opportunity, as the tree files it. A scope on that need or its goal covers it.
  • AutoMergeScopeDef (definition_blocks.py) and AutonomyScopeDef (autonomy.py) validate entries:
    • a merging scope names its repo and base;
    • an off scope names none;
    • a node is scoped once per list;
    • at most 50 entries.
  • Deviation in shape: these are two lists, one in each block that owns its concern, instead of one combined {ref, autonomy?, auto_merge_base?} list. Each block stays owner-only and rollback-safe on its own. An auto-merge entry also names its repo (targets are repo and base pairs) and can say merge: false. The verb takes the combined shape {auto_merge, base?, repo?, autonomy?} and writes both lists.
  • Rollback-safe storage, the same pattern as auto_merge_targets and autonomy_level:
    • A version stores the lists under their own top-level keys, auto_merge_scopes and autonomy_scopes.
    • While empty, the lists are left out (SPARSE_FIELDS).
    • A server from before them drops only the scopes, and autonomy.projects/level keep their values. Nothing merges by a scope then. That rollback is not safe in every direction: a merge: false scope under factory-wide targets and an autonomy scope stricter than autonomy.level are dropped too, so the old server merges those PRs and decides those ops at the looser factory level (the docstrings and docs/FACTORY.md say so).

S13. The merger and autonomy.decide look up the nearest scoped ancestor

  • auto_merge.Targets is now {pairs, scopes, lineage}. Targets.cover(project) returns the pairs a project's PRs merge into and the scope that decided them.
  • is_target, scope_refusal, local_refusal, merges_itself and github_refusal take the project. They also still accept the bare pairs, so callers that pass a frozenset keep working.
  • factory_targets() reads the lineage the scopes need, and only when there are scopes. It reads only what is needed: initiatives for feature scopes; opportunities and the applied proposals for need and goal scopes. Callers that read their own lineage (the node projection) pass lineage=False, and the snapshot reads the targets only while this server runs the merger.
  • The merger passes the PR's project to GitHub's re-check. Its pr.auto_merged event records data.scope, and the summary and log name it ("the merger logs the scope it used", plan §4).
  • The waits, snapshot PR views, node status and lenses read the same Targets, so "merges automatically" and "needs you: merge" follow the scope. The PL charter's merge policy follows a project or feature scope.
  • autonomy.decide(…, levels=[…]) decides each op at its own level. autonomy.op_level takes the strictest across the projects an op touches, proposals.scoped_levels places them, and the result goes into the event data.
    • A new project counts where applying files it: in the feature the plan extends (analysis.initiative.id, which link_created files it under), else under the proposal's opportunity. A create that only cites the ask that started a feature lands outside that feature, so the feature's level does not decide it.
    • A move counts both where the project is and where it goes.
    • joined (the tryout create_joined class) is computed when any scope is tryout.
    • proposal.opened records scoped: [{op, ref, level}].

S13. Owner-only, HUMAN_ORIGIN; agents can never add or widen a scope

  • AGENT_NEVER gains product.delivery.auto_merge_scopes. autonomy was already in it, so autonomy.scopes is covered.
  • PUT …/definition and the verb need MANAGE and a person's request (human_only for a runner token).
  • Moving a project into a scoped subtree is still a structural op, which a person approves.

S13. A preview/apply/Undo verb "Autonomy & merge" on a node (node_verbs._plan_auto)

  • Served where structure.verbs lists auto (feature and project by default) on a stored goal, need, feature or project. _verb_applies now offers it only there; an implicit node gets 422 not_here.
  • Args are auto_merge (true/false), base, repo, and autonomy (a level or inherit). A node whose ancestors already give what is asked keeps no entry of its own. If nothing changes, the verb answers 409 nothing_to_do.
  • The preview carries:
    • rollup_delta: Auto-merge off → → temp-main (here), and Autonomy Careful → Tryout;
    • effects as {label, text} lines: Merges into, Covers, First effect (the open PRs that will merge themselves), Not affected, Autonomy;
    • apply_label "Turn on / off for this subtree";
    • approver "you" for owners. For editors the approver is "an owner" and can_apply is false (403 authority on apply);
    • a warning when the server runs no merger.
  • Apply is tuning.human_edit (a human C-n, a definition version, based_on its version). Undo re-pins the version before while the one the verb made is still in force.
  • Node pages:
    • a chip auto-merge → <base> · this subtree / from <node> (accent tone) on the scope node and every node under it;
    • auto-merge · off where an off scope actually stops something;
    • autonomy · Tryout · from <node> (accent tone) where the level differs from the factory's;
    • the subtree chips come before the link chips, so they are never folded into "+n";
    • auto_merge: true on covered summaries, own or inherited (the tile badge): a project whose PRs into its base merge by a scope or a factory-wide target; a goal, need or feature that a merging scope covers, else one with a live project under it that merges.
  • Web (VerbPreviews.tsx):
    • an "Autonomy here" choice (Keep, Careful, Standard, Tryout, As above), sent only once one is picked;
    • auto-merge has a Keep state too. A node that auto-merge does not cover yet opens on turning it on (the prototype's first use). A node it covers keeps it, so picking only a level sends no auto_merge. "Keep as it is" drops the auto-merge part;
    • when the server asks the owner to name the base (400 on args.base, which now also returns the base it would have taken), an "Into base" field appears with that base as its placeholder. Nothing is named for the owner: main is sent only once they type it.
    • The sample engine reads all of it.
  • Docs: docs/FACTORY.md covers the scopes (the auto-merge section, the autonomy policy, the Tier 0 invariant row) and the verb's args.

S13 tests (plan wording, "temp-main" spelled tryout-base per FORBIDDEN_SAMPLE_TOKENS)

  • An outcome on the tryout base covers its projects and future ones:
    • test_an_outcome_on_the_tryout_base_covers_its_projects_and_future_ones runs preview, apply and Undo. A project added later through the add verb is covered.
    • The merger end to end over the fake GitHub: test_auto_merge_subtree.py::test_the_outcome_merges_itself_and_main_waits_for_a_person and ::test_a_project_filed_under_the_outcome_later_is_covered.
  • A project on main is untouched: the same tests. drafts/api-status on main still ask a person to merge.
  • main is never auto-merged unless the owner names it: test_main_is_never_auto_merged_unless_the_owner_names_it, for both a project and a feature whose projects target main (400 invalid_args on args.base until it is named).
  • Agents refused: test_agents_never_add_or_widen_a_scope covers both paths and both lead seats. test_only_an_owner_applies_it_and_only_as_a_person covers the editor (403) and the runner token (human_only).
  • Also: test_the_nearest_scope_decides_and_off_keeps_people_merging, test_a_tryout_outcome_grows_by_itself_while_the_factory_stays_careful (a lead's plan that extends the outcome applies at its tryout level, lands in it and is covered by its auto-merge; a create that only cites the ask waits), test_a_factory_wide_target_counts_on_the_nodes_it_covers, test_a_projects_scope_chain_is_its_place_in_the_tree (five fixtures, every project), test_a_scope_on_a_need_covers_the_projects_in_its_implicit_features, test_a_projects_level_covers_only_that_project (one proposal about two subtrees waits: the strictest wins), test_scopes_are_stored_apart_and_only_once_set (an old AutonomyDef and DeliveryDef still validate the stored blocks), validation, and the pure lookups.

Deviations

  • Two lists, not one combined list (see above). An auto-merge entry carries repo and a merge: false form.
  • Scopes name stored nodes only (goal, need, feature, project). An implicit parent must be named first, and tasks inherit from their project. A stored need or goal above an implicit feature does cover the projects in it.
  • The PL charter's merge-policy line follows project- and feature-level scopes, which can be read from the row. Need- and goal-level scopes are not in that line. It is words only: agents never merge either way.
  • The existing AutonomyAndMerge stories render the new controls. Storybook was built locally to check the panel at 1440 (light, dark) and 390.

Test Plan

  • Rebased on staging after cockpit v3: lenses (lineage, timeline, money) and the factory tree (W7, W9) #216 (one conflict in definition_blocks.upgrade: both _rename_on_areas and the scope lowering kept).
  • uv run --frozen pytest -q -n 8 tests/server/factory: 4514 passed.
  • New: tests/server/factory/test_subtree_scopes.py (32) and tests/server/factory/test_auto_merge_subtree.py (3). Updated: the golden defaults, test_auto_merge (Targets.pairs), test_autonomy_levels, test_definition, and test_node_verbs (auto is now served).
  • tests/examples/test_factory_sample_tokens.py: 3 passed.
  • Web (web/): tsc -b, oxlint, prettier --check . are clean. vitest run src/components/factory src/pages/factory: 130 files, 2093 tests passed, with tests for the autonomy choice, the Keep state (a level alone on an auto-merged node sends no auto_merge) and the base field.
  • pre-commit on the changed files: ruff, prettier, oxlint, web tsc and the hygiene hooks pass. pyrefly shows only the pre-existing errors (opentelemetry imports, reconcile.py, the Gensee launcher).
  • No migration: no table changed.

To try it: run a factory with OMNIGENT_FACTORY_AUTO_MERGE_S>0.

  1. Open ?cockpit=v3 on a feature and press u.
  2. Read Covers and Not affected, then Turn on for this subtree.
  3. The feature and its projects show the auto-merge chip. A ready, green PR of one of them merges itself (pr.auto_merged names the scope), while a project on main stays in Needs you. Undo within 10 minutes takes it back.
  4. Pick Autonomy "Tryout" for the feature: an agent's plan that extends the feature (analysis.initiative.id) applies at once (an FYI), while the factory stays at its own level.

Review follow-ups (second commit)

  • A create is decided where applying files it. Before, a create that only cited the ask that started I-1 was decided at I-1's level but filed outside I-1 (in feature:from-C-n), so a tryout scope let an agent add projects anywhere by citing the ask. Now only analysis.initiative.id (what link_created files under) places a create in a feature. Lineage.above uses the proposal's opportunity only for a new feature, never over an extended one.
  • Projects in an implicit feature inherit as the tree shows them. Lineage reads the applied proposals for need and goal scopes, so a scope on O-n or its goal covers a project filed under feature:from-C-n of a proposal that planned O-n. A feature with no plan.opportunity takes its origin proposal's opportunity, as the tree files it. A test checks, for every project in five fixtures, that the scope chain is the project's stored ancestors in the tree.
  • node.auto_merge counts the factory-wide targets, so the verb opens on turning it off where the factory's target already covers the node (no more 409 by default).
  • Web: auto-merge has a Keep state; args.base refusals show an "Into base" field.
  • Docs: the rollback note now says it widens for merge: false and stricter autonomy scopes.
  • Performance: factory_targets(lineage=False) for the node projection (one lineage read per build, not two); the snapshot reads targets only while the merger runs; one HomeFinder per lineage instead of one per project.
  • Chips: the scope chips have the accent tone and come before the link chips.

Demo

N/A (server-first; the preview's new choice is covered by the v3 web test and the existing stories).

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The merger runs end to end over the in-memory GitHub fake. The verb, the definition storage and the autonomy decision are tested through the real routes.

Changelog

Owners can turn on auto-merge and an autonomy level for one feature or project in cockpit v3 ("Autonomy & merge"), and the setting covers everything under it.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Pull request size: XL label Nov 4, 2026
LittleChimera and others added 2 commits November 4, 2026 07:03
Owners scope auto-merge (product.delivery.auto_merge_scopes) and the
autonomy level (autonomy.scopes) to a goal, need, feature or project; the
setting covers everything under it, also work added later. The merger and
autonomy.decide look up the nearest scoped ancestor of a PR's or an op's
project (its home feature, that feature's need, the need's goal). A scope
always names its base: main is never inferred. Both lists are owner-only
(class B, AGENT_NEVER) and stored under their own top-level keys, so an
older server drops only them.

The "Autonomy & merge" verb (u) previews what a scope covers now and later,
its first effect and what it leaves alone, applies as a definition version
and has Undo; node pages show "auto-merge -> <base> · from <node>" on every
node under it. The v3 preview gains an autonomy level choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ects as the tree does

- landing: a new project is decided in the feature its plan extends
  (analysis.initiative.id), else under the proposal's opportunity; a create
  that only cites the ask that started a feature is no longer decided at
  that feature's level (it lands outside it).
- Lineage mirrors the node tree: a project with no home feature sits under
  the opportunity of the proposal that created it (and that need's goal);
  a feature's need falls back to its origin proposal's opportunity, as the
  tree files it. Lineage.above uses the need only for a new feature.
- node.auto_merge counts the factory-wide targets too (own or inherited);
  scope chips come first and use the accent tone.
- factory_targets(lineage=False) for callers with their own lineage; the
  snapshot reads targets only while this server runs the merger; homes are
  computed with one HomeFinder per lineage.
- web: auto-merge has a Keep state (sent only once touched) and a base field
  when the server asks the owner to name the base (args.base).
- docs: a rollback drops merge: false and stricter autonomy scopes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LittleChimera
LittleChimera merged commit 74d87c8 into staging Nov 4, 2026
23 of 25 checks passed
@LittleChimera
LittleChimera deleted the factory/v3-m2-subtree branch November 4, 2026 06:13
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

❌ UI Snapshot doesn't match the committed baseline.

If this UI change is intentional, update the baseline — each path renders in the same pinned image, so the result matches this gate:

  • Label the PR (recommended): add the update-ui-snapshot label — the bot regenerates the baseline in the pinned image, pushes it back here, and re-runs the checks.
  • Locally with Docker: run tests/e2e_ui/visual/regen_baseline_docker.sh, review the PNG, then commit + push.

Diff PNGs (expected_=baseline, actual_=your render, diff_) are in the run artifact. Full guide: tests/e2e_ui/visual/README.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant