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
|
@@ -61,6 +61,7 @@ module Harnex
|
|
|
61
61
|
MAX_PENDING_PI_COMMANDS = 256
|
|
62
62
|
# Inline cap for a turn's final reply; the full text is in its file.
|
|
63
63
|
FINAL_MESSAGE_LIMIT = 4000
|
|
64
|
+
MAX_EARLIER_REPLIES = 3
|
|
64
65
|
class EventCounters
|
|
65
66
|
def initialize
|
|
66
67
|
@counts = {
|
|
@@ -171,6 +172,7 @@ module Harnex
|
|
|
171
172
|
@turn_follow_ups_sent = 0
|
|
172
173
|
@pi_follow_up_pending = 0
|
|
173
174
|
@turn_final_text = nil
|
|
175
|
+
@turn_earlier_replies = []
|
|
174
176
|
@completion_notifier = completion_notifier || CompletionNotifier.new(
|
|
175
177
|
repo_root: repo_root,
|
|
176
178
|
id: @id,
|
|
@@ -190,6 +192,7 @@ module Harnex
|
|
|
190
192
|
@observed_commands_mutex = Mutex.new
|
|
191
193
|
@observed_commands = []
|
|
192
194
|
@pi_pending_commands = {}
|
|
195
|
+
@pi_git_heads = {}
|
|
193
196
|
@pi_unobserved_commands = 0
|
|
194
197
|
# Final gates and the failure flag cover the whole command history; the
|
|
195
198
|
# retained @observed_commands window is capped at MAX_COMMANDS (#68).
|
|
@@ -480,10 +483,12 @@ module Harnex
|
|
|
480
483
|
{ ok: true, signal: "exit_sequence_sent" }
|
|
481
484
|
end
|
|
482
485
|
|
|
483
|
-
|
|
486
|
+
# `from` names who sent a message through `harnex send` (#92); the
|
|
487
|
+
# launch prompt has none.
|
|
488
|
+
def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
|
|
484
489
|
if structured_transport?
|
|
485
490
|
return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force,
|
|
486
|
-
follow_up: follow_up)
|
|
491
|
+
follow_up: follow_up, from: from)
|
|
487
492
|
end
|
|
488
493
|
|
|
489
494
|
after_seq = current_event_seq
|
|
@@ -509,7 +514,7 @@ module Harnex
|
|
|
509
514
|
force: payload[:force],
|
|
510
515
|
after_seq: after_seq
|
|
511
516
|
)
|
|
512
|
-
.tap { emit_send_event(text, force: payload[:force]) }
|
|
517
|
+
.tap { emit_send_event(text, force: payload[:force], from: from) }
|
|
513
518
|
end
|
|
514
519
|
|
|
515
520
|
# #79: a structured adapter that can queue a message into the running turn
|
|
@@ -525,7 +530,7 @@ module Harnex
|
|
|
525
530
|
@events_mutex.synchronize { @events_log_seq }
|
|
526
531
|
end
|
|
527
532
|
|
|
528
|
-
def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false)
|
|
533
|
+
def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
|
|
529
534
|
if @runtime_budget&.expired?
|
|
530
535
|
stop_for_runtime_budget!
|
|
531
536
|
raise ArgumentError, "session runtime budget is exhausted"
|
|
@@ -560,6 +565,8 @@ module Harnex
|
|
|
560
565
|
# is reset here. If Pi has in fact gone idle it starts a new run,
|
|
561
566
|
# whose agent_start begins the next turn.
|
|
562
567
|
unless queued_into_run
|
|
568
|
+
# A new prompt answers nothing earlier in this turn (#92).
|
|
569
|
+
@pi_last_answer = nil
|
|
563
570
|
# Reserve work before the blocking RPC. Steering an active run does
|
|
564
571
|
# not reset its clocks, but a new prompt cannot reuse old proof.
|
|
565
572
|
@activity.start_turn
|
|
@@ -584,7 +591,7 @@ module Harnex
|
|
|
584
591
|
end
|
|
585
592
|
end
|
|
586
593
|
|
|
587
|
-
emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force])
|
|
594
|
+
emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force], from: from)
|
|
588
595
|
{
|
|
589
596
|
ok: true,
|
|
590
597
|
cli: adapter.key,
|
|
@@ -930,6 +937,7 @@ module Harnex
|
|
|
930
937
|
adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
|
|
931
938
|
schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
|
|
932
939
|
when "message_start"
|
|
940
|
+
note_pi_user_message(message["message"])
|
|
933
941
|
@pi_active_message_key = pi_message_key(message["message"])
|
|
934
942
|
@pi_streamed_text_by_message[@pi_active_message_key] = false
|
|
935
943
|
when "message_update"
|
|
@@ -955,6 +963,7 @@ module Harnex
|
|
|
955
963
|
if pi_command_tool?(message)
|
|
956
964
|
@event_counters.record_item({ "type" => "commandExecution" })
|
|
957
965
|
start_pi_command(message)
|
|
966
|
+
note_pi_git_head(message)
|
|
958
967
|
else
|
|
959
968
|
@event_counters.record_item({ "type" => "dynamicToolCall" })
|
|
960
969
|
end
|
|
@@ -962,7 +971,10 @@ module Harnex
|
|
|
962
971
|
"tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
|
|
963
972
|
)
|
|
964
973
|
when "tool_execution_end"
|
|
965
|
-
|
|
974
|
+
if pi_command_tool?(message)
|
|
975
|
+
finish_pi_command(message)
|
|
976
|
+
record_pi_worker_commits(message)
|
|
977
|
+
end
|
|
966
978
|
tool_name = message["toolName"] || "tool"
|
|
967
979
|
status = message["isError"] ? "error" : "ok"
|
|
968
980
|
record_synthesized("tool-result: #{tool_name} (#{status})")
|
|
@@ -1198,6 +1210,9 @@ module Harnex
|
|
|
1198
1210
|
|
|
1199
1211
|
text = pi_extract_message_text(message).to_s
|
|
1200
1212
|
@turn_final_text = text unless text.strip.empty?
|
|
1213
|
+
# An answer (stopReason "stop"), kept apart from the run outcome that
|
|
1214
|
+
# every turn_start resets (#92).
|
|
1215
|
+
@pi_last_answer = message["stopReason"] == "stop" && !text.strip.empty? ? text : nil
|
|
1201
1216
|
|
|
1202
1217
|
reason = message["stopReason"].to_s
|
|
1203
1218
|
@pi_last_assistant_stop_reason = reason unless reason.empty?
|
|
@@ -1205,6 +1220,49 @@ module Harnex
|
|
|
1205
1220
|
@pi_last_assistant_error = error unless error.empty?
|
|
1206
1221
|
end
|
|
1207
1222
|
|
|
1223
|
+
# #92: a message that reaches the worker after it has answered (a
|
|
1224
|
+
# follow-up from the lane's owner or from anyone else) starts a new
|
|
1225
|
+
# answer; keep the one it replaces so the turn's result still has it.
|
|
1226
|
+
def note_pi_user_message(message)
|
|
1227
|
+
return unless message.is_a?(Hash) && message["role"] == "user"
|
|
1228
|
+
|
|
1229
|
+
text = @pi_last_answer.to_s.strip
|
|
1230
|
+
@pi_last_answer = nil
|
|
1231
|
+
return if text.empty? || @turn_earlier_replies.last == text
|
|
1232
|
+
|
|
1233
|
+
@turn_earlier_replies = (@turn_earlier_replies + [text]).last(MAX_EARLIER_REPLIES)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# #91: commits the worker made, told apart from HEAD moving under it
|
|
1237
|
+
# (an orchestrator committing in the same checkout): HEAD is read around
|
|
1238
|
+
# each command that runs git, and only commits that appear during one
|
|
1239
|
+
# count as the worker's.
|
|
1240
|
+
def note_pi_git_head(message)
|
|
1241
|
+
args = message["args"]
|
|
1242
|
+
command = args["command"] if args.is_a?(Hash)
|
|
1243
|
+
return unless command.is_a?(String) && command.match?(/\bgit\b/) && @git_start && !@git_start.empty?
|
|
1244
|
+
|
|
1245
|
+
head = observed_git_head
|
|
1246
|
+
@pi_git_heads[message["toolCallId"].to_s] = head if head
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def record_pi_worker_commits(message)
|
|
1250
|
+
before = @pi_git_heads.delete(message["toolCallId"].to_s)
|
|
1251
|
+
after = before && observed_git_head
|
|
1252
|
+
return if after.nil? || after == before
|
|
1253
|
+
|
|
1254
|
+
shas = Harnex.git_output(git_observation_root, "rev-list", "--max-count=100", "#{before}..#{after}").split
|
|
1255
|
+
emit_event("worker_commits", shas: shas) unless shas.empty?
|
|
1256
|
+
rescue StandardError
|
|
1257
|
+
nil
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
def observed_git_head
|
|
1261
|
+
Harnex.git_output(git_observation_root, "rev-parse", "HEAD")
|
|
1262
|
+
rescue StandardError
|
|
1263
|
+
nil
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1208
1266
|
def settle_pi_task!
|
|
1209
1267
|
return if @stop_requested || @pi_transport_failed
|
|
1210
1268
|
|
|
@@ -1392,6 +1450,8 @@ module Harnex
|
|
|
1392
1450
|
@completion_notification_sent = false
|
|
1393
1451
|
@turn_follow_ups_sent = 0
|
|
1394
1452
|
@turn_final_text = nil
|
|
1453
|
+
@turn_earlier_replies = []
|
|
1454
|
+
@pi_last_answer = nil
|
|
1395
1455
|
@completion_turn += 1
|
|
1396
1456
|
end
|
|
1397
1457
|
return unless turn
|
|
@@ -1451,6 +1511,7 @@ module Harnex
|
|
|
1451
1511
|
end
|
|
1452
1512
|
fields = { final_message: text[0, FINAL_MESSAGE_LIMIT], final_message_path: path }
|
|
1453
1513
|
fields[:final_message_truncated] = true if text.length > FINAL_MESSAGE_LIMIT
|
|
1514
|
+
fields[:earlier_replies] = @turn_earlier_replies.map { |reply| reply[0, FINAL_MESSAGE_LIMIT] } if @turn_earlier_replies.any?
|
|
1454
1515
|
fields
|
|
1455
1516
|
rescue StandardError
|
|
1456
1517
|
{ final_message: text[0, FINAL_MESSAGE_LIMIT] }
|
|
@@ -1658,11 +1719,13 @@ module Harnex
|
|
|
1658
1719
|
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
1659
1720
|
end
|
|
1660
1721
|
|
|
1661
|
-
def emit_send_event(text, force:)
|
|
1722
|
+
def emit_send_event(text, force:, from: nil)
|
|
1662
1723
|
compact = text.to_s
|
|
1663
1724
|
truncated = compact.length > 200
|
|
1664
1725
|
preview = truncated ? "#{compact[0, 200]}…" : compact
|
|
1665
|
-
|
|
1726
|
+
payload = { msg: preview, msg_truncated: truncated, forced: !!force }
|
|
1727
|
+
payload[:from] = from if from.is_a?(Hash) && !from.empty?
|
|
1728
|
+
emit_event("send", **payload)
|
|
1666
1729
|
end
|
|
1667
1730
|
|
|
1668
1731
|
def emit_started_event
|
|
@@ -1685,7 +1748,7 @@ module Harnex
|
|
|
1685
1748
|
)
|
|
1686
1749
|
return if @git_start.empty?
|
|
1687
1750
|
|
|
1688
|
-
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch] }
|
|
1751
|
+
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch], tracks_worker_commits: true }
|
|
1689
1752
|
payload[:git_root] = git_observation_root if git_lane?
|
|
1690
1753
|
emit_event("git", **payload)
|
|
1691
1754
|
end
|
data/lib/harnex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
|
|
14
14
|
the result back in one call: lanes in git worktrees, follow-ups that keep context,
|