harnex 0.10.2 → 0.12.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.
@@ -8,6 +8,7 @@ module Harnex
8
8
  class Status
9
9
  DESCRIPTION_WIDTH = 30
10
10
  REPO_WIDTH = 20
11
+ REJECTED_OUTCOME_CLASSES = Session::PROOF_REJECTION_CLASSES
11
12
 
12
13
  def self.usage(program_name = "harnex status")
13
14
  <<~TEXT
@@ -30,8 +31,8 @@ module Harnex
30
31
  Use --all when supervising workers launched from sibling worktrees.
31
32
  With --id, terminal summaries can report completed/failed/unknown
32
33
  even after the live session registry is gone.
33
- `state` is process/session state; use JSON `done`/`work_state`
34
- or `harnex wait --until done` for work-level completion.
34
+ Settled work outranks prompt state in the table: done, rejected, or failed.
35
+ JSON `done`/`work_state` retains the structured work-level fields.
35
36
  A prompt-like state is not a completion signal by itself.
36
37
  TEXT
37
38
  end
@@ -217,6 +218,13 @@ module Harnex
217
218
  end
218
219
 
219
220
  def table_state(session)
221
+ failed = task_failed?(session) || session["work_state"].to_s == "failed"
222
+ completed = task_complete?(session) || session["work_state"].to_s == "completed"
223
+ if failed || completed
224
+ return "rejected" if rejected_work?(session)
225
+ return failed ? "failed" : "done"
226
+ end
227
+
220
228
  input_state = session.dig("input_state", "state").to_s
221
229
  return input_state unless input_state.empty?
222
230
 
@@ -224,6 +232,12 @@ module Harnex
224
232
  state.empty? ? "-" : state
225
233
  end
226
234
 
235
+ def rejected_work?(session)
236
+ return true if REJECTED_OUTCOME_CLASSES.include?(session["outcome_class"].to_s)
237
+
238
+ !task_failed?(session) && session["artifact_report_status"].to_s == "rejected"
239
+ end
240
+
227
241
  def timeago(timestamp)
228
242
  return "-" if timestamp.to_s.empty?
229
243
 
data/lib/harnex/core.rb CHANGED
@@ -24,6 +24,8 @@ module Harnex
24
24
  GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
25
25
  STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
26
26
  SESSIONS_DIR = File.join(STATE_DIR, "sessions")
27
+ COMPLETION_OUTCOMES = %w[completed rejected failed error].freeze
28
+ COMPLETION_MARKERS_DIR = File.join(STATE_DIR, "done")
27
29
  WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
28
30
  ID_ADJECTIVES = %w[
29
31
  bold blue calm cool dark dry fast gold gray green
@@ -395,6 +397,19 @@ module Harnex
395
397
  File.join(exit_dir, "#{session_file_slug(repo_root, id)}.json")
396
398
  end
397
399
 
400
+ def completion_marker_path(repo_root, id, outcome)
401
+ normalized_outcome = outcome.to_s
402
+ unless COMPLETION_OUTCOMES.include?(normalized_outcome)
403
+ raise ArgumentError, "unsupported completion outcome #{outcome.inspect}"
404
+ end
405
+
406
+ File.join(COMPLETION_MARKERS_DIR, "#{session_file_slug(repo_root, id)}.#{normalized_outcome}")
407
+ end
408
+
409
+ def completion_marker_paths(repo_root, id)
410
+ COMPLETION_OUTCOMES.map { |outcome| completion_marker_path(repo_root, id, outcome) }
411
+ end
412
+
398
413
  def output_log_path(repo_root, id)
399
414
  output_dir = File.join(STATE_DIR, "output")
400
415
  FileUtils.mkdir_p(output_dir)
@@ -0,0 +1,137 @@
1
+ require "time"
2
+
3
+ module Harnex
4
+ class CompletionNotifier
5
+ WORK_STATES = {
6
+ "completed" => "completed", "rejected" => "failed",
7
+ "failed" => "failed", "error" => "failed"
8
+ }.freeze
9
+
10
+ def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil,
11
+ started_at: Time.now, clock: nil, event_sink: nil, spawn: nil,
12
+ detach: nil, atomic_write: nil, remove: nil, stderr: $stderr)
13
+ @repo_root = Harnex.canonical_repo_root(repo_root)
14
+ @id = Harnex.normalize_id(id)
15
+ @session_id = session_id.to_s
16
+ @receipt_path = receipt_path.to_s
17
+ @hook_command = hook_command.to_s
18
+ @hook_command = nil if @hook_command.empty?
19
+ @started_at = started_at
20
+ @clock = clock || -> { Time.now }
21
+ @event_sink = event_sink
22
+ @spawn = spawn || method(:spawn_hook)
23
+ @detach = detach || ->(pid) { Process.detach(pid) }
24
+ @atomic_write = atomic_write || Harnex.method(:atomic_write_json)
25
+ @remove = remove || ->(path) { FileUtils.rm_f(path) }
26
+ @stderr = stderr
27
+ @mutex = Mutex.new
28
+ @registered = false
29
+ @notified = false
30
+ end
31
+
32
+ # Registration is the stale-marker boundary for a reused repo/id pair.
33
+ def register!
34
+ @mutex.synchronize do
35
+ return true if @registered
36
+
37
+ Harnex.completion_marker_paths(@repo_root, @id).each do |path|
38
+ @remove.call(path)
39
+ rescue StandardError => e
40
+ report_error("completion marker cleanup failed", "cleanup", e, path: path)
41
+ end
42
+ @registered = true
43
+ end
44
+ true
45
+ end
46
+
47
+ def notify(outcome:, work_state:, outcome_class:, artifact_report_status:,
48
+ end_sha:, terminal_signal:)
49
+ outcome = outcome.to_s
50
+ work_state = work_state.to_s
51
+ expected = WORK_STATES[outcome]
52
+ raise ArgumentError, "unsupported completion outcome #{outcome.inspect}" unless expected
53
+ raise ArgumentError, "#{outcome.inspect} completion requires work_state #{expected.inspect}" unless work_state == expected
54
+ return false unless @mutex.synchronize do
55
+ @registered && !@notified ? (@notified = true) : false
56
+ end
57
+
58
+ now = @clock.call
59
+ path = Harnex.completion_marker_path(@repo_root, @id, outcome)
60
+ payload = {
61
+ schema_version: 1, id: @id, session_id: @session_id,
62
+ repo_root: @repo_root, outcome: outcome, work_state: work_state,
63
+ outcome_class: optional_string(outcome_class),
64
+ artifact_report_status: optional_string(artifact_report_status),
65
+ receipt_path: @receipt_path, end_sha: optional_string(end_sha),
66
+ elapsed_s: elapsed_seconds(now), terminal_signal: terminal_signal.to_s,
67
+ notified_at: now.utc.iso8601
68
+ }
69
+ marker_written = write_marker(path, payload)
70
+ hook_launched = launch_hook(payload)
71
+ emit("completion_notification", outcome: outcome, work_state: work_state,
72
+ marker_path: path, marker_written: marker_written,
73
+ hook_configured: !@hook_command.nil?, hook_launched: hook_launched)
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def optional_string(value)
80
+ text = value.to_s
81
+ text.empty? ? nil : text
82
+ end
83
+
84
+ def elapsed_seconds(now)
85
+ [(now - @started_at).to_i, 0].max
86
+ rescue StandardError
87
+ 0
88
+ end
89
+
90
+ def write_marker(path, payload)
91
+ @atomic_write.call(path, payload)
92
+ true
93
+ rescue StandardError => e
94
+ report_error("completion marker write failed", "marker", e, path: path)
95
+ false
96
+ end
97
+
98
+ def launch_hook(payload)
99
+ return false unless @hook_command
100
+
101
+ env = {
102
+ "HARNEX_ID" => @id,
103
+ "HARNEX_OUTCOME" => payload.fetch(:outcome),
104
+ "HARNEX_WORK_STATE" => payload.fetch(:work_state),
105
+ "HARNEX_RECEIPT_PATH" => @receipt_path,
106
+ "HARNEX_END_SHA" => payload[:end_sha].to_s,
107
+ "HARNEX_ELAPSED_S" => payload.fetch(:elapsed_s).to_s
108
+ }
109
+ pid = @spawn.call(env, @hook_command, {
110
+ chdir: @repo_root, in: File::NULL, out: @stderr, err: @stderr
111
+ })
112
+ @detach.call(pid)
113
+ true
114
+ rescue StandardError => e
115
+ report_error("completion hook launch failed", "hook", e)
116
+ false
117
+ end
118
+
119
+ def spawn_hook(env, command, options)
120
+ Process.spawn(env, "/bin/sh", "-c", command, **options)
121
+ end
122
+
123
+ # Do not include exception messages: injected spawn failures can echo CMD.
124
+ def report_error(message, component, error, path: nil)
125
+ @stderr.puts("harnex: #{message} (#{error.class})") rescue nil
126
+ payload = { component: component, error_class: error.class.name }
127
+ payload[:path] = path if path
128
+ emit("completion_notification_error", **payload)
129
+ end
130
+
131
+ def emit(type, **payload)
132
+ @event_sink&.call(type, payload)
133
+ rescue StandardError => e
134
+ @stderr.puts("harnex: completion diagnostic event failed (#{e.class})") rescue nil
135
+ end
136
+ end
137
+ end
@@ -1,5 +1,6 @@
1
1
  require "io/console"
2
2
  require "json"
3
+ require "monitor"
3
4
  require "pty"
4
5
  require "shellwords"
5
6
 
@@ -39,6 +40,9 @@ module Harnex
39
40
  adapter_close real_disconnections stream_interruptions stalls force_resumes compactions recovered
40
41
  ].freeze
41
42
  SUCCESSFUL_TURN_STATUSES = %w[completed success succeeded].freeze
43
+ PROOF_REJECTION_CLASSES = %w[
44
+ completed_no_activity report_missing report_invalid report_rejected
45
+ ].freeze
42
46
  class EventCounters
43
47
  def initialize
44
48
  @counts = {
@@ -91,7 +95,7 @@ module Harnex
91
95
  :output_log_path, :events_log_path, :started_at, :ended_at, :exit_code, :term_signal,
92
96
  :require_artifact_report
93
97
 
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)
98
+ 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, on_done: nil, launch_cwd: nil, child_cwd: nil, completion_notifier: nil)
95
99
  @adapter = adapter
96
100
  @command = command
97
101
  @repo_root = repo_root
@@ -132,6 +136,20 @@ module Harnex
132
136
  @injected_count = 0
133
137
  @last_injected_at = nil
134
138
  @started_at = Time.now
139
+ @completion_transition_lock = Monitor.new
140
+ @completion_notification_mutex = Mutex.new
141
+ @completion_notification_registered = false
142
+ @completion_notification_sent = false
143
+ @pending_completion_notification = nil
144
+ @completion_notifier = completion_notifier || CompletionNotifier.new(
145
+ repo_root: repo_root,
146
+ id: @id,
147
+ session_id: @session_id,
148
+ receipt_path: @artifact_report_path,
149
+ hook_command: on_done,
150
+ started_at: @started_at,
151
+ event_sink: ->(type, payload) { emit_event(type, **payload) }
152
+ )
135
153
  @server = nil
136
154
  @reader = nil
137
155
  @output_log = nil
@@ -161,6 +179,10 @@ module Harnex
161
179
  @completion_report_status = nil
162
180
  @completion_diagnostics = []
163
181
  @pi_streamed_text_by_message = {}
182
+ @pi_active_message_key = nil
183
+ @pi_last_assistant_stop_reason = nil
184
+ @pi_last_assistant_error = nil
185
+ @pi_last_retry_error = nil
164
186
  @auto_stop = !!auto_stop
165
187
  @auto_stop_fired = false
166
188
  @auto_stop_seen_busy = false
@@ -197,6 +219,7 @@ module Harnex
197
219
  end
198
220
 
199
221
  def run(validate_binary: true)
222
+ configure_adapter_startup!
200
223
  validate_binary! if validate_binary
201
224
  prune_retained_logs
202
225
  prepare_output_log
@@ -221,6 +244,7 @@ module Harnex
221
244
  @server = ApiServer.new(self)
222
245
  @server.start
223
246
  persist_registry
247
+ register_completion_notifier!
224
248
  append_dispatch_start_record
225
249
 
226
250
  stdin_state = STDIN.tty? ? STDIN.raw! : nil
@@ -471,10 +495,18 @@ module Harnex
471
495
 
472
496
  def validate_binary!
473
497
  self.class.validate_binary!(command)
498
+ adapter.validate_runtime! if adapter.respond_to?(:validate_runtime!)
474
499
  end
475
500
 
476
501
  private
477
502
 
503
+ def configure_adapter_startup!
504
+ return unless adapter.respond_to?(:configure_startup)
505
+
506
+ adapter.configure_startup(model: meta_hash["model"], effort: meta_hash["effort"])
507
+ @command = adapter.build_command
508
+ end
509
+
478
510
  def structured_transport?
479
511
  %i[stdio_jsonrpc stdio_jsonl_rpc].include?(adapter.transport)
480
512
  end
@@ -493,6 +525,7 @@ module Harnex
493
525
  @server = ApiServer.new(self)
494
526
  @server.start
495
527
  persist_registry
528
+ register_completion_notifier!
496
529
  append_dispatch_start_record
497
530
 
498
531
  watch_thread = start_watch_thread
@@ -501,9 +534,18 @@ module Harnex
501
534
 
502
535
  if @pid
503
536
  begin
504
- _, status = Process.wait2(@pid)
505
- @term_signal = status.signaled? ? status.termsig : nil
506
- @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
537
+ status = if adapter.respond_to?(:wait_for_exit)
538
+ adapter.wait_for_exit
539
+ else
540
+ _waited_pid, process_status = Process.wait2(@pid)
541
+ process_status
542
+ end
543
+ if status
544
+ @term_signal = status.signaled? ? status.termsig : nil
545
+ @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
546
+ else
547
+ @exit_code = 0
548
+ end
507
549
  rescue Errno::ECHILD
508
550
  @exit_code = 0
509
551
  end
@@ -636,6 +678,10 @@ module Harnex
636
678
  end
637
679
 
638
680
  def record_successful_completion(payload)
681
+ @completion_transition_lock.synchronize { record_successful_completion_locked(payload) }
682
+ end
683
+
684
+ def record_successful_completion_locked(payload)
639
685
  assessment = completion_gate_required? ? assess_completion_proof : { accepted: true }
640
686
  unless assessment[:accepted]
641
687
  mark_task_failed(
@@ -646,7 +692,6 @@ module Harnex
646
692
  artifact_report_status: assessment[:report_status],
647
693
  diagnostics: assessment[:diagnostics]
648
694
  )
649
- enforce_required_artifact_report!
650
695
  return false
651
696
  end
652
697
 
@@ -663,13 +708,17 @@ module Harnex
663
708
  # Persist proof before publishing task_complete. A coordinator that sees
664
709
  # the completion event can immediately validate the harness-authored file.
665
710
  enforce_required_artifact_report!
666
- return false if task_failed?
711
+ if task_failed?
712
+ publish_completion_notification("task_failed")
713
+ return false
714
+ end
667
715
 
668
716
  event_payload = payload.dup
669
717
  event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
670
718
  event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
671
719
  event_payload[:artifact_report_path] = artifact_report_path
672
720
  emit_event("task_complete", **event_payload)
721
+ publish_completion_notification("task_complete")
673
722
  true
674
723
  end
675
724
 
@@ -735,7 +784,11 @@ module Harnex
735
784
  end
736
785
  end
737
786
 
738
- def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil, outcome_class: nil, artifact_report_status: nil, diagnostics: nil)
787
+ def mark_task_failed(**attributes)
788
+ @completion_transition_lock.synchronize { mark_task_failed_locked(**attributes) }
789
+ end
790
+
791
+ def mark_task_failed_locked(turn_id: nil, status: nil, error: nil, codex_error_info: nil, outcome_class: nil, artifact_report_status: nil, diagnostics: nil, publish: true)
739
792
  @last_completed_at = nil if outcome_class
740
793
  @last_failed_at = Time.now
741
794
  @last_failed_status = status.to_s.empty? ? "failed" : status.to_s
@@ -752,6 +805,11 @@ module Harnex
752
805
  payload[:artifact_report_status] = artifact_report_status if artifact_report_status
753
806
  payload[:diagnostics] = @completion_diagnostics unless @completion_diagnostics.empty?
754
807
  emit_event("task_failed", **payload)
808
+ if publish
809
+ enforce_required_artifact_report!
810
+ terminal_signal = @last_failed_status == "dispatch_error" ? "dispatch_error" : "task_failed"
811
+ publish_completion_notification(terminal_signal)
812
+ end
755
813
  end
756
814
 
757
815
  def extract_error_notification_message(params)
@@ -792,29 +850,43 @@ module Harnex
792
850
  event_type = message["type"].to_s
793
851
 
794
852
  case event_type
795
- when "agent_start", "turn_start"
796
- @turn_started_seen = true if event_type == "turn_start"
853
+ when "agent_start"
854
+ reset_pi_run_outcome!
855
+ @state_machine.force_busy!
856
+ when "turn_start"
857
+ @turn_started_seen = true
797
858
  @state_machine.force_busy!
798
- emit_event("turn_started") if event_type == "turn_start"
859
+ emit_event("turn_started")
799
860
  when "agent_end"
861
+ capture_pi_agent_end(message)
862
+ @state_machine.force_busy!
863
+ emit_event("agent_end", will_retry: message["willRetry"] == true)
864
+ adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
865
+ when "agent_settled"
800
866
  @state_machine.force_prompt!
801
- record_successful_completion({})
867
+ emit_event("agent_settled", stop_reason: @pi_last_assistant_stop_reason)
868
+ settle_pi_task!
802
869
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
803
- schedule_auto_stop("task_complete", interrupt: false)
870
+ schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
804
871
  when "message_start"
805
- @pi_streamed_text_by_message[pi_message_key(message["message"])] = false
872
+ @pi_active_message_key = pi_message_key(message["message"])
873
+ @pi_streamed_text_by_message[@pi_active_message_key] = false
806
874
  when "message_update"
807
875
  event = message["assistantMessageEvent"] || {}
808
876
  delta = event["delta"]
809
- key = pi_message_key(message["message"])
877
+ key = @pi_active_message_key || pi_message_key(message["message"])
810
878
  if event["type"] == "text_delta" && delta && !delta.empty?
811
879
  @pi_streamed_text_by_message[key] = true
812
880
  record_synthesized(delta, newline: false)
813
881
  end
814
882
  when "message_end"
815
- key = pi_message_key(message["message"])
883
+ key = @pi_active_message_key || pi_message_key(message["message"])
816
884
  streamed = @pi_streamed_text_by_message.delete(key)
817
- unless streamed
885
+ @pi_active_message_key = nil
886
+ capture_pi_assistant_message(message["message"])
887
+ if streamed
888
+ record_synthesized("\n", newline: false)
889
+ else
818
890
  text = pi_extract_message_text(message["message"])
819
891
  record_synthesized(text) if text
820
892
  end
@@ -828,13 +900,25 @@ module Harnex
828
900
  status = message["isError"] ? "error" : "ok"
829
901
  record_synthesized("tool-result: #{tool_name} (#{status})")
830
902
  when "compaction_start", "compaction_end"
831
- emit_event("compaction", reason: message["reason"], phase: event_type)
903
+ emit_event(
904
+ "compaction",
905
+ reason: message["reason"],
906
+ phase: event_type,
907
+ will_retry: message["willRetry"],
908
+ aborted: message["aborted"],
909
+ error_message: message["errorMessage"]
910
+ )
832
911
  when "queue_update"
833
912
  nil
834
913
  when "auto_retry_start", "auto_retry_end"
835
914
  payload = message.reject { |k, _| k == "type" }
915
+ @pi_last_retry_error = message["finalError"].to_s if event_type == "auto_retry_end" && message["success"] == false
836
916
  emit_event(event_type, **payload)
837
917
  record_attempt_transition(type: "attempt_retry_scheduled", trigger: "adapter_auto_retry") if event_type == "auto_retry_start"
918
+ when "summarization_retry_scheduled", "summarization_retry_attempt_start", "summarization_retry_finished"
919
+ emit_event(event_type, **message.reject { |k, _| k == "type" })
920
+ when "bash_execution_update"
921
+ emit_event(event_type, id: message["id"], bytes: message["delta"].to_s.bytesize)
838
922
  when "extension_ui_request"
839
923
  handle_extension_ui_request(message)
840
924
  when "extension_error"
@@ -876,9 +960,19 @@ module Harnex
876
960
  return
877
961
  end
878
962
 
963
+ if msg.to_s.empty? && adapter.respond_to?(:disconnect_diagnostic)
964
+ msg = adapter.disconnect_diagnostic
965
+ end
879
966
  @last_error = msg.to_s unless msg.to_s.empty?
880
967
  @state_machine.force_busy!
881
968
  emit_event("disconnected", source: "transport", message: msg) rescue nil
969
+ if adapter.respond_to?(:terminate_subprocess)
970
+ Thread.new do
971
+ adapter.terminate_subprocess
972
+ rescue StandardError
973
+ nil
974
+ end
975
+ end
882
976
  signal_rpc_done!
883
977
  end
884
978
 
@@ -946,6 +1040,52 @@ module Harnex
946
1040
  ""
947
1041
  end
948
1042
 
1043
+ def reset_pi_run_outcome!
1044
+ @pi_last_assistant_stop_reason = nil
1045
+ @pi_last_assistant_error = nil
1046
+ @pi_last_retry_error = nil
1047
+ end
1048
+
1049
+ def capture_pi_agent_end(message)
1050
+ assistant = Array(message["messages"]).reverse.find do |candidate|
1051
+ candidate.is_a?(Hash) && candidate["role"] == "assistant"
1052
+ end
1053
+ capture_pi_assistant_message(assistant) if assistant
1054
+ end
1055
+
1056
+ def capture_pi_assistant_message(message)
1057
+ return unless message.is_a?(Hash) && message["role"] == "assistant"
1058
+
1059
+ reason = message["stopReason"].to_s
1060
+ @pi_last_assistant_stop_reason = reason unless reason.empty?
1061
+ error = message["errorMessage"].to_s
1062
+ @pi_last_assistant_error = error unless error.empty?
1063
+ end
1064
+
1065
+ def settle_pi_task!
1066
+ return if @stop_requested && !task_complete? && !task_failed?
1067
+
1068
+ reason = @pi_last_assistant_stop_reason
1069
+ case reason
1070
+ when "stop"
1071
+ record_successful_completion({})
1072
+ when "error", "aborted", "length"
1073
+ error = @pi_last_assistant_error || @pi_last_retry_error
1074
+ error = "Pi agent settled with stopReason=#{reason}" if error.to_s.empty?
1075
+ mark_task_failed(status: reason, error: error)
1076
+ when nil, ""
1077
+ mark_task_failed(
1078
+ status: "missing_final_message",
1079
+ error: "Pi agent settled without an authoritative final assistant stop reason"
1080
+ )
1081
+ else
1082
+ mark_task_failed(
1083
+ status: "unsupported_stop_reason",
1084
+ error: "Pi agent settled with unsupported stopReason=#{reason}"
1085
+ )
1086
+ end
1087
+ end
1088
+
949
1089
  def pi_extract_message_text(message)
950
1090
  return nil unless message.is_a?(Hash)
951
1091
 
@@ -1071,6 +1211,71 @@ module Harnex
1071
1211
  Harnex.write_registry(@registry_path, payload)
1072
1212
  end
1073
1213
 
1214
+ def register_completion_notifier!
1215
+ @completion_notification_mutex.synchronize do
1216
+ @completion_notifier.register!
1217
+ @completion_notification_registered = true
1218
+ deliver_completion_notification(@pending_completion_notification) if @pending_completion_notification
1219
+ end
1220
+ rescue StandardError => e
1221
+ warn("harnex: completion marker cleanup failed (#{e.class})")
1222
+ emit_event("completion_notification_error", component: "cleanup", error_class: e.class.name)
1223
+ end
1224
+
1225
+ def publish_completion_notification(terminal_signal)
1226
+ snapshot = completion_notification_snapshot(terminal_signal)
1227
+ @completion_notification_mutex.synchronize do
1228
+ return false if @completion_notification_sent || @pending_completion_notification
1229
+ return !(@pending_completion_notification = snapshot).nil? unless @completion_notification_registered
1230
+
1231
+ deliver_completion_notification(snapshot)
1232
+ end
1233
+ rescue StandardError => e
1234
+ warn("harnex: completion notification failed (#{e.class})")
1235
+ emit_event("completion_notification_error", component: "session", error_class: e.class.name)
1236
+ false
1237
+ end
1238
+
1239
+ def deliver_completion_notification(snapshot)
1240
+ @completion_notification_sent = true
1241
+ @pending_completion_notification = nil
1242
+ @completion_notifier.notify(**snapshot)
1243
+ end
1244
+
1245
+ def completion_notification_snapshot(terminal_signal)
1246
+ outcome = completion_notification_outcome(terminal_signal)
1247
+ {
1248
+ outcome: outcome, work_state: outcome == "completed" ? "completed" : "failed",
1249
+ outcome_class: @completion_outcome_class, artifact_report_status: @completion_report_status,
1250
+ end_sha: summary_string(@git_end[:sha]), terminal_signal: terminal_signal
1251
+ }
1252
+ end
1253
+
1254
+ def completion_notification_outcome(terminal_signal)
1255
+ signal = terminal_signal.to_s
1256
+ return "error" if %w[dispatch_error finalization].include?(signal)
1257
+ return "rejected" if PROOF_REJECTION_CLASSES.include?(@completion_outcome_class.to_s)
1258
+ return "rejected" if signal == "task_complete" && observed_receipt_outcome_status == "rejected"
1259
+ return "completed" if signal == "task_complete"
1260
+ return "failed" if signal == "task_failed"
1261
+
1262
+ "error"
1263
+ end
1264
+
1265
+ def observed_receipt_outcome_status
1266
+ JSON.parse(File.read(artifact_report_path)).dig("outcome", "status")
1267
+ rescue StandardError
1268
+ nil
1269
+ end
1270
+
1271
+ def current_terminal_signal
1272
+ return "dispatch_error" if task_failed? && @last_failed_status == "dispatch_error"
1273
+ return "task_failed" if task_failed?
1274
+ return "task_complete" if task_complete?
1275
+
1276
+ nil
1277
+ end
1278
+
1074
1279
  # Post-injection registry refresh. By the time this runs the prompt has
1075
1280
  # already reached the agent, so a failed bookkeeping write must not report
1076
1281
  # the send as failed — that would make an orchestrator retry a turn the
@@ -1338,7 +1543,11 @@ module Harnex
1338
1543
  @context_summary = normalized_context_summary(nil)
1339
1544
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1340
1545
  end
1341
- enforce_required_artifact_report!
1546
+ @completion_transition_lock.synchronize do
1547
+ terminal_signal = current_terminal_signal
1548
+ enforce_required_artifact_report!
1549
+ publish_completion_notification(terminal_signal || "finalization")
1550
+ end
1342
1551
  @exit_reason ||= classify_exit
1343
1552
  record = DispatchHistory.build_record(self)
1344
1553
  append_dispatch_history_record(record)
@@ -1456,7 +1665,8 @@ module Harnex
1456
1665
  "code" => "receipt_write_error",
1457
1666
  "path" => "$",
1458
1667
  "message" => "harness could not write the observed-state receipt"
1459
- }]
1668
+ }],
1669
+ publish: false
1460
1670
  )
1461
1671
  @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1462
1672
  @term_signal = nil if @exit_code == 1
@@ -2193,8 +2403,9 @@ module Harnex
2193
2403
  end
2194
2404
 
2195
2405
  def summary_model
2196
- meta_hash["model"] || @usage_summary[:model] ||
2197
- (adapter.current_model if adapter.respond_to?(:current_model))
2406
+ @usage_summary[:model] ||
2407
+ (adapter.current_model if adapter.respond_to?(:current_model)) ||
2408
+ meta_hash["model"]
2198
2409
  end
2199
2410
 
2200
2411
  def summary_service_tier
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.10.2"
3
- RELEASE_DATE = "2026-08-08"
2
+ VERSION = "0.12.0"
3
+ RELEASE_DATE = "2026-09-03"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "harnex/runtime/session_state"
19
19
  require_relative "harnex/runtime/message"
20
20
  require_relative "harnex/runtime/inbox"
21
21
  require_relative "harnex/runtime/file_change_hook"
22
+ require_relative "harnex/runtime/completion_notifier"
22
23
  require_relative "harnex/runtime/api_server"
23
24
  require_relative "harnex/runtime/session"
24
25
  require_relative "harnex/commands/watch"