harnex 0.7.13 → 0.8.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 +82 -1
- data/README.md +53 -11
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +58 -6
- data/lib/harnex/artifact_report.rb +507 -35
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/history.rb +52 -2
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +93 -6
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +164 -62
- data/lib/harnex/commands/watch.rb +5 -2
- data/lib/harnex/core.rb +11 -1
- data/lib/harnex/dispatch_history.rb +95 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +295 -15
- data/lib/harnex/terminal_status.rb +8 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +5 -2
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -14,6 +14,19 @@ module Harnex
|
|
|
14
14
|
EVENT_PREDICATES = %w[task_complete task_failed].freeze
|
|
15
15
|
LEGACY_EVENT_TYPES = %w[agent_state exited task_complete task_failed].freeze
|
|
16
16
|
|
|
17
|
+
# Exit-code contract for `--until done` (documented in guides/04_monitoring.md).
|
|
18
|
+
DONE_EXIT_DONE = 0
|
|
19
|
+
DONE_EXIT_FAILED = 1
|
|
20
|
+
DONE_EXIT_REJECTED_PROOF = 2
|
|
21
|
+
DONE_EXIT_NO_SESSION = 3
|
|
22
|
+
DONE_EXIT_TIMEOUT = 124
|
|
23
|
+
|
|
24
|
+
# Outcome classes where the process completed but the work was not
|
|
25
|
+
# accepted: proof was missing, invalid, rejected, or nothing happened.
|
|
26
|
+
REJECTED_PROOF_CLASSES = %w[
|
|
27
|
+
completed_no_activity report_missing report_invalid report_rejected
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
17
30
|
def self.usage(program_name = "harnex wait")
|
|
18
31
|
<<~TEXT
|
|
19
32
|
Usage: #{program_name} [options]
|
|
@@ -41,8 +54,18 @@ module Harnex
|
|
|
41
54
|
#{program_name} --id cx-i-42 --until prompt --timeout 120
|
|
42
55
|
#{program_name} --id cx-i-42
|
|
43
56
|
|
|
57
|
+
Exit codes for --until done:
|
|
58
|
+
0 completed with accepted work
|
|
59
|
+
1 failed (work failed, process failed, or killed)
|
|
60
|
+
2 completed but proof rejected (completed_no_activity,
|
|
61
|
+
report_missing, report_invalid, report_rejected)
|
|
62
|
+
3 no such session (no live, start, event, or terminal signal)
|
|
63
|
+
124 --timeout elapsed while the session was still running
|
|
64
|
+
|
|
44
65
|
Gotchas:
|
|
45
66
|
done is the safest work-level fence for monitors.
|
|
67
|
+
While the session's pid is alive, --until done blocks (up to
|
|
68
|
+
--timeout); "no signal yet" from a live worker is never terminal.
|
|
46
69
|
task_complete/task_failed are event predicates; prompt/busy are live state polls.
|
|
47
70
|
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
48
71
|
Exit waits can resolve from terminal summary rows when live registry/
|
|
@@ -134,7 +157,9 @@ module Harnex
|
|
|
134
157
|
end
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
|
|
160
|
+
# min_ts: with a live session in view, events written before it started
|
|
161
|
+
# belong to an earlier run that reused the id — never match on them.
|
|
162
|
+
def scan_events(path, offset, predicate, task_complete_seen, start_time, min_ts: nil)
|
|
138
163
|
return [nil, offset, task_complete_seen] unless File.exist?(path) && File.size(path) > offset
|
|
139
164
|
|
|
140
165
|
File.open(path, "r") do |f|
|
|
@@ -142,6 +167,7 @@ module Harnex
|
|
|
142
167
|
f.each_line do |line|
|
|
143
168
|
event = parse_event(line)
|
|
144
169
|
next unless event
|
|
170
|
+
next if stale_event?(event, min_ts)
|
|
145
171
|
|
|
146
172
|
task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
|
|
147
173
|
if matches?(event, predicate, task_complete_seen)
|
|
@@ -154,6 +180,17 @@ module Harnex
|
|
|
154
180
|
[nil, offset, task_complete_seen]
|
|
155
181
|
end
|
|
156
182
|
|
|
183
|
+
def stale_event?(event, min_ts)
|
|
184
|
+
return false unless min_ts
|
|
185
|
+
|
|
186
|
+
# Events without a parseable ts (legacy formats) cannot be dated;
|
|
187
|
+
# treat them as fresh rather than silently dropping completion signals.
|
|
188
|
+
ts = parse_wait_time(event["ts"])
|
|
189
|
+
return false unless ts
|
|
190
|
+
|
|
191
|
+
ts < min_ts
|
|
192
|
+
end
|
|
193
|
+
|
|
157
194
|
def parse_event(line)
|
|
158
195
|
event = JSON.parse(line)
|
|
159
196
|
event.is_a?(Hash) ? event : nil
|
|
@@ -206,94 +243,157 @@ module Harnex
|
|
|
206
243
|
seq: event["seq"],
|
|
207
244
|
waited_seconds: waited
|
|
208
245
|
}
|
|
246
|
+
exit_code = 0
|
|
209
247
|
if predicate == "done"
|
|
210
248
|
failed = done_event_failed?(event)
|
|
249
|
+
exit_code = failed ? done_failure_exit_code(event["outcome_class"]) : DONE_EXIT_DONE
|
|
211
250
|
payload.merge!(
|
|
212
251
|
ok: !failed,
|
|
213
252
|
status: failed ? "failed" : "done",
|
|
253
|
+
wait_result: done_wait_result(exit_code),
|
|
214
254
|
state: "running",
|
|
215
255
|
process_state: "running",
|
|
216
256
|
terminal: false,
|
|
217
257
|
task_complete: !failed,
|
|
258
|
+
task_failed: failed,
|
|
218
259
|
done: !failed,
|
|
219
|
-
work_state: failed ? "failed" : "completed"
|
|
260
|
+
work_state: failed ? "failed" : "completed",
|
|
261
|
+
outcome_class: event["outcome_class"],
|
|
262
|
+
artifact_report_status: event["artifact_report_status"]
|
|
220
263
|
)
|
|
221
264
|
payload[:last_error] = event["message"] || event["error"] if failed
|
|
222
265
|
end
|
|
223
266
|
puts JSON.generate(payload)
|
|
224
|
-
|
|
267
|
+
exit_code
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def done_failure_exit_code(outcome_class)
|
|
271
|
+
REJECTED_PROOF_CLASSES.include?(outcome_class.to_s) ? DONE_EXIT_REJECTED_PROOF : DONE_EXIT_FAILED
|
|
272
|
+
end
|
|
225
273
|
|
|
226
|
-
|
|
274
|
+
def done_wait_result(exit_code)
|
|
275
|
+
case exit_code
|
|
276
|
+
when DONE_EXIT_DONE then "done"
|
|
277
|
+
when DONE_EXIT_REJECTED_PROOF then "rejected_proof"
|
|
278
|
+
else "failed"
|
|
279
|
+
end
|
|
227
280
|
end
|
|
228
281
|
|
|
282
|
+
# Contract: while the session's pid is alive, block (up to --timeout).
|
|
283
|
+
# Liveness is re-checked every poll from the registry, falling back to an
|
|
284
|
+
# uncompleted dispatch-start row, so a running worker is never classified
|
|
285
|
+
# as dead just because one signal source is missing. "No signal yet"
|
|
286
|
+
# while alive is not terminal.
|
|
229
287
|
def wait_until_done
|
|
230
288
|
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
231
289
|
events_path = Harnex.events_log_path(repo_root, @options[:id])
|
|
232
290
|
exit_path = Harnex.exit_status_path(repo_root, @options[:id])
|
|
233
|
-
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
234
291
|
start_time = Time.now
|
|
235
292
|
deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
|
|
236
293
|
|
|
237
294
|
offset = 0
|
|
238
295
|
task_complete_seen = false
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
242
|
-
return status if status
|
|
296
|
+
observed_live = nil
|
|
297
|
+
session_started_at = nil
|
|
243
298
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
unless File.exist?(events_path)
|
|
250
|
-
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
251
|
-
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
|
|
252
|
-
task_complete: false, done: false, work_state: "unknown", status: "unknown")
|
|
253
|
-
return 1
|
|
299
|
+
loop do
|
|
300
|
+
live = live_session(repo_root)
|
|
301
|
+
if live
|
|
302
|
+
observed_live = live
|
|
303
|
+
session_started_at ||= parse_wait_time(live["started_at"])
|
|
254
304
|
end
|
|
255
|
-
end
|
|
256
305
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
loop do
|
|
260
|
-
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
306
|
+
status, offset, task_complete_seen =
|
|
307
|
+
scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
|
|
261
308
|
return status if status
|
|
262
309
|
|
|
263
|
-
unless
|
|
310
|
+
unless live
|
|
311
|
+
if observed_live
|
|
312
|
+
return resolve_after_exit(repo_root, events_path, exit_path, offset,
|
|
313
|
+
task_complete_seen, start_time, observed_live, session_started_at)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Historical query: never observed the session alive.
|
|
317
|
+
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
318
|
+
|
|
264
319
|
terminal = done_status(repo_root)
|
|
265
320
|
return emit_done_terminal_status(terminal) if terminal
|
|
266
|
-
|
|
321
|
+
|
|
322
|
+
unless File.exist?(events_path)
|
|
323
|
+
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
324
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "no_such_session",
|
|
325
|
+
wait_result: "no_such_session", state: "unknown", process_state: "unknown",
|
|
326
|
+
terminal: false, task_complete: false, done: false, work_state: "unknown")
|
|
327
|
+
return DONE_EXIT_NO_SESSION
|
|
328
|
+
end
|
|
267
329
|
end
|
|
268
330
|
|
|
269
331
|
if deadline && Time.now >= deadline
|
|
270
332
|
waited = (Time.now - start_time).round(1)
|
|
271
|
-
puts JSON.generate(ok: false, id: @options[:id], status: "timeout",
|
|
272
|
-
|
|
273
|
-
|
|
333
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", wait_result: "timeout",
|
|
334
|
+
waited_seconds: waited, done: false,
|
|
335
|
+
work_state: live ? "running" : "unknown")
|
|
336
|
+
return DONE_EXIT_TIMEOUT
|
|
274
337
|
end
|
|
275
338
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await_exit_status(exit_path)
|
|
280
|
-
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
339
|
+
sleep EVENT_POLL_INTERVAL
|
|
340
|
+
end
|
|
341
|
+
end
|
|
281
342
|
|
|
282
|
-
|
|
283
|
-
|
|
343
|
+
# Registry row first; when it is not visible from this context, an
|
|
344
|
+
# uncompleted dispatch-start row with an alive pid still proves liveness.
|
|
345
|
+
def live_session(repo_root)
|
|
346
|
+
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
347
|
+
return registry if registry
|
|
284
348
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
terminal: true, task_complete: false, done: false, work_state: "unknown",
|
|
288
|
-
waited_seconds: waited)
|
|
289
|
-
return 1
|
|
290
|
-
end
|
|
291
|
-
else
|
|
292
|
-
final_event_deadline = nil
|
|
293
|
-
end
|
|
349
|
+
Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
|
|
350
|
+
end
|
|
294
351
|
|
|
295
|
-
|
|
352
|
+
# The session was alive and its pid is now gone: give teardown a bounded
|
|
353
|
+
# grace to land the final events, summary row, and exit-status file, then
|
|
354
|
+
# classify from the freshest signal that belongs to the observed session.
|
|
355
|
+
def resolve_after_exit(repo_root, events_path, exit_path, offset, task_complete_seen,
|
|
356
|
+
start_time, observed_live, session_started_at)
|
|
357
|
+
await_exit_status(exit_path)
|
|
358
|
+
|
|
359
|
+
status, _offset, _seen =
|
|
360
|
+
scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
|
|
361
|
+
return status if status
|
|
362
|
+
|
|
363
|
+
if File.exist?(exit_path) && fresh_exit_status?(exit_path, observed_live, session_started_at)
|
|
364
|
+
return emit_done_exit_status(exit_path, @options[:id])
|
|
296
365
|
end
|
|
366
|
+
|
|
367
|
+
terminal = done_status(repo_root, min_started_at: session_started_at)
|
|
368
|
+
return emit_done_terminal_status(terminal) if terminal
|
|
369
|
+
|
|
370
|
+
waited = (Time.now - start_time).round(1)
|
|
371
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
|
|
372
|
+
terminal: true, task_complete: false, done: false, work_state: "unknown",
|
|
373
|
+
wait_result: "failed", waited_seconds: waited)
|
|
374
|
+
DONE_EXIT_FAILED
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Guards against classifying from an exit-status file left behind by an
|
|
378
|
+
# earlier dispatch that reused the same id.
|
|
379
|
+
def fresh_exit_status?(exit_path, observed_live, session_started_at)
|
|
380
|
+
data = JSON.parse(File.read(exit_path))
|
|
381
|
+
observed_session_id = observed_live["session_id"].to_s
|
|
382
|
+
exit_session_id = data["session_id"].to_s
|
|
383
|
+
return exit_session_id == observed_session_id unless exit_session_id.empty? || observed_session_id.empty?
|
|
384
|
+
|
|
385
|
+
exited_at = parse_wait_time(data["exited_at"])
|
|
386
|
+
return true unless exited_at && session_started_at
|
|
387
|
+
|
|
388
|
+
exited_at >= session_started_at
|
|
389
|
+
rescue StandardError
|
|
390
|
+
true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def parse_wait_time(value)
|
|
394
|
+
Time.iso8601(value.to_s)
|
|
395
|
+
rescue ArgumentError
|
|
396
|
+
nil
|
|
297
397
|
end
|
|
298
398
|
|
|
299
399
|
def wait_until_state
|
|
@@ -438,11 +538,16 @@ module Harnex
|
|
|
438
538
|
status
|
|
439
539
|
end
|
|
440
540
|
|
|
441
|
-
def done_status(repo_root)
|
|
541
|
+
def done_status(repo_root, min_started_at: nil)
|
|
442
542
|
status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
|
|
443
543
|
return nil unless status
|
|
444
544
|
return nil unless status["done"] || status["terminal"]
|
|
445
545
|
|
|
546
|
+
if min_started_at
|
|
547
|
+
row_started = parse_wait_time(status["started_at"])
|
|
548
|
+
return nil if row_started && row_started < min_started_at
|
|
549
|
+
end
|
|
550
|
+
|
|
446
551
|
status
|
|
447
552
|
end
|
|
448
553
|
|
|
@@ -454,6 +559,7 @@ module Harnex
|
|
|
454
559
|
exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
|
|
455
560
|
state = exit_success ? "completed" : "failed"
|
|
456
561
|
done = task_complete || exit_success
|
|
562
|
+
result_code = done ? DONE_EXIT_DONE : done_failure_exit_code(data["outcome_class"])
|
|
457
563
|
payload = data.merge(
|
|
458
564
|
"ok" => done,
|
|
459
565
|
"id" => id,
|
|
@@ -463,32 +569,26 @@ module Harnex
|
|
|
463
569
|
"task_complete" => task_complete,
|
|
464
570
|
"task_failed" => task_failed,
|
|
465
571
|
"done" => done,
|
|
466
|
-
"work_state" => Harnex.work_state_for(state, task_complete: task_complete)
|
|
572
|
+
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
573
|
+
"wait_result" => done_wait_result(result_code)
|
|
467
574
|
)
|
|
468
|
-
success = done
|
|
469
575
|
puts JSON.generate(payload)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
|
|
576
|
+
result_code
|
|
473
577
|
rescue JSON::ParserError
|
|
474
578
|
puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
|
|
475
|
-
task_complete: false, done: false, work_state: "failed",
|
|
476
|
-
|
|
579
|
+
task_complete: false, done: false, work_state: "failed",
|
|
580
|
+
status: "invalid_exit_status", wait_result: "failed")
|
|
581
|
+
DONE_EXIT_FAILED
|
|
477
582
|
end
|
|
478
583
|
|
|
479
584
|
def emit_done_terminal_status(status)
|
|
480
585
|
payload = terminal_payload(status)
|
|
481
586
|
payload[:ok] = !!payload[:done]
|
|
482
587
|
payload[:status] = payload[:done] ? "done" : status["state"]
|
|
588
|
+
result_code = payload[:done] ? DONE_EXIT_DONE : done_failure_exit_code(status["outcome_class"])
|
|
589
|
+
payload[:wait_result] = done_wait_result(result_code)
|
|
483
590
|
puts JSON.generate(payload)
|
|
484
|
-
|
|
485
|
-
if payload[:ok]
|
|
486
|
-
0
|
|
487
|
-
elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
|
|
488
|
-
status["exit_code"]
|
|
489
|
-
else
|
|
490
|
-
1
|
|
491
|
-
end
|
|
591
|
+
result_code
|
|
492
592
|
end
|
|
493
593
|
|
|
494
594
|
def emit_terminal_status(status)
|
|
@@ -520,6 +620,8 @@ module Harnex
|
|
|
520
620
|
task_failed: task_failed,
|
|
521
621
|
done: done,
|
|
522
622
|
work_state: work_state,
|
|
623
|
+
outcome_class: status["outcome_class"],
|
|
624
|
+
artifact_report_status: status["artifact_report_status"],
|
|
523
625
|
exit: status["exit"],
|
|
524
626
|
exit_code: status["exit_code"],
|
|
525
627
|
summary_out: status["summary_out"],
|
|
@@ -304,6 +304,8 @@ module Harnex
|
|
|
304
304
|
exit_code: exit_code,
|
|
305
305
|
status: payload["status"],
|
|
306
306
|
work_state: payload["work_state"],
|
|
307
|
+
outcome_class: payload["outcome_class"],
|
|
308
|
+
artifact_report_status: payload["artifact_report_status"],
|
|
307
309
|
task_complete: payload["task_complete"] || payload["event"] == "task_complete",
|
|
308
310
|
task_failed: payload["task_failed"] || payload["event"] == "task_failed",
|
|
309
311
|
done: payload["done"],
|
|
@@ -348,8 +350,9 @@ module Harnex
|
|
|
348
350
|
-h, --help Show this help
|
|
349
351
|
|
|
350
352
|
`harnex watch` is the safe watcher for existing --tmux or detached
|
|
351
|
-
dispatches.
|
|
352
|
-
|
|
353
|
+
dispatches. Exit codes follow the `harnex wait --until done` contract:
|
|
354
|
+
0 accepted work, 1 failed, 2 completed-but-proof-rejected, 3 no such
|
|
355
|
+
session, 124 for --max-wait timeouts.
|
|
353
356
|
|
|
354
357
|
For launch-and-babysit stall recovery, use `harnex run --watch`.
|
|
355
358
|
TEXT
|
data/lib/harnex/core.rb
CHANGED
|
@@ -157,8 +157,18 @@ module Harnex
|
|
|
157
157
|
{}
|
|
158
158
|
end
|
|
159
159
|
|
|
160
|
+
# Canonical path resolution shared by every registry/exit/events writer and
|
|
161
|
+
# reader. realpath collapses symlinked prefixes so a session registered from
|
|
162
|
+
# a symlinked cwd stays visible to checkers resolving the physical path.
|
|
163
|
+
def canonical_repo_root(path)
|
|
164
|
+
expanded = File.expand_path(path)
|
|
165
|
+
File.realpath(expanded)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
expanded
|
|
168
|
+
end
|
|
169
|
+
|
|
160
170
|
def repo_key(repo_root)
|
|
161
|
-
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
171
|
+
Digest::SHA256.hexdigest(canonical_repo_root(repo_root))[0, 16]
|
|
162
172
|
end
|
|
163
173
|
|
|
164
174
|
def normalize_id(id)
|
|
@@ -62,12 +62,107 @@ module Harnex
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def start_record?(record)
|
|
66
|
+
record.is_a?(Hash) && record["record_type"] == "dispatch_start"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def end_record?(record)
|
|
70
|
+
return false unless record.is_a?(Hash)
|
|
71
|
+
return true if record["record_type"] == "dispatch_end"
|
|
72
|
+
|
|
73
|
+
# Legacy end rows predate record_type.
|
|
74
|
+
record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def end_matches_start?(end_record, start_record)
|
|
78
|
+
end_session = end_record["session_id"].to_s
|
|
79
|
+
start_session = start_record["session_id"].to_s
|
|
80
|
+
return end_session == start_session unless end_session.empty? || start_session.empty?
|
|
81
|
+
|
|
82
|
+
end_record["id"].to_s == start_record["id"].to_s &&
|
|
83
|
+
end_record["started_at"].to_s == start_record["started_at"].to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Latest start row for `id` and any end row that completes it.
|
|
87
|
+
def latest_rows(path, id)
|
|
88
|
+
latest_start = nil
|
|
89
|
+
matching_end = nil
|
|
90
|
+
|
|
91
|
+
return { start: nil, end: nil } unless File.file?(path)
|
|
92
|
+
|
|
93
|
+
File.foreach(path) do |line|
|
|
94
|
+
record = JSON.parse(line)
|
|
95
|
+
next unless record.is_a?(Hash)
|
|
96
|
+
next unless record["id"].to_s == id
|
|
97
|
+
|
|
98
|
+
if start_record?(record)
|
|
99
|
+
latest_start = record
|
|
100
|
+
matching_end = nil
|
|
101
|
+
elsif end_record?(record)
|
|
102
|
+
matching_end = record if latest_start && end_matches_start?(record, latest_start)
|
|
103
|
+
end
|
|
104
|
+
rescue JSON::ParserError
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{ start: latest_start, end: matching_end }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A dispatch_start row with no completing end row whose pid is alive on
|
|
112
|
+
# this host — evidence of a running session even when the live registry
|
|
113
|
+
# is not visible from the caller's context.
|
|
114
|
+
def live_start_record(repo_root:, id:)
|
|
115
|
+
normalized_id = Harnex.normalize_id(id)
|
|
116
|
+
rows = latest_rows(path_for(repo_root), normalized_id)
|
|
117
|
+
start = rows[:start]
|
|
118
|
+
return nil unless start
|
|
119
|
+
return nil if rows[:end]
|
|
120
|
+
return nil unless same_host?(start)
|
|
121
|
+
|
|
122
|
+
pid = start["pid"]
|
|
123
|
+
return nil unless pid && Harnex.alive_pid?(pid)
|
|
124
|
+
|
|
125
|
+
start
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def same_host?(record)
|
|
131
|
+
host = record["host"].to_s
|
|
132
|
+
return true if host.empty?
|
|
133
|
+
|
|
134
|
+
host == Harnex.host_info[:host].to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Appended at registration so a running dispatch always has a durable
|
|
138
|
+
# trace; the dispatch_end row written in finalize_session! completes it.
|
|
139
|
+
def build_start_record(session)
|
|
140
|
+
{
|
|
141
|
+
schema_version: 1,
|
|
142
|
+
record_type: "dispatch_start",
|
|
143
|
+
id: session.id,
|
|
144
|
+
session_id: session.session_id,
|
|
145
|
+
pid: session.pid,
|
|
146
|
+
host: Harnex.host_info[:host],
|
|
147
|
+
cli: session.adapter.key,
|
|
148
|
+
description: session.description,
|
|
149
|
+
started_at: session.started_at.utc.iso8601,
|
|
150
|
+
repo_root: session.repo_root,
|
|
151
|
+
tier: session.__send__(:meta_hash)["tier"],
|
|
152
|
+
meta: session.__send__(:meta_hash),
|
|
153
|
+
summary_out_path: session.summary_out,
|
|
154
|
+
events_log_path: session.events_log_path
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
65
158
|
def build_record(session)
|
|
66
159
|
ended_at = session.ended_at || Time.now
|
|
67
160
|
status, terminal_event = classify(session)
|
|
68
161
|
{
|
|
69
162
|
schema_version: 1,
|
|
163
|
+
record_type: "dispatch_end",
|
|
70
164
|
id: session.id,
|
|
165
|
+
session_id: session.session_id,
|
|
71
166
|
description: session.description,
|
|
72
167
|
cli: session.adapter.key,
|
|
73
168
|
started_at: session.started_at.utc.iso8601,
|