harnex 0.7.14 → 0.9.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 +132 -1
- data/GUIDE.md +17 -10
- data/README.md +30 -23
- data/TECHNICAL.md +26 -5
- data/docs/codex-appserver.md +172 -0
- data/docs/configuration.md +111 -0
- data/docs/dispatch-telemetry.md +461 -0
- data/docs/events.md +133 -0
- data/guides/01_dispatch.md +6 -0
- data/guides/04_monitoring.md +50 -3
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- data/lib/harnex/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +56 -2
- data/lib/harnex/commands/run.rb +77 -7
- data/lib/harnex/commands/status.rb +43 -4
- data/lib/harnex/commands/wait.rb +158 -61
- data/lib/harnex/commands/watch.rb +3 -2
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +11 -8
- data/lib/harnex/dispatch_history.rb +107 -3
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +156 -13
- data/lib/harnex/terminal_status.rb +12 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
data/guides/05_naming.md
CHANGED
|
@@ -66,17 +66,27 @@ If `--id` is missing, harnex generates a random session ID. The tmux window may
|
|
|
66
66
|
look right, but `harnex status`, `harnex pane --id`, and logs need the random
|
|
67
67
|
ID.
|
|
68
68
|
|
|
69
|
-
## Retry Suffixes
|
|
69
|
+
## Retry Suffixes And Linkage
|
|
70
70
|
|
|
71
|
-
If a session fails and you dispatch a fresh attempt,
|
|
71
|
+
If a session fails and you dispatch a fresh attempt, use a new ID and link it
|
|
72
|
+
to the completed parent so Harnex can derive chain counts and recovery:
|
|
72
73
|
|
|
73
74
|
```text
|
|
74
|
-
pi-i-42
|
|
75
|
-
pi-i-
|
|
76
|
-
pi-i-
|
|
75
|
+
pi-i-42 initial attempt
|
|
76
|
+
pi-i-42-r1 retry of pi-i-42
|
|
77
|
+
pi-i-42-r2 retry of pi-i-42-r1
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
```bash
|
|
81
|
+
harnex run pi --id pi-i-42-r1 --tmux pi-i-42-r1 \
|
|
82
|
+
--attempt-kind retry --parent-dispatch-id pi-i-42 \
|
|
83
|
+
--context "Retry the bounded task."
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Retry/fix/fallback/superseding work is refused while its named parent is still
|
|
87
|
+
running unless `--allow-live-parent` explicitly authorizes isolated parallelism.
|
|
88
|
+
Keep old logs for diagnosis; retention removes only expired/over-cap logs that
|
|
89
|
+
do not belong to a current or live session.
|
|
80
90
|
|
|
81
91
|
## Task Files
|
|
82
92
|
|
|
@@ -104,8 +114,8 @@ If a legacy workflow still expects a done marker, derive it from the session ID:
|
|
|
104
114
|
```
|
|
105
115
|
|
|
106
116
|
Treat done markers as compatibility hints only. Canonical completion should come
|
|
107
|
-
from harnex terminal telemetry (`harnex wait` / `harnex status --json` /
|
|
108
|
-
rows in `.harnex/dispatch.jsonl`).
|
|
117
|
+
from harnex terminal telemetry (`harnex wait` / `harnex status --json` / v2
|
|
118
|
+
`dispatch_end` rows in `.harnex/dispatch.jsonl`).
|
|
109
119
|
|
|
110
120
|
When a brief asks for a completion marker, make it one line and include the
|
|
111
121
|
highest-signal result: tests passed, review clean, or the blocking issue.
|
data/lib/harnex/adapters/base.rb
CHANGED
|
@@ -37,6 +37,16 @@ module Harnex
|
|
|
37
37
|
nil
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Whether the usage this adapter captures reports input_tokens
|
|
41
|
+
# INCLUSIVE of cached_tokens. Depends on the capture path, not just
|
|
42
|
+
# the provider: codex app-server JSON reports cached as a subset of
|
|
43
|
+
# input, while the codex TUI line the PTY adapter scrapes shows
|
|
44
|
+
# non-cached input with cached as a separate additive count. Feeds
|
|
45
|
+
# Pricing.compute (plan 33 Phase 2).
|
|
46
|
+
def usage_input_includes_cached?
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
# Probes `<base_command.first> --version` with a short timeout and
|
|
41
51
|
# memoizes the result for the adapter's lifetime. Returns nil when
|
|
42
52
|
# the binary is missing, exits non-zero, or stalls past the timeout.
|
|
@@ -59,6 +59,7 @@ module Harnex
|
|
|
59
59
|
@initial_prompt = extract_initial_prompt(extra_args)
|
|
60
60
|
@client = nil
|
|
61
61
|
@thread_id = nil
|
|
62
|
+
@current_model = nil
|
|
62
63
|
@current_turn_id = nil
|
|
63
64
|
@state = :disconnected
|
|
64
65
|
@last_completed_at = nil
|
|
@@ -78,6 +79,21 @@ module Harnex
|
|
|
78
79
|
true
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
# thread/tokenUsage/updated totals report cachedInputTokens as a
|
|
83
|
+
# subset of inputTokens (verified against a live captured row:
|
|
84
|
+
# input + output == total exactly, cached < input).
|
|
85
|
+
def usage_input_includes_cached?
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Effective model reported by the app-server's thread/start or
|
|
90
|
+
# thread/resume response (schema-required field). Feeds
|
|
91
|
+
# Session#summary_model so price-table cost can resolve without the
|
|
92
|
+
# caller passing `--meta '{"model": ...}'`.
|
|
93
|
+
def current_model
|
|
94
|
+
@current_model
|
|
95
|
+
end
|
|
96
|
+
|
|
81
97
|
def context_telemetry_supported?
|
|
82
98
|
true
|
|
83
99
|
end
|
|
@@ -202,6 +218,7 @@ module Harnex
|
|
|
202
218
|
ensure_open!
|
|
203
219
|
result = @client.request("thread/resume", { threadId: thread_id })
|
|
204
220
|
@thread_id = thread_id
|
|
221
|
+
@current_model = extract_model(result) || @current_model
|
|
205
222
|
@state = :prompt
|
|
206
223
|
result
|
|
207
224
|
end
|
|
@@ -279,6 +296,7 @@ module Harnex
|
|
|
279
296
|
|
|
280
297
|
result = @client.request("thread/start", {})
|
|
281
298
|
@thread_id = extract_thread_id(result)
|
|
299
|
+
@current_model = extract_model(result) || @current_model
|
|
282
300
|
end
|
|
283
301
|
|
|
284
302
|
def extract_thread_id(payload)
|
|
@@ -287,6 +305,13 @@ module Harnex
|
|
|
287
305
|
payload.dig("thread", "id")
|
|
288
306
|
end
|
|
289
307
|
|
|
308
|
+
def extract_model(payload)
|
|
309
|
+
return nil unless payload.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
model = payload["model"]
|
|
312
|
+
model.is_a?(String) && !model.empty? ? model : nil
|
|
313
|
+
end
|
|
314
|
+
|
|
290
315
|
def extract_initial_prompt(extra_args)
|
|
291
316
|
return nil unless extra_args.is_a?(Array)
|
|
292
317
|
|
|
@@ -7,7 +7,7 @@ module Harnex
|
|
|
7
7
|
|
|
8
8
|
def self.usage
|
|
9
9
|
<<~TEXT
|
|
10
|
-
Usage: harnex doctor [--sweep]
|
|
10
|
+
Usage: harnex doctor [--sweep] [--prune [--dry-run]]
|
|
11
11
|
|
|
12
12
|
Runs preflight checks for harnex's adapter dependencies.
|
|
13
13
|
Currently verifies that Codex CLI is installed and at version
|
|
@@ -16,16 +16,21 @@ module Harnex
|
|
|
16
16
|
|
|
17
17
|
Options:
|
|
18
18
|
--sweep Include a read-only report of harnex/tmux session drift
|
|
19
|
+
--prune Apply bounded harnex events/output retention pruning
|
|
20
|
+
--dry-run Preview --prune candidates without deleting
|
|
19
21
|
-h, --help Show this help
|
|
20
22
|
|
|
21
23
|
Common patterns:
|
|
22
24
|
harnex doctor
|
|
23
25
|
harnex doctor --sweep
|
|
26
|
+
harnex doctor --prune --dry-run
|
|
27
|
+
harnex doctor --prune
|
|
24
28
|
harnex doctor --help
|
|
25
29
|
|
|
26
30
|
Gotchas:
|
|
27
31
|
doctor validates local adapter prerequisites; it does not start sessions.
|
|
28
32
|
--sweep is diagnostic only; it does not stop sessions or remove files.
|
|
33
|
+
--dry-run must be paired with --prune.
|
|
29
34
|
Run it after installing or upgrading Codex CLI.
|
|
30
35
|
TEXT
|
|
31
36
|
end
|
|
@@ -34,21 +39,26 @@ module Harnex
|
|
|
34
39
|
@argv = argv.dup
|
|
35
40
|
@options = {
|
|
36
41
|
sweep: false,
|
|
42
|
+
prune: false,
|
|
43
|
+
dry_run: false,
|
|
37
44
|
help: false
|
|
38
45
|
}
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def run
|
|
42
49
|
parser.parse!(@argv)
|
|
50
|
+
validate_options!
|
|
43
51
|
if @options[:help]
|
|
44
52
|
puts self.class.usage
|
|
45
53
|
return 0
|
|
46
54
|
end
|
|
47
55
|
|
|
48
56
|
checks = [check_codex]
|
|
57
|
+
retention = retention_payload
|
|
49
58
|
summary = {
|
|
50
|
-
ok: checks.all? { |c| c[:ok] },
|
|
51
|
-
checks: checks
|
|
59
|
+
ok: checks.all? { |c| c[:ok] } && retention.fetch(:ok, true),
|
|
60
|
+
checks: checks,
|
|
61
|
+
retention: retention
|
|
52
62
|
}
|
|
53
63
|
summary[:sweep] = sweep_payload if @options[:sweep]
|
|
54
64
|
puts JSON.generate(summary)
|
|
@@ -59,12 +69,36 @@ module Harnex
|
|
|
59
69
|
|
|
60
70
|
def parser
|
|
61
71
|
@parser ||= OptionParser.new do |opts|
|
|
62
|
-
opts.banner = "Usage: harnex doctor [--sweep]"
|
|
72
|
+
opts.banner = "Usage: harnex doctor [--sweep] [--prune [--dry-run]]"
|
|
63
73
|
opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
|
|
74
|
+
opts.on("--prune", "Apply retention pruning") { @options[:prune] = true }
|
|
75
|
+
opts.on("--dry-run", "Preview --prune candidates without deleting") { @options[:dry_run] = true }
|
|
64
76
|
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
65
77
|
end
|
|
66
78
|
end
|
|
67
79
|
|
|
80
|
+
def validate_options!
|
|
81
|
+
return if @options[:help]
|
|
82
|
+
return unless @options[:dry_run] && !@options[:prune]
|
|
83
|
+
|
|
84
|
+
raise OptionParser::InvalidOption, "--dry-run requires --prune"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def retention_payload
|
|
88
|
+
repo_root = Harnex.resolve_repo_root(Dir.pwd)
|
|
89
|
+
if @options[:prune]
|
|
90
|
+
Harnex::Retention.prune(
|
|
91
|
+
repo_root: repo_root,
|
|
92
|
+
dry_run: @options[:dry_run],
|
|
93
|
+
force: true
|
|
94
|
+
)
|
|
95
|
+
else
|
|
96
|
+
Harnex::Retention.status(repo_root: repo_root)
|
|
97
|
+
end
|
|
98
|
+
rescue Harnex::Config::ConfigError => e
|
|
99
|
+
{ ok: false, error: e.message }
|
|
100
|
+
end
|
|
101
|
+
|
|
68
102
|
def check_codex
|
|
69
103
|
result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
|
|
70
104
|
|
|
@@ -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,64 @@ 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
|
+
# Rows in neither shape (pre-0.7.3 schemas) are skipped entirely.
|
|
89
|
+
def derived_records
|
|
90
|
+
raw = load_records
|
|
91
|
+
ended = Set.new
|
|
92
|
+
raw.each do |record|
|
|
93
|
+
next unless DispatchHistory.end_record?(record)
|
|
94
|
+
|
|
95
|
+
session_id = record["session_id"].to_s
|
|
96
|
+
ended << "sid:#{session_id}" unless session_id.empty?
|
|
97
|
+
ended << "leg:#{record['id']}|#{record['started_at']}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
raw.filter_map do |record|
|
|
101
|
+
if DispatchHistory.start_record?(record)
|
|
102
|
+
next nil if start_completed?(record, ended)
|
|
103
|
+
|
|
104
|
+
derive_live_record(record)
|
|
105
|
+
elsif DispatchHistory.end_record?(record)
|
|
106
|
+
record
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def start_completed?(record, ended)
|
|
112
|
+
session_id = record["session_id"].to_s
|
|
113
|
+
return true if !session_id.empty? && ended.include?("sid:#{session_id}")
|
|
114
|
+
|
|
115
|
+
ended.include?("leg:#{record['id']}|#{record['started_at']}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def derive_live_record(record)
|
|
119
|
+
alive = DispatchHistory.same_host?(record) &&
|
|
120
|
+
record["pid"] && Harnex.alive_pid?(record["pid"])
|
|
121
|
+
record.merge(
|
|
122
|
+
"status" => alive ? "running" : "interrupted",
|
|
123
|
+
"terminal_event" => nil,
|
|
124
|
+
"duration_s" => alive ? seconds_since(record["started_at"]) : nil,
|
|
125
|
+
"ended_at" => nil
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def seconds_since(timestamp)
|
|
130
|
+
seconds = (Time.now - Time.iso8601(timestamp.to_s)).to_i
|
|
131
|
+
seconds.negative? ? 0 : seconds
|
|
132
|
+
rescue ArgumentError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
84
136
|
def load_records
|
|
85
137
|
path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
|
|
86
138
|
return [] unless File.file?(path)
|
|
@@ -129,7 +181,9 @@ module Harnex
|
|
|
129
181
|
end
|
|
130
182
|
|
|
131
183
|
def format_duration(value)
|
|
132
|
-
|
|
184
|
+
return "-" if value.nil?
|
|
185
|
+
|
|
186
|
+
seconds = Integer(value)
|
|
133
187
|
hours = seconds / 3600
|
|
134
188
|
minutes = (seconds % 3600) / 60
|
|
135
189
|
rest = seconds % 60
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -33,8 +33,13 @@ module Harnex
|
|
|
33
33
|
--id --description --detach --tmux --host --port --watch --watch-file
|
|
34
34
|
--stall-after --max-resumes --preset --context --meta --summary-out
|
|
35
35
|
--artifact-report --validation-report --cwd --root --timeout --inbox-ttl
|
|
36
|
-
--require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
|
|
36
|
+
--require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
|
|
37
|
+
--allow-live-parent --help
|
|
37
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 fallback superseding].freeze
|
|
38
43
|
VALUE_FLAGS = %w[
|
|
39
44
|
--id --description --host --port --watch --watch-file --stall-after
|
|
40
45
|
--max-resumes --preset --context --meta --summary-out --artifact-report
|
|
@@ -62,7 +67,7 @@ module Harnex
|
|
|
62
67
|
--fast (codex only) Use Codex service_tier="fast".
|
|
63
68
|
Default Codex runs force service_tier="flex".
|
|
64
69
|
--meta JSON Attach parsed JSON metadata to the started event
|
|
65
|
-
--summary-out PATH
|
|
70
|
+
--summary-out PATH Also mirror the dispatch end record JSONL to PATH
|
|
66
71
|
--artifact-report PATH
|
|
67
72
|
Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
|
|
68
73
|
--validation-report PATH
|
|
@@ -82,11 +87,17 @@ module Harnex
|
|
|
82
87
|
--model NAME Requested model metadata (also used for structured dispatch)
|
|
83
88
|
--effort LEVEL Requested reasoning effort metadata (structured dispatch)
|
|
84
89
|
--parent-dispatch-id ID
|
|
85
|
-
Parent dispatch id for retry/fix/review joins
|
|
90
|
+
Parent dispatch id for retry/fix/review/fallback joins
|
|
86
91
|
--parent-attempt-id ID
|
|
87
|
-
Parent attempt id for retry/fix/review joins
|
|
92
|
+
Parent attempt id for retry/fix/review/fallback joins
|
|
88
93
|
--attempt-kind KIND
|
|
89
|
-
initial, retry, fix, review, or superseding (default: initial)
|
|
94
|
+
initial, retry, fix, review, fallback, or superseding (default: initial).
|
|
95
|
+
retry/fallback 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)
|
|
90
101
|
--orchestration-run-id ID
|
|
91
102
|
Logical primary-orchestrator run id for queue rollups
|
|
92
103
|
--orchestration-generation-id ID
|
|
@@ -164,6 +175,7 @@ module Harnex
|
|
|
164
175
|
cwd: nil,
|
|
165
176
|
root: nil,
|
|
166
177
|
auto_stop: false,
|
|
178
|
+
allow_live_parent: false,
|
|
167
179
|
detach: false,
|
|
168
180
|
tmux: false,
|
|
169
181
|
tmux_name: nil,
|
|
@@ -191,10 +203,12 @@ module Harnex
|
|
|
191
203
|
validate_required_attribution!
|
|
192
204
|
|
|
193
205
|
repo_root = resolve_run_root(cli_name, child_args)
|
|
206
|
+
validate_repo_phase_policy!(repo_root)
|
|
194
207
|
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
195
208
|
@options[:artifact_report] = resolve_artifact_report(repo_root)
|
|
196
209
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
197
210
|
validate_unique_id!(repo_root)
|
|
211
|
+
validate_live_parent_guard!(repo_root)
|
|
198
212
|
effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
|
|
199
213
|
adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
|
|
200
214
|
@options[:detach] = true if @options[:tmux]
|
|
@@ -258,6 +272,7 @@ module Harnex
|
|
|
258
272
|
tmux_cmd += [flag, value] if flag && value
|
|
259
273
|
end
|
|
260
274
|
tmux_cmd << "--require-attribution" if @options[:require_attribution]
|
|
275
|
+
tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
|
|
261
276
|
tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
|
|
262
277
|
tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
|
|
263
278
|
tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
|
|
@@ -357,6 +372,57 @@ module Harnex
|
|
|
357
372
|
"Use a different --id or stop the existing session first."
|
|
358
373
|
end
|
|
359
374
|
|
|
375
|
+
# Duplicate-dispatch guard (issue #62): a retry/fix/fallback/superseding attempt
|
|
376
|
+
# whose parent dispatch is still running would duplicate work in the same
|
|
377
|
+
# checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
|
|
378
|
+
# lineage of a live spawner must not trip this.
|
|
379
|
+
def validate_live_parent_guard!(repo_root)
|
|
380
|
+
metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
|
|
381
|
+
kind = metadata["attempt_kind"].to_s
|
|
382
|
+
parent_id = metadata["parent_dispatch_id"].to_s.strip
|
|
383
|
+
|
|
384
|
+
if %w[retry fallback].include?(kind) && parent_id.empty?
|
|
385
|
+
raise "harnex run: --attempt-kind #{kind} requires --parent-dispatch-id " \
|
|
386
|
+
"so the duplicate-dispatch guard can verify the parent is not still running."
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
return if @options[:allow_live_parent]
|
|
390
|
+
return if parent_id.empty?
|
|
391
|
+
return unless LIVE_PARENT_GUARDED_KINDS.include?(kind)
|
|
392
|
+
|
|
393
|
+
live = Harnex.active_sessions(repo_root, id: parent_id).first
|
|
394
|
+
return unless live
|
|
395
|
+
|
|
396
|
+
raise "harnex run: refusing #{kind} dispatch — parent dispatch #{parent_id.inspect} " \
|
|
397
|
+
"is still running (pid #{live['pid']}, started #{live['started_at']}). " \
|
|
398
|
+
"Wait for it (harnex wait --id #{parent_id} --until done), stop it, " \
|
|
399
|
+
"or pass --allow-live-parent for intentional parallelism."
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def validate_repo_phase_policy!(repo_root)
|
|
403
|
+
config = Harnex::Config.load_repo(repo_root)
|
|
404
|
+
Harnex::Config.retention_limits(config: config)
|
|
405
|
+
phase_config = config.phase
|
|
406
|
+
return unless phase_config
|
|
407
|
+
|
|
408
|
+
metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
|
|
409
|
+
phase = metadata["phase"].to_s
|
|
410
|
+
allowlist = phase_config.fetch("allowlist")
|
|
411
|
+
return if allowlist.include?(phase)
|
|
412
|
+
|
|
413
|
+
policy = phase_config.fetch("policy")
|
|
414
|
+
message = "harnex run: meta.phase #{phase.empty? ? '(none)' : phase.inspect} " \
|
|
415
|
+
"is not allowlisted by #{config.path} " \
|
|
416
|
+
"(allowed: #{allowlist.join(', ')}; policy: #{policy})"
|
|
417
|
+
if policy == "warn"
|
|
418
|
+
warn("harnex: warning: #{message}")
|
|
419
|
+
else
|
|
420
|
+
raise OptionParser::InvalidOption, message
|
|
421
|
+
end
|
|
422
|
+
rescue Harnex::Config::ConfigError => e
|
|
423
|
+
raise OptionParser::InvalidOption, "harnex run: invalid config #{e.message}"
|
|
424
|
+
end
|
|
425
|
+
|
|
360
426
|
def build_session(adapter, repo_root)
|
|
361
427
|
watch = Harnex.build_watch_config(@options[:watch], repo_root)
|
|
362
428
|
Session.new(
|
|
@@ -541,6 +607,8 @@ module Harnex
|
|
|
541
607
|
@options[:context] = required_option_value("--context", Regexp.last_match(1))
|
|
542
608
|
when "--auto-stop"
|
|
543
609
|
@options[:auto_stop] = true
|
|
610
|
+
when "--allow-live-parent"
|
|
611
|
+
@options[:allow_live_parent] = true
|
|
544
612
|
when "--require-attribution"
|
|
545
613
|
@options[:require_attribution] = true
|
|
546
614
|
when "--fast"
|
|
@@ -646,7 +714,7 @@ module Harnex
|
|
|
646
714
|
case arg
|
|
647
715
|
when "--"
|
|
648
716
|
return false
|
|
649
|
-
when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty"
|
|
717
|
+
when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty", "--allow-live-parent"
|
|
650
718
|
nil
|
|
651
719
|
when /\A--tmux=/
|
|
652
720
|
nil
|
|
@@ -799,9 +867,11 @@ module Harnex
|
|
|
799
867
|
raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
|
|
800
868
|
end
|
|
801
869
|
|
|
870
|
+
# Explicit-only mirror: the tracked dispatch stream is the canonical
|
|
871
|
+
# destination; --summary-out just duplicates the end record elsewhere.
|
|
802
872
|
def resolve_summary_out(repo_root)
|
|
803
873
|
configured = @options[:summary_out]
|
|
804
|
-
return
|
|
874
|
+
return nil if configured.nil?
|
|
805
875
|
|
|
806
876
|
File.expand_path(configured, repo_root)
|
|
807
877
|
end
|
|
@@ -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)
|