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.
@@ -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,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
- 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
225
273
 
226
- 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
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
- final_event_deadline = nil
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
- unless registry
245
- terminal = done_status(repo_root)
246
- return emit_done_terminal_status(terminal) if terminal
247
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
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
- target_pid = registry && registry["pid"]
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 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
+
264
319
  terminal = done_status(repo_root)
265
320
  return emit_done_terminal_status(terminal) if terminal
266
- 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
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", waited_seconds: waited,
272
- done: false, work_state: "running")
273
- 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
274
337
  end
275
338
 
276
- if target_pid && !Harnex.alive_pid?(target_pid)
277
- final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
278
- if Time.now >= final_event_deadline
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
- terminal = done_status(repo_root)
283
- 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
284
348
 
285
- waited = (Time.now - start_time).round(1)
286
- puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
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
- 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])
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
- return 0 if success
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", status: "invalid_exit_status")
476
- 1
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. It exits 0 for task_complete/done, non-zero for task_failed
352
- 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.
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,