harnex 0.12.0 → 0.29.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. data/lib/harnex/codex/app_server/client.rb +0 -347
@@ -1,6 +1,8 @@
1
+ require "digest"
1
2
  require "fileutils"
2
3
  require "json"
3
4
  require "open3"
5
+ require "tempfile"
4
6
  require "time"
5
7
 
6
8
  module Harnex
@@ -8,6 +10,11 @@ module Harnex
8
10
  module_function
9
11
 
10
12
  MAX_REPO_WALK_LEVELS = 10
13
+ ARCHIVE_PROTOCOL_VERSION = 1
14
+ ARCHIVER_ENV = "HARNEX_DISPATCH_ARCHIVER"
15
+ HISTORY_READER_ENV = "HARNEX_DISPATCH_HISTORY_READER"
16
+
17
+ class HistoryUnavailable < StandardError; end
11
18
 
12
19
  # v2 marks the unified era: one rich dispatch_end row per dispatch
13
20
  # carrying both the thin envelope and the summary sections. Readers
@@ -57,17 +64,139 @@ module Harnex
57
64
  nil
58
65
  end
59
66
 
67
+ def lock_path(path)
68
+ digest = Digest::SHA256.hexdigest(File.expand_path(path))
69
+ File.join(STATE_DIR, "locks", "dispatch-#{digest}.lock")
70
+ end
71
+
72
+ # Serialize a copy: runtime paths stay absolute and existing rows are untouched.
73
+ def portable_record(value, home: Dir.home)
74
+ case value
75
+ when Hash
76
+ value.to_h { |key, item| [key, portable_record(item, home: home)] }
77
+ when Array
78
+ value.map { |item| portable_record(item, home: home) }
79
+ when String
80
+ portable_path(value, home: home)
81
+ else
82
+ value
83
+ end
84
+ end
85
+
86
+ def portable_path(value, home: Dir.home)
87
+ root = home.to_s.sub(%r{/+\z}, "")
88
+ return value if root.empty? || !root.start_with?(File::SEPARATOR)
89
+ return "~" if value == root
90
+ return "~#{value.delete_prefix(root)}" if value.start_with?("#{root}/")
91
+
92
+ value
93
+ end
94
+
95
+ def expand_portable_path(value)
96
+ return value unless value.is_a?(String) && (value == "~" || value.start_with?("~/"))
97
+
98
+ File.expand_path(value)
99
+ end
100
+
60
101
  def append(path, record)
61
102
  FileUtils.mkdir_p(File.dirname(path))
62
- line = JSON.generate(record) + "\n"
63
- File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
103
+ state_dir = File.dirname(path)
104
+ Harnex.ensure_git_excluded(File.dirname(state_dir)) if File.basename(state_dir) == ".harnex"
105
+ line = JSON.generate(portable_record(record)) + "\n"
106
+ with_lock(path) do
107
+ prepare_archive(path, line.bytesize)
108
+ File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |file|
109
+ file.write(line)
110
+ file.flush
111
+ file.fsync
112
+ end
113
+ end
114
+ end
115
+
116
+ def with_lock(path)
117
+ lock = lock_path(path)
118
+ FileUtils.mkdir_p(File.dirname(lock), mode: 0o700)
119
+ File.open(lock, File::RDWR | File::CREAT, 0o600) do |file|
64
120
  file.flock(File::LOCK_EX)
65
- file.write(line)
121
+ yield
66
122
  ensure
67
123
  file.flock(File::LOCK_UN) unless file.closed?
68
124
  end
69
125
  end
70
126
 
127
+ def prepare_archive(path, incoming_bytes)
128
+ helper = ENV.fetch(ARCHIVER_ENV, "").strip
129
+ return if helper.empty?
130
+
131
+ unless helper.start_with?(File::SEPARATOR) && File.file?(helper) && File.executable?(helper) && !File.symlink?(helper)
132
+ warn("harnex: archive preparation failed; active dispatch history retained")
133
+ return
134
+ end
135
+ success = system(
136
+ helper,
137
+ "--canonical", File.expand_path(path),
138
+ "--incoming-bytes", incoming_bytes.to_s,
139
+ in: File::NULL,
140
+ out: File::NULL,
141
+ err: File::NULL
142
+ )
143
+ warn("harnex: archive preparation failed; active dispatch history retained") unless success
144
+ rescue StandardError
145
+ warn("harnex: archive preparation failed; active dispatch history retained")
146
+ end
147
+
148
+ def each_record(path, locked: false)
149
+ return enum_for(__method__, path, locked: locked) unless block_given?
150
+
151
+ external = !ENV.fetch(HISTORY_READER_ENV, "").strip.empty?
152
+ each_history_line(path, locked: locked) do |line|
153
+ next if line.strip.empty?
154
+
155
+ begin
156
+ record = JSON.parse(line)
157
+ rescue JSON::ParserError => e
158
+ raise HistoryUnavailable, "complete dispatch history is unavailable: malformed reader output" if external
159
+
160
+ next
161
+ end
162
+ yield record
163
+ end
164
+ end
165
+
166
+ def each_history_line(path, locked: false, &block)
167
+ operation = proc do
168
+ helper = ENV.fetch(HISTORY_READER_ENV, "").strip
169
+ if helper.empty?
170
+ File.foreach(path, &block) if File.file?(path)
171
+ next
172
+ end
173
+ unless helper.start_with?(File::SEPARATOR) && File.file?(helper) && File.executable?(helper) && !File.symlink?(helper)
174
+ raise HistoryUnavailable, "complete dispatch history is unavailable: invalid reader"
175
+ end
176
+
177
+ FileUtils.mkdir_p(STATE_DIR)
178
+ Tempfile.create(["harnex-complete-history", ".jsonl"], STATE_DIR, mode: 0o600) do |temporary|
179
+ success = system(
180
+ helper,
181
+ "--canonical", File.expand_path(path),
182
+ in: File::NULL,
183
+ out: temporary,
184
+ err: File::NULL
185
+ )
186
+ raise HistoryUnavailable, "complete dispatch history is unavailable: reader failed" unless success
187
+
188
+ temporary.flush
189
+ temporary.rewind
190
+ temporary.each_line(&block)
191
+ end
192
+ end
193
+ locked ? operation.call : with_lock(path, &operation)
194
+ rescue HistoryUnavailable
195
+ raise
196
+ rescue StandardError => e
197
+ raise HistoryUnavailable, "complete dispatch history is unavailable: reader failed", cause: e
198
+ end
199
+
71
200
  def start_record?(record)
72
201
  record.is_a?(Hash) && record["record_type"] == "dispatch_start"
73
202
  end
@@ -90,14 +219,15 @@ module Harnex
90
219
  end
91
220
 
92
221
  # Latest start row for `id` and any end row that completes it.
93
- def latest_rows(path, id)
222
+ def latest_rows(path, id, complete: true)
94
223
  latest_start = nil
95
224
  matching_end = nil
96
225
 
97
- return { start: nil, end: nil } unless File.file?(path)
226
+ reader_configured = complete && !ENV.fetch(HISTORY_READER_ENV, "").strip.empty?
227
+ return { start: nil, end: nil } unless File.file?(path) || reader_configured
98
228
 
99
- File.foreach(path) do |line|
100
- record = JSON.parse(line)
229
+ records = complete ? each_record(path) : active_records(path)
230
+ records.each do |record|
101
231
  next unless record.is_a?(Hash)
102
232
  next unless record["id"].to_s == id
103
233
 
@@ -107,8 +237,6 @@ module Harnex
107
237
  elsif end_record?(record)
108
238
  matching_end = record if latest_start && end_matches_start?(record, latest_start)
109
239
  end
110
- rescue JSON::ParserError
111
- next
112
240
  end
113
241
 
114
242
  { start: latest_start, end: matching_end }
@@ -119,7 +247,7 @@ module Harnex
119
247
  # is not visible from the caller's context.
120
248
  def live_start_record(repo_root:, id:)
121
249
  normalized_id = Harnex.normalize_id(id)
122
- rows = latest_rows(path_for(repo_root), normalized_id)
250
+ rows = latest_rows(path_for(repo_root), normalized_id, complete: false)
123
251
  start = rows[:start]
124
252
  return nil unless start
125
253
  return nil if rows[:end]
@@ -133,6 +261,23 @@ module Harnex
133
261
  nil
134
262
  end
135
263
 
264
+ def active_records(path)
265
+ return enum_for(__method__, path) unless block_given?
266
+ return unless File.file?(path)
267
+
268
+ with_lock(path) do
269
+ File.foreach(path) do |line|
270
+ next if line.strip.empty?
271
+
272
+ begin
273
+ yield JSON.parse(line)
274
+ rescue JSON::ParserError
275
+ next
276
+ end
277
+ end
278
+ end
279
+ end
280
+
136
281
  def same_host?(record)
137
282
  host = record["host"].to_s
138
283
  return true if host.empty?
@@ -182,6 +327,8 @@ module Harnex
182
327
  status: status,
183
328
  terminal_event: terminal_event,
184
329
  commit_sha: commit_sha(session.git_start, session.git_end),
330
+ # #80: the --root repository's head when git was observed in a lane.
331
+ root_commit_sha: session.respond_to?(:root_commit_sha, true) ? session.__send__(:root_commit_sha) : nil,
185
332
  tier: session.__send__(:meta_hash)["tier"],
186
333
  events_log_path: session.events_log_path,
187
334
  tmux_state: tmux_state(session.__send__(:summary_tmux_session))