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
@@ -2,6 +2,7 @@ require "json"
2
2
  require "open3"
3
3
  require "rubygems/version"
4
4
  require "timeout"
5
+ require_relative "../owned_process_group"
5
6
 
6
7
  module Harnex
7
8
  module Adapters
@@ -83,13 +84,40 @@ module Harnex
83
84
  ["pi", "--mode", "rpc"]
84
85
  end
85
86
 
87
+ TRUST_FLAGS = %w[--approve -a --no-approve -na].freeze
88
+ HARNEX_MCP_TOOLS = %w[ask delegate result reply land discard lanes].map { |tool| "mcp__harnex__#{tool}" }.freeze
89
+ MCP_VERSION = Gem::Version.new("0.99.0")
90
+ NO_MCP_VERSION = Gem::Version.new("1.0.4")
91
+
86
92
  def build_command
87
93
  args = cli_extra_args
94
+ # RPC cannot show Pi's project-trust prompt: ignore project-local
95
+ # resources unless the caller passes --approve (#87).
96
+ args = ["--no-approve"] + args unless args.any? { |arg| TRUST_FLAGS.include?(arg) }
97
+ args = guard_mcp(args)
88
98
  args += ["--model", @startup_model] if @startup_model
89
99
  args += ["--thinking", @startup_effort] if @startup_effort
90
100
  base_command + args
91
101
  end
92
102
 
103
+ # #87: a worker never gets harnex's own MCP tools, so it cannot launch
104
+ # more workers (recursion) or escape a read-only ask through delegate.
105
+ # Pi >= 1.0.4 keeps MCP tools despite a --tools allowlist, so an
106
+ # allowlisted (read-only) worker runs with --no-mcp.
107
+ def guard_mcp(args)
108
+ version = parsed_agent_version
109
+ return args unless version && version >= MCP_VERSION
110
+ return args if args.include?("--no-mcp")
111
+ return args + ["--no-mcp"] if version >= NO_MCP_VERSION && (args & %w[--tools -t]).any?
112
+
113
+ index = args.index("--exclude-tools") || args.index("-xt")
114
+ return args + ["--exclude-tools", HARNEX_MCP_TOOLS.join(",")] unless index && args[index + 1]
115
+
116
+ merged = args.dup
117
+ merged[index + 1] = ([args[index + 1]] + HARNEX_MCP_TOOLS).join(",")
118
+ merged
119
+ end
120
+
93
121
  def configure_startup(model: nil, effort: nil)
94
122
  requested_model = model.to_s.strip
95
123
  requested_effort = effort.to_s.strip
@@ -146,9 +174,16 @@ module Harnex
146
174
  }
147
175
  end
148
176
 
149
- def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
177
+ # Pi queues a `followUp` prompt itself and delivers it when the running
178
+ # turn ends (before agent_settled), so a busy send never expires (#79).
179
+ def follow_up_supported?
180
+ true
181
+ end
182
+
183
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false, follow_up: false)
150
184
  state = input_state(nil)
151
- if !force && submit && !enter_only && state[:input_ready] != true
185
+ follow_up = follow_up && !force && state[:state] == "busy"
186
+ if !force && !follow_up && submit && !enter_only && state[:input_ready] != true
152
187
  raise ArgumentError, blocked_message(state, enter_only: enter_only)
153
188
  end
154
189
 
@@ -157,6 +192,7 @@ module Harnex
157
192
 
158
193
  dispatch = { prompt: text.to_s }
159
194
  dispatch[:streaming_behavior] = "steer" if force && state[:state] == "busy"
195
+ dispatch[:streaming_behavior] = "followUp" if follow_up
160
196
  {
161
197
  dispatch: dispatch,
162
198
  input_state: state,
@@ -176,8 +212,15 @@ module Harnex
176
212
  @disconnect_handler = block
177
213
  end
178
214
 
215
+ # Called once IO/PID ownership is installed, before any protocol writes.
216
+ # The owner should only publish readiness/schedule work, not block here.
217
+ def on_spawn(&block)
218
+ @spawn_handler = block
219
+ end
220
+
179
221
  def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
180
222
  if read_io && write_io
223
+ @owned_process_group = nil
181
224
  @read_io = read_io
182
225
  @write_io = write_io
183
226
  @pid = pid
@@ -189,6 +232,7 @@ module Harnex
189
232
  @closed = false
190
233
  @disconnect_signaled = false
191
234
  @state = :prompt
235
+ @spawn_handler&.call(@pid)
192
236
  @stderr_thread = Thread.new { drain_stderr } if @stderr_io
193
237
  @reader_thread = Thread.new { read_loop }
194
238
  request_state_async
@@ -200,8 +244,10 @@ module Harnex
200
244
  apply_dispatch_overrides(model: model, effort: effort) unless @state == :busy
201
245
  payload = { "type" => "prompt", "message" => prompt.to_s }
202
246
  payload["streamingBehavior"] = streaming_behavior if streaming_behavior
203
- request(payload)
247
+ # Events may settle a fast run before the prompt response wakes us.
248
+ # Mark busy before writing, never after the response.
204
249
  @state = :busy
250
+ request(payload)
205
251
  nil
206
252
  end
207
253
 
@@ -291,23 +337,32 @@ module Harnex
291
337
  end
292
338
 
293
339
  def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
294
- return false unless @pid
340
+ if @owned_process_group
341
+ return @owned_process_group.terminate(
342
+ term_grace_seconds: term_grace_seconds,
343
+ kill_grace_seconds: kill_grace_seconds
344
+ )
345
+ end
346
+
347
+ # Injected IO/PID callers retain direct-PID behavior, never group rights.
348
+ pid = @pid
349
+ return false unless pid
295
350
 
296
351
  begin
297
- Process.kill("TERM", @pid)
352
+ Process.kill("TERM", pid)
298
353
  rescue Errno::ESRCH
299
354
  return true
300
355
  end
301
356
 
302
- return true if wait_for_process_exit(@pid, term_grace_seconds)
357
+ return true if wait_for_process_exit(pid, term_grace_seconds)
303
358
 
304
359
  begin
305
- Process.kill("KILL", @pid)
360
+ Process.kill("KILL", pid)
306
361
  rescue Errno::ESRCH
307
362
  return true
308
363
  end
309
364
 
310
- wait_for_process_exit(@pid, kill_grace_seconds)
365
+ wait_for_process_exit(pid, kill_grace_seconds)
311
366
  end
312
367
 
313
368
  def pid
@@ -324,6 +379,9 @@ module Harnex
324
379
  process_status
325
380
  end
326
381
  @pid = nil
382
+ # Reaping can beat event/EOF processing. Bound an actual blocked read,
383
+ # not a local Session callback doing receipt/output I/O on this thread.
384
+ wait_for_reader_drain
327
385
  status
328
386
  rescue Errno::ECHILD
329
387
  @pid = nil
@@ -465,10 +523,25 @@ module Harnex
465
523
  nil
466
524
  end
467
525
 
526
+ def wait_for_reader_drain(timeout: 2.0)
527
+ exited_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
528
+ while @reader_thread&.alive?
529
+ @reader_thread.join(0.05)
530
+ read_started = @read_wait_started_at
531
+ next unless @reader_thread.alive? && read_started
532
+ next if Process.clock_gettime(Process::CLOCK_MONOTONIC) - [read_started, exited_at].max < timeout
533
+
534
+ signal_disconnect(Timeout::Error.new("pi rpc output did not close after process exit"))
535
+ break
536
+ end
537
+ end
538
+
468
539
  def read_loop
469
540
  buffer = +""
470
541
  loop do
542
+ @read_wait_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
471
543
  chunk = @read_io.readpartial(4096)
544
+ @read_wait_started_at = nil
472
545
  buffer << chunk
473
546
 
474
547
  while (idx = buffer.index("\n"))
@@ -481,16 +554,22 @@ module Harnex
481
554
  end
482
555
  end
483
556
  rescue EOFError, IOError, Errno::EIO
484
- nil
557
+ @read_wait_started_at = nil
558
+ if buffer.b.match?(/[^\t\n\v\f\r ]/n)
559
+ signal_disconnect(JSON::ParserError.new("pi rpc unterminated JSONL record at EOF"))
560
+ end
485
561
  ensure
562
+ @read_wait_started_at = nil
563
+ buffer&.clear
486
564
  signal_disconnect(nil)
487
565
  end
488
566
 
489
567
  def handle_line(line)
490
568
  message = JSON.parse(line)
491
569
  handle_message(message)
492
- rescue JSON::ParserError => e
493
- signal_disconnect(e)
570
+ rescue JSON::ParserError
571
+ # Parser diagnostics can quote arbitrary payloads, including thinking.
572
+ signal_disconnect(JSON::ParserError.new("pi rpc malformed JSONL record"))
494
573
  end
495
574
 
496
575
  def handle_message(message)
@@ -736,9 +815,10 @@ module Harnex
736
815
 
737
816
  def spawn_subprocess(env, cwd)
738
817
  spawn_env = env || {}
739
- opts = {}
818
+ opts = { pgroup: true }
740
819
  opts[:chdir] = cwd if cwd
741
820
  stdin_io, stdout_io, stderr_io, wait_thr = Open3.popen3(spawn_env, *build_command, **opts)
821
+ @owned_process_group = OwnedProcessGroup.new(wait_thr.pid)
742
822
  [wait_thr.pid, stdin_io, stdout_io, stderr_io, wait_thr]
743
823
  end
744
824
 
@@ -1,7 +1,5 @@
1
1
  require_relative "adapters/base"
2
2
  require_relative "adapters/generic"
3
- require_relative "adapters/codex"
4
- require_relative "adapters/codex_appserver"
5
3
  require_relative "adapters/claude"
6
4
  require_relative "adapters/opencode"
7
5
  require_relative "adapters/pi"
@@ -18,20 +16,8 @@ module Harnex
18
16
  !key.to_s.strip.empty?
19
17
  end
20
18
 
21
- # Phase 3 flipped the default — `codex` resolves to CodexAppServer.
22
- # Legacy PTY adapter is reachable via `legacy_pty: true` (driven by
23
- # `harnex run codex --legacy-pty`); kept as a long-term supported
24
- # fallback for interactive/TUI use cases.
25
- def codex_appserver_enabled?
26
- true
27
- end
28
-
29
- def build(key, extra_args = [], legacy_pty: false)
19
+ def build(key, extra_args = [])
30
20
  key_str = key.to_s
31
- if key_str == "codex"
32
- return legacy_pty ? Codex.new(extra_args) : CodexAppServer.new(extra_args)
33
- end
34
-
35
21
  adapter_class = registry[key_str]
36
22
  return adapter_class.new(extra_args) if adapter_class
37
23
 
@@ -41,7 +27,6 @@ module Harnex
41
27
  def registry
42
28
  @registry ||= {
43
29
  "claude" => Claude,
44
- "codex" => Codex,
45
30
  "opencode" => Opencode,
46
31
  "pi" => Pi
47
32
  }
@@ -1,6 +1,7 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
+ require "shellwords"
4
5
  require "time"
5
6
 
6
7
  module Harnex
@@ -14,11 +15,17 @@ module Harnex
14
15
  MAX_STRING_LENGTH = 2_000
15
16
  MAX_DIAGNOSTICS = 100
16
17
  MAX_CHANGED_PATHS = 200
18
+ MAX_FINAL_GATES = 20
17
19
  RECEIPT_VERSION = 1
18
20
  CLAIM_SEVERITIES = %w[P1 P2 P3].freeze
19
- COMMAND_OBSERVATION_STATUSES = %w[observed unsupported].freeze
21
+ COMMAND_OBSERVATION_STATUSES = %w[observed partial unsupported].freeze
20
22
  GIT_OBSERVATION_STATUSES = %w[observed unavailable].freeze
21
23
  USAGE_STATUSES = %w[observed estimated unsupported missing zero].freeze
24
+ FINAL_GATE_STATUSES = %w[pass fail unknown undeclared].freeze
25
+ # Shells a structured adapter may wrap a model command in (`bash -lc
26
+ # '<cmd>'`). Unwrapping that transport layer is not fuzzy matching: the
27
+ # inner script must still equal the declared gate exactly.
28
+ FINAL_GATE_SHELLS = %w[sh bash zsh dash].freeze
22
29
 
23
30
  REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
24
31
  VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
@@ -252,24 +259,93 @@ module Harnex
252
259
  {}
253
260
  end
254
261
 
262
+ # Declared final gates as given to `harnex run --final-gate` (or meta
263
+ # `final_gates`): non-empty, bounded, de-duplicated, order kept.
264
+ def normalize_final_gates(value)
265
+ Array(value).filter_map do |gate|
266
+ next unless gate.is_a?(String)
267
+
268
+ text = gate.strip
269
+ text.empty? ? nil : bounded_string(text)
270
+ end.uniq.first(MAX_FINAL_GATES)
271
+ end
272
+
273
+ def final_gate_matches?(gate, command)
274
+ return false unless gate.is_a?(String) && command.is_a?(String)
275
+ return true if command == gate
276
+
277
+ inner = shell_wrapped_script(command)
278
+ !inner.nil? && inner == gate
279
+ end
280
+
281
+ # gates: [{"cmd" => String, "exit_code" => Integer or nil}], where
282
+ # exit_code is the harness-observed exit of the gate's LAST run.
283
+ # A gate never observed fails only when command observation was complete;
284
+ # otherwise it may be among the unobserved calls and reads unknown.
285
+ def final_gates_summary(gates, command_observation:)
286
+ entries = Array(gates).filter_map do |gate|
287
+ next unless gate.is_a?(Hash) && non_empty_string?(gate["cmd"])
288
+
289
+ { "cmd" => bounded_string(gate["cmd"]), "exit_code" => integer_or_nil(gate["exit_code"]) }
290
+ end.first(MAX_FINAL_GATES)
291
+ return { "declared" => 0, "observed_pass" => 0, "status" => "undeclared" } if entries.empty?
292
+
293
+ passed = entries.count { |entry| entry["exit_code"]&.zero? }
294
+ failed = entries.count { |entry| entry["exit_code"] && !entry["exit_code"].zero? }
295
+ missing = entries.count { |entry| entry["exit_code"].nil? }
296
+ status = if failed.positive?
297
+ "fail"
298
+ elsif missing.positive?
299
+ command_observation.to_s == "observed" ? "fail" : "unknown"
300
+ else
301
+ "pass"
302
+ end
303
+ {
304
+ "declared" => entries.length,
305
+ "observed_pass" => passed,
306
+ "observed_fail" => failed,
307
+ "missing" => missing,
308
+ "status" => status,
309
+ "gates" => entries
310
+ }
311
+ end
312
+
255
313
  def build_observed(
256
314
  id:, session_id:, generated_at:, successful:, outcome_status:,
257
315
  outcome_summary:, git:, commands:, turn:, usage:, claims:,
258
- command_observation:
316
+ command_observation:, stop: nil, commands_unobserved: 0,
317
+ final_gates: nil, commands_had_failures: nil
259
318
  )
260
319
  observed_commands = compact_commands(commands).select do |entry|
261
320
  non_empty_string?(entry["cmd"]) && entry["exit_code"].is_a?(Integer)
262
321
  end
322
+ unobserved = non_negative_integer_or_nil(commands_unobserved).to_i
263
323
  git_payload = compact_observed_git(git)
264
324
  turn_payload = compact_observed_turn(turn)
265
325
  usage_payload = compact_observed_usage(usage)
266
326
  claims_payload = compact_claims(claims)
267
- validation_status = if observed_commands.empty?
327
+ # The session tracks failures across its whole history; the retained
328
+ # command window is capped, so it is only the fallback.
329
+ had_failures = if commands_had_failures.nil?
330
+ observed_commands.any? { |entry| !entry["exit_code"].zero? }
331
+ else
332
+ !!commands_had_failures
333
+ end
334
+ gates = final_gates_summary(final_gates, command_observation: command_observation)
335
+ # Declared final gates decide validation; exploratory failures before a
336
+ # green final run stay visible in commands_had_failures (#68). Without
337
+ # gates, commands that ran without structured exit evidence keep an
338
+ # otherwise clean history from reading as a pass.
339
+ validation_status = if gates["status"] != "undeclared"
340
+ gates["status"]
341
+ elsif had_failures
342
+ "fail"
343
+ elsif unobserved.positive?
344
+ "unknown"
345
+ elsif observed_commands.empty?
268
346
  "not_run"
269
- elsif observed_commands.all? { |entry| entry["exit_code"].zero? }
270
- "pass"
271
347
  else
272
- "fail"
348
+ "pass"
273
349
  end
274
350
  start_sha = git_payload["start_sha"].to_s
275
351
  end_sha = git_payload["end_sha"].to_s
@@ -287,7 +363,9 @@ module Harnex
287
363
  "validation" => {
288
364
  "status" => validation_status,
289
365
  "commands" => observed_commands,
290
- "final_reported" => true
366
+ "final_reported" => true,
367
+ "commands_had_failures" => had_failures,
368
+ "final_gates" => gates
291
369
  },
292
370
  "artifacts" => [],
293
371
  "receipt" => {
@@ -305,6 +383,8 @@ module Harnex
305
383
  "usage" => usage_payload
306
384
  }
307
385
  }
386
+ report["observed"]["commands_unobserved"] = unobserved if unobserved.positive?
387
+ report["observed"]["stop"] = compact_observed_stop(stop) if stop
308
388
  report["claims"] = claims_payload unless claims_payload.empty?
309
389
  fit_observed_report!(report)
310
390
  end
@@ -461,12 +541,61 @@ module Harnex
461
541
  end
462
542
 
463
543
  validate_commands(value["commands"], diagnostics) if value.key?("commands")
464
- if value.key?("final_reported") && !boolean?(value["final_reported"])
544
+ %w[final_reported commands_had_failures].each do |key|
545
+ next unless value.key?(key) && !boolean?(value[key])
546
+
465
547
  diagnostics << diagnostic(
466
548
  "boolean_required",
467
- "$.validation.final_reported",
468
- "validation.final_reported must be true or false"
549
+ "$.validation.#{key}",
550
+ "validation.#{key} must be true or false"
551
+ )
552
+ end
553
+ validate_final_gates(value["final_gates"], diagnostics) if value.key?("final_gates")
554
+ end
555
+
556
+ def validate_final_gates(value, diagnostics)
557
+ unless value.is_a?(Hash)
558
+ diagnostics << diagnostic("object_required", "$.validation.final_gates", "validation.final_gates must be an object")
559
+ return
560
+ end
561
+
562
+ unless FINAL_GATE_STATUSES.include?(value["status"])
563
+ diagnostics << diagnostic(
564
+ "enum",
565
+ "$.validation.final_gates.status",
566
+ "validation.final_gates.status must be one of #{FINAL_GATE_STATUSES.join(', ')}"
567
+ )
568
+ end
569
+ %w[declared observed_pass observed_fail missing].each do |key|
570
+ next if !value.key?(key) && !%w[declared observed_pass].include?(key)
571
+ next if value[key].is_a?(Integer) && value[key] >= 0
572
+
573
+ diagnostics << diagnostic(
574
+ "integer_required",
575
+ "$.validation.final_gates.#{key}",
576
+ "validation.final_gates.#{key} must be a non-negative integer"
577
+ )
578
+ end
579
+ return unless value.key?("gates")
580
+
581
+ unless value["gates"].is_a?(Array) && value["gates"].length <= MAX_FINAL_GATES
582
+ diagnostics << diagnostic(
583
+ "array_required",
584
+ "$.validation.final_gates.gates",
585
+ "validation.final_gates.gates must be an array of at most #{MAX_FINAL_GATES} gates"
469
586
  )
587
+ return
588
+ end
589
+ value["gates"].each_with_index do |gate, index|
590
+ path = "$.validation.final_gates.gates[#{index}]"
591
+ unless gate.is_a?(Hash)
592
+ diagnostics << diagnostic("object_required", path, "final gate must be an object")
593
+ next
594
+ end
595
+ validate_required_string(gate, "cmd", "#{path}.cmd", diagnostics)
596
+ next if gate["exit_code"].nil? || gate["exit_code"].is_a?(Integer)
597
+
598
+ diagnostics << diagnostic("integer_required", "#{path}.exit_code", "final gate exit_code must be an integer or null")
470
599
  end
471
600
  end
472
601
 
@@ -624,14 +753,18 @@ module Harnex
624
753
  if value.key?("commands_truncated") && !boolean?(value["commands_truncated"])
625
754
  diagnostics << diagnostic("boolean_required", "$.observed.commands_truncated", "observed.commands_truncated must be true or false")
626
755
  end
756
+ if value.key?("commands_unobserved") && !(value["commands_unobserved"].is_a?(Integer) && value["commands_unobserved"] >= 0)
757
+ diagnostics << diagnostic("integer_required", "$.observed.commands_unobserved", "observed.commands_unobserved must be a non-negative integer")
758
+ end
627
759
  unless COMMAND_OBSERVATION_STATUSES.include?(value["command_observation"])
628
760
  diagnostics << diagnostic(
629
761
  "enum",
630
762
  "$.observed.command_observation",
631
- "observed.command_observation must be observed or unsupported"
763
+ "observed.command_observation must be observed, partial, or unsupported"
632
764
  )
633
765
  end
634
766
 
767
+ validate_observed_stop(value["stop"], diagnostics) if value.key?("stop")
635
768
  turn = value["turn"]
636
769
  unless turn.is_a?(Hash)
637
770
  diagnostics << diagnostic("object_required", "$.observed.turn", "observed.turn must be an object")
@@ -657,6 +790,30 @@ module Harnex
657
790
  end
658
791
  end
659
792
 
793
+ def validate_observed_stop(value, diagnostics)
794
+ path = "$.observed.stop"
795
+ unless value.is_a?(Hash)
796
+ diagnostics << diagnostic("object_required", path, "observed.stop must be an object")
797
+ return
798
+ end
799
+ { "reason" => StopRequest::REASONS, "origin" => StopRequest::ORIGINS,
800
+ "work_state" => StopRequest::WORK_STATES }.each do |key, allowed|
801
+ next if allowed.include?(value[key])
802
+
803
+ diagnostics << diagnostic("enum", "#{path}.#{key}", "stop #{key} is not supported")
804
+ end
805
+ validate_required_string(value, "requested_at", "#{path}.requested_at", diagnostics)
806
+ begin
807
+ Time.iso8601(value["requested_at"].to_s)
808
+ rescue ArgumentError
809
+ diagnostics << diagnostic("timestamp_required", "#{path}.requested_at", "stop timestamp must be ISO 8601")
810
+ end
811
+ if value.key?("runtime_limit_s") &&
812
+ !(finite_number?(value["runtime_limit_s"]) && value["runtime_limit_s"].positive?)
813
+ diagnostics << diagnostic("number_required", "#{path}.runtime_limit_s", "runtime limit must be finite and positive")
814
+ end
815
+ end
816
+
660
817
  def validate_final_contract(report, diagnostics)
661
818
  if harness_receipt?(report)
662
819
  validate_observed_final_contract(report, diagnostics)
@@ -908,9 +1065,11 @@ module Harnex
908
1065
  "git" => compact_observed_git(value["git"]),
909
1066
  "commands" => compact_commands(value["commands"]),
910
1067
  "commands_truncated" => boolean_or_nil(value["commands_truncated"]),
1068
+ "commands_unobserved" => non_negative_integer_or_nil(value["commands_unobserved"]),
911
1069
  "command_observation" => bounded_string_or_nil(value["command_observation"]),
912
1070
  "turn" => compact_observed_turn(value["turn"]),
913
- "usage" => compact_observed_usage(value["usage"])
1071
+ "usage" => compact_observed_usage(value["usage"]),
1072
+ "stop" => compact_observed_stop(value["stop"])
914
1073
  }
915
1074
  payload.delete_if { |_key, item| item.nil? }
916
1075
  payload.empty? ? nil : payload
@@ -955,6 +1114,17 @@ module Harnex
955
1114
  payload
956
1115
  end
957
1116
 
1117
+ def compact_observed_stop(value)
1118
+ return nil unless value.is_a?(Hash)
1119
+
1120
+ payload = %w[reason origin work_state requested_at].to_h do |key|
1121
+ [key, bounded_string_or_nil(hash_value(value, key))]
1122
+ end
1123
+ limit = finite_float_or_nil(hash_value(value, "runtime_limit_s"))
1124
+ payload["runtime_limit_s"] = limit unless limit.nil?
1125
+ payload
1126
+ end
1127
+
958
1128
  def compact_observed_usage(value)
959
1129
  value = {} unless value.is_a?(Hash)
960
1130
  payload = {}
@@ -995,10 +1165,34 @@ module Harnex
995
1165
  payload["status"] = bounded_string_or_nil(value["status"])
996
1166
  payload["commands"] = compact_commands(value["commands"])
997
1167
  payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
1168
+ payload["commands_had_failures"] = value["commands_had_failures"] if boolean?(value["commands_had_failures"])
1169
+ payload["final_gates"] = compact_final_gates(value["final_gates"])
998
1170
  payload.delete_if { |_key, item| item.nil? || item == [] }
999
1171
  payload.empty? ? nil : payload
1000
1172
  end
1001
1173
 
1174
+ # Dispatch rows keep the counts and status, not the gate command text.
1175
+ def compact_final_gates(value)
1176
+ return nil unless value.is_a?(Hash) && FINAL_GATE_STATUSES.include?(value["status"])
1177
+
1178
+ compact = { "status" => value["status"] }
1179
+ %w[declared observed_pass observed_fail missing].each do |key|
1180
+ compact[key] = value[key] if value[key].is_a?(Integer) && value[key] >= 0
1181
+ end
1182
+ compact
1183
+ end
1184
+
1185
+ def shell_wrapped_script(command)
1186
+ words = Shellwords.split(command)
1187
+ return nil unless words.length == 3
1188
+ return nil unless FINAL_GATE_SHELLS.include?(File.basename(words[0]))
1189
+ return nil unless %w[-c -lc].include?(words[1])
1190
+
1191
+ words[2]
1192
+ rescue ArgumentError
1193
+ nil
1194
+ end
1195
+
1002
1196
  def compact_commands(value)
1003
1197
  Array(value).first(MAX_COMMANDS).filter_map do |entry|
1004
1198
  next unless entry.is_a?(Hash)