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.
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, append a suffix:
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 first attempt
75
- pi-i-42b second attempt
76
- pi-i-42c third attempt
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
- Keep the old session's logs. They are useful for diagnosis.
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` / summary
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.
@@ -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 = 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
+ # 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
- seconds = Integer(value || 0)
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
@@ -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 --help
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 Append dispatch telemetry summary JSONL to 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 Harnex.default_summary_out_path(repo_root) if configured.nil?
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)