harnex 0.9.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 +100 -0
- data/README.md +38 -38
- data/TECHNICAL.md +17 -15
- data/docs/codex-appserver.md +16 -13
- data/docs/configuration.md +11 -4
- data/docs/dispatch-telemetry.md +153 -112
- data/docs/events.md +2 -3
- data/guides/01_dispatch.md +29 -32
- data/guides/04_monitoring.md +4 -4
- 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 +1 -1
- data/lib/harnex/commands/run.rb +22 -37
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +6 -2
- data/lib/harnex/core.rb +200 -14
- data/lib/harnex/dispatch_history.rb +3 -3
- data/lib/harnex/retention.rb +13 -4
- data/lib/harnex/runtime/session.rb +317 -160
- data/lib/harnex/terminal_status.rb +7 -20
- data/lib/harnex/version.rb +1 -1
- metadata +2 -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
|
|
@@ -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 = {}
|
|
@@ -257,7 +272,9 @@ module Harnex
|
|
|
257
272
|
last_injected_at: @last_injected_at&.iso8601,
|
|
258
273
|
injected_count: @injected_count,
|
|
259
274
|
output_log_path: output_log_path,
|
|
260
|
-
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
|
|
261
278
|
}
|
|
262
279
|
payload.merge!(log_activity_snapshot)
|
|
263
280
|
payload[:description] = description if description
|
|
@@ -427,7 +444,7 @@ module Harnex
|
|
|
427
444
|
@state_machine.force_busy!
|
|
428
445
|
@injected_count += 1
|
|
429
446
|
@last_injected_at = Time.now
|
|
430
|
-
|
|
447
|
+
refresh_registry
|
|
431
448
|
end
|
|
432
449
|
|
|
433
450
|
emit_send_event(dispatch.fetch(:prompt, text), force: payload[:force])
|
|
@@ -571,6 +588,7 @@ module Harnex
|
|
|
571
588
|
when "item/completed"
|
|
572
589
|
emit_event("item_completed", item: params["item"])
|
|
573
590
|
@event_counters.record_item(params["item"])
|
|
591
|
+
record_observed_command(params["item"])
|
|
574
592
|
text = render_item_text(params["item"])
|
|
575
593
|
record_synthesized(text) if text
|
|
576
594
|
when "thread/compacted"
|
|
@@ -628,6 +646,7 @@ module Harnex
|
|
|
628
646
|
artifact_report_status: assessment[:report_status],
|
|
629
647
|
diagnostics: assessment[:diagnostics]
|
|
630
648
|
)
|
|
649
|
+
enforce_required_artifact_report!
|
|
631
650
|
return false
|
|
632
651
|
end
|
|
633
652
|
|
|
@@ -641,9 +660,15 @@ module Harnex
|
|
|
641
660
|
@completion_diagnostics = []
|
|
642
661
|
end
|
|
643
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
|
+
|
|
644
668
|
event_payload = payload.dup
|
|
645
669
|
event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
|
|
646
670
|
event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
|
|
671
|
+
event_payload[:artifact_report_path] = artifact_report_path
|
|
647
672
|
emit_event("task_complete", **event_payload)
|
|
648
673
|
true
|
|
649
674
|
end
|
|
@@ -656,85 +681,30 @@ module Harnex
|
|
|
656
681
|
end
|
|
657
682
|
|
|
658
683
|
def assess_completion_proof
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
report_stale = report_result && Harnex::ArtifactReport.accepted_final?(report_result) && !report_fresh
|
|
662
|
-
report_status = report_stale ? "stale" : report_result&.status
|
|
663
|
-
report_diagnostics = if report_stale
|
|
664
|
-
[artifact_report_stale_diagnostic]
|
|
665
|
-
else
|
|
666
|
-
report_result&.diagnostics || []
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
if report_result
|
|
670
|
-
if report_fresh && Harnex::ArtifactReport.accepted_final?(report_result)
|
|
671
|
-
return {
|
|
672
|
-
accepted: true,
|
|
673
|
-
outcome_class: "completed_with_proof",
|
|
674
|
-
report_status: "accepted",
|
|
675
|
-
diagnostics: []
|
|
676
|
-
}
|
|
677
|
-
end
|
|
678
|
-
|
|
679
|
-
report_outcome = Harnex::ArtifactReport.outcome_status(report_result)
|
|
680
|
-
if report_fresh && report_outcome == "rejected"
|
|
681
|
-
return {
|
|
682
|
-
accepted: false,
|
|
683
|
-
outcome_class: "report_rejected",
|
|
684
|
-
report_status: "rejected",
|
|
685
|
-
diagnostics: report_result.diagnostics
|
|
686
|
-
}
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
if require_artifact_report
|
|
690
|
-
outcome_class = report_result.status == "missing" ? "report_missing" : "report_invalid"
|
|
691
|
-
return {
|
|
692
|
-
accepted: false,
|
|
693
|
-
outcome_class: outcome_class,
|
|
694
|
-
report_status: report_status,
|
|
695
|
-
diagnostics: report_diagnostics
|
|
696
|
-
}
|
|
697
|
-
end
|
|
698
|
-
elsif require_artifact_report
|
|
699
|
-
return {
|
|
700
|
-
accepted: false,
|
|
701
|
-
outcome_class: "report_missing",
|
|
702
|
-
report_status: "missing",
|
|
703
|
-
diagnostics: []
|
|
704
|
-
}
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
if structured_activity_observed? || git_activity_observed?
|
|
684
|
+
refresh_git_end_snapshot
|
|
685
|
+
if structured_activity_observed? || git_activity_observed?(@git_end)
|
|
708
686
|
{
|
|
709
687
|
accepted: true,
|
|
710
688
|
outcome_class: "completed_with_activity",
|
|
711
|
-
report_status:
|
|
689
|
+
report_status: nil,
|
|
712
690
|
diagnostics: []
|
|
713
691
|
}
|
|
714
692
|
else
|
|
715
693
|
{
|
|
716
694
|
accepted: false,
|
|
717
695
|
outcome_class: "completed_no_activity",
|
|
718
|
-
report_status:
|
|
719
|
-
diagnostics:
|
|
696
|
+
report_status: "rejected",
|
|
697
|
+
diagnostics: []
|
|
720
698
|
}
|
|
721
699
|
end
|
|
722
700
|
end
|
|
723
701
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
current !=
|
|
730
|
-
end
|
|
731
|
-
|
|
732
|
-
def artifact_report_stale_diagnostic
|
|
733
|
-
{
|
|
734
|
-
"code" => "report_stale",
|
|
735
|
-
"path" => "$",
|
|
736
|
-
"message" => "artifact report was not created or updated during this session"
|
|
737
|
-
}
|
|
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)
|
|
738
708
|
end
|
|
739
709
|
|
|
740
710
|
def structured_activity_observed?
|
|
@@ -742,10 +712,10 @@ module Harnex
|
|
|
742
712
|
counters[:commands_executed].to_i.positive? || counters[:tool_calls].to_i.positive?
|
|
743
713
|
end
|
|
744
714
|
|
|
745
|
-
def git_activity_observed?
|
|
715
|
+
def git_activity_observed?(snapshot = nil)
|
|
746
716
|
return false if @git_start[:sha].to_s.empty?
|
|
747
717
|
|
|
748
|
-
snapshot
|
|
718
|
+
snapshot ||= Harnex.git_capture_end(repo_root, @git_start)
|
|
749
719
|
snapshot[:commits].to_i.positive? || Array(snapshot[:changed_paths]).any? ||
|
|
750
720
|
(!snapshot[:sha].to_s.empty? && snapshot[:sha].to_s != @git_start[:sha].to_s)
|
|
751
721
|
end
|
|
@@ -753,7 +723,7 @@ module Harnex
|
|
|
753
723
|
def completion_failure_message(outcome_class)
|
|
754
724
|
case outcome_class
|
|
755
725
|
when "completed_no_activity"
|
|
756
|
-
"turn completed without command/tool execution
|
|
726
|
+
"turn completed without command/tool execution or Git delta"
|
|
757
727
|
when "report_missing"
|
|
758
728
|
"required artifact report is missing"
|
|
759
729
|
when "report_rejected"
|
|
@@ -942,6 +912,33 @@ module Harnex
|
|
|
942
912
|
end
|
|
943
913
|
end
|
|
944
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
|
+
|
|
945
942
|
def summarize(value)
|
|
946
943
|
str = value.is_a?(String) ? value : JSON.generate(value)
|
|
947
944
|
str.length > 120 ? "#{str[0, 117]}..." : str
|
|
@@ -1006,7 +1003,9 @@ module Harnex
|
|
|
1006
1003
|
if artifact_report_path
|
|
1007
1004
|
env["HARNEX_ARTIFACT_REPORT_PATH"] = artifact_report_path
|
|
1008
1005
|
env["HARNEX_VALIDATION_REPORT_PATH"] = artifact_report_path
|
|
1006
|
+
env["HARNEX_ARTIFACT_CLAIMS_PATH"] = artifact_claims_path
|
|
1009
1007
|
env["HARNEX_ARTIFACT_REPORT_SCHEMA"] = Harnex::ArtifactReport::SCHEMA
|
|
1008
|
+
env["HARNEX_ARTIFACT_REPORT_MODE"] = "observed_state"
|
|
1010
1009
|
env["HARNEX_ARTIFACT_REPORT_REQUIRED"] = "1" if require_artifact_report
|
|
1011
1010
|
end
|
|
1012
1011
|
env["HARNEX_SPAWNER_PANE"] = ENV["TMUX_PANE"] if ENV["TMUX_PANE"]
|
|
@@ -1046,7 +1045,7 @@ module Harnex
|
|
|
1046
1045
|
injected_count = @mutex.synchronize do
|
|
1047
1046
|
@injected_count += 1
|
|
1048
1047
|
@last_injected_at = Time.now
|
|
1049
|
-
|
|
1048
|
+
refresh_registry
|
|
1050
1049
|
@injected_count
|
|
1051
1050
|
end
|
|
1052
1051
|
|
|
@@ -1072,6 +1071,17 @@ module Harnex
|
|
|
1072
1071
|
Harnex.write_registry(@registry_path, payload)
|
|
1073
1072
|
end
|
|
1074
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
|
+
|
|
1075
1085
|
def persist_exit_status
|
|
1076
1086
|
return unless defined?(@exit_code) && !@exit_code.nil?
|
|
1077
1087
|
|
|
@@ -1126,19 +1136,38 @@ module Harnex
|
|
|
1126
1136
|
rescue EOFError, Errno::EIO, IOError
|
|
1127
1137
|
break
|
|
1128
1138
|
end
|
|
1139
|
+
rescue StandardError => e
|
|
1140
|
+
warn("harnex: input forwarder stopped: #{e.class}: #{e.message}")
|
|
1129
1141
|
end
|
|
1130
1142
|
end
|
|
1131
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`.
|
|
1132
1150
|
def start_output_thread
|
|
1133
1151
|
Thread.new do
|
|
1152
|
+
echo = true
|
|
1134
1153
|
loop do
|
|
1135
1154
|
chunk = @reader.readpartial(4096)
|
|
1136
1155
|
record_output(chunk)
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
|
1141
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}")
|
|
1142
1171
|
end
|
|
1143
1172
|
end
|
|
1144
1173
|
|
|
@@ -1158,7 +1187,7 @@ module Harnex
|
|
|
1158
1187
|
def prune_retained_logs
|
|
1159
1188
|
Harnex::Retention.auto_prune(
|
|
1160
1189
|
repo_root: repo_root,
|
|
1161
|
-
current_paths: [output_log_path, events_log_path]
|
|
1190
|
+
current_paths: [output_log_path, events_log_path, artifact_report_path, artifact_claims_path]
|
|
1162
1191
|
)
|
|
1163
1192
|
end
|
|
1164
1193
|
|
|
@@ -1227,19 +1256,34 @@ module Harnex
|
|
|
1227
1256
|
end
|
|
1228
1257
|
|
|
1229
1258
|
def emit_git_start_event
|
|
1230
|
-
@git_start = Harnex.git_capture_start(
|
|
1259
|
+
@git_start = Harnex.git_capture_start(
|
|
1260
|
+
repo_root,
|
|
1261
|
+
exclude_paths: git_observation_excluded_paths
|
|
1262
|
+
)
|
|
1231
1263
|
return if @git_start.empty?
|
|
1232
1264
|
|
|
1233
1265
|
emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
|
|
1234
1266
|
end
|
|
1235
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
|
+
|
|
1236
1280
|
def emit_session_end_telemetry
|
|
1237
1281
|
summary = collect_session_summary
|
|
1238
1282
|
@usage_summary = normalized_usage_summary(summary)
|
|
1239
1283
|
@context_summary = normalized_context_summary(summary)
|
|
1240
1284
|
emit_event("usage", **@usage_summary)
|
|
1241
1285
|
|
|
1242
|
-
@git_end = Harnex.git_capture_end(repo_root, @git_start
|
|
1286
|
+
@git_end = Harnex.git_capture_end(repo_root, @git_start)
|
|
1243
1287
|
return if @git_end.empty?
|
|
1244
1288
|
|
|
1245
1289
|
emit_event(
|
|
@@ -1250,14 +1294,15 @@ module Harnex
|
|
|
1250
1294
|
loc_removed: @git_end[:loc_removed],
|
|
1251
1295
|
files_changed: @git_end[:files_changed],
|
|
1252
1296
|
changed_paths: @git_end[:changed_paths],
|
|
1253
|
-
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]
|
|
1254
1301
|
)
|
|
1255
1302
|
end
|
|
1256
1303
|
|
|
1257
1304
|
def emit_summary_event
|
|
1258
|
-
|
|
1259
|
-
payload[:mirror_path] = summary_out if summary_out
|
|
1260
|
-
emit_event("summary", **payload)
|
|
1305
|
+
emit_event("summary", path: dispatch_history_path, exit: @exit_reason)
|
|
1261
1306
|
end
|
|
1262
1307
|
|
|
1263
1308
|
def emit_attempt_finished(attempt)
|
|
@@ -1293,10 +1338,10 @@ module Harnex
|
|
|
1293
1338
|
@context_summary = normalized_context_summary(nil)
|
|
1294
1339
|
warn("harnex: failed to collect session-end telemetry: #{e.message}")
|
|
1295
1340
|
end
|
|
1341
|
+
enforce_required_artifact_report!
|
|
1296
1342
|
@exit_reason ||= classify_exit
|
|
1297
1343
|
record = DispatchHistory.build_record(self)
|
|
1298
1344
|
append_dispatch_history_record(record)
|
|
1299
|
-
append_summary_record(record)
|
|
1300
1345
|
emit_summary_event
|
|
1301
1346
|
emit_attempt_finished(record.fetch(:attempt))
|
|
1302
1347
|
emit_exit_event
|
|
@@ -1391,48 +1436,174 @@ module Harnex
|
|
|
1391
1436
|
AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
|
|
1392
1437
|
end
|
|
1393
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.
|
|
1394
1442
|
def enforce_required_artifact_report!
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
report_accepted = Harnex::ArtifactReport.accepted_final?(result)
|
|
1401
|
-
report_stale = report_accepted && !report_fresh
|
|
1402
|
-
if report_fresh && report_accepted
|
|
1403
|
-
@completion_outcome_class = "completed_with_proof"
|
|
1404
|
-
@completion_report_status = "accepted"
|
|
1405
|
-
@completion_diagnostics = []
|
|
1406
|
-
return
|
|
1407
|
-
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?
|
|
1408
1448
|
|
|
1409
|
-
|
|
1410
|
-
outcome_class = if report_fresh && report_outcome == "rejected"
|
|
1411
|
-
"report_rejected"
|
|
1412
|
-
elsif result.status == "missing"
|
|
1413
|
-
"report_missing"
|
|
1414
|
-
else
|
|
1415
|
-
"report_invalid"
|
|
1416
|
-
end
|
|
1417
|
-
report_status = if report_stale
|
|
1418
|
-
"stale"
|
|
1419
|
-
elsif report_fresh && report_outcome == "rejected"
|
|
1420
|
-
"rejected"
|
|
1421
|
-
else
|
|
1422
|
-
result.status
|
|
1423
|
-
end
|
|
1424
|
-
diagnostics = report_stale ? [artifact_report_stale_diagnostic] : result.diagnostics
|
|
1449
|
+
@last_completed_at = nil
|
|
1425
1450
|
mark_task_failed(
|
|
1426
|
-
status:
|
|
1427
|
-
error:
|
|
1428
|
-
outcome_class:
|
|
1429
|
-
artifact_report_status:
|
|
1430
|
-
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
|
+
}]
|
|
1431
1460
|
)
|
|
1432
1461
|
@exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
|
|
1433
1462
|
@term_signal = nil if @exit_code == 1
|
|
1434
1463
|
end
|
|
1435
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
|
+
|
|
1436
1607
|
def normalize_work_acceptance_exit_code!
|
|
1437
1608
|
return unless task_failed? && @completion_outcome_class
|
|
1438
1609
|
|
|
@@ -1482,7 +1653,7 @@ module Harnex
|
|
|
1482
1653
|
end
|
|
1483
1654
|
|
|
1484
1655
|
def build_summary_record
|
|
1485
|
-
artifact_payload =
|
|
1656
|
+
artifact_payload = @artifact_report_generated ? artifact_report_summary : nil
|
|
1486
1657
|
attribution = build_summary_attribution
|
|
1487
1658
|
outcome = build_summary_outcome(artifact_payload)
|
|
1488
1659
|
record = {
|
|
@@ -1691,16 +1862,20 @@ module Harnex
|
|
|
1691
1862
|
end
|
|
1692
1863
|
|
|
1693
1864
|
def build_summary_outcome(artifact_payload)
|
|
1694
|
-
|
|
1865
|
+
receipt_outcome = artifact_payload&.dig("outcome") || {}
|
|
1695
1866
|
outcome_class, report_status = summary_proof_classification
|
|
1696
|
-
proof_failure = %w[completed_no_activity report_missing report_invalid task_failed].include?(outcome_class)
|
|
1697
|
-
|
|
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
|
|
1698
1871
|
status = "no_change" if status.nil? && !proof_failure && @git_end.key?(:changed_paths) && @git_end[:changed_paths].empty?
|
|
1699
1872
|
status ||= "unknown"
|
|
1700
1873
|
status = "unknown" unless Harnex::ArtifactReport::OUTCOME_STATUSES.include?(status)
|
|
1701
|
-
source = if
|
|
1874
|
+
source = if harness_receipt
|
|
1875
|
+
"harnex_observed_state"
|
|
1876
|
+
elsif proof_failure
|
|
1702
1877
|
"harnex_completion_gate"
|
|
1703
|
-
elsif
|
|
1878
|
+
elsif receipt_outcome["status"]
|
|
1704
1879
|
"artifact_report"
|
|
1705
1880
|
else
|
|
1706
1881
|
"harnex_git_observation"
|
|
@@ -1711,7 +1886,7 @@ module Harnex
|
|
|
1711
1886
|
"class" => outcome_class,
|
|
1712
1887
|
"source" => source,
|
|
1713
1888
|
"report_status" => report_status,
|
|
1714
|
-
"commit_sha" =>
|
|
1889
|
+
"commit_sha" => receipt_outcome["commit_sha"] || summary_commit_sha,
|
|
1715
1890
|
"changed_paths" => @git_end.key?(:changed_paths) ? @git_end[:changed_paths] : nil,
|
|
1716
1891
|
"loc_added" => @git_end[:loc_added],
|
|
1717
1892
|
"loc_removed" => @git_end[:loc_removed],
|
|
@@ -1725,13 +1900,10 @@ module Harnex
|
|
|
1725
1900
|
return [@completion_outcome_class, @completion_report_status] if @completion_outcome_class
|
|
1726
1901
|
return ["task_failed", @completion_report_status] if task_failed?
|
|
1727
1902
|
|
|
1728
|
-
if
|
|
1903
|
+
if @artifact_report_generated
|
|
1729
1904
|
result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
return ["completed_with_proof", "accepted"] if report_fresh && report_accepted
|
|
1733
|
-
return ["report_rejected", "rejected"] if report_fresh && Harnex::ArtifactReport.outcome_status(result) == "rejected"
|
|
1734
|
-
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"
|
|
1735
1907
|
return ["unknown", result.status]
|
|
1736
1908
|
end
|
|
1737
1909
|
|
|
@@ -2109,20 +2281,6 @@ module Harnex
|
|
|
2109
2281
|
meta.is_a?(Hash) ? meta : {}
|
|
2110
2282
|
end
|
|
2111
2283
|
|
|
2112
|
-
# Explicit-only mirror: the identical end record lands here in addition
|
|
2113
|
-
# to the tracked stream when --summary-out is configured.
|
|
2114
|
-
def append_summary_record(record)
|
|
2115
|
-
return unless summary_out
|
|
2116
|
-
|
|
2117
|
-
FileUtils.mkdir_p(File.dirname(summary_out))
|
|
2118
|
-
File.open(summary_out, "ab") do |file|
|
|
2119
|
-
file.write(JSON.generate(record))
|
|
2120
|
-
file.write("\n")
|
|
2121
|
-
end
|
|
2122
|
-
rescue StandardError => e
|
|
2123
|
-
warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
|
|
2124
|
-
end
|
|
2125
|
-
|
|
2126
2284
|
def append_dispatch_start_record
|
|
2127
2285
|
DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
|
|
2128
2286
|
rescue StandardError => e
|
|
@@ -2135,10 +2293,9 @@ module Harnex
|
|
|
2135
2293
|
warn("harnex: failed to write dispatch history: #{e.message}")
|
|
2136
2294
|
end
|
|
2137
2295
|
|
|
2138
|
-
#
|
|
2139
|
-
#
|
|
2140
|
-
#
|
|
2141
|
-
# 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.
|
|
2142
2299
|
def dispatch_history_path
|
|
2143
2300
|
@dispatch_history_path ||= DispatchHistory.path_for(repo_root)
|
|
2144
2301
|
end
|