harnex 0.29.0 → 0.30.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 +67 -0
- data/agents/gpt.md +16 -8
- data/docs/agents.md +5 -2
- data/docs/configuration.md +4 -0
- data/docs/lanes.md +56 -6
- data/guides/01_dispatch.md +2 -1
- data/lib/harnex/commands/ask.rb +24 -7
- data/lib/harnex/commands/delegate.rb +37 -10
- data/lib/harnex/commands/reply.rb +2 -1
- data/lib/harnex/commands/result.rb +1 -1
- data/lib/harnex/commands/run.rb +3 -2
- 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 +252 -22
- data/lib/harnex/mcp.rb +5 -3
- 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 +72 -9
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
data/lib/harnex/lanes.rb
CHANGED
|
@@ -19,6 +19,9 @@ module Harnex
|
|
|
19
19
|
WORKTREES_DIR = File.join(".harnex", "worktrees").freeze
|
|
20
20
|
CLOSED_STATES = %w[landed discarded].freeze
|
|
21
21
|
DEFAULT_TIMEOUT = 540
|
|
22
|
+
# A lane's run is tagged meta.phase = its kind unless the caller names a
|
|
23
|
+
# phase; repo phase allowlists exempt these harnex-owned phases (#88).
|
|
24
|
+
PHASES = %w[delegate ask].freeze
|
|
22
25
|
|
|
23
26
|
module_function
|
|
24
27
|
|
|
@@ -60,21 +63,56 @@ module Harnex
|
|
|
60
63
|
lane && !CLOSED_STATES.include?(lane["state"])
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# Older harnex kept the record of a lane refused before it started (#88).
|
|
67
|
+
def never_started?(lane)
|
|
68
|
+
lane && lane["state"] == "failed" && lane["turns"].to_i.zero? && !live?(lane)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def forget(lane)
|
|
72
|
+
FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
|
|
73
|
+
FileUtils.rm_f(brief_path(lane["repo_root"], lane["name"]))
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
# A name resolves to this repo's lane first, then to the only lane
|
|
64
|
-
# anywhere
|
|
77
|
+
# anywhere whose worker is running (those names are unique, #89), then
|
|
78
|
+
# in flight, open, or at all.
|
|
65
79
|
def resolve(name, from: Dir.pwd)
|
|
66
80
|
root = main_root(from)
|
|
67
81
|
lane = root && load(root, name)
|
|
68
82
|
return lane if lane
|
|
69
83
|
|
|
70
84
|
matches = all.select { |candidate| candidate["name"] == name }
|
|
71
|
-
return matches.first if matches.one?
|
|
72
85
|
raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
|
|
73
86
|
|
|
87
|
+
[matches.select { |l| open?(l) && live?(l) }, matches.select { |l| in_flight?(l) },
|
|
88
|
+
matches.select { |l| open?(l) }, matches].each do |group|
|
|
89
|
+
return group.first if group.one?
|
|
90
|
+
end
|
|
74
91
|
raise OptionParser::InvalidArgument,
|
|
75
92
|
"several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
|
|
76
93
|
end
|
|
77
94
|
|
|
95
|
+
# Working (or finished but uncollected), or a worktree not yet landed,
|
|
96
|
+
# in a repo that still exists.
|
|
97
|
+
def in_flight?(lane)
|
|
98
|
+
open?(lane) && !never_started?(lane) && (lane["state"] == "running" || !lane["branch"].nil?) &&
|
|
99
|
+
File.directory?(lane["repo_root"].to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# #89: the in-flight lane anywhere that holds `name`, if any. Generated
|
|
103
|
+
# names skip these, so `harnex result NAME` and `reply NAME` find their
|
|
104
|
+
# lane from anywhere.
|
|
105
|
+
def holder(name)
|
|
106
|
+
all.find { |lane| lane["name"] == name && in_flight?(lane) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The lane anywhere whose worker is running under `name`, if any. A name
|
|
110
|
+
# someone chooses is refused only while another repo's worker has it, so
|
|
111
|
+
# stale lanes elsewhere (an old eval, an abandoned clone) never block it.
|
|
112
|
+
def live_holder(name)
|
|
113
|
+
all.find { |lane| lane["name"] == name && open?(lane) && live?(lane) }
|
|
114
|
+
end
|
|
115
|
+
|
|
78
116
|
# Lanes this caller started in this repo whose current turn nobody has
|
|
79
117
|
# collected yet.
|
|
80
118
|
def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
|
|
@@ -85,20 +123,32 @@ module Harnex
|
|
|
85
123
|
|
|
86
124
|
FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
|
|
87
125
|
run use add make have has can could would should will do does just then also your you
|
|
88
|
-
our my me i we gpt worker workers task repo repository git command file files code
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
126
|
+
our my me i we gpt worker workers task repo repository git command file files code
|
|
127
|
+
are is be carry out brief lane].freeze
|
|
128
|
+
|
|
129
|
+
# A lane working in another worktree of the repo is named after that
|
|
130
|
+
# worktree (`pi-night-1024_s3`); otherwise after the task's first words.
|
|
131
|
+
# Either way no repo has the name in flight (#89).
|
|
132
|
+
def generate_name(root, task, cwd: nil, prefix: "pi")
|
|
133
|
+
base = ([prefix] + name_words(root, task, cwd)).join("-")[0, 40].sub(/[-_.]+\z/, "")
|
|
93
134
|
name = base
|
|
94
135
|
index = 2
|
|
95
|
-
while open?(load(root, name)) || Harnex.read_registry(root, name)
|
|
136
|
+
while open?(load(root, name)) || holder(name) || Harnex.read_registry(root, name)
|
|
96
137
|
name = "#{base}-#{index}"
|
|
97
138
|
index += 1
|
|
98
139
|
end
|
|
99
140
|
name
|
|
100
141
|
end
|
|
101
142
|
|
|
143
|
+
def name_words(root, task, cwd)
|
|
144
|
+
top = cwd && toplevel(cwd)
|
|
145
|
+
if top && root && !same_dir?(top, root)
|
|
146
|
+
slug = File.basename(top).downcase.gsub(/[^a-z0-9._-]+/, "-").sub(/\A[^a-z0-9]+/, "")
|
|
147
|
+
return [slug] unless slug.empty?
|
|
148
|
+
end
|
|
149
|
+
task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) || word.match?(/\A[a-z]\z/) }.first(4)
|
|
150
|
+
end
|
|
151
|
+
|
|
102
152
|
def live?(lane)
|
|
103
153
|
registry = Harnex.read_registry(lane["run_root"], lane["name"])
|
|
104
154
|
!registry.nil? && Harnex.alive_pid?(registry["pid"])
|
|
@@ -142,17 +192,43 @@ module Harnex
|
|
|
142
192
|
end
|
|
143
193
|
|
|
144
194
|
# The main checkout of the repo that contains `path` (also from inside one
|
|
145
|
-
# of its worktrees), or nil outside git.
|
|
195
|
+
# of its worktrees), or nil outside git. In a bare layout (`repo/.bare`
|
|
196
|
+
# plus sibling worktrees, #89) it is the worktree on the bare repo's HEAD
|
|
197
|
+
# branch, so every worktree of the repo shares one lane namespace.
|
|
146
198
|
def main_root(path)
|
|
147
199
|
common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
148
200
|
return nil unless ok
|
|
149
201
|
|
|
150
202
|
return File.dirname(common) if File.basename(common) == ".git"
|
|
151
203
|
|
|
204
|
+
bare_main_worktree(path, common) || toplevel(path)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def same_dir?(one, two)
|
|
208
|
+
File.realpath(one.to_s) == File.realpath(two.to_s)
|
|
209
|
+
rescue SystemCallError
|
|
210
|
+
File.expand_path(one.to_s) == File.expand_path(two.to_s)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def toplevel(path)
|
|
152
214
|
top, _err, ok = git(path, "rev-parse", "--show-toplevel")
|
|
153
215
|
ok ? top : nil
|
|
154
216
|
end
|
|
155
217
|
|
|
218
|
+
def bare_main_worktree(path, common)
|
|
219
|
+
list, _err, ok = git(path, "worktree", "list", "--porcelain")
|
|
220
|
+
return nil unless ok
|
|
221
|
+
|
|
222
|
+
entries = list.split(/\n{2,}/).map do |block|
|
|
223
|
+
block.lines.to_h { |line| line.chomp.split(" ", 2).then { |key, value| [key, value.to_s] } }
|
|
224
|
+
end
|
|
225
|
+
return nil unless entries.first&.key?("bare")
|
|
226
|
+
|
|
227
|
+
head, _err, ok = git(common, "symbolic-ref", "-q", "HEAD")
|
|
228
|
+
main = ok && entries.find { |entry| entry["branch"] == head && File.directory?(entry["worktree"].to_s) }
|
|
229
|
+
main ? main["worktree"] : nil
|
|
230
|
+
end
|
|
231
|
+
|
|
156
232
|
def head_sha(dir)
|
|
157
233
|
sha, _err, ok = git(dir, "rev-parse", "HEAD")
|
|
158
234
|
ok ? sha : nil
|
|
@@ -163,10 +239,28 @@ module Harnex
|
|
|
163
239
|
ok ? out.lines.map { |line| line[3..].to_s.strip } : []
|
|
164
240
|
end
|
|
165
241
|
|
|
242
|
+
# #90: a worktree lane lives at .harnex/worktrees/NAME on harnex/NAME,
|
|
243
|
+
# unless the repo's .harnex/config.json `lanes` templates say otherwise
|
|
244
|
+
# ({name}, {yyyy}, {mm}, {dd}; worktree_path is relative to the main
|
|
245
|
+
# checkout, so "../{name}" puts lanes beside it).
|
|
246
|
+
def worktree_layout(root, name, now: Time.now)
|
|
247
|
+
templates = Config.load_repo(root).lanes || {}
|
|
248
|
+
fill = lambda do |template|
|
|
249
|
+
template.gsub("{name}", name).gsub("{yyyy}", now.strftime("%Y")).gsub("{mm}", now.strftime("%m"))
|
|
250
|
+
.gsub("{dd}", now.strftime("%d"))
|
|
251
|
+
end
|
|
252
|
+
path = fill.call(templates["worktree_path"] || File.join(WORKTREES_DIR, "{name}"))
|
|
253
|
+
[File.expand_path(path, root), fill.call(templates["branch"] || "#{BRANCH_PREFIX}{name}")]
|
|
254
|
+
rescue Config::ConfigError => e
|
|
255
|
+
raise OptionParser::InvalidArgument, "invalid config #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
166
258
|
def create_worktree(root, name)
|
|
167
259
|
Harnex.ensure_git_excluded(root)
|
|
168
|
-
worktree =
|
|
169
|
-
|
|
260
|
+
worktree, branch = worktree_layout(root, name)
|
|
261
|
+
_out, _err, valid = git(root, "check-ref-format", "--branch", branch)
|
|
262
|
+
raise OptionParser::InvalidArgument, "lanes.branch gives #{branch.inspect}, which is not a valid branch name" unless valid
|
|
263
|
+
|
|
170
264
|
_out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
171
265
|
if exists || File.exist?(worktree)
|
|
172
266
|
raise OptionParser::InvalidArgument,
|
|
@@ -227,8 +321,7 @@ module Harnex
|
|
|
227
321
|
closed_at = Time.iso8601((lane["landed_at"] || lane["updated_at"]).to_s)
|
|
228
322
|
next if closed_at > cutoff
|
|
229
323
|
|
|
230
|
-
|
|
231
|
-
FileUtils.rm_f(File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md"))
|
|
324
|
+
forget(lane)
|
|
232
325
|
rescue ArgumentError
|
|
233
326
|
next
|
|
234
327
|
end
|
|
@@ -247,14 +340,22 @@ module Harnex
|
|
|
247
340
|
merged
|
|
248
341
|
end
|
|
249
342
|
|
|
343
|
+
# Inside the checkout: "a/b"; beside it: "../x"; anywhere else: absolute.
|
|
250
344
|
def relative(root, path)
|
|
251
|
-
path.to_s.
|
|
345
|
+
return path.to_s.delete_prefix("#{root}/") if path.to_s.start_with?("#{root}/")
|
|
346
|
+
|
|
347
|
+
sibling = "../#{File.basename(path.to_s)}"
|
|
348
|
+
File.expand_path(sibling, root.to_s) == File.expand_path(path.to_s) ? sibling : path.to_s
|
|
252
349
|
end
|
|
253
350
|
|
|
254
351
|
# ---- briefs ------------------------------------------------------------
|
|
255
352
|
|
|
353
|
+
def brief_path(repo_root, name)
|
|
354
|
+
File.join(dir, "briefs", "#{Harnex.repo_key(repo_root)}--#{name}.md")
|
|
355
|
+
end
|
|
356
|
+
|
|
256
357
|
def write_brief(lane, task)
|
|
257
|
-
file =
|
|
358
|
+
file = brief_path(lane["repo_root"], lane["name"])
|
|
258
359
|
footer = if lane["branch"]
|
|
259
360
|
"You are working in a git worktree on branch #{lane['branch']}; the main checkout is not " \
|
|
260
361
|
"affected. When you are done, commit your changes on this branch, then reply with a short " \
|
|
@@ -275,6 +376,7 @@ module Harnex
|
|
|
275
376
|
# done); without, returns [launch_payload, 0]. A launch that fails before
|
|
276
377
|
# any result (bad flag, busy id, no tmux) raises with harnex's message.
|
|
277
378
|
def launch(lane, harnex_command, context_args:, child_args: [], wait: true, timeout: DEFAULT_TIMEOUT, detach: false)
|
|
379
|
+
before = lane.slice(*RELAUNCH_KEYS)
|
|
278
380
|
lane["launched_at"] = now
|
|
279
381
|
lane["state"] = "running"
|
|
280
382
|
lane.delete("after_seq")
|
|
@@ -283,7 +385,8 @@ module Harnex
|
|
|
283
385
|
|
|
284
386
|
argv = harnex_command + ["run", "pi", "--id", lane["name"]]
|
|
285
387
|
argv += detach ? ["--detach"] : ["--tmux", lane["name"]]
|
|
286
|
-
argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"]
|
|
388
|
+
argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"],
|
|
389
|
+
"--phase", lane["phase"] || lane["kind"]]
|
|
287
390
|
argv += context_args
|
|
288
391
|
argv += ["--model", lane["requested_model"]] if lane["requested_model"]
|
|
289
392
|
argv += ["--effort", lane["effort"]] if lane["effort"]
|
|
@@ -292,9 +395,8 @@ module Harnex
|
|
|
292
395
|
stdout, stderr, status = Open3.capture3(*argv)
|
|
293
396
|
payload = last_json(stdout)
|
|
294
397
|
if payload.empty?
|
|
295
|
-
|
|
296
|
-
lane
|
|
297
|
-
save(lane)
|
|
398
|
+
# 124: the worker did not register in time and may still come up.
|
|
399
|
+
status.exitstatus == Waiter::DONE_EXIT_TIMEOUT ? fail_launch(lane, stderr) : unlaunch(lane, before)
|
|
298
400
|
raise LaunchError, "harnex could not start lane #{lane['name']}: #{stderr.strip.lines.last(3).join.strip}"
|
|
299
401
|
end
|
|
300
402
|
return [payload, 0] unless wait
|
|
@@ -302,6 +404,24 @@ module Harnex
|
|
|
302
404
|
[payload["turn_result"] || payload, status.exitstatus]
|
|
303
405
|
end
|
|
304
406
|
|
|
407
|
+
RELAUNCH_KEYS = %w[state launched_at after_seq last_error].freeze
|
|
408
|
+
|
|
409
|
+
# A launch refused before the worker started (a bad flag, repo phase
|
|
410
|
+
# policy, a busy id) leaves no trace: a new lane's record goes, and a
|
|
411
|
+
# resumed lane keeps what its last turn left.
|
|
412
|
+
def unlaunch(lane, before)
|
|
413
|
+
return forget(lane) if lane["turns"].to_i.zero?
|
|
414
|
+
|
|
415
|
+
RELAUNCH_KEYS.each { |key| before.key?(key) ? lane[key] = before[key] : lane.delete(key) }
|
|
416
|
+
save(lane)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def fail_launch(lane, stderr)
|
|
420
|
+
lane["state"] = "failed"
|
|
421
|
+
lane["last_error"] = stderr.strip.lines.last.to_s.strip
|
|
422
|
+
save(lane)
|
|
423
|
+
end
|
|
424
|
+
|
|
305
425
|
def wait(lane, harnex_command, timeout: DEFAULT_TIMEOUT)
|
|
306
426
|
argv = harnex_command + ["wait", "--id", lane["name"], "--repo", lane["run_root"], "--until", "done",
|
|
307
427
|
"--since", lane["launched_at"]]
|
|
@@ -384,7 +504,7 @@ module Harnex
|
|
|
384
504
|
lane["settled_at"] = now
|
|
385
505
|
# Kept so a later `harnex result NAME` can show the result again.
|
|
386
506
|
lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
|
|
387
|
-
"outcome_class", "status", "last_error", "end_sha")
|
|
507
|
+
"outcome_class", "status", "last_error", "end_sha", "earlier_replies")
|
|
388
508
|
save(lane)
|
|
389
509
|
end
|
|
390
510
|
|
|
@@ -401,13 +521,79 @@ module Harnex
|
|
|
401
521
|
|
|
402
522
|
def render(lane, turn, exit_code)
|
|
403
523
|
lines = []
|
|
404
|
-
message = final_message(turn)
|
|
524
|
+
message = with_earlier_replies(final_message(turn), turn)
|
|
405
525
|
lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
|
|
406
526
|
lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
|
|
407
|
-
lines.concat(details(lane, exit_code).map { |line| " #{line}" })
|
|
527
|
+
lines.concat((interjection_lines(lane) + details(lane, exit_code)).map { |line| " #{line}" })
|
|
408
528
|
lines.join("\n")
|
|
409
529
|
end
|
|
410
530
|
|
|
531
|
+
# An answered ask prints only its answer, plus what else reached the
|
|
532
|
+
# worker (#92).
|
|
533
|
+
def render_answer(lane, turn)
|
|
534
|
+
text = with_earlier_replies(final_message(turn), turn)
|
|
535
|
+
notes = interjection_lines(lane)
|
|
536
|
+
return text if notes.empty?
|
|
537
|
+
|
|
538
|
+
[text.rstrip, "", "── #{lane['name']}: #{notes.first}", *notes.drop(1).map { |line| " #{line}" }].join("\n")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# #92: when a later message reached the worker after it had answered,
|
|
542
|
+
# the turn's result is its answer to that message; the answers it
|
|
543
|
+
# replaced (the original task's report among them) come first.
|
|
544
|
+
def with_earlier_replies(message, turn)
|
|
545
|
+
earlier = Array(turn["earlier_replies"]).map(&:to_s).reject { |text| text.strip.empty? }
|
|
546
|
+
return message if earlier.empty? || message.strip.empty?
|
|
547
|
+
|
|
548
|
+
parts = earlier.map { |text| "[earlier reply this turn, before another message reached the worker]\n#{text.rstrip}" }
|
|
549
|
+
(parts + ["[latest reply]\n#{message.rstrip}"]).join("\n\n")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# #92: messages that reached the worker from anyone but the lane's own
|
|
553
|
+
# owner and its `harnex reply` (someone's `harnex send` from another
|
|
554
|
+
# terminal or session), since the lane's latest launch.
|
|
555
|
+
def interjections(lane)
|
|
556
|
+
events(lane).select do |event|
|
|
557
|
+
event["type"] == "send" && event["from"].is_a?(Hash) && after?(event["ts"], lane["launched_at"]) &&
|
|
558
|
+
!own_message?(lane, event["from"])
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def own_message?(lane, from)
|
|
563
|
+
owner = lane["owner"].to_s
|
|
564
|
+
from["owner"].to_s == owner && (!owner.empty? || from["via"] == "reply")
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def interjection_lines(lane)
|
|
568
|
+
found = interjections(lane)
|
|
569
|
+
return [] if found.empty?
|
|
570
|
+
|
|
571
|
+
count = found.length == 1 ? "1 message" : "#{found.length} messages"
|
|
572
|
+
["interjected: #{count} reached the worker from outside this lane " \
|
|
573
|
+
"(the reply above may answer #{found.length == 1 ? 'it' : 'them'}; all: `harnex events --id #{lane['name']}`):"] +
|
|
574
|
+
found.last(5).map { |event| " #{clock(event['ts'])} #{sender_label(event['from'])}: #{preview(event)}" }
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def sender_label(from)
|
|
578
|
+
who = if from["session"] then "harnex session #{from['session']}"
|
|
579
|
+
elsif from["owner"] then "session #{from['owner'][0, 8]}"
|
|
580
|
+
else "a terminal"
|
|
581
|
+
end
|
|
582
|
+
"from #{who} (harnex #{from['via'] || 'send'})"
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def preview(event)
|
|
586
|
+
text = event["msg"].to_s.sub(/\A\[harnex [^\]]*\]\s*/, "").gsub(/\s+/, " ").strip
|
|
587
|
+
cut = text.length > 160 || event["msg_truncated"]
|
|
588
|
+
"\"#{text[0, 160]}#{cut ? '…' : ''}\""
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def clock(ts)
|
|
592
|
+
Time.iso8601(ts.to_s).localtime.strftime("%H:%M:%S")
|
|
593
|
+
rescue ArgumentError
|
|
594
|
+
ts.to_s
|
|
595
|
+
end
|
|
596
|
+
|
|
411
597
|
def headline(lane, turn, exit_code)
|
|
412
598
|
model = turn["model"] || lane["model"] || lane["requested_model"]
|
|
413
599
|
parts = []
|
|
@@ -430,6 +616,9 @@ module Harnex
|
|
|
430
616
|
dir = lane["worktree"] || lane["cwd"]
|
|
431
617
|
return nil unless lane["base_sha"] && File.directory?(dir.to_s)
|
|
432
618
|
|
|
619
|
+
shas = lane["branch"] ? nil : worker_commits(lane)
|
|
620
|
+
return worker_change_summary(lane, dir, shas) if shas
|
|
621
|
+
|
|
433
622
|
count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
|
|
434
623
|
return nil unless ok
|
|
435
624
|
|
|
@@ -438,6 +627,47 @@ module Harnex
|
|
|
438
627
|
stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
|
|
439
628
|
end
|
|
440
629
|
|
|
630
|
+
# #91: a lane working in place shares HEAD with whoever else commits
|
|
631
|
+
# there, so count only the commits the worker made (worker_commits
|
|
632
|
+
# events). nil when the worker's harnex did not track them.
|
|
633
|
+
def worker_commits(lane)
|
|
634
|
+
found = events(lane).select { |event| after?(event["ts"], lane["created_at"]) }
|
|
635
|
+
return nil unless found.any? { |event| event["type"] == "git" && event["tracks_worker_commits"] }
|
|
636
|
+
|
|
637
|
+
found.select { |event| event["type"] == "worker_commits" }.flat_map { |event| Array(event["shas"]) }
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def worker_change_summary(lane, dir, shas)
|
|
641
|
+
moved, _err, ok = git(dir, "rev-list", "#{lane['base_sha']}..HEAD")
|
|
642
|
+
return nil unless ok
|
|
643
|
+
|
|
644
|
+
history = moved.split
|
|
645
|
+
mine = history & shas
|
|
646
|
+
text = "#{mine.length} commit#{mine.length == 1 ? '' : 's'}"
|
|
647
|
+
stat = mine.empty? ? nil : commits_stat(dir, mine)
|
|
648
|
+
text += ", #{stat}" if stat
|
|
649
|
+
others = history.length - mine.length
|
|
650
|
+
text += " (HEAD also moved #{others} commit#{others == 1 ? '' : 's'} by others)" if others.positive?
|
|
651
|
+
text
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def commits_stat(dir, shas)
|
|
655
|
+
out, _err, ok = git(dir, "show", "--numstat", "--format=", *shas)
|
|
656
|
+
return nil unless ok
|
|
657
|
+
|
|
658
|
+
files = {}
|
|
659
|
+
added = removed = 0
|
|
660
|
+
out.each_line do |line|
|
|
661
|
+
plus, minus, path = line.chomp.split("\t", 3)
|
|
662
|
+
next unless path
|
|
663
|
+
|
|
664
|
+
files[path] = true
|
|
665
|
+
added += plus.to_i
|
|
666
|
+
removed += minus.to_i
|
|
667
|
+
end
|
|
668
|
+
"#{files.size} file#{files.size == 1 ? '' : 's'} (+#{added} -#{removed})"
|
|
669
|
+
end
|
|
670
|
+
|
|
441
671
|
def compact_stat(stat)
|
|
442
672
|
files = stat[/(\d+) files? changed/, 1]
|
|
443
673
|
added = stat[/(\d+) insertions?/, 1]
|
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
|
|
@@ -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
|