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,125 @@
1
+ module Harnex
2
+ # `harnex reply` (#87): send a lane more work. A worker still on its turn
3
+ # takes the message as a follow-up; a lane that has finished is relaunched
4
+ # on its own Pi session, so the worker keeps everything it knew.
5
+ class Reply
6
+ def self.usage
7
+ <<~TEXT
8
+ Usage: harnex reply NAME [options] [--] MESSAGE...
9
+
10
+ Send a lane a follow-up and print the result, in the same format as
11
+ `harnex delegate`. The worker keeps its context: a busy worker gets the
12
+ message during its turn; a finished lane resumes its Pi session.
13
+
14
+ Options:
15
+ --model NAME Model for this turn (default: the lane's)
16
+ --no-wait Send and return at once; collect with `harnex result NAME`
17
+ --timeout DUR Wait at most DUR (default 9m; `none` waits without a cap)
18
+ --json Print the lane record and result as JSON
19
+ -h, --help Show this help
20
+
21
+ Exit codes: 0 done, 1 failed, 2 usage error, 124 still working.
22
+ TEXT
23
+ end
24
+
25
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
26
+ @argv = argv.dup
27
+ @out = out
28
+ @err = err
29
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
30
+ @options = { model: nil, wait: true, timeout: Lanes::DEFAULT_TIMEOUT, json: false, help: false }
31
+ end
32
+
33
+ def run
34
+ name, message = parse
35
+ if @options[:help]
36
+ @out.puts self.class.usage
37
+ return 0
38
+ end
39
+ raise OptionParser::MissingArgument, "NAME" if name.nil?
40
+ raise OptionParser::MissingArgument, "MESSAGE" if message.strip.empty?
41
+
42
+ lane = Lanes.resolve(name)
43
+ unless Lanes.open?(lane)
44
+ raise OptionParser::InvalidArgument, "lane #{name} was #{lane['state']}; start a new one with `harnex delegate`"
45
+ end
46
+
47
+ lane["requested_model"] = @options[:model] if @options[:model]
48
+ turn, exit_code = Lanes.live?(lane) ? follow_up(lane, message) : relaunch(lane, message)
49
+ return sent(lane) unless @options[:wait]
50
+
51
+ lane = Lanes.settle(lane, turn, exit_code)
52
+ @out.puts(@options[:json] ? JSON.generate(lane: lane, result: turn, exit_code: exit_code) : Lanes.render(lane, turn, exit_code))
53
+ exit_code
54
+ rescue Lanes::LaunchError => e
55
+ @err.puts(e.message)
56
+ 1
57
+ end
58
+
59
+ private
60
+
61
+ def follow_up(lane, message)
62
+ argv = @harnex_command + ["send", "--id", lane["name"], "--repo", lane["run_root"], "--message", message]
63
+ argv += ["--wait-done"] + (@options[:timeout] ? ["--timeout", @options[:timeout].to_s] : []) if @options[:wait]
64
+ stdout, stderr, status = Open3.capture3(*argv)
65
+ result = Lanes.last_json(stdout)
66
+ raise Lanes::LaunchError, "harnex could not reach lane #{lane['name']}: #{stderr.strip.lines.last}" if result.empty?
67
+
68
+ # The lane's turn is out again; `result` waits for the turn this
69
+ # message started or joined.
70
+ lane["state"] = "running"
71
+ lane["after_seq"] = result["after_seq"] if result["after_seq"]
72
+ Lanes.save(lane)
73
+ [result, status.exitstatus]
74
+ end
75
+
76
+ def relaunch(lane, message)
77
+ child_args = []
78
+ child_args += ["--session", lane["agent_session_id"]] if lane["agent_session_id"]
79
+ child_args += ["--tools", Ask::READ_ONLY_TOOLS] if lane["read_only"]
80
+ unless lane["agent_session_id"]
81
+ @err.puts("harnex reply: no Pi session recorded for #{lane['name']}; the worker starts without its earlier context")
82
+ end
83
+ Lanes.launch(lane, @harnex_command, context_args: ["--context", message], child_args: child_args,
84
+ wait: @options[:wait], timeout: @options[:timeout])
85
+ end
86
+
87
+ def sent(lane)
88
+ @out.puts(@options[:json] ? JSON.generate(lane) : "── #{lane['name']}: message sent; collect: harnex result #{lane['name']}")
89
+ 0
90
+ end
91
+
92
+ def parse
93
+ words = []
94
+ index = 0
95
+ while index < @argv.length
96
+ arg = @argv[index]
97
+ case arg
98
+ when "--"
99
+ words.concat(@argv[(index + 1)..] || [])
100
+ break
101
+ when "-h", "--help" then @options[:help] = true
102
+ when "--no-wait" then @options[:wait] = false
103
+ when "--json" then @options[:json] = true
104
+ when "--model"
105
+ @options[:model] = @argv[index += 1] || raise(OptionParser::MissingArgument, arg)
106
+ when "--timeout", "--wait-timeout"
107
+ @options[:timeout] = Lanes.parse_timeout(@argv[index += 1] || raise(OptionParser::MissingArgument, arg))
108
+ when "--id"
109
+ @options[:name] = @argv[index += 1] || raise(OptionParser::MissingArgument, arg)
110
+ when "--message"
111
+ @options[:message] = @argv[index += 1] || raise(OptionParser::MissingArgument, arg)
112
+ when "--wait-done", "--tmux" then nil
113
+ when /\A--/
114
+ raise OptionParser::InvalidOption, "harnex reply: unknown flag #{arg}; see harnex reply --help"
115
+ else
116
+ words << arg
117
+ end
118
+ index += 1
119
+ end
120
+ words.unshift(@options[:name]) if @options[:name]
121
+ words << @options[:message] if @options[:message]
122
+ [words.first, words.drop(1).join(" ")]
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,108 @@
1
+ module Harnex
2
+ # `harnex result` (#87): block until lanes report and print each result as
3
+ # it lands, in the same format as `delegate`. With no names it waits for
4
+ # every lane this caller started in this repo that nobody has collected.
5
+ class LaneResult
6
+ def self.usage
7
+ <<~TEXT
8
+ Usage: harnex result [NAME...] [options]
9
+
10
+ Wait for lanes to report and print each result as it lands. With no
11
+ NAME: every lane you started in this repo whose result you have not
12
+ collected yet (from `harnex delegate --no-wait`, a timed-out delegate,
13
+ reply or ask).
14
+
15
+ Options:
16
+ --timeout DUR Wait at most DUR (default 9m; `none` waits without a cap).
17
+ On 124 the workers keep going: run the same command again
18
+ --json One JSON line per lane instead of text
19
+ -h, --help Show this help
20
+
21
+ Exit codes: 0 every lane done, 1 a lane failed, 124 a lane still working.
22
+ TEXT
23
+ end
24
+
25
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
26
+ @argv = argv.dup
27
+ @out = out
28
+ @err = err
29
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
30
+ @options = { timeout: Lanes::DEFAULT_TIMEOUT, json: false, help: false }
31
+ @print_lock = Mutex.new
32
+ end
33
+
34
+ def run
35
+ names = parse
36
+ if @options[:help]
37
+ @out.puts self.class.usage
38
+ return 0
39
+ end
40
+ Lanes.sweep_merged(Lanes.main_root(Dir.pwd))
41
+ lanes = names.empty? ? Lanes.pending : names.map { |name| Lanes.resolve(name) }
42
+ if lanes.empty?
43
+ @out.puts "no lanes waiting to be collected (see `harnex lanes`)"
44
+ return 0
45
+ end
46
+
47
+ codes = lanes.map { |lane| Thread.new { collect(lane) } }.map(&:value)
48
+ return Waiter::DONE_EXIT_TIMEOUT if codes.include?(Waiter::DONE_EXIT_TIMEOUT)
49
+
50
+ codes.all?(&:zero?) ? 0 : 1
51
+ end
52
+
53
+ # Exposed for `ask --resume` and the MCP server.
54
+ def collect(lane)
55
+ if lane["state"] == "running"
56
+ turn, exit_code = Lanes.wait(lane, @harnex_command, timeout: @options[:timeout])
57
+ exit_code ||= 1
58
+ lane = Lanes.settle(lane, turn, exit_code)
59
+ else
60
+ # Already collected: show the last result again.
61
+ turn = lane["last_turn"] || {}
62
+ exit_code = lane["state"] == "failed" ? 1 : 0
63
+ end
64
+ print_result(lane, turn, exit_code)
65
+ exit_code
66
+ end
67
+
68
+ private
69
+
70
+ def print_result(lane, turn, exit_code)
71
+ text = if @options[:json]
72
+ JSON.generate(lane: lane["name"], exit_code: exit_code, state: lane["state"], result: turn)
73
+ elsif lane["kind"] == "ask" && exit_code.zero?
74
+ Lanes.final_message(turn)
75
+ else
76
+ Lanes.render(lane, turn, exit_code)
77
+ end
78
+ @print_lock.synchronize do
79
+ @out.puts text
80
+ @out.puts unless @options[:json]
81
+ @out.flush
82
+ end
83
+ end
84
+
85
+ def parse
86
+ names = []
87
+ index = 0
88
+ while index < @argv.length
89
+ arg = @argv[index]
90
+ case arg
91
+ when "-h", "--help" then @options[:help] = true
92
+ when "--json" then @options[:json] = true
93
+ when "--timeout"
94
+ value = @argv[index += 1]
95
+ raise OptionParser::MissingArgument, arg if value.nil?
96
+
97
+ @options[:timeout] = Lanes.parse_timeout(value)
98
+ when /\A--/
99
+ raise OptionParser::InvalidOption, "harnex result: unknown flag #{arg}; see harnex result --help"
100
+ else
101
+ names << arg
102
+ end
103
+ index += 1
104
+ end
105
+ names
106
+ end
107
+ end
108
+ end