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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Engine
|
|
5
|
+
# One line of `run --log FILE`, laid out as fixed-width columns.
|
|
6
|
+
#
|
|
7
|
+
# The log file is the only record of a headless run, and it is shared: two
|
|
8
|
+
# `agentilda run` invocations pointed at the same `--log` both append to
|
|
9
|
+
# it, interleaved, and so does every round within one run. That is what the
|
|
10
|
+
# process id column is for. Without it a reader cannot tell one run's agents
|
|
11
|
+
# from another's, and "started 003.00" twice looks like a bug rather than two
|
|
12
|
+
# machines doing the same work.
|
|
13
|
+
#
|
|
14
|
+
# Fixed columns are what make the file readable by eye and by `awk` alike. A
|
|
15
|
+
# value wider than its column is cut rather than allowed to push the columns
|
|
16
|
+
# out, and a value that is missing still occupies its width, so a round header
|
|
17
|
+
# with no plan or agent of its own lines up with the lines underneath it.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# ProgressLog.render("editing spec.md", plan: "003.00", status: "⭐️ Planned",
|
|
21
|
+
# agent: "yoda-writer", seconds: 42, round: "01", pid: 91_234)
|
|
22
|
+
|
|
23
|
+
# #=> "[16:22:14 | 003.00 | ⭐️ Planned | yoda-writer | 01 | 91234 | 42s] editing spec.md"
|
|
24
|
+
module ProgressLog
|
|
25
|
+
# Wall clock only. A log line answers "when", and the date is the file's.
|
|
26
|
+
TIME_FORMAT = "%H:%M:%S"
|
|
27
|
+
|
|
28
|
+
# Width of {TIME_FORMAT}'s output.
|
|
29
|
+
TIME_WIDTH = 8
|
|
30
|
+
|
|
31
|
+
# "003.00".
|
|
32
|
+
PLAN_WIDTH = 6
|
|
33
|
+
|
|
34
|
+
# Derived from the widest state as {Status#to_s} renders it, emoji
|
|
35
|
+
# included, so a state added to {STATUSES} with a longer label cannot
|
|
36
|
+
# silently knock the columns after it out of line — then trimmed by five
|
|
37
|
+
# cells: the emoji already names the state, so the longest labels can
|
|
38
|
+
# afford to lose their tails to keep the line short.
|
|
39
|
+
STATUS_WIDTH = Plans::STATUSES.map { |status| UI.display_width(status.to_s) }.max - 5
|
|
40
|
+
|
|
41
|
+
# The longest specialist name in `agents/` is `palpatine-planner` at 17,
|
|
42
|
+
# and the names are hyphenated words rather than a bounded vocabulary, so
|
|
43
|
+
# this leaves room for one more without a reflow.
|
|
44
|
+
AGENT_WIDTH = 20
|
|
45
|
+
|
|
46
|
+
# Enough for a 32-bit process id, right justified.
|
|
47
|
+
PID_WIDTH = 7
|
|
48
|
+
|
|
49
|
+
# "01". Rounds cap at two digits; a run that reaches a third has bigger
|
|
50
|
+
# problems than this column.
|
|
51
|
+
ROUND_WIDTH = 2
|
|
52
|
+
|
|
53
|
+
# " 907s". Anything longer than four digits of seconds is an agent nobody
|
|
54
|
+
# is still waiting on.
|
|
55
|
+
SECONDS_WIDTH = 6
|
|
56
|
+
|
|
57
|
+
# Between columns, inside the brackets.
|
|
58
|
+
SEPARATOR = " | "
|
|
59
|
+
|
|
60
|
+
# Cells from the opening bracket to the closing one, separators included.
|
|
61
|
+
# The message starts one space after this on every line, whatever fields
|
|
62
|
+
# that line happens to be missing, so a reader indenting a wrapped message
|
|
63
|
+
# has a number to indent by.
|
|
64
|
+
COLUMN_WIDTHS = [TIME_WIDTH, PLAN_WIDTH, STATUS_WIDTH, AGENT_WIDTH, ROUND_WIDTH, PID_WIDTH,
|
|
65
|
+
SECONDS_WIDTH].freeze
|
|
66
|
+
|
|
67
|
+
# @see COLUMN_WIDTHS
|
|
68
|
+
LINE_WIDTH = COLUMN_WIDTHS.sum + (SEPARATOR.length * (COLUMN_WIDTHS.size - 1)) + 2
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Render one log line: bracketed fixed-width columns, then the message.
|
|
72
|
+
#
|
|
73
|
+
# No colour. The line goes to a file, where an escape sequence is neither
|
|
74
|
+
# readable nor the width it claims to be.
|
|
75
|
+
#
|
|
76
|
+
# @param message [String] the free-form tail, the only variable-width part
|
|
77
|
+
# @param plan [String, nil] the plan's ordinal, e.g. "003.00"
|
|
78
|
+
# @param status [String, nil] emoji and label, e.g. "⭐️ Planned"
|
|
79
|
+
# @param agent [String, nil] the specialist's name, e.g. "yoda-writer"
|
|
80
|
+
# @param seconds [Numeric, nil] how long this agent has been alive
|
|
81
|
+
# @param round [String, nil] which pass over the tree, e.g. "01"
|
|
82
|
+
# @param pid [Integer, nil] which run wrote the line
|
|
83
|
+
# @param at [Time] injectable, so a caller can render a fixed clock
|
|
84
|
+
# @return [String] one line, with no trailing newline
|
|
85
|
+
def render(message, plan: nil, status: nil, agent: nil, seconds: nil,
|
|
86
|
+
round: nil, pid: Process.pid, at: Time.now)
|
|
87
|
+
columns = [
|
|
88
|
+
left(at.strftime(TIME_FORMAT), TIME_WIDTH),
|
|
89
|
+
left(plan, PLAN_WIDTH),
|
|
90
|
+
left(status, STATUS_WIDTH),
|
|
91
|
+
left(agent, AGENT_WIDTH),
|
|
92
|
+
right(round, ROUND_WIDTH),
|
|
93
|
+
right(pid, PID_WIDTH),
|
|
94
|
+
right(duration(seconds), SECONDS_WIDTH)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
text = message.to_s
|
|
98
|
+
prefix = "[#{columns.join(SEPARATOR)}]"
|
|
99
|
+
text.empty? ? prefix : "#{prefix} #{text}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param seconds [Numeric, nil]
|
|
103
|
+
# @return [String] whole seconds with a trailing "s", or blank for nil
|
|
104
|
+
def duration(seconds) = seconds.nil? ? "" : "#{seconds.round}s"
|
|
105
|
+
|
|
106
|
+
# @param value [Object, nil]
|
|
107
|
+
# @param width [Integer] terminal cells
|
|
108
|
+
# @return [String] padded on the right to exactly +width+ cells
|
|
109
|
+
def left(value, width) = UI.fit(value, width)
|
|
110
|
+
|
|
111
|
+
# {UI.fit} pads on the right, which is the wrong end for a number. Fit
|
|
112
|
+
# first so an over-long value is still truncated to the column, then move
|
|
113
|
+
# the padding to the front.
|
|
114
|
+
#
|
|
115
|
+
# @param value [Object, nil]
|
|
116
|
+
# @param width [Integer] terminal cells
|
|
117
|
+
# @return [String] padded on the left to exactly +width+ cells
|
|
118
|
+
def right(value, width)
|
|
119
|
+
text = UI.fit(value, width).rstrip
|
|
120
|
+
UI.fit("", width - UI.display_width(text)) + text
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Engine
|
|
5
|
+
# Drives specialist agents over a `.plans` tree until nothing is left to
|
|
6
|
+
# dispatch.
|
|
7
|
+
#
|
|
8
|
+
# The loop itself is {Dispatcher}. This holds what the loop needs about
|
|
9
|
+
# the run: the tree and its scope, the roster, the executor, how many run
|
|
10
|
+
# at once, where each plan is checked out, and how a finished plan is
|
|
11
|
+
# published. Blocked plans (⭕️ 🅱️) are stepped around, never assigned.
|
|
12
|
+
class Runner
|
|
13
|
+
# What one agent did to one plan.
|
|
14
|
+
Attempt = Data.define(:ordinal,
|
|
15
|
+
:agent,
|
|
16
|
+
:from,
|
|
17
|
+
:to,
|
|
18
|
+
:ok,
|
|
19
|
+
:note,
|
|
20
|
+
:up,
|
|
21
|
+
:down,
|
|
22
|
+
:subagents,
|
|
23
|
+
:delegated,
|
|
24
|
+
:seconds,
|
|
25
|
+
:round,
|
|
26
|
+
:file,
|
|
27
|
+
:model,
|
|
28
|
+
:status) do
|
|
29
|
+
def initialize(round: 1, file: "", model: nil, status: nil, **rest) = super
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether the plan actually moved
|
|
32
|
+
def advanced? = ok && from != to
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# One unit of work: an agent, a plan, and the checkout it happens in.
|
|
36
|
+
Task = Data.define(:agent, :subject, :root, :checkout, :round) do
|
|
37
|
+
# @return [Hash] columns for the log
|
|
38
|
+
def log_fields
|
|
39
|
+
{ plan: subject.feature.ordinal.to_s, status: subject.status.to_s,
|
|
40
|
+
agent: agent.name, round: format("%02d", round) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param rounds [Integer, nil] `--rounds`, capping each agent's own
|
|
45
|
+
# @param state [Agentilda::Engine::StateFile, nil]
|
|
46
|
+
# @param sleeper [Proc]
|
|
47
|
+
# @param on_board [Proc, nil]
|
|
48
|
+
def initialize(tree:, executor:, agents: Agents::Registry.new, isolation: :shared, jobs: 1, worktree: nil,
|
|
49
|
+
plans: nil, publisher: nil, dry_run: false, rounds: nil, state: nil,
|
|
50
|
+
sleeper: ->(seconds) { sleep(seconds) }, on_board: nil)
|
|
51
|
+
@tree = tree
|
|
52
|
+
@executor = executor
|
|
53
|
+
@agents = agents
|
|
54
|
+
@isolation = isolation
|
|
55
|
+
@worktree = worktree
|
|
56
|
+
@plans = plans
|
|
57
|
+
@publisher = publisher
|
|
58
|
+
@dry_run = dry_run
|
|
59
|
+
@rounds = rounds
|
|
60
|
+
@state = dry_run ? nil : state
|
|
61
|
+
@sleeper = sleeper
|
|
62
|
+
@on_board = on_board
|
|
63
|
+
@attempts = []
|
|
64
|
+
@jobs = isolated? ? jobs : 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :jobs, :worktree, :tree, :attempts, :executor, :agents
|
|
68
|
+
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def isolated? = @isolation == :worktree
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def dry_run? = @dry_run
|
|
74
|
+
|
|
75
|
+
# @return [String] the repository root
|
|
76
|
+
def root = isolated? ? worktree.root : shared_root
|
|
77
|
+
|
|
78
|
+
# @param subject [Agentilda::Plans::Subject]
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)
|
|
81
|
+
|
|
82
|
+
# @return [Array<Agentilda::Plans::Subject>]
|
|
83
|
+
def in_scope = tree.subjects.select { |s| in_scope?(s) }
|
|
84
|
+
|
|
85
|
+
# Run until nothing is dispatchable.
|
|
86
|
+
#
|
|
87
|
+
# @yieldparam dispatcher [Agentilda::Engine::Dispatcher] before it runs, so a
|
|
88
|
+
# console can attach itself and reach the running jobs
|
|
89
|
+
# @return [Array<Agentilda::Engine::Runner::Attempt>]
|
|
90
|
+
def call
|
|
91
|
+
dispatcher = Dispatcher.new(runner: self, state: @state, rounds: @rounds, sleeper: @sleeper, on_board: @on_board)
|
|
92
|
+
yield dispatcher if block_given?
|
|
93
|
+
@attempts = dispatcher.run
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Array<Agentilda::Plans::Subject>]
|
|
97
|
+
def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }
|
|
98
|
+
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def settled? = in_scope.all? { |s| Plans::StateMachine::SETTLED.include?(s.status.key) }
|
|
101
|
+
|
|
102
|
+
# A dry run gets the shared root whatever the isolation says: nothing
|
|
103
|
+
# runs in the checkout, and `git worktree add` is a side effect a
|
|
104
|
+
# preview must not have. It also fails outright when the plan's branch
|
|
105
|
+
# is the one checked out where the run was typed, which is where a dry
|
|
106
|
+
# run of that plan is typed.
|
|
107
|
+
#
|
|
108
|
+
# @return [Agentilda::Engine::Runner::Task]
|
|
109
|
+
def prepare(agent, subject, round)
|
|
110
|
+
return Task.new(agent:, subject:, root: shared_root, checkout: nil, round:) if !isolated? || dry_run?
|
|
111
|
+
|
|
112
|
+
checkout = worktree.checkout_for(subject.feature)
|
|
113
|
+
Task.new(agent:, subject:, root: checkout.path, checkout:, round:)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Agentilda::Vcs::Publisher::Publication, nil]
|
|
117
|
+
def publish(task, subject)
|
|
118
|
+
return nil unless @publisher && task.checkout&.dirty?
|
|
119
|
+
|
|
120
|
+
publication = @publisher.publish(checkout: task.checkout, subject:)
|
|
121
|
+
record_pull_request(subject.feature.path, publication) if publication.published?
|
|
122
|
+
publication
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# @return [String]
|
|
128
|
+
def shared_root = File.dirname(tree.dir)
|
|
129
|
+
|
|
130
|
+
# @return [void]
|
|
131
|
+
def record_pull_request(path, publication)
|
|
132
|
+
number = publication.url.to_s[%r{/pull/(\d+)}, 1]
|
|
133
|
+
rows = Plans::PullRequests.new(dir: path).all.map { |pr|
|
|
134
|
+
{ number: pr.number, title: pr.title, url: pr.url, state: pr.state, body: "" }
|
|
135
|
+
}
|
|
136
|
+
rows << { number:, title: publication.title, url: publication.url, state: "Open 🟡", body: "" }
|
|
137
|
+
Plans::PullRequests.upsert(File.join(path, Plans::PullRequests::FILENAME), rows)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Agentilda
|
|
7
|
+
module Engine
|
|
8
|
+
# Where a run stands, written every second, so a harness that dies leaves
|
|
9
|
+
# the next one something to restart from.
|
|
10
|
+
#
|
|
11
|
+
# The ledger inside each document is the record humans read and the
|
|
12
|
+
# dispatcher acts on; this is the index of it, plus what the ledger cannot
|
|
13
|
+
# hold: which run wrote what, process ids, tokens, and whether the run
|
|
14
|
+
# that wrote a `Started` is still alive. It sits in `.plans/` rather than
|
|
15
|
+
# the system temp dir because a reboot must not lose it, and it is
|
|
16
|
+
# gitignored because it is about one machine's run, not the plan.
|
|
17
|
+
class StateFile
|
|
18
|
+
FILENAME = "agentilda-state.json"
|
|
19
|
+
|
|
20
|
+
# Where it used to live, one directory down. Read by {#load} when the
|
|
21
|
+
# current path holds nothing, so a run started under the old layout is
|
|
22
|
+
# picked up rather than silently restarted from zero. Nothing writes
|
|
23
|
+
# here any more and nothing deletes it: the file left behind is the
|
|
24
|
+
# only record a half-finished run has.
|
|
25
|
+
LEGACY_DIRNAME = "tmp"
|
|
26
|
+
|
|
27
|
+
# The lines {.ensure_ignored!} adds. The second covers the sibling
|
|
28
|
+
# {#save} writes and renames over the first — brief, but `.plans/` is a
|
|
29
|
+
# directory whose other contents are committed, so a `git status` run
|
|
30
|
+
# during that instant must not offer it.
|
|
31
|
+
IGNORE = ["#{Agentilda::PLANS_DIR}/#{FILENAME}", "#{Agentilda::PLANS_DIR}/#{FILENAME}.*.tmp"].freeze
|
|
32
|
+
|
|
33
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
34
|
+
# @return [String]
|
|
35
|
+
def self.for(tree) = File.join(tree.dir, FILENAME)
|
|
36
|
+
|
|
37
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
38
|
+
# @return [String] where {LEGACY_DIRNAME} put it
|
|
39
|
+
def self.legacy_for(tree) = File.join(tree.dir, LEGACY_DIRNAME, FILENAME)
|
|
40
|
+
|
|
41
|
+
# Add the ignore lines once. Editing somebody's `.gitignore` is a thing
|
|
42
|
+
# to announce, which is why this returns whether it did.
|
|
43
|
+
#
|
|
44
|
+
# Only the first line is asked about: git answers for a literal path,
|
|
45
|
+
# and the second is a glob no path equals. They are written together,
|
|
46
|
+
# so one being ignored means both are.
|
|
47
|
+
#
|
|
48
|
+
# @param root [String] repository root
|
|
49
|
+
# @return [Boolean] true when lines were added
|
|
50
|
+
def self.ensure_ignored!(root)
|
|
51
|
+
return false unless system("git", "-C", root, "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
|
52
|
+
return false if system("git", "-C", root, "check-ignore", "-q", IGNORE.first, out: File::NULL, err: File::NULL)
|
|
53
|
+
|
|
54
|
+
path = File.join(root, ".gitignore")
|
|
55
|
+
existing = File.file?(path) ? File.read(path) : ""
|
|
56
|
+
glue = existing.empty? || existing.end_with?("\n") ? "" : "\n"
|
|
57
|
+
File.write(path, "#{existing}#{glue}#{IGNORE.join("\n")}\n")
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param path [String]
|
|
62
|
+
# @param pid [Integer] this run's process id
|
|
63
|
+
def initialize(path:, pid: Process.pid)
|
|
64
|
+
@path = path
|
|
65
|
+
@pid = pid
|
|
66
|
+
@data = { "run" => {}, "plans" => {} }
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String]
|
|
71
|
+
attr_reader :path
|
|
72
|
+
|
|
73
|
+
# Where a run started before the file moved up out of {LEGACY_DIRNAME}
|
|
74
|
+
# would have written. {#load} falls back to it; {#save} never uses it.
|
|
75
|
+
#
|
|
76
|
+
# @return [String]
|
|
77
|
+
def legacy_path = File.join(File.dirname(path), LEGACY_DIRNAME, File.basename(path))
|
|
78
|
+
|
|
79
|
+
# Reads the current path, or the legacy one when the current path holds
|
|
80
|
+
# nothing. A run interrupted under the old layout is otherwise invisible
|
|
81
|
+
# to the run that picks it up, and {#stranded} would report no stranded
|
|
82
|
+
# stages for a harness that died with several.
|
|
83
|
+
#
|
|
84
|
+
# @return [self]
|
|
85
|
+
def load
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
source = [path, legacy_path].find { |candidate| File.file?(candidate) }
|
|
88
|
+
@data = JSON.parse(File.read(source)) if source
|
|
89
|
+
@data = { "run" => {}, "plans" => {} } unless @data.is_a?(Hash) && @data["plans"].is_a?(Hash)
|
|
90
|
+
end
|
|
91
|
+
self
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Written whole to a sibling and renamed over, so a reader never sees
|
|
97
|
+
# half a file.
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def save
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
103
|
+
temp = "#{path}.#{@pid}.tmp"
|
|
104
|
+
File.write(temp, JSON.pretty_generate(@data))
|
|
105
|
+
File.rename(temp, path)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param root [String]
|
|
110
|
+
# @return [void]
|
|
111
|
+
def begin_run!(root:)
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@data["run"] = { "pid" => @pid, "root" => root, "started_at" => Time.now.iso8601,
|
|
114
|
+
"heartbeat_at" => Time.now.iso8601 }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [void]
|
|
119
|
+
def heartbeat!
|
|
120
|
+
@mutex.synchronize { @data["run"]["heartbeat_at"] = Time.now.iso8601 }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param ordinal [String]
|
|
124
|
+
# @param agent [String]
|
|
125
|
+
# @param round [Integer]
|
|
126
|
+
# @param fields [Hash] anything else worth keeping: status, model, file,
|
|
127
|
+
# pid, up, down, state, next, exit
|
|
128
|
+
# @return [void]
|
|
129
|
+
def record(ordinal, agent:, round:, **fields)
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
plan = (@data["plans"][ordinal.to_s] ||= { "stages" => [] })
|
|
132
|
+
stage = plan["stages"].find { |s| s["agent"] == agent && s["round"] == round }
|
|
133
|
+
unless stage
|
|
134
|
+
stage = { "agent" => agent, "round" => round, "run_pid" => @pid }
|
|
135
|
+
plan["stages"] << stage
|
|
136
|
+
end
|
|
137
|
+
fields.each { |key, value| stage[key.to_s] = value }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param ordinal [String]
|
|
142
|
+
# @return [Array<Hash>]
|
|
143
|
+
def stages(ordinal) = @mutex.synchronize { (@data.dig("plans", ordinal.to_s, "stages") || []).map(&:dup) }
|
|
144
|
+
|
|
145
|
+
# @return [Array<String>] every ordinal with a stage recorded
|
|
146
|
+
def plans = @mutex.synchronize { @data["plans"].keys }
|
|
147
|
+
|
|
148
|
+
# @return [Boolean] whether the run that last wrote this file is gone
|
|
149
|
+
def previous_run_dead?
|
|
150
|
+
previous = @data.dig("run", "pid")
|
|
151
|
+
return false if previous.nil? || previous == @pid
|
|
152
|
+
|
|
153
|
+
Process.kill(0, previous)
|
|
154
|
+
false
|
|
155
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Stages a dead run left `Started`. Each is an agent that was cut off
|
|
160
|
+
# mid-flight and has to be re-run, with the harness's own Interrupted
|
|
161
|
+
# line written so the document agrees with this file.
|
|
162
|
+
#
|
|
163
|
+
# @return [Array<Array(String, Hash)>] ordinal and stage
|
|
164
|
+
def stranded
|
|
165
|
+
return [] unless previous_run_dead?
|
|
166
|
+
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
@data["plans"].flat_map { |ordinal, plan|
|
|
169
|
+
plan["stages"].select { |s| s["status"] == "Started" && s["run_pid"] != @pid }
|
|
170
|
+
.map { |s| [ordinal, s.dup] }
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Engine
|
|
5
|
+
# What a `run` cost, once it is over.
|
|
6
|
+
#
|
|
7
|
+
# The loop reports movement — which plan went from ⭐️ to 🟡, which agent
|
|
8
|
+
# failed and why — and says nothing about the bill. A round of five agents
|
|
9
|
+
# against a large repository spends several million tokens and the better
|
|
10
|
+
# part of an hour, and until this existed the only record of that was a
|
|
11
|
+
# directory of trace files nobody reads.
|
|
12
|
+
#
|
|
13
|
+
# {#render} returns a string and prints nothing, so the caller decides where
|
|
14
|
+
# it goes, the same way {Reporter} works.
|
|
15
|
+
class Tally
|
|
16
|
+
# One agent's share of the run, added up across every round it ran in.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] name
|
|
19
|
+
# @return [String]
|
|
20
|
+
# @!attribute [r] invocations
|
|
21
|
+
# @return [Integer] times this agent was started
|
|
22
|
+
# @!attribute [r] subagents
|
|
23
|
+
# @return [Integer] sub-agents it spawned, across all of them
|
|
24
|
+
# @!attribute [r] up
|
|
25
|
+
# @return [Integer] tokens sent, sub-agent spend included
|
|
26
|
+
# @!attribute [r] down
|
|
27
|
+
# @return [Integer] tokens generated
|
|
28
|
+
# @!attribute [r] seconds
|
|
29
|
+
# @return [Float] how long it was running, added up
|
|
30
|
+
Row = Data.define(:name, :invocations, :subagents, :up, :down, :seconds)
|
|
31
|
+
|
|
32
|
+
# Cells per column, so the header, the rows and the rule cannot drift.
|
|
33
|
+
WIDTHS = { name: 22, invocations: 6, subagents: 11, up: 9, down: 9, seconds: 8 }.freeze
|
|
34
|
+
|
|
35
|
+
# @param attempts [Array<Agentilda::Engine::Runner::Attempt>]
|
|
36
|
+
# @param seconds [Float] wall clock for the whole loop, which is not the
|
|
37
|
+
# sum of the agents' own times: several of them run at once
|
|
38
|
+
# @param rounds [Integer, nil] accepted so an older caller still works, and
|
|
39
|
+
# otherwise unused: the loop has no rounds of its own any more, each
|
|
40
|
+
# agent counts its own per plan, so the summary counts invocations
|
|
41
|
+
def initialize(attempts:, seconds:, rounds: nil)
|
|
42
|
+
@attempts = attempts.reject { |a| a.ordinal == "?" }
|
|
43
|
+
@seconds = seconds
|
|
44
|
+
@rounds = rounds
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Agentilda::Engine::Runner::Attempt>]
|
|
48
|
+
attr_reader :attempts
|
|
49
|
+
|
|
50
|
+
# @return [Float] wall clock
|
|
51
|
+
attr_reader :seconds
|
|
52
|
+
|
|
53
|
+
# @return [Integer, nil]
|
|
54
|
+
attr_reader :rounds
|
|
55
|
+
|
|
56
|
+
# Plans an agent was actually started against, counted once each however
|
|
57
|
+
# many rounds they took.
|
|
58
|
+
#
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
def plans = attempts.map(&:ordinal).uniq.size
|
|
61
|
+
|
|
62
|
+
# @return [Integer] tokens sent, everything included
|
|
63
|
+
def up = attempts.sum(&:up)
|
|
64
|
+
|
|
65
|
+
# @return [Integer] tokens generated
|
|
66
|
+
def down = attempts.sum(&:down)
|
|
67
|
+
|
|
68
|
+
# @return [Integer] sub-agents spawned by every agent in the run
|
|
69
|
+
def subagents = attempts.sum(&:subagents)
|
|
70
|
+
|
|
71
|
+
# Sub-agent spend that arrived as one number. `claude` reports what a
|
|
72
|
+
# sub-agent cost without splitting it between the two directions, so this
|
|
73
|
+
# much of {#up} is really up and down together.
|
|
74
|
+
#
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
def delegated = attempts.sum(&:delegated)
|
|
77
|
+
|
|
78
|
+
# @return [Float] seconds of agent time, added up across all of them
|
|
79
|
+
def busy = attempts.sum(&:seconds)
|
|
80
|
+
|
|
81
|
+
# How many agents were running at any given moment, averaged over the
|
|
82
|
+
# run: agent-seconds divided by wall-clock seconds. Below the `-j` ceiling
|
|
83
|
+
# by however much the round spent waiting for its slowest member.
|
|
84
|
+
#
|
|
85
|
+
# @return [Float]
|
|
86
|
+
def concurrency = seconds.positive? ? busy / seconds : 0.0
|
|
87
|
+
|
|
88
|
+
# @return [Array<Agentilda::Engine::Tally::Row>] one per agent, dearest first
|
|
89
|
+
def by_agent
|
|
90
|
+
attempts.group_by(&:agent).map { |name, list|
|
|
91
|
+
Row.new(name:,
|
|
92
|
+
invocations: list.size,
|
|
93
|
+
subagents: list.sum(&:subagents),
|
|
94
|
+
up: list.sum(&:up),
|
|
95
|
+
down: list.sum(&:down),
|
|
96
|
+
seconds: list.sum(&:seconds))
|
|
97
|
+
}.sort_by { |row| -row.up }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [String] the table and the summary line under it
|
|
101
|
+
def render
|
|
102
|
+
return summary if attempts.empty?
|
|
103
|
+
|
|
104
|
+
([header, rule] + by_agent.map { |row| line(row) } + [rule, totals, "", summary]).join("\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String] the one-line version, for a box
|
|
108
|
+
def summary
|
|
109
|
+
[
|
|
110
|
+
"#{plans} plan#{"s" unless plans == 1} addressed",
|
|
111
|
+
"#{attempts.size} invocation#{"s" unless attempts.size == 1}",
|
|
112
|
+
duration(seconds),
|
|
113
|
+
"#{format("%.1f", concurrency)} agents at a time",
|
|
114
|
+
"↑ #{UI.abbreviate(up)} ↓ #{UI.abbreviate(down)}"
|
|
115
|
+
].join(" · ") + delegation
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# @return [String] the caveat, where there is one to make
|
|
121
|
+
def delegation
|
|
122
|
+
return "" if delegated.zero?
|
|
123
|
+
|
|
124
|
+
"\n#{subagents} sub-agent#{"s" unless subagents == 1} reported #{UI.abbreviate(delegated)} " \
|
|
125
|
+
"tokens without splitting them, counted above as ↑."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [String]
|
|
129
|
+
def header
|
|
130
|
+
cell("Agent", :name) + %i[invocations subagents up down seconds]
|
|
131
|
+
.zip(["runs", "sub-agents", "↑", "↓", "time"])
|
|
132
|
+
.map { |key, text| right(text, key) }.join
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [String]
|
|
136
|
+
def rule = WIDTHS.values.map { |width| ("─" * (width - 1)) + " " }.join
|
|
137
|
+
|
|
138
|
+
# @param row [Agentilda::Engine::Tally::Row]
|
|
139
|
+
# @return [String]
|
|
140
|
+
def line(row)
|
|
141
|
+
cell(row.name, :name) + right(row.invocations.to_s, :invocations) +
|
|
142
|
+
right(row.subagents.to_s, :subagents) + right(UI.abbreviate(row.up), :up) +
|
|
143
|
+
right(UI.abbreviate(row.down), :down) + right(duration(row.seconds), :seconds)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [String]
|
|
147
|
+
def totals
|
|
148
|
+
cell("", :name) + right(attempts.size.to_s, :invocations) + right(subagents.to_s, :subagents) +
|
|
149
|
+
right(UI.abbreviate(up), :up) + right(UI.abbreviate(down), :down) + right(duration(busy), :seconds)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param text [String]
|
|
153
|
+
# @param key [Symbol]
|
|
154
|
+
# @return [String] padded to its column, left-justified
|
|
155
|
+
def cell(text, key) = UI.fit(text, WIDTHS.fetch(key))
|
|
156
|
+
|
|
157
|
+
# @param text [String]
|
|
158
|
+
# @param key [Symbol]
|
|
159
|
+
# @return [String] padded to its column, right-justified
|
|
160
|
+
def right(text, key)
|
|
161
|
+
width = WIDTHS.fetch(key) - 1
|
|
162
|
+
text = UI.fit(text, width).rstrip
|
|
163
|
+
UI.fit("", width - UI.display_width(text)) + text + " "
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param seconds [Numeric]
|
|
167
|
+
# @return [String] e.g. "42s", "12m40s", "1h04m"
|
|
168
|
+
def duration(seconds)
|
|
169
|
+
total = seconds.to_i
|
|
170
|
+
return "#{total}s" if total < 60
|
|
171
|
+
return "#{total / 60}m#{format("%02ds", total % 60)}" if total < 3600
|
|
172
|
+
|
|
173
|
+
"#{total / 3600}h#{format("%02dm", (total % 3600) / 60)}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# The run loop: which agent works which plan, in what order, until a round
|
|
5
|
+
# changes nothing. It decides; {Execution} carries the decision out.
|
|
6
|
+
module Engine
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @return [Agentilda::Engine::Runner]
|
|
10
|
+
def runner(**) = Runner.new(**)
|
|
11
|
+
|
|
12
|
+
# @param tree [Agentilda::Plans::Tree]
|
|
13
|
+
# @return [Agentilda::Engine::StateFile]
|
|
14
|
+
def state_file(tree) = StateFile.new(path: StateFile.for(tree))
|
|
15
|
+
|
|
16
|
+
# @return [Agentilda::Engine::Tally]
|
|
17
|
+
def tally(**) = Tally.new(**)
|
|
18
|
+
end
|
|
19
|
+
end
|