diff --git a/.machine_readable/merge-orchestration/LEDGER.a2ml b/.machine_readable/merge-orchestration/LEDGER.a2ml index 0acb1dc..ebc2739 100644 --- a/.machine_readable/merge-orchestration/LEDGER.a2ml +++ b/.machine_readable/merge-orchestration/LEDGER.a2ml @@ -9,7 +9,7 @@ [metadata] workstream = "merge-orchestration" -version = "0.5.0" +version = "0.6.0" last-updated = "2026-06-14" owner = "paraordinate (Jonathan D.A. Jewell)" branch = "claude/peaceful-pascal-IRlgq" # same branch name across all in-scope repos @@ -66,4 +66,4 @@ hold = "6a2/STATE,META + anchors/ANCHOR in {hypatia,gitbot-fleet,.git-p other-agents = "claude/nice-ptolemy-lNB8E (standards), claude/loving-cannon-AtUSm (echidna), claude/abi-tier2-and-l10-trans-cycle (vcl-ut), claude/affinescript-migration-wip (reposystem)" [next] -step = "Wave-0 design COMPLETE (a1-a5). RUNTIME COMPLETE + OPERATIONALISED + LIVE LOOP. Pipeline merged to main: SENSE Sensor (#492); DELIBERATE Strategist (route_authority public) + Kin.Council competence-aware via KinCompetence (#494); RUNTIME TIER KinGate + FileStore (#494); ACTUATE Dispatcher -> merge-decisions.jsonl -> .git-private-farm actuator (#77); SENSE PRODUCER observe.sh (#78). LIVE LOOP (this commit, Loop module): one run(opts)/plan(...) over the shared store -- read observations -> Sensor.sense -> (KinCompetence weight if trust snapshot given) -> Strategist decide -> KinGate gate -> write merge-decisions.jsonl. THE GATE FINALLY BITES HERE: an arm stays auto_execute only if it CLAIMS the repo -- two mutual-exclusion layers: in-cycle (>=2nd same-repo arm per run -> deferred to report_only via MapSet) + cross-agent/cross-cycle (KinGate persistent per-repo lease; another holder -> deferred) + LE2 meta refuse (defence-in-depth). Token-free: loop only READS store + WRITES manifest/leases; actuator (separate process, holds PAT) reads manifest + merges. Store layout under opts[:store]: observations/ pools/ attestations/ leases/ + merge-decisions.jsonl. Tests: 58 ExUnit (was 54: +4 Loop -- in-cycle dedup, cross-agent conflict, mixed-stats, full store->manifest round-trip; +1 Jason-guarded), 0 failures, local elixir 1.14, mix-format-clean, scanner-clean. PR pending: hypatia (Loop). REMAINING (all owner-side or scheduling, NOT new brain logic): the SCHEDULED TRIGGER that calls Loop.run on a cron/GenServer with a live GoT trust snapshot (KinCompetence.trust_from_got(GraphOfTrust snapshot, bots)) -- pure wiring to an existing scheduler; a5 standards adoption (owner-applied); P3 handshake + mass_squash in actuator; .git-private-farm Actions billing is dead -- fix before the actuator runs scheduled." +step = "MERGE-ORCHESTRATION SYSTEM COMPLETE + AUTONOMY LOOP CLOSED. Full pipeline on main: SENSE Sensor (#492) + producer observe.sh (#78); DELIBERATE Strategist (route_authority public) + Kin.Council competence-aware via KinCompetence (#494); RUNTIME TIER KinGate + FileStore (#494); LIVE LOOP (#495) -- one Loop.run over the store: observations -> Sensor.sense -> KinCompetence weight -> Strategist/KinCouncil decide -> KinGate gate (in-cycle MapSet dedup + cross-agent per-repo lease + LE2 meta refuse) -> merge-decisions.jsonl; ACTUATE Dispatcher manifest -> .git-private-farm actuate.sh (#77, independent re-verify + gh merge). SCHEDULED TRIGGER (this commit): Scheduler.cycle/1 + Mix task `hypatia.merge_orchestrate` -- the cron/CI entry point. cycle resolves store (opt | MERGE_ORCH_STORE env | data/verisim default), snapshots GoT into the council via KinCompetence.trust_from_got over the fleet roster (:got=:auto builds Hypatia.Neural.GraphOfTrust late-bound via apply/3, degrading to uniform on any failure so a cron never crashes; :got=nil forces uniform; a struct is used directly), runs Loop.run, logs the stats. GoT.trust_score defaults unknown entities to 0.5 neutral. Tests: 61 ExUnit (was 58: +3 Scheduler -- uniform-arms, zero-trust-recuses-to-flag [GoT engaged end to end], MERGE_ORCH_STORE env fallback), 0 failures, local elixir 1.14, mix-format-clean, scanner-clean. PR pending: hypatia (Scheduler + Mix task). The full sense->deliberate->gate->actuate loop now runs from a single `mix hypatia.merge_orchestrate`. REMAINING (all owner-side, NO new brain logic): wire a cron/GenServer-tick to invoke the Mix task on a schedule (e.g. add to learning_scheduler tick, or a CI cron) -- one-liner; a5 standards adoption (owner-applied); P3 handshake + mass_squash in actuator; .git-private-farm Actions billing is dead -- fix before the actuator runs scheduled." diff --git a/lib/merge_orchestration/scheduler.ex b/lib/merge_orchestration/scheduler.ex new file mode 100644 index 0000000..d61af96 --- /dev/null +++ b/lib/merge_orchestration/scheduler.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +defmodule Hypatia.MergeOrchestration.Scheduler do + @moduledoc """ + The scheduled trigger — closes the autonomy loop. + + A cron / CI / GenServer tick calls `cycle/1`, which assembles the *live* + inputs the pure `Loop` doesn't: it snapshots Graph-of-Trust into the + competence weighting, resolves the store path, and runs one `Loop.run/1`. + + The Mix task `hypatia.merge_orchestrate` is the canonical entry point: + + mix hypatia.merge_orchestrate --store data/verisim + + Trust source (`:got` opt): + + * `:auto` (default) — build a live `GraphOfTrust` snapshot, degrading to a + uniform council if the neural layer / outcome data isn't available (a + scheduled job must never crash the cron); + * `nil` — force a uniform council; + * a struct — use it directly. + + The `GraphOfTrust` call is late-bound (`apply/3`) so this module stays + decoupled from the neural stack at compile time and the logic tests run + dependency-free. + """ + + alias Hypatia.MergeOrchestration.{Loop, KinCompetence} + require Logger + + # the fleet whose attestations the council weighs (the bots route_authority names) + @fleet ~w(echidnabot patch-bridge panicbot robot-repo-automaton rhodibot sustainabot ci) + @default_store "data/verisim" + + @doc "Run one merge-orchestration cycle. Returns the `Loop.run/1` summary (+ `:manifest_path`)." + def cycle(opts \\ []) do + store = Keyword.get(opts, :store) || System.get_env("MERGE_ORCH_STORE") || @default_store + trust = Keyword.get_lazy(opts, :trust, fn -> trust_snapshot(opts) end) + + passthrough = Keyword.take(opts, [:holder, :now, :encode, :decode, :acquire]) + result = Loop.run([store: store, trust: trust] ++ passthrough) + + Logger.info("[merge-orchestration] cycle #{inspect(result.stats)} -> #{result.manifest_path}") + result + end + + defp trust_snapshot(opts) do + bots = Keyword.get(opts, :bots, @fleet) + + case Keyword.get(opts, :got, :auto) do + nil -> %{} + :auto -> auto_trust(bots) + got -> KinCompetence.trust_from_got(got, bots) + end + end + + # Build a live GoT snapshot; degrade to a uniform council if it can't be built. + defp auto_trust(bots) do + got = apply(Hypatia.Neural.GraphOfTrust, :build, []) + KinCompetence.trust_from_got(got, bots) + rescue + e -> + Logger.warning( + "[merge-orchestration] GoT snapshot unavailable (#{inspect(e)}); uniform council" + ) + + %{} + end +end diff --git a/lib/mix/tasks/hypatia.merge_orchestrate.ex b/lib/mix/tasks/hypatia.merge_orchestrate.ex new file mode 100644 index 0000000..18936ee --- /dev/null +++ b/lib/mix/tasks/hypatia.merge_orchestrate.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +defmodule Mix.Tasks.Hypatia.MergeOrchestrate do + @shortdoc "Run one merge-orchestration cycle (sense → deliberate → gate → manifest)" + @moduledoc """ + The scheduled trigger for the merge-orchestration runtime. Reads the shared + store, decides + gates every open-PR observation, and writes the merge-decision + manifest the `.git-private-farm` actuator consumes. + + mix hypatia.merge_orchestrate [--store PATH] [--holder NAME] + + Invoke from cron / CI on a schedule. The competence weighting snapshots + Graph-of-Trust automatically (degrading to a uniform council if the neural + layer is unavailable). The brain only reads the store and writes the manifest; + the actuator (a separate, token-bearing process) performs the merges. + """ + use Mix.Task + + alias Hypatia.MergeOrchestration.Scheduler + + @impl Mix.Task + def run(argv) do + Mix.Task.run("app.start") + {opts, _, _} = OptionParser.parse(argv, strict: [store: :string, holder: :string]) + + result = Scheduler.cycle(Keyword.take(opts, [:store, :holder])) + Mix.shell().info("[merge-orchestration] #{inspect(result.stats)} -> #{result.manifest_path}") + end +end diff --git a/test/merge_orchestration/scheduler_test.exs b/test/merge_orchestration/scheduler_test.exs new file mode 100644 index 0000000..eb4b9d4 --- /dev/null +++ b/test/merge_orchestration/scheduler_test.exs @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +defmodule Hypatia.MergeOrchestration.SchedulerTest do + # async: false — one test mutates MERGE_ORCH_STORE in the process env + use ExUnit.Case, async: false + alias Hypatia.MergeOrchestration.Scheduler + + # a minimal struct exposing trust_score/2 (mirrors Neural.GraphOfTrust's neutral 0.5 default) + defmodule FakeGoT do + defstruct scores: %{} + def trust_score(%__MODULE__{scores: s}, bot), do: Map.get(s, bot, 0.5) + end + + @now ~U[2026-06-14 12:00:00Z] + + defp enc, do: fn t -> t |> :erlang.term_to_binary() |> Base.encode64() end + defp dec, do: fn b -> b |> Base.decode64!() |> :erlang.binary_to_term() end + + # a store with one council-approved bump (sole approver "ci") + defp seed_store do + store = Path.join(System.tmp_dir!(), "sched-#{System.unique_integer([:positive])}") + + for sub <- ~w(observations pools attestations leases), + do: File.mkdir_p!(Path.join(store, sub)) + + e = enc() + + File.write!( + Path.join([store, "observations", "hyperpolymath__a__1.json"]), + e.(%{ + "repo" => "hyperpolymath/a", + "number" => 1, + "branch" => "dependabot/cargo/x", + "files" => ["Cargo.lock"] + }) + ) + + File.write!(Path.join([store, "pools", "hyperpolymath__a.json"]), e.(%{"pool" => "P2"})) + + File.write!( + Path.join([store, "attestations", "ci.json"]), + e.(%{ + "subject" => %{"repo" => "hyperpolymath/a", "number" => 1}, + "bot" => "ci", + "verdict" => "approve", + "confidence" => 0.99 + }) + ) + + store + end + + defp codec, do: [now: @now, encode: enc(), decode: dec()] + + test "a cycle with no trust source runs a uniform council and arms the bump" do + store = seed_store() + r = Scheduler.cycle([store: store, got: nil] ++ codec()) + assert r.stats.armed == 1 + File.rm_rf!(store) + end + + test "the GoT snapshot flows into the council: a zero-trust sole approver recuses → flagged, not armed" do + store = seed_store() + + r = + Scheduler.cycle( + [store: store, got: %FakeGoT{scores: %{"ci" => 0.0}}, bots: ["ci"]] ++ codec() + ) + + assert r.stats.armed == 0 + assert r.stats.flagged == 1 + File.rm_rf!(store) + end + + test "the store path falls back to MERGE_ORCH_STORE when :store is not given" do + store = seed_store() + System.put_env("MERGE_ORCH_STORE", store) + + r = Scheduler.cycle([got: nil] ++ codec()) + assert r.manifest_path == Path.join(store, "merge-decisions.jsonl") + assert r.stats.armed == 1 + + System.delete_env("MERGE_ORCH_STORE") + File.rm_rf!(store) + end +end