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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Screen
|
|
7
|
+
# A ratatui_ruby-backed alternative to {Screen}, selected with `--tui
|
|
8
|
+
# ratatui`. Unlike {Screen}, whose #draw renders synchronously from the
|
|
9
|
+
# dispatcher's own tick, ratatui_ruby owns a blocking render+input loop
|
|
10
|
+
# of its own — see the lifecycle methods added in the next task. This
|
|
11
|
+
# class carries the parts of that loop with no terminal dependency: the
|
|
12
|
+
# widget tree built from a {Board}.
|
|
13
|
+
class Ratatui
|
|
14
|
+
# Cells each column takes. The last one is stretched to the edge by
|
|
15
|
+
# ratatui's legacy flex. The elapsed/time-left columns (indices 8 and 9) are 17,
|
|
16
|
+
# not 13: {Bar.cell} emits a 10-cell bar plus a clock suffix up to 7
|
|
17
|
+
# characters wide (e.g. " 12:41"), and a narrower column clips the
|
|
18
|
+
# clock digits once ratatui actually lays the table out (see
|
|
19
|
+
# spec/agentilda/screen/ratatui_spec.rb's real-terminal regression
|
|
20
|
+
# test, which is the only one of this file's specs that can see
|
|
21
|
+
# that — the rest inspect the pre-layout `Text::Line` object).
|
|
22
|
+
COLUMNS = [9, 7, 16, 18, 8, 8, 5, 13, 17, 17].freeze
|
|
23
|
+
|
|
24
|
+
HEADER = %w[time plan feature agent round model subs tokens elapsed left].freeze
|
|
25
|
+
|
|
26
|
+
# Blank lines between two agents.
|
|
27
|
+
ROW_GAP = 1
|
|
28
|
+
|
|
29
|
+
# The highlight symbol's column. Reserved even with nothing selected,
|
|
30
|
+
# so the activity line's x never shifts when a selection appears.
|
|
31
|
+
HIGHLIGHT_SYMBOL = "> "
|
|
32
|
+
|
|
33
|
+
# The activity line starts under the feature column: past the
|
|
34
|
+
# highlight symbol, time and plan, each column plus its 1-cell gap.
|
|
35
|
+
ACTIVITY_INDENT = HIGHLIGHT_SYMBOL.length + COLUMNS[0] + 1 + COLUMNS[1] + 1
|
|
36
|
+
|
|
37
|
+
# Cells a status line stops short of the terminal's right edge.
|
|
38
|
+
ACTIVITY_MARGIN = 5
|
|
39
|
+
|
|
40
|
+
# Status lines drawn under an agent's row when nobody says otherwise.
|
|
41
|
+
SCROLL_HEIGHT = 3
|
|
42
|
+
|
|
43
|
+
# Seconds between samples for the running-agent-count sparkline.
|
|
44
|
+
SAMPLE_INTERVAL = 10
|
|
45
|
+
|
|
46
|
+
# Points the sparkline keeps before the oldest scrolls off the left.
|
|
47
|
+
HISTORY = 60
|
|
48
|
+
|
|
49
|
+
# @return [Array<Integer>] running-agent-count samples, oldest first
|
|
50
|
+
attr_reader :history
|
|
51
|
+
|
|
52
|
+
# @return [Integer] status lines drawn under each agent's row
|
|
53
|
+
attr_reader :scroll_height
|
|
54
|
+
|
|
55
|
+
# @param runner [#call] `RatatuiRuby.method(:run)` by default; a fake
|
|
56
|
+
# in tests, so this class never needs a real terminal to exercise
|
|
57
|
+
# its own orchestration
|
|
58
|
+
# @param scroll_height [Integer] how many of an agent's latest statuses
|
|
59
|
+
# show under its row, newest at the top
|
|
60
|
+
def initialize(runner: RatatuiRuby.method(:run), scroll_height: SCROLL_HEIGHT)
|
|
61
|
+
@runner = runner
|
|
62
|
+
@scroll_height = Integer(scroll_height).clamp(1, Board::Row::HISTORY)
|
|
63
|
+
@board = nil
|
|
64
|
+
@keyboard = nil
|
|
65
|
+
@history = []
|
|
66
|
+
@last_sample = nil
|
|
67
|
+
# Read from the dispatcher's thread in #tick's `until @closing` via
|
|
68
|
+
# #run_loop, written from the caller's thread in #close. Safe only
|
|
69
|
+
# under MRI/CRuby's GVL, which serializes the read and the write;
|
|
70
|
+
# a GVL-free Ruby would need a real memory barrier here.
|
|
71
|
+
@closing = false
|
|
72
|
+
@error = nil
|
|
73
|
+
@mutex = Mutex.new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param keyboard [Agentilda::Keyboard] built with `.new`, never
|
|
77
|
+
# `.start`'d — this is what feeds it keys, from the thread
|
|
78
|
+
# ratatui_ruby's own loop runs on.
|
|
79
|
+
# @return [void]
|
|
80
|
+
def attach_keyboard(keyboard) = @keyboard = keyboard
|
|
81
|
+
|
|
82
|
+
# @return [void]
|
|
83
|
+
def open
|
|
84
|
+
@closing = false
|
|
85
|
+
@error = nil
|
|
86
|
+
@thread = Thread.new do
|
|
87
|
+
Thread.current.report_on_exception = false
|
|
88
|
+
begin
|
|
89
|
+
@runner.call { |tui| run_loop(tui) }
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
# A background render thread's job is never to crash the run:
|
|
92
|
+
# #close's `@thread&.join` would otherwise re-raise this inside
|
|
93
|
+
# `run.rb`'s `ensure`, after every agent has already run,
|
|
94
|
+
# discarding the whole run report. Stash it; #close surfaces it
|
|
95
|
+
# with a one-line warning instead.
|
|
96
|
+
@error = e
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Called by {Console#paint} from the dispatcher's own thread, exactly
|
|
102
|
+
# as {Screen#draw} is. Stores the board; the ratatui thread picks it
|
|
103
|
+
# up on its own next tick, a fraction of a frame later.
|
|
104
|
+
#
|
|
105
|
+
# @param board [Agentilda::Board]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def draw(board) = @mutex.synchronize { @board = board }
|
|
108
|
+
|
|
109
|
+
# @return [void]
|
|
110
|
+
def close
|
|
111
|
+
@closing = true
|
|
112
|
+
@thread&.join
|
|
113
|
+
@thread = nil
|
|
114
|
+
UI.line("ratatui backend failed: #{@error.message} (the run itself continued; nothing was drawn)") if @error
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param tui [RatatuiRuby::TUI]
|
|
118
|
+
# @return [void]
|
|
119
|
+
def run_loop(tui)
|
|
120
|
+
# `TableState.new`'s documented `selected = nil` default isn't
|
|
121
|
+
# honored by the native binding — it raises ArgumentError given
|
|
122
|
+
# zero arguments (see spec/agentilda/screen/ratatui_spec.rb's own
|
|
123
|
+
# `table_state` let) — so the nil has to be explicit.
|
|
124
|
+
table_state = tui.table_state(nil)
|
|
125
|
+
tick(tui, table_state) until @closing
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One frame: draw the latest board, sample the sparkline, forward the
|
|
129
|
+
# next key. Public so the lifecycle spec can drive it without needing
|
|
130
|
+
# to manage the `until @closing` loop.
|
|
131
|
+
#
|
|
132
|
+
# @param tui [RatatuiRuby::TUI]
|
|
133
|
+
# @param table_state [RatatuiRuby::TableState]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def tick(tui, table_state)
|
|
136
|
+
board = @mutex.synchronize { @board }
|
|
137
|
+
unless board
|
|
138
|
+
# `tui.poll_event` below is what paces this loop (ratatui's
|
|
139
|
+
# default poll timeout is ~16ms); returning ahead of it, as here,
|
|
140
|
+
# would otherwise spin a CPU core flat out until the dispatcher's
|
|
141
|
+
# first paint.
|
|
142
|
+
sleep(0.05)
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sample(board)
|
|
147
|
+
tui.draw { |frame| render(tui, frame, frame.area, board, table_state) }
|
|
148
|
+
forward(tui.poll_event)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param tui [RatatuiRuby::TUI]
|
|
152
|
+
# @param frame [RatatuiRuby::Frame]
|
|
153
|
+
# @param area [RatatuiRuby::Layout::Rect]
|
|
154
|
+
# @param board [Agentilda::Board]
|
|
155
|
+
# @param table_state [RatatuiRuby::TableState]
|
|
156
|
+
# @return [void]
|
|
157
|
+
def render(tui, frame, area, board, table_state)
|
|
158
|
+
sync_selection(board, table_state)
|
|
159
|
+
_, top, _, table_area, bottom = tui.layout_split(area,
|
|
160
|
+
direction: :vertical,
|
|
161
|
+
constraints: [tui.constraint_length(1), tui.constraint_length(1), tui.constraint_length(1),
|
|
162
|
+
tui.constraint_fill(1), tui.constraint_length(1)])
|
|
163
|
+
_bottom_text, bottom_spark = tui.layout_split(bottom,
|
|
164
|
+
direction: :horizontal,
|
|
165
|
+
constraints: [tui.constraint_fill(3), tui.constraint_fill(1)])
|
|
166
|
+
|
|
167
|
+
frame.render_widget(top_bar(tui, board), top)
|
|
168
|
+
frame.render_stateful_widget(table(tui, board), table_area, table_state)
|
|
169
|
+
render_activities(tui, frame, table_area, board, table_state)
|
|
170
|
+
# The bar is painted across the whole strip first, sparkline area
|
|
171
|
+
# included, so the cyan reaches both edges; the sparkline is then
|
|
172
|
+
# drawn over its right-hand share of it, in the same colors.
|
|
173
|
+
frame.render_widget(bottom_bar(tui, board), bottom)
|
|
174
|
+
frame.render_widget(tui.sparkline(data: @history, style: tui.style(fg: :black, bg: :cyan)), bottom_spark)
|
|
175
|
+
render_overlay(tui, frame, area, board)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param board [Agentilda::Board]
|
|
179
|
+
# @return [void]
|
|
180
|
+
def sample(board)
|
|
181
|
+
now = UI.monotonic
|
|
182
|
+
return if @last_sample && now - @last_sample < SAMPLE_INTERVAL
|
|
183
|
+
|
|
184
|
+
@last_sample = now
|
|
185
|
+
@history << board.running
|
|
186
|
+
@history.shift while @history.size > HISTORY
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param event [RatatuiRuby::Event]
|
|
190
|
+
# @return [void]
|
|
191
|
+
def forward(event)
|
|
192
|
+
key = KeyTranslator.call(event) or return
|
|
193
|
+
@keyboard&.handle(key)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# @param board [Agentilda::Board]
|
|
199
|
+
# @param table_state [RatatuiRuby::TableState]
|
|
200
|
+
# @return [void]
|
|
201
|
+
def sync_selection(board, table_state)
|
|
202
|
+
index = board.selected && board.rows.index { |r| r.key == board.selected }
|
|
203
|
+
table_state.select(index)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [RatatuiRuby::Widgets::Paragraph]
|
|
207
|
+
def top_bar(tui, board)
|
|
208
|
+
tui.paragraph(
|
|
209
|
+
text: "#{board.status} · plans: #{board.plans.join(", ")} " \
|
|
210
|
+
"· tokens ↑#{UI.abbreviate(board.up)} ↓#{UI.abbreviate(board.down)}",
|
|
211
|
+
style: tui.style(fg: :black, bg: :cyan)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [RatatuiRuby::Widgets::Paragraph]
|
|
216
|
+
def bottom_bar(tui, board)
|
|
217
|
+
tui.paragraph(
|
|
218
|
+
text: "working in #{board.root} · agents running: #{board.running} " \
|
|
219
|
+
"· live ↑#{UI.abbreviate(board.live_up)} ↓#{UI.abbreviate(board.live_down)}",
|
|
220
|
+
style: tui.style(fg: :black, bg: :cyan)
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @return [RatatuiRuby::Widgets::Table]
|
|
225
|
+
def table(tui, board)
|
|
226
|
+
tui.table(header: HEADER,
|
|
227
|
+
rows: board.rows.map { |row| table_row(tui, row) },
|
|
228
|
+
widths: COLUMNS.map { |w| tui.constraint_length(w) },
|
|
229
|
+
highlight_symbol: HIGHLIGHT_SYMBOL,
|
|
230
|
+
highlight_spacing: :always,
|
|
231
|
+
row_highlight_style: tui.style(modifiers: [:reversed]))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Lines one agent takes: its table row, then its statuses.
|
|
235
|
+
#
|
|
236
|
+
# @return [Integer]
|
|
237
|
+
def row_height = 1 + scroll_height
|
|
238
|
+
|
|
239
|
+
# Paints each visible agent's latest statuses over the empty lines
|
|
240
|
+
# under its table row, newest first: the newest bold, the rest plain,
|
|
241
|
+
# all yellow. A table cell cannot span columns, so a status would
|
|
242
|
+
# otherwise be clipped to one column's width; drawn on top, it gets
|
|
243
|
+
# everything from the feature column to {ACTIVITY_MARGIN} cells before
|
|
244
|
+
# the right edge, and a longer status ends in an ellipsis.
|
|
245
|
+
#
|
|
246
|
+
# @return [void]
|
|
247
|
+
def render_activities(tui, frame, table_area, board, table_state)
|
|
248
|
+
width = table_area.width - ACTIVITY_INDENT - ACTIVITY_MARGIN
|
|
249
|
+
return if width <= 0
|
|
250
|
+
|
|
251
|
+
bottom = table_area.y + table_area.height
|
|
252
|
+
board.rows.drop(table_state.offset.to_i).each_with_index do |row, index|
|
|
253
|
+
# +1 for the header line, +1 more to land under the row's own line.
|
|
254
|
+
top = table_area.y + 1 + (index * (row_height + ROW_GAP)) + 1
|
|
255
|
+
break if top >= bottom
|
|
256
|
+
|
|
257
|
+
statuses(row).each_with_index do |text, line|
|
|
258
|
+
y = top + line
|
|
259
|
+
break if y >= bottom
|
|
260
|
+
|
|
261
|
+
modifiers = line.zero? ? [:bold] : []
|
|
262
|
+
frame.render_widget(
|
|
263
|
+
tui.paragraph(text: ellipsize(text, width), style: tui.style(fg: :yellow, modifiers:)),
|
|
264
|
+
tui.rect(x: table_area.x + ACTIVITY_INDENT, y:, width:, height: 1)
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @param text [String]
|
|
271
|
+
# @param width [Integer] cells available
|
|
272
|
+
# @return [String] the text, or its head and an ellipsis within +width+
|
|
273
|
+
def ellipsize(text, width) = text.length > width ? "#{text[0, width - 1]}…" : text
|
|
274
|
+
|
|
275
|
+
# @param row [Agentilda::Board::Row]
|
|
276
|
+
# @return [Array<String>] newest first, at most {#scroll_height}
|
|
277
|
+
def statuses(row)
|
|
278
|
+
lines = row.history.empty? ? [row.message.to_s] : row.history
|
|
279
|
+
lines.first(scroll_height)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [RatatuiRuby::Widgets::Row]
|
|
283
|
+
def table_row(tui, row)
|
|
284
|
+
tui.row(height: row_height,
|
|
285
|
+
bottom_margin: ROW_GAP,
|
|
286
|
+
cells: [
|
|
287
|
+
row.at.strftime("%H:%M:%S"),
|
|
288
|
+
row.ordinal,
|
|
289
|
+
row.file,
|
|
290
|
+
tui.text_span(content: row.agent, style: tui.style(fg: :yellow, modifiers: [:bold])),
|
|
291
|
+
"R:#{row.round}/#{row.rounds}",
|
|
292
|
+
row.model.to_s,
|
|
293
|
+
row.subagents.to_s,
|
|
294
|
+
"↑#{UI.abbreviate(row.up)} ↓#{UI.abbreviate(row.down)}",
|
|
295
|
+
Bar.cell(tui, row.elapsed, Bar.elapsed_color(row.elapsed)),
|
|
296
|
+
Bar.cell(tui, row.remaining, Bar.remaining_color(row.remaining))
|
|
297
|
+
])
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [void]
|
|
301
|
+
def render_overlay(tui, frame, area, board)
|
|
302
|
+
text = board.dialog || board.help || board.about or return
|
|
303
|
+
title = if board.dialog
|
|
304
|
+
"Agent"
|
|
305
|
+
else
|
|
306
|
+
(board.help ? "Keys" : "About")
|
|
307
|
+
end
|
|
308
|
+
lines = text.lines.size + 4
|
|
309
|
+
width = [text.lines.map { |l| l.chomp.length }.max.to_i + 6, area.width].min
|
|
310
|
+
overlay_area = tui.rect(x: [(area.width - width) / 2, 0].max,
|
|
311
|
+
y: [(area.height - lines) / 2, 0].max,
|
|
312
|
+
width:,
|
|
313
|
+
height: lines)
|
|
314
|
+
frame.render_widget(tui.paragraph(text:, block: tui.block(title:, borders: [:all])), overlay_area)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
# Where a run stands, written every second, so a harness that dies leaves
|
|
8
|
+
# the next one something to restart from.
|
|
9
|
+
#
|
|
10
|
+
# The ledger inside each document is the record humans read and the
|
|
11
|
+
# dispatcher acts on; this is the index of it, plus what the ledger cannot
|
|
12
|
+
# hold: which run wrote what, process ids, tokens, and whether the run
|
|
13
|
+
# that wrote a `Started` is still alive. It sits in `.plans/` rather than
|
|
14
|
+
# the system temp dir because a reboot must not lose it, and it is
|
|
15
|
+
# gitignored because it is about one machine's run, not the plan.
|
|
16
|
+
class StateFile
|
|
17
|
+
FILENAME = "agentilda-state.json"
|
|
18
|
+
|
|
19
|
+
# Where it used to live, one directory down. Read by {#load} when the
|
|
20
|
+
# current path holds nothing, so a run started under the old layout is
|
|
21
|
+
# picked up rather than silently restarted from zero. Nothing writes
|
|
22
|
+
# here any more and nothing deletes it: the file left behind is the
|
|
23
|
+
# only record a half-finished run has.
|
|
24
|
+
LEGACY_DIRNAME = "tmp"
|
|
25
|
+
|
|
26
|
+
# The lines {.ensure_ignored!} adds. The second covers the sibling
|
|
27
|
+
# {#save} writes and renames over the first — brief, but `.plans/` is a
|
|
28
|
+
# directory whose other contents are committed, so a `git status` run
|
|
29
|
+
# during that instant must not offer it.
|
|
30
|
+
IGNORE = ["#{Agentilda::PLANS_DIR}/#{FILENAME}", "#{Agentilda::PLANS_DIR}/#{FILENAME}.*.tmp"].freeze
|
|
31
|
+
|
|
32
|
+
# @param tree [Agentilda::Tree]
|
|
33
|
+
# @return [String]
|
|
34
|
+
def self.for(tree) = File.join(tree.dir, FILENAME)
|
|
35
|
+
|
|
36
|
+
# @param tree [Agentilda::Tree]
|
|
37
|
+
# @return [String] where {LEGACY_DIRNAME} put it
|
|
38
|
+
def self.legacy_for(tree) = File.join(tree.dir, LEGACY_DIRNAME, FILENAME)
|
|
39
|
+
|
|
40
|
+
# Add the ignore lines once. Editing somebody's `.gitignore` is a thing
|
|
41
|
+
# to announce, which is why this returns whether it did.
|
|
42
|
+
#
|
|
43
|
+
# Only the first line is asked about: git answers for a literal path,
|
|
44
|
+
# and the second is a glob no path equals. They are written together,
|
|
45
|
+
# so one being ignored means both are.
|
|
46
|
+
#
|
|
47
|
+
# @param root [String] repository root
|
|
48
|
+
# @return [Boolean] true when lines were added
|
|
49
|
+
def self.ensure_ignored!(root)
|
|
50
|
+
return false unless system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
|
51
|
+
return false if system("git", "-C", root, "check-ignore", "-q", IGNORE.first, out: File::NULL, err: File::NULL)
|
|
52
|
+
|
|
53
|
+
path = File.join(root, ".gitignore")
|
|
54
|
+
existing = File.file?(path) ? File.read(path) : ""
|
|
55
|
+
glue = existing.empty? || existing.end_with?("\n") ? "" : "\n"
|
|
56
|
+
File.write(path, "#{existing}#{glue}#{IGNORE.join("\n")}\n")
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param path [String]
|
|
61
|
+
# @param pid [Integer] this run's process id
|
|
62
|
+
def initialize(path:, pid: Process.pid)
|
|
63
|
+
@path = path
|
|
64
|
+
@pid = pid
|
|
65
|
+
@data = { "run" => {}, "plans" => {} }
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [String]
|
|
70
|
+
attr_reader :path
|
|
71
|
+
|
|
72
|
+
# Where a run started before the file moved up out of {LEGACY_DIRNAME}
|
|
73
|
+
# would have written. {#load} falls back to it; {#save} never uses it.
|
|
74
|
+
#
|
|
75
|
+
# @return [String]
|
|
76
|
+
def legacy_path = File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))
|
|
77
|
+
|
|
78
|
+
# Reads the current path, or the legacy one when the current path holds
|
|
79
|
+
# nothing. A run interrupted under the old layout is otherwise invisible
|
|
80
|
+
# to the run that picks it up, and {#stranded} would report no stranded
|
|
81
|
+
# stages for a harness that died with several.
|
|
82
|
+
#
|
|
83
|
+
# @return [self]
|
|
84
|
+
def load
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
source = [path, legacy_path].find { |candidate| File.file?(candidate) }
|
|
87
|
+
@data = JSON.parse(File.read(source)) if source
|
|
88
|
+
@data = { "run" => {}, "plans" => {} } unless @data.is_a?(Hash) && @data["plans"].is_a?(Hash)
|
|
89
|
+
end
|
|
90
|
+
self
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Written whole to a sibling and renamed over, so a reader never sees
|
|
96
|
+
# half a file.
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
def save
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
102
|
+
temp = "#{path}.#{@pid}.tmp"
|
|
103
|
+
File.write(temp, JSON.pretty_generate(@data))
|
|
104
|
+
File.rename(temp, path)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param root [String]
|
|
109
|
+
# @return [void]
|
|
110
|
+
def begin_run!(root:)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
@data["run"] = { "pid" => @pid, "root" => root, "started_at" => Time.now.iso8601,
|
|
113
|
+
"heartbeat_at" => Time.now.iso8601 }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [void]
|
|
118
|
+
def heartbeat!
|
|
119
|
+
@mutex.synchronize { @data["run"]["heartbeat_at"] = Time.now.iso8601 }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param ordinal [String]
|
|
123
|
+
# @param agent [String]
|
|
124
|
+
# @param round [Integer]
|
|
125
|
+
# @param fields [Hash] anything else worth keeping: status, model, file,
|
|
126
|
+
# pid, up, down, state, next, exit
|
|
127
|
+
# @return [void]
|
|
128
|
+
def record(ordinal, agent:, round:, **fields)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
plan = (@data["plans"][ordinal.to_s] ||= { "stages" => [] })
|
|
131
|
+
stage = plan["stages"].find { |s| s["agent"] == agent && s["round"] == round }
|
|
132
|
+
unless stage
|
|
133
|
+
stage = { "agent" => agent, "round" => round, "run_pid" => @pid }
|
|
134
|
+
plan["stages"] << stage
|
|
135
|
+
end
|
|
136
|
+
fields.each { |key, value| stage[key.to_s] = value }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param ordinal [String]
|
|
141
|
+
# @return [Array<Hash>]
|
|
142
|
+
def stages(ordinal) = @mutex.synchronize { (@data.dig("plans", ordinal.to_s, "stages") || []).map(&:dup) }
|
|
143
|
+
|
|
144
|
+
# @return [Array<String>] every ordinal with a stage recorded
|
|
145
|
+
def plans = @mutex.synchronize { @data["plans"].keys }
|
|
146
|
+
|
|
147
|
+
# @return [Boolean] whether the run that last wrote this file is gone
|
|
148
|
+
def previous_run_dead?
|
|
149
|
+
previous = @data.dig("run", "pid")
|
|
150
|
+
return false if previous.nil? || previous == @pid
|
|
151
|
+
|
|
152
|
+
Process.kill(0, previous)
|
|
153
|
+
false
|
|
154
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Stages a dead run left `Started`. Each is an agent that was cut off
|
|
159
|
+
# mid-flight and has to be re-run, with the harness's own Interrupted
|
|
160
|
+
# line written so the document agrees with this file.
|
|
161
|
+
#
|
|
162
|
+
# @return [Array<Array(String, Hash)>] ordinal and stage
|
|
163
|
+
def stranded
|
|
164
|
+
return [] unless previous_run_dead?
|
|
165
|
+
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
@data["plans"].flat_map { |ordinal, plan|
|
|
168
|
+
plan["stages"].select { |s| s["status"] == "Started" && s["run_pid"] != @pid }
|
|
169
|
+
.map { |s| [ordinal, s.dup] }
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "aasm"
|
|
4
4
|
|
|
5
|
+
require "agentilda/status"
|
|
6
|
+
|
|
5
7
|
module Agentilda
|
|
6
8
|
# The state machine for one plan folder.
|
|
7
9
|
#
|
|
@@ -34,18 +36,19 @@ module Agentilda
|
|
|
34
36
|
# work back through both halves of building, it does not skip the reviewer
|
|
35
37
|
# and it does not assume the half nobody complained about still holds.
|
|
36
38
|
SPINE = {
|
|
37
|
-
retroactive:
|
|
38
|
-
new:
|
|
39
|
-
researched:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
retroactive: :planned,
|
|
40
|
+
new: :researched,
|
|
41
|
+
researched: :ready_for_planning,
|
|
42
|
+
ready_for_planning: :planned,
|
|
43
|
+
planned: :building,
|
|
44
|
+
building: :building_ui,
|
|
45
|
+
building_ui: :ready_for_review,
|
|
46
|
+
ready_for_review: :in_review,
|
|
47
|
+
in_review: :approved,
|
|
48
|
+
approved: :deployed,
|
|
49
|
+
rejected: :building_ui,
|
|
50
|
+
rolled_back: :ready_for_review,
|
|
51
|
+
shit: :planned
|
|
49
52
|
}.freeze
|
|
50
53
|
|
|
51
54
|
# Preference order when several states fit a folder's contents at once.
|
|
@@ -54,7 +57,7 @@ module Agentilda
|
|
|
54
57
|
# absence of documents rather than the presence of any.
|
|
55
58
|
PREFERENCE = %i[
|
|
56
59
|
discarded rolled_back shit deferred blocked product_blocked
|
|
57
|
-
deployed approved rejected in_review ready_for_review building planned
|
|
60
|
+
deployed approved rejected in_review ready_for_review building_ui building planned ready_for_planning
|
|
58
61
|
researched new
|
|
59
62
|
retroactive
|
|
60
63
|
].freeze
|
|
@@ -66,18 +69,19 @@ module Agentilda
|
|
|
66
69
|
# ⭕️ and 🅱️ both mean "a human must decide before this moves"; *which*
|
|
67
70
|
# human is recorded nowhere but the emoji.
|
|
68
71
|
#
|
|
69
|
-
# 🟡
|
|
70
|
-
#
|
|
71
|
-
# is
|
|
72
|
-
#
|
|
73
|
-
#
|
|
72
|
+
# ⭐️ 🟡 🎨 look identical on disk: a `spec.md` and a `plan.md` with work
|
|
73
|
+
# units in it. Whether nobody has started, the back end is under way, or
|
|
74
|
+
# the interface is, is recorded only by the agent that renamed the folder
|
|
75
|
+
# when it started. 🟢 👀 🔴 add an open pull request and are just as
|
|
76
|
+
# mute about whether a reviewer has seen it. So `resync` never moves
|
|
77
|
+
# between any of them; they advance by events alone.
|
|
74
78
|
#
|
|
75
|
-
# Order matters: the
|
|
76
|
-
#
|
|
77
|
-
# family justify only its floor, never its ceiling.
|
|
79
|
+
# Order matters: the members run weakest claim first, and a folder
|
|
80
|
+
# arriving from outside lands on the weakest member its contents fit.
|
|
81
|
+
# Contents that fit the family justify only its floor, never its ceiling.
|
|
78
82
|
FAMILIES = [
|
|
79
83
|
%i[blocked product_blocked],
|
|
80
|
-
%i[building ready_for_review in_review rejected]
|
|
84
|
+
%i[planned building building_ui ready_for_review in_review rejected]
|
|
81
85
|
].freeze
|
|
82
86
|
|
|
83
87
|
# States the agent loop leaves alone: work that is finished (✅ 😎), work
|
|
@@ -95,6 +99,14 @@ module Agentilda
|
|
|
95
99
|
# `approved -> deployed`, and today nothing does.
|
|
96
100
|
SETTLED = %i[approved deployed discarded blocked product_blocked deferred].freeze
|
|
97
101
|
|
|
102
|
+
# Every state an agent that may sign `Blocked` works in. A block the
|
|
103
|
+
# machine refuses is reported as a failed attempt and parks nothing, so a
|
|
104
|
+
# state missing here turns an honest "I need a decision" into a failure.
|
|
105
|
+
# The agents spec checks every prompt that describes blocking against it.
|
|
106
|
+
#
|
|
107
|
+
# Review is absent: a reviewer rules, it does not ask.
|
|
108
|
+
BLOCKABLE = %i[new researched retroactive ready_for_planning planned building building_ui rejected].freeze
|
|
109
|
+
|
|
98
110
|
# Rerouting a transition below makes the hand-drawn
|
|
99
111
|
# `docs/img/plan-spec-build.png` stale — `just docs` will show you, because
|
|
100
112
|
# the mermaid source in the generated document is derived from this block.
|
|
@@ -111,13 +123,22 @@ module Agentilda
|
|
|
111
123
|
transitions from: %i[new retroactive blocked product_blocked deferred], to: :researched
|
|
112
124
|
end
|
|
113
125
|
|
|
126
|
+
# The specification is done and the planner has not started. Entered
|
|
127
|
+
# by the harness when `yoda-writer` completes; `resync` reaches it on
|
|
128
|
+
# a blank `plan.md`. Without it ⭐️ Planned required a file only the
|
|
129
|
+
# agent handling ⭐️ Planned writes, and nothing could enter it.
|
|
130
|
+
event :ready_to_plan, guard: :justified? do
|
|
131
|
+
transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
|
|
132
|
+
to: :ready_for_planning
|
|
133
|
+
end
|
|
134
|
+
|
|
114
135
|
# `new` stays in this list. The spine routes a plan through research, and
|
|
115
136
|
# that is what the agent loop follows — but a specification somebody has
|
|
116
137
|
# already researched by hand should not have to pretend otherwise to get
|
|
117
138
|
# planned. What research buys is not enforced here; it is enforced by
|
|
118
139
|
# `yoda-writer` handling `researched` and nothing else.
|
|
119
140
|
event :plan, guard: :justified? do
|
|
120
|
-
transitions from: %i[researched new retroactive shit blocked product_blocked deferred],
|
|
141
|
+
transitions from: %i[ready_for_planning researched new retroactive shit blocked product_blocked deferred],
|
|
121
142
|
to: :planned
|
|
122
143
|
end
|
|
123
144
|
|
|
@@ -136,8 +157,10 @@ module Agentilda
|
|
|
136
157
|
transitions from: %i[building rejected], to: :building_ui
|
|
137
158
|
end
|
|
138
159
|
|
|
160
|
+
# The pair lands one pull request, and whichever half finishes last
|
|
161
|
+
# carries the plan to review from wherever the folder stands.
|
|
139
162
|
event :submit, guard: :justified? do
|
|
140
|
-
transitions from: %i[building_ui rolled_back], to: :ready_for_review
|
|
163
|
+
transitions from: %i[building building_ui rejected rolled_back], to: :ready_for_review
|
|
141
164
|
end
|
|
142
165
|
|
|
143
166
|
event :review, guard: :justified? do
|
|
@@ -169,15 +192,15 @@ module Agentilda
|
|
|
169
192
|
end
|
|
170
193
|
|
|
171
194
|
event :block, guard: :justified? do
|
|
172
|
-
transitions from:
|
|
195
|
+
transitions from: BLOCKABLE, to: :blocked
|
|
173
196
|
end
|
|
174
197
|
|
|
175
198
|
event :block_on_product, guard: :justified? do
|
|
176
|
-
transitions from:
|
|
199
|
+
transitions from: BLOCKABLE, to: :product_blocked
|
|
177
200
|
end
|
|
178
201
|
|
|
179
202
|
event :defer, guard: :justified? do
|
|
180
|
-
transitions from: %i[
|
|
203
|
+
transitions from: BLOCKABLE + %i[blocked product_blocked], to: :deferred
|
|
181
204
|
end
|
|
182
205
|
|
|
183
206
|
# Anything, from anywhere, may be dropped for good.
|
|
@@ -310,10 +333,12 @@ module Agentilda
|
|
|
310
333
|
#
|
|
311
334
|
# - **Already inside one** — the current name wins. Re-deriving it would be
|
|
312
335
|
# a guess dressed up as a correction, and every ⭕️ would become 🅱️.
|
|
313
|
-
# - **Arriving from outside** — the
|
|
314
|
-
# furthest. A
|
|
315
|
-
#
|
|
316
|
-
#
|
|
336
|
+
# - **Arriving from outside** — the *weakest* member the contents fit
|
|
337
|
+
# wins, not the furthest. A 📋 folder whose `plan.md` grew work units
|
|
338
|
+
# lands on ⭐️, not 🟡, because nobody has started. A ✅ folder found
|
|
339
|
+
# with an open pull request is demonstrably back in the PR phase, and
|
|
340
|
+
# ⭐️ refuses an open pull request, so it lands on 🟡 rather than
|
|
341
|
+
# claiming 🔴.
|
|
317
342
|
#
|
|
318
343
|
# @return [Agentilda::Status, nil] nil when nothing fits at all
|
|
319
344
|
def best_fit
|
|
@@ -324,7 +349,10 @@ module Agentilda
|
|
|
324
349
|
return status if self.class.family_of(key).include?(best.key)
|
|
325
350
|
|
|
326
351
|
family = self.class.family_of(best.key)
|
|
327
|
-
|
|
352
|
+
return best if family.empty?
|
|
353
|
+
|
|
354
|
+
floor = family.find { |k| fitting.any? { |s| s.key == k } }
|
|
355
|
+
STATUS_BY_KEY.fetch(floor)
|
|
328
356
|
end
|
|
329
357
|
|
|
330
358
|
private
|