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
data/lib/harnex/cli.rb
CHANGED
|
@@ -37,6 +37,24 @@ module Harnex
|
|
|
37
37
|
AgentsGuide.new(@argv.drop(1)).run
|
|
38
38
|
when "doctor"
|
|
39
39
|
Doctor.new(@argv.drop(1)).run
|
|
40
|
+
when "preflight"
|
|
41
|
+
Preflight.new(@argv.drop(1)).run
|
|
42
|
+
when "ask"
|
|
43
|
+
Ask.new(@argv.drop(1)).run
|
|
44
|
+
when "delegate"
|
|
45
|
+
Delegate.new(@argv.drop(1)).run
|
|
46
|
+
when "result"
|
|
47
|
+
LaneResult.new(@argv.drop(1)).run
|
|
48
|
+
when "reply"
|
|
49
|
+
Reply.new(@argv.drop(1)).run
|
|
50
|
+
when "land"
|
|
51
|
+
Land.new(@argv.drop(1)).run
|
|
52
|
+
when "discard"
|
|
53
|
+
Land.new(@argv.drop(1), discard: true).run
|
|
54
|
+
when "lanes"
|
|
55
|
+
LanesCommand.new(@argv.drop(1)).run
|
|
56
|
+
when "mcp"
|
|
57
|
+
MCPCommand.new(@argv.drop(1)).run
|
|
40
58
|
when "orchestration"
|
|
41
59
|
OrchestrationCommand.new(@argv.drop(1)).run
|
|
42
60
|
when "artifact-report"
|
|
@@ -89,6 +107,24 @@ module Harnex
|
|
|
89
107
|
AgentsGuide.usage
|
|
90
108
|
when "doctor"
|
|
91
109
|
Doctor.usage
|
|
110
|
+
when "preflight"
|
|
111
|
+
Preflight.usage
|
|
112
|
+
when "ask"
|
|
113
|
+
Ask.usage
|
|
114
|
+
when "delegate"
|
|
115
|
+
Delegate.usage
|
|
116
|
+
when "result"
|
|
117
|
+
LaneResult.usage
|
|
118
|
+
when "reply"
|
|
119
|
+
Reply.usage
|
|
120
|
+
when "land"
|
|
121
|
+
Land.usage
|
|
122
|
+
when "discard"
|
|
123
|
+
Land.usage(discard: true)
|
|
124
|
+
when "lanes"
|
|
125
|
+
LanesCommand.usage
|
|
126
|
+
when "mcp"
|
|
127
|
+
MCPCommand.usage
|
|
92
128
|
when "orchestration"
|
|
93
129
|
OrchestrationCommand.usage
|
|
94
130
|
when "artifact-report"
|
|
@@ -102,7 +138,23 @@ module Harnex
|
|
|
102
138
|
|
|
103
139
|
def usage
|
|
104
140
|
<<~TEXT
|
|
105
|
-
|
|
141
|
+
harnex: Pi workers (GPT models) as tools for you and your agents.
|
|
142
|
+
|
|
143
|
+
Hand work to a worker (each runs in a visible tmux window):
|
|
144
|
+
harnex ask QUESTION A read-only answer from another model
|
|
145
|
+
harnex delegate [--worktree NAME] TASK
|
|
146
|
+
A worker does the task; prints its reply
|
|
147
|
+
harnex delegate --no-wait ... Start lanes in parallel, then:
|
|
148
|
+
harnex result [NAME...] Wait for lanes and print their results
|
|
149
|
+
harnex reply NAME MESSAGE Follow up; the worker keeps its context
|
|
150
|
+
harnex land [NAME...] Merge worktree lanes, remove the worktrees
|
|
151
|
+
harnex discard NAME Drop a lane and its worktree
|
|
152
|
+
harnex lanes Your lanes and what each needs next
|
|
153
|
+
|
|
154
|
+
Blocking commands wait 9m by default and exit 124 if the worker is still
|
|
155
|
+
going; run `harnex result NAME` to keep waiting. Exit 0 done, 1 failed.
|
|
156
|
+
|
|
157
|
+
Low-level session control:
|
|
106
158
|
harnex run <cli> [options] [--] [cli-args...]
|
|
107
159
|
harnex send --id ID [options] [text...]
|
|
108
160
|
harnex wait --id ID [options]
|
|
@@ -113,61 +165,27 @@ module Harnex
|
|
|
113
165
|
harnex events --id ID [options]
|
|
114
166
|
harnex history [options]
|
|
115
167
|
harnex pane --id ID [options]
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
|
|
169
|
+
The same verbs as native MCP tools (Claude Code, Pi):
|
|
170
|
+
harnex mcp install Register the harnex MCP server
|
|
171
|
+
harnex mcp Serve MCP over stdio (clients run this)
|
|
172
|
+
|
|
173
|
+
Setup, docs and telemetry:
|
|
174
|
+
harnex doctor Check Pi and harnex setup
|
|
175
|
+
harnex preflight ADAPTER [options] Prove an adapter with one tiny dispatch
|
|
176
|
+
harnex agents-guide [topic] Agent guides (dispatch, chain, buddy, monitoring, naming, claude)
|
|
177
|
+
harnex guide | recipes Getting started; workflow recipes
|
|
118
178
|
harnex orchestration sample|report [options]
|
|
119
179
|
harnex artifact-report init|validate PATH [options]
|
|
120
180
|
harnex telemetry assert-canonical|reconcile [options]
|
|
121
181
|
harnex help [command]
|
|
122
182
|
|
|
123
|
-
Commands:
|
|
124
|
-
run Start a wrapped interactive session and local API
|
|
125
|
-
send Send text to an active session
|
|
126
|
-
wait Block until a session exits or reaches a state
|
|
127
|
-
watch Safely watch existing work until done/task_failed/timeout
|
|
128
|
-
stop Send the adapter stop sequence to a session
|
|
129
|
-
status List live sessions
|
|
130
|
-
logs Read session output transcripts
|
|
131
|
-
events Stream per-session JSONL runtime events
|
|
132
|
-
history List completed dispatches from .harnex/dispatch.jsonl
|
|
133
|
-
pane Capture the current tmux pane for a live session
|
|
134
|
-
recipes List and read workflow recipes
|
|
135
|
-
guide Show the getting started guide
|
|
136
|
-
agents-guide
|
|
137
|
-
Show agent dispatch, chain, buddy, monitoring, and naming guides
|
|
138
|
-
doctor Run preflight checks and optional read-only session sweep
|
|
139
|
-
orchestration
|
|
140
|
-
Emit external primary samples and report orchestration tax
|
|
141
|
-
artifact-report
|
|
142
|
-
Validate harness-authored receipts or initialize legacy v1 documents
|
|
143
|
-
telemetry
|
|
144
|
-
Assert or reconcile canonical dispatch telemetry
|
|
145
|
-
help Show command help
|
|
146
|
-
|
|
147
|
-
New to harnex? Start with: harnex guide
|
|
148
|
-
Working with agents (dispatching tasks)? Read: harnex agents-guide
|
|
149
|
-
|
|
150
|
-
Notes:
|
|
151
|
-
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
152
|
-
Any other CLI name is launched with generic wrapping.
|
|
153
|
-
|
|
154
183
|
Examples:
|
|
155
|
-
harnex
|
|
156
|
-
harnex
|
|
157
|
-
harnex
|
|
158
|
-
harnex
|
|
159
|
-
harnex
|
|
160
|
-
harnex logs --id main --follow
|
|
161
|
-
harnex events --id main --snapshot
|
|
162
|
-
harnex history --limit 20
|
|
163
|
-
harnex pane --id main --lines 40
|
|
164
|
-
harnex agents-guide dispatch
|
|
165
|
-
harnex doctor
|
|
166
|
-
harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
|
|
167
|
-
harnex artifact-report validate /path/from-dispatch-row.json --final
|
|
168
|
-
harnex artifact-report init .harnex/reports/legacy-v1.json
|
|
169
|
-
harnex telemetry assert-canonical --canonical .harnex/dispatch.jsonl --json
|
|
170
|
-
harnex send --id main --message "Summarize current progress."
|
|
184
|
+
harnex ask "Where is the dispatch row written, and is that path tested?"
|
|
185
|
+
harnex delegate --worktree word-count "Add TextKit.word_count with a test"
|
|
186
|
+
harnex land word-count
|
|
187
|
+
harnex delegate --no-wait --worktree fix-a "Fix A"; harnex delegate --no-wait --worktree fix-b "Fix B"; harnex result
|
|
188
|
+
harnex run pi --tmux pi-i-42 --id pi-i-42 --context-file /tmp/task-42.md
|
|
171
189
|
TEXT
|
|
172
190
|
end
|
|
173
191
|
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Harnex
|
|
8
|
+
# `harnex ask` (#85): hand one question to a Pi worker (a GPT or other
|
|
9
|
+
# provider model) and print its answer, so a Claude session consults
|
|
10
|
+
# another model the way it calls any tool. Read-only by default; the
|
|
11
|
+
# dispatch is a normal harnex run (tmux window, receipt, dispatch rows).
|
|
12
|
+
class Ask
|
|
13
|
+
READ_ONLY_TOOLS = "read,grep,find,ls".freeze
|
|
14
|
+
INTENT = "ask".freeze
|
|
15
|
+
|
|
16
|
+
def self.usage
|
|
17
|
+
<<~TEXT
|
|
18
|
+
Usage: harnex ask [options] [--] QUESTION...
|
|
19
|
+
harnex ask --file PATH [--section TEXT] [options]
|
|
20
|
+
harnex ask --resume ID [--timeout DUR] [--json]
|
|
21
|
+
|
|
22
|
+
Ask a Pi worker one question and print its answer on stdout. The worker
|
|
23
|
+
runs in a visible tmux window, reads the repository (it cannot run
|
|
24
|
+
commands unless --write; to run things use `harnex delegate`), and stops
|
|
25
|
+
when it has answered. stderr gets one line naming the lane, its time and model.
|
|
26
|
+
A read-only ask records its dispatch in the global harnex stream, so the
|
|
27
|
+
repository stays untouched; --write records in the repository.
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--model NAME Pi model (provider/model); default: Pi's own default
|
|
31
|
+
(`defaultProvider`/`defaultModel` in ~/.pi/agent/settings.json)
|
|
32
|
+
--fallback-model NAME
|
|
33
|
+
Ask once more with NAME when the first model fails on
|
|
34
|
+
provider capacity or rate limits (provider_capacity)
|
|
35
|
+
--effort LEVEL Reasoning effort / Pi thinking level
|
|
36
|
+
--cwd DIR Directory the worker works in (default: current directory)
|
|
37
|
+
--file PATH Read the question from PATH (see run --context-file)
|
|
38
|
+
--section TEXT With --file: send only that section
|
|
39
|
+
--write Let the worker edit files and run commands (default:
|
|
40
|
+
read-only tools #{READ_ONLY_TOOLS})
|
|
41
|
+
--id ID Session id (default: pi-ask-<random>)
|
|
42
|
+
--timeout DUR Cap the wait: 540, 9m or 8m30s (default 9m; `none` waits
|
|
43
|
+
without a cap). On 124 the worker keeps going and stderr
|
|
44
|
+
names the command that keeps waiting (`harnex result ID`)
|
|
45
|
+
--resume ID Same as `harnex result ID` (kept for older callers)
|
|
46
|
+
--detach Run headless instead of in a tmux window
|
|
47
|
+
--json Print the full result JSON instead of the answer
|
|
48
|
+
-h, --help Show this help
|
|
49
|
+
|
|
50
|
+
Exit codes: 0 answered, 1 the worker failed (stderr says why), 2 proof
|
|
51
|
+
rejected or a usage error (usage errors print no JSON and no answer),
|
|
52
|
+
124 timeout (the worker keeps running).
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
harnex ask "Which module parses the dispatch rows, and is it tested?"
|
|
56
|
+
harnex ask --model openai/gpt-5.1 --file /tmp/review.md --timeout 9m
|
|
57
|
+
harnex ask --write --cwd ../lane "Fix the failing test and commit"
|
|
58
|
+
TEXT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
|
|
62
|
+
@argv = argv.dup
|
|
63
|
+
@out = out
|
|
64
|
+
@err = err
|
|
65
|
+
@harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
|
|
66
|
+
@options = { model: nil, fallback_model: nil, effort: nil, cwd: nil, file: nil, section: nil, write: false,
|
|
67
|
+
id: nil, timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false, resume: nil,
|
|
68
|
+
repo: nil }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run
|
|
72
|
+
question = parse
|
|
73
|
+
if @options[:help]
|
|
74
|
+
@out.puts self.class.usage
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
return report(*resume_once(@options[:resume]), Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) if @options[:resume]
|
|
79
|
+
|
|
80
|
+
if question.empty? && @options[:file].nil?
|
|
81
|
+
raise OptionParser::MissingArgument, "QUESTION (or --file PATH)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
result, exit_code = ask_once(question, @options[:model], @options[:id])
|
|
85
|
+
if exit_code == 1 && @options[:fallback_model] && result.dig("turn_result", "outcome_class") == "provider_capacity"
|
|
86
|
+
@err.puts("harnex ask: #{result['id']} hit provider capacity; asking #{@options[:fallback_model]} instead")
|
|
87
|
+
retry_id = @options[:id] && "#{@options[:id]}-fallback"
|
|
88
|
+
result, exit_code = ask_once(question, @options[:fallback_model], retry_id)
|
|
89
|
+
end
|
|
90
|
+
report(result, exit_code, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Each ask is a lane (#87), so `harnex result ID` keeps waiting on it and
|
|
96
|
+
# `harnex reply ID "..."` follows up with the worker's context.
|
|
97
|
+
def ask_once(question, model, id)
|
|
98
|
+
id ||= "pi-ask-#{SecureRandom.hex(2)}"
|
|
99
|
+
lane = register_lane(id, model)
|
|
100
|
+
stdout, status = launch(question, model, id)
|
|
101
|
+
result = parse_result(stdout)
|
|
102
|
+
Lanes.settle(lane, result["turn_result"] || {}, status.exitstatus) unless result.empty?
|
|
103
|
+
[result, status.exitstatus]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def register_lane(id, model)
|
|
107
|
+
cwd = File.expand_path(@options[:cwd] || Dir.pwd)
|
|
108
|
+
Lanes.save(
|
|
109
|
+
"name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
|
|
110
|
+
"cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
|
|
111
|
+
"requested_model" => model, "effort" => @options[:effort], "created_at" => Lanes.now,
|
|
112
|
+
"launched_at" => Lanes.now, "state" => "running", "turns" => 0
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def launch(question, model, id)
|
|
117
|
+
argv = @harnex_command + ["run", "pi", "--id", id]
|
|
118
|
+
argv += @options[:detach] ? ["--detach"] : ["--tmux", id]
|
|
119
|
+
argv += ["--cwd", File.expand_path(@options[:cwd] || Dir.pwd)]
|
|
120
|
+
# A consult is not repository work: register and record it under a
|
|
121
|
+
# harnex-owned root (not a git repo, so rows go to the global stream).
|
|
122
|
+
argv += ["--root", ask_root] unless @options[:write]
|
|
123
|
+
if @options[:file]
|
|
124
|
+
argv += ["--context-file", @options[:file]]
|
|
125
|
+
argv += ["--context-section", @options[:section]] if @options[:section]
|
|
126
|
+
else
|
|
127
|
+
argv += ["--context", question]
|
|
128
|
+
end
|
|
129
|
+
argv += ["--auto-stop", "--wait-done", "--intent", INTENT]
|
|
130
|
+
argv += ["--wait-timeout", @options[:timeout].to_s] if @options[:timeout]
|
|
131
|
+
argv += ["--model", model] if model
|
|
132
|
+
argv += ["--effort", @options[:effort]] if @options[:effort]
|
|
133
|
+
argv += ["--", "--no-approve"]
|
|
134
|
+
argv += ["--tools", READ_ONLY_TOOLS] unless @options[:write]
|
|
135
|
+
stdout, status = Open3.capture2(*argv)
|
|
136
|
+
[stdout, status]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A subagent's Bash call is capped at 10 minutes, so a long ask comes back
|
|
140
|
+
# 124 and the caller re-runs this until the answer lands. `wait` prints the
|
|
141
|
+
# turn result unnested; nest it so the answer is reported like a fresh ask.
|
|
142
|
+
def resume_once(id)
|
|
143
|
+
lane = begin
|
|
144
|
+
Lanes.resolve(id)
|
|
145
|
+
rescue OptionParser::InvalidArgument
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
return legacy_resume(id) unless lane
|
|
149
|
+
return [{ "id" => id, "turn_result" => lane["last_turn"] || {} }, lane["state"] == "failed" ? 1 : 0] unless lane["state"] == "running"
|
|
150
|
+
|
|
151
|
+
turn, exit_code = Lanes.wait(lane, @harnex_command, timeout: @options[:timeout])
|
|
152
|
+
Lanes.settle(lane, turn, exit_code || 1)
|
|
153
|
+
[{ "id" => id, "turn_result" => turn }, exit_code || 1]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Asks from before lanes (0.21.0) have no record; their resume line named --repo.
|
|
157
|
+
def legacy_resume(id)
|
|
158
|
+
raise OptionParser::InvalidArgument, "no lane named #{id.inspect}" unless @options[:repo]
|
|
159
|
+
|
|
160
|
+
argv = @harnex_command + ["wait", "--id", id, "--repo", @options[:repo], "--until", "done"]
|
|
161
|
+
argv += ["--timeout", @options[:timeout].to_s] if @options[:timeout]
|
|
162
|
+
stdout, status = Open3.capture2(*argv)
|
|
163
|
+
[{ "id" => id, "turn_result" => parse_result(stdout) }, status.exitstatus]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def resume_command(id)
|
|
167
|
+
"#{Harnex.cli_command} result #{id}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The root `run` records under: ask_root for a consult, the work
|
|
171
|
+
# directory for --write (run takes --cwd as the root when --root is absent).
|
|
172
|
+
def run_root
|
|
173
|
+
return @options[:repo] if @options[:resume]
|
|
174
|
+
|
|
175
|
+
@options[:write] ? File.expand_path(@options[:cwd] || Dir.pwd) : ask_root
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_result(stdout)
|
|
179
|
+
JSON.parse(stdout.to_s.lines.last.to_s)
|
|
180
|
+
rescue JSON::ParserError
|
|
181
|
+
{}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def ask_root
|
|
185
|
+
root = File.join(Harnex::STATE_DIR, "ask")
|
|
186
|
+
FileUtils.mkdir_p(root)
|
|
187
|
+
root
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def report(result, exit_code, elapsed)
|
|
191
|
+
if @options[:json]
|
|
192
|
+
@out.puts JSON.generate(result) unless result.empty?
|
|
193
|
+
return exit_code
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
turn = result["turn_result"] || {}
|
|
197
|
+
answer = full_answer(turn)
|
|
198
|
+
case exit_code
|
|
199
|
+
when 0
|
|
200
|
+
@out.puts answer.to_s
|
|
201
|
+
@err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
|
|
202
|
+
when Waiter::DONE_EXIT_TIMEOUT
|
|
203
|
+
@err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
|
|
204
|
+
else
|
|
205
|
+
@out.puts answer unless answer.to_s.empty?
|
|
206
|
+
reason = turn["last_error"] || turn["outcome_class"] || turn["status"] || result["error"] || "no result"
|
|
207
|
+
@err.puts("harnex ask: #{result['id'] || 'the worker'} did not answer (#{reason})")
|
|
208
|
+
end
|
|
209
|
+
exit_code
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def full_answer(turn)
|
|
213
|
+
path = turn["final_message_path"].to_s
|
|
214
|
+
return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
|
|
215
|
+
|
|
216
|
+
turn["final_message"]
|
|
217
|
+
rescue StandardError
|
|
218
|
+
turn["final_message"]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def parse
|
|
222
|
+
question = []
|
|
223
|
+
index = 0
|
|
224
|
+
while index < @argv.length
|
|
225
|
+
arg = @argv[index]
|
|
226
|
+
case arg
|
|
227
|
+
when "--"
|
|
228
|
+
question.concat(@argv[(index + 1)..] || [])
|
|
229
|
+
break
|
|
230
|
+
when "-h", "--help" then @options[:help] = true
|
|
231
|
+
when "--write" then @options[:write] = true
|
|
232
|
+
when "--detach" then @options[:detach] = true
|
|
233
|
+
when "--json" then @options[:json] = true
|
|
234
|
+
when "--model", "--effort", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
|
|
235
|
+
@options[arg.delete_prefix("--").tr("-", "_").to_sym] = value(index += 1, arg)
|
|
236
|
+
# Habits from `harnex run` (#87): an ask already runs in a tmux window
|
|
237
|
+
# named after its id and stops after answering.
|
|
238
|
+
when "--tmux"
|
|
239
|
+
following = @argv[index + 1]
|
|
240
|
+
if following&.match?(Lanes::NAME) && !following.start_with?("-") && @argv.length > index + 2
|
|
241
|
+
@options[:id] ||= following
|
|
242
|
+
index += 1
|
|
243
|
+
end
|
|
244
|
+
when "--auto-stop", "--wait-done" then nil
|
|
245
|
+
when "--wait-timeout"
|
|
246
|
+
@options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
|
|
247
|
+
when "--context" then question << value(index += 1, arg)
|
|
248
|
+
when "--context-file" then @options[:file] = value(index += 1, arg)
|
|
249
|
+
when "--timeout"
|
|
250
|
+
@options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
|
|
251
|
+
when /\A--/
|
|
252
|
+
raise OptionParser::InvalidOption, "harnex ask: unknown flag #{arg}; see harnex ask --help"
|
|
253
|
+
else
|
|
254
|
+
question << arg
|
|
255
|
+
end
|
|
256
|
+
index += 1
|
|
257
|
+
end
|
|
258
|
+
if @options[:section] && @options[:file].nil?
|
|
259
|
+
raise OptionParser::InvalidOption, "harnex ask: --section requires --file"
|
|
260
|
+
end
|
|
261
|
+
if @options[:resume] && (!question.empty? || @options[:file])
|
|
262
|
+
raise OptionParser::InvalidOption, "harnex ask: --resume takes a lane id and no question"
|
|
263
|
+
end
|
|
264
|
+
if @options[:repo] && @options[:resume].nil?
|
|
265
|
+
raise OptionParser::InvalidOption, "harnex ask: --repo requires --resume"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
question.join(" ")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def value(index, name)
|
|
272
|
+
value = @argv[index]
|
|
273
|
+
raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
|
|
274
|
+
|
|
275
|
+
value
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# `harnex delegate` (#87): hand one task to a Pi worker and print its
|
|
3
|
+
# reply plus a lane footer (state, time, model, cost, commits, next
|
|
4
|
+
# commands). With --worktree NAME the worker gets its own git worktree on
|
|
5
|
+
# branch harnex/NAME, so parallel lanes never touch the main checkout.
|
|
6
|
+
class Delegate
|
|
7
|
+
def self.usage
|
|
8
|
+
<<~TEXT
|
|
9
|
+
Usage: harnex delegate [options] [--] TASK...
|
|
10
|
+
harnex delegate --file PATH [options]
|
|
11
|
+
|
|
12
|
+
Hand a task to a Pi worker (a GPT model by default) and print its reply
|
|
13
|
+
and a lane summary. The worker runs in a visible tmux window, may edit
|
|
14
|
+
files and run commands, and stops when it has replied. Follow up with
|
|
15
|
+
`harnex reply NAME "..."`; it keeps the worker's context.
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
|
|
19
|
+
harnex/NAME); finish with `harnex land NAME` or
|
|
20
|
+
`harnex discard NAME`
|
|
21
|
+
--name NAME Lane name without a worktree (default: from the task)
|
|
22
|
+
--cwd DIR Directory the worker works in (default: current directory)
|
|
23
|
+
--file PATH Read the task from PATH
|
|
24
|
+
--model NAME Pi model (provider/model); default: Pi's own default
|
|
25
|
+
--effort LEVEL Reasoning effort / Pi thinking level
|
|
26
|
+
--no-wait Start the lane and return at once; collect with
|
|
27
|
+
`harnex result` (all your lanes) or `harnex result NAME`
|
|
28
|
+
--timeout DUR Wait at most DUR (default 9m, fits a 10-minute tool cap;
|
|
29
|
+
`none` waits without a cap). On 124 the worker keeps going:
|
|
30
|
+
run `harnex result NAME`
|
|
31
|
+
--detach Run headless instead of in a tmux window
|
|
32
|
+
--json Print the lane record and result as JSON
|
|
33
|
+
-h, --help Show this help
|
|
34
|
+
|
|
35
|
+
Exit codes: 0 done, 1 failed, 2 usage error, 124 still working.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
harnex delegate --worktree word-count "Add TextKit.word_count with a test"
|
|
39
|
+
harnex delegate --no-wait --worktree fix-a "Fix A" # then: harnex result
|
|
40
|
+
harnex delegate "Run bin/slow-bench and report the score"
|
|
41
|
+
TEXT
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
|
|
45
|
+
@argv = argv.dup
|
|
46
|
+
@out = out
|
|
47
|
+
@err = err
|
|
48
|
+
@harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
|
|
49
|
+
@options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, wait: true,
|
|
50
|
+
timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run
|
|
54
|
+
task = parse
|
|
55
|
+
if @options[:help]
|
|
56
|
+
@out.puts self.class.usage
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
task = File.read(@options[:file]) if task.empty? && @options[:file]
|
|
60
|
+
raise OptionParser::MissingArgument, "TASK (or --file PATH)" if task.strip.empty?
|
|
61
|
+
|
|
62
|
+
lane = @lane = build_lane(task)
|
|
63
|
+
brief = Lanes.write_brief(lane, task)
|
|
64
|
+
turn, exit_code = Lanes.launch(lane, @harnex_command, context_args: ["--context-file", brief],
|
|
65
|
+
wait: @options[:wait], timeout: @options[:timeout],
|
|
66
|
+
detach: @options[:detach])
|
|
67
|
+
return started(lane) unless @options[:wait]
|
|
68
|
+
|
|
69
|
+
lane = Lanes.settle(lane, turn, exit_code)
|
|
70
|
+
report(lane, turn, exit_code)
|
|
71
|
+
rescue Lanes::LaunchError => e
|
|
72
|
+
discard_unstarted
|
|
73
|
+
@err.puts(e.message)
|
|
74
|
+
1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def build_lane(task)
|
|
80
|
+
workdir = File.expand_path(@options[:cwd] || Dir.pwd)
|
|
81
|
+
raise OptionParser::InvalidArgument, "--cwd #{workdir} is not a directory" unless File.directory?(workdir)
|
|
82
|
+
|
|
83
|
+
root = Lanes.main_root(workdir)
|
|
84
|
+
raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
|
|
85
|
+
|
|
86
|
+
Lanes.sweep_merged(root)
|
|
87
|
+
root ||= workdir
|
|
88
|
+
name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
|
|
89
|
+
ensure_free!(root, name)
|
|
90
|
+
lane = {
|
|
91
|
+
"name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
|
|
92
|
+
"owner" => Harnex.ambient_owner_id, "requested_model" => @options[:model], "effort" => @options[:effort],
|
|
93
|
+
"created_at" => Lanes.now, "turns" => 0
|
|
94
|
+
}
|
|
95
|
+
if @options[:worktree]
|
|
96
|
+
warn_uncommitted(root)
|
|
97
|
+
lane["worktree"], lane["branch"], lane["base_sha"] = Lanes.create_worktree(root, name)
|
|
98
|
+
lane["cwd"] = lane["worktree"]
|
|
99
|
+
else
|
|
100
|
+
Harnex.ensure_git_excluded(root) if Lanes.main_root(workdir)
|
|
101
|
+
lane["base_sha"] = Lanes.head_sha(workdir)
|
|
102
|
+
end
|
|
103
|
+
lane
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A lane that never started leaves nothing behind.
|
|
107
|
+
def discard_unstarted
|
|
108
|
+
return unless @lane && @lane["worktree"] && @lane["turns"].to_i.zero?
|
|
109
|
+
|
|
110
|
+
Lanes.remove_worktree(@lane, force: true)
|
|
111
|
+
@lane["state"] = "discarded"
|
|
112
|
+
Lanes.save(@lane)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def ensure_free!(root, name)
|
|
116
|
+
existing = Lanes.load(root, name)
|
|
117
|
+
if Lanes.open?(existing)
|
|
118
|
+
raise OptionParser::InvalidArgument,
|
|
119
|
+
"lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
|
|
120
|
+
"or `harnex land #{name}` / `harnex discard #{name}` first"
|
|
121
|
+
end
|
|
122
|
+
return unless Harnex.read_registry(root, name)
|
|
123
|
+
|
|
124
|
+
raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def warn_uncommitted(root)
|
|
128
|
+
dirty = Lanes.dirty_paths(root)
|
|
129
|
+
return if dirty.empty?
|
|
130
|
+
|
|
131
|
+
@err.puts("harnex delegate: the main checkout has #{dirty.length} uncommitted change(s); " \
|
|
132
|
+
"the worktree starts from HEAD and won't see them")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def started(lane)
|
|
136
|
+
if @options[:json]
|
|
137
|
+
@out.puts JSON.generate(lane)
|
|
138
|
+
else
|
|
139
|
+
where = lane["worktree"] ? " in #{Lanes.relative(lane['repo_root'], lane['worktree'])}" : ""
|
|
140
|
+
@out.puts "── #{lane['name']}: started#{where}"
|
|
141
|
+
@out.puts " collect: harnex result #{lane['name']} (or `harnex result` for all your lanes)"
|
|
142
|
+
end
|
|
143
|
+
0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def report(lane, turn, exit_code)
|
|
147
|
+
if @options[:json]
|
|
148
|
+
@out.puts JSON.generate(lane: lane, result: turn, exit_code: exit_code)
|
|
149
|
+
else
|
|
150
|
+
@out.puts Lanes.render(lane, turn, exit_code)
|
|
151
|
+
end
|
|
152
|
+
exit_code
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parse
|
|
156
|
+
task = []
|
|
157
|
+
index = 0
|
|
158
|
+
while index < @argv.length
|
|
159
|
+
arg = @argv[index]
|
|
160
|
+
case arg
|
|
161
|
+
when "--"
|
|
162
|
+
task.concat(@argv[(index + 1)..] || [])
|
|
163
|
+
break
|
|
164
|
+
when "-h", "--help" then @options[:help] = true
|
|
165
|
+
when "--no-wait" then @options[:wait] = false
|
|
166
|
+
when "--detach" then @options[:detach] = true
|
|
167
|
+
when "--json" then @options[:json] = true
|
|
168
|
+
when "--worktree", "--name", "--cwd", "--file", "--model", "--effort"
|
|
169
|
+
@options[arg.delete_prefix("--").to_sym] = value(index += 1, arg)
|
|
170
|
+
when "--timeout", "--wait-timeout"
|
|
171
|
+
@options[:timeout] = Lanes.parse_timeout(value(index += 1, arg))
|
|
172
|
+
# Habits from `harnex run`: a lane already runs in a tmux window named
|
|
173
|
+
# after it, stops after its reply and waits for it, so these are
|
|
174
|
+
# accepted rather than refused.
|
|
175
|
+
when "--id" then @options[:name] = value(index += 1, arg)
|
|
176
|
+
when "--tmux" then index += 1 if lane_name_follows?(index) && (@options[:name] ||= @argv[index + 1])
|
|
177
|
+
when "--auto-stop", "--wait-done" then nil
|
|
178
|
+
when "--context" then task << value(index += 1, arg)
|
|
179
|
+
when "--context-file" then @options[:file] = value(index += 1, arg)
|
|
180
|
+
when /\A--/
|
|
181
|
+
raise OptionParser::InvalidOption, "harnex delegate: unknown flag #{arg}; see harnex delegate --help"
|
|
182
|
+
else
|
|
183
|
+
task << arg
|
|
184
|
+
end
|
|
185
|
+
index += 1
|
|
186
|
+
end
|
|
187
|
+
task.join(" ")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def lane_name_follows?(index)
|
|
191
|
+
following = @argv[index + 1]
|
|
192
|
+
!following.nil? && following.match?(Lanes::NAME) && !following.start_with?("-")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def value(index, name)
|
|
196
|
+
value = @argv[index]
|
|
197
|
+
raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
|
|
198
|
+
|
|
199
|
+
value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|