harnex 0.12.0 → 0.29.0

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. data/lib/harnex/codex/app_server/client.rb +0 -347
@@ -0,0 +1,513 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "net/http"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "time"
7
+
8
+ module Harnex
9
+ # #87: a lane is the agent-facing unit of delegated work. Its name is also
10
+ # its session id; a delegate lane may own a git worktree on branch
11
+ # harnex/<name>. A record under STATE_DIR/lanes lets `result`, `reply`,
12
+ # `land` and `discard` find a lane by name from anywhere in its repo.
13
+ #
14
+ # Record states: running (a turn is out and nobody has collected it),
15
+ # done, failed, landed, discarded.
16
+ module Lanes
17
+ NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,47}\z/
18
+ BRANCH_PREFIX = "harnex/".freeze
19
+ WORKTREES_DIR = File.join(".harnex", "worktrees").freeze
20
+ CLOSED_STATES = %w[landed discarded].freeze
21
+ DEFAULT_TIMEOUT = 540
22
+
23
+ module_function
24
+
25
+ def dir
26
+ File.join(STATE_DIR, "lanes")
27
+ end
28
+
29
+ def path(repo_root, name)
30
+ File.join(dir, "#{Harnex.repo_key(repo_root)}--#{name}.json")
31
+ end
32
+
33
+ def validate_name!(name)
34
+ return name if name.to_s.match?(NAME)
35
+
36
+ raise OptionParser::InvalidArgument, "lane name #{name.inspect}: use 1-48 letters, digits, '.', '_' or '-'"
37
+ end
38
+
39
+ def save(lane)
40
+ lane["updated_at"] = now
41
+ Harnex.atomic_write_json(path(lane["repo_root"], lane["name"]), lane)
42
+ lane
43
+ end
44
+
45
+ def load(repo_root, name)
46
+ JSON.parse(File.read(path(repo_root, name)))
47
+ rescue Errno::ENOENT, JSON::ParserError
48
+ nil
49
+ end
50
+
51
+ def all
52
+ Dir.glob(File.join(dir, "*.json")).filter_map do |file|
53
+ JSON.parse(File.read(file))
54
+ rescue JSON::ParserError, Errno::ENOENT
55
+ nil
56
+ end
57
+ end
58
+
59
+ def open?(lane)
60
+ lane && !CLOSED_STATES.include?(lane["state"])
61
+ end
62
+
63
+ # A name resolves to this repo's lane first, then to the only lane
64
+ # anywhere with that name.
65
+ def resolve(name, from: Dir.pwd)
66
+ root = main_root(from)
67
+ lane = root && load(root, name)
68
+ return lane if lane
69
+
70
+ matches = all.select { |candidate| candidate["name"] == name }
71
+ return matches.first if matches.one?
72
+ raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
73
+
74
+ raise OptionParser::InvalidArgument,
75
+ "several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
76
+ end
77
+
78
+ # Lanes this caller started in this repo whose current turn nobody has
79
+ # collected yet.
80
+ def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
81
+ root = main_root(from) || File.expand_path(from)
82
+ all.select { |lane| lane["repo_root"] == root && lane["owner"] == owner && lane["state"] == "running" }
83
+ .sort_by { |lane| lane["launched_at"].to_s }
84
+ end
85
+
86
+ FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
87
+ run use add make have has can could would should will do does just then also your you
88
+ our my me i we gpt worker workers task repo repository git command file files code].freeze
89
+
90
+ def generate_name(root, task, prefix: "pi")
91
+ words = task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) }.first(4)
92
+ base = ([prefix] + words).join("-")[0, 40].sub(/-+\z/, "")
93
+ name = base
94
+ index = 2
95
+ while open?(load(root, name)) || Harnex.read_registry(root, name)
96
+ name = "#{base}-#{index}"
97
+ index += 1
98
+ end
99
+ name
100
+ end
101
+
102
+ def live?(lane)
103
+ registry = Harnex.read_registry(lane["run_root"], lane["name"])
104
+ !registry.nil? && Harnex.alive_pid?(registry["pid"])
105
+ end
106
+
107
+ # The live worker's activity snapshot (see ActivityTracker), or nil.
108
+ def activity(lane)
109
+ registry = Harnex.read_registry(lane["run_root"], lane["name"])
110
+ return nil unless registry && Harnex.alive_pid?(registry["pid"])
111
+
112
+ uri = URI("http://#{registry['host']}:#{registry['port']}/status")
113
+ request = Net::HTTP::Get.new(uri)
114
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
115
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 0.5, read_timeout: 0.5) { |http| http.request(request) }
116
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body)["activity"] : nil
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ # "wc: working 2m10s · 7 tool calls · now bash (12s)"
122
+ def progress_line(lane)
123
+ line = "#{lane['name']}: working #{duration(lane['launched_at'])}"
124
+ act = activity(lane)
125
+ return line unless act.is_a?(Hash)
126
+
127
+ line += " · #{act['tool_calls']} tool call#{act['tool_calls'] == 1 ? '' : 's'}" if act["tool_calls"].to_i.positive?
128
+ if act["current_tool"]
129
+ line + " · now #{act['current_tool']} #{act['current_tool_s'].to_i}s"
130
+ elsif act["model_active"]
131
+ line + " · thinking"
132
+ else
133
+ line
134
+ end
135
+ end
136
+
137
+ # ---- git ---------------------------------------------------------------
138
+
139
+ def git(dir, *args)
140
+ out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
141
+ [out.strip, err.strip, status.success?]
142
+ end
143
+
144
+ # The main checkout of the repo that contains `path` (also from inside one
145
+ # of its worktrees), or nil outside git.
146
+ def main_root(path)
147
+ common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
148
+ return nil unless ok
149
+
150
+ return File.dirname(common) if File.basename(common) == ".git"
151
+
152
+ top, _err, ok = git(path, "rev-parse", "--show-toplevel")
153
+ ok ? top : nil
154
+ end
155
+
156
+ def head_sha(dir)
157
+ sha, _err, ok = git(dir, "rev-parse", "HEAD")
158
+ ok ? sha : nil
159
+ end
160
+
161
+ def dirty_paths(dir)
162
+ out, _err, ok = git(dir, "status", "--porcelain")
163
+ ok ? out.lines.map { |line| line[3..].to_s.strip } : []
164
+ end
165
+
166
+ def create_worktree(root, name)
167
+ Harnex.ensure_git_excluded(root)
168
+ worktree = File.join(root, WORKTREES_DIR, name)
169
+ branch = BRANCH_PREFIX + name
170
+ _out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
171
+ if exists || File.exist?(worktree)
172
+ raise OptionParser::InvalidArgument,
173
+ "branch #{branch} or #{relative(root, worktree)} already exists; `harnex discard #{name}` or pick another name"
174
+ end
175
+
176
+ base = head_sha(root)
177
+ raise OptionParser::InvalidArgument, "#{root} has no commits yet; commit once before using --worktree" unless base
178
+
179
+ _out, err, ok = git(root, "worktree", "add", "-q", "-b", branch, worktree, base)
180
+ raise OptionParser::InvalidArgument, "git worktree add failed: #{err}" unless ok
181
+
182
+ [worktree, branch, base]
183
+ end
184
+
185
+ def remove_worktree(lane, force: false)
186
+ worktree = lane["worktree"]
187
+ if worktree && File.directory?(worktree)
188
+ args = ["worktree", "remove"]
189
+ args << "--force" if force
190
+ _out, err, ok = git(lane["repo_root"], *args, worktree)
191
+ return [false, err] unless ok
192
+ end
193
+ git(lane["repo_root"], "worktree", "prune")
194
+ branch = lane["branch"]
195
+ git(lane["repo_root"], "branch", force ? "-D" : "-d", branch) if branch
196
+ [true, nil]
197
+ end
198
+
199
+ # Lanes whose branch the caller merged by hand: once harnex/NAME is in
200
+ # HEAD and the worktree is clean, the lane is landed; tidy it (#87).
201
+ def sweep_merged(root)
202
+ prune_closed
203
+ return [] unless root
204
+
205
+ tidied = all.select { |lane| lane["repo_root"] == root && sweepable?(lane) }
206
+ tidied.each do |lane|
207
+ removed, = remove_worktree(lane)
208
+ next unless removed
209
+
210
+ lane["state"] = "landed"
211
+ lane["landed_at"] = now
212
+ lane["landed_by"] = "merged outside harnex land"
213
+ save(lane)
214
+ end
215
+ tidied.map { |lane| lane["name"] }
216
+ end
217
+
218
+ CLOSED_RETENTION_DAYS = 30
219
+
220
+ # Landed and discarded lanes leave only their record and brief; drop
221
+ # both once they are a month old. Open lanes are never pruned.
222
+ def prune_closed(now: Time.now)
223
+ cutoff = now - CLOSED_RETENTION_DAYS * 86_400
224
+ all.each do |lane|
225
+ next unless CLOSED_STATES.include?(lane["state"])
226
+
227
+ closed_at = Time.iso8601((lane["landed_at"] || lane["updated_at"]).to_s)
228
+ next if closed_at > cutoff
229
+
230
+ FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
231
+ FileUtils.rm_f(File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md"))
232
+ rescue ArgumentError
233
+ next
234
+ end
235
+ end
236
+
237
+ def sweepable?(lane)
238
+ return false unless %w[done failed].include?(lane["state"]) && lane["branch"] && lane["base_sha"]
239
+
240
+ tip = head_sha(lane["worktree"] || lane["repo_root"])
241
+ branch_tip, _err, ok = git(lane["repo_root"], "rev-parse", "--verify", "--quiet", lane["branch"])
242
+ return false unless ok && branch_tip != lane["base_sha"]
243
+ return false if lane["worktree"] && File.directory?(lane["worktree"]) && (dirty_paths(lane["worktree"]).any? || tip != branch_tip)
244
+ return false if live?(lane)
245
+
246
+ _out, _err, merged = git(lane["repo_root"], "merge-base", "--is-ancestor", lane["branch"], "HEAD")
247
+ merged
248
+ end
249
+
250
+ def relative(root, path)
251
+ path.to_s.start_with?("#{root}/") ? path.to_s.delete_prefix("#{root}/") : path.to_s
252
+ end
253
+
254
+ # ---- briefs ------------------------------------------------------------
255
+
256
+ def write_brief(lane, task)
257
+ file = File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md")
258
+ footer = if lane["branch"]
259
+ "You are working in a git worktree on branch #{lane['branch']}; the main checkout is not " \
260
+ "affected. When you are done, commit your changes on this branch, then reply with a short " \
261
+ "summary: what you changed, how you verified it, and anything left undone."
262
+ else
263
+ "When you are done, reply with a short summary: what you changed, how you verified it, and " \
264
+ "anything left undone."
265
+ end
266
+ FileUtils.mkdir_p(File.dirname(file))
267
+ File.write(file, "#{task.to_s.rstrip}\n\n---\nharnex: #{footer}\n")
268
+ file
269
+ end
270
+
271
+ # ---- launching and waiting ---------------------------------------------
272
+
273
+ # Starts (or restarts) the lane's Pi worker. With wait, blocks for the
274
+ # turn's result and returns [turn, exit_code] (exit codes of wait --until
275
+ # done); without, returns [launch_payload, 0]. A launch that fails before
276
+ # any result (bad flag, busy id, no tmux) raises with harnex's message.
277
+ def launch(lane, harnex_command, context_args:, child_args: [], wait: true, timeout: DEFAULT_TIMEOUT, detach: false)
278
+ lane["launched_at"] = now
279
+ lane["state"] = "running"
280
+ lane.delete("after_seq")
281
+ lane.delete("last_error")
282
+ save(lane)
283
+
284
+ argv = harnex_command + ["run", "pi", "--id", lane["name"]]
285
+ argv += detach ? ["--detach"] : ["--tmux", lane["name"]]
286
+ argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"]]
287
+ argv += context_args
288
+ argv += ["--model", lane["requested_model"]] if lane["requested_model"]
289
+ argv += ["--effort", lane["effort"]] if lane["effort"]
290
+ argv += ["--wait-done"] + (timeout ? ["--wait-timeout", timeout.to_s] : []) if wait
291
+ argv += ["--"] + child_args unless child_args.empty?
292
+ stdout, stderr, status = Open3.capture3(*argv)
293
+ payload = last_json(stdout)
294
+ if payload.empty?
295
+ lane["state"] = "failed"
296
+ lane["last_error"] = stderr.strip.lines.last.to_s.strip
297
+ save(lane)
298
+ raise LaunchError, "harnex could not start lane #{lane['name']}: #{stderr.strip.lines.last(3).join.strip}"
299
+ end
300
+ return [payload, 0] unless wait
301
+
302
+ [payload["turn_result"] || payload, status.exitstatus]
303
+ end
304
+
305
+ def wait(lane, harnex_command, timeout: DEFAULT_TIMEOUT)
306
+ argv = harnex_command + ["wait", "--id", lane["name"], "--repo", lane["run_root"], "--until", "done",
307
+ "--since", lane["launched_at"]]
308
+ argv += ["--after-seq", lane["after_seq"].to_s] if lane["after_seq"]
309
+ argv += ["--timeout", timeout.to_s] if timeout
310
+ stdout, _stderr, status = Open3.capture3(*argv)
311
+ [last_json(stdout), status.exitstatus]
312
+ end
313
+
314
+ def last_json(stdout)
315
+ JSON.parse(stdout.to_s.lines.reverse.find { |line| line.start_with?("{") }.to_s)
316
+ rescue JSON::ParserError
317
+ {}
318
+ end
319
+
320
+ class LaunchError < StandardError; end
321
+
322
+ # Shared by the lane commands: `--timeout none` waits without a cap.
323
+ def parse_timeout(value, option_name: "--timeout")
324
+ return nil if %w[none 0].include?(value.to_s)
325
+
326
+ Harnex.parse_duration_seconds(value, option_name: option_name)
327
+ end
328
+
329
+ # ---- outcomes ----------------------------------------------------------
330
+
331
+ # The usage event of the launch that started at `since` (written right
332
+ # after the worker exits, so give it a moment).
333
+ def usage_since(lane, since, wait_seconds: 3.0)
334
+ return nil unless File.exist?(Harnex.events_log_path(lane["run_root"], lane["name"]))
335
+
336
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait_seconds
337
+ loop do
338
+ found = events(lane).reverse.find { |e| e["type"] == "usage" && after?(e["ts"], since) }
339
+ return found if found
340
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
341
+
342
+ sleep 0.2
343
+ end
344
+ end
345
+
346
+ def events(lane)
347
+ File.readlines(Harnex.events_log_path(lane["run_root"], lane["name"])).filter_map do |line|
348
+ JSON.parse(line)
349
+ rescue JSON::ParserError
350
+ nil
351
+ end
352
+ rescue Errno::ENOENT
353
+ []
354
+ end
355
+
356
+ def after?(ts, since)
357
+ return true if since.nil?
358
+
359
+ Time.iso8601(ts.to_s) >= Time.iso8601(since.to_s)
360
+ rescue ArgumentError
361
+ false
362
+ end
363
+
364
+ def now
365
+ Time.now.utc.iso8601(3)
366
+ end
367
+
368
+ # Records the outcome of the pending turn. `turn` is the result object of
369
+ # `wait --until done` (or run/send --wait-done's turn_result); exit_code
370
+ # follows wait: 0 done, 124 still working, else failed.
371
+ def settle(lane, turn, exit_code)
372
+ return lane if exit_code == Waiter::DONE_EXIT_TIMEOUT
373
+
374
+ lane["state"] = exit_code.zero? ? "done" : "failed"
375
+ lane["outcome"] = turn["outcome_class"] || turn["status"]
376
+ lane["last_error"] = turn["last_error"] if turn["last_error"]
377
+ lane["model"] = turn["model"] if turn["model"]
378
+ usage = usage_since(lane, lane["launched_at"])
379
+ if usage
380
+ lane["agent_session_id"] = usage["agent_session_id"] if usage["agent_session_id"]
381
+ lane["cost_usd"] = (lane["cost_usd"].to_f + usage["cost_usd"].to_f).round(4) if usage["cost_usd"]
382
+ end
383
+ lane["turns"] = lane["turns"].to_i + 1
384
+ lane["settled_at"] = now
385
+ # Kept so a later `harnex result NAME` can show the result again.
386
+ lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
387
+ "outcome_class", "status", "last_error", "end_sha")
388
+ save(lane)
389
+ end
390
+
391
+ def final_message(turn)
392
+ path = turn["final_message_path"].to_s
393
+ return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
394
+
395
+ turn["final_message"].to_s
396
+ rescue StandardError
397
+ turn["final_message"].to_s
398
+ end
399
+
400
+ # ---- rendering ---------------------------------------------------------
401
+
402
+ def render(lane, turn, exit_code)
403
+ lines = []
404
+ message = final_message(turn)
405
+ lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
406
+ lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
407
+ lines.concat(details(lane, exit_code).map { |line| " #{line}" })
408
+ lines.join("\n")
409
+ end
410
+
411
+ def headline(lane, turn, exit_code)
412
+ model = turn["model"] || lane["model"] || lane["requested_model"]
413
+ parts = []
414
+ case exit_code
415
+ when 0
416
+ parts << "done in #{duration(lane['launched_at'])}"
417
+ when Waiter::DONE_EXIT_TIMEOUT
418
+ parts << "still working (#{duration(lane['launched_at'])} so far)"
419
+ else
420
+ reason = turn["last_error"] || turn["outcome_class"] || turn["status"] || "no result"
421
+ parts << "FAILED after #{duration(lane['launched_at'])} (#{reason.to_s.lines.first.to_s.strip[0, 160]})"
422
+ end
423
+ parts << model if model
424
+ parts << format("$%.2f", lane["cost_usd"]) if lane["cost_usd"] && exit_code != Waiter::DONE_EXIT_TIMEOUT
425
+ parts << change_summary(lane) if exit_code != Waiter::DONE_EXIT_TIMEOUT && lane["kind"] != "ask"
426
+ parts.compact.join(" · ")
427
+ end
428
+
429
+ def change_summary(lane)
430
+ dir = lane["worktree"] || lane["cwd"]
431
+ return nil unless lane["base_sha"] && File.directory?(dir.to_s)
432
+
433
+ count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
434
+ return nil unless ok
435
+
436
+ stat, = git(dir, "diff", "--shortstat", lane["base_sha"], "HEAD")
437
+ commits = "#{count} commit#{count == '1' ? '' : 's'}"
438
+ stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
439
+ end
440
+
441
+ def compact_stat(stat)
442
+ files = stat[/(\d+) files? changed/, 1]
443
+ added = stat[/(\d+) insertions?/, 1]
444
+ removed = stat[/(\d+) deletions?/, 1]
445
+ "#{files} file#{files == '1' ? '' : 's'} (+#{added || 0} -#{removed || 0})"
446
+ end
447
+
448
+ def details(lane, exit_code)
449
+ name = lane["name"]
450
+ return ["keep waiting: harnex result #{name}"] if exit_code == Waiter::DONE_EXIT_TIMEOUT
451
+ return ["more: harnex reply #{name} \"...\""] if lane["kind"] == "ask"
452
+
453
+ out = []
454
+ dirty = lane["worktree"] ? dirty_paths(lane["worktree"]) : []
455
+ if lane["worktree"]
456
+ out << "worktree #{relative(lane['repo_root'], lane['worktree'])} (branch #{lane['branch']})" \
457
+ "#{dirty.empty? ? '' : ", #{dirty.length} uncommitted file#{dirty.length == 1 ? '' : 's'}"}"
458
+ end
459
+ if exit_code.zero?
460
+ if lane["branch"] && commit_count(lane).zero? && dirty.empty?
461
+ out << "next: no commits to merge; `harnex land #{name}` removes the worktree; " \
462
+ "more work: `harnex reply #{name} \"...\"`"
463
+ elsif lane["branch"]
464
+ out << "next: review `git diff HEAD...#{lane['branch']}`, then `harnex land #{name}` (merges and removes " \
465
+ "the worktree and branch; use it instead of git merge); more work: `harnex reply #{name} \"...\"`; " \
466
+ "drop it: `harnex discard #{name}`"
467
+ else
468
+ out << "worked in place in #{lane['cwd']}; more work: `harnex reply #{name} \"...\"`"
469
+ end
470
+ else
471
+ retry_model = model_problem?(lane) ? other_model(lane) : nil
472
+ out << "next: `harnex reply #{name} \"Continue the same task from where you stopped\"" \
473
+ "#{retry_model ? " --model #{retry_model}" : ''}`" \
474
+ "#{retry_model ? ' (keeps the task; another model)' : ''}" \
475
+ "#{lane['branch'] ? " or `harnex discard #{name}`" : ''}"
476
+ end
477
+ out
478
+ end
479
+
480
+ def commit_count(lane)
481
+ count, _err, ok = git(lane["worktree"] || lane["cwd"], "rev-list", "--count", "#{lane['base_sha']}..HEAD")
482
+ ok ? count.to_i : 0
483
+ end
484
+
485
+ MODEL_PROBLEM = /model_not_found|model not found|rate.?limit|capacity|overloaded|quota|429|\b404\b|unknown model|provider/i
486
+
487
+ def model_problem?(lane)
488
+ lane["outcome"].to_s == "provider_capacity" || lane["last_error"].to_s.match?(MODEL_PROBLEM)
489
+ end
490
+
491
+ # Pi's default model, unless that is the one that just failed.
492
+ def other_model(lane)
493
+ settings = JSON.parse(File.read(File.join(ENV["PI_CODING_AGENT_DIR"] || File.expand_path("~/.pi/agent"), "settings.json")))
494
+ default = [settings["defaultProvider"], settings["defaultModel"]].compact.join("/")
495
+ failed = lane["requested_model"].to_s
496
+ return nil if default.empty? || failed.empty? || failed.end_with?(settings["defaultModel"].to_s)
497
+
498
+ default
499
+ rescue StandardError
500
+ nil
501
+ end
502
+
503
+ def duration(since)
504
+ seconds = (Time.now - Time.iso8601(since.to_s)).to_i
505
+ return "#{seconds}s" if seconds < 60
506
+ return format("%dm%02ds", seconds / 60, seconds % 60) if seconds < 3600
507
+
508
+ format("%dh%02dm", seconds / 3600, (seconds % 3600) / 60)
509
+ rescue ArgumentError
510
+ "?"
511
+ end
512
+ end
513
+ end