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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. 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
@@ -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
- # - codex app-server (input_includes_cached: true): the JSON
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
- # - codex PTY (false): the scraped TUI line `input=X (+ Y cached)` shows
50
- # NON-cached input with cached as a separate additive count (fixture:
51
- # total 106,867 == input 104,158 + output 2,709, cached 250,880 > input).
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)