harnex 0.4.0 → 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/lib/harnex/adapters/base.rb +4 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/commands/run.rb +41 -4
- data/lib/harnex/core.rb +76 -0
- data/lib/harnex/runtime/session.rb +208 -4
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b38fbca70a1ec608f414f362bb16bff85bdb5c279a5c562c4dc7048fa6acb41
|
|
4
|
+
data.tar.gz: 7a9048de3efb9461489ab0678683e0d245e60e2ac3a1dd7e617d868fce927a51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a222bd5df7a1e02e7b6cebb2e0a63649b4433baef17be35a3dd03b4128e9b406af52ae71e9c063a6912dbb9af04b187072a693271fb72f24e4d61de462a7bf1c
|
|
7
|
+
data.tar.gz: eb2ca6564d079b0e162e72e9d07d01239ba2ce0c5b118fbd4360671d555c452571b2c64cb0f5a4be3c0b23c13e57b606d7ca98632d345f7570d5c69caacc9ad8
|
data/lib/harnex/adapters/base.rb
CHANGED
|
@@ -56,6 +56,25 @@ module Harnex
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def parse_session_summary(transcript_tail)
|
|
60
|
+
summary = empty_session_summary
|
|
61
|
+
text = transcript_tail.to_s
|
|
62
|
+
|
|
63
|
+
if (match = text.match(/Token usage:\s+total=([\d,]+)\s+input=([\d,]+)(?:\s+\(\+\s+([\d,]+)\s+cached\))?\s+output=([\d,]+)(?:\s+\(reasoning\s+([\d,]+)\))?/))
|
|
64
|
+
summary[:total_tokens] = parse_token_count(match[1])
|
|
65
|
+
summary[:input_tokens] = parse_token_count(match[2])
|
|
66
|
+
summary[:cached_tokens] = parse_token_count(match[3])
|
|
67
|
+
summary[:output_tokens] = parse_token_count(match[4])
|
|
68
|
+
summary[:reasoning_tokens] = parse_token_count(match[5])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if (match = text.match(/codex resume\s+([0-9a-f-]{36})/))
|
|
72
|
+
summary[:agent_session_id] = match[1]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
summary
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
def send_wait_seconds(submit:, enter_only:)
|
|
60
79
|
return 0.0 unless submit
|
|
61
80
|
return 0.0 if enter_only
|
|
@@ -101,6 +120,23 @@ module Harnex
|
|
|
101
120
|
|
|
102
121
|
protected
|
|
103
122
|
|
|
123
|
+
def empty_session_summary
|
|
124
|
+
{
|
|
125
|
+
input_tokens: nil,
|
|
126
|
+
output_tokens: nil,
|
|
127
|
+
reasoning_tokens: nil,
|
|
128
|
+
cached_tokens: nil,
|
|
129
|
+
total_tokens: nil,
|
|
130
|
+
agent_session_id: nil
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_token_count(value)
|
|
135
|
+
return nil if value.nil?
|
|
136
|
+
|
|
137
|
+
Integer(value.delete(","))
|
|
138
|
+
end
|
|
139
|
+
|
|
104
140
|
def submit_delay_ms(text)
|
|
105
141
|
extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
|
|
106
142
|
SUBMIT_DELAY_MS + extra
|
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 --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,6 +32,8 @@ 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})
|
|
36
39
|
-h, --help Show this help
|
|
@@ -60,6 +63,8 @@ module Harnex
|
|
|
60
63
|
preset: nil,
|
|
61
64
|
watch: nil,
|
|
62
65
|
context: nil,
|
|
66
|
+
meta: nil,
|
|
67
|
+
summary_out: nil,
|
|
63
68
|
detach: false,
|
|
64
69
|
tmux: false,
|
|
65
70
|
tmux_name: nil,
|
|
@@ -79,6 +84,7 @@ module Harnex
|
|
|
79
84
|
raise OptionParser::MissingArgument, "cli" if cli_name.nil?
|
|
80
85
|
|
|
81
86
|
repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
|
|
87
|
+
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
82
88
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
83
89
|
validate_unique_id!(repo_root)
|
|
84
90
|
effective_child_args = apply_context(child_args)
|
|
@@ -137,6 +143,8 @@ module Harnex
|
|
|
137
143
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
138
144
|
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
139
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]
|
|
140
148
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
141
149
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
142
150
|
|
|
@@ -239,6 +247,8 @@ module Harnex
|
|
|
239
247
|
id: @options[:id],
|
|
240
248
|
watch: watch,
|
|
241
249
|
description: @options[:description],
|
|
250
|
+
meta: @options[:meta],
|
|
251
|
+
summary_out: @options[:summary_out],
|
|
242
252
|
inbox_ttl: @options[:inbox_ttl]
|
|
243
253
|
)
|
|
244
254
|
end
|
|
@@ -385,6 +395,16 @@ module Harnex
|
|
|
385
395
|
@options[:context] = required_option_value(arg, argv[index])
|
|
386
396
|
when /\A--context=(.+)\z/
|
|
387
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))
|
|
388
408
|
when "--timeout"
|
|
389
409
|
index += 1
|
|
390
410
|
@options[:timeout] = Float(required_option_value(arg, argv[index]))
|
|
@@ -440,7 +460,7 @@ module Harnex
|
|
|
440
460
|
nil
|
|
441
461
|
when *VALUE_FLAGS
|
|
442
462
|
index += 1
|
|
443
|
-
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|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)=/
|
|
444
464
|
nil
|
|
445
465
|
when /\A--preset=/
|
|
446
466
|
nil
|
|
@@ -458,7 +478,8 @@ module Harnex
|
|
|
458
478
|
arg == "-h" ||
|
|
459
479
|
arg.start_with?(
|
|
460
480
|
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
461
|
-
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--
|
|
481
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
|
|
482
|
+
"--timeout=", "--inbox-ttl="
|
|
462
483
|
)
|
|
463
484
|
end
|
|
464
485
|
|
|
@@ -489,6 +510,22 @@ module Harnex
|
|
|
489
510
|
raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
|
|
490
511
|
end
|
|
491
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)
|
|
527
|
+
end
|
|
528
|
+
|
|
492
529
|
def default_inbox_ttl
|
|
493
530
|
value = ENV["HARNEX_INBOX_TTL"]
|
|
494
531
|
return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
|
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
|
|
@@ -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
|
|
@@ -5,10 +5,41 @@ require "pty"
|
|
|
5
5
|
module Harnex
|
|
6
6
|
class Session
|
|
7
7
|
OUTPUT_BUFFER_LIMIT = 64 * 1024
|
|
8
|
+
TRANSCRIPT_TAIL_BYTES = 16 * 1024
|
|
9
|
+
USAGE_FIELDS = %i[
|
|
10
|
+
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
|
|
11
|
+
].freeze
|
|
12
|
+
class EventCounters
|
|
13
|
+
def initialize
|
|
14
|
+
@counts = {
|
|
15
|
+
stalls: 0,
|
|
16
|
+
force_resumes: 0,
|
|
17
|
+
disconnections: 0,
|
|
18
|
+
compactions: 0
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(type)
|
|
23
|
+
case type.to_s
|
|
24
|
+
when "log_idle"
|
|
25
|
+
@counts[:stalls] += 1
|
|
26
|
+
when "resume"
|
|
27
|
+
@counts[:force_resumes] += 1
|
|
28
|
+
when "disconnect", "disconnection"
|
|
29
|
+
@counts[:disconnections] += 1
|
|
30
|
+
when "compaction"
|
|
31
|
+
@counts[:compactions] += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
8
34
|
|
|
9
|
-
|
|
35
|
+
def snapshot
|
|
36
|
+
@counts.dup
|
|
37
|
+
end
|
|
38
|
+
end
|
|
10
39
|
|
|
11
|
-
|
|
40
|
+
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
|
|
41
|
+
|
|
42
|
+
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL)
|
|
12
43
|
@adapter = adapter
|
|
13
44
|
@command = command
|
|
14
45
|
@repo_root = repo_root
|
|
@@ -17,6 +48,9 @@ module Harnex
|
|
|
17
48
|
@watch = watch
|
|
18
49
|
@description = description.to_s.strip
|
|
19
50
|
@description = nil if @description.empty?
|
|
51
|
+
@meta = meta
|
|
52
|
+
@summary_out = summary_out.to_s.strip
|
|
53
|
+
@summary_out = nil if @summary_out.empty?
|
|
20
54
|
@registry_path = Harnex.registry_path(repo_root, @id)
|
|
21
55
|
@output_log_path = Harnex.output_log_path(repo_root, @id)
|
|
22
56
|
@events_log_path = Harnex.events_log_path(repo_root, @id)
|
|
@@ -34,6 +68,12 @@ module Harnex
|
|
|
34
68
|
@output_log = nil
|
|
35
69
|
@events_log = nil
|
|
36
70
|
@events_log_seq = 0
|
|
71
|
+
@event_counters = EventCounters.new
|
|
72
|
+
@git_start = {}
|
|
73
|
+
@git_end = {}
|
|
74
|
+
@usage_summary = {}
|
|
75
|
+
@ended_at = nil
|
|
76
|
+
@exit_reason = nil
|
|
37
77
|
@writer = nil
|
|
38
78
|
@pid = nil
|
|
39
79
|
@term_signal = nil
|
|
@@ -67,7 +107,8 @@ module Harnex
|
|
|
67
107
|
prepare_events_log
|
|
68
108
|
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
69
109
|
@writer.sync = true
|
|
70
|
-
|
|
110
|
+
emit_started_event
|
|
111
|
+
emit_git_start_event
|
|
71
112
|
|
|
72
113
|
install_signal_handlers
|
|
73
114
|
sync_window_size
|
|
@@ -84,9 +125,15 @@ module Harnex
|
|
|
84
125
|
_, status = Process.wait2(pid)
|
|
85
126
|
@term_signal = status.signaled? ? status.termsig : nil
|
|
86
127
|
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
87
|
-
|
|
128
|
+
@ended_at = Time.now
|
|
88
129
|
|
|
89
130
|
output_thread.join(1)
|
|
131
|
+
emit_session_end_telemetry
|
|
132
|
+
@exit_reason = classify_exit
|
|
133
|
+
summary_record = build_summary_record
|
|
134
|
+
append_summary_record(summary_record)
|
|
135
|
+
emit_summary_event
|
|
136
|
+
emit_exit_event
|
|
90
137
|
input_thread&.kill
|
|
91
138
|
watch_thread&.kill
|
|
92
139
|
@exit_code
|
|
@@ -390,13 +437,170 @@ module Harnex
|
|
|
390
437
|
emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
|
|
391
438
|
end
|
|
392
439
|
|
|
440
|
+
def emit_started_event
|
|
441
|
+
payload = { pid: @pid }
|
|
442
|
+
payload[:meta] = meta if meta
|
|
443
|
+
emit_event("started", **payload)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def emit_git_start_event
|
|
447
|
+
@git_start = Harnex.git_capture_start(repo_root)
|
|
448
|
+
return if @git_start.empty?
|
|
449
|
+
|
|
450
|
+
emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def emit_session_end_telemetry
|
|
454
|
+
@usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
|
|
455
|
+
emit_event("usage", **@usage_summary)
|
|
456
|
+
|
|
457
|
+
@git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
|
|
458
|
+
return if @git_end.empty?
|
|
459
|
+
|
|
460
|
+
emit_event(
|
|
461
|
+
"git",
|
|
462
|
+
phase: "end",
|
|
463
|
+
sha: @git_end[:sha],
|
|
464
|
+
loc_added: @git_end[:loc_added],
|
|
465
|
+
loc_removed: @git_end[:loc_removed],
|
|
466
|
+
files_changed: @git_end[:files_changed],
|
|
467
|
+
commits: @git_end[:commits]
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def emit_summary_event
|
|
472
|
+
emit_event("summary", path: summary_out, exit: @exit_reason)
|
|
473
|
+
end
|
|
474
|
+
|
|
393
475
|
def emit_exit_event
|
|
394
476
|
payload = { code: @exit_code }
|
|
395
477
|
payload[:signal] = @term_signal if @term_signal
|
|
478
|
+
payload[:reason] = @exit_reason if @exit_reason
|
|
396
479
|
emit_event("exited", **payload)
|
|
397
480
|
end
|
|
398
481
|
|
|
482
|
+
def classify_exit
|
|
483
|
+
return "timeout" if @exit_code == 124
|
|
484
|
+
return "failure" unless @exit_code == 0
|
|
485
|
+
return "success" if session_summary_present?
|
|
486
|
+
|
|
487
|
+
"disconnected"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def session_summary_present?
|
|
491
|
+
@usage_summary.values.any? { |value| !value.nil? }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def build_summary_record
|
|
495
|
+
{
|
|
496
|
+
meta: build_summary_meta,
|
|
497
|
+
predicted: summary_predicted_payload,
|
|
498
|
+
actual: build_summary_actual
|
|
499
|
+
}
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def build_summary_meta
|
|
503
|
+
info = Harnex.host_info
|
|
504
|
+
passthrough = meta_hash
|
|
505
|
+
|
|
506
|
+
{
|
|
507
|
+
id: id,
|
|
508
|
+
tmux_session: id,
|
|
509
|
+
description: description,
|
|
510
|
+
started_at: @started_at.iso8601,
|
|
511
|
+
ended_at: @ended_at&.iso8601,
|
|
512
|
+
harness: "harnex",
|
|
513
|
+
harness_version: Harnex.harness_version,
|
|
514
|
+
agent: adapter.key,
|
|
515
|
+
agent_version: nil,
|
|
516
|
+
agent_provider: nil,
|
|
517
|
+
agent_deployment: nil,
|
|
518
|
+
host: info[:host],
|
|
519
|
+
platform: info[:platform],
|
|
520
|
+
orchestrator: passthrough["orchestrator"],
|
|
521
|
+
orchestrator_session: passthrough["orchestrator_session"],
|
|
522
|
+
chain_id: passthrough["chain_id"],
|
|
523
|
+
parent_dispatch_id: passthrough["parent_dispatch_id"],
|
|
524
|
+
tier: passthrough["tier"],
|
|
525
|
+
phase: passthrough["phase"],
|
|
526
|
+
issue: passthrough["issue"],
|
|
527
|
+
plan: passthrough["plan"],
|
|
528
|
+
task_brief: passthrough["task_brief"],
|
|
529
|
+
repo: repo_root,
|
|
530
|
+
branch: @git_start[:branch],
|
|
531
|
+
start_sha: @git_start[:sha],
|
|
532
|
+
end_sha: @git_end[:sha]
|
|
533
|
+
}
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def build_summary_actual
|
|
537
|
+
counters = @event_counters.snapshot
|
|
538
|
+
counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
|
|
539
|
+
|
|
540
|
+
{
|
|
541
|
+
model: meta_hash["model"],
|
|
542
|
+
effort: meta_hash["effort"],
|
|
543
|
+
duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
|
|
544
|
+
input_tokens: @usage_summary[:input_tokens],
|
|
545
|
+
output_tokens: @usage_summary[:output_tokens],
|
|
546
|
+
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
547
|
+
cached_tokens: @usage_summary[:cached_tokens],
|
|
548
|
+
cost_usd: nil,
|
|
549
|
+
loc_added: @git_end[:loc_added],
|
|
550
|
+
loc_removed: @git_end[:loc_removed],
|
|
551
|
+
files_changed: @git_end[:files_changed],
|
|
552
|
+
commits: @git_end[:commits],
|
|
553
|
+
exit: @exit_reason,
|
|
554
|
+
stalls: counters[:stalls],
|
|
555
|
+
force_resumes: counters[:force_resumes],
|
|
556
|
+
disconnections: counters[:disconnections],
|
|
557
|
+
compactions: counters[:compactions],
|
|
558
|
+
tests_run: nil,
|
|
559
|
+
tests_passed: nil,
|
|
560
|
+
tests_failed: nil
|
|
561
|
+
}
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def summary_predicted_payload
|
|
565
|
+
predicted = meta_hash["predicted"]
|
|
566
|
+
predicted.is_a?(Hash) ? predicted : {}
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def meta_hash
|
|
570
|
+
meta.is_a?(Hash) ? meta : {}
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def append_summary_record(record)
|
|
574
|
+
return unless summary_out
|
|
575
|
+
|
|
576
|
+
FileUtils.mkdir_p(File.dirname(summary_out))
|
|
577
|
+
File.open(summary_out, "ab") do |file|
|
|
578
|
+
file.write(JSON.generate(record))
|
|
579
|
+
file.write("\n")
|
|
580
|
+
end
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def normalized_usage_summary(summary)
|
|
586
|
+
summary ||= {}
|
|
587
|
+
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def transcript_tail
|
|
591
|
+
return "" unless File.file?(output_log_path)
|
|
592
|
+
|
|
593
|
+
File.open(output_log_path, "rb") do |file|
|
|
594
|
+
size = file.size
|
|
595
|
+
file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
|
|
596
|
+
Harnex.strip_ansi(file.read.to_s)
|
|
597
|
+
end
|
|
598
|
+
rescue StandardError
|
|
599
|
+
""
|
|
600
|
+
end
|
|
601
|
+
|
|
399
602
|
def emit_event(type, **payload)
|
|
603
|
+
@event_counters.record(type)
|
|
400
604
|
@events_mutex.synchronize do
|
|
401
605
|
return unless @events_log
|
|
402
606
|
|
data/lib/harnex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
|
|
14
14
|
adds a control plane for discovery, messaging, and coordination.
|