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.
@@ -0,0 +1,348 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Harnex
5
+ module Codex
6
+ module AppServer
7
+ # Extracted from Adapters::CodexAppServer per issue #41 Slice A.
8
+ # Pure move; behavior unchanged.
9
+ class Client
10
+ attr_reader :pid
11
+
12
+ def initialize(read_io:, write_io:, pid: nil)
13
+ @read_io = read_io
14
+ @write_io = write_io
15
+ @pid = pid
16
+ @next_id = 1
17
+ @pending = {}
18
+ @id_mutex = Mutex.new
19
+ @write_mutex = Mutex.new
20
+ @notification_handler = nil
21
+ @request_handler = nil
22
+ @disconnect_handler = nil
23
+ @disconnect_signaled = false
24
+ @closed = false
25
+ @reader_thread = nil
26
+ end
27
+
28
+ def on_notification(&block)
29
+ @notification_handler = block
30
+ end
31
+
32
+ # Handler for server-initiated requests (id + method). The block
33
+ # receives (method, params) and returns the response body for the
34
+ # JSON-RPC `result` field, or nil to reject with -32601.
35
+ def on_request(&block)
36
+ @request_handler = block
37
+ end
38
+
39
+ def on_disconnect(&block)
40
+ @disconnect_handler = block
41
+ end
42
+
43
+ def start
44
+ @reader_thread = Thread.new { read_loop }
45
+ end
46
+
47
+ def request(method, params = {})
48
+ raise "codex_appserver client is closed" if @closed
49
+
50
+ queue = Queue.new
51
+ id = @id_mutex.synchronize do
52
+ assigned = @next_id
53
+ @next_id += 1
54
+ @pending[assigned] = queue
55
+ assigned
56
+ end
57
+
58
+ write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
59
+ result = queue.pop
60
+ raise result if result.is_a?(Exception)
61
+
62
+ result
63
+ end
64
+
65
+ def notify(method, params = {})
66
+ return if @closed
67
+
68
+ write_line({ jsonrpc: "2.0", method: method, params: params })
69
+ end
70
+
71
+ def close
72
+ return if @closed
73
+
74
+ @closed = true
75
+
76
+ @id_mutex.synchronize do
77
+ @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
78
+ @pending.clear
79
+ end
80
+
81
+ begin
82
+ @write_io.close unless @write_io.closed?
83
+ rescue IOError
84
+ nil
85
+ end
86
+
87
+ if @pid && process_alive?(@pid)
88
+ sleep 0.05
89
+ begin
90
+ Process.kill("TERM", @pid)
91
+ rescue Errno::ESRCH
92
+ nil
93
+ end
94
+ end
95
+
96
+ @reader_thread&.join(2)
97
+ end
98
+
99
+ def terminate_process(term_grace_seconds:, kill_grace_seconds:)
100
+ return false unless @pid
101
+
102
+ begin
103
+ Process.kill("TERM", @pid)
104
+ rescue Errno::ESRCH
105
+ return true
106
+ end
107
+
108
+ return true if wait_for_process_exit(@pid, term_grace_seconds)
109
+
110
+ begin
111
+ Process.kill("KILL", @pid)
112
+ rescue Errno::ESRCH
113
+ return true
114
+ end
115
+
116
+ wait_for_process_exit(@pid, kill_grace_seconds)
117
+ end
118
+
119
+ # Plan 30 Phase 2 — deployment-fallback subprocess restart.
120
+ #
121
+ # Spawns a `codex app-server` subprocess against a deployment_config
122
+ # and wraps it in a fresh Client. Caller is responsible for wiring
123
+ # handlers and running the JSON-RPC handshake — or use
124
+ # `spawn_with_fallback` for the full restart-with-resume flow.
125
+ def self.spawn(deployment_config:)
126
+ command = deployment_config[:command] || deployment_config["command"]
127
+ raise ArgumentError, "deployment_config requires :command" if command.nil? || Array(command).empty?
128
+
129
+ env = deployment_config[:env] || deployment_config["env"] || {}
130
+ cwd = deployment_config[:cwd] || deployment_config["cwd"]
131
+
132
+ opts = {}
133
+ opts[:chdir] = cwd if cwd
134
+
135
+ stdin_io, stdout_io, _stderr_io, wait_thr = Open3.popen3(env, *Array(command), **opts)
136
+ new(read_io: stdout_io, write_io: stdin_io, pid: wait_thr.pid)
137
+ end
138
+
139
+ # Plan 30 Phase 2 — full subprocess restart for deployment fallback.
140
+ #
141
+ # Spawns a new subprocess against the alternate deployment, wires
142
+ # the supplied handlers, runs the initialize handshake, and resumes
143
+ # the prior threadId. Returns the started Client.
144
+ #
145
+ # Handlers are installed *before* the reader thread starts so no
146
+ # post-handshake notification is dropped.
147
+ def self.spawn_with_fallback(prior_thread_id:, deployment_config:, handshake_params:,
148
+ notification_handler: nil, request_handler: nil,
149
+ disconnect_handler: nil)
150
+ raise ArgumentError, "prior_thread_id required" if prior_thread_id.nil? || prior_thread_id.to_s.empty?
151
+
152
+ client = spawn(deployment_config: deployment_config)
153
+ client.on_notification(&notification_handler) if notification_handler
154
+ client.on_request(&request_handler) if request_handler
155
+ client.on_disconnect(&disconnect_handler) if disconnect_handler
156
+ client.start
157
+
158
+ begin
159
+ client.request("initialize", handshake_params)
160
+ client.notify("initialized", {})
161
+ client.request("thread/resume", { threadId: prior_thread_id })
162
+ rescue StandardError
163
+ client.close
164
+ raise
165
+ end
166
+
167
+ client
168
+ end
169
+
170
+ # Plan 30 Phase 2 — graceful stop ahead of a deployment switch.
171
+ #
172
+ # Best-effort: issues `turn/interrupt` for any in-flight turn
173
+ # (bounded by interrupt_grace_seconds), drains pending RPC, and
174
+ # tears the subprocess down with the same TERM/KILL escalation
175
+ # used by `terminate_process`.
176
+ def stop_for_fallback(in_flight_turn: nil,
177
+ term_grace_seconds: 0.5,
178
+ kill_grace_seconds: 1.0,
179
+ interrupt_grace_seconds: 0.5)
180
+ if in_flight_turn && !@closed
181
+ interrupt_thread = Thread.new do
182
+ begin
183
+ request("turn/interrupt", in_flight_turn)
184
+ rescue StandardError
185
+ nil
186
+ end
187
+ end
188
+ interrupt_thread.kill unless interrupt_thread.join(interrupt_grace_seconds)
189
+ end
190
+
191
+ return true if @closed
192
+
193
+ @closed = true
194
+
195
+ fail_pending_requests(StandardError.new("codex_appserver client closed for fallback"))
196
+
197
+ begin
198
+ @write_io.close unless @write_io.closed?
199
+ rescue IOError
200
+ nil
201
+ end
202
+
203
+ terminated =
204
+ if @pid
205
+ terminate_process(
206
+ term_grace_seconds: term_grace_seconds,
207
+ kill_grace_seconds: kill_grace_seconds
208
+ )
209
+ else
210
+ true
211
+ end
212
+
213
+ @reader_thread&.join(2)
214
+ terminated
215
+ end
216
+
217
+ private
218
+
219
+ def write_line(message)
220
+ @write_mutex.synchronize do
221
+ @write_io.write(JSON.generate(message))
222
+ @write_io.write("\n")
223
+ @write_io.flush
224
+ end
225
+ rescue Errno::EPIPE, IOError
226
+ signal_disconnect(nil)
227
+ end
228
+
229
+ def read_loop
230
+ buffer = +""
231
+ loop do
232
+ chunk = @read_io.readpartial(4096)
233
+ buffer << chunk
234
+ while (idx = buffer.index("\n"))
235
+ line = buffer.slice!(0, idx + 1).chomp
236
+ next if line.strip.empty?
237
+
238
+ handle_line(line)
239
+ end
240
+ end
241
+ rescue EOFError, IOError, Errno::EIO
242
+ nil
243
+ ensure
244
+ signal_disconnect(nil)
245
+ end
246
+
247
+ def handle_line(line)
248
+ message = JSON.parse(line)
249
+ rescue JSON::ParserError => e
250
+ signal_disconnect(e)
251
+ return
252
+ else
253
+ dispatch_message(message)
254
+ end
255
+
256
+ def dispatch_message(message)
257
+ if message["id"] && message["method"]
258
+ handle_server_request(message)
259
+ return
260
+ end
261
+
262
+ if message.key?("id")
263
+ pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
264
+ return unless pending
265
+
266
+ if message["error"]
267
+ err_msg = message.dig("error", "message") || "RPC error"
268
+ pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
269
+ signal_disconnect(message["error"])
270
+ else
271
+ pending.push(message["result"] || {})
272
+ end
273
+ return
274
+ end
275
+
276
+ @notification_handler&.call(message) if message["method"]
277
+ end
278
+
279
+ def handle_server_request(message)
280
+ result =
281
+ begin
282
+ @request_handler&.call(message["method"], message["params"] || {})
283
+ rescue StandardError
284
+ nil
285
+ end
286
+
287
+ if result.nil?
288
+ write_line({
289
+ jsonrpc: "2.0",
290
+ id: message["id"],
291
+ error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
292
+ })
293
+ else
294
+ write_line({
295
+ jsonrpc: "2.0",
296
+ id: message["id"],
297
+ result: result
298
+ })
299
+ end
300
+ end
301
+
302
+ def signal_disconnect(error)
303
+ return if @disconnect_signaled
304
+
305
+ @disconnect_signaled = true
306
+ fail_pending_requests(error)
307
+ @disconnect_handler&.call(error)
308
+ end
309
+
310
+ def fail_pending_requests(error)
311
+ exception =
312
+ if error.is_a?(Exception)
313
+ error
314
+ else
315
+ message = error.is_a?(Hash) ? error["message"] : nil
316
+ StandardError.new(message.to_s.empty? ? "codex_appserver disconnected" : message.to_s)
317
+ end
318
+
319
+ @id_mutex.synchronize do
320
+ @pending.each_value { |queue| queue.push(exception) }
321
+ @pending.clear
322
+ end
323
+ end
324
+
325
+ def process_alive?(pid)
326
+ Process.kill(0, pid)
327
+ true
328
+ rescue Errno::ESRCH, Errno::EPERM
329
+ false
330
+ end
331
+
332
+ def wait_for_process_exit(pid, timeout_seconds)
333
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
334
+ loop do
335
+ return true unless process_alive?(pid)
336
+
337
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
338
+ break if remaining <= 0
339
+
340
+ sleep([0.05, remaining].min)
341
+ end
342
+
343
+ !process_alive?(pid)
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "optparse"
2
3
 
3
4
  module Harnex
4
5
  class Doctor
@@ -6,29 +7,40 @@ module Harnex
6
7
 
7
8
  def self.usage
8
9
  <<~TEXT
9
- Usage: harnex doctor
10
+ Usage: harnex doctor [--sweep]
10
11
 
11
12
  Runs preflight checks for harnex's adapter dependencies.
12
13
  Currently verifies that Codex CLI is installed and at version
13
14
  >= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
14
15
  adapter).
15
16
 
17
+ Options:
18
+ --sweep Include a read-only report of harnex/tmux session drift
19
+ -h, --help Show this help
20
+
16
21
  Common patterns:
17
22
  harnex doctor
23
+ harnex doctor --sweep
18
24
  harnex doctor --help
19
25
 
20
26
  Gotchas:
21
27
  doctor validates local adapter prerequisites; it does not start sessions.
28
+ --sweep is diagnostic only; it does not stop sessions or remove files.
22
29
  Run it after installing or upgrading Codex CLI.
23
30
  TEXT
24
31
  end
25
32
 
26
33
  def initialize(argv = [])
27
34
  @argv = argv.dup
35
+ @options = {
36
+ sweep: false,
37
+ help: false
38
+ }
28
39
  end
29
40
 
30
41
  def run
31
- if @argv.include?("-h") || @argv.include?("--help")
42
+ parser.parse!(@argv)
43
+ if @options[:help]
32
44
  puts self.class.usage
33
45
  return 0
34
46
  end
@@ -38,12 +50,21 @@ module Harnex
38
50
  ok: checks.all? { |c| c[:ok] },
39
51
  checks: checks
40
52
  }
53
+ summary[:sweep] = sweep_payload if @options[:sweep]
41
54
  puts JSON.generate(summary)
42
55
  summary[:ok] ? 0 : 1
43
56
  end
44
57
 
45
58
  private
46
59
 
60
+ def parser
61
+ @parser ||= OptionParser.new do |opts|
62
+ opts.banner = "Usage: harnex doctor [--sweep]"
63
+ opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
64
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
65
+ end
66
+ end
67
+
47
68
  def check_codex
48
69
  result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
49
70
 
@@ -75,6 +96,78 @@ module Harnex
75
96
  [e.message, nil]
76
97
  end
77
98
 
99
+ def sweep_payload
100
+ active, stale = read_session_registry
101
+ tmux_windows = read_tmux_windows
102
+ live_ids = active.map { |session| session["id"].to_s }.to_set
103
+
104
+ {
105
+ harnex_sessions: active,
106
+ tmux_windows_cx: tmux_windows,
107
+ orphan_tmux: tmux_windows.reject { |window| live_ids.include?(window[:session]) || live_ids.include?(window[:window]) },
108
+ stale_pid_files: stale
109
+ }
110
+ end
111
+
112
+ def read_session_registry
113
+ return [[], []] unless Dir.exist?(Harnex::SESSIONS_DIR)
114
+
115
+ active = []
116
+ stale = []
117
+
118
+ Dir.glob(File.join(Harnex::SESSIONS_DIR, "*.json")).sort.each do |path|
119
+ data = JSON.parse(File.read(path)).merge("registry_path" => path)
120
+ if data["pid"] && Harnex.alive_pid?(data["pid"])
121
+ active << data
122
+ else
123
+ stale << stale_session_files(data, path)
124
+ end
125
+ rescue JSON::ParserError
126
+ stale << { registry_path: path, error: "invalid_json" }
127
+ end
128
+
129
+ [active.sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }.reverse, stale]
130
+ end
131
+
132
+ def stale_session_files(data, registry_path)
133
+ repo_root = data["repo_root"].to_s
134
+ id = data["id"].to_s
135
+ slug = repo_root.empty? || id.empty? ? nil : Harnex.session_file_slug(repo_root, id)
136
+ output_log_path = slug ? File.join(Harnex::STATE_DIR, "output", "#{slug}.log") : nil
137
+ events_log_path = slug ? File.join(Harnex::STATE_DIR, "events", "#{slug}.jsonl") : nil
138
+
139
+ {
140
+ id: id.empty? ? nil : id,
141
+ pid: data["pid"],
142
+ repo_root: repo_root.empty? ? nil : repo_root,
143
+ registry_path: registry_path,
144
+ output_log_path: file_or_nil(output_log_path),
145
+ events_log_path: file_or_nil(events_log_path)
146
+ }
147
+ end
148
+
149
+ def read_tmux_windows
150
+ output, status = capture("tmux list-windows -a -F '\#{session_name}\t\#{window_name}\t\#{pane_pid}'")
151
+ return [] unless status&.success?
152
+
153
+ output.lines.filter_map do |line|
154
+ session, window, pid = line.chomp.split("\t", 3)
155
+ next unless session.to_s.start_with?("cx-") || window.to_s.start_with?("cx-")
156
+
157
+ { session: session, window: window, pid: integer_or_nil(pid) }
158
+ end
159
+ end
160
+
161
+ def file_or_nil(path)
162
+ path if path && File.exist?(path)
163
+ end
164
+
165
+ def integer_or_nil(value)
166
+ Integer(value)
167
+ rescue ArgumentError, TypeError
168
+ nil
169
+ end
170
+
78
171
  def parse_version(text)
79
172
  match = text.match(/(\d+\.\d+\.\d+)/)
80
173
  match ? Gem::Version.new(match[1]) : nil
@@ -0,0 +1,149 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "time"
4
+
5
+ module Harnex
6
+ class History
7
+ DEFAULT_LIMIT = 5
8
+
9
+ def self.usage(program_name = "harnex history")
10
+ <<~TEXT
11
+ Usage: #{program_name} [options]
12
+
13
+ Options:
14
+ --limit N Number of records to show (default: #{DEFAULT_LIMIT})
15
+ --since DUR Only show records started within DUR (examples: 1h, 1d)
16
+ --id TEXT Filter records whose id contains TEXT
17
+ --global Read the global no-repo history file
18
+ --json Output JSONL instead of a table
19
+ --all Show all matching records
20
+ -h, --help Show this help
21
+ TEXT
22
+ end
23
+
24
+ def initialize(argv)
25
+ @argv = argv.dup
26
+ @options = {
27
+ limit: DEFAULT_LIMIT,
28
+ since: nil,
29
+ id: nil,
30
+ global: false,
31
+ json: false,
32
+ all: false,
33
+ help: false
34
+ }
35
+ end
36
+
37
+ def run
38
+ parser.parse!(@argv)
39
+ if @options[:help]
40
+ puts self.class.usage
41
+ return 0
42
+ end
43
+
44
+ records = filtered_records
45
+ if @options[:json]
46
+ records.each { |record| puts JSON.generate(record) }
47
+ elsif !records.empty?
48
+ puts render_table(records)
49
+ end
50
+ 0
51
+ end
52
+
53
+ private
54
+
55
+ def parser
56
+ @parser ||= OptionParser.new do |opts|
57
+ opts.banner = "Usage: harnex history [options]"
58
+ opts.on("--limit N", Integer, "Number of records to show") do |value|
59
+ raise OptionParser::InvalidArgument, "--limit must be greater than 0" if value <= 0
60
+
61
+ @options[:limit] = value
62
+ end
63
+ opts.on("--since DUR", "Only show records started within DUR") { |value| @options[:since] = parse_since(value) }
64
+ opts.on("--id TEXT", "Filter records by id substring") { |value| @options[:id] = value.to_s }
65
+ opts.on("--global", "Read the global no-repo history file") { @options[:global] = true }
66
+ opts.on("--json", "Output JSONL") { @options[:json] = true }
67
+ opts.on("--all", "Show all matching records") { @options[:all] = true }
68
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
69
+ end
70
+ end
71
+
72
+ def parse_since(value)
73
+ Time.now - Harnex.parse_duration_seconds(value, option_name: "--since")
74
+ end
75
+
76
+ def filtered_records
77
+ records = load_records
78
+ records = records.select { |record| record["id"].to_s.include?(@options[:id]) } if @options[:id]
79
+ records = records.select { |record| started_after?(record, @options[:since]) } if @options[:since]
80
+ records = records.last(@options[:limit]) unless @options[:all]
81
+ records
82
+ end
83
+
84
+ def load_records
85
+ path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
86
+ return [] unless File.file?(path)
87
+
88
+ File.readlines(path, chomp: true).filter_map do |line|
89
+ next if line.strip.empty?
90
+
91
+ JSON.parse(line)
92
+ rescue JSON::ParserError
93
+ nil
94
+ end
95
+ end
96
+
97
+ def started_after?(record, floor)
98
+ Time.iso8601(record.fetch("started_at")) >= floor
99
+ rescue KeyError, ArgumentError
100
+ false
101
+ end
102
+
103
+ def render_table(records)
104
+ columns = ["ID", "STARTED", "DURATION", "STATUS", "COMMIT", "TERMINAL", "TMUX"]
105
+ rows = records.map { |record| table_row(record) }
106
+ widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
107
+
108
+ ([columns.to_h { |column| [column, column] }] + rows)
109
+ .map { |row| columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ") }
110
+ .join("\n")
111
+ end
112
+
113
+ def table_row(record)
114
+ {
115
+ "ID" => record["id"].to_s,
116
+ "STARTED" => format_started(record["started_at"]),
117
+ "DURATION" => format_duration(record["duration_s"]),
118
+ "STATUS" => record["status"].to_s,
119
+ "COMMIT" => short_commit(record["commit_sha"]),
120
+ "TERMINAL" => record["terminal_event"].to_s,
121
+ "TMUX" => record["tmux_state"].to_s
122
+ }
123
+ end
124
+
125
+ def format_started(value)
126
+ Time.iso8601(value.to_s).getlocal.strftime("%Y-%m-%d %H:%M")
127
+ rescue ArgumentError
128
+ "-"
129
+ end
130
+
131
+ def format_duration(value)
132
+ seconds = Integer(value || 0)
133
+ hours = seconds / 3600
134
+ minutes = (seconds % 3600) / 60
135
+ rest = seconds % 60
136
+ return "#{hours}h#{minutes.to_s.rjust(2, '0')}m" if hours.positive?
137
+ return "#{minutes}m#{rest.to_s.rjust(2, '0')}s" if minutes.positive?
138
+
139
+ "#{rest}s"
140
+ rescue ArgumentError
141
+ "-"
142
+ end
143
+
144
+ def short_commit(value)
145
+ text = value.to_s
146
+ text.empty? ? "-" : text[0, 8]
147
+ end
148
+ end
149
+ end