harnex 0.7.3 → 0.7.5
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 +40 -0
- data/README.md +55 -36
- data/guides/01_dispatch.md +24 -23
- data/guides/02_chain.md +6 -3
- data/guides/03_buddy.md +12 -11
- data/guides/04_monitoring.md +38 -24
- data/guides/05_naming.md +22 -17
- data/lib/harnex/adapters/base.rb +3 -2
- data/lib/harnex/adapters/pi.rb +512 -0
- data/lib/harnex/adapters.rb +3 -1
- data/lib/harnex/cli.rb +1 -1
- data/lib/harnex/commands/run.rb +3 -3
- data/lib/harnex/commands/status.rb +35 -6
- data/lib/harnex/commands/wait.rb +45 -1
- data/lib/harnex/runtime/session.rb +164 -23
- data/lib/harnex/terminal_status.rb +202 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +6 -4
|
@@ -28,6 +28,8 @@ module Harnex
|
|
|
28
28
|
Gotchas:
|
|
29
29
|
By default, status filters to the current repo root.
|
|
30
30
|
Use --all when supervising workers launched from sibling worktrees.
|
|
31
|
+
With --id, terminal summaries can report completed/failed/unknown
|
|
32
|
+
even after the live session registry is gone.
|
|
31
33
|
A prompt-like state is not a completion signal by itself.
|
|
32
34
|
TEXT
|
|
33
35
|
end
|
|
@@ -83,12 +85,31 @@ module Harnex
|
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def load_sessions
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
active_repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
|
|
89
|
+
fallback_repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
90
|
+
sessions = Harnex.active_sessions(active_repo_root, id: @options[:id])
|
|
91
|
+
|
|
92
|
+
live = sessions.map { |session| normalize_live_status(load_live_status(session)) }
|
|
93
|
+
.sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
|
|
94
|
+
.reverse
|
|
95
|
+
return live unless @options[:id]
|
|
96
|
+
return [live.first] unless live.empty?
|
|
97
|
+
|
|
98
|
+
terminal = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: fallback_repo_root)
|
|
99
|
+
[terminal || Harnex::TerminalStatus.unknown(id: @options[:id], repo_root: fallback_repo_root)]
|
|
100
|
+
end
|
|
88
101
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
def normalize_live_status(session)
|
|
103
|
+
session.merge(
|
|
104
|
+
"state" => "running",
|
|
105
|
+
"terminal" => false,
|
|
106
|
+
"task_complete" => !session["last_completed_at"].to_s.empty?,
|
|
107
|
+
"exit" => nil,
|
|
108
|
+
"exit_code" => nil,
|
|
109
|
+
"summary_out" => nil,
|
|
110
|
+
"ended_at" => nil,
|
|
111
|
+
"source" => "live"
|
|
112
|
+
)
|
|
92
113
|
end
|
|
93
114
|
|
|
94
115
|
def load_live_status(session)
|
|
@@ -128,7 +149,7 @@ module Harnex
|
|
|
128
149
|
"PORT" => session["port"].to_s,
|
|
129
150
|
"AGE" => timeago(session["started_at"]),
|
|
130
151
|
"IDLE" => format_idle(session["log_idle_s"]),
|
|
131
|
-
"STATE" =>
|
|
152
|
+
"STATE" => table_state(session),
|
|
132
153
|
"DESC" => truncate(session["description"])
|
|
133
154
|
}
|
|
134
155
|
row["REPO"] = truncate_repo(session["repo_root"])
|
|
@@ -139,6 +160,14 @@ module Harnex
|
|
|
139
160
|
columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ")
|
|
140
161
|
end
|
|
141
162
|
|
|
163
|
+
def table_state(session)
|
|
164
|
+
input_state = session.dig("input_state", "state").to_s
|
|
165
|
+
return input_state unless input_state.empty?
|
|
166
|
+
|
|
167
|
+
state = session["state"].to_s
|
|
168
|
+
state.empty? ? "-" : state
|
|
169
|
+
end
|
|
170
|
+
|
|
142
171
|
def timeago(timestamp)
|
|
143
172
|
return "-" if timestamp.to_s.empty?
|
|
144
173
|
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -38,6 +38,8 @@ module Harnex
|
|
|
38
38
|
Gotchas:
|
|
39
39
|
task_complete is an event predicate; prompt/busy are live state polls.
|
|
40
40
|
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
41
|
+
Exit waits can resolve from terminal summary rows when live registry/
|
|
42
|
+
exit-status files are already gone.
|
|
41
43
|
Without --timeout, wait can block indefinitely.
|
|
42
44
|
TEXT
|
|
43
45
|
end
|
|
@@ -238,7 +240,11 @@ module Harnex
|
|
|
238
240
|
unless registry
|
|
239
241
|
return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
240
242
|
|
|
243
|
+
terminal = terminal_status(repo_root)
|
|
244
|
+
return emit_terminal_status(terminal) if terminal
|
|
245
|
+
|
|
241
246
|
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
247
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
|
|
242
248
|
return 1
|
|
243
249
|
end
|
|
244
250
|
|
|
@@ -248,7 +254,13 @@ module Harnex
|
|
|
248
254
|
loop do
|
|
249
255
|
unless Harnex.alive_pid?(target_pid)
|
|
250
256
|
await_exit_status(exit_path)
|
|
251
|
-
return read_exit_status(exit_path, @options[:id])
|
|
257
|
+
return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
258
|
+
|
|
259
|
+
terminal = terminal_status(repo_root)
|
|
260
|
+
return emit_terminal_status(terminal) if terminal
|
|
261
|
+
|
|
262
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
|
|
263
|
+
return 1
|
|
252
264
|
end
|
|
253
265
|
|
|
254
266
|
if deadline && Time.now >= deadline
|
|
@@ -308,6 +320,38 @@ module Harnex
|
|
|
308
320
|
end
|
|
309
321
|
end
|
|
310
322
|
|
|
323
|
+
def terminal_status(repo_root)
|
|
324
|
+
status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
|
|
325
|
+
return nil unless status
|
|
326
|
+
return nil unless status["terminal"]
|
|
327
|
+
|
|
328
|
+
status
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def emit_terminal_status(status)
|
|
332
|
+
payload = {
|
|
333
|
+
ok: status["state"] == "completed",
|
|
334
|
+
id: status["id"],
|
|
335
|
+
state: status["state"],
|
|
336
|
+
terminal: true,
|
|
337
|
+
task_complete: status["task_complete"],
|
|
338
|
+
exit: status["exit"],
|
|
339
|
+
exit_code: status["exit_code"],
|
|
340
|
+
summary_out: status["summary_out"],
|
|
341
|
+
ended_at: status["ended_at"],
|
|
342
|
+
source: status["source"]
|
|
343
|
+
}
|
|
344
|
+
puts JSON.generate(payload)
|
|
345
|
+
|
|
346
|
+
if payload[:ok]
|
|
347
|
+
0
|
|
348
|
+
elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
|
|
349
|
+
status["exit_code"]
|
|
350
|
+
else
|
|
351
|
+
1
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
311
355
|
def parser
|
|
312
356
|
@parser ||= OptionParser.new do |opts|
|
|
313
357
|
opts.banner = "Usage: harnex wait [options]"
|
|
@@ -8,7 +8,12 @@ module Harnex
|
|
|
8
8
|
TRANSCRIPT_TAIL_BYTES = 16 * 1024
|
|
9
9
|
AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT = 5.0
|
|
10
10
|
USAGE_FIELDS = %i[
|
|
11
|
-
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
11
|
+
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
12
|
+
agent_session_id cost_usd tool_calls model agent_provider
|
|
13
|
+
].freeze
|
|
14
|
+
SESSION_SUMMARY_SIGNAL_FIELDS = %i[
|
|
15
|
+
input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
|
|
16
|
+
agent_session_id cost_usd
|
|
12
17
|
].freeze
|
|
13
18
|
BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
|
|
14
19
|
class EventCounters
|
|
@@ -98,6 +103,7 @@ module Harnex
|
|
|
98
103
|
@session_finalized = false
|
|
99
104
|
@turn_started_seen = false
|
|
100
105
|
@last_completed_at = nil
|
|
106
|
+
@pi_streamed_text_by_message = {}
|
|
101
107
|
@auto_stop = !!auto_stop
|
|
102
108
|
@auto_stop_fired = false
|
|
103
109
|
@auto_stop_seen_busy = false
|
|
@@ -138,7 +144,7 @@ module Harnex
|
|
|
138
144
|
prepare_output_log
|
|
139
145
|
prepare_events_log
|
|
140
146
|
|
|
141
|
-
return
|
|
147
|
+
return run_structured if structured_transport?
|
|
142
148
|
|
|
143
149
|
run_pty
|
|
144
150
|
end
|
|
@@ -218,7 +224,7 @@ module Harnex
|
|
|
218
224
|
payload[:agent_state] = @state_machine.to_s
|
|
219
225
|
payload[:inbox] = @inbox.stats
|
|
220
226
|
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
221
|
-
payload[:model] =
|
|
227
|
+
payload[:model] = summary_model
|
|
222
228
|
payload[:effort] = meta_hash["effort"]
|
|
223
229
|
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
224
230
|
payload
|
|
@@ -247,13 +253,13 @@ module Harnex
|
|
|
247
253
|
end
|
|
248
254
|
|
|
249
255
|
def inject_stop(turn_id: nil)
|
|
250
|
-
unless
|
|
256
|
+
unless structured_transport?
|
|
251
257
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
252
258
|
end
|
|
253
259
|
|
|
254
260
|
return { ok: true, signal: "already_requested" } if stop_requested!
|
|
255
261
|
|
|
256
|
-
if
|
|
262
|
+
if structured_transport?
|
|
257
263
|
if adapter.respond_to?(:terminate_subprocess)
|
|
258
264
|
Thread.new do
|
|
259
265
|
begin
|
|
@@ -283,8 +289,8 @@ module Harnex
|
|
|
283
289
|
end
|
|
284
290
|
|
|
285
291
|
def inject_via_adapter(text:, submit:, enter_only:, force: false)
|
|
286
|
-
if
|
|
287
|
-
return
|
|
292
|
+
if structured_transport?
|
|
293
|
+
return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force)
|
|
288
294
|
end
|
|
289
295
|
|
|
290
296
|
snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
|
|
@@ -311,7 +317,7 @@ module Harnex
|
|
|
311
317
|
.tap { emit_send_event(text, force: payload[:force]) }
|
|
312
318
|
end
|
|
313
319
|
|
|
314
|
-
def
|
|
320
|
+
def inject_via_structured(text:, submit:, enter_only:, force: false)
|
|
315
321
|
payload = adapter.build_send_payload(
|
|
316
322
|
text: text,
|
|
317
323
|
submit: submit,
|
|
@@ -360,9 +366,13 @@ module Harnex
|
|
|
360
366
|
|
|
361
367
|
private
|
|
362
368
|
|
|
363
|
-
def
|
|
364
|
-
adapter.
|
|
365
|
-
|
|
369
|
+
def structured_transport?
|
|
370
|
+
%i[stdio_jsonrpc stdio_jsonl_rpc].include?(adapter.transport)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def run_structured
|
|
374
|
+
adapter.on_notification { |msg| handle_structured_notification(msg) }
|
|
375
|
+
adapter.on_disconnect { |err| handle_structured_disconnect(err) }
|
|
366
376
|
|
|
367
377
|
adapter.start_rpc(env: child_env, cwd: repo_root)
|
|
368
378
|
@pid = adapter.pid
|
|
@@ -415,6 +425,8 @@ module Harnex
|
|
|
415
425
|
@events_log&.close unless @events_log&.closed?
|
|
416
426
|
end
|
|
417
427
|
|
|
428
|
+
alias run_jsonrpc run_structured
|
|
429
|
+
|
|
418
430
|
def signal_rpc_done!
|
|
419
431
|
@rpc_done = true
|
|
420
432
|
if defined?(@rpc_done_lock) && @rpc_done_lock
|
|
@@ -422,6 +434,15 @@ module Harnex
|
|
|
422
434
|
end
|
|
423
435
|
end
|
|
424
436
|
|
|
437
|
+
def handle_structured_notification(message)
|
|
438
|
+
case adapter.transport
|
|
439
|
+
when :stdio_jsonrpc
|
|
440
|
+
handle_rpc_notification(message)
|
|
441
|
+
when :stdio_jsonl_rpc
|
|
442
|
+
handle_jsonl_notification(message)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
425
446
|
def handle_rpc_notification(message)
|
|
426
447
|
method = message["method"]
|
|
427
448
|
params = message["params"] || {}
|
|
@@ -470,6 +491,86 @@ module Harnex
|
|
|
470
491
|
warn("harnex: rpc notification handler error: #{e.message}")
|
|
471
492
|
end
|
|
472
493
|
|
|
494
|
+
def handle_jsonl_notification(message)
|
|
495
|
+
event_type = message["type"].to_s
|
|
496
|
+
|
|
497
|
+
case event_type
|
|
498
|
+
when "agent_start", "turn_start"
|
|
499
|
+
@turn_started_seen = true if event_type == "turn_start"
|
|
500
|
+
@state_machine.force_busy!
|
|
501
|
+
emit_event("turn_started") if event_type == "turn_start"
|
|
502
|
+
when "agent_end"
|
|
503
|
+
@last_completed_at = Time.now
|
|
504
|
+
@state_machine.force_prompt!
|
|
505
|
+
emit_event("task_complete")
|
|
506
|
+
adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
|
|
507
|
+
schedule_auto_stop("task_complete")
|
|
508
|
+
when "message_start"
|
|
509
|
+
@pi_streamed_text_by_message[pi_message_key(message["message"])] = false
|
|
510
|
+
when "message_update"
|
|
511
|
+
event = message["assistantMessageEvent"] || {}
|
|
512
|
+
delta = event["delta"]
|
|
513
|
+
key = pi_message_key(message["message"])
|
|
514
|
+
if event["type"] == "text_delta" && delta && !delta.empty?
|
|
515
|
+
@pi_streamed_text_by_message[key] = true
|
|
516
|
+
record_synthesized(delta, newline: false)
|
|
517
|
+
end
|
|
518
|
+
when "message_end"
|
|
519
|
+
key = pi_message_key(message["message"])
|
|
520
|
+
streamed = @pi_streamed_text_by_message.delete(key)
|
|
521
|
+
unless streamed
|
|
522
|
+
text = pi_extract_message_text(message["message"])
|
|
523
|
+
record_synthesized(text) if text
|
|
524
|
+
end
|
|
525
|
+
when "tool_execution_start"
|
|
526
|
+
@event_counters.record_item({ "type" => "dynamicToolCall" })
|
|
527
|
+
record_synthesized(
|
|
528
|
+
"tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
|
|
529
|
+
)
|
|
530
|
+
when "tool_execution_end"
|
|
531
|
+
tool_name = message["toolName"] || "tool"
|
|
532
|
+
status = message["isError"] ? "error" : "ok"
|
|
533
|
+
record_synthesized("tool-result: #{tool_name} (#{status})")
|
|
534
|
+
when "compaction_start", "compaction_end"
|
|
535
|
+
emit_event("compaction", reason: message["reason"], phase: event_type)
|
|
536
|
+
when "queue_update"
|
|
537
|
+
nil
|
|
538
|
+
when "auto_retry_start", "auto_retry_end"
|
|
539
|
+
emit_event(event_type, **message.reject { |k, _| k == "type" })
|
|
540
|
+
when "extension_ui_request"
|
|
541
|
+
handle_extension_ui_request(message)
|
|
542
|
+
when "extension_error"
|
|
543
|
+
@last_error = message["error"].to_s unless message["error"].to_s.empty?
|
|
544
|
+
emit_event("extension_error", **message.reject { |k, _| k == "type" })
|
|
545
|
+
when "response"
|
|
546
|
+
# Adapter-level command responses are handled in the adapter.
|
|
547
|
+
nil
|
|
548
|
+
end
|
|
549
|
+
rescue StandardError => e
|
|
550
|
+
warn("harnex: rpc notification handler error: #{e.message}")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def handle_extension_ui_request(message)
|
|
554
|
+
method = message["method"].to_s
|
|
555
|
+
request_id = message["id"]
|
|
556
|
+
cancelled = false
|
|
557
|
+
if adapter.respond_to?(:respond_extension_ui_cancel)
|
|
558
|
+
cancelled = adapter.respond_extension_ui_cancel(request_id: request_id, method: method)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
payload = {
|
|
562
|
+
method: method,
|
|
563
|
+
request_id: request_id,
|
|
564
|
+
auto_cancelled: !!cancelled
|
|
565
|
+
}
|
|
566
|
+
emit_event("extension_ui_request", **payload)
|
|
567
|
+
record_synthesized("extension-ui: #{method}#{cancelled ? " (auto-cancelled)" : ""}")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def handle_structured_disconnect(error)
|
|
571
|
+
handle_rpc_disconnect(error)
|
|
572
|
+
end
|
|
573
|
+
|
|
473
574
|
def handle_rpc_disconnect(error)
|
|
474
575
|
msg = error.is_a?(Hash) ? error["message"] : error&.message
|
|
475
576
|
@last_error = msg.to_s unless msg.to_s.empty?
|
|
@@ -484,7 +585,7 @@ module Harnex
|
|
|
484
585
|
prompt = adapter.initial_prompt
|
|
485
586
|
return if prompt.to_s.empty?
|
|
486
587
|
|
|
487
|
-
|
|
588
|
+
inject_via_structured(text: prompt, submit: true, enter_only: false, force: false)
|
|
488
589
|
end
|
|
489
590
|
|
|
490
591
|
def render_item_text(item)
|
|
@@ -511,11 +612,37 @@ module Harnex
|
|
|
511
612
|
""
|
|
512
613
|
end
|
|
513
614
|
|
|
514
|
-
def
|
|
615
|
+
def pi_extract_message_text(message)
|
|
616
|
+
return nil unless message.is_a?(Hash)
|
|
617
|
+
|
|
618
|
+
content = message["content"]
|
|
619
|
+
case content
|
|
620
|
+
when String
|
|
621
|
+
content
|
|
622
|
+
when Array
|
|
623
|
+
parts = content.filter_map do |item|
|
|
624
|
+
next unless item.is_a?(Hash)
|
|
625
|
+
next unless item["type"] == "text"
|
|
626
|
+
|
|
627
|
+
item["text"].to_s
|
|
628
|
+
end
|
|
629
|
+
parts.empty? ? nil : parts.join
|
|
630
|
+
else
|
|
631
|
+
nil
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def pi_message_key(message)
|
|
636
|
+
return "unknown" unless message.is_a?(Hash)
|
|
637
|
+
|
|
638
|
+
message["entryId"] || message["id"] || message["timestamp"] || message.object_id
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def record_synthesized(text, newline: true)
|
|
515
642
|
return if text.nil? || text.to_s.empty?
|
|
516
643
|
|
|
517
644
|
payload = text.to_s.dup
|
|
518
|
-
payload << "\n"
|
|
645
|
+
payload << "\n" if newline && !payload.end_with?("\n")
|
|
519
646
|
bytes = payload.b
|
|
520
647
|
@mutex.synchronize do
|
|
521
648
|
append_output_log(bytes)
|
|
@@ -894,7 +1021,7 @@ module Harnex
|
|
|
894
1021
|
end
|
|
895
1022
|
|
|
896
1023
|
def boot_failure_exit?
|
|
897
|
-
return false unless
|
|
1024
|
+
return false unless structured_transport?
|
|
898
1025
|
return false if @turn_started_seen
|
|
899
1026
|
|
|
900
1027
|
lifetime = (@ended_at || Time.now) - @started_at
|
|
@@ -902,7 +1029,7 @@ module Harnex
|
|
|
902
1029
|
end
|
|
903
1030
|
|
|
904
1031
|
def session_summary_present?
|
|
905
|
-
|
|
1032
|
+
SESSION_SUMMARY_SIGNAL_FIELDS.any? { |field| !@usage_summary[field].nil? }
|
|
906
1033
|
end
|
|
907
1034
|
|
|
908
1035
|
def build_summary_record
|
|
@@ -927,7 +1054,7 @@ module Harnex
|
|
|
927
1054
|
harness_version: Harnex.harness_version,
|
|
928
1055
|
agent: adapter.key,
|
|
929
1056
|
agent_version: adapter.agent_version,
|
|
930
|
-
agent_provider:
|
|
1057
|
+
agent_provider: summary_agent_provider,
|
|
931
1058
|
host: info[:host],
|
|
932
1059
|
platform: info[:platform],
|
|
933
1060
|
orchestrator: passthrough["orchestrator"],
|
|
@@ -954,7 +1081,7 @@ module Harnex
|
|
|
954
1081
|
end
|
|
955
1082
|
|
|
956
1083
|
actual = {
|
|
957
|
-
model:
|
|
1084
|
+
model: summary_model,
|
|
958
1085
|
effort: meta_hash["effort"],
|
|
959
1086
|
duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
|
|
960
1087
|
input_tokens: @usage_summary[:input_tokens],
|
|
@@ -962,6 +1089,7 @@ module Harnex
|
|
|
962
1089
|
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
963
1090
|
cached_tokens: @usage_summary[:cached_tokens],
|
|
964
1091
|
total_tokens: @usage_summary[:total_tokens],
|
|
1092
|
+
cost_usd: @usage_summary[:cost_usd],
|
|
965
1093
|
agent_session_id: summary_agent_session_id,
|
|
966
1094
|
adapter_transport: adapter.transport.to_s,
|
|
967
1095
|
loc_added: @git_end[:loc_added],
|
|
@@ -979,7 +1107,7 @@ module Harnex
|
|
|
979
1107
|
disconnections: counters[:disconnections],
|
|
980
1108
|
compactions: counters[:compactions],
|
|
981
1109
|
turn_count: @injected_count,
|
|
982
|
-
tool_calls: counters
|
|
1110
|
+
tool_calls: summary_tool_calls(counters),
|
|
983
1111
|
commands_executed: counters[:commands_executed],
|
|
984
1112
|
rate_limits: @rate_limits,
|
|
985
1113
|
output_lines: output_measurements[:lines],
|
|
@@ -1027,6 +1155,19 @@ module Harnex
|
|
|
1027
1155
|
(adapter.thread_id if adapter.respond_to?(:thread_id))
|
|
1028
1156
|
end
|
|
1029
1157
|
|
|
1158
|
+
def summary_agent_provider
|
|
1159
|
+
@usage_summary[:agent_provider] || adapter.provider
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
def summary_model
|
|
1163
|
+
meta_hash["model"] || @usage_summary[:model] ||
|
|
1164
|
+
(adapter.current_model if adapter.respond_to?(:current_model))
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
def summary_tool_calls(counters)
|
|
1168
|
+
@usage_summary[:tool_calls] || counters[:tool_calls]
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1030
1171
|
def summary_predicted_payload
|
|
1031
1172
|
predicted = meta_hash["predicted"]
|
|
1032
1173
|
predicted.is_a?(Hash) ? predicted : {}
|
|
@@ -1060,13 +1201,13 @@ module Harnex
|
|
|
1060
1201
|
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
1061
1202
|
end
|
|
1062
1203
|
|
|
1063
|
-
#
|
|
1064
|
-
#
|
|
1065
|
-
# transcript to scrape; fall back to the schema-true cumulative
|
|
1066
|
-
# `total` block. Other adapters parse the transcript tail.
|
|
1204
|
+
# Structured adapters emit usage directly (JSON-RPC token snapshots,
|
|
1205
|
+
# Pi RPC stats). PTY adapters parse transcript tails when supported.
|
|
1067
1206
|
def collect_session_summary
|
|
1068
1207
|
if adapter.transport == :stdio_jsonrpc
|
|
1069
1208
|
summary_from_token_usage
|
|
1209
|
+
elsif adapter.respond_to?(:collect_session_summary)
|
|
1210
|
+
adapter.collect_session_summary
|
|
1070
1211
|
else
|
|
1071
1212
|
adapter.parse_session_summary(transcript_tail)
|
|
1072
1213
|
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Harnex
|
|
5
|
+
module TerminalStatus
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def resolve(id:, repo_root: Dir.pwd)
|
|
9
|
+
normalized_id = Harnex.normalize_id(id)
|
|
10
|
+
root = File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root)
|
|
11
|
+
|
|
12
|
+
latest_summary = nil
|
|
13
|
+
latest_summary_path = nil
|
|
14
|
+
latest_history = nil
|
|
15
|
+
|
|
16
|
+
history_paths(root).each do |path|
|
|
17
|
+
summary, history = scan_dispatch_path(path, normalized_id)
|
|
18
|
+
if newer_summary?(summary, latest_summary)
|
|
19
|
+
latest_summary = summary
|
|
20
|
+
latest_summary_path = path
|
|
21
|
+
end
|
|
22
|
+
latest_history = history if newer_history?(history, latest_history)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
summary_path = latest_history && latest_history["summary_out_path"].to_s.strip
|
|
26
|
+
if summary_path && !summary_path.empty? && File.file?(summary_path)
|
|
27
|
+
summary, = scan_dispatch_path(summary_path, normalized_id)
|
|
28
|
+
if newer_summary?(summary, latest_summary)
|
|
29
|
+
latest_summary = summary
|
|
30
|
+
latest_summary_path = summary_path
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
return build_from_summary(latest_summary, latest_summary_path, root) if latest_summary
|
|
35
|
+
return build_from_history(latest_history, root) if latest_history
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def unknown(id:, repo_root: Dir.pwd)
|
|
41
|
+
{
|
|
42
|
+
"id" => Harnex.normalize_id(id),
|
|
43
|
+
"repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
|
|
44
|
+
"state" => "unknown",
|
|
45
|
+
"terminal" => false,
|
|
46
|
+
"task_complete" => false,
|
|
47
|
+
"exit" => nil,
|
|
48
|
+
"exit_code" => nil,
|
|
49
|
+
"summary_out" => nil,
|
|
50
|
+
"started_at" => nil,
|
|
51
|
+
"ended_at" => nil,
|
|
52
|
+
"source" => "none"
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def history_paths(repo_root)
|
|
57
|
+
local_path = DispatchHistory.path_for(repo_root)
|
|
58
|
+
return [local_path] if File.file?(local_path)
|
|
59
|
+
|
|
60
|
+
global_path = DispatchHistory.global_path
|
|
61
|
+
return [global_path] if File.file?(global_path)
|
|
62
|
+
|
|
63
|
+
[]
|
|
64
|
+
rescue StandardError
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def scan_dispatch_path(path, id)
|
|
69
|
+
summary_record = nil
|
|
70
|
+
history_record = nil
|
|
71
|
+
|
|
72
|
+
File.foreach(path) do |line|
|
|
73
|
+
record = JSON.parse(line)
|
|
74
|
+
next unless record.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
if summary_record?(record) && record.dig("meta", "id").to_s == id
|
|
77
|
+
summary_record = record
|
|
78
|
+
elsif history_record?(record) && record["id"].to_s == id
|
|
79
|
+
history_record = record
|
|
80
|
+
end
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[summary_record, history_record]
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
[nil, nil]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def summary_record?(record)
|
|
91
|
+
record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def history_record?(record)
|
|
95
|
+
record["schema_version"] == 1 && record.key?("status")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def newer_summary?(candidate, current)
|
|
99
|
+
return false unless candidate
|
|
100
|
+
return true unless current
|
|
101
|
+
|
|
102
|
+
summary_time(candidate) >= summary_time(current)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def newer_history?(candidate, current)
|
|
106
|
+
return false unless candidate
|
|
107
|
+
return true unless current
|
|
108
|
+
|
|
109
|
+
history_time(candidate) >= history_time(current)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def summary_time(record)
|
|
113
|
+
parse_time(record.dig("meta", "ended_at")) || parse_time(record.dig("meta", "started_at")) || Time.at(0)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def history_time(record)
|
|
117
|
+
parse_time(record["ended_at"]) || parse_time(record["started_at"]) || Time.at(0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_time(value)
|
|
121
|
+
Time.iso8601(value.to_s)
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_from_summary(record, summary_path, fallback_repo_root)
|
|
127
|
+
meta = record["meta"] || {}
|
|
128
|
+
actual = record["actual"] || {}
|
|
129
|
+
state = classify_summary_state(actual)
|
|
130
|
+
{
|
|
131
|
+
"id" => meta["id"].to_s,
|
|
132
|
+
"repo_root" => meta["repo"] || fallback_repo_root,
|
|
133
|
+
"state" => state,
|
|
134
|
+
"terminal" => state != "unknown",
|
|
135
|
+
"task_complete" => !!actual["task_complete"],
|
|
136
|
+
"exit" => blank_to_nil(actual["exit"]),
|
|
137
|
+
"exit_code" => actual["exit_code"],
|
|
138
|
+
"summary_out" => summary_path,
|
|
139
|
+
"started_at" => meta["started_at"],
|
|
140
|
+
"ended_at" => meta["ended_at"],
|
|
141
|
+
"source" => "summary_out"
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def classify_summary_state(actual)
|
|
146
|
+
exit = actual["exit"].to_s
|
|
147
|
+
exit_code = actual["exit_code"]
|
|
148
|
+
|
|
149
|
+
return "completed" if exit == "success"
|
|
150
|
+
return "completed" if exit.empty? && exit_code == 0
|
|
151
|
+
return "failed" unless exit.empty? && exit_code.nil?
|
|
152
|
+
|
|
153
|
+
"unknown"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_from_history(record, fallback_repo_root)
|
|
157
|
+
status = record["status"].to_s
|
|
158
|
+
state =
|
|
159
|
+
case status
|
|
160
|
+
when "completed"
|
|
161
|
+
"completed"
|
|
162
|
+
when "failed", "timeout", "killed"
|
|
163
|
+
"failed"
|
|
164
|
+
else
|
|
165
|
+
"unknown"
|
|
166
|
+
end
|
|
167
|
+
{
|
|
168
|
+
"id" => record["id"].to_s,
|
|
169
|
+
"repo_root" => fallback_repo_root,
|
|
170
|
+
"state" => state,
|
|
171
|
+
"terminal" => state != "unknown",
|
|
172
|
+
"task_complete" => record["terminal_event"].to_s == "task_complete",
|
|
173
|
+
"exit" => history_exit(status),
|
|
174
|
+
"exit_code" => nil,
|
|
175
|
+
"summary_out" => blank_to_nil(record["summary_out_path"]),
|
|
176
|
+
"started_at" => record["started_at"],
|
|
177
|
+
"ended_at" => record["ended_at"],
|
|
178
|
+
"source" => "dispatch_history"
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def history_exit(status)
|
|
183
|
+
case status
|
|
184
|
+
when "completed"
|
|
185
|
+
"success"
|
|
186
|
+
when "timeout"
|
|
187
|
+
"timeout"
|
|
188
|
+
when "killed"
|
|
189
|
+
"killed"
|
|
190
|
+
when "failed"
|
|
191
|
+
"failure"
|
|
192
|
+
else
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def blank_to_nil(value)
|
|
198
|
+
text = value.to_s
|
|
199
|
+
text.empty? ? nil : text
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/harnex/version.rb
CHANGED