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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +62 -17
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +11 -5
- 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 +522 -33
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +68 -5
- data/lib/harnex/commands/wait.rb +6 -1
- data/lib/harnex/commands/watch.rb +2 -0
- data/lib/harnex/context_telemetry.rb +104 -0
- data/lib/harnex/core.rb +2 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +620 -30
- data/lib/harnex/terminal_status.rb +7 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +4 -0
- metadata +6 -2
|
@@ -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
|
-
|
|
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
|
|
528
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1464
|
-
return
|
|
2050
|
+
def record_rpc_context_sample(token_usage)
|
|
2051
|
+
return unless adapter.context_telemetry_supported?
|
|
1465
2052
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|