harnex 0.9.0 → 0.10.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.
@@ -31,7 +31,7 @@ module Harnex
31
31
 
32
32
  KNOWN_FLAGS = %w[
33
33
  --id --description --detach --tmux --host --port --watch --watch-file
34
- --stall-after --max-resumes --preset --context --meta --summary-out
34
+ --stall-after --max-resumes --preset --context --meta
35
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
36
36
  --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
37
37
  --allow-live-parent --help
@@ -42,7 +42,7 @@ module Harnex
42
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
- --max-resumes --preset --context --meta --summary-out --artifact-report
45
+ --max-resumes --preset --context --meta --artifact-report
46
46
  --validation-report --cwd --root --timeout --inbox-ttl
47
47
  ].concat(TELEMETRY_FLAGS.keys).freeze
48
48
 
@@ -67,14 +67,13 @@ 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 Also mirror the dispatch end record JSONL to PATH
71
70
  --artifact-report PATH
72
- Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
71
+ Override the harness-authored observed-state receipt path
73
72
  --validation-report PATH
74
- Alias for --artifact-report; also exposed as HARNEX_VALIDATION_REPORT_PATH
73
+ Compatibility alias for --artifact-report
75
74
  --require-artifact-report
76
- Fail closed unless PATH contains accepted final proof;
77
- requires --artifact-report or --validation-report
75
+ Compatibility strict-proof flag; a default harness receipt
76
+ path is used when no explicit path is provided
78
77
  --project-id ID Queue telemetry project id (first-class flags override --meta)
79
78
  --queue-id ID Queue telemetry queue id
80
79
  --entry-id ID Queue telemetry entry id
@@ -124,8 +123,9 @@ module Harnex
124
123
  Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
125
124
  Bare `--watch` enables the babysitter.
126
125
  --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.
126
+ accepted completion after command/tool activity or a Git delta.
127
+ Every dispatch gets a harness-authored receipt. Workers may write only
128
+ optional claims to HARNEX_ARTIFACT_CLAIMS_PATH; claims never accept work.
129
129
  Explicit --stall-after/--max-resumes values override --preset defaults.
130
130
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
131
131
  Any other CLI name is launched with generic wrapping.
@@ -136,7 +136,7 @@ module Harnex
136
136
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
137
137
  #{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
138
138
  #{program_name} codex --cwd /tmp/public-bundle --id eval-001 --context "Read README.md and write OUTPUT.md" --auto-stop
139
- #{program_name} pi --id pi-i-52 --artifact-report .harnex/reports/pi-i-52.json --context "Write proof to $HARNEX_ARTIFACT_REPORT_PATH" --auto-stop
139
+ #{program_name} pi --id pi-r-64 --context 'Optionally write review claims to $HARNEX_ARTIFACT_CLAIMS_PATH' --auto-stop
140
140
  #{program_name} pi --project-id harnex --queue-id queue-005 --entry-id SP-4 --phase implement --intent queue-work --require-attribution --context "Implement SP-4"
141
141
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
142
142
 
@@ -169,7 +169,6 @@ module Harnex
169
169
  meta: nil,
170
170
  telemetry: {},
171
171
  require_attribution: false,
172
- summary_out: nil,
173
172
  artifact_report: nil,
174
173
  require_artifact_report: false,
175
174
  cwd: nil,
@@ -204,7 +203,6 @@ module Harnex
204
203
 
205
204
  repo_root = resolve_run_root(cli_name, child_args)
206
205
  validate_repo_phase_policy!(repo_root)
207
- @options[:summary_out] = resolve_summary_out(repo_root)
208
206
  @options[:artifact_report] = resolve_artifact_report(repo_root)
209
207
  @options[:id] ||= Harnex.generate_id(repo_root)
210
208
  validate_unique_id!(repo_root)
@@ -273,7 +271,6 @@ module Harnex
273
271
  end
274
272
  tmux_cmd << "--require-attribution" if @options[:require_attribution]
275
273
  tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
276
- tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
277
274
  tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
278
275
  tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
279
276
  tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
@@ -308,7 +305,9 @@ module Harnex
308
305
  port: registry["port"],
309
306
  mode: "tmux",
310
307
  window: window_name,
311
- output_log_path: Harnex.output_log_path(repo_root, @options[:id])
308
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id]),
309
+ artifact_report_path: registry["artifact_report_path"],
310
+ artifact_claims_path: registry["artifact_claims_path"]
312
311
  }
313
312
  payload[:description] = @options[:description] if @options[:description]
314
313
  puts JSON.generate(payload)
@@ -347,7 +346,9 @@ module Harnex
347
346
  port: registry["port"],
348
347
  mode: "headless",
349
348
  log: log_path,
350
- output_log_path: Harnex.output_log_path(repo_root, @options[:id])
349
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id]),
350
+ artifact_report_path: registry["artifact_report_path"],
351
+ artifact_claims_path: registry["artifact_claims_path"]
351
352
  }
352
353
  payload[:description] = @options[:description] if @options[:description]
353
354
  puts JSON.generate(payload) if emit_payload
@@ -435,7 +436,6 @@ module Harnex
435
436
  watch: watch,
436
437
  description: @options[:description],
437
438
  meta: @options[:meta],
438
- summary_out: @options[:summary_out],
439
439
  artifact_report_path: @options[:artifact_report],
440
440
  require_artifact_report: @options[:require_artifact_report],
441
441
  inbox_ttl: @options[:inbox_ttl],
@@ -624,11 +624,6 @@ module Harnex
624
624
  when telemetry_equals_regex
625
625
  flag = "--#{Regexp.last_match(1)}"
626
626
  @options[:telemetry][TELEMETRY_FLAGS.fetch(flag)] = required_option_value(flag, Regexp.last_match(2))
627
- when "--summary-out"
628
- index += 1
629
- @options[:summary_out] = required_option_value(arg, argv[index])
630
- when /\A--summary-out=(.+)\z/
631
- @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
632
627
  when "--artifact-report", "--validation-report"
633
628
  index += 1
634
629
  @options[:artifact_report] = required_option_value(arg, argv[index])
@@ -720,7 +715,7 @@ module Harnex
720
715
  nil
721
716
  when *VALUE_FLAGS
722
717
  index += 1
723
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
718
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
724
719
  nil
725
720
  when telemetry_equals_regex
726
721
  nil
@@ -740,7 +735,7 @@ module Harnex
740
735
  arg == "-h" ||
741
736
  arg.start_with?(
742
737
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
743
- "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
738
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
744
739
  "--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
745
740
  *TELEMETRY_EQUALS_PREFIXES
746
741
  )
@@ -771,12 +766,11 @@ module Harnex
771
766
  raise OptionParser::InvalidOption, "harnex run: --auto-stop requires --context"
772
767
  end
773
768
 
769
+ # Retained for compatibility with callers that still pass the strict flag.
770
+ # Session now allocates a harness-owned default receipt path, so an explicit
771
+ # --artifact-report path is no longer required.
774
772
  def validate_required_artifact_report!
775
- return unless @options[:require_artifact_report]
776
- return unless @options[:artifact_report].to_s.strip.empty?
777
-
778
- raise OptionParser::InvalidOption,
779
- "harnex run: --require-artifact-report requires --artifact-report PATH"
773
+ true
780
774
  end
781
775
 
782
776
  def apply_telemetry_options!
@@ -867,15 +861,6 @@ module Harnex
867
861
  raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
868
862
  end
869
863
 
870
- # Explicit-only mirror: the tracked dispatch stream is the canonical
871
- # destination; --summary-out just duplicates the end record elsewhere.
872
- def resolve_summary_out(repo_root)
873
- configured = @options[:summary_out]
874
- return nil if configured.nil?
875
-
876
- File.expand_path(configured, repo_root)
877
- end
878
-
879
864
  def resolve_artifact_report(repo_root)
880
865
  configured = @options[:artifact_report]
881
866
  return nil if configured.nil?
@@ -127,7 +127,6 @@ module Harnex
127
127
  "work_state" => "running",
128
128
  "exit" => nil,
129
129
  "exit_code" => nil,
130
- "summary_out" => nil,
131
130
  "ended_at" => nil,
132
131
  "source" => "dispatch_start",
133
132
  "degraded" => true,
@@ -150,7 +149,6 @@ module Harnex
150
149
  "work_state" => work_state,
151
150
  "exit" => nil,
152
151
  "exit_code" => nil,
153
- "summary_out" => nil,
154
152
  "ended_at" => nil,
155
153
  "source" => degraded ? "registry" : "live",
156
154
  "degraded" => degraded
@@ -624,7 +624,6 @@ module Harnex
624
624
  artifact_report_status: status["artifact_report_status"],
625
625
  exit: status["exit"],
626
626
  exit_code: status["exit_code"],
627
- summary_out: status["summary_out"],
628
627
  ended_at: status["ended_at"],
629
628
  source: status["source"]
630
629
  }
data/lib/harnex/config.rb CHANGED
@@ -4,7 +4,7 @@ module Harnex
4
4
  module Config
5
5
  CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
6
6
  PHASE_POLICIES = %w[warn reject].freeze
7
- RETENTION_DIRS = %w[events output].freeze
7
+ RETENTION_DIRS = %w[events output receipts].freeze
8
8
  RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
9
9
  RETENTION_ENV = {
10
10
  "events" => {
@@ -14,6 +14,10 @@ module Harnex
14
14
  "output" => {
15
15
  "max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
16
16
  "max_bytes" => "HARNEX_OUTPUT_MAX_BYTES"
17
+ },
18
+ "receipts" => {
19
+ "max_age_days" => "HARNEX_RECEIPTS_MAX_AGE_DAYS",
20
+ "max_bytes" => "HARNEX_RECEIPTS_MAX_BYTES"
17
21
  }
18
22
  }.freeze
19
23
  DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
@@ -127,7 +131,7 @@ module Harnex
127
131
  retention.each do |dir, value|
128
132
  unless RETENTION_DIRS.include?(dir)
129
133
  raise ConfigError,
130
- "#{path}: $.retention.#{dir} is not supported (expected events or output)"
134
+ "#{path}: $.retention.#{dir} is not supported (expected events, output, or receipts)"
131
135
  end
132
136
  unless value.is_a?(Hash)
133
137
  raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
data/lib/harnex/core.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
+ require "open3"
3
4
  require "optparse"
4
5
  require "securerandom"
5
6
  require "set"
@@ -19,6 +20,8 @@ module Harnex
19
20
  DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
20
21
  DEFAULT_ID = "default"
21
22
  WATCH_DEBOUNCE_SECONDS = 1.0
23
+ GIT_FINGERPRINT_FULL_BYTES = 4 * 1024 * 1024
24
+ GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
22
25
  STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
23
26
  SESSIONS_DIR = File.join(STATE_DIR, "sessions")
24
27
  WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
@@ -114,42 +117,204 @@ module Harnex
114
117
  text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
115
118
  end
116
119
 
117
- def git_capture_start(repo_root)
120
+ def git_capture_start(repo_root, exclude_paths: [])
118
121
  sha = git_output(repo_root, "rev-parse", "HEAD")
119
122
  branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
120
123
  return {} if sha.empty? || branch.empty?
121
124
 
125
+ excluded_paths = normalize_git_excluded_paths(exclude_paths)
126
+ worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
122
127
  {
123
128
  sha: sha,
124
- branch: branch
129
+ branch: branch,
130
+ worktree: worktree,
131
+ excluded_paths: excluded_paths
125
132
  }
126
133
  rescue StandardError
127
134
  {}
128
135
  end
129
136
 
130
- def git_capture_end(repo_root, start_sha)
131
- start_sha = start_sha.to_s.strip
137
+ # Captures committed and in-progress work relative to the state observed at
138
+ # session start. Passing the full git_capture_start hash avoids treating an
139
+ # unchanged pre-existing dirty tree as worker activity; a SHA string remains
140
+ # supported for older callers.
141
+ def git_capture_end(repo_root, start, exclude_paths: nil)
142
+ start_capture = start.is_a?(Hash) ? start : {}
143
+ start_sha = (start_capture[:sha] || start_capture["sha"] || start).to_s.strip
132
144
  return {} if start_sha.empty?
133
145
 
146
+ excluded_paths ||= start_capture[:excluded_paths] || start_capture["excluded_paths"] || []
147
+ excluded_paths = normalize_git_excluded_paths(excluded_paths)
134
148
  end_sha = git_output(repo_root, "rev-parse", "HEAD")
135
149
  range = "#{start_sha}..#{end_sha}"
136
- shortstat = git_output(repo_root, "diff", "--shortstat", range)
137
- changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
138
150
  commits = Integer(git_output(repo_root, "rev-list", "--count", range))
139
- stats = parse_git_shortstat(shortstat)
151
+ committed_paths = git_null_paths(repo_root, "diff", "--name-only", "-z", range)
152
+ .reject { |path| excluded_paths.include?(path) }
153
+ committed_stats = if committed_paths.empty?
154
+ { loc_added: 0, loc_removed: 0 }
155
+ else
156
+ parse_git_numstat(
157
+ git_output(repo_root, "diff", "--numstat", range, "--", *committed_paths.first(200))
158
+ )
159
+ end
160
+
161
+ start_worktree = start_capture[:worktree] || start_capture["worktree"]
162
+ end_worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
163
+ worktree_paths = changed_worktree_paths(start_worktree, end_worktree)
164
+ tracked_worktree_paths = worktree_paths.select do |path|
165
+ entry = end_worktree.fetch(:entries).fetch(path, nil)
166
+ entry && entry.fetch(:tracked)
167
+ end
168
+ worktree_stats = if tracked_worktree_paths.empty?
169
+ { loc_added: 0, loc_removed: 0 }
170
+ else
171
+ parse_git_numstat(
172
+ git_output(repo_root, "diff", "--numstat", end_sha, "--", *tracked_worktree_paths.first(200))
173
+ )
174
+ end
175
+ untracked_stats = untracked_worktree_stats(start_worktree, end_worktree, worktree_paths)
176
+ all_paths = (committed_paths + worktree_paths).uniq
140
177
 
141
178
  {
142
179
  sha: end_sha,
143
- loc_added: stats.fetch(:loc_added),
144
- loc_removed: stats.fetch(:loc_removed),
145
- files_changed: stats.fetch(:files_changed),
146
- changed_paths: changed_paths,
147
- commits: commits
180
+ loc_added: committed_stats.fetch(:loc_added) + worktree_stats.fetch(:loc_added) + untracked_stats.fetch(:loc_added),
181
+ loc_removed: committed_stats.fetch(:loc_removed) + worktree_stats.fetch(:loc_removed) + untracked_stats.fetch(:loc_removed),
182
+ files_changed: all_paths.length,
183
+ changed_paths: all_paths.first(200),
184
+ commits: commits,
185
+ start_dirty: start_worktree ? !start_worktree.fetch(:entries).empty? : nil,
186
+ end_dirty: !end_worktree.fetch(:entries).empty?,
187
+ worktree_changed: !worktree_paths.empty?
148
188
  }
149
189
  rescue StandardError
150
190
  {}
151
191
  end
152
192
 
193
+ def git_worktree_snapshot(repo_root, exclude_paths: [])
194
+ excluded_paths = normalize_git_excluded_paths(exclude_paths)
195
+ tracked_paths = (
196
+ git_null_paths(repo_root, "diff", "--name-only", "-z") +
197
+ git_null_paths(repo_root, "diff", "--cached", "--name-only", "-z")
198
+ ).uniq.reject { |path| excluded_paths.include?(path) }
199
+ untracked_paths = git_null_paths(repo_root, "ls-files", "--others", "--exclude-standard", "-z")
200
+ .reject { |path| excluded_paths.include?(path) }
201
+ entries = {}
202
+ tracked_paths.each do |path|
203
+ entries[path] = git_worktree_entry(repo_root, path, tracked: true)
204
+ end
205
+ untracked_paths.each do |path|
206
+ entries[path] = git_worktree_entry(repo_root, path, tracked: false)
207
+ end
208
+ { entries: entries }
209
+ end
210
+
211
+ def normalize_git_excluded_paths(paths)
212
+ Array(paths).filter_map do |path|
213
+ text = path.to_s.sub(%r{\A\./}, "")
214
+ text unless text.empty?
215
+ end.uniq
216
+ end
217
+
218
+ def git_worktree_entry(repo_root, relative_path, tracked:)
219
+ path = File.join(repo_root.to_s, relative_path)
220
+ stat = File.lstat(path)
221
+ if stat.symlink?
222
+ content = File.readlink(path)
223
+ return {
224
+ tracked: tracked,
225
+ fingerprint: "symlink:#{Digest::SHA256.hexdigest(content)}",
226
+ lines: 0,
227
+ binary: false
228
+ }
229
+ end
230
+
231
+ return { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } unless stat.file?
232
+
233
+ if stat.size > GIT_FINGERPRINT_FULL_BYTES
234
+ first = File.binread(path, GIT_FINGERPRINT_SAMPLE_BYTES)
235
+ last = File.open(path, "rb") do |file|
236
+ file.seek(-[stat.size, GIT_FINGERPRINT_SAMPLE_BYTES].min, IO::SEEK_END)
237
+ file.read(GIT_FINGERPRINT_SAMPLE_BYTES).to_s
238
+ end
239
+ digest = Digest::SHA256.new
240
+ digest.update("#{stat.mode}:#{stat.size}:#{(stat.mtime.to_r * 1_000_000_000).to_i}")
241
+ digest.update(first)
242
+ digest.update(last)
243
+ return {
244
+ tracked: tracked,
245
+ fingerprint: "large:#{digest.hexdigest}",
246
+ lines: 0,
247
+ binary: first.include?("\0") || last.include?("\0")
248
+ }
249
+ end
250
+
251
+ digest = Digest::SHA256.new
252
+ lines = 0
253
+ binary = false
254
+ last_byte = nil
255
+ File.open(path, "rb") do |file|
256
+ buffer = +""
257
+ while file.read(16 * 1024, buffer)
258
+ digest.update(buffer)
259
+ binary ||= buffer.include?("\0")
260
+ lines += buffer.count("\n")
261
+ last_byte = buffer.byteslice(-1)
262
+ end
263
+ end
264
+ lines += 1 if stat.size.positive? && last_byte != "\n"
265
+ {
266
+ tracked: tracked,
267
+ fingerprint: "file:#{stat.mode}:#{digest.hexdigest}",
268
+ lines: binary ? 0 : lines,
269
+ binary: binary
270
+ }
271
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
272
+ { tracked: tracked, fingerprint: "missing", lines: 0, binary: false }
273
+ end
274
+
275
+ def changed_worktree_paths(start_worktree, end_worktree)
276
+ end_entries = end_worktree.fetch(:entries)
277
+ return end_entries.keys.sort unless start_worktree.is_a?(Hash)
278
+
279
+ start_entries = start_worktree[:entries] || start_worktree["entries"] || {}
280
+ (start_entries.keys + end_entries.keys).uniq.select do |path|
281
+ start_entries[path] != end_entries[path]
282
+ end.sort
283
+ end
284
+
285
+ def untracked_worktree_stats(start_worktree, end_worktree, changed_paths)
286
+ start_entries = if start_worktree.is_a?(Hash)
287
+ start_worktree[:entries] || start_worktree["entries"] || {}
288
+ else
289
+ {}
290
+ end
291
+ end_entries = end_worktree.fetch(:entries)
292
+ changed_paths.each_with_object({ loc_added: 0, loc_removed: 0 }) do |path, stats|
293
+ before = start_entries[path]
294
+ after = end_entries[path]
295
+ next if after && after.fetch(:tracked)
296
+ next if before && before.fetch(:tracked)
297
+
298
+ stats[:loc_removed] += before.fetch(:lines) if before
299
+ stats[:loc_added] += after.fetch(:lines) if after
300
+ end
301
+ end
302
+
303
+ def git_null_paths(repo_root, *args)
304
+ output, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
305
+ raise "git #{args.join(' ')} failed" unless status.success?
306
+
307
+ output.split("\0").reject(&:empty?)
308
+ end
309
+
310
+ def parse_git_numstat(text)
311
+ text.to_s.lines.each_with_object({ loc_added: 0, loc_removed: 0 }) do |line, stats|
312
+ added, removed, = line.split("\t", 3)
313
+ stats[:loc_added] += Integer(added, exception: false).to_i
314
+ stats[:loc_removed] += Integer(removed, exception: false).to_i
315
+ end
316
+ end
317
+
153
318
  # Canonical path resolution shared by every registry/exit/events writer and
154
319
  # reader. realpath collapses symlinked prefixes so a session registered from
155
320
  # a symlinked cwd stays visible to checkers resolving the physical path.
@@ -285,6 +450,12 @@ module Harnex
285
450
  false
286
451
  rescue Errno::EPERM
287
452
  true
453
+ rescue ArgumentError, TypeError
454
+ # A non-numeric pid means a truncated, hand-edited, or foreign-written
455
+ # registry file. Report it dead so active_sessions prunes it, the same
456
+ # self-healing it already applies to unparseable JSON. Raising here would
457
+ # take down every command that scans sessions -- status, send, pane.
458
+ false
288
459
  end
289
460
 
290
461
  def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
@@ -352,10 +523,25 @@ module Harnex
352
523
  nil
353
524
  end
354
525
 
355
- def write_registry(path, payload)
356
- tmp = "#{path}.tmp.#{Process.pid}"
526
+ # Atomically replace a JSON state file.
527
+ #
528
+ # The temp name must be unique per write, not just per process. Several
529
+ # threads in one session write the same registry path — the startup persist,
530
+ # the inbox delivery thread, and one thread per API client — and a shared
531
+ # temp name lets one thread rename the file another is still writing, which
532
+ # surfaces as ENOENT on the loser's rename. mkdir_p covers a state directory
533
+ # that was reaped (tmpfs, operator cleanup) while the process was live.
534
+ def atomic_write_json(path, payload)
535
+ FileUtils.mkdir_p(File.dirname(path))
536
+ tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}"
357
537
  File.write(tmp, JSON.pretty_generate(payload))
358
538
  File.rename(tmp, path)
539
+ ensure
540
+ FileUtils.rm_f(tmp) if tmp
541
+ end
542
+
543
+ def write_registry(path, payload)
544
+ atomic_write_json(path, payload)
359
545
  end
360
546
 
361
547
  def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
@@ -156,8 +156,9 @@ module Harnex
156
156
  repo_root: session.repo_root,
157
157
  tier: session.__send__(:meta_hash)["tier"],
158
158
  meta: session.__send__(:meta_hash),
159
- summary_out_path: session.summary_out,
160
- events_log_path: session.events_log_path
159
+ events_log_path: session.events_log_path,
160
+ artifact_report_path: session.artifact_report_path,
161
+ artifact_claims_path: session.artifact_claims_path
161
162
  }
162
163
  end
163
164
 
@@ -182,7 +183,6 @@ module Harnex
182
183
  terminal_event: terminal_event,
183
184
  commit_sha: commit_sha(session.git_start, session.git_end),
184
185
  tier: session.__send__(:meta_hash)["tier"],
185
- summary_out_path: session.summary_out,
186
186
  events_log_path: session.events_log_path,
187
187
  tmux_state: tmux_state(session.__send__(:summary_tmux_session))
188
188
  }.merge(session.__send__(:build_summary_record))
@@ -7,7 +7,8 @@ module Harnex
7
7
  module Retention
8
8
  DIRS = {
9
9
  "events" => "events",
10
- "output" => "output"
10
+ "output" => "output",
11
+ "receipts" => "receipts"
11
12
  }.freeze
12
13
  THROTTLE_SECONDS = 3600
13
14
  MAX_REPORTED_PATHS = 100
@@ -184,6 +185,8 @@ module Harnex
184
185
 
185
186
  protect(paths, data["events_log_path"])
186
187
  protect(paths, data["output_log_path"])
188
+ protect(paths, data["artifact_report_path"])
189
+ protect(paths, data["artifact_claims_path"])
187
190
  protect_derived_session_paths(paths, data)
188
191
  rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
189
192
  next
@@ -200,6 +203,8 @@ module Harnex
200
203
  live_start_records(path).each do |record|
201
204
  protect(paths, record["events_log_path"])
202
205
  protect(paths, record["output_log_path"])
206
+ protect(paths, record["artifact_report_path"])
207
+ protect(paths, record["artifact_claims_path"])
203
208
  protect_derived_session_paths(paths, record)
204
209
  end
205
210
  end
@@ -243,6 +248,12 @@ module Harnex
243
248
 
244
249
  protect(paths, Harnex.events_log_path(repo, id))
245
250
  protect(paths, Harnex.output_log_path(repo, id))
251
+ session_id = data["session_id"].to_s
252
+ unless session_id.empty?
253
+ receipt = Harnex::ArtifactReport.default_path(repo_root: repo, id: id, session_id: session_id)
254
+ protect(paths, receipt)
255
+ protect(paths, Harnex::ArtifactReport.claims_path(receipt))
256
+ end
246
257
  rescue StandardError
247
258
  nil
248
259
  end
@@ -301,9 +312,7 @@ module Harnex
301
312
  end
302
313
  }
303
314
  }
304
- tmp = "#{METADATA_PATH}.tmp.#{Process.pid}"
305
- File.write(tmp, JSON.pretty_generate(payload))
306
- File.rename(tmp, METADATA_PATH)
315
+ Harnex.atomic_write_json(METADATA_PATH, payload)
307
316
  rescue StandardError => e
308
317
  warn("harnex: failed to persist retention metadata: #{e.message}")
309
318
  end