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
|
@@ -54,13 +54,14 @@ module Harnex
|
|
|
54
54
|
# rate limits, not the work. Structured signal only, never text.
|
|
55
55
|
OUTCOME_CLASSES = (
|
|
56
56
|
%w[completed_with_proof completed_with_activity] + PROOF_REJECTION_CLASSES +
|
|
57
|
-
%w[task_failed provider_capacity] + PROCESS_OUTCOME_CLASSES + %w[unknown]
|
|
57
|
+
%w[task_failed provider_capacity policy_refusal provider_error_unexplained] + PROCESS_OUTCOME_CLASSES + %w[unknown]
|
|
58
58
|
).freeze
|
|
59
59
|
# Pi tools whose structured results carry an integer exit code.
|
|
60
60
|
PI_COMMAND_TOOLS = %w[bash].freeze
|
|
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,53 @@ 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
|
+
|
|
1266
|
+
# #93: these are Pi's structured errorMessage/finalError, not transcript
|
|
1267
|
+
# text. Explicit provider errors take precedence over exhausted retries.
|
|
1268
|
+
PI_POLICY_REFUSAL = /\bcyber_policy\b|content[\s_-]*(?:filter|policy)|flagged for possible cybersecurity risk/i
|
|
1269
|
+
|
|
1208
1270
|
def settle_pi_task!
|
|
1209
1271
|
return if @stop_requested || @pi_transport_failed
|
|
1210
1272
|
|
|
@@ -1215,8 +1277,16 @@ module Harnex
|
|
|
1215
1277
|
when "error", "aborted", "length"
|
|
1216
1278
|
error = @pi_last_assistant_error || @pi_last_retry_error
|
|
1217
1279
|
error = "Pi agent settled with stopReason=#{reason}" if error.to_s.empty?
|
|
1218
|
-
|
|
1219
|
-
|
|
1280
|
+
outcome_class = if reason == "error"
|
|
1281
|
+
if [error, @pi_last_retry_error].any? { |text| text.to_s.match?(PI_POLICY_REFUSAL) }
|
|
1282
|
+
"policy_refusal"
|
|
1283
|
+
elsif error == "Unknown error (no error details in response)"
|
|
1284
|
+
"provider_error_unexplained"
|
|
1285
|
+
elsif !@pi_last_retry_error.to_s.empty?
|
|
1286
|
+
"provider_capacity"
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
mark_task_failed(status: reason, error: error, outcome_class: outcome_class)
|
|
1220
1290
|
when nil, ""
|
|
1221
1291
|
mark_task_failed(
|
|
1222
1292
|
status: "missing_final_message",
|
|
@@ -1392,6 +1462,8 @@ module Harnex
|
|
|
1392
1462
|
@completion_notification_sent = false
|
|
1393
1463
|
@turn_follow_ups_sent = 0
|
|
1394
1464
|
@turn_final_text = nil
|
|
1465
|
+
@turn_earlier_replies = []
|
|
1466
|
+
@pi_last_answer = nil
|
|
1395
1467
|
@completion_turn += 1
|
|
1396
1468
|
end
|
|
1397
1469
|
return unless turn
|
|
@@ -1451,6 +1523,7 @@ module Harnex
|
|
|
1451
1523
|
end
|
|
1452
1524
|
fields = { final_message: text[0, FINAL_MESSAGE_LIMIT], final_message_path: path }
|
|
1453
1525
|
fields[:final_message_truncated] = true if text.length > FINAL_MESSAGE_LIMIT
|
|
1526
|
+
fields[:earlier_replies] = @turn_earlier_replies.map { |reply| reply[0, FINAL_MESSAGE_LIMIT] } if @turn_earlier_replies.any?
|
|
1454
1527
|
fields
|
|
1455
1528
|
rescue StandardError
|
|
1456
1529
|
{ final_message: text[0, FINAL_MESSAGE_LIMIT] }
|
|
@@ -1658,11 +1731,13 @@ module Harnex
|
|
|
1658
1731
|
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
1659
1732
|
end
|
|
1660
1733
|
|
|
1661
|
-
def emit_send_event(text, force:)
|
|
1734
|
+
def emit_send_event(text, force:, from: nil)
|
|
1662
1735
|
compact = text.to_s
|
|
1663
1736
|
truncated = compact.length > 200
|
|
1664
1737
|
preview = truncated ? "#{compact[0, 200]}…" : compact
|
|
1665
|
-
|
|
1738
|
+
payload = { msg: preview, msg_truncated: truncated, forced: !!force }
|
|
1739
|
+
payload[:from] = from if from.is_a?(Hash) && !from.empty?
|
|
1740
|
+
emit_event("send", **payload)
|
|
1666
1741
|
end
|
|
1667
1742
|
|
|
1668
1743
|
def emit_started_event
|
|
@@ -1685,7 +1760,7 @@ module Harnex
|
|
|
1685
1760
|
)
|
|
1686
1761
|
return if @git_start.empty?
|
|
1687
1762
|
|
|
1688
|
-
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch] }
|
|
1763
|
+
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch], tracks_worker_commits: true }
|
|
1689
1764
|
payload[:git_root] = git_observation_root if git_lane?
|
|
1690
1765
|
emit_event("git", **payload)
|
|
1691
1766
|
end
|
|
@@ -2486,6 +2561,7 @@ module Harnex
|
|
|
2486
2561
|
# Status/source derive from the proof verdict, not the refined label.
|
|
2487
2562
|
proof_failure = %w[
|
|
2488
2563
|
completed_no_activity report_missing report_invalid report_rejected task_failed provider_capacity
|
|
2564
|
+
policy_refusal provider_error_unexplained
|
|
2489
2565
|
].include?(base_class)
|
|
2490
2566
|
harness_receipt = artifact_payload&.dig("receipt", "author") == "harnex"
|
|
2491
2567
|
status = receipt_outcome["status"]
|
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.1
|
|
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-28 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,
|