Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .machine_readable/merge-orchestration/LEDGER.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
69 changes: 69 additions & 0 deletions lib/merge_orchestration/scheduler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
29 changes: 29 additions & 0 deletions lib/mix/tasks/hypatia.merge_orchestrate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
86 changes: 86 additions & 0 deletions test/merge_orchestration/scheduler_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
Loading