agentilda 1.0.3 → 2.0.0
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 +8 -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 +78 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +22 -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 +61 -15
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/cli.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli/autocomplete/command"
|
|
4
|
+
require "tty-screen"
|
|
4
5
|
|
|
5
6
|
# One file per command, `subcommands/` under the prefixed ones. Base carries
|
|
6
7
|
# the shared flags and plumbing, so it loads first; the rest only meet each
|
|
@@ -21,6 +22,10 @@ require_relative "cli/version/version"
|
|
|
21
22
|
require_relative "cli/states/states"
|
|
22
23
|
require_relative "cli/agents/subcommands/list"
|
|
23
24
|
require_relative "cli/agents/subcommands/describe"
|
|
25
|
+
require_relative "cli/worktree/worktree"
|
|
26
|
+
require_relative "cli/mail/mail"
|
|
27
|
+
require_relative "cli/mail/subcommands/send"
|
|
28
|
+
require_relative "cli/mail/subcommands/read"
|
|
24
29
|
|
|
25
30
|
module Agentilda
|
|
26
31
|
# The command line. Every command is a thin shell over one library class:
|
|
@@ -37,6 +42,37 @@ module Agentilda
|
|
|
37
42
|
module CLI
|
|
38
43
|
extend Dry::CLI::Registry
|
|
39
44
|
|
|
45
|
+
# What `agentilda -h` prints above and below the command list. Rendering,
|
|
46
|
+
# wrapping and colour belong to dry-cli-help; only the words live here.
|
|
47
|
+
# dry-cli-help 0.2 configures once per process, not per registry, which
|
|
48
|
+
# suits a gem with exactly one registry.
|
|
49
|
+
Dry::CLI::Help.configure do
|
|
50
|
+
title "agentilda — Agentic Specification-Driven Development v#{Agentilda::VERSION}"
|
|
51
|
+
|
|
52
|
+
description <<~TEXT
|
|
53
|
+
Drives the agentic flow: spec → plan → build → review → tune/fix → approve.
|
|
54
|
+
|
|
55
|
+
Keeps the .plans folders in step with GitHub pull requests and Linear issues.
|
|
56
|
+
The final merge and deploy stay manual.
|
|
57
|
+
TEXT
|
|
58
|
+
|
|
59
|
+
epilogue <<~TEXT.chomp
|
|
60
|
+
Global flags: -C, --no-color disables colour in every command.
|
|
61
|
+
|
|
62
|
+
Documentation: https://github.com/kigster/agentilda
|
|
63
|
+
TEXT
|
|
64
|
+
|
|
65
|
+
# The bare program prints this screen, and asking for help is not a failure.
|
|
66
|
+
exit_code_without_arguments 0
|
|
67
|
+
|
|
68
|
+
# Wrap at column 90, or two short of the terminal when it is narrower.
|
|
69
|
+
width [90, TTY::Screen.width - 2].min
|
|
70
|
+
|
|
71
|
+
group "Plans", "create", "list-plans", "index", "resync", "unblock", "worktree"
|
|
72
|
+
group "Agents", "run", "agents", "describe", "mail"
|
|
73
|
+
group "Reference", "docs", "states", "linear", "completion", "version"
|
|
74
|
+
end
|
|
75
|
+
|
|
40
76
|
register "create", Create, aliases: %w[new c]
|
|
41
77
|
# Renamed from `status`, which read as "is the tool OK?" rather than "what
|
|
42
78
|
# plans are there?". The old spellings stay registered: other repos, agent
|
|
@@ -44,6 +80,7 @@ module Agentilda
|
|
|
44
80
|
register "list-plans", ListPlans, aliases: %w[status st]
|
|
45
81
|
register "run", Run
|
|
46
82
|
register "unblock", Unblock
|
|
83
|
+
register "worktree", Worktree
|
|
47
84
|
register "docs", Docs
|
|
48
85
|
register "states", States, aliases: %w[diagram]
|
|
49
86
|
register "index", Index, aliases: %w[idx]
|
|
@@ -69,6 +106,12 @@ module Agentilda
|
|
|
69
106
|
prefix.register "projects", Linear::Projects
|
|
70
107
|
end
|
|
71
108
|
|
|
109
|
+
# What a paired agent shells out to between steps. See {Agentilda::Mailbox}.
|
|
110
|
+
register "mail" do |prefix|
|
|
111
|
+
prefix.register "send", Mail::Send
|
|
112
|
+
prefix.register "read", Mail::Read
|
|
113
|
+
end
|
|
114
|
+
|
|
72
115
|
register "completion", ::Dry::CLI::Autocomplete::Command[::Agentilda::CLI]
|
|
73
116
|
end
|
|
74
117
|
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The advisory clock one invocation runs against.
|
|
5
|
+
#
|
|
6
|
+
# It never terminates the agent on its own. It writes into the control file
|
|
7
|
+
# the agent polls: a warning ten and five minutes out, a wrap-up request at
|
|
8
|
+
# one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
|
|
9
|
+
# is killed, and that is the executor's act on this clock's say-so, so the
|
|
10
|
+
# agent has every chance to write its ledger line first.
|
|
11
|
+
#
|
|
12
|
+
# `now` is injectable so the moments can be tested without sleeping.
|
|
13
|
+
class Clock
|
|
14
|
+
# Seconds before the deadline, and what the control file says then.
|
|
15
|
+
# A moment already in the past when the clock starts is skipped: a five
|
|
16
|
+
# minute agent told "10 minutes left" at t=0 has been lied to.
|
|
17
|
+
WARNINGS = [
|
|
18
|
+
[600, "WARN: 10 minutes left"],
|
|
19
|
+
[300, "WARN: 5 minutes left"],
|
|
20
|
+
[60, "WRAP_UP: 1 minute left, write to disk now"]
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Seconds after STOP before {#on_expire} fires.
|
|
24
|
+
GRACE = 60
|
|
25
|
+
|
|
26
|
+
# @param seconds [Integer] the budget
|
|
27
|
+
# @param control [String] the invocation's control file
|
|
28
|
+
# @param on_expire [Proc] called once, GRACE seconds after STOP
|
|
29
|
+
# @param now [Proc] a monotonic clock reading
|
|
30
|
+
def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
|
|
31
|
+
@control = control
|
|
32
|
+
@on_expire = on_expire
|
|
33
|
+
@now = now
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
arm(seconds)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Integer] whole seconds left, floored at zero
|
|
39
|
+
def remaining = [@deadline - @now.call, 0].max.round
|
|
40
|
+
|
|
41
|
+
# @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
|
|
42
|
+
attr_reader :phase
|
|
43
|
+
|
|
44
|
+
# Evaluate every moment once. Idempotent within a phase.
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
def tick
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
left = @deadline - @now.call
|
|
50
|
+
WARNINGS.each do |before, text|
|
|
51
|
+
next if @fired.include?(before) || left > before
|
|
52
|
+
|
|
53
|
+
@fired << before
|
|
54
|
+
Control.write(@control, text)
|
|
55
|
+
@phase = before == 60 ? :wrap_up : :warned
|
|
56
|
+
end
|
|
57
|
+
if left <= 0 && !@fired.include?(:stop)
|
|
58
|
+
@fired << :stop
|
|
59
|
+
Control.write(@control, Control::STOP)
|
|
60
|
+
@phase = :stopped
|
|
61
|
+
end
|
|
62
|
+
if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
|
|
63
|
+
@fired << :expired
|
|
64
|
+
@phase = :expired
|
|
65
|
+
@on_expire.call
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [self]
|
|
71
|
+
def start
|
|
72
|
+
@thread ||= Thread.new do
|
|
73
|
+
Thread.current.report_on_exception = false
|
|
74
|
+
loop do
|
|
75
|
+
tick
|
|
76
|
+
break if phase == :expired
|
|
77
|
+
|
|
78
|
+
sleep(1)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [void]
|
|
85
|
+
def stop
|
|
86
|
+
@thread&.kill
|
|
87
|
+
@thread = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Ten more minutes, or however many. The file is emptied so the next poll
|
|
91
|
+
# reads a reprieve, and every moment is re-armed against the new deadline.
|
|
92
|
+
#
|
|
93
|
+
# @param seconds [Integer]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def extend!(seconds)
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@deadline += seconds
|
|
98
|
+
@fired = []
|
|
99
|
+
@phase = :calm
|
|
100
|
+
Control.write(@control, "")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# @param seconds [Integer]
|
|
107
|
+
# @return [void]
|
|
108
|
+
def arm(seconds)
|
|
109
|
+
@deadline = @now.call + seconds
|
|
110
|
+
@fired = WARNINGS.map(&:first).select { |before| before >= seconds }
|
|
111
|
+
@phase = :calm
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# What the keys do to the screen, and to the running agents.
|
|
5
|
+
#
|
|
6
|
+
# Selection and the dialog live here, never on the screen: the screen
|
|
7
|
+
# draws whatever board it is handed, and this decides what the board
|
|
8
|
+
# says. Pending changes are visible only in the dialog and take effect
|
|
9
|
+
# only on ENTER, so a stray `k` kills nobody.
|
|
10
|
+
class Console
|
|
11
|
+
# Seconds each `x` adds.
|
|
12
|
+
EXTENSION = 600
|
|
13
|
+
|
|
14
|
+
# @param screen [Agentilda::Screen::Ratatui]
|
|
15
|
+
def initialize(screen:)
|
|
16
|
+
@screen = screen
|
|
17
|
+
@dispatcher = nil
|
|
18
|
+
@board = nil
|
|
19
|
+
@selected = nil
|
|
20
|
+
@dialog = false
|
|
21
|
+
@help = false
|
|
22
|
+
@about = false
|
|
23
|
+
@pending = { kill: false, extend: 0 }
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] the selected row's key
|
|
28
|
+
attr_reader :selected
|
|
29
|
+
|
|
30
|
+
# @return [Hash] `{kill: Boolean, extend: Integer}`
|
|
31
|
+
attr_reader :pending
|
|
32
|
+
|
|
33
|
+
# @return [Agentilda::Screen::Ratatui]
|
|
34
|
+
attr_reader :screen
|
|
35
|
+
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def dialog? = @dialog
|
|
38
|
+
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def help? = @help
|
|
41
|
+
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def about? = @about
|
|
44
|
+
|
|
45
|
+
# The dispatcher exists only once the run starts, after the keyboard is
|
|
46
|
+
# already listening, so it arrives late.
|
|
47
|
+
#
|
|
48
|
+
# @param dispatcher [Agentilda::Dispatcher]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def attach(dispatcher) = @dispatcher = dispatcher
|
|
51
|
+
|
|
52
|
+
# Called by the dispatcher each tick.
|
|
53
|
+
#
|
|
54
|
+
# @param board [Agentilda::Board]
|
|
55
|
+
# @return [void]
|
|
56
|
+
def paint(board)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@board = board
|
|
59
|
+
@selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
|
|
60
|
+
@screen.draw(board.with(selected: @selected,
|
|
61
|
+
dialog: (dialog_text if @dialog),
|
|
62
|
+
help: (help_text if @help),
|
|
63
|
+
about: (about_text if @about)))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [void]
|
|
68
|
+
def select_next = move(1)
|
|
69
|
+
|
|
70
|
+
# @return [void]
|
|
71
|
+
def select_prev = move(-1)
|
|
72
|
+
|
|
73
|
+
# @return [void]
|
|
74
|
+
def toggle_kill
|
|
75
|
+
select_next unless @selected
|
|
76
|
+
return unless @selected
|
|
77
|
+
|
|
78
|
+
@pending[:kill] = !@pending[:kill]
|
|
79
|
+
@dialog = true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [void]
|
|
83
|
+
def extend
|
|
84
|
+
select_next unless @selected
|
|
85
|
+
return unless @selected
|
|
86
|
+
|
|
87
|
+
@pending[:extend] += EXTENSION
|
|
88
|
+
@dialog = true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ENTER.
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
def apply
|
|
95
|
+
return unless @dialog && @selected && @dispatcher
|
|
96
|
+
|
|
97
|
+
@dispatcher.kill(@selected) if @pending[:kill]
|
|
98
|
+
@dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
|
|
99
|
+
UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
|
|
100
|
+
close_dialog
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ESC: the dialog first, then the help, then the about screen, then the
|
|
104
|
+
# selection.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def escape
|
|
108
|
+
if @dialog
|
|
109
|
+
close_dialog
|
|
110
|
+
elsif @help
|
|
111
|
+
@help = false
|
|
112
|
+
elsif @about
|
|
113
|
+
@about = false
|
|
114
|
+
else
|
|
115
|
+
@selected = nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [void]
|
|
120
|
+
def toggle_help = @help = !@help
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
def toggle_about = @about = !@about
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# @return [void]
|
|
128
|
+
def close_dialog
|
|
129
|
+
@dialog = false
|
|
130
|
+
@pending = { kill: false, extend: 0 }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param step [Integer]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def move(step)
|
|
136
|
+
keys = (@board&.rows || []).select(&:running?).map(&:key)
|
|
137
|
+
return @selected = nil if keys.empty?
|
|
138
|
+
|
|
139
|
+
index = keys.index(@selected)
|
|
140
|
+
@selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [String]
|
|
144
|
+
def dialog_text
|
|
145
|
+
row = @board&.rows&.find { |r| r.key == @selected }
|
|
146
|
+
who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
|
|
147
|
+
extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
|
|
148
|
+
"#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String]
|
|
152
|
+
def help_text = Keyboard.new(input: $stdin).help
|
|
153
|
+
|
|
154
|
+
# @return [String]
|
|
155
|
+
def about_text = Keyboard.new(input: $stdin).about
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/agentilda/control.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
3
5
|
module Agentilda
|
|
4
6
|
# The side channel between a keypress and a running agent.
|
|
5
7
|
#
|
|
@@ -16,6 +18,13 @@ module Agentilda
|
|
|
16
18
|
WRAP_UP = "WRAP_UP"
|
|
17
19
|
STOP = "STOP"
|
|
18
20
|
|
|
21
|
+
# Ctrl-C: STOP, and the run will be started again, so the agent leaves
|
|
22
|
+
# a resume note before it goes. The prompt says what that note is.
|
|
23
|
+
INTERRUPT = "INTERRUPT"
|
|
24
|
+
|
|
25
|
+
# The clock's warnings start with this; the agent's prompt explains them.
|
|
26
|
+
WARN = "WARN"
|
|
27
|
+
|
|
19
28
|
# Seconds between {#quit!} and the harness terminating whatever is still
|
|
20
29
|
# running. Long enough to write files and a handoff note; short enough
|
|
21
30
|
# that q means quit rather than "quit eventually".
|
|
@@ -24,6 +33,7 @@ module Agentilda
|
|
|
24
33
|
@mutex = Mutex.new
|
|
25
34
|
@files = []
|
|
26
35
|
@quit = false
|
|
36
|
+
@interrupted = false
|
|
27
37
|
@deadline = nil
|
|
28
38
|
|
|
29
39
|
class << self
|
|
@@ -52,6 +62,18 @@ module Agentilda
|
|
|
52
62
|
FileUtils.rm_f(path)
|
|
53
63
|
end
|
|
54
64
|
|
|
65
|
+
# One line into one file, from the clock. Not a broadcast: a warning is
|
|
66
|
+
# about one agent's own deadline.
|
|
67
|
+
#
|
|
68
|
+
# @param path [String]
|
|
69
|
+
# @param text [String]
|
|
70
|
+
# @return [void]
|
|
71
|
+
def write(path, text)
|
|
72
|
+
File.write(path, text.empty? ? "" : "#{text}\n")
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
# The invocation finished and released its file; nothing to warn.
|
|
75
|
+
end
|
|
76
|
+
|
|
55
77
|
# `w`: every running agent is asked to finish the essential remainder
|
|
56
78
|
# as fast as it can. The loop itself keeps going.
|
|
57
79
|
#
|
|
@@ -78,9 +100,49 @@ module Agentilda
|
|
|
78
100
|
}
|
|
79
101
|
end
|
|
80
102
|
|
|
103
|
+
# Ctrl-C, the first time: {#quit!}, except that every running agent is
|
|
104
|
+
# told the run is being interrupted rather than stopped, so it writes
|
|
105
|
+
# down where it got to. A restart then reads that note instead of
|
|
106
|
+
# redoing, or half-redoing, the work.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def interrupt!
|
|
110
|
+
broadcast(INTERRUPT)
|
|
111
|
+
@mutex.synchronize {
|
|
112
|
+
@quit = true
|
|
113
|
+
@interrupted = true
|
|
114
|
+
@deadline ||= UI.monotonic + GRACE
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
81
118
|
# @return [Boolean] whether `q` has been pressed
|
|
82
119
|
def quit? = @mutex.synchronize { @quit }
|
|
83
120
|
|
|
121
|
+
# @return [Boolean] whether Ctrl-C has been pressed
|
|
122
|
+
def interrupted? = @mutex.synchronize { @interrupted }
|
|
123
|
+
|
|
124
|
+
# Ctrl-C where no keyboard reads it: a run with no terminal to put in
|
|
125
|
+
# raw mode still gets the graceful first press and the hard second.
|
|
126
|
+
#
|
|
127
|
+
# A trap handler may not take a mutex, so the first press hands
|
|
128
|
+
# {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
|
|
129
|
+
#
|
|
130
|
+
# @yieldreturn [Object]
|
|
131
|
+
# @return [Object] the block's value
|
|
132
|
+
def on_interrupt
|
|
133
|
+
previous = Signal.trap("INT") do
|
|
134
|
+
# A plain read, not {#interrupted?}: a trap handler may not lock.
|
|
135
|
+
raise Interrupt if @interrupted || @pending_interrupt
|
|
136
|
+
|
|
137
|
+
@pending_interrupt = true
|
|
138
|
+
Thread.new { interrupt! }
|
|
139
|
+
end
|
|
140
|
+
yield
|
|
141
|
+
ensure
|
|
142
|
+
@pending_interrupt = false
|
|
143
|
+
Signal.trap("INT", previous || "DEFAULT")
|
|
144
|
+
end
|
|
145
|
+
|
|
84
146
|
# @return [Boolean] whether the grace period after `q` has run out
|
|
85
147
|
def overdue?
|
|
86
148
|
@mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
|
|
@@ -95,6 +157,7 @@ module Agentilda
|
|
|
95
157
|
@files.each { |f| FileUtils.rm_f(f) }
|
|
96
158
|
@files.clear
|
|
97
159
|
@quit = false
|
|
160
|
+
@interrupted = false
|
|
98
161
|
@deadline = nil
|
|
99
162
|
}
|
|
100
163
|
end
|
data/lib/agentilda/creator.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require "agentilda/status"
|
|
7
|
+
|
|
3
8
|
module Agentilda
|
|
4
9
|
# Mints new plan folders. Backs `/spec-create`.
|
|
5
10
|
#
|
|
@@ -52,8 +57,8 @@ module Agentilda
|
|
|
52
57
|
return [] unless File.directory?(dir)
|
|
53
58
|
|
|
54
59
|
Dir.children(dir)
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
.select { |c| File.directory?(File.join(dir, c)) }
|
|
61
|
+
.filter_map { |c| Ordinal.from_dirname(c) }
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The ratatui dashboard for agent work that is not a `run`: `unblock`
|
|
5
|
+
# handing plans to lando-broker, `create` drafting a spec.md. `run` has a
|
|
6
|
+
# {Dispatcher} to build its {Board}; this builds one from the items
|
|
7
|
+
# {UI.concurrently} hands it, so every agent a person waits on is drawn
|
|
8
|
+
# the same way, with the same keys.
|
|
9
|
+
#
|
|
10
|
+
# It stands in for the dispatcher behind {Console}, which is why it
|
|
11
|
+
# answers {#kill} and {#extend}: a row whose work passed its
|
|
12
|
+
# {Tracker#handle} on to {Executor#call} can be killed or given more time.
|
|
13
|
+
class Dashboard
|
|
14
|
+
# Seconds between frames handed to the screen.
|
|
15
|
+
REFRESH = 0.25
|
|
16
|
+
|
|
17
|
+
# Seconds a finished row stays on screen, so a fast agent is still seen.
|
|
18
|
+
LINGER = 3
|
|
19
|
+
|
|
20
|
+
# One item's row, and the {UI::Line} its work streams progress through.
|
|
21
|
+
# Nothing is drawn by the line itself; the dashboard reads it each frame.
|
|
22
|
+
class Tracker < UI::Line
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_reader :key
|
|
25
|
+
|
|
26
|
+
# @return [Agentilda::Executor::Handle] handed to {Executor#call} by a
|
|
27
|
+
# caller that wants the kill and extend keys to reach its agent.
|
|
28
|
+
# Not `@handle`: {UI::Line} keeps its dry-cli-ui line under that name.
|
|
29
|
+
def handle = @executor_handle
|
|
30
|
+
|
|
31
|
+
# @return [Symbol] :running, :done or :failed
|
|
32
|
+
attr_reader :state
|
|
33
|
+
|
|
34
|
+
# @return [Float, nil] monotonic reading of when it stopped running
|
|
35
|
+
attr_reader :finished_at
|
|
36
|
+
|
|
37
|
+
# @param key [String]
|
|
38
|
+
# @param fields [Hash] plan, status, agent and round, for the log and the row
|
|
39
|
+
# @param file [String] what the row's feature column says
|
|
40
|
+
# @param message [String] shown until the agent says something
|
|
41
|
+
# @param timeout [Integer, nil]
|
|
42
|
+
def initialize(key:, fields:, file:, message:, timeout: nil)
|
|
43
|
+
super(fields:, timeout:)
|
|
44
|
+
@key = key
|
|
45
|
+
@file = file
|
|
46
|
+
@message = message
|
|
47
|
+
@executor_handle = Executor::Handle.new
|
|
48
|
+
@state = :running
|
|
49
|
+
@update = nil
|
|
50
|
+
@history = []
|
|
51
|
+
@finished_at = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param update [Agentilda::Transcript::Progress]
|
|
55
|
+
# @return [void]
|
|
56
|
+
def call(update)
|
|
57
|
+
super
|
|
58
|
+
@update = update
|
|
59
|
+
return unless update.activity
|
|
60
|
+
|
|
61
|
+
@message = update.activity
|
|
62
|
+
@history = Board::Row.remember(@history, @message)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [void]
|
|
66
|
+
def done
|
|
67
|
+
super
|
|
68
|
+
finish(:done)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param reason [String]
|
|
72
|
+
# @return [void]
|
|
73
|
+
def failed(reason)
|
|
74
|
+
super
|
|
75
|
+
@message = reason
|
|
76
|
+
@history = Board::Row.remember(@history, reason)
|
|
77
|
+
finish(:failed)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Agentilda::Board::Row]
|
|
81
|
+
def row
|
|
82
|
+
Board::Row.new(key:,
|
|
83
|
+
at: Time.now,
|
|
84
|
+
ordinal: @fields[:plan].to_s,
|
|
85
|
+
file: @file,
|
|
86
|
+
agent: @fields[:agent].to_s,
|
|
87
|
+
role: @fields[:agent].to_s,
|
|
88
|
+
round: @fields[:round] || 1,
|
|
89
|
+
rounds: @fields[:round] || 1,
|
|
90
|
+
model: "default",
|
|
91
|
+
remaining: handle.remaining || remaining,
|
|
92
|
+
phase: handle.phase,
|
|
93
|
+
up: @update&.up.to_i,
|
|
94
|
+
down: @update&.down.to_i,
|
|
95
|
+
message: @message,
|
|
96
|
+
state:,
|
|
97
|
+
elapsed: seconds.round,
|
|
98
|
+
subagents: @update&.subagents.to_i,
|
|
99
|
+
history: @history)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @param state [Symbol]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def finish(state)
|
|
107
|
+
@state = state
|
|
108
|
+
@finished_at = UI.monotonic
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Opens the screen, runs the block, and always closes it again.
|
|
113
|
+
#
|
|
114
|
+
# @param root [String] shown in the bottom bar
|
|
115
|
+
# @param screen [Agentilda::Screen::Ratatui]
|
|
116
|
+
# @yieldparam dashboard [Agentilda::Dashboard]
|
|
117
|
+
# @return [Object] the block's value
|
|
118
|
+
def self.open(root: Dir.pwd, screen: Screen::Ratatui.new)
|
|
119
|
+
dashboard = new(root:, screen:)
|
|
120
|
+
dashboard.start
|
|
121
|
+
begin
|
|
122
|
+
yield dashboard
|
|
123
|
+
ensure
|
|
124
|
+
dashboard.stop
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param root [String]
|
|
129
|
+
# @param screen [Agentilda::Screen::Ratatui]
|
|
130
|
+
def initialize(root:, screen:)
|
|
131
|
+
@root = root
|
|
132
|
+
@screen = screen
|
|
133
|
+
@console = Console.new(screen:)
|
|
134
|
+
@console.attach(self)
|
|
135
|
+
@trackers = []
|
|
136
|
+
@lock = Mutex.new
|
|
137
|
+
@started_at = UI.monotonic
|
|
138
|
+
@closing = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [void]
|
|
142
|
+
def start
|
|
143
|
+
# ratatui owns the terminal's input, so the keyboard is fed from its
|
|
144
|
+
# loop rather than reading STDIN on a thread of its own.
|
|
145
|
+
@keyboard = Keyboard.new(sink: @console)
|
|
146
|
+
@screen.attach_keyboard(@keyboard)
|
|
147
|
+
@screen.open
|
|
148
|
+
@painter = Thread.new do
|
|
149
|
+
Thread.current.report_on_exception = false
|
|
150
|
+
until @closing
|
|
151
|
+
paint
|
|
152
|
+
sleep(REFRESH)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [void]
|
|
158
|
+
def stop
|
|
159
|
+
@closing = true
|
|
160
|
+
@painter&.join
|
|
161
|
+
paint
|
|
162
|
+
@screen.close
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param key [String]
|
|
166
|
+
# @param fields [Hash]
|
|
167
|
+
# @param file [String]
|
|
168
|
+
# @param message [String]
|
|
169
|
+
# @param timeout [Integer, nil]
|
|
170
|
+
# @return [Tracker]
|
|
171
|
+
def track(key:, fields: {}, file: "", message: "starting", timeout: nil)
|
|
172
|
+
Tracker.new(key:, fields:, file:, message:, timeout:).tap do |tracker|
|
|
173
|
+
@lock.synchronize { @trackers << tracker }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [void]
|
|
178
|
+
def paint = @console.paint(board)
|
|
179
|
+
|
|
180
|
+
# @return [Agentilda::Board]
|
|
181
|
+
def board
|
|
182
|
+
rows = visible.map(&:row)
|
|
183
|
+
running = rows.select(&:running?)
|
|
184
|
+
Board.new(started_at: @started_at,
|
|
185
|
+
status: Control.quit? ? :quitting : :running,
|
|
186
|
+
plans: rows.map(&:ordinal).reject(&:empty?).uniq,
|
|
187
|
+
up: rows.sum(&:up),
|
|
188
|
+
down: rows.sum(&:down),
|
|
189
|
+
rows:,
|
|
190
|
+
root: @root,
|
|
191
|
+
running: running.size,
|
|
192
|
+
live_up: running.sum(&:up),
|
|
193
|
+
live_down: running.sum(&:down))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @param key [String]
|
|
197
|
+
# @return [void]
|
|
198
|
+
def kill(key) = find(key)&.handle&.kill!
|
|
199
|
+
|
|
200
|
+
# @param key [String]
|
|
201
|
+
# @param seconds [Integer]
|
|
202
|
+
# @return [void]
|
|
203
|
+
def extend(key, seconds) = find(key)&.handle&.extend!(seconds)
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
# @return [Array<Tracker>] running, plus whatever finished a moment ago
|
|
208
|
+
def visible
|
|
209
|
+
now = UI.monotonic
|
|
210
|
+
@lock.synchronize do
|
|
211
|
+
@trackers.reject { |t| t.finished_at && now - t.finished_at > LINGER }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @param key [String]
|
|
216
|
+
# @return [Tracker, nil]
|
|
217
|
+
def find(key) = @lock.synchronize { @trackers.find { |t| t.key == key } }
|
|
218
|
+
end
|
|
219
|
+
end
|