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.
@@ -0,0 +1,94 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Harnex
5
+ class ArtifactReportCommand
6
+ def self.usage(program_name = "harnex artifact-report")
7
+ <<~TEXT
8
+ Usage:
9
+ #{program_name} init PATH [--force]
10
+ #{program_name} validate PATH [--final]
11
+
12
+ Commands:
13
+ init Write a bounded harnex.artifact_report.v1 skeleton
14
+ validate Validate schema and field shapes without printing report data
15
+
16
+ Options:
17
+ --final Require an accepted/no_change final outcome, passing proof,
18
+ and validation.final_reported=true
19
+ --force Replace an existing file during init
20
+ -h, --help Show this help
21
+
22
+ Both commands print machine-readable JSON. `validate` exits 0 only when
23
+ the requested contract is satisfied; diagnostics contain field paths and
24
+ shape errors, never report payloads or transcripts.
25
+ TEXT
26
+ end
27
+
28
+ def initialize(argv)
29
+ @argv = argv.dup
30
+ end
31
+
32
+ def run
33
+ command = @argv.shift
34
+ case command
35
+ when "init"
36
+ run_init(@argv)
37
+ when "validate"
38
+ run_validate(@argv)
39
+ when nil, "help", "-h", "--help"
40
+ puts self.class.usage
41
+ 0
42
+ else
43
+ raise OptionParser::ParseError, "unknown artifact-report command #{command.inspect}"
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def run_init(argv)
50
+ options = { force: false, help: false }
51
+ parser = OptionParser.new do |opts|
52
+ opts.banner = "Usage: harnex artifact-report init PATH [--force]"
53
+ opts.on("--force", "Replace an existing report") { options[:force] = true }
54
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
55
+ end
56
+ parser.parse!(argv)
57
+ return print_help if options[:help]
58
+
59
+ path = exactly_one_path!(argv, parser)
60
+ report_path = ArtifactReport.initialize_file(path, force: options[:force])
61
+ result = ArtifactReport.validate(report_path)
62
+ puts JSON.generate(result.public_payload(final: false).merge("created" => true))
63
+ result.ok ? 0 : 1
64
+ end
65
+
66
+ def run_validate(argv)
67
+ options = { final: false, help: false }
68
+ parser = OptionParser.new do |opts|
69
+ opts.banner = "Usage: harnex artifact-report validate PATH [--final]"
70
+ opts.on("--final", "Require accepted final proof") { options[:final] = true }
71
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
72
+ end
73
+ parser.parse!(argv)
74
+ return print_help if options[:help]
75
+
76
+ path = exactly_one_path!(argv, parser)
77
+ result = ArtifactReport.validate(path, final: options[:final])
78
+ puts JSON.generate(result.public_payload(final: options[:final]))
79
+ result.ok ? 0 : 1
80
+ end
81
+
82
+ def exactly_one_path!(argv, parser)
83
+ raise OptionParser::MissingArgument, "PATH" if argv.empty?
84
+ raise OptionParser::InvalidArgument, "expected exactly one PATH\n#{parser}" unless argv.length == 1
85
+
86
+ argv.first
87
+ end
88
+
89
+ def print_help
90
+ puts self.class.usage
91
+ 0
92
+ end
93
+ end
94
+ end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "optparse"
3
+ require "set"
3
4
  require "time"
4
5
 
5
6
  module Harnex
@@ -74,13 +75,60 @@ module Harnex
74
75
  end
75
76
 
76
77
  def filtered_records
77
- records = load_records
78
+ records = derived_records
78
79
  records = records.select { |record| record["id"].to_s.include?(@options[:id]) } if @options[:id]
79
80
  records = records.select { |record| started_after?(record, @options[:since]) } if @options[:since]
80
81
  records = records.last(@options[:limit]) unless @options[:all]
81
82
  records
82
83
  end
83
84
 
85
+ # One row per dispatch: start rows completed by an end row are dropped
86
+ # (the end row carries the outcome); uncompleted start rows surface as
87
+ # running (pid alive on this host) or interrupted (no end row, pid gone).
88
+ def derived_records
89
+ raw = load_records
90
+ ended = Set.new
91
+ raw.each do |record|
92
+ next unless DispatchHistory.end_record?(record)
93
+
94
+ session_id = record["session_id"].to_s
95
+ ended << "sid:#{session_id}" unless session_id.empty?
96
+ ended << "leg:#{record['id']}|#{record['started_at']}"
97
+ end
98
+
99
+ raw.filter_map do |record|
100
+ next record unless DispatchHistory.start_record?(record)
101
+ next nil if start_completed?(record, ended)
102
+
103
+ derive_live_record(record)
104
+ end
105
+ end
106
+
107
+ def start_completed?(record, ended)
108
+ session_id = record["session_id"].to_s
109
+ return true if !session_id.empty? && ended.include?("sid:#{session_id}")
110
+
111
+ ended.include?("leg:#{record['id']}|#{record['started_at']}")
112
+ end
113
+
114
+ def derive_live_record(record)
115
+ alive = DispatchHistory.same_host?(record) &&
116
+ record["pid"] && Harnex.alive_pid?(record["pid"])
117
+ record.merge(
118
+ "status" => alive ? "running" : "interrupted",
119
+ "terminal_event" => nil,
120
+ "duration_s" => alive ? seconds_since(record["started_at"]) : nil,
121
+ "ended_at" => nil
122
+ )
123
+ end
124
+
125
+ def seconds_since(timestamp)
126
+ seconds = (Time.now - Time.iso8601(timestamp.to_s)).to_i
127
+ seconds.negative? ? 0 : seconds
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+
84
132
  def load_records
85
133
  path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
86
134
  return [] unless File.file?(path)
@@ -129,7 +177,9 @@ module Harnex
129
177
  end
130
178
 
131
179
  def format_duration(value)
132
- seconds = Integer(value || 0)
180
+ return "-" if value.nil?
181
+
182
+ seconds = Integer(value)
133
183
  hours = seconds / 3600
134
184
  minutes = (seconds % 3600) / 60
135
185
  rest = seconds % 60
@@ -0,0 +1,170 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Harnex
5
+ class OrchestrationCommand
6
+ def self.usage(program_name = "harnex orchestration")
7
+ <<~TEXT
8
+ Usage:
9
+ #{program_name} sample --out PATH --run-id ID --generation-id ID [options]
10
+ #{program_name} report --dispatch PATH --run-id ID [--samples PATH] [--json]
11
+
12
+ Sample options:
13
+ --project-id ID
14
+ --queue-id ID
15
+ --session-id ID
16
+ --event NAME sample, generation_started, generation_finished, rotation, recovery, or compaction
17
+ --ts ISO8601
18
+ --context-status STATUS observed, estimated, unsupported, missing, or zero
19
+ --context-tokens N
20
+ --context-window-tokens N
21
+ --context-percent N
22
+ --context-peak-tokens N
23
+ --context-peak-percent N
24
+ --usage-status STATUS observed, estimated, unsupported, missing, or zero
25
+ --usage-input-tokens N
26
+ --usage-output-tokens N
27
+ --usage-cached-input-tokens N
28
+ --usage-reasoning-tokens N
29
+ --usage-total-tokens N
30
+ --usage-cost-usd N
31
+ --usage-cost-source TEXT
32
+ --tool-calls N
33
+ --compactions N
34
+ --rotation-reason TEXT
35
+
36
+ Report options:
37
+ --dispatch PATH Dispatch summary JSONL
38
+ --samples PATH External primary sample JSONL
39
+ --run-id ID Logical orchestration run id
40
+ --json Output the full report JSON
41
+ -h, --help Show this help
42
+ TEXT
43
+ end
44
+
45
+ def initialize(argv)
46
+ @argv = argv.dup
47
+ end
48
+
49
+ def run
50
+ subcommand = @argv.shift
51
+ case subcommand
52
+ when "sample"
53
+ run_sample(@argv)
54
+ when "report"
55
+ run_report(@argv)
56
+ when "-h", "--help", nil
57
+ puts self.class.usage
58
+ 0
59
+ else
60
+ raise OptionParser::ParseError, "unknown orchestration subcommand #{subcommand.inspect}"
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def run_sample(argv)
67
+ options = {
68
+ context: {},
69
+ usage: {}
70
+ }
71
+ sample_parser(options).parse!(argv)
72
+ if options.delete(:help)
73
+ puts self.class.usage
74
+ return 0
75
+ end
76
+ path = required_option(options.delete(:out), "--out")
77
+ options["orchestration_run_id"] = required_option(options.delete(:run_id), "--run-id")
78
+ options["generation_id"] = required_option(options.delete(:generation_id), "--generation-id")
79
+ sample = Harnex::Orchestration.append_sample(path, options)
80
+ puts JSON.generate("ok" => true, "path" => path, "sample" => sample)
81
+ 0
82
+ end
83
+
84
+ def run_report(argv)
85
+ options = { json: false }
86
+ report_parser(options).parse!(argv)
87
+ if options[:help]
88
+ puts self.class.usage
89
+ return 0
90
+ end
91
+ report = Harnex::Orchestration.report(
92
+ dispatch_path: required_option(options[:dispatch], "--dispatch"),
93
+ samples_path: options[:samples],
94
+ run_id: required_option(options[:run_id], "--run-id")
95
+ )
96
+ if options[:json]
97
+ puts JSON.generate(report)
98
+ else
99
+ puts render_report(report)
100
+ end
101
+ 0
102
+ end
103
+
104
+ def sample_parser(options)
105
+ OptionParser.new do |opts|
106
+ opts.banner = "Usage: harnex orchestration sample --out PATH --run-id ID --generation-id ID [options]"
107
+ opts.on("--out PATH") { |value| options[:out] = value }
108
+ opts.on("--run-id ID") { |value| options[:run_id] = value }
109
+ opts.on("--generation-id ID") { |value| options[:generation_id] = value }
110
+ opts.on("--project-id ID") { |value| options["project_id"] = value }
111
+ opts.on("--queue-id ID") { |value| options["queue_id"] = value }
112
+ opts.on("--session-id ID") { |value| options["session_id"] = value }
113
+ opts.on("--event NAME") { |value| options["event"] = value }
114
+ opts.on("--ts ISO8601") { |value| options["ts"] = value }
115
+ opts.on("--context-status STATUS") { |value| options[:context]["status"] = value }
116
+ opts.on("--context-tokens N") { |value| options[:context]["terminal_tokens"] = value }
117
+ opts.on("--context-window-tokens N") { |value| options[:context]["window_tokens"] = value }
118
+ opts.on("--context-percent N") { |value| options[:context]["terminal_percent"] = value }
119
+ opts.on("--context-peak-tokens N") { |value| options[:context]["peak_tokens"] = value }
120
+ opts.on("--context-peak-percent N") { |value| options[:context]["peak_percent"] = value }
121
+ opts.on("--usage-status STATUS") { |value| options[:usage]["status"] = value }
122
+ opts.on("--usage-input-tokens N") { |value| options[:usage]["input_tokens"] = value }
123
+ opts.on("--usage-output-tokens N") { |value| options[:usage]["output_tokens"] = value }
124
+ opts.on("--usage-cached-input-tokens N") { |value| options[:usage]["cached_input_tokens"] = value }
125
+ opts.on("--usage-reasoning-tokens N") { |value| options[:usage]["reasoning_tokens"] = value }
126
+ opts.on("--usage-total-tokens N") { |value| options[:usage]["total_tokens"] = value }
127
+ opts.on("--usage-cost-usd N") { |value| options[:usage]["cost_usd"] = value }
128
+ opts.on("--usage-cost-source TEXT") { |value| options[:usage]["cost_source"] = value }
129
+ opts.on("--tool-calls N") { |value| options["tool_calls"] = value }
130
+ opts.on("--compactions N") { |value| options["compactions"] = value }
131
+ opts.on("--rotation-reason TEXT") { |value| options["rotation_reason"] = value }
132
+ opts.on("-h", "--help") { options[:help] = true }
133
+ end
134
+ end
135
+
136
+ def report_parser(options)
137
+ OptionParser.new do |opts|
138
+ opts.banner = "Usage: harnex orchestration report --dispatch PATH --run-id ID [options]"
139
+ opts.on("--dispatch PATH") { |value| options[:dispatch] = value }
140
+ opts.on("--samples PATH") { |value| options[:samples] = value }
141
+ opts.on("--run-id ID") { |value| options[:run_id] = value }
142
+ opts.on("--json") { options[:json] = true }
143
+ opts.on("-h", "--help") { options[:help] = true }
144
+ end
145
+ end
146
+
147
+ def required_option(value, name)
148
+ text = value.to_s.strip
149
+ raise OptionParser::MissingArgument, name if text.empty?
150
+
151
+ text
152
+ end
153
+
154
+ def render_report(report)
155
+ primary = report.fetch("primary")
156
+ workers = report.fetch("workers")
157
+ ratios = report.fetch("ratios")
158
+ [
159
+ "Run: #{report.fetch('orchestration_run_id')}",
160
+ "Primary: generations=#{primary.fetch('generation_count')} usage=#{primary.dig('usage', 'status')} total_tokens=#{format_value(primary.dig('usage', 'total_tokens'))} peak_context=#{format_value(primary.dig('context', 'peak_tokens'))} tool_calls=#{primary.fetch('tool_calls')}",
161
+ "Workers: dispatches=#{workers.fetch('dispatches')} usage=#{workers.dig('usage', 'status')} active_s=#{workers.fetch('active_s')} accepted=#{workers.dig('outcomes', 'accepted')} rejected=#{workers.dig('outcomes', 'rejected')} blocked=#{workers.dig('outcomes', 'blocked')} unknown=#{workers.dig('outcomes', 'unknown')}",
162
+ "Ratios: primary_tokens_per_accepted_entry=#{format_value(ratios.fetch('primary_total_tokens_per_accepted_entry'))} primary_tool_calls_per_accepted_entry=#{format_value(ratios.fetch('primary_tool_calls_per_accepted_entry'))}"
163
+ ].join("\n")
164
+ end
165
+
166
+ def format_value(value)
167
+ value.nil? ? "-" : value
168
+ end
169
+ end
170
+ end
@@ -19,7 +19,12 @@ module Harnex
19
19
  "--effort" => "effort",
20
20
  "--parent-dispatch-id" => "parent_dispatch_id",
21
21
  "--parent-attempt-id" => "parent_attempt_id",
22
- "--attempt-kind" => "attempt_kind"
22
+ "--attempt-kind" => "attempt_kind",
23
+ "--orchestration-run-id" => "orchestration_run_id",
24
+ "--orchestration-generation-id" => "orchestration_generation_id",
25
+ "--orchestration-role" => "orchestration_role",
26
+ "--orchestration-session-id" => "orchestration_session_id",
27
+ "--orchestration-rotation-reason" => "orchestration_rotation_reason"
23
28
  }.freeze
24
29
  TELEMETRY_KEYS_TO_FLAGS = TELEMETRY_FLAGS.invert.freeze
25
30
  TELEMETRY_EQUALS_PREFIXES = TELEMETRY_FLAGS.keys.map { |flag| "#{flag}=" }.freeze
@@ -28,8 +33,13 @@ module Harnex
28
33
  --id --description --detach --tmux --host --port --watch --watch-file
29
34
  --stall-after --max-resumes --preset --context --meta --summary-out
30
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
31
- --require-attribution --auto-stop --fast --legacy-pty --help
36
+ --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
37
+ --allow-live-parent --help
32
38
  ].concat(TELEMETRY_FLAGS.keys).freeze
39
+
40
+ # Attempt kinds that redo the parent's work; dispatching one while the
41
+ # parent is still running duplicates work in the same checkout.
42
+ LIVE_PARENT_GUARDED_KINDS = %w[retry fix superseding].freeze
33
43
  VALUE_FLAGS = %w[
34
44
  --id --description --host --port --watch --watch-file --stall-after
35
45
  --max-resumes --preset --context --meta --summary-out --artifact-report
@@ -53,7 +63,7 @@ module Harnex
53
63
  --preset NAME Watch preset: impl, plan, gate (requires --watch)
54
64
  --watch-file PATH Auto-send a file-change hook on modification
55
65
  --context TEXT Inject as the initial prompt (prepends session header)
56
- --auto-stop Stop after the first task completion from --context
66
+ --auto-stop Stop after the first accepted task completion from --context
57
67
  --fast (codex only) Use Codex service_tier="fast".
58
68
  Default Codex runs force service_tier="flex".
59
69
  --meta JSON Attach parsed JSON metadata to the started event
@@ -62,6 +72,9 @@ module Harnex
62
72
  Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
63
73
  --validation-report PATH
64
74
  Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
75
+ --require-artifact-report
76
+ Fail closed unless PATH contains accepted final proof;
77
+ requires --artifact-report or --validation-report
65
78
  --project-id ID Queue telemetry project id (first-class flags override --meta)
66
79
  --queue-id ID Queue telemetry queue id
67
80
  --entry-id ID Queue telemetry entry id
@@ -78,7 +91,23 @@ module Harnex
78
91
  --parent-attempt-id ID
79
92
  Parent attempt id for retry/fix/review joins
80
93
  --attempt-kind KIND
81
- initial, retry, fix, review, or superseding (default: initial)
94
+ initial, retry, fix, review, or superseding (default: initial).
95
+ retry requires --parent-dispatch-id so the
96
+ duplicate-dispatch guard can verify the parent
97
+ --allow-live-parent
98
+ Dispatch even though --parent-dispatch-id names a
99
+ session that is still running (intentional
100
+ parallelism, e.g. isolated worktrees)
101
+ --orchestration-run-id ID
102
+ Logical primary-orchestrator run id for queue rollups
103
+ --orchestration-generation-id ID
104
+ Primary generation id after rotation/recovery boundaries
105
+ --orchestration-role ROLE
106
+ primary or worker; defaults to worker in rollups when omitted
107
+ --orchestration-session-id ID
108
+ External primary session id to preserve in rollups
109
+ --orchestration-rotation-reason TEXT
110
+ Clean rotation/recovery reason for the generation
82
111
  --require-attribution
83
112
  Fail before launch unless project/phase/intent and one work id are present
84
113
  --cwd DIR Run the wrapped agent from DIR and use DIR as the session root
@@ -94,7 +123,9 @@ module Harnex
94
123
  Notes:
95
124
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
96
125
  Bare `--watch` enables the babysitter.
97
- --auto-stop requires --context and fires once after the first completion.
126
+ --auto-stop requires --context. Structured Codex turns only count as
127
+ accepted completion after activity, Git delta, or accepted sidecar proof.
128
+ --require-artifact-report makes sidecar validation part of the run verdict.
98
129
  Explicit --stall-after/--max-resumes values override --preset defaults.
99
130
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
100
131
  Any other CLI name is launched with generic wrapping.
@@ -140,9 +171,11 @@ module Harnex
140
171
  require_attribution: false,
141
172
  summary_out: nil,
142
173
  artifact_report: nil,
174
+ require_artifact_report: false,
143
175
  cwd: nil,
144
176
  root: nil,
145
177
  auto_stop: false,
178
+ allow_live_parent: false,
146
179
  detach: false,
147
180
  tmux: false,
148
181
  tmux_name: nil,
@@ -163,8 +196,10 @@ module Harnex
163
196
 
164
197
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
165
198
  validate_auto_stop_context!
199
+ validate_required_artifact_report!
166
200
  apply_telemetry_options!
167
201
  validate_attempt_metadata!
202
+ validate_orchestration_metadata!
168
203
  validate_required_attribution!
169
204
 
170
205
  repo_root = resolve_run_root(cli_name, child_args)
@@ -172,6 +207,7 @@ module Harnex
172
207
  @options[:artifact_report] = resolve_artifact_report(repo_root)
173
208
  @options[:id] ||= Harnex.generate_id(repo_root)
174
209
  validate_unique_id!(repo_root)
210
+ validate_live_parent_guard!(repo_root)
175
211
  effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
176
212
  adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
177
213
  @options[:detach] = true if @options[:tmux]
@@ -235,8 +271,10 @@ module Harnex
235
271
  tmux_cmd += [flag, value] if flag && value
236
272
  end
237
273
  tmux_cmd << "--require-attribution" if @options[:require_attribution]
274
+ tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
238
275
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
239
276
  tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
277
+ tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
240
278
  tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
241
279
  tmux_cmd += ["--root", @options[:root]] if @options[:root]
242
280
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
@@ -333,6 +371,33 @@ module Harnex
333
371
  "Use a different --id or stop the existing session first."
334
372
  end
335
373
 
374
+ # Duplicate-dispatch guard (issue #62): a retry/fix/superseding attempt
375
+ # whose parent dispatch is still running would duplicate work in the same
376
+ # checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
377
+ # lineage of a live spawner must not trip this.
378
+ def validate_live_parent_guard!(repo_root)
379
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
380
+ kind = metadata["attempt_kind"].to_s
381
+ parent_id = metadata["parent_dispatch_id"].to_s.strip
382
+
383
+ if kind == "retry" && parent_id.empty?
384
+ raise "harnex run: --attempt-kind retry requires --parent-dispatch-id " \
385
+ "so the duplicate-dispatch guard can verify the parent is not still running."
386
+ end
387
+
388
+ return if @options[:allow_live_parent]
389
+ return if parent_id.empty?
390
+ return unless LIVE_PARENT_GUARDED_KINDS.include?(kind)
391
+
392
+ live = Harnex.active_sessions(repo_root, id: parent_id).first
393
+ return unless live
394
+
395
+ raise "harnex run: refusing #{kind} dispatch — parent dispatch #{parent_id.inspect} " \
396
+ "is still running (pid #{live['pid']}, started #{live['started_at']}). " \
397
+ "Wait for it (harnex wait --id #{parent_id} --until done), stop it, " \
398
+ "or pass --allow-live-parent for intentional parallelism."
399
+ end
400
+
336
401
  def build_session(adapter, repo_root)
337
402
  watch = Harnex.build_watch_config(@options[:watch], repo_root)
338
403
  Session.new(
@@ -347,6 +412,7 @@ module Harnex
347
412
  meta: @options[:meta],
348
413
  summary_out: @options[:summary_out],
349
414
  artifact_report_path: @options[:artifact_report],
415
+ require_artifact_report: @options[:require_artifact_report],
350
416
  inbox_ttl: @options[:inbox_ttl],
351
417
  auto_stop: @options[:auto_stop],
352
418
  launch_cwd: history_cwd,
@@ -516,6 +582,8 @@ module Harnex
516
582
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
517
583
  when "--auto-stop"
518
584
  @options[:auto_stop] = true
585
+ when "--allow-live-parent"
586
+ @options[:allow_live_parent] = true
519
587
  when "--require-attribution"
520
588
  @options[:require_attribution] = true
521
589
  when "--fast"
@@ -539,6 +607,8 @@ module Harnex
539
607
  when "--artifact-report", "--validation-report"
540
608
  index += 1
541
609
  @options[:artifact_report] = required_option_value(arg, argv[index])
610
+ when "--require-artifact-report"
611
+ @options[:require_artifact_report] = true
542
612
  when /\A--artifact-report=(.+)\z/
543
613
  @options[:artifact_report] = required_option_value("--artifact-report", Regexp.last_match(1))
544
614
  when /\A--validation-report=(.+)\z/
@@ -619,7 +689,7 @@ module Harnex
619
689
  case arg
620
690
  when "--"
621
691
  return false
622
- when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-attribution", "--fast", "--legacy-pty"
692
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty", "--allow-live-parent"
623
693
  nil
624
694
  when /\A--tmux=/
625
695
  nil
@@ -676,6 +746,14 @@ module Harnex
676
746
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
677
747
  end
678
748
 
749
+ def validate_required_artifact_report!
750
+ return unless @options[:require_artifact_report]
751
+ return unless @options[:artifact_report].to_s.strip.empty?
752
+
753
+ raise OptionParser::InvalidOption,
754
+ "harnex run: --require-artifact-report requires --artifact-report PATH"
755
+ end
756
+
679
757
  def apply_telemetry_options!
680
758
  explicit = @options[:telemetry]
681
759
  return if explicit.empty? && @options[:meta].is_a?(Hash)
@@ -693,6 +771,15 @@ module Harnex
693
771
  "harnex run: --attempt-kind must be one of #{Session::ATTEMPT_KINDS.join(', ')}"
694
772
  end
695
773
 
774
+ def validate_orchestration_metadata!
775
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
776
+ role = metadata["orchestration_role"].to_s
777
+ return if role.empty? || Orchestration::ROLES.include?(role)
778
+
779
+ raise OptionParser::InvalidOption,
780
+ "harnex run: --orchestration-role must be one of #{Orchestration::ROLES.join(', ')}"
781
+ end
782
+
696
783
  def validate_required_attribution!
697
784
  return unless @options[:require_attribution]
698
785
 
@@ -97,14 +97,49 @@ module Harnex
97
97
  return live unless @options[:id]
98
98
  return [live.first] unless live.empty?
99
99
 
100
+ running = running_from_start_record(fallback_repo_root)
101
+ return [running] if running
102
+
100
103
  terminal = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: fallback_repo_root)
101
104
  [terminal || Harnex::TerminalStatus.unknown(id: @options[:id], repo_root: fallback_repo_root)]
102
105
  end
103
106
 
107
+ # Registry row missing but the dispatch stream has an uncompleted start
108
+ # row whose pid is alive: the worker is running, just not registry-visible
109
+ # from this context. Report running (labelled degraded), never dead.
110
+ def running_from_start_record(repo_root)
111
+ start = Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
112
+ return nil unless start
113
+
114
+ {
115
+ "id" => start["id"].to_s,
116
+ "cli" => start["cli"],
117
+ "pid" => start["pid"],
118
+ "description" => start["description"],
119
+ "repo_root" => start["repo_root"] || repo_root,
120
+ "started_at" => start["started_at"],
121
+ "state" => "running",
122
+ "process_state" => "running",
123
+ "terminal" => false,
124
+ "task_complete" => false,
125
+ "task_failed" => false,
126
+ "done" => false,
127
+ "work_state" => "running",
128
+ "exit" => nil,
129
+ "exit_code" => nil,
130
+ "summary_out" => nil,
131
+ "ended_at" => nil,
132
+ "source" => "dispatch_start",
133
+ "degraded" => true,
134
+ "live_status" => "unreachable"
135
+ }
136
+ end
137
+
104
138
  def normalize_live_status(session)
105
139
  task_failed = task_failed?(session)
106
140
  task_complete = task_complete?(session) && !task_failed
107
141
  work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
142
+ degraded = session["live_status"] == "unreachable"
108
143
  session.merge(
109
144
  "state" => "running",
110
145
  "process_state" => "running",
@@ -117,7 +152,8 @@ module Harnex
117
152
  "exit_code" => nil,
118
153
  "summary_out" => nil,
119
154
  "ended_at" => nil,
120
- "source" => "live"
155
+ "source" => degraded ? "registry" : "live",
156
+ "degraded" => degraded
121
157
  )
122
158
  end
123
159
 
@@ -131,6 +167,9 @@ module Harnex
131
167
  !session["last_failed_at"].to_s.empty?
132
168
  end
133
169
 
170
+ # On HTTP failure the row is still backed by a verified-alive pid, but the
171
+ # data is the registry snapshot, not the live API — label it as degraded
172
+ # instead of silently passing it off as live.
134
173
  def load_live_status(session)
135
174
  uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
136
175
  request = Net::HTTP::Get.new(uri)
@@ -140,11 +179,11 @@ module Harnex
140
179
  http.request(request)
141
180
  end
142
181
 
143
- return session unless response.is_a?(Net::HTTPSuccess)
182
+ return session.merge("live_status" => "unreachable") unless response.is_a?(Net::HTTPSuccess)
144
183
 
145
- session.merge(JSON.parse(response.body))
184
+ session.merge(JSON.parse(response.body)).merge("live_status" => "ok")
146
185
  rescue StandardError
147
- session
186
+ session.merge("live_status" => "unreachable")
148
187
  end
149
188
 
150
189
  def render_table(sessions)