harnex 0.8.0 → 0.9.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/CHANGELOG.md +88 -0
- data/GUIDE.md +17 -10
- data/README.md +30 -23
- data/TECHNICAL.md +26 -5
- data/docs/codex-appserver.md +172 -0
- data/docs/configuration.md +111 -0
- data/docs/dispatch-telemetry.md +461 -0
- data/docs/events.md +133 -0
- data/guides/01_dispatch.md +6 -0
- data/guides/04_monitoring.md +6 -5
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- data/lib/harnex/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +7 -3
- data/lib/harnex/commands/run.rb +37 -10
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +0 -7
- data/lib/harnex/dispatch_history.rb +13 -4
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +140 -12
- data/lib/harnex/terminal_status.rb +11 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +8 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "set"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
module Retention
|
|
8
|
+
DIRS = {
|
|
9
|
+
"events" => "events",
|
|
10
|
+
"output" => "output"
|
|
11
|
+
}.freeze
|
|
12
|
+
THROTTLE_SECONDS = 3600
|
|
13
|
+
MAX_REPORTED_PATHS = 100
|
|
14
|
+
METADATA_PATH = File.join(STATE_DIR, "retention.json").freeze
|
|
15
|
+
LOCK_PATH = File.join(STATE_DIR, "retention.lock").freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def status(repo_root:, env: ENV, now: Time.now)
|
|
20
|
+
limits = Config.retention_limits(repo_root, env: env)
|
|
21
|
+
{
|
|
22
|
+
ok: true,
|
|
23
|
+
checked_at: now.utc.iso8601,
|
|
24
|
+
config: limits,
|
|
25
|
+
directories: DIRS.to_h do |key, dirname|
|
|
26
|
+
files = inventory(File.join(STATE_DIR, dirname))
|
|
27
|
+
[key, {
|
|
28
|
+
path: File.join(STATE_DIR, dirname),
|
|
29
|
+
count: files.length,
|
|
30
|
+
bytes: files.sum { |file| file.fetch(:bytes) },
|
|
31
|
+
max_age_days: limits.fetch(key).fetch("max_age_days"),
|
|
32
|
+
max_bytes: limits.fetch(key).fetch("max_bytes")
|
|
33
|
+
}]
|
|
34
|
+
end,
|
|
35
|
+
last_prune: last_prune_metadata
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def auto_prune(repo_root:, current_paths: [], env: ENV, now: Time.now)
|
|
40
|
+
prune(
|
|
41
|
+
repo_root: repo_root,
|
|
42
|
+
current_paths: current_paths,
|
|
43
|
+
env: env,
|
|
44
|
+
now: now,
|
|
45
|
+
dry_run: false,
|
|
46
|
+
force: false
|
|
47
|
+
)
|
|
48
|
+
rescue Config::ConfigError
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn("harnex: retention prune failed: #{e.message}; run `harnex doctor --prune --dry-run` to inspect candidates")
|
|
52
|
+
{ ok: false, error: e.message }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prune(repo_root:, current_paths: [], env: ENV, now: Time.now, dry_run: false, force: false)
|
|
56
|
+
limits = Config.retention_limits(repo_root, env: env)
|
|
57
|
+
with_lock do
|
|
58
|
+
last = last_prune_metadata
|
|
59
|
+
if !force && !dry_run && throttled?(last, now)
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
skipped: true,
|
|
63
|
+
reason: "throttled",
|
|
64
|
+
throttle_seconds: THROTTLE_SECONDS,
|
|
65
|
+
last_prune: last
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
protected = protected_paths(repo_root, current_paths)
|
|
70
|
+
report = {
|
|
71
|
+
ok: true,
|
|
72
|
+
skipped: false,
|
|
73
|
+
dry_run: dry_run,
|
|
74
|
+
timestamp: now.utc.iso8601,
|
|
75
|
+
directories: DIRS.to_h do |key, dirname|
|
|
76
|
+
[key.to_sym, prune_directory(
|
|
77
|
+
File.join(STATE_DIR, dirname),
|
|
78
|
+
limits.fetch(key),
|
|
79
|
+
protected,
|
|
80
|
+
now,
|
|
81
|
+
dry_run: dry_run
|
|
82
|
+
)]
|
|
83
|
+
end
|
|
84
|
+
}
|
|
85
|
+
persist_last_prune(report)
|
|
86
|
+
report
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inventory(dir)
|
|
91
|
+
FileUtils.mkdir_p(dir)
|
|
92
|
+
base = File.expand_path(dir)
|
|
93
|
+
Dir.children(base).sort.filter_map do |name|
|
|
94
|
+
path = File.expand_path(File.join(base, name))
|
|
95
|
+
next unless path.start_with?("#{base}#{File::SEPARATOR}")
|
|
96
|
+
|
|
97
|
+
stat = File.lstat(path)
|
|
98
|
+
next unless stat.file?
|
|
99
|
+
|
|
100
|
+
{ path: path, bytes: stat.size, mtime: stat.mtime }
|
|
101
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def prune_directory(dir, limits, protected, now, dry_run:)
|
|
107
|
+
files = inventory(dir)
|
|
108
|
+
before_bytes = files.sum { |file| file.fetch(:bytes) }
|
|
109
|
+
protected_files = files.select { |file| protected.include?(file.fetch(:path)) }
|
|
110
|
+
deleted = []
|
|
111
|
+
deleted_paths = Set.new
|
|
112
|
+
projected_bytes = before_bytes
|
|
113
|
+
|
|
114
|
+
cutoff = now - limits.fetch("max_age_days") * 86_400
|
|
115
|
+
age_candidates = files.reject { |file| protected.include?(file.fetch(:path)) }
|
|
116
|
+
.select { |file| file.fetch(:mtime) < cutoff }
|
|
117
|
+
.sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
|
|
118
|
+
age_candidates.each do |file|
|
|
119
|
+
next if deleted_paths.include?(file.fetch(:path))
|
|
120
|
+
|
|
121
|
+
if delete_candidate(file, dry_run: dry_run)
|
|
122
|
+
deleted << file
|
|
123
|
+
deleted_paths.add(file.fetch(:path))
|
|
124
|
+
projected_bytes -= file.fetch(:bytes)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
size_candidates = files.reject { |file| protected.include?(file.fetch(:path)) || deleted_paths.include?(file.fetch(:path)) }
|
|
129
|
+
.sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
|
|
130
|
+
size_candidates.each do |file|
|
|
131
|
+
break if projected_bytes <= limits.fetch("max_bytes")
|
|
132
|
+
|
|
133
|
+
if delete_candidate(file, dry_run: dry_run)
|
|
134
|
+
deleted << file
|
|
135
|
+
deleted_paths.add(file.fetch(:path))
|
|
136
|
+
projected_bytes -= file.fetch(:bytes)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
after_bytes = dry_run ? projected_bytes : inventory(dir).sum { |file| file.fetch(:bytes) }
|
|
141
|
+
{
|
|
142
|
+
path: dir,
|
|
143
|
+
before_count: files.length,
|
|
144
|
+
before_bytes: before_bytes,
|
|
145
|
+
after_count: dry_run ? files.length - deleted.length : inventory(dir).length,
|
|
146
|
+
after_bytes: after_bytes,
|
|
147
|
+
deleted_count: deleted.length,
|
|
148
|
+
deleted_bytes: deleted.sum { |file| file.fetch(:bytes) },
|
|
149
|
+
deleted_paths: deleted.first(MAX_REPORTED_PATHS).map { |file| file.fetch(:path) },
|
|
150
|
+
deleted_paths_truncated: deleted.length > MAX_REPORTED_PATHS,
|
|
151
|
+
protected_count: protected_files.length,
|
|
152
|
+
protected_bytes: protected_files.sum { |file| file.fetch(:bytes) },
|
|
153
|
+
max_age_days: limits.fetch("max_age_days"),
|
|
154
|
+
max_bytes: limits.fetch("max_bytes"),
|
|
155
|
+
over_cap: after_bytes > limits.fetch("max_bytes")
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def delete_candidate(file, dry_run:)
|
|
160
|
+
return true if dry_run
|
|
161
|
+
|
|
162
|
+
File.delete(file.fetch(:path))
|
|
163
|
+
true
|
|
164
|
+
rescue Errno::ENOENT
|
|
165
|
+
true
|
|
166
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::ENOTDIR
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def protected_paths(repo_root, current_paths)
|
|
171
|
+
paths = Set.new
|
|
172
|
+
current_paths.each { |path| protect(paths, path) }
|
|
173
|
+
protect_live_registry_paths(paths)
|
|
174
|
+
protect_live_start_row_paths(paths, repo_root)
|
|
175
|
+
paths
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def protect_live_registry_paths(paths)
|
|
179
|
+
return unless Dir.exist?(SESSIONS_DIR)
|
|
180
|
+
|
|
181
|
+
Dir.glob(File.join(SESSIONS_DIR, "*.json")).sort.each do |path|
|
|
182
|
+
data = JSON.parse(File.read(path))
|
|
183
|
+
next unless data["pid"] && Harnex.alive_pid?(data["pid"])
|
|
184
|
+
|
|
185
|
+
protect(paths, data["events_log_path"])
|
|
186
|
+
protect(paths, data["output_log_path"])
|
|
187
|
+
protect_derived_session_paths(paths, data)
|
|
188
|
+
rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def protect_live_start_row_paths(paths, repo_root)
|
|
194
|
+
history_paths = [
|
|
195
|
+
DispatchHistory.path_for(repo_root),
|
|
196
|
+
DispatchHistory.global_path
|
|
197
|
+
].uniq
|
|
198
|
+
|
|
199
|
+
history_paths.each do |path|
|
|
200
|
+
live_start_records(path).each do |record|
|
|
201
|
+
protect(paths, record["events_log_path"])
|
|
202
|
+
protect(paths, record["output_log_path"])
|
|
203
|
+
protect_derived_session_paths(paths, record)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def live_start_records(path)
|
|
209
|
+
return [] unless File.file?(path)
|
|
210
|
+
|
|
211
|
+
open = {}
|
|
212
|
+
File.foreach(path) do |line|
|
|
213
|
+
record = JSON.parse(line)
|
|
214
|
+
next unless record.is_a?(Hash)
|
|
215
|
+
|
|
216
|
+
if DispatchHistory.start_record?(record)
|
|
217
|
+
next unless DispatchHistory.same_host?(record)
|
|
218
|
+
next unless record["pid"] && Harnex.alive_pid?(record["pid"])
|
|
219
|
+
|
|
220
|
+
open[start_key(record)] = record
|
|
221
|
+
elsif DispatchHistory.end_record?(record)
|
|
222
|
+
open.delete_if { |_key, start| DispatchHistory.end_matches_start?(record, start) }
|
|
223
|
+
end
|
|
224
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
open.values
|
|
228
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
229
|
+
[]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def start_key(record)
|
|
233
|
+
session_id = record["session_id"].to_s
|
|
234
|
+
return "session:#{session_id}" unless session_id.empty?
|
|
235
|
+
|
|
236
|
+
"id:#{record['id']}\0#{record['started_at']}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def protect_derived_session_paths(paths, data)
|
|
240
|
+
repo = data["repo_root"].to_s
|
|
241
|
+
id = data["id"].to_s
|
|
242
|
+
return if repo.empty? || id.empty?
|
|
243
|
+
|
|
244
|
+
protect(paths, Harnex.events_log_path(repo, id))
|
|
245
|
+
protect(paths, Harnex.output_log_path(repo, id))
|
|
246
|
+
rescue StandardError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def protect(paths, path)
|
|
251
|
+
text = path.to_s
|
|
252
|
+
return if text.empty?
|
|
253
|
+
|
|
254
|
+
paths.add(File.expand_path(text))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def with_lock
|
|
258
|
+
FileUtils.mkdir_p(STATE_DIR)
|
|
259
|
+
File.open(LOCK_PATH, File::RDWR | File::CREAT, 0o644) do |file|
|
|
260
|
+
file.flock(File::LOCK_EX)
|
|
261
|
+
yield
|
|
262
|
+
ensure
|
|
263
|
+
file.flock(File::LOCK_UN) unless file.closed?
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def throttled?(last, now)
|
|
268
|
+
timestamp = last.is_a?(Hash) ? last["timestamp"].to_s : ""
|
|
269
|
+
return false if timestamp.empty?
|
|
270
|
+
|
|
271
|
+
now - Time.parse(timestamp) < THROTTLE_SECONDS
|
|
272
|
+
rescue ArgumentError
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def last_prune_metadata
|
|
277
|
+
data = JSON.parse(File.read(METADATA_PATH))
|
|
278
|
+
data["last_prune"]
|
|
279
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def persist_last_prune(report)
|
|
284
|
+
payload = {
|
|
285
|
+
"last_prune" => {
|
|
286
|
+
"timestamp" => report.fetch(:timestamp),
|
|
287
|
+
"dry_run" => report.fetch(:dry_run),
|
|
288
|
+
"applied" => !report.fetch(:dry_run),
|
|
289
|
+
"directories" => report.fetch(:directories).transform_keys(&:to_s).transform_values do |stats|
|
|
290
|
+
{
|
|
291
|
+
"before_count" => stats.fetch(:before_count),
|
|
292
|
+
"before_bytes" => stats.fetch(:before_bytes),
|
|
293
|
+
"after_count" => stats.fetch(:after_count),
|
|
294
|
+
"after_bytes" => stats.fetch(:after_bytes),
|
|
295
|
+
"deleted_count" => stats.fetch(:deleted_count),
|
|
296
|
+
"deleted_bytes" => stats.fetch(:deleted_bytes),
|
|
297
|
+
"protected_count" => stats.fetch(:protected_count),
|
|
298
|
+
"protected_bytes" => stats.fetch(:protected_bytes),
|
|
299
|
+
"over_cap" => stats.fetch(:over_cap)
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
tmp = "#{METADATA_PATH}.tmp.#{Process.pid}"
|
|
305
|
+
File.write(tmp, JSON.pretty_generate(payload))
|
|
306
|
+
File.rename(tmp, METADATA_PATH)
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
warn("harnex: failed to persist retention metadata: #{e.message}")
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -23,7 +23,7 @@ module Harnex
|
|
|
23
23
|
terminal_tokens terminal_percent peak_tokens peak_percent
|
|
24
24
|
].freeze
|
|
25
25
|
CONTEXT_SAMPLE_STATUSES = %w[observed estimated missing].freeze
|
|
26
|
-
ATTEMPT_KINDS = %w[initial retry fix review superseding].freeze
|
|
26
|
+
ATTEMPT_KINDS = %w[initial retry fix review fallback superseding].freeze
|
|
27
27
|
SESSION_SUMMARY_SIGNAL_FIELDS = %i[
|
|
28
28
|
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
29
29
|
agent_session_id cost_usd
|
|
@@ -183,6 +183,7 @@ module Harnex
|
|
|
183
183
|
|
|
184
184
|
def run(validate_binary: true)
|
|
185
185
|
validate_binary! if validate_binary
|
|
186
|
+
prune_retained_logs
|
|
186
187
|
prepare_output_log
|
|
187
188
|
prepare_events_log
|
|
188
189
|
|
|
@@ -1154,6 +1155,13 @@ module Harnex
|
|
|
1154
1155
|
@output_log_failed = false
|
|
1155
1156
|
end
|
|
1156
1157
|
|
|
1158
|
+
def prune_retained_logs
|
|
1159
|
+
Harnex::Retention.auto_prune(
|
|
1160
|
+
repo_root: repo_root,
|
|
1161
|
+
current_paths: [output_log_path, events_log_path]
|
|
1162
|
+
)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1157
1165
|
def prepare_events_log
|
|
1158
1166
|
@events_log&.close unless @events_log&.closed?
|
|
1159
1167
|
@events_log = File.open(events_log_path, "ab")
|
|
@@ -1247,7 +1255,9 @@ module Harnex
|
|
|
1247
1255
|
end
|
|
1248
1256
|
|
|
1249
1257
|
def emit_summary_event
|
|
1250
|
-
|
|
1258
|
+
payload = { path: dispatch_history_path, exit: @exit_reason }
|
|
1259
|
+
payload[:mirror_path] = summary_out if summary_out
|
|
1260
|
+
emit_event("summary", **payload)
|
|
1251
1261
|
end
|
|
1252
1262
|
|
|
1253
1263
|
def emit_attempt_finished(attempt)
|
|
@@ -1284,9 +1294,9 @@ module Harnex
|
|
|
1284
1294
|
warn("harnex: failed to collect session-end telemetry: #{e.message}")
|
|
1285
1295
|
end
|
|
1286
1296
|
@exit_reason ||= classify_exit
|
|
1287
|
-
record =
|
|
1297
|
+
record = DispatchHistory.build_record(self)
|
|
1298
|
+
append_dispatch_history_record(record)
|
|
1288
1299
|
append_summary_record(record)
|
|
1289
|
-
append_dispatch_history_record
|
|
1290
1300
|
emit_summary_event
|
|
1291
1301
|
emit_attempt_finished(record.fetch(:attempt))
|
|
1292
1302
|
emit_exit_event
|
|
@@ -1581,10 +1591,30 @@ module Harnex
|
|
|
1581
1591
|
cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
|
|
1582
1592
|
cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
|
|
1583
1593
|
|
|
1594
|
+
cost_price_as_of = nil
|
|
1595
|
+
if values[:cost_usd].nil? && %w[observed zero].include?(status)
|
|
1596
|
+
priced = Pricing.compute(
|
|
1597
|
+
provider: summary_agent_provider,
|
|
1598
|
+
model: summary_model,
|
|
1599
|
+
input_tokens: values[:input_tokens],
|
|
1600
|
+
output_tokens: values[:output_tokens],
|
|
1601
|
+
cached_tokens: values[:cached_tokens],
|
|
1602
|
+
service_tier: summary_service_tier,
|
|
1603
|
+
context_tokens: @context_summary[:peak_tokens],
|
|
1604
|
+
input_includes_cached: adapter.usage_input_includes_cached?
|
|
1605
|
+
)
|
|
1606
|
+
if priced
|
|
1607
|
+
values[:cost_usd] = priced[:cost_usd]
|
|
1608
|
+
cost_source = "price_table"
|
|
1609
|
+
cost_price_as_of = priced[:as_of]
|
|
1610
|
+
end
|
|
1611
|
+
end
|
|
1612
|
+
|
|
1584
1613
|
{
|
|
1585
1614
|
"status" => status,
|
|
1586
1615
|
"cost_usd" => values[:cost_usd],
|
|
1587
1616
|
"cost_source" => cost_source,
|
|
1617
|
+
"cost_price_as_of" => cost_price_as_of,
|
|
1588
1618
|
"input_tokens" => values[:input_tokens],
|
|
1589
1619
|
"output_tokens" => values[:output_tokens],
|
|
1590
1620
|
"cached_input_tokens" => values[:cached_tokens],
|
|
@@ -1762,6 +1792,101 @@ module Harnex
|
|
|
1762
1792
|
@exit_reason == "success"
|
|
1763
1793
|
end
|
|
1764
1794
|
|
|
1795
|
+
def attempt_chain_summary
|
|
1796
|
+
@attempt_chain_summary ||= begin
|
|
1797
|
+
current = {
|
|
1798
|
+
id: id,
|
|
1799
|
+
parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
|
|
1800
|
+
kind: summary_attempt_kind,
|
|
1801
|
+
outcome: summary_attempt_succeeded? ? "succeeded" : "failed"
|
|
1802
|
+
}
|
|
1803
|
+
index = historical_attempt_index
|
|
1804
|
+
nodes = [current]
|
|
1805
|
+
seen = {}
|
|
1806
|
+
seen[current[:id]] = true if current[:id]
|
|
1807
|
+
parent = current[:parent_dispatch_id]
|
|
1808
|
+
immediate_parent = nil
|
|
1809
|
+
|
|
1810
|
+
while parent && !seen[parent]
|
|
1811
|
+
record = index[parent]
|
|
1812
|
+
break unless record
|
|
1813
|
+
|
|
1814
|
+
node = historical_attempt_node(record)
|
|
1815
|
+
break unless node
|
|
1816
|
+
|
|
1817
|
+
immediate_parent ||= node
|
|
1818
|
+
nodes << node
|
|
1819
|
+
seen[node[:id]] = true if node[:id]
|
|
1820
|
+
parent = node[:parent_dispatch_id]
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
{
|
|
1824
|
+
attempts_total: nodes.length,
|
|
1825
|
+
attempts_succeeded: nodes.count { |node| node[:outcome] == "succeeded" },
|
|
1826
|
+
attempts_failed: nodes.count { |node| node[:outcome] == "failed" },
|
|
1827
|
+
fallback_triggered: nodes.any? { |node| node[:kind] == "fallback" },
|
|
1828
|
+
recovered: current[:outcome] == "succeeded" && immediate_parent&.dig(:outcome) == "failed"
|
|
1829
|
+
}
|
|
1830
|
+
end
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
def historical_attempt_index
|
|
1834
|
+
path = dispatch_history_path
|
|
1835
|
+
return {} unless File.file?(path)
|
|
1836
|
+
|
|
1837
|
+
records = {}
|
|
1838
|
+
duplicates = {}
|
|
1839
|
+
File.foreach(path) do |line|
|
|
1840
|
+
record = JSON.parse(line)
|
|
1841
|
+
next unless DispatchHistory.end_record?(record)
|
|
1842
|
+
|
|
1843
|
+
dispatch_id = summary_string(record["id"])
|
|
1844
|
+
next unless dispatch_id
|
|
1845
|
+
|
|
1846
|
+
if records.key?(dispatch_id)
|
|
1847
|
+
records.delete(dispatch_id)
|
|
1848
|
+
duplicates[dispatch_id] = true
|
|
1849
|
+
elsif !duplicates.key?(dispatch_id)
|
|
1850
|
+
records[dispatch_id] = record
|
|
1851
|
+
end
|
|
1852
|
+
rescue JSON::ParserError
|
|
1853
|
+
next
|
|
1854
|
+
end
|
|
1855
|
+
records
|
|
1856
|
+
rescue StandardError
|
|
1857
|
+
{}
|
|
1858
|
+
end
|
|
1859
|
+
|
|
1860
|
+
def historical_attempt_node(record)
|
|
1861
|
+
attempt = record["attempt"].is_a?(Hash) ? record["attempt"] : {}
|
|
1862
|
+
meta = record["meta"].is_a?(Hash) ? record["meta"] : {}
|
|
1863
|
+
dispatch_id = summary_string(record["id"])
|
|
1864
|
+
return nil unless dispatch_id
|
|
1865
|
+
|
|
1866
|
+
{
|
|
1867
|
+
id: dispatch_id,
|
|
1868
|
+
parent_dispatch_id: summary_string(attempt["parent_dispatch_id"]) || summary_string(meta["parent_dispatch_id"]),
|
|
1869
|
+
kind: summary_string(attempt["kind"]),
|
|
1870
|
+
outcome: historical_attempt_outcome(record, attempt)
|
|
1871
|
+
}
|
|
1872
|
+
end
|
|
1873
|
+
|
|
1874
|
+
def historical_attempt_outcome(record, attempt)
|
|
1875
|
+
case summary_string(attempt["status"])
|
|
1876
|
+
when "succeeded"
|
|
1877
|
+
"succeeded"
|
|
1878
|
+
when "failed"
|
|
1879
|
+
"failed"
|
|
1880
|
+
else
|
|
1881
|
+
case summary_string(record["status"])
|
|
1882
|
+
when "completed"
|
|
1883
|
+
"succeeded"
|
|
1884
|
+
when "failed", "timeout", "killed"
|
|
1885
|
+
"failed"
|
|
1886
|
+
end
|
|
1887
|
+
end
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1765
1890
|
def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
|
|
1766
1891
|
return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
|
|
1767
1892
|
|
|
@@ -1785,6 +1910,7 @@ module Harnex
|
|
|
1785
1910
|
def build_summary_reliability
|
|
1786
1911
|
counters = reliability_event_counters
|
|
1787
1912
|
real_disconnections = counters[:disconnections].to_i
|
|
1913
|
+
chain = attempt_chain_summary
|
|
1788
1914
|
{
|
|
1789
1915
|
"adapter_close" => summary_adapter_close(real_disconnections),
|
|
1790
1916
|
"real_disconnections" => real_disconnections,
|
|
@@ -1792,7 +1918,7 @@ module Harnex
|
|
|
1792
1918
|
"stalls" => counters[:stalls].to_i,
|
|
1793
1919
|
"force_resumes" => counters[:force_resumes].to_i,
|
|
1794
1920
|
"compactions" => counters[:compactions].to_i,
|
|
1795
|
-
"recovered" =>
|
|
1921
|
+
"recovered" => chain[:recovered]
|
|
1796
1922
|
}
|
|
1797
1923
|
end
|
|
1798
1924
|
|
|
@@ -1801,7 +1927,7 @@ module Harnex
|
|
|
1801
1927
|
output_measurements = summary_output_measurements
|
|
1802
1928
|
accepted = outcome["status"] == "accepted"
|
|
1803
1929
|
duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
|
|
1804
|
-
|
|
1930
|
+
chain = attempt_chain_summary
|
|
1805
1931
|
total_tokens = @usage_summary[:total_tokens]
|
|
1806
1932
|
|
|
1807
1933
|
actual = {
|
|
@@ -1839,9 +1965,9 @@ module Harnex
|
|
|
1839
1965
|
event_records: @events_log_seq,
|
|
1840
1966
|
output_log_path: output_log_path,
|
|
1841
1967
|
events_log_path: events_log_path,
|
|
1842
|
-
attempts_total:
|
|
1843
|
-
attempts_succeeded:
|
|
1844
|
-
attempts_failed:
|
|
1968
|
+
attempts_total: chain[:attempts_total],
|
|
1969
|
+
attempts_succeeded: chain[:attempts_succeeded],
|
|
1970
|
+
attempts_failed: chain[:attempts_failed],
|
|
1845
1971
|
retry_count: counters[:retries],
|
|
1846
1972
|
throttle_429_count: counters[:throttle_429],
|
|
1847
1973
|
disconnect_count: counters[:disconnections],
|
|
@@ -1849,7 +1975,7 @@ module Harnex
|
|
|
1849
1975
|
throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
|
|
1850
1976
|
retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
|
|
1851
1977
|
unattributed: attribution["status"] != "complete",
|
|
1852
|
-
fallback_triggered:
|
|
1978
|
+
fallback_triggered: chain[:fallback_triggered]
|
|
1853
1979
|
}
|
|
1854
1980
|
actual
|
|
1855
1981
|
end
|
|
@@ -1983,6 +2109,8 @@ module Harnex
|
|
|
1983
2109
|
meta.is_a?(Hash) ? meta : {}
|
|
1984
2110
|
end
|
|
1985
2111
|
|
|
2112
|
+
# Explicit-only mirror: the identical end record lands here in addition
|
|
2113
|
+
# to the tracked stream when --summary-out is configured.
|
|
1986
2114
|
def append_summary_record(record)
|
|
1987
2115
|
return unless summary_out
|
|
1988
2116
|
|
|
@@ -2001,8 +2129,8 @@ module Harnex
|
|
|
2001
2129
|
warn("harnex: failed to write dispatch start record: #{e.message}")
|
|
2002
2130
|
end
|
|
2003
2131
|
|
|
2004
|
-
def append_dispatch_history_record
|
|
2005
|
-
DispatchHistory.append(dispatch_history_path,
|
|
2132
|
+
def append_dispatch_history_record(record)
|
|
2133
|
+
DispatchHistory.append(dispatch_history_path, record)
|
|
2006
2134
|
rescue StandardError => e
|
|
2007
2135
|
warn("harnex: failed to write dispatch history: #{e.message}")
|
|
2008
2136
|
end
|
|
@@ -80,7 +80,16 @@ module Harnex
|
|
|
80
80
|
next unless record.is_a?(Hash)
|
|
81
81
|
next if DispatchHistory.start_record?(record)
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# Branch on record_type first: a v2 end row carries both the thin
|
|
84
|
+
# envelope and the rich sections, so it resolves as summary and
|
|
85
|
+
# history in one shot — never double-counted via the legacy
|
|
86
|
+
# duck-types below, which stay for pre-v2 files.
|
|
87
|
+
if record["record_type"] == "dispatch_end"
|
|
88
|
+
next unless record["id"].to_s == id
|
|
89
|
+
|
|
90
|
+
history_record = record
|
|
91
|
+
summary_record = record if summary_record?(record)
|
|
92
|
+
elsif summary_record?(record) && record.dig("meta", "id").to_s == id
|
|
84
93
|
summary_record = record
|
|
85
94
|
elsif history_record?(record) && record["id"].to_s == id
|
|
86
95
|
history_record = record
|
|
@@ -98,6 +107,7 @@ module Harnex
|
|
|
98
107
|
record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
|
|
99
108
|
end
|
|
100
109
|
|
|
110
|
+
# Legacy thin end rows predate record_type.
|
|
101
111
|
def history_record?(record)
|
|
102
112
|
record["schema_version"] == 1 && record.key?("status")
|
|
103
113
|
end
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -5,6 +5,9 @@ require "open3"
|
|
|
5
5
|
require_relative "harnex/version"
|
|
6
6
|
require_relative "harnex/core"
|
|
7
7
|
require_relative "harnex/dispatch_history"
|
|
8
|
+
require_relative "harnex/config"
|
|
9
|
+
require_relative "harnex/retention"
|
|
10
|
+
require_relative "harnex/pricing"
|
|
8
11
|
require_relative "harnex/terminal_status"
|
|
9
12
|
require_relative "harnex/artifact_report"
|
|
10
13
|
require_relative "harnex/context_telemetry"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
@@ -26,6 +26,10 @@ files:
|
|
|
26
26
|
- TECHNICAL.md
|
|
27
27
|
- bin/gem-push
|
|
28
28
|
- bin/harnex
|
|
29
|
+
- docs/codex-appserver.md
|
|
30
|
+
- docs/configuration.md
|
|
31
|
+
- docs/dispatch-telemetry.md
|
|
32
|
+
- docs/events.md
|
|
29
33
|
- guides/01_dispatch.md
|
|
30
34
|
- guides/02_chain.md
|
|
31
35
|
- guides/03_buddy.md
|
|
@@ -60,10 +64,13 @@ files:
|
|
|
60
64
|
- lib/harnex/commands/wait.rb
|
|
61
65
|
- lib/harnex/commands/watch.rb
|
|
62
66
|
- lib/harnex/commands/watch_presets.rb
|
|
67
|
+
- lib/harnex/config.rb
|
|
63
68
|
- lib/harnex/context_telemetry.rb
|
|
64
69
|
- lib/harnex/core.rb
|
|
65
70
|
- lib/harnex/dispatch_history.rb
|
|
66
71
|
- lib/harnex/orchestration.rb
|
|
72
|
+
- lib/harnex/pricing.rb
|
|
73
|
+
- lib/harnex/retention.rb
|
|
67
74
|
- lib/harnex/runtime/api_server.rb
|
|
68
75
|
- lib/harnex/runtime/file_change_hook.rb
|
|
69
76
|
- lib/harnex/runtime/inbox.rb
|