harnex 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/GUIDE.md +17 -10
- data/README.md +61 -54
- data/TECHNICAL.md +38 -15
- data/docs/codex-appserver.md +175 -0
- data/docs/configuration.md +118 -0
- data/docs/dispatch-telemetry.md +502 -0
- data/docs/events.md +132 -0
- data/guides/01_dispatch.md +31 -28
- data/guides/04_monitoring.md +10 -9
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- data/lib/harnex/artifact_report.rb +455 -6
- data/lib/harnex/cli.rb +3 -3
- data/lib/harnex/commands/artifact_report.rb +8 -7
- data/lib/harnex/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +7 -3
- data/lib/harnex/commands/run.rb +55 -43
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +170 -0
- data/lib/harnex/core.rb +200 -21
- data/lib/harnex/dispatch_history.rb +16 -7
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +320 -0
- data/lib/harnex/runtime/session.rb +451 -166
- data/lib/harnex/terminal_status.rb +18 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "io/console"
|
|
2
2
|
require "json"
|
|
3
3
|
require "pty"
|
|
4
|
+
require "shellwords"
|
|
4
5
|
|
|
5
6
|
module Harnex
|
|
6
7
|
class Session
|
|
@@ -23,7 +24,7 @@ module Harnex
|
|
|
23
24
|
terminal_tokens terminal_percent peak_tokens peak_percent
|
|
24
25
|
].freeze
|
|
25
26
|
CONTEXT_SAMPLE_STATUSES = %w[observed estimated missing].freeze
|
|
26
|
-
ATTEMPT_KINDS = %w[initial retry fix review superseding].freeze
|
|
27
|
+
ATTEMPT_KINDS = %w[initial retry fix review fallback superseding].freeze
|
|
27
28
|
SESSION_SUMMARY_SIGNAL_FIELDS = %i[
|
|
28
29
|
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
29
30
|
agent_session_id cost_usd
|
|
@@ -86,10 +87,11 @@ module Harnex
|
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
|
|
89
|
-
:inbox, :description, :meta, :
|
|
90
|
-
:started_at, :ended_at, :exit_code, :term_signal,
|
|
90
|
+
:inbox, :description, :meta, :artifact_report_path, :artifact_claims_path,
|
|
91
|
+
:output_log_path, :events_log_path, :started_at, :ended_at, :exit_code, :term_signal,
|
|
92
|
+
:require_artifact_report
|
|
91
93
|
|
|
92
|
-
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil,
|
|
94
|
+
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, artifact_report_path: nil, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
|
|
93
95
|
@adapter = adapter
|
|
94
96
|
@command = command
|
|
95
97
|
@repo_root = repo_root
|
|
@@ -97,22 +99,29 @@ module Harnex
|
|
|
97
99
|
@child_cwd = child_cwd.to_s.empty? ? nil : File.expand_path(child_cwd)
|
|
98
100
|
@host = host
|
|
99
101
|
@id = Harnex.normalize_id(id)
|
|
102
|
+
@session_id = SecureRandom.hex(8)
|
|
100
103
|
@watch = watch
|
|
101
104
|
@description = description.to_s.strip
|
|
102
105
|
@description = nil if @description.empty?
|
|
103
106
|
@meta = meta
|
|
104
|
-
|
|
105
|
-
@
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
configured_report_path = artifact_report_path.to_s.strip
|
|
108
|
+
@artifact_report_path = if configured_report_path.empty?
|
|
109
|
+
Harnex::ArtifactReport.default_path(
|
|
110
|
+
repo_root: repo_root,
|
|
111
|
+
id: @id,
|
|
112
|
+
session_id: @session_id
|
|
113
|
+
)
|
|
114
|
+
else
|
|
115
|
+
File.expand_path(configured_report_path, repo_root)
|
|
116
|
+
end
|
|
117
|
+
@artifact_claims_path = Harnex::ArtifactReport.claims_path(@artifact_report_path)
|
|
118
|
+
FileUtils.mkdir_p(File.dirname(@artifact_report_path))
|
|
109
119
|
@require_artifact_report = !!require_artifact_report
|
|
110
|
-
|
|
111
|
-
@
|
|
120
|
+
@artifact_report_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_report_path)
|
|
121
|
+
@artifact_claims_start_fingerprint = Harnex::ArtifactReport.fingerprint(@artifact_claims_path)
|
|
112
122
|
@registry_path = Harnex.registry_path(repo_root, @id)
|
|
113
123
|
@output_log_path = Harnex.output_log_path(repo_root, @id)
|
|
114
124
|
@events_log_path = Harnex.events_log_path(repo_root, @id)
|
|
115
|
-
@session_id = SecureRandom.hex(8)
|
|
116
125
|
@token = SecureRandom.hex(16)
|
|
117
126
|
@port = Harnex.allocate_port(repo_root, @id, port, host: host)
|
|
118
127
|
@mutex = Mutex.new
|
|
@@ -129,6 +138,12 @@ module Harnex
|
|
|
129
138
|
@events_log = nil
|
|
130
139
|
@events_log_seq = 0
|
|
131
140
|
@event_counters = EventCounters.new
|
|
141
|
+
@observed_commands_mutex = Mutex.new
|
|
142
|
+
@observed_commands = []
|
|
143
|
+
@artifact_claims = {}
|
|
144
|
+
@artifact_claims_loaded = false
|
|
145
|
+
@artifact_report_generated = false
|
|
146
|
+
@artifact_report_write_error = nil
|
|
132
147
|
@git_start = {}
|
|
133
148
|
@git_end = {}
|
|
134
149
|
@usage_summary = {}
|
|
@@ -183,6 +198,7 @@ module Harnex
|
|
|
183
198
|
|
|
184
199
|
def run(validate_binary: true)
|
|
185
200
|
validate_binary! if validate_binary
|
|
201
|
+
prune_retained_logs
|
|
186
202
|
prepare_output_log
|
|
187
203
|
prepare_events_log
|
|
188
204
|
|
|
@@ -256,7 +272,9 @@ module Harnex
|
|
|
256
272
|
last_injected_at: @last_injected_at&.iso8601,
|
|
257
273
|
injected_count: @injected_count,
|
|
258
274
|
output_log_path: output_log_path,
|
|
259
|
-
events_log_path: events_log_path
|
|
275
|
+
events_log_path: events_log_path,
|
|
276
|
+
artifact_report_path: artifact_report_path,
|
|
277
|
+
artifact_claims_path: artifact_claims_path
|
|
260
278
|
}
|
|
261
279
|
payload.merge!(log_activity_snapshot)
|
|
262
280
|
payload[:description] = description if description
|
|
@@ -426,7 +444,7 @@ module Harnex
|
|
|
426
444
|
@state_machine.force_busy!
|
|
427
445
|
@injected_count += 1
|
|
428
446
|
@last_injected_at = Time.now
|
|
429
|
-
|
|
447
|
+
refresh_registry
|
|
430
448
|
end
|
|
431
449
|
|
|
432
450
|
emit_send_event(dispatch.fetch(:prompt, text), force: payload[:force])
|
|
@@ -570,6 +588,7 @@ module Harnex
|
|
|
570
588
|
when "item/completed"
|
|
571
589
|
emit_event("item_completed", item: params["item"])
|
|
572
590
|
@event_counters.record_item(params["item"])
|
|
591
|
+
record_observed_command(params["item"])
|
|
573
592
|
text = render_item_text(params["item"])
|
|
574
593
|
record_synthesized(text) if text
|
|
575
594
|
when "thread/compacted"
|
|
@@ -627,6 +646,7 @@ module Harnex
|
|
|
627
646
|
artifact_report_status: assessment[:report_status],
|
|
628
647
|
diagnostics: assessment[:diagnostics]
|
|
629
648
|
)
|
|
649
|
+
enforce_required_artifact_report!
|
|
630
650
|
return false
|
|
631
651
|
end
|
|
632
652
|
|
|
@@ -640,9 +660,15 @@ module Harnex
|
|
|
640
660
|
@completion_diagnostics = []
|
|
641
661
|
end
|
|
642
662
|
|
|
663
|
+
# Persist proof before publishing task_complete. A coordinator that sees
|
|
664
|
+
# the completion event can immediately validate the harness-authored file.
|
|
665
|
+
enforce_required_artifact_report!
|
|
666
|
+
return false if task_failed?
|
|
667
|
+
|
|
643
668
|
event_payload = payload.dup
|
|
644
669
|
event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
|
|
645
670
|
event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
|
|
671
|
+
event_payload[:artifact_report_path] = artifact_report_path
|
|
646
672
|
emit_event("task_complete", **event_payload)
|
|
647
673
|
true
|
|
648
674
|
end
|
|
@@ -655,85 +681,30 @@ module Harnex
|
|
|
655
681
|
end
|
|
656
682
|
|
|
657
683
|
def assess_completion_proof
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
report_stale = report_result && Harnex::ArtifactReport.accepted_final?(report_result) && !report_fresh
|
|
661
|
-
report_status = report_stale ? "stale" : report_result&.status
|
|
662
|
-
report_diagnostics = if report_stale
|
|
663
|
-
[artifact_report_stale_diagnostic]
|
|
664
|
-
else
|
|
665
|
-
report_result&.diagnostics || []
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
if report_result
|
|
669
|
-
if report_fresh && Harnex::ArtifactReport.accepted_final?(report_result)
|
|
670
|
-
return {
|
|
671
|
-
accepted: true,
|
|
672
|
-
outcome_class: "completed_with_proof",
|
|
673
|
-
report_status: "accepted",
|
|
674
|
-
diagnostics: []
|
|
675
|
-
}
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
report_outcome = Harnex::ArtifactReport.outcome_status(report_result)
|
|
679
|
-
if report_fresh && report_outcome == "rejected"
|
|
680
|
-
return {
|
|
681
|
-
accepted: false,
|
|
682
|
-
outcome_class: "report_rejected",
|
|
683
|
-
report_status: "rejected",
|
|
684
|
-
diagnostics: report_result.diagnostics
|
|
685
|
-
}
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
if require_artifact_report
|
|
689
|
-
outcome_class = report_result.status == "missing" ? "report_missing" : "report_invalid"
|
|
690
|
-
return {
|
|
691
|
-
accepted: false,
|
|
692
|
-
outcome_class: outcome_class,
|
|
693
|
-
report_status: report_status,
|
|
694
|
-
diagnostics: report_diagnostics
|
|
695
|
-
}
|
|
696
|
-
end
|
|
697
|
-
elsif require_artifact_report
|
|
698
|
-
return {
|
|
699
|
-
accepted: false,
|
|
700
|
-
outcome_class: "report_missing",
|
|
701
|
-
report_status: "missing",
|
|
702
|
-
diagnostics: []
|
|
703
|
-
}
|
|
704
|
-
end
|
|
705
|
-
|
|
706
|
-
if structured_activity_observed? || git_activity_observed?
|
|
684
|
+
refresh_git_end_snapshot
|
|
685
|
+
if structured_activity_observed? || git_activity_observed?(@git_end)
|
|
707
686
|
{
|
|
708
687
|
accepted: true,
|
|
709
688
|
outcome_class: "completed_with_activity",
|
|
710
|
-
report_status:
|
|
689
|
+
report_status: nil,
|
|
711
690
|
diagnostics: []
|
|
712
691
|
}
|
|
713
692
|
else
|
|
714
693
|
{
|
|
715
694
|
accepted: false,
|
|
716
695
|
outcome_class: "completed_no_activity",
|
|
717
|
-
report_status:
|
|
718
|
-
diagnostics:
|
|
696
|
+
report_status: "rejected",
|
|
697
|
+
diagnostics: []
|
|
719
698
|
}
|
|
720
699
|
end
|
|
721
700
|
end
|
|
722
701
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
current !=
|
|
729
|
-
end
|
|
730
|
-
|
|
731
|
-
def artifact_report_stale_diagnostic
|
|
732
|
-
{
|
|
733
|
-
"code" => "report_stale",
|
|
734
|
-
"path" => "$",
|
|
735
|
-
"message" => "artifact report was not created or updated during this session"
|
|
736
|
-
}
|
|
702
|
+
# Fingerprints now protect only optional claims ingestion. They are never
|
|
703
|
+
# an acceptance gate: Harnex always replaces the receipt with fresh
|
|
704
|
+
# observed state for this session.
|
|
705
|
+
def artifact_input_fresh?(path, start_fingerprint)
|
|
706
|
+
current = Harnex::ArtifactReport.fingerprint(path)
|
|
707
|
+
current && (!start_fingerprint || current != start_fingerprint)
|
|
737
708
|
end
|
|
738
709
|
|
|
739
710
|
def structured_activity_observed?
|
|
@@ -741,10 +712,10 @@ module Harnex
|
|
|
741
712
|
counters[:commands_executed].to_i.positive? || counters[:tool_calls].to_i.positive?
|
|
742
713
|
end
|
|
743
714
|
|
|
744
|
-
def git_activity_observed?
|
|
715
|
+
def git_activity_observed?(snapshot = nil)
|
|
745
716
|
return false if @git_start[:sha].to_s.empty?
|
|
746
717
|
|
|
747
|
-
snapshot
|
|
718
|
+
snapshot ||= Harnex.git_capture_end(repo_root, @git_start)
|
|
748
719
|
snapshot[:commits].to_i.positive? || Array(snapshot[:changed_paths]).any? ||
|
|
749
720
|
(!snapshot[:sha].to_s.empty? && snapshot[:sha].to_s != @git_start[:sha].to_s)
|
|
750
721
|
end
|
|
@@ -752,7 +723,7 @@ module Harnex
|
|
|
752
723
|
def completion_failure_message(outcome_class)
|
|
753
724
|
case outcome_class
|
|
754
725
|
when "completed_no_activity"
|
|
755
|
-
"turn completed without command/tool execution
|
|
726
|
+
"turn completed without command/tool execution or Git delta"
|
|
756
727
|
when "report_missing"
|
|
757
728
|
"required artifact report is missing"
|
|
758
729
|
when "report_rejected"
|
|
@@ -941,6 +912,33 @@ module Harnex
|
|
|
941
912
|
end
|
|
942
913
|
end
|
|
943
914
|
|
|
915
|
+
def record_observed_command(item)
|
|
916
|
+
return unless item.is_a?(Hash) && item["type"] == "commandExecution"
|
|
917
|
+
|
|
918
|
+
command = item["command"]
|
|
919
|
+
command = Shellwords.join(command.map(&:to_s)) if command.is_a?(Array)
|
|
920
|
+
command = command.to_s
|
|
921
|
+
exit_code = item["exitCode"]
|
|
922
|
+
exit_code = item["exit_code"] if exit_code.nil?
|
|
923
|
+
exit_code = Integer(exit_code)
|
|
924
|
+
return if command.empty?
|
|
925
|
+
|
|
926
|
+
duration = item["duration_s"]
|
|
927
|
+
duration = item["durationMs"].to_f / 1000.0 if duration.nil? && item["durationMs"]
|
|
928
|
+
observed = {
|
|
929
|
+
"cmd" => command,
|
|
930
|
+
"exit_code" => exit_code,
|
|
931
|
+
"status" => item["status"],
|
|
932
|
+
"duration_s" => duration
|
|
933
|
+
}.compact
|
|
934
|
+
@observed_commands_mutex.synchronize do
|
|
935
|
+
@observed_commands << observed
|
|
936
|
+
@observed_commands = @observed_commands.last(Harnex::ArtifactReport::MAX_COMMANDS)
|
|
937
|
+
end
|
|
938
|
+
rescue ArgumentError, TypeError
|
|
939
|
+
nil
|
|
940
|
+
end
|
|
941
|
+
|
|
944
942
|
def summarize(value)
|
|
945
943
|
str = value.is_a?(String) ? value : JSON.generate(value)
|
|
946
944
|
str.length > 120 ? "#{str[0, 117]}..." : str
|
|
@@ -1005,7 +1003,9 @@ module Harnex
|
|
|
1005
1003
|
if artifact_report_path
|
|
1006
1004
|
env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
|
|
1007
1005
|
env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
|
|
1006
|
+
env["HARNEX_ARTIFACT_CLAIMS_PATH"] = artifact_claims_path
|
|
1008
1007
|
env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
|
|
1008
|
+
env["HARNEX_ARTIFACT_REPORT_MODE"] = "observed_state"
|
|
1009
1009
|
env["HARNEX_ARTIFACT_REPORT_REQUIRED"] = "1" if require_artifact_report
|
|
1010
1010
|
end
|
|
1011
1011
|
env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
|
|
@@ -1045,7 +1045,7 @@ module Harnex
|
|
|
1045
1045
|
injected_count = @mutex.synchronize do
|
|
1046
1046
|
@injected_count += 1
|
|
1047
1047
|
@last_injected_at = Time.now
|
|
1048
|
-
|
|
1048
|
+
refresh_registry
|
|
1049
1049
|
@injected_count
|
|
1050
1050
|
end
|
|
1051
1051
|
|
|
@@ -1071,6 +1071,17 @@ module Harnex
|
|
|
1071
1071
|
Harnex.write_registry(@registry_path, payload)
|
|
1072
1072
|
end
|
|
1073
1073
|
|
|
1074
|
+
# Post-injection registry refresh. By the time this runs the prompt has
|
|
1075
|
+
# already reached the agent, so a failed bookkeeping write must not report
|
|
1076
|
+
# the send as failed — that would make an orchestrator retry a turn the
|
|
1077
|
+
# agent is already working on. Startup persistence stays strict: a session
|
|
1078
|
+
# nothing can discover should fail loudly instead of running unreachable.
|
|
1079
|
+
def refresh_registry
|
|
1080
|
+
persist_registry
|
|
1081
|
+
rescue StandardError => e
|
|
1082
|
+
warn("harnex: failed to refresh session registry: #{e.message}")
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1074
1085
|
def persist_exit_status
|
|
1075
1086
|
return unless defined?(@exit_code) && !@exit_code.nil?
|
|
1076
1087
|
|
|
@@ -1125,19 +1136,38 @@ module Harnex
|
|
|
1125
1136
|
rescue EOFError, Errno::EIO, IOError
|
|
1126
1137
|
break
|
|
1127
1138
|
end
|
|
1139
|
+
rescue StandardError => e
|
|
1140
|
+
warn("harnex: input forwarder stopped: #{e.class}: #{e.message}")
|
|
1128
1141
|
end
|
|
1129
1142
|
end
|
|
1130
1143
|
|
|
1144
|
+
# Draining the PTY is the one job this thread must never stop doing: if it
|
|
1145
|
+
# dies, the kernel buffer fills and the wrapped agent blocks forever on
|
|
1146
|
+
# write, which looks like the agent hanging rather than the harness
|
|
1147
|
+
# failing. Echoing to our own stdout is best-effort by comparison —
|
|
1148
|
+
# Errno::EPIPE/EBADF (not IOError subclasses, so they used to escape and
|
|
1149
|
+
# kill the thread) just mean nobody is watching, e.g. `harnex run … | head`.
|
|
1131
1150
|
def start_output_thread
|
|
1132
1151
|
Thread.new do
|
|
1152
|
+
echo = true
|
|
1133
1153
|
loop do
|
|
1134
1154
|
chunk = @reader.readpartial(4096)
|
|
1135
1155
|
record_output(chunk)
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1156
|
+
next unless echo
|
|
1157
|
+
|
|
1158
|
+
begin
|
|
1159
|
+
STDOUT.write(chunk)
|
|
1160
|
+
STDOUT.flush
|
|
1161
|
+
rescue StandardError => e
|
|
1162
|
+
echo = false
|
|
1163
|
+
warn("harnex: stopped mirroring output to stdout: #{e.message}")
|
|
1164
|
+
end
|
|
1140
1165
|
end
|
|
1166
|
+
rescue EOFError, Errno::EIO, IOError
|
|
1167
|
+
nil
|
|
1168
|
+
rescue StandardError => e
|
|
1169
|
+
# Never disappear quietly; a dead reader must be diagnosable.
|
|
1170
|
+
warn("harnex: output reader stopped: #{e.class}: #{e.message}")
|
|
1141
1171
|
end
|
|
1142
1172
|
end
|
|
1143
1173
|
|
|
@@ -1154,6 +1184,13 @@ module Harnex
|
|
|
1154
1184
|
@output_log_failed = false
|
|
1155
1185
|
end
|
|
1156
1186
|
|
|
1187
|
+
def prune_retained_logs
|
|
1188
|
+
Harnex::Retention.auto_prune(
|
|
1189
|
+
repo_root: repo_root,
|
|
1190
|
+
current_paths: [output_log_path, events_log_path, artifact_report_path, artifact_claims_path]
|
|
1191
|
+
)
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1157
1194
|
def prepare_events_log
|
|
1158
1195
|
@events_log&.close unless @events_log&.closed?
|
|
1159
1196
|
@events_log = File.open(events_log_path, "ab")
|
|
@@ -1219,19 +1256,34 @@ module Harnex
|
|
|
1219
1256
|
end
|
|
1220
1257
|
|
|
1221
1258
|
def emit_git_start_event
|
|
1222
|
-
@git_start = Harnex.git_capture_start(
|
|
1259
|
+
@git_start = Harnex.git_capture_start(
|
|
1260
|
+
repo_root,
|
|
1261
|
+
exclude_paths: git_observation_excluded_paths
|
|
1262
|
+
)
|
|
1223
1263
|
return if @git_start.empty?
|
|
1224
1264
|
|
|
1225
1265
|
emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
|
|
1226
1266
|
end
|
|
1227
1267
|
|
|
1268
|
+
def git_observation_excluded_paths
|
|
1269
|
+
git_root = DispatchHistory.find_git_root(repo_root)
|
|
1270
|
+
return [] unless git_root
|
|
1271
|
+
|
|
1272
|
+
root = File.expand_path(git_root)
|
|
1273
|
+
prefix = "#{root}#{File::SEPARATOR}"
|
|
1274
|
+
[dispatch_history_path, artifact_report_path, artifact_claims_path].filter_map do |path|
|
|
1275
|
+
absolute = File.expand_path(path.to_s, root)
|
|
1276
|
+
absolute.delete_prefix(prefix) if absolute.start_with?(prefix)
|
|
1277
|
+
end.uniq
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1228
1280
|
def emit_session_end_telemetry
|
|
1229
1281
|
summary = collect_session_summary
|
|
1230
1282
|
@usage_summary = normalized_usage_summary(summary)
|
|
1231
1283
|
@context_summary = normalized_context_summary(summary)
|
|
1232
1284
|
emit_event("usage", **@usage_summary)
|
|
1233
1285
|
|
|
1234
|
-
@git_end = Harnex.git_capture_end(repo_root, @git_start
|
|
1286
|
+
@git_end = Harnex.git_capture_end(repo_root, @git_start)
|
|
1235
1287
|
return if @git_end.empty?
|
|
1236
1288
|
|
|
1237
1289
|
emit_event(
|
|
@@ -1242,12 +1294,15 @@ module Harnex
|
|
|
1242
1294
|
loc_removed: @git_end[:loc_removed],
|
|
1243
1295
|
files_changed: @git_end[:files_changed],
|
|
1244
1296
|
changed_paths: @git_end[:changed_paths],
|
|
1245
|
-
commits: @git_end[:commits]
|
|
1297
|
+
commits: @git_end[:commits],
|
|
1298
|
+
start_dirty: @git_end[:start_dirty],
|
|
1299
|
+
end_dirty: @git_end[:end_dirty],
|
|
1300
|
+
worktree_changed: @git_end[:worktree_changed]
|
|
1246
1301
|
)
|
|
1247
1302
|
end
|
|
1248
1303
|
|
|
1249
1304
|
def emit_summary_event
|
|
1250
|
-
emit_event("summary", path:
|
|
1305
|
+
emit_event("summary", path: dispatch_history_path, exit: @exit_reason)
|
|
1251
1306
|
end
|
|
1252
1307
|
|
|
1253
1308
|
def emit_attempt_finished(attempt)
|
|
@@ -1283,10 +1338,10 @@ module Harnex
|
|
|
1283
1338
|
@context_summary = normalized_context_summary(nil)
|
|
1284
1339
|
warn("harnex: failed to collect session-end telemetry: #{e.message}")
|
|
1285
1340
|
end
|
|
1341
|
+
enforce_required_artifact_report!
|
|
1286
1342
|
@exit_reason ||= classify_exit
|
|
1287
|
-
record =
|
|
1288
|
-
|
|
1289
|
-
append_dispatch_history_record
|
|
1343
|
+
record = DispatchHistory.build_record(self)
|
|
1344
|
+
append_dispatch_history_record(record)
|
|
1290
1345
|
emit_summary_event
|
|
1291
1346
|
emit_attempt_finished(record.fetch(:attempt))
|
|
1292
1347
|
emit_exit_event
|
|
@@ -1381,48 +1436,174 @@ module Harnex
|
|
|
1381
1436
|
AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
|
|
1382
1437
|
end
|
|
1383
1438
|
|
|
1439
|
+
# Kept under the existing method name for call-site compatibility. The
|
|
1440
|
+
# worker no longer has to author or validate proof: Harnex overwrites the
|
|
1441
|
+
# configured/default path with a receipt derived from observed state.
|
|
1384
1442
|
def enforce_required_artifact_report!
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
report_accepted = Harnex::ArtifactReport.accepted_final?(result)
|
|
1391
|
-
report_stale = report_accepted && !report_fresh
|
|
1392
|
-
if report_fresh && report_accepted
|
|
1393
|
-
@completion_outcome_class = "completed_with_proof"
|
|
1394
|
-
@completion_report_status = "accepted"
|
|
1395
|
-
@completion_diagnostics = []
|
|
1396
|
-
return
|
|
1397
|
-
end
|
|
1443
|
+
persist_observed_receipt!
|
|
1444
|
+
rescue StandardError => e
|
|
1445
|
+
@artifact_report_write_error = e.message
|
|
1446
|
+
warn("harnex: failed to write observed-state receipt #{artifact_report_path}: #{e.message}")
|
|
1447
|
+
return if task_failed?
|
|
1398
1448
|
|
|
1399
|
-
|
|
1400
|
-
outcome_class = if report_fresh && report_outcome == "rejected"
|
|
1401
|
-
"report_rejected"
|
|
1402
|
-
elsif result.status == "missing"
|
|
1403
|
-
"report_missing"
|
|
1404
|
-
else
|
|
1405
|
-
"report_invalid"
|
|
1406
|
-
end
|
|
1407
|
-
report_status = if report_stale
|
|
1408
|
-
"stale"
|
|
1409
|
-
elsif report_fresh && report_outcome == "rejected"
|
|
1410
|
-
"rejected"
|
|
1411
|
-
else
|
|
1412
|
-
result.status
|
|
1413
|
-
end
|
|
1414
|
-
diagnostics = report_stale ? [artifact_report_stale_diagnostic] : result.diagnostics
|
|
1449
|
+
@last_completed_at = nil
|
|
1415
1450
|
mark_task_failed(
|
|
1416
|
-
status:
|
|
1417
|
-
error:
|
|
1418
|
-
outcome_class:
|
|
1419
|
-
artifact_report_status:
|
|
1420
|
-
diagnostics:
|
|
1451
|
+
status: "report_invalid",
|
|
1452
|
+
error: "harness could not generate the observed-state receipt",
|
|
1453
|
+
outcome_class: "report_invalid",
|
|
1454
|
+
artifact_report_status: "write_error",
|
|
1455
|
+
diagnostics: [{
|
|
1456
|
+
"code" => "receipt_write_error",
|
|
1457
|
+
"path" => "$",
|
|
1458
|
+
"message" => "harness could not write the observed-state receipt"
|
|
1459
|
+
}]
|
|
1421
1460
|
)
|
|
1422
1461
|
@exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
|
|
1423
1462
|
@term_signal = nil if @exit_code == 1
|
|
1424
1463
|
end
|
|
1425
1464
|
|
|
1465
|
+
def persist_observed_receipt!
|
|
1466
|
+
refresh_git_end_snapshot
|
|
1467
|
+
load_artifact_claims
|
|
1468
|
+
successful = receipt_successful?
|
|
1469
|
+
if successful
|
|
1470
|
+
@completion_outcome_class = "completed_with_proof" if task_complete? || require_artifact_report
|
|
1471
|
+
@completion_report_status = "accepted"
|
|
1472
|
+
@completion_diagnostics = []
|
|
1473
|
+
else
|
|
1474
|
+
@completion_report_status ||= "rejected"
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
result = Harnex::ArtifactReport.write_observed(
|
|
1478
|
+
artifact_report_path,
|
|
1479
|
+
id: id,
|
|
1480
|
+
session_id: session_id,
|
|
1481
|
+
generated_at: Time.now.utc,
|
|
1482
|
+
successful: successful,
|
|
1483
|
+
outcome_status: receipt_outcome_status(successful),
|
|
1484
|
+
outcome_summary: receipt_outcome_summary(successful),
|
|
1485
|
+
git: receipt_git_payload,
|
|
1486
|
+
commands: observed_commands_snapshot,
|
|
1487
|
+
turn: receipt_turn_payload(successful),
|
|
1488
|
+
usage: build_summary_usage,
|
|
1489
|
+
claims: @artifact_claims,
|
|
1490
|
+
command_observation: command_observation_status
|
|
1491
|
+
)
|
|
1492
|
+
@artifact_report_generated = true
|
|
1493
|
+
@artifact_report_write_error = nil
|
|
1494
|
+
result
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
def refresh_git_end_snapshot
|
|
1498
|
+
return @git_end = {} if @git_start[:sha].to_s.empty?
|
|
1499
|
+
|
|
1500
|
+
snapshot = Harnex.git_capture_end(repo_root, @git_start)
|
|
1501
|
+
@git_end = snapshot unless snapshot.empty?
|
|
1502
|
+
@git_end
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
def load_artifact_claims
|
|
1506
|
+
return @artifact_claims if @artifact_claims_loaded
|
|
1507
|
+
|
|
1508
|
+
@artifact_claims_loaded = true
|
|
1509
|
+
candidates = [
|
|
1510
|
+
[artifact_claims_path, @artifact_claims_start_fingerprint],
|
|
1511
|
+
[artifact_report_path, @artifact_report_start_fingerprint]
|
|
1512
|
+
]
|
|
1513
|
+
candidates.each do |path, start_fingerprint|
|
|
1514
|
+
next unless artifact_input_fresh?(path, start_fingerprint)
|
|
1515
|
+
|
|
1516
|
+
claims = Harnex::ArtifactReport.extract_claims(path)
|
|
1517
|
+
next if claims.empty?
|
|
1518
|
+
|
|
1519
|
+
@artifact_claims = claims
|
|
1520
|
+
break
|
|
1521
|
+
end
|
|
1522
|
+
@artifact_claims
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1525
|
+
def observed_commands_snapshot
|
|
1526
|
+
@observed_commands_mutex.synchronize { @observed_commands.map(&:dup) }
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
def command_observation_status
|
|
1530
|
+
adapter.transport == :stdio_jsonrpc ? "observed" : "unsupported"
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1533
|
+
def receipt_successful?
|
|
1534
|
+
return false if task_failed?
|
|
1535
|
+
return @exit_code.to_i.zero? unless @exit_code.nil?
|
|
1536
|
+
|
|
1537
|
+
task_complete?
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
def receipt_outcome_status(successful)
|
|
1541
|
+
return "rejected" unless successful
|
|
1542
|
+
|
|
1543
|
+
git = receipt_git_payload
|
|
1544
|
+
if git[:status] == "observed" &&
|
|
1545
|
+
git[:commits].to_i.zero? &&
|
|
1546
|
+
Array(git[:changed_paths]).empty? &&
|
|
1547
|
+
!git[:start_sha].to_s.empty? &&
|
|
1548
|
+
git[:start_sha].to_s == git[:end_sha].to_s
|
|
1549
|
+
"no_change"
|
|
1550
|
+
else
|
|
1551
|
+
"accepted"
|
|
1552
|
+
end
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
def receipt_outcome_summary(successful)
|
|
1556
|
+
return "Harnex observed an unsuccessful completion." unless successful
|
|
1557
|
+
|
|
1558
|
+
if receipt_outcome_status(true) == "no_change"
|
|
1559
|
+
"Harnex observed successful completion with no Git delta."
|
|
1560
|
+
else
|
|
1561
|
+
"Harnex observed successful completion with repository or structured activity."
|
|
1562
|
+
end
|
|
1563
|
+
end
|
|
1564
|
+
|
|
1565
|
+
def receipt_git_payload
|
|
1566
|
+
start_sha = @git_start[:sha].to_s
|
|
1567
|
+
end_sha = @git_end[:sha].to_s
|
|
1568
|
+
return { status: "unavailable" } if start_sha.empty? || end_sha.empty?
|
|
1569
|
+
|
|
1570
|
+
{
|
|
1571
|
+
status: "observed",
|
|
1572
|
+
start_sha: start_sha,
|
|
1573
|
+
end_sha: end_sha,
|
|
1574
|
+
branch: @git_start[:branch],
|
|
1575
|
+
changed_paths: Array(@git_end[:changed_paths]),
|
|
1576
|
+
loc_added: @git_end[:loc_added],
|
|
1577
|
+
loc_removed: @git_end[:loc_removed],
|
|
1578
|
+
files_changed: @git_end[:files_changed],
|
|
1579
|
+
commits: @git_end[:commits],
|
|
1580
|
+
start_dirty: @git_end[:start_dirty],
|
|
1581
|
+
end_dirty: @git_end[:end_dirty],
|
|
1582
|
+
worktree_changed: @git_end[:worktree_changed]
|
|
1583
|
+
}
|
|
1584
|
+
end
|
|
1585
|
+
|
|
1586
|
+
def receipt_turn_payload(successful)
|
|
1587
|
+
status = if task_failed?
|
|
1588
|
+
"failed"
|
|
1589
|
+
elsif task_complete?
|
|
1590
|
+
"completed"
|
|
1591
|
+
elsif !@exit_code.nil?
|
|
1592
|
+
"process_exit"
|
|
1593
|
+
else
|
|
1594
|
+
"completed"
|
|
1595
|
+
end
|
|
1596
|
+
{
|
|
1597
|
+
status: status,
|
|
1598
|
+
outcome_class: @completion_outcome_class,
|
|
1599
|
+
task_complete: task_complete?,
|
|
1600
|
+
task_failed: task_failed?,
|
|
1601
|
+
accepted: successful,
|
|
1602
|
+
exit_code: @exit_code,
|
|
1603
|
+
signal: @term_signal
|
|
1604
|
+
}
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1426
1607
|
def normalize_work_acceptance_exit_code!
|
|
1427
1608
|
return unless task_failed? && @completion_outcome_class
|
|
1428
1609
|
|
|
@@ -1472,7 +1653,7 @@ module Harnex
|
|
|
1472
1653
|
end
|
|
1473
1654
|
|
|
1474
1655
|
def build_summary_record
|
|
1475
|
-
artifact_payload =
|
|
1656
|
+
artifact_payload = @artifact_report_generated ? artifact_report_summary : nil
|
|
1476
1657
|
attribution = build_summary_attribution
|
|
1477
1658
|
outcome = build_summary_outcome(artifact_payload)
|
|
1478
1659
|
record = {
|
|
@@ -1581,10 +1762,30 @@ module Harnex
|
|
|
1581
1762
|
cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
|
|
1582
1763
|
cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
|
|
1583
1764
|
|
|
1765
|
+
cost_price_as_of = nil
|
|
1766
|
+
if values[:cost_usd].nil? && %w[observed zero].include?(status)
|
|
1767
|
+
priced = Pricing.compute(
|
|
1768
|
+
provider: summary_agent_provider,
|
|
1769
|
+
model: summary_model,
|
|
1770
|
+
input_tokens: values[:input_tokens],
|
|
1771
|
+
output_tokens: values[:output_tokens],
|
|
1772
|
+
cached_tokens: values[:cached_tokens],
|
|
1773
|
+
service_tier: summary_service_tier,
|
|
1774
|
+
context_tokens: @context_summary[:peak_tokens],
|
|
1775
|
+
input_includes_cached: adapter.usage_input_includes_cached?
|
|
1776
|
+
)
|
|
1777
|
+
if priced
|
|
1778
|
+
values[:cost_usd] = priced[:cost_usd]
|
|
1779
|
+
cost_source = "price_table"
|
|
1780
|
+
cost_price_as_of = priced[:as_of]
|
|
1781
|
+
end
|
|
1782
|
+
end
|
|
1783
|
+
|
|
1584
1784
|
{
|
|
1585
1785
|
"status" => status,
|
|
1586
1786
|
"cost_usd" => values[:cost_usd],
|
|
1587
1787
|
"cost_source" => cost_source,
|
|
1788
|
+
"cost_price_as_of" => cost_price_as_of,
|
|
1588
1789
|
"input_tokens" => values[:input_tokens],
|
|
1589
1790
|
"output_tokens" => values[:output_tokens],
|
|
1590
1791
|
"cached_input_tokens" => values[:cached_tokens],
|
|
@@ -1661,16 +1862,20 @@ module Harnex
|
|
|
1661
1862
|
end
|
|
1662
1863
|
|
|
1663
1864
|
def build_summary_outcome(artifact_payload)
|
|
1664
|
-
|
|
1865
|
+
receipt_outcome = artifact_payload&.dig("outcome") || {}
|
|
1665
1866
|
outcome_class, report_status = summary_proof_classification
|
|
1666
|
-
proof_failure = %w[completed_no_activity report_missing report_invalid task_failed].include?(outcome_class)
|
|
1667
|
-
|
|
1867
|
+
proof_failure = %w[completed_no_activity report_missing report_invalid report_rejected task_failed].include?(outcome_class)
|
|
1868
|
+
harness_receipt = artifact_payload&.dig("receipt", "author") == "harnex"
|
|
1869
|
+
status = receipt_outcome["status"]
|
|
1870
|
+
status = nil if status == "accepted" && proof_failure
|
|
1668
1871
|
status = "no_change" if status.nil? && !proof_failure && @git_end.key?(:changed_paths) && @git_end[:changed_paths].empty?
|
|
1669
1872
|
status ||= "unknown"
|
|
1670
1873
|
status = "unknown" unless Harnex::ArtifactReport::OUTCOME_STATUSES.include?(status)
|
|
1671
|
-
source = if
|
|
1874
|
+
source = if harness_receipt
|
|
1875
|
+
"harnex_observed_state"
|
|
1876
|
+
elsif proof_failure
|
|
1672
1877
|
"harnex_completion_gate"
|
|
1673
|
-
elsif
|
|
1878
|
+
elsif receipt_outcome["status"]
|
|
1674
1879
|
"artifact_report"
|
|
1675
1880
|
else
|
|
1676
1881
|
"harnex_git_observation"
|
|
@@ -1681,7 +1886,7 @@ module Harnex
|
|
|
1681
1886
|
"class" => outcome_class,
|
|
1682
1887
|
"source" => source,
|
|
1683
1888
|
"report_status" => report_status,
|
|
1684
|
-
"commit_sha" =>
|
|
1889
|
+
"commit_sha" => receipt_outcome["commit_sha"] || summary_commit_sha,
|
|
1685
1890
|
"changed_paths" => @git_end.key?(:changed_paths) ? @git_end[:changed_paths] : nil,
|
|
1686
1891
|
"loc_added" => @git_end[:loc_added],
|
|
1687
1892
|
"loc_removed" => @git_end[:loc_removed],
|
|
@@ -1695,13 +1900,10 @@ module Harnex
|
|
|
1695
1900
|
return [@completion_outcome_class, @completion_report_status] if @completion_outcome_class
|
|
1696
1901
|
return ["task_failed", @completion_report_status] if task_failed?
|
|
1697
1902
|
|
|
1698
|
-
if
|
|
1903
|
+
if @artifact_report_generated
|
|
1699
1904
|
result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
return ["completed_with_proof", "accepted"] if report_fresh && report_accepted
|
|
1703
|
-
return ["report_rejected", "rejected"] if report_fresh && Harnex::ArtifactReport.outcome_status(result) == "rejected"
|
|
1704
|
-
return ["unknown", "stale"] if report_accepted && !report_fresh
|
|
1905
|
+
return ["completed_with_proof", "accepted"] if Harnex::ArtifactReport.accepted_final?(result)
|
|
1906
|
+
return ["report_rejected", "rejected"] if Harnex::ArtifactReport.outcome_status(result) == "rejected"
|
|
1705
1907
|
return ["unknown", result.status]
|
|
1706
1908
|
end
|
|
1707
1909
|
|
|
@@ -1762,6 +1964,101 @@ module Harnex
|
|
|
1762
1964
|
@exit_reason == "success"
|
|
1763
1965
|
end
|
|
1764
1966
|
|
|
1967
|
+
def attempt_chain_summary
|
|
1968
|
+
@attempt_chain_summary ||= begin
|
|
1969
|
+
current = {
|
|
1970
|
+
id: id,
|
|
1971
|
+
parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
|
|
1972
|
+
kind: summary_attempt_kind,
|
|
1973
|
+
outcome: summary_attempt_succeeded? ? "succeeded" : "failed"
|
|
1974
|
+
}
|
|
1975
|
+
index = historical_attempt_index
|
|
1976
|
+
nodes = [current]
|
|
1977
|
+
seen = {}
|
|
1978
|
+
seen[current[:id]] = true if current[:id]
|
|
1979
|
+
parent = current[:parent_dispatch_id]
|
|
1980
|
+
immediate_parent = nil
|
|
1981
|
+
|
|
1982
|
+
while parent && !seen[parent]
|
|
1983
|
+
record = index[parent]
|
|
1984
|
+
break unless record
|
|
1985
|
+
|
|
1986
|
+
node = historical_attempt_node(record)
|
|
1987
|
+
break unless node
|
|
1988
|
+
|
|
1989
|
+
immediate_parent ||= node
|
|
1990
|
+
nodes << node
|
|
1991
|
+
seen[node[:id]] = true if node[:id]
|
|
1992
|
+
parent = node[:parent_dispatch_id]
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
{
|
|
1996
|
+
attempts_total: nodes.length,
|
|
1997
|
+
attempts_succeeded: nodes.count { |node| node[:outcome] == "succeeded" },
|
|
1998
|
+
attempts_failed: nodes.count { |node| node[:outcome] == "failed" },
|
|
1999
|
+
fallback_triggered: nodes.any? { |node| node[:kind] == "fallback" },
|
|
2000
|
+
recovered: current[:outcome] == "succeeded" && immediate_parent&.dig(:outcome) == "failed"
|
|
2001
|
+
}
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
def historical_attempt_index
|
|
2006
|
+
path = dispatch_history_path
|
|
2007
|
+
return {} unless File.file?(path)
|
|
2008
|
+
|
|
2009
|
+
records = {}
|
|
2010
|
+
duplicates = {}
|
|
2011
|
+
File.foreach(path) do |line|
|
|
2012
|
+
record = JSON.parse(line)
|
|
2013
|
+
next unless DispatchHistory.end_record?(record)
|
|
2014
|
+
|
|
2015
|
+
dispatch_id = summary_string(record["id"])
|
|
2016
|
+
next unless dispatch_id
|
|
2017
|
+
|
|
2018
|
+
if records.key?(dispatch_id)
|
|
2019
|
+
records.delete(dispatch_id)
|
|
2020
|
+
duplicates[dispatch_id] = true
|
|
2021
|
+
elsif !duplicates.key?(dispatch_id)
|
|
2022
|
+
records[dispatch_id] = record
|
|
2023
|
+
end
|
|
2024
|
+
rescue JSON::ParserError
|
|
2025
|
+
next
|
|
2026
|
+
end
|
|
2027
|
+
records
|
|
2028
|
+
rescue StandardError
|
|
2029
|
+
{}
|
|
2030
|
+
end
|
|
2031
|
+
|
|
2032
|
+
def historical_attempt_node(record)
|
|
2033
|
+
attempt = record["attempt"].is_a?(Hash) ? record["attempt"] : {}
|
|
2034
|
+
meta = record["meta"].is_a?(Hash) ? record["meta"] : {}
|
|
2035
|
+
dispatch_id = summary_string(record["id"])
|
|
2036
|
+
return nil unless dispatch_id
|
|
2037
|
+
|
|
2038
|
+
{
|
|
2039
|
+
id: dispatch_id,
|
|
2040
|
+
parent_dispatch_id: summary_string(attempt["parent_dispatch_id"]) || summary_string(meta["parent_dispatch_id"]),
|
|
2041
|
+
kind: summary_string(attempt["kind"]),
|
|
2042
|
+
outcome: historical_attempt_outcome(record, attempt)
|
|
2043
|
+
}
|
|
2044
|
+
end
|
|
2045
|
+
|
|
2046
|
+
def historical_attempt_outcome(record, attempt)
|
|
2047
|
+
case summary_string(attempt["status"])
|
|
2048
|
+
when "succeeded"
|
|
2049
|
+
"succeeded"
|
|
2050
|
+
when "failed"
|
|
2051
|
+
"failed"
|
|
2052
|
+
else
|
|
2053
|
+
case summary_string(record["status"])
|
|
2054
|
+
when "completed"
|
|
2055
|
+
"succeeded"
|
|
2056
|
+
when "failed", "timeout", "killed"
|
|
2057
|
+
"failed"
|
|
2058
|
+
end
|
|
2059
|
+
end
|
|
2060
|
+
end
|
|
2061
|
+
|
|
1765
2062
|
def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
|
|
1766
2063
|
return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
|
|
1767
2064
|
|
|
@@ -1785,6 +2082,7 @@ module Harnex
|
|
|
1785
2082
|
def build_summary_reliability
|
|
1786
2083
|
counters = reliability_event_counters
|
|
1787
2084
|
real_disconnections = counters[:disconnections].to_i
|
|
2085
|
+
chain = attempt_chain_summary
|
|
1788
2086
|
{
|
|
1789
2087
|
"adapter_close" => summary_adapter_close(real_disconnections),
|
|
1790
2088
|
"real_disconnections" => real_disconnections,
|
|
@@ -1792,7 +2090,7 @@ module Harnex
|
|
|
1792
2090
|
"stalls" => counters[:stalls].to_i,
|
|
1793
2091
|
"force_resumes" => counters[:force_resumes].to_i,
|
|
1794
2092
|
"compactions" => counters[:compactions].to_i,
|
|
1795
|
-
"recovered" =>
|
|
2093
|
+
"recovered" => chain[:recovered]
|
|
1796
2094
|
}
|
|
1797
2095
|
end
|
|
1798
2096
|
|
|
@@ -1801,7 +2099,7 @@ module Harnex
|
|
|
1801
2099
|
output_measurements = summary_output_measurements
|
|
1802
2100
|
accepted = outcome["status"] == "accepted"
|
|
1803
2101
|
duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
|
|
1804
|
-
|
|
2102
|
+
chain = attempt_chain_summary
|
|
1805
2103
|
total_tokens = @usage_summary[:total_tokens]
|
|
1806
2104
|
|
|
1807
2105
|
actual = {
|
|
@@ -1839,9 +2137,9 @@ module Harnex
|
|
|
1839
2137
|
event_records: @events_log_seq,
|
|
1840
2138
|
output_log_path: output_log_path,
|
|
1841
2139
|
events_log_path: events_log_path,
|
|
1842
|
-
attempts_total:
|
|
1843
|
-
attempts_succeeded:
|
|
1844
|
-
attempts_failed:
|
|
2140
|
+
attempts_total: chain[:attempts_total],
|
|
2141
|
+
attempts_succeeded: chain[:attempts_succeeded],
|
|
2142
|
+
attempts_failed: chain[:attempts_failed],
|
|
1845
2143
|
retry_count: counters[:retries],
|
|
1846
2144
|
throttle_429_count: counters[:throttle_429],
|
|
1847
2145
|
disconnect_count: counters[:disconnections],
|
|
@@ -1849,7 +2147,7 @@ module Harnex
|
|
|
1849
2147
|
throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
|
|
1850
2148
|
retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
|
|
1851
2149
|
unattributed: attribution["status"] != "complete",
|
|
1852
|
-
fallback_triggered:
|
|
2150
|
+
fallback_triggered: chain[:fallback_triggered]
|
|
1853
2151
|
}
|
|
1854
2152
|
actual
|
|
1855
2153
|
end
|
|
@@ -1983,34 +2281,21 @@ module Harnex
|
|
|
1983
2281
|
meta.is_a?(Hash) ? meta : {}
|
|
1984
2282
|
end
|
|
1985
2283
|
|
|
1986
|
-
def append_summary_record(record)
|
|
1987
|
-
return unless summary_out
|
|
1988
|
-
|
|
1989
|
-
FileUtils.mkdir_p(File.dirname(summary_out))
|
|
1990
|
-
File.open(summary_out, "ab") do |file|
|
|
1991
|
-
file.write(JSON.generate(record))
|
|
1992
|
-
file.write("\n")
|
|
1993
|
-
end
|
|
1994
|
-
rescue StandardError => e
|
|
1995
|
-
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
1996
|
-
end
|
|
1997
|
-
|
|
1998
2284
|
def append_dispatch_start_record
|
|
1999
2285
|
DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
|
|
2000
2286
|
rescue StandardError => e
|
|
2001
2287
|
warn("harnex: failed to write dispatch start record: #{e.message}")
|
|
2002
2288
|
end
|
|
2003
2289
|
|
|
2004
|
-
def append_dispatch_history_record
|
|
2005
|
-
DispatchHistory.append(dispatch_history_path,
|
|
2290
|
+
def append_dispatch_history_record(record)
|
|
2291
|
+
DispatchHistory.append(dispatch_history_path, record)
|
|
2006
2292
|
rescue StandardError => e
|
|
2007
2293
|
warn("harnex: failed to write dispatch history: #{e.message}")
|
|
2008
2294
|
end
|
|
2009
2295
|
|
|
2010
|
-
#
|
|
2011
|
-
#
|
|
2012
|
-
#
|
|
2013
|
-
# paths already key off.
|
|
2296
|
+
# The only telemetry destination: the start row and the end row must land
|
|
2297
|
+
# in the same file so readers can pair them. repo_root — not the launch
|
|
2298
|
+
# cwd — is the root that registry and events paths already key off.
|
|
2014
2299
|
def dispatch_history_path
|
|
2015
2300
|
@dispatch_history_path ||= DispatchHistory.path_for(repo_root)
|
|
2016
2301
|
end
|