harnex 0.29.1 → 0.30.1
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 +64 -1
- data/TECHNICAL.md +10 -2
- data/agents/gpt.md +16 -8
- data/docs/agents.md +5 -2
- data/docs/configuration.md +22 -0
- data/docs/dispatch-telemetry.md +12 -5
- data/docs/lanes.md +66 -8
- data/docs/mcp.md +7 -3
- data/docs/pi-rpc.md +9 -0
- data/guides/04_monitoring.md +4 -1
- data/guides/06_claude.md +6 -1
- data/lib/harnex/commands/ask.rb +13 -1
- data/lib/harnex/commands/delegate.rb +24 -3
- data/lib/harnex/commands/lanes.rb +1 -1
- data/lib/harnex/commands/orchestration.rb +2 -1
- data/lib/harnex/commands/reply.rb +2 -1
- data/lib/harnex/commands/result.rb +1 -1
- data/lib/harnex/commands/send.rb +2 -1
- data/lib/harnex/commands/wait.rb +2 -1
- data/lib/harnex/config.rb +21 -0
- data/lib/harnex/core.rb +11 -0
- data/lib/harnex/lanes.rb +277 -17
- data/lib/harnex/mcp.rb +5 -3
- data/lib/harnex/orchestration.rb +2 -1
- data/lib/harnex/runtime/api_server.rb +13 -2
- data/lib/harnex/runtime/inbox.rb +3 -1
- data/lib/harnex/runtime/message.rb +1 -1
- data/lib/harnex/runtime/session.rb +88 -12
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
data/lib/harnex/config.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Harnex
|
|
|
6
6
|
PHASE_POLICIES = %w[warn reject].freeze
|
|
7
7
|
RETENTION_DIRS = %w[events output receipts].freeze
|
|
8
8
|
RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
|
|
9
|
+
LANE_TEMPLATES = %w[worktree_path branch].freeze
|
|
9
10
|
RETENTION_ENV = {
|
|
10
11
|
"events" => {
|
|
11
12
|
"max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
|
|
@@ -38,6 +39,10 @@ module Harnex
|
|
|
38
39
|
def retention
|
|
39
40
|
data.is_a?(Hash) ? data["retention"] : nil
|
|
40
41
|
end
|
|
42
|
+
|
|
43
|
+
def lanes
|
|
44
|
+
data.is_a?(Hash) ? data["lanes"] : nil
|
|
45
|
+
end
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
module_function
|
|
@@ -63,6 +68,22 @@ module Harnex
|
|
|
63
68
|
|
|
64
69
|
validate_phase!(document["phase"], path) if document.key?("phase")
|
|
65
70
|
validate_retention!(document["retention"], path) if document.key?("retention")
|
|
71
|
+
validate_lanes!(document["lanes"], path) if document.key?("lanes")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# #90: where worktree lanes live and what their branches are called,
|
|
75
|
+
# e.g. {"worktree_path": "../{name}", "branch": "night/{mm}{dd}_{name}"}.
|
|
76
|
+
def validate_lanes!(lanes, path)
|
|
77
|
+
raise ConfigError, "#{path}: $.lanes must be an object" unless lanes.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
lanes.each do |key, template|
|
|
80
|
+
unless LANE_TEMPLATES.include?(key)
|
|
81
|
+
raise ConfigError, "#{path}: $.lanes.#{key} is not supported (expected #{LANE_TEMPLATES.join(' or ')})"
|
|
82
|
+
end
|
|
83
|
+
next if template.is_a?(String) && template.include?("{name}")
|
|
84
|
+
|
|
85
|
+
raise ConfigError, "#{path}: $.lanes.#{key} must be a string containing {name}"
|
|
86
|
+
end
|
|
66
87
|
end
|
|
67
88
|
|
|
68
89
|
def validate_phase!(phase, path)
|
data/lib/harnex/core.rb
CHANGED
|
@@ -450,6 +450,17 @@ module Harnex
|
|
|
450
450
|
nil
|
|
451
451
|
end
|
|
452
452
|
|
|
453
|
+
# #92: who is sending a message, for the receiving lane's footer. `via`
|
|
454
|
+
# is "reply" when `harnex reply` sends on the lane's behalf
|
|
455
|
+
# (HARNEX_SEND_VIA), else "send".
|
|
456
|
+
def sender_identity(env = ENV)
|
|
457
|
+
{
|
|
458
|
+
"via" => env["HARNEX_SEND_VIA"].to_s.strip.empty? ? "send" : env["HARNEX_SEND_VIA"].strip,
|
|
459
|
+
"session" => env["HARNEX_ID"].to_s.strip,
|
|
460
|
+
"owner" => ambient_owner_id(env).to_s
|
|
461
|
+
}.reject { |_key, value| value.empty? }
|
|
462
|
+
end
|
|
463
|
+
|
|
453
464
|
# How to invoke this harnex in a printed hint: the checkout's bin when
|
|
454
465
|
# running from source, plain `harnex` for the installed gem.
|
|
455
466
|
def cli_command
|
data/lib/harnex/lanes.rb
CHANGED
|
@@ -11,8 +11,9 @@ module Harnex
|
|
|
11
11
|
# harnex/<name>. A record under STATE_DIR/lanes lets `result`, `reply`,
|
|
12
12
|
# `land` and `discard` find a lane by name from anywhere in its repo.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# Internal record states: running (a turn is out and uncollected), done,
|
|
15
|
+
# failed, landed, discarded. Machine listings project the worker's settled
|
|
16
|
+
# state independently, with `collected` indicating the record's boundary.
|
|
16
17
|
module Lanes
|
|
17
18
|
NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,47}\z/
|
|
18
19
|
BRANCH_PREFIX = "harnex/".freeze
|
|
@@ -74,20 +75,45 @@ module Harnex
|
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
# A name resolves to this repo's lane first, then to the only lane
|
|
77
|
-
# anywhere
|
|
78
|
+
# anywhere whose worker is running (those names are unique, #89), then
|
|
79
|
+
# in flight, open, or at all.
|
|
78
80
|
def resolve(name, from: Dir.pwd)
|
|
79
81
|
root = main_root(from)
|
|
80
82
|
lane = root && load(root, name)
|
|
81
83
|
return lane if lane
|
|
82
84
|
|
|
83
85
|
matches = all.select { |candidate| candidate["name"] == name }
|
|
84
|
-
return matches.first if matches.one?
|
|
85
86
|
raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
|
|
86
87
|
|
|
88
|
+
[matches.select { |l| open?(l) && live?(l) }, matches.select { |l| in_flight?(l) },
|
|
89
|
+
matches.select { |l| open?(l) }, matches].each do |group|
|
|
90
|
+
return group.first if group.one?
|
|
91
|
+
end
|
|
87
92
|
raise OptionParser::InvalidArgument,
|
|
88
93
|
"several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
|
|
89
94
|
end
|
|
90
95
|
|
|
96
|
+
# Working (or finished but uncollected), or a worktree not yet landed,
|
|
97
|
+
# in a repo that still exists.
|
|
98
|
+
def in_flight?(lane)
|
|
99
|
+
open?(lane) && !never_started?(lane) && (lane["state"] == "running" || !lane["branch"].nil?) &&
|
|
100
|
+
File.directory?(lane["repo_root"].to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# #89: the in-flight lane anywhere that holds `name`, if any. Generated
|
|
104
|
+
# names skip these, so `harnex result NAME` and `reply NAME` find their
|
|
105
|
+
# lane from anywhere.
|
|
106
|
+
def holder(name)
|
|
107
|
+
all.find { |lane| lane["name"] == name && in_flight?(lane) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The lane anywhere whose worker is running under `name`, if any. A name
|
|
111
|
+
# someone chooses is refused only while another repo's worker has it, so
|
|
112
|
+
# stale lanes elsewhere (an old eval, an abandoned clone) never block it.
|
|
113
|
+
def live_holder(name)
|
|
114
|
+
all.find { |lane| lane["name"] == name && open?(lane) && live?(lane) }
|
|
115
|
+
end
|
|
116
|
+
|
|
91
117
|
# Lanes this caller started in this repo whose current turn nobody has
|
|
92
118
|
# collected yet.
|
|
93
119
|
def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
|
|
@@ -98,25 +124,88 @@ module Harnex
|
|
|
98
124
|
|
|
99
125
|
FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
|
|
100
126
|
run use add make have has can could would should will do does just then also your you
|
|
101
|
-
our my me i we gpt worker workers task repo repository git command file files code
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
our my me i we gpt worker workers task repo repository git command file files code
|
|
128
|
+
are is be carry out brief lane].freeze
|
|
129
|
+
|
|
130
|
+
# A lane working in another worktree of the repo is named after that
|
|
131
|
+
# worktree (`pi-night-1024_s3`); otherwise after the task's first words.
|
|
132
|
+
# Either way no repo has the name in flight (#89).
|
|
133
|
+
def generate_name(root, task, cwd: nil, prefix: "pi")
|
|
134
|
+
base = ([prefix] + name_words(root, task, cwd)).join("-")[0, 40].sub(/[-_.]+\z/, "")
|
|
106
135
|
name = base
|
|
107
136
|
index = 2
|
|
108
|
-
while open?(load(root, name)) || Harnex.read_registry(root, name)
|
|
137
|
+
while open?(load(root, name)) || holder(name) || Harnex.read_registry(root, name)
|
|
109
138
|
name = "#{base}-#{index}"
|
|
110
139
|
index += 1
|
|
111
140
|
end
|
|
112
141
|
name
|
|
113
142
|
end
|
|
114
143
|
|
|
144
|
+
def name_words(root, task, cwd)
|
|
145
|
+
top = cwd && toplevel(cwd)
|
|
146
|
+
if top && root && !same_dir?(top, root)
|
|
147
|
+
slug = File.basename(top).downcase.gsub(/[^a-z0-9._-]+/, "-").sub(/\A[^a-z0-9]+/, "")
|
|
148
|
+
return [slug] unless slug.empty?
|
|
149
|
+
end
|
|
150
|
+
task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) || word.match?(/\A[a-z]\z/) }.first(4)
|
|
151
|
+
end
|
|
152
|
+
|
|
115
153
|
def live?(lane)
|
|
116
154
|
registry = Harnex.read_registry(lane["run_root"], lane["name"])
|
|
117
155
|
!registry.nil? && Harnex.alive_pid?(registry["pid"])
|
|
118
156
|
end
|
|
119
157
|
|
|
158
|
+
# #93: listing is read-only, not result collection. The record's running
|
|
159
|
+
# state still drives pending/result and the table's "finished, uncollected".
|
|
160
|
+
# Fence evidence to this launch and follow-up so a reused name cannot pick
|
|
161
|
+
# up an earlier turn. Settlement can precede process exit (Pi at prompt).
|
|
162
|
+
def snapshot(lane)
|
|
163
|
+
live = live?(lane)
|
|
164
|
+
view = lane.merge("live" => live, "collected" => lane["state"] != "running")
|
|
165
|
+
return view unless lane["state"] == "running"
|
|
166
|
+
|
|
167
|
+
%w[outcome last_error settled_at last_turn].each { |key| view.delete(key) }
|
|
168
|
+
event = events(lane).reverse.find do |row|
|
|
169
|
+
row.is_a?(Hash) && %w[task_complete task_failed work_turn agent_start].include?(row["type"]) &&
|
|
170
|
+
after?(row["ts"], lane["launched_at"]) &&
|
|
171
|
+
(!lane["after_seq"] || row["seq"].to_i > lane["after_seq"].to_i)
|
|
172
|
+
end
|
|
173
|
+
if event && %w[task_complete task_failed].include?(event["type"])
|
|
174
|
+
failed = event["type"] == "task_failed" ||
|
|
175
|
+
(!event["status"].to_s.empty? && !%w[completed success succeeded].include?(event["status"]))
|
|
176
|
+
view.merge!("state" => failed ? "failed" : "done", "outcome" => event["outcome_class"] || event["status"],
|
|
177
|
+
"settled_at" => event["ts"])
|
|
178
|
+
view["last_error"] = event["message"] || event["error"] if failed
|
|
179
|
+
elsif !live
|
|
180
|
+
# Process failures may exit without a task event. The canonical end
|
|
181
|
+
# row supplies their class; ignore old rows from a previous launch.
|
|
182
|
+
terminal = TerminalStatus.resolve(id: lane["name"], repo_root: lane["run_root"])
|
|
183
|
+
if terminal && terminal["terminal"] && after?(terminal["ended_at"], lane["launched_at"])
|
|
184
|
+
view.merge!("state" => terminal["work_state"] == "completed" ? "done" : "failed",
|
|
185
|
+
"outcome" => terminal["outcome_class"], "settled_at" => terminal["ended_at"])
|
|
186
|
+
elsif (exit_status = fresh_exit_status(lane))
|
|
187
|
+
done = exit_status["task_complete"] == true ||
|
|
188
|
+
(exit_status["task_failed"] != true && exit_status["exit_code"] == 0)
|
|
189
|
+
view.merge!("state" => done ? "done" : "failed", "outcome" => exit_status["outcome_class"],
|
|
190
|
+
"settled_at" => exit_status["exited_at"])
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
view
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fresh_exit_status(lane)
|
|
197
|
+
path = Harnex.exit_status_path(lane["run_root"], lane["name"])
|
|
198
|
+
data = JSON.parse(File.read(path))
|
|
199
|
+
return nil unless data.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
# Exit timestamps have second precision; mtime also fences same-second
|
|
202
|
+
# relaunches so an earlier dispatch's leftover file cannot settle one.
|
|
203
|
+
since = Time.iso8601(lane["launched_at"].to_s)
|
|
204
|
+
data if File.mtime(path) >= since && after?(data["exited_at"], since.iso8601)
|
|
205
|
+
rescue Errno::ENOENT, JSON::ParserError, ArgumentError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
120
209
|
# The live worker's activity snapshot (see ActivityTracker), or nil.
|
|
121
210
|
def activity(lane)
|
|
122
211
|
registry = Harnex.read_registry(lane["run_root"], lane["name"])
|
|
@@ -155,17 +244,43 @@ module Harnex
|
|
|
155
244
|
end
|
|
156
245
|
|
|
157
246
|
# The main checkout of the repo that contains `path` (also from inside one
|
|
158
|
-
# of its worktrees), or nil outside git.
|
|
247
|
+
# of its worktrees), or nil outside git. In a bare layout (`repo/.bare`
|
|
248
|
+
# plus sibling worktrees, #89) it is the worktree on the bare repo's HEAD
|
|
249
|
+
# branch, so every worktree of the repo shares one lane namespace.
|
|
159
250
|
def main_root(path)
|
|
160
251
|
common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
161
252
|
return nil unless ok
|
|
162
253
|
|
|
163
254
|
return File.dirname(common) if File.basename(common) == ".git"
|
|
164
255
|
|
|
256
|
+
bare_main_worktree(path, common) || toplevel(path)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def same_dir?(one, two)
|
|
260
|
+
File.realpath(one.to_s) == File.realpath(two.to_s)
|
|
261
|
+
rescue SystemCallError
|
|
262
|
+
File.expand_path(one.to_s) == File.expand_path(two.to_s)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def toplevel(path)
|
|
165
266
|
top, _err, ok = git(path, "rev-parse", "--show-toplevel")
|
|
166
267
|
ok ? top : nil
|
|
167
268
|
end
|
|
168
269
|
|
|
270
|
+
def bare_main_worktree(path, common)
|
|
271
|
+
list, _err, ok = git(path, "worktree", "list", "--porcelain")
|
|
272
|
+
return nil unless ok
|
|
273
|
+
|
|
274
|
+
entries = list.split(/\n{2,}/).map do |block|
|
|
275
|
+
block.lines.to_h { |line| line.chomp.split(" ", 2).then { |key, value| [key, value.to_s] } }
|
|
276
|
+
end
|
|
277
|
+
return nil unless entries.first&.key?("bare")
|
|
278
|
+
|
|
279
|
+
head, _err, ok = git(common, "symbolic-ref", "-q", "HEAD")
|
|
280
|
+
main = ok && entries.find { |entry| entry["branch"] == head && File.directory?(entry["worktree"].to_s) }
|
|
281
|
+
main ? main["worktree"] : nil
|
|
282
|
+
end
|
|
283
|
+
|
|
169
284
|
def head_sha(dir)
|
|
170
285
|
sha, _err, ok = git(dir, "rev-parse", "HEAD")
|
|
171
286
|
ok ? sha : nil
|
|
@@ -176,10 +291,28 @@ module Harnex
|
|
|
176
291
|
ok ? out.lines.map { |line| line[3..].to_s.strip } : []
|
|
177
292
|
end
|
|
178
293
|
|
|
294
|
+
# #90: a worktree lane lives at .harnex/worktrees/NAME on harnex/NAME,
|
|
295
|
+
# unless the repo's .harnex/config.json `lanes` templates say otherwise
|
|
296
|
+
# ({name}, {yyyy}, {mm}, {dd}; worktree_path is relative to the main
|
|
297
|
+
# checkout, so "../{name}" puts lanes beside it).
|
|
298
|
+
def worktree_layout(root, name, now: Time.now)
|
|
299
|
+
templates = Config.load_repo(root).lanes || {}
|
|
300
|
+
fill = lambda do |template|
|
|
301
|
+
template.gsub("{name}", name).gsub("{yyyy}", now.strftime("%Y")).gsub("{mm}", now.strftime("%m"))
|
|
302
|
+
.gsub("{dd}", now.strftime("%d"))
|
|
303
|
+
end
|
|
304
|
+
path = fill.call(templates["worktree_path"] || File.join(WORKTREES_DIR, "{name}"))
|
|
305
|
+
[File.expand_path(path, root), fill.call(templates["branch"] || "#{BRANCH_PREFIX}{name}")]
|
|
306
|
+
rescue Config::ConfigError => e
|
|
307
|
+
raise OptionParser::InvalidArgument, "invalid config #{e.message}"
|
|
308
|
+
end
|
|
309
|
+
|
|
179
310
|
def create_worktree(root, name)
|
|
180
311
|
Harnex.ensure_git_excluded(root)
|
|
181
|
-
worktree =
|
|
182
|
-
|
|
312
|
+
worktree, branch = worktree_layout(root, name)
|
|
313
|
+
_out, _err, valid = git(root, "check-ref-format", "--branch", branch)
|
|
314
|
+
raise OptionParser::InvalidArgument, "lanes.branch gives #{branch.inspect}, which is not a valid branch name" unless valid
|
|
315
|
+
|
|
183
316
|
_out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
184
317
|
if exists || File.exist?(worktree)
|
|
185
318
|
raise OptionParser::InvalidArgument,
|
|
@@ -259,8 +392,12 @@ module Harnex
|
|
|
259
392
|
merged
|
|
260
393
|
end
|
|
261
394
|
|
|
395
|
+
# Inside the checkout: "a/b"; beside it: "../x"; anywhere else: absolute.
|
|
262
396
|
def relative(root, path)
|
|
263
|
-
path.to_s.
|
|
397
|
+
return path.to_s.delete_prefix("#{root}/") if path.to_s.start_with?("#{root}/")
|
|
398
|
+
|
|
399
|
+
sibling = "../#{File.basename(path.to_s)}"
|
|
400
|
+
File.expand_path(sibling, root.to_s) == File.expand_path(path.to_s) ? sibling : path.to_s
|
|
264
401
|
end
|
|
265
402
|
|
|
266
403
|
# ---- briefs ------------------------------------------------------------
|
|
@@ -419,7 +556,7 @@ module Harnex
|
|
|
419
556
|
lane["settled_at"] = now
|
|
420
557
|
# Kept so a later `harnex result NAME` can show the result again.
|
|
421
558
|
lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
|
|
422
|
-
"outcome_class", "status", "last_error", "end_sha")
|
|
559
|
+
"outcome_class", "status", "last_error", "end_sha", "earlier_replies")
|
|
423
560
|
save(lane)
|
|
424
561
|
end
|
|
425
562
|
|
|
@@ -436,13 +573,79 @@ module Harnex
|
|
|
436
573
|
|
|
437
574
|
def render(lane, turn, exit_code)
|
|
438
575
|
lines = []
|
|
439
|
-
message = final_message(turn)
|
|
576
|
+
message = with_earlier_replies(final_message(turn), turn)
|
|
440
577
|
lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
|
|
441
578
|
lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
|
|
442
|
-
lines.concat(details(lane, exit_code).map { |line| " #{line}" })
|
|
579
|
+
lines.concat((interjection_lines(lane) + details(lane, exit_code)).map { |line| " #{line}" })
|
|
443
580
|
lines.join("\n")
|
|
444
581
|
end
|
|
445
582
|
|
|
583
|
+
# An answered ask prints only its answer, plus what else reached the
|
|
584
|
+
# worker (#92).
|
|
585
|
+
def render_answer(lane, turn)
|
|
586
|
+
text = with_earlier_replies(final_message(turn), turn)
|
|
587
|
+
notes = interjection_lines(lane)
|
|
588
|
+
return text if notes.empty?
|
|
589
|
+
|
|
590
|
+
[text.rstrip, "", "── #{lane['name']}: #{notes.first}", *notes.drop(1).map { |line| " #{line}" }].join("\n")
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# #92: when a later message reached the worker after it had answered,
|
|
594
|
+
# the turn's result is its answer to that message; the answers it
|
|
595
|
+
# replaced (the original task's report among them) come first.
|
|
596
|
+
def with_earlier_replies(message, turn)
|
|
597
|
+
earlier = Array(turn["earlier_replies"]).map(&:to_s).reject { |text| text.strip.empty? }
|
|
598
|
+
return message if earlier.empty? || message.strip.empty?
|
|
599
|
+
|
|
600
|
+
parts = earlier.map { |text| "[earlier reply this turn, before another message reached the worker]\n#{text.rstrip}" }
|
|
601
|
+
(parts + ["[latest reply]\n#{message.rstrip}"]).join("\n\n")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# #92: messages that reached the worker from anyone but the lane's own
|
|
605
|
+
# owner and its `harnex reply` (someone's `harnex send` from another
|
|
606
|
+
# terminal or session), since the lane's latest launch.
|
|
607
|
+
def interjections(lane)
|
|
608
|
+
events(lane).select do |event|
|
|
609
|
+
event["type"] == "send" && event["from"].is_a?(Hash) && after?(event["ts"], lane["launched_at"]) &&
|
|
610
|
+
!own_message?(lane, event["from"])
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def own_message?(lane, from)
|
|
615
|
+
owner = lane["owner"].to_s
|
|
616
|
+
from["owner"].to_s == owner && (!owner.empty? || from["via"] == "reply")
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def interjection_lines(lane)
|
|
620
|
+
found = interjections(lane)
|
|
621
|
+
return [] if found.empty?
|
|
622
|
+
|
|
623
|
+
count = found.length == 1 ? "1 message" : "#{found.length} messages"
|
|
624
|
+
["interjected: #{count} reached the worker from outside this lane " \
|
|
625
|
+
"(the reply above may answer #{found.length == 1 ? 'it' : 'them'}; all: `harnex events --id #{lane['name']}`):"] +
|
|
626
|
+
found.last(5).map { |event| " #{clock(event['ts'])} #{sender_label(event['from'])}: #{preview(event)}" }
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def sender_label(from)
|
|
630
|
+
who = if from["session"] then "harnex session #{from['session']}"
|
|
631
|
+
elsif from["owner"] then "session #{from['owner'][0, 8]}"
|
|
632
|
+
else "a terminal"
|
|
633
|
+
end
|
|
634
|
+
"from #{who} (harnex #{from['via'] || 'send'})"
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def preview(event)
|
|
638
|
+
text = event["msg"].to_s.sub(/\A\[harnex [^\]]*\]\s*/, "").gsub(/\s+/, " ").strip
|
|
639
|
+
cut = text.length > 160 || event["msg_truncated"]
|
|
640
|
+
"\"#{text[0, 160]}#{cut ? '…' : ''}\""
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def clock(ts)
|
|
644
|
+
Time.iso8601(ts.to_s).localtime.strftime("%H:%M:%S")
|
|
645
|
+
rescue ArgumentError
|
|
646
|
+
ts.to_s
|
|
647
|
+
end
|
|
648
|
+
|
|
446
649
|
def headline(lane, turn, exit_code)
|
|
447
650
|
model = turn["model"] || lane["model"] || lane["requested_model"]
|
|
448
651
|
parts = []
|
|
@@ -465,6 +668,9 @@ module Harnex
|
|
|
465
668
|
dir = lane["worktree"] || lane["cwd"]
|
|
466
669
|
return nil unless lane["base_sha"] && File.directory?(dir.to_s)
|
|
467
670
|
|
|
671
|
+
shas = lane["branch"] ? nil : worker_commits(lane)
|
|
672
|
+
return worker_change_summary(lane, dir, shas) if shas
|
|
673
|
+
|
|
468
674
|
count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
|
|
469
675
|
return nil unless ok
|
|
470
676
|
|
|
@@ -473,6 +679,47 @@ module Harnex
|
|
|
473
679
|
stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
|
|
474
680
|
end
|
|
475
681
|
|
|
682
|
+
# #91: a lane working in place shares HEAD with whoever else commits
|
|
683
|
+
# there, so count only the commits the worker made (worker_commits
|
|
684
|
+
# events). nil when the worker's harnex did not track them.
|
|
685
|
+
def worker_commits(lane)
|
|
686
|
+
found = events(lane).select { |event| after?(event["ts"], lane["created_at"]) }
|
|
687
|
+
return nil unless found.any? { |event| event["type"] == "git" && event["tracks_worker_commits"] }
|
|
688
|
+
|
|
689
|
+
found.select { |event| event["type"] == "worker_commits" }.flat_map { |event| Array(event["shas"]) }
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def worker_change_summary(lane, dir, shas)
|
|
693
|
+
moved, _err, ok = git(dir, "rev-list", "#{lane['base_sha']}..HEAD")
|
|
694
|
+
return nil unless ok
|
|
695
|
+
|
|
696
|
+
history = moved.split
|
|
697
|
+
mine = history & shas
|
|
698
|
+
text = "#{mine.length} commit#{mine.length == 1 ? '' : 's'}"
|
|
699
|
+
stat = mine.empty? ? nil : commits_stat(dir, mine)
|
|
700
|
+
text += ", #{stat}" if stat
|
|
701
|
+
others = history.length - mine.length
|
|
702
|
+
text += " (HEAD also moved #{others} commit#{others == 1 ? '' : 's'} by others)" if others.positive?
|
|
703
|
+
text
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def commits_stat(dir, shas)
|
|
707
|
+
out, _err, ok = git(dir, "show", "--numstat", "--format=", *shas)
|
|
708
|
+
return nil unless ok
|
|
709
|
+
|
|
710
|
+
files = {}
|
|
711
|
+
added = removed = 0
|
|
712
|
+
out.each_line do |line|
|
|
713
|
+
plus, minus, path = line.chomp.split("\t", 3)
|
|
714
|
+
next unless path
|
|
715
|
+
|
|
716
|
+
files[path] = true
|
|
717
|
+
added += plus.to_i
|
|
718
|
+
removed += minus.to_i
|
|
719
|
+
end
|
|
720
|
+
"#{files.size} file#{files.size == 1 ? '' : 's'} (+#{added} -#{removed})"
|
|
721
|
+
end
|
|
722
|
+
|
|
476
723
|
def compact_stat(stat)
|
|
477
724
|
files = stat[/(\d+) files? changed/, 1]
|
|
478
725
|
added = stat[/(\d+) insertions?/, 1]
|
|
@@ -483,6 +730,8 @@ module Harnex
|
|
|
483
730
|
def details(lane, exit_code)
|
|
484
731
|
name = lane["name"]
|
|
485
732
|
return ["keep waiting: harnex result #{name}"] if exit_code == Waiter::DONE_EXIT_TIMEOUT
|
|
733
|
+
provider_hint = provider_failure_hint(lane)
|
|
734
|
+
return ["next: #{provider_hint}"] if lane["kind"] == "ask" && !exit_code.zero? && provider_hint
|
|
486
735
|
return ["more: harnex reply #{name} \"...\""] if lane["kind"] == "ask"
|
|
487
736
|
|
|
488
737
|
out = []
|
|
@@ -502,6 +751,8 @@ module Harnex
|
|
|
502
751
|
else
|
|
503
752
|
out << "worked in place in #{lane['cwd']}; more work: `harnex reply #{name} \"...\"`"
|
|
504
753
|
end
|
|
754
|
+
elsif provider_hint
|
|
755
|
+
out << "next: #{provider_hint}#{lane['branch'] ? "; or `harnex discard #{name}`" : ''}"
|
|
505
756
|
else
|
|
506
757
|
retry_model = model_problem?(lane) ? other_model(lane) : nil
|
|
507
758
|
out << "next: `harnex reply #{name} \"Continue the same task from where you stopped\"" \
|
|
@@ -512,6 +763,15 @@ module Harnex
|
|
|
512
763
|
out
|
|
513
764
|
end
|
|
514
765
|
|
|
766
|
+
def provider_failure_hint(lane)
|
|
767
|
+
case lane["outcome"]
|
|
768
|
+
when "policy_refusal"
|
|
769
|
+
"provider refused the content; a retry needs a rephrased brief or another route"
|
|
770
|
+
when "provider_error_unexplained"
|
|
771
|
+
"read the provider/gateway logs before retrying; the provider returned no error details"
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
|
|
515
775
|
def commit_count(lane)
|
|
516
776
|
count, _err, ok = git(lane["worktree"] || lane["cwd"], "rev-list", "--count", "#{lane['base_sha']}..HEAD")
|
|
517
777
|
ok ? count.to_i : 0
|
data/lib/harnex/mcp.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Harnex
|
|
|
41
41
|
"model" => MODEL, "effort" => EFFORT,
|
|
42
42
|
"fallback_model" => { "type" => "string", "description" => "Ask this model instead if the first is rate limited" },
|
|
43
43
|
"write" => { "type" => "boolean", "description" => "Let the worker edit files and run commands (default false)" },
|
|
44
|
+
"name" => { "type" => "string", "description" => "Lane name (default: pi-ask-<random>)" },
|
|
44
45
|
"cwd" => CWD, "wait_minutes" => WAIT_MINUTES
|
|
45
46
|
} },
|
|
46
47
|
"annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
|
|
@@ -56,7 +57,7 @@ module Harnex
|
|
|
56
57
|
"inputSchema" => { "type" => "object", "required" => ["task"], "properties" => {
|
|
57
58
|
"task" => { "type" => "string", "description" => "Self-contained brief: files, expected behavior, how to verify (e.g. the test command)" },
|
|
58
59
|
"worktree" => { "type" => "string", "description" => "Lane name; work in a new git worktree on branch harnex/NAME (recommended for edits)" },
|
|
59
|
-
"name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: from the task)" },
|
|
60
|
+
"name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: the cwd worktree's name, else from the task)" },
|
|
60
61
|
"wait" => { "type" => "boolean", "description" => "Wait for the result (default true); false starts the lane and returns at once" },
|
|
61
62
|
"model" => MODEL, "effort" => EFFORT, "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
|
|
62
63
|
} },
|
|
@@ -250,10 +251,11 @@ module Harnex
|
|
|
250
251
|
case tool
|
|
251
252
|
when "delegate"
|
|
252
253
|
if args["worktree"].to_s.empty? && args["name"].to_s.empty?
|
|
253
|
-
|
|
254
|
-
args["name"] = Lanes.generate_name(
|
|
254
|
+
dir = work_dir(args)
|
|
255
|
+
args["name"] = Lanes.generate_name(Lanes.main_root(dir) || dir, args["task"] || args["prompt"] || args["brief"], cwd: dir)
|
|
255
256
|
end
|
|
256
257
|
when "ask"
|
|
258
|
+
args["id"] = args["name"] unless args["name"].to_s.strip.empty?
|
|
257
259
|
args["id"] ||= "pi-ask-#{SecureRandom.hex(2)}"
|
|
258
260
|
end
|
|
259
261
|
args
|
data/lib/harnex/orchestration.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Harnex
|
|
|
23
23
|
# are not fatal unless a caller names them (#57). provider_capacity was
|
|
24
24
|
# task_failed before #82 and stays fatal; pass fatal classes to exempt it.
|
|
25
25
|
DEFAULT_FATAL_CLASSES = %w[
|
|
26
|
-
completed_no_activity task_failed provider_capacity
|
|
26
|
+
completed_no_activity task_failed provider_capacity policy_refusal provider_error_unexplained
|
|
27
|
+
boot_failure permission_prompt timeout process_failed
|
|
27
28
|
].freeze
|
|
28
29
|
|
|
29
30
|
module_function
|
|
@@ -78,7 +78,8 @@ module Harnex
|
|
|
78
78
|
text: payload[:text],
|
|
79
79
|
submit: payload[:submit],
|
|
80
80
|
enter_only: payload[:enter_only],
|
|
81
|
-
force: payload[:force]
|
|
81
|
+
force: payload[:force],
|
|
82
|
+
from: payload[:from]
|
|
82
83
|
)
|
|
83
84
|
http_code = result.delete(:http_status) || 200
|
|
84
85
|
json(client, http_code, result)
|
|
@@ -136,6 +137,15 @@ module Harnex
|
|
|
136
137
|
context
|
|
137
138
|
end
|
|
138
139
|
|
|
140
|
+
# Who sent a message (#92), as `harnex send` reports it: bounded strings.
|
|
141
|
+
def sender(value)
|
|
142
|
+
return nil unless value.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
value.slice("via", "session", "owner").filter_map do |key, text|
|
|
145
|
+
[key, text.to_s[0, 120]] unless text.to_s.strip.empty?
|
|
146
|
+
end.to_h
|
|
147
|
+
end
|
|
148
|
+
|
|
139
149
|
def parse_send_body(headers, body)
|
|
140
150
|
if headers["content-type"].to_s.include?("application/json")
|
|
141
151
|
parsed = JSON.parse(body.empty? ? "{}" : body)
|
|
@@ -145,7 +155,8 @@ module Harnex
|
|
|
145
155
|
text: parsed["text"].to_s,
|
|
146
156
|
submit: parsed.fetch("submit", true),
|
|
147
157
|
enter_only: parsed.fetch("enter_only", false),
|
|
148
|
-
force: parsed.fetch("force", false)
|
|
158
|
+
force: parsed.fetch("force", false),
|
|
159
|
+
from: sender(parsed["from"])
|
|
149
160
|
}
|
|
150
161
|
else
|
|
151
162
|
{
|
data/lib/harnex/runtime/inbox.rb
CHANGED
|
@@ -32,13 +32,14 @@ module Harnex
|
|
|
32
32
|
@thread&.kill
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def enqueue(text:, submit:, enter_only:, force: false)
|
|
35
|
+
def enqueue(text:, submit:, enter_only:, force: false, from: nil)
|
|
36
36
|
msg = Message.new(
|
|
37
37
|
id: SecureRandom.hex(8),
|
|
38
38
|
text: text,
|
|
39
39
|
submit: submit,
|
|
40
40
|
enter_only: enter_only,
|
|
41
41
|
force: force,
|
|
42
|
+
from: from,
|
|
42
43
|
queued_at: Time.now,
|
|
43
44
|
status: :queued
|
|
44
45
|
)
|
|
@@ -139,6 +140,7 @@ module Harnex
|
|
|
139
140
|
def deliver_now(msg, follow_up: false)
|
|
140
141
|
options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
|
|
141
142
|
options[:follow_up] = true if follow_up
|
|
143
|
+
options[:from] = msg.from if msg.from
|
|
142
144
|
result = @session.inject_via_adapter(**options)
|
|
143
145
|
msg.status = :delivered
|
|
144
146
|
msg.delivered_at = Time.now
|