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.
@@ -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, :summary_out, :artifact_report_path, :output_log_path, :events_log_path,
90
- :started_at, :ended_at, :exit_code, :term_signal, :require_artifact_report
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, 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)
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
- @summary_out = summary_out.to_s.strip
105
- @summary_out = nil if @summary_out.empty?
106
- @artifact_report_path = artifact_report_path.to_s.strip
107
- @artifact_report_path = nil if @artifact_report_path.empty?
108
- @artifact_report_path = File.expand_path(@artifact_report_path, repo_root) if @artifact_report_path
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
- 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
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
- persist_registry
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
- report_result = artifact_report_path ? Harnex::ArtifactReport.validate(artifact_report_path, final: true) : nil
659
- report_fresh = report_result && report_result.status != "missing" && artifact_report_fresh?
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: 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: report_status,
718
- diagnostics: report_diagnostics
696
+ report_status: "rejected",
697
+ diagnostics: []
719
698
  }
720
699
  end
721
700
  end
722
701
 
723
- def artifact_report_fresh?
724
- current = Harnex::ArtifactReport.fingerprint(artifact_report_path)
725
- return false unless current
726
- return true unless @artifact_report_start_fingerprint
727
-
728
- current != @artifact_report_start_fingerprint
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 = Harnex.git_capture_end(repo_root, @git_start[:sha])
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, Git delta, or accepted artifact report"
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
- persist_registry
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
- STDOUT.write(chunk)
1137
- STDOUT.flush
1138
- rescue EOFError, Errno::EIO, IOError
1139
- break
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(repo_root)
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[:sha])
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: summary_out, exit: @exit_reason)
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 = build_summary_record
1288
- append_summary_record(record)
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
- return unless require_artifact_report
1386
- return if task_failed? && %w[report_missing report_invalid report_rejected].include?(@completion_outcome_class)
1387
-
1388
- result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1389
- report_fresh = result.status != "missing" && artifact_report_fresh?
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
- report_outcome = Harnex::ArtifactReport.outcome_status(result)
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: outcome_class,
1417
- error: completion_failure_message(outcome_class),
1418
- outcome_class: outcome_class,
1419
- artifact_report_status: report_status,
1420
- diagnostics: 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 = artifact_report_path ? artifact_report_summary : nil
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
- sidecar_outcome = artifact_payload&.dig("outcome") || {}
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
- status = proof_failure ? nil : sidecar_outcome["status"]
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 proof_failure
1874
+ source = if harness_receipt
1875
+ "harnex_observed_state"
1876
+ elsif proof_failure
1672
1877
  "harnex_completion_gate"
1673
- elsif sidecar_outcome["status"]
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" => sidecar_outcome["commit_sha"] || summary_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 artifact_report_path
1903
+ if @artifact_report_generated
1699
1904
  result = Harnex::ArtifactReport.validate(artifact_report_path, final: true)
1700
- report_fresh = result.status != "missing" && artifact_report_fresh?
1701
- report_accepted = Harnex::ArtifactReport.accepted_final?(result)
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" => false
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
- attempt_succeeded = summary_attempt_succeeded?
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: 1,
1843
- attempts_succeeded: attempt_succeeded ? 1 : 0,
1844
- attempts_failed: attempt_succeeded ? 0 : 1,
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: false
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, DispatchHistory.build_record(self))
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
- # One canonical stream per session: the start row and the end row must
2011
- # land in the same file so readers can pair them. repo_root — not the
2012
- # launch cwd — is the root that registry, events, and default summary
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