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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- 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
|
-
|
|
63
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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))
|