harnex 0.3.4 → 0.5.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/GUIDE.md +11 -0
- data/README.md +37 -5
- data/TECHNICAL.md +72 -16
- data/lib/harnex/adapters/base.rb +4 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/cli.rb +7 -0
- data/lib/harnex/commands/events.rb +212 -0
- data/lib/harnex/commands/run.rb +165 -14
- data/lib/harnex/commands/status.rb +17 -3
- data/lib/harnex/commands/watch.rb +209 -0
- data/lib/harnex/commands/watch_presets.rb +17 -0
- data/lib/harnex/core.rb +109 -0
- data/lib/harnex/runtime/session.rb +280 -3
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- data/skills/harnex-buddy/SKILL.md +5 -0
- data/skills/harnex-dispatch/SKILL.md +16 -0
- metadata +5 -2
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -6,10 +6,13 @@ module Harnex
|
|
|
6
6
|
class Runner
|
|
7
7
|
DEFAULT_TIMEOUT = 5.0
|
|
8
8
|
KNOWN_FLAGS = %w[
|
|
9
|
-
--id --description --detach --tmux --host --port --watch --
|
|
9
|
+
--id --description --detach --tmux --host --port --watch --watch-file
|
|
10
|
+
--stall-after --max-resumes --preset --context --meta --summary-out
|
|
11
|
+
--timeout --inbox-ttl --help
|
|
10
12
|
].freeze
|
|
11
13
|
VALUE_FLAGS = %w[
|
|
12
|
-
--id --description --host --port --watch --
|
|
14
|
+
--id --description --host --port --watch --watch-file --stall-after
|
|
15
|
+
--max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
|
|
13
16
|
].freeze
|
|
14
17
|
|
|
15
18
|
def self.usage(program_name = "harnex run")
|
|
@@ -23,13 +26,22 @@ module Harnex
|
|
|
23
26
|
--tmux [NAME] Run in a tmux window (implies --detach)
|
|
24
27
|
--host HOST Bind host for the local API (default: #{DEFAULT_HOST})
|
|
25
28
|
--port PORT Force a specific local API port
|
|
26
|
-
--watch
|
|
29
|
+
--watch Enable blocking babysitter mode (foreground only)
|
|
30
|
+
--stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
|
|
31
|
+
--max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
|
|
32
|
+
--preset NAME Watch preset: impl, plan, gate (requires --watch)
|
|
33
|
+
--watch-file PATH Auto-send a file-change hook on modification
|
|
27
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
|
|
28
37
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
29
38
|
--inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
|
|
30
39
|
-h, --help Show this help
|
|
31
40
|
|
|
32
41
|
Notes:
|
|
42
|
+
Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
|
|
43
|
+
Bare `--watch` enables the babysitter.
|
|
44
|
+
Explicit --stall-after/--max-resumes values override --preset defaults.
|
|
33
45
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
34
46
|
Any other CLI name is launched with generic wrapping.
|
|
35
47
|
Wrapper options may appear before or after <cli>.
|
|
@@ -43,8 +55,16 @@ module Harnex
|
|
|
43
55
|
description: nil,
|
|
44
56
|
host: DEFAULT_HOST,
|
|
45
57
|
port: nil,
|
|
58
|
+
watch_enabled: false,
|
|
59
|
+
stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
|
|
60
|
+
stall_after_explicit: false,
|
|
61
|
+
max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
|
|
62
|
+
max_resumes_explicit: false,
|
|
63
|
+
preset: nil,
|
|
46
64
|
watch: nil,
|
|
47
65
|
context: nil,
|
|
66
|
+
meta: nil,
|
|
67
|
+
summary_out: nil,
|
|
48
68
|
detach: false,
|
|
49
69
|
tmux: false,
|
|
50
70
|
tmux_name: nil,
|
|
@@ -64,13 +84,18 @@ module Harnex
|
|
|
64
84
|
raise OptionParser::MissingArgument, "cli" if cli_name.nil?
|
|
65
85
|
|
|
66
86
|
repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
|
|
87
|
+
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
67
88
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
68
89
|
validate_unique_id!(repo_root)
|
|
69
90
|
effective_child_args = apply_context(child_args)
|
|
70
91
|
adapter = Harnex.build_adapter(cli_name, effective_child_args)
|
|
71
92
|
@options[:detach] = true if @options[:tmux]
|
|
93
|
+
validate_watch_mode!
|
|
94
|
+
resolve_watch_preset!
|
|
72
95
|
|
|
73
|
-
if @options[:
|
|
96
|
+
if @options[:watch_enabled]
|
|
97
|
+
run_watch_mode(adapter, repo_root)
|
|
98
|
+
elsif @options[:detach]
|
|
74
99
|
run_detached(adapter, cli_name, child_args, repo_root)
|
|
75
100
|
else
|
|
76
101
|
run_foreground(adapter, repo_root)
|
|
@@ -90,10 +115,25 @@ module Harnex
|
|
|
90
115
|
if @options[:tmux]
|
|
91
116
|
run_in_tmux(cli_name, child_args, repo_root)
|
|
92
117
|
else
|
|
93
|
-
run_headless(adapter, repo_root)
|
|
118
|
+
result = run_headless(adapter, repo_root)
|
|
119
|
+
result[:exit_code]
|
|
94
120
|
end
|
|
95
121
|
end
|
|
96
122
|
|
|
123
|
+
def run_watch_mode(adapter, repo_root)
|
|
124
|
+
Session.validate_binary!(adapter.build_command)
|
|
125
|
+
|
|
126
|
+
result = run_headless(adapter, repo_root, emit_payload: false)
|
|
127
|
+
return result[:exit_code] unless result[:ok]
|
|
128
|
+
|
|
129
|
+
RunWatcher.new(
|
|
130
|
+
id: @options[:id],
|
|
131
|
+
repo_root: repo_root,
|
|
132
|
+
stall_after_s: @options[:stall_after_s],
|
|
133
|
+
max_resumes: @options[:max_resumes]
|
|
134
|
+
).run
|
|
135
|
+
end
|
|
136
|
+
|
|
97
137
|
def run_in_tmux(cli_name, child_args, repo_root)
|
|
98
138
|
harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
|
|
99
139
|
tmux_cmd = [harnex_bin, "run", cli_name]
|
|
@@ -101,8 +141,10 @@ module Harnex
|
|
|
101
141
|
tmux_cmd += ["--description", @options[:description]] if @options[:description]
|
|
102
142
|
tmux_cmd += ["--host", @options[:host]]
|
|
103
143
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
104
|
-
tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
|
|
144
|
+
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
105
145
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
146
|
+
tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
|
|
147
|
+
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
106
148
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
107
149
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
108
150
|
|
|
@@ -137,7 +179,7 @@ module Harnex
|
|
|
137
179
|
0
|
|
138
180
|
end
|
|
139
181
|
|
|
140
|
-
def run_headless(adapter, repo_root)
|
|
182
|
+
def run_headless(adapter, repo_root, emit_payload: true)
|
|
141
183
|
log_dir = File.join(Harnex::STATE_DIR, "logs")
|
|
142
184
|
FileUtils.mkdir_p(log_dir)
|
|
143
185
|
log_path = File.join(log_dir, "#{@options[:id]}.log")
|
|
@@ -159,7 +201,7 @@ module Harnex
|
|
|
159
201
|
Process.detach(child_pid)
|
|
160
202
|
|
|
161
203
|
registry = wait_for_registration(repo_root)
|
|
162
|
-
return registration_timeout(@options[:id]) unless registry
|
|
204
|
+
return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
|
|
163
205
|
|
|
164
206
|
payload = {
|
|
165
207
|
ok: true,
|
|
@@ -172,12 +214,19 @@ module Harnex
|
|
|
172
214
|
output_log_path: Harnex.output_log_path(repo_root, @options[:id])
|
|
173
215
|
}
|
|
174
216
|
payload[:description] = @options[:description] if @options[:description]
|
|
175
|
-
puts JSON.generate(payload)
|
|
176
|
-
0
|
|
217
|
+
puts JSON.generate(payload) if emit_payload
|
|
218
|
+
{ ok: true, exit_code: 0, registry: registry, payload: payload }
|
|
177
219
|
end
|
|
178
220
|
|
|
179
221
|
private
|
|
180
222
|
|
|
223
|
+
def validate_watch_mode!
|
|
224
|
+
return unless @options[:watch_enabled]
|
|
225
|
+
return unless @options[:detach]
|
|
226
|
+
|
|
227
|
+
raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
|
|
228
|
+
end
|
|
229
|
+
|
|
181
230
|
def validate_unique_id!(repo_root)
|
|
182
231
|
existing = Harnex.read_registry(repo_root, @options[:id])
|
|
183
232
|
return unless existing
|
|
@@ -198,6 +247,8 @@ module Harnex
|
|
|
198
247
|
id: @options[:id],
|
|
199
248
|
watch: watch,
|
|
200
249
|
description: @options[:description],
|
|
250
|
+
meta: @options[:meta],
|
|
251
|
+
summary_out: @options[:summary_out],
|
|
201
252
|
inbox_ttl: @options[:inbox_ttl]
|
|
202
253
|
)
|
|
203
254
|
end
|
|
@@ -294,15 +345,66 @@ module Harnex
|
|
|
294
345
|
when /\A--port=(.+)\z/
|
|
295
346
|
@options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
|
|
296
347
|
when "--watch"
|
|
297
|
-
index
|
|
298
|
-
|
|
348
|
+
value = argv[index + 1]
|
|
349
|
+
if value.nil? || value == "--" || wrapper_option_token?(value)
|
|
350
|
+
@options[:watch_enabled] = true
|
|
351
|
+
else
|
|
352
|
+
index += 1
|
|
353
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
354
|
+
end
|
|
299
355
|
when /\A--watch=(.+)\z/
|
|
300
356
|
@options[:watch] = required_option_value("--watch", Regexp.last_match(1))
|
|
357
|
+
when "--watch-file"
|
|
358
|
+
index += 1
|
|
359
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
360
|
+
when /\A--watch-file=(.+)\z/
|
|
361
|
+
@options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
|
|
362
|
+
when "--stall-after"
|
|
363
|
+
index += 1
|
|
364
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
365
|
+
required_option_value(arg, argv[index]),
|
|
366
|
+
option_name: "--stall-after"
|
|
367
|
+
)
|
|
368
|
+
@options[:stall_after_explicit] = true
|
|
369
|
+
when /\A--stall-after=(.+)\z/
|
|
370
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
371
|
+
required_option_value("--stall-after", Regexp.last_match(1)),
|
|
372
|
+
option_name: "--stall-after"
|
|
373
|
+
)
|
|
374
|
+
@options[:stall_after_explicit] = true
|
|
375
|
+
when "--max-resumes"
|
|
376
|
+
index += 1
|
|
377
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
378
|
+
required_option_value(arg, argv[index]),
|
|
379
|
+
option_name: "--max-resumes"
|
|
380
|
+
)
|
|
381
|
+
@options[:max_resumes_explicit] = true
|
|
382
|
+
when /\A--max-resumes=(.+)\z/
|
|
383
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
384
|
+
required_option_value("--max-resumes", Regexp.last_match(1)),
|
|
385
|
+
option_name: "--max-resumes"
|
|
386
|
+
)
|
|
387
|
+
@options[:max_resumes_explicit] = true
|
|
388
|
+
when "--preset"
|
|
389
|
+
index += 1
|
|
390
|
+
@options[:preset] = required_option_value(arg, argv[index])
|
|
391
|
+
when /\A--preset=(.+)\z/
|
|
392
|
+
@options[:preset] = required_option_value("--preset", Regexp.last_match(1))
|
|
301
393
|
when "--context"
|
|
302
394
|
index += 1
|
|
303
395
|
@options[:context] = required_option_value(arg, argv[index])
|
|
304
396
|
when /\A--context=(.+)\z/
|
|
305
397
|
@options[:context] = required_option_value("--context", Regexp.last_match(1))
|
|
398
|
+
when "--meta"
|
|
399
|
+
index += 1
|
|
400
|
+
@options[:meta] = parse_meta(required_option_value(arg, argv[index]))
|
|
401
|
+
when /\A--meta=(.+)\z/
|
|
402
|
+
@options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
|
|
403
|
+
when "--summary-out"
|
|
404
|
+
index += 1
|
|
405
|
+
@options[:summary_out] = required_option_value(arg, argv[index])
|
|
406
|
+
when /\A--summary-out=(.+)\z/
|
|
407
|
+
@options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
|
|
306
408
|
when "--timeout"
|
|
307
409
|
index += 1
|
|
308
410
|
@options[:timeout] = Float(required_option_value(arg, argv[index]))
|
|
@@ -358,7 +460,9 @@ module Harnex
|
|
|
358
460
|
nil
|
|
359
461
|
when *VALUE_FLAGS
|
|
360
462
|
index += 1
|
|
361
|
-
when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
|
|
463
|
+
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
|
|
464
|
+
nil
|
|
465
|
+
when /\A--preset=/
|
|
362
466
|
nil
|
|
363
467
|
else
|
|
364
468
|
return true
|
|
@@ -372,7 +476,54 @@ module Harnex
|
|
|
372
476
|
def wrapper_option_token?(arg)
|
|
373
477
|
KNOWN_FLAGS.include?(arg) ||
|
|
374
478
|
arg == "-h" ||
|
|
375
|
-
arg.start_with?(
|
|
479
|
+
arg.start_with?(
|
|
480
|
+
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
481
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
|
|
482
|
+
"--timeout=", "--inbox-ttl="
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def resolve_watch_preset!
|
|
487
|
+
preset_name = @options[:preset]
|
|
488
|
+
return if preset_name.nil?
|
|
489
|
+
|
|
490
|
+
unless @options[:watch_enabled]
|
|
491
|
+
raise "harnex run: --preset requires --watch"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
preset = WatchPresets.fetch(preset_name)
|
|
495
|
+
unless preset
|
|
496
|
+
valid = WatchPresets.valid_names.join(", ")
|
|
497
|
+
raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
@options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
|
|
501
|
+
@options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def parse_non_negative_integer(value, option_name:)
|
|
505
|
+
integer = Integer(value)
|
|
506
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
|
|
507
|
+
|
|
508
|
+
integer
|
|
509
|
+
rescue ArgumentError
|
|
510
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def parse_meta(value)
|
|
514
|
+
parsed = JSON.parse(value)
|
|
515
|
+
return parsed if parsed.is_a?(Hash)
|
|
516
|
+
|
|
517
|
+
raise OptionParser::InvalidOption, "--meta must be a JSON object"
|
|
518
|
+
rescue JSON::ParserError => e
|
|
519
|
+
raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def resolve_summary_out(repo_root)
|
|
523
|
+
configured = @options[:summary_out]
|
|
524
|
+
return Harnex.default_summary_out_path(repo_root) if configured.nil?
|
|
525
|
+
|
|
526
|
+
File.expand_path(configured, repo_root)
|
|
376
527
|
end
|
|
377
528
|
|
|
378
529
|
def default_inbox_ttl
|
|
@@ -98,7 +98,7 @@ module Harnex
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def render_table(sessions)
|
|
101
|
-
columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
|
|
101
|
+
columns = ["ID", "CLI", "PID", "PORT", "AGE", "IDLE", "STATE", "REPO", "DESC"]
|
|
102
102
|
|
|
103
103
|
rows = sessions.map { |session| table_row(session, columns) }
|
|
104
104
|
widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
|
|
@@ -117,6 +117,7 @@ module Harnex
|
|
|
117
117
|
"PID" => session["pid"].to_s,
|
|
118
118
|
"PORT" => session["port"].to_s,
|
|
119
119
|
"AGE" => timeago(session["started_at"]),
|
|
120
|
+
"IDLE" => format_idle(session["log_idle_s"]),
|
|
120
121
|
"STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
|
|
121
122
|
"DESC" => truncate(session["description"])
|
|
122
123
|
}
|
|
@@ -133,7 +134,22 @@ module Harnex
|
|
|
133
134
|
|
|
134
135
|
seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
|
|
135
136
|
seconds = 0 if seconds.negative?
|
|
137
|
+
compact_duration(seconds)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
timestamp.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_idle(idle_seconds)
|
|
143
|
+
return "-" if idle_seconds.nil?
|
|
144
|
+
|
|
145
|
+
seconds = Integer(idle_seconds)
|
|
146
|
+
seconds = 0 if seconds.negative?
|
|
147
|
+
compact_duration(seconds)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
"-"
|
|
150
|
+
end
|
|
136
151
|
|
|
152
|
+
def compact_duration(seconds)
|
|
137
153
|
case seconds
|
|
138
154
|
when 0...60
|
|
139
155
|
"#{seconds}s"
|
|
@@ -144,8 +160,6 @@ module Harnex
|
|
|
144
160
|
else
|
|
145
161
|
"#{seconds / 86_400}d"
|
|
146
162
|
end
|
|
147
|
-
rescue StandardError
|
|
148
|
-
timestamp.to_s
|
|
149
163
|
end
|
|
150
164
|
|
|
151
165
|
def truncate(value)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
class RunWatcher
|
|
7
|
+
DEFAULT_STALL_AFTER_S = 8 * 60.0
|
|
8
|
+
DEFAULT_MAX_RESUMES = 1
|
|
9
|
+
POLL_INTERVAL_S = 60.0
|
|
10
|
+
MAX_STATUS_ERRORS = 3
|
|
11
|
+
RESUME_TEXT = "resume"
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
id:,
|
|
15
|
+
repo_root:,
|
|
16
|
+
stall_after_s: DEFAULT_STALL_AFTER_S,
|
|
17
|
+
max_resumes: DEFAULT_MAX_RESUMES,
|
|
18
|
+
poll_interval_s: POLL_INTERVAL_S,
|
|
19
|
+
sleeper: nil,
|
|
20
|
+
monotonic_clock: nil,
|
|
21
|
+
out: $stdout,
|
|
22
|
+
err: $stderr
|
|
23
|
+
)
|
|
24
|
+
@id = Harnex.normalize_id(id)
|
|
25
|
+
@repo_root = repo_root
|
|
26
|
+
@stall_after_s = Float(stall_after_s)
|
|
27
|
+
@max_resumes = Integer(max_resumes)
|
|
28
|
+
@poll_interval_s = Float(poll_interval_s)
|
|
29
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
30
|
+
@monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
31
|
+
@out = out
|
|
32
|
+
@err = err
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
polls = 0
|
|
37
|
+
resumes = 0
|
|
38
|
+
final_state = "unknown"
|
|
39
|
+
outcome = :error
|
|
40
|
+
status_errors = 0
|
|
41
|
+
start_at = now
|
|
42
|
+
|
|
43
|
+
@out.puts(
|
|
44
|
+
"harnex watch: id=#{@id} stall-after=#{format_duration(@stall_after_s)} " \
|
|
45
|
+
"max-resumes=#{@max_resumes} poll=#{format_duration(@poll_interval_s)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
loop do
|
|
49
|
+
polls += 1
|
|
50
|
+
snapshot = fetch_snapshot
|
|
51
|
+
|
|
52
|
+
case snapshot[:kind]
|
|
53
|
+
when :exited
|
|
54
|
+
final_state = "exited"
|
|
55
|
+
outcome = :exited
|
|
56
|
+
@out.puts("harnex watch: session exited")
|
|
57
|
+
break
|
|
58
|
+
when :error
|
|
59
|
+
if snapshot[:fatal]
|
|
60
|
+
@err.puts("harnex watch: #{snapshot[:error]}")
|
|
61
|
+
outcome = :error
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
status_errors += 1
|
|
66
|
+
if status_errors >= MAX_STATUS_ERRORS
|
|
67
|
+
@err.puts("harnex watch: #{snapshot[:error]} (status retry limit reached)")
|
|
68
|
+
outcome = :error
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
when :status
|
|
72
|
+
status_errors = 0
|
|
73
|
+
final_state = snapshot[:agent_state]
|
|
74
|
+
|
|
75
|
+
if snapshot[:stalled]
|
|
76
|
+
if resumes < @max_resumes
|
|
77
|
+
send_resume(snapshot[:registry])
|
|
78
|
+
resumes += 1
|
|
79
|
+
@out.puts(
|
|
80
|
+
"harnex watch: resume #{resumes}/#{@max_resumes} " \
|
|
81
|
+
"(idle=#{format_duration(snapshot[:idle_seconds])}, state=#{final_state})"
|
|
82
|
+
)
|
|
83
|
+
else
|
|
84
|
+
outcome = :escalated
|
|
85
|
+
@out.puts("harnex watch: max resumes reached, escalating")
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@sleeper.call(@poll_interval_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
elapsed = (now - start_at).round(1)
|
|
95
|
+
@out.puts(
|
|
96
|
+
"harnex watch: summary id=#{@id} polls=#{polls} resumes=#{resumes} " \
|
|
97
|
+
"final_state=#{final_state} outcome=#{outcome} elapsed_s=#{elapsed}"
|
|
98
|
+
)
|
|
99
|
+
outcome_to_exit_code(outcome)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
@err.puts("harnex watch: #{e.message}")
|
|
102
|
+
1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def fetch_snapshot
|
|
108
|
+
registry = Harnex.read_registry(@repo_root, @id)
|
|
109
|
+
return { kind: :exited } unless registry
|
|
110
|
+
|
|
111
|
+
status = fetch_status(registry)
|
|
112
|
+
return status if status[:kind] == :error
|
|
113
|
+
|
|
114
|
+
payload = status[:payload]
|
|
115
|
+
unless payload.key?("log_idle_s")
|
|
116
|
+
return {
|
|
117
|
+
kind: :error,
|
|
118
|
+
fatal: true,
|
|
119
|
+
error: "status payload missing log_idle_s; upgrade to a Layer-1+ harnex build"
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
agent_state = payload["agent_state"].to_s.strip
|
|
124
|
+
return { kind: :exited } if agent_state == "exited"
|
|
125
|
+
|
|
126
|
+
idle_seconds = parse_idle_seconds(payload["log_idle_s"])
|
|
127
|
+
{
|
|
128
|
+
kind: :status,
|
|
129
|
+
registry: registry,
|
|
130
|
+
agent_state: agent_state.empty? ? "unknown" : agent_state,
|
|
131
|
+
idle_seconds: idle_seconds,
|
|
132
|
+
stalled: !idle_seconds.nil? && idle_seconds >= @stall_after_s
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fetch_status(registry)
|
|
137
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/status")
|
|
138
|
+
request = Net::HTTP::Get.new(uri)
|
|
139
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
140
|
+
|
|
141
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
142
|
+
http.request(request)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
146
|
+
return { kind: :error, error: "status request failed with HTTP #{response.code} for session #{@id}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
{ kind: :status_payload, payload: JSON.parse(response.body) }
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
{ kind: :error, error: "status request failed for session #{@id}: #{e.message}" }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def send_resume(registry)
|
|
155
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
|
|
156
|
+
request = Net::HTTP::Post.new(uri)
|
|
157
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
158
|
+
request["Content-Type"] = "application/json"
|
|
159
|
+
request.body = JSON.generate(
|
|
160
|
+
text: RESUME_TEXT,
|
|
161
|
+
submit: true,
|
|
162
|
+
enter_only: false,
|
|
163
|
+
force: true
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
167
|
+
http.request(request)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
171
|
+
|
|
172
|
+
raise "resume send failed with HTTP #{response.code} for session #{@id}"
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
raise "resume send failed for session #{@id}: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_idle_seconds(value)
|
|
178
|
+
return nil if value.nil?
|
|
179
|
+
|
|
180
|
+
seconds = Integer(value)
|
|
181
|
+
seconds.negative? ? 0 : seconds
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def outcome_to_exit_code(outcome)
|
|
187
|
+
case outcome
|
|
188
|
+
when :exited
|
|
189
|
+
0
|
|
190
|
+
when :escalated
|
|
191
|
+
2
|
|
192
|
+
else
|
|
193
|
+
1
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def format_duration(seconds)
|
|
198
|
+
value = seconds.to_f
|
|
199
|
+
return "#{value.round(1)}s" if value < 60
|
|
200
|
+
return "#{(value / 60).round(1)}m" if value < 3600
|
|
201
|
+
|
|
202
|
+
"#{(value / 3600).round(1)}h"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def now
|
|
206
|
+
@monotonic_clock.call
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
module WatchPresets
|
|
3
|
+
TABLE = {
|
|
4
|
+
"impl" => { stall_after_s: 8 * 60.0, max_resumes: 1 }.freeze,
|
|
5
|
+
"plan" => { stall_after_s: 3 * 60.0, max_resumes: 2 }.freeze,
|
|
6
|
+
"gate" => { stall_after_s: 15 * 60.0, max_resumes: 0 }.freeze
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def self.fetch(name)
|
|
10
|
+
TABLE[name]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.valid_names
|
|
14
|
+
TABLE.keys
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|