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