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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/README.md +53 -11
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +58 -6
- data/lib/harnex/artifact_report.rb +507 -35
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/history.rb +52 -2
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +93 -6
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +164 -62
- data/lib/harnex/commands/watch.rb +5 -2
- data/lib/harnex/core.rb +11 -1
- data/lib/harnex/dispatch_history.rb +95 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +295 -15
- data/lib/harnex/terminal_status.rb +8 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +5 -2
|
@@ -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 =
|
|
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
|
-
|
|
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
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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
|
|
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
|
|
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)
|