agentilda 2.0.1 → 2.0.4

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