agentilda 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/agentilda.gemspec +8 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +78 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +22 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +61 -15
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/diagram.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "agentilda/status"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
# `agentilda states` — the state machine as a terminal picture.
|
|
5
7
|
#
|
|
@@ -43,7 +45,8 @@ module Agentilda
|
|
|
43
45
|
#
|
|
44
46
|
# @return [String]
|
|
45
47
|
def spine_section
|
|
46
|
-
section("MAIN SPINE",
|
|
48
|
+
section("MAIN SPINE",
|
|
49
|
+
"a bare `promote` walks this path",
|
|
47
50
|
[" " + spine_chain.map { |key| node(key) }.join(" #{ARROW} ")])
|
|
48
51
|
end
|
|
49
52
|
|
|
@@ -54,8 +57,8 @@ module Agentilda
|
|
|
54
57
|
# @return [String, nil]
|
|
55
58
|
def rejoin_section
|
|
56
59
|
rows = StateMachine::SPINE
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
.except(*spine_chain)
|
|
61
|
+
.map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
|
|
59
62
|
|
|
60
63
|
section("REJOINING THE SPINE", "off-spine states whose default promotion lands back on it", rows)
|
|
61
64
|
end
|
|
@@ -74,9 +77,11 @@ module Agentilda
|
|
|
74
77
|
[s.key, targets] unless targets.empty?
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
section("OTHER TRANSITIONS",
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
section("OTHER TRANSITIONS",
|
|
81
|
+
"everything else the machine permits",
|
|
82
|
+
groups.flat_map { |key, targets|
|
|
83
|
+
fan_out(key, targets)
|
|
84
|
+
})
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
# @return [String, nil]
|
|
@@ -118,7 +123,7 @@ module Agentilda
|
|
|
118
123
|
# @return [Array<String>]
|
|
119
124
|
def fan_out(key, targets)
|
|
120
125
|
lines = targets.each_with_index.map { |to, i|
|
|
121
|
-
connector =
|
|
126
|
+
connector = i == targets.size - 1 ? "└─▶" : "├─▶"
|
|
122
127
|
" #{connector} #{node(to)}"
|
|
123
128
|
}
|
|
124
129
|
[" #{node(key)}", *lines]
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "agentilda/status"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# The loop. One tick a second: reap what finished and settle it against
|
|
7
|
+
# the ledger, poll what is running for its latest line, start what is
|
|
8
|
+
# dispatchable, heartbeat the state file, hand the screen a board.
|
|
9
|
+
#
|
|
10
|
+
# Nothing here guesses. An agent that finished says so in its document;
|
|
11
|
+
# the state machine says whether the folder may move; the state file says
|
|
12
|
+
# what an earlier run left behind. When the three disagree the row goes
|
|
13
|
+
# red with the reason, and nobody is re-run to paper over it.
|
|
14
|
+
class Dispatcher
|
|
15
|
+
# One running invocation. `from` is the state the plan showed when the
|
|
16
|
+
# agent was picked; `state` is the one it ran under, after any
|
|
17
|
+
# `starts_as` rename. History is keyed by `state`: luke picked at ⭐️
|
|
18
|
+
# works at 🟡, and keying by ⭐️ would offer him 🟡 afterwards as though
|
|
19
|
+
# he had never been there (the 020.00 regression, run twice over).
|
|
20
|
+
Job = Struct.new(:key,
|
|
21
|
+
:task,
|
|
22
|
+
:thread,
|
|
23
|
+
:handle,
|
|
24
|
+
:started_at,
|
|
25
|
+
:file,
|
|
26
|
+
:status,
|
|
27
|
+
:message,
|
|
28
|
+
:up,
|
|
29
|
+
:down,
|
|
30
|
+
:subagents,
|
|
31
|
+
:result,
|
|
32
|
+
:from,
|
|
33
|
+
:state,
|
|
34
|
+
:frame,
|
|
35
|
+
:history) do
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def finished? = !thread.alive?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# How long a settled row stays on the screen.
|
|
41
|
+
LINGER = 10
|
|
42
|
+
|
|
43
|
+
# Seconds a kill waits between STOP and SIGKILL.
|
|
44
|
+
KILL_GRACE = 15
|
|
45
|
+
|
|
46
|
+
# @param runner [Agentilda::Runner]
|
|
47
|
+
# @param state [Agentilda::StateFile, nil] nil under a dry run
|
|
48
|
+
# @param rounds [Integer, nil] `--rounds`, a cap that only tightens
|
|
49
|
+
# @param sleeper [Proc] how a tick waits; the suite passes a no-op
|
|
50
|
+
# @param on_board [Proc, nil] receives a {Board} each tick
|
|
51
|
+
def initialize(runner:, state: nil, rounds: nil, sleeper: ->(seconds) { sleep(seconds) }, on_board: nil)
|
|
52
|
+
@runner = runner
|
|
53
|
+
@state = state
|
|
54
|
+
@rounds_cap = rounds
|
|
55
|
+
@sleeper = sleeper
|
|
56
|
+
@on_board = on_board
|
|
57
|
+
@running = []
|
|
58
|
+
@recent = []
|
|
59
|
+
@attempts = []
|
|
60
|
+
@history = Hash.new { |h, k| h[k] = [] }
|
|
61
|
+
@finished_plans = []
|
|
62
|
+
@preferred = {}
|
|
63
|
+
@started_at = UI.monotonic
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<Agentilda::Dispatcher::Job>]
|
|
68
|
+
attr_reader :running
|
|
69
|
+
|
|
70
|
+
# @return [Array<Agentilda::Runner::Attempt>]
|
|
71
|
+
attr_reader :attempts
|
|
72
|
+
|
|
73
|
+
# @return [Array<Agentilda::Runner::Attempt>]
|
|
74
|
+
def run
|
|
75
|
+
resume_stranded
|
|
76
|
+
loop do
|
|
77
|
+
tick
|
|
78
|
+
break if done?
|
|
79
|
+
|
|
80
|
+
@sleeper.call(1)
|
|
81
|
+
end
|
|
82
|
+
@attempts
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# One cycle. Public so the suite can drive it by hand.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def tick
|
|
89
|
+
reap
|
|
90
|
+
poll
|
|
91
|
+
@running.each { |job| job.handle.kill!(grace: 0, reason: :quit) } if Control.overdue?
|
|
92
|
+
dispatch unless Control.quit?
|
|
93
|
+
persist
|
|
94
|
+
@on_board&.call(board)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def done? = @running.empty? && (Control.quit? || next_dispatchable.nil?)
|
|
99
|
+
|
|
100
|
+
# `k` from the console: STOP, {KILL_GRACE} seconds, SIGKILL, on a thread
|
|
101
|
+
# so the tick is not held for fifteen seconds.
|
|
102
|
+
#
|
|
103
|
+
# @param key [String] a row's key
|
|
104
|
+
# @return [void]
|
|
105
|
+
def kill(key)
|
|
106
|
+
job = @running.find { |j| j.key == key } or return
|
|
107
|
+
Thread.new { job.handle.kill!(grace: KILL_GRACE) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `x` from the console.
|
|
111
|
+
#
|
|
112
|
+
# @param key [String]
|
|
113
|
+
# @param seconds [Integer]
|
|
114
|
+
# @return [void]
|
|
115
|
+
def extend(key, seconds)
|
|
116
|
+
@running.find { |j| j.key == key }&.handle&.extend!(seconds)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Agentilda::Board]
|
|
120
|
+
def board
|
|
121
|
+
up = @attempts.sum(&:up) + @running.sum { |j| j.up.to_i }
|
|
122
|
+
down = @attempts.sum(&:down) + @running.sum { |j| j.down.to_i }
|
|
123
|
+
Board.new(started_at: @started_at,
|
|
124
|
+
status: run_status,
|
|
125
|
+
plans: (@running.map { |j| j.task.subject.feature.ordinal.to_s } + @recent.map { |r| r[:row].ordinal }).uniq,
|
|
126
|
+
up:,
|
|
127
|
+
down:,
|
|
128
|
+
rows: rows,
|
|
129
|
+
root: @runner.root,
|
|
130
|
+
running: @running.size,
|
|
131
|
+
live_up: @running.sum { |j| j.up.to_i },
|
|
132
|
+
live_down: @running.sum { |j| j.down.to_i })
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# @return [Symbol]
|
|
138
|
+
def run_status
|
|
139
|
+
return :quitting if Control.quit?
|
|
140
|
+
return :wrapping_up if @running.any? { |j| %i[wrap_up stopped].include?(j.handle.phase) }
|
|
141
|
+
|
|
142
|
+
:running
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array<Agentilda::Board::Row>]
|
|
146
|
+
def rows
|
|
147
|
+
now = UI.monotonic
|
|
148
|
+
@recent.reject! { |r| now - r[:at] > LINGER }
|
|
149
|
+
@running.map { |job| row_for(job) } + @recent.map { |r| r[:row] }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param job [Agentilda::Dispatcher::Job]
|
|
153
|
+
# @return [Agentilda::Board::Row]
|
|
154
|
+
def row_for(job)
|
|
155
|
+
task = job.task
|
|
156
|
+
job.frame = job.frame.to_i + 1
|
|
157
|
+
Board::Row.new(key: job.key,
|
|
158
|
+
at: Time.now,
|
|
159
|
+
ordinal: task.subject.feature.ordinal.to_s,
|
|
160
|
+
file: job.file || task.agent.ledger.first.to_s,
|
|
161
|
+
agent: task.agent.name,
|
|
162
|
+
role: task.agent.role,
|
|
163
|
+
round: task.round,
|
|
164
|
+
rounds: rounds_for(task.agent),
|
|
165
|
+
model: model_for(task.agent),
|
|
166
|
+
remaining: job.handle.remaining,
|
|
167
|
+
phase: job.handle.phase,
|
|
168
|
+
up: job.up.to_i,
|
|
169
|
+
down: job.down.to_i,
|
|
170
|
+
message: job.message,
|
|
171
|
+
state: :running,
|
|
172
|
+
pr: pr_for(task),
|
|
173
|
+
frame: job.frame,
|
|
174
|
+
elapsed: (UI.monotonic - job.started_at).round,
|
|
175
|
+
subagents: job.subagents.to_i,
|
|
176
|
+
history: job.history || [])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param task [Agentilda::Runner::Task]
|
|
180
|
+
# @return [Hash, nil]
|
|
181
|
+
def pr_for(task)
|
|
182
|
+
return nil unless task.agent.ledger.include?("pull-requests.md")
|
|
183
|
+
|
|
184
|
+
subject = Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return nil
|
|
185
|
+
pull = subject.pull_requests.find(&:open?) or return nil
|
|
186
|
+
{ number: pull.number, url: pull.url, rejected: subject.status.key == :rejected }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param agent [Agentilda::Agent]
|
|
190
|
+
# @return [Integer]
|
|
191
|
+
def rounds_for(agent) = [agent.rounds, @rounds_cap].compact.min
|
|
192
|
+
|
|
193
|
+
# @param agent [Agentilda::Agent]
|
|
194
|
+
# @return [String]
|
|
195
|
+
def model_for(agent)
|
|
196
|
+
executor = @runner.executor
|
|
197
|
+
(executor.respond_to?(:model) && executor.model) || agent.model || "default"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ---- starting ---------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
# Started stages a dead run left behind get the harness's own line and a
|
|
203
|
+
# place in history, so eligibility treats them as an Interrupted round.
|
|
204
|
+
#
|
|
205
|
+
# @return [void]
|
|
206
|
+
def resume_stranded
|
|
207
|
+
return unless @state
|
|
208
|
+
|
|
209
|
+
@state.load
|
|
210
|
+
@state.stranded.each do |ordinal, stage|
|
|
211
|
+
subject = @runner.tree.reload.find(Ordinal.parse(ordinal)) or next
|
|
212
|
+
agent = @runner.agents.find(stage["agent"]) or next
|
|
213
|
+
write_interrupted(subject, agent, stage["round"], "harness died")
|
|
214
|
+
remember(ordinal, agent.name, subject.status.key, "Interrupted", stage["round"])
|
|
215
|
+
@state.record(ordinal, agent: agent.name, round: stage["round"], status: "Interrupted", exit: "harness died")
|
|
216
|
+
end
|
|
217
|
+
@state.begin_run!(root: @runner.root)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [void]
|
|
221
|
+
def dispatch
|
|
222
|
+
while @running.size < @runner.jobs && (pair = next_dispatchable)
|
|
223
|
+
start(*pair)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The next agent and plan to start, honouring a `next:` an agent wrote
|
|
228
|
+
# over definition order.
|
|
229
|
+
#
|
|
230
|
+
# @return [Array(Agentilda::Agent, Agentilda::Subject), nil]
|
|
231
|
+
def next_dispatchable
|
|
232
|
+
reconcile
|
|
233
|
+
@runner.in_scope.each do |subject|
|
|
234
|
+
# After {#reconcile} this is the folder's name. On a dry run, which
|
|
235
|
+
# renames nothing, it is what the name would have become.
|
|
236
|
+
state = Resync::Dirs.target(subject)
|
|
237
|
+
next if StateMachine::SETTLED.include?(state.key)
|
|
238
|
+
next if @finished_plans.include?(subject.feature.ordinal.to_s)
|
|
239
|
+
|
|
240
|
+
candidates = @runner.agents.for_status(state)
|
|
241
|
+
preferred = @preferred[subject.feature.ordinal.to_s]
|
|
242
|
+
candidates = candidates.sort_by { |a| a.name == preferred ? 0 : 1 } if preferred
|
|
243
|
+
agent = candidates.find { |a| eligible?(a, subject) }
|
|
244
|
+
return [agent, subject] if agent
|
|
245
|
+
end
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A folder's name can lag its contents: a run killed before its rename,
|
|
250
|
+
# a plan.md written by hand, a resync nobody ran. So the names are
|
|
251
|
+
# reconciled before every dispatch, the way `resync dirs` would, and a
|
|
252
|
+
# ⚪️ folder that already holds a plan goes to the pair under its honest
|
|
253
|
+
# name rather than to leah-researcher under its stale one. Plans an
|
|
254
|
+
# agent is working in right now are left alone: the prompt names the
|
|
255
|
+
# folder by path, and moving it under a running agent would strand
|
|
256
|
+
# every write it makes afterwards.
|
|
257
|
+
#
|
|
258
|
+
# @return [void]
|
|
259
|
+
def reconcile
|
|
260
|
+
busy = @running.map { |j| j.task.subject.feature.ordinal.to_s }
|
|
261
|
+
Resync::Dirs.new(tree: @runner.tree.reload, except: busy).call(commit: !@runner.dry_run?)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# @param agent [Agentilda::Agent]
|
|
265
|
+
# @param subject [Agentilda::Subject]
|
|
266
|
+
# @return [Boolean]
|
|
267
|
+
def eligible?(agent, subject)
|
|
268
|
+
ordinal = subject.feature.ordinal.to_s
|
|
269
|
+
return false if @running.any? { |j| j.task.agent.name == agent.name && j.task.subject.feature.ordinal.to_s == ordinal }
|
|
270
|
+
|
|
271
|
+
past = @history[[ordinal, agent.name]].select { |h| h[:state] == subject.status.key }
|
|
272
|
+
return true if past.empty?
|
|
273
|
+
return false if %w[Completed Blocked].include?(past.last[:status])
|
|
274
|
+
|
|
275
|
+
past.size < rounds_for(agent)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @param agent [Agentilda::Agent]
|
|
279
|
+
# @param subject [Agentilda::Subject]
|
|
280
|
+
# @return [void]
|
|
281
|
+
def start(agent, subject)
|
|
282
|
+
ordinal = subject.feature.ordinal.to_s
|
|
283
|
+
round = @history[[ordinal, agent.name]].count { |h| h[:state] == subject.status.key } + 1
|
|
284
|
+
from = subject.status.key
|
|
285
|
+
subject = rename_on_start(subject, agent) unless @runner.dry_run?
|
|
286
|
+
task = @runner.prepare(agent, subject, round)
|
|
287
|
+
handle = Executor::Handle.new
|
|
288
|
+
job = Job.new(key: "#{ordinal}/#{agent.name}",
|
|
289
|
+
task:,
|
|
290
|
+
handle:,
|
|
291
|
+
started_at: UI.monotonic,
|
|
292
|
+
from:,
|
|
293
|
+
state: subject.status.key,
|
|
294
|
+
up: 0,
|
|
295
|
+
down: 0,
|
|
296
|
+
subagents: 0,
|
|
297
|
+
frame: 0)
|
|
298
|
+
successor = @runner.agents.for_status(STATUS_BY_KEY.fetch(agent.advances_to)).first&.name if agent.advances_to && STATUS_BY_KEY.key?(agent.advances_to)
|
|
299
|
+
# Each member of a pair is told who the others are, so the executor
|
|
300
|
+
# can name them beside the plan's mailbox.
|
|
301
|
+
partners = @runner.agents.team_for(Resync::Dirs.target(subject)) - [agent]
|
|
302
|
+
remember(ordinal, agent.name, job.state, "Started", round)
|
|
303
|
+
@state&.record(ordinal,
|
|
304
|
+
agent: agent.name,
|
|
305
|
+
round:,
|
|
306
|
+
status: "Started",
|
|
307
|
+
state: from.to_s,
|
|
308
|
+
model: model_for(agent),
|
|
309
|
+
file: agent.ledger.first,
|
|
310
|
+
started_at: Time.now.iso8601)
|
|
311
|
+
UI.log("started", **task.log_fields)
|
|
312
|
+
job.thread = Thread.new do
|
|
313
|
+
Thread.current.report_on_exception = false
|
|
314
|
+
job.result = begin
|
|
315
|
+
@runner.executor.call(agent, subject, root: task.root, round:, successor:, handle:, partners:) { |progress|
|
|
316
|
+
job.up = progress.up
|
|
317
|
+
job.down = progress.down
|
|
318
|
+
job.subagents = progress.subagents
|
|
319
|
+
job.message = progress.message || progress.activity
|
|
320
|
+
job.history = Board::Row.remember(job.history || [], job.message)
|
|
321
|
+
}
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
e
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
@running << job
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# `starts_as`: the folder moves the moment the agent is dispatched, where
|
|
330
|
+
# the topology allows, so luke's start is what makes a plan 🟡.
|
|
331
|
+
#
|
|
332
|
+
# @return [Agentilda::Subject] fresh, under whichever name it now has
|
|
333
|
+
def rename_on_start(subject, agent)
|
|
334
|
+
target = agent.starts_as
|
|
335
|
+
return subject unless target && subject.machine.may?(target)
|
|
336
|
+
|
|
337
|
+
subject.machine.promote!(target)
|
|
338
|
+
Tree.new(dir: @runner.tree.dir).find(subject.feature.ordinal) || subject
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# ---- polling ----------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
# @return [void]
|
|
344
|
+
def poll
|
|
345
|
+
@running.each do |job|
|
|
346
|
+
reading = read_ledger(job.task)
|
|
347
|
+
entry = Ledger.last_for(reading, job.task.agent.name)
|
|
348
|
+
job.file = entry&.file || job.file
|
|
349
|
+
job.status = entry&.status
|
|
350
|
+
@state&.record(job.task.subject.feature.ordinal.to_s,
|
|
351
|
+
agent: job.task.agent.name,
|
|
352
|
+
round: job.task.round,
|
|
353
|
+
status: entry&.status || "Started",
|
|
354
|
+
file: job.file,
|
|
355
|
+
up: job.up,
|
|
356
|
+
down: job.down,
|
|
357
|
+
pid: job.handle.pid)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @param task [Agentilda::Runner::Task]
|
|
362
|
+
# @return [Agentilda::Ledger::Reading]
|
|
363
|
+
def read_ledger(task)
|
|
364
|
+
subject = Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return Ledger::Reading.empty
|
|
365
|
+
Ledger.read(subject.feature.path, task.agent.ledger)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# ---- settling ---------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
# @return [void]
|
|
371
|
+
def reap
|
|
372
|
+
@running.select(&:finished?).each do |job|
|
|
373
|
+
@running.delete(job)
|
|
374
|
+
attempt = settle(job)
|
|
375
|
+
@attempts << attempt
|
|
376
|
+
row = row_for(job)
|
|
377
|
+
@recent << { at: UI.monotonic, row: row.with(state: attempt.ok ? :done : :failed,
|
|
378
|
+
message: attempt.note,
|
|
379
|
+
history: Board::Row.remember(row.history, attempt.note),
|
|
380
|
+
remaining: nil,
|
|
381
|
+
phase: nil,
|
|
382
|
+
bold: !attempt.ok) }
|
|
383
|
+
UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}", **job.task.log_fields)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @param job [Agentilda::Dispatcher::Job]
|
|
388
|
+
# @return [Agentilda::Runner::Attempt]
|
|
389
|
+
def settle(job)
|
|
390
|
+
task = job.task
|
|
391
|
+
agent = task.agent
|
|
392
|
+
ordinal = task.subject.feature.ordinal.to_s
|
|
393
|
+
result = job.result
|
|
394
|
+
base = attempt_for(job)
|
|
395
|
+
return crashed(job, base, result) if result.is_a?(Exception)
|
|
396
|
+
return base.with(note: result.note) if @runner.dry_run?
|
|
397
|
+
|
|
398
|
+
reading = read_ledger(task)
|
|
399
|
+
entry = Ledger.last_for(reading, agent.name)
|
|
400
|
+
entry = nil if entry && entry.round != task.round
|
|
401
|
+
problems = reading.problems.map { |p| "#{p.file}:#{p.line} unreadable ledger line" }
|
|
402
|
+
|
|
403
|
+
attempt = if result.killed || (result.ok && (entry.nil? || entry.status == "Started"))
|
|
404
|
+
verify_and_sign(job, base, reason_for(result))
|
|
405
|
+
elsif !result.ok
|
|
406
|
+
remember(ordinal, agent.name, job.state, "Interrupted", task.round)
|
|
407
|
+
base.with(ok: false, note: result.note)
|
|
408
|
+
elsif entry.completed?
|
|
409
|
+
complete(job, base, entry, reading)
|
|
410
|
+
elsif entry.blocked?
|
|
411
|
+
park(job, base, entry)
|
|
412
|
+
else
|
|
413
|
+
remember(ordinal, agent.name, job.state, entry.status, task.round)
|
|
414
|
+
base.with(status: entry.status, note: retry_note(agent, entry))
|
|
415
|
+
end
|
|
416
|
+
attempt = attempt.with(note: "#{attempt.note}; #{problems.join(", ")}") unless problems.empty?
|
|
417
|
+
attempt = attempt.with(to: fresh(task)&.status&.key || attempt.to)
|
|
418
|
+
@state&.record(ordinal,
|
|
419
|
+
agent: agent.name,
|
|
420
|
+
round: task.round,
|
|
421
|
+
status: attempt.status || "Completed",
|
|
422
|
+
exit: attempt.ok ? "ok" : attempt.note,
|
|
423
|
+
ended_at: Time.now.iso8601,
|
|
424
|
+
up: attempt.up,
|
|
425
|
+
down: attempt.down)
|
|
426
|
+
attempt
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @param result [Agentilda::Executor::Result]
|
|
430
|
+
# @return [String]
|
|
431
|
+
def reason_for(result)
|
|
432
|
+
case result.killed
|
|
433
|
+
when :key then "killed by harness after #{KILL_GRACE}s grace"
|
|
434
|
+
when :timeout then "timed out, killed #{Clock::GRACE}s after STOP"
|
|
435
|
+
when :quit then "interrupted, killed #{Control::GRACE}s after #{Control.interrupted? ? "ctrl-c" : "q"}"
|
|
436
|
+
else Control.interrupted? ? "interrupted by ctrl-c" : "no closing ledger line"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# @return [String]
|
|
441
|
+
def retry_note(agent, entry)
|
|
442
|
+
left = rounds_for(agent) - entry.round
|
|
443
|
+
left.positive? ? "#{entry.status}; #{left} round#{"s" unless left == 1} left" : "#{entry.status}; no rounds left"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# The agent stopped without saying it finished. If the state it was
|
|
447
|
+
# advancing to is justified by what is on disk, the work happened and
|
|
448
|
+
# the harness signs for it, in bold, saying why it had to.
|
|
449
|
+
#
|
|
450
|
+
# @return [Agentilda::Runner::Attempt]
|
|
451
|
+
def verify_and_sign(job, base, reason)
|
|
452
|
+
task = job.task
|
|
453
|
+
agent = task.agent
|
|
454
|
+
subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
|
|
455
|
+
write_interrupted(subject, agent, task.round, reason)
|
|
456
|
+
target = agent.advances_to
|
|
457
|
+
if target && subject.machine.may?(target)
|
|
458
|
+
successor = @runner.agents.for_status(STATUS_BY_KEY.fetch(target)).first&.name
|
|
459
|
+
lines = [Ledger.render(Ledger::Entry.new(at: Time.now,
|
|
460
|
+
agent: agent.name,
|
|
461
|
+
status: "Completed",
|
|
462
|
+
round: task.round,
|
|
463
|
+
note: "signed by harness: work verified on disk",
|
|
464
|
+
file: "",
|
|
465
|
+
line: 0))]
|
|
466
|
+
lines << Ledger.render_handoff(Ledger::Handoff.new(at: Time.now, next: successor, file: "", line: 0)) if successor
|
|
467
|
+
Ledger.append(File.join(subject.feature.path, agent.ledger.first), *lines)
|
|
468
|
+
entry = Ledger.last_for(read_ledger(task), agent.name)
|
|
469
|
+
complete(job, base.with(note: "#{reason}; signed by harness"), entry, read_ledger(task))
|
|
470
|
+
else
|
|
471
|
+
remember(subject.feature.ordinal.to_s, agent.name, job.state, "Interrupted", task.round)
|
|
472
|
+
base.with(ok: false, status: "Interrupted", note: "#{reason}; #{retry_note(agent, Ledger::Entry.new(at: Time.now, agent: agent.name, status: "Interrupted", round: task.round, file: "", line: 0))}")
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# @return [void]
|
|
477
|
+
def write_interrupted(subject, agent, round, reason)
|
|
478
|
+
line = Ledger.render(Ledger::Entry.new(at: Time.now,
|
|
479
|
+
agent: agent.name,
|
|
480
|
+
status: "Interrupted",
|
|
481
|
+
round:,
|
|
482
|
+
note: reason,
|
|
483
|
+
file: "",
|
|
484
|
+
line: 0,
|
|
485
|
+
bold: true))
|
|
486
|
+
Ledger.append(File.join(subject.feature.path, agent.ledger.first), line)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# A Completed entry: move the folder, publish if that made it reviewable,
|
|
490
|
+
# and honour the `next:` line.
|
|
491
|
+
#
|
|
492
|
+
# @return [Agentilda::Runner::Attempt]
|
|
493
|
+
def complete(job, base, entry, reading)
|
|
494
|
+
task = job.task
|
|
495
|
+
agent = task.agent
|
|
496
|
+
ordinal = task.subject.feature.ordinal.to_s
|
|
497
|
+
subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
|
|
498
|
+
remember(ordinal, agent.name, job.state, "Completed", task.round)
|
|
499
|
+
verdict = verdict_of(entry)
|
|
500
|
+
partner_running = @running.any? { |j| j.task.subject.feature.ordinal.to_s == ordinal }
|
|
501
|
+
target = target_for(agent, verdict, partner_running)
|
|
502
|
+
|
|
503
|
+
if verdict == :approved
|
|
504
|
+
@finished_plans << ordinal
|
|
505
|
+
return base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge")
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
attempt = base.with(status: "Completed")
|
|
509
|
+
if target
|
|
510
|
+
return attempt.with(ok: false, note: "ledger says Completed but #{STATUS_BY_KEY.fetch(target)} is not justified: #{STATUS_BY_KEY.fetch(target).violation(subject)}") unless subject.machine.may?(target)
|
|
511
|
+
|
|
512
|
+
subject.machine.promote!(target)
|
|
513
|
+
attempt = attempt.with(to: target, note: "#{base.note}#{" (#{entry.note})" if entry.note}")
|
|
514
|
+
attempt = publish_if_reviewable(task, target, attempt)
|
|
515
|
+
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
handoff = Ledger.handoff_after(reading, entry)
|
|
519
|
+
attempt = honour(handoff, subject, attempt) if handoff
|
|
520
|
+
attempt
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# @param entry [Agentilda::Ledger::Entry]
|
|
524
|
+
# @return [Symbol, nil] :rejected, :approved, :slop, :product or nil
|
|
525
|
+
def verdict_of(entry)
|
|
526
|
+
note = entry.note.to_s.downcase
|
|
527
|
+
return :rejected if note.include?("reject")
|
|
528
|
+
return :approved if note.include?("approv")
|
|
529
|
+
return :slop if note.include?("slop") || note.include?("rewrite")
|
|
530
|
+
return :product if note.include?("product")
|
|
531
|
+
|
|
532
|
+
nil
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# @return [Symbol, nil] where the folder goes
|
|
536
|
+
def target_for(agent, verdict, partner_running)
|
|
537
|
+
case verdict
|
|
538
|
+
when :rejected then :rejected
|
|
539
|
+
when :slop then :shit
|
|
540
|
+
else partner_running ? agent.holds_at : agent.advances_to
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# @return [Agentilda::Runner::Attempt]
|
|
545
|
+
def publish_if_reviewable(task, target, attempt)
|
|
546
|
+
return attempt unless target == :ready_for_review
|
|
547
|
+
|
|
548
|
+
subject = fresh(task) or return attempt
|
|
549
|
+
publication = @runner.publish(task, subject)
|
|
550
|
+
if publication&.published?
|
|
551
|
+
attempt.with(note: "#{attempt.note}; opened #{publication.url}")
|
|
552
|
+
elsif publication&.refusal
|
|
553
|
+
attempt.with(note: "#{attempt.note}; publish refused: #{publication.refusal}")
|
|
554
|
+
else
|
|
555
|
+
attempt
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# A `next:` that names someone who handles the plan's new state is
|
|
560
|
+
# honoured by preferring them; one that does not is reported, and the
|
|
561
|
+
# state's own agents take it, because a wrong name must not stall a plan.
|
|
562
|
+
#
|
|
563
|
+
# @return [Agentilda::Runner::Attempt]
|
|
564
|
+
def honour(handoff, subject, attempt)
|
|
565
|
+
current = fresh_by(subject.feature.ordinal) || subject
|
|
566
|
+
named = @runner.agents.find(handoff.next)
|
|
567
|
+
if named&.handles?(current.status)
|
|
568
|
+
@preferred[current.feature.ordinal.to_s] = named.name
|
|
569
|
+
attempt
|
|
570
|
+
else
|
|
571
|
+
who = named ? "does not handle #{current.status}" : "is not an agent"
|
|
572
|
+
attempt.with(note: "#{attempt.note}; next: names #{handoff.next}, who #{who}")
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# @return [Agentilda::Runner::Attempt]
|
|
577
|
+
def park(job, base, entry)
|
|
578
|
+
task = job.task
|
|
579
|
+
subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
|
|
580
|
+
remember(subject.feature.ordinal.to_s, task.agent.name, job.state, "Blocked", task.round)
|
|
581
|
+
target = verdict_of(entry) == :product ? :product_blocked : :blocked
|
|
582
|
+
if subject.machine.may?(target)
|
|
583
|
+
subject.machine.promote!(target)
|
|
584
|
+
base.with(to: target, status: "Blocked", note: "blocked; see blocked.md")
|
|
585
|
+
else
|
|
586
|
+
base.with(ok: false, status: "Blocked", note: "ledger says Blocked but #{STATUS_BY_KEY.fetch(target).violation(subject)}")
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# @return [Agentilda::Runner::Attempt]
|
|
591
|
+
def crashed(job, base, error)
|
|
592
|
+
remember(base.ordinal, base.agent, job.state, "Interrupted", base.round)
|
|
593
|
+
base.with(ok: false, note: error.message.lines.first.to_s.strip)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# @param job [Agentilda::Dispatcher::Job]
|
|
597
|
+
# @return [Agentilda::Runner::Attempt]
|
|
598
|
+
def attempt_for(job)
|
|
599
|
+
task = job.task
|
|
600
|
+
result = job.result
|
|
601
|
+
spend = ->(field) { result.respond_to?(field) ? result.public_send(field) : 0 }
|
|
602
|
+
Runner::Attempt.new(ordinal: task.subject.feature.ordinal.to_s,
|
|
603
|
+
agent: task.agent.name,
|
|
604
|
+
from: job.from,
|
|
605
|
+
to: job.from,
|
|
606
|
+
ok: result.respond_to?(:ok) ? !!result.ok : false,
|
|
607
|
+
note: result.respond_to?(:note) ? result.note.to_s : "",
|
|
608
|
+
up: spend.call(:up),
|
|
609
|
+
down: spend.call(:down),
|
|
610
|
+
subagents: spend.call(:subagents),
|
|
611
|
+
delegated: spend.call(:delegated),
|
|
612
|
+
seconds: spend.call(:seconds),
|
|
613
|
+
round: task.round,
|
|
614
|
+
file: job.file || task.agent.ledger.first.to_s,
|
|
615
|
+
model: model_for(task.agent),
|
|
616
|
+
status: job.status)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# @return [Agentilda::Subject, nil] the plan read fresh from the main tree
|
|
620
|
+
def fresh(task) = fresh_by(task.subject.feature.ordinal)
|
|
621
|
+
|
|
622
|
+
# @return [Agentilda::Subject, nil]
|
|
623
|
+
def fresh_by(ordinal) = Tree.new(dir: @runner.tree.dir).find(ordinal)
|
|
624
|
+
|
|
625
|
+
# One entry per round: the Started written at dispatch is overwritten by
|
|
626
|
+
# the outcome rather than joined by it. Two entries a round would spend
|
|
627
|
+
# an agent's rounds at double speed and number them 1, 3, 5.
|
|
628
|
+
#
|
|
629
|
+
# @return [void]
|
|
630
|
+
def remember(ordinal, agent, state, status, round)
|
|
631
|
+
@mutex.synchronize do
|
|
632
|
+
entries = @history[[ordinal.to_s, agent]]
|
|
633
|
+
entry = entries.find { |h| h[:state] == state && h[:round] == round }
|
|
634
|
+
entry ? entry[:status] = status : entries << { state:, status:, round: }
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# @return [void]
|
|
639
|
+
def persist
|
|
640
|
+
return unless @state
|
|
641
|
+
|
|
642
|
+
@state.heartbeat!
|
|
643
|
+
@state.save
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
end
|