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
57 changes: 57 additions & 0 deletions bag/lib/bag/action_result.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.ActionResult do
@moduledoc """
The result of an action, carrying structured **residue** (the echo-types idea:
lossy/partial transformations retain a proof-relevant residue, not just
success/failure).

`residue` is one of:
* `:clean` — nothing owed, nothing to repair
* `{:owes, obligation}` — succeeded, but an obligation remains
(e.g. a *relegated* check still owes the
GitHub required-status-check it can't
natively satisfy)
* `{:dirty, repair_obligation}` — a partial/failed action left damage that
must be repaired or discarded

A cheap action is only truly cheap if its residue is cheap to verify/repair.
"""
defstruct [:verdict, :node, :residue, :baton]

@type residue :: :clean | {:owes, term()} | {:dirty, term()}
@type t :: %__MODULE__{verdict: atom(), node: String.t() | nil, residue: residue(), baton: any()}

@doc """
Classify the residue from a verdict + context.

Opts: `:relegated` (ran off the canonical paid route → owes its native gate),
`:repair` (the repair obligation to attach to a dirty/partial verdict).
"""
def classify(verdict, opts \\ []) do
cond do
verdict in [:dirty_partial, :catastrophic_partial] ->
{:dirty, Keyword.get(opts, :repair, :unspecified_repair_obligation)}

verdict == :pass and Keyword.get(opts, :relegated, false) ->
{:owes, :github_required_check}

true ->
:clean
end
end

@doc "Build a result, classifying residue from the verdict + context."
def new(verdict, node, opts \\ []) do
%__MODULE__{
verdict: verdict,
node: node,
residue: classify(verdict, opts),
baton: Keyword.get(opts, :baton)
}
end

@doc "The repair obligation a dirty result imposes, or `:none`."
def repair_obligation(%__MODULE__{residue: {:dirty, ob}}), do: {:ok, ob}
def repair_obligation(%__MODULE__{}), do: :none
end
47 changes: 47 additions & 0 deletions bag/lib/bag/budget.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.Budget do
@moduledoc """
Typed, **non-fungible** resource budgets.

Budgets are NOT a single scalar: a paid-runner money budget, a mutation
budget, human-review attention, and rollback/repair capacity are not
interchangeable. Budget exhaustion is a *context change* — when a typed budget
runs out, plans that relied on it stop type-checking (the resource is simply
not available to the planner), which is how a depleted paid quota removes the
paid route while cheaper/owned routes remain.

Each field is either a number (remaining units) or `:infinity` (untracked).
"""
defstruct money: :infinity, mutation: :infinity, human_review: :infinity, repair: :infinity

@type level :: number() | :infinity
@type t :: %__MODULE__{money: level, mutation: level, human_review: level, repair: level}

@doc "A new budget from keyword/map fields; unset dimensions are `:infinity`."
def new(fields \\ []), do: struct(__MODULE__, fields)

@doc "An untracked (unlimited) budget — the back-compatible default."
def unlimited, do: %__MODULE__{}

@doc "True if `key` has at least `amount` units left (`:infinity` always affords)."
def affords?(%__MODULE__{} = b, key, amount) do
case Map.get(b, key) do
:infinity -> true
have when is_number(have) -> have >= amount
_ -> false
end
end

@doc "True if `key` cannot afford even one unit (the resource is exhausted)."
def exhausted?(%__MODULE__{} = b, key), do: not affords?(b, key, 1)

@doc "Spend `amount` of `key` (floored at 0; `:infinity` is unchanged)."
def debit(%__MODULE__{} = b, key, amount) do
case Map.get(b, key) do
:infinity -> b
have when is_number(have) -> Map.put(b, key, max(have - amount, 0))
_ -> b
end
end
end
16 changes: 14 additions & 2 deletions bag/lib/bag/ci_baton.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ defmodule Bag.CiBaton do
that replaces a paid GitHub Actions run — it can migrate to, and be consumed
on, any other node without re-executing the check.
"""
defstruct [:id, :check_id, :node, :required_cap, :command, :verdict, :exit_code]
defstruct [
:id,
:check_id,
:node,
:required_cap,
:command,
:verdict,
:exit_code,
mutating: false,
risk: :low
]

@type verdict :: :pending | :pass | :fail | :suspended | :error

Expand Down Expand Up @@ -40,7 +50,9 @@ defmodule Bag.CiBaton do
required_cap: Keyword.get(opts, :required_cap, "linux"),
command: command,
verdict: :pending,
exit_code: nil
exit_code: nil,
mutating: Keyword.get(opts, :mutating, false),
risk: Keyword.get(opts, :risk, :low)
}
end
end
23 changes: 20 additions & 3 deletions bag/lib/bag/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,27 @@ defmodule Bag.Executor do
(`estate.zig` ← `verification/proofs/Bag/Estate.idr`) via the Zig host — so the
orchestrator never keeps its own copy of the node list to drift out of step.
"""
def list_nodes do
def list_nodes, do: Map.keys(node_costs())

@doc """
Returns `%{node_name => cost}` — the tropical (min-plus) money grade of each
node, read from the same mirrored manifest. Owned nodes are cheap; the paid
github-runner is expensive. Drives the planner's cheapest-capable routing.
"""
def node_costs do
case System.cmd(@executor_path, ["nodes"], cd: Path.expand("../../../", __DIR__)) do
{output, 0} -> String.split(output, "\n", trim: true)
{_output, _code} -> []
{output, 0} ->
output
|> String.split("\n", trim: true)
|> Map.new(fn line ->
case String.split(line, "\t") do
[name, cost] -> {name, String.to_integer(cost)}
[name] -> {name, 0}
end
end)

{_output, _code} ->
%{}
end
end

Expand Down
52 changes: 48 additions & 4 deletions bag/lib/bag/mesh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Bag.Mesh do
The distributed orchestrator for Batons.
"""
use GenServer
alias Bag.{Baton, CiBaton, Executor}
alias Bag.{ActionResult, Baton, CiBaton, Budget, Executor, Planner}

def start_link(opts) do
{:ok, pid} = GenServer.start_link(__MODULE__, opts, name: __MODULE__)
Expand All @@ -27,6 +27,18 @@ defmodule Bag.Mesh do
GenServer.call(__MODULE__, {:submit_check, check_id, command, opts}, 60_000)
end

@doc """
Budget-and-capability-aware submission. The planner selects the cheapest
capable node the budget can afford (gating mutating work on a verifier); the
check runs there; the result carries structured residue.

`spec`: `%{check_id, command, required_cap, mutating?, risk?, verifier?}`.
Returns a `Bag.ActionResult` (verdict `:pass | :fail | :rejected | :suspended`).
"""
def submit_planned(spec, %Budget{} = budget \\ Budget.unlimited()) do
GenServer.call(__MODULE__, {:submit_planned, spec, budget}, 60_000)
end

@doc """
Submits a new Baton to the Mesh.
"""
Expand Down Expand Up @@ -97,18 +109,50 @@ defmodule Bag.Mesh do

case capable do
[] ->
IO.puts("Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.")
# Operational logs go to stderr — stdout is reserved for machine output.
IO.puts(:stderr, "Mesh: NO node satisfies '#{required_cap}' for check #{check_id}. Suspended.")
{:reply, {:suspended, nil, nil}, state}

[node | _] ->
baton = CiBaton.new(check_id, command, node: node, required_cap: required_cap)
IO.puts("Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})")
IO.puts(:stderr, "Mesh: routing check #{check_id} → #{node} (cap: #{required_cap})")
{verdict, updated, _output} = Executor.run_check(baton, freeze_path)
IO.puts("Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)")
IO.puts(:stderr, "Mesh: check #{check_id} verdict=#{verdict} on #{node} (0 GitHub minutes)")
{:reply, {verdict, node, updated}, state}
end
end

@impl true
def handle_call({:submit_planned, spec, budget}, _from, state) do
case Planner.plan(spec, budget) do
{:ok, node, cost} ->
IO.puts(:stderr, "Mesh: planned #{spec.check_id} → #{node} (money cost #{cost})")

baton =
CiBaton.new(spec.check_id, spec.command,
node: node,
required_cap: spec.required_cap,
mutating: Map.get(spec, :mutating, false),
risk: Map.get(spec, :risk, :low)
)

freeze_path = Path.join(System.tmp_dir!(), "#{spec.check_id}.baton")
{verdict, updated, _output} = Executor.run_check(baton, freeze_path)
# Relegated = ran on an owned node, not GitHub's required-check route.
relegated = node != "mesh-github-runner"
result = ActionResult.new(verdict, node, relegated: relegated, baton: updated)
{:reply, result, state}

{:rejected, reason} ->
IO.puts(:stderr, "Mesh: REJECTED #{spec.check_id}: #{reason}")
{:reply, %ActionResult{verdict: :rejected, node: nil, residue: {:owes, reason}}, state}

{:suspended, reason} ->
IO.puts(:stderr, "Mesh: SUSPENDED #{spec.check_id}: #{reason} (budget/capability)")
{:reply, %ActionResult{verdict: :suspended, node: nil, residue: :clean}, state}
end
end

@impl true
def handle_cast({:process, baton}, state) do
IO.puts("Mesh [#{Node.self()}]: Claimed Baton #{baton.id} with count #{baton.counter}")
Expand Down
51 changes: 51 additions & 0 deletions bag/lib/bag/planner.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.Planner do
@moduledoc """
Capability- and budget-typed action selection.

Mirrors the formal routing objective `Bag.Estate.cheapestCapable` (Idris):
among nodes that satisfy the required capability AND that the current typed
budget can still afford, choose the least-cost (tropical ⊕ = min) one.

Policy realised here:
* relegate to the cheapest capable node (reserve the paid route for work
whose capability only it provides);
* an exhausted/insufficient typed budget removes a route (context change) —
work that needs an unaffordable route is *suspended*, not run unsafely;
* mutating / hard-to-undo work requires a verifier before it is planned.

A `spec` is a map: `%{required_cap: String, mutating: bool (default false),
risk: atom (default :low), verifier: any | nil}`.
Returns `{:ok, node, cost}` | `{:rejected, reason}` | `{:suspended, reason}`.
"""
alias Bag.{Budget, Executor}

def plan(spec, %Budget{} = budget) when is_map(spec) do
required_cap = Map.fetch!(spec, :required_cap)
mutating = Map.get(spec, :mutating, false)
verifier = Map.get(spec, :verifier)

cond do
# Gate: mutating / irreversible work needs a verifier or approval path.
mutating and is_nil(verifier) ->
{:rejected, :mutation_requires_verifier}

true ->
feasible =
Executor.node_costs()
|> Enum.filter(fn {name, cost} ->
Executor.node_satisfies?(name, [required_cap]) and Budget.affords?(budget, :money, cost)
end)

case feasible do
[] ->
{:suspended, :no_affordable_capable_node}

nodes ->
{name, cost} = Enum.min_by(nodes, fn {_name, cost} -> cost end)
{:ok, name, cost}
end
end
end
end
12 changes: 11 additions & 1 deletion bag/test/mesh_check_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Bag.MeshCheckTest do
emitter — all on owned compute, zero GitHub Actions minutes.
"""
use ExUnit.Case, async: false
alias Bag.{Mesh, CiSweep, Executor}
alias Bag.{Mesh, CiSweep, Executor, Budget}

@repo_root Path.expand("../..", __DIR__)

Expand Down Expand Up @@ -46,6 +46,16 @@ defmodule Bag.MeshCheckTest do
Mesh.submit_check("mesh-susp", ["true"], required_cap: "bogus-cap")
end

test "submit_planned routes by budget, runs the check, and returns residue" do
spec = %{check_id: "planned-fmt", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"}
result = Mesh.submit_planned(spec, Budget.unlimited())

assert result.verdict == :pass
assert result.node in ["mesh-server-1", "mesh-laptop"]
# Ran on owned compute (relegated) → still owes the GitHub-native gate.
assert result.residue == {:owes, :github_required_check}
end

test "CiSweep runs a batch of checks and summarises verdicts" do
checks = [
%{check_id: "sweep-ok", command: ["zig", "fmt", "--check", "build.zig"], required_cap: "zig"},
Expand Down
62 changes: 62 additions & 0 deletions bag/test/planner_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Bag.PlannerTest do
@moduledoc """
Typed-budget + capability action selection, and structured residue.
Demonstrates the acceptance scenarios: an exhausted/insufficient paid budget
removes the paid route (the "Claude/Codex out, cheaper route remains" analog),
cheap reversible work relegates to the cheapest capable node, mutating work is
gated on a verifier, and partial results carry repair obligations.
"""
use ExUnit.Case, async: false
alias Bag.{Planner, Budget, ActionResult}

@repo_root Path.expand("../..", __DIR__)

setup_all do
{_out, 0} = System.cmd("zig", ["build"], cd: @repo_root, stderr_to_stdout: true)
:ok
end

test "work that NEEDS the paid route uses it when the budget can afford it" do
spec = %{check_id: "scan", command: ["true"], required_cap: "secret_access"}
assert {:ok, "mesh-github-runner", 100} = Planner.plan(spec, Budget.unlimited())
end

test "insufficient paid budget removes the paid route → work needing it is SUSPENDED" do
# Only mesh-github-runner (cost 100) has secret_access; a $50 budget can't
# afford it, and nothing cheaper can do the work → suspend, don't run unsafely.
spec = %{check_id: "scan", command: ["true"], required_cap: "secret_access"}
assert {:suspended, :no_affordable_capable_node} = Planner.plan(spec, Budget.new(money: 50))
end

test "cheap reversible work relegates to the cheapest capable node" do
spec = %{check_id: "fmt", command: ["true"], required_cap: "zig"}
# mesh-laptop (2) and mesh-server-1 (1) both have zig; the min-cost wins.
assert {:ok, "mesh-server-1", 1} = Planner.plan(spec, Budget.new(money: 50))
end

test "mutating work is rejected without a verifier, permitted with one" do
base = %{check_id: "apply", command: ["true"], required_cap: "linux", mutating: true}
assert {:rejected, :mutation_requires_verifier} = Planner.plan(base, Budget.unlimited())
assert {:ok, _node, _cost} = Planner.plan(Map.put(base, :verifier, :human_approved), Budget.unlimited())
end

test "non-fungible budgets: money exhaustion does not touch other dimensions" do
b = Budget.new(money: 0, mutation: 5)
assert Budget.exhausted?(b, :money)
refute Budget.exhausted?(b, :mutation)
assert Budget.affords?(b, :mutation, 3)
end

test "a relegated pass still OWES the GitHub required-status-check (echo residue)" do
r = ActionResult.new(:pass, "mesh-server-1", relegated: true)
assert r.residue == {:owes, :github_required_check}
end

test "a dirty partial yields a repair obligation" do
r = ActionResult.new(:dirty_partial, "mesh-server-1", repair: :revert_touched_files)
assert {:dirty, :revert_touched_files} = r.residue
assert {:ok, :revert_touched_files} = ActionResult.repair_obligation(r)
end
end
Loading