maf 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2844c9f128a0b025a9a1c933091642b9e885f069d3bfe7daff77f2fe3659c56
4
- data.tar.gz: c464cfff417c58d40add531944b4ec12a93a5251510dc01d7f2d81a0a7905997
3
+ metadata.gz: 9d59c62e5b37a338b1da8b57db74bfb670bb735ca4fa90059d6b874a18ffd25e
4
+ data.tar.gz: 2c33e35cec6ad395fc539a4bc5ef3813a2e73fdcdb17464a247dbfdf1111ec0d
5
5
  SHA512:
6
- metadata.gz: d906ee9673550164f3c61c195eee88a41173f80443176c5be7809a89d2da32036f8c6ad03620ca8ddadb8994a345a565602b660e0a150563d4411c6c0e896fae
7
- data.tar.gz: 3fe01e2df87c0676ced712531aa44ec1935d8a5d5982d1521e765a3d846a4d9fd524485076738ba1c35927f8eb2cb00fea6db174d3fb570ada0565b19ebdeff1
6
+ metadata.gz: 1fe86c50be42049d5dedef7e561c6e042469d7686828bc7932931d63cece3f6fcb54e516e08c2fef48792d5fd86aa4e16932d7e5d3e7e7ae72dd1bc9f6a0f72b
7
+ data.tar.gz: 19f1244d7243c253c6f1852fd1376028e25c9e67d0bd249cb0e3fc44f25dd88b7b293ab466eb670d5040faec8e48c87fe00a8cb1a1cc02a81677a0eff37f9742
data/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0] - 2026-10-22
6
+
7
+ - Fix: in the macOS sandbox, headless Chrome aborted at start, and macOS showed a crash dialog
8
+ for each try. New `.maf/sandbox.json` key `launch_services` lists the apps that can use
9
+ LaunchServices. After each run, the dispatcher reports apps that the run started and that
10
+ crashed, and LaunchServices denials, once for each app to the project manager.
11
+ - Fix: after Ctrl+C or SIGHUP, the dispatcher stopped, but the agent run continued in its own
12
+ session. The dispatcher now stops the process group of the agent first.
13
+ - New: `steward`, a daemon outside the sandbox that runs a fixed list of host operations for the
14
+ leads: `worker-restart`, `worker-retire`, `worktree-sync`, `goal-sync`, `runtime-restore`. An
15
+ agent asks with `coord op request OPERATION [ARGUMENT]`. steward checks each argument, runs no
16
+ shell, allows 3 runs per hour for one argument, and sends the result to the inbox of the role
17
+ that asked. `maf start` starts steward. The project manager and the architect prompts list the
18
+ operations. `maf update` installs `.maf/bin/steward`.
19
+ - New: operations that wait for the user: `worker-start`, `worker-model WORKER MODEL`, and
20
+ `bundle-install` (frozen to `Gemfile.lock`). `maf op list|approve ID|deny ID` handles them.
21
+ `.maf/sandbox.json` key `ops` sets the level of each operation: `auto`, `approve`, or `off`.
22
+ - After `maf untrack`, maf moves the old branches. `maf start` fast-forwards a worker branch
23
+ also when the only changes are in flow files that the base branch no longer tracks: maf
24
+ discards them first. `maf update` runs the new `coord goal migrate`, which merges the base
25
+ branch into each goal branch that still tracks the flow files. Before, the user did these
26
+ steps by hand, because the sandbox blocks an agent from deleting the flow files.
27
+ - Fix: `maf worker stop` did nothing for a session that `maf worker list` showed as running.
28
+ `stop` read only the presence file, and that file can name an older session. `stop`, `start`,
29
+ and `status` now find the session from the records of `maf start`, as `list` does.
30
+ - `maf worker stop` reports a session that does not stop after TERM, and prints the `kill -9`
31
+ command. A session in a closed terminal can ignore TERM.
32
+
5
33
  ## [0.4.0] - 2026-10-21
6
34
 
7
35
  - Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
data/assets/coord CHANGED
@@ -25,6 +25,7 @@ begin
25
25
  require_relative "../lib/maf/shared/git_exclude"
26
26
  require_relative "../lib/maf/shared/git_identity"
27
27
  require_relative "../lib/maf/shared/harness"
28
+ require_relative "../lib/maf/shared/flow_files"
28
29
  rescue LoadError
29
30
  abort "coord: the maf shared library is missing. Run maf update."
30
31
  end
@@ -72,6 +73,7 @@ module Coord
72
73
  coord goal list list open goals
73
74
  coord goal show ID show a goal and its tasks
74
75
  coord goal sync ID merge the base branch into the goal branch
76
+ coord goal migrate sync each goal branch that still tracks the flow files (after maf untrack)
75
77
  coord goal done ID close a goal (refused while a task is open or the goal lacks the base head)
76
78
  coord goal pr ID push the goal branch as the GitHub bot and open or update its pull request
77
79
  coord review-watch [--once] [--interval S]
@@ -97,6 +99,10 @@ module Coord
97
99
  coord lesson ID dead_end|corrected TEXT...
98
100
  save a lesson of a task to the graph memory: an approach that
99
101
  failed (dead_end), or the right way (corrected)
102
+ coord op request OPERATION [ARGUMENT...] ask steward for a host operation; the result comes to your inbox.
103
+ auto: worker-restart W, worker-retire W, worktree-sync W, goal-sync ID,
104
+ runtime-restore. The user approves: worker-start W, worker-model W MODEL,
105
+ bundle-install
100
106
  coord msg [--from A] [--task ID] [--fyi] TO TEXT...
101
107
  TO is a role or a worker; a worker's mail goes to its role inbox.
102
108
  --task also saves the text as a note on the task.
@@ -134,7 +140,7 @@ module Coord
134
140
  "land" => :cmd_land, "gc" => :cmd_gc, "reap" => :cmd_reap, "review-watch" => :cmd_review_watch,
135
141
  "show" => :cmd_show, "escalate" => :cmd_escalate,
136
142
  "status" => :cmd_status, "board" => :cmd_board, "export" => :cmd_export,
137
- "who" => :cmd_who, "help" => :cmd_help
143
+ "who" => :cmd_who, "op" => :cmd_op, "help" => :cmd_help
138
144
  }.freeze
139
145
 
140
146
  ADD_OPTS = { "--role" => :role, "--scope" => :scope, "--ref" => :ref,
@@ -1260,8 +1266,10 @@ end
1260
1266
  goal
1261
1267
  end
1262
1268
 
1269
+ def open = @tasks.pending.select { |task| task["role"] == ROLE }
1270
+
1263
1271
  def list
1264
- goals = @tasks.pending.select { |task| task["role"] == ROLE }
1272
+ goals = open
1265
1273
  return puts("no open goals") if goals.empty?
1266
1274
 
1267
1275
  goals.each { |goal| puts goal_line(goal) }
@@ -1332,7 +1340,7 @@ end
1332
1340
  paths = Git.changed(@dir)
1333
1341
  return if paths.empty?
1334
1342
 
1335
- flow = paths.all? { |path| path.start_with?(".maf/") || Worktree::RUNTIME_FILES.include?(path) }
1343
+ flow = paths.all? { |path| Maf::Shared::FlowFiles.path?(path) || Worktree::RUNTIME_FILES.include?(path) }
1336
1344
  raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
1337
1345
  flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
1338
1346
  end
@@ -1496,11 +1504,13 @@ end
1496
1504
 
1497
1505
  # A missing base ref has no head to lack.
1498
1506
  def behind? = Git.run(@root, "rev-parse", "-q", "--verify", ref) && !Git.ancestor?(@root, ref, @branch)
1507
+ def untracked_upstream? = Dir.exist?(@dir) && Maf::Shared::FlowFiles.tracked?(@dir) && !flow_in_ref?
1508
+ def flow_in_ref? = !Git.out(@root, "ls-tree", "--name-only", ref, "--", ".maf").empty?
1499
1509
  def ref = @ref ||= Git.base_ref(@root, @base)
1500
1510
 
1501
1511
  def run
1502
1512
  return puts("#{@branch} already contains #{ref}") unless behind?
1503
- raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
1513
+ raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless clean?
1504
1514
 
1505
1515
  merge
1506
1516
  puts "merged #{ref} into #{@branch}"
@@ -1508,6 +1518,8 @@ end
1508
1518
 
1509
1519
  private
1510
1520
 
1521
+ def clean? = Maf::Shared::FlowFiles.discard(@dir, ref) && Git.clean?(@dir)
1522
+
1511
1523
  def merge
1512
1524
  return if Git.run(@dir, "merge", "-q", "--no-edit", ref)
1513
1525
 
@@ -1945,14 +1957,18 @@ end
1945
1957
 
1946
1958
  private
1947
1959
 
1948
- def checks
1960
+ # The flow files are discarded last, only when the fast-forward can run.
1961
+ def checks = ready_checks + [[-> { clean? }, "the worktree has uncommitted changes"]]
1962
+
1963
+ def ready_checks
1949
1964
  [[-> { head == @branch }, "#{@branch} is not checked out"],
1950
- [-> { Git.clean?(@dir) }, "the worktree has uncommitted changes"],
1951
1965
  [-> { Git.run(@dir, "fetch", "-q", "origin", @base) }, "git fetch origin #{@base} failed"],
1952
1966
  [-> { Git.run(@dir, "merge-base", "--is-ancestor", "HEAD", target) },
1953
1967
  "#{@branch} has commits that #{target} lacks"]]
1954
1968
  end
1955
1969
 
1970
+ def clean? = Maf::Shared::FlowFiles.discard(@dir, target) && Git.clean?(@dir)
1971
+
1956
1972
  def merge
1957
1973
  return if rev("HEAD") == rev(target)
1958
1974
 
@@ -2183,6 +2199,7 @@ end
2183
2199
  def shift = @argv.shift
2184
2200
  def first = @argv.first
2185
2201
  def empty? = @argv.empty?
2202
+ def rest = @argv.shift(@argv.size)
2186
2203
  def join = @argv.join(" ")
2187
2204
 
2188
2205
  # Splits argv at the first literal "--" and returns the words after it,
@@ -2669,11 +2686,11 @@ end
2669
2686
  # Goal commands create, sync, land, and close goals.
2670
2687
  module GoalCommands
2671
2688
  GOAL_COMMANDS = { "add" => :goal_add, "list" => :goal_list, "show" => :goal_show, "done" => :goal_done,
2672
- "sync" => :goal_sync, "pr" => :goal_pr }.freeze
2689
+ "sync" => :goal_sync, "migrate" => :goal_migrate, "pr" => :goal_pr }.freeze
2673
2690
 
2674
2691
  def cmd_goal
2675
2692
  sub = @args.shift
2676
- abort "usage: coord goal add|list|show|sync|pr|done" unless GOAL_COMMANDS.key?(sub)
2693
+ abort "usage: coord goal add|list|show|sync|migrate|pr|done" unless GOAL_COMMANDS.key?(sub)
2677
2694
 
2678
2695
  send(GOAL_COMMANDS[sub])
2679
2696
  end
@@ -2713,6 +2730,20 @@ end
2713
2730
  event_log.append("goal-sync", @worker, goal["description"])
2714
2731
  end
2715
2732
 
2733
+ def goal_migrate = goals.open.each { |goal| migrate_goal(goal) }
2734
+
2735
+ def migrate_goal(goal)
2736
+ sync = goal_sync_for(goal)
2737
+ sync.untracked_upstream? && run_migration(sync, goal)
2738
+ end
2739
+
2740
+ def run_migration(sync, goal)
2741
+ sync.run
2742
+ event_log.append("goal-sync", @worker, goal["description"])
2743
+ rescue Error => e
2744
+ warn "coord: #{e.message}"
2745
+ end
2746
+
2716
2747
  def goal_sync_for(goal) = GoalSync.new(main_root, goal["uuid"], Git.base_branch(main_root, manifest))
2717
2748
 
2718
2749
  def goal_done
@@ -2890,6 +2921,26 @@ end
2890
2921
  end
2891
2922
  end
2892
2923
 
2924
+ # OpCommands writes a request for steward. steward validates and runs it on the host.
2925
+ module OpCommands
2926
+ OP_USAGE = "usage: coord op request OPERATION [ARGUMENT...]"
2927
+
2928
+ def cmd_op
2929
+ abort OP_USAGE unless @args.shift == "request" && (op = @args.shift)
2930
+
2931
+ id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{format("%06x", rand(0xffffff))}"
2932
+ write_op(id, "id" => id, "op" => op, "args" => @args.rest, "role" => @role, "worker" => @worker)
2933
+ puts "op #{id} requested. steward sends the result to the #{@role} inbox."
2934
+ end
2935
+
2936
+ def write_op(id, request)
2937
+ dir = File.join(paths.coord_dir, "ops", "requests")
2938
+ FileUtils.mkdir_p(dir)
2939
+ File.write(File.join(dir, "#{id}.json"), JSON.generate(request.merge("at" => Time.now.utc.iso8601)))
2940
+ event_log.append("op-request", @worker, [request["op"], *request["args"]].join(" "))
2941
+ end
2942
+ end
2943
+
2893
2944
  # Upkeep commands handle locks, the event log, worktrees, and cleanup.
2894
2945
  module UpkeepCommands
2895
2946
  def cmd_lock
@@ -3029,6 +3080,7 @@ end
3029
3080
  include GoalCommands
3030
3081
  include ReviewCommands
3031
3082
  include UpkeepCommands
3083
+ include OpCommands
3032
3084
  include CommandHelpers
3033
3085
  include Wiring
3034
3086
 
data/assets/dispatcher CHANGED
@@ -137,6 +137,7 @@ require "fileutils"
137
137
  require "optparse"
138
138
  require "rbconfig"
139
139
  require "shellwords"
140
+ require "time"
140
141
  begin
141
142
  require_relative "../lib/maf/shared/processes"
142
143
  require_relative "../lib/maf/shared/project"
@@ -690,8 +691,10 @@ module Dispatcher
690
691
 
691
692
  # Spawn runs a command in its own process group. Any end reason except a
692
693
  # normal exit kills the whole group (TERM, then KILL), which also stops the
693
- # children of an `sh -c` template. A completion signal is success even when
694
- # the process must be killed. An abort signal is failure.
694
+ # children of an `sh -c` template. An exception in the dispatcher (Ctrl+C,
695
+ # SIGHUP) also kills the group, so no agent run outlives the dispatcher.
696
+ # A completion signal is success even when the process must be killed.
697
+ # An abort signal is failure.
695
698
  module Spawn
696
699
  GRACE = 5
697
700
  TIMEOUTS = %i[timeout idle].freeze
@@ -724,6 +727,8 @@ module Dispatcher
724
727
  reason = watch.wait(waiter, stream)
725
728
  stop(pid, waiter) unless reason == :exit
726
729
  result(stream.close(watch.grace), waiter, watch, reason)
730
+ ensure
731
+ stop(pid, waiter) if waiter&.alive?
727
732
  end
728
733
 
729
734
  def self.result(output, waiter, watch, reason)
@@ -741,6 +746,71 @@ module Dispatcher
741
746
  end
742
747
  end
743
748
 
749
+ # SandboxCrashes reports, once per app, a program that a run started in the
750
+ # macOS sandbox and that crashed, or that the sandbox refused LaunchServices.
751
+ # macOS shows a crash dialog for each crash, so the user must learn the cause.
752
+ module SandboxCrashes
753
+ REPORTS = File.expand_path("~/Library/Logs/DiagnosticReports")
754
+ DENIAL = /Sandbox: (.+?)\(\d+\) deny\(\d+\) mach-lookup com\.apple\.coreservices\.launchservicesd/
755
+ PREDICATE = 'sender == "Sandbox" AND eventMessage CONTAINS "com.apple.coreservices.launchservicesd"'
756
+ NOTICE = "Dispatcher of %<worker>s: in the macOS sandbox, %<found>s. If the project needs the app, " \
757
+ "the user adds it to \"launch_services\" in .maf/sandbox.json. Then the app can start other " \
758
+ "apps outside the sandbox. An app with its own sandbox (Chrome) also needs it off (--no-sandbox), " \
759
+ "because macOS cannot start a sandbox in a sandbox. Tell the user."
760
+
761
+ @seen = []
762
+
763
+ def self.guard(harness, config, env, &block)
764
+ Maf::Shared::Sandbox.keeping_links(harness, coord_dir: config.coord_dir) { watch(config, env, &block) }
765
+ end
766
+
767
+ def self.watch(config, env, reports: REPORTS)
768
+ return yield unless Maf::Shared::Sandbox.mode == "seatbelt"
769
+
770
+ start = Time.now
771
+ yield.tap { report(config, env, crashes(start, reports), denials(start)) }
772
+ end
773
+
774
+ def self.report(config, env, crashes, denied)
775
+ crashes = crashes.except(*@seen)
776
+ denied -= @seen
777
+ return if crashes.empty? && denied.empty?
778
+
779
+ @seen.concat(crashes.keys, denied)
780
+ text = format(NOTICE, worker: config.worker, found: found(crashes, denied))
781
+ Log.line(text)
782
+ CoordCall.run(env, "escalate", text)
783
+ end
784
+
785
+ def self.found(crashes, denied)
786
+ parts = []
787
+ parts << "these apps crashed: #{crashes.map { |app, count| "#{app} (#{count}x)" }.join(", ")}" if crashes.any?
788
+ parts << "the sandbox refused LaunchServices to #{denied.join(", ")}" if denied.any?
789
+ parts.join("; ")
790
+ end
791
+
792
+ def self.crashes(start, reports)
793
+ files = Dir.glob(File.join(reports, "*.ips")).select { |path| File.mtime(path) >= start - 1 }
794
+ files.filter_map { |path| crashed_app(path, start) }.tally
795
+ end
796
+
797
+ def self.crashed_app(path, start)
798
+ body = JSON.parse(File.read(path).split("\n", 2).last.to_s)
799
+ return unless body["procPath"] && Time.parse(body["procLaunch"].to_s) >= start - 1
800
+
801
+ body["procPath"][%r{\A.*?\.app(?=/)}] || body["procPath"]
802
+ rescue SystemCallError, JSON::ParserError, ArgumentError
803
+ nil
804
+ end
805
+
806
+ def self.denials(start)
807
+ args = ["show", "--start", start.strftime("%Y-%m-%d %H:%M:%S"), "--style", "compact", "--predicate", PREDICATE]
808
+ IO.popen(["/usr/bin/log", *args], err: File::NULL, &:read).scan(DENIAL).flatten.uniq
809
+ rescue SystemCallError
810
+ []
811
+ end
812
+ end
813
+
744
814
  Events = Maf::Shared::Harness::Events
745
815
 
746
816
  # TokenUsage adds the token usage of one run to the totals of the worker in
@@ -893,9 +963,8 @@ module Dispatcher
893
963
  command = Project.manifest["verify"].to_s
894
964
  return true if command.empty? || LEADS.include?(config.role)
895
965
 
896
- sandbox = Maf::Shared::Sandbox
897
966
  run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
898
- check(sandbox.keeping_links(Harness::Generic, coord_dir: config.coord_dir, &run), command)
967
+ check(SandboxCrashes.guard(Harness::Generic, config, env, &run), command)
899
968
  end
900
969
 
901
970
  def self.sandboxed(command, config)
@@ -1096,7 +1165,7 @@ module Dispatcher
1096
1165
  end
1097
1166
 
1098
1167
  def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
1099
- def keeping_links(&block) = Maf::Shared::Sandbox.keeping_links(@harness, coord_dir: @config.coord_dir, &block)
1168
+ def keeping_links(&block) = SandboxCrashes.guard(@harness, @config, @env, &block)
1100
1169
 
1101
1170
  def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
1102
1171
 
data/assets/steward ADDED
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # steward - run the host operations that the leads request.
5
+ #
6
+ # Usage: steward [start|stop|status|daemon]
7
+ # steward # start the daemon; no-op if it already runs
8
+ # steward stop
9
+ # steward status
10
+ #
11
+ # An agent runs `coord op request OPERATION [ARGUMENT]`. coord writes the
12
+ # request to .maf/coordination/ops/requests/. The daemon runs only the
13
+ # operations in Steward::Ops::TABLE, outside the sandbox, and sends the
14
+ # result to the inbox of the role that asked.
15
+ require "json"
16
+ require "fileutils"
17
+ require "rbconfig"
18
+ require "time"
19
+ begin
20
+ require_relative "../lib/maf/shared/project"
21
+ require_relative "../lib/maf/shared/processes"
22
+ require_relative "../lib/maf/shared/sandbox"
23
+ rescue LoadError
24
+ abort "steward: the maf shared library is missing. Run maf update."
25
+ end
26
+
27
+ # Steward runs the operations of Steward::Ops::TABLE for the leads.
28
+ module Steward
29
+ COORD_DIR = File.join(".maf", "coordination")
30
+ OPS_DIR = File.join(COORD_DIR, "ops")
31
+ PID_FILE = File.join(COORD_DIR, "steward.pid")
32
+ LOG_FILE = File.join(COORD_DIR, "steward.log")
33
+ POLL = 3
34
+
35
+ COMMANDS = { "stop" => :stop, "status" => :status, "daemon" => :loop_forever }.freeze
36
+
37
+ def self.run(argv)
38
+ Dir.chdir(Maf::Shared::Project.root)
39
+ return Approvals.run(argv) if Approvals::COMMANDS.include?(argv.first)
40
+
41
+ Daemon.public_send(COMMANDS.fetch(argv.first, :start))
42
+ end
43
+
44
+ def self.coord = [RbConfig.ruby, File.join(".maf", "bin", "coord")]
45
+
46
+ # Daemon starts, stops, and runs the poll loop. It exits when the pid file is gone.
47
+ module Daemon
48
+ NESTED = "steward: this process runs in the sandbox. Start steward from a terminal."
49
+ LOG = { out: LOG_FILE, err: LOG_FILE, pgroup: true }.freeze
50
+
51
+ def self.start
52
+ abort NESTED if inside_sandbox?
53
+ return puts("steward: already running (pid #{Pid.read})") if Pid.running?
54
+
55
+ spawn_daemon
56
+ end
57
+
58
+ def self.inside_sandbox? = Maf::Shared::Sandbox::Seatbelt.inside? || ENV["MAF_BWRAP"] == "1"
59
+
60
+ def self.spawn_daemon
61
+ FileUtils.mkdir_p(COORD_DIR)
62
+ pid = Process.spawn(RbConfig.ruby, File.expand_path(__FILE__), "daemon", **LOG)
63
+ Process.detach(pid) && Pid.write(pid)
64
+ puts "steward: started (pid #{pid}), log at #{LOG_FILE}"
65
+ end
66
+
67
+ def self.stop
68
+ return puts("steward: not running") unless Pid.running?
69
+
70
+ Process.kill("TERM", Pid.read)
71
+ Pid.clear
72
+ puts "steward: stopped"
73
+ end
74
+
75
+ def self.status = puts(Pid.running? ? "steward: running (pid #{Pid.read})" : "steward: not running")
76
+
77
+ def self.loop_forever
78
+ Pid.write(Process.pid)
79
+ Signal.trap("TERM") { exit }
80
+ tick while File.exist?(PID_FILE)
81
+ end
82
+
83
+ def self.tick
84
+ Queue.new(OPS_DIR).each_request { |request| Handler.new(request).run }
85
+ sleep POLL
86
+ end
87
+ end
88
+
89
+ # Pid reads and writes the pid file. A non-positive pid is not a process.
90
+ module Pid
91
+ def self.running? = read.positive? && Maf::Shared::Processes.alive?(read)
92
+ def self.read = File.exist?(PID_FILE) ? File.read(PID_FILE).to_i : 0
93
+ def self.write(pid) = File.write(PID_FILE, pid)
94
+ def self.clear = FileUtils.rm_f(PID_FILE)
95
+ end
96
+
97
+ # Queue moves each request file to done/ before the handler runs it, so a
98
+ # restart of the daemon never runs a request twice.
99
+ class Queue
100
+ def initialize(dir)
101
+ @dir = dir
102
+ end
103
+
104
+ def each_request
105
+ Dir.glob(File.join(@dir, "requests", "*.json")).each do |path|
106
+ request = take(path)
107
+ yield request if request
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def take(path)
114
+ done = done_path(path)
115
+ File.rename(path, done)
116
+ JSON.parse(File.read(done)).merge("path" => done)
117
+ rescue JSON::ParserError, SystemCallError
118
+ nil
119
+ end
120
+
121
+ def done_path(path)
122
+ File.join(@dir, "done", File.basename(path)).tap { |done| FileUtils.mkdir_p(File.dirname(done)) }
123
+ end
124
+ end
125
+
126
+ # Ops holds the fixed list of operations. Each one maps validated
127
+ # arguments to an argument list. No request text reaches a shell.
128
+ module Ops
129
+ TABLE = {
130
+ "worker-restart" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "worker", "restart", w["id"]] } },
131
+ "worker-retire" => { args: %i[worker], level: "auto", argv: ->(w) { ["maf", "retire", w["id"]] } },
132
+ "worktree-sync" => { args: %i[worker], level: "auto",
133
+ argv: ->(w) { [*Steward.coord, "worktree", w["role"], w["worker_id"]] } },
134
+ "goal-sync" => { args: %i[goal], level: "auto", argv: ->(g) { [*Steward.coord, "goal", "sync", g] } },
135
+ "runtime-restore" => { args: [], level: "auto", argv: -> { %w[maf update] } },
136
+ "worker-start" => { args: %i[worker], level: "approve", argv: ->(w) { ["maf", "worker", "start", w["id"]] } },
137
+ "worker-model" => { args: %i[worker model], level: "approve", argv: ->(w, m) { Ops.replace(w, m) } },
138
+ "bundle-install" => { args: [], level: "approve",
139
+ argv: -> { Validate.lockfile && [{ "BUNDLE_FROZEN" => "true" }, "bundle", "install"] } }
140
+ }.freeze
141
+
142
+ def self.spec(name) = TABLE.fetch(name) { raise Refused, "unknown operation #{name}. Known: #{known}" }
143
+ def self.known = TABLE.keys.join(" ")
144
+ def self.level(name) = Levels.read.fetch(name, spec(name)[:level])
145
+
146
+ def self.argv(name, args)
147
+ kinds = spec(name)[:args]
148
+ raise Refused, "#{name} takes #{kinds.size} argument(s): #{kinds.join(" ")}" unless args.size == kinds.size
149
+
150
+ spec(name)[:argv].call(*kinds.zip(args).map { |kind, arg| Validate.public_send(kind, arg) })
151
+ end
152
+
153
+ def self.replace(worker, model)
154
+ id = "#{worker["role"]}_#{worker["worker_id"]}"
155
+ ["maf", "prepare", worker["harness"], id, "--model", model, "--replace", id,
156
+ worker["dispatch"] ? "--dispatch" : "--interactive"]
157
+ end
158
+ end
159
+
160
+ # Levels reads the key ops of .maf/sandbox.json. The sandbox protects that file.
161
+ module Levels
162
+ VALUES = %w[auto approve off].freeze
163
+
164
+ def self.read
165
+ data = JSON.parse(File.read(File.join(".maf", "sandbox.json"))).fetch("ops", {})
166
+ data.is_a?(Hash) ? data.select { |_, level| VALUES.include?(level) } : {}
167
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
168
+ {}
169
+ end
170
+ end
171
+
172
+ class Refused < StandardError; end
173
+
174
+ # Validate checks each argument against the project state, not against the request.
175
+ module Validate
176
+ WORKTREES = File.join(".maf", "worktrees")
177
+
178
+ def self.model(arg)
179
+ return arg if arg.to_s.match?(%r{\A[A-Za-z0-9][A-Za-z0-9._:/@-]{0,99}\z})
180
+
181
+ raise Refused, "a model is letters, digits, and . _ : / @ -"
182
+ end
183
+
184
+ def self.lockfile = File.exist?("Gemfile.lock") || raise(Refused, "the project has no Gemfile.lock")
185
+
186
+ def self.worker(arg)
187
+ entry = workers.fetch(arg.to_s) { raise Refused, "no worker #{arg}" }
188
+ raise Refused, "worker #{arg} is outside #{WORKTREES}" unless inside_worktrees?(entry["dir"].to_s)
189
+
190
+ entry.merge("id" => arg.to_s)
191
+ end
192
+
193
+ def self.goal(arg)
194
+ raise Refused, "a goal id is 8 hex characters" unless arg.to_s.match?(/\A\h{8}\z/)
195
+ raise Refused, "no goal branch goal/#{arg}" unless system("git", "rev-parse", "-q", "--verify",
196
+ "refs/heads/goal/#{arg}", out: File::NULL)
197
+
198
+ arg
199
+ end
200
+
201
+ def self.workers
202
+ JSON.parse(File.read(File.join(COORD_DIR, "workers.json")))
203
+ rescue JSON::ParserError, SystemCallError
204
+ {}
205
+ end
206
+
207
+ def self.inside_worktrees?(dir)
208
+ root = File.realpath(WORKTREES)
209
+ File.realpath(dir).start_with?("#{root}/")
210
+ rescue SystemCallError
211
+ false
212
+ end
213
+ end
214
+
215
+ # Limit allows each operation 3 runs per hour for one argument. A loop of
216
+ # restarts then stops, and the project manager hears about it.
217
+ class Limit
218
+ MAX = 3
219
+ WINDOW = 3600
220
+
221
+ def initialize(path) = @path = path
222
+
223
+ def allow?(key) = recent.count(key) < MAX
224
+ def record(key) = File.open(@path, "a") { |file| file.puts(JSON.generate("at" => Time.now.to_i, "key" => key)) }
225
+
226
+ private
227
+
228
+ def recent
229
+ return [] unless File.exist?(@path)
230
+
231
+ File.readlines(@path).filter_map { |line| entry(line) }
232
+ end
233
+
234
+ def entry(line)
235
+ data = JSON.parse(line)
236
+ data["key"] if Time.now.to_i - data["at"].to_i < WINDOW
237
+ rescue JSON::ParserError
238
+ nil
239
+ end
240
+ end
241
+
242
+ # Handler runs one request and reports the result to the role that asked.
243
+ class Handler
244
+ TIMEOUT = 2000
245
+
246
+ def initialize(request, runner: Runner, limit: Limit.new(File.join(OPS_DIR, "history.jsonl")), approved: false)
247
+ @request = request
248
+ @runner = runner
249
+ @limit = limit
250
+ @approved = approved
251
+ end
252
+
253
+ def run
254
+ route(Ops.argv(name, args), Ops.level(name))
255
+ rescue Refused => e
256
+ report(false, "refused: #{e.message}")
257
+ end
258
+
259
+ def route(argv, level)
260
+ raise Refused, "#{name} is off in .maf/sandbox.json" if level == "off"
261
+ return park if level == "approve" && !@approved
262
+
263
+ execute(argv)
264
+ end
265
+
266
+ def execute(argv)
267
+ return over_limit unless @limit.allow?(key)
268
+
269
+ @limit.record(key)
270
+ report(*@runner.call(argv, TIMEOUT))
271
+ end
272
+
273
+ private
274
+
275
+ def name = @request["op"].to_s
276
+ def args = Array(@request["args"]).map(&:to_s)
277
+ def key = [name, *args].join(" ")
278
+
279
+ def park
280
+ File.rename(@request["path"], Approvals.pending_path(@request["id"]))
281
+ text = "op #{@request["id"]} #{key}: waits for the user. The user runs: maf op approve #{@request["id"]}"
282
+ Reply.event(text)
283
+ [@request["role"].to_s, "project-manager"].uniq.each { |role| Reply.deliver(role, text) }
284
+ end
285
+
286
+ def over_limit
287
+ report(false, "refused: #{key} ran #{Limit::MAX} times in the last hour")
288
+ Reply.deliver("project-manager", "ESCALATION from steward: #{key} ran #{Limit::MAX} times in an hour. " \
289
+ "Check the cause before a new request.")
290
+ end
291
+
292
+ def report(success, output)
293
+ text = "op #{@request["id"]} #{key}: #{success ? "done" : "failed"}. #{output.to_s.lines.last(5).join.strip}"
294
+ File.write(@request["path"], JSON.generate(@request.merge("ok" => success, "output" => output.to_s)))
295
+ Reply.event(text)
296
+ Reply.deliver(@request["role"].to_s, text)
297
+ end
298
+ end
299
+
300
+ # Approvals lists, approves, and denies the waiting requests. The user runs
301
+ # them through maf op, outside the sandbox.
302
+ module Approvals
303
+ COMMANDS = %w[list approve deny].freeze
304
+ ID = /\A\d{8}T\d{6}Z-\h{6}\z/
305
+ REFUSED = "steward: only the user approves, from a terminal outside the sandbox."
306
+
307
+ def self.run(argv)
308
+ abort REFUSED if Daemon.inside_sandbox? || !ENV["COORD_ROLE"].to_s.empty?
309
+ argv.first == "list" ? list : public_send(argv.first, argv[1].to_s)
310
+ rescue Refused => e
311
+ abort "steward: #{e.message}"
312
+ end
313
+
314
+ def self.list
315
+ files = Dir.glob(File.join(OPS_DIR, "pending", "*.json"))
316
+ puts "No request waits." if files.empty?
317
+ files.each { |path| puts line(JSON.parse(File.read(path))) }
318
+ end
319
+
320
+ def self.line(req) = [req["id"], [req["op"], *req["args"]].join(" "), "from #{req["worker"]}"].join(" ")
321
+
322
+ def self.approve(id)
323
+ done = File.join(OPS_DIR, "done", "#{id}.json")
324
+ File.rename(waiting(id), done)
325
+ Handler.new(JSON.parse(File.read(done)).merge("path" => done), approved: true).run
326
+ puts File.read(done)
327
+ end
328
+
329
+ def self.deny(id)
330
+ request = JSON.parse(File.read(waiting(id)))
331
+ File.delete(waiting(id))
332
+ notify(request, "op #{id} #{[request["op"], *request["args"]].join(" ")}: denied by the user.")
333
+ end
334
+
335
+ def self.notify(request, text)
336
+ Reply.event(text)
337
+ Reply.deliver(request["role"].to_s, text)
338
+ puts text
339
+ end
340
+
341
+ def self.waiting(id) = pending_path(id).tap { |path| File.exist?(path) || raise(Refused, "no request #{id}") }
342
+
343
+ def self.pending_path(id)
344
+ raise Refused, "a request id looks like 20261021T120000Z-a1b2c3" unless id.to_s.match?(ID)
345
+
346
+ File.join(OPS_DIR, "pending", "#{id}.json").tap { |path| FileUtils.mkdir_p(File.dirname(path)) }
347
+ end
348
+ end
349
+
350
+ # Runner runs an argument list without a shell and stops it at the timeout.
351
+ module Runner
352
+ def self.call(argv, timeout)
353
+ reader, writer = IO.pipe
354
+ pid = start(argv, writer)
355
+ writer.close
356
+ pid ? collect(pid, reader, timeout) : [false, "cannot start #{argv.first}"]
357
+ end
358
+
359
+ def self.start(argv, writer)
360
+ Process.spawn(*argv, out: writer, err: writer, pgroup: true)
361
+ rescue SystemCallError
362
+ nil
363
+ end
364
+
365
+ def self.collect(pid, reader, timeout)
366
+ output = Thread.new { reader.read }
367
+ [succeeded?(pid, timeout), output.value]
368
+ end
369
+
370
+ def self.succeeded?(pid, timeout)
371
+ deadline = Time.now + timeout
372
+ sleep 0.2 until (status = Process.waitpid2(pid, Process::WNOHANG)&.last) || Time.now > deadline
373
+ kill(pid) unless status
374
+ status&.success? || false
375
+ end
376
+
377
+ def self.kill(pid)
378
+ Process.kill("KILL", -pid)
379
+ Process.wait(pid)
380
+ end
381
+ end
382
+
383
+ # Reply writes the event log line and the inbox message.
384
+ module Reply
385
+ def self.event(text)
386
+ line = [Time.now.utc.iso8601, "op", "steward", text.tr("\t\n", " ")].join("\t")
387
+ File.open(File.join(COORD_DIR, "events.log"), "a") { |file| file.puts(line) }
388
+ end
389
+
390
+ def self.deliver(role, text)
391
+ return if role.empty?
392
+
393
+ system(*Steward.coord, "msg", "--from", "steward", role, text, out: File::NULL, err: File::NULL)
394
+ end
395
+ end
396
+ end
397
+
398
+ Steward.run(ARGV) if __FILE__ == $PROGRAM_NAME
@@ -8,8 +8,8 @@ module Maf
8
8
  # Each step is [planner, method]. The order is the order of the output.
9
9
  PLAN_STEPS = [
10
10
  %i[layout dirs], %i[scripts shared], %i[scripts coord], %i[scripts dispatcher], %i[scripts vault],
11
- %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc], %i[text exclude],
12
- %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
11
+ %i[scripts steward], %i[scripts dashboard], %i[scripts analyst], %i[scripts env], %i[text taskrc],
12
+ %i[text exclude], %i[scripts hooks], %i[scripts opencode_plugin], %i[claude plan], %i[git commit_guard],
13
13
  %i[scripts doc_graph], %i[git doc_graph_hooks]
14
14
  ].freeze
15
15
  PLANNERS = { layout: LayoutPlanner, scripts: ScriptPlanner, text: TextPlanner, claude: ClaudeSettings,
@@ -46,6 +46,10 @@ module Maf
46
46
  script("vault", VAULT_SIGNATURE)
47
47
  end
48
48
 
49
+ def steward
50
+ script("steward", STEWARD_SIGNATURE)
51
+ end
52
+
49
53
  # env.sh puts .maf/bin on PATH. A worker runs `source .maf/env.sh`.
50
54
  def env
51
55
  script("env.sh", ENV_SIGNATURE, @project.path(MAF_DIR, "env.sh"))
data/lib/maf/bootstrap.rb CHANGED
@@ -29,6 +29,7 @@ module Maf
29
29
  SETUP_AGENT_SIGNATURE = "setup_agent - create a worktree for one agent and launch its harness session."
30
30
  DISPATCHER_SIGNATURE = "dispatcher - task board and inbox monitor that starts one-shot agents."
31
31
  VAULT_SIGNATURE = "vault - shared knowledge base watcher (graphify + Obsidian + MCP)."
32
+ STEWARD_SIGNATURE = "steward - run the host operations that the leads request."
32
33
  DASHBOARD_SIGNATURE = "dashboard - local observability web UI for multi-agent coordination."
33
34
  DASHBOARD_PAGE_SIGNATURE = "dashboard.html - page of the maf dashboard."
34
35
  ANALYST_SIGNATURE = "analyst - ask a small model for token hints about one dispatched worker."
data/lib/maf/cli.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "setup_agent"
11
11
  require_relative "menu"
12
12
  require_relative "team"
13
13
  require_relative "team_command"
14
+ require_relative "op_command"
14
15
  require_relative "worker_control"
15
16
  require_relative "worker_list"
16
17
  require_relative "untrack"
@@ -57,6 +58,7 @@ module Maf
57
58
  "[--max-session-runs N] [--cache-window S] list the running workers, or control one worker; " \
58
59
  "start and restart save the session limits of the role",
59
60
  "team" => "[set --max N --allow HARNESS[:MODEL]] show the team, or set its budget",
61
+ "op" => "list | approve ID | deny ID the host operations that wait for you (steward)",
60
62
  "uninstall" => "[--check] [--yes] [--force] remove the flow from the project",
61
63
  "migrate" => "[--check] [--yes] move an old-layout install into .maf/",
62
64
  "untrack" => "[--check] [--yes] remove the flow from git; the files stay (then commit)",
@@ -118,7 +120,7 @@ module Maf
118
120
  def run_remove = Maf.flow(*AgentArgs.convert(@args, "--remove"))
119
121
  def run_update
120
122
  Maf.flow(*@args)
121
- Untrack.warn_tracked(SetupAgent::Project.root)
123
+ Untrack.after_update(SetupAgent::Project.root)
122
124
  end
123
125
  def run_roles = Maf.flow("--list-roles")
124
126
 
@@ -131,17 +133,16 @@ module Maf
131
133
  def run_start = @args.empty? ? SetupAgent.run_here : SetupAgent.run(@args)
132
134
  def run_prepare = Prepare.new(@args).run
133
135
  def run_team = TeamCommand.new(@args, SetupAgent::Project.root).run
136
+ def run_op = OpCommand.new(@args, SetupAgent::Project.root).run
134
137
  def run_worker
135
138
  return WorkerList.new(SetupAgent::Project.root).run if @args.first == "list"
136
139
 
137
- spec = @args[1] || abort(worker_usage)
140
+ spec = @args[1] || abort(WorkerControl::USAGE)
138
141
  limits = RoleLimits.parse(@args)
139
142
  WorkerControl.new(SetupAgent::Project.root, spec, force: @args.include?("--force"), limits: limits)
140
143
  .run(@args[0].to_s)
141
144
  end
142
145
 
143
- def worker_usage = "usage: maf worker list | #{WorkerControl::ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
144
- "[--max-context N] [--max-session-runs N] [--cache-window S]"
145
146
  def run_retire = Retire.new(SetupAgent::Project.root, @args.first || abort("usage: maf retire ROLE[_WORKER]")).run
146
147
  def run_uninstall = Uninstall::Runner.new(["--project", Dir.pwd, *@args]).run
147
148
  def run_menu = Menu.new.run
@@ -149,6 +149,18 @@ module Maf
149
149
  A goal pull request uses a merge commit, never a squash.
150
150
  TEXT
151
151
 
152
+ HOST_OPS_RULE = <<~TEXT.strip
153
+ - The sandbox blocks some repairs. Request them from steward: `coord op request <operation> [<argument>]`.
154
+ `worker-restart <worker>`, `worker-retire <worker>`: a worker that is stuck or not seen.
155
+ `worktree-sync <worker>`: a worker branch behind the base branch, or flow files that block it.
156
+ `goal-sync <goal-short-id>`: `coord goal sync` fails with "Operation not permitted".
157
+ `runtime-restore`: the files of `.maf/` are missing in a worktree.
158
+ These wait for the user: `worker-start <worker>`, `worker-model <worker> <model>`,
159
+ `bundle-install` (gems are missing after a Gemfile.lock change).
160
+ steward sends the result to your inbox. Tell the user only if steward refuses or the operation fails.
161
+ If an operation waits, tell the user the `maf op approve <id>` command once. Do not request it again.
162
+ TEXT
163
+
152
164
  ARCHITECT_RULES = <<~TEXT.strip
153
165
  - Never edit files directly. Dispatch work.
154
166
  - Change a goal branch only with `coord land` and `coord goal sync`. Never run `git merge` or `git commit` on it.
@@ -164,6 +176,7 @@ module Maf
164
176
  - Before you revise an artifact that a reviewer read, copy it to `<name>.r<round>.md`. Name both files
165
177
  in the review task. The reviewer reads `diff -u` of the two files and checks the open findings only.
166
178
  #{GRAPH_RULE}
179
+ #{HOST_OPS_RULE}
167
180
  TEXT
168
181
 
169
182
  # The architect takes goals from the project manager when that role exists,
@@ -259,6 +272,7 @@ module Maf
259
272
  - Do not read the project docs in full. Each file that you read stays in your context for the whole session.
260
273
  Ask the architect, or start one subagent that returns a summary of at most 300 words.
261
274
  - If no report has arrived yet, tell the user and check again with `coord inbox project-manager`.
275
+ #{HOST_OPS_RULE}
262
276
  #{STE_RULE}
263
277
  #{SUBAGENT_RULE}
264
278
  LOOP
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # op_command.rb - list, approve, or deny the host operations that wait for the user.
4
+ #
5
+ # maf op list | approve ID | deny ID
6
+ require "rbconfig"
7
+ require_relative "team"
8
+
9
+ module Maf
10
+ # OpCommand passes the command to .maf/bin/steward. Only the user runs it:
11
+ # an approved operation runs on the host, outside the sandbox.
12
+ class OpCommand
13
+ USAGE = "usage: maf op list | approve ID | deny ID"
14
+ AGENT = "maf: only the user approves host operations. Ask the user to run this command in a terminal."
15
+
16
+ def initialize(argv, root)
17
+ @argv = argv
18
+ @root = root
19
+ end
20
+
21
+ def run
22
+ abort AGENT if Team.agent?
23
+ abort USAGE unless %w[list approve deny].include?(@argv.first)
24
+
25
+ exec(RbConfig.ruby, File.join(@root, ".maf", "bin", "steward"), *@argv)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # session_records.rb - the live sessions that maf start registered.
4
+ require "json"
5
+ require "time"
6
+ require_relative "shared/processes"
7
+
8
+ module Maf
9
+ # SessionRecords reads .maf/coordination/sessions/*.maf.json. The session
10
+ # guard writes one record for each maf start. A record is live while its pid
11
+ # runs and the process started before the record was written, so a reused
12
+ # pid does not count.
13
+ class SessionRecords
14
+ LSTART = "%a %b %d %H:%M:%S %Y %z"
15
+
16
+ def initialize(coord_dir)
17
+ @dir = File.join(coord_dir, "sessions")
18
+ end
19
+
20
+ def live = Dir.glob(File.join(@dir, "*.maf.json")).filter_map { |path| entry(path) }
21
+ def find(worker) = live.find { |record| record["worker"] == worker }
22
+
23
+ private
24
+
25
+ def entry(path)
26
+ record = JSON.parse(File.read(path))
27
+ started = Shared::Processes.started_at(record["pid"])
28
+ record.merge("started" => started) if live?(record["pid"], started, File.mtime(path))
29
+ rescue JSON::ParserError, SystemCallError
30
+ nil
31
+ end
32
+
33
+ def live?(pid, started, registered)
34
+ Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
35
+ rescue ArgumentError
36
+ false
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Maf
6
+ module SetupAgent
7
+ # Steward starts the steward daemon of the project. It runs before the
8
+ # agent variables are set, so steward does not run as an agent.
9
+ module Steward
10
+ def self.ensure(root)
11
+ script = File.join(root, ".maf", "bin", "steward")
12
+ return if ENV["STEWARD_SKIP"] || !File.exist?(script)
13
+
14
+ system(RbConfig.ruby, script, chdir: root, out: File::NULL, err: File::NULL)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -81,6 +81,7 @@ module Maf
81
81
  end
82
82
 
83
83
  def self.launch(args, _worktree)
84
+ Steward.ensure(Project.root)
84
85
  identify(args)
85
86
  return Dispatch.launch(args) if args.dispatch
86
87
 
@@ -106,4 +107,5 @@ require_relative "setup_agent/worktree"
106
107
  require_relative "setup_agent/launcher"
107
108
  require_relative "setup_agent/project"
108
109
  require_relative "setup_agent/runtime_hooks"
110
+ require_relative "setup_agent/steward"
109
111
  require_relative "../../assets/harness-hooks/session-guard"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # maf shared library - code that the maf CLI and the scripts in .maf/bin share.
4
+ # The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
5
+ # Use the Ruby stdlib only: a script loads this file without a gem bundle.
6
+ require_relative "harness"
7
+
8
+ module Maf
9
+ module Shared
10
+ # FlowFiles finds the files of the flow in a worktree. A branch from before
11
+ # maf untrack still tracks them, and maf rewrites them in each worktree.
12
+ module FlowFiles
13
+ def self.path?(path) = path.start_with?(".maf/") || Harness.all.flat_map(&:runtime_files).include?(path)
14
+
15
+ # Only a change that a merge of TARGET deletes is discarded. Returns true when no change is left.
16
+ def self.discard(dir, target)
17
+ paths = changed(dir)
18
+ return true if paths.empty?
19
+ return false unless discardable?(dir, target, paths)
20
+
21
+ system("git", "-C", dir, "checkout", "-q", "HEAD", "--", *paths)
22
+ end
23
+
24
+ def self.discardable?(dir, target, paths)
25
+ paths.all? { |path| path?(path) } && git(dir, "ls-tree", "-r", "--name-only", target, "--", *paths).empty?
26
+ end
27
+
28
+ def self.tracked?(dir) = !git(dir, "ls-files", "--", ".maf").empty?
29
+ def self.changed(dir) = git(dir, "diff", "--name-only", "HEAD")
30
+ def self.git(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:readlines).map(&:chomp)
31
+ end
32
+ end
33
+ end
@@ -24,11 +24,12 @@ module Maf
24
24
 
25
25
  attr_reader :root
26
26
 
27
- def initialize(harnesses, root, coord_dir, extra)
27
+ def initialize(harnesses, root, coord_dir, policy)
28
28
  @harness = HarnessFiles.new(harnesses, ->(paths) { real(paths) })
29
29
  @root = root
30
30
  @coord_dir = coord_dir || File.join(root, ".maf", "coordination")
31
- @extra = extra.map { |path| File.expand_path(path, root) }
31
+ @extra = Array(policy["write"]).map { |path| File.expand_path(path, root) }
32
+ @app_paths = Array(policy["launch_services"]).map { |path| File.expand_path(path, root) }
32
33
  end
33
34
 
34
35
  def dirs = @dirs ||= real([@root, @coord_dir, *@extra, ENV.fetch("TMPDIR", nil), *COMMON])
@@ -45,6 +46,13 @@ module Maf
45
46
  end
46
47
 
47
48
  def existing = dirs.select { |dir| File.exist?(dir) }
49
+
50
+ def apps = @apps ||= real(@app_paths).select { |path| File.exist?(path) }.each { |path| fixed_app!(path) }
51
+
52
+ def fixed_app!(path)
53
+ writable?(path) && abort("maf: #{path} in launch_services is in a folder that the agent can change.")
54
+ end
55
+
48
56
  def sockets = real([@root, @coord_dir])
49
57
 
50
58
  def protected
@@ -18,6 +18,7 @@ module Maf
18
18
  TMP_PREFIX = "maf-sandbox-"
19
19
  SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
20
20
  "(deny appleevent-send)\n"
21
+ LAUNCH_SERVICES = "com.apple.coreservices.launchservicesd"
21
22
 
22
23
  def self.wrap(cmd, paths)
23
24
  tmp = private_tmp(paths.root)
@@ -52,7 +53,23 @@ module Maf
52
53
 
53
54
  def self.profile(paths, tmp)
54
55
  "(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
55
- "#{sockets([*paths.sockets, tmp])}#{SERVICES}"
56
+ "#{sockets([*paths.sockets, tmp])}#{SERVICES}#{launch_services(paths.apps)}"
57
+ end
58
+
59
+ # A later rule wins, so this exception comes after the denial.
60
+ def self.launch_services(apps)
61
+ programs = apps.flat_map { |app| programs(app) }.uniq
62
+ return "" if programs.empty?
63
+
64
+ filters = programs.map { |path| "(process-path #{quote(path)})" }
65
+ "(allow mach-lookup (require-all (global-name \"#{LAUNCH_SERVICES}\")\n " \
66
+ "(require-any #{filters.join("\n ")})))\n"
67
+ end
68
+
69
+ def self.programs(app)
70
+ return [app] unless File.directory?(app)
71
+
72
+ Dir.glob(File.join(app, "**", "Contents", "MacOS", "*")).select { |path| File.file?(path) }
56
73
  end
57
74
 
58
75
  def self.writes(paths)
@@ -74,7 +74,7 @@ module Maf
74
74
 
75
75
  def self.paths(harness, policy, coord_dir)
76
76
  protect_policy
77
- Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, Array(policy["write"]))
77
+ Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, policy)
78
78
  end
79
79
 
80
80
  def self.project_harnesses = Project.manifest.fetch("agents", []).filter_map { |a| Harness[a["harness"]] }
@@ -9,6 +9,7 @@ module Maf
9
9
  ".maf/bin/dashboard.html" => Bootstrap::DASHBOARD_PAGE_SIGNATURE,
10
10
  ".maf/bin/analyst" => Bootstrap::ANALYST_SIGNATURE,
11
11
  ".maf/bin/vault" => Bootstrap::VAULT_SIGNATURE,
12
+ ".maf/bin/steward" => Bootstrap::STEWARD_SIGNATURE,
12
13
  ".maf/bin/doc-graph-refresh" => Bootstrap::DOC_GRAPH_SIGNATURE,
13
14
  ".maf/env.sh" => Bootstrap::ENV_SIGNATURE,
14
15
  Bootstrap::OPENCODE_PLUGIN => Bootstrap::OPENCODE_BOARD_WATCH_SIGNATURE }.freeze
data/lib/maf/untrack.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # untrack.rb - move an install that git tracks to the local layout.
4
4
  require "json"
5
+ require "rbconfig"
5
6
  require_relative "uninstall"
6
7
 
7
8
  module Maf
@@ -23,8 +24,19 @@ module Maf
23
24
 
24
25
  def self.warn_tracked(root) = (warn TRACKED unless new(root, []).tracked.empty?)
25
26
 
27
+ def self.after_update(root)
28
+ warn_tracked(root)
29
+ migrate_goals(root)
30
+ end
31
+
32
+ def self.migrate_goals(root)
33
+ coord = File.join(root, ".maf", "bin", "coord")
34
+ system(RbConfig.ruby, coord, "goal", "migrate", chdir: root) if File.exist?(coord)
35
+ end
36
+
26
37
  DONE = ["", "The flow left git. Review the change with `git status`, then commit it.",
27
38
  "A branch that still tracks the flow files can delete them in a checkout or a merge.",
39
+ "After you push the commit, maf start fast-forwards each worker, and maf update syncs each goal.",
28
40
  "maf keeps a backup of the flow config in .git/maf/backup. Run `maf restore` to put it back."].freeze
29
41
 
30
42
  def run
data/lib/maf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maf
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -8,6 +8,7 @@ require_relative "workers"
8
8
  require_relative "retire"
9
9
  require_relative "role_limits"
10
10
  require_relative "shared/processes"
11
+ require_relative "session_records"
11
12
 
12
13
  module Maf
13
14
  # WorkerControl stops, starts, or restarts one worker. Each action is
@@ -17,8 +18,12 @@ module Maf
17
18
  # only when the session is idle. maf then prints the start command.
18
19
  class WorkerControl
19
20
  ACTIONS = %w[status stop start restart].freeze
21
+ USAGE = "usage: maf worker list | #{ACTIONS.join("|")} ROLE[_WORKER] [--force] " \
22
+ "[--max-context N] [--max-session-runs N] [--cache-window S]".freeze
20
23
  MAF = File.expand_path("../../exe/maf", __dir__)
21
24
  IDLE = 60
25
+ STUCK = "maf: %<worker>s did not stop after TERM. Its terminal may be closed. " \
26
+ "Stop it with: pkill -9 -P %<pid>s; kill -9 %<pid>s"
22
27
 
23
28
  # force stops an interactive session also when maf cannot tell if it is idle.
24
29
  # limits are session limits of the role. start and restart save them first.
@@ -83,8 +88,12 @@ module Maf
83
88
  abort "maf: #{@worker} is in a turn. Try again when the session is idle." if busy?
84
89
  abort "maf: maf cannot tell if #{@worker} is idle. Stop it in its terminal, or add --force." if unknown?
85
90
 
91
+ terminate_session
92
+ end
93
+
94
+ def terminate_session
86
95
  RunningProcesses.terminate(pid)
87
- RunningProcesses.wait_for_exit(pid, timeout: 10)
96
+ abort format(STUCK, worker: @worker, pid: pid) unless RunningProcesses.wait_for_exit(pid, timeout: 10)
88
97
  puts "Stopped #{@worker}. To start it again: #{start_hint}"
89
98
  end
90
99
 
@@ -95,7 +104,8 @@ module Maf
95
104
 
96
105
  def start_hint = "cd #{@entry["dir"]} && maf start"
97
106
  def coord_dir = File.join(@root, ".maf", "coordination")
98
- def presence = read(File.join(coord_dir, "presence", "#{@worker}.json"))
107
+ def presence = @presence ||= SessionRecords.new(coord_dir).find(@worker) || presence_file
108
+ def presence_file = read(File.join(coord_dir, "presence", "#{@worker}.json"))
99
109
  def pid = presence["pid"].to_i
100
110
  def live? = Shared::Processes.live?(presence)
101
111
  def ours? = Shared::Processes.same?(pid, presence["started"])
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require_relative "workers"
7
7
  require_relative "shared/processes"
8
+ require_relative "session_records"
8
9
 
9
10
  module Maf
10
11
  # WorkerList shows each running worker process of the project. A worker
@@ -12,7 +13,6 @@ module Maf
12
13
  class WorkerList
13
14
  Row = Struct.new(:worker, :role, :harness, :mode, :pid, :started)
14
15
  HEADER = %w[WORKER ROLE HARNESS MODE PID STARTED].freeze
15
- LSTART = "%a %b %d %H:%M:%S %Y %z"
16
16
 
17
17
  def initialize(root)
18
18
  @root = root
@@ -31,18 +31,7 @@ module Maf
31
31
  def coord_dir = File.join(@root, ".maf", "coordination")
32
32
  def records(dir, pattern) = Dir.glob(File.join(coord_dir, dir, pattern)).map { |path| [path, read(path)] }
33
33
 
34
- def sessions
35
- records("sessions", "*.maf.json").filter_map do |path, record|
36
- row(record, "session") if session_live?(record["pid"], File.mtime(path))
37
- end
38
- end
39
-
40
- def session_live?(pid, registered)
41
- started = Shared::Processes.started_at(pid)
42
- Shared::Processes.alive?(pid) && !started.empty? && Time.strptime("#{started} +0000", LSTART) <= registered
43
- rescue ArgumentError
44
- false
45
- end
34
+ def sessions = SessionRecords.new(coord_dir).live.map { |record| row(record, "session") }
46
35
 
47
36
  def dispatchers
48
37
  records("presence", "*.json").filter_map do |_, entry|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominik Alberski
@@ -53,6 +53,7 @@ files:
53
53
  - assets/harness-hooks/next-task.rb
54
54
  - assets/harness-hooks/session-guard.rb
55
55
  - assets/lima/maf.yaml.erb
56
+ - assets/steward
56
57
  - assets/taskrc.append
57
58
  - assets/vault
58
59
  - assets/worktree-env.example.rb
@@ -115,10 +116,12 @@ files:
115
116
  - lib/maf/migrate/role_files.rb
116
117
  - lib/maf/migrate/runner.rb
117
118
  - lib/maf/migrate/worktrees.rb
119
+ - lib/maf/op_command.rb
118
120
  - lib/maf/prompt.rb
119
121
  - lib/maf/restore.rb
120
122
  - lib/maf/retire.rb
121
123
  - lib/maf/role_limits.rb
124
+ - lib/maf/session_records.rb
122
125
  - lib/maf/setup_agent.rb
123
126
  - lib/maf/setup_agent/args.rb
124
127
  - lib/maf/setup_agent/dispatch.rb
@@ -128,7 +131,9 @@ files:
128
131
  - lib/maf/setup_agent/pty_relay.rb
129
132
  - lib/maf/setup_agent/role_file.rb
130
133
  - lib/maf/setup_agent/runtime_hooks.rb
134
+ - lib/maf/setup_agent/steward.rb
131
135
  - lib/maf/setup_agent/worktree.rb
136
+ - lib/maf/shared/flow_files.rb
132
137
  - lib/maf/shared/git_exclude.rb
133
138
  - lib/maf/shared/git_identity.rb
134
139
  - lib/maf/shared/harness.rb