agentilda 1.0.3 → 2.0.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +10 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +65 -13
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "agentilda/status"
4
+
3
5
  module Agentilda
4
6
  # `agentilda states` — the state machine as a terminal picture.
5
7
  #
@@ -43,7 +45,8 @@ module Agentilda
43
45
  #
44
46
  # @return [String]
45
47
  def spine_section
46
- section("MAIN SPINE", "a bare `promote` walks this path",
48
+ section("MAIN SPINE",
49
+ "a bare `promote` walks this path",
47
50
  [" " + spine_chain.map { |key| node(key) }.join(" #{ARROW} ")])
48
51
  end
49
52
 
@@ -54,8 +57,8 @@ module Agentilda
54
57
  # @return [String, nil]
55
58
  def rejoin_section
56
59
  rows = StateMachine::SPINE
57
- .except(*spine_chain)
58
- .map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
60
+ .except(*spine_chain)
61
+ .map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
59
62
 
60
63
  section("REJOINING THE SPINE", "off-spine states whose default promotion lands back on it", rows)
61
64
  end
@@ -74,9 +77,11 @@ module Agentilda
74
77
  [s.key, targets] unless targets.empty?
75
78
  }
76
79
 
77
- section("OTHER TRANSITIONS", "everything else the machine permits", groups.flat_map { |key, targets|
78
- fan_out(key, targets)
79
- })
80
+ section("OTHER TRANSITIONS",
81
+ "everything else the machine permits",
82
+ groups.flat_map { |key, targets|
83
+ fan_out(key, targets)
84
+ })
80
85
  end
81
86
 
82
87
  # @return [String, nil]
@@ -118,7 +123,7 @@ module Agentilda
118
123
  # @return [Array<String>]
119
124
  def fan_out(key, targets)
120
125
  lines = targets.each_with_index.map { |to, i|
121
- connector = (i == targets.size - 1) ? "└─▶" : "├─▶"
126
+ connector = i == targets.size - 1 ? "└─▶" : "├─▶"
122
127
  " #{connector} #{node(to)}"
123
128
  }
124
129
  [" #{node(key)}", *lines]
@@ -0,0 +1,646 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agentilda/status"
4
+
5
+ module Agentilda
6
+ # The loop. One tick a second: reap what finished and settle it against
7
+ # the ledger, poll what is running for its latest line, start what is
8
+ # dispatchable, heartbeat the state file, hand the screen a board.
9
+ #
10
+ # Nothing here guesses. An agent that finished says so in its document;
11
+ # the state machine says whether the folder may move; the state file says
12
+ # what an earlier run left behind. When the three disagree the row goes
13
+ # red with the reason, and nobody is re-run to paper over it.
14
+ class Dispatcher
15
+ # One running invocation. `from` is the state the plan showed when the
16
+ # agent was picked; `state` is the one it ran under, after any
17
+ # `starts_as` rename. History is keyed by `state`: luke picked at ⭐️
18
+ # works at 🟡, and keying by ⭐️ would offer him 🟡 afterwards as though
19
+ # he had never been there (the 020.00 regression, run twice over).
20
+ Job = Struct.new(:key,
21
+ :task,
22
+ :thread,
23
+ :handle,
24
+ :started_at,
25
+ :file,
26
+ :status,
27
+ :message,
28
+ :up,
29
+ :down,
30
+ :subagents,
31
+ :result,
32
+ :from,
33
+ :state,
34
+ :frame,
35
+ :history) do
36
+ # @return [Boolean]
37
+ def finished? = !thread.alive?
38
+ end
39
+
40
+ # How long a settled row stays on the screen.
41
+ LINGER = 10
42
+
43
+ # Seconds a kill waits between STOP and SIGKILL.
44
+ KILL_GRACE = 15
45
+
46
+ # @param runner [Agentilda::Runner]
47
+ # @param state [Agentilda::StateFile, nil] nil under a dry run
48
+ # @param rounds [Integer, nil] `--rounds`, a cap that only tightens
49
+ # @param sleeper [Proc] how a tick waits; the suite passes a no-op
50
+ # @param on_board [Proc, nil] receives a {Board} each tick
51
+ def initialize(runner:, state: nil, rounds: nil, sleeper: ->(seconds) { sleep(seconds) }, on_board: nil)
52
+ @runner = runner
53
+ @state = state
54
+ @rounds_cap = rounds
55
+ @sleeper = sleeper
56
+ @on_board = on_board
57
+ @running = []
58
+ @recent = []
59
+ @attempts = []
60
+ @history = Hash.new { |h, k| h[k] = [] }
61
+ @finished_plans = []
62
+ @preferred = {}
63
+ @started_at = UI.monotonic
64
+ @mutex = Mutex.new
65
+ end
66
+
67
+ # @return [Array<Agentilda::Dispatcher::Job>]
68
+ attr_reader :running
69
+
70
+ # @return [Array<Agentilda::Runner::Attempt>]
71
+ attr_reader :attempts
72
+
73
+ # @return [Array<Agentilda::Runner::Attempt>]
74
+ def run
75
+ resume_stranded
76
+ loop do
77
+ tick
78
+ break if done?
79
+
80
+ @sleeper.call(1)
81
+ end
82
+ @attempts
83
+ end
84
+
85
+ # One cycle. Public so the suite can drive it by hand.
86
+ #
87
+ # @return [void]
88
+ def tick
89
+ reap
90
+ poll
91
+ @running.each { |job| job.handle.kill!(grace: 0, reason: :quit) } if Control.overdue?
92
+ dispatch unless Control.quit?
93
+ persist
94
+ @on_board&.call(board)
95
+ end
96
+
97
+ # @return [Boolean]
98
+ def done? = @running.empty? && (Control.quit? || next_dispatchable.nil?)
99
+
100
+ # `k` from the console: STOP, {KILL_GRACE} seconds, SIGKILL, on a thread
101
+ # so the tick is not held for fifteen seconds.
102
+ #
103
+ # @param key [String] a row's key
104
+ # @return [void]
105
+ def kill(key)
106
+ job = @running.find { |j| j.key == key } or return
107
+ Thread.new { job.handle.kill!(grace: KILL_GRACE) }
108
+ end
109
+
110
+ # `x` from the console.
111
+ #
112
+ # @param key [String]
113
+ # @param seconds [Integer]
114
+ # @return [void]
115
+ def extend(key, seconds)
116
+ @running.find { |j| j.key == key }&.handle&.extend!(seconds)
117
+ end
118
+
119
+ # @return [Agentilda::Board]
120
+ def board
121
+ up = @attempts.sum(&:up) + @running.sum { |j| j.up.to_i }
122
+ down = @attempts.sum(&:down) + @running.sum { |j| j.down.to_i }
123
+ Board.new(started_at: @started_at,
124
+ status: run_status,
125
+ plans: (@running.map { |j| j.task.subject.feature.ordinal.to_s } + @recent.map { |r| r[:row].ordinal }).uniq,
126
+ up:,
127
+ down:,
128
+ rows: rows,
129
+ root: @runner.root,
130
+ running: @running.size,
131
+ live_up: @running.sum { |j| j.up.to_i },
132
+ live_down: @running.sum { |j| j.down.to_i })
133
+ end
134
+
135
+ private
136
+
137
+ # @return [Symbol]
138
+ def run_status
139
+ return :quitting if Control.quit?
140
+ return :wrapping_up if @running.any? { |j| %i[wrap_up stopped].include?(j.handle.phase) }
141
+
142
+ :running
143
+ end
144
+
145
+ # @return [Array<Agentilda::Board::Row>]
146
+ def rows
147
+ now = UI.monotonic
148
+ @recent.reject! { |r| now - r[:at] > LINGER }
149
+ @running.map { |job| row_for(job) } + @recent.map { |r| r[:row] }
150
+ end
151
+
152
+ # @param job [Agentilda::Dispatcher::Job]
153
+ # @return [Agentilda::Board::Row]
154
+ def row_for(job)
155
+ task = job.task
156
+ job.frame = job.frame.to_i + 1
157
+ Board::Row.new(key: job.key,
158
+ at: Time.now,
159
+ ordinal: task.subject.feature.ordinal.to_s,
160
+ file: job.file || task.agent.ledger.first.to_s,
161
+ agent: task.agent.name,
162
+ role: task.agent.role,
163
+ round: task.round,
164
+ rounds: rounds_for(task.agent),
165
+ model: model_for(task.agent),
166
+ remaining: job.handle.remaining,
167
+ phase: job.handle.phase,
168
+ up: job.up.to_i,
169
+ down: job.down.to_i,
170
+ message: job.message,
171
+ state: :running,
172
+ pr: pr_for(task),
173
+ frame: job.frame,
174
+ elapsed: (UI.monotonic - job.started_at).round,
175
+ subagents: job.subagents.to_i,
176
+ history: job.history || [])
177
+ end
178
+
179
+ # @param task [Agentilda::Runner::Task]
180
+ # @return [Hash, nil]
181
+ def pr_for(task)
182
+ return nil unless task.agent.ledger.include?("pull-requests.md")
183
+
184
+ subject = Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return nil
185
+ pull = subject.pull_requests.find(&:open?) or return nil
186
+ { number: pull.number, url: pull.url, rejected: subject.status.key == :rejected }
187
+ end
188
+
189
+ # @param agent [Agentilda::Agent]
190
+ # @return [Integer]
191
+ def rounds_for(agent) = [agent.rounds, @rounds_cap].compact.min
192
+
193
+ # @param agent [Agentilda::Agent]
194
+ # @return [String]
195
+ def model_for(agent)
196
+ executor = @runner.executor
197
+ (executor.respond_to?(:model) && executor.model) || agent.model || "default"
198
+ end
199
+
200
+ # ---- starting ---------------------------------------------------------
201
+
202
+ # Started stages a dead run left behind get the harness's own line and a
203
+ # place in history, so eligibility treats them as an Interrupted round.
204
+ #
205
+ # @return [void]
206
+ def resume_stranded
207
+ return unless @state
208
+
209
+ @state.load
210
+ @state.stranded.each do |ordinal, stage|
211
+ subject = @runner.tree.reload.find(Ordinal.parse(ordinal)) or next
212
+ agent = @runner.agents.find(stage["agent"]) or next
213
+ write_interrupted(subject, agent, stage["round"], "harness died")
214
+ remember(ordinal, agent.name, subject.status.key, "Interrupted", stage["round"])
215
+ @state.record(ordinal, agent: agent.name, round: stage["round"], status: "Interrupted", exit: "harness died")
216
+ end
217
+ @state.begin_run!(root: @runner.root)
218
+ end
219
+
220
+ # @return [void]
221
+ def dispatch
222
+ while @running.size < @runner.jobs && (pair = next_dispatchable)
223
+ start(*pair)
224
+ end
225
+ end
226
+
227
+ # The next agent and plan to start, honouring a `next:` an agent wrote
228
+ # over definition order.
229
+ #
230
+ # @return [Array(Agentilda::Agent, Agentilda::Subject), nil]
231
+ def next_dispatchable
232
+ reconcile
233
+ @runner.in_scope.each do |subject|
234
+ # After {#reconcile} this is the folder's name. On a dry run, which
235
+ # renames nothing, it is what the name would have become.
236
+ state = Resync::Dirs.target(subject)
237
+ next if StateMachine::SETTLED.include?(state.key)
238
+ next if @finished_plans.include?(subject.feature.ordinal.to_s)
239
+
240
+ candidates = @runner.agents.for_status(state)
241
+ preferred = @preferred[subject.feature.ordinal.to_s]
242
+ candidates = candidates.sort_by { |a| a.name == preferred ? 0 : 1 } if preferred
243
+ agent = candidates.find { |a| eligible?(a, subject) }
244
+ return [agent, subject] if agent
245
+ end
246
+ nil
247
+ end
248
+
249
+ # A folder's name can lag its contents: a run killed before its rename,
250
+ # a plan.md written by hand, a resync nobody ran. So the names are
251
+ # reconciled before every dispatch, the way `resync dirs` would, and a
252
+ # ⚪️ folder that already holds a plan goes to the pair under its honest
253
+ # name rather than to leah-researcher under its stale one. Plans an
254
+ # agent is working in right now are left alone: the prompt names the
255
+ # folder by path, and moving it under a running agent would strand
256
+ # every write it makes afterwards.
257
+ #
258
+ # @return [void]
259
+ def reconcile
260
+ busy = @running.map { |j| j.task.subject.feature.ordinal.to_s }
261
+ Resync::Dirs.new(tree: @runner.tree.reload, except: busy).call(commit: !@runner.dry_run?)
262
+ end
263
+
264
+ # @param agent [Agentilda::Agent]
265
+ # @param subject [Agentilda::Subject]
266
+ # @return [Boolean]
267
+ def eligible?(agent, subject)
268
+ ordinal = subject.feature.ordinal.to_s
269
+ return false if @running.any? { |j| j.task.agent.name == agent.name && j.task.subject.feature.ordinal.to_s == ordinal }
270
+
271
+ past = @history[[ordinal, agent.name]].select { |h| h[:state] == subject.status.key }
272
+ return true if past.empty?
273
+ return false if %w[Completed Blocked].include?(past.last[:status])
274
+
275
+ past.size < rounds_for(agent)
276
+ end
277
+
278
+ # @param agent [Agentilda::Agent]
279
+ # @param subject [Agentilda::Subject]
280
+ # @return [void]
281
+ def start(agent, subject)
282
+ ordinal = subject.feature.ordinal.to_s
283
+ round = @history[[ordinal, agent.name]].count { |h| h[:state] == subject.status.key } + 1
284
+ from = subject.status.key
285
+ subject = rename_on_start(subject, agent) unless @runner.dry_run?
286
+ task = @runner.prepare(agent, subject, round)
287
+ handle = Executor::Handle.new
288
+ job = Job.new(key: "#{ordinal}/#{agent.name}",
289
+ task:,
290
+ handle:,
291
+ started_at: UI.monotonic,
292
+ from:,
293
+ state: subject.status.key,
294
+ up: 0,
295
+ down: 0,
296
+ subagents: 0,
297
+ frame: 0)
298
+ successor = @runner.agents.for_status(STATUS_BY_KEY.fetch(agent.advances_to)).first&.name if agent.advances_to && STATUS_BY_KEY.key?(agent.advances_to)
299
+ # Each member of a pair is told who the others are, so the executor
300
+ # can name them beside the plan's mailbox.
301
+ partners = @runner.agents.team_for(Resync::Dirs.target(subject)) - [agent]
302
+ remember(ordinal, agent.name, job.state, "Started", round)
303
+ @state&.record(ordinal,
304
+ agent: agent.name,
305
+ round:,
306
+ status: "Started",
307
+ state: from.to_s,
308
+ model: model_for(agent),
309
+ file: agent.ledger.first,
310
+ started_at: Time.now.iso8601)
311
+ UI.log("started", **task.log_fields)
312
+ job.thread = Thread.new do
313
+ Thread.current.report_on_exception = false
314
+ job.result = begin
315
+ @runner.executor.call(agent, subject, root: task.root, round:, successor:, handle:, partners:) { |progress|
316
+ job.up = progress.up
317
+ job.down = progress.down
318
+ job.subagents = progress.subagents
319
+ job.message = progress.message || progress.activity
320
+ job.history = Board::Row.remember(job.history || [], job.message)
321
+ }
322
+ rescue StandardError => e
323
+ e
324
+ end
325
+ end
326
+ @running << job
327
+ end
328
+
329
+ # `starts_as`: the folder moves the moment the agent is dispatched, where
330
+ # the topology allows, so luke's start is what makes a plan 🟡.
331
+ #
332
+ # @return [Agentilda::Subject] fresh, under whichever name it now has
333
+ def rename_on_start(subject, agent)
334
+ target = agent.starts_as
335
+ return subject unless target && subject.machine.may?(target)
336
+
337
+ subject.machine.promote!(target)
338
+ Tree.new(dir: @runner.tree.dir).find(subject.feature.ordinal) || subject
339
+ end
340
+
341
+ # ---- polling ----------------------------------------------------------
342
+
343
+ # @return [void]
344
+ def poll
345
+ @running.each do |job|
346
+ reading = read_ledger(job.task)
347
+ entry = Ledger.last_for(reading, job.task.agent.name)
348
+ job.file = entry&.file || job.file
349
+ job.status = entry&.status
350
+ @state&.record(job.task.subject.feature.ordinal.to_s,
351
+ agent: job.task.agent.name,
352
+ round: job.task.round,
353
+ status: entry&.status || "Started",
354
+ file: job.file,
355
+ up: job.up,
356
+ down: job.down,
357
+ pid: job.handle.pid)
358
+ end
359
+ end
360
+
361
+ # @param task [Agentilda::Runner::Task]
362
+ # @return [Agentilda::Ledger::Reading]
363
+ def read_ledger(task)
364
+ subject = Tree.new(dir: @runner.tree.dir).find(task.subject.feature.ordinal) or return Ledger::Reading.empty
365
+ Ledger.read(subject.feature.path, task.agent.ledger)
366
+ end
367
+
368
+ # ---- settling ---------------------------------------------------------
369
+
370
+ # @return [void]
371
+ def reap
372
+ @running.select(&:finished?).each do |job|
373
+ @running.delete(job)
374
+ attempt = settle(job)
375
+ @attempts << attempt
376
+ row = row_for(job)
377
+ @recent << { at: UI.monotonic, row: row.with(state: attempt.ok ? :done : :failed,
378
+ message: attempt.note,
379
+ history: Board::Row.remember(row.history, attempt.note),
380
+ remaining: nil,
381
+ phase: nil,
382
+ bold: !attempt.ok) }
383
+ UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}", **job.task.log_fields)
384
+ end
385
+ end
386
+
387
+ # @param job [Agentilda::Dispatcher::Job]
388
+ # @return [Agentilda::Runner::Attempt]
389
+ def settle(job)
390
+ task = job.task
391
+ agent = task.agent
392
+ ordinal = task.subject.feature.ordinal.to_s
393
+ result = job.result
394
+ base = attempt_for(job)
395
+ return crashed(job, base, result) if result.is_a?(Exception)
396
+ return base.with(note: result.note) if @runner.dry_run?
397
+
398
+ reading = read_ledger(task)
399
+ entry = Ledger.last_for(reading, agent.name)
400
+ entry = nil if entry && entry.round != task.round
401
+ problems = reading.problems.map { |p| "#{p.file}:#{p.line} unreadable ledger line" }
402
+
403
+ attempt = if result.killed || (result.ok && (entry.nil? || entry.status == "Started"))
404
+ verify_and_sign(job, base, reason_for(result))
405
+ elsif !result.ok
406
+ remember(ordinal, agent.name, job.state, "Interrupted", task.round)
407
+ base.with(ok: false, note: result.note)
408
+ elsif entry.completed?
409
+ complete(job, base, entry, reading)
410
+ elsif entry.blocked?
411
+ park(job, base, entry)
412
+ else
413
+ remember(ordinal, agent.name, job.state, entry.status, task.round)
414
+ base.with(status: entry.status, note: retry_note(agent, entry))
415
+ end
416
+ attempt = attempt.with(note: "#{attempt.note}; #{problems.join(", ")}") unless problems.empty?
417
+ attempt = attempt.with(to: fresh(task)&.status&.key || attempt.to)
418
+ @state&.record(ordinal,
419
+ agent: agent.name,
420
+ round: task.round,
421
+ status: attempt.status || "Completed",
422
+ exit: attempt.ok ? "ok" : attempt.note,
423
+ ended_at: Time.now.iso8601,
424
+ up: attempt.up,
425
+ down: attempt.down)
426
+ attempt
427
+ end
428
+
429
+ # @param result [Agentilda::Executor::Result]
430
+ # @return [String]
431
+ def reason_for(result)
432
+ case result.killed
433
+ when :key then "killed by harness after #{KILL_GRACE}s grace"
434
+ when :timeout then "timed out, killed #{Clock::GRACE}s after STOP"
435
+ when :quit then "interrupted, killed #{Control::GRACE}s after #{Control.interrupted? ? "ctrl-c" : "q"}"
436
+ else Control.interrupted? ? "interrupted by ctrl-c" : "no closing ledger line"
437
+ end
438
+ end
439
+
440
+ # @return [String]
441
+ def retry_note(agent, entry)
442
+ left = rounds_for(agent) - entry.round
443
+ left.positive? ? "#{entry.status}; #{left} round#{"s" unless left == 1} left" : "#{entry.status}; no rounds left"
444
+ end
445
+
446
+ # The agent stopped without saying it finished. If the state it was
447
+ # advancing to is justified by what is on disk, the work happened and
448
+ # the harness signs for it, in bold, saying why it had to.
449
+ #
450
+ # @return [Agentilda::Runner::Attempt]
451
+ def verify_and_sign(job, base, reason)
452
+ task = job.task
453
+ agent = task.agent
454
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
455
+ write_interrupted(subject, agent, task.round, reason)
456
+ target = agent.advances_to
457
+ if target && subject.machine.may?(target)
458
+ successor = @runner.agents.for_status(STATUS_BY_KEY.fetch(target)).first&.name
459
+ lines = [Ledger.render(Ledger::Entry.new(at: Time.now,
460
+ agent: agent.name,
461
+ status: "Completed",
462
+ round: task.round,
463
+ note: "signed by harness: work verified on disk",
464
+ file: "",
465
+ line: 0))]
466
+ lines << Ledger.render_handoff(Ledger::Handoff.new(at: Time.now, next: successor, file: "", line: 0)) if successor
467
+ Ledger.append(File.join(subject.feature.path, agent.ledger.first), *lines)
468
+ entry = Ledger.last_for(read_ledger(task), agent.name)
469
+ complete(job, base.with(note: "#{reason}; signed by harness"), entry, read_ledger(task))
470
+ else
471
+ remember(subject.feature.ordinal.to_s, agent.name, job.state, "Interrupted", task.round)
472
+ base.with(ok: false, status: "Interrupted", note: "#{reason}; #{retry_note(agent, Ledger::Entry.new(at: Time.now, agent: agent.name, status: "Interrupted", round: task.round, file: "", line: 0))}")
473
+ end
474
+ end
475
+
476
+ # @return [void]
477
+ def write_interrupted(subject, agent, round, reason)
478
+ line = Ledger.render(Ledger::Entry.new(at: Time.now,
479
+ agent: agent.name,
480
+ status: "Interrupted",
481
+ round:,
482
+ note: reason,
483
+ file: "",
484
+ line: 0,
485
+ bold: true))
486
+ Ledger.append(File.join(subject.feature.path, agent.ledger.first), line)
487
+ end
488
+
489
+ # A Completed entry: move the folder, publish if that made it reviewable,
490
+ # and honour the `next:` line.
491
+ #
492
+ # @return [Agentilda::Runner::Attempt]
493
+ def complete(job, base, entry, reading)
494
+ task = job.task
495
+ agent = task.agent
496
+ ordinal = task.subject.feature.ordinal.to_s
497
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
498
+ remember(ordinal, agent.name, job.state, "Completed", task.round)
499
+ verdict = verdict_of(entry)
500
+ partner_running = @running.any? { |j| j.task.subject.feature.ordinal.to_s == ordinal }
501
+ target = target_for(agent, verdict, partner_running)
502
+
503
+ if verdict == :approved
504
+ @finished_plans << ordinal
505
+ return base.with(status: "Completed", note: "#{base.note}; approved, awaiting merge")
506
+ end
507
+
508
+ attempt = base.with(status: "Completed")
509
+ if target
510
+ return attempt.with(ok: false, note: "ledger says Completed but #{STATUS_BY_KEY.fetch(target)} is not justified: #{STATUS_BY_KEY.fetch(target).violation(subject)}") unless subject.machine.may?(target)
511
+
512
+ subject.machine.promote!(target)
513
+ attempt = attempt.with(to: target, note: "#{base.note}#{" (#{entry.note})" if entry.note}")
514
+ attempt = publish_if_reviewable(task, target, attempt)
515
+
516
+ end
517
+
518
+ handoff = Ledger.handoff_after(reading, entry)
519
+ attempt = honour(handoff, subject, attempt) if handoff
520
+ attempt
521
+ end
522
+
523
+ # @param entry [Agentilda::Ledger::Entry]
524
+ # @return [Symbol, nil] :rejected, :approved, :slop, :product or nil
525
+ def verdict_of(entry)
526
+ note = entry.note.to_s.downcase
527
+ return :rejected if note.include?("reject")
528
+ return :approved if note.include?("approv")
529
+ return :slop if note.include?("slop") || note.include?("rewrite")
530
+ return :product if note.include?("product")
531
+
532
+ nil
533
+ end
534
+
535
+ # @return [Symbol, nil] where the folder goes
536
+ def target_for(agent, verdict, partner_running)
537
+ case verdict
538
+ when :rejected then :rejected
539
+ when :slop then :shit
540
+ else partner_running ? agent.holds_at : agent.advances_to
541
+ end
542
+ end
543
+
544
+ # @return [Agentilda::Runner::Attempt]
545
+ def publish_if_reviewable(task, target, attempt)
546
+ return attempt unless target == :ready_for_review
547
+
548
+ subject = fresh(task) or return attempt
549
+ publication = @runner.publish(task, subject)
550
+ if publication&.published?
551
+ attempt.with(note: "#{attempt.note}; opened #{publication.url}")
552
+ elsif publication&.refusal
553
+ attempt.with(note: "#{attempt.note}; publish refused: #{publication.refusal}")
554
+ else
555
+ attempt
556
+ end
557
+ end
558
+
559
+ # A `next:` that names someone who handles the plan's new state is
560
+ # honoured by preferring them; one that does not is reported, and the
561
+ # state's own agents take it, because a wrong name must not stall a plan.
562
+ #
563
+ # @return [Agentilda::Runner::Attempt]
564
+ def honour(handoff, subject, attempt)
565
+ current = fresh_by(subject.feature.ordinal) || subject
566
+ named = @runner.agents.find(handoff.next)
567
+ if named&.handles?(current.status)
568
+ @preferred[current.feature.ordinal.to_s] = named.name
569
+ attempt
570
+ else
571
+ who = named ? "does not handle #{current.status}" : "is not an agent"
572
+ attempt.with(note: "#{attempt.note}; next: names #{handoff.next}, who #{who}")
573
+ end
574
+ end
575
+
576
+ # @return [Agentilda::Runner::Attempt]
577
+ def park(job, base, entry)
578
+ task = job.task
579
+ subject = fresh(task) or return base.with(ok: false, note: "plan folder vanished")
580
+ remember(subject.feature.ordinal.to_s, task.agent.name, job.state, "Blocked", task.round)
581
+ target = verdict_of(entry) == :product ? :product_blocked : :blocked
582
+ if subject.machine.may?(target)
583
+ subject.machine.promote!(target)
584
+ base.with(to: target, status: "Blocked", note: "blocked; see blocked.md")
585
+ else
586
+ base.with(ok: false, status: "Blocked", note: "ledger says Blocked but #{STATUS_BY_KEY.fetch(target).violation(subject)}")
587
+ end
588
+ end
589
+
590
+ # @return [Agentilda::Runner::Attempt]
591
+ def crashed(job, base, error)
592
+ remember(base.ordinal, base.agent, job.state, "Interrupted", base.round)
593
+ base.with(ok: false, note: error.message.lines.first.to_s.strip)
594
+ end
595
+
596
+ # @param job [Agentilda::Dispatcher::Job]
597
+ # @return [Agentilda::Runner::Attempt]
598
+ def attempt_for(job)
599
+ task = job.task
600
+ result = job.result
601
+ spend = ->(field) { result.respond_to?(field) ? result.public_send(field) : 0 }
602
+ Runner::Attempt.new(ordinal: task.subject.feature.ordinal.to_s,
603
+ agent: task.agent.name,
604
+ from: job.from,
605
+ to: job.from,
606
+ ok: result.respond_to?(:ok) ? !!result.ok : false,
607
+ note: result.respond_to?(:note) ? result.note.to_s : "",
608
+ up: spend.call(:up),
609
+ down: spend.call(:down),
610
+ subagents: spend.call(:subagents),
611
+ delegated: spend.call(:delegated),
612
+ seconds: spend.call(:seconds),
613
+ round: task.round,
614
+ file: job.file || task.agent.ledger.first.to_s,
615
+ model: model_for(task.agent),
616
+ status: job.status)
617
+ end
618
+
619
+ # @return [Agentilda::Subject, nil] the plan read fresh from the main tree
620
+ def fresh(task) = fresh_by(task.subject.feature.ordinal)
621
+
622
+ # @return [Agentilda::Subject, nil]
623
+ def fresh_by(ordinal) = Tree.new(dir: @runner.tree.dir).find(ordinal)
624
+
625
+ # One entry per round: the Started written at dispatch is overwritten by
626
+ # the outcome rather than joined by it. Two entries a round would spend
627
+ # an agent's rounds at double speed and number them 1, 3, 5.
628
+ #
629
+ # @return [void]
630
+ def remember(ordinal, agent, state, status, round)
631
+ @mutex.synchronize do
632
+ entries = @history[[ordinal.to_s, agent]]
633
+ entry = entries.find { |h| h[:state] == state && h[:round] == round }
634
+ entry ? entry[:status] = status : entries << { state:, status:, round: }
635
+ end
636
+ end
637
+
638
+ # @return [void]
639
+ def persist
640
+ return unless @state
641
+
642
+ @state.heartbeat!
643
+ @state.save
644
+ end
645
+ end
646
+ end