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.
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
- # Record states: running (a turn is out and nobody has collected it),
15
- # done, failed, landed, discarded.
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 with that name.
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].freeze
102
-
103
- def generate_name(root, task, prefix: "pi")
104
- words = task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) }.first(4)
105
- base = ([prefix] + words).join("-")[0, 40].sub(/-+\z/, "")
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 = File.join(root, WORKTREES_DIR, name)
182
- branch = BRANCH_PREFIX + name
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.start_with?("#{root}/") ? path.to_s.delete_prefix("#{root}/") : 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
- root = Lanes.main_root(work_dir(args)) || work_dir(args)
254
- args["name"] = Lanes.generate_name(root, args["task"] || args["prompt"] || args["brief"])
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
@@ -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 boot_failure permission_prompt timeout process_failed
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
  {
@@ -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
@@ -1,6 +1,6 @@
1
1
  module Harnex
2
2
  Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error,
3
- :after_seq, :delivery, keyword_init: true) do
3
+ :after_seq, :delivery, :from, keyword_init: true) do
4
4
  def to_h
5
5
  {
6
6
  id: id,