agentilda 1.0.3 → 2.0.1
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 +10 -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 +92 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +46 -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 +65 -13
- 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/runner.rb
CHANGED
|
@@ -1,437 +1,117 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Agentilda
|
|
4
|
-
# Drives specialist agents over a `.plans` tree until
|
|
4
|
+
# Drives specialist agents over a `.plans` tree until nothing is left to
|
|
5
|
+
# dispatch.
|
|
5
6
|
#
|
|
6
|
-
# The
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# Blocked plans are not failures and not work. ⭕️ and 🅱️ mean a human must
|
|
12
|
-
# decide, so the loop reports them and steps around them. An agent that could
|
|
13
|
-
# move them would make the states meaningless.
|
|
7
|
+
# The loop itself is {Dispatcher}. This holds what the loop needs about
|
|
8
|
+
# the run: the tree and its scope, the roster, the executor, how many run
|
|
9
|
+
# at once, where each plan is checked out, and how a finished plan is
|
|
10
|
+
# published. Blocked plans (⭕️ 🅱️) are stepped around, never assigned.
|
|
14
11
|
class Runner
|
|
15
12
|
# What one agent did to one plan.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# @!attribute [r] subagents
|
|
34
|
-
# @return [Integer] sub-agents this agent spawned
|
|
35
|
-
# @!attribute [r] delegated
|
|
36
|
-
# @return [Integer] of {#up}, how much arrived unsplit from a sub-agent
|
|
37
|
-
# @!attribute [r] seconds
|
|
38
|
-
# @return [Float] how long the agent ran
|
|
39
|
-
Attempt = Data.define(:ordinal, :agent, :from, :to, :ok, :note, :up, :down, :subagents,
|
|
40
|
-
:delegated, :seconds) do
|
|
13
|
+
Attempt = Data.define(:ordinal,
|
|
14
|
+
:agent,
|
|
15
|
+
:from,
|
|
16
|
+
:to,
|
|
17
|
+
:ok,
|
|
18
|
+
:note,
|
|
19
|
+
:up,
|
|
20
|
+
:down,
|
|
21
|
+
:subagents,
|
|
22
|
+
:delegated,
|
|
23
|
+
:seconds,
|
|
24
|
+
:round,
|
|
25
|
+
:file,
|
|
26
|
+
:model,
|
|
27
|
+
:status) do
|
|
28
|
+
def initialize(round: 1, file: "", model: nil, status: nil, **rest) = super
|
|
29
|
+
|
|
41
30
|
# @return [Boolean] whether the plan actually moved
|
|
42
31
|
def advanced? = ok && from != to
|
|
43
32
|
end
|
|
44
33
|
|
|
45
|
-
# One pass over the tree.
|
|
46
|
-
#
|
|
47
|
-
# @!attribute [r] number
|
|
48
|
-
# @return [Integer] 1-based
|
|
49
|
-
# @!attribute [r] attempts
|
|
50
|
-
# @return [Array<Agentilda::Runner::Attempt>]
|
|
51
|
-
Round = Data.define(:number, :attempts) do
|
|
52
|
-
# @return [Integer]
|
|
53
|
-
def advanced = attempts.count(&:advanced?)
|
|
54
|
-
|
|
55
|
-
# @return [Boolean] nothing moved, so another identical round is pointless
|
|
56
|
-
def dry? = advanced.zero?
|
|
57
|
-
end
|
|
58
|
-
|
|
59
34
|
# One unit of work: an agent, a plan, and the checkout it happens in.
|
|
60
|
-
#
|
|
61
|
-
# @!attribute [r] agent
|
|
62
|
-
# @return [Agentilda::Agent]
|
|
63
|
-
# @!attribute [r] subject
|
|
64
|
-
# @return [Agentilda::Subject]
|
|
65
|
-
# @!attribute [r] root
|
|
66
|
-
# @return [String] the tree this agent sees
|
|
67
|
-
# @!attribute [r] checkout
|
|
68
|
-
# @return [Agentilda::Worktree::Checkout, nil] nil when sharing a tree
|
|
69
|
-
# @!attribute [r] round
|
|
70
|
-
# @return [Integer] which pass over the tree this task belongs to
|
|
71
35
|
Task = Data.define(:agent, :subject, :root, :checkout, :round) do
|
|
72
|
-
# @return [
|
|
73
|
-
# beside this through the line's own `:pid` token, once known
|
|
74
|
-
def label = "#{subject.feature.ordinal} #{UI.paint(agent.name, :yellow, :bold)}"
|
|
75
|
-
|
|
76
|
-
# The same facts unpainted and apart, for the log file, where they
|
|
77
|
-
# are columns rather than a sentence.
|
|
78
|
-
#
|
|
79
|
-
# @return [Hash]
|
|
36
|
+
# @return [Hash] columns for the log
|
|
80
37
|
def log_fields
|
|
81
|
-
{plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
|
|
82
|
-
agent: agent.name, round: format("%02d", round)}
|
|
38
|
+
{ plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
|
|
39
|
+
agent: agent.name, round: format("%02d", round) }
|
|
83
40
|
end
|
|
84
41
|
end
|
|
85
42
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# Rounds with no movement before the loop concedes. One is not enough: an
|
|
94
|
-
# agent can legitimately spend a round writing something another agent needs
|
|
95
|
-
# before either can advance.
|
|
96
|
-
DRY_ROUNDS = 2
|
|
97
|
-
|
|
98
|
-
# The most agents one task will chain through in a single round. The
|
|
99
|
-
# pipeline is shorter than this, so hitting the cap means states are
|
|
100
|
-
# cycling, and a cap beats a loop.
|
|
101
|
-
MAX_CHAIN_HOPS = 6
|
|
102
|
-
|
|
103
|
-
# @param tree [Agentilda::Tree]
|
|
104
|
-
# @param executor [#call] receives (agent, subject) and returns [ok, note]
|
|
105
|
-
# @param agents [Agentilda::Agents]
|
|
106
|
-
# @param max_rounds [Integer] a hard ceiling, so a loop cannot run forever
|
|
107
|
-
# @param isolation [Symbol] `:worktree` gives each plan its own checkout
|
|
108
|
-
# and branch; `:shared` runs every agent against one tree, which is only
|
|
109
|
-
# safe serially
|
|
110
|
-
# @param jobs [Integer] how many agents run at once
|
|
111
|
-
# @param plans [Array<Agentilda::Ordinal>, nil] restrict the loop to
|
|
112
|
-
# these plans; nil (the default) is the whole tree
|
|
113
|
-
# @param publisher [Agentilda::Publisher, nil] pushes a finished
|
|
114
|
-
# worktree and opens its pull request as soon as one lands, rather than
|
|
115
|
-
# once at the very end of the whole loop. nil (the default) never
|
|
116
|
-
# pushes anything — the caller's opt-out.
|
|
117
|
-
# @param dry_run [Boolean] no agent is invoked, so the per-round resync
|
|
118
|
-
# must not rename anything either — a preview that moves folders is
|
|
119
|
-
# not a preview.
|
|
120
|
-
# @param chain [Boolean] when an agent finishes and the plan's CONTENTS
|
|
121
|
-
# now justify the next state, hand the plan straight to that state's
|
|
122
|
-
# agent in the same round — researcher to writer to planner — instead
|
|
123
|
-
# of paying a full round per hop. The folder is not renamed mid-round
|
|
124
|
-
# (the serial resync still owns that); the chain reads {Subject#best_fit}
|
|
125
|
-
# afresh, which needs no rename. Off when the caller restricted the run
|
|
126
|
-
# to one agent, since chaining past the restriction would un-restrict it.
|
|
127
|
-
def initialize(tree:, executor:, agents: Agents.new, max_rounds: 10,
|
|
128
|
-
isolation: :shared, jobs: 1, worktree: nil, plans: nil, publisher: nil,
|
|
129
|
-
dry_run: false, chain: false)
|
|
43
|
+
# @param rounds [Integer, nil] `--rounds`, capping each agent's own
|
|
44
|
+
# @param state [Agentilda::StateFile, nil]
|
|
45
|
+
# @param sleeper [Proc]
|
|
46
|
+
# @param on_board [Proc, nil]
|
|
47
|
+
def initialize(tree:, executor:, agents: Agents.new, isolation: :shared, jobs: 1, worktree: nil,
|
|
48
|
+
plans: nil, publisher: nil, dry_run: false, rounds: nil, state: nil,
|
|
49
|
+
sleeper: ->(seconds) { sleep(seconds) }, on_board: nil)
|
|
130
50
|
@tree = tree
|
|
131
51
|
@executor = executor
|
|
132
52
|
@agents = agents
|
|
133
|
-
@max_rounds = max_rounds
|
|
134
53
|
@isolation = isolation
|
|
135
54
|
@worktree = worktree
|
|
136
55
|
@plans = plans
|
|
137
56
|
@publisher = publisher
|
|
138
57
|
@dry_run = dry_run
|
|
139
|
-
@
|
|
140
|
-
@
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# the last writer wins silently. Refuse rather than corrupt.
|
|
58
|
+
@rounds = rounds
|
|
59
|
+
@state = dry_run ? nil : state
|
|
60
|
+
@sleeper = sleeper
|
|
61
|
+
@on_board = on_board
|
|
62
|
+
@attempts = []
|
|
145
63
|
@jobs = isolated? ? jobs : 1
|
|
146
64
|
end
|
|
147
65
|
|
|
66
|
+
attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
|
|
67
|
+
|
|
148
68
|
# @return [Boolean]
|
|
149
69
|
def isolated? = @isolation == :worktree
|
|
150
70
|
|
|
151
|
-
# @
|
|
152
|
-
|
|
153
|
-
# `--plan` restricts it; with no `--plan` every plan is in scope
|
|
154
|
-
def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
|
|
155
|
-
|
|
156
|
-
# @return [Integer] agents running at once
|
|
157
|
-
attr_reader :jobs
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def dry_run? = @dry_run
|
|
158
73
|
|
|
159
|
-
# @return [
|
|
160
|
-
|
|
74
|
+
# @return [String] the repository root
|
|
75
|
+
def root = isolated? ? worktree.root : shared_root
|
|
161
76
|
|
|
162
|
-
# @
|
|
163
|
-
|
|
77
|
+
# @param subject [Agentilda::Subject]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
|
|
164
80
|
|
|
165
|
-
# @return [Array<Agentilda::
|
|
166
|
-
|
|
81
|
+
# @return [Array<Agentilda::Subject>]
|
|
82
|
+
def in_scope = tree.subjects.select { |s| in_scope?(s) }
|
|
167
83
|
|
|
168
|
-
# Run until
|
|
84
|
+
# Run until nothing is dispatchable.
|
|
169
85
|
#
|
|
170
|
-
# @
|
|
86
|
+
# @yieldparam dispatcher [Agentilda::Dispatcher] before it runs, so a
|
|
87
|
+
# console can attach itself and reach the running jobs
|
|
88
|
+
# @return [Array<Agentilda::Runner::Attempt>]
|
|
171
89
|
def call
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# `q` ends the loop at the next seam rather than instantly: the round
|
|
176
|
-
# in flight finishes (its agents were asked to STOP and get a grace
|
|
177
|
-
# period to write out), and no new round starts.
|
|
178
|
-
break if Control.quit?
|
|
179
|
-
|
|
180
|
-
round = run_round(number)
|
|
181
|
-
@rounds << round
|
|
182
|
-
break if round.attempts.empty?
|
|
183
|
-
|
|
184
|
-
dry = round.dry? ? dry + 1 : 0
|
|
185
|
-
break if dry >= DRY_ROUNDS
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
@rounds
|
|
90
|
+
dispatcher = Dispatcher.new(runner: self, state: @state, rounds: @rounds, sleeper: @sleeper, on_board: @on_board)
|
|
91
|
+
yield dispatcher if block_given?
|
|
92
|
+
@attempts = dispatcher.run
|
|
189
93
|
end
|
|
190
94
|
|
|
191
|
-
# Plans nobody may act on, for the closing report.
|
|
192
|
-
#
|
|
193
95
|
# @return [Array<Agentilda::Subject>]
|
|
194
96
|
def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
|
|
195
97
|
|
|
196
|
-
# @return [Boolean]
|
|
197
|
-
def settled?
|
|
198
|
-
in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
private
|
|
202
|
-
|
|
203
|
-
# @param number [Integer]
|
|
204
|
-
# @return [Agentilda::Runner::Round]
|
|
205
|
-
def run_round(number)
|
|
206
|
-
tree.reload
|
|
207
|
-
tasks = assignments.map { |agent, subject| prepare(agent, subject, number) }
|
|
208
|
-
return Round.new(number:, attempts: []) if tasks.empty?
|
|
209
|
-
|
|
210
|
-
results = UI.concurrently(tasks, "round #{number} — #{tasks.size} plans", jobs:,
|
|
211
|
-
label: :label.to_proc, fields: :log_fields.to_proc, failure: FAILURE,
|
|
212
|
-
header: {round: format("%02d", number)}, timeout: timeout_for) do |task, progress|
|
|
213
|
-
attempt(task, &progress)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# One serial pass over the *main* tree, run once per round rather than
|
|
217
|
-
# once per task. `Executor#prompt_for` always names a plan folder by its
|
|
218
|
-
# main-tree path — that is where `spec.md`/`plan.md`/a rename actually
|
|
219
|
-
# land, under every isolation mode — and running `Resync::Dirs` from
|
|
220
|
-
# `jobs` threads at once on the one tree they all share would be exactly
|
|
221
|
-
# the hazard a worktree exists to prevent for code, just aimed at
|
|
222
|
-
# `.plans` instead. Doing it here, after `UI.concurrently` has already
|
|
223
|
-
# joined every thread, costs nothing: nobody is still writing.
|
|
224
|
-
#
|
|
225
|
-
# It renames only what a real round may have moved: a dry run invoked
|
|
226
|
-
# no agent, and "dry run" that renames a folder anyway is a preview
|
|
227
|
-
# that already happened.
|
|
228
|
-
Resync::Dirs.new(tree:).call(commit: !@dry_run)
|
|
229
|
-
|
|
230
|
-
attempts = tasks.zip(results).flat_map do |task, r|
|
|
231
|
-
next [failed(r)] unless r.is_a?(Array)
|
|
232
|
-
|
|
233
|
-
*hops, last = r
|
|
234
|
-
hops + [finish(task, last)]
|
|
235
|
-
end
|
|
236
|
-
Round.new(number:, attempts:)
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
# Each line's countdown starts from that agent's own clock. The executor
|
|
240
|
-
# is a seam the suite fills with doubles that answer only `call`, so a
|
|
241
|
-
# stand-in without the method simply draws no timer rather than failing
|
|
242
|
-
# the round.
|
|
243
|
-
#
|
|
244
|
-
# @return [Proc] task -> seconds, or nil when the executor keeps no clock
|
|
245
|
-
def timeout_for
|
|
246
|
-
return UI::NO_TIMEOUT unless @executor.respond_to?(:timeout_for)
|
|
247
|
-
|
|
248
|
-
->(task) { @executor.timeout_for(task.agent) }
|
|
249
|
-
end
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def settled? = in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
|
|
250
100
|
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
101
|
+
# A dry run gets the shared root whatever the isolation says: nothing
|
|
102
|
+
# runs in the checkout, and `git worktree add` is a side effect a
|
|
103
|
+
# preview must not have. It also fails outright when the plan's branch
|
|
104
|
+
# is the one checked out where the run was typed, which is where a dry
|
|
105
|
+
# run of that plan is typed.
|
|
255
106
|
#
|
|
256
|
-
# @param agent [Agentilda::Agent]
|
|
257
|
-
# @param subject [Agentilda::Subject]
|
|
258
|
-
# @param round [Integer]
|
|
259
107
|
# @return [Agentilda::Runner::Task]
|
|
260
108
|
def prepare(agent, subject, round)
|
|
261
|
-
return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:)
|
|
109
|
+
return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
|
|
262
110
|
|
|
263
111
|
checkout = worktree.checkout_for(subject.feature)
|
|
264
112
|
Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
|
|
265
113
|
end
|
|
266
114
|
|
|
267
|
-
# @return [String]
|
|
268
|
-
def shared_root = File.dirname(tree.dir)
|
|
269
|
-
|
|
270
|
-
# @return [Array<Agentilda::Subject>] the tree, or just the plans
|
|
271
|
-
# `--plan` named — computed fresh each call, since {#run_round} reloads
|
|
272
|
-
# {#tree} before reading it
|
|
273
|
-
def in_scope = tree.subjects.select { |s| in_scope?(s) }
|
|
274
|
-
|
|
275
|
-
# @param error [Exception]
|
|
276
|
-
# @return [Agentilda::Runner::Attempt]
|
|
277
|
-
def failed(error)
|
|
278
|
-
Attempt.new(ordinal: "?", agent: "?", from: :unknown, to: :unknown, ok: false,
|
|
279
|
-
note: error.is_a?(Exception) ? error.message.lines.first.to_s.strip : error.to_s,
|
|
280
|
-
up: 0, down: 0, subagents: 0, delegated: 0, seconds: 0.0)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# Exactly one agent per plan per round — the first that handles its state.
|
|
284
|
-
# Offering a plan to two agents in one round invites them to write the same
|
|
285
|
-
# file from two directions.
|
|
286
|
-
#
|
|
287
|
-
# @return [Array<Array(Agentilda::Agent, Agentilda::Subject)>]
|
|
288
|
-
def assignments
|
|
289
|
-
in_scope.filter_map do |subject|
|
|
290
|
-
next if StateMachine::SETTLED.include?(subject.status.key)
|
|
291
|
-
|
|
292
|
-
agent = @agents.for_status(subject.status).first
|
|
293
|
-
agent && [agent, subject]
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
# Runs the agent and records what it claimed. `to` is left equal to
|
|
298
|
-
# `from` here — deliberately unfinished — because whether the folder
|
|
299
|
-
# actually moved cannot be answered yet: several of these run at once,
|
|
300
|
-
# each in its own checkout, and the one tree that would prove it moved is
|
|
301
|
-
# not safe to resync until every thread has stopped writing. {#finish}
|
|
302
|
-
# settles it, once, after {#run_round}'s single serial resync.
|
|
303
|
-
#
|
|
304
|
-
# With chaining on, one task can carry a plan through several agents:
|
|
305
|
-
# each hop re-reads the folder's contents, and only a state the contents
|
|
306
|
-
# JUSTIFY (per {Subject#best_fit}) hands the plan to the next agent. A
|
|
307
|
-
# state in {StateMachine::SETTLED} — parked, blocked, done — ends the
|
|
308
|
-
# chain the same way it keeps a plan out of {#assignments}.
|
|
309
|
-
#
|
|
310
|
-
# @param task [Agentilda::Runner::Task]
|
|
311
|
-
# @return [Array<Agentilda::Runner::Attempt>] one per agent that ran
|
|
312
|
-
# @yieldparam progress [Agentilda::Transcript::Progress] what the agent
|
|
313
|
-
# is doing and what it has spent, forwarded to its line
|
|
314
|
-
def attempt(task, &on_progress)
|
|
315
|
-
ordinal = task.subject.feature.ordinal.to_s
|
|
316
|
-
agent = task.agent
|
|
317
|
-
subject = task.subject
|
|
318
|
-
from = subject.status.key
|
|
319
|
-
attempts = []
|
|
320
|
-
|
|
321
|
-
loop do
|
|
322
|
-
result = @executor.call(agent, subject, root: task.root, &on_progress)
|
|
323
|
-
ok, note = result
|
|
324
|
-
note = "#{note} (#{task.checkout.branch})" if task.checkout
|
|
325
|
-
|
|
326
|
-
attempts << Attempt.new(ordinal:, agent: agent.name, from:, ok: !!ok, note: note.to_s, to: from,
|
|
327
|
-
up: spend(result, :up), down: spend(result, :down), subagents: spend(result, :subagents),
|
|
328
|
-
delegated: spend(result, :delegated), seconds: spend(result, :seconds))
|
|
329
|
-
|
|
330
|
-
# `n` deliberately does not appear here: stopping an agent early and
|
|
331
|
-
# letting the chain hand its plan to the next one is what n means.
|
|
332
|
-
# `q` stops the chain along with everything else.
|
|
333
|
-
break unless @chain && ok && attempts.size < MAX_CHAIN_HOPS && !Control.quit?
|
|
334
|
-
|
|
335
|
-
# Re-locate the plan by ordinal rather than by path: several agents
|
|
336
|
-
# rename their own folder when they finish, so the path this hop
|
|
337
|
-
# started with may already be stale. A fresh single-use Tree keeps the
|
|
338
|
-
# read out of the shared, memoized one that other threads see.
|
|
339
|
-
current = Tree.new(dir: tree.dir).find(task.subject.feature.ordinal)
|
|
340
|
-
break if current.nil?
|
|
341
|
-
|
|
342
|
-
fit = further_of(current)
|
|
343
|
-
break if fit.nil? || fit.key == from || StateMachine::SETTLED.include?(fit.key)
|
|
344
|
-
|
|
345
|
-
succ = @agents.for_status(fit).first
|
|
346
|
-
break if succ.nil?
|
|
347
|
-
|
|
348
|
-
attempts[-1] = attempts[-1].with(to: fit.key)
|
|
349
|
-
agent = succ
|
|
350
|
-
from = fit.key
|
|
351
|
-
subject = current
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
attempts
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
# The state a hop hands forward: whichever of the name the folder claims
|
|
358
|
-
# and the state its contents justify sits FURTHER along the pipeline.
|
|
359
|
-
# Either alone misleads — an agent that renamed its folder is ahead of
|
|
360
|
-
# what `best_fit` can prove (yoda-writer moves to ⭐️ before `plan.md`
|
|
361
|
-
# exists), and an agent that only wrote files is ahead of its unrenamed
|
|
362
|
-
# folder (leah-researcher leaves ⚪️ on the door of a researched spec).
|
|
363
|
-
# Taking the maximum is forward-monotonic, so a chain cannot cycle.
|
|
364
|
-
#
|
|
365
|
-
# @param subject [Agentilda::Subject]
|
|
366
|
-
# @return [Agentilda::Status, nil]
|
|
367
|
-
def further_of(subject)
|
|
368
|
-
[subject.status, subject.best_fit].compact.max_by { |s| STATUSES.index { |x| x.key == s.key } || -1 }
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# An executor is anything that answers `call` and returns something that
|
|
372
|
-
# destructures as `ok, note` — the specs pass an array, and a future one
|
|
373
|
-
# may too. Whatever it spent is an extra it does not have to report.
|
|
374
|
-
#
|
|
375
|
-
# @param result [Object]
|
|
376
|
-
# @param field [Symbol]
|
|
377
|
-
# @return [Numeric]
|
|
378
|
-
def spend(result, field) = result.respond_to?(field) ? result.public_send(field) : 0
|
|
379
|
-
|
|
380
|
-
# Reads what {#run_round}'s resync just settled, rather than trusting
|
|
381
|
-
# what the agent claims — an agent that says "done" but wrote nothing
|
|
382
|
-
# shows up as not having moved. Publishing is the one further thing this
|
|
383
|
-
# adds on top of that read.
|
|
384
|
-
#
|
|
385
|
-
# `resync` never moves a folder within a {StateMachine::FAMILIES} group on
|
|
386
|
-
# its own — a `plan.md` and some pull requests look identical whether
|
|
387
|
-
# nobody has looked yet or a reviewer just asked for changes, so guessing
|
|
388
|
-
# between them would be a coin flip dressed up as a correction. Whether a
|
|
389
|
-
# plan is ready to leave a building state is the implementer's call, not
|
|
390
|
-
# the harness's: each renames the plan folder itself once there is no unit
|
|
391
|
-
# of its own left in `plan.md` — `luke-backend` to 🎨 and `rey-frontend`
|
|
392
|
-
# to 🟢 — and the resync
|
|
393
|
-
# above leaves that rename standing — "the current name always wins"
|
|
394
|
-
# inside a family — even though nothing has opened a pull request yet.
|
|
395
|
-
# This is what reacts to it: publish, so the invariant that rename is
|
|
396
|
-
# jumping ahead of becomes true within the same round.
|
|
397
|
-
#
|
|
398
|
-
# @param task [Agentilda::Runner::Task]
|
|
399
|
-
# @param attempt [Agentilda::Runner::Attempt]
|
|
400
|
-
# @return [Agentilda::Runner::Attempt]
|
|
401
|
-
def finish(task, attempt)
|
|
402
|
-
return attempt unless attempt.ok
|
|
403
|
-
|
|
404
|
-
current = tree.find(task.subject.feature.ordinal)
|
|
405
|
-
to = current&.status&.key || attempt.from
|
|
406
|
-
settled = attempt.with(to:)
|
|
407
|
-
# The agent that ran last is the attempt's, which under chaining is not
|
|
408
|
-
# necessarily the one the task started with.
|
|
409
|
-
finisher = @agents.find(attempt.agent) || task.agent
|
|
410
|
-
return settled unless finisher.advances_to == :ready_for_review && to == :ready_for_review
|
|
411
|
-
|
|
412
|
-
publication = publish(task, current)
|
|
413
|
-
note = if publication&.published?
|
|
414
|
-
"#{settled.note}; opened #{publication.url}"
|
|
415
|
-
elsif publication&.refusal
|
|
416
|
-
"#{settled.note}; publish refused: #{publication.refusal}"
|
|
417
|
-
else
|
|
418
|
-
settled.note
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
settled.with(note:)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
# Push the finished branch and open its pull request, then record it in
|
|
425
|
-
# the plan's own `pull-requests.md` — the file {Status::STATUS_BY_KEY}'s
|
|
426
|
-
# `ready_for_review` invariant actually reads.
|
|
427
|
-
#
|
|
428
|
-
# `--isolation shared` has no branch of its own to push, so this is a
|
|
429
|
-
# no-op there by construction rather than by a special case: {@publisher}
|
|
430
|
-
# is nil unless the caller asked for pushing, and a shared task never has
|
|
431
|
-
# a {Worktree::Checkout} to push in the first place.
|
|
432
|
-
#
|
|
433
|
-
# @param task [Agentilda::Runner::Task]
|
|
434
|
-
# @param subject [Agentilda::Subject] read fresh, under its new name
|
|
435
115
|
# @return [Agentilda::Publisher::Publication, nil]
|
|
436
116
|
def publish(task, subject)
|
|
437
117
|
return nil unless @publisher && task.checkout&.dirty?
|
|
@@ -441,16 +121,19 @@ module Agentilda
|
|
|
441
121
|
publication
|
|
442
122
|
end
|
|
443
123
|
|
|
444
|
-
|
|
445
|
-
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# @return [String]
|
|
127
|
+
def shared_root = File.dirname(tree.dir)
|
|
128
|
+
|
|
446
129
|
# @return [void]
|
|
447
130
|
def record_pull_request(path, publication)
|
|
448
131
|
number = publication.url.to_s[%r{/pull/(\d+)}, 1]
|
|
449
132
|
rows = PullRequests.new(dir: path).all.map { |pr|
|
|
450
|
-
{number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: ""}
|
|
133
|
+
{ number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: "" }
|
|
451
134
|
}
|
|
452
|
-
rows << {number:, title: publication.title, url: publication.url, state: "Open 🟡", body: ""}
|
|
453
|
-
|
|
135
|
+
rows << { number:, title: publication.title, url: publication.url, state: "Open 🟡", body: "" }
|
|
136
|
+
PullRequests.upsert(File.join(path, PullRequests::FILENAME), rows)
|
|
454
137
|
end
|
|
455
138
|
end
|
|
456
139
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Screen
|
|
7
|
+
class Ratatui
|
|
8
|
+
module Bar
|
|
9
|
+
# Cells the fill portion occupies.
|
|
10
|
+
WIDTH = 10
|
|
11
|
+
|
|
12
|
+
# Seconds a bar visually maxes out at. Cosmetic only — the color
|
|
13
|
+
# thresholds below are independent, absolute-minute rules.
|
|
14
|
+
CAP = 1800
|
|
15
|
+
|
|
16
|
+
# @param tui [RatatuiRuby::TUI]
|
|
17
|
+
# @param seconds [Integer, nil] nil draws an empty, dim bar
|
|
18
|
+
# @param color [Symbol] the fill's foreground color
|
|
19
|
+
# @return [RatatuiRuby::Text::Line]
|
|
20
|
+
def self.cell(tui, seconds, color)
|
|
21
|
+
return empty(tui) if seconds.nil?
|
|
22
|
+
|
|
23
|
+
filled = ((seconds.to_f / CAP).clamp(0.0, 1.0) * WIDTH).round
|
|
24
|
+
bar = ("▓" * filled) + ("░" * (WIDTH - filled))
|
|
25
|
+
clock = format(" %2d:%02d", seconds / 60, seconds % 60)
|
|
26
|
+
tui.text_line(spans: [
|
|
27
|
+
tui.text_span(content: bar, style: tui.style(fg: color)),
|
|
28
|
+
tui.text_span(content: clock, style: tui.style(fg: :bright_black))
|
|
29
|
+
])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param tui [RatatuiRuby::TUI]
|
|
33
|
+
# @return [RatatuiRuby::Text::Line]
|
|
34
|
+
def self.empty(tui)
|
|
35
|
+
tui.text_line(spans: [
|
|
36
|
+
tui.text_span(content: " " * WIDTH, style: tui.style(fg: :bright_black)),
|
|
37
|
+
tui.text_span(content: " --:--", style: tui.style(fg: :bright_black))
|
|
38
|
+
])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param elapsed [Integer]
|
|
42
|
+
# @return [Symbol]
|
|
43
|
+
def self.elapsed_color(elapsed) = elapsed >= 900 ? :yellow : :green
|
|
44
|
+
|
|
45
|
+
# @param remaining [Integer, nil]
|
|
46
|
+
# @return [Symbol]
|
|
47
|
+
def self.remaining_color(remaining)
|
|
48
|
+
return :bright_black if remaining.nil?
|
|
49
|
+
return :red if remaining < 300
|
|
50
|
+
return :yellow if remaining < 900
|
|
51
|
+
|
|
52
|
+
:green
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Screen
|
|
7
|
+
class Ratatui
|
|
8
|
+
# RatatuiRuby::Event -> the exact string Keyboard#handle already
|
|
9
|
+
# expects, so every key binding (select, kill, extend, wrap-up, quit,
|
|
10
|
+
# the dialog, ctrl-c) is reused verbatim rather than reimplemented.
|
|
11
|
+
module KeyTranslator
|
|
12
|
+
# @param event [RatatuiRuby::Event]
|
|
13
|
+
# @return [String, nil] nil for anything that is not a key press
|
|
14
|
+
def self.call(event)
|
|
15
|
+
return nil unless event.key?
|
|
16
|
+
return "\u0003" if event.interrupt?
|
|
17
|
+
|
|
18
|
+
case event.code
|
|
19
|
+
when "up" then "\e[A"
|
|
20
|
+
when "down" then "\e[B"
|
|
21
|
+
when "enter" then "\r"
|
|
22
|
+
when "esc" then "\e"
|
|
23
|
+
else event.code
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|