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.
@@ -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
- def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false)
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
- finish_pi_command(message) if pi_command_tool?(message)
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
- capacity = reason == "error" && !@pi_last_retry_error.to_s.empty?
1219
- mark_task_failed(status: reason, error: error, outcome_class: capacity ? "provider_capacity" : nil)
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
- emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
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"]
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.29.1"
3
- RELEASE_DATE = "2026-10-23"
2
+ VERSION = "0.30.1"
3
+ RELEASE_DATE = "2026-10-25"
4
4
  end
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.29.1
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-24 00:00:00.000000000 Z
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,