harnex 0.7.14 → 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 +132 -1
- 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 +50 -3
- 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 +56 -2
- data/lib/harnex/commands/run.rb +77 -7
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +158 -61
- data/lib/harnex/commands/watch.rb +3 -2
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +11 -8
- data/lib/harnex/dispatch_history.rb +107 -3
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +156 -13
- data/lib/harnex/terminal_status.rb +12 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
|
@@ -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
|
|
|
@@ -205,6 +206,7 @@ module Harnex
|
|
|
205
206
|
@server = ApiServer.new(self)
|
|
206
207
|
@server.start
|
|
207
208
|
persist_registry
|
|
209
|
+
append_dispatch_start_record
|
|
208
210
|
|
|
209
211
|
stdin_state = STDIN.tty? ? STDIN.raw! : nil
|
|
210
212
|
watch_thread = start_watch_thread
|
|
@@ -474,6 +476,7 @@ module Harnex
|
|
|
474
476
|
@server = ApiServer.new(self)
|
|
475
477
|
@server.start
|
|
476
478
|
persist_registry
|
|
479
|
+
append_dispatch_start_record
|
|
477
480
|
|
|
478
481
|
watch_thread = start_watch_thread
|
|
479
482
|
@inbox.start
|
|
@@ -1152,6 +1155,13 @@ module Harnex
|
|
|
1152
1155
|
@output_log_failed = false
|
|
1153
1156
|
end
|
|
1154
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
|
+
|
|
1155
1165
|
def prepare_events_log
|
|
1156
1166
|
@events_log&.close unless @events_log&.closed?
|
|
1157
1167
|
@events_log = File.open(events_log_path, "ab")
|
|
@@ -1245,7 +1255,9 @@ module Harnex
|
|
|
1245
1255
|
end
|
|
1246
1256
|
|
|
1247
1257
|
def emit_summary_event
|
|
1248
|
-
|
|
1258
|
+
payload = { path: dispatch_history_path, exit: @exit_reason }
|
|
1259
|
+
payload[:mirror_path] = summary_out if summary_out
|
|
1260
|
+
emit_event("summary", **payload)
|
|
1249
1261
|
end
|
|
1250
1262
|
|
|
1251
1263
|
def emit_attempt_finished(attempt)
|
|
@@ -1282,9 +1294,9 @@ module Harnex
|
|
|
1282
1294
|
warn("harnex: failed to collect session-end telemetry: #{e.message}")
|
|
1283
1295
|
end
|
|
1284
1296
|
@exit_reason ||= classify_exit
|
|
1285
|
-
record =
|
|
1297
|
+
record = DispatchHistory.build_record(self)
|
|
1298
|
+
append_dispatch_history_record(record)
|
|
1286
1299
|
append_summary_record(record)
|
|
1287
|
-
append_dispatch_history_record
|
|
1288
1300
|
emit_summary_event
|
|
1289
1301
|
emit_attempt_finished(record.fetch(:attempt))
|
|
1290
1302
|
emit_exit_event
|
|
@@ -1579,10 +1591,30 @@ module Harnex
|
|
|
1579
1591
|
cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
|
|
1580
1592
|
cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
|
|
1581
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
|
+
|
|
1582
1613
|
{
|
|
1583
1614
|
"status" => status,
|
|
1584
1615
|
"cost_usd" => values[:cost_usd],
|
|
1585
1616
|
"cost_source" => cost_source,
|
|
1617
|
+
"cost_price_as_of" => cost_price_as_of,
|
|
1586
1618
|
"input_tokens" => values[:input_tokens],
|
|
1587
1619
|
"output_tokens" => values[:output_tokens],
|
|
1588
1620
|
"cached_input_tokens" => values[:cached_tokens],
|
|
@@ -1760,6 +1792,101 @@ module Harnex
|
|
|
1760
1792
|
@exit_reason == "success"
|
|
1761
1793
|
end
|
|
1762
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
|
+
|
|
1763
1890
|
def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
|
|
1764
1891
|
return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
|
|
1765
1892
|
|
|
@@ -1783,6 +1910,7 @@ module Harnex
|
|
|
1783
1910
|
def build_summary_reliability
|
|
1784
1911
|
counters = reliability_event_counters
|
|
1785
1912
|
real_disconnections = counters[:disconnections].to_i
|
|
1913
|
+
chain = attempt_chain_summary
|
|
1786
1914
|
{
|
|
1787
1915
|
"adapter_close" => summary_adapter_close(real_disconnections),
|
|
1788
1916
|
"real_disconnections" => real_disconnections,
|
|
@@ -1790,7 +1918,7 @@ module Harnex
|
|
|
1790
1918
|
"stalls" => counters[:stalls].to_i,
|
|
1791
1919
|
"force_resumes" => counters[:force_resumes].to_i,
|
|
1792
1920
|
"compactions" => counters[:compactions].to_i,
|
|
1793
|
-
"recovered" =>
|
|
1921
|
+
"recovered" => chain[:recovered]
|
|
1794
1922
|
}
|
|
1795
1923
|
end
|
|
1796
1924
|
|
|
@@ -1799,7 +1927,7 @@ module Harnex
|
|
|
1799
1927
|
output_measurements = summary_output_measurements
|
|
1800
1928
|
accepted = outcome["status"] == "accepted"
|
|
1801
1929
|
duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
|
|
1802
|
-
|
|
1930
|
+
chain = attempt_chain_summary
|
|
1803
1931
|
total_tokens = @usage_summary[:total_tokens]
|
|
1804
1932
|
|
|
1805
1933
|
actual = {
|
|
@@ -1837,9 +1965,9 @@ module Harnex
|
|
|
1837
1965
|
event_records: @events_log_seq,
|
|
1838
1966
|
output_log_path: output_log_path,
|
|
1839
1967
|
events_log_path: events_log_path,
|
|
1840
|
-
attempts_total:
|
|
1841
|
-
attempts_succeeded:
|
|
1842
|
-
attempts_failed:
|
|
1968
|
+
attempts_total: chain[:attempts_total],
|
|
1969
|
+
attempts_succeeded: chain[:attempts_succeeded],
|
|
1970
|
+
attempts_failed: chain[:attempts_failed],
|
|
1843
1971
|
retry_count: counters[:retries],
|
|
1844
1972
|
throttle_429_count: counters[:throttle_429],
|
|
1845
1973
|
disconnect_count: counters[:disconnections],
|
|
@@ -1847,7 +1975,7 @@ module Harnex
|
|
|
1847
1975
|
throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
|
|
1848
1976
|
retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
|
|
1849
1977
|
unattributed: attribution["status"] != "complete",
|
|
1850
|
-
fallback_triggered:
|
|
1978
|
+
fallback_triggered: chain[:fallback_triggered]
|
|
1851
1979
|
}
|
|
1852
1980
|
actual
|
|
1853
1981
|
end
|
|
@@ -1981,6 +2109,8 @@ module Harnex
|
|
|
1981
2109
|
meta.is_a?(Hash) ? meta : {}
|
|
1982
2110
|
end
|
|
1983
2111
|
|
|
2112
|
+
# Explicit-only mirror: the identical end record lands here in addition
|
|
2113
|
+
# to the tracked stream when --summary-out is configured.
|
|
1984
2114
|
def append_summary_record(record)
|
|
1985
2115
|
return unless summary_out
|
|
1986
2116
|
|
|
@@ -1993,13 +2123,26 @@ module Harnex
|
|
|
1993
2123
|
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
1994
2124
|
end
|
|
1995
2125
|
|
|
1996
|
-
def
|
|
1997
|
-
|
|
1998
|
-
|
|
2126
|
+
def append_dispatch_start_record
|
|
2127
|
+
DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
|
|
2128
|
+
rescue StandardError => e
|
|
2129
|
+
warn("harnex: failed to write dispatch start record: #{e.message}")
|
|
2130
|
+
end
|
|
2131
|
+
|
|
2132
|
+
def append_dispatch_history_record(record)
|
|
2133
|
+
DispatchHistory.append(dispatch_history_path, record)
|
|
1999
2134
|
rescue StandardError => e
|
|
2000
2135
|
warn("harnex: failed to write dispatch history: #{e.message}")
|
|
2001
2136
|
end
|
|
2002
2137
|
|
|
2138
|
+
# One canonical stream per session: the start row and the end row must
|
|
2139
|
+
# land in the same file so readers can pair them. repo_root — not the
|
|
2140
|
+
# launch cwd — is the root that registry, events, and default summary
|
|
2141
|
+
# paths already key off.
|
|
2142
|
+
def dispatch_history_path
|
|
2143
|
+
@dispatch_history_path ||= DispatchHistory.path_for(repo_root)
|
|
2144
|
+
end
|
|
2145
|
+
|
|
2003
2146
|
def normalized_usage_summary(summary)
|
|
2004
2147
|
summary ||= {}
|
|
2005
2148
|
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
@@ -78,8 +78,18 @@ module Harnex
|
|
|
78
78
|
File.foreach(path) do |line|
|
|
79
79
|
record = JSON.parse(line)
|
|
80
80
|
next unless record.is_a?(Hash)
|
|
81
|
+
next if DispatchHistory.start_record?(record)
|
|
81
82
|
|
|
82
|
-
|
|
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
|
|
83
93
|
summary_record = record
|
|
84
94
|
elsif history_record?(record) && record["id"].to_s == id
|
|
85
95
|
history_record = record
|
|
@@ -97,6 +107,7 @@ module Harnex
|
|
|
97
107
|
record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
# Legacy thin end rows predate record_type.
|
|
100
111
|
def history_record?(record)
|
|
101
112
|
record["schema_version"] == 1 && record.key?("status")
|
|
102
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,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.9.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-08-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
|
|
14
14
|
and adds a control plane for discovery, messaging, and coordination.
|
|
@@ -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
|