harnex 0.4.0 → 0.6.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 +39 -0
- data/TECHNICAL.md +19 -1
- data/lib/harnex/adapters/base.rb +14 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/adapters/codex_appserver.rb +390 -0
- data/lib/harnex/adapters.rb +16 -3
- data/lib/harnex/cli.rb +3 -0
- data/lib/harnex/commands/doctor.rb +75 -0
- data/lib/harnex/commands/run.rb +51 -7
- data/lib/harnex/commands/wait.rb +109 -3
- data/lib/harnex/core.rb +78 -2
- data/lib/harnex/runtime/session.rb +408 -4
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +5 -2
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -7,11 +7,12 @@ module Harnex
|
|
|
7
7
|
DEFAULT_TIMEOUT = 5.0
|
|
8
8
|
KNOWN_FLAGS = %w[
|
|
9
9
|
--id --description --detach --tmux --host --port --watch --watch-file
|
|
10
|
-
--stall-after --max-resumes --preset --context --
|
|
10
|
+
--stall-after --max-resumes --preset --context --meta --summary-out
|
|
11
|
+
--timeout --inbox-ttl --legacy-pty --help
|
|
11
12
|
].freeze
|
|
12
13
|
VALUE_FLAGS = %w[
|
|
13
14
|
--id --description --host --port --watch --watch-file --stall-after
|
|
14
|
-
--max-resumes --preset --context --timeout --inbox-ttl
|
|
15
|
+
--max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
|
|
15
16
|
].freeze
|
|
16
17
|
|
|
17
18
|
def self.usage(program_name = "harnex run")
|
|
@@ -31,8 +32,13 @@ module Harnex
|
|
|
31
32
|
--preset NAME Watch preset: impl, plan, gate (requires --watch)
|
|
32
33
|
--watch-file PATH Auto-send a file-change hook on modification
|
|
33
34
|
--context TEXT Inject as the initial prompt (prepends session header)
|
|
35
|
+
--meta JSON Attach parsed JSON metadata to the started event
|
|
36
|
+
--summary-out PATH Append dispatch telemetry summary JSONL to PATH
|
|
34
37
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
35
38
|
--inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
|
|
39
|
+
--legacy-pty (codex only) Use the legacy PTY adapter instead of
|
|
40
|
+
the JSON-RPC `app-server` adapter. Deprecated; will
|
|
41
|
+
be removed in 0.7.0.
|
|
36
42
|
-h, --help Show this help
|
|
37
43
|
|
|
38
44
|
Notes:
|
|
@@ -60,11 +66,14 @@ module Harnex
|
|
|
60
66
|
preset: nil,
|
|
61
67
|
watch: nil,
|
|
62
68
|
context: nil,
|
|
69
|
+
meta: nil,
|
|
70
|
+
summary_out: nil,
|
|
63
71
|
detach: false,
|
|
64
72
|
tmux: false,
|
|
65
73
|
tmux_name: nil,
|
|
66
74
|
timeout: DEFAULT_TIMEOUT,
|
|
67
75
|
inbox_ttl: default_inbox_ttl,
|
|
76
|
+
legacy_pty: false,
|
|
68
77
|
help: false
|
|
69
78
|
}
|
|
70
79
|
end
|
|
@@ -79,10 +88,11 @@ module Harnex
|
|
|
79
88
|
raise OptionParser::MissingArgument, "cli" if cli_name.nil?
|
|
80
89
|
|
|
81
90
|
repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
|
|
91
|
+
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
82
92
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
83
93
|
validate_unique_id!(repo_root)
|
|
84
94
|
effective_child_args = apply_context(child_args)
|
|
85
|
-
adapter = Harnex.build_adapter(cli_name, effective_child_args)
|
|
95
|
+
adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
|
|
86
96
|
@options[:detach] = true if @options[:tmux]
|
|
87
97
|
validate_watch_mode!
|
|
88
98
|
resolve_watch_preset!
|
|
@@ -137,7 +147,10 @@ module Harnex
|
|
|
137
147
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
138
148
|
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
139
149
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
150
|
+
tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
|
|
151
|
+
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
140
152
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
153
|
+
tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
|
|
141
154
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
142
155
|
|
|
143
156
|
window_name = @options[:tmux_name] || @options[:id]
|
|
@@ -239,12 +252,14 @@ module Harnex
|
|
|
239
252
|
id: @options[:id],
|
|
240
253
|
watch: watch,
|
|
241
254
|
description: @options[:description],
|
|
255
|
+
meta: @options[:meta],
|
|
256
|
+
summary_out: @options[:summary_out],
|
|
242
257
|
inbox_ttl: @options[:inbox_ttl]
|
|
243
258
|
)
|
|
244
259
|
end
|
|
245
260
|
|
|
246
261
|
def adapter_repo_path(cli_name, child_args)
|
|
247
|
-
Harnex.build_adapter(cli_name, child_args).infer_repo_path(child_args)
|
|
262
|
+
Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
|
|
248
263
|
end
|
|
249
264
|
|
|
250
265
|
def apply_context(child_args)
|
|
@@ -385,6 +400,16 @@ module Harnex
|
|
|
385
400
|
@options[:context] = required_option_value(arg, argv[index])
|
|
386
401
|
when /\A--context=(.+)\z/
|
|
387
402
|
@options[:context] = required_option_value("--context", Regexp.last_match(1))
|
|
403
|
+
when "--meta"
|
|
404
|
+
index += 1
|
|
405
|
+
@options[:meta] = parse_meta(required_option_value(arg, argv[index]))
|
|
406
|
+
when /\A--meta=(.+)\z/
|
|
407
|
+
@options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
|
|
408
|
+
when "--summary-out"
|
|
409
|
+
index += 1
|
|
410
|
+
@options[:summary_out] = required_option_value(arg, argv[index])
|
|
411
|
+
when /\A--summary-out=(.+)\z/
|
|
412
|
+
@options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
|
|
388
413
|
when "--timeout"
|
|
389
414
|
index += 1
|
|
390
415
|
@options[:timeout] = Float(required_option_value(arg, argv[index]))
|
|
@@ -395,6 +420,8 @@ module Harnex
|
|
|
395
420
|
@options[:inbox_ttl] = Float(required_option_value(arg, argv[index]))
|
|
396
421
|
when /\A--inbox-ttl=(.+)\z/
|
|
397
422
|
@options[:inbox_ttl] = Float(required_option_value("--inbox-ttl", Regexp.last_match(1)))
|
|
423
|
+
when "--legacy-pty"
|
|
424
|
+
@options[:legacy_pty] = true
|
|
398
425
|
else
|
|
399
426
|
if cli_name.nil?
|
|
400
427
|
cli_name = arg
|
|
@@ -434,13 +461,13 @@ module Harnex
|
|
|
434
461
|
case arg
|
|
435
462
|
when "--"
|
|
436
463
|
return false
|
|
437
|
-
when "-h", "--help", "--detach", "--tmux"
|
|
464
|
+
when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
|
|
438
465
|
nil
|
|
439
466
|
when /\A--tmux=/
|
|
440
467
|
nil
|
|
441
468
|
when *VALUE_FLAGS
|
|
442
469
|
index += 1
|
|
443
|
-
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
|
|
470
|
+
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
|
|
444
471
|
nil
|
|
445
472
|
when /\A--preset=/
|
|
446
473
|
nil
|
|
@@ -458,7 +485,8 @@ module Harnex
|
|
|
458
485
|
arg == "-h" ||
|
|
459
486
|
arg.start_with?(
|
|
460
487
|
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
461
|
-
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--
|
|
488
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
|
|
489
|
+
"--timeout=", "--inbox-ttl="
|
|
462
490
|
)
|
|
463
491
|
end
|
|
464
492
|
|
|
@@ -489,6 +517,22 @@ module Harnex
|
|
|
489
517
|
raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
|
|
490
518
|
end
|
|
491
519
|
|
|
520
|
+
def parse_meta(value)
|
|
521
|
+
parsed = JSON.parse(value)
|
|
522
|
+
return parsed if parsed.is_a?(Hash)
|
|
523
|
+
|
|
524
|
+
raise OptionParser::InvalidOption, "--meta must be a JSON object"
|
|
525
|
+
rescue JSON::ParserError => e
|
|
526
|
+
raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def resolve_summary_out(repo_root)
|
|
530
|
+
configured = @options[:summary_out]
|
|
531
|
+
return Harnex.default_summary_out_path(repo_root) if configured.nil?
|
|
532
|
+
|
|
533
|
+
File.expand_path(configured, repo_root)
|
|
534
|
+
end
|
|
535
|
+
|
|
492
536
|
def default_inbox_ttl
|
|
493
537
|
value = ENV["HARNEX_INBOX_TTL"]
|
|
494
538
|
return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -7,14 +7,20 @@ module Harnex
|
|
|
7
7
|
class Waiter
|
|
8
8
|
POLL_INTERVAL = 0.5
|
|
9
9
|
|
|
10
|
+
EVENT_PREDICATES = %w[task_complete].freeze
|
|
11
|
+
|
|
10
12
|
def self.usage(program_name = "harnex wait")
|
|
11
13
|
<<~TEXT
|
|
12
14
|
Usage: #{program_name} [options]
|
|
13
15
|
|
|
14
16
|
Options:
|
|
15
17
|
--id ID Session ID to wait for (required)
|
|
16
|
-
--until STATE Wait until session reaches STATE
|
|
17
|
-
|
|
18
|
+
--until STATE Wait until session reaches STATE. Supported:
|
|
19
|
+
task_complete (events JSONL — fires on
|
|
20
|
+
turn/completed; adapter-agnostic)
|
|
21
|
+
<other> (agent_state HTTP poll, e.g.
|
|
22
|
+
"prompt", "busy")
|
|
23
|
+
Without --until, waits for session exit (default).
|
|
18
24
|
--repo PATH Resolve session using PATH's repo root (default: current repo)
|
|
19
25
|
--timeout SECS Maximum time to wait in seconds (default: unlimited)
|
|
20
26
|
-h, --help Show this help
|
|
@@ -42,7 +48,11 @@ module Harnex
|
|
|
42
48
|
raise "--id is required for harnex wait" unless @options[:id]
|
|
43
49
|
|
|
44
50
|
if @options[:until_state]
|
|
45
|
-
|
|
51
|
+
if EVENT_PREDICATES.include?(@options[:until_state])
|
|
52
|
+
wait_until_event(@options[:until_state])
|
|
53
|
+
else
|
|
54
|
+
wait_until_state
|
|
55
|
+
end
|
|
46
56
|
else
|
|
47
57
|
wait_until_exit
|
|
48
58
|
end
|
|
@@ -50,6 +60,102 @@ module Harnex
|
|
|
50
60
|
|
|
51
61
|
private
|
|
52
62
|
|
|
63
|
+
def wait_until_event(predicate)
|
|
64
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
65
|
+
events_path = Harnex.events_log_path(repo_root, @options[:id])
|
|
66
|
+
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
67
|
+
start_time = Time.now
|
|
68
|
+
deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
|
|
69
|
+
|
|
70
|
+
unless registry || File.exist?(events_path)
|
|
71
|
+
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
72
|
+
return 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
offset = 0
|
|
76
|
+
task_complete_seen = false
|
|
77
|
+
|
|
78
|
+
# Replay existing events first — we may already be past the predicate.
|
|
79
|
+
if File.exist?(events_path)
|
|
80
|
+
File.open(events_path, "r") do |f|
|
|
81
|
+
f.each_line do |line|
|
|
82
|
+
offset = f.pos
|
|
83
|
+
event = parse_event(line)
|
|
84
|
+
next unless event
|
|
85
|
+
task_complete_seen = true if event["type"] == "task_complete"
|
|
86
|
+
if matches?(event, predicate, task_complete_seen)
|
|
87
|
+
return emit_event_match(event, start_time)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
target_pid = registry && registry["pid"]
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
if target_pid && !Harnex.alive_pid?(target_pid)
|
|
97
|
+
waited = (Time.now - start_time).round(1)
|
|
98
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
|
|
99
|
+
return 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if deadline && Time.now >= deadline
|
|
103
|
+
waited = (Time.now - start_time).round(1)
|
|
104
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited)
|
|
105
|
+
return 124
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if File.exist?(events_path) && File.size(events_path) > offset
|
|
109
|
+
File.open(events_path, "r") do |f|
|
|
110
|
+
f.seek(offset)
|
|
111
|
+
f.each_line do |line|
|
|
112
|
+
event = parse_event(line)
|
|
113
|
+
next unless event
|
|
114
|
+
task_complete_seen = true if event["type"] == "task_complete"
|
|
115
|
+
if matches?(event, predicate, task_complete_seen)
|
|
116
|
+
offset = f.pos
|
|
117
|
+
return emit_event_match(event, start_time)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
offset = f.pos
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sleep 0.1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_event(line)
|
|
129
|
+
JSON.parse(line)
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def matches?(event, predicate, task_complete_seen)
|
|
135
|
+
type = event["type"]
|
|
136
|
+
case predicate
|
|
137
|
+
when "task_complete"
|
|
138
|
+
type == "task_complete"
|
|
139
|
+
when "prompt"
|
|
140
|
+
type == "task_complete" ||
|
|
141
|
+
(task_complete_seen && type == "agent_state" && event["state"] == "prompt")
|
|
142
|
+
else
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def emit_event_match(event, start_time)
|
|
148
|
+
waited = (Time.now - start_time).round(1)
|
|
149
|
+
puts JSON.generate(
|
|
150
|
+
ok: true,
|
|
151
|
+
id: @options[:id],
|
|
152
|
+
event: event["type"],
|
|
153
|
+
seq: event["seq"],
|
|
154
|
+
waited_seconds: waited
|
|
155
|
+
)
|
|
156
|
+
0
|
|
157
|
+
end
|
|
158
|
+
|
|
53
159
|
def wait_until_state
|
|
54
160
|
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
55
161
|
target_state = @options[:until_state]
|
data/lib/harnex/core.rb
CHANGED
|
@@ -64,6 +64,67 @@ module Harnex
|
|
|
64
64
|
seconds
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def harness_version
|
|
68
|
+
VERSION
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def host_info
|
|
72
|
+
{
|
|
73
|
+
host: Socket.gethostname,
|
|
74
|
+
platform: RUBY_PLATFORM
|
|
75
|
+
}
|
|
76
|
+
rescue StandardError
|
|
77
|
+
{
|
|
78
|
+
host: nil,
|
|
79
|
+
platform: RUBY_PLATFORM
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def strip_ansi(text)
|
|
84
|
+
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def default_summary_out_path(repo_root)
|
|
88
|
+
koder_dir = File.join(repo_root.to_s, "koder")
|
|
89
|
+
return nil unless File.directory?(koder_dir)
|
|
90
|
+
|
|
91
|
+
File.join(koder_dir, "DISPATCH.jsonl")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def git_capture_start(repo_root)
|
|
95
|
+
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
96
|
+
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
97
|
+
return {} if sha.empty? || branch.empty?
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
sha: sha,
|
|
101
|
+
branch: branch
|
|
102
|
+
}
|
|
103
|
+
rescue StandardError
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def git_capture_end(repo_root, start_sha)
|
|
108
|
+
start_sha = start_sha.to_s.strip
|
|
109
|
+
return {} if start_sha.empty?
|
|
110
|
+
|
|
111
|
+
end_sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
112
|
+
range = "#{start_sha}..#{end_sha}"
|
|
113
|
+
shortstat = git_output(repo_root, "diff", "--shortstat", range)
|
|
114
|
+
commits = Integer(git_output(repo_root, "rev-list", "--count", range))
|
|
115
|
+
stats = parse_git_shortstat(shortstat)
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
sha: end_sha,
|
|
119
|
+
loc_added: stats.fetch(:loc_added),
|
|
120
|
+
loc_removed: stats.fetch(:loc_removed),
|
|
121
|
+
files_changed: stats.fetch(:files_changed),
|
|
122
|
+
commits: commits
|
|
123
|
+
}
|
|
124
|
+
rescue StandardError
|
|
125
|
+
{}
|
|
126
|
+
end
|
|
127
|
+
|
|
67
128
|
def repo_key(repo_root)
|
|
68
129
|
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
69
130
|
end
|
|
@@ -288,10 +349,10 @@ module Harnex
|
|
|
288
349
|
false
|
|
289
350
|
end
|
|
290
351
|
|
|
291
|
-
def build_adapter(cli, argv)
|
|
352
|
+
def build_adapter(cli, argv, legacy_pty: false)
|
|
292
353
|
raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
|
|
293
354
|
|
|
294
|
-
Adapters.build(cli, argv)
|
|
355
|
+
Adapters.build(cli, argv, legacy_pty: legacy_pty)
|
|
295
356
|
end
|
|
296
357
|
|
|
297
358
|
def session_cli(session)
|
|
@@ -316,4 +377,19 @@ module Harnex
|
|
|
316
377
|
debounce_seconds: WATCH_DEBOUNCE_SECONDS
|
|
317
378
|
)
|
|
318
379
|
end
|
|
380
|
+
|
|
381
|
+
def git_output(repo_root, *args)
|
|
382
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
|
|
383
|
+
raise "git #{args.join(' ')} failed" unless status.success?
|
|
384
|
+
|
|
385
|
+
stdout.strip
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def parse_git_shortstat(text)
|
|
389
|
+
{
|
|
390
|
+
files_changed: text.to_s[/(\d+)\s+files?\s+changed/, 1].to_i,
|
|
391
|
+
loc_added: text.to_s[/(\d+)\s+insertions?\(\+\)/, 1].to_i,
|
|
392
|
+
loc_removed: text.to_s[/(\d+)\s+deletions?\(-\)/, 1].to_i
|
|
393
|
+
}
|
|
394
|
+
end
|
|
319
395
|
end
|