harnex 0.12.0 → 0.29.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 +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- data/lib/harnex/codex/app_server/client.rb +0 -347
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
# #77: `harnex watch --owner`. One compact JSON line per lifecycle change of
|
|
8
|
+
# every dispatch an owner started, for a Claude Code `Monitor` or a panel.
|
|
9
|
+
# Sources are the #72 owner records (registered `running`, then settled),
|
|
10
|
+
# the dispatch row's #57 class for `error` settlements, and live status via
|
|
11
|
+
# Harnex::Stall. Only event lines reach stdout; nothing is nudged or stopped.
|
|
12
|
+
class OwnerStream
|
|
13
|
+
SCHEMA = "harnex.owner_event.v1"
|
|
14
|
+
EVENTS = %w[started done rejected failed stalled stopped timeout].freeze
|
|
15
|
+
POLL_INTERVAL_S = 2.0
|
|
16
|
+
# An `error` settlement precedes its dispatch row by milliseconds; give the
|
|
17
|
+
# row this long to land before classifying without it.
|
|
18
|
+
ROW_GRACE_S = 5.0
|
|
19
|
+
STALL_POLL_MAX_INTERVAL_S = 15.0
|
|
20
|
+
MAX_WAIT_EXIT = 124
|
|
21
|
+
CURSOR_NAME = /\A[A-Za-z0-9._-]{1,64}\z/
|
|
22
|
+
|
|
23
|
+
# once: (#84) exit 0 after the first pass that delivers anything (results
|
|
24
|
+
# or stalls; every one ready in that pass), so one blocking call covers
|
|
25
|
+
# every lane. `started` lines are skipped there: they repeat on every
|
|
26
|
+
# start and would end each call.
|
|
27
|
+
# cursor: a named cursor, so a parent and its subagent (which share one
|
|
28
|
+
# Claude session id) do not take each other's events.
|
|
29
|
+
# match: lane-id globs; other lanes of the owner are ignored (#85).
|
|
30
|
+
# until_idle: like once, but keep collecting until no matching lane is
|
|
31
|
+
# still running, so one call waits for a whole batch of lanes.
|
|
32
|
+
def initialize(owner_id:, stall_after: nil, max_wait: nil, once: false, cursor: nil, match: nil,
|
|
33
|
+
until_idle: false, out: $stdout, err: $stderr, clock: nil, wall_clock: nil, sleeper: nil,
|
|
34
|
+
status_fetcher: nil)
|
|
35
|
+
@owner_id = owner_id.to_s
|
|
36
|
+
raise ArgumentError, "owner id is required" if @owner_id.strip.empty?
|
|
37
|
+
if cursor && !cursor.to_s.match?(CURSOR_NAME)
|
|
38
|
+
raise ArgumentError, "cursor name must be 1-64 letters, digits, '.', '_' or '-'"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@stall_after = stall_after
|
|
42
|
+
@max_wait = max_wait
|
|
43
|
+
@until_idle = until_idle
|
|
44
|
+
@once = once || until_idle
|
|
45
|
+
@busy_lanes = 0
|
|
46
|
+
@match = Array(match).flat_map { |glob| glob.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
47
|
+
@emitted = 0
|
|
48
|
+
@out = out
|
|
49
|
+
@err = err
|
|
50
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
51
|
+
@wall_clock = wall_clock || -> { Time.now }
|
|
52
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
53
|
+
@status_fetcher = status_fetcher || method(:fetch_status)
|
|
54
|
+
@dir = Harnex.owned_notifications_dir(@owner_id)
|
|
55
|
+
@cursor_path = File.join(@dir, "watch", cursor ? "cursor-#{cursor}.json" : "cursor.json")
|
|
56
|
+
@running = {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run
|
|
60
|
+
deadline = @max_wait && @clock.call + @max_wait
|
|
61
|
+
load_cursor
|
|
62
|
+
loop do
|
|
63
|
+
scan
|
|
64
|
+
return 0 if @until_idle ? @busy_lanes.zero? : once_satisfied?
|
|
65
|
+
if deadline && @clock.call >= deadline
|
|
66
|
+
@err.puts("harnex watch: no owner event within #{duration(@max_wait)}") if @once
|
|
67
|
+
return MAX_WAIT_EXIT
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@sleeper.call(POLL_INTERVAL_S)
|
|
71
|
+
end
|
|
72
|
+
rescue Interrupt
|
|
73
|
+
0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One pass over the owner's records; public for deterministic tests.
|
|
77
|
+
def scan
|
|
78
|
+
load_cursor unless @delivered
|
|
79
|
+
list = records
|
|
80
|
+
seen = list.to_h { |record| [record["session_id"], true] }
|
|
81
|
+
mine = list.select { |record| matches?(record) }
|
|
82
|
+
delivered = deliver_results(mine)
|
|
83
|
+
@busy_lanes = 0
|
|
84
|
+
mine.each do |record|
|
|
85
|
+
if record["outcome"].to_s.empty?
|
|
86
|
+
next unless turn_of(record) > delivered.fetch(record["session_id"])
|
|
87
|
+
|
|
88
|
+
observe_running(record)
|
|
89
|
+
@busy_lanes += 1 if @running.key?(record["session_id"])
|
|
90
|
+
else
|
|
91
|
+
@running.delete(record["session_id"])
|
|
92
|
+
# A settled lane whose result still waits for its dispatch row.
|
|
93
|
+
@busy_lanes += 1 if delivered.fetch(record["session_id"]) < results_of(record).map { |r| turn_of(r) }.max.to_i
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
@running.select! { |session_id, _| seen[session_id] }
|
|
97
|
+
prune_cursor(seen)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def once_satisfied?
|
|
103
|
+
@once && @emitted.positive?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def matches?(record)
|
|
107
|
+
@match.empty? || @match.any? { |glob| File.fnmatch?(glob, record["id"].to_s) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Every turn result not yet delivered, across all lanes in the order
|
|
111
|
+
# they settled, so a watcher that was away while several turns ended
|
|
112
|
+
# still gets each one (#85). Returns each dispatch's last delivered turn.
|
|
113
|
+
def deliver_results(records)
|
|
114
|
+
delivered = records.to_h { |record| [record["session_id"], delivered_turn(record["session_id"])] }
|
|
115
|
+
pending = records.flat_map do |record|
|
|
116
|
+
results = results_of(record)
|
|
117
|
+
results.filter_map do |result|
|
|
118
|
+
next if turn_of(result) <= delivered[record["session_id"]]
|
|
119
|
+
|
|
120
|
+
final = result.equal?(results.last) && !record["outcome"].to_s.empty?
|
|
121
|
+
[result["notified_at"].to_s, turn_of(result), record, result, final]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
blocked = {}
|
|
125
|
+
pending.sort_by { |at, turn, record, _result, _final| [at, record["session_id"], turn] }.each do |_at, turn, record, result, final|
|
|
126
|
+
session_id = record["session_id"]
|
|
127
|
+
next if blocked[session_id]
|
|
128
|
+
next blocked[session_id] = true unless settle(record.merge(result), final: final)
|
|
129
|
+
|
|
130
|
+
delivered[session_id] = turn
|
|
131
|
+
end
|
|
132
|
+
delivered
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Owner records from 0.18.1 keep a bounded `results` history; older ones
|
|
136
|
+
# hold only their latest settlement at the top level.
|
|
137
|
+
def results_of(record)
|
|
138
|
+
history = record["results"]
|
|
139
|
+
list = history.is_a?(Array) ? history.select { |item| item.is_a?(Hash) && item["outcome"] } : []
|
|
140
|
+
list = [record] if list.empty? && !record["outcome"].to_s.empty?
|
|
141
|
+
list.sort_by { |item| turn_of(item) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The cursor keeps one value per dispatch: the key of its last delivered
|
|
145
|
+
# turn result ("<notified_at>#N"; turn 1 is the bare notified_at, as
|
|
146
|
+
# before #81) or the runner_gone report "gone#N" for running turn N.
|
|
147
|
+
def delivered_turn(session_id)
|
|
148
|
+
value = @delivered[session_id].to_s
|
|
149
|
+
return 0 if value.empty?
|
|
150
|
+
|
|
151
|
+
number = value[/#(\d+)\z/, 1]
|
|
152
|
+
number ? number.to_i : 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def turn_of(record)
|
|
156
|
+
[record["turn"].to_i, 1].max
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A first turn keys on notified_at alone, as cursors before #81 did.
|
|
160
|
+
def settled_key(record)
|
|
161
|
+
key = record["notified_at"].to_s
|
|
162
|
+
key = "settled" if key.empty?
|
|
163
|
+
turn_of(record) > 1 ? "#{key}##{turn_of(record)}" : key
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def gone_key(record)
|
|
167
|
+
"gone##{turn_of(record)}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def records
|
|
171
|
+
return [] unless File.directory?(@dir)
|
|
172
|
+
|
|
173
|
+
Dir.glob(File.join(@dir, "*.json")).sort.filter_map do |path|
|
|
174
|
+
record = JSON.parse(File.read(path))
|
|
175
|
+
next unless record.is_a?(Hash) && record["owner_id"] == @owner_id
|
|
176
|
+
next if record["session_id"].to_s.empty? || record["id"].to_s.empty?
|
|
177
|
+
|
|
178
|
+
record
|
|
179
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def observe_running(record)
|
|
185
|
+
session_id = record["session_id"]
|
|
186
|
+
state = @running[session_id]
|
|
187
|
+
unless state
|
|
188
|
+
state = @running[session_id] = { stalled: @stalled.key?(session_id), next_check: nil, status: nil }
|
|
189
|
+
# A later turn (a nudge) is watched silently; its result is reported.
|
|
190
|
+
if turn_of(record) <= 1 && !@once
|
|
191
|
+
emit("started", record, msg: "#{record['id']} started in #{portable(record['repo_root'])}")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A runner that died without settling will never write an outcome.
|
|
196
|
+
pid = record["runner_pid"]
|
|
197
|
+
if pid && !Harnex.alive_pid?(pid)
|
|
198
|
+
emit("failed", record, reason: "runner_gone",
|
|
199
|
+
msg: "#{record['id']} failed: its harnex runner exited without a result")
|
|
200
|
+
deliver(record, gone_key(record))
|
|
201
|
+
@running.delete(session_id)
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
check_stall(record, state) if @stall_after
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def check_stall(record, state)
|
|
209
|
+
now = @clock.call
|
|
210
|
+
return if state[:next_check] && now < state[:next_check]
|
|
211
|
+
|
|
212
|
+
state[:next_check] = now + [[@stall_after / 4.0, 0.5].max, STALL_POLL_MAX_INTERVAL_S].min
|
|
213
|
+
status = @status_fetcher.call(record)
|
|
214
|
+
stall = Harnex::Stall.stalled(status, @stall_after)
|
|
215
|
+
if stall && !state[:stalled]
|
|
216
|
+
state[:stalled] = true
|
|
217
|
+
remember_stall(record["session_id"], true)
|
|
218
|
+
running = status.is_a?(Hash) ? Array(status["running_commands"]).first(3) : []
|
|
219
|
+
stall = stall.merge("running_commands" => running) unless running.empty?
|
|
220
|
+
detail = running.empty? ? "" : " while running `#{running.first}`"
|
|
221
|
+
emit("stalled", record.merge("elapsed_s" => running_elapsed(record)), stall: stall,
|
|
222
|
+
msg: "#{record['id']} stalled: no progress for #{duration(stall['idle_s'])} (#{stall['clock']})#{detail}")
|
|
223
|
+
elsif stall.nil?
|
|
224
|
+
state[:stalled] = false # progress resumed (or nothing observable): re-arm
|
|
225
|
+
remember_stall(record["session_id"], false)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Seconds since the running turn began (or the dispatch, for turn 1).
|
|
230
|
+
def running_elapsed(record)
|
|
231
|
+
since = Time.iso8601((record["turn_started_at"] || record["started_at"]).to_s)
|
|
232
|
+
[(@wall_clock.call - since).to_i, 0].max
|
|
233
|
+
rescue ArgumentError
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# --once runs one process per event, so a stall episode must outlive the
|
|
238
|
+
# process or every call would end on the same stall.
|
|
239
|
+
def remember_stall(session_id, stalled)
|
|
240
|
+
return unless @once
|
|
241
|
+
return if @stalled.key?(session_id) == stalled
|
|
242
|
+
|
|
243
|
+
stalled ? @stalled[session_id] = true : @stalled.delete(session_id)
|
|
244
|
+
save_cursor
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# final: the dispatch's last result once it settled for good; only that
|
|
248
|
+
# one may take its cause from the dispatch row. Returns false while it
|
|
249
|
+
# waits for that row.
|
|
250
|
+
def settle(record, final: true)
|
|
251
|
+
outcome = record["outcome"].to_s
|
|
252
|
+
outcome_class = record["outcome_class"]
|
|
253
|
+
event = { "completed" => "done", "rejected" => "rejected" }[outcome]
|
|
254
|
+
if event.nil?
|
|
255
|
+
# "failed"/"error": a stop or deadline can surface as either (Pi
|
|
256
|
+
# settles an interrupted turn as failed). The dispatch row's #57
|
|
257
|
+
# class names the cause; it lands moments after an auto-stop
|
|
258
|
+
# settlement, so wait briefly for it before falling back.
|
|
259
|
+
row = final ? dispatch_row(record) : nil
|
|
260
|
+
return false if final && row.nil? && within_row_grace?(record)
|
|
261
|
+
|
|
262
|
+
outcome_class = row&.dig("outcome", "class") || outcome_class
|
|
263
|
+
event = { "timeout" => "timeout", "stopped_by_caller" => "stopped" }.fetch(outcome_class.to_s, "failed")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
emit(event, record, outcome_class: outcome_class, msg: settled_message(event, record, outcome_class))
|
|
267
|
+
deliver(record, settled_key(record))
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def settled_message(event, record, outcome_class)
|
|
272
|
+
id = record["id"]
|
|
273
|
+
id = "#{id} turn #{turn_of(record)}" if turn_of(record) > 1
|
|
274
|
+
after = record["elapsed_s"] ? " after #{duration(record['elapsed_s'])}" : ""
|
|
275
|
+
case event
|
|
276
|
+
when "done" then "#{id} done (#{outcome_class || 'completed'})#{after}"
|
|
277
|
+
when "rejected" then "#{id} rejected: #{outcome_class || 'proof rejected'}#{after}"
|
|
278
|
+
when "timeout" then "#{id} hit its runtime budget#{after}"
|
|
279
|
+
when "stopped" then "#{id} stopped by its caller#{after}"
|
|
280
|
+
else "#{id} failed (#{outcome_class || 'unknown'})#{after}"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def emit(event, record, msg:, outcome_class: nil, stall: nil, reason: nil)
|
|
285
|
+
line = {
|
|
286
|
+
"schema" => SCHEMA,
|
|
287
|
+
"event" => event,
|
|
288
|
+
"id" => record["id"],
|
|
289
|
+
"session_id" => record["session_id"],
|
|
290
|
+
"turn" => turn_of(record),
|
|
291
|
+
"repo" => portable(record["repo_root"]),
|
|
292
|
+
"ts" => @wall_clock.call.utc.iso8601(3),
|
|
293
|
+
"work_state" => { "started" => "running", "stalled" => "running", "done" => "completed" }.fetch(event, "failed"),
|
|
294
|
+
"outcome_class" => outcome_class,
|
|
295
|
+
"artifact_report_status" => record["artifact_report_status"],
|
|
296
|
+
"receipt_path" => portable(record["receipt_path"]),
|
|
297
|
+
"end_sha" => record["end_sha"],
|
|
298
|
+
"turn_commits" => (record["turn_commits"] if record["outcome"]),
|
|
299
|
+
"model" => (record["model"] if record["outcome"]),
|
|
300
|
+
"elapsed_s" => record["elapsed_s"],
|
|
301
|
+
"settled_at" => (record["notified_at"] if record["outcome"]),
|
|
302
|
+
"follow_ups" => record["follow_ups"],
|
|
303
|
+
"final_message" => (record["final_message"] if record["outcome"]),
|
|
304
|
+
"final_message_path" => (portable(record["final_message_path"]) if record["outcome"]),
|
|
305
|
+
"stall" => stall,
|
|
306
|
+
"reason" => reason,
|
|
307
|
+
"msg" => msg
|
|
308
|
+
}.compact
|
|
309
|
+
@out.puts(JSON.generate(line))
|
|
310
|
+
@out.flush
|
|
311
|
+
@emitted += 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def deliver(record, key)
|
|
315
|
+
@delivered[record["session_id"]] = key
|
|
316
|
+
save_cursor
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def within_row_grace?(record)
|
|
320
|
+
notified = Time.iso8601(record["notified_at"].to_s)
|
|
321
|
+
@wall_clock.call - notified < ROW_GRACE_S
|
|
322
|
+
rescue ArgumentError
|
|
323
|
+
false
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def dispatch_row(record)
|
|
327
|
+
path = Harnex::DispatchHistory.path_for(record["repo_root"].to_s)
|
|
328
|
+
return nil unless File.file?(path)
|
|
329
|
+
|
|
330
|
+
File.readlines(path).reverse_each do |line|
|
|
331
|
+
row = JSON.parse(line)
|
|
332
|
+
return row if Harnex::DispatchHistory.end_record?(row) && row["session_id"] == record["session_id"]
|
|
333
|
+
rescue JSON::ParserError
|
|
334
|
+
next
|
|
335
|
+
end
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Live status for this exact attempt: a reused id must not be mistaken
|
|
340
|
+
# for the dispatch the record describes.
|
|
341
|
+
def fetch_status(record)
|
|
342
|
+
registry = Harnex.read_registry(record["repo_root"].to_s, record["id"])
|
|
343
|
+
return nil unless registry && registry["session_id"] == record["session_id"]
|
|
344
|
+
|
|
345
|
+
uri = URI("http://#{registry['host']}:#{registry['port']}/status")
|
|
346
|
+
request = Net::HTTP::Get.new(uri)
|
|
347
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
348
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 2) { |http| http.request(request) }
|
|
349
|
+
response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
|
|
350
|
+
rescue StandardError
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def load_cursor
|
|
355
|
+
data = JSON.parse(File.read(@cursor_path))
|
|
356
|
+
@delivered = data["delivered"].is_a?(Hash) ? data["delivered"] : {}
|
|
357
|
+
@stalled = data["stalled"].is_a?(Hash) ? data["stalled"] : {}
|
|
358
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
359
|
+
@delivered = {}
|
|
360
|
+
@stalled = {}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def save_cursor
|
|
364
|
+
FileUtils.mkdir_p(File.dirname(@cursor_path), mode: 0o700)
|
|
365
|
+
state = { schema_version: 1, delivered: @delivered }
|
|
366
|
+
state[:stalled] = @stalled unless @stalled.empty?
|
|
367
|
+
Harnex.atomic_write_json(@cursor_path, state)
|
|
368
|
+
rescue StandardError => e
|
|
369
|
+
@err.puts("harnex watch: could not save the owner cursor (#{e.class}); results may repeat after a restart")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Records outlive delivery; drop cursor entries only for vanished records.
|
|
373
|
+
def prune_cursor(seen)
|
|
374
|
+
stale = @delivered.keys.reject { |session_id| seen[session_id] }
|
|
375
|
+
gone_stalls = @stalled.keys.reject { |session_id| seen[session_id] }
|
|
376
|
+
return if stale.empty? && gone_stalls.empty?
|
|
377
|
+
|
|
378
|
+
stale.each { |session_id| @delivered.delete(session_id) }
|
|
379
|
+
gone_stalls.each { |session_id| @stalled.delete(session_id) }
|
|
380
|
+
save_cursor
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def portable(path)
|
|
384
|
+
return nil if path.nil? || path.to_s.empty?
|
|
385
|
+
|
|
386
|
+
Harnex::DispatchHistory.portable_path(path.to_s)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def duration(seconds)
|
|
390
|
+
value = seconds.to_f.round
|
|
391
|
+
return "#{value}s" if value < 60
|
|
392
|
+
return format("%dm%02ds", value / 60, value % 60) if value < 600
|
|
393
|
+
return "#{(value / 60.0).round}m" if value < 3600
|
|
394
|
+
|
|
395
|
+
hours, rest = value.divmod(3600)
|
|
396
|
+
format("%dh%02dm", hours, rest / 60)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
data/lib/harnex/pricing.rb
CHANGED
|
@@ -40,16 +40,11 @@ module Harnex
|
|
|
40
40
|
# provider, so the caller passes `input_includes_cached:` (sourced from
|
|
41
41
|
# `Adapters::Base#usage_input_includes_cached?`):
|
|
42
42
|
#
|
|
43
|
-
# -
|
|
44
|
-
# TokenUsageBreakdown reports cachedInputTokens as a subset of
|
|
45
|
-
# inputTokens and reasoningOutputTokens as a subset of outputTokens
|
|
46
|
-
# (verified against test/fixtures/codex_schema/v2 and a live captured
|
|
47
|
-
# row: input 3,084,697 + output 17,705 == total 3,102,402 exactly).
|
|
43
|
+
# - input_includes_cached: true: cached tokens are a subset of input.
|
|
48
44
|
# Billable input = input - cached.
|
|
49
|
-
# -
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# Billable input = input; cached prices additively at the cached rate.
|
|
45
|
+
# - false: input is NON-cached; cached prices additively at the cached
|
|
46
|
+
# rate. (Pi reports provider cost directly, so this table is only the
|
|
47
|
+
# fallback for rows without a provider-reported cost.)
|
|
53
48
|
# - anthropic (false): the Messages API reports input_tokens EXCLUSIVE of
|
|
54
49
|
# cache reads (cache_read_input_tokens is a separate field). Cache
|
|
55
50
|
# WRITES (cache_creation_input_tokens, billed at 1.25x input) have no
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
# Observed protocol activity, not output/log freshness. Retains timestamps,
|
|
5
|
+
# tool names and a tool count only: thinking, messages, tool arguments and
|
|
6
|
+
# results never enter this object.
|
|
7
|
+
class ActivityTracker
|
|
8
|
+
def initialize(transport:, monotonic_clock: nil, wall_clock: nil)
|
|
9
|
+
@source = { stdio_jsonl_rpc: "pi_rpc" }[transport]
|
|
10
|
+
@clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
11
|
+
@wall_clock = wall_clock || -> { Time.now }
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@turn_active = false
|
|
14
|
+
@model_active = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start_turn
|
|
18
|
+
@mutex.synchronize { start_turn_locked } if @source
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def settle
|
|
22
|
+
@mutex.synchronize { @turn_active = @model_active = false } if @source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def observe_pi(message)
|
|
26
|
+
return unless @source == "pi_rpc"
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
case message["type"]
|
|
30
|
+
when "agent_start", "turn_start"
|
|
31
|
+
start_turn_locked
|
|
32
|
+
when "agent_settled"
|
|
33
|
+
@turn_active = @model_active = false
|
|
34
|
+
when "message_start", "message_end"
|
|
35
|
+
if message["message"].is_a?(Hash) && message["message"]["role"] == "assistant"
|
|
36
|
+
model_activity_locked(finished: message["type"] == "message_end")
|
|
37
|
+
end
|
|
38
|
+
when "message_update"
|
|
39
|
+
event = message["assistantMessageEvent"] || {}
|
|
40
|
+
if %w[text_delta thinking_delta toolcall_delta].include?(event["type"]) && !event["delta"].to_s.empty?
|
|
41
|
+
model_activity_locked
|
|
42
|
+
end
|
|
43
|
+
when "tool_execution_start"
|
|
44
|
+
@last_tool = stamp
|
|
45
|
+
@tool_calls = @tool_calls.to_i + 1
|
|
46
|
+
@current_tool = [message["toolName"].to_s, @last_tool]
|
|
47
|
+
when "tool_execution_update"
|
|
48
|
+
@last_tool = stamp
|
|
49
|
+
when "tool_execution_end"
|
|
50
|
+
@last_tool = stamp
|
|
51
|
+
@current_tool = nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def snapshot
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
now = @clock.call
|
|
59
|
+
{
|
|
60
|
+
status: @source ? "observed" : "unknown",
|
|
61
|
+
source: @source,
|
|
62
|
+
turn_active: @source ? @turn_active : nil,
|
|
63
|
+
turn_started_at: @turn_active ? @turn_start&.last : nil,
|
|
64
|
+
turn_age_s: @turn_active ? age(@turn_start, now) : nil,
|
|
65
|
+
model_active: @source ? @model_active : nil,
|
|
66
|
+
model_started_at: @model_active ? @model_start&.last : nil,
|
|
67
|
+
model_age_s: @model_active ? age(@model_start, now) : nil,
|
|
68
|
+
last_model_activity_at: @last_model&.last,
|
|
69
|
+
model_idle_s: age(@last_model, now),
|
|
70
|
+
last_tool_activity_at: @last_tool&.last,
|
|
71
|
+
tool_idle_s: age(@last_tool, now),
|
|
72
|
+
# #87: what the worker is doing now, for progress lines.
|
|
73
|
+
current_tool: @turn_active ? @current_tool&.first : nil,
|
|
74
|
+
current_tool_s: @turn_active && @current_tool ? age(@current_tool.last, now) : nil,
|
|
75
|
+
tool_calls: @source ? @tool_calls.to_i : nil
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def start_turn_locked
|
|
83
|
+
return if @turn_active
|
|
84
|
+
|
|
85
|
+
@turn_active = true
|
|
86
|
+
@turn_start = stamp
|
|
87
|
+
@model_active = false
|
|
88
|
+
@model_start = @last_model = @last_tool = @current_tool = nil
|
|
89
|
+
@tool_calls = 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def model_activity_locked(finished: false)
|
|
93
|
+
current = stamp
|
|
94
|
+
@model_start = current unless @model_active || finished
|
|
95
|
+
@last_model = current
|
|
96
|
+
@model_active = !finished
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stamp
|
|
100
|
+
[@clock.call, @wall_clock.call.getutc.iso8601(3)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def age(value, now)
|
|
104
|
+
value ? [now - value.first, 0.0].max.round(3) : nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -66,7 +66,7 @@ module Harnex
|
|
|
66
66
|
when ["POST", "/stop"]
|
|
67
67
|
return unauthorized(client) unless authorized?(headers)
|
|
68
68
|
|
|
69
|
-
json(client, 200, @session.inject_stop)
|
|
69
|
+
json(client, 200, @session.inject_stop(**parse_stop_body(body)))
|
|
70
70
|
when ["POST", "/send"]
|
|
71
71
|
return unauthorized(client) unless authorized?(headers)
|
|
72
72
|
|
|
@@ -126,6 +126,16 @@ module Harnex
|
|
|
126
126
|
client.close unless client.closed?
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
def parse_stop_body(body)
|
|
130
|
+
parsed = body.to_s.empty? ? {} : JSON.parse(body)
|
|
131
|
+
raise ArgumentError, "stop body must be a JSON object" unless parsed.is_a?(Hash)
|
|
132
|
+
raise ArgumentError, "stop accepts only reason and origin" unless (parsed.keys - %w[reason origin]).empty?
|
|
133
|
+
|
|
134
|
+
context = { reason: parsed.fetch("reason", "manual"), origin: parsed.fetch("origin", "api") }
|
|
135
|
+
StopRequest.validate!(**context)
|
|
136
|
+
context
|
|
137
|
+
end
|
|
138
|
+
|
|
129
139
|
def parse_send_body(headers, body)
|
|
130
140
|
if headers["content-type"].to_s.include?("application/json")
|
|
131
141
|
parsed = JSON.parse(body.empty? ? "{}" : body)
|