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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- data/lib/harnex/codex/app_server/client.rb +0 -347
data/lib/harnex/adapters/pi.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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(
|
|
357
|
+
return true if wait_for_process_exit(pid, term_grace_seconds)
|
|
303
358
|
|
|
304
359
|
begin
|
|
305
|
-
Process.kill("KILL",
|
|
360
|
+
Process.kill("KILL", pid)
|
|
306
361
|
rescue Errno::ESRCH
|
|
307
362
|
return true
|
|
308
363
|
end
|
|
309
364
|
|
|
310
|
-
wait_for_process_exit(
|
|
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
|
|
493
|
-
|
|
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
|
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
468
|
-
"validation
|
|
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)
|