harnex 0.7.14 → 0.9.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.
@@ -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
- def scan_events(path, offset, predicate, task_complete_seen, start_time)
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,11 +243,14 @@ 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,
@@ -224,79 +264,136 @@ module Harnex
224
264
  payload[:last_error] = event["message"] || event["error"] if failed
225
265
  end
226
266
  puts JSON.generate(payload)
227
- return 1 if predicate == "done" && done_event_failed?(event)
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
228
273
 
229
- 0
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
230
280
  end
231
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.
232
287
  def wait_until_done
233
288
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
234
289
  events_path = Harnex.events_log_path(repo_root, @options[:id])
235
290
  exit_path = Harnex.exit_status_path(repo_root, @options[:id])
236
- registry = Harnex.read_registry(repo_root, @options[:id])
237
291
  start_time = Time.now
238
292
  deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
239
293
 
240
294
  offset = 0
241
295
  task_complete_seen = false
242
- final_event_deadline = nil
243
-
244
- status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
245
- return status if status
296
+ observed_live = nil
297
+ session_started_at = nil
246
298
 
247
- unless registry
248
- terminal = done_status(repo_root)
249
- return emit_done_terminal_status(terminal) if terminal
250
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
251
-
252
- unless File.exist?(events_path)
253
- warn("harnex wait: no session found with id #{@options[:id].inspect}")
254
- puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
255
- task_complete: false, done: false, work_state: "unknown", status: "unknown")
256
- 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"])
257
304
  end
258
- end
259
305
 
260
- target_pid = registry && registry["pid"]
261
-
262
- loop do
263
- 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)
264
308
  return status if status
265
309
 
266
- unless registry
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
+
267
319
  terminal = done_status(repo_root)
268
320
  return emit_done_terminal_status(terminal) if terminal
269
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
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
270
329
  end
271
330
 
272
331
  if deadline && Time.now >= deadline
273
332
  waited = (Time.now - start_time).round(1)
274
- puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited,
275
- done: false, work_state: "running")
276
- return 124
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
277
337
  end
278
338
 
279
- if target_pid && !Harnex.alive_pid?(target_pid)
280
- final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
281
- if Time.now >= final_event_deadline
282
- await_exit_status(exit_path)
283
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
339
+ sleep EVENT_POLL_INTERVAL
340
+ end
341
+ end
284
342
 
285
- terminal = done_status(repo_root)
286
- return emit_done_terminal_status(terminal) if terminal
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
287
348
 
288
- waited = (Time.now - start_time).round(1)
289
- puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
290
- terminal: true, task_complete: false, done: false, work_state: "unknown",
291
- waited_seconds: waited)
292
- return 1
293
- end
294
- else
295
- final_event_deadline = nil
296
- end
349
+ Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
350
+ end
297
351
 
298
- sleep EVENT_POLL_INTERVAL
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])
299
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
300
397
  end
301
398
 
302
399
  def wait_until_state
@@ -441,11 +538,16 @@ module Harnex
441
538
  status
442
539
  end
443
540
 
444
- def done_status(repo_root)
541
+ def done_status(repo_root, min_started_at: nil)
445
542
  status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
446
543
  return nil unless status
447
544
  return nil unless status["done"] || status["terminal"]
448
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
+
449
551
  status
450
552
  end
451
553
 
@@ -457,6 +559,7 @@ module Harnex
457
559
  exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
458
560
  state = exit_success ? "completed" : "failed"
459
561
  done = task_complete || exit_success
562
+ result_code = done ? DONE_EXIT_DONE : done_failure_exit_code(data["outcome_class"])
460
563
  payload = data.merge(
461
564
  "ok" => done,
462
565
  "id" => id,
@@ -466,32 +569,26 @@ module Harnex
466
569
  "task_complete" => task_complete,
467
570
  "task_failed" => task_failed,
468
571
  "done" => done,
469
- "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)
470
574
  )
471
- success = done
472
575
  puts JSON.generate(payload)
473
- return 0 if success
474
-
475
- exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
576
+ result_code
476
577
  rescue JSON::ParserError
477
578
  puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
478
- task_complete: false, done: false, work_state: "failed", status: "invalid_exit_status")
479
- 1
579
+ task_complete: false, done: false, work_state: "failed",
580
+ status: "invalid_exit_status", wait_result: "failed")
581
+ DONE_EXIT_FAILED
480
582
  end
481
583
 
482
584
  def emit_done_terminal_status(status)
483
585
  payload = terminal_payload(status)
484
586
  payload[:ok] = !!payload[:done]
485
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)
486
590
  puts JSON.generate(payload)
487
-
488
- if payload[:ok]
489
- 0
490
- elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
491
- status["exit_code"]
492
- else
493
- 1
494
- end
591
+ result_code
495
592
  end
496
593
 
497
594
  def emit_terminal_status(status)
@@ -350,8 +350,9 @@ module Harnex
350
350
  -h, --help Show this help
351
351
 
352
352
  `harnex watch` is the safe watcher for existing --tmux or detached
353
- dispatches. It exits 0 for task_complete/done, non-zero for task_failed
354
- or failed terminal summaries, and 124 for --max-wait timeouts.
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.
355
356
 
356
357
  For launch-and-babysit stall recovery, use `harnex run --watch`.
357
358
  TEXT
@@ -0,0 +1,166 @@
1
+ require "json"
2
+
3
+ module Harnex
4
+ module Config
5
+ CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
6
+ PHASE_POLICIES = %w[warn reject].freeze
7
+ RETENTION_DIRS = %w[events output].freeze
8
+ RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
9
+ RETENTION_ENV = {
10
+ "events" => {
11
+ "max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
12
+ "max_bytes" => "HARNEX_EVENTS_MAX_BYTES"
13
+ },
14
+ "output" => {
15
+ "max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
16
+ "max_bytes" => "HARNEX_OUTPUT_MAX_BYTES"
17
+ }
18
+ }.freeze
19
+ DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
20
+ [dir, { "max_age_days" => 45, "max_bytes" => 1_073_741_824 }]
21
+ end.freeze
22
+
23
+ ConfigError = Class.new(StandardError)
24
+
25
+ RepoConfig = Struct.new(:root, :path, :data, keyword_init: true) do
26
+ def present?
27
+ !path.nil? && File.file?(path)
28
+ end
29
+
30
+ def phase
31
+ data.is_a?(Hash) ? data["phase"] : nil
32
+ end
33
+
34
+ def retention
35
+ data.is_a?(Hash) ? data["retention"] : nil
36
+ end
37
+ end
38
+
39
+ module_function
40
+
41
+ def load_repo(start_path)
42
+ root = DispatchHistory.find_git_root(start_path)
43
+ return RepoConfig.new(root: nil, path: nil, data: {}) unless root
44
+
45
+ path = File.join(root, CONFIG_RELATIVE_PATH)
46
+ return RepoConfig.new(root: root, path: path, data: {}) unless File.file?(path)
47
+
48
+ parsed = JSON.parse(File.read(path))
49
+ validate_document!(parsed, path)
50
+ RepoConfig.new(root: root, path: path, data: parsed)
51
+ rescue JSON::ParserError => e
52
+ raise ConfigError, "#{path}: malformed JSON: #{e.message}"
53
+ end
54
+
55
+ def validate_document!(document, path)
56
+ unless document.is_a?(Hash)
57
+ raise ConfigError, "#{path}: config must be a JSON object"
58
+ end
59
+
60
+ validate_phase!(document["phase"], path) if document.key?("phase")
61
+ validate_retention!(document["retention"], path) if document.key?("retention")
62
+ end
63
+
64
+ def validate_phase!(phase, path)
65
+ unless phase.is_a?(Hash)
66
+ raise ConfigError, "#{path}: $.phase must be an object"
67
+ end
68
+
69
+ allowlist = phase["allowlist"]
70
+ unless allowlist.is_a?(Array)
71
+ raise ConfigError, "#{path}: $.phase.allowlist must be an array"
72
+ end
73
+
74
+ allowlist.each_with_index do |entry, index|
75
+ next if entry.is_a?(String) && !entry.strip.empty?
76
+
77
+ raise ConfigError,
78
+ "#{path}: $.phase.allowlist[#{index}] must be a non-empty string"
79
+ end
80
+
81
+ policy = phase["policy"]
82
+ unless PHASE_POLICIES.include?(policy)
83
+ raise ConfigError,
84
+ "#{path}: $.phase.policy must be one of #{PHASE_POLICIES.join(', ')}"
85
+ end
86
+ end
87
+
88
+ def retention_limits(start_path = Dir.pwd, env: ENV, config: nil)
89
+ config ||= load_repo(start_path)
90
+ limits = deep_dup_retention_defaults
91
+ retention = config.retention
92
+
93
+ if retention
94
+ RETENTION_DIRS.each do |dir|
95
+ next unless retention.key?(dir)
96
+
97
+ RETENTION_FIELDS.each do |field|
98
+ next unless retention.fetch(dir).key?(field)
99
+
100
+ limits.fetch(dir)[field] = parse_positive_integer!(
101
+ retention.fetch(dir).fetch(field),
102
+ "#{config.path}: $.retention.#{dir}.#{field}"
103
+ )
104
+ end
105
+ end
106
+ end
107
+
108
+ RETENTION_ENV.each do |dir, fields|
109
+ fields.each do |field, name|
110
+ next unless env.key?(name)
111
+
112
+ limits.fetch(dir)[field] = parse_positive_integer!(
113
+ env.fetch(name),
114
+ name
115
+ )
116
+ end
117
+ end
118
+
119
+ limits
120
+ end
121
+
122
+ def validate_retention!(retention, path)
123
+ unless retention.is_a?(Hash)
124
+ raise ConfigError, "#{path}: $.retention must be an object"
125
+ end
126
+
127
+ retention.each do |dir, value|
128
+ unless RETENTION_DIRS.include?(dir)
129
+ raise ConfigError,
130
+ "#{path}: $.retention.#{dir} is not supported (expected events or output)"
131
+ end
132
+ unless value.is_a?(Hash)
133
+ raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
134
+ end
135
+
136
+ value.each do |field, limit|
137
+ unless RETENTION_FIELDS.include?(field)
138
+ raise ConfigError,
139
+ "#{path}: $.retention.#{dir}.#{field} is not supported"
140
+ end
141
+
142
+ parse_positive_integer!(
143
+ limit,
144
+ "#{path}: $.retention.#{dir}.#{field}"
145
+ )
146
+ end
147
+ end
148
+ end
149
+
150
+ def parse_positive_integer!(value, label)
151
+ text = value.to_s.strip
152
+ unless text.match?(/\A[0-9]+\z/)
153
+ raise ConfigError, "#{label} must be a positive integer"
154
+ end
155
+
156
+ integer = Integer(text)
157
+ raise ConfigError, "#{label} must be a positive integer" if integer <= 0
158
+
159
+ integer
160
+ end
161
+
162
+ def deep_dup_retention_defaults
163
+ DEFAULT_RETENTION_LIMITS.transform_values(&:dup)
164
+ end
165
+ end
166
+ end
data/lib/harnex/core.rb CHANGED
@@ -114,13 +114,6 @@ module Harnex
114
114
  text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
115
115
  end
116
116
 
117
- def default_summary_out_path(repo_root)
118
- root = repo_root.to_s
119
- return nil if root.empty?
120
-
121
- File.join(root, ".harnex", "dispatch.jsonl")
122
- end
123
-
124
117
  def git_capture_start(repo_root)
125
118
  sha = git_output(repo_root, "rev-parse", "HEAD")
126
119
  branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
@@ -157,8 +150,18 @@ module Harnex
157
150
  {}
158
151
  end
159
152
 
153
+ # Canonical path resolution shared by every registry/exit/events writer and
154
+ # reader. realpath collapses symlinked prefixes so a session registered from
155
+ # a symlinked cwd stays visible to checkers resolving the physical path.
156
+ def canonical_repo_root(path)
157
+ expanded = File.expand_path(path)
158
+ File.realpath(expanded)
159
+ rescue StandardError
160
+ expanded
161
+ end
162
+
160
163
  def repo_key(repo_root)
161
- Digest::SHA256.hexdigest(repo_root)[0, 16]
164
+ Digest::SHA256.hexdigest(canonical_repo_root(repo_root))[0, 16]
162
165
  end
163
166
 
164
167
  def normalize_id(id)
@@ -9,6 +9,12 @@ module Harnex
9
9
 
10
10
  MAX_REPO_WALK_LEVELS = 10
11
11
 
12
+ # v2 marks the unified era: one rich dispatch_end row per dispatch
13
+ # carrying both the thin envelope and the summary sections. Readers
14
+ # key on record_type, not this stamp; legacy clauses keep accepting
15
+ # v1 and envelope-less rows mixed in the same file.
16
+ SCHEMA_VERSION = 2
17
+
12
18
  def global_path
13
19
  File.join(STATE_DIR, "dispatch.jsonl")
14
20
  end
@@ -62,12 +68,111 @@ module Harnex
62
68
  end
63
69
  end
64
70
 
71
+ def start_record?(record)
72
+ record.is_a?(Hash) && record["record_type"] == "dispatch_start"
73
+ end
74
+
75
+ def end_record?(record)
76
+ return false unless record.is_a?(Hash)
77
+ return true if record["record_type"] == "dispatch_end"
78
+
79
+ # Legacy end rows predate record_type.
80
+ record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
81
+ end
82
+
83
+ def end_matches_start?(end_record, start_record)
84
+ end_session = end_record["session_id"].to_s
85
+ start_session = start_record["session_id"].to_s
86
+ return end_session == start_session unless end_session.empty? || start_session.empty?
87
+
88
+ end_record["id"].to_s == start_record["id"].to_s &&
89
+ end_record["started_at"].to_s == start_record["started_at"].to_s
90
+ end
91
+
92
+ # Latest start row for `id` and any end row that completes it.
93
+ def latest_rows(path, id)
94
+ latest_start = nil
95
+ matching_end = nil
96
+
97
+ return { start: nil, end: nil } unless File.file?(path)
98
+
99
+ File.foreach(path) do |line|
100
+ record = JSON.parse(line)
101
+ next unless record.is_a?(Hash)
102
+ next unless record["id"].to_s == id
103
+
104
+ if start_record?(record)
105
+ latest_start = record
106
+ matching_end = nil
107
+ elsif end_record?(record)
108
+ matching_end = record if latest_start && end_matches_start?(record, latest_start)
109
+ end
110
+ rescue JSON::ParserError
111
+ next
112
+ end
113
+
114
+ { start: latest_start, end: matching_end }
115
+ end
116
+
117
+ # A dispatch_start row with no completing end row whose pid is alive on
118
+ # this host — evidence of a running session even when the live registry
119
+ # is not visible from the caller's context.
120
+ def live_start_record(repo_root:, id:)
121
+ normalized_id = Harnex.normalize_id(id)
122
+ rows = latest_rows(path_for(repo_root), normalized_id)
123
+ start = rows[:start]
124
+ return nil unless start
125
+ return nil if rows[:end]
126
+ return nil unless same_host?(start)
127
+
128
+ pid = start["pid"]
129
+ return nil unless pid && Harnex.alive_pid?(pid)
130
+
131
+ start
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def same_host?(record)
137
+ host = record["host"].to_s
138
+ return true if host.empty?
139
+
140
+ host == Harnex.host_info[:host].to_s
141
+ end
142
+
143
+ # Appended at registration so a running dispatch always has a durable
144
+ # trace; the dispatch_end row written in finalize_session! completes it.
145
+ def build_start_record(session)
146
+ {
147
+ schema_version: SCHEMA_VERSION,
148
+ record_type: "dispatch_start",
149
+ id: session.id,
150
+ session_id: session.session_id,
151
+ pid: session.pid,
152
+ host: Harnex.host_info[:host],
153
+ cli: session.adapter.key,
154
+ description: session.description,
155
+ started_at: session.started_at.utc.iso8601,
156
+ repo_root: session.repo_root,
157
+ tier: session.__send__(:meta_hash)["tier"],
158
+ meta: session.__send__(:meta_hash),
159
+ summary_out_path: session.summary_out,
160
+ events_log_path: session.events_log_path
161
+ }
162
+ end
163
+
164
+ # The v2 end row: the thin envelope merged with the rich summary
165
+ # sections. The envelope carries no raw meta passthrough — the summary's
166
+ # meta section (a superset with provenance) rides in its place; top-level
167
+ # tier stays for the history renderer.
65
168
  def build_record(session)
66
169
  ended_at = session.ended_at || Time.now
67
170
  status, terminal_event = classify(session)
68
171
  {
69
- schema_version: 1,
172
+ schema_version: SCHEMA_VERSION,
173
+ record_type: "dispatch_end",
70
174
  id: session.id,
175
+ session_id: session.session_id,
71
176
  description: session.description,
72
177
  cli: session.adapter.key,
73
178
  started_at: session.started_at.utc.iso8601,
@@ -77,11 +182,10 @@ module Harnex
77
182
  terminal_event: terminal_event,
78
183
  commit_sha: commit_sha(session.git_start, session.git_end),
79
184
  tier: session.__send__(:meta_hash)["tier"],
80
- meta: session.__send__(:meta_hash),
81
185
  summary_out_path: session.summary_out,
82
186
  events_log_path: session.events_log_path,
83
187
  tmux_state: tmux_state(session.__send__(:summary_tmux_session))
84
- }
188
+ }.merge(session.__send__(:build_summary_record))
85
189
  end
86
190
 
87
191
  def classify(session)