maf 0.1.0 → 0.3.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 +174 -0
- data/README.md +29 -11
- data/assets/agents-contract.md +2 -0
- data/assets/analyst +29 -51
- data/assets/coord +168 -41
- data/assets/dashboard +1 -5
- data/assets/dispatcher +127 -298
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/install.md +6 -1
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/cli.rb +22 -9
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +3 -2
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +3 -3
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +14 -38
- data/lib/maf/flow/options.rb +33 -5
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +13 -14
- data/lib/maf/flow/report.rb +8 -9
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +7 -22
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +7 -21
- data/lib/maf/menu.rb +49 -15
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +3 -3
- data/lib/maf/setup_agent/launcher.rb +16 -65
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +11 -13
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +147 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +137 -0
- data/lib/maf/shared/harnesses/codex.rb +202 -0
- data/lib/maf/shared/harnesses/cursor.rb +120 -0
- data/lib/maf/shared/harnesses/hermes.rb +122 -0
- data/lib/maf/shared/harnesses/opencode.rb +192 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team.rb +5 -4
- data/lib/maf/team_command.rb +5 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +12 -25
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +16 -8
- data/lib/maf/worker_list.rb +83 -0
- data/lib/maf/workers.rb +27 -0
- data/templates/roles.yml +14 -0
- metadata +33 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
data/assets/coord
CHANGED
|
@@ -24,6 +24,7 @@ begin
|
|
|
24
24
|
require_relative "../lib/maf/shared/roles"
|
|
25
25
|
require_relative "../lib/maf/shared/git_exclude"
|
|
26
26
|
require_relative "../lib/maf/shared/git_identity"
|
|
27
|
+
require_relative "../lib/maf/shared/harness"
|
|
27
28
|
rescue LoadError
|
|
28
29
|
abort "coord: the maf shared library is missing. Run maf update."
|
|
29
30
|
end
|
|
@@ -53,6 +54,7 @@ module Coord
|
|
|
53
54
|
# A claim older than this is presumed abandoned (crashed worker) and can be
|
|
54
55
|
# taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
|
|
55
56
|
DEFAULT_LEASE_TTL = 4 * 3600
|
|
57
|
+
BLOCKED_TAG = "blocked"
|
|
56
58
|
DEFAULT_POLL_INTERVAL = 60
|
|
57
59
|
|
|
58
60
|
# Lead roles plan and talk to the user. They never own a task, so they wait
|
|
@@ -85,6 +87,10 @@ module Coord
|
|
|
85
87
|
coord conflicts list pending tasks with overlapping scopes
|
|
86
88
|
coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
|
|
87
89
|
coord unclaim ID release a claim without finishing it
|
|
90
|
+
coord block ID [--kind K] TEXT... release the claim, hold the task out of coord next, and tell
|
|
91
|
+
the architect (K=dependency) or the project manager
|
|
92
|
+
(K=needs_input, the default, capability, or failing)
|
|
93
|
+
coord unblock ID return a blocked task to the backlog
|
|
88
94
|
coord done ID [--force] complete (refused while the task branch lacks the goal head,
|
|
89
95
|
or while the verify command fails)
|
|
90
96
|
coord annotate ID TEXT...
|
|
@@ -119,7 +125,7 @@ module Coord
|
|
|
119
125
|
COMMANDS = {
|
|
120
126
|
"init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
|
|
121
127
|
"conflicts" => :cmd_conflicts,
|
|
122
|
-
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
|
|
128
|
+
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim, "block" => :cmd_block, "unblock" => :cmd_unblock,
|
|
123
129
|
"done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
|
|
124
130
|
"msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
|
|
125
131
|
"log" => :cmd_log, "hooks" => :cmd_hooks,
|
|
@@ -200,7 +206,7 @@ module Coord
|
|
|
200
206
|
def ensure_taskrc
|
|
201
207
|
had_marker = File.exist?(@taskrc) && File.read(@taskrc).include?(MARKER)
|
|
202
208
|
FileUtils.mkdir_p(File.dirname(@taskrc))
|
|
203
|
-
FileUtils.touch(@taskrc)
|
|
209
|
+
FileUtils.touch(@taskrc) unless File.exist?(@taskrc)
|
|
204
210
|
ensure_data_location(warn_when_external: !had_marker)
|
|
205
211
|
had_marker ? add_missing_udas : append_uda_block
|
|
206
212
|
end
|
|
@@ -345,6 +351,9 @@ module Coord
|
|
|
345
351
|
rescue ArgumentError
|
|
346
352
|
false
|
|
347
353
|
end
|
|
354
|
+
|
|
355
|
+
def self.blocked?(task) = Array(task["tags"]).include?(BLOCKED_TAG)
|
|
356
|
+
def self.held?(task) = waiting?(task) || blocked?(task)
|
|
348
357
|
end
|
|
349
358
|
|
|
350
359
|
# TaskCli runs Taskwarrior and parses its output. No domain logic here.
|
|
@@ -407,6 +416,8 @@ module Coord
|
|
|
407
416
|
export([id, "export"]).first || {}
|
|
408
417
|
end
|
|
409
418
|
|
|
419
|
+
def with_goal(uuid) = export(["goalid:#{uuid}", "export"])
|
|
420
|
+
|
|
410
421
|
private
|
|
411
422
|
|
|
412
423
|
def run(*args)
|
|
@@ -516,7 +527,7 @@ module Coord
|
|
|
516
527
|
end
|
|
517
528
|
|
|
518
529
|
def mine(role, worker)
|
|
519
|
-
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] }
|
|
530
|
+
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] && actionable?(task) }
|
|
520
531
|
end
|
|
521
532
|
|
|
522
533
|
# Returns the prior holder (nil if there was none) so the caller can warn
|
|
@@ -536,10 +547,18 @@ module Coord
|
|
|
536
547
|
@cli.modify(id, "worker:")
|
|
537
548
|
end
|
|
538
549
|
|
|
550
|
+
def block(id)
|
|
551
|
+
unclaim(id) if @cli.task(id)["start"]
|
|
552
|
+
@cli.modify(id, "+#{BLOCKED_TAG}")
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def unblock(id) = @cli.modify(id, "-#{BLOCKED_TAG}")
|
|
556
|
+
|
|
539
557
|
def find(id) = @cli.task(id)
|
|
540
558
|
# A short id of digits only would read as a task number. The filter avoids that.
|
|
541
559
|
def find_short(short) = @cli.task("uuid.startswith:#{short}")
|
|
542
560
|
def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
|
|
561
|
+
def any_for_goal?(uuid) = @cli.with_goal(uuid).any?
|
|
543
562
|
def done(id) = @cli.done(id)
|
|
544
563
|
def annotate(id, text) = @cli.annotate(id, text)
|
|
545
564
|
def pending = @pending ||= @cli.pending
|
|
@@ -547,7 +566,7 @@ module Coord
|
|
|
547
566
|
|
|
548
567
|
private
|
|
549
568
|
|
|
550
|
-
def actionable?(task) = !Schedule.
|
|
569
|
+
def actionable?(task) = !Schedule.held?(task)
|
|
551
570
|
|
|
552
571
|
def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
|
|
553
572
|
|
|
@@ -840,20 +859,7 @@ module Coord
|
|
|
840
859
|
entries.each { |entry| puts line(entry) }
|
|
841
860
|
end
|
|
842
861
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
def self.live_entry?(entry)
|
|
846
|
-
return false unless Maf::Shared::Processes.alive?(entry["pid"])
|
|
847
|
-
|
|
848
|
-
recorded = entry["started"].to_s
|
|
849
|
-
recorded.empty? ? legacy_live?(entry) : recorded == Maf::Shared::Processes.started_at(entry["pid"])
|
|
850
|
-
end
|
|
851
|
-
|
|
852
|
-
def self.legacy_live?(entry)
|
|
853
|
-
Time.now - Time.parse(entry["seen_at"].to_s) < LEGACY_LIVE_WINDOW
|
|
854
|
-
rescue ArgumentError
|
|
855
|
-
false
|
|
856
|
-
end
|
|
862
|
+
def self.live_entry?(entry) = Maf::Shared::Processes.live?(entry)
|
|
857
863
|
|
|
858
864
|
private
|
|
859
865
|
|
|
@@ -1109,9 +1115,10 @@ end
|
|
|
1109
1115
|
# Board renders status summaries and the Obsidian kanban file.
|
|
1110
1116
|
class Board
|
|
1111
1117
|
SECTIONS = {
|
|
1112
|
-
"Backlog" => ->(task) { !Schedule.
|
|
1113
|
-
"In progress" => ->(task) { !Schedule.
|
|
1114
|
-
"Waiting" => ->(task) { Schedule.waiting?(task) }
|
|
1118
|
+
"Backlog" => ->(task) { !Schedule.held?(task) && !task["start"] },
|
|
1119
|
+
"In progress" => ->(task) { !Schedule.held?(task) && task["start"] },
|
|
1120
|
+
"Waiting" => ->(task) { Schedule.waiting?(task) && !Schedule.blocked?(task) },
|
|
1121
|
+
"Blocked" => ->(task) { Schedule.blocked?(task) }
|
|
1115
1122
|
}.freeze
|
|
1116
1123
|
|
|
1117
1124
|
def initialize(tasks, paths)
|
|
@@ -1161,6 +1168,7 @@ end
|
|
|
1161
1168
|
end
|
|
1162
1169
|
|
|
1163
1170
|
def bucket(task)
|
|
1171
|
+
return "blocked" if Schedule.blocked?(task)
|
|
1164
1172
|
return "waiting" if Schedule.waiting?(task)
|
|
1165
1173
|
|
|
1166
1174
|
task["start"] ? "in-progress" : "backlog"
|
|
@@ -1197,6 +1205,8 @@ end
|
|
|
1197
1205
|
def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
|
|
1198
1206
|
def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
|
|
1199
1207
|
def self.clean?(dir) = `git -C #{Shellwords.escape(dir)} status --porcelain --untracked-files=no`.strip.empty?
|
|
1208
|
+
def self.changed(dir) = out(dir, "diff", "HEAD", "--name-only").lines(chomp: true)
|
|
1209
|
+
def self.remote_branch?(dir, remote, name) = run(dir, "ls-remote", "--exit-code", "--heads", remote, name)
|
|
1200
1210
|
def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
|
|
1201
1211
|
def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
|
|
1202
1212
|
def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
|
|
@@ -1257,11 +1267,12 @@ end
|
|
|
1257
1267
|
goals.each { |goal| puts goal_line(goal) }
|
|
1258
1268
|
end
|
|
1259
1269
|
|
|
1260
|
-
def show(id)
|
|
1270
|
+
def show(id, artifacts_dir)
|
|
1261
1271
|
goal = find!(id)
|
|
1262
1272
|
puts goal_line(goal)
|
|
1263
1273
|
list = @tasks.for_goal(goal["uuid"])
|
|
1264
1274
|
list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
|
|
1275
|
+
plan_hint(goal["uuid"], artifacts_dir)
|
|
1265
1276
|
end
|
|
1266
1277
|
|
|
1267
1278
|
def done(id)
|
|
@@ -1278,6 +1289,13 @@ end
|
|
|
1278
1289
|
|
|
1279
1290
|
private
|
|
1280
1291
|
|
|
1292
|
+
def plan_hint(uuid, artifacts_dir)
|
|
1293
|
+
plan = File.join(artifacts_dir, uuid, "plan.md")
|
|
1294
|
+
return unless File.exist?(plan) && !@tasks.any_for_goal?(uuid)
|
|
1295
|
+
|
|
1296
|
+
puts " plan ready, no tasks: #{plan}. The plan waits for approval."
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1281
1299
|
def goal_line(goal)
|
|
1282
1300
|
open = @tasks.for_goal(goal["uuid"]).size
|
|
1283
1301
|
[goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
|
|
@@ -1298,7 +1316,7 @@ end
|
|
|
1298
1316
|
end
|
|
1299
1317
|
|
|
1300
1318
|
def start(uuid, base)
|
|
1301
|
-
|
|
1319
|
+
refuse_dirty!
|
|
1302
1320
|
|
|
1303
1321
|
branch = "task/#{Goals.short(uuid)}"
|
|
1304
1322
|
raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
|
|
@@ -1308,6 +1326,17 @@ end
|
|
|
1308
1326
|
|
|
1309
1327
|
private
|
|
1310
1328
|
|
|
1329
|
+
FLOW_FILES = "These are files of the flow, and git tracks them. In the main checkout, run maf untrack and commit."
|
|
1330
|
+
|
|
1331
|
+
def refuse_dirty!
|
|
1332
|
+
paths = Git.changed(@dir)
|
|
1333
|
+
return if paths.empty?
|
|
1334
|
+
|
|
1335
|
+
flow = paths.all? { |path| path.start_with?(".maf/") || Worktree::RUNTIME_FILES.include?(path) }
|
|
1336
|
+
raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
|
|
1337
|
+
flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1311
1340
|
def checkout(branch, base)
|
|
1312
1341
|
return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
|
|
1313
1342
|
|
|
@@ -1363,8 +1392,8 @@ end
|
|
|
1363
1392
|
# commit. The task branch is then deleted. A worker worktree that still has
|
|
1364
1393
|
# the task branch checked out moves back to its worker branch first.
|
|
1365
1394
|
class Land
|
|
1366
|
-
def initialize(root, task)
|
|
1367
|
-
@root, @task = root, task
|
|
1395
|
+
def initialize(root, task, base)
|
|
1396
|
+
@root, @task, @base = root, task, base
|
|
1368
1397
|
end
|
|
1369
1398
|
|
|
1370
1399
|
def run(subject)
|
|
@@ -1393,8 +1422,14 @@ end
|
|
|
1393
1422
|
return nil unless Git.own_commits?(@root, goal_branch, branch)
|
|
1394
1423
|
raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
|
|
1395
1424
|
|
|
1425
|
+
parent = base_parent
|
|
1396
1426
|
merge_squash
|
|
1397
|
-
commit(SquashMessage.for(@task, subject))
|
|
1427
|
+
commit(SquashMessage.for(@task, subject), parent)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
def base_parent
|
|
1431
|
+
held = Git.out(@root, "merge-base", Git.base_ref(@root, @base), branch)
|
|
1432
|
+
held unless held.empty? || Git.ancestor?(@root, held, goal_branch)
|
|
1398
1433
|
end
|
|
1399
1434
|
|
|
1400
1435
|
STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
|
|
@@ -1407,14 +1442,30 @@ end
|
|
|
1407
1442
|
end
|
|
1408
1443
|
|
|
1409
1444
|
# coord makes this commit, not the role, so the commit guard does not apply.
|
|
1410
|
-
def commit(message)
|
|
1411
|
-
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet")
|
|
1445
|
+
def commit(message, parent)
|
|
1446
|
+
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet") && parent.nil?
|
|
1412
1447
|
|
|
1448
|
+
parent ? merge_commit(message, parent) : squash_commit(message)
|
|
1449
|
+
Git.out(goal_dir, "rev-parse", "--short", "HEAD")
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
def squash_commit(message)
|
|
1413
1453
|
args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
|
|
1414
1454
|
IO.popen(args, "w") { |io| io.write(message) }
|
|
1415
1455
|
raise Error, "git commit on #{goal_branch} failed" unless $?.success?
|
|
1456
|
+
end
|
|
1416
1457
|
|
|
1417
|
-
|
|
1458
|
+
def merge_commit(message, parent)
|
|
1459
|
+
head = Git.out(goal_dir, "rev-parse", "HEAD")
|
|
1460
|
+
sha = commit_tree(message, head, parent)
|
|
1461
|
+
moved = sha && Git.run(goal_dir, "update-ref", "HEAD", sha, head)
|
|
1462
|
+
raise Error, "git commit on #{goal_branch} failed" unless moved
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
def commit_tree(message, *parents)
|
|
1466
|
+
args = ["commit-tree", Git.out(goal_dir, "write-tree"), *parents.flat_map { |sha| ["-p", sha] }, "-F", "-"]
|
|
1467
|
+
sha, status = Open3.capture2("git", "-C", goal_dir, *args, stdin_data: message)
|
|
1468
|
+
sha.strip if status.success?
|
|
1418
1469
|
end
|
|
1419
1470
|
|
|
1420
1471
|
def release_branch
|
|
@@ -1660,6 +1711,18 @@ end
|
|
|
1660
1711
|
raise Error, "git push of #{branch} to #{remote} failed"
|
|
1661
1712
|
end
|
|
1662
1713
|
|
|
1714
|
+
def push_with_base(branch, base)
|
|
1715
|
+
push_missing(base)
|
|
1716
|
+
push(branch)
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
def push_missing(branch)
|
|
1720
|
+
return if !Git.branch?(@root, branch) || Git.remote_branch?(@root, remote, branch)
|
|
1721
|
+
|
|
1722
|
+
push(branch)
|
|
1723
|
+
puts "pushed #{branch}: #{remote} had no branch #{branch}"
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1663
1726
|
def create_pr(head:, base:, title:, body:)
|
|
1664
1727
|
args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
|
|
1665
1728
|
gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
|
|
@@ -1823,11 +1886,17 @@ end
|
|
|
1823
1886
|
def write(dir, slug)
|
|
1824
1887
|
slot = Slots.new(@coord_dir).assign(slug)
|
|
1825
1888
|
FileUtils.mkdir_p(File.join(dir, MAF_DIR))
|
|
1826
|
-
|
|
1889
|
+
save(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
|
|
1827
1890
|
end
|
|
1828
1891
|
|
|
1829
1892
|
private
|
|
1830
1893
|
|
|
1894
|
+
def save(file, text)
|
|
1895
|
+
File.write(file, text) unless File.exist?(file) && File.read(file) == text
|
|
1896
|
+
rescue SystemCallError => e
|
|
1897
|
+
warn "coord: cannot write #{file} (#{e.message}). Run coord worktree outside the sandbox to update it."
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1831
1900
|
def base_lines(slot)
|
|
1832
1901
|
"export COORD_DIR=#{@coord_dir}\nexport TASKRC=#{File.join(@coord_dir, "taskrc")}\n" \
|
|
1833
1902
|
"export COORD_SLOT=#{slot}\n#{bin_lines}"
|
|
@@ -1913,10 +1982,11 @@ end
|
|
|
1913
1982
|
# slash, so it also matches the symlink.
|
|
1914
1983
|
GRAPH_DIR = "graphify-out"
|
|
1915
1984
|
LOCAL_EXCLUDES = [ENV_FILE, "/#{GRAPH_DIR}"].freeze
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1985
|
+
# The hook files of the flow, and the files that each harness writes in a worktree.
|
|
1986
|
+
RUNTIME_FILES = (%w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
|
|
1987
|
+
.maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
|
|
1988
|
+
.maf/coordination/harness-hooks/context-watch.rb] +
|
|
1989
|
+
Maf::Shared::Harness.all.flat_map(&:runtime_files)).freeze
|
|
1920
1990
|
|
|
1921
1991
|
def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
|
|
1922
1992
|
|
|
@@ -2025,6 +2095,14 @@ end
|
|
|
2025
2095
|
|
|
2026
2096
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
2027
2097
|
FileUtils.cp(src, dest, preserve: true)
|
|
2098
|
+
rescue Errno::EPERM, Errno::EROFS => e
|
|
2099
|
+
blocked(src, e)
|
|
2100
|
+
end
|
|
2101
|
+
|
|
2102
|
+
def blocked(src, error)
|
|
2103
|
+
raise error unless src.start_with?(File.join(@root, MAF_DIR, ""))
|
|
2104
|
+
|
|
2105
|
+
abort "coord: cannot copy #{src} into the worktree in the sandbox. Run coord worktree on the host."
|
|
2028
2106
|
end
|
|
2029
2107
|
|
|
2030
2108
|
# The graph is not committed. A relative symlink gives the worktree the
|
|
@@ -2083,7 +2161,10 @@ end
|
|
|
2083
2161
|
def timeout = (v = value_of("--timeout")) && v.to_i
|
|
2084
2162
|
|
|
2085
2163
|
def require_arg(command, name)
|
|
2086
|
-
|
|
2164
|
+
value = shift
|
|
2165
|
+
abort("usage: coord #{command} #{name}") if value.nil? || value.start_with?("-")
|
|
2166
|
+
|
|
2167
|
+
value
|
|
2087
2168
|
end
|
|
2088
2169
|
|
|
2089
2170
|
def require_text(command)
|
|
@@ -2241,6 +2322,42 @@ end
|
|
|
2241
2322
|
end
|
|
2242
2323
|
end
|
|
2243
2324
|
|
|
2325
|
+
# Block commands hold a task out of coord next, tell the role that can
|
|
2326
|
+
# remove the blocker, and return the task.
|
|
2327
|
+
module BlockCommands
|
|
2328
|
+
BLOCK_KINDS = %w[needs_input dependency capability failing].freeze
|
|
2329
|
+
BLOCK_USAGE = "usage: coord block ID [--kind #{BLOCK_KINDS.join("|")}] TEXT...".freeze
|
|
2330
|
+
|
|
2331
|
+
def cmd_block
|
|
2332
|
+
kind = block_kind(@args.value_of("--kind") || BLOCK_KINDS.first)
|
|
2333
|
+
id = @args.require_arg("block", "ID")
|
|
2334
|
+
block(id, kind, @args.require_text("block"))
|
|
2335
|
+
puts "blocked #{id} (#{kind})"
|
|
2336
|
+
end
|
|
2337
|
+
|
|
2338
|
+
def block(id, kind, text)
|
|
2339
|
+
tasks.annotate(id, "BLOCKED (#{kind}): #{text}")
|
|
2340
|
+
tasks.block(id)
|
|
2341
|
+
event_log.append("block", @worker, "#{id} #{kind} #{text}")
|
|
2342
|
+
notify_block(id, kind, text)
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
def block_kind(kind) = BLOCK_KINDS.include?(kind) ? kind : abort(BLOCK_USAGE)
|
|
2346
|
+
|
|
2347
|
+
def notify_block(id, kind, text)
|
|
2348
|
+
return deliver("architect", @role, "BLOCKED task #{id} (dependency), #{@worker}: #{text}") if kind == "dependency"
|
|
2349
|
+
|
|
2350
|
+
send_escalation(id, "blocked (#{kind}): #{text}")
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
def cmd_unblock
|
|
2354
|
+
id = @args.require_arg("unblock", "ID")
|
|
2355
|
+
tasks.unblock(id)
|
|
2356
|
+
event_log.append("unblock", @worker, id)
|
|
2357
|
+
puts "unblocked #{id}"
|
|
2358
|
+
end
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2244
2361
|
# Claim commands take, release, note, and finish a task.
|
|
2245
2362
|
module ClaimCommands
|
|
2246
2363
|
def cmd_claim
|
|
@@ -2454,8 +2571,9 @@ end
|
|
|
2454
2571
|
event_log.append("broadcast", @worker, text)
|
|
2455
2572
|
end
|
|
2456
2573
|
|
|
2457
|
-
NO_PUSH = "coord: %<role>s has no live session and no message hook.
|
|
2458
|
-
"
|
|
2574
|
+
NO_PUSH = "coord: %<role>s has no live session and no message hook. " \
|
|
2575
|
+
"The message waits until a session of %<role>s starts. " \
|
|
2576
|
+
"Fix: maf start HARNESS %<role>s --dispatch --detach, " \
|
|
2459
2577
|
"or add an executable .maf/coordination/message-hooks/%<role>s.sh"
|
|
2460
2578
|
|
|
2461
2579
|
# A message is pushed by a hook or read by a live session. Without both,
|
|
@@ -2564,7 +2682,7 @@ end
|
|
|
2564
2682
|
def cmd_land
|
|
2565
2683
|
subject = @args.value_of("--subject")
|
|
2566
2684
|
task = find_task!(@args.require_arg("land", "ID"))
|
|
2567
|
-
land = Land.new(main_root, task)
|
|
2685
|
+
land = Land.new(main_root, task, Git.base_branch(main_root, manifest))
|
|
2568
2686
|
commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
|
|
2569
2687
|
record_landing(task, commit, land)
|
|
2570
2688
|
end
|
|
@@ -2587,7 +2705,7 @@ end
|
|
|
2587
2705
|
setup.ensure_taskrc
|
|
2588
2706
|
goals.find!(id)["uuid"]
|
|
2589
2707
|
end
|
|
2590
|
-
def goal_show = goals.show(@args.require_arg("goal show", "ID"))
|
|
2708
|
+
def goal_show = goals.show(@args.require_arg("goal show", "ID"), File.join(paths.coord_dir, "artifacts"))
|
|
2591
2709
|
|
|
2592
2710
|
def goal_sync
|
|
2593
2711
|
goal = goals.find!(@args.require_arg("goal sync", "ID"))
|
|
@@ -2617,11 +2735,18 @@ end
|
|
|
2617
2735
|
raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
|
|
2618
2736
|
|
|
2619
2737
|
uuid = tasks.add(title: opts[:title], role: Goals::ROLE, ref: opts[:ref])
|
|
2620
|
-
|
|
2738
|
+
open_goal_branch(uuid, base)
|
|
2621
2739
|
event_log.append("goal", @worker, opts[:title])
|
|
2622
2740
|
puts uuid
|
|
2623
2741
|
end
|
|
2624
2742
|
|
|
2743
|
+
def open_goal_branch(uuid, base)
|
|
2744
|
+
Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
|
|
2745
|
+
return if !Git.run(main_root, "remote", "get-url", "origin") || Git.remote_branch?(main_root, "origin", base)
|
|
2746
|
+
|
|
2747
|
+
warn "coord: origin has no branch #{base}. coord goal pr pushes the local #{base} again."
|
|
2748
|
+
end
|
|
2749
|
+
|
|
2625
2750
|
UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
|
|
2626
2751
|
"Run the merge suite again. Then run coord goal done again."
|
|
2627
2752
|
|
|
@@ -2715,7 +2840,7 @@ end
|
|
|
2715
2840
|
|
|
2716
2841
|
def publish_goal(goal, entry)
|
|
2717
2842
|
branch = Goals.branch(goal["uuid"])
|
|
2718
|
-
github.
|
|
2843
|
+
github.push_with_base(branch, Git.base_branch(main_root, manifest))
|
|
2719
2844
|
push_memory
|
|
2720
2845
|
entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
|
|
2721
2846
|
review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
|
|
@@ -2802,6 +2927,7 @@ end
|
|
|
2802
2927
|
def cmd_worktree
|
|
2803
2928
|
role = @args.require_arg("worktree", "ROLE")
|
|
2804
2929
|
worker = @args.shift
|
|
2930
|
+
abort "usage: coord worktree ROLE [WORKER]" if worker&.start_with?("-")
|
|
2805
2931
|
warn_missing_role_files(role)
|
|
2806
2932
|
Worktree.new(@project_root).create(role, worker)
|
|
2807
2933
|
end
|
|
@@ -2897,6 +3023,7 @@ end
|
|
|
2897
3023
|
class CLI
|
|
2898
3024
|
include TaskCommands
|
|
2899
3025
|
include ClaimCommands
|
|
3026
|
+
include BlockCommands
|
|
2900
3027
|
include MessageCommands
|
|
2901
3028
|
include InboxCommands
|
|
2902
3029
|
include GoalCommands
|
data/assets/dashboard
CHANGED
|
@@ -137,11 +137,7 @@ end
|
|
|
137
137
|
{}
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
def live?(presence)
|
|
142
|
-
pid = presence["pid"].to_i
|
|
143
|
-
Maf::Shared::Processes.alive?(pid) && [nil, "", Maf::Shared::Processes.started_at(pid)].include?(presence["started"])
|
|
144
|
-
end
|
|
140
|
+
def live?(presence) = Maf::Shared::Processes.live?(presence)
|
|
145
141
|
|
|
146
142
|
def tail(name)
|
|
147
143
|
path = File.join(sessions, name)
|