agentilda 2.0.3 → 2.0.4

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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -1,57 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,318 +0,0 @@
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
@@ -1,174 +0,0 @@
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