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/lanes.rb
ADDED
|
@@ -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
|