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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da71f86b7e940d085a54ae59ce7f70a9579ca23204213cbc41a017fd5d11ed9f
4
- data.tar.gz: 194f3ac0c6fabf118e74542dd4836c5014721cdf0000973b03be07dceceeee3e
3
+ metadata.gz: e087c98409987c2494911c830e3670a23abc5129ed22030bebd7cee953f920fd
4
+ data.tar.gz: 036eb960f719874ba7f7954bcf3c4191ca324d4ff77f7098069b231327efbd87
5
5
  SHA512:
6
- metadata.gz: 7975b8610b1d21c230fb0ccf7b55c16bdd9e9ff71c770bdb42f831e687313e47c9e01b20157fc8f95c767bdf716e3501fcb56a490d37dfb277f7ac1bd03fda74
7
- data.tar.gz: 54e0d45f362d94efa2d0d698911c2d2b1daaf534c97be95bef99512d3c6ab8ad44bdb6d099eaff6d5bb38e08acee563901d94db528ab0b03df8a856648c88dd5
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. Missing or malformed reports are warning telemetry, not wrapped-process
285
- crashes.
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. Terminal summary `actual` records timing, exit classification, token
297
- usage when the adapter can capture it, adapter-reported `cost_usd` when
298
- reliably available, git deltas,
299
- task-completion state, operational counters (`stalls`, `force_resumes`,
300
- `disconnections`, `tool_calls`, `commands_executed`), output/event log paths,
301
- and rough volume measurements such as `lines_changed`, `output_lines`,
302
- `output_bytes`, and `event_records`.
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.
@@ -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
@@ -60,6 +60,10 @@ module Harnex
60
60
  end
61
61
  end
62
62
 
63
+ def usage_telemetry_supported?
64
+ true
65
+ end
66
+
63
67
  def parse_session_summary(transcript_tail)
64
68
  summary = empty_session_summary
65
69
  text = transcript_tail.to_s
@@ -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
@@ -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
- @session_stats_requested = true
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
- response = request("type" => "get_session_stats")
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)
@@ -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 it; the cumulative `total` is read at session end.
528
- @token_usage = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
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
- emit_event(event_type, **message.reject { |k, _| k == "type" })
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
- @usage_summary = normalized_usage_summary(collect_session_summary)
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
- append_summary_record(build_summary_record)
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!(artifact_report_summary) if artifact_report_path
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: @ended_at ? (@ended_at - @started_at).to_i : nil,
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
- return {} unless @token_usage.is_a?(Hash)
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
- total = @token_usage["total"]
1464
- return {} unless total.is_a?(Hash)
1785
+ def record_rpc_context_sample(token_usage)
1786
+ return unless adapter.context_telemetry_supported?
1465
1787
 
1466
- {
1467
- input_tokens: total["inputTokens"],
1468
- output_tokens: total["outputTokens"],
1469
- reasoning_tokens: total["reasoningOutputTokens"],
1470
- cached_tokens: total["cachedInputTokens"],
1471
- total_tokens: total["totalTokens"]
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
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.12"
3
- RELEASE_DATE = "2026-07-10"
2
+ VERSION = "0.7.13"
3
+ RELEASE_DATE = "2026-07-15"
4
4
  end
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.12
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-10 00:00:00.000000000 Z
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