harnex 0.3.3 → 0.4.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 +95 -42
- data/lib/harnex/cli.rb +7 -0
- data/lib/harnex/commands/events.rb +212 -0
- data/lib/harnex/commands/run.rb +128 -14
- data/lib/harnex/commands/skills.rb +30 -10
- 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 +33 -0
- data/lib/harnex/runtime/session.rb +75 -2
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- data/skills/harnex/SKILL.md +9 -337
- data/skills/harnex-buddy/SKILL.md +20 -15
- data/skills/harnex-chain/SKILL.md +90 -192
- data/skills/harnex-dispatch/SKILL.md +115 -9
- metadata +5 -2
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -6,10 +6,12 @@ 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 --timeout --inbox-ttl --help
|
|
10
11
|
].freeze
|
|
11
12
|
VALUE_FLAGS = %w[
|
|
12
|
-
--id --description --host --port --watch --
|
|
13
|
+
--id --description --host --port --watch --watch-file --stall-after
|
|
14
|
+
--max-resumes --preset --context --timeout --inbox-ttl
|
|
13
15
|
].freeze
|
|
14
16
|
|
|
15
17
|
def self.usage(program_name = "harnex run")
|
|
@@ -23,13 +25,20 @@ module Harnex
|
|
|
23
25
|
--tmux [NAME] Run in a tmux window (implies --detach)
|
|
24
26
|
--host HOST Bind host for the local API (default: #{DEFAULT_HOST})
|
|
25
27
|
--port PORT Force a specific local API port
|
|
26
|
-
--watch
|
|
28
|
+
--watch Enable blocking babysitter mode (foreground only)
|
|
29
|
+
--stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
|
|
30
|
+
--max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
|
|
31
|
+
--preset NAME Watch preset: impl, plan, gate (requires --watch)
|
|
32
|
+
--watch-file PATH Auto-send a file-change hook on modification
|
|
27
33
|
--context TEXT Inject as the initial prompt (prepends session header)
|
|
28
34
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
29
35
|
--inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
|
|
30
36
|
-h, --help Show this help
|
|
31
37
|
|
|
32
38
|
Notes:
|
|
39
|
+
Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
|
|
40
|
+
Bare `--watch` enables the babysitter.
|
|
41
|
+
Explicit --stall-after/--max-resumes values override --preset defaults.
|
|
33
42
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
34
43
|
Any other CLI name is launched with generic wrapping.
|
|
35
44
|
Wrapper options may appear before or after <cli>.
|
|
@@ -43,6 +52,12 @@ module Harnex
|
|
|
43
52
|
description: nil,
|
|
44
53
|
host: DEFAULT_HOST,
|
|
45
54
|
port: nil,
|
|
55
|
+
watch_enabled: false,
|
|
56
|
+
stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
|
|
57
|
+
stall_after_explicit: false,
|
|
58
|
+
max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
|
|
59
|
+
max_resumes_explicit: false,
|
|
60
|
+
preset: nil,
|
|
46
61
|
watch: nil,
|
|
47
62
|
context: nil,
|
|
48
63
|
detach: false,
|
|
@@ -69,8 +84,12 @@ module Harnex
|
|
|
69
84
|
effective_child_args = apply_context(child_args)
|
|
70
85
|
adapter = Harnex.build_adapter(cli_name, effective_child_args)
|
|
71
86
|
@options[:detach] = true if @options[:tmux]
|
|
87
|
+
validate_watch_mode!
|
|
88
|
+
resolve_watch_preset!
|
|
72
89
|
|
|
73
|
-
if @options[:
|
|
90
|
+
if @options[:watch_enabled]
|
|
91
|
+
run_watch_mode(adapter, repo_root)
|
|
92
|
+
elsif @options[:detach]
|
|
74
93
|
run_detached(adapter, cli_name, child_args, repo_root)
|
|
75
94
|
else
|
|
76
95
|
run_foreground(adapter, repo_root)
|
|
@@ -90,10 +109,25 @@ module Harnex
|
|
|
90
109
|
if @options[:tmux]
|
|
91
110
|
run_in_tmux(cli_name, child_args, repo_root)
|
|
92
111
|
else
|
|
93
|
-
run_headless(adapter, repo_root)
|
|
112
|
+
result = run_headless(adapter, repo_root)
|
|
113
|
+
result[:exit_code]
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
116
|
|
|
117
|
+
def run_watch_mode(adapter, repo_root)
|
|
118
|
+
Session.validate_binary!(adapter.build_command)
|
|
119
|
+
|
|
120
|
+
result = run_headless(adapter, repo_root, emit_payload: false)
|
|
121
|
+
return result[:exit_code] unless result[:ok]
|
|
122
|
+
|
|
123
|
+
RunWatcher.new(
|
|
124
|
+
id: @options[:id],
|
|
125
|
+
repo_root: repo_root,
|
|
126
|
+
stall_after_s: @options[:stall_after_s],
|
|
127
|
+
max_resumes: @options[:max_resumes]
|
|
128
|
+
).run
|
|
129
|
+
end
|
|
130
|
+
|
|
97
131
|
def run_in_tmux(cli_name, child_args, repo_root)
|
|
98
132
|
harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
|
|
99
133
|
tmux_cmd = [harnex_bin, "run", cli_name]
|
|
@@ -101,7 +135,7 @@ module Harnex
|
|
|
101
135
|
tmux_cmd += ["--description", @options[:description]] if @options[:description]
|
|
102
136
|
tmux_cmd += ["--host", @options[:host]]
|
|
103
137
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
104
|
-
tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
|
|
138
|
+
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
105
139
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
106
140
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
107
141
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
@@ -137,7 +171,7 @@ module Harnex
|
|
|
137
171
|
0
|
|
138
172
|
end
|
|
139
173
|
|
|
140
|
-
def run_headless(adapter, repo_root)
|
|
174
|
+
def run_headless(adapter, repo_root, emit_payload: true)
|
|
141
175
|
log_dir = File.join(Harnex::STATE_DIR, "logs")
|
|
142
176
|
FileUtils.mkdir_p(log_dir)
|
|
143
177
|
log_path = File.join(log_dir, "#{@options[:id]}.log")
|
|
@@ -159,7 +193,7 @@ module Harnex
|
|
|
159
193
|
Process.detach(child_pid)
|
|
160
194
|
|
|
161
195
|
registry = wait_for_registration(repo_root)
|
|
162
|
-
return registration_timeout(@options[:id]) unless registry
|
|
196
|
+
return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
|
|
163
197
|
|
|
164
198
|
payload = {
|
|
165
199
|
ok: true,
|
|
@@ -172,12 +206,19 @@ module Harnex
|
|
|
172
206
|
output_log_path: Harnex.output_log_path(repo_root, @options[:id])
|
|
173
207
|
}
|
|
174
208
|
payload[:description] = @options[:description] if @options[:description]
|
|
175
|
-
puts JSON.generate(payload)
|
|
176
|
-
0
|
|
209
|
+
puts JSON.generate(payload) if emit_payload
|
|
210
|
+
{ ok: true, exit_code: 0, registry: registry, payload: payload }
|
|
177
211
|
end
|
|
178
212
|
|
|
179
213
|
private
|
|
180
214
|
|
|
215
|
+
def validate_watch_mode!
|
|
216
|
+
return unless @options[:watch_enabled]
|
|
217
|
+
return unless @options[:detach]
|
|
218
|
+
|
|
219
|
+
raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
|
|
220
|
+
end
|
|
221
|
+
|
|
181
222
|
def validate_unique_id!(repo_root)
|
|
182
223
|
existing = Harnex.read_registry(repo_root, @options[:id])
|
|
183
224
|
return unless existing
|
|
@@ -294,10 +335,51 @@ module Harnex
|
|
|
294
335
|
when /\A--port=(.+)\z/
|
|
295
336
|
@options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
|
|
296
337
|
when "--watch"
|
|
297
|
-
index
|
|
298
|
-
|
|
338
|
+
value = argv[index + 1]
|
|
339
|
+
if value.nil? || value == "--" || wrapper_option_token?(value)
|
|
340
|
+
@options[:watch_enabled] = true
|
|
341
|
+
else
|
|
342
|
+
index += 1
|
|
343
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
344
|
+
end
|
|
299
345
|
when /\A--watch=(.+)\z/
|
|
300
346
|
@options[:watch] = required_option_value("--watch", Regexp.last_match(1))
|
|
347
|
+
when "--watch-file"
|
|
348
|
+
index += 1
|
|
349
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
350
|
+
when /\A--watch-file=(.+)\z/
|
|
351
|
+
@options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
|
|
352
|
+
when "--stall-after"
|
|
353
|
+
index += 1
|
|
354
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
355
|
+
required_option_value(arg, argv[index]),
|
|
356
|
+
option_name: "--stall-after"
|
|
357
|
+
)
|
|
358
|
+
@options[:stall_after_explicit] = true
|
|
359
|
+
when /\A--stall-after=(.+)\z/
|
|
360
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
361
|
+
required_option_value("--stall-after", Regexp.last_match(1)),
|
|
362
|
+
option_name: "--stall-after"
|
|
363
|
+
)
|
|
364
|
+
@options[:stall_after_explicit] = true
|
|
365
|
+
when "--max-resumes"
|
|
366
|
+
index += 1
|
|
367
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
368
|
+
required_option_value(arg, argv[index]),
|
|
369
|
+
option_name: "--max-resumes"
|
|
370
|
+
)
|
|
371
|
+
@options[:max_resumes_explicit] = true
|
|
372
|
+
when /\A--max-resumes=(.+)\z/
|
|
373
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
374
|
+
required_option_value("--max-resumes", Regexp.last_match(1)),
|
|
375
|
+
option_name: "--max-resumes"
|
|
376
|
+
)
|
|
377
|
+
@options[:max_resumes_explicit] = true
|
|
378
|
+
when "--preset"
|
|
379
|
+
index += 1
|
|
380
|
+
@options[:preset] = required_option_value(arg, argv[index])
|
|
381
|
+
when /\A--preset=(.+)\z/
|
|
382
|
+
@options[:preset] = required_option_value("--preset", Regexp.last_match(1))
|
|
301
383
|
when "--context"
|
|
302
384
|
index += 1
|
|
303
385
|
@options[:context] = required_option_value(arg, argv[index])
|
|
@@ -358,7 +440,9 @@ module Harnex
|
|
|
358
440
|
nil
|
|
359
441
|
when *VALUE_FLAGS
|
|
360
442
|
index += 1
|
|
361
|
-
when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
|
|
443
|
+
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
|
|
444
|
+
nil
|
|
445
|
+
when /\A--preset=/
|
|
362
446
|
nil
|
|
363
447
|
else
|
|
364
448
|
return true
|
|
@@ -372,7 +456,37 @@ module Harnex
|
|
|
372
456
|
def wrapper_option_token?(arg)
|
|
373
457
|
KNOWN_FLAGS.include?(arg) ||
|
|
374
458
|
arg == "-h" ||
|
|
375
|
-
arg.start_with?(
|
|
459
|
+
arg.start_with?(
|
|
460
|
+
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
461
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def resolve_watch_preset!
|
|
466
|
+
preset_name = @options[:preset]
|
|
467
|
+
return if preset_name.nil?
|
|
468
|
+
|
|
469
|
+
unless @options[:watch_enabled]
|
|
470
|
+
raise "harnex run: --preset requires --watch"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
preset = WatchPresets.fetch(preset_name)
|
|
474
|
+
unless preset
|
|
475
|
+
valid = WatchPresets.valid_names.join(", ")
|
|
476
|
+
raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
@options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
|
|
480
|
+
@options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def parse_non_negative_integer(value, option_name:)
|
|
484
|
+
integer = Integer(value)
|
|
485
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
|
|
486
|
+
|
|
487
|
+
integer
|
|
488
|
+
rescue ArgumentError
|
|
489
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
|
|
376
490
|
end
|
|
377
491
|
|
|
378
492
|
def default_inbox_ttl
|
|
@@ -4,20 +4,26 @@ module Harnex
|
|
|
4
4
|
class Skills
|
|
5
5
|
SKILLS_ROOT = File.expand_path("../../../../skills", __FILE__)
|
|
6
6
|
INSTALL_SKILLS = %w[harnex-dispatch harnex-chain harnex-buddy].freeze
|
|
7
|
-
DEPRECATED_SKILLS = %w[dispatch chain-implement].freeze
|
|
7
|
+
DEPRECATED_SKILLS = %w[harnex dispatch chain-implement].freeze
|
|
8
|
+
SKILL_ALIASES = {
|
|
9
|
+
"harnex" => "harnex-dispatch",
|
|
10
|
+
"dispatch" => "harnex-dispatch",
|
|
11
|
+
"chain-implement" => "harnex-chain"
|
|
12
|
+
}.freeze
|
|
8
13
|
|
|
9
14
|
def self.usage
|
|
10
15
|
<<~TEXT
|
|
11
|
-
Usage: harnex skills <subcommand> [--local]
|
|
16
|
+
Usage: harnex skills <subcommand> [SKILL...] [--local]
|
|
12
17
|
|
|
13
18
|
Subcommands:
|
|
14
|
-
install Install bundled skills (globally by default)
|
|
19
|
+
install Install bundled skills (globally by default; optional skill names)
|
|
15
20
|
uninstall Remove installed skills (globally by default)
|
|
16
21
|
|
|
17
22
|
Options:
|
|
18
23
|
--local Target the current repo instead of global ~/.claude/
|
|
19
24
|
|
|
20
25
|
Installs: #{INSTALL_SKILLS.join(', ')}
|
|
26
|
+
Aliases: harnex|dispatch -> harnex-dispatch, chain-implement -> harnex-chain
|
|
21
27
|
|
|
22
28
|
By default, copies each skill to ~/.claude/skills/<skill>/
|
|
23
29
|
and symlinks ~/.codex/skills/<skill> to it.
|
|
@@ -35,12 +41,13 @@ module Harnex
|
|
|
35
41
|
subcommand = @argv.shift
|
|
36
42
|
case subcommand
|
|
37
43
|
when "install"
|
|
38
|
-
local, help = parse_args(@argv)
|
|
44
|
+
local, help, requested_skills = parse_args(@argv, allow_positional: true)
|
|
39
45
|
return (puts self.class.usage; 0) if help
|
|
40
46
|
|
|
41
47
|
remove_deprecated(local)
|
|
48
|
+
install_skills = requested_skills.empty? ? INSTALL_SKILLS : canonical_skill_names(requested_skills)
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
install_skills.each do |skill_name|
|
|
44
51
|
skill_source = resolve_skill_source(skill_name)
|
|
45
52
|
unless skill_source
|
|
46
53
|
return missing_skill(skill_name)
|
|
@@ -51,7 +58,7 @@ module Harnex
|
|
|
51
58
|
end
|
|
52
59
|
0
|
|
53
60
|
when "uninstall"
|
|
54
|
-
local, help = parse_args(@argv)
|
|
61
|
+
local, help, = parse_args(@argv)
|
|
55
62
|
return (puts self.class.usage; 0) if help
|
|
56
63
|
|
|
57
64
|
(INSTALL_SKILLS + DEPRECATED_SKILLS).each do |skill_name|
|
|
@@ -70,9 +77,10 @@ module Harnex
|
|
|
70
77
|
|
|
71
78
|
private
|
|
72
79
|
|
|
73
|
-
def parse_args(args)
|
|
80
|
+
def parse_args(args, allow_positional: false)
|
|
74
81
|
local = false
|
|
75
82
|
help = false
|
|
83
|
+
positional = []
|
|
76
84
|
|
|
77
85
|
args.each do |arg|
|
|
78
86
|
case arg
|
|
@@ -83,12 +91,16 @@ module Harnex
|
|
|
83
91
|
when /\A-/
|
|
84
92
|
raise "harnex skills: unknown option #{arg.inspect}"
|
|
85
93
|
else
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
if allow_positional
|
|
95
|
+
positional << arg
|
|
96
|
+
else
|
|
97
|
+
warn("harnex skills: unexpected argument #{arg.inspect}")
|
|
98
|
+
raise "harnex skills takes no positional arguments"
|
|
99
|
+
end
|
|
88
100
|
end
|
|
89
101
|
end
|
|
90
102
|
|
|
91
|
-
[local, help]
|
|
103
|
+
[local, help, positional]
|
|
92
104
|
end
|
|
93
105
|
|
|
94
106
|
def resolve_skill_source(skill_name)
|
|
@@ -107,6 +119,14 @@ module Harnex
|
|
|
107
119
|
end
|
|
108
120
|
end
|
|
109
121
|
|
|
122
|
+
def canonical_skill_names(skill_names)
|
|
123
|
+
skill_names.map { |name| canonical_skill_name(name) }.uniq
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def canonical_skill_name(skill_name)
|
|
127
|
+
SKILL_ALIASES.fetch(skill_name, skill_name)
|
|
128
|
+
end
|
|
129
|
+
|
|
110
130
|
def install_local(skill_name, skill_source)
|
|
111
131
|
repo_root = Harnex.resolve_repo_root(Dir.pwd)
|
|
112
132
|
claude_dir = File.join(repo_root, ".claude", "skills", skill_name)
|
|
@@ -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
|
data/lib/harnex/core.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "optparse"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
require "set"
|
|
5
6
|
require "socket"
|
|
@@ -37,6 +38,32 @@ module Harnex
|
|
|
37
38
|
File.expand_path(path)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
def parse_duration_seconds(value, option_name:)
|
|
42
|
+
text = value.to_s.strip
|
|
43
|
+
raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty?
|
|
44
|
+
|
|
45
|
+
match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/)
|
|
46
|
+
unless match
|
|
47
|
+
raise OptionParser::InvalidArgument,
|
|
48
|
+
"#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
amount = Float(match[1])
|
|
52
|
+
multiplier =
|
|
53
|
+
case match[2].downcase
|
|
54
|
+
when "", "s" then 1.0
|
|
55
|
+
when "m" then 60.0
|
|
56
|
+
when "h" then 3600.0
|
|
57
|
+
else
|
|
58
|
+
raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
seconds = amount * multiplier
|
|
62
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0
|
|
63
|
+
|
|
64
|
+
seconds
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
def repo_key(repo_root)
|
|
41
68
|
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
42
69
|
end
|
|
@@ -113,6 +140,12 @@ module Harnex
|
|
|
113
140
|
File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
|
|
114
141
|
end
|
|
115
142
|
|
|
143
|
+
def events_log_path(repo_root, id)
|
|
144
|
+
events_dir = File.join(STATE_DIR, "events")
|
|
145
|
+
FileUtils.mkdir_p(events_dir)
|
|
146
|
+
File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
|
|
147
|
+
end
|
|
148
|
+
|
|
116
149
|
def session_file_slug(repo_root, id)
|
|
117
150
|
slug = id_key(id)
|
|
118
151
|
slug = "default" if slug.empty?
|