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.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /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
|
data/lib/agentilda/state_file.rb
DELETED
|
@@ -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
|