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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +22 -7
- data/TECHNICAL.md +25 -0
- data/docs/pi-rpc.md +151 -0
- data/guides/01_dispatch.md +26 -2
- data/guides/04_monitoring.md +32 -8
- data/lib/harnex/adapters/pi.rb +220 -21
- data/lib/harnex/commands/doctor.rb +57 -9
- data/lib/harnex/commands/run.rb +34 -6
- data/lib/harnex/commands/status.rb +16 -2
- data/lib/harnex/core.rb +15 -0
- data/lib/harnex/runtime/completion_notifier.rb +137 -0
- data/lib/harnex/runtime/session.rb +232 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +4 -2
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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(
|
|
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"
|
|
796
|
-
|
|
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")
|
|
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
|
-
|
|
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("
|
|
870
|
+
schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
|
|
804
871
|
when "message_start"
|
|
805
|
-
@
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/harnex/version.rb
CHANGED
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"
|