harnex 0.7.12 → 0.7.14

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.
@@ -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
@@ -18,6 +31,9 @@ module Harnex
18
31
  BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
19
32
  QUEUE_FIELDS = %w[project_id queue_id entry_id entry_title issue plan phase tier intent].freeze
20
33
  AGENT_FIELDS = %w[cli provider model_requested model_effective reasoning_effort service_tier adapter_transport].freeze
34
+ ORCHESTRATION_FIELDS = %w[
35
+ run_id generation_id role project_id queue_id session_id rotation_reason
36
+ ].freeze
21
37
  RELIABILITY_FIELDS = %w[
22
38
  adapter_close real_disconnections stream_interruptions stalls force_resumes compactions recovered
23
39
  ].freeze
@@ -29,6 +45,8 @@ module Harnex
29
45
  force_resumes: 0,
30
46
  disconnections: 0,
31
47
  compactions: 0,
48
+ retries: 0,
49
+ throttle_429: 0,
32
50
  tool_calls: 0,
33
51
  commands_executed: 0
34
52
  }
@@ -44,6 +62,10 @@ module Harnex
44
62
  @counts[:disconnections] += 1
45
63
  when "compaction"
46
64
  @counts[:compactions] += 1
65
+ when "attempt_retry_scheduled"
66
+ @counts[:retries] += 1
67
+ when "throttle_429"
68
+ @counts[:throttle_429] += 1
47
69
  end
48
70
  end
49
71
 
@@ -51,7 +73,7 @@ module Harnex
51
73
  return unless item.is_a?(Hash)
52
74
 
53
75
  case item["type"]
54
- when "mcpToolCall", "dynamicToolCall"
76
+ when "mcpToolCall", "dynamicToolCall", "fileChange", "webSearch"
55
77
  @counts[:tool_calls] += 1
56
78
  when "commandExecution"
57
79
  @counts[:commands_executed] += 1
@@ -65,9 +87,9 @@ module Harnex
65
87
 
66
88
  attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
67
89
  :inbox, :description, :meta, :summary_out, :artifact_report_path, :output_log_path, :events_log_path,
68
- :started_at, :ended_at, :exit_code, :term_signal
90
+ :started_at, :ended_at, :exit_code, :term_signal, :require_artifact_report
69
91
 
70
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, artifact_report_path: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
92
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, artifact_report_path: nil, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
71
93
  @adapter = adapter
72
94
  @command = command
73
95
  @repo_root = repo_root
@@ -84,6 +106,9 @@ module Harnex
84
106
  @artifact_report_path = artifact_report_path.to_s.strip
85
107
  @artifact_report_path = nil if @artifact_report_path.empty?
86
108
  @artifact_report_path = File.expand_path(@artifact_report_path, repo_root) if @artifact_report_path
109
+ @require_artifact_report = !!require_artifact_report
110
+ raise ArgumentError, "require_artifact_report requires artifact_report_path" if @require_artifact_report && !@artifact_report_path
111
+ @artifact_report_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_report_path) if @artifact_report_path
87
112
  @registry_path = Harnex.registry_path(repo_root, @id)
88
113
  @output_log_path = Harnex.output_log_path(repo_root, @id)
89
114
  @events_log_path = Harnex.events_log_path(repo_root, @id)
@@ -107,6 +132,8 @@ module Harnex
107
132
  @git_start = {}
108
133
  @git_end = {}
109
134
  @usage_summary = {}
135
+ @context_summary = {}
136
+ @rpc_context_telemetry = nil
110
137
  @ended_at = nil
111
138
  @exit_reason = nil
112
139
  @last_error = nil
@@ -115,6 +142,9 @@ module Harnex
115
142
  @last_completed_at = nil
116
143
  @last_failed_at = nil
117
144
  @last_failed_status = nil
145
+ @completion_outcome_class = nil
146
+ @completion_report_status = nil
147
+ @completion_diagnostics = []
118
148
  @pi_streamed_text_by_message = {}
119
149
  @auto_stop = !!auto_stop
120
150
  @auto_stop_fired = false
@@ -187,6 +217,8 @@ module Harnex
187
217
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
188
218
  @ended_at = Time.now
189
219
 
220
+ enforce_required_artifact_report!
221
+ normalize_work_acceptance_exit_code!
190
222
  normalize_auto_stop_exit_code!
191
223
  drain_auto_stop_threads
192
224
  output_thread.join(1)
@@ -247,6 +279,8 @@ module Harnex
247
279
  payload[:task_failed] = task_failed
248
280
  payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
249
281
  payload[:work_state] = work_state
282
+ payload[:outcome_class] = @completion_outcome_class
283
+ payload[:artifact_report_status] = @completion_report_status
250
284
  payload[:last_error] = @last_error
251
285
  payload[:model] = summary_model
252
286
  payload[:effort] = meta_hash["effort"]
@@ -262,6 +296,23 @@ module Harnex
262
296
  !!@last_failed_at
263
297
  end
264
298
 
299
+ # Public seam for structured recovery/fallback owners (#42 / plan 30).
300
+ # The current session remains the parent attempt; a recovery implementation
301
+ # supplies a new child attempt id when it starts an independently billable arm.
302
+ def record_attempt_transition(type:, child_attempt_id: nil, trigger: nil)
303
+ unless %w[attempt_retry_scheduled attempt_fallback_switched].include?(type.to_s)
304
+ raise ArgumentError, "unsupported attempt transition #{type.inspect}"
305
+ end
306
+
307
+ emit_event(
308
+ type.to_s,
309
+ **attempt_lifecycle_context.merge(
310
+ child_attempt_id: summary_string(child_attempt_id),
311
+ trigger: summary_string(trigger)
312
+ )
313
+ )
314
+ end
315
+
265
316
  def git_start
266
317
  @git_start || {}
267
318
  end
@@ -444,6 +495,8 @@ module Harnex
444
495
  end
445
496
  @ended_at = Time.now
446
497
 
498
+ enforce_required_artifact_report!
499
+ normalize_work_acceptance_exit_code!
447
500
  normalize_auto_stop_exit_code!
448
501
  drain_auto_stop_threads
449
502
  finalize_session!
@@ -502,8 +555,7 @@ module Harnex
502
555
  payload[:status] = status if status
503
556
  payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
504
557
  if successful_turn_status?(status)
505
- @last_completed_at = Time.now
506
- emit_event("task_complete", **payload)
558
+ record_successful_completion(payload)
507
559
  else
508
560
  mark_task_failed(
509
561
  turn_id: turn_id,
@@ -524,8 +576,12 @@ module Harnex
524
576
  # Schema: ThreadTokenUsageUpdatedNotification carries
525
577
  # `tokenUsage: { last, total, modelContextWindow? }` where each
526
578
  # 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)
579
+ # Snapshot cumulative `total` for usage and aggregate `last` separately
580
+ # as conservative active-context pressure.
581
+ if params["tokenUsage"].is_a?(Hash)
582
+ @token_usage = params["tokenUsage"]
583
+ record_rpc_context_sample(@token_usage)
584
+ end
529
585
  when "thread/status/changed"
530
586
  # State machine reflects RPC state; no event needed.
531
587
  nil
@@ -544,6 +600,7 @@ module Harnex
544
600
  threadId: params["threadId"],
545
601
  turnId: params["turnId"]
546
602
  )
603
+ emit_event("throttle_429", source: "error_notification", message: message) if throttle_429?(message, params)
547
604
  signal_rpc_done! if params["turnId"].to_s.empty?
548
605
  end
549
606
  rescue StandardError => e
@@ -557,15 +614,170 @@ module Harnex
557
614
  SUCCESSFUL_TURN_STATUSES.include?(text)
558
615
  end
559
616
 
560
- def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil)
617
+ def record_successful_completion(payload)
618
+ assessment = completion_gate_required? ? assess_completion_proof : { accepted: true }
619
+ unless assessment[:accepted]
620
+ mark_task_failed(
621
+ turn_id: payload[:turnId],
622
+ status: assessment.fetch(:outcome_class),
623
+ error: completion_failure_message(assessment.fetch(:outcome_class)),
624
+ outcome_class: assessment.fetch(:outcome_class),
625
+ artifact_report_status: assessment[:report_status],
626
+ diagnostics: assessment[:diagnostics]
627
+ )
628
+ return false
629
+ end
630
+
631
+ @last_failed_at = nil
632
+ @last_failed_status = nil
633
+ @last_error = nil
634
+ @last_completed_at = Time.now
635
+ if assessment[:outcome_class]
636
+ @completion_outcome_class = assessment[:outcome_class]
637
+ @completion_report_status = assessment[:report_status]
638
+ @completion_diagnostics = []
639
+ end
640
+
641
+ event_payload = payload.dup
642
+ event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
643
+ event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
644
+ emit_event("task_complete", **event_payload)
645
+ true
646
+ end
647
+
648
+ def completion_gate_required?
649
+ return true if require_artifact_report
650
+ return false unless adapter.transport == :stdio_jsonrpc
651
+
652
+ @auto_stop || (adapter.respond_to?(:initial_prompt) && !adapter.initial_prompt.to_s.empty?)
653
+ end
654
+
655
+ def assess_completion_proof
656
+ report_result = artifact_report_path ? Harnex::ArtifactReport.validate(artifact_report_path, final: true) : nil
657
+ report_fresh = report_result && report_result.status != "missing" && artifact_report_fresh?
658
+ report_stale = report_result && Harnex::ArtifactReport.accepted_final?(report_result) && !report_fresh
659
+ report_status = report_stale ? "stale" : report_result&.status
660
+ report_diagnostics = if report_stale
661
+ [artifact_report_stale_diagnostic]
662
+ else
663
+ report_result&.diagnostics || []
664
+ end
665
+
666
+ if report_result
667
+ if report_fresh && Harnex::ArtifactReport.accepted_final?(report_result)
668
+ return {
669
+ accepted: true,
670
+ outcome_class: "completed_with_proof",
671
+ report_status: "accepted",
672
+ diagnostics: []
673
+ }
674
+ end
675
+
676
+ report_outcome = Harnex::ArtifactReport.outcome_status(report_result)
677
+ if report_fresh && report_outcome == "rejected"
678
+ return {
679
+ accepted: false,
680
+ outcome_class: "report_rejected",
681
+ report_status: "rejected",
682
+ diagnostics: report_result.diagnostics
683
+ }
684
+ end
685
+
686
+ if require_artifact_report
687
+ outcome_class = report_result.status == "missing" ? "report_missing" : "report_invalid"
688
+ return {
689
+ accepted: false,
690
+ outcome_class: outcome_class,
691
+ report_status: report_status,
692
+ diagnostics: report_diagnostics
693
+ }
694
+ end
695
+ elsif require_artifact_report
696
+ return {
697
+ accepted: false,
698
+ outcome_class: "report_missing",
699
+ report_status: "missing",
700
+ diagnostics: []
701
+ }
702
+ end
703
+
704
+ if structured_activity_observed? || git_activity_observed?
705
+ {
706
+ accepted: true,
707
+ outcome_class: "completed_with_activity",
708
+ report_status: report_status,
709
+ diagnostics: []
710
+ }
711
+ else
712
+ {
713
+ accepted: false,
714
+ outcome_class: "completed_no_activity",
715
+ report_status: report_status,
716
+ diagnostics: report_diagnostics
717
+ }
718
+ end
719
+ end
720
+
721
+ def artifact_report_fresh?
722
+ current = Harnex::ArtifactReport.fingerprint(artifact_report_path)
723
+ return false unless current
724
+ return true unless @artifact_report_start_fingerprint
725
+
726
+ current != @artifact_report_start_fingerprint
727
+ end
728
+
729
+ def artifact_report_stale_diagnostic
730
+ {
731
+ "code" => "report_stale",
732
+ "path" => "$",
733
+ "message" => "artifact report was not created or updated during this session"
734
+ }
735
+ end
736
+
737
+ def structured_activity_observed?
738
+ counters = @event_counters.snapshot
739
+ counters[:commands_executed].to_i.positive? || counters[:tool_calls].to_i.positive?
740
+ end
741
+
742
+ def git_activity_observed?
743
+ return false if @git_start[:sha].to_s.empty?
744
+
745
+ snapshot = Harnex.git_capture_end(repo_root, @git_start[:sha])
746
+ snapshot[:commits].to_i.positive? || Array(snapshot[:changed_paths]).any? ||
747
+ (!snapshot[:sha].to_s.empty? && snapshot[:sha].to_s != @git_start[:sha].to_s)
748
+ end
749
+
750
+ def completion_failure_message(outcome_class)
751
+ case outcome_class
752
+ when "completed_no_activity"
753
+ "turn completed without command/tool execution, Git delta, or accepted artifact report"
754
+ when "report_missing"
755
+ "required artifact report is missing"
756
+ when "report_rejected"
757
+ "artifact report rejected work completion"
758
+ when "report_invalid"
759
+ "required artifact report is not valid final proof"
760
+ else
761
+ "work completion was not accepted"
762
+ end
763
+ end
764
+
765
+ def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil, outcome_class: nil, artifact_report_status: nil, diagnostics: nil)
766
+ @last_completed_at = nil if outcome_class
561
767
  @last_failed_at = Time.now
562
768
  @last_failed_status = status.to_s.empty? ? "failed" : status.to_s
563
769
  @last_error = error.to_s unless error.to_s.empty?
770
+ @completion_outcome_class = outcome_class if outcome_class
771
+ @completion_report_status = artifact_report_status if artifact_report_status
772
+ @completion_diagnostics = Array(diagnostics).first(Harnex::ArtifactReport::MAX_DIAGNOSTICS) if diagnostics
564
773
 
565
774
  payload = { status: @last_failed_status }
566
775
  payload[:turnId] = turn_id if turn_id
567
776
  payload[:message] = error unless error.to_s.empty?
568
777
  payload[:codex_error_info] = codex_error_info if codex_error_info
778
+ payload[:outcome_class] = outcome_class if outcome_class
779
+ payload[:artifact_report_status] = artifact_report_status if artifact_report_status
780
+ payload[:diagnostics] = @completion_diagnostics unless @completion_diagnostics.empty?
569
781
  emit_event("task_failed", **payload)
570
782
  end
571
783
 
@@ -583,6 +795,13 @@ module Harnex
583
795
  error.is_a?(Hash) ? error["codexErrorInfo"] : nil
584
796
  end
585
797
 
798
+ def throttle_429?(message, params)
799
+ return true if message.to_s.match?(/\b429\b|rate limit/i)
800
+
801
+ error = params["error"]
802
+ error.is_a?(Hash) && error.values.any? { |value| value.to_s.match?(/\b429\b|rate limit/i) }
803
+ end
804
+
586
805
  def extract_turn_error_message(turn)
587
806
  error = turn["error"]
588
807
  return error["message"] if error.is_a?(Hash)
@@ -605,9 +824,8 @@ module Harnex
605
824
  @state_machine.force_busy!
606
825
  emit_event("turn_started") if event_type == "turn_start"
607
826
  when "agent_end"
608
- @last_completed_at = Time.now
609
827
  @state_machine.force_prompt!
610
- emit_event("task_complete")
828
+ record_successful_completion({})
611
829
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
612
830
  schedule_auto_stop("task_complete", interrupt: false)
613
831
  when "message_start"
@@ -641,7 +859,9 @@ module Harnex
641
859
  when "queue_update"
642
860
  nil
643
861
  when "auto_retry_start", "auto_retry_end"
644
- emit_event(event_type, **message.reject { |k, _| k == "type" })
862
+ payload = message.reject { |k, _| k == "type" }
863
+ emit_event(event_type, **payload)
864
+ record_attempt_transition(type: "attempt_retry_scheduled", trigger: "adapter_auto_retry") if event_type == "auto_retry_start"
645
865
  when "extension_ui_request"
646
866
  handle_extension_ui_request(message)
647
867
  when "extension_error"
@@ -784,6 +1004,7 @@ module Harnex
784
1004
  env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
785
1005
  env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
786
1006
  env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
1007
+ env["HARNEX_ARTIFACT_REPORT_REQUIRED"] = "1" if require_artifact_report
787
1008
  end
788
1009
  env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
789
1010
  env
@@ -868,6 +1089,8 @@ module Harnex
868
1089
  task_failed: task_failed,
869
1090
  done: Harnex.work_done_for(state, task_complete: task_complete),
870
1091
  work_state: Harnex.work_state_for(state, task_complete: task_complete),
1092
+ outcome_class: @completion_outcome_class,
1093
+ artifact_report_status: @completion_report_status,
871
1094
  started_at: @started_at.iso8601,
872
1095
  exited_at: Time.now.iso8601,
873
1096
  injected_count: @injected_count
@@ -987,6 +1210,10 @@ module Harnex
987
1210
  payload = { pid: @pid }
988
1211
  payload[:meta] = meta if meta
989
1212
  emit_event("started", **payload)
1213
+ emit_event(
1214
+ "attempt_started",
1215
+ **attempt_lifecycle_context.merge(kind: summary_attempt_kind)
1216
+ )
990
1217
  end
991
1218
 
992
1219
  def emit_git_start_event
@@ -997,7 +1224,9 @@ module Harnex
997
1224
  end
998
1225
 
999
1226
  def emit_session_end_telemetry
1000
- @usage_summary = normalized_usage_summary(collect_session_summary)
1227
+ summary = collect_session_summary
1228
+ @usage_summary = normalized_usage_summary(summary)
1229
+ @context_summary = normalized_context_summary(summary)
1001
1230
  emit_event("usage", **@usage_summary)
1002
1231
 
1003
1232
  @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
@@ -1010,6 +1239,7 @@ module Harnex
1010
1239
  loc_added: @git_end[:loc_added],
1011
1240
  loc_removed: @git_end[:loc_removed],
1012
1241
  files_changed: @git_end[:files_changed],
1242
+ changed_paths: @git_end[:changed_paths],
1013
1243
  commits: @git_end[:commits]
1014
1244
  )
1015
1245
  end
@@ -1018,6 +1248,19 @@ module Harnex
1018
1248
  emit_event("summary", path: summary_out, exit: @exit_reason)
1019
1249
  end
1020
1250
 
1251
+ def emit_attempt_finished(attempt)
1252
+ emit_event(
1253
+ "attempt_finished",
1254
+ **attempt_lifecycle_context.merge(
1255
+ parent_attempt_id: attempt["parent_attempt_id"],
1256
+ status: attempt["status"],
1257
+ exit_reason: attempt["exit_reason"],
1258
+ end_ts: attempt["end_ts"],
1259
+ wall_ms: attempt["wall_ms"]
1260
+ )
1261
+ )
1262
+ end
1263
+
1021
1264
  def emit_exit_event
1022
1265
  payload = { code: @exit_code }
1023
1266
  payload[:signal] = @term_signal if @term_signal
@@ -1035,12 +1278,15 @@ module Harnex
1035
1278
  emit_session_end_telemetry
1036
1279
  rescue StandardError => e
1037
1280
  @usage_summary = normalized_usage_summary(nil)
1281
+ @context_summary = normalized_context_summary(nil)
1038
1282
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1039
1283
  end
1040
1284
  @exit_reason ||= classify_exit
1041
- append_summary_record(build_summary_record)
1285
+ record = build_summary_record
1286
+ append_summary_record(record)
1042
1287
  append_dispatch_history_record
1043
1288
  emit_summary_event
1289
+ emit_attempt_finished(record.fetch(:attempt))
1044
1290
  emit_exit_event
1045
1291
  end
1046
1292
 
@@ -1068,7 +1314,10 @@ module Harnex
1068
1314
  seen_busy = @auto_stop_mutex.synchronize do
1069
1315
  @auto_stop_seen_busy ||= old_state == :busy || new_state == :busy
1070
1316
  end
1071
- schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
1317
+ return unless seen_busy && new_state == :prompt
1318
+
1319
+ record_successful_completion({}) if require_artifact_report
1320
+ schedule_auto_stop("prompt_after_busy")
1072
1321
  end
1073
1322
 
1074
1323
  def schedule_auto_stop(reason, turn_id: nil, interrupt: true)
@@ -1130,6 +1379,55 @@ module Harnex
1130
1379
  AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
1131
1380
  end
1132
1381
 
1382
+ def enforce_required_artifact_report!
1383
+ return unless require_artifact_report
1384
+ return if task_failed? && %w[report_missing report_invalid report_rejected].include?(@completion_outcome_class)
1385
+
1386
+ result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1387
+ report_fresh = result.status != "missing" && artifact_report_fresh?
1388
+ report_accepted = Harnex::ArtifactReport.accepted_final?(result)
1389
+ report_stale = report_accepted && !report_fresh
1390
+ if report_fresh && report_accepted
1391
+ @completion_outcome_class = "completed_with_proof"
1392
+ @completion_report_status = "accepted"
1393
+ @completion_diagnostics = []
1394
+ return
1395
+ end
1396
+
1397
+ report_outcome = Harnex::ArtifactReport.outcome_status(result)
1398
+ outcome_class = if report_fresh && report_outcome == "rejected"
1399
+ "report_rejected"
1400
+ elsif result.status == "missing"
1401
+ "report_missing"
1402
+ else
1403
+ "report_invalid"
1404
+ end
1405
+ report_status = if report_stale
1406
+ "stale"
1407
+ elsif report_fresh && report_outcome == "rejected"
1408
+ "rejected"
1409
+ else
1410
+ result.status
1411
+ end
1412
+ diagnostics = report_stale ? [artifact_report_stale_diagnostic] : result.diagnostics
1413
+ mark_task_failed(
1414
+ status: outcome_class,
1415
+ error: completion_failure_message(outcome_class),
1416
+ outcome_class: outcome_class,
1417
+ artifact_report_status: report_status,
1418
+ diagnostics: diagnostics
1419
+ )
1420
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1421
+ @term_signal = nil if @exit_code == 1
1422
+ end
1423
+
1424
+ def normalize_work_acceptance_exit_code!
1425
+ return unless task_failed? && @completion_outcome_class
1426
+
1427
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1428
+ @term_signal = nil if @exit_code == 1
1429
+ end
1430
+
1133
1431
  def normalize_auto_stop_exit_code!
1134
1432
  return unless @auto_stop
1135
1433
  return unless @auto_stop_fired
@@ -1148,9 +1446,11 @@ module Harnex
1148
1446
 
1149
1447
  def classify_exit
1150
1448
  return "timeout" if @exit_code == 124
1449
+ return "failure" if task_failed? && @completion_outcome_class
1151
1450
  return "boot_failure" if boot_failure_exit?
1152
1451
  return "failure" if task_failed?
1153
1452
  return "success" if @exit_code == 0 && task_complete?
1453
+ return "success" if @exit_code == 0 && @completion_outcome_class == "completed_with_proof"
1154
1454
  return "success" if @exit_code == 0 && session_summary_present?
1155
1455
  return "failure" unless @exit_code == 0
1156
1456
 
@@ -1170,16 +1470,26 @@ module Harnex
1170
1470
  end
1171
1471
 
1172
1472
  def build_summary_record
1473
+ artifact_payload = artifact_report_path ? artifact_report_summary : nil
1474
+ attribution = build_summary_attribution
1475
+ outcome = build_summary_outcome(artifact_payload)
1173
1476
  record = {
1174
1477
  meta: build_summary_meta,
1175
1478
  predicted: summary_predicted_payload,
1176
- actual: build_summary_actual,
1479
+ actual: build_summary_actual(outcome: outcome, attribution: attribution),
1177
1480
  agent: build_summary_agent,
1481
+ usage: build_summary_usage,
1482
+ context: build_summary_context,
1483
+ attribution: attribution,
1484
+ outcome: outcome,
1485
+ attempt: build_summary_attempt,
1178
1486
  reliability: build_summary_reliability
1179
1487
  }
1180
1488
  queue = build_summary_queue
1181
1489
  record[:queue] = queue if queue
1182
- record.merge!(artifact_report_summary) if artifact_report_path
1490
+ orchestration = build_summary_orchestration
1491
+ record[:orchestration] = orchestration if orchestration
1492
+ record.merge!(artifact_payload.reject { |key, _value| key == "outcome" }) if artifact_payload
1183
1493
  record
1184
1494
  end
1185
1495
 
@@ -1223,6 +1533,14 @@ module Harnex
1223
1533
  queue
1224
1534
  end
1225
1535
 
1536
+ def build_summary_orchestration
1537
+ orchestration = Orchestration.normalize_metadata(meta_hash)
1538
+ return nil unless orchestration
1539
+
1540
+ orchestration["session_id"] ||= summary_agent_session_id || session_id
1541
+ orchestration
1542
+ end
1543
+
1226
1544
  def build_summary_agent
1227
1545
  {
1228
1546
  "cli" => adapter.key,
@@ -1235,6 +1553,233 @@ module Harnex
1235
1553
  }
1236
1554
  end
1237
1555
 
1556
+ def build_summary_usage
1557
+ declared = meta_hash["usage"].is_a?(Hash) ? meta_hash["usage"] : {}
1558
+ observed = USAGE_MEASUREMENT_FIELDS.any? { |field| !@usage_summary[field].nil? }
1559
+ values = USAGE_MEASUREMENT_FIELDS.to_h do |field|
1560
+ [field, @usage_summary[field].nil? ? declared_usage_value(declared, field) : @usage_summary[field]]
1561
+ end
1562
+ numeric_observations = USAGE_MEASUREMENT_FIELDS.filter_map do |field|
1563
+ value = @usage_summary[field]
1564
+ value if value.is_a?(Numeric)
1565
+ end
1566
+ estimated = declared["status"].to_s == "estimated"
1567
+ status = if observed && numeric_observations.any? && numeric_observations.all?(&:zero?)
1568
+ "zero"
1569
+ elsif observed
1570
+ "observed"
1571
+ elsif estimated
1572
+ "estimated"
1573
+ elsif adapter.usage_telemetry_supported?
1574
+ "missing"
1575
+ else
1576
+ "unsupported"
1577
+ end
1578
+ cost_source = summary_string(@usage_summary[:cost_source])
1579
+ cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
1580
+ cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
1581
+
1582
+ {
1583
+ "status" => status,
1584
+ "cost_usd" => values[:cost_usd],
1585
+ "cost_source" => cost_source,
1586
+ "input_tokens" => values[:input_tokens],
1587
+ "output_tokens" => values[:output_tokens],
1588
+ "cached_input_tokens" => values[:cached_tokens],
1589
+ "reasoning_tokens" => values[:reasoning_tokens],
1590
+ "total_tokens" => values[:total_tokens]
1591
+ }
1592
+ end
1593
+
1594
+ def build_summary_context
1595
+ measurement_present = CONTEXT_MEASUREMENT_FIELDS.any? do |field|
1596
+ @context_summary[field].is_a?(Numeric)
1597
+ end
1598
+ reported_status = summary_string(@context_summary[:status])
1599
+ status = if measurement_present
1600
+ %w[observed estimated].include?(reported_status) ? reported_status : "observed"
1601
+ elsif adapter.context_telemetry_supported?
1602
+ "missing"
1603
+ else
1604
+ "unsupported"
1605
+ end
1606
+ source = summary_string(@context_summary[:source])
1607
+ source ||= adapter.context_telemetry_source if adapter.context_telemetry_supported?
1608
+ latest_sample_status = summary_string(@context_summary[:latest_sample_status])
1609
+ latest_sample_status = nil unless CONTEXT_SAMPLE_STATUSES.include?(latest_sample_status)
1610
+
1611
+ {
1612
+ "status" => status,
1613
+ "source" => source,
1614
+ "terminal_tokens" => @context_summary[:terminal_tokens],
1615
+ "window_tokens" => @context_summary[:window_tokens],
1616
+ "terminal_percent" => @context_summary[:terminal_percent],
1617
+ "peak_tokens" => @context_summary[:peak_tokens],
1618
+ "peak_percent" => @context_summary[:peak_percent],
1619
+ "samples" => context_sample_count(:samples),
1620
+ "missing_samples" => context_sample_count(:missing_samples),
1621
+ "latest_sample_status" => latest_sample_status
1622
+ }
1623
+ end
1624
+
1625
+ def context_sample_count(field)
1626
+ value = @context_summary[field]
1627
+ return 0 unless value.is_a?(Numeric) && value.finite? && !value.negative?
1628
+
1629
+ value.to_i
1630
+ end
1631
+
1632
+ def declared_usage_value(declared, field)
1633
+ return nil unless declared["status"].to_s == "estimated"
1634
+
1635
+ declared[field.to_s] || declared[usage_field_alias(field)]
1636
+ end
1637
+
1638
+ def usage_field_alias(field)
1639
+ field == :cached_tokens ? "cached_input_tokens" : field.to_s
1640
+ end
1641
+
1642
+ def build_summary_attribution
1643
+ queue = build_summary_queue || {}
1644
+ required = %w[project_id phase intent]
1645
+ work_fields = %w[entry_id issue plan queue_id]
1646
+ required_complete = required.all? { |field| !queue[field].nil? }
1647
+ work_field = work_fields.find { |field| !queue[field].nil? }
1648
+ known = required.any? { |field| !queue[field].nil? } || work_field
1649
+ status = required_complete && work_field ? "complete" : (known ? "partial" : "missing")
1650
+
1651
+ {
1652
+ "status" => status,
1653
+ "project_id" => queue["project_id"],
1654
+ "phase" => queue["phase"],
1655
+ "intent" => queue["intent"],
1656
+ "work_type" => work_field,
1657
+ "work_id" => work_field ? queue[work_field] : nil
1658
+ }
1659
+ end
1660
+
1661
+ def build_summary_outcome(artifact_payload)
1662
+ sidecar_outcome = artifact_payload&.dig("outcome") || {}
1663
+ outcome_class, report_status = summary_proof_classification
1664
+ proof_failure = %w[completed_no_activity report_missing report_invalid task_failed].include?(outcome_class)
1665
+ status = proof_failure ? nil : sidecar_outcome["status"]
1666
+ status = "no_change" if status.nil? && !proof_failure && @git_end.key?(:changed_paths) && @git_end[:changed_paths].empty?
1667
+ status ||= "unknown"
1668
+ status = "unknown" unless Harnex::ArtifactReport::OUTCOME_STATUSES.include?(status)
1669
+ source = if proof_failure
1670
+ "harnex_completion_gate"
1671
+ elsif sidecar_outcome["status"]
1672
+ "artifact_report"
1673
+ else
1674
+ "harnex_git_observation"
1675
+ end
1676
+
1677
+ {
1678
+ "status" => status,
1679
+ "class" => outcome_class,
1680
+ "source" => source,
1681
+ "report_status" => report_status,
1682
+ "commit_sha" => sidecar_outcome["commit_sha"] || summary_commit_sha,
1683
+ "changed_paths" => @git_end.key?(:changed_paths) ? @git_end[:changed_paths] : nil,
1684
+ "loc_added" => @git_end[:loc_added],
1685
+ "loc_removed" => @git_end[:loc_removed],
1686
+ "lines_changed" => summary_lines_changed,
1687
+ "files_changed" => @git_end[:files_changed],
1688
+ "commits" => @git_end[:commits]
1689
+ }
1690
+ end
1691
+
1692
+ def summary_proof_classification
1693
+ return [@completion_outcome_class, @completion_report_status] if @completion_outcome_class
1694
+ return ["task_failed", @completion_report_status] if task_failed?
1695
+
1696
+ if artifact_report_path
1697
+ result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1698
+ report_fresh = result.status != "missing" && artifact_report_fresh?
1699
+ report_accepted = Harnex::ArtifactReport.accepted_final?(result)
1700
+ return ["completed_with_proof", "accepted"] if report_fresh && report_accepted
1701
+ return ["report_rejected", "rejected"] if report_fresh && Harnex::ArtifactReport.outcome_status(result) == "rejected"
1702
+ return ["unknown", "stale"] if report_accepted && !report_fresh
1703
+ return ["unknown", result.status]
1704
+ end
1705
+
1706
+ return ["completed_with_activity", @completion_report_status] if task_complete? && structured_activity_observed?
1707
+
1708
+ ["unknown", @completion_report_status]
1709
+ end
1710
+
1711
+ def build_summary_attempt
1712
+ {
1713
+ "run_id" => id,
1714
+ "id" => session_id,
1715
+ "parent_attempt_id" => summary_string(meta_hash["parent_attempt_id"]),
1716
+ "parent_dispatch_id" => summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
1717
+ "kind" => summary_attempt_kind,
1718
+ "project_id" => summary_string(meta_hash["project_id"]),
1719
+ "phase" => summary_string(meta_hash["phase"]),
1720
+ "intent" => summary_string(meta_hash["intent"]),
1721
+ "model_requested" => summary_string(meta_hash["model"]),
1722
+ "model_effective" => summary_string(summary_model),
1723
+ "deployment_effective" => summary_service_tier,
1724
+ "reasoning_effort" => summary_string(meta_hash["effort"]),
1725
+ "started_at" => @started_at.iso8601,
1726
+ "ended_at" => @ended_at&.iso8601,
1727
+ "start_ts" => @started_at.iso8601,
1728
+ "end_ts" => @ended_at&.iso8601,
1729
+ "wall_ms" => @ended_at ? ((@ended_at - @started_at) * 1000).round : nil,
1730
+ "exit_reason" => @exit_reason,
1731
+ "status" => summary_attempt_succeeded? ? "succeeded" : "failed"
1732
+ }
1733
+ end
1734
+
1735
+ def attempt_lifecycle_context
1736
+ {
1737
+ run_id: id,
1738
+ attempt_id: session_id,
1739
+ parent_attempt_id: summary_string(meta_hash["parent_attempt_id"]),
1740
+ parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
1741
+ project: summary_string(meta_hash["project_id"]),
1742
+ phase: summary_string(meta_hash["phase"]),
1743
+ intent: summary_string(meta_hash["intent"]),
1744
+ model_requested: summary_string(meta_hash["model"]),
1745
+ model_effective: summary_string(summary_model),
1746
+ deployment_effective: summary_service_tier,
1747
+ reasoning_effort: summary_string(meta_hash["effort"]),
1748
+ start_ts: @started_at.iso8601
1749
+ }
1750
+ end
1751
+
1752
+ def summary_attempt_kind
1753
+ candidate = summary_string(meta_hash["attempt_kind"])
1754
+ ATTEMPT_KINDS.include?(candidate) ? candidate : "initial"
1755
+ end
1756
+
1757
+ def summary_attempt_succeeded?
1758
+ return false if task_failed?
1759
+
1760
+ @exit_reason == "success"
1761
+ end
1762
+
1763
+ def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
1764
+ return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
1765
+
1766
+ total_tokens.to_f / duration_s
1767
+ end
1768
+
1769
+ def accepted_throughput_successes_per_h(duration_s, accepted)
1770
+ return nil unless accepted && duration_s.to_f.positive?
1771
+
1772
+ 3600.0 / duration_s
1773
+ end
1774
+
1775
+ def summary_commit_sha
1776
+ start_sha = @git_start[:sha].to_s
1777
+ end_sha = @git_end[:sha].to_s
1778
+ return nil if start_sha.empty? || end_sha.empty? || start_sha == end_sha
1779
+
1780
+ end_sha
1781
+ end
1782
+
1238
1783
  def build_summary_reliability
1239
1784
  counters = reliability_event_counters
1240
1785
  real_disconnections = counters[:disconnections].to_i
@@ -1249,14 +1794,18 @@ module Harnex
1249
1794
  }
1250
1795
  end
1251
1796
 
1252
- def build_summary_actual
1797
+ def build_summary_actual(outcome:, attribution:)
1253
1798
  counters = legacy_summary_event_counters
1254
1799
  output_measurements = summary_output_measurements
1800
+ accepted = outcome["status"] == "accepted"
1801
+ duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
1802
+ attempt_succeeded = summary_attempt_succeeded?
1803
+ total_tokens = @usage_summary[:total_tokens]
1255
1804
 
1256
1805
  actual = {
1257
1806
  model: summary_model,
1258
1807
  effort: meta_hash["effort"],
1259
- duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
1808
+ duration_s: duration_s,
1260
1809
  input_tokens: @usage_summary[:input_tokens],
1261
1810
  output_tokens: @usage_summary[:output_tokens],
1262
1811
  reasoning_tokens: @usage_summary[:reasoning_tokens],
@@ -1287,7 +1836,18 @@ module Harnex
1287
1836
  output_bytes: output_measurements[:bytes],
1288
1837
  event_records: @events_log_seq,
1289
1838
  output_log_path: output_log_path,
1290
- events_log_path: events_log_path
1839
+ events_log_path: events_log_path,
1840
+ attempts_total: 1,
1841
+ attempts_succeeded: attempt_succeeded ? 1 : 0,
1842
+ attempts_failed: attempt_succeeded ? 0 : 1,
1843
+ retry_count: counters[:retries],
1844
+ throttle_429_count: counters[:throttle_429],
1845
+ disconnect_count: counters[:disconnections],
1846
+ throughput_tokens_per_s: accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted),
1847
+ throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
1848
+ retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
1849
+ unattributed: attribution["status"] != "complete",
1850
+ fallback_triggered: false
1291
1851
  }
1292
1852
  actual
1293
1853
  end
@@ -1445,6 +2005,20 @@ module Harnex
1445
2005
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
1446
2006
  end
1447
2007
 
2008
+ def normalized_context_summary(summary)
2009
+ summary ||= {}
2010
+ context = summary[:context] || summary["context"]
2011
+ context = {} unless context.is_a?(Hash)
2012
+ CONTEXT_FIELDS.to_h do |field|
2013
+ value = if context.key?(field)
2014
+ context[field]
2015
+ else
2016
+ context[field.to_s]
2017
+ end
2018
+ [field, value]
2019
+ end
2020
+ end
2021
+
1448
2022
  # Structured adapters emit usage directly (JSON-RPC token snapshots,
1449
2023
  # Pi RPC stats). PTY adapters parse transcript tails when supported.
1450
2024
  def collect_session_summary
@@ -1458,18 +2032,34 @@ module Harnex
1458
2032
  end
1459
2033
 
1460
2034
  def summary_from_token_usage
1461
- return {} unless @token_usage.is_a?(Hash)
2035
+ summary = {}
2036
+ if @token_usage.is_a?(Hash) && @token_usage["total"].is_a?(Hash)
2037
+ total = @token_usage["total"]
2038
+ summary.merge!(
2039
+ input_tokens: total["inputTokens"],
2040
+ output_tokens: total["outputTokens"],
2041
+ reasoning_tokens: total["reasoningOutputTokens"],
2042
+ cached_tokens: total["cachedInputTokens"],
2043
+ total_tokens: total["totalTokens"]
2044
+ )
2045
+ end
2046
+ summary[:context] = @rpc_context_telemetry.snapshot if @rpc_context_telemetry
2047
+ summary
2048
+ end
1462
2049
 
1463
- total = @token_usage["total"]
1464
- return {} unless total.is_a?(Hash)
2050
+ def record_rpc_context_sample(token_usage)
2051
+ return unless adapter.context_telemetry_supported?
1465
2052
 
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
- }
2053
+ @rpc_context_telemetry ||= ContextTelemetry.new(
2054
+ status: "estimated",
2055
+ source: adapter.context_telemetry_source
2056
+ )
2057
+ last = token_usage["last"]
2058
+ last = {} unless last.is_a?(Hash)
2059
+ @rpc_context_telemetry.record(
2060
+ tokens: last["totalTokens"],
2061
+ window_tokens: token_usage["modelContextWindow"]
2062
+ )
1473
2063
  end
1474
2064
 
1475
2065
  def transcript_tail