maf 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +411 -0
- data/assets/agents-contract.md +80 -0
- data/assets/analyst +240 -0
- data/assets/coord +2936 -0
- data/assets/dashboard +553 -0
- data/assets/dashboard.html +341 -0
- data/assets/dispatcher +1687 -0
- data/assets/doc-graph-refresh +286 -0
- data/assets/env.sh +6 -0
- data/assets/git-hooks/post-commit +7 -0
- data/assets/git-hooks/post-merge +7 -0
- data/assets/git-hooks/pre-commit +32 -0
- data/assets/harness-hooks/board-watch-opencode.js +87 -0
- data/assets/harness-hooks/board-watch.rb +286 -0
- data/assets/harness-hooks/context-watch.rb +268 -0
- data/assets/harness-hooks/next-task-hermes.sh +48 -0
- data/assets/harness-hooks/next-task.rb +97 -0
- data/assets/harness-hooks/session-guard.rb +128 -0
- data/assets/taskrc.append +11 -0
- data/assets/vault +224 -0
- data/assets/worktree-env.example.rb +26 -0
- data/exe/maf +14 -0
- data/install.md +326 -0
- data/lib/maf/bootstrap/claude_settings.rb +55 -0
- data/lib/maf/bootstrap/dependencies.rb +37 -0
- data/lib/maf/bootstrap/git_hook_planner.rb +68 -0
- data/lib/maf/bootstrap/global_taskrc_warning.rb +33 -0
- data/lib/maf/bootstrap/graph_home.rb +62 -0
- data/lib/maf/bootstrap/hook_merger.rb +53 -0
- data/lib/maf/bootstrap/installer.rb +66 -0
- data/lib/maf/bootstrap/layout_planner.rb +18 -0
- data/lib/maf/bootstrap/marked_block.rb +44 -0
- data/lib/maf/bootstrap/memory_branch.rb +77 -0
- data/lib/maf/bootstrap/options.rb +34 -0
- data/lib/maf/bootstrap/project.rb +77 -0
- data/lib/maf/bootstrap/script_planner.rb +81 -0
- data/lib/maf/bootstrap/text_planner.rb +42 -0
- data/lib/maf/bootstrap/vault_starter.rb +41 -0
- data/lib/maf/bootstrap/writer.rb +69 -0
- data/lib/maf/bootstrap.rb +162 -0
- data/lib/maf/budget.rb +59 -0
- data/lib/maf/cli.rb +135 -0
- data/lib/maf/env_exclude.rb +23 -0
- data/lib/maf/flow/agent_links.rb +79 -0
- data/lib/maf/flow/bootstrapper.rb +36 -0
- data/lib/maf/flow/codex_hooks.rb +50 -0
- data/lib/maf/flow/generator.rb +63 -0
- data/lib/maf/flow/harness_linker.rb +37 -0
- data/lib/maf/flow/hermes_hook.rb +48 -0
- data/lib/maf/flow/hermes_hook_setup.rb +69 -0
- data/lib/maf/flow/hook_files.rb +16 -0
- data/lib/maf/flow/hook_installer.rb +33 -0
- data/lib/maf/flow/legacy_codex_hook.rb +71 -0
- data/lib/maf/flow/manifest.rb +51 -0
- data/lib/maf/flow/mcp_config.rb +72 -0
- data/lib/maf/flow/mcp_installer.rb +45 -0
- data/lib/maf/flow/models.rb +61 -0
- data/lib/maf/flow/options.rb +65 -0
- data/lib/maf/flow/prompt_builder.rb +85 -0
- data/lib/maf/flow/prompt_text.rb +263 -0
- data/lib/maf/flow/report.rb +89 -0
- data/lib/maf/flow/role_catalog.rb +40 -0
- data/lib/maf/flow/role_files.rb +72 -0
- data/lib/maf/flow/role_stub.rb +38 -0
- data/lib/maf/flow/roster.rb +28 -0
- data/lib/maf/flow/validator.rb +38 -0
- data/lib/maf/flow/workflow.rb +28 -0
- data/lib/maf/flow.rb +84 -0
- data/lib/maf/local_exclude.rb +53 -0
- data/lib/maf/menu.rb +101 -0
- data/lib/maf/migrate/moves.rb +44 -0
- data/lib/maf/migrate/rewrites.rb +53 -0
- data/lib/maf/migrate/role_files.rb +35 -0
- data/lib/maf/migrate/runner.rb +66 -0
- data/lib/maf/migrate/worktrees.rb +65 -0
- data/lib/maf/migrate.rb +62 -0
- data/lib/maf/prompt.rb +40 -0
- data/lib/maf/retire.rb +116 -0
- data/lib/maf/role_limits.rb +49 -0
- data/lib/maf/setup_agent/args.rb +57 -0
- data/lib/maf/setup_agent/dispatch.rb +44 -0
- data/lib/maf/setup_agent/hermes_launcher.rb +34 -0
- data/lib/maf/setup_agent/hermes_skill.rb +26 -0
- data/lib/maf/setup_agent/launcher.rb +85 -0
- data/lib/maf/setup_agent/manifest.rb +35 -0
- data/lib/maf/setup_agent/project.rb +9 -0
- data/lib/maf/setup_agent/role_file.rb +30 -0
- data/lib/maf/setup_agent/runtime_hooks.rb +37 -0
- data/lib/maf/setup_agent/worktree.rb +50 -0
- data/lib/maf/setup_agent.rb +111 -0
- data/lib/maf/shared/git_exclude.rb +33 -0
- data/lib/maf/shared/git_identity.rb +41 -0
- data/lib/maf/shared/peak_rate.rb +20 -0
- data/lib/maf/shared/processes.rb +31 -0
- data/lib/maf/shared/project.rb +34 -0
- data/lib/maf/shared/roles.rb +19 -0
- data/lib/maf/team.rb +114 -0
- data/lib/maf/team_command.rb +73 -0
- data/lib/maf/uninstall/claude_settings.rb +40 -0
- data/lib/maf/uninstall/codex_hooks.rb +18 -0
- data/lib/maf/uninstall/commit_guard.rb +16 -0
- data/lib/maf/uninstall/coordination.rb +15 -0
- data/lib/maf/uninstall/doc_graph_hooks.rb +38 -0
- data/lib/maf/uninstall/git.rb +13 -0
- data/lib/maf/uninstall/local_files.rb +33 -0
- data/lib/maf/uninstall/manifest.rb +29 -0
- data/lib/maf/uninstall/marked_files.rb +37 -0
- data/lib/maf/uninstall/mcp_entries.rb +43 -0
- data/lib/maf/uninstall/notes.rb +31 -0
- data/lib/maf/uninstall/owned.rb +12 -0
- data/lib/maf/uninstall/role_files.rb +51 -0
- data/lib/maf/uninstall/runner.rb +67 -0
- data/lib/maf/uninstall/scripts.rb +35 -0
- data/lib/maf/uninstall/vault_watcher.rb +21 -0
- data/lib/maf/uninstall/worktrees.rb +30 -0
- data/lib/maf/uninstall.rb +59 -0
- data/lib/maf/untrack.rb +90 -0
- data/lib/maf/version.rb +5 -0
- data/lib/maf/worker_archive.rb +63 -0
- data/lib/maf/worker_control.rb +137 -0
- data/lib/maf/workers.rb +37 -0
- data/lib/maf.rb +5 -0
- data/templates/claude.md.erb +16 -0
- data/templates/codex.md.erb +7 -0
- data/templates/hermes.md.erb +12 -0
- data/templates/opencode.md.erb +24 -0
- data/templates/role-stub.yml.erb +15 -0
- data/templates/roles.yml +289 -0
- data/templates/workflows/panel.md +20 -0
- data/templates/workflows/plan-review.md +9 -0
- data/templates/workflows/simple.md +4 -0
- data/templates/workflows/tdd.md +8 -0
- metadata +193 -0
data/assets/coord
ADDED
|
@@ -0,0 +1,2936 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# coord - shared coordination layer for multi-agent development.
|
|
5
|
+
#
|
|
6
|
+
# Storage: .maf/coordination/ (Taskwarrior board, inbox, locks, exports)
|
|
7
|
+
# Locking: mkdir-based (portable on macOS and Linux; no flock required)
|
|
8
|
+
# Deps: task (Taskwarrior). Ruby parses the JSON itself; no jq needed.
|
|
9
|
+
# Ruby: 3.0+ (uses endless method defs: `def x = expr`). This file will
|
|
10
|
+
# not parse under Ruby 2.x; ensure `ruby` on PATH is 3.0+.
|
|
11
|
+
#
|
|
12
|
+
# Agents should use this wrapper, not raw `task`, so the protocol stays stable.
|
|
13
|
+
require "json"
|
|
14
|
+
require "fileutils"
|
|
15
|
+
require "time"
|
|
16
|
+
require "shellwords"
|
|
17
|
+
require "rbconfig"
|
|
18
|
+
require "optparse"
|
|
19
|
+
require "open3"
|
|
20
|
+
require "pathname"
|
|
21
|
+
begin
|
|
22
|
+
require_relative "../lib/maf/shared/processes"
|
|
23
|
+
require_relative "../lib/maf/shared/project"
|
|
24
|
+
require_relative "../lib/maf/shared/roles"
|
|
25
|
+
require_relative "../lib/maf/shared/git_exclude"
|
|
26
|
+
require_relative "../lib/maf/shared/git_identity"
|
|
27
|
+
rescue LoadError
|
|
28
|
+
abort "coord: the maf shared library is missing. Run maf update."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module Coord
|
|
32
|
+
class Error < StandardError; end
|
|
33
|
+
|
|
34
|
+
# NOTE: MARKER is duplicated in bootstrap.rb on purpose. Both scripts run
|
|
35
|
+
# standalone (coord is copied into projects), so they share no load path.
|
|
36
|
+
MARKER = ">>> multi-agent-flow >>>"
|
|
37
|
+
# The one folder that holds every file of the flow in a project.
|
|
38
|
+
MAF_DIR = ".maf"
|
|
39
|
+
UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
|
40
|
+
|
|
41
|
+
TASKRC_BLOCK = <<~BLOCK
|
|
42
|
+
# >>> multi-agent-flow >>>
|
|
43
|
+
uda.role.type=string
|
|
44
|
+
uda.worker.type=string
|
|
45
|
+
uda.scope.type=string
|
|
46
|
+
uda.taskref.type=string
|
|
47
|
+
uda.goalid.type=string
|
|
48
|
+
# Hide the "TASKRC override" footnote. coord always sets TASKRC.
|
|
49
|
+
verbose=blank,header,footnote,label,new-id,affected,edit,special,project,sync,filter,recur
|
|
50
|
+
# <<< multi-agent-flow <<<
|
|
51
|
+
BLOCK
|
|
52
|
+
|
|
53
|
+
# A claim older than this is presumed abandoned (crashed worker) and can be
|
|
54
|
+
# taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
|
|
55
|
+
DEFAULT_LEASE_TTL = 4 * 3600
|
|
56
|
+
DEFAULT_POLL_INTERVAL = 60
|
|
57
|
+
|
|
58
|
+
# Lead roles plan and talk to the user. They never own a task, so they wait
|
|
59
|
+
# on their inbox, not on the task board.
|
|
60
|
+
LEADS = Maf::Shared::Roles::LEADS
|
|
61
|
+
|
|
62
|
+
# Usage holds the help text of coord.
|
|
63
|
+
module Usage
|
|
64
|
+
TEXT = <<~TEXT
|
|
65
|
+
coord - multi-agent coordination
|
|
66
|
+
|
|
67
|
+
coord init
|
|
68
|
+
coord add --role R --scope S --title T [--project P] [--ref R] [--goal ID]
|
|
69
|
+
coord goal add --title T [--ref R] [--base BRANCH] create a goal, its branch and its worktree
|
|
70
|
+
coord goal list list open goals
|
|
71
|
+
coord goal show ID show a goal and its tasks
|
|
72
|
+
coord goal sync ID merge the base branch into the goal branch
|
|
73
|
+
coord goal done ID close a goal (refused while a task is open or the goal lacks the base head)
|
|
74
|
+
coord goal pr ID push the goal branch as the GitHub bot and open or update its pull request
|
|
75
|
+
coord review-watch [--once] [--interval S]
|
|
76
|
+
send new reviews of goal pull requests to the architect;
|
|
77
|
+
close a goal and run gc --yes after its pull request merges
|
|
78
|
+
coord land ID [--subject TEXT] squash a done task branch into its goal branch, then delete the task branch
|
|
79
|
+
coord reap [--minutes N] release the claims of workers not seen for N minutes
|
|
80
|
+
(default: team.reap_minutes, else 90; a live dispatcher is never released)
|
|
81
|
+
coord gc [--yes] list (or with --yes delete) merged flow branches and finished goal worktrees
|
|
82
|
+
coord show ID show one task: its fields and its annotations
|
|
83
|
+
coord start-task ID check out branch task/<id> from its goal branch
|
|
84
|
+
coord next [ROLE] [--mine] [--wait [--interval S] [--timeout S]]
|
|
85
|
+
coord conflicts list pending tasks with overlapping scopes
|
|
86
|
+
coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
|
|
87
|
+
coord unclaim ID release a claim without finishing it
|
|
88
|
+
coord done ID [--force] complete (refused while the task branch lacks the goal head,
|
|
89
|
+
or while the verify command fails)
|
|
90
|
+
coord annotate ID TEXT...
|
|
91
|
+
coord lesson ID dead_end|corrected TEXT...
|
|
92
|
+
save a lesson of a task to the graph memory: an approach that
|
|
93
|
+
failed (dead_end), or the right way (corrected)
|
|
94
|
+
coord msg [--from A] [--task ID] [--fyi] TO TEXT...
|
|
95
|
+
TO is a role or a worker; a worker's mail goes to its role inbox.
|
|
96
|
+
--task also saves the text as a note on the task.
|
|
97
|
+
--fyi wakes no one: the next run of TO reads the message.
|
|
98
|
+
coord escalate [--task ID] TEXT... report a problem you cannot fix to the project manager
|
|
99
|
+
(copy to the architect)
|
|
100
|
+
coord broadcast [--from A] [--to workers|leads|all] TEXT...
|
|
101
|
+
send to a group of roles (default: workers)
|
|
102
|
+
coord inbox [ROLE] [--peek] [--all] [--wait [--interval S] [--timeout S]]
|
|
103
|
+
coord await [--timeout S] interactive session: arm the stop hook, then end the turn.
|
|
104
|
+
The hook waits for work without model calls (default: 3000s)
|
|
105
|
+
coord hooks [ROLE] show message hooks for a role or all
|
|
106
|
+
coord log [N] show last N coordination events
|
|
107
|
+
coord lock NAME [--ttl SECONDS] [--worker W]
|
|
108
|
+
coord unlock NAME
|
|
109
|
+
coord with-lock NAME [--ttl SECONDS] -- CMD...
|
|
110
|
+
coord worktree ROLE [WORKER] create a git worktree + branch for a role
|
|
111
|
+
coord status
|
|
112
|
+
coord who list workers with their presence (live or gone)
|
|
113
|
+
coord board
|
|
114
|
+
coord export
|
|
115
|
+
coord help
|
|
116
|
+
TEXT
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
COMMANDS = {
|
|
120
|
+
"init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
|
|
121
|
+
"conflicts" => :cmd_conflicts,
|
|
122
|
+
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
|
|
123
|
+
"done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
|
|
124
|
+
"msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
|
|
125
|
+
"log" => :cmd_log, "hooks" => :cmd_hooks,
|
|
126
|
+
"lock" => :cmd_lock, "unlock" => :cmd_unlock, "with-lock" => :cmd_with_lock,
|
|
127
|
+
"worktree" => :cmd_worktree, "goal" => :cmd_goal, "start-task" => :cmd_start_task,
|
|
128
|
+
"land" => :cmd_land, "gc" => :cmd_gc, "reap" => :cmd_reap, "review-watch" => :cmd_review_watch,
|
|
129
|
+
"show" => :cmd_show, "escalate" => :cmd_escalate,
|
|
130
|
+
"status" => :cmd_status, "board" => :cmd_board, "export" => :cmd_export,
|
|
131
|
+
"who" => :cmd_who, "help" => :cmd_help
|
|
132
|
+
}.freeze
|
|
133
|
+
|
|
134
|
+
ADD_OPTS = { "--role" => :role, "--scope" => :scope, "--ref" => :ref,
|
|
135
|
+
"--project" => :project, "--title" => :title, "--goal" => :goal }.freeze
|
|
136
|
+
GOAL_OPTS = { "--title" => :title, "--ref" => :ref, "--base" => :base }.freeze
|
|
137
|
+
|
|
138
|
+
# Wiring lazily builds the collaborators the CLI needs.
|
|
139
|
+
module Wiring
|
|
140
|
+
def paths = @paths ||= Paths.new(@coord_dir)
|
|
141
|
+
def setup = @setup ||= Setup.new(paths, @taskrc)
|
|
142
|
+
def tasks = @tasks ||= Tasks.new
|
|
143
|
+
def conflicts = @conflicts ||= Conflicts.new(tasks)
|
|
144
|
+
def locks = @locks ||= Locks.new(paths)
|
|
145
|
+
def messages = @messages ||= Messages.new(paths, @role)
|
|
146
|
+
def board = @board ||= Board.new(tasks, paths)
|
|
147
|
+
def event_log = @event_log ||= EventLog.new(paths)
|
|
148
|
+
def hooks = @hooks ||= Hooks.new(paths)
|
|
149
|
+
def presence = @presence ||= Presence.new(paths)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Paths resolves every coordination path from one root.
|
|
153
|
+
class Paths
|
|
154
|
+
attr_reader :coord_dir
|
|
155
|
+
|
|
156
|
+
def initialize(coord_dir)
|
|
157
|
+
@coord_dir = coord_dir
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def inbox_dir(role) = File.join(coord_dir, "inbox", role.to_s)
|
|
161
|
+
|
|
162
|
+
# Folders named <role>-<n>, written by older versions for one worker.
|
|
163
|
+
def worker_inbox_dirs(role)
|
|
164
|
+
pattern = /\A#{Regexp.escape(role.to_s)}-\d+\z/
|
|
165
|
+
Dir.glob(File.join(coord_dir, "inbox", "#{role}-*")).select { |dir| File.basename(dir).match?(pattern) }
|
|
166
|
+
end
|
|
167
|
+
def message_hooks_dir = File.join(coord_dir, "message-hooks")
|
|
168
|
+
def hook_path(role) = File.join(message_hooks_dir, "#{role}.sh")
|
|
169
|
+
def locks_dir = File.join(coord_dir, "locks")
|
|
170
|
+
def exports_dir = File.join(coord_dir, "exports")
|
|
171
|
+
def lock_dir(name) = File.join(locks_dir, "#{name}.d")
|
|
172
|
+
def lock_meta(name) = File.join(lock_dir(name), "meta.json")
|
|
173
|
+
def export_path = File.join(exports_dir, "tasks.json")
|
|
174
|
+
def board_path = File.join(exports_dir, "board.md")
|
|
175
|
+
def taskdata_dir = File.join(coord_dir, "taskdata")
|
|
176
|
+
def log_path = File.join(coord_dir, "events.log")
|
|
177
|
+
def hook_log(role) = File.join(message_hooks_dir, "#{role}.log")
|
|
178
|
+
def presence_dir = File.join(coord_dir, "presence")
|
|
179
|
+
def usage_dir = File.join(coord_dir, "usage")
|
|
180
|
+
def manifest_path = File.join(File.dirname(File.expand_path(coord_dir)), "config.json")
|
|
181
|
+
def vault_path = File.join(File.dirname(File.expand_path(coord_dir)), "bin", "vault")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Setup creates the coordination directories and the Taskwarrior UDAs.
|
|
185
|
+
# The Taskwarrior database is project-local (.maf/coordination/taskdata), not the
|
|
186
|
+
# user's global ~/.task, so two projects never share one board. An explicit
|
|
187
|
+
# TASKRC env var still overrides this (see CLI#initialize).
|
|
188
|
+
class Setup
|
|
189
|
+
SUBDIRS = %w[inbox locks exports message-hooks].freeze
|
|
190
|
+
|
|
191
|
+
def initialize(paths, taskrc)
|
|
192
|
+
@paths = paths
|
|
193
|
+
@taskrc = taskrc
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ensure_dirs
|
|
197
|
+
SUBDIRS.each { |sub| FileUtils.mkdir_p(File.join(@paths.coord_dir, sub)) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def ensure_taskrc
|
|
201
|
+
had_marker = File.exist?(@taskrc) && File.read(@taskrc).include?(MARKER)
|
|
202
|
+
FileUtils.mkdir_p(File.dirname(@taskrc))
|
|
203
|
+
FileUtils.touch(@taskrc)
|
|
204
|
+
ensure_data_location(warn_when_external: !had_marker)
|
|
205
|
+
had_marker ? add_missing_udas : append_uda_block
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# An explicit TASKRC pointed outside coord_dir (e.g. a user's own
|
|
211
|
+
# ~/.taskrc) is a deliberate choice to use their own database. Only touch
|
|
212
|
+
# data.location in the taskrc this tool created and owns, never redirect
|
|
213
|
+
# someone else's existing Taskwarrior config to a project-local database.
|
|
214
|
+
def project_local?
|
|
215
|
+
File.expand_path(File.dirname(@taskrc)) == File.expand_path(@paths.coord_dir)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Never redirect an external taskrc (e.g. a personal ~/.taskrc), and warn
|
|
219
|
+
# once that it is used as-is. A project-local taskrc this tool owns but
|
|
220
|
+
# that predates the project-local database (marker present, no
|
|
221
|
+
# data.location) would otherwise fall back to the global ~/.task; add the
|
|
222
|
+
# missing data.location in place.
|
|
223
|
+
def ensure_data_location(warn_when_external:)
|
|
224
|
+
return warn_external if warn_when_external && !project_local?
|
|
225
|
+
|
|
226
|
+
write_data_location if project_local? && !has_data_location?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def warn_external
|
|
230
|
+
warn "coord: TASKRC=#{@taskrc} is outside .maf/coordination/; using its existing database as-is, " \
|
|
231
|
+
"not redirecting it."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def write_data_location
|
|
235
|
+
FileUtils.mkdir_p(@paths.taskdata_dir)
|
|
236
|
+
File.open(@taskrc, "a") do |file|
|
|
237
|
+
file.puts unless file.size.zero?
|
|
238
|
+
file.puts("data.location=#{File.expand_path(@paths.taskdata_dir)}")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def append_uda_block
|
|
243
|
+
File.open(@taskrc, "a") do |file|
|
|
244
|
+
file.puts unless file.size.zero?
|
|
245
|
+
file.puts(TASKRC_BLOCK)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A taskrc from an older coord lacks UDAs and settings added later. Taskwarrior reads an
|
|
250
|
+
# unknown NAME:VALUE as description text, so add each missing line.
|
|
251
|
+
def add_missing_udas
|
|
252
|
+
text = File.read(@taskrc)
|
|
253
|
+
missing = TASKRC_BLOCK.lines.grep(/\A(uda\.|verbose=)/).reject { |line| text.include?(line) }
|
|
254
|
+
File.write(@taskrc, text.sub(/^# <<< multi-agent-flow <<<$/) { missing.join + $& }) unless missing.empty?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def has_data_location?
|
|
258
|
+
File.exist?(@taskrc) && File.read(@taskrc).match?(/^data\.location=/)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Scopes compares path scopes with a component-prefix heuristic.
|
|
263
|
+
# It understands "dir/**" and exact paths, not {} alternation or mid-globs.
|
|
264
|
+
module Scopes
|
|
265
|
+
GLOB = /[*?\[\{]/
|
|
266
|
+
|
|
267
|
+
def self.overlap?(left, right)
|
|
268
|
+
patterns(left).product(patterns(right)).any? { |a, b| pair_overlap?(a, b) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def self.pair_overlap?(left, right)
|
|
272
|
+
# A top-level glob ("*.rb") has an empty base: an empty prefix matches
|
|
273
|
+
# any path, so treating it like a normal prefix would flag it as
|
|
274
|
+
# overlapping every other scope. Only count it as overlap when the
|
|
275
|
+
# pattern is literally the same.
|
|
276
|
+
return left == right if base(left).empty? || base(right).empty?
|
|
277
|
+
|
|
278
|
+
prefix?(base(left), base(right)) || prefix?(base(right), base(left))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.base(scope)
|
|
282
|
+
scope.split("/").take_while { |part| part !~ GLOB }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.prefix?(short, long)
|
|
286
|
+
short == long.first(short.size)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def self.patterns(scope)
|
|
290
|
+
scope.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Conflicts finds overlapping scopes among pending tasks and reports them.
|
|
295
|
+
class Conflicts
|
|
296
|
+
def initialize(tasks)
|
|
297
|
+
@tasks = tasks
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def with(scope, exclude: nil)
|
|
301
|
+
candidates(exclude).select { |task| Scopes.overlap?(task["scope"], scope) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def warn_with(scope, exclude:)
|
|
305
|
+
with(scope, exclude: exclude).each { |task| warn warning(scope, task) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def report
|
|
309
|
+
pairs = all
|
|
310
|
+
return puts("no scope conflicts") if pairs.empty?
|
|
311
|
+
|
|
312
|
+
pairs.each { |a, b| puts line(a, b) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
|
|
317
|
+
def all
|
|
318
|
+
@tasks.pending.combination(2).select { |a, b| Scopes.overlap?(a["scope"], b["scope"]) }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def candidates(exclude)
|
|
322
|
+
@tasks.pending.reject { |task| task["uuid"] == exclude }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def warning(scope, task)
|
|
326
|
+
"coord: scope '#{scope}' overlaps task #{task["uuid"]} " \
|
|
327
|
+
"owned by #{task["role"]} (#{task["scope"]})"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def line(a, b)
|
|
331
|
+
"#{a["role"]} #{a["scope"]} (#{a["uuid"]}) <> #{b["role"]} #{b["scope"]} (#{b["uuid"]})"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Tasks wraps the Taskwarrior CLI. Every query is scoped to one task.
|
|
336
|
+
# Schedule interprets Taskwarrior date fields.
|
|
337
|
+
module Schedule
|
|
338
|
+
# Taskwarrior keeps the `wait` field after the date elapses; it only stops
|
|
339
|
+
# filtering the task out of default reports. Compare the date, not presence.
|
|
340
|
+
def self.waiting?(task)
|
|
341
|
+
wait = task["wait"].to_s
|
|
342
|
+
return false if wait.empty?
|
|
343
|
+
|
|
344
|
+
Time.parse(wait) > Time.now
|
|
345
|
+
rescue ArgumentError
|
|
346
|
+
false
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# TaskCli runs Taskwarrior and parses its output. No domain logic here.
|
|
351
|
+
class TaskCli
|
|
352
|
+
def available?
|
|
353
|
+
@available = system("task", "--version", out: File::NULL, err: File::NULL) if @available.nil?
|
|
354
|
+
@available
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def require!
|
|
358
|
+
raise Error, "Taskwarrior ('task') is not installed. See README (brew install task)." unless available?
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# `--` forces every following word into the description, even one that
|
|
362
|
+
# reads like an attribute ("due:tomorrow", "scope:x"). Without it, a title
|
|
363
|
+
# or annotation matching NAME:VALUE for a built-in or registered UDA is
|
|
364
|
+
# parsed as that attribute and the text is silently lost.
|
|
365
|
+
def create(args)
|
|
366
|
+
require!
|
|
367
|
+
output = capture(args)
|
|
368
|
+
extract_uuid(output) || add_failed(output)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The add may have succeeded even when the uuid could not be read back
|
|
372
|
+
# (extract_uuid queries the created task again). Distinguish the two so a
|
|
373
|
+
# caller does not retry and create a duplicate.
|
|
374
|
+
def add_failed(output)
|
|
375
|
+
id = created_id(output)
|
|
376
|
+
raise Error, "task #{id} was created but its uuid could not be read" if id
|
|
377
|
+
|
|
378
|
+
raise Error, "task add failed: #{last_line(output)}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def modify(id, *args)
|
|
382
|
+
run(id, "modify", *args) || raise(Error, "task modify failed")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def done(id)
|
|
386
|
+
require!
|
|
387
|
+
run(id, "done") || raise(Error, "task done failed")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def stop(id)
|
|
391
|
+
run(id, "stop") || raise(Error, "task stop failed")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def annotate(id, text)
|
|
395
|
+
require!
|
|
396
|
+
run(id, "annotate", "--", text) || raise(Error, "annotate failed")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# `status:pending` hides waited tasks; include them so the board can show
|
|
400
|
+
# a Waiting column. Waited tasks still export status "pending"; the `wait`
|
|
401
|
+
# field is the discriminator.
|
|
402
|
+
def pending
|
|
403
|
+
export(["(", "status:pending", "or", "status:waiting", ")", "export"])
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def task(id)
|
|
407
|
+
export([id, "export"]).first || {}
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
private
|
|
411
|
+
|
|
412
|
+
def run(*args)
|
|
413
|
+
system("task", *args, out: File::NULL, err: File::NULL)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def capture(args)
|
|
417
|
+
`task #{Shellwords.join(args)} 2>&1`
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def export(args)
|
|
421
|
+
raw = `task #{Shellwords.join(args)} 2>/dev/null`
|
|
422
|
+
raw.strip.empty? ? [] : JSON.parse(raw)
|
|
423
|
+
rescue JSON::ParserError
|
|
424
|
+
[]
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Prefer a UUID printed by Taskwarrior; otherwise read the id from
|
|
428
|
+
# "Created task N" and export that one task. Never query the whole database.
|
|
429
|
+
def extract_uuid(output)
|
|
430
|
+
output[UUID_PATTERN] || uuid_from_created(output)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def uuid_from_created(output)
|
|
434
|
+
id = created_id(output)
|
|
435
|
+
id && task(id)["uuid"]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def created_id(output)
|
|
439
|
+
output[/Created task (\d+)/, 1]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def last_line(output)
|
|
443
|
+
output.lines.map(&:strip).reject(&:empty?).last || "no task id returned"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Lease interprets how long a claim is honored without renewal. A claim
|
|
448
|
+
# older than the lease is presumed abandoned (the worker crashed) and
|
|
449
|
+
# becomes claimable again without --force.
|
|
450
|
+
module Lease
|
|
451
|
+
@warned = {}
|
|
452
|
+
|
|
453
|
+
# An unset, empty, non-numeric, or non-positive COORD_LEASE_TTL falls back
|
|
454
|
+
# to the default instead of collapsing to 0 (which would make every claim
|
|
455
|
+
# look instantly expired). Warn once per distinct bad value.
|
|
456
|
+
def self.ttl
|
|
457
|
+
raw = ENV["COORD_LEASE_TTL"].to_s
|
|
458
|
+
return DEFAULT_LEASE_TTL if raw.empty?
|
|
459
|
+
|
|
460
|
+
value = Integer(raw, exception: false)
|
|
461
|
+
value&.positive? ? value : invalid_ttl(raw)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def self.invalid_ttl(raw)
|
|
465
|
+
warn "coord: invalid COORD_LEASE_TTL=#{raw.inspect}; using default #{DEFAULT_LEASE_TTL}s" unless @warned[raw]
|
|
466
|
+
@warned[raw] = true
|
|
467
|
+
DEFAULT_LEASE_TTL
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def self.expired?(task)
|
|
471
|
+
start = task["start"].to_s
|
|
472
|
+
return false if start.empty?
|
|
473
|
+
|
|
474
|
+
Time.parse(start) + ttl <= Time.now
|
|
475
|
+
rescue ArgumentError
|
|
476
|
+
false
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# TaskView formats one task with its annotations. An agent reads the task
|
|
481
|
+
# spec through `coord show`, never through raw `task`.
|
|
482
|
+
module TaskView
|
|
483
|
+
FIELDS = %w[uuid description role scope goalid worker status start].freeze
|
|
484
|
+
|
|
485
|
+
def self.lines(task)
|
|
486
|
+
FIELDS.filter_map { |field| "#{field}: #{task[field]}" if task[field] } + notes(task)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def self.notes(task)
|
|
490
|
+
(task["annotations"] || []).map { |note| "note #{note["entry"]}: #{note["description"]}" }
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Tasks exposes the task operations the flow needs.
|
|
495
|
+
class Tasks
|
|
496
|
+
# The previous owner of a task, split so a steal notice can go to the
|
|
497
|
+
# role's inbox (what agents poll) while still naming the exact worker.
|
|
498
|
+
Holder = Struct.new(:role, :worker)
|
|
499
|
+
|
|
500
|
+
# The goal attribute is `goalid`, not `goal`: Taskwarrior drops the value
|
|
501
|
+
# `role:goal` when a UDA has the same name as the value.
|
|
502
|
+
ATTRIBUTES = { role: "role", scope: "scope", ref: "taskref", project: "project", goal: "goalid" }.freeze
|
|
503
|
+
|
|
504
|
+
def initialize
|
|
505
|
+
@cli = TaskCli.new
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def available? = @cli.available?
|
|
509
|
+
|
|
510
|
+
def add(title:, **opts)
|
|
511
|
+
@cli.create(add_args(title, opts))
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def next_for(role, worker = nil)
|
|
515
|
+
pending.select { |task| task["role"] == role && claimable?(task) && actionable?(task) && !held_by?(task, worker) }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def mine(role, worker)
|
|
519
|
+
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Returns the prior holder (nil if there was none) so the caller can warn
|
|
523
|
+
# or notify them. Refuses only an active (non-expired) claim by someone
|
|
524
|
+
# else, unless --force.
|
|
525
|
+
def claim(id, worker, force: false)
|
|
526
|
+
@cli.require!
|
|
527
|
+
task = @cli.task(id)
|
|
528
|
+
holder = task["worker"].to_s
|
|
529
|
+
refuse_steal(id, holder) if active_claim?(task, holder, worker) && !force
|
|
530
|
+
@cli.modify(id, "worker:#{worker}", "start:now") && (Holder.new(task["role"].to_s, holder) unless holder.empty?)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def unclaim(id)
|
|
534
|
+
@cli.require!
|
|
535
|
+
@cli.stop(id)
|
|
536
|
+
@cli.modify(id, "worker:")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def find(id) = @cli.task(id)
|
|
540
|
+
# A short id of digits only would read as a task number. The filter avoids that.
|
|
541
|
+
def find_short(short) = @cli.task("uuid.startswith:#{short}")
|
|
542
|
+
def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
|
|
543
|
+
def done(id) = @cli.done(id)
|
|
544
|
+
def annotate(id, text) = @cli.annotate(id, text)
|
|
545
|
+
def pending = @pending ||= @cli.pending
|
|
546
|
+
def export_to(path) = File.write(path, JSON.pretty_generate(pending))
|
|
547
|
+
|
|
548
|
+
private
|
|
549
|
+
|
|
550
|
+
def actionable?(task) = !Schedule.waiting?(task)
|
|
551
|
+
|
|
552
|
+
def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
|
|
553
|
+
|
|
554
|
+
def claimable?(task) = task["worker"].to_s.empty? || Lease.expired?(task)
|
|
555
|
+
|
|
556
|
+
def active_claim?(task, holder, worker)
|
|
557
|
+
!holder.empty? && holder != worker && !Lease.expired?(task)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def refuse_steal(id, holder)
|
|
561
|
+
raise Error, "task #{id} is already claimed by #{holder} (use --force to steal)"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def add_args(title, opts)
|
|
565
|
+
["add"] + attribute_args(opts) + ["--", title]
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def attribute_args(opts)
|
|
569
|
+
ATTRIBUTES.filter_map { |key, attr| "#{attr}:#{opts[key]}" if opts[key] }
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Locks provides mkdir-based mutual exclusion with TTL metadata.
|
|
574
|
+
class Locks
|
|
575
|
+
def initialize(paths)
|
|
576
|
+
@paths = paths
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def lock(name, ttl:, worker:)
|
|
580
|
+
acquire!(name, ttl: ttl, worker: worker)
|
|
581
|
+
puts "locked #{name} by #{worker} (ttl #{ttl}s)"
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def unlock(name)
|
|
585
|
+
release(name)
|
|
586
|
+
puts "unlocked #{name}"
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def with_lock(name, ttl:, command:, worker:)
|
|
590
|
+
acquire!(name, ttl: ttl, worker: worker)
|
|
591
|
+
run_locked(name, command)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Short critical section (e.g. claiming a task). Retries briefly instead of
|
|
595
|
+
# failing when two workers contend.
|
|
596
|
+
def with_mutex(name, worker:, &block)
|
|
597
|
+
acquire_with_retry(name, worker)
|
|
598
|
+
locked(name, &block)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The lock is released only after it was taken.
|
|
602
|
+
def locked(name)
|
|
603
|
+
yield
|
|
604
|
+
ensure
|
|
605
|
+
release(name)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def acquire!(name, ttl:, worker:)
|
|
609
|
+
reclaim_if_stale(name)
|
|
610
|
+
Dir.mkdir(@paths.lock_dir(name))
|
|
611
|
+
write_meta(name, ttl, worker)
|
|
612
|
+
rescue Errno::EEXIST
|
|
613
|
+
raise Error, "lock #{name} busy"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def release(name)
|
|
617
|
+
FileUtils.rm_rf(@paths.lock_dir(name))
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
private
|
|
621
|
+
|
|
622
|
+
def acquire_with_retry(name, worker, attempts: 20)
|
|
623
|
+
attempts.times { return true if try_acquire(name, worker) }
|
|
624
|
+
raise Error, "could not lock #{name}"
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def try_acquire(name, worker)
|
|
628
|
+
acquire!(name, ttl: 30, worker: worker) || true
|
|
629
|
+
rescue Error
|
|
630
|
+
sleep 0.05
|
|
631
|
+
false
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def run_locked(name, command)
|
|
635
|
+
system(*command)
|
|
636
|
+
ensure
|
|
637
|
+
release(name)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def reclaim_if_stale(name)
|
|
641
|
+
return unless Dir.exist?(@paths.lock_dir(name))
|
|
642
|
+
raise Error, "locked by #{holder(name)} (coord unlock #{name})" unless stale?(name)
|
|
643
|
+
|
|
644
|
+
evict(name)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Rename is atomic: of several processes that see the same stale lock, one
|
|
648
|
+
# renames it away. The others get ENOENT and never delete a fresh lock.
|
|
649
|
+
# Metadata lives inside the lock dir, so it moves with the rename.
|
|
650
|
+
def evict(name)
|
|
651
|
+
tomb = "#{@paths.lock_dir(name)}.stale-#{Process.pid}-#{Time.now.to_f}"
|
|
652
|
+
File.rename(@paths.lock_dir(name), tomb)
|
|
653
|
+
FileUtils.rm_rf(tomb)
|
|
654
|
+
rescue Errno::ENOENT
|
|
655
|
+
nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# A lock whose metadata is missing is either a crashed writer or a
|
|
659
|
+
# concurrent writer between mkdir and write_meta. Treat it as stale only
|
|
660
|
+
# after a short grace period, so a racing acquire cannot delete a lock
|
|
661
|
+
# that was just created.
|
|
662
|
+
GRACE_PERIOD = 5
|
|
663
|
+
|
|
664
|
+
def stale?(name)
|
|
665
|
+
meta = read_meta(name)
|
|
666
|
+
return meta["ts"].to_i + meta["ttl"].to_i <= Time.now.to_i unless meta.empty?
|
|
667
|
+
|
|
668
|
+
lock_age(name) > GRACE_PERIOD
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def lock_age(name)
|
|
672
|
+
Time.now - File.mtime(@paths.lock_dir(name))
|
|
673
|
+
rescue Errno::ENOENT
|
|
674
|
+
Float::INFINITY
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def holder(name)
|
|
678
|
+
read_meta(name)["worker"] || "unknown"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def read_meta(name)
|
|
682
|
+
JSON.parse(File.read(@paths.lock_meta(name)))
|
|
683
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
684
|
+
{}
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def write_meta(name, ttl, worker)
|
|
688
|
+
File.write(@paths.lock_meta(name), JSON.generate(worker: worker, ts: Time.now.to_i, ttl: ttl))
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Messages writes inbox files and archives them when read.
|
|
693
|
+
class Messages
|
|
694
|
+
def initialize(paths, default_from)
|
|
695
|
+
@paths = paths
|
|
696
|
+
@from = default_from
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# A message for one worker goes to the inbox of its role. The header
|
|
700
|
+
# names the worker, so the reader knows who the message is for.
|
|
701
|
+
# An FYI message (fyi: true) waits in the inbox and wakes no one. The
|
|
702
|
+
# next run of the role reads it together with the next waking message.
|
|
703
|
+
def send_message(to, from, text, worker: nil, fyi: false)
|
|
704
|
+
path = new_message_path(to, fyi)
|
|
705
|
+
File.write(path, body(to, from, text, worker))
|
|
706
|
+
puts "msg -> #{path}"
|
|
707
|
+
path
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def inbox(role, peek: false, all: false)
|
|
711
|
+
files = files(role, all: all)
|
|
712
|
+
return puts("no messages for #{role}") if files.empty?
|
|
713
|
+
|
|
714
|
+
files.each { |file| read_message(file, peek) }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Paths of the agent's message files, oldest first. Shared with the
|
|
718
|
+
# `--wait` poller so "is there mail yet" tests exactly what `inbox` reads.
|
|
719
|
+
def files(role, all: false)
|
|
720
|
+
fold_worker_inboxes(role)
|
|
721
|
+
dir = @paths.inbox_dir(role)
|
|
722
|
+
Dir.glob(File.join(dir, all ? "**" : "", "*.md")).sort
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# The unread messages that justify a model call: every message except FYI.
|
|
726
|
+
def wake_files(role) = files(role).reject { |file| Messages.fyi?(file) }
|
|
727
|
+
|
|
728
|
+
# NOTE: assets/dispatcher and board-watch.rb read the same name mark.
|
|
729
|
+
def self.fyi?(path) = File.basename(path).include?(".fyi.")
|
|
730
|
+
|
|
731
|
+
# Broadcast sends one message to each role in ROLES. Returns the list of
|
|
732
|
+
# [role, path] pairs so the caller can fire hooks for each.
|
|
733
|
+
def broadcast(from, text, roles)
|
|
734
|
+
results = roles.map { |role| [role, send_message(role, from, text)] }
|
|
735
|
+
puts "broadcast -> #{roles.size} role#{"s" if roles.size != 1}"
|
|
736
|
+
results
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
private
|
|
740
|
+
|
|
741
|
+
def new_message_path(to, fyi)
|
|
742
|
+
dir = @paths.inbox_dir(to)
|
|
743
|
+
FileUtils.mkdir_p(dir)
|
|
744
|
+
File.join(dir, "#{stamp}-#{Process.pid}#{".fyi" if fyi}.md")
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def body(to, from, text, worker)
|
|
748
|
+
worker_line = worker ? "# for: #{worker}\n" : ""
|
|
749
|
+
"# to: #{to}\n#{worker_line}# from: #{from}\n# at: #{Time.now.utc.iso8601}\n\n#{text}\n"
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Older versions wrote a message for a worker to inbox/<worker>/. No
|
|
753
|
+
# session reads that folder. Move its unread messages to the role inbox.
|
|
754
|
+
def fold_worker_inboxes(role)
|
|
755
|
+
@paths.worker_inbox_dirs(role).each do |dir|
|
|
756
|
+
Dir.glob(File.join(dir, "*.md")).each { |file| move_to_role(file, role) }
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def move_to_role(file, role)
|
|
761
|
+
FileUtils.mkdir_p(@paths.inbox_dir(role))
|
|
762
|
+
FileUtils.mv(file, File.join(@paths.inbox_dir(role), File.basename(file)))
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def stamp
|
|
766
|
+
Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def read_message(file, peek)
|
|
770
|
+
puts "== #{file} =="
|
|
771
|
+
puts File.read(file)
|
|
772
|
+
puts
|
|
773
|
+
archive(file) unless peek || file.include?("/read/")
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def archive(file)
|
|
777
|
+
dir = File.join(File.dirname(file), "read")
|
|
778
|
+
FileUtils.mkdir_p(dir)
|
|
779
|
+
FileUtils.mv(file, File.join(dir, File.basename(file)))
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Hooks runs a per-role shell script when a message is delivered. The hook
|
|
784
|
+
# is a plain user-authored script at .maf/coordination/message-hooks/<role>.sh — coord
|
|
785
|
+
# does not know or care what harness the agent runs in. It starts a fresh
|
|
786
|
+
# one-shot, sends a notification, or does nothing at all. The hook receives
|
|
787
|
+
# COORD_ROLE, COORD_FROM, and COORD_MSG_FILE in its environment, and the
|
|
788
|
+
# message file path as $1. If the hook is absent, coord is silent — it just
|
|
789
|
+
# writes the inbox file, and the agent picks it up on its next `coord inbox`.
|
|
790
|
+
#
|
|
791
|
+
# The hook runs detached in its own process group, so a slow hook (one that
|
|
792
|
+
# starts a whole agent run) never blocks the sender. Its stdout and stderr
|
|
793
|
+
# go to .maf/coordination/message-hooks/<role>.log.
|
|
794
|
+
class Hooks
|
|
795
|
+
def initialize(paths)
|
|
796
|
+
@paths = paths
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def run(role, from:, message_path:)
|
|
800
|
+
hook = @paths.hook_path(role)
|
|
801
|
+
return false unless File.executable?(hook)
|
|
802
|
+
|
|
803
|
+
Process.detach(spawn_hook(hook, role, from, message_path))
|
|
804
|
+
true
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
private
|
|
808
|
+
|
|
809
|
+
def spawn_hook(hook, role, from, message_path)
|
|
810
|
+
env = { "COORD_ROLE" => role.to_s, "COORD_FROM" => from.to_s, "COORD_MSG_FILE" => message_path }
|
|
811
|
+
log = @paths.hook_log(role)
|
|
812
|
+
Process.spawn(env, hook, message_path, out: [log, "a"], err: [:child, :out], pgroup: true)
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Presence records which workers run now, in .maf/coordination/presence/<worker>.json.
|
|
817
|
+
# `maf start` exports COORD_SESSION_PID (the harness pid, kept across exec),
|
|
818
|
+
# so every coord call from that session can record it. The dispatcher writes
|
|
819
|
+
# its own file (mode "dispatch"); a dispatched run (COORD_DISPATCHED) writes
|
|
820
|
+
# none. A worker is live while its pid runs. A session in a container shows
|
|
821
|
+
# as gone, because the host cannot see its pid.
|
|
822
|
+
class Presence
|
|
823
|
+
def initialize(paths)
|
|
824
|
+
@paths = paths
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def touch(role, worker, env)
|
|
828
|
+
pid = env["COORD_SESSION_PID"].to_i
|
|
829
|
+
return unless pid.positive? && recordable?(role, worker, env)
|
|
830
|
+
|
|
831
|
+
write(worker, "role" => role, "worker" => worker, "mode" => "session", "pid" => pid,
|
|
832
|
+
"started" => Maf::Shared::Processes.started_at(pid))
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def live?(role) = entries.any? { |entry| entry["role"] == role && self.class.live_entry?(entry) }
|
|
836
|
+
|
|
837
|
+
def report
|
|
838
|
+
return puts("no presence records") if entries.empty?
|
|
839
|
+
|
|
840
|
+
entries.each { |entry| puts line(entry) }
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
LEGACY_LIVE_WINDOW = 3600
|
|
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
|
|
857
|
+
|
|
858
|
+
private
|
|
859
|
+
|
|
860
|
+
def recordable?(role, worker, env)
|
|
861
|
+
!env["COORD_DISPATCHED"] && ([role, worker] & ["unknown", ""]).empty? && Dir.exist?(@paths.coord_dir)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def write(worker, data)
|
|
865
|
+
FileUtils.mkdir_p(@paths.presence_dir)
|
|
866
|
+
path = File.join(@paths.presence_dir, "#{worker}.json")
|
|
867
|
+
File.write(path, JSON.generate(data.merge("seen_at" => Time.now.utc.iso8601)))
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def entries = Dir.glob(File.join(@paths.presence_dir, "*.json")).sort.filter_map { |path| read(path) }
|
|
871
|
+
|
|
872
|
+
def read(path)
|
|
873
|
+
JSON.parse(File.read(path))
|
|
874
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
875
|
+
nil
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def line(entry)
|
|
879
|
+
state = self.class.live_entry?(entry) ? "live" : "gone"
|
|
880
|
+
fields = entry.values_at("worker", "role", "mode") + [state, "pid=#{entry["pid"]}", "seen=#{entry["seen_at"]}"]
|
|
881
|
+
fields.join("\t")
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# EventLog is a shared, append-only log in .maf/coordination/events.log. Every
|
|
886
|
+
# claim, completion, unclaim, message, and broadcast appends one line so any
|
|
887
|
+
# agent can see the history of coordination without reading individual inbox
|
|
888
|
+
# files. The log is not the source of truth (Taskwarrior is); it is a
|
|
889
|
+
# convenience projection for "what happened while I was working."
|
|
890
|
+
class EventLog
|
|
891
|
+
DETAIL_LIMIT = 80
|
|
892
|
+
|
|
893
|
+
def initialize(paths)
|
|
894
|
+
@paths = paths
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def append(action, worker, detail)
|
|
898
|
+
FileUtils.mkdir_p(@paths.coord_dir)
|
|
899
|
+
line = [Time.now.utc.iso8601, action, worker, clean(detail)].join("\t")
|
|
900
|
+
File.open(@paths.log_path, "a") { |file| file.puts(line) }
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def read(lines = nil)
|
|
904
|
+
path = @paths.log_path
|
|
905
|
+
return puts("no events yet") unless File.exist?(path)
|
|
906
|
+
|
|
907
|
+
all = File.readlines(path)
|
|
908
|
+
recent = lines ? all.last(lines) : all
|
|
909
|
+
recent.each { |line| print line }
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
private
|
|
913
|
+
|
|
914
|
+
# Tabs separate fields and newlines separate events, so neither may
|
|
915
|
+
# appear inside a field.
|
|
916
|
+
def clean(detail)
|
|
917
|
+
detail.to_s.gsub(/\s+/, " ").strip.slice(0, DETAIL_LIMIT)
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# Roster lists every role coord knows about: roles that own a pending task,
|
|
922
|
+
# plus roles declared in the project's .maf/config.json (written by
|
|
923
|
+
# maf add). The manifest sits next to .maf/coordination/, so a worktree
|
|
924
|
+
# that points COORD_DIR at the main checkout still finds it.
|
|
925
|
+
class Roster
|
|
926
|
+
def initialize(paths, tasks)
|
|
927
|
+
@paths = paths
|
|
928
|
+
@tasks = tasks
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def roles
|
|
932
|
+
(task_roles + manifest_roles).uniq.sort
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
private
|
|
936
|
+
|
|
937
|
+
def task_roles
|
|
938
|
+
@tasks.pending.map { |task| task["role"] }.compact - [Goals::ROLE]
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
def manifest_roles = Manifest.new(@paths.manifest_path).agents.map { |entry| entry["role"] }.compact
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# Manifest reads .maf/config.json, which `maf add` writes. A project
|
|
945
|
+
# without maf has no manifest; every reader then gets empty defaults.
|
|
946
|
+
class Manifest
|
|
947
|
+
def initialize(path)
|
|
948
|
+
@data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
949
|
+
rescue JSON::ParserError
|
|
950
|
+
@data = {}
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def agents = @data.fetch("agents", [])
|
|
954
|
+
def base_branch = @data["base_branch"]
|
|
955
|
+
def verify = @data["verify"].to_s
|
|
956
|
+
def copy_to_worktree = Array(@data["copy_to_worktree"]).map(&:to_s)
|
|
957
|
+
def github = @data.fetch("github", {})
|
|
958
|
+
def reap_minutes = Integer(@data.dig("team", "reap_minutes").to_s, exception: false) || Reaper::DEFAULT_MINUTES
|
|
959
|
+
def harnesses = agents.map { |entry| entry["harness"] }.compact.uniq
|
|
960
|
+
|
|
961
|
+
def harnesses_for(role) = agents.select { |entry| entry["role"] == role }.map { |entry| entry["harness"] }
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Audiences names the role groups that `coord broadcast --to` accepts.
|
|
965
|
+
# Leads plan and talk to the user. Workers do the tasks. A notice about
|
|
966
|
+
# ports or test databases is for workers only.
|
|
967
|
+
module Audiences
|
|
968
|
+
PICK = {
|
|
969
|
+
"workers" => ->(role) { !LEADS.include?(role) },
|
|
970
|
+
"leads" => ->(role) { LEADS.include?(role) },
|
|
971
|
+
"all" => ->(_role) { true }
|
|
972
|
+
}.freeze
|
|
973
|
+
|
|
974
|
+
def self.pick(name)
|
|
975
|
+
PICK.fetch(name) { raise Error, "unknown audience '#{name}' (use #{PICK.keys.join(", ")})" }
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# TokenTotals reads the token usage that the dispatcher adds up per worker
|
|
980
|
+
# in .maf/coordination/usage/<worker>.json. A broken file is skipped.
|
|
981
|
+
class TokenTotals
|
|
982
|
+
def initialize(paths)
|
|
983
|
+
@paths = paths
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def lines = Dir.glob(File.join(@paths.usage_dir, "*.json")).sort.filter_map { |path| line(path) }
|
|
987
|
+
|
|
988
|
+
private
|
|
989
|
+
|
|
990
|
+
def line(path)
|
|
991
|
+
data = JSON.parse(File.read(path))
|
|
992
|
+
"tokens #{File.basename(path, ".json")}: input=#{data["input_tokens"].to_i} " \
|
|
993
|
+
"cached=#{data["cached_input_tokens"].to_i} output=#{data["output_tokens"].to_i} runs=#{data["runs"].to_i}"
|
|
994
|
+
rescue JSON::ParserError
|
|
995
|
+
nil
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# GraphAge asks `vault age --json` for the graph age: the commits since the
|
|
1000
|
+
# graph build. Without the vault script or on a failure it gives no line.
|
|
1001
|
+
class GraphAge
|
|
1002
|
+
def initialize(paths)
|
|
1003
|
+
@paths = paths
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def line
|
|
1007
|
+
output = IO.popen([RbConfig.ruby, @paths.vault_path, "age", "--json"], chdir: root, err: File::NULL, &:read)
|
|
1008
|
+
data = JSON.parse(output)
|
|
1009
|
+
data["commits"] ? "graph age: #{data["commits"]} commits (#{data["state"]})" : "graph age: #{data["state"]}"
|
|
1010
|
+
rescue SystemCallError, JSON::ParserError
|
|
1011
|
+
nil
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
private
|
|
1015
|
+
|
|
1016
|
+
def root = File.dirname(File.dirname(File.expand_path(@paths.coord_dir)))
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# WorkMemory keeps what the team learned after a task closes. `graphify
|
|
1020
|
+
# save-result` writes one note to graphify-out/memory/. The note cites the graph
|
|
1021
|
+
# nodes of the files that the task branch changed. `graphify reflect` then
|
|
1022
|
+
# rebuilds graphify-out/reflections/LESSONS.md from all notes, and the
|
|
1023
|
+
# dispatcher puts its dead ends and corrections into each task prompt.
|
|
1024
|
+
# Without a graph, it saves nothing.
|
|
1025
|
+
class WorkMemory
|
|
1026
|
+
BRANCH = "maf/memory"
|
|
1027
|
+
ANSWER_LIMIT = 2000
|
|
1028
|
+
NODE_LIMIT = 20
|
|
1029
|
+
|
|
1030
|
+
def self.notes(task)
|
|
1031
|
+
texts = (task["annotations"] || []).map { |note| note["description"].to_s }
|
|
1032
|
+
texts.reject { |text| text.start_with?("LANDED:") }.join("\n")
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def initialize(root)
|
|
1036
|
+
@root = root
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Returns false when there is no graph or graphify fails.
|
|
1040
|
+
def save(task, outcome, answer, correction = nil)
|
|
1041
|
+
return false unless File.exist?(graph)
|
|
1042
|
+
return false unless write_note(task, outcome, answer.to_s[0, ANSWER_LIMIT], correction)
|
|
1043
|
+
|
|
1044
|
+
commit("memory: #{outcome}: #{task["description"]}")
|
|
1045
|
+
reflect
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
private
|
|
1049
|
+
|
|
1050
|
+
def write_note(task, outcome, answer, correction)
|
|
1051
|
+
graphify("save-result", "--question", question(task, outcome, answer), "--outcome", outcome, "--answer", answer,
|
|
1052
|
+
"--memory-dir", folder("memory"), *correction_args(correction), *nodes_args(task))
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# graphify-out/memory/ can be a worktree of the branch maf/memory (ADR 0006).
|
|
1056
|
+
# Each note is one commit there. A commit fails while another one runs. The
|
|
1057
|
+
# note then stays, and the next commit takes it.
|
|
1058
|
+
def commit(message)
|
|
1059
|
+
dir = folder("memory")
|
|
1060
|
+
Git.run(dir, "add", "-A") && Git.run(dir, "commit", "-q", "-m", message) if File.exist?(File.join(dir, ".git"))
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def reflect
|
|
1064
|
+
graphify("reflect", "--graph", graph, "--memory-dir", folder("memory"), "--out", folder("reflections/LESSONS.md"))
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
# LESSONS.md lists a dead end by its question only. The question keeps the
|
|
1068
|
+
# reason, so the reason reaches the prompts.
|
|
1069
|
+
def question(task, outcome, answer)
|
|
1070
|
+
outcome == "dead_end" ? "#{task["description"]}: #{answer}" : task["description"].to_s
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
def correction_args(text) = text ? ["--correction", text] : []
|
|
1074
|
+
def graph = folder("graph.json")
|
|
1075
|
+
def folder(name) = File.join(@root, "graphify-out", name)
|
|
1076
|
+
|
|
1077
|
+
# graphify takes paths from the arguments, never from GRAPHIFY_OUT.
|
|
1078
|
+
def graphify(*args)
|
|
1079
|
+
system({ "GRAPHIFY_OUT" => nil }, "graphify", *args, chdir: @root, out: File::NULL, err: File::NULL)
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# --nodes takes the rest of the arguments, so it comes last.
|
|
1083
|
+
def nodes_args(task)
|
|
1084
|
+
ids = node_ids(changed_files(task)).first(NODE_LIMIT)
|
|
1085
|
+
ids.empty? ? [] : ["--nodes", *ids]
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
def changed_files(task)
|
|
1089
|
+
return [] if task["goalid"].to_s.empty?
|
|
1090
|
+
|
|
1091
|
+
range = "#{Goals.branch(task["goalid"])}...task/#{Goals.short(task["uuid"])}"
|
|
1092
|
+
Git.out(@root, "diff", "--name-only", range).lines(chomp: true)
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def node_ids(files)
|
|
1096
|
+
nodes = files.empty? ? [] : JSON.parse(File.read(graph))["nodes"] || []
|
|
1097
|
+
files.filter_map { |file| nodes.find { |node| file_node?(node, file) }&.fetch("id", nil) }
|
|
1098
|
+
rescue JSON::ParserError
|
|
1099
|
+
[]
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# A file node has the file name as label. graphify may store the source path
|
|
1103
|
+
# relative to the project or absolute.
|
|
1104
|
+
def file_node?(node, file)
|
|
1105
|
+
node["label"] == File.basename(file) && [file, File.join(@root, file)].include?(node["source_file"])
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# Board renders status summaries and the Obsidian kanban file.
|
|
1110
|
+
class Board
|
|
1111
|
+
SECTIONS = {
|
|
1112
|
+
"Backlog" => ->(task) { !Schedule.waiting?(task) && !task["start"] },
|
|
1113
|
+
"In progress" => ->(task) { !Schedule.waiting?(task) && task["start"] },
|
|
1114
|
+
"Waiting" => ->(task) { Schedule.waiting?(task) }
|
|
1115
|
+
}.freeze
|
|
1116
|
+
|
|
1117
|
+
def initialize(tasks, paths)
|
|
1118
|
+
@tasks = tasks
|
|
1119
|
+
@paths = paths
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
def status
|
|
1123
|
+
rows = grouped
|
|
1124
|
+
rows.empty? ? puts("no tasks") : rows.each { |role, list| puts summary_line(role, list) }
|
|
1125
|
+
TokenTotals.new(@paths).lines.each { |line| puts line }
|
|
1126
|
+
GraphAge.new(@paths).line&.then { |line| puts line }
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
def next_tasks(role, worker = nil)
|
|
1130
|
+
list = @tasks.next_for(role, worker)
|
|
1131
|
+
return puts("no unclaimed tasks for #{role}") if list.empty?
|
|
1132
|
+
|
|
1133
|
+
list.each { |task| puts task_line(task) }
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
def mine_tasks(role, worker)
|
|
1137
|
+
list = @tasks.mine(role, worker)
|
|
1138
|
+
return puts("no in-progress tasks for #{worker}") if list.empty?
|
|
1139
|
+
|
|
1140
|
+
list.each { |task| puts task_line(task) }
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
def board
|
|
1144
|
+
File.write(@paths.board_path, board_body)
|
|
1145
|
+
puts "board -> #{@paths.board_path}"
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
private
|
|
1149
|
+
|
|
1150
|
+
def summary_line(role, list)
|
|
1151
|
+
counts = list.group_by { |task| bucket(task) }.transform_values(&:size)
|
|
1152
|
+
"#{role}: #{counts.map { |key, value| "#{key}=#{value}" }.join(" ")}"
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
def task_line(task)
|
|
1156
|
+
[task["uuid"], task["description"], task["scope"], task["worker"]].compact.join("\t")
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
def grouped
|
|
1160
|
+
@tasks.pending.group_by { |task| task["role"] || "unassigned" }.sort.to_h
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def bucket(task)
|
|
1164
|
+
return "waiting" if Schedule.waiting?(task)
|
|
1165
|
+
|
|
1166
|
+
task["start"] ? "in-progress" : "backlog"
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
# The `kanban-plugin: basic` frontmatter is what the Obsidian Kanban
|
|
1170
|
+
# community plugin (mgmeyers/obsidian-kanban) checks for to render this
|
|
1171
|
+
# file as a board instead of a plain markdown note. Without it, `##`
|
|
1172
|
+
# headings and `- [ ]` items still read fine as an outline, but Obsidian
|
|
1173
|
+
# never offers the drag-and-drop board view for the file.
|
|
1174
|
+
def board_body
|
|
1175
|
+
frontmatter = "---\nkanban-plugin: basic\n---\n\n"
|
|
1176
|
+
frontmatter + SECTIONS.map { |title, pick| section(title, @tasks.pending.select(&pick)) }.join
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def section(title, list)
|
|
1180
|
+
"## #{title}\n\n#{list.map { |task| card(task) }.join("\n")}\n\n"
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
def card(task)
|
|
1184
|
+
scope = task["scope"].to_s
|
|
1185
|
+
scope_part = scope.empty? ? "" : " `#{scope}`"
|
|
1186
|
+
"- [ ] #{owner_label(task)} \u2014 #{task["description"]}#{scope_part}"
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def owner_label(task)
|
|
1190
|
+
worker = task["worker"].to_s
|
|
1191
|
+
worker.empty? ? (task["role"] || "unassigned") : "#{task["role"]}/#{worker}"
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# Git wraps the few git calls that goals and task branches need.
|
|
1196
|
+
module Git
|
|
1197
|
+
def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
|
|
1198
|
+
def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
|
|
1199
|
+
def self.clean?(dir) = `git -C #{Shellwords.escape(dir)} status --porcelain --untracked-files=no`.strip.empty?
|
|
1200
|
+
def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
|
|
1201
|
+
def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
|
|
1202
|
+
def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
|
|
1203
|
+
def self.branches(dir, pattern)
|
|
1204
|
+
out(dir, "branch", "--list", pattern, "--format=%(refname:short)").lines(chomp: true)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# With an origin, the remote base branch is the truth: a merged pull
|
|
1208
|
+
# request lands there first. Without an origin, the local branch counts.
|
|
1209
|
+
def self.base_ref(dir, base) = run(dir, "fetch", "-q", "origin", base) ? "origin/#{base}" : base
|
|
1210
|
+
|
|
1211
|
+
# Maps each checked-out branch to the worktree that holds it.
|
|
1212
|
+
def self.checked_out(dir)
|
|
1213
|
+
blocks = out(dir, "worktree", "list", "--porcelain").split("\n\n")
|
|
1214
|
+
blocks.filter_map do |block|
|
|
1215
|
+
branch = block[%r{^branch refs/heads/(.+)$}, 1]
|
|
1216
|
+
[branch, block[/^worktree (.+)$/, 1]] if branch
|
|
1217
|
+
end.to_h
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
# The base branch is where each goal starts: the manifest's base_branch,
|
|
1221
|
+
# else the branch that origin/HEAD names, else main.
|
|
1222
|
+
def self.base_branch(dir, manifest)
|
|
1223
|
+
configured = manifest.base_branch
|
|
1224
|
+
return configured unless configured.to_s.empty?
|
|
1225
|
+
|
|
1226
|
+
remote = `git -C #{Shellwords.escape(dir)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
|
|
1227
|
+
remote.delete_prefix("origin/").then { |name| name.empty? ? "main" : name }
|
|
1228
|
+
end
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
# Goals tracks user-visible outcomes above tasks. A goal is a task with
|
|
1232
|
+
# role "goal". Its tasks store the goal's uuid in the `goal` attribute.
|
|
1233
|
+
# Each goal has its own branch goal/<short id> from the base branch, so
|
|
1234
|
+
# one goal never builds on top of another goal.
|
|
1235
|
+
class Goals
|
|
1236
|
+
ROLE = "goal"
|
|
1237
|
+
|
|
1238
|
+
def self.short(uuid) = uuid.to_s[0, 8]
|
|
1239
|
+
def self.branch(uuid) = "goal/#{short(uuid)}"
|
|
1240
|
+
def self.slug(uuid) = "goal-#{short(uuid)}"
|
|
1241
|
+
|
|
1242
|
+
def initialize(tasks)
|
|
1243
|
+
@tasks = tasks
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
def find!(id)
|
|
1247
|
+
goal = @tasks.find(id)
|
|
1248
|
+
raise Error, "no goal #{id}" unless goal["role"] == ROLE
|
|
1249
|
+
|
|
1250
|
+
goal
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def list
|
|
1254
|
+
goals = @tasks.pending.select { |task| task["role"] == ROLE }
|
|
1255
|
+
return puts("no open goals") if goals.empty?
|
|
1256
|
+
|
|
1257
|
+
goals.each { |goal| puts goal_line(goal) }
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
def show(id)
|
|
1261
|
+
goal = find!(id)
|
|
1262
|
+
puts goal_line(goal)
|
|
1263
|
+
list = @tasks.for_goal(goal["uuid"])
|
|
1264
|
+
list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
def done(id)
|
|
1268
|
+
goal = find!(id)
|
|
1269
|
+
refuse_open!(goal)
|
|
1270
|
+
@tasks.done(goal["uuid"])
|
|
1271
|
+
goal
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
def refuse_open!(goal)
|
|
1275
|
+
open = @tasks.for_goal(goal["uuid"]).size
|
|
1276
|
+
raise Error, "goal #{goal["uuid"]} has #{open} open task#{"s" if open != 1}" if open.positive?
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
private
|
|
1280
|
+
|
|
1281
|
+
def goal_line(goal)
|
|
1282
|
+
open = @tasks.for_goal(goal["uuid"]).size
|
|
1283
|
+
[goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
def task_line(task)
|
|
1287
|
+
state = task["start"] ? "in-progress" : "backlog"
|
|
1288
|
+
" " + [task["uuid"], task["role"], state, task["worker"], task["description"]].compact.join("\t")
|
|
1289
|
+
end
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# TaskBranch checks out one branch per task in the worker's worktree. The
|
|
1293
|
+
# branch starts from the task's goal branch, so each task builds on its own
|
|
1294
|
+
# goal only. A task without a goal starts from the base branch.
|
|
1295
|
+
class TaskBranch
|
|
1296
|
+
def initialize(dir)
|
|
1297
|
+
@dir = dir
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def start(uuid, base)
|
|
1301
|
+
raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
|
|
1302
|
+
|
|
1303
|
+
branch = "task/#{Goals.short(uuid)}"
|
|
1304
|
+
raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
|
|
1305
|
+
|
|
1306
|
+
puts "on branch #{branch} (from #{base})"
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
private
|
|
1310
|
+
|
|
1311
|
+
def checkout(branch, base)
|
|
1312
|
+
return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
|
|
1313
|
+
|
|
1314
|
+
Git.run(@dir, "checkout", "-q", "-b", branch, base)
|
|
1315
|
+
end
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# TaskSync checks that a task branch contains the head of its goal branch.
|
|
1319
|
+
# Task tests on a branch without the merged sibling tasks miss semantic
|
|
1320
|
+
# conflicts, so `coord done` refuses a stale branch. A task branch without
|
|
1321
|
+
# own commits (a review task) has nothing to integrate and passes.
|
|
1322
|
+
class TaskSync
|
|
1323
|
+
def initialize(dir)
|
|
1324
|
+
@dir = dir
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
def stale(task)
|
|
1328
|
+
goal = task["goalid"]
|
|
1329
|
+
return nil if goal.to_s.empty?
|
|
1330
|
+
|
|
1331
|
+
pair = [Goals.branch(goal), "task/#{Goals.short(task["uuid"])}"]
|
|
1332
|
+
pair if pair.all? { |name| Git.branch?(@dir, name) } && behind?(*pair)
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
private
|
|
1336
|
+
|
|
1337
|
+
def behind?(goal, branch)
|
|
1338
|
+
own = `git -C #{Shellwords.escape(@dir)} rev-list --count #{goal}..#{branch} 2>/dev/null`.to_i
|
|
1339
|
+
own.positive? && !Git.run(@dir, "merge-base", "--is-ancestor", goal, branch)
|
|
1340
|
+
end
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
# SquashMessage builds the commit message of one landed task. The trailers
|
|
1344
|
+
# keep the trace to the task after the squash.
|
|
1345
|
+
module SquashMessage
|
|
1346
|
+
SUBJECT_LIMIT = 72
|
|
1347
|
+
|
|
1348
|
+
def self.for(task, subject)
|
|
1349
|
+
head = (subject || task["description"]).to_s.strip.slice(0, SUBJECT_LIMIT)
|
|
1350
|
+
trailers = { "Task" => task["uuid"], "Goal" => task["goalid"], "Worker" => task["worker"],
|
|
1351
|
+
"Tests" => tests(task) }
|
|
1352
|
+
"#{head}\n\n#{trailers.filter_map { |key, value| "#{key}: #{value}" unless value.to_s.empty? }.join("\n")}\n"
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
# The worker report holds "TESTS: <result>. NOTES: ...". The last report counts.
|
|
1356
|
+
def self.tests(task)
|
|
1357
|
+
notes = (task["annotations"] || []).map { |note| note["description"].to_s }
|
|
1358
|
+
notes.reverse.find { |text| text.include?("TESTS:") }.to_s[/TESTS:\s*(.+?)(?:\s+NOTES:|\z)/m, 1]
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
# Land squashes the branch of one done task into its goal branch as one
|
|
1363
|
+
# commit. The task branch is then deleted. A worker worktree that still has
|
|
1364
|
+
# the task branch checked out moves back to its worker branch first.
|
|
1365
|
+
class Land
|
|
1366
|
+
def initialize(root, task)
|
|
1367
|
+
@root, @task = root, task
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
def run(subject)
|
|
1371
|
+
check!
|
|
1372
|
+
commit = squash(subject)
|
|
1373
|
+
release_branch
|
|
1374
|
+
commit
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
def goal_branch = Goals.branch(@task["goalid"])
|
|
1378
|
+
def branch = "task/#{Goals.short(@task["uuid"])}"
|
|
1379
|
+
|
|
1380
|
+
private
|
|
1381
|
+
|
|
1382
|
+
def goal_dir = Worktree.dir_for(@root, Goals.slug(@task["goalid"]))
|
|
1383
|
+
|
|
1384
|
+
def check!
|
|
1385
|
+
raise Error, "task #{@task["uuid"]} has no goal. coord land needs a goal branch." if @task["goalid"].to_s.empty?
|
|
1386
|
+
raise Error, "task #{@task["uuid"]} is not done. Run coord done first." unless @task["status"] == "completed"
|
|
1387
|
+
raise Error, "branch #{branch} does not exist" unless Git.branch?(@root, branch)
|
|
1388
|
+
raise Error, "uncommitted changes in #{goal_dir}. Commit or stash them first." unless Git.clean?(goal_dir)
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
# A branch without own commits (a review task) lands nothing.
|
|
1392
|
+
def squash(subject)
|
|
1393
|
+
return nil unless Git.own_commits?(@root, goal_branch, branch)
|
|
1394
|
+
raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
|
|
1395
|
+
|
|
1396
|
+
merge_squash
|
|
1397
|
+
commit(SquashMessage.for(@task, subject))
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
|
|
1401
|
+
|
|
1402
|
+
def merge_squash
|
|
1403
|
+
return if Git.run(goal_dir, "merge", "--squash", "-q", branch)
|
|
1404
|
+
|
|
1405
|
+
Git.run(goal_dir, "reset", "-q", "--merge")
|
|
1406
|
+
raise Error, "the squash of #{branch} into #{goal_branch} conflicts. Open a fix task."
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
# 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")
|
|
1412
|
+
|
|
1413
|
+
args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
|
|
1414
|
+
IO.popen(args, "w") { |io| io.write(message) }
|
|
1415
|
+
raise Error, "git commit on #{goal_branch} failed" unless $?.success?
|
|
1416
|
+
|
|
1417
|
+
Git.out(goal_dir, "rev-parse", "--short", "HEAD")
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
def release_branch
|
|
1421
|
+
holder = Git.checked_out(@root)[branch]
|
|
1422
|
+
return warn("coord: kept #{branch}. #{holder} has uncommitted changes.") if holder && !leave(holder)
|
|
1423
|
+
|
|
1424
|
+
Git.run(@root, "branch", "-D", branch)
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
# A worker worktree goes back to worker/<slug>. Without that branch, it detaches at the goal head.
|
|
1428
|
+
def leave(dir)
|
|
1429
|
+
return false unless Git.clean?(dir)
|
|
1430
|
+
|
|
1431
|
+
worker = "worker/#{File.basename(dir)}"
|
|
1432
|
+
target = Git.branch?(@root, worker) ? [worker] : ["--detach", goal_branch]
|
|
1433
|
+
Git.run(dir, "switch", "-q", *target)
|
|
1434
|
+
end
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
# GoalSync merges the base branch into a goal branch, so the pull request
|
|
1438
|
+
# of the goal has no conflict and the merge suite tests the merged result.
|
|
1439
|
+
class GoalSync
|
|
1440
|
+
def initialize(root, goal_uuid, base)
|
|
1441
|
+
@root, @base = root, base
|
|
1442
|
+
@branch = Goals.branch(goal_uuid)
|
|
1443
|
+
@dir = Worktree.dir_for(root, Goals.slug(goal_uuid))
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
# A missing base ref has no head to lack.
|
|
1447
|
+
def behind? = Git.run(@root, "rev-parse", "-q", "--verify", ref) && !Git.ancestor?(@root, ref, @branch)
|
|
1448
|
+
def ref = @ref ||= Git.base_ref(@root, @base)
|
|
1449
|
+
|
|
1450
|
+
def run
|
|
1451
|
+
return puts("#{@branch} already contains #{ref}") unless behind?
|
|
1452
|
+
raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless Git.clean?(@dir)
|
|
1453
|
+
|
|
1454
|
+
merge
|
|
1455
|
+
puts "merged #{ref} into #{@branch}"
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
private
|
|
1459
|
+
|
|
1460
|
+
def merge
|
|
1461
|
+
return if Git.run(@dir, "merge", "-q", "--no-edit", ref)
|
|
1462
|
+
|
|
1463
|
+
Git.run(@dir, "merge", "--abort")
|
|
1464
|
+
raise Error, "the merge of #{ref} into #{@branch} conflicts. Open a fix task to merge #{ref} into #{@branch}."
|
|
1465
|
+
end
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
# Sweep finds flow branches and worktrees that finished their work. It
|
|
1469
|
+
# deletes only what git or the task board proves is merged or landed.
|
|
1470
|
+
# Everything else stays and gets a reason.
|
|
1471
|
+
class Sweep
|
|
1472
|
+
Item = Struct.new(:label, :action, :reason)
|
|
1473
|
+
|
|
1474
|
+
def initialize(root, tasks, base)
|
|
1475
|
+
@root, @tasks, @base = root, tasks, base
|
|
1476
|
+
end
|
|
1477
|
+
|
|
1478
|
+
def items = task_items + goal_items + worker_items
|
|
1479
|
+
|
|
1480
|
+
private
|
|
1481
|
+
|
|
1482
|
+
def ref = @ref ||= Git.base_ref(@root, @base)
|
|
1483
|
+
def held = @held ||= Git.checked_out(@root)
|
|
1484
|
+
|
|
1485
|
+
def task_items
|
|
1486
|
+
Git.branches(@root, "task/*").map { |name| item("branch #{name}", task_reason(name)) { delete(name) } }
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
def goal_items
|
|
1490
|
+
Git.branches(@root, "goal/*").map do |name|
|
|
1491
|
+
item("goal #{name} and its worktree", goal_reason(name)) { drop_goal(name) }
|
|
1492
|
+
end
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
def worker_items
|
|
1496
|
+
Git.branches(@root, "worker/*").map { |name| item("branch #{name}", worker_reason(name)) { delete(name) } }
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
# A nil reason means: safe to delete.
|
|
1500
|
+
def item(label, reason, &action) = Item.new(label, reason ? nil : action, reason)
|
|
1501
|
+
|
|
1502
|
+
def task_reason(name)
|
|
1503
|
+
return "checked out in #{held[name]}" if held[name]
|
|
1504
|
+
return nil if Git.ancestor?(@root, name, ref)
|
|
1505
|
+
|
|
1506
|
+
task = @tasks.find_short(name.delete_prefix("task/"))
|
|
1507
|
+
return "task is not done" unless task["status"] == "completed"
|
|
1508
|
+
|
|
1509
|
+
landed?(task) ? nil : "task is done but not landed (coord land)"
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1512
|
+
def landed?(task) = (task["annotations"] || []).any? { |note| note["description"].to_s.start_with?("LANDED:") }
|
|
1513
|
+
|
|
1514
|
+
def goal_reason(name)
|
|
1515
|
+
goal = @tasks.find_short(name.delete_prefix("goal/"))
|
|
1516
|
+
return "goal is not done" unless goal["status"] == "completed"
|
|
1517
|
+
|
|
1518
|
+
Git.ancestor?(@root, name, ref) ? nil : "not merged into #{ref}"
|
|
1519
|
+
end
|
|
1520
|
+
|
|
1521
|
+
def worker_reason(name)
|
|
1522
|
+
return "checked out in #{held[name]}" if held[name]
|
|
1523
|
+
|
|
1524
|
+
Git.ancestor?(@root, name, ref) ? nil : "has commits that #{ref} lacks"
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
def delete(name) = Git.run(@root, "branch", "-D", name)
|
|
1528
|
+
|
|
1529
|
+
# Untracked runtime copies would block `worktree remove`. Git.clean? has
|
|
1530
|
+
# already checked the tracked files, so --force removes only those copies.
|
|
1531
|
+
def drop_goal(name)
|
|
1532
|
+
dir = held[name]
|
|
1533
|
+
return false if dir && !(Git.clean?(dir) && Git.run(@root, "worktree", "remove", "--force", dir))
|
|
1534
|
+
|
|
1535
|
+
delete(name)
|
|
1536
|
+
end
|
|
1537
|
+
end
|
|
1538
|
+
|
|
1539
|
+
# Await arms the stop hook of an interactive session. The agent ends its
|
|
1540
|
+
# turn, and the hook (harness-hooks/next-task.rb) waits for work outside the
|
|
1541
|
+
# model. A wait in a tool call costs one model call per return; this wait
|
|
1542
|
+
# costs none. NOTE: next-task.rb reads the same file.
|
|
1543
|
+
class Await
|
|
1544
|
+
DEFAULT_SECONDS = 3000
|
|
1545
|
+
|
|
1546
|
+
def initialize(paths, worker)
|
|
1547
|
+
@path = File.join(paths.locks_dir, "await-#{worker}.json")
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
def arm(role, seconds)
|
|
1551
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
1552
|
+
File.write(@path, JSON.generate("role" => role, "until" => Time.now.to_i + seconds))
|
|
1553
|
+
end
|
|
1554
|
+
end
|
|
1555
|
+
|
|
1556
|
+
# InboxWaiter allows one `coord inbox --wait` for each role. Two waiters on
|
|
1557
|
+
# one inbox split the messages between two agents. The pid file of a waiter
|
|
1558
|
+
# that exited without cleanup counts as free.
|
|
1559
|
+
class InboxWaiter
|
|
1560
|
+
def initialize(paths, role)
|
|
1561
|
+
@role = role
|
|
1562
|
+
@path = File.join(paths.locks_dir, "inbox-wait-#{role}.pid")
|
|
1563
|
+
end
|
|
1564
|
+
|
|
1565
|
+
def hold
|
|
1566
|
+
refuse_other!
|
|
1567
|
+
write_pid
|
|
1568
|
+
yield
|
|
1569
|
+
ensure
|
|
1570
|
+
File.delete(@path) if own?
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
def write_pid
|
|
1574
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
1575
|
+
File.write(@path, Process.pid.to_s)
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
def own? = File.exist?(@path) && File.read(@path).to_i == Process.pid
|
|
1579
|
+
|
|
1580
|
+
private
|
|
1581
|
+
|
|
1582
|
+
def refuse_other!
|
|
1583
|
+
pid = File.exist?(@path) ? File.read(@path).to_i : 0
|
|
1584
|
+
return unless pid.positive? && pid != Process.pid && Maf::Shared::Processes.alive?(pid)
|
|
1585
|
+
|
|
1586
|
+
raise Error, "another coord inbox --wait for #{@role} runs (pid #{pid}). Read the messages in that session."
|
|
1587
|
+
end
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
# Reaper finds claims of workers that were not seen for a while. A worker
|
|
1591
|
+
# counts as seen at its claim, at each change of the task, at each event
|
|
1592
|
+
# it logs, and at each coord call of its session (presence).
|
|
1593
|
+
# A live dispatcher is never stale: a run can take hours on a local model,
|
|
1594
|
+
# and the dispatcher resumes the claims of its worker by itself.
|
|
1595
|
+
class Reaper
|
|
1596
|
+
DEFAULT_MINUTES = 90
|
|
1597
|
+
|
|
1598
|
+
def initialize(tasks, paths, minutes)
|
|
1599
|
+
@tasks, @paths, @limit = tasks, paths, minutes * 60
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
def stale = @tasks.pending.select { |task| claimed?(task) && !dispatched?(task) && quiet?(task) }
|
|
1603
|
+
def quiet?(task) = Time.now - last_seen(task) >= @limit
|
|
1604
|
+
|
|
1605
|
+
def last_seen(task)
|
|
1606
|
+
times = [task["start"], task["modified"], presence(task["worker"]), last_event(task["worker"])]
|
|
1607
|
+
times.filter_map { |value| parse(value) }.max || Time.now
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
private
|
|
1611
|
+
|
|
1612
|
+
def claimed?(task) = task["start"] && !task["worker"].to_s.empty?
|
|
1613
|
+
|
|
1614
|
+
def parse(value)
|
|
1615
|
+
value && Time.parse(value.to_s)
|
|
1616
|
+
rescue ArgumentError
|
|
1617
|
+
nil
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
def dispatched?(task)
|
|
1621
|
+
entry = entry(task["worker"])
|
|
1622
|
+
entry["mode"] == "dispatch" && Presence.live_entry?(entry)
|
|
1623
|
+
end
|
|
1624
|
+
|
|
1625
|
+
def presence(worker) = entry(worker)["seen_at"]
|
|
1626
|
+
|
|
1627
|
+
def entry(worker)
|
|
1628
|
+
JSON.parse(File.read(File.join(@paths.presence_dir, "#{worker}.json")))
|
|
1629
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
1630
|
+
{}
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
def last_event(worker) = events.reverse.find { |line| line.split("\t")[2] == worker }&.split("\t")&.first
|
|
1634
|
+
def events = @events ||= File.exist?(@paths.log_path) ? File.readlines(@paths.log_path).last(2000) : []
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
# GitHub runs `gh` as the bot account of the github section in
|
|
1638
|
+
# .maf/config.json. The bot opens the pull request, so the reviewer can
|
|
1639
|
+
# approve it or request changes. Your own `gh` login stays active.
|
|
1640
|
+
class GitHub
|
|
1641
|
+
def initialize(root, settings)
|
|
1642
|
+
@root, @settings = root, settings
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
def bot = @settings["bot_user"].to_s
|
|
1646
|
+
def reviewer = @settings["reviewer"].to_s
|
|
1647
|
+
def configured? = !bot.empty? && !reviewer.empty?
|
|
1648
|
+
|
|
1649
|
+
def repo
|
|
1650
|
+
@repo ||= @settings["repo"] || origin_repo ||
|
|
1651
|
+
raise(Error, "no GitHub repository. Set github.repo in .maf/config.json.")
|
|
1652
|
+
end
|
|
1653
|
+
|
|
1654
|
+
# ssh_host is a Host alias of ~/.ssh/config with the key of the bot.
|
|
1655
|
+
def remote = @settings["ssh_host"] ? "git@#{@settings["ssh_host"]}:#{repo}.git" : "origin"
|
|
1656
|
+
|
|
1657
|
+
def push(branch)
|
|
1658
|
+
return if Git.run(@root, "push", "-q", remote, "#{branch}:#{branch}")
|
|
1659
|
+
|
|
1660
|
+
raise Error, "git push of #{branch} to #{remote} failed"
|
|
1661
|
+
end
|
|
1662
|
+
|
|
1663
|
+
def create_pr(head:, base:, title:, body:)
|
|
1664
|
+
args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
|
|
1665
|
+
gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
|
|
1666
|
+
end
|
|
1667
|
+
|
|
1668
|
+
def request_review(url) = gh("pr", "edit", url, "--add-reviewer", reviewer)
|
|
1669
|
+
def comment(url, text) = gh("pr", "comment", url, "--body-file", "-", input: text)
|
|
1670
|
+
def view(url) = JSON.parse(gh("pr", "view", url, "--json", "state,reviews,comments"))
|
|
1671
|
+
def inline_comments(url) = JSON.parse(gh("api", "repos/#{repo}/pulls/#{url[%r{/pull/(\d+)}, 1]}/comments"))
|
|
1672
|
+
|
|
1673
|
+
private
|
|
1674
|
+
|
|
1675
|
+
# The origin URL can use an ssh Host alias such as github.com-maf-bot.
|
|
1676
|
+
ORIGIN = %r{github\.com[^:/]*[:/]([^/]+/[^/]+?)(?:\.git)?\z}
|
|
1677
|
+
|
|
1678
|
+
def origin_repo = Git.out(@root, "remote", "get-url", "origin")[ORIGIN, 1]
|
|
1679
|
+
|
|
1680
|
+
def gh(*args, input: nil)
|
|
1681
|
+
out, err, status = Open3.capture3(env, "gh", *args, stdin_data: input.to_s)
|
|
1682
|
+
raise Error, "gh #{args.first(2).join(" ")} failed: #{err.strip}" unless status.success?
|
|
1683
|
+
|
|
1684
|
+
out
|
|
1685
|
+
rescue Errno::ENOENT
|
|
1686
|
+
raise Error, "gh is not installed. See https://cli.github.com"
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
def env = @env ||= { "GH_TOKEN" => token }
|
|
1690
|
+
|
|
1691
|
+
def token
|
|
1692
|
+
out, _err, status = Open3.capture3("gh", "auth", "token", "--user", bot)
|
|
1693
|
+
raise Error, "gh has no login for #{bot}. Run: gh auth login (as #{bot})" unless status.success?
|
|
1694
|
+
|
|
1695
|
+
out.strip
|
|
1696
|
+
end
|
|
1697
|
+
end
|
|
1698
|
+
|
|
1699
|
+
# ReviewState keeps the pull request of each goal and the review items that
|
|
1700
|
+
# coord already forwarded, in .maf/coordination/reviews.json.
|
|
1701
|
+
class ReviewState
|
|
1702
|
+
def initialize(paths)
|
|
1703
|
+
@path = File.join(paths.coord_dir, "reviews.json")
|
|
1704
|
+
@locks = Locks.new(paths)
|
|
1705
|
+
end
|
|
1706
|
+
|
|
1707
|
+
def all = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
1708
|
+
def get(uuid) = all[uuid]
|
|
1709
|
+
def put(uuid, entry) = change { |data| data.merge(uuid => entry) }
|
|
1710
|
+
def drop(uuid) = change { |data| data.reject { |key, _| key == uuid } }
|
|
1711
|
+
|
|
1712
|
+
private
|
|
1713
|
+
|
|
1714
|
+
def change
|
|
1715
|
+
@locks.with_mutex("reviews", worker: "coord") { File.write(@path, JSON.pretty_generate(yield(all))) }
|
|
1716
|
+
end
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
# ReviewFeed reads one pull request and lists the review items that coord
|
|
1720
|
+
# has not forwarded yet. It ignores every author except the reviewer.
|
|
1721
|
+
class ReviewFeed
|
|
1722
|
+
def initialize(github, entry)
|
|
1723
|
+
@github, @entry = github, entry
|
|
1724
|
+
@seen = entry.fetch("seen", [])
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
def state = view["state"]
|
|
1728
|
+
def items = @items ||= (review_items + inline_items + comment_items).reject { |id, _| @seen.include?(id) }
|
|
1729
|
+
def seen_after = @seen + items.map(&:first)
|
|
1730
|
+
def changes_requested? = new_reviews.any? { |review| review["state"] == "CHANGES_REQUESTED" }
|
|
1731
|
+
|
|
1732
|
+
private
|
|
1733
|
+
|
|
1734
|
+
def view = @view ||= @github.view(@entry["url"])
|
|
1735
|
+
def by_reviewer?(item, key = "author") = item.dig(key, "login") == @github.reviewer
|
|
1736
|
+
def new_reviews = (view["reviews"] || []).select { |r| by_reviewer?(r) && !@seen.include?("review-#{r["id"]}") }
|
|
1737
|
+
def review_items = new_reviews.map { |r| ["review-#{r["id"]}", "#{r["state"]}: #{r["body"]}".strip] }
|
|
1738
|
+
def comment_items
|
|
1739
|
+
(view["comments"] || []).select { |c| by_reviewer?(c) }.map { |c| ["comment-#{c["id"]}", c["body"]] }
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
# Inline comments come with a review, so only a new review needs the extra call.
|
|
1743
|
+
def inline_items
|
|
1744
|
+
return [] if new_reviews.empty?
|
|
1745
|
+
|
|
1746
|
+
list = @github.inline_comments(@entry["url"]).select { |c| by_reviewer?(c, "user") }
|
|
1747
|
+
list.map { |c| ["inline-#{c["id"]}", "#{c["path"]}:#{c["line"] || c["original_line"]}: #{c["body"]}"] }
|
|
1748
|
+
end
|
|
1749
|
+
end
|
|
1750
|
+
|
|
1751
|
+
# PrBody describes a goal pull request: one line for each landed task.
|
|
1752
|
+
module PrBody
|
|
1753
|
+
def self.for(root, goal, base_ref)
|
|
1754
|
+
range = "#{base_ref}..#{Goals.branch(goal["uuid"])}"
|
|
1755
|
+
commits = Git.out(root, "log", "--first-parent", "--reverse", "--format=- %h %s", range)
|
|
1756
|
+
"Goal: #{goal["description"]}\nGoal id: #{goal["uuid"]}\n\nCommits (one for each task):\n#{commits}\n\n" \
|
|
1757
|
+
"Review with Approve or Request changes. Coord sends your review to the architect.\n" \
|
|
1758
|
+
"Merge with a merge commit. Do not squash.\n"
|
|
1759
|
+
end
|
|
1760
|
+
end
|
|
1761
|
+
|
|
1762
|
+
# Notify shows a desktop notification on macOS. MAF_NOTIFY=0 turns it off.
|
|
1763
|
+
module Notify
|
|
1764
|
+
def self.user(title, text)
|
|
1765
|
+
return if ENV["MAF_NOTIFY"] == "0" || !system("command -v osascript >/dev/null 2>&1")
|
|
1766
|
+
|
|
1767
|
+
clean = ->(value) { value.to_s.delete("\"\\") }
|
|
1768
|
+
system("osascript", "-e", %(display notification "#{clean.(text)}" with title "#{clean.(title)}"),
|
|
1769
|
+
out: File::NULL, err: File::NULL)
|
|
1770
|
+
end
|
|
1771
|
+
end
|
|
1772
|
+
|
|
1773
|
+
# Slots gives each worktree a stable number. Parallel worktrees use the
|
|
1774
|
+
# number to derive unique test databases and server ports. The main
|
|
1775
|
+
# worktree has no .maf/env.sh and counts as slot 0.
|
|
1776
|
+
class Slots
|
|
1777
|
+
def initialize(coord_dir)
|
|
1778
|
+
@coord_dir = coord_dir
|
|
1779
|
+
@path = File.join(coord_dir, "slots.json")
|
|
1780
|
+
@paths = Paths.new(coord_dir)
|
|
1781
|
+
end
|
|
1782
|
+
|
|
1783
|
+
def assign(slug)
|
|
1784
|
+
FileUtils.mkdir_p(@paths.locks_dir)
|
|
1785
|
+
Locks.new(@paths).with_mutex("slots", worker: "coord") { write(slot_for(live, slug))[slug] }
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
private
|
|
1789
|
+
|
|
1790
|
+
def slot_for(slots, slug)
|
|
1791
|
+
slots[slug] ||= (slots.values.max || 0) + 1
|
|
1792
|
+
slots
|
|
1793
|
+
end
|
|
1794
|
+
|
|
1795
|
+
def read = File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
1796
|
+
|
|
1797
|
+
# A worktree that is gone frees its slot, so a retired worker does not
|
|
1798
|
+
# push every later worker's port higher. Retire and a manual
|
|
1799
|
+
# `git worktree remove` both free the slot on the next assign.
|
|
1800
|
+
def live = read.select { |slug, _slot| Dir.exist?(worktree_dir(slug)) }
|
|
1801
|
+
|
|
1802
|
+
def worktree_dir(slug) = Worktree.dir_for(project_root, slug)
|
|
1803
|
+
def project_root = File.dirname(File.dirname(File.expand_path(@coord_dir)))
|
|
1804
|
+
|
|
1805
|
+
def write(slots)
|
|
1806
|
+
File.write(@path, JSON.pretty_generate(slots))
|
|
1807
|
+
slots
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
# WorktreeEnv writes a worktree's .maf/env.sh: the shared board paths, the
|
|
1812
|
+
# worktree's slot, and the output of the optional project hook
|
|
1813
|
+
# .maf/coordination/worktree-env.rb. The hook gets COORD_SLOT and COORD_WORKTREE
|
|
1814
|
+
# and prints `export NAME=VALUE` lines (see assets/worktree-env.example.rb).
|
|
1815
|
+
class WorktreeEnv
|
|
1816
|
+
FILE = ".maf/env.sh"
|
|
1817
|
+
HOOK = "worktree-env.rb"
|
|
1818
|
+
|
|
1819
|
+
def initialize(coord_dir)
|
|
1820
|
+
@coord_dir = coord_dir
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
def write(dir, slug)
|
|
1824
|
+
slot = Slots.new(@coord_dir).assign(slug)
|
|
1825
|
+
FileUtils.mkdir_p(File.join(dir, MAF_DIR))
|
|
1826
|
+
File.write(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
private
|
|
1830
|
+
|
|
1831
|
+
def base_lines(slot)
|
|
1832
|
+
"export COORD_DIR=#{@coord_dir}\nexport TASKRC=#{File.join(@coord_dir, "taskrc")}\n" \
|
|
1833
|
+
"export COORD_SLOT=#{slot}\n#{bin_lines}"
|
|
1834
|
+
end
|
|
1835
|
+
|
|
1836
|
+
# Same two lines as the env.sh of the main project: MAF_BIN and PATH.
|
|
1837
|
+
def bin_lines
|
|
1838
|
+
"export MAF_BIN=#{File.join(File.dirname(@coord_dir), "bin")}\n" \
|
|
1839
|
+
"case \":$PATH:\" in *\":$MAF_BIN:\"*) ;; *) export PATH=\"$MAF_BIN:$PATH\" ;; esac\n"
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
def hook_lines(dir, slot)
|
|
1843
|
+
hook = File.join(@coord_dir, HOOK)
|
|
1844
|
+
return "" unless File.exist?(hook)
|
|
1845
|
+
|
|
1846
|
+
run_hook(hook, { "COORD_SLOT" => slot.to_s, "COORD_WORKTREE" => dir })
|
|
1847
|
+
end
|
|
1848
|
+
|
|
1849
|
+
# Keep only `export NAME=VALUE` lines. A shell that sources .maf/env.sh
|
|
1850
|
+
# then runs nothing else the hook prints.
|
|
1851
|
+
def run_hook(hook, env)
|
|
1852
|
+
output = IO.popen(env, [RbConfig.ruby, hook], &:read)
|
|
1853
|
+
return output.lines.grep(/\Aexport \w+=/).join if $?.success?
|
|
1854
|
+
|
|
1855
|
+
warn "coord: #{hook} failed. .maf/env.sh has no project variables."
|
|
1856
|
+
""
|
|
1857
|
+
end
|
|
1858
|
+
end
|
|
1859
|
+
|
|
1860
|
+
# FastForward moves a reused worker branch to origin/<base branch>, so a
|
|
1861
|
+
# long-lived worktree does not drift. It acts only on a worker/ branch that
|
|
1862
|
+
# is checked out in a clean worktree, and only for a fast-forward.
|
|
1863
|
+
# Otherwise it skips and logs the reason. A project without origin is skipped
|
|
1864
|
+
# without a log line.
|
|
1865
|
+
class FastForward
|
|
1866
|
+
def initialize(dir, branch, base)
|
|
1867
|
+
@dir, @branch, @base = dir, branch, base
|
|
1868
|
+
end
|
|
1869
|
+
|
|
1870
|
+
def run
|
|
1871
|
+
return unless @branch.start_with?("worker/") && Git.run(@dir, "remote", "get-url", "origin")
|
|
1872
|
+
|
|
1873
|
+
reason = checks.find { |check, _| !check.call }&.last
|
|
1874
|
+
reason ? warn("coord: no fast-forward of #{@branch}: #{reason}") : merge
|
|
1875
|
+
end
|
|
1876
|
+
|
|
1877
|
+
private
|
|
1878
|
+
|
|
1879
|
+
def checks
|
|
1880
|
+
[[-> { head == @branch }, "#{@branch} is not checked out"],
|
|
1881
|
+
[-> { Git.clean?(@dir) }, "the worktree has uncommitted changes"],
|
|
1882
|
+
[-> { Git.run(@dir, "fetch", "-q", "origin", @base) }, "git fetch origin #{@base} failed"],
|
|
1883
|
+
[-> { Git.run(@dir, "merge-base", "--is-ancestor", "HEAD", target) },
|
|
1884
|
+
"#{@branch} has commits that #{target} lacks"]]
|
|
1885
|
+
end
|
|
1886
|
+
|
|
1887
|
+
def merge
|
|
1888
|
+
return if rev("HEAD") == rev(target)
|
|
1889
|
+
|
|
1890
|
+
Git.run(@dir, "merge", "-q", "--ff-only", target) && puts("fast-forwarded #{@branch} to #{target}")
|
|
1891
|
+
end
|
|
1892
|
+
|
|
1893
|
+
def target = "origin/#{@base}"
|
|
1894
|
+
def head = git_out("branch", "--show-current")
|
|
1895
|
+
def rev(name) = git_out("rev-parse", "-q", "--verify", name)
|
|
1896
|
+
def git_out(*args) = IO.popen(["git", "-C", @dir, *args], err: File::NULL, &:read).strip
|
|
1897
|
+
end
|
|
1898
|
+
|
|
1899
|
+
# Worktree creates a per-worker git worktree and branch, so "one branch or
|
|
1900
|
+
# worktree per worker" (the contract's own rule) is a command, not a manual
|
|
1901
|
+
# step. The worktree shares the main project's .maf/coordination/ dir and task
|
|
1902
|
+
# board through COORD_DIR/TASKRC exported by an untracked .maf/env.sh, not
|
|
1903
|
+
# by symlinking tracked paths (a symlink would dirty the worktree and, if
|
|
1904
|
+
# committed, break the coordination dir on merge).
|
|
1905
|
+
#
|
|
1906
|
+
# All worktrees live inside the project under .maf/worktrees/<slug>.
|
|
1907
|
+
# .maf/worktrees/ is gitignored, so agents stay within the project directory
|
|
1908
|
+
# and need no cross-directory file access permissions. maf start finds the
|
|
1909
|
+
# worktree with the same shared formula.
|
|
1910
|
+
class Worktree
|
|
1911
|
+
ENV_FILE = WorktreeEnv::FILE
|
|
1912
|
+
# The graph folder at the project root. The exclude line has no trailing
|
|
1913
|
+
# slash, so it also matches the symlink.
|
|
1914
|
+
GRAPH_DIR = "graphify-out"
|
|
1915
|
+
LOCAL_EXCLUDES = [ENV_FILE, "/#{GRAPH_DIR}"].freeze
|
|
1916
|
+
RUNTIME_FILES = %w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
|
|
1917
|
+
.maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
|
|
1918
|
+
.maf/coordination/harness-hooks/context-watch.rb .codex/hooks.json
|
|
1919
|
+
.opencode/plugins/board-watch.js].freeze
|
|
1920
|
+
|
|
1921
|
+
def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
|
|
1922
|
+
|
|
1923
|
+
def initialize(project_root)
|
|
1924
|
+
@root = project_root
|
|
1925
|
+
end
|
|
1926
|
+
|
|
1927
|
+
# Idempotent: a repeat call must not abort just because a previous call
|
|
1928
|
+
# (or a `git worktree remove` that left the branch behind) already did
|
|
1929
|
+
# part of the work.
|
|
1930
|
+
def create(role, worker)
|
|
1931
|
+
slug = self.class.slug(role, worker)
|
|
1932
|
+
checkout(slug, "worker/#{slug}", base_branch)
|
|
1933
|
+
end
|
|
1934
|
+
|
|
1935
|
+
# A new worker branch starts on the base branch, not on the HEAD of the
|
|
1936
|
+
# main checkout. That HEAD can be a task branch of another role.
|
|
1937
|
+
def base_branch
|
|
1938
|
+
name = Git.base_branch(@root, Manifest.new(File.join(@root, ".maf/config.json")))
|
|
1939
|
+
name if Git.branch?(@root, name)
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
# BASE is the start point of a new branch. Without BASE, git uses HEAD.
|
|
1943
|
+
def checkout(slug, branch, base = nil)
|
|
1944
|
+
dir = self.class.dir_for(@root, slug)
|
|
1945
|
+
return reuse(dir, branch) if Dir.exist?(dir)
|
|
1946
|
+
|
|
1947
|
+
add_worktree(dir, branch, base)
|
|
1948
|
+
finish(dir, branch, "worktree #{dir} on branch #{branch}")
|
|
1949
|
+
end
|
|
1950
|
+
|
|
1951
|
+
def add_worktree(dir, branch, base)
|
|
1952
|
+
FileUtils.mkdir_p(File.dirname(dir))
|
|
1953
|
+
ok = system("git", "-C", @root, "worktree", "add", *add_args(dir, branch, base))
|
|
1954
|
+
abort "coord: git worktree add failed" unless ok
|
|
1955
|
+
end
|
|
1956
|
+
|
|
1957
|
+
# A WORKER that already starts with the role ("frontend-developer-2") is
|
|
1958
|
+
# the full name. Joining it with the role again repeats the role.
|
|
1959
|
+
def self.slug(role, worker)
|
|
1960
|
+
return worker if worker&.start_with?("#{role}-")
|
|
1961
|
+
|
|
1962
|
+
[role, worker].compact.join("-")
|
|
1963
|
+
end
|
|
1964
|
+
|
|
1965
|
+
private
|
|
1966
|
+
|
|
1967
|
+
# No `-b` when the branch already exists (e.g. its worktree was removed
|
|
1968
|
+
# without deleting the branch) -- `git worktree add -b` refuses to reuse
|
|
1969
|
+
# a branch name, `git worktree add` without `-b` checks it out instead.
|
|
1970
|
+
def add_args(dir, branch, base)
|
|
1971
|
+
Git.branch?(@root, branch) ? [dir, branch] : [dir, "-b", branch, *base]
|
|
1972
|
+
end
|
|
1973
|
+
|
|
1974
|
+
def reuse(dir, branch)
|
|
1975
|
+
unless worktree?(dir)
|
|
1976
|
+
abort "coord: #{dir} exists but is not a git worktree. Remove it or choose a different slug."
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1979
|
+
FastForward.new(dir, branch, Git.base_branch(@root, Manifest.new(File.join(@root, ".maf/config.json")))).run
|
|
1980
|
+
finish(dir, branch, "worktree #{dir} already exists, reusing (branch #{branch})")
|
|
1981
|
+
end
|
|
1982
|
+
|
|
1983
|
+
def worktree?(dir)
|
|
1984
|
+
system("git", "-C", dir, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
1985
|
+
end
|
|
1986
|
+
|
|
1987
|
+
def finish(dir, branch, message)
|
|
1988
|
+
ensure_runtime_files(dir)
|
|
1989
|
+
link_graph(dir)
|
|
1990
|
+
exclude_local_files(dir)
|
|
1991
|
+
WorktreeEnv.new(File.join(@root, MAF_DIR, "coordination")).write(dir, File.basename(dir))
|
|
1992
|
+
puts message, SOURCE_HINT
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
SOURCE_HINT = "In that worktree, run: source #{ENV_FILE} (shares this project's .maf/coordination/ " \
|
|
1996
|
+
"and task board)"
|
|
1997
|
+
|
|
1998
|
+
# The flow is not committed: it is a tool, not a part of the project. So
|
|
1999
|
+
# a fresh worktree checks out none of these files. Copy the main
|
|
2000
|
+
# worktree's copy of each missing file. A reused worktree
|
|
2001
|
+
# gets files that were added after it was created. An existing file is
|
|
2002
|
+
# never overwritten, so a change by the agent stays.
|
|
2003
|
+
def ensure_runtime_files(dir)
|
|
2004
|
+
paths = (RUNTIME_FILES + bin_files + copy_list).uniq
|
|
2005
|
+
paths.each { |path| copy_missing(File.join(@root, path), File.join(dir, path)) }
|
|
2006
|
+
end
|
|
2007
|
+
|
|
2008
|
+
# Every script of .maf/bin, not only coord: dispatch mode needs the dispatcher.
|
|
2009
|
+
# The scripts load the shared library from .maf/lib.
|
|
2010
|
+
def bin_files
|
|
2011
|
+
Dir.glob("#{MAF_DIR}/{bin/*,lib/**/*.rb}", base: @root).select { |path| File.file?(File.join(@root, path)) }
|
|
2012
|
+
end
|
|
2013
|
+
|
|
2014
|
+
# copy_to_worktree in .maf/config.json lists more host files for each
|
|
2015
|
+
# worktree, for example .env. Only paths inside the project are copied.
|
|
2016
|
+
def copy_list
|
|
2017
|
+
list = Manifest.new(File.join(@root, ".maf/config.json")).copy_to_worktree
|
|
2018
|
+
outside = list.select { |path| path.start_with?("/") || path.split("/").include?("..") }
|
|
2019
|
+
warn "coord: copy_to_worktree skips paths outside the project: #{outside.join(", ")}" unless outside.empty?
|
|
2020
|
+
list - outside
|
|
2021
|
+
end
|
|
2022
|
+
|
|
2023
|
+
def copy_missing(src, dest)
|
|
2024
|
+
return if File.exist?(dest) || !File.exist?(src)
|
|
2025
|
+
|
|
2026
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
2027
|
+
FileUtils.cp(src, dest, preserve: true)
|
|
2028
|
+
end
|
|
2029
|
+
|
|
2030
|
+
# The graph is not committed. A relative symlink gives the worktree the
|
|
2031
|
+
# graph of the main checkout at the default path of graphify, so a query
|
|
2032
|
+
# or a saved note in the worktree uses the main graph.
|
|
2033
|
+
def link_graph(dir)
|
|
2034
|
+
link = File.join(dir, GRAPH_DIR)
|
|
2035
|
+
return if File.exist?(link) || File.symlink?(link)
|
|
2036
|
+
|
|
2037
|
+
FileUtils.mkdir_p(File.join(@root, GRAPH_DIR))
|
|
2038
|
+
File.symlink(Pathname.new(File.join(@root, GRAPH_DIR)).relative_path_from(Pathname.new(dir)).to_s, link)
|
|
2039
|
+
end
|
|
2040
|
+
|
|
2041
|
+
# .maf/env.sh holds absolute host paths, and the graph link points into
|
|
2042
|
+
# the main checkout. Neither may be committed. Git has no per-worktree
|
|
2043
|
+
# exclude, and .gitignore may not be committed yet, so add both to the
|
|
2044
|
+
# repository's shared local exclude instead. bootstrap excludes the graph
|
|
2045
|
+
# too; an install from before the graph link lacks that line.
|
|
2046
|
+
def exclude_local_files(dir)
|
|
2047
|
+
exclude = Maf::Shared::GitExclude.path(dir)
|
|
2048
|
+
missing = exclude && unlisted(exclude)
|
|
2049
|
+
return if missing.nil? || missing.empty?
|
|
2050
|
+
|
|
2051
|
+
FileUtils.mkdir_p(File.dirname(exclude))
|
|
2052
|
+
File.open(exclude, "a") { |file| file.puts(missing) }
|
|
2053
|
+
end
|
|
2054
|
+
|
|
2055
|
+
def unlisted(exclude) = LOCAL_EXCLUDES - (File.exist?(exclude) ? File.read(exclude).lines.map(&:strip) : [])
|
|
2056
|
+
end
|
|
2057
|
+
|
|
2058
|
+
# CLI dispatches commands. Keep methods small; collaborators do the work.
|
|
2059
|
+
# Args wraps the argument list with small parsing helpers.
|
|
2060
|
+
class Args
|
|
2061
|
+
def initialize(argv)
|
|
2062
|
+
@argv = argv
|
|
2063
|
+
end
|
|
2064
|
+
|
|
2065
|
+
def flag?(name)
|
|
2066
|
+
index = @argv.index(name)
|
|
2067
|
+
return false unless index
|
|
2068
|
+
|
|
2069
|
+
@argv.delete_at(index)
|
|
2070
|
+
true
|
|
2071
|
+
end
|
|
2072
|
+
|
|
2073
|
+
def value_of(name)
|
|
2074
|
+
index = @argv.index(name)
|
|
2075
|
+
return nil unless index
|
|
2076
|
+
|
|
2077
|
+
@argv.delete_at(index)
|
|
2078
|
+
@argv.delete_at(index)
|
|
2079
|
+
end
|
|
2080
|
+
|
|
2081
|
+
def ttl = (value_of("--ttl") || 3600).to_i
|
|
2082
|
+
def interval = (value_of("--interval") || DEFAULT_POLL_INTERVAL).to_i
|
|
2083
|
+
def timeout = (v = value_of("--timeout")) && v.to_i
|
|
2084
|
+
|
|
2085
|
+
def require_arg(command, name)
|
|
2086
|
+
shift || abort("usage: coord #{command} #{name}")
|
|
2087
|
+
end
|
|
2088
|
+
|
|
2089
|
+
def require_text(command)
|
|
2090
|
+
text = @argv.join(" ")
|
|
2091
|
+
abort "usage: coord #{command} ... TEXT" if text.empty?
|
|
2092
|
+
|
|
2093
|
+
text
|
|
2094
|
+
end
|
|
2095
|
+
|
|
2096
|
+
def parse_options(spec)
|
|
2097
|
+
opts = {}
|
|
2098
|
+
OptionParser.new { |o| spec.each { |flag, key| o.on("#{flag} VALUE") { |v| opts[key] = v } } }.parse!(@argv)
|
|
2099
|
+
opts
|
|
2100
|
+
end
|
|
2101
|
+
|
|
2102
|
+
def shift = @argv.shift
|
|
2103
|
+
def first = @argv.first
|
|
2104
|
+
def empty? = @argv.empty?
|
|
2105
|
+
def join = @argv.join(" ")
|
|
2106
|
+
|
|
2107
|
+
# Splits argv at the first literal "--" and returns the words after it,
|
|
2108
|
+
# leaving only the option part in @argv. This keeps a wrapped command's own
|
|
2109
|
+
# flags (e.g. "--ttl") out of coord's option parsing.
|
|
2110
|
+
def take_command
|
|
2111
|
+
index = @argv.index("--")
|
|
2112
|
+
return nil unless index
|
|
2113
|
+
|
|
2114
|
+
command = @argv[(index + 1)..]
|
|
2115
|
+
@argv.replace(@argv[0...index])
|
|
2116
|
+
command
|
|
2117
|
+
end
|
|
2118
|
+
end
|
|
2119
|
+
|
|
2120
|
+
# Each *Commands module implements coord subcommands. State lives on the CLI instance.
|
|
2121
|
+
# Task commands add, list, show, and export tasks.
|
|
2122
|
+
module TaskCommands
|
|
2123
|
+
def cmd_init
|
|
2124
|
+
setup.ensure_dirs
|
|
2125
|
+
setup.ensure_taskrc
|
|
2126
|
+
puts ".maf/coordination/ ready (#{@coord_dir})"
|
|
2127
|
+
end
|
|
2128
|
+
|
|
2129
|
+
def cmd_add
|
|
2130
|
+
opts = add_options
|
|
2131
|
+
id = tasks.add(**opts)
|
|
2132
|
+
event_log.append("add", @worker, "#{id} #{opts[:title]}")
|
|
2133
|
+
after_add(opts, id)
|
|
2134
|
+
end
|
|
2135
|
+
|
|
2136
|
+
def after_add(opts, id)
|
|
2137
|
+
conflicts.warn_with(opts[:scope], exclude: id)
|
|
2138
|
+
report_unstaffed(opts[:role], id)
|
|
2139
|
+
puts id
|
|
2140
|
+
end
|
|
2141
|
+
|
|
2142
|
+
def add_options
|
|
2143
|
+
opts = @args.parse_options(ADD_OPTS)
|
|
2144
|
+
opts[:title] ||= @args.join unless @args.empty?
|
|
2145
|
+
abort "usage: coord add --role R --scope S --title T" if opts[:title].to_s.empty?
|
|
2146
|
+
opts[:goal] &&= goal_uuid(opts[:goal])
|
|
2147
|
+
opts
|
|
2148
|
+
end
|
|
2149
|
+
|
|
2150
|
+
def cmd_next
|
|
2151
|
+
flags = next_flags
|
|
2152
|
+
role = require_role!(@args.shift || @role, "next ROLE, or export COORD_ROLE")
|
|
2153
|
+
refuse_lead!(role, "has no tasks to wait for") if flags[:wait]
|
|
2154
|
+
return board.mine_tasks(role, @worker) if flags[:mine]
|
|
2155
|
+
|
|
2156
|
+
flags[:wait] ? wait_next(role, flags) : board.next_tasks(role, @worker)
|
|
2157
|
+
end
|
|
2158
|
+
|
|
2159
|
+
# Every flag must be parsed out before the positional `shift`: `shift`
|
|
2160
|
+
# takes whatever token is first, flag or not, so a flag left unparsed
|
|
2161
|
+
# (e.g. --timeout) would be swallowed as the ROLE argument.
|
|
2162
|
+
def next_flags
|
|
2163
|
+
{ mine: @args.flag?("--mine"), wait: @args.flag?("--wait"), interval: @args.interval, timeout: @args.timeout }
|
|
2164
|
+
end
|
|
2165
|
+
|
|
2166
|
+
# A fresh Tasks instance per check: Tasks#pending memoizes (so one
|
|
2167
|
+
# `coord board` run only shells out to `task` once), but reusing the
|
|
2168
|
+
# single Wiring-memoized instance across poll iterations would freeze
|
|
2169
|
+
# the result at the first, possibly-empty, snapshot forever.
|
|
2170
|
+
# A message ends the wait too: an agent blocked here must not miss it.
|
|
2171
|
+
def wait_next(role, flags)
|
|
2172
|
+
wait_for(flags[:interval], flags[:timeout], -> { Tasks.new.next_for(role, @worker) + messages.files(role) }) do
|
|
2173
|
+
board.next_tasks(role, @worker)
|
|
2174
|
+
announce_messages(role)
|
|
2175
|
+
end
|
|
2176
|
+
end
|
|
2177
|
+
|
|
2178
|
+
def announce_messages(role)
|
|
2179
|
+
count = messages.files(role).size
|
|
2180
|
+
puts "#{count} unread message#{"s" if count != 1}. Run coord inbox." if count.positive?
|
|
2181
|
+
end
|
|
2182
|
+
|
|
2183
|
+
def cmd_conflicts = conflicts.report
|
|
2184
|
+
|
|
2185
|
+
def cmd_show
|
|
2186
|
+
puts TaskView.lines(find_task!(@args.require_arg("show", "ID")))
|
|
2187
|
+
end
|
|
2188
|
+
|
|
2189
|
+
def cmd_start_task
|
|
2190
|
+
task = find_task!(@args.require_arg("start-task", "ID"))
|
|
2191
|
+
base = task["goalid"] ? Goals.branch(task["goalid"]) : Git.base_branch(@project_root, manifest)
|
|
2192
|
+
TaskBranch.new(@project_root).start(task["uuid"], base)
|
|
2193
|
+
event_log.append("start-task", @worker, task["uuid"])
|
|
2194
|
+
end
|
|
2195
|
+
|
|
2196
|
+
def cmd_status = board.status
|
|
2197
|
+
def cmd_who = presence.report
|
|
2198
|
+
def cmd_board = board.board
|
|
2199
|
+
|
|
2200
|
+
def cmd_export
|
|
2201
|
+
tasks.export_to(paths.export_path)
|
|
2202
|
+
puts "export -> #{paths.export_path}"
|
|
2203
|
+
end
|
|
2204
|
+
|
|
2205
|
+
def cmd_help = puts(Usage::TEXT)
|
|
2206
|
+
|
|
2207
|
+
private
|
|
2208
|
+
|
|
2209
|
+
UNSTAFFED = "No worker runs role %s."
|
|
2210
|
+
|
|
2211
|
+
# With a worker registry (.maf/coordination/workers.json), a task for a role
|
|
2212
|
+
# without a worker waits forever. Tell the project manager once per role.
|
|
2213
|
+
def report_unstaffed(role, id)
|
|
2214
|
+
return if role.nil? || staffed?(role) || unstaffed_notice?(role)
|
|
2215
|
+
|
|
2216
|
+
event_log.append("unstaffed", @worker, role)
|
|
2217
|
+
text = "#{format(UNSTAFFED, role)} Task #{id} waits. Add a worker: maf prepare HARNESS #{role}_1 --dispatch"
|
|
2218
|
+
return warn("coord: #{text}") unless manifest.agents.any? { |a| a["role"] == "project-manager" }
|
|
2219
|
+
|
|
2220
|
+
deliver("project-manager", "coord", text)
|
|
2221
|
+
end
|
|
2222
|
+
|
|
2223
|
+
def staffed?(role)
|
|
2224
|
+
path = File.join(paths.coord_dir, "workers.json")
|
|
2225
|
+
!File.exist?(path) || JSON.parse(File.read(path)).values.any? { |entry| entry["role"] == role }
|
|
2226
|
+
end
|
|
2227
|
+
|
|
2228
|
+
# The event log records each notice. A message in the inbox is not enough:
|
|
2229
|
+
# a project without a project-manager role gets no message at all, and the
|
|
2230
|
+
# log is the only place the notice is remembered.
|
|
2231
|
+
def unstaffed_notice?(role)
|
|
2232
|
+
path = paths.log_path
|
|
2233
|
+
return false unless File.exist?(path)
|
|
2234
|
+
|
|
2235
|
+
File.readlines(path).any? { |line| unstaffed_line?(line, role) }
|
|
2236
|
+
end
|
|
2237
|
+
|
|
2238
|
+
def unstaffed_line?(line, role)
|
|
2239
|
+
_time, action, _worker, detail = line.split("\t")
|
|
2240
|
+
action == "unstaffed" && detail.to_s.strip == role
|
|
2241
|
+
end
|
|
2242
|
+
end
|
|
2243
|
+
|
|
2244
|
+
# Claim commands take, release, note, and finish a task.
|
|
2245
|
+
module ClaimCommands
|
|
2246
|
+
def cmd_claim
|
|
2247
|
+
id = @args.require_arg("claim", "ID")
|
|
2248
|
+
force = @args.flag?("--force")
|
|
2249
|
+
require_worker!(@worker, "claim ID (needs COORD_WORKER or COORD_ROLE)")
|
|
2250
|
+
refuse_lead!(@role, "never claims a task")
|
|
2251
|
+
claimed(id, locks.with_mutex("task-#{id}", worker: @worker) { tasks.claim(id, @worker, force: force) })
|
|
2252
|
+
end
|
|
2253
|
+
|
|
2254
|
+
def claimed(id, holder)
|
|
2255
|
+
notify_prior_holder(id, holder) if holder && holder.worker != @worker
|
|
2256
|
+
event_log.append("claim", @worker, id)
|
|
2257
|
+
puts "claimed #{id} by #{@worker}"
|
|
2258
|
+
end
|
|
2259
|
+
|
|
2260
|
+
def cmd_unclaim
|
|
2261
|
+
id = @args.require_arg("unclaim", "ID")
|
|
2262
|
+
tasks.unclaim(id)
|
|
2263
|
+
event_log.append("unclaim", @worker, id)
|
|
2264
|
+
puts "unclaimed #{id}"
|
|
2265
|
+
end
|
|
2266
|
+
|
|
2267
|
+
def cmd_done
|
|
2268
|
+
force = @args.flag?("--force")
|
|
2269
|
+
id = @args.require_arg("done", "ID")
|
|
2270
|
+
task = tasks.find(id)
|
|
2271
|
+
refuse_done!(task, force)
|
|
2272
|
+
finish_task(id, task)
|
|
2273
|
+
end
|
|
2274
|
+
|
|
2275
|
+
def refuse_done!(task, force)
|
|
2276
|
+
refuse_foreign!(task, force)
|
|
2277
|
+
refuse_stale!(task) unless force
|
|
2278
|
+
refuse_unverified!
|
|
2279
|
+
end
|
|
2280
|
+
|
|
2281
|
+
def finish_task(id, task)
|
|
2282
|
+
tasks.done(id)
|
|
2283
|
+
event_log.append("done", @worker, id)
|
|
2284
|
+
report_done(task)
|
|
2285
|
+
WorkMemory.new(main_root).save(task, "useful", WorkMemory.notes(task))
|
|
2286
|
+
puts "done #{id}"
|
|
2287
|
+
end
|
|
2288
|
+
|
|
2289
|
+
# A dispatched architect starts only on a message. Without this notice,
|
|
2290
|
+
# the architect never learns that a task is ready for its check.
|
|
2291
|
+
def report_done(task)
|
|
2292
|
+
return if [nil, "architect", Goals::ROLE].include?(task["role"]) || @role == "architect"
|
|
2293
|
+
|
|
2294
|
+
text = "Task #{task["uuid"]} is done: #{task["description"]}. Check the diff and the TESTS line."
|
|
2295
|
+
deliver("architect", @worker, text)
|
|
2296
|
+
end
|
|
2297
|
+
|
|
2298
|
+
def cmd_annotate
|
|
2299
|
+
id = @args.require_arg("annotate", "ID")
|
|
2300
|
+
tasks.annotate(id, @args.require_text("annotate"))
|
|
2301
|
+
event_log.append("annotate", @worker, id)
|
|
2302
|
+
puts "annotated #{id}"
|
|
2303
|
+
end
|
|
2304
|
+
|
|
2305
|
+
LESSON_USAGE = "usage: coord lesson ID dead_end|corrected TEXT..."
|
|
2306
|
+
LESSON_FAILED = "coord: no graph in graphify-out/, or graphify failed. The lesson is not saved."
|
|
2307
|
+
|
|
2308
|
+
# A dead end saves the text as the answer. A correction saves the task
|
|
2309
|
+
# notes as the answer and the text as the right way.
|
|
2310
|
+
def cmd_lesson
|
|
2311
|
+
id = @args.require_arg("lesson", "ID")
|
|
2312
|
+
outcome = @args.shift
|
|
2313
|
+
abort LESSON_USAGE unless %w[dead_end corrected].include?(outcome)
|
|
2314
|
+
|
|
2315
|
+
save_lesson(find_task!(id), outcome, @args.require_text("lesson"))
|
|
2316
|
+
end
|
|
2317
|
+
|
|
2318
|
+
def save_lesson(task, outcome, text)
|
|
2319
|
+
memory = WorkMemory.new(main_root)
|
|
2320
|
+
answer, correction = outcome == "dead_end" ? [text, nil] : [WorkMemory.notes(task), text]
|
|
2321
|
+
abort LESSON_FAILED unless memory.save(task, outcome, answer, correction)
|
|
2322
|
+
|
|
2323
|
+
event_log.append("lesson", @worker, "#{task["uuid"]} #{outcome}")
|
|
2324
|
+
puts "lesson saved for #{task["uuid"]}"
|
|
2325
|
+
end
|
|
2326
|
+
|
|
2327
|
+
def find_task!(id)
|
|
2328
|
+
task = tasks.find(id)
|
|
2329
|
+
raise Error, "no task found" if task["uuid"].nil?
|
|
2330
|
+
|
|
2331
|
+
task
|
|
2332
|
+
end
|
|
2333
|
+
|
|
2334
|
+
private
|
|
2335
|
+
|
|
2336
|
+
STALE = "coord: branch %<task>s lacks the head of %<goal>s. In your worktree, run `git merge %<goal>s`. " \
|
|
2337
|
+
"Run the task tests again. Then run coord done again. Use --force only if the merge is not wanted."
|
|
2338
|
+
|
|
2339
|
+
# A stalled run can wake up after its claim was released. It must not close
|
|
2340
|
+
# a task that is closed or that another worker holds now.
|
|
2341
|
+
def refuse_foreign!(task, force)
|
|
2342
|
+
status = task["status"]
|
|
2343
|
+
raise Error, "task #{task["uuid"]} is already #{status}. Stop work on it." if status && status != "pending"
|
|
2344
|
+
|
|
2345
|
+
holder = task["worker"].to_s
|
|
2346
|
+
return if force || holder.empty? || holder == @worker
|
|
2347
|
+
|
|
2348
|
+
raise Error, "task #{task["uuid"]} belongs to #{holder} now. Stop work on it (use --force to close it anyway)."
|
|
2349
|
+
end
|
|
2350
|
+
|
|
2351
|
+
def refuse_stale!(task)
|
|
2352
|
+
goal, branch = TaskSync.new(@project_root).stale(task)
|
|
2353
|
+
abort format(STALE, task: branch, goal: goal) if goal
|
|
2354
|
+
end
|
|
2355
|
+
|
|
2356
|
+
VERIFY_FAILED = "coord: the verify command failed: %<command>s\n%<tail>s\n" \
|
|
2357
|
+
"Fix the failures. Then run coord done again. --force does not skip this check."
|
|
2358
|
+
|
|
2359
|
+
# The verify command in .maf/config.json is a mechanical gate for done.
|
|
2360
|
+
# It runs in the current worktree. No key means no check.
|
|
2361
|
+
def refuse_unverified!
|
|
2362
|
+
command = manifest.verify
|
|
2363
|
+
return if command.empty?
|
|
2364
|
+
|
|
2365
|
+
output = IO.popen(["sh", "-c", command], err: %i[child out], &:read)
|
|
2366
|
+
abort format(VERIFY_FAILED, command: command, tail: output.lines.last(20).join.strip) unless $?.success?
|
|
2367
|
+
end
|
|
2368
|
+
|
|
2369
|
+
def notify_prior_holder(id, holder)
|
|
2370
|
+
warn "coord: task #{id} was claimed from #{holder.worker} by #{@worker}"
|
|
2371
|
+
# Send to the role's inbox (what agents poll with `coord inbox`), not the
|
|
2372
|
+
# worker id, which no agent reads. Name the exact worker in the body.
|
|
2373
|
+
to = holder.role.empty? ? holder.worker : holder.role
|
|
2374
|
+
messages.send_message(to, @worker, "task #{id} was claimed from worker #{holder.worker} by #{@worker}")
|
|
2375
|
+
end
|
|
2376
|
+
end
|
|
2377
|
+
|
|
2378
|
+
# Message commands send messages and push them to a role.
|
|
2379
|
+
module MessageCommands
|
|
2380
|
+
def cmd_msg
|
|
2381
|
+
from = require_role!(@args.value_of("--from") || @role, "msg --from A, or export COORD_ROLE")
|
|
2382
|
+
task = @args.value_of("--task")&.then { |id| find_task!(id) }
|
|
2383
|
+
fyi = @args.flag?("--fyi")
|
|
2384
|
+
name = @args.require_arg("msg", "TO")
|
|
2385
|
+
deliver_to(name, from, task_message(task, from, @args.require_text("msg")), fyi: fyi)
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2388
|
+
# An FYI message wakes no one, so it needs no push.
|
|
2389
|
+
def deliver_to(name, from, text, fyi: false)
|
|
2390
|
+
role, worker = recipient(name)
|
|
2391
|
+
path = messages.send_message(role, from, text, worker: worker, fyi: fyi)
|
|
2392
|
+
push(role, from, path) unless fyi
|
|
2393
|
+
event_log.append("msg", @worker, "to #{name}#{" (fyi)" if fyi}: #{text}")
|
|
2394
|
+
end
|
|
2395
|
+
|
|
2396
|
+
# A worker can stop before it reads its mail. The note on the task stays
|
|
2397
|
+
# for the worker that continues the task.
|
|
2398
|
+
def task_message(task, from, text)
|
|
2399
|
+
return text unless task
|
|
2400
|
+
|
|
2401
|
+
tasks.annotate(task["uuid"], "MSG from #{from}: #{text}")
|
|
2402
|
+
"Task #{task["uuid"]}: #{text}\nThis text is also a note on the task (coord show #{task["uuid"]})."
|
|
2403
|
+
end
|
|
2404
|
+
|
|
2405
|
+
# Returns [role, worker]. A worker has no inbox of its own: its mail goes
|
|
2406
|
+
# to its role inbox. A name that is no role and no worker is a typo.
|
|
2407
|
+
def recipient(name)
|
|
2408
|
+
roles = Roster.new(paths, tasks).roles | LEADS
|
|
2409
|
+
return [name, nil] if roles.include?(name) || roles == LEADS
|
|
2410
|
+
|
|
2411
|
+
role = roles.find { |known| name.match?(/\A#{Regexp.escape(known)}-\d+\z/) }
|
|
2412
|
+
role ? [role, name] : raise(Error, "unknown recipient '#{name}'. Use a role or a worker: #{roles.join(", ")}")
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
# Broadcast sends a message to every known role except the sender (see
|
|
2416
|
+
# Roster). The architect uses this for scope changes or blockers that
|
|
2417
|
+
# affect everyone, instead of messaging each role individually.
|
|
2418
|
+
def cmd_broadcast
|
|
2419
|
+
from = require_role!(@args.value_of("--from") || @role, "broadcast --from A, or export COORD_ROLE")
|
|
2420
|
+
audience = Audiences.pick(@args.value_of("--to") || "workers")
|
|
2421
|
+
text = @args.require_text("broadcast")
|
|
2422
|
+
deliver_broadcast(from, text, broadcast_roles(from, audience))
|
|
2423
|
+
end
|
|
2424
|
+
|
|
2425
|
+
def broadcast_roles(from, audience)
|
|
2426
|
+
roles = Roster.new(paths, tasks).roles.select(&audience) - [from]
|
|
2427
|
+
abort "coord: no other roles to broadcast to" if roles.empty?
|
|
2428
|
+
|
|
2429
|
+
roles
|
|
2430
|
+
end
|
|
2431
|
+
|
|
2432
|
+
# Hooks shows which role hooks are installed. With a ROLE argument, shows
|
|
2433
|
+
# just that role's hook. The hooks themselves are plain shell scripts at
|
|
2434
|
+
# .maf/coordination/message-hooks/<role>.sh, fired by `coord msg` and `coord broadcast`.
|
|
2435
|
+
def cmd_hooks
|
|
2436
|
+
return puts("no message hooks directory (run coord init)") unless Dir.exist?(paths.message_hooks_dir)
|
|
2437
|
+
|
|
2438
|
+
found = hook_files(@args.shift)
|
|
2439
|
+
return puts("no hooks installed") if found.empty?
|
|
2440
|
+
|
|
2441
|
+
found.each { |path| puts hook_line(path) }
|
|
2442
|
+
end
|
|
2443
|
+
|
|
2444
|
+
private
|
|
2445
|
+
|
|
2446
|
+
def deliver(to, from, text)
|
|
2447
|
+
path = messages.send_message(to, from, text)
|
|
2448
|
+
push(to, from, path)
|
|
2449
|
+
end
|
|
2450
|
+
|
|
2451
|
+
def deliver_broadcast(from, text, roles)
|
|
2452
|
+
results = messages.broadcast(from, text, roles)
|
|
2453
|
+
results.each { |role, path| push(role, from, path) }
|
|
2454
|
+
event_log.append("broadcast", @worker, text)
|
|
2455
|
+
end
|
|
2456
|
+
|
|
2457
|
+
NO_PUSH = "coord: %<role>s has no live session and no message hook. The message waits until a %<role>s " \
|
|
2458
|
+
"session starts. Fix: maf start HARNESS %<role>s --dispatch --detach, " \
|
|
2459
|
+
"or add an executable .maf/coordination/message-hooks/%<role>s.sh"
|
|
2460
|
+
|
|
2461
|
+
# A message is pushed by a hook or read by a live session. Without both,
|
|
2462
|
+
# it waits unseen. Say so at send time, not hours later.
|
|
2463
|
+
def push(role, from, path)
|
|
2464
|
+
return if hooks.run(role, from: from, message_path: path) || presence.live?(role)
|
|
2465
|
+
|
|
2466
|
+
warn format(NO_PUSH, role: role)
|
|
2467
|
+
end
|
|
2468
|
+
|
|
2469
|
+
def hook_files(role)
|
|
2470
|
+
found = Dir.glob(File.join(paths.message_hooks_dir, "*.sh")).sort
|
|
2471
|
+
role ? found.select { |path| File.basename(path, ".sh") == role } : found
|
|
2472
|
+
end
|
|
2473
|
+
|
|
2474
|
+
def hook_line(path)
|
|
2475
|
+
status = File.executable?(path) ? "active" : "inactive (chmod +x)"
|
|
2476
|
+
"#{File.basename(path)}\t#{status}\t#{path}"
|
|
2477
|
+
end
|
|
2478
|
+
end
|
|
2479
|
+
|
|
2480
|
+
# Inbox commands escalate problems and wait for messages.
|
|
2481
|
+
module InboxCommands
|
|
2482
|
+
ESCALATE_PM = "project-manager"
|
|
2483
|
+
|
|
2484
|
+
# A worker cannot fix a problem outside its task, such as a missing tool
|
|
2485
|
+
# or a refused guard. The project manager asks the user. The architect
|
|
2486
|
+
# gets a copy, because the task waits.
|
|
2487
|
+
def cmd_escalate
|
|
2488
|
+
task = @args.value_of("--task")
|
|
2489
|
+
text = @args.require_text("escalate")
|
|
2490
|
+
abort "coord: the project manager cannot escalate to itself. Ask the user." if @role == ESCALATE_PM
|
|
2491
|
+
tasks.annotate(find_task!(task)["uuid"], "ESCALATED: #{text}") if task
|
|
2492
|
+
escalate(task, text)
|
|
2493
|
+
end
|
|
2494
|
+
|
|
2495
|
+
def escalate(task, text)
|
|
2496
|
+
send_escalation(task, text)
|
|
2497
|
+
event_log.append("escalate", @worker, "#{task} #{text}".strip)
|
|
2498
|
+
end
|
|
2499
|
+
|
|
2500
|
+
def send_escalation(task, text)
|
|
2501
|
+
targets = escalation_targets
|
|
2502
|
+
abort "coord: no project manager and no other lead to receive the escalation. Ask the user." if targets.empty?
|
|
2503
|
+
targets.each { |to| deliver(to, @role, escalation_text(task, text)) }
|
|
2504
|
+
end
|
|
2505
|
+
|
|
2506
|
+
def escalation_text(task, text)
|
|
2507
|
+
where = task ? " (task #{task})" : ""
|
|
2508
|
+
"ESCALATION from #{@worker}#{where}: #{text}"
|
|
2509
|
+
end
|
|
2510
|
+
|
|
2511
|
+
# Without a project manager in the project, the architect is the only receiver.
|
|
2512
|
+
def escalation_targets
|
|
2513
|
+
pm = manifest.agents.any? { |agent| agent["role"] == ESCALATE_PM }
|
|
2514
|
+
[(ESCALATE_PM if pm), ("architect" unless @role == "architect")].compact
|
|
2515
|
+
end
|
|
2516
|
+
|
|
2517
|
+
AWAIT_ARMED = "coord: the stop hook waits up to %<minutes>d minutes for work for %<role>s. " \
|
|
2518
|
+
"Tell the user that you wait and that Esc ends the wait. Then end your turn."
|
|
2519
|
+
AWAIT_DISPATCHED = "coord: a dispatched run cannot wait. Stop: the dispatcher starts a run on new work."
|
|
2520
|
+
|
|
2521
|
+
def cmd_await
|
|
2522
|
+
abort AWAIT_DISPATCHED if @env["COORD_DISPATCHED"]
|
|
2523
|
+
role = require_role!(@role, "await, export COORD_ROLE")
|
|
2524
|
+
seconds = @args.timeout || Await::DEFAULT_SECONDS
|
|
2525
|
+
Await.new(paths, require_worker!(@worker, "await, export COORD_WORKER")).arm(role, seconds)
|
|
2526
|
+
puts format(AWAIT_ARMED, minutes: seconds / 60, role: role)
|
|
2527
|
+
end
|
|
2528
|
+
|
|
2529
|
+
def cmd_inbox
|
|
2530
|
+
flags = inbox_flags
|
|
2531
|
+
role = require_role!(@args.shift || @role, "inbox ROLE, or export COORD_ROLE")
|
|
2532
|
+
return messages.inbox(role, peek: flags[:peek], all: flags[:all]) unless flags[:wait]
|
|
2533
|
+
|
|
2534
|
+
InboxWaiter.new(paths, role).hold { wait_inbox(role, flags) }
|
|
2535
|
+
end
|
|
2536
|
+
|
|
2537
|
+
# Same ordering requirement as cmd_next: parse every flag before the
|
|
2538
|
+
# positional `shift`, or an unparsed flag gets read as the ROLE.
|
|
2539
|
+
def inbox_flags
|
|
2540
|
+
{ wait: @args.flag?("--wait"), interval: @args.interval, timeout: @args.timeout,
|
|
2541
|
+
peek: @args.flag?("--peek"), all: @args.flag?("--all") }
|
|
2542
|
+
end
|
|
2543
|
+
|
|
2544
|
+
def wait_inbox(role, flags)
|
|
2545
|
+
wait_for(flags[:interval], flags[:timeout], -> { messages.wake_files(role) }) do
|
|
2546
|
+
messages.inbox(role, peek: flags[:peek], all: flags[:all])
|
|
2547
|
+
end
|
|
2548
|
+
end
|
|
2549
|
+
end
|
|
2550
|
+
|
|
2551
|
+
# Goal commands create, sync, land, and close goals.
|
|
2552
|
+
module GoalCommands
|
|
2553
|
+
GOAL_COMMANDS = { "add" => :goal_add, "list" => :goal_list, "show" => :goal_show, "done" => :goal_done,
|
|
2554
|
+
"sync" => :goal_sync, "pr" => :goal_pr }.freeze
|
|
2555
|
+
|
|
2556
|
+
def cmd_goal
|
|
2557
|
+
sub = @args.shift
|
|
2558
|
+
abort "usage: coord goal add|list|show|sync|pr|done" unless GOAL_COMMANDS.key?(sub)
|
|
2559
|
+
|
|
2560
|
+
send(GOAL_COMMANDS[sub])
|
|
2561
|
+
end
|
|
2562
|
+
|
|
2563
|
+
# Only the architect lands, one squash at a time for each goal.
|
|
2564
|
+
def cmd_land
|
|
2565
|
+
subject = @args.value_of("--subject")
|
|
2566
|
+
task = find_task!(@args.require_arg("land", "ID"))
|
|
2567
|
+
land = Land.new(main_root, task)
|
|
2568
|
+
commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
|
|
2569
|
+
record_landing(task, commit, land)
|
|
2570
|
+
end
|
|
2571
|
+
|
|
2572
|
+
def record_landing(task, commit, land)
|
|
2573
|
+
where = commit ? "#{commit} on #{land.goal_branch}" : "no changes"
|
|
2574
|
+
tasks.annotate(task["uuid"], "LANDED: #{where}")
|
|
2575
|
+
event_log.append("land", @worker, "#{task["uuid"]} #{where}")
|
|
2576
|
+
puts "landed #{land.branch}: #{where}"
|
|
2577
|
+
end
|
|
2578
|
+
|
|
2579
|
+
private
|
|
2580
|
+
|
|
2581
|
+
def goals = @goals ||= Goals.new(tasks)
|
|
2582
|
+
|
|
2583
|
+
def goal_list = goals.list
|
|
2584
|
+
|
|
2585
|
+
# Older taskrc files lack the `goal` UDA. Add it before the first use.
|
|
2586
|
+
def goal_uuid(id)
|
|
2587
|
+
setup.ensure_taskrc
|
|
2588
|
+
goals.find!(id)["uuid"]
|
|
2589
|
+
end
|
|
2590
|
+
def goal_show = goals.show(@args.require_arg("goal show", "ID"))
|
|
2591
|
+
|
|
2592
|
+
def goal_sync
|
|
2593
|
+
goal = goals.find!(@args.require_arg("goal sync", "ID"))
|
|
2594
|
+
goal_sync_for(goal).run
|
|
2595
|
+
event_log.append("goal-sync", @worker, goal["description"])
|
|
2596
|
+
end
|
|
2597
|
+
|
|
2598
|
+
def goal_sync_for(goal) = GoalSync.new(main_root, goal["uuid"], Git.base_branch(main_root, manifest))
|
|
2599
|
+
|
|
2600
|
+
def goal_done
|
|
2601
|
+
id = @args.require_arg("goal done", "ID")
|
|
2602
|
+
refuse_unsynced!(goals.find!(id))
|
|
2603
|
+
goal = goals.done(id)
|
|
2604
|
+
event_log.append("goal-done", @worker, goal["description"])
|
|
2605
|
+
puts "goal #{id} done. The goal branch is #{Goals.branch(goal["uuid"])}."
|
|
2606
|
+
end
|
|
2607
|
+
|
|
2608
|
+
def goal_add
|
|
2609
|
+
opts = @args.parse_options(GOAL_OPTS)
|
|
2610
|
+
opts[:title] ||= @args.join unless @args.empty?
|
|
2611
|
+
abort "usage: coord goal add --title T [--ref R] [--base BRANCH]" if opts[:title].to_s.empty?
|
|
2612
|
+
setup.ensure_taskrc
|
|
2613
|
+
create_goal(opts, opts[:base] || Git.base_branch(main_root, manifest))
|
|
2614
|
+
end
|
|
2615
|
+
|
|
2616
|
+
def create_goal(opts, base)
|
|
2617
|
+
raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
|
|
2618
|
+
|
|
2619
|
+
uuid = tasks.add(title: opts[:title], role: Goals::ROLE, ref: opts[:ref])
|
|
2620
|
+
Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
|
|
2621
|
+
event_log.append("goal", @worker, opts[:title])
|
|
2622
|
+
puts uuid
|
|
2623
|
+
end
|
|
2624
|
+
|
|
2625
|
+
UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
|
|
2626
|
+
"Run the merge suite again. Then run coord goal done again."
|
|
2627
|
+
|
|
2628
|
+
# A goal without its branch (an old goal) has nothing to check.
|
|
2629
|
+
def refuse_unsynced!(goal)
|
|
2630
|
+
sync = goal_sync_for(goal)
|
|
2631
|
+
branch = Goals.branch(goal["uuid"])
|
|
2632
|
+
return unless Git.branch?(main_root, branch) && sync.behind?
|
|
2633
|
+
|
|
2634
|
+
abort format(UNSYNCED, branch: branch, ref: sync.ref, id: goal["uuid"])
|
|
2635
|
+
end
|
|
2636
|
+
end
|
|
2637
|
+
|
|
2638
|
+
# Review commands open goal pull requests and forward their reviews.
|
|
2639
|
+
module ReviewCommands
|
|
2640
|
+
# The architect dispatcher runs `coord review-watch --once` on a timer.
|
|
2641
|
+
def cmd_review_watch
|
|
2642
|
+
once = @args.flag?("--once")
|
|
2643
|
+
interval = @args.interval
|
|
2644
|
+
require_github!
|
|
2645
|
+
watch_reviews(once, interval)
|
|
2646
|
+
end
|
|
2647
|
+
|
|
2648
|
+
def watch_reviews(once, interval)
|
|
2649
|
+
review_all
|
|
2650
|
+
loop { sleep(interval).then { review_all } } unless once
|
|
2651
|
+
end
|
|
2652
|
+
|
|
2653
|
+
def review_all = review_state.all.each { |uuid, entry| review_goal(uuid, entry) }
|
|
2654
|
+
|
|
2655
|
+
# One failing pull request must not stop the watch of the others.
|
|
2656
|
+
def record_review(uuid, entry, feed)
|
|
2657
|
+
review_state.put(uuid, entry.merge("seen" => feed.seen_after))
|
|
2658
|
+
finish_review(uuid, feed.state) if %w[MERGED CLOSED].include?(feed.state)
|
|
2659
|
+
end
|
|
2660
|
+
|
|
2661
|
+
def review_goal(uuid, entry)
|
|
2662
|
+
feed = ReviewFeed.new(github, entry)
|
|
2663
|
+
forward_review(uuid, entry["url"], feed) unless feed.items.empty?
|
|
2664
|
+
record_review(uuid, entry, feed)
|
|
2665
|
+
rescue Error, JSON::ParserError => e
|
|
2666
|
+
warn "coord: review-watch of goal #{uuid}: #{e.message}"
|
|
2667
|
+
end
|
|
2668
|
+
|
|
2669
|
+
REVIEW_FIX = "Create a fix task for each point. Land the fixes. Run coord goal sync %<id>s. " \
|
|
2670
|
+
"Then run coord goal pr %<id>s."
|
|
2671
|
+
|
|
2672
|
+
def forward_review(uuid, url, feed)
|
|
2673
|
+
text = "Review of goal #{uuid} (#{url}):\n#{feed.items.map { |_, body| "- #{body}" }.join("\n")}\n"
|
|
2674
|
+
text += format(REVIEW_FIX, id: uuid) if feed.changes_requested?
|
|
2675
|
+
tasks.annotate(uuid, "REVIEW: changes requested") if feed.changes_requested?
|
|
2676
|
+
deliver("architect", "coord", text)
|
|
2677
|
+
event_log.append("review", @worker, "#{uuid} #{feed.items.size} item(s)")
|
|
2678
|
+
end
|
|
2679
|
+
|
|
2680
|
+
def finish_review(uuid, state)
|
|
2681
|
+
review_state.drop(uuid)
|
|
2682
|
+
state == "CLOSED" ? deliver("architect", "coord", format(PR_CLOSED, id: uuid)) : close_merged_goal(uuid)
|
|
2683
|
+
end
|
|
2684
|
+
|
|
2685
|
+
PR_CLOSED = "The pull request of goal %<id>s was closed without a merge. Ask the project manager what to do."
|
|
2686
|
+
PR_MERGED = "The pull request of goal %<id>s was merged. coord closed the goal and ran coord gc."
|
|
2687
|
+
|
|
2688
|
+
def close_merged_goal(uuid)
|
|
2689
|
+
tasks.done(uuid) if tasks.find(uuid)["status"] == "pending"
|
|
2690
|
+
event_log.append("goal-merged", @worker, uuid)
|
|
2691
|
+
sweep(true)
|
|
2692
|
+
deliver("architect", "coord", format(PR_MERGED, id: uuid))
|
|
2693
|
+
Notify.user("Goal merged", tasks.find(uuid)["description"])
|
|
2694
|
+
end
|
|
2695
|
+
|
|
2696
|
+
private
|
|
2697
|
+
|
|
2698
|
+
def github = @github ||= GitHub.new(main_root, manifest.github)
|
|
2699
|
+
def review_state = @review_state ||= ReviewState.new(paths)
|
|
2700
|
+
|
|
2701
|
+
def require_github!
|
|
2702
|
+
return if github.configured?
|
|
2703
|
+
|
|
2704
|
+
raise Error, "no github section in .maf/config.json. Set bot_user, bot_email and reviewer."
|
|
2705
|
+
end
|
|
2706
|
+
|
|
2707
|
+
# The first call opens the pull request. A later call pushes the fixes and asks for a new review.
|
|
2708
|
+
def goal_pr
|
|
2709
|
+
require_github!
|
|
2710
|
+
goal = goals.find!(@args.require_arg("goal pr", "ID"))
|
|
2711
|
+
goals.refuse_open!(goal)
|
|
2712
|
+
refuse_unsynced!(goal)
|
|
2713
|
+
publish_goal(goal, review_state.get(goal["uuid"]))
|
|
2714
|
+
end
|
|
2715
|
+
|
|
2716
|
+
def publish_goal(goal, entry)
|
|
2717
|
+
branch = Goals.branch(goal["uuid"])
|
|
2718
|
+
github.push(branch)
|
|
2719
|
+
push_memory
|
|
2720
|
+
entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
|
|
2721
|
+
review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
|
|
2722
|
+
end
|
|
2723
|
+
|
|
2724
|
+
# The notes go to the remote with each goal pull request. Another clone may
|
|
2725
|
+
# have pushed notes first. Each note has its own file name, so the merge has
|
|
2726
|
+
# no conflicts. A failed push only warns: the notes stay in the local branch.
|
|
2727
|
+
def push_memory
|
|
2728
|
+
return unless Git.branch?(main_root, WorkMemory::BRANCH)
|
|
2729
|
+
|
|
2730
|
+
merge_remote_memory(File.join(main_root, "graphify-out", "memory"))
|
|
2731
|
+
github.push(WorkMemory::BRANCH)
|
|
2732
|
+
rescue Error => e
|
|
2733
|
+
warn "coord: #{e.message}. The notes stay in the local branch #{WorkMemory::BRANCH}."
|
|
2734
|
+
end
|
|
2735
|
+
|
|
2736
|
+
def merge_remote_memory(dir)
|
|
2737
|
+
fetched = File.exist?(File.join(dir, ".git")) && Git.run(dir, "fetch", "-q", github.remote, WorkMemory::BRANCH)
|
|
2738
|
+
fetched && Git.run(dir, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD")
|
|
2739
|
+
end
|
|
2740
|
+
|
|
2741
|
+
def create_pr(goal, branch)
|
|
2742
|
+
body = PrBody.for(main_root, goal, goal_sync_for(goal).ref)
|
|
2743
|
+
github.create_pr(head: branch, base: Git.base_branch(main_root, manifest), title: goal["description"], body: body)
|
|
2744
|
+
end
|
|
2745
|
+
|
|
2746
|
+
def open_pr(goal, branch)
|
|
2747
|
+
url = create_pr(goal, branch)
|
|
2748
|
+
tasks.annotate(goal["uuid"], "PR: #{url}")
|
|
2749
|
+
announce_pr("Pull request ready for your review", goal, url)
|
|
2750
|
+
url
|
|
2751
|
+
end
|
|
2752
|
+
|
|
2753
|
+
def update_pr(goal, entry, branch)
|
|
2754
|
+
commits = Git.out(main_root, "log", "--reverse", "--format=- %h %s", "#{entry["head"]}..#{branch}")
|
|
2755
|
+
github.comment(entry["url"], "Coord pushed new commits. Please review again.\n\n#{commits}\n")
|
|
2756
|
+
github.request_review(entry["url"])
|
|
2757
|
+
announce_pr("Pull request updated for your review", goal, entry["url"])
|
|
2758
|
+
entry
|
|
2759
|
+
end
|
|
2760
|
+
|
|
2761
|
+
def announce_pr(title, goal, url)
|
|
2762
|
+
Notify.user(title, goal["description"])
|
|
2763
|
+
event_log.append("goal-pr", @worker, url)
|
|
2764
|
+
puts "#{title}: #{url}"
|
|
2765
|
+
end
|
|
2766
|
+
end
|
|
2767
|
+
|
|
2768
|
+
# Upkeep commands handle locks, the event log, worktrees, and cleanup.
|
|
2769
|
+
module UpkeepCommands
|
|
2770
|
+
def cmd_lock
|
|
2771
|
+
name = @args.require_arg("lock", "NAME")
|
|
2772
|
+
worker = require_worker!(@args.value_of("--worker") || @worker, "lock NAME --worker W, or export COORD_WORKER")
|
|
2773
|
+
locks.lock(name, ttl: @args.ttl, worker: worker)
|
|
2774
|
+
end
|
|
2775
|
+
|
|
2776
|
+
def cmd_unlock = locks.unlock(@args.require_arg("unlock", "NAME"))
|
|
2777
|
+
|
|
2778
|
+
def cmd_with_lock
|
|
2779
|
+
name = @args.require_arg("with-lock", "NAME")
|
|
2780
|
+
command = @args.take_command
|
|
2781
|
+
abort "usage: coord with-lock NAME [--ttl SECONDS] -- CMD..." if command.nil? || command.empty?
|
|
2782
|
+
|
|
2783
|
+
exit(run_with_lock(name, command) ? 0 : 1)
|
|
2784
|
+
end
|
|
2785
|
+
|
|
2786
|
+
def run_with_lock(name, command)
|
|
2787
|
+
started = Time.now
|
|
2788
|
+
ok = locks.with_lock(name, ttl: @args.ttl, command: command, worker: @worker)
|
|
2789
|
+
event_log.append("with-lock", @worker, "#{name} #{ok ? "ok" : "failed"} #{(Time.now - started).round}s")
|
|
2790
|
+
ok
|
|
2791
|
+
end
|
|
2792
|
+
|
|
2793
|
+
# Log shows the shared coordination history: claims, completions, unclaims,
|
|
2794
|
+
# and broadcasts, newest last. Optional N limits to the last N entries.
|
|
2795
|
+
def cmd_log
|
|
2796
|
+
raw = @args.shift
|
|
2797
|
+
count = raw && Integer(raw, exception: false)
|
|
2798
|
+
abort "coord: log N (positive integer)" if raw && (count.nil? || count < 1)
|
|
2799
|
+
event_log.read(count)
|
|
2800
|
+
end
|
|
2801
|
+
|
|
2802
|
+
def cmd_worktree
|
|
2803
|
+
role = @args.require_arg("worktree", "ROLE")
|
|
2804
|
+
worker = @args.shift
|
|
2805
|
+
warn_missing_role_files(role)
|
|
2806
|
+
Worktree.new(@project_root).create(role, worker)
|
|
2807
|
+
end
|
|
2808
|
+
|
|
2809
|
+
def cmd_gc = sweep(@args.flag?("--yes"))
|
|
2810
|
+
|
|
2811
|
+
REAPED = "Released the claim of task %<id>s. Worker %<worker>s was not seen for %<minutes>s minutes. " \
|
|
2812
|
+
"Check the task branch before the next worker continues."
|
|
2813
|
+
|
|
2814
|
+
def cmd_reap
|
|
2815
|
+
minutes = Integer(@args.value_of("--minutes") || manifest.reap_minutes)
|
|
2816
|
+
stale = Reaper.new(tasks, paths, minutes).stale
|
|
2817
|
+
return puts("no stale claims") if stale.empty?
|
|
2818
|
+
|
|
2819
|
+
stale.each { |task| reap(task, format(REAPED, id: task["uuid"], worker: task["worker"], minutes: minutes)) }
|
|
2820
|
+
end
|
|
2821
|
+
|
|
2822
|
+
def reap(task, text)
|
|
2823
|
+
tasks.unclaim(task["uuid"])
|
|
2824
|
+
tasks.annotate(task["uuid"], "RELEASED: #{text}")
|
|
2825
|
+
event_log.append("reap", @worker, "#{task["uuid"]} #{task["worker"]}")
|
|
2826
|
+
deliver("architect", "coord", text)
|
|
2827
|
+
end
|
|
2828
|
+
|
|
2829
|
+
def sweep(apply)
|
|
2830
|
+
items = Sweep.new(main_root, tasks, Git.base_branch(main_root, manifest)).items
|
|
2831
|
+
return puts("nothing to clean") if items.empty?
|
|
2832
|
+
|
|
2833
|
+
items.each { |item| puts sweep_line(item, apply) }
|
|
2834
|
+
apply ? Git.run(main_root, "worktree", "prune") : puts("Dry run. Run coord gc --yes to delete.")
|
|
2835
|
+
end
|
|
2836
|
+
|
|
2837
|
+
def sweep_line(item, apply)
|
|
2838
|
+
return "keep #{item.label}: #{item.reason}" if item.reason
|
|
2839
|
+
return "delete #{item.label}" unless apply
|
|
2840
|
+
|
|
2841
|
+
item.action.call ? "deleted #{item.label}" : "failed #{item.label}"
|
|
2842
|
+
end
|
|
2843
|
+
|
|
2844
|
+
private
|
|
2845
|
+
|
|
2846
|
+
# A worker started in a harness that has no file for its role runs
|
|
2847
|
+
# without its duties. Name each harness that lacks the role file. maf sets
|
|
2848
|
+
# MAF_HARNESS, because maf knows the harness that starts the worker.
|
|
2849
|
+
def warn_missing_role_files(role)
|
|
2850
|
+
harnesses = ENV["MAF_HARNESS"] ? [ENV["MAF_HARNESS"]] : manifest.harnesses
|
|
2851
|
+
missing = harnesses - manifest.harnesses_for(role)
|
|
2852
|
+
return if missing.empty?
|
|
2853
|
+
|
|
2854
|
+
warn "coord: #{role} has no role file for #{missing.join(", ")}. " \
|
|
2855
|
+
"Fix: maf add #{missing.map { |harness| "#{harness}:#{role}" }.join(" ")}"
|
|
2856
|
+
end
|
|
2857
|
+
end
|
|
2858
|
+
|
|
2859
|
+
# CommandHelpers holds the guards and lookups that many commands share.
|
|
2860
|
+
module CommandHelpers
|
|
2861
|
+
private
|
|
2862
|
+
|
|
2863
|
+
def manifest = @manifest ||= Manifest.new(paths.manifest_path)
|
|
2864
|
+
def main_root = File.dirname(File.dirname(File.expand_path(@coord_dir)))
|
|
2865
|
+
|
|
2866
|
+
def require_role!(value, usage) = require_set!(value, "role", "COORD_ROLE", usage)
|
|
2867
|
+
def require_worker!(value, usage) = require_set!(value, "worker", "COORD_WORKER", usage)
|
|
2868
|
+
|
|
2869
|
+
def require_set!(value, term, env_name, usage)
|
|
2870
|
+
return value unless value.nil? || value.empty? || value == "unknown"
|
|
2871
|
+
|
|
2872
|
+
abort "coord: no #{term} set. Pass one explicitly or `export #{env_name}=<#{term}>` (usage: #{usage})"
|
|
2873
|
+
end
|
|
2874
|
+
|
|
2875
|
+
LEAD_HINT = "coord: role %<role>s is a lead role and %<what>s. Wait for messages with coord inbox --wait."
|
|
2876
|
+
|
|
2877
|
+
def refuse_lead!(role, what)
|
|
2878
|
+
abort format(LEAD_HINT, role: role, what: what) if LEADS.include?(role)
|
|
2879
|
+
end
|
|
2880
|
+
|
|
2881
|
+
# Polls at `interval` seconds until `check` returns a non-empty result, then
|
|
2882
|
+
# runs the given block once. Matches the documented "wait 60s, do not spin"
|
|
2883
|
+
# rule in code instead of leaving it to agent self-discipline.
|
|
2884
|
+
def wait_for(interval, timeout, check)
|
|
2885
|
+
deadline = Time.now + timeout if timeout
|
|
2886
|
+
sleep interval while check.call.empty? && !timed_out!(deadline, timeout)
|
|
2887
|
+
yield
|
|
2888
|
+
end
|
|
2889
|
+
|
|
2890
|
+
def timed_out!(deadline, timeout)
|
|
2891
|
+
abort "coord: timed out after #{timeout}s waiting" if deadline && Time.now >= deadline
|
|
2892
|
+
false
|
|
2893
|
+
end
|
|
2894
|
+
end
|
|
2895
|
+
|
|
2896
|
+
# CLI parses arguments and dispatches to a command.
|
|
2897
|
+
class CLI
|
|
2898
|
+
include TaskCommands
|
|
2899
|
+
include ClaimCommands
|
|
2900
|
+
include MessageCommands
|
|
2901
|
+
include InboxCommands
|
|
2902
|
+
include GoalCommands
|
|
2903
|
+
include ReviewCommands
|
|
2904
|
+
include UpkeepCommands
|
|
2905
|
+
include CommandHelpers
|
|
2906
|
+
include Wiring
|
|
2907
|
+
|
|
2908
|
+
def initialize(argv, env: ENV)
|
|
2909
|
+
@args, @env, @project_root = Args.new(argv), env, Dir.pwd
|
|
2910
|
+
@coord_dir = env.fetch("COORD_DIR", File.join(MAF_DIR, "coordination"))
|
|
2911
|
+
@role = env.fetch("COORD_ROLE", "unknown")
|
|
2912
|
+
@worker = env.fetch("COORD_WORKER", @role)
|
|
2913
|
+
ENV["TASKRC"] = @taskrc = env.fetch("TASKRC", File.join(@coord_dir, "taskrc"))
|
|
2914
|
+
end
|
|
2915
|
+
|
|
2916
|
+
# Each commit of coord, as each commit of an agent, uses the git persona of the agents.
|
|
2917
|
+
def run
|
|
2918
|
+
Maf::Shared::GitIdentity.apply(Maf::Shared::GitIdentity.read(paths.manifest_path))
|
|
2919
|
+
presence.touch(@role, @worker, @env)
|
|
2920
|
+
dispatch(@args.shift || "help")
|
|
2921
|
+
rescue Error => e
|
|
2922
|
+
abort "coord: #{e.message}"
|
|
2923
|
+
end
|
|
2924
|
+
|
|
2925
|
+
private
|
|
2926
|
+
|
|
2927
|
+
def dispatch(command)
|
|
2928
|
+
method = COMMANDS[command]
|
|
2929
|
+
abort "coord: unknown command '#{command}'\n\n#{Usage::TEXT}" unless method
|
|
2930
|
+
|
|
2931
|
+
send(method)
|
|
2932
|
+
end
|
|
2933
|
+
end
|
|
2934
|
+
end
|
|
2935
|
+
|
|
2936
|
+
Coord::CLI.new(ARGV).run if __FILE__ == $PROGRAM_NAME
|