agentilda 1.0.3
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/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Drives specialist agents over a `.plans` tree until it stops changing.
|
|
5
|
+
#
|
|
6
|
+
# The hard part of any agent loop is knowing when to stop, and this one does
|
|
7
|
+
# not have to guess: the state machine already defines "satisfied". A round
|
|
8
|
+
# advances plans; the loop ends at a FIXED POINT — a round in which no plan
|
|
9
|
+
# changed state — or when nothing is left that an agent may touch.
|
|
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.
|
|
14
|
+
class Runner
|
|
15
|
+
# What one agent did to one plan.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] ordinal
|
|
18
|
+
# @return [String]
|
|
19
|
+
# @!attribute [r] agent
|
|
20
|
+
# @return [String]
|
|
21
|
+
# @!attribute [r] from
|
|
22
|
+
# @return [Symbol] state before
|
|
23
|
+
# @!attribute [r] to
|
|
24
|
+
# @return [Symbol] state after
|
|
25
|
+
# @!attribute [r] ok
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
# @!attribute [r] note
|
|
28
|
+
# @return [String]
|
|
29
|
+
# @!attribute [r] up
|
|
30
|
+
# @return [Integer] tokens sent, sub-agents included
|
|
31
|
+
# @!attribute [r] down
|
|
32
|
+
# @return [Integer] tokens generated
|
|
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
|
|
41
|
+
# @return [Boolean] whether the plan actually moved
|
|
42
|
+
def advanced? = ok && from != to
|
|
43
|
+
end
|
|
44
|
+
|
|
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
|
+
# 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
|
+
Task = Data.define(:agent, :subject, :root, :checkout, :round) do
|
|
72
|
+
# @return [String] for the spinner line — the pid and round render
|
|
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]
|
|
80
|
+
def log_fields
|
|
81
|
+
{plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
|
|
82
|
+
agent: agent.name, round: format("%02d", round)}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# How a task's return value reads on its spinner line: any hop that was
|
|
87
|
+
# not ok makes the whole line a failure, shown with that hop's note. The
|
|
88
|
+
# executor reports failure by returning rather than raising, and a line
|
|
89
|
+
# that drew ✓ "done" over a timed-out agent — directly above a round
|
|
90
|
+
# table saying FAIL — was the contradiction this closes.
|
|
91
|
+
FAILURE = ->(result) { result.find { |a| !a.ok }&.note if result.is_a?(Array) }
|
|
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)
|
|
130
|
+
@tree = tree
|
|
131
|
+
@executor = executor
|
|
132
|
+
@agents = agents
|
|
133
|
+
@max_rounds = max_rounds
|
|
134
|
+
@isolation = isolation
|
|
135
|
+
@worktree = worktree
|
|
136
|
+
@plans = plans
|
|
137
|
+
@publisher = publisher
|
|
138
|
+
@dry_run = dry_run
|
|
139
|
+
@chain = chain
|
|
140
|
+
@rounds = []
|
|
141
|
+
|
|
142
|
+
# Concurrency without isolation is the exact failure the worktree exists
|
|
143
|
+
# to prevent: two agents editing one checkout produce no git conflict, so
|
|
144
|
+
# the last writer wins silently. Refuse rather than corrupt.
|
|
145
|
+
@jobs = isolated? ? jobs : 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def isolated? = @isolation == :worktree
|
|
150
|
+
|
|
151
|
+
# @param subject [Agentilda::Subject]
|
|
152
|
+
# @return [Boolean] whether this run's scope covers this plan at all —
|
|
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
|
|
158
|
+
|
|
159
|
+
# @return [Agentilda::Worktree, nil]
|
|
160
|
+
attr_reader :worktree
|
|
161
|
+
|
|
162
|
+
# @return [Agentilda::Tree]
|
|
163
|
+
attr_reader :tree
|
|
164
|
+
|
|
165
|
+
# @return [Array<Agentilda::Runner::Round>]
|
|
166
|
+
attr_reader :rounds
|
|
167
|
+
|
|
168
|
+
# Run until the tree stops changing.
|
|
169
|
+
#
|
|
170
|
+
# @return [Array<Agentilda::Runner::Round>]
|
|
171
|
+
def call
|
|
172
|
+
dry = 0
|
|
173
|
+
|
|
174
|
+
1.upto(@max_rounds) do |number|
|
|
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
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Plans nobody may act on, for the closing report.
|
|
192
|
+
#
|
|
193
|
+
# @return [Array<Agentilda::Subject>]
|
|
194
|
+
def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
|
|
195
|
+
|
|
196
|
+
# @return [Boolean] every plan in scope is either finished or deliberately parked
|
|
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
|
|
250
|
+
|
|
251
|
+
# Give the task somewhere to work. Under isolation that is a fresh git
|
|
252
|
+
# worktree on its own branch named `<user>/NNN.MM-slug` — which is also
|
|
253
|
+
# what `resync prs` reads first, so the plan number carries itself all the
|
|
254
|
+
# way to a merged pull request.
|
|
255
|
+
#
|
|
256
|
+
# @param agent [Agentilda::Agent]
|
|
257
|
+
# @param subject [Agentilda::Subject]
|
|
258
|
+
# @param round [Integer]
|
|
259
|
+
# @return [Agentilda::Runner::Task]
|
|
260
|
+
def prepare(agent, subject, round)
|
|
261
|
+
return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) unless isolated?
|
|
262
|
+
|
|
263
|
+
checkout = worktree.checkout_for(subject.feature)
|
|
264
|
+
Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
|
|
265
|
+
end
|
|
266
|
+
|
|
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
|
+
# @return [Agentilda::Publisher::Publication, nil]
|
|
436
|
+
def publish(task, subject)
|
|
437
|
+
return nil unless @publisher && task.checkout&.dirty?
|
|
438
|
+
|
|
439
|
+
publication = @publisher.publish(checkout: task.checkout, subject:)
|
|
440
|
+
record_pull_request(subject.feature.path, publication) if publication.published?
|
|
441
|
+
publication
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# @param path [String] the plan folder, in the main tree
|
|
445
|
+
# @param publication [Agentilda::Publisher::Publication]
|
|
446
|
+
# @return [void]
|
|
447
|
+
def record_pull_request(path, publication)
|
|
448
|
+
number = publication.url.to_s[%r{/pull/(\d+)}, 1]
|
|
449
|
+
rows = PullRequests.new(dir: path).all.map { |pr|
|
|
450
|
+
{number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: ""}
|
|
451
|
+
}
|
|
452
|
+
rows << {number:, title: publication.title, url: publication.url, state: "Open 🟡", body: ""}
|
|
453
|
+
File.write(File.join(path, PullRequests::FILENAME), PullRequests.render(rows))
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|