harnex 0.8.0 → 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 +88 -0
- 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 +6 -5
- 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 +7 -3
- data/lib/harnex/commands/run.rb +37 -10
- data/lib/harnex/config.rb +166 -0
- data/lib/harnex/core.rb +0 -7
- data/lib/harnex/dispatch_history.rb +13 -4
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +311 -0
- data/lib/harnex/runtime/session.rb +140 -12
- data/lib/harnex/terminal_status.rb +11 -1
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +8 -1
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
|
|
|
@@ -85,6 +85,7 @@ module Harnex
|
|
|
85
85
|
# One row per dispatch: start rows completed by an end row are dropped
|
|
86
86
|
# (the end row carries the outcome); uncompleted start rows surface as
|
|
87
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.
|
|
88
89
|
def derived_records
|
|
89
90
|
raw = load_records
|
|
90
91
|
ended = Set.new
|
|
@@ -97,10 +98,13 @@ module Harnex
|
|
|
97
98
|
end
|
|
98
99
|
|
|
99
100
|
raw.filter_map do |record|
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
if DispatchHistory.start_record?(record)
|
|
102
|
+
next nil if start_completed?(record, ended)
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
derive_live_record(record)
|
|
105
|
+
elsif DispatchHistory.end_record?(record)
|
|
106
|
+
record
|
|
107
|
+
end
|
|
104
108
|
end
|
|
105
109
|
end
|
|
106
110
|
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Harnex
|
|
|
39
39
|
|
|
40
40
|
# Attempt kinds that redo the parent's work; dispatching one while the
|
|
41
41
|
# parent is still running duplicates work in the same checkout.
|
|
42
|
-
LIVE_PARENT_GUARDED_KINDS = %w[retry fix superseding].freeze
|
|
42
|
+
LIVE_PARENT_GUARDED_KINDS = %w[retry fix fallback superseding].freeze
|
|
43
43
|
VALUE_FLAGS = %w[
|
|
44
44
|
--id --description --host --port --watch --watch-file --stall-after
|
|
45
45
|
--max-resumes --preset --context --meta --summary-out --artifact-report
|
|
@@ -67,7 +67,7 @@ module Harnex
|
|
|
67
67
|
--fast (codex only) Use Codex service_tier="fast".
|
|
68
68
|
Default Codex runs force service_tier="flex".
|
|
69
69
|
--meta JSON Attach parsed JSON metadata to the started event
|
|
70
|
-
--summary-out PATH
|
|
70
|
+
--summary-out PATH Also mirror the dispatch end record JSONL to PATH
|
|
71
71
|
--artifact-report PATH
|
|
72
72
|
Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
|
|
73
73
|
--validation-report PATH
|
|
@@ -87,12 +87,12 @@ module Harnex
|
|
|
87
87
|
--model NAME Requested model metadata (also used for structured dispatch)
|
|
88
88
|
--effort LEVEL Requested reasoning effort metadata (structured dispatch)
|
|
89
89
|
--parent-dispatch-id ID
|
|
90
|
-
Parent dispatch id for retry/fix/review joins
|
|
90
|
+
Parent dispatch id for retry/fix/review/fallback joins
|
|
91
91
|
--parent-attempt-id ID
|
|
92
|
-
Parent attempt id for retry/fix/review joins
|
|
92
|
+
Parent attempt id for retry/fix/review/fallback joins
|
|
93
93
|
--attempt-kind KIND
|
|
94
|
-
initial, retry, fix, review, or superseding (default: initial).
|
|
95
|
-
retry requires --parent-dispatch-id so the
|
|
94
|
+
initial, retry, fix, review, fallback, or superseding (default: initial).
|
|
95
|
+
retry/fallback requires --parent-dispatch-id so the
|
|
96
96
|
duplicate-dispatch guard can verify the parent
|
|
97
97
|
--allow-live-parent
|
|
98
98
|
Dispatch even though --parent-dispatch-id names a
|
|
@@ -203,6 +203,7 @@ module Harnex
|
|
|
203
203
|
validate_required_attribution!
|
|
204
204
|
|
|
205
205
|
repo_root = resolve_run_root(cli_name, child_args)
|
|
206
|
+
validate_repo_phase_policy!(repo_root)
|
|
206
207
|
@options[:summary_out] = resolve_summary_out(repo_root)
|
|
207
208
|
@options[:artifact_report] = resolve_artifact_report(repo_root)
|
|
208
209
|
@options[:id] ||= Harnex.generate_id(repo_root)
|
|
@@ -371,7 +372,7 @@ module Harnex
|
|
|
371
372
|
"Use a different --id or stop the existing session first."
|
|
372
373
|
end
|
|
373
374
|
|
|
374
|
-
# Duplicate-dispatch guard (issue #62): a retry/fix/superseding attempt
|
|
375
|
+
# Duplicate-dispatch guard (issue #62): a retry/fix/fallback/superseding attempt
|
|
375
376
|
# whose parent dispatch is still running would duplicate work in the same
|
|
376
377
|
# checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
|
|
377
378
|
# lineage of a live spawner must not trip this.
|
|
@@ -380,8 +381,8 @@ module Harnex
|
|
|
380
381
|
kind = metadata["attempt_kind"].to_s
|
|
381
382
|
parent_id = metadata["parent_dispatch_id"].to_s.strip
|
|
382
383
|
|
|
383
|
-
if kind
|
|
384
|
-
raise "harnex run: --attempt-kind
|
|
384
|
+
if %w[retry fallback].include?(kind) && parent_id.empty?
|
|
385
|
+
raise "harnex run: --attempt-kind #{kind} requires --parent-dispatch-id " \
|
|
385
386
|
"so the duplicate-dispatch guard can verify the parent is not still running."
|
|
386
387
|
end
|
|
387
388
|
|
|
@@ -398,6 +399,30 @@ module Harnex
|
|
|
398
399
|
"or pass --allow-live-parent for intentional parallelism."
|
|
399
400
|
end
|
|
400
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
|
+
|
|
401
426
|
def build_session(adapter, repo_root)
|
|
402
427
|
watch = Harnex.build_watch_config(@options[:watch], repo_root)
|
|
403
428
|
Session.new(
|
|
@@ -842,9 +867,11 @@ module Harnex
|
|
|
842
867
|
raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
|
|
843
868
|
end
|
|
844
869
|
|
|
870
|
+
# Explicit-only mirror: the tracked dispatch stream is the canonical
|
|
871
|
+
# destination; --summary-out just duplicates the end record elsewhere.
|
|
845
872
|
def resolve_summary_out(repo_root)
|
|
846
873
|
configured = @options[:summary_out]
|
|
847
|
-
return
|
|
874
|
+
return nil if configured.nil?
|
|
848
875
|
|
|
849
876
|
File.expand_path(configured, repo_root)
|
|
850
877
|
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
module Config
|
|
5
|
+
CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
|
|
6
|
+
PHASE_POLICIES = %w[warn reject].freeze
|
|
7
|
+
RETENTION_DIRS = %w[events output].freeze
|
|
8
|
+
RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
|
|
9
|
+
RETENTION_ENV = {
|
|
10
|
+
"events" => {
|
|
11
|
+
"max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
|
|
12
|
+
"max_bytes" => "HARNEX_EVENTS_MAX_BYTES"
|
|
13
|
+
},
|
|
14
|
+
"output" => {
|
|
15
|
+
"max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
|
|
16
|
+
"max_bytes" => "HARNEX_OUTPUT_MAX_BYTES"
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
|
|
20
|
+
[dir, { "max_age_days" => 45, "max_bytes" => 1_073_741_824 }]
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
23
|
+
ConfigError = Class.new(StandardError)
|
|
24
|
+
|
|
25
|
+
RepoConfig = Struct.new(:root, :path, :data, keyword_init: true) do
|
|
26
|
+
def present?
|
|
27
|
+
!path.nil? && File.file?(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def phase
|
|
31
|
+
data.is_a?(Hash) ? data["phase"] : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def retention
|
|
35
|
+
data.is_a?(Hash) ? data["retention"] : nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def load_repo(start_path)
|
|
42
|
+
root = DispatchHistory.find_git_root(start_path)
|
|
43
|
+
return RepoConfig.new(root: nil, path: nil, data: {}) unless root
|
|
44
|
+
|
|
45
|
+
path = File.join(root, CONFIG_RELATIVE_PATH)
|
|
46
|
+
return RepoConfig.new(root: root, path: path, data: {}) unless File.file?(path)
|
|
47
|
+
|
|
48
|
+
parsed = JSON.parse(File.read(path))
|
|
49
|
+
validate_document!(parsed, path)
|
|
50
|
+
RepoConfig.new(root: root, path: path, data: parsed)
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
raise ConfigError, "#{path}: malformed JSON: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_document!(document, path)
|
|
56
|
+
unless document.is_a?(Hash)
|
|
57
|
+
raise ConfigError, "#{path}: config must be a JSON object"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
validate_phase!(document["phase"], path) if document.key?("phase")
|
|
61
|
+
validate_retention!(document["retention"], path) if document.key?("retention")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_phase!(phase, path)
|
|
65
|
+
unless phase.is_a?(Hash)
|
|
66
|
+
raise ConfigError, "#{path}: $.phase must be an object"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
allowlist = phase["allowlist"]
|
|
70
|
+
unless allowlist.is_a?(Array)
|
|
71
|
+
raise ConfigError, "#{path}: $.phase.allowlist must be an array"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
allowlist.each_with_index do |entry, index|
|
|
75
|
+
next if entry.is_a?(String) && !entry.strip.empty?
|
|
76
|
+
|
|
77
|
+
raise ConfigError,
|
|
78
|
+
"#{path}: $.phase.allowlist[#{index}] must be a non-empty string"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
policy = phase["policy"]
|
|
82
|
+
unless PHASE_POLICIES.include?(policy)
|
|
83
|
+
raise ConfigError,
|
|
84
|
+
"#{path}: $.phase.policy must be one of #{PHASE_POLICIES.join(', ')}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def retention_limits(start_path = Dir.pwd, env: ENV, config: nil)
|
|
89
|
+
config ||= load_repo(start_path)
|
|
90
|
+
limits = deep_dup_retention_defaults
|
|
91
|
+
retention = config.retention
|
|
92
|
+
|
|
93
|
+
if retention
|
|
94
|
+
RETENTION_DIRS.each do |dir|
|
|
95
|
+
next unless retention.key?(dir)
|
|
96
|
+
|
|
97
|
+
RETENTION_FIELDS.each do |field|
|
|
98
|
+
next unless retention.fetch(dir).key?(field)
|
|
99
|
+
|
|
100
|
+
limits.fetch(dir)[field] = parse_positive_integer!(
|
|
101
|
+
retention.fetch(dir).fetch(field),
|
|
102
|
+
"#{config.path}: $.retention.#{dir}.#{field}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
RETENTION_ENV.each do |dir, fields|
|
|
109
|
+
fields.each do |field, name|
|
|
110
|
+
next unless env.key?(name)
|
|
111
|
+
|
|
112
|
+
limits.fetch(dir)[field] = parse_positive_integer!(
|
|
113
|
+
env.fetch(name),
|
|
114
|
+
name
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
limits
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_retention!(retention, path)
|
|
123
|
+
unless retention.is_a?(Hash)
|
|
124
|
+
raise ConfigError, "#{path}: $.retention must be an object"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
retention.each do |dir, value|
|
|
128
|
+
unless RETENTION_DIRS.include?(dir)
|
|
129
|
+
raise ConfigError,
|
|
130
|
+
"#{path}: $.retention.#{dir} is not supported (expected events or output)"
|
|
131
|
+
end
|
|
132
|
+
unless value.is_a?(Hash)
|
|
133
|
+
raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
value.each do |field, limit|
|
|
137
|
+
unless RETENTION_FIELDS.include?(field)
|
|
138
|
+
raise ConfigError,
|
|
139
|
+
"#{path}: $.retention.#{dir}.#{field} is not supported"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
parse_positive_integer!(
|
|
143
|
+
limit,
|
|
144
|
+
"#{path}: $.retention.#{dir}.#{field}"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_positive_integer!(value, label)
|
|
151
|
+
text = value.to_s.strip
|
|
152
|
+
unless text.match?(/\A[0-9]+\z/)
|
|
153
|
+
raise ConfigError, "#{label} must be a positive integer"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
integer = Integer(text)
|
|
157
|
+
raise ConfigError, "#{label} must be a positive integer" if integer <= 0
|
|
158
|
+
|
|
159
|
+
integer
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def deep_dup_retention_defaults
|
|
163
|
+
DEFAULT_RETENTION_LIMITS.transform_values(&:dup)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/harnex/core.rb
CHANGED
|
@@ -114,13 +114,6 @@ module Harnex
|
|
|
114
114
|
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def default_summary_out_path(repo_root)
|
|
118
|
-
root = repo_root.to_s
|
|
119
|
-
return nil if root.empty?
|
|
120
|
-
|
|
121
|
-
File.join(root, ".harnex", "dispatch.jsonl")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
117
|
def git_capture_start(repo_root)
|
|
125
118
|
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
126
119
|
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
@@ -9,6 +9,12 @@ module Harnex
|
|
|
9
9
|
|
|
10
10
|
MAX_REPO_WALK_LEVELS = 10
|
|
11
11
|
|
|
12
|
+
# v2 marks the unified era: one rich dispatch_end row per dispatch
|
|
13
|
+
# carrying both the thin envelope and the summary sections. Readers
|
|
14
|
+
# key on record_type, not this stamp; legacy clauses keep accepting
|
|
15
|
+
# v1 and envelope-less rows mixed in the same file.
|
|
16
|
+
SCHEMA_VERSION = 2
|
|
17
|
+
|
|
12
18
|
def global_path
|
|
13
19
|
File.join(STATE_DIR, "dispatch.jsonl")
|
|
14
20
|
end
|
|
@@ -138,7 +144,7 @@ module Harnex
|
|
|
138
144
|
# trace; the dispatch_end row written in finalize_session! completes it.
|
|
139
145
|
def build_start_record(session)
|
|
140
146
|
{
|
|
141
|
-
schema_version:
|
|
147
|
+
schema_version: SCHEMA_VERSION,
|
|
142
148
|
record_type: "dispatch_start",
|
|
143
149
|
id: session.id,
|
|
144
150
|
session_id: session.session_id,
|
|
@@ -155,11 +161,15 @@ module Harnex
|
|
|
155
161
|
}
|
|
156
162
|
end
|
|
157
163
|
|
|
164
|
+
# The v2 end row: the thin envelope merged with the rich summary
|
|
165
|
+
# sections. The envelope carries no raw meta passthrough — the summary's
|
|
166
|
+
# meta section (a superset with provenance) rides in its place; top-level
|
|
167
|
+
# tier stays for the history renderer.
|
|
158
168
|
def build_record(session)
|
|
159
169
|
ended_at = session.ended_at || Time.now
|
|
160
170
|
status, terminal_event = classify(session)
|
|
161
171
|
{
|
|
162
|
-
schema_version:
|
|
172
|
+
schema_version: SCHEMA_VERSION,
|
|
163
173
|
record_type: "dispatch_end",
|
|
164
174
|
id: session.id,
|
|
165
175
|
session_id: session.session_id,
|
|
@@ -172,11 +182,10 @@ module Harnex
|
|
|
172
182
|
terminal_event: terminal_event,
|
|
173
183
|
commit_sha: commit_sha(session.git_start, session.git_end),
|
|
174
184
|
tier: session.__send__(:meta_hash)["tier"],
|
|
175
|
-
meta: session.__send__(:meta_hash),
|
|
176
185
|
summary_out_path: session.summary_out,
|
|
177
186
|
events_log_path: session.events_log_path,
|
|
178
187
|
tmux_state: tmux_state(session.__send__(:summary_tmux_session))
|
|
179
|
-
}
|
|
188
|
+
}.merge(session.__send__(:build_summary_record))
|
|
180
189
|
end
|
|
181
190
|
|
|
182
191
|
def classify(session)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# Static price table for computing usage.cost_usd when an adapter reports
|
|
3
|
+
# tokens but no cost (plan 33 Phase 2, locked decision 6). Cost is computed,
|
|
4
|
+
# never guessed: an unknown provider/model/service tier or a missing token
|
|
5
|
+
# component leaves cost_usd null.
|
|
6
|
+
#
|
|
7
|
+
# ## Update procedure
|
|
8
|
+
#
|
|
9
|
+
# Rates are USD per 1M tokens, copied by hand from the provider pricing
|
|
10
|
+
# pages at the URLs below. To update:
|
|
11
|
+
#
|
|
12
|
+
# 1. Fetch the provider's pricing page and read the per-1M-token rates for
|
|
13
|
+
# input, cached input (cache read), and output.
|
|
14
|
+
# 2. Replace the entry's rates and set `as_of` to the date you read them.
|
|
15
|
+
# Add new models as new entries; never delete an entry solely because a
|
|
16
|
+
# model left the pricing page (old rows in dispatch.jsonl were priced
|
|
17
|
+
# against it — the `as_of` on each row records which table vintage
|
|
18
|
+
# applied).
|
|
19
|
+
# 3. Update the expected costs in test/harnex/pricing_test.rb — rate
|
|
20
|
+
# changes are meant to be conscious, test-visible edits.
|
|
21
|
+
# 4. Never backfill: rows written before a table change keep the cost they
|
|
22
|
+
# were written with.
|
|
23
|
+
#
|
|
24
|
+
# Models whose pricing varies by service tier use a nested `service_tiers`
|
|
25
|
+
# table. For those models a nil or unknown service tier is unpriceable. A
|
|
26
|
+
# `max_context_tokens_exclusive` entry also requires an observed context
|
|
27
|
+
# high-water below that boundary; Harnex leaves cost null when context is
|
|
28
|
+
# missing or entered a differently-priced long-context tier. Do not infer
|
|
29
|
+
# standard/flex/fast or context tier outside the recorded measurements.
|
|
30
|
+
# OpenAI documents `priority` as the fast alias for gpt-5.5 short-context
|
|
31
|
+
# pricing; Harnex maps that alias only where the source supports it.
|
|
32
|
+
#
|
|
33
|
+
# Known scheduled change: Anthropic's claude-sonnet-5 entry below carries
|
|
34
|
+
# introductory pricing ($2/$10) that ends 2026-08-31; standard pricing is
|
|
35
|
+
# $3/$15 (cache read $0.30) from 2026-09-01. Refresh the entry then.
|
|
36
|
+
#
|
|
37
|
+
# ## Token semantics (verified 2026-08-02, plan 33 Phase 2)
|
|
38
|
+
#
|
|
39
|
+
# What input_tokens contains depends on the CAPTURE PATH, not just the
|
|
40
|
+
# provider, so the caller passes `input_includes_cached:` (sourced from
|
|
41
|
+
# `Adapters::Base#usage_input_includes_cached?`):
|
|
42
|
+
#
|
|
43
|
+
# - codex app-server (input_includes_cached: true): the JSON
|
|
44
|
+
# TokenUsageBreakdown reports cachedInputTokens as a subset of
|
|
45
|
+
# inputTokens and reasoningOutputTokens as a subset of outputTokens
|
|
46
|
+
# (verified against test/fixtures/codex_schema/v2 and a live captured
|
|
47
|
+
# row: input 3,084,697 + output 17,705 == total 3,102,402 exactly).
|
|
48
|
+
# Billable input = input - cached.
|
|
49
|
+
# - codex PTY (false): the scraped TUI line `input=X (+ Y cached)` shows
|
|
50
|
+
# NON-cached input with cached as a separate additive count (fixture:
|
|
51
|
+
# total 106,867 == input 104,158 + output 2,709, cached 250,880 > input).
|
|
52
|
+
# Billable input = input; cached prices additively at the cached rate.
|
|
53
|
+
# - anthropic (false): the Messages API reports input_tokens EXCLUSIVE of
|
|
54
|
+
# cache reads (cache_read_input_tokens is a separate field). Cache
|
|
55
|
+
# WRITES (cache_creation_input_tokens, billed at 1.25x input) have no
|
|
56
|
+
# harnex usage field yet — the Phase 3 Claude usage producer must either
|
|
57
|
+
# fold them into input_tokens or extend this formula before pricing rows
|
|
58
|
+
# that include cache writes.
|
|
59
|
+
#
|
|
60
|
+
# Reasoning tokens are never priced separately on any path — they are
|
|
61
|
+
# already inside output_tokens.
|
|
62
|
+
module Pricing
|
|
63
|
+
PRICES = {
|
|
64
|
+
"openai" => {
|
|
65
|
+
# Source: https://developers.openai.com/api/docs/pricing
|
|
66
|
+
"gpt-5.3-codex" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
67
|
+
# Short-context service-tier rates read 2026-08-03. Long-context
|
|
68
|
+
# rates are deliberately absent until exact source pricing is known.
|
|
69
|
+
"gpt-5.5" => {
|
|
70
|
+
max_context_tokens_exclusive: 272_000,
|
|
71
|
+
service_tier_aliases: { "priority" => "fast" }.freeze,
|
|
72
|
+
service_tiers: {
|
|
73
|
+
"standard" => { input: 5.00, cached_input: 0.50, output: 30.00, as_of: "2026-08-03" },
|
|
74
|
+
"flex" => { input: 2.50, cached_input: 0.25, output: 15.00, as_of: "2026-08-03" },
|
|
75
|
+
"fast" => { input: 12.50, cached_input: 1.25, output: 75.00, as_of: "2026-08-03" }
|
|
76
|
+
}.freeze
|
|
77
|
+
}.freeze,
|
|
78
|
+
"gpt-5.2" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
79
|
+
"gpt-5.1" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
80
|
+
"gpt-5" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
81
|
+
"gpt-5-mini" => { input: 0.25, cached_input: 0.025, output: 2.00, as_of: "2026-08-02" }
|
|
82
|
+
}.freeze,
|
|
83
|
+
"anthropic" => {
|
|
84
|
+
# Source: https://claude.com/platform/api (cache read = 0.1x input)
|
|
85
|
+
"claude-fable-5" => { input: 10.00, cached_input: 1.00, output: 50.00, as_of: "2026-08-02" },
|
|
86
|
+
"claude-opus-5" => { input: 5.00, cached_input: 0.50, output: 25.00, as_of: "2026-08-02" },
|
|
87
|
+
"claude-sonnet-5" => { input: 2.00, cached_input: 0.20, output: 10.00, as_of: "2026-08-02" },
|
|
88
|
+
"claude-haiku-4-5" => { input: 1.00, cached_input: 0.10, output: 5.00, as_of: "2026-08-02" }
|
|
89
|
+
}.freeze
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
# Returns { cost_usd:, as_of: } or nil when the cost cannot be computed
|
|
95
|
+
# (unknown provider/model/service tier, or input/output token counts missing).
|
|
96
|
+
def compute(provider:, model:, input_tokens:, output_tokens:, cached_tokens: nil,
|
|
97
|
+
service_tier: nil, context_tokens: nil,
|
|
98
|
+
input_includes_cached: false)
|
|
99
|
+
model_entry = PRICES.dig(provider.to_s, model.to_s)
|
|
100
|
+
return nil unless model_entry
|
|
101
|
+
return nil unless context_priceable?(model_entry, context_tokens)
|
|
102
|
+
|
|
103
|
+
entry = rates_for_service_tier(model_entry, service_tier)
|
|
104
|
+
return nil unless entry
|
|
105
|
+
return nil unless input_tokens.is_a?(Numeric) && output_tokens.is_a?(Numeric)
|
|
106
|
+
|
|
107
|
+
cached = cached_tokens.is_a?(Numeric) ? cached_tokens : 0
|
|
108
|
+
billable_input = input_includes_cached ? input_tokens - cached : input_tokens
|
|
109
|
+
billable_input = 0 if billable_input.negative?
|
|
110
|
+
|
|
111
|
+
cost = (billable_input * entry[:input] +
|
|
112
|
+
cached * entry[:cached_input] +
|
|
113
|
+
output_tokens * entry[:output]) / 1_000_000.0
|
|
114
|
+
{ cost_usd: cost.round(6), as_of: entry[:as_of] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def context_priceable?(entry, context_tokens)
|
|
118
|
+
limit = entry[:max_context_tokens_exclusive]
|
|
119
|
+
return true unless limit
|
|
120
|
+
|
|
121
|
+
context_tokens.is_a?(Numeric) && context_tokens >= 0 && context_tokens < limit
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rates_for_service_tier(entry, service_tier)
|
|
125
|
+
tiers = entry[:service_tiers]
|
|
126
|
+
return entry unless tiers
|
|
127
|
+
|
|
128
|
+
tier = service_tier.to_s
|
|
129
|
+
return nil if tier.empty?
|
|
130
|
+
|
|
131
|
+
aliases = entry[:service_tier_aliases] || {}
|
|
132
|
+
tiers[aliases.fetch(tier, tier)]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|