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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- 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
|