agentilda 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agentilda
6
+ module Execution
7
+ # What an agent is doing, while it is still doing it.
8
+ #
9
+ # `claude -p` prints nothing until it exits, so a fifteen-minute agent used to
10
+ # sit behind a spinner that said only its name. Asked for `--output-format
11
+ # stream-json` it emits one JSON object per line as it works, and this turns
12
+ # that stream into the short phrase a spinner line has room for: "editing
13
+ # spec.md", "reading plan.md", whatever the agent last said about itself.
14
+ #
15
+ # Three things make this less trivial than parsing JSON.
16
+ #
17
+ # Chunks arrive from `readpartial`, so a line is split across two of them as
18
+ # often as not. {#push} buffers and only ever parses whole lines.
19
+ #
20
+ # Not every line is JSON. `claude` reports its own failures as prose on
21
+ # stdout, and the 401 that cost a whole run three minutes an agent was one of
22
+ # those. Those lines are kept in {#plain} so a failure can still be explained
23
+ # by what was actually printed.
24
+ #
25
+ # The reader runs on its own thread, so {#activity} is written from one
26
+ # thread and read from another. It is a single assignment of an immutable
27
+ # string, which is why there is no lock here.
28
+ #
29
+ # Every line is also kept verbatim in an optional trace file, written before
30
+ # anything is made of it, so a run that failed can be read back afterwards
31
+ # even where this parser made nothing of an event it had never seen. See
32
+ # {Executor::TRACE_DIR} for where those land and how to read one.
33
+ class Transcript
34
+ # What one line of the display knows: the phrase, and the meter beside it.
35
+ #
36
+ # Yielded whole rather than as loose arguments so a caller cannot render
37
+ # half an update, and frozen so the reader thread and the drawing thread
38
+ # are never looking at the same mutable object.
39
+ #
40
+ # @!attribute [r] activity
41
+ # @return [String, nil] the last thing the agent was seen doing
42
+ # @!attribute [r] up
43
+ # @return [Integer] tokens sent, cache reads included
44
+ # @!attribute [r] down
45
+ # @return [Integer] tokens generated
46
+ # @!attribute [r] subagents
47
+ # @return [Integer] sub-agents spawned so far
48
+ # @!attribute [r] pid
49
+ # @return [Integer, nil] the `claude` process, once the harness has
50
+ # found it — nil until then, and for callers that never look
51
+ # @!attribute [r] message
52
+ # @return [String, nil] the last thing the agent chose to say directly,
53
+ # via `SendUserMessage`, or nil if it never sent one
54
+ Progress = Data.define(:activity, :up, :down, :subagents, :pid, :message) do
55
+ def initialize(pid: nil, message: nil, **rest) = super
56
+ end
57
+
58
+ # The one kind of line the trace does not keep.
59
+ #
60
+ # `--include-partial-messages` is what makes {#meter} possible, and it
61
+ # also emits one event per handful of generated characters. Those are the
62
+ # bulk of the stream and the only part of it nothing reads back: a
63
+ # fifteen-minute agent writes thousands of them, and they would bury the
64
+ # events a failed run is actually read for. Matched as a substring rather
65
+ # than parsed because the trace is written before a line is understood,
66
+ # which is what makes it complete when the process dies mid-invocation.
67
+ DELTA_NOISE = '"content_block_delta"'
68
+
69
+ # Longest phrase kept. A sanity cap, not a layout rule: each place that
70
+ # draws a phrase cuts it to the room it has, and the dashboard has most
71
+ # of a terminal's width.
72
+ LIMIT = 300
73
+
74
+ # How each tool reads as something being done, rather than as a tool name.
75
+ # A spinner says what is happening; "Edit" is a noun and says nothing.
76
+ VERBS = {
77
+ "Read" => "reading", "Write" => "writing", "Edit" => "editing",
78
+ "MultiEdit" => "editing", "NotebookEdit" => "editing",
79
+ "Bash" => "running", "Grep" => "searching for", "Glob" => "looking for",
80
+ "Task" => "delegating", "TodoWrite" => "planning",
81
+ "WebFetch" => "fetching", "WebSearch" => "searching the web for",
82
+ "SendUserMessage" => "saying"
83
+ }.freeze
84
+
85
+ # The input key worth naming, per tool, in the order we would rather say it.
86
+ # `description` outranks `command` deliberately: Claude's Bash tool carries
87
+ # a written summary of why it is running something, and "Read NOTES.md" is
88
+ # a better spinner line than sixty characters of absolute path.
89
+ SUBJECTS = %w[file_path pattern description command query url].freeze
90
+
91
+ # @param trace [String, nil] a file to keep the raw stream in, or nil to
92
+ # keep none. Every line is written before anything is made of it, so a
93
+ # run that failed can be read back afterwards even where this parser made
94
+ # nothing of an event it had never seen.
95
+ # @yieldparam progress [Agentilda::Execution::Transcript::Progress] each time the
96
+ # agent moves on to something new, and each time the meter moves
97
+ def initialize(trace: nil, &on_progress)
98
+ @on_progress = on_progress
99
+ @buffer = +""
100
+ @plain = []
101
+ @activity = nil
102
+ @message = nil
103
+ @result = nil
104
+ @error = nil
105
+ @trace_path = trace
106
+ @trace = trace && File.open(trace, "a")
107
+ @tools = 0
108
+ @main = { up: 0, down: 0 }
109
+ @streamed = {}
110
+ @tasks = {}
111
+ @spawned = 0
112
+ end
113
+
114
+ # Everything sent to the model, which on any turn after the first is
115
+ # mostly cache reads: a typical agent turn is two fresh input tokens
116
+ # against a quarter of a million read back from cache. Counting only the
117
+ # fresh ones would report a fifteen-minute agent as having said nothing.
118
+ #
119
+ # @return [Integer]
120
+ def up = @main[:up] + streamed[:up] + delegated
121
+
122
+ # @return [Integer] tokens the model generated, thinking included
123
+ def down = @main[:down] + streamed[:down]
124
+
125
+ # @return [Integer] sub-agents this invocation started
126
+ attr_reader :spawned
127
+
128
+ # The `claude` process this transcript is reading, set by the {Executor}
129
+ # once it has found the child, so the spinner line can name a pid someone
130
+ # can actually `ps` while the agent runs.
131
+ #
132
+ # @return [Integer, nil]
133
+ attr_accessor :pid
134
+
135
+ # Sub-agent totals arrive as one number with no split between what went up
136
+ # and what came back, so they are counted as {#up}. They are held here as
137
+ # well, unmixed, because a report that says "of which N could not be
138
+ # split" is honest and one that quietly rounds is not.
139
+ #
140
+ # @return [Integer]
141
+ def delegated
142
+ @tasks.reject { |_, task| @streamed.key?(task[:tool_use_id]) }
143
+ .sum { |_, task| task[:total] }
144
+ end
145
+
146
+ # @return [Agentilda::Execution::Transcript::Progress] a snapshot, safe to keep
147
+ def progress = Progress.new(activity: @activity, up:, down:, subagents: @spawned, pid: @pid, message: @message)
148
+
149
+ # @return [Integer] how many tool calls have gone past, which is the one
150
+ # honest measure of how much work an agent that says "done" actually did
151
+ attr_reader :tools
152
+
153
+ # @return [String, nil] the file the raw stream is being kept in
154
+ attr_reader :trace_path
155
+
156
+ # @return [String, nil] the last thing the agent was seen doing
157
+ attr_reader :activity
158
+
159
+ # @return [String, nil] the last thing the agent sent via `SendUserMessage`
160
+ attr_reader :message
161
+
162
+ # @return [String, nil] the final text, from the `result` event
163
+ attr_reader :result
164
+
165
+ # @return [Array<String>] lines that were not JSON, in order
166
+ attr_reader :plain
167
+
168
+ # @return [Boolean] whether the stream reported its own failure
169
+ def failed? = !@error.nil?
170
+
171
+ # @return [String, nil] what the agent said went wrong
172
+ attr_reader :error
173
+
174
+ # Feed one chunk of stdout. Safe to call with a partial line, which is the
175
+ # normal case.
176
+ #
177
+ # @param chunk [String, nil]
178
+ # @return [void]
179
+ def push(chunk)
180
+ return if chunk.nil?
181
+
182
+ @buffer << chunk
183
+ while (index = @buffer.index("\n"))
184
+ line = @buffer.slice!(0..index).chomp
185
+ consume(line)
186
+ end
187
+ end
188
+
189
+ # Whatever is left when the process exits, which may be a last line with no
190
+ # newline after it.
191
+ #
192
+ # @return [void]
193
+ def finish
194
+ consume(@buffer.slice!(0..-1).to_s)
195
+ @trace&.close
196
+ @trace = nil
197
+ end
198
+
199
+ private
200
+
201
+ # @param line [String]
202
+ # @return [void]
203
+ def consume(line)
204
+ text = line.strip
205
+ return if text.empty?
206
+
207
+ record(text)
208
+
209
+ unless text.start_with?("{")
210
+ @plain << text
211
+ return
212
+ end
213
+
214
+ event = begin
215
+ JSON.parse(text)
216
+ rescue JSON::ParserError
217
+ @plain << text
218
+ return
219
+ end
220
+
221
+ handle(event)
222
+ end
223
+
224
+ # Written before the line is understood, and flushed, so the file is a
225
+ # complete record even when the process dies mid-invocation.
226
+ #
227
+ # @param text [String]
228
+ # @return [void]
229
+ def record(text)
230
+ return unless @trace
231
+ return if text.include?(DELTA_NOISE)
232
+
233
+ @trace.write("#{text}\n")
234
+ @trace.flush
235
+ end
236
+
237
+ # @param event [Hash]
238
+ # @return [void]
239
+ def handle(event)
240
+ case event["type"]
241
+ when "assistant"
242
+ capture_message(event)
243
+ @tools += tool_calls(event)
244
+ announce(phrase_for(event))
245
+ when "stream_event" then meter(event)
246
+ when "system" then delegation(event)
247
+ when "result" then record_result(event)
248
+ end
249
+ end
250
+
251
+ # `--brief` gives the agent SendUserMessage, and what it sends is the
252
+ # one line on the screen the agent itself chose. It outranks the tool
253
+ # phrase until the agent sends another.
254
+ #
255
+ # @param event [Hash]
256
+ # @return [void]
257
+ def capture_message(event)
258
+ blocks = event.dig("message", "content")
259
+ return unless blocks.is_a?(Array)
260
+
261
+ blocks.each do |block|
262
+ next unless block.is_a?(Hash) && block["type"] == "tool_use" && block["name"] == "SendUserMessage"
263
+
264
+ text = block.dig("input", "message").to_s.strip
265
+ @message = clip(text) unless text.empty?
266
+ end
267
+ end
268
+
269
+ # @param event [Hash]
270
+ # @return [Integer] how many tools this one message called
271
+ def tool_calls(event)
272
+ blocks = event.dig("message", "content")
273
+ return 0 unless blocks.is_a?(Array)
274
+
275
+ blocks.count { |block| block.is_a?(Hash) && block["type"] == "tool_use" }
276
+ end
277
+
278
+ # Where the token count comes from.
279
+ #
280
+ # Only `message_delta` is trusted. The `assistant` events carry a usage
281
+ # block too, and its input side is right, but its `output_tokens` is the
282
+ # placeholder the API sends when a message *starts*: a four-thousand-token
283
+ # answer reports 2 there. `message_delta` closes each message with the
284
+ # settled figure for both directions, which is why {Executor} asks for
285
+ # `--include-partial-messages` at all.
286
+ #
287
+ # A message from a sub-agent carries `parent_tool_use_id`, and is kept in
288
+ # its own bucket so {#delegated} knows not to count that sub-agent twice.
289
+ #
290
+ # @param event [Hash]
291
+ # @return [void]
292
+ def meter(event)
293
+ return unless event.dig("event", "type") == "message_delta"
294
+
295
+ usage = event.dig("event", "usage") or return
296
+ bucket = bucket_for(event["parent_tool_use_id"])
297
+ bucket[:up] += usage.values_at("input_tokens",
298
+ "cache_creation_input_tokens",
299
+ "cache_read_input_tokens").compact.sum
300
+ bucket[:down] += usage["output_tokens"].to_i
301
+ publish
302
+ end
303
+
304
+ # @param parent [String, nil] the tool call a sub-agent's message belongs to
305
+ # @return [Hash] the counters that message belongs in
306
+ def bucket_for(parent)
307
+ return @main if parent.nil?
308
+
309
+ @streamed[parent] ||= { up: 0, down: 0 }
310
+ end
311
+
312
+ # @return [Hash] every streamed sub-agent's counters, added together
313
+ def streamed
314
+ @streamed.values.each_with_object({ up: 0, down: 0 }) do |b, sum|
315
+ sum[:up] += b[:up]
316
+ sum[:down] += b[:down]
317
+ end
318
+ end
319
+
320
+ # A sub-agent starting, and a sub-agent reporting what it spent.
321
+ #
322
+ # `claude` reports a sub-agent's spend as a single `total_tokens` with no
323
+ # split, and reports it cumulatively: the last number for a task is the
324
+ # whole of it, not an increment. So the total is stored per task and
325
+ # replaced rather than added, which also makes a `task_progress` stream
326
+ # and a closing `task_notification` for the same task agree instead of
327
+ # counting it twice.
328
+ #
329
+ # @param event [Hash]
330
+ # @return [void]
331
+ def delegation(event)
332
+ case event["subtype"]
333
+ when "task_started" then start_task(event)
334
+ when "task_progress", "task_notification" then update_task(event)
335
+ end
336
+ end
337
+
338
+ # @param event [Hash]
339
+ # @return [void]
340
+ def start_task(event)
341
+ id = event["task_id"] or return
342
+
343
+ @tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 }
344
+ @spawned = @tasks.size
345
+ publish
346
+ end
347
+
348
+ # @param event [Hash]
349
+ # @return [void]
350
+ def update_task(event)
351
+ id = event["task_id"] or return
352
+
353
+ task = (@tasks[id] ||= { tool_use_id: event["tool_use_id"], total: 0 })
354
+ task[:tool_use_id] ||= event["tool_use_id"]
355
+ task[:total] = event.dig("usage", "total_tokens").to_i
356
+ @spawned = @tasks.size
357
+ publish
358
+ end
359
+
360
+ # The `result` event settles what the stream could only accumulate: its
361
+ # usage is the invocation's own final accounting, so it replaces the
362
+ # running total rather than adding to it. Sub-agent spend is left alone,
363
+ # because `claude` leaves it out of this figure — a run whose two
364
+ # sub-agents burned 34k each reported 493 output tokens here.
365
+ #
366
+ # @param event [Hash]
367
+ # @return [void]
368
+ def record_result(event)
369
+ @result = event["result"].to_s
370
+ @error = @result if event["is_error"]
371
+ settle(event["usage"])
372
+ @spawned = [@spawned, event.dig("subagent_stats", "spawned").to_i].max
373
+ publish
374
+ end
375
+
376
+ # The most recent block wins: an agent that says "now I'll edit the spec"
377
+ # and then edits it is more usefully reported as editing it.
378
+ #
379
+ # @param event [Hash]
380
+ # @return [String, nil]
381
+ def phrase_for(event)
382
+ blocks = event.dig("message", "content")
383
+ return nil unless blocks.is_a?(Array)
384
+
385
+ blocks.reverse.filter_map { |block| block_phrase(block) }.first
386
+ end
387
+
388
+ # @param block [Hash]
389
+ # @return [String, nil]
390
+ def block_phrase(block)
391
+ case block["type"]
392
+ when "tool_use" then tool_phrase(block)
393
+ when "text" then sentence(block["text"])
394
+ end
395
+ end
396
+
397
+ # @param block [Hash]
398
+ # @return [String]
399
+ def tool_phrase(block)
400
+ verb = VERBS.fetch(block["name"].to_s, "using #{block["name"]}")
401
+ input = block["input"]
402
+ return verb unless input.is_a?(Hash)
403
+
404
+ key = SUBJECTS.find { |k| input[k].to_s.strip != "" }
405
+ return verb unless key
406
+ # A description is already a phrase. Prefixing it with a verb gives
407
+ # "running Read NOTES.md", which reads worse than either half alone.
408
+ return clip(noun(key, input[key])) if key == "description"
409
+
410
+ clip("#{verb} #{noun(key, input[key])}")
411
+ end
412
+
413
+ # A path is only ever interesting by its last segment on a line this short.
414
+ #
415
+ # @param key [String]
416
+ # @param value [Object]
417
+ # @return [String]
418
+ def noun(key, value)
419
+ text = value.to_s.strip.tr("\n", " ").squeeze(" ")
420
+ key == "file_path" ? File.basename(text) : text
421
+ end
422
+
423
+ # The agent's own narration, first sentence only. Everything after it is
424
+ # detail the spinner has no room for.
425
+ #
426
+ # @param text [String, nil]
427
+ # @return [String, nil]
428
+ def sentence(text)
429
+ line = text.to_s.split("\n").map(&:strip).find { |l| !l.empty? } or return nil
430
+ line = line.sub(/\A[#>*\-\s]+/, "").split(/(?<=[.!?])\s/).first.to_s.strip
431
+ line.empty? ? nil : clip(line)
432
+ end
433
+
434
+ # @param text [String]
435
+ # @return [String]
436
+ def clip(text) = text.length > LIMIT ? "#{text[0, LIMIT - 1]}…" : text
437
+
438
+ # @param usage [Hash, nil] the `result` event's own accounting
439
+ # @return [void]
440
+ def settle(usage)
441
+ return unless usage.is_a?(Hash)
442
+
443
+ @main = {
444
+ up: usage.values_at("input_tokens",
445
+ "cache_creation_input_tokens",
446
+ "cache_read_input_tokens").compact.sum,
447
+ down: usage["output_tokens"].to_i
448
+ }
449
+ end
450
+
451
+ # @param phrase [String, nil]
452
+ # @return [void]
453
+ def announce(phrase)
454
+ return if phrase.nil? || phrase == @activity
455
+
456
+ @activity = phrase
457
+ publish
458
+ end
459
+
460
+ # Hand the caller a whole snapshot rather than the one field that moved.
461
+ # A spinner line draws the phrase and the meter together, and a caller
462
+ # given only the half that changed would have to remember the other.
463
+ #
464
+ # @return [void]
465
+ def publish = @on_progress&.call(progress)
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # One agent invocation: the child process, its clock, the control file it
5
+ # polls, and the transcript it streams back. The autonomy boundary lives
6
+ # here, because this is the only place a coding agent is started.
7
+ module Execution
8
+ module_function
9
+
10
+ # @return [Agentilda::Execution::Executor]
11
+ def executor(**) = Executor.new(**)
12
+
13
+ # Run the block with Ctrl-C turned into a cooperative interrupt.
14
+ #
15
+ # @return [Object] whatever the block returns
16
+ def on_interrupt(&) = Control.on_interrupt(&)
17
+ end
18
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "parallel"
5
+
6
+ module Agentilda
7
+ module Lifecycle
8
+ # Gives a plan folder to every pull request that has no plan to point at.
9
+ #
10
+ # `resync prs` can resolve most pull requests from their branch or their
11
+ # diff. What it cannot resolve it used to flag for a human — "diff touches
12
+ # 021.00, 022.00, 024.00 and none is obviously primary" — and stop. That is
13
+ # honest but not useful: the work exists, it is unspecified, and leaving it
14
+ # unnumbered means it never appears in the index at all.
15
+ #
16
+ # So an orphan is adopted instead. It gets a retroactive plan of its own,
17
+ # numbered in the gap after the furthest plan it can see, holding the pull
18
+ # request that produced it.
19
+ #
20
+ # == Why the numbering is allocated serially
21
+ #
22
+ # The obvious parallel design — every worker reads the highest number and
23
+ # adds `.01` — is deterministic but not unique, and the collision is the
24
+ # common case rather than the rare one. Fifteen open pull requests branched
25
+ # off the same main all see the same highest number and all compute the same
26
+ # slot. Determinism is not the property that matters here; distinctness is.
27
+ #
28
+ # So the pipeline is **gather in parallel, allocate serially, apply in
29
+ # parallel**. The allocation is pure arithmetic over an array that is
30
+ # already in memory, so the serial section costs microseconds; the two
31
+ # expensive phases — reading each branch and writing each folder — keep the
32
+ # whole fan-out. And because the slots are handed out before any worker
33
+ # starts, no worker can want a number another worker holds: no locks, no
34
+ # retries, and the same input produces the same numbers every run.
35
+ class Adoption
36
+ # One pull request and the plan it is being given.
37
+ #
38
+ # @!attribute [r] pull
39
+ # @return [Hash] as returned by {GitHub#pulls}
40
+ # @!attribute [r] ordinal
41
+ # @return [Agentilda::Plans::Ordinal] the slot it was allocated
42
+ # @!attribute [r] major
43
+ # @return [Integer] the furthest plan its branch or diff could see
44
+ # @!attribute [r] path
45
+ # @return [String, nil] the folder, once created
46
+ Adoptee = Data.define(:pull, :ordinal, :major, :path) do
47
+ # @return [String] the folder name this pull request earns
48
+ def dirname = Agentilda::Plans.plan_dirname(ordinal, Plans::STATUS_BY_KEY.fetch(:retroactive), slug)
49
+
50
+ # The PR title, stripped of any prefix, as the folder's tail.
51
+ #
52
+ # @return [String]
53
+ def slug = Creator.slugify(pull[:title].to_s.sub(/\A\[[^\]]+\](?:\([A-Z]\))?\s*/, ""))
54
+
55
+ # @return [String] a single auditable line
56
+ def to_s = "##{pull[:number]} → #{dirname}"
57
+ end
58
+
59
+ # @param tree [Agentilda::Plans::Tree]
60
+ # @param github [Agentilda::Vcs::GitHub]
61
+ # @param root [String] repository root, for reading branches
62
+ # @param jobs [Integer] workers for the two parallel phases
63
+ def initialize(tree:, github: Vcs::GitHub.new, root: nil, jobs: UI.default_jobs)
64
+ @tree = tree
65
+ @github = github
66
+ @root = root || File.dirname(tree.dir)
67
+ @jobs = jobs
68
+ end
69
+
70
+ # @return [Agentilda::Plans::Tree]
71
+ attr_reader :tree
72
+
73
+ # @return [Agentilda::Vcs::GitHub]
74
+ attr_reader :github
75
+
76
+ # @return [String]
77
+ attr_reader :root
78
+
79
+ # @return [Integer]
80
+ attr_reader :jobs
81
+
82
+ # Work out what each orphan would be given, without creating anything.
83
+ #
84
+ # @param pulls [Array<Hash>] the orphans, from {Resync::Prs}
85
+ # @return [Array<Agentilda::Lifecycle::Adoption::Adoptee>] in pull request order
86
+ def plan(pulls)
87
+ allocate(gather(pulls))
88
+ end
89
+
90
+ # Adopt every orphan: create its folder and record its pull request.
91
+ #
92
+ # @param pulls [Array<Hash>]
93
+ # @return [Array<Agentilda::Lifecycle::Adoption::Adoptee>] with `path` filled in
94
+ def call(pulls)
95
+ adoptees = plan(pulls)
96
+ return adoptees if adoptees.empty?
97
+
98
+ created = Parallel.map(adoptees, in_threads: jobs) { |adoptee| adopt(adoptee) }
99
+ tree.reload
100
+ created
101
+ end
102
+
103
+ private
104
+
105
+ # Phase one, in parallel: ask each pull request's branch how far the
106
+ # sequence had got when the work started. Branches are not all rebased
107
+ # onto the same main, so this is per-branch rather than tree-wide, and it
108
+ # is a network-free `ls-tree` per pull request.
109
+ #
110
+ # @param pulls [Array<Hash>]
111
+ # @return [Array<Array(Hash, Integer)>] each pull with its major
112
+ def gather(pulls)
113
+ ordered = pulls.sort_by { |pull| pull[:number].to_i }
114
+
115
+ Parallel.map(ordered, in_threads: jobs) { |pull| [pull, major_for(pull)] }
116
+ end
117
+
118
+ # The furthest whole plan this pull request can see: on its own branch
119
+ # first, then whatever it touched, then the tree we are standing in. A
120
+ # pull request whose branch is long gone still has a diff.
121
+ #
122
+ # @param pull [Hash]
123
+ # @return [Integer]
124
+ def major_for(pull)
125
+ candidates = Agentilda.plans_on_ref(root, pull[:branch].to_s)
126
+ candidates = touched(pull) if candidates.empty?
127
+ candidates = tree.subjects.map { |s| s.feature.ordinal } if candidates.empty?
128
+
129
+ candidates.map(&:major).max.to_i
130
+ end
131
+
132
+ # @param pull [Hash]
133
+ # @return [Array<Agentilda::Plans::Ordinal>]
134
+ def touched(pull)
135
+ Array(pull[:files]).filter_map { |path|
136
+ parts = path.to_s.split("/")
137
+ index = parts.index(Agentilda::PLANS_DIR)
138
+ index && parts[index + 1] && Plans::Ordinal.from_dirname(parts[index + 1])
139
+ }.uniq
140
+ end
141
+
142
+ # Phase two, serial and deliberately so. Minors are handed out per major,
143
+ # continuing past every slot the tree already holds, in ascending pull
144
+ # request order — so the numbers follow the order the work was opened in,
145
+ # and two pull requests cannot be given the same one.
146
+ #
147
+ # @param gathered [Array<Array(Hash, Integer)>]
148
+ # @return [Array<Agentilda::Lifecycle::Adoption::Adoptee>]
149
+ def allocate(gathered)
150
+ taken = Hash.new { |h, major| h[major] = minors_taken(major) }
151
+
152
+ gathered.filter_map do |pull, major|
153
+ minor = ((taken[major].max || 0) + 1)
154
+ next if minor > Plans::Ordinal::MAX_MINOR
155
+
156
+ taken[major] << minor
157
+ Adoptee.new(pull:, major:, ordinal: Plans::Ordinal.new(major:, minor:), path: nil)
158
+ end
159
+ end
160
+
161
+ # @param major [Integer]
162
+ # @return [Array<Integer>] minors already used under this major
163
+ def minors_taken(major)
164
+ tree.subjects.map { |s| s.feature.ordinal }.select { |o| o.major == major }.map(&:minor)
165
+ end
166
+
167
+ # Phase three, in parallel: each worker owns a number nobody else can
168
+ # want, so it can create its folder without coordinating with anyone.
169
+ #
170
+ # @param adoptee [Agentilda::Lifecycle::Adoption::Adoptee]
171
+ # @return [Agentilda::Lifecycle::Adoption::Adoptee]
172
+ def adopt(adoptee)
173
+ path = File.join(tree.dir, adoptee.dirname)
174
+ return adoptee if File.exist?(path)
175
+
176
+ FileUtils.mkdir_p(path)
177
+ File.write(File.join(path, Plans::PullRequests::FILENAME),
178
+ Plans::PullRequests.render([pull_request_for(adoptee)]))
179
+
180
+ adoptee.with(path:)
181
+ end
182
+
183
+ # The body is what a specification would be written from, so it is worth
184
+ # a second request. A failure here costs the description, not the folder.
185
+ #
186
+ # @param adoptee [Agentilda::Lifecycle::Adoption::Adoptee]
187
+ # @return [Hash]
188
+ def pull_request_for(adoptee)
189
+ pull = adoptee.pull
190
+ github.pull_request(pull[:number].to_s)
191
+ rescue Agentilda::Error
192
+ { number: pull[:number], title: pull[:title], url: pull[:url],
193
+ state: pull[:state] || "Unknown", body: "" }
194
+ end
195
+ end
196
+ end
197
+ end