maf 0.4.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/assets/coord +60 -8
- data/assets/dispatcher +74 -5
- data/assets/steward +398 -0
- data/lib/maf/bootstrap/installer.rb +2 -2
- data/lib/maf/bootstrap/script_planner.rb +4 -0
- data/lib/maf/bootstrap.rb +1 -0
- data/lib/maf/cli.rb +5 -4
- data/lib/maf/flow/prompt_text.rb +14 -0
- data/lib/maf/op_command.rb +28 -0
- data/lib/maf/session_records.rb +39 -0
- data/lib/maf/setup_agent/launcher.rb +2 -1
- data/lib/maf/setup_agent/steward.rb +18 -0
- data/lib/maf/setup_agent.rb +2 -0
- data/lib/maf/shared/flow_files.rb +33 -0
- data/lib/maf/shared/sandbox/paths.rb +10 -2
- data/lib/maf/shared/sandbox/seatbelt.rb +21 -1
- data/lib/maf/shared/sandbox.rb +15 -1
- data/lib/maf/uninstall/scripts.rb +1 -0
- data/lib/maf/untrack.rb +12 -0
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -2
- data/lib/maf/worker_list.rb +2 -13
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df94fb01940bd1e9d53fbd46bd33d023ef9584eebafcf018a11c71fcc2edf4e9
|
|
4
|
+
data.tar.gz: 063fcde6929e5a1476ec12db56bcc65dff75dc1d9f3867208b4a8e91fa865335
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f60c0cbcb6a60b2a079e09e3ccf9587c3501c1420f33cbfb6424c80211440d2831b1ddce1894d4adc171299ac7a25bd2cdebc84b2b8d565c54ff88623cbe44da
|
|
7
|
+
data.tar.gz: 8a86dbb9aa864b37426c7db5f3d2e4d26ebd145086cc0f766053468f79d5863598ef9aadd645bce991879ac86acd2efbe962690ca8e660efa38fe2bda58ed12a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
- New: `.maf/sandbox.json` key `scope`. `dispatch` (the default) runs only the dispatched runs
|
|
8
|
+
and the analyst in the sandbox. `all` also runs the interactive sessions in it, as before.
|
|
9
|
+
An interactive session has approvals, so it now runs on the host by default. Set
|
|
10
|
+
`"scope": "all"` to keep the old behavior. A project manager in an interactive session with
|
|
11
|
+
`dispatch` can start a dispatcher on macOS.
|
|
12
|
+
- Fix: in the macOS sandbox, a copy did not paste. `pbcopy` could not reach
|
|
13
|
+
`com.apple.lsd.mapdb`, so it wrote the text with a type that no app reads. The sandbox now
|
|
14
|
+
allows this service. It only reads type names; `open` and Apple Events stay blocked.
|
|
15
|
+
|
|
16
|
+
## [0.5.0] - 2026-10-22
|
|
17
|
+
|
|
18
|
+
- Fix: in the macOS sandbox, headless Chrome aborted at start, and macOS showed a crash dialog
|
|
19
|
+
for each try. New `.maf/sandbox.json` key `launch_services` lists the apps that can use
|
|
20
|
+
LaunchServices. After each run, the dispatcher reports apps that the run started and that
|
|
21
|
+
crashed, and LaunchServices denials, once for each app to the project manager.
|
|
22
|
+
- Fix: after Ctrl+C or SIGHUP, the dispatcher stopped, but the agent run continued in its own
|
|
23
|
+
session. The dispatcher now stops the process group of the agent first.
|
|
24
|
+
- New: `steward`, a daemon outside the sandbox that runs a fixed list of host operations for the
|
|
25
|
+
leads: `worker-restart`, `worker-retire`, `worktree-sync`, `goal-sync`, `runtime-restore`. An
|
|
26
|
+
agent asks with `coord op request OPERATION [ARGUMENT]`. steward checks each argument, runs no
|
|
27
|
+
shell, allows 3 runs per hour for one argument, and sends the result to the inbox of the role
|
|
28
|
+
that asked. `maf start` starts steward. The project manager and the architect prompts list the
|
|
29
|
+
operations. `maf update` installs `.maf/bin/steward`.
|
|
30
|
+
- New: operations that wait for the user: `worker-start`, `worker-model WORKER MODEL`, and
|
|
31
|
+
`bundle-install` (frozen to `Gemfile.lock`). `maf op list|approve ID|deny ID` handles them.
|
|
32
|
+
`.maf/sandbox.json` key `ops` sets the level of each operation: `auto`, `approve`, or `off`.
|
|
33
|
+
- After `maf untrack`, maf moves the old branches. `maf start` fast-forwards a worker branch
|
|
34
|
+
also when the only changes are in flow files that the base branch no longer tracks: maf
|
|
35
|
+
discards them first. `maf update` runs the new `coord goal migrate`, which merges the base
|
|
36
|
+
branch into each goal branch that still tracks the flow files. Before, the user did these
|
|
37
|
+
steps by hand, because the sandbox blocks an agent from deleting the flow files.
|
|
38
|
+
- Fix: `maf worker stop` did nothing for a session that `maf worker list` showed as running.
|
|
39
|
+
`stop` read only the presence file, and that file can name an older session. `stop`, `start`,
|
|
40
|
+
and `status` now find the session from the records of `maf start`, as `list` does.
|
|
41
|
+
- `maf worker stop` reports a session that does not stop after TERM, and prints the `kill -9`
|
|
42
|
+
command. A session in a closed terminal can ignore TERM.
|
|
43
|
+
|
|
5
44
|
## [0.4.0] - 2026-10-21
|
|
6
45
|
|
|
7
46
|
- 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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
694
|
-
# the
|
|
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(
|
|
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) =
|
|
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
|
|
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.
|
|
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(
|
|
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
|
data/lib/maf/flow/prompt_text.rb
CHANGED
|
@@ -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
|
|
@@ -7,10 +7,11 @@ module Maf
|
|
|
7
7
|
module SetupAgent
|
|
8
8
|
# Launcher replaces this process with the interactive session of a harness.
|
|
9
9
|
# The harness file builds the command (Maf::Shared::Harnesses).
|
|
10
|
+
# The session runs in the sandbox only when .maf/sandbox.json sets "scope": "all".
|
|
10
11
|
module Launcher
|
|
11
12
|
def self.launch(harness, role, model, effort = nil)
|
|
12
13
|
ENV.update(harness.launch_env)
|
|
13
|
-
return exec_or_die(harness.launch(role, model, effort)) if Maf::Shared::Sandbox.
|
|
14
|
+
return exec_or_die(harness.launch(role, model, effort)) if Maf::Shared::Sandbox.interactive_mode == "off"
|
|
14
15
|
|
|
15
16
|
warn "maf: sandbox: #{Maf::Shared::Sandbox.describe(harness)}"
|
|
16
17
|
exit Maf::Shared::Sandbox.keeping_links(harness) { relay_or_die(sandboxed(harness, role, model, effort)) }
|
|
@@ -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
|
data/lib/maf/setup_agent.rb
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
|
@@ -16,8 +16,12 @@ module Maf
|
|
|
16
16
|
'(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
|
|
17
17
|
DNS = '(path-literal "/private/var/run/mDNSResponder")'
|
|
18
18
|
TMP_PREFIX = "maf-sandbox-"
|
|
19
|
+
# The map database of LaunchServices only reads: it turns a type name into a type.
|
|
20
|
+
# Without it, pbcopy writes the text with a type that no app reads.
|
|
19
21
|
SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
|
|
22
|
+
"(allow mach-lookup (global-name \"com.apple.lsd.mapdb\"))\n" \
|
|
20
23
|
"(deny appleevent-send)\n"
|
|
24
|
+
LAUNCH_SERVICES = "com.apple.coreservices.launchservicesd"
|
|
21
25
|
|
|
22
26
|
def self.wrap(cmd, paths)
|
|
23
27
|
tmp = private_tmp(paths.root)
|
|
@@ -52,7 +56,23 @@ module Maf
|
|
|
52
56
|
|
|
53
57
|
def self.profile(paths, tmp)
|
|
54
58
|
"(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
|
|
55
|
-
"#{sockets([*paths.sockets, tmp])}#{SERVICES}"
|
|
59
|
+
"#{sockets([*paths.sockets, tmp])}#{SERVICES}#{launch_services(paths.apps)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A later rule wins, so this exception comes after the denial.
|
|
63
|
+
def self.launch_services(apps)
|
|
64
|
+
programs = apps.flat_map { |app| programs(app) }.uniq
|
|
65
|
+
return "" if programs.empty?
|
|
66
|
+
|
|
67
|
+
filters = programs.map { |path| "(process-path #{quote(path)})" }
|
|
68
|
+
"(allow mach-lookup (require-all (global-name \"#{LAUNCH_SERVICES}\")\n " \
|
|
69
|
+
"(require-any #{filters.join("\n ")})))\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.programs(app)
|
|
73
|
+
return [app] unless File.directory?(app)
|
|
74
|
+
|
|
75
|
+
Dir.glob(File.join(app, "**", "Contents", "MacOS", "*")).select { |path| File.file?(path) }
|
|
56
76
|
end
|
|
57
77
|
|
|
58
78
|
def self.writes(paths)
|
data/lib/maf/shared/sandbox.rb
CHANGED
|
@@ -17,6 +17,8 @@ module Maf
|
|
|
17
17
|
module Sandbox
|
|
18
18
|
BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
|
|
19
19
|
MODES = ["off", "auto", *BACKENDS.keys].freeze
|
|
20
|
+
# dispatch: only the runs of a dispatcher and the analyst. all: also the interactive sessions.
|
|
21
|
+
SCOPES = %w[dispatch all].freeze
|
|
20
22
|
POLICY = File.join(".maf", "sandbox.json")
|
|
21
23
|
NESTED = "maf: this process runs in the macOS sandbox, and macOS cannot start a second sandbox in it. " \
|
|
22
24
|
"Start the worker from your terminal: cd <worktree> && maf start"
|
|
@@ -39,6 +41,18 @@ module Maf
|
|
|
39
41
|
|
|
40
42
|
def self.native = RUBY_PLATFORM.include?("darwin") ? "seatbelt" : "bwrap"
|
|
41
43
|
|
|
44
|
+
def self.scope(policy = self.policy)
|
|
45
|
+
scope = policy["scope"] || "dispatch"
|
|
46
|
+
abort "maf: unknown sandbox scope '#{scope}' (use #{SCOPES.join(", ")})" unless SCOPES.include?(scope)
|
|
47
|
+
scope
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# An interactive session runs in the sandbox only with scope "all".
|
|
51
|
+
def self.interactive_mode
|
|
52
|
+
policy = self.policy
|
|
53
|
+
scope(policy) == "all" ? mode(policy) : "off"
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
43
57
|
policy = self.policy
|
|
44
58
|
mode = mode(policy)
|
|
@@ -74,7 +88,7 @@ module Maf
|
|
|
74
88
|
|
|
75
89
|
def self.paths(harness, policy, coord_dir)
|
|
76
90
|
protect_policy
|
|
77
|
-
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir,
|
|
91
|
+
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, policy)
|
|
78
92
|
end
|
|
79
93
|
|
|
80
94
|
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
data/lib/maf/worker_control.rb
CHANGED
|
@@ -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 =
|
|
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"])
|
data/lib/maf/worker_list.rb
CHANGED
|
@@ -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
|
+
version: 0.6.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
|