harnex 0.7.12 → 0.7.13
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 +33 -0
- data/README.md +12 -9
- data/lib/harnex/adapters/base.rb +16 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +12 -0
- data/lib/harnex/adapters/pi.rb +46 -5
- data/lib/harnex/artifact_report.rb +17 -0
- data/lib/harnex/commands/run.rb +20 -1
- data/lib/harnex/context_telemetry.rb +104 -0
- data/lib/harnex/core.rb +2 -0
- data/lib/harnex/runtime/session.rb +346 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e087c98409987c2494911c830e3670a23abc5129ed22030bebd7cee953f920fd
|
|
4
|
+
data.tar.gz: 036eb960f719874ba7f7954bcf3c4191ca324d4ff77f7098069b231327efbd87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a96531d075117e46512181ebb89fff0c0abb3ffbd829807d4fa9624b5c684255fc896882455678b7cce0e9094be11d4279beb5a6af84ad0a23e2974496d5d173
|
|
7
|
+
data.tar.gz: 530d365ae7a6bbd2d2d9f34a708babb8f5c9446290808ed23170fc8329997c2b9c6ce5dd62ae326905bec7f46cd222253b4815c61d484c9e600d40ff675d4fee
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.13] - 2026-07-15 | 12:24 PM | IST
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Dispatch summaries now include a stable additive `context` block with
|
|
10
|
+
terminal and peak active-context pressure, source/status provenance, bounded
|
|
11
|
+
sample counts, and explicit missing-sample handling. Pi RPC reports observed
|
|
12
|
+
`get_session_stats.contextUsage`; Codex app-server conservatively estimates
|
|
13
|
+
pressure from `tokenUsage.last` plus `modelContextWindow`; unsupported
|
|
14
|
+
adapters do not fabricate values.
|
|
15
|
+
- Dispatch summaries now include additive `usage`, `attribution`, `outcome`, and
|
|
16
|
+
per-session `attempt` blocks. `usage.status` distinguishes provider-observed,
|
|
17
|
+
explicit-zero, caller-estimated, unsupported, and missing values so nullable
|
|
18
|
+
cost/token fields are never mistaken for zero.
|
|
19
|
+
- `harnex run` accepts `--parent-dispatch-id`, `--parent-attempt-id`, and
|
|
20
|
+
`--attempt-kind` (`initial`, `retry`, `fix`, `review`, or `superseding`) to
|
|
21
|
+
preserve parent/child relationships while retaining separate raw effort rows.
|
|
22
|
+
- Terminal event streams now emit `attempt_started` / `attempt_finished` and
|
|
23
|
+
translate Pi internal retry notices into `attempt_retry_scheduled`; the
|
|
24
|
+
Session exposes an `attempt_fallback_switched` telemetry seam for recovery
|
|
25
|
+
and fallback owners.
|
|
26
|
+
- Bounded artifact reports can optionally record a semantic outcome
|
|
27
|
+
(`accepted`, `rejected`, `no_change`, or `unknown`). Final outcome blocks
|
|
28
|
+
retain git commit/path/LOC observations without claiming they prove authorship.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Dispatch telemetry documentation now distinguishes cumulative usage from
|
|
33
|
+
active context-window pressure and defines context sample/high-water/null
|
|
34
|
+
semantics alongside usage-cost provenance, strict attribution quality,
|
|
35
|
+
sidecar-backed outcomes, attempt linkage, and safe throughput/reliability
|
|
36
|
+
query examples.
|
|
37
|
+
|
|
5
38
|
## [0.7.12] - 2026-07-10 | 11:19 PM | IST
|
|
6
39
|
|
|
7
40
|
### Fixed
|
data/README.md
CHANGED
|
@@ -281,8 +281,9 @@ harnex run pi --id pi-i-52 \
|
|
|
281
281
|
The sidecar schema is `harnex.artifact_report.v1`; harnex exposes the path as
|
|
282
282
|
`HARNEX_ARTIFACT_REPORT_PATH` / `HARNEX_VALIDATION_REPORT_PATH`, then records a
|
|
283
283
|
compact `artifact_report`, `validation`, and `artifacts` summary in the dispatch
|
|
284
|
-
row.
|
|
285
|
-
|
|
284
|
+
row. A sidecar can also assert the bounded outcome (`accepted`, `rejected`,
|
|
285
|
+
`no_change`, or `unknown`); git changes alone never imply semantic acceptance.
|
|
286
|
+
Missing or malformed reports are warning telemetry, not wrapped-process crashes.
|
|
286
287
|
|
|
287
288
|
Queue runners can pass first-class attribution without hiding it in prose:
|
|
288
289
|
|
|
@@ -293,13 +294,15 @@ harnex run pi --project-id harnex --queue-id queue-005 --entry-id SP-4 \
|
|
|
293
294
|
|
|
294
295
|
Soft budget metadata is copied into summary `meta`; queue/agent/reliability
|
|
295
296
|
metadata is copied into top-level `queue`, `agent`, and `reliability` summary
|
|
296
|
-
blocks.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
`
|
|
301
|
-
|
|
302
|
-
`
|
|
297
|
+
blocks. Every terminal row also has `usage` (so null is distinguishable from
|
|
298
|
+
explicit zero or an estimate), `attribution`, `outcome`, and a joinable
|
|
299
|
+
per-session `attempt` block. Terminal summary `actual` records timing, exit
|
|
300
|
+
classification, token usage when the adapter can capture it, adapter-reported
|
|
301
|
+
`cost_usd` when reliably available, git deltas, task-completion state,
|
|
302
|
+
operational counters (`stalls`, `force_resumes`, `disconnections`,
|
|
303
|
+
`tool_calls`, `commands_executed`), output/event log paths, and rough volume
|
|
304
|
+
measurements such as `lines_changed`, `output_lines`, `output_bytes`, and
|
|
305
|
+
`event_records`.
|
|
303
306
|
Harnex records the data only; consumers decide whether to fail closed. See
|
|
304
307
|
[docs/dispatch-telemetry.md](docs/dispatch-telemetry.md) for the field
|
|
305
308
|
contract.
|
data/lib/harnex/adapters/base.rb
CHANGED
|
@@ -73,6 +73,22 @@ module Harnex
|
|
|
73
73
|
{}
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Whether this adapter has a supported source for token/cost telemetry.
|
|
77
|
+
# A supported source can still be missing for a particular run.
|
|
78
|
+
def usage_telemetry_supported?
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether this adapter exposes active context-window occupancy separately
|
|
83
|
+
# from cumulative usage. Supported sources can still yield no valid sample.
|
|
84
|
+
def context_telemetry_supported?
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def context_telemetry_source
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
76
92
|
def send_wait_seconds(submit:, enter_only:)
|
|
77
93
|
0.0
|
|
78
94
|
end
|
|
@@ -74,6 +74,18 @@ module Harnex
|
|
|
74
74
|
"openai"
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
def usage_telemetry_supported?
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def context_telemetry_supported?
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def context_telemetry_source
|
|
86
|
+
"codex_thread_token_usage_last"
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def base_command
|
|
78
90
|
["codex", "app-server"]
|
|
79
91
|
end
|
data/lib/harnex/adapters/pi.rb
CHANGED
|
@@ -31,6 +31,10 @@ module Harnex
|
|
|
31
31
|
@write_mutex = Mutex.new
|
|
32
32
|
@summary_mutex = Mutex.new
|
|
33
33
|
@session_summary = {}
|
|
34
|
+
@context_telemetry = ContextTelemetry.new(
|
|
35
|
+
status: "observed",
|
|
36
|
+
source: context_telemetry_source
|
|
37
|
+
)
|
|
34
38
|
@model = nil
|
|
35
39
|
@provider = nil
|
|
36
40
|
@session_stats_requested = false
|
|
@@ -49,6 +53,18 @@ module Harnex
|
|
|
49
53
|
@model
|
|
50
54
|
end
|
|
51
55
|
|
|
56
|
+
def usage_telemetry_supported?
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def context_telemetry_supported?
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def context_telemetry_source
|
|
65
|
+
"pi_get_session_stats"
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def base_command
|
|
53
69
|
["pi", "--mode", "rpc"]
|
|
54
70
|
end
|
|
@@ -147,11 +163,17 @@ module Harnex
|
|
|
147
163
|
|
|
148
164
|
def request_session_stats_async
|
|
149
165
|
return if @closed
|
|
150
|
-
return if @session_stats_requested
|
|
151
166
|
|
|
152
|
-
|
|
167
|
+
should_request = @summary_mutex.synchronize do
|
|
168
|
+
next false if @session_stats_requested
|
|
169
|
+
|
|
170
|
+
@session_stats_requested = true
|
|
171
|
+
end
|
|
172
|
+
return unless should_request
|
|
173
|
+
|
|
153
174
|
write_line("type" => "get_session_stats")
|
|
154
175
|
rescue StandardError
|
|
176
|
+
clear_session_stats_request
|
|
155
177
|
nil
|
|
156
178
|
end
|
|
157
179
|
|
|
@@ -181,8 +203,10 @@ module Harnex
|
|
|
181
203
|
agent_session_id: @session_summary[:agent_session_id],
|
|
182
204
|
tool_calls: @session_summary[:tool_calls],
|
|
183
205
|
cost_usd: @session_summary[:cost_usd],
|
|
206
|
+
cost_source: @session_summary[:cost_source],
|
|
184
207
|
model: @session_summary[:model],
|
|
185
|
-
agent_provider: @session_summary[:agent_provider]
|
|
208
|
+
agent_provider: @session_summary[:agent_provider],
|
|
209
|
+
context: @context_telemetry.snapshot
|
|
186
210
|
}
|
|
187
211
|
end
|
|
188
212
|
end
|
|
@@ -263,8 +287,7 @@ module Harnex
|
|
|
263
287
|
end
|
|
264
288
|
|
|
265
289
|
def attempt_live_summary_refresh
|
|
266
|
-
|
|
267
|
-
absorb_session_stats(response)
|
|
290
|
+
request("type" => "get_session_stats")
|
|
268
291
|
rescue StandardError
|
|
269
292
|
nil
|
|
270
293
|
end
|
|
@@ -338,6 +361,8 @@ module Harnex
|
|
|
338
361
|
@state = :prompt
|
|
339
362
|
@last_completed_at = Time.now
|
|
340
363
|
request_session_stats_async
|
|
364
|
+
when "compaction_end"
|
|
365
|
+
request_session_stats_async
|
|
341
366
|
when "message_end"
|
|
342
367
|
absorb_model_from_message(message["message"])
|
|
343
368
|
end
|
|
@@ -362,6 +387,8 @@ module Harnex
|
|
|
362
387
|
return unless data.is_a?(Hash)
|
|
363
388
|
|
|
364
389
|
tokens = data["tokens"] || {}
|
|
390
|
+
context_usage = data["contextUsage"]
|
|
391
|
+
context_usage = {} unless context_usage.is_a?(Hash)
|
|
365
392
|
@summary_mutex.synchronize do
|
|
366
393
|
@session_summary[:input_tokens] = numeric_or_nil(tokens["input"])
|
|
367
394
|
@session_summary[:output_tokens] = numeric_or_nil(tokens["output"])
|
|
@@ -369,10 +396,24 @@ module Harnex
|
|
|
369
396
|
@session_summary[:total_tokens] = numeric_or_nil(tokens["total"])
|
|
370
397
|
@session_summary[:tool_calls] = numeric_or_nil(data["toolCalls"])
|
|
371
398
|
@session_summary[:cost_usd] = float_or_nil(data["cost"])
|
|
399
|
+
@session_summary[:cost_source] = "provider_reported" unless @session_summary[:cost_usd].nil?
|
|
372
400
|
@session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
|
|
373
401
|
@session_summary[:model] = @model if @model
|
|
374
402
|
@session_summary[:agent_provider] = @provider if @provider
|
|
403
|
+
@context_telemetry.record(
|
|
404
|
+
tokens: context_usage["tokens"],
|
|
405
|
+
window_tokens: context_usage["contextWindow"],
|
|
406
|
+
percent: context_usage["percent"]
|
|
407
|
+
)
|
|
375
408
|
end
|
|
409
|
+
ensure
|
|
410
|
+
clear_session_stats_request
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def clear_session_stats_request
|
|
414
|
+
@summary_mutex.synchronize { @session_stats_requested = false }
|
|
415
|
+
rescue StandardError
|
|
416
|
+
@session_stats_requested = false
|
|
376
417
|
end
|
|
377
418
|
|
|
378
419
|
def absorb_model_from_message(message)
|
|
@@ -74,6 +74,7 @@ module Harnex
|
|
|
74
74
|
def build_payload(path, bytes:, sha256:, report:)
|
|
75
75
|
artifacts = compact_artifacts(report["artifacts"])
|
|
76
76
|
validation = compact_validation(report["validation"])
|
|
77
|
+
outcome = compact_outcome(report["outcome"])
|
|
77
78
|
payload = {
|
|
78
79
|
"artifact_report" => metadata(
|
|
79
80
|
path,
|
|
@@ -89,6 +90,7 @@ module Harnex
|
|
|
89
90
|
}
|
|
90
91
|
payload["validation"] = validation if validation
|
|
91
92
|
payload["artifacts"] = artifacts unless artifacts.empty?
|
|
93
|
+
payload["outcome"] = outcome if outcome
|
|
92
94
|
payload
|
|
93
95
|
end
|
|
94
96
|
|
|
@@ -144,6 +146,21 @@ module Harnex
|
|
|
144
146
|
end
|
|
145
147
|
end
|
|
146
148
|
|
|
149
|
+
def compact_outcome(value)
|
|
150
|
+
return nil unless value.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
status = value["status"].to_s
|
|
153
|
+
return nil unless %w[accepted rejected no_change unknown].include?(status)
|
|
154
|
+
|
|
155
|
+
payload = {
|
|
156
|
+
"status" => status,
|
|
157
|
+
"summary" => bounded_string_or_nil(value["summary"]),
|
|
158
|
+
"commit_sha" => bounded_string_or_nil(value["commit_sha"])
|
|
159
|
+
}
|
|
160
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
161
|
+
payload
|
|
162
|
+
end
|
|
163
|
+
|
|
147
164
|
def compact_artifacts(value)
|
|
148
165
|
Array(value).first(MAX_ARTIFACTS).filter_map do |entry|
|
|
149
166
|
next unless entry.is_a?(Hash)
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -16,7 +16,10 @@ module Harnex
|
|
|
16
16
|
"--plan" => "plan",
|
|
17
17
|
"--intent" => "intent",
|
|
18
18
|
"--model" => "model",
|
|
19
|
-
"--effort" => "effort"
|
|
19
|
+
"--effort" => "effort",
|
|
20
|
+
"--parent-dispatch-id" => "parent_dispatch_id",
|
|
21
|
+
"--parent-attempt-id" => "parent_attempt_id",
|
|
22
|
+
"--attempt-kind" => "attempt_kind"
|
|
20
23
|
}.freeze
|
|
21
24
|
TELEMETRY_KEYS_TO_FLAGS = TELEMETRY_FLAGS.invert.freeze
|
|
22
25
|
TELEMETRY_EQUALS_PREFIXES = TELEMETRY_FLAGS.keys.map { |flag| "#{flag}=" }.freeze
|
|
@@ -70,6 +73,12 @@ module Harnex
|
|
|
70
73
|
--intent TEXT Queue/work intent telemetry
|
|
71
74
|
--model NAME Requested model metadata (also used for structured dispatch)
|
|
72
75
|
--effort LEVEL Requested reasoning effort metadata (structured dispatch)
|
|
76
|
+
--parent-dispatch-id ID
|
|
77
|
+
Parent dispatch id for retry/fix/review joins
|
|
78
|
+
--parent-attempt-id ID
|
|
79
|
+
Parent attempt id for retry/fix/review joins
|
|
80
|
+
--attempt-kind KIND
|
|
81
|
+
initial, retry, fix, review, or superseding (default: initial)
|
|
73
82
|
--require-attribution
|
|
74
83
|
Fail before launch unless project/phase/intent and one work id are present
|
|
75
84
|
--cwd DIR Run the wrapped agent from DIR and use DIR as the session root
|
|
@@ -155,6 +164,7 @@ module Harnex
|
|
|
155
164
|
raise OptionParser::MissingArgument, "cli" if cli_name.nil?
|
|
156
165
|
validate_auto_stop_context!
|
|
157
166
|
apply_telemetry_options!
|
|
167
|
+
validate_attempt_metadata!
|
|
158
168
|
validate_required_attribution!
|
|
159
169
|
|
|
160
170
|
repo_root = resolve_run_root(cli_name, child_args)
|
|
@@ -674,6 +684,15 @@ module Harnex
|
|
|
674
684
|
@options[:meta] = (@options[:meta].is_a?(Hash) ? @options[:meta].dup : {}).merge(explicit)
|
|
675
685
|
end
|
|
676
686
|
|
|
687
|
+
def validate_attempt_metadata!
|
|
688
|
+
metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
|
|
689
|
+
kind = metadata["attempt_kind"].to_s
|
|
690
|
+
return if kind.empty? || Session::ATTEMPT_KINDS.include?(kind)
|
|
691
|
+
|
|
692
|
+
raise OptionParser::InvalidOption,
|
|
693
|
+
"harnex run: --attempt-kind must be one of #{Session::ATTEMPT_KINDS.join(', ')}"
|
|
694
|
+
end
|
|
695
|
+
|
|
677
696
|
def validate_required_attribution!
|
|
678
697
|
return unless @options[:require_attribution]
|
|
679
698
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# Bounded in-memory aggregate for active context-window samples.
|
|
3
|
+
#
|
|
4
|
+
# It retains only the final valid occupancy sample, independent high-water
|
|
5
|
+
# marks, and counters that make unavailable samples visible without storing
|
|
6
|
+
# raw protocol payloads.
|
|
7
|
+
class ContextTelemetry
|
|
8
|
+
MEASUREMENT_STATUSES = %w[observed estimated].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(status:, source:)
|
|
11
|
+
measurement_status = status.to_s
|
|
12
|
+
unless MEASUREMENT_STATUSES.include?(measurement_status)
|
|
13
|
+
raise ArgumentError, "context telemetry status must be observed or estimated"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@measurement_status = measurement_status
|
|
17
|
+
@source = source.to_s.empty? ? nil : source.to_s
|
|
18
|
+
@terminal_tokens = nil
|
|
19
|
+
@window_tokens = nil
|
|
20
|
+
@terminal_percent = nil
|
|
21
|
+
@peak_tokens = nil
|
|
22
|
+
@peak_percent = nil
|
|
23
|
+
@samples = 0
|
|
24
|
+
@missing_samples = 0
|
|
25
|
+
@latest_sample_status = nil
|
|
26
|
+
@valid_sample_seen = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record(tokens:, window_tokens:, percent: nil)
|
|
30
|
+
@samples += 1
|
|
31
|
+
token_count = nonnegative_integer(tokens)
|
|
32
|
+
window_size = positive_integer(window_tokens)
|
|
33
|
+
pressure_percent = nonnegative_float(percent)
|
|
34
|
+
pressure_percent ||= derived_percent(token_count, window_size)
|
|
35
|
+
|
|
36
|
+
if token_count.nil?
|
|
37
|
+
@missing_samples += 1
|
|
38
|
+
@latest_sample_status = "missing"
|
|
39
|
+
return snapshot
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@valid_sample_seen = true
|
|
43
|
+
@terminal_tokens = token_count
|
|
44
|
+
@window_tokens = window_size
|
|
45
|
+
@terminal_percent = pressure_percent
|
|
46
|
+
@peak_tokens = [@peak_tokens, token_count].compact.max
|
|
47
|
+
@peak_percent = [@peak_percent, pressure_percent].compact.max
|
|
48
|
+
@latest_sample_status = @measurement_status
|
|
49
|
+
snapshot
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def snapshot
|
|
53
|
+
{
|
|
54
|
+
status: @valid_sample_seen ? @measurement_status : "missing",
|
|
55
|
+
source: @source,
|
|
56
|
+
terminal_tokens: @terminal_tokens,
|
|
57
|
+
window_tokens: @window_tokens,
|
|
58
|
+
terminal_percent: @terminal_percent,
|
|
59
|
+
peak_tokens: @peak_tokens,
|
|
60
|
+
peak_percent: @peak_percent,
|
|
61
|
+
samples: @samples,
|
|
62
|
+
missing_samples: @missing_samples,
|
|
63
|
+
latest_sample_status: @latest_sample_status
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def nonnegative_integer(value)
|
|
70
|
+
return nil if value.nil?
|
|
71
|
+
|
|
72
|
+
parsed = Integer(value)
|
|
73
|
+
return nil if value.is_a?(Numeric) && value.to_f != parsed.to_f
|
|
74
|
+
return nil if parsed.negative?
|
|
75
|
+
|
|
76
|
+
parsed
|
|
77
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def positive_integer(value)
|
|
82
|
+
parsed = nonnegative_integer(value)
|
|
83
|
+
parsed&.positive? ? parsed : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def nonnegative_float(value)
|
|
87
|
+
return nil if value.nil?
|
|
88
|
+
|
|
89
|
+
parsed = Float(value)
|
|
90
|
+
return nil unless parsed.finite?
|
|
91
|
+
return nil if parsed.negative?
|
|
92
|
+
|
|
93
|
+
parsed
|
|
94
|
+
rescue ArgumentError, TypeError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def derived_percent(token_count, window_size)
|
|
99
|
+
return nil if token_count.nil? || window_size.nil?
|
|
100
|
+
|
|
101
|
+
((token_count.to_f / window_size) * 100).round(2)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/harnex/core.rb
CHANGED
|
@@ -141,6 +141,7 @@ module Harnex
|
|
|
141
141
|
end_sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
142
142
|
range = "#{start_sha}..#{end_sha}"
|
|
143
143
|
shortstat = git_output(repo_root, "diff", "--shortstat", range)
|
|
144
|
+
changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
|
|
144
145
|
commits = Integer(git_output(repo_root, "rev-list", "--count", range))
|
|
145
146
|
stats = parse_git_shortstat(shortstat)
|
|
146
147
|
|
|
@@ -149,6 +150,7 @@ module Harnex
|
|
|
149
150
|
loc_added: stats.fetch(:loc_added),
|
|
150
151
|
loc_removed: stats.fetch(:loc_removed),
|
|
151
152
|
files_changed: stats.fetch(:files_changed),
|
|
153
|
+
changed_paths: changed_paths,
|
|
152
154
|
commits: commits
|
|
153
155
|
}
|
|
154
156
|
rescue StandardError
|
|
@@ -9,8 +9,21 @@ module Harnex
|
|
|
9
9
|
AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT = 5.0
|
|
10
10
|
USAGE_FIELDS = %i[
|
|
11
11
|
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
12
|
-
agent_session_id cost_usd tool_calls model agent_provider
|
|
12
|
+
agent_session_id cost_usd cost_source tool_calls model agent_provider
|
|
13
13
|
].freeze
|
|
14
|
+
USAGE_MEASUREMENT_FIELDS = %i[
|
|
15
|
+
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens cost_usd
|
|
16
|
+
].freeze
|
|
17
|
+
USAGE_STATUSES = %w[observed estimated unsupported missing zero].freeze
|
|
18
|
+
CONTEXT_FIELDS = %i[
|
|
19
|
+
status source terminal_tokens window_tokens terminal_percent peak_tokens
|
|
20
|
+
peak_percent samples missing_samples latest_sample_status
|
|
21
|
+
].freeze
|
|
22
|
+
CONTEXT_MEASUREMENT_FIELDS = %i[
|
|
23
|
+
terminal_tokens terminal_percent peak_tokens peak_percent
|
|
24
|
+
].freeze
|
|
25
|
+
CONTEXT_SAMPLE_STATUSES = %w[observed estimated missing].freeze
|
|
26
|
+
ATTEMPT_KINDS = %w[initial retry fix review superseding].freeze
|
|
14
27
|
SESSION_SUMMARY_SIGNAL_FIELDS = %i[
|
|
15
28
|
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
16
29
|
agent_session_id cost_usd
|
|
@@ -29,6 +42,8 @@ module Harnex
|
|
|
29
42
|
force_resumes: 0,
|
|
30
43
|
disconnections: 0,
|
|
31
44
|
compactions: 0,
|
|
45
|
+
retries: 0,
|
|
46
|
+
throttle_429: 0,
|
|
32
47
|
tool_calls: 0,
|
|
33
48
|
commands_executed: 0
|
|
34
49
|
}
|
|
@@ -44,6 +59,10 @@ module Harnex
|
|
|
44
59
|
@counts[:disconnections] += 1
|
|
45
60
|
when "compaction"
|
|
46
61
|
@counts[:compactions] += 1
|
|
62
|
+
when "attempt_retry_scheduled"
|
|
63
|
+
@counts[:retries] += 1
|
|
64
|
+
when "throttle_429"
|
|
65
|
+
@counts[:throttle_429] += 1
|
|
47
66
|
end
|
|
48
67
|
end
|
|
49
68
|
|
|
@@ -107,6 +126,8 @@ module Harnex
|
|
|
107
126
|
@git_start = {}
|
|
108
127
|
@git_end = {}
|
|
109
128
|
@usage_summary = {}
|
|
129
|
+
@context_summary = {}
|
|
130
|
+
@rpc_context_telemetry = nil
|
|
110
131
|
@ended_at = nil
|
|
111
132
|
@exit_reason = nil
|
|
112
133
|
@last_error = nil
|
|
@@ -262,6 +283,23 @@ module Harnex
|
|
|
262
283
|
!!@last_failed_at
|
|
263
284
|
end
|
|
264
285
|
|
|
286
|
+
# Public seam for structured recovery/fallback owners (#42 / plan 30).
|
|
287
|
+
# The current session remains the parent attempt; a recovery implementation
|
|
288
|
+
# supplies a new child attempt id when it starts an independently billable arm.
|
|
289
|
+
def record_attempt_transition(type:, child_attempt_id: nil, trigger: nil)
|
|
290
|
+
unless %w[attempt_retry_scheduled attempt_fallback_switched].include?(type.to_s)
|
|
291
|
+
raise ArgumentError, "unsupported attempt transition #{type.inspect}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
emit_event(
|
|
295
|
+
type.to_s,
|
|
296
|
+
**attempt_lifecycle_context.merge(
|
|
297
|
+
child_attempt_id: summary_string(child_attempt_id),
|
|
298
|
+
trigger: summary_string(trigger)
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
|
|
265
303
|
def git_start
|
|
266
304
|
@git_start || {}
|
|
267
305
|
end
|
|
@@ -524,8 +562,12 @@ module Harnex
|
|
|
524
562
|
# Schema: ThreadTokenUsageUpdatedNotification carries
|
|
525
563
|
# `tokenUsage: { last, total, modelContextWindow? }` where each
|
|
526
564
|
# breakdown has camelCase {input,output,cachedInput,reasoningOutput,total}Tokens.
|
|
527
|
-
# Snapshot
|
|
528
|
-
|
|
565
|
+
# Snapshot cumulative `total` for usage and aggregate `last` separately
|
|
566
|
+
# as conservative active-context pressure.
|
|
567
|
+
if params["tokenUsage"].is_a?(Hash)
|
|
568
|
+
@token_usage = params["tokenUsage"]
|
|
569
|
+
record_rpc_context_sample(@token_usage)
|
|
570
|
+
end
|
|
529
571
|
when "thread/status/changed"
|
|
530
572
|
# State machine reflects RPC state; no event needed.
|
|
531
573
|
nil
|
|
@@ -544,6 +586,7 @@ module Harnex
|
|
|
544
586
|
threadId: params["threadId"],
|
|
545
587
|
turnId: params["turnId"]
|
|
546
588
|
)
|
|
589
|
+
emit_event("throttle_429", source: "error_notification", message: message) if throttle_429?(message, params)
|
|
547
590
|
signal_rpc_done! if params["turnId"].to_s.empty?
|
|
548
591
|
end
|
|
549
592
|
rescue StandardError => e
|
|
@@ -583,6 +626,13 @@ module Harnex
|
|
|
583
626
|
error.is_a?(Hash) ? error["codexErrorInfo"] : nil
|
|
584
627
|
end
|
|
585
628
|
|
|
629
|
+
def throttle_429?(message, params)
|
|
630
|
+
return true if message.to_s.match?(/\b429\b|rate limit/i)
|
|
631
|
+
|
|
632
|
+
error = params["error"]
|
|
633
|
+
error.is_a?(Hash) && error.values.any? { |value| value.to_s.match?(/\b429\b|rate limit/i) }
|
|
634
|
+
end
|
|
635
|
+
|
|
586
636
|
def extract_turn_error_message(turn)
|
|
587
637
|
error = turn["error"]
|
|
588
638
|
return error["message"] if error.is_a?(Hash)
|
|
@@ -641,7 +691,9 @@ module Harnex
|
|
|
641
691
|
when "queue_update"
|
|
642
692
|
nil
|
|
643
693
|
when "auto_retry_start", "auto_retry_end"
|
|
644
|
-
|
|
694
|
+
payload = message.reject { |k, _| k == "type" }
|
|
695
|
+
emit_event(event_type, **payload)
|
|
696
|
+
record_attempt_transition(type: "attempt_retry_scheduled", trigger: "adapter_auto_retry") if event_type == "auto_retry_start"
|
|
645
697
|
when "extension_ui_request"
|
|
646
698
|
handle_extension_ui_request(message)
|
|
647
699
|
when "extension_error"
|
|
@@ -987,6 +1039,10 @@ module Harnex
|
|
|
987
1039
|
payload = { pid: @pid }
|
|
988
1040
|
payload[:meta] = meta if meta
|
|
989
1041
|
emit_event("started", **payload)
|
|
1042
|
+
emit_event(
|
|
1043
|
+
"attempt_started",
|
|
1044
|
+
**attempt_lifecycle_context.merge(kind: summary_attempt_kind)
|
|
1045
|
+
)
|
|
990
1046
|
end
|
|
991
1047
|
|
|
992
1048
|
def emit_git_start_event
|
|
@@ -997,7 +1053,9 @@ module Harnex
|
|
|
997
1053
|
end
|
|
998
1054
|
|
|
999
1055
|
def emit_session_end_telemetry
|
|
1000
|
-
|
|
1056
|
+
summary = collect_session_summary
|
|
1057
|
+
@usage_summary = normalized_usage_summary(summary)
|
|
1058
|
+
@context_summary = normalized_context_summary(summary)
|
|
1001
1059
|
emit_event("usage", **@usage_summary)
|
|
1002
1060
|
|
|
1003
1061
|
@git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
|
|
@@ -1010,6 +1068,7 @@ module Harnex
|
|
|
1010
1068
|
loc_added: @git_end[:loc_added],
|
|
1011
1069
|
loc_removed: @git_end[:loc_removed],
|
|
1012
1070
|
files_changed: @git_end[:files_changed],
|
|
1071
|
+
changed_paths: @git_end[:changed_paths],
|
|
1013
1072
|
commits: @git_end[:commits]
|
|
1014
1073
|
)
|
|
1015
1074
|
end
|
|
@@ -1018,6 +1077,19 @@ module Harnex
|
|
|
1018
1077
|
emit_event("summary", path: summary_out, exit: @exit_reason)
|
|
1019
1078
|
end
|
|
1020
1079
|
|
|
1080
|
+
def emit_attempt_finished(attempt)
|
|
1081
|
+
emit_event(
|
|
1082
|
+
"attempt_finished",
|
|
1083
|
+
**attempt_lifecycle_context.merge(
|
|
1084
|
+
parent_attempt_id: attempt["parent_attempt_id"],
|
|
1085
|
+
status: attempt["status"],
|
|
1086
|
+
exit_reason: attempt["exit_reason"],
|
|
1087
|
+
end_ts: attempt["end_ts"],
|
|
1088
|
+
wall_ms: attempt["wall_ms"]
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1021
1093
|
def emit_exit_event
|
|
1022
1094
|
payload = { code: @exit_code }
|
|
1023
1095
|
payload[:signal] = @term_signal if @term_signal
|
|
@@ -1035,12 +1107,15 @@ module Harnex
|
|
|
1035
1107
|
emit_session_end_telemetry
|
|
1036
1108
|
rescue StandardError => e
|
|
1037
1109
|
@usage_summary = normalized_usage_summary(nil)
|
|
1110
|
+
@context_summary = normalized_context_summary(nil)
|
|
1038
1111
|
warn("harnex: failed to collect session-end telemetry: #{e.message}")
|
|
1039
1112
|
end
|
|
1040
1113
|
@exit_reason ||= classify_exit
|
|
1041
|
-
|
|
1114
|
+
record = build_summary_record
|
|
1115
|
+
append_summary_record(record)
|
|
1042
1116
|
append_dispatch_history_record
|
|
1043
1117
|
emit_summary_event
|
|
1118
|
+
emit_attempt_finished(record.fetch(:attempt))
|
|
1044
1119
|
emit_exit_event
|
|
1045
1120
|
end
|
|
1046
1121
|
|
|
@@ -1170,16 +1245,24 @@ module Harnex
|
|
|
1170
1245
|
end
|
|
1171
1246
|
|
|
1172
1247
|
def build_summary_record
|
|
1248
|
+
artifact_payload = artifact_report_path ? artifact_report_summary : nil
|
|
1249
|
+
attribution = build_summary_attribution
|
|
1250
|
+
outcome = build_summary_outcome(artifact_payload)
|
|
1173
1251
|
record = {
|
|
1174
1252
|
meta: build_summary_meta,
|
|
1175
1253
|
predicted: summary_predicted_payload,
|
|
1176
|
-
actual: build_summary_actual,
|
|
1254
|
+
actual: build_summary_actual(outcome: outcome, attribution: attribution),
|
|
1177
1255
|
agent: build_summary_agent,
|
|
1256
|
+
usage: build_summary_usage,
|
|
1257
|
+
context: build_summary_context,
|
|
1258
|
+
attribution: attribution,
|
|
1259
|
+
outcome: outcome,
|
|
1260
|
+
attempt: build_summary_attempt,
|
|
1178
1261
|
reliability: build_summary_reliability
|
|
1179
1262
|
}
|
|
1180
1263
|
queue = build_summary_queue
|
|
1181
1264
|
record[:queue] = queue if queue
|
|
1182
|
-
record.merge!(
|
|
1265
|
+
record.merge!(artifact_payload.reject { |key, _value| key == "outcome" }) if artifact_payload
|
|
1183
1266
|
record
|
|
1184
1267
|
end
|
|
1185
1268
|
|
|
@@ -1235,6 +1318,203 @@ module Harnex
|
|
|
1235
1318
|
}
|
|
1236
1319
|
end
|
|
1237
1320
|
|
|
1321
|
+
def build_summary_usage
|
|
1322
|
+
declared = meta_hash["usage"].is_a?(Hash) ? meta_hash["usage"] : {}
|
|
1323
|
+
observed = USAGE_MEASUREMENT_FIELDS.any? { |field| !@usage_summary[field].nil? }
|
|
1324
|
+
values = USAGE_MEASUREMENT_FIELDS.to_h do |field|
|
|
1325
|
+
[field, @usage_summary[field].nil? ? declared_usage_value(declared, field) : @usage_summary[field]]
|
|
1326
|
+
end
|
|
1327
|
+
numeric_observations = USAGE_MEASUREMENT_FIELDS.filter_map do |field|
|
|
1328
|
+
value = @usage_summary[field]
|
|
1329
|
+
value if value.is_a?(Numeric)
|
|
1330
|
+
end
|
|
1331
|
+
estimated = declared["status"].to_s == "estimated"
|
|
1332
|
+
status = if observed && numeric_observations.any? && numeric_observations.all?(&:zero?)
|
|
1333
|
+
"zero"
|
|
1334
|
+
elsif observed
|
|
1335
|
+
"observed"
|
|
1336
|
+
elsif estimated
|
|
1337
|
+
"estimated"
|
|
1338
|
+
elsif adapter.usage_telemetry_supported?
|
|
1339
|
+
"missing"
|
|
1340
|
+
else
|
|
1341
|
+
"unsupported"
|
|
1342
|
+
end
|
|
1343
|
+
cost_source = summary_string(@usage_summary[:cost_source])
|
|
1344
|
+
cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
|
|
1345
|
+
cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
|
|
1346
|
+
|
|
1347
|
+
{
|
|
1348
|
+
"status" => status,
|
|
1349
|
+
"cost_usd" => values[:cost_usd],
|
|
1350
|
+
"cost_source" => cost_source,
|
|
1351
|
+
"input_tokens" => values[:input_tokens],
|
|
1352
|
+
"output_tokens" => values[:output_tokens],
|
|
1353
|
+
"cached_input_tokens" => values[:cached_tokens],
|
|
1354
|
+
"reasoning_tokens" => values[:reasoning_tokens],
|
|
1355
|
+
"total_tokens" => values[:total_tokens]
|
|
1356
|
+
}
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1359
|
+
def build_summary_context
|
|
1360
|
+
measurement_present = CONTEXT_MEASUREMENT_FIELDS.any? do |field|
|
|
1361
|
+
@context_summary[field].is_a?(Numeric)
|
|
1362
|
+
end
|
|
1363
|
+
reported_status = summary_string(@context_summary[:status])
|
|
1364
|
+
status = if measurement_present
|
|
1365
|
+
%w[observed estimated].include?(reported_status) ? reported_status : "observed"
|
|
1366
|
+
elsif adapter.context_telemetry_supported?
|
|
1367
|
+
"missing"
|
|
1368
|
+
else
|
|
1369
|
+
"unsupported"
|
|
1370
|
+
end
|
|
1371
|
+
source = summary_string(@context_summary[:source])
|
|
1372
|
+
source ||= adapter.context_telemetry_source if adapter.context_telemetry_supported?
|
|
1373
|
+
latest_sample_status = summary_string(@context_summary[:latest_sample_status])
|
|
1374
|
+
latest_sample_status = nil unless CONTEXT_SAMPLE_STATUSES.include?(latest_sample_status)
|
|
1375
|
+
|
|
1376
|
+
{
|
|
1377
|
+
"status" => status,
|
|
1378
|
+
"source" => source,
|
|
1379
|
+
"terminal_tokens" => @context_summary[:terminal_tokens],
|
|
1380
|
+
"window_tokens" => @context_summary[:window_tokens],
|
|
1381
|
+
"terminal_percent" => @context_summary[:terminal_percent],
|
|
1382
|
+
"peak_tokens" => @context_summary[:peak_tokens],
|
|
1383
|
+
"peak_percent" => @context_summary[:peak_percent],
|
|
1384
|
+
"samples" => context_sample_count(:samples),
|
|
1385
|
+
"missing_samples" => context_sample_count(:missing_samples),
|
|
1386
|
+
"latest_sample_status" => latest_sample_status
|
|
1387
|
+
}
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
def context_sample_count(field)
|
|
1391
|
+
value = @context_summary[field]
|
|
1392
|
+
return 0 unless value.is_a?(Numeric) && value.finite? && !value.negative?
|
|
1393
|
+
|
|
1394
|
+
value.to_i
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def declared_usage_value(declared, field)
|
|
1398
|
+
return nil unless declared["status"].to_s == "estimated"
|
|
1399
|
+
|
|
1400
|
+
declared[field.to_s] || declared[usage_field_alias(field)]
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
def usage_field_alias(field)
|
|
1404
|
+
field == :cached_tokens ? "cached_input_tokens" : field.to_s
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
def build_summary_attribution
|
|
1408
|
+
queue = build_summary_queue || {}
|
|
1409
|
+
required = %w[project_id phase intent]
|
|
1410
|
+
work_fields = %w[entry_id issue plan queue_id]
|
|
1411
|
+
required_complete = required.all? { |field| !queue[field].nil? }
|
|
1412
|
+
work_field = work_fields.find { |field| !queue[field].nil? }
|
|
1413
|
+
known = required.any? { |field| !queue[field].nil? } || work_field
|
|
1414
|
+
status = required_complete && work_field ? "complete" : (known ? "partial" : "missing")
|
|
1415
|
+
|
|
1416
|
+
{
|
|
1417
|
+
"status" => status,
|
|
1418
|
+
"project_id" => queue["project_id"],
|
|
1419
|
+
"phase" => queue["phase"],
|
|
1420
|
+
"intent" => queue["intent"],
|
|
1421
|
+
"work_type" => work_field,
|
|
1422
|
+
"work_id" => work_field ? queue[work_field] : nil
|
|
1423
|
+
}
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
def build_summary_outcome(artifact_payload)
|
|
1427
|
+
sidecar_outcome = artifact_payload&.dig("outcome") || {}
|
|
1428
|
+
status = sidecar_outcome["status"]
|
|
1429
|
+
status = "no_change" if status.nil? && @git_end.key?(:changed_paths) && @git_end[:changed_paths].empty?
|
|
1430
|
+
status ||= "unknown"
|
|
1431
|
+
status = "unknown" unless %w[accepted rejected no_change unknown].include?(status)
|
|
1432
|
+
|
|
1433
|
+
{
|
|
1434
|
+
"status" => status,
|
|
1435
|
+
"source" => sidecar_outcome["status"] ? "artifact_report" : "harnex_git_observation",
|
|
1436
|
+
"commit_sha" => sidecar_outcome["commit_sha"] || summary_commit_sha,
|
|
1437
|
+
"changed_paths" => @git_end.key?(:changed_paths) ? @git_end[:changed_paths] : nil,
|
|
1438
|
+
"loc_added" => @git_end[:loc_added],
|
|
1439
|
+
"loc_removed" => @git_end[:loc_removed],
|
|
1440
|
+
"lines_changed" => summary_lines_changed,
|
|
1441
|
+
"files_changed" => @git_end[:files_changed],
|
|
1442
|
+
"commits" => @git_end[:commits]
|
|
1443
|
+
}
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
def build_summary_attempt
|
|
1447
|
+
{
|
|
1448
|
+
"run_id" => id,
|
|
1449
|
+
"id" => session_id,
|
|
1450
|
+
"parent_attempt_id" => summary_string(meta_hash["parent_attempt_id"]),
|
|
1451
|
+
"parent_dispatch_id" => summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
|
|
1452
|
+
"kind" => summary_attempt_kind,
|
|
1453
|
+
"project_id" => summary_string(meta_hash["project_id"]),
|
|
1454
|
+
"phase" => summary_string(meta_hash["phase"]),
|
|
1455
|
+
"intent" => summary_string(meta_hash["intent"]),
|
|
1456
|
+
"model_requested" => summary_string(meta_hash["model"]),
|
|
1457
|
+
"model_effective" => summary_string(summary_model),
|
|
1458
|
+
"deployment_effective" => summary_service_tier,
|
|
1459
|
+
"reasoning_effort" => summary_string(meta_hash["effort"]),
|
|
1460
|
+
"started_at" => @started_at.iso8601,
|
|
1461
|
+
"ended_at" => @ended_at&.iso8601,
|
|
1462
|
+
"start_ts" => @started_at.iso8601,
|
|
1463
|
+
"end_ts" => @ended_at&.iso8601,
|
|
1464
|
+
"wall_ms" => @ended_at ? ((@ended_at - @started_at) * 1000).round : nil,
|
|
1465
|
+
"exit_reason" => @exit_reason,
|
|
1466
|
+
"status" => summary_attempt_succeeded? ? "succeeded" : "failed"
|
|
1467
|
+
}
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
def attempt_lifecycle_context
|
|
1471
|
+
{
|
|
1472
|
+
run_id: id,
|
|
1473
|
+
attempt_id: session_id,
|
|
1474
|
+
parent_attempt_id: summary_string(meta_hash["parent_attempt_id"]),
|
|
1475
|
+
parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
|
|
1476
|
+
project: summary_string(meta_hash["project_id"]),
|
|
1477
|
+
phase: summary_string(meta_hash["phase"]),
|
|
1478
|
+
intent: summary_string(meta_hash["intent"]),
|
|
1479
|
+
model_requested: summary_string(meta_hash["model"]),
|
|
1480
|
+
model_effective: summary_string(summary_model),
|
|
1481
|
+
deployment_effective: summary_service_tier,
|
|
1482
|
+
reasoning_effort: summary_string(meta_hash["effort"]),
|
|
1483
|
+
start_ts: @started_at.iso8601
|
|
1484
|
+
}
|
|
1485
|
+
end
|
|
1486
|
+
|
|
1487
|
+
def summary_attempt_kind
|
|
1488
|
+
candidate = summary_string(meta_hash["attempt_kind"])
|
|
1489
|
+
ATTEMPT_KINDS.include?(candidate) ? candidate : "initial"
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
def summary_attempt_succeeded?
|
|
1493
|
+
return false if task_failed?
|
|
1494
|
+
|
|
1495
|
+
@exit_reason == "success"
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
|
|
1499
|
+
return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
|
|
1500
|
+
|
|
1501
|
+
total_tokens.to_f / duration_s
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
def accepted_throughput_successes_per_h(duration_s, accepted)
|
|
1505
|
+
return nil unless accepted && duration_s.to_f.positive?
|
|
1506
|
+
|
|
1507
|
+
3600.0 / duration_s
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
def summary_commit_sha
|
|
1511
|
+
start_sha = @git_start[:sha].to_s
|
|
1512
|
+
end_sha = @git_end[:sha].to_s
|
|
1513
|
+
return nil if start_sha.empty? || end_sha.empty? || start_sha == end_sha
|
|
1514
|
+
|
|
1515
|
+
end_sha
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1238
1518
|
def build_summary_reliability
|
|
1239
1519
|
counters = reliability_event_counters
|
|
1240
1520
|
real_disconnections = counters[:disconnections].to_i
|
|
@@ -1249,14 +1529,18 @@ module Harnex
|
|
|
1249
1529
|
}
|
|
1250
1530
|
end
|
|
1251
1531
|
|
|
1252
|
-
def build_summary_actual
|
|
1532
|
+
def build_summary_actual(outcome:, attribution:)
|
|
1253
1533
|
counters = legacy_summary_event_counters
|
|
1254
1534
|
output_measurements = summary_output_measurements
|
|
1535
|
+
accepted = outcome["status"] == "accepted"
|
|
1536
|
+
duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
|
|
1537
|
+
attempt_succeeded = summary_attempt_succeeded?
|
|
1538
|
+
total_tokens = @usage_summary[:total_tokens]
|
|
1255
1539
|
|
|
1256
1540
|
actual = {
|
|
1257
1541
|
model: summary_model,
|
|
1258
1542
|
effort: meta_hash["effort"],
|
|
1259
|
-
duration_s:
|
|
1543
|
+
duration_s: duration_s,
|
|
1260
1544
|
input_tokens: @usage_summary[:input_tokens],
|
|
1261
1545
|
output_tokens: @usage_summary[:output_tokens],
|
|
1262
1546
|
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
@@ -1287,7 +1571,18 @@ module Harnex
|
|
|
1287
1571
|
output_bytes: output_measurements[:bytes],
|
|
1288
1572
|
event_records: @events_log_seq,
|
|
1289
1573
|
output_log_path: output_log_path,
|
|
1290
|
-
events_log_path: events_log_path
|
|
1574
|
+
events_log_path: events_log_path,
|
|
1575
|
+
attempts_total: 1,
|
|
1576
|
+
attempts_succeeded: attempt_succeeded ? 1 : 0,
|
|
1577
|
+
attempts_failed: attempt_succeeded ? 0 : 1,
|
|
1578
|
+
retry_count: counters[:retries],
|
|
1579
|
+
throttle_429_count: counters[:throttle_429],
|
|
1580
|
+
disconnect_count: counters[:disconnections],
|
|
1581
|
+
throughput_tokens_per_s: accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted),
|
|
1582
|
+
throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
|
|
1583
|
+
retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
|
|
1584
|
+
unattributed: attribution["status"] != "complete",
|
|
1585
|
+
fallback_triggered: false
|
|
1291
1586
|
}
|
|
1292
1587
|
actual
|
|
1293
1588
|
end
|
|
@@ -1445,6 +1740,20 @@ module Harnex
|
|
|
1445
1740
|
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
1446
1741
|
end
|
|
1447
1742
|
|
|
1743
|
+
def normalized_context_summary(summary)
|
|
1744
|
+
summary ||= {}
|
|
1745
|
+
context = summary[:context] || summary["context"]
|
|
1746
|
+
context = {} unless context.is_a?(Hash)
|
|
1747
|
+
CONTEXT_FIELDS.to_h do |field|
|
|
1748
|
+
value = if context.key?(field)
|
|
1749
|
+
context[field]
|
|
1750
|
+
else
|
|
1751
|
+
context[field.to_s]
|
|
1752
|
+
end
|
|
1753
|
+
[field, value]
|
|
1754
|
+
end
|
|
1755
|
+
end
|
|
1756
|
+
|
|
1448
1757
|
# Structured adapters emit usage directly (JSON-RPC token snapshots,
|
|
1449
1758
|
# Pi RPC stats). PTY adapters parse transcript tails when supported.
|
|
1450
1759
|
def collect_session_summary
|
|
@@ -1458,18 +1767,34 @@ module Harnex
|
|
|
1458
1767
|
end
|
|
1459
1768
|
|
|
1460
1769
|
def summary_from_token_usage
|
|
1461
|
-
|
|
1770
|
+
summary = {}
|
|
1771
|
+
if @token_usage.is_a?(Hash) && @token_usage["total"].is_a?(Hash)
|
|
1772
|
+
total = @token_usage["total"]
|
|
1773
|
+
summary.merge!(
|
|
1774
|
+
input_tokens: total["inputTokens"],
|
|
1775
|
+
output_tokens: total["outputTokens"],
|
|
1776
|
+
reasoning_tokens: total["reasoningOutputTokens"],
|
|
1777
|
+
cached_tokens: total["cachedInputTokens"],
|
|
1778
|
+
total_tokens: total["totalTokens"]
|
|
1779
|
+
)
|
|
1780
|
+
end
|
|
1781
|
+
summary[:context] = @rpc_context_telemetry.snapshot if @rpc_context_telemetry
|
|
1782
|
+
summary
|
|
1783
|
+
end
|
|
1462
1784
|
|
|
1463
|
-
|
|
1464
|
-
return
|
|
1785
|
+
def record_rpc_context_sample(token_usage)
|
|
1786
|
+
return unless adapter.context_telemetry_supported?
|
|
1465
1787
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1788
|
+
@rpc_context_telemetry ||= ContextTelemetry.new(
|
|
1789
|
+
status: "estimated",
|
|
1790
|
+
source: adapter.context_telemetry_source
|
|
1791
|
+
)
|
|
1792
|
+
last = token_usage["last"]
|
|
1793
|
+
last = {} unless last.is_a?(Hash)
|
|
1794
|
+
@rpc_context_telemetry.record(
|
|
1795
|
+
tokens: last["totalTokens"],
|
|
1796
|
+
window_tokens: token_usage["modelContextWindow"]
|
|
1797
|
+
)
|
|
1473
1798
|
end
|
|
1474
1799
|
|
|
1475
1800
|
def transcript_tail
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "harnex/core"
|
|
|
7
7
|
require_relative "harnex/dispatch_history"
|
|
8
8
|
require_relative "harnex/terminal_status"
|
|
9
9
|
require_relative "harnex/artifact_report"
|
|
10
|
+
require_relative "harnex/context_telemetry"
|
|
10
11
|
require_relative "harnex/watcher"
|
|
11
12
|
require_relative "harnex/adapters"
|
|
12
13
|
require_relative "harnex/runtime/session_state"
|
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.7.
|
|
4
|
+
version: 0.7.13
|
|
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-07-
|
|
11
|
+
date: 2026-07-15 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.
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- lib/harnex/commands/wait.rb
|
|
59
59
|
- lib/harnex/commands/watch.rb
|
|
60
60
|
- lib/harnex/commands/watch_presets.rb
|
|
61
|
+
- lib/harnex/context_telemetry.rb
|
|
61
62
|
- lib/harnex/core.rb
|
|
62
63
|
- lib/harnex/dispatch_history.rb
|
|
63
64
|
- lib/harnex/runtime/api_server.rb
|