harnex 0.6.5 → 0.7.3

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.
@@ -8,7 +8,7 @@ module Harnex
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
10
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --timeout --inbox-ttl --auto-stop --legacy-pty --help
11
+ --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
12
12
  ].freeze
13
13
  VALUE_FLAGS = %w[
14
14
  --id --description --host --port --watch --watch-file --stall-after
@@ -33,6 +33,8 @@ module Harnex
33
33
  --watch-file PATH Auto-send a file-change hook on modification
34
34
  --context TEXT Inject as the initial prompt (prepends session header)
35
35
  --auto-stop Stop after the first task completion from --context
36
+ --fast (codex only) Use Codex service_tier="fast".
37
+ Default Codex runs force service_tier="flex".
36
38
  --meta JSON Attach parsed JSON metadata to the started event
37
39
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
38
40
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
@@ -70,6 +72,7 @@ module Harnex
70
72
 
71
73
  def initialize(argv)
72
74
  @argv = argv.dup
75
+ @launch_cwd = Dir.pwd
73
76
  @options = {
74
77
  id: nil,
75
78
  description: nil,
@@ -91,6 +94,7 @@ module Harnex
91
94
  tmux_name: nil,
92
95
  timeout: DEFAULT_TIMEOUT,
93
96
  inbox_ttl: default_inbox_ttl,
97
+ fast: false,
94
98
  legacy_pty: false,
95
99
  help: false
96
100
  }
@@ -110,7 +114,7 @@ module Harnex
110
114
  @options[:summary_out] = resolve_summary_out(repo_root)
111
115
  @options[:id] ||= Harnex.generate_id(repo_root)
112
116
  validate_unique_id!(repo_root)
113
- effective_child_args = apply_context(child_args)
117
+ effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
114
118
  adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
115
119
  @options[:detach] = true if @options[:tmux]
116
120
  validate_watch_mode!
@@ -170,6 +174,7 @@ module Harnex
170
174
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
171
175
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
172
176
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
177
+ tmux_cmd << "--fast" if @options[:fast]
173
178
  tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
174
179
  tmux_cmd += ["--"] + child_args unless child_args.empty?
175
180
 
@@ -178,9 +183,9 @@ module Harnex
178
183
 
179
184
  started =
180
185
  if ENV["TMUX"]
181
- system("tmux", "new-window", "-n", window_name, "-d", shell_cmd)
186
+ system("tmux", "new-window", "-c", @launch_cwd, "-n", window_name, "-d", shell_cmd)
182
187
  else
183
- system("tmux", "new-session", "-d", "-s", "harnex", "-n", window_name, shell_cmd)
188
+ system("tmux", "new-session", "-c", @launch_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
184
189
  end
185
190
 
186
191
  raise "tmux failed to start #{cli_name.inspect}" unless started
@@ -275,7 +280,8 @@ module Harnex
275
280
  meta: @options[:meta],
276
281
  summary_out: @options[:summary_out],
277
282
  inbox_ttl: @options[:inbox_ttl],
278
- auto_stop: @options[:auto_stop]
283
+ auto_stop: @options[:auto_stop],
284
+ launch_cwd: @launch_cwd
279
285
  )
280
286
  end
281
287
 
@@ -423,6 +429,8 @@ module Harnex
423
429
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
424
430
  when "--auto-stop"
425
431
  @options[:auto_stop] = true
432
+ when "--fast"
433
+ @options[:fast] = true
426
434
  when "--meta"
427
435
  index += 1
428
436
  @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
@@ -446,6 +454,7 @@ module Harnex
446
454
  when "--legacy-pty"
447
455
  @options[:legacy_pty] = true
448
456
  else
457
+ reject_unknown_long_flag!(arg) if unknown_long_flag?(arg)
449
458
  if cli_name.nil?
450
459
  cli_name = arg
451
460
  else
@@ -458,6 +467,16 @@ module Harnex
458
467
  [cli_name, forwarded]
459
468
  end
460
469
 
470
+ def unknown_long_flag?(arg)
471
+ arg.start_with?("--")
472
+ end
473
+
474
+ def reject_unknown_long_flag!(arg)
475
+ flag = arg.split("=", 2).first
476
+ raise OptionParser::InvalidOption,
477
+ "harnex run: unknown flag #{flag}; see harnex run --help"
478
+ end
479
+
461
480
  def required_option_value(option_name, value)
462
481
  raise OptionParser::MissingArgument, option_name if value.nil?
463
482
  raise OptionParser::MissingArgument, option_name if value.match?(/\A-[A-Za-z]/)
@@ -484,7 +503,7 @@ module Harnex
484
503
  case arg
485
504
  when "--"
486
505
  return false
487
- when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--legacy-pty"
506
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--fast", "--legacy-pty"
488
507
  nil
489
508
  when /\A--tmux=/
490
509
  nil
@@ -538,6 +557,25 @@ module Harnex
538
557
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
539
558
  end
540
559
 
560
+ def apply_codex_service_tier(cli_name, child_args)
561
+ return child_args unless cli_name.to_s == "codex"
562
+ return child_args if child_service_tier_config?(child_args)
563
+
564
+ child_args + ["-c", "service_tier=\"#{@options[:fast] ? 'fast' : 'flex'}\""]
565
+ end
566
+
567
+ def child_service_tier_config?(child_args)
568
+ child_args.each_with_index.any? do |arg, index|
569
+ text = arg.to_s
570
+ text.start_with?("service_tier=") ||
571
+ text.start_with?("service_tier.") ||
572
+ text == "-c" && child_args[index + 1].to_s.start_with?("service_tier") ||
573
+ text == "--config" && child_args[index + 1].to_s.start_with?("service_tier") ||
574
+ text.start_with?("-cservice_tier") ||
575
+ text.start_with?("--config=service_tier")
576
+ end
577
+ end
578
+
541
579
  def parse_non_negative_integer(value, option_name:)
542
580
  integer = Integer(value)
543
581
  raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
@@ -6,8 +6,13 @@ require "uri"
6
6
  module Harnex
7
7
  class Waiter
8
8
  POLL_INTERVAL = 0.5
9
+ EVENT_POLL_INTERVAL = 0.1
10
+ EXIT_STATUS_GRACE_SECONDS_DEFAULT = 5.0
11
+ EXIT_STATUS_GRACE_POLL_INTERVAL = 0.05
12
+ FINAL_EVENT_GRACE_SECONDS = 5.0
9
13
 
10
14
  EVENT_PREDICATES = %w[task_complete].freeze
15
+ LEGACY_EVENT_TYPES = %w[agent_state exited task_complete].freeze
11
16
 
12
17
  def self.usage(program_name = "harnex wait")
13
18
  <<~TEXT
@@ -84,30 +89,17 @@ module Harnex
84
89
 
85
90
  offset = 0
86
91
  task_complete_seen = false
92
+ final_event_deadline = nil
87
93
 
88
94
  # Replay existing events first — we may already be past the predicate.
89
- if File.exist?(events_path)
90
- File.open(events_path, "r") do |f|
91
- f.each_line do |line|
92
- offset = f.pos
93
- event = parse_event(line)
94
- next unless event
95
- task_complete_seen = true if event["type"] == "task_complete"
96
- if matches?(event, predicate, task_complete_seen)
97
- return emit_event_match(event, start_time)
98
- end
99
- end
100
- end
101
- end
95
+ status, offset, task_complete_seen = scan_events(events_path, offset, predicate, task_complete_seen, start_time)
96
+ return status if status
102
97
 
103
98
  target_pid = registry && registry["pid"]
104
99
 
105
100
  loop do
106
- if target_pid && !Harnex.alive_pid?(target_pid)
107
- waited = (Time.now - start_time).round(1)
108
- puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
109
- return 1
110
- end
101
+ status, offset, task_complete_seen = scan_events(events_path, offset, predicate, task_complete_seen, start_time)
102
+ return status if status
111
103
 
112
104
  if deadline && Time.now >= deadline
113
105
  waited = (Time.now - start_time).round(1)
@@ -115,34 +107,62 @@ module Harnex
115
107
  return 124
116
108
  end
117
109
 
118
- if File.exist?(events_path) && File.size(events_path) > offset
119
- File.open(events_path, "r") do |f|
120
- f.seek(offset)
121
- f.each_line do |line|
122
- event = parse_event(line)
123
- next unless event
124
- task_complete_seen = true if event["type"] == "task_complete"
125
- if matches?(event, predicate, task_complete_seen)
126
- offset = f.pos
127
- return emit_event_match(event, start_time)
128
- end
129
- end
130
- offset = f.pos
110
+ if target_pid && !Harnex.alive_pid?(target_pid)
111
+ final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
112
+ if Time.now >= final_event_deadline
113
+ waited = (Time.now - start_time).round(1)
114
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
115
+ return 1
131
116
  end
117
+ else
118
+ final_event_deadline = nil
132
119
  end
133
120
 
134
- sleep 0.1
121
+ sleep EVENT_POLL_INTERVAL
122
+ end
123
+ end
124
+
125
+ def scan_events(path, offset, predicate, task_complete_seen, start_time)
126
+ return [nil, offset, task_complete_seen] unless File.exist?(path) && File.size(path) > offset
127
+
128
+ File.open(path, "r") do |f|
129
+ f.seek(offset)
130
+ f.each_line do |line|
131
+ event = parse_event(line)
132
+ next unless event
133
+
134
+ task_complete_seen = true if event_type(event) == "task_complete"
135
+ if matches?(event, predicate, task_complete_seen)
136
+ return [emit_event_match(event, start_time), f.pos, task_complete_seen]
137
+ end
138
+ end
139
+ offset = f.pos
135
140
  end
141
+
142
+ [nil, offset, task_complete_seen]
136
143
  end
137
144
 
138
145
  def parse_event(line)
139
- JSON.parse(line)
146
+ event = JSON.parse(line)
147
+ event.is_a?(Hash) ? event : nil
140
148
  rescue JSON::ParserError
141
- nil
149
+ legacy_type = line.to_s.strip
150
+ return nil unless LEGACY_EVENT_TYPES.include?(legacy_type)
151
+
152
+ { "type" => legacy_type }
142
153
  end
143
154
 
144
- def matches?(event, predicate, task_complete_seen)
155
+ def event_type(event)
145
156
  type = event["type"]
157
+ return type if type.is_a?(String) && !type.empty?
158
+
159
+ legacy_type = event["terminal_event"] || event["event"]
160
+ legacy_type = legacy_type.to_s
161
+ LEGACY_EVENT_TYPES.include?(legacy_type) ? legacy_type : nil
162
+ end
163
+
164
+ def matches?(event, predicate, task_complete_seen)
165
+ type = event_type(event)
146
166
  case predicate
147
167
  when "task_complete"
148
168
  type == "task_complete"
@@ -159,7 +179,7 @@ module Harnex
159
179
  puts JSON.generate(
160
180
  ok: true,
161
181
  id: @options[:id],
162
- event: event["type"],
182
+ event: event_type(event),
163
183
  seq: event["seq"],
164
184
  waited_seconds: waited
165
185
  )
@@ -227,6 +247,7 @@ module Harnex
227
247
 
228
248
  loop do
229
249
  unless Harnex.alive_pid?(target_pid)
250
+ await_exit_status(exit_path)
230
251
  return read_exit_status(exit_path, @options[:id])
231
252
  end
232
253
 
@@ -239,6 +260,26 @@ module Harnex
239
260
  end
240
261
  end
241
262
 
263
+ # Subprocess death races the parent's DISPATCH-row write; the exit-status
264
+ # file is written *after* the row, so polling it bounds the race.
265
+ def await_exit_status(exit_path)
266
+ return if File.exist?(exit_path)
267
+
268
+ grace_deadline = Time.now + exit_status_grace_seconds
269
+ until File.exist?(exit_path) || Time.now >= grace_deadline
270
+ sleep EXIT_STATUS_GRACE_POLL_INTERVAL
271
+ end
272
+ end
273
+
274
+ def exit_status_grace_seconds
275
+ override = ENV["HARNEX_EXIT_STATUS_GRACE_SECONDS"]
276
+ return EXIT_STATUS_GRACE_SECONDS_DEFAULT if override.to_s.strip.empty?
277
+
278
+ Float(override)
279
+ rescue ArgumentError
280
+ EXIT_STATUS_GRACE_SECONDS_DEFAULT
281
+ end
282
+
242
283
  def fetch_agent_state(host, port, token)
243
284
  uri = URI("http://#{host}:#{port}/status")
244
285
  request = Net::HTTP::Get.new(uri)
data/lib/harnex/core.rb CHANGED
@@ -85,10 +85,10 @@ module Harnex
85
85
  end
86
86
 
87
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)
88
+ root = repo_root.to_s
89
+ return nil if root.empty?
90
90
 
91
- File.join(koder_dir, "DISPATCH.jsonl")
91
+ File.join(root, ".harnex", "dispatch.jsonl")
92
92
  end
93
93
 
94
94
  def git_capture_start(repo_root)
@@ -0,0 +1,112 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "open3"
4
+ require "time"
5
+
6
+ module Harnex
7
+ module DispatchHistory
8
+ module_function
9
+
10
+ MAX_REPO_WALK_LEVELS = 10
11
+
12
+ def global_path
13
+ File.join(STATE_DIR, "dispatch.jsonl")
14
+ end
15
+
16
+ def path_for(start_path = Dir.pwd, global: false)
17
+ return global_path if global
18
+
19
+ repo_root = find_git_root(start_path)
20
+ return global_path unless repo_root
21
+
22
+ File.join(repo_root, ".harnex", "dispatch.jsonl")
23
+ end
24
+
25
+ def find_git_root(start_path)
26
+ path = File.expand_path(start_path.to_s.empty? ? Dir.pwd : start_path)
27
+ path = File.dirname(path) unless File.directory?(path)
28
+
29
+ git_root = git_toplevel(path)
30
+ return git_root if git_root
31
+
32
+ (MAX_REPO_WALK_LEVELS + 1).times do
33
+ return path if File.directory?(File.join(path, ".git"))
34
+
35
+ parent = File.dirname(path)
36
+ break if parent == path
37
+
38
+ path = parent
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ def git_toplevel(path)
45
+ output, status = Open3.capture2("git", "-C", path, "rev-parse", "--show-toplevel", err: File::NULL)
46
+ root = output.to_s.strip
47
+ return root if status.success? && !root.empty?
48
+
49
+ nil
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def append(path, record)
55
+ FileUtils.mkdir_p(File.dirname(path))
56
+ line = JSON.generate(record) + "\n"
57
+ File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
58
+ file.flock(File::LOCK_EX)
59
+ file.write(line)
60
+ ensure
61
+ file.flock(File::LOCK_UN) unless file.closed?
62
+ end
63
+ end
64
+
65
+ def build_record(session)
66
+ ended_at = session.ended_at || Time.now
67
+ status, terminal_event = classify(session)
68
+ {
69
+ schema_version: 1,
70
+ id: session.id,
71
+ description: session.description,
72
+ cli: session.adapter.key,
73
+ started_at: session.started_at.utc.iso8601,
74
+ ended_at: ended_at.utc.iso8601,
75
+ duration_s: (ended_at - session.started_at).to_i,
76
+ status: status,
77
+ terminal_event: terminal_event,
78
+ commit_sha: commit_sha(session.git_start, session.git_end),
79
+ tier: session.__send__(:meta_hash)["tier"],
80
+ meta: session.__send__(:meta_hash),
81
+ summary_out_path: session.summary_out,
82
+ events_log_path: session.events_log_path,
83
+ tmux_state: tmux_state(session.__send__(:summary_tmux_session))
84
+ }
85
+ end
86
+
87
+ def classify(session)
88
+ return ["completed", "task_complete"] if session.task_complete?
89
+ return ["timeout", "timeout"] if session.exit_code == 124
90
+ return ["killed", "process_kill"] if session.term_signal
91
+ return ["completed", "process_exit"] if session.exit_code == 0
92
+
93
+ ["failed", "dispatch_failed"]
94
+ end
95
+
96
+ def commit_sha(git_start, git_end)
97
+ start_sha = git_start[:sha].to_s
98
+ end_sha = git_end[:sha].to_s
99
+ return nil if start_sha.empty? || end_sha.empty? || start_sha == end_sha
100
+
101
+ end_sha
102
+ end
103
+
104
+ def tmux_state(tmux_session)
105
+ return "torn-down" if tmux_session.to_s.empty?
106
+
107
+ system("tmux", "has-session", "-t", tmux_session.to_s, out: File::NULL, err: File::NULL) ? "live" : "torn-down"
108
+ rescue StandardError
109
+ "torn-down"
110
+ end
111
+ end
112
+ end