harnex 0.12.0 → 0.29.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. data/lib/harnex/codex/app_server/client.rb +0 -347
@@ -2,21 +2,29 @@ require "time"
2
2
 
3
3
  module Harnex
4
4
  class CompletionNotifier
5
+ class RegistrationError < StandardError; end
6
+
7
+ # Per-turn results kept in the owner record, so a watcher that was away
8
+ # while several turns ended still receives each one.
9
+ MAX_RESULTS = 20
5
10
  WORK_STATES = {
6
11
  "completed" => "completed", "rejected" => "failed",
7
12
  "failed" => "failed", "error" => "failed"
8
13
  }.freeze
9
14
 
10
- def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil,
15
+ def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil, owner_id: nil,
11
16
  started_at: Time.now, clock: nil, event_sink: nil, spawn: nil,
12
17
  detach: nil, atomic_write: nil, remove: nil, stderr: $stderr)
13
18
  @repo_root = Harnex.canonical_repo_root(repo_root)
14
19
  @id = Harnex.normalize_id(id)
15
20
  @session_id = session_id.to_s
16
21
  @receipt_path = receipt_path.to_s
22
+ @owner_id = owner_id.to_s.strip
23
+ @owner_id = nil if @owner_id.empty?
17
24
  @hook_command = hook_command.to_s
18
25
  @hook_command = nil if @hook_command.empty?
19
26
  @started_at = started_at
27
+ @turn_started_at = started_at
20
28
  @clock = clock || -> { Time.now }
21
29
  @event_sink = event_sink
22
30
  @spawn = spawn || method(:spawn_hook)
@@ -26,7 +34,8 @@ module Harnex
26
34
  @stderr = stderr
27
35
  @mutex = Mutex.new
28
36
  @registered = false
29
- @notified = false
37
+ @notified_turn = 0
38
+ @results = []
30
39
  end
31
40
 
32
41
  # Registration is the stale-marker boundary for a reused repo/id pair.
@@ -39,20 +48,31 @@ module Harnex
39
48
  rescue StandardError => e
40
49
  report_error("completion marker cleanup failed", "cleanup", e, path: path)
41
50
  end
51
+ register_owned_delivery!
42
52
  @registered = true
43
53
  end
44
54
  true
45
55
  end
46
56
 
57
+ # One result per turn (#81). The owner record is rewritten for every turn
58
+ # so `watch --owner` reports a nudged turn too; the marker files and the
59
+ # --on-done hook keep their documented once-per-session contract.
47
60
  def notify(outcome:, work_state:, outcome_class:, artifact_report_status:,
48
- end_sha:, terminal_signal:)
61
+ end_sha:, terminal_signal:, turn: 1, follow_ups: nil,
62
+ final_message: nil, final_message_path: nil, turn_details: nil)
49
63
  outcome = outcome.to_s
50
64
  work_state = work_state.to_s
65
+ turn = [turn.to_i, 1].max
51
66
  expected = WORK_STATES[outcome]
52
67
  raise ArgumentError, "unsupported completion outcome #{outcome.inspect}" unless expected
53
68
  raise ArgumentError, "#{outcome.inspect} completion requires work_state #{expected.inspect}" unless work_state == expected
69
+ first = nil
54
70
  return false unless @mutex.synchronize do
55
- @registered && !@notified ? (@notified = true) : false
71
+ next false unless @registered && turn > @notified_turn
72
+
73
+ first = @notified_turn.zero?
74
+ @notified_turn = turn
75
+ true
56
76
  end
57
77
 
58
78
  now = @clock.call
@@ -63,26 +83,85 @@ module Harnex
63
83
  outcome_class: optional_string(outcome_class),
64
84
  artifact_report_status: optional_string(artifact_report_status),
65
85
  receipt_path: @receipt_path, end_sha: optional_string(end_sha),
66
- elapsed_s: elapsed_seconds(now), terminal_signal: terminal_signal.to_s,
67
- notified_at: now.utc.iso8601
86
+ elapsed_s: elapsed_seconds(now, first ? @started_at : @turn_started_at),
87
+ terminal_signal: terminal_signal.to_s, notified_at: now.utc.iso8601
68
88
  }
69
- marker_written = write_marker(path, payload)
70
- hook_launched = launch_hook(payload)
71
- emit("completion_notification", outcome: outcome, work_state: work_state,
89
+ marker_written = first ? write_marker(path, payload) : false
90
+ if @owner_id
91
+ result = payload.slice(:outcome, :work_state, :outcome_class, :artifact_report_status,
92
+ :end_sha, :elapsed_s, :terminal_signal, :notified_at).merge(turn: turn)
93
+ result[:follow_ups] = follow_ups if follow_ups
94
+ result[:final_message] = final_message if final_message
95
+ result[:final_message_path] = final_message_path if final_message_path
96
+ result.merge!(turn_details.compact) if turn_details.is_a?(Hash)
97
+ results = @mutex.synchronize do
98
+ @results << result
99
+ @results.shift while @results.length > MAX_RESULTS
100
+ @results.dup
101
+ end
102
+ write_owned_delivery(payload.merge(result).merge(results: results))
103
+ end
104
+ hook_launched = first ? launch_hook(payload) : false
105
+ emit("completion_notification", outcome: outcome, work_state: work_state, turn: turn,
72
106
  marker_path: path, marker_written: marker_written,
73
107
  hook_configured: !@hook_command.nil?, hook_launched: hook_launched)
74
108
  true
75
109
  end
76
110
 
111
+ # A message injected after a published result starts the next turn: the
112
+ # owner record returns to running so the stream watches it again.
113
+ def turn_started(turn)
114
+ @turn_started_at = @clock.call
115
+ return false unless @owner_id && @mutex.synchronize { @registered }
116
+
117
+ path = Harnex.owned_notification_path(@owner_id, @session_id)
118
+ results = @mutex.synchronize { @results.dup }
119
+ @atomic_write.call(path, owned_identity.merge(work_state: "running", turn: [turn.to_i, 1].max,
120
+ turn_started_at: @turn_started_at.utc.iso8601, results: results))
121
+ true
122
+ rescue StandardError => e
123
+ report_error("owned notification turn update failed", "delivery", e)
124
+ false
125
+ end
126
+
77
127
  private
78
128
 
129
+ def owned_identity
130
+ {
131
+ schema_version: 1, owner_id: @owner_id, id: @id, session_id: @session_id,
132
+ repo_root: @repo_root, receipt_path: @receipt_path,
133
+ runner_pid: Process.pid, started_at: @started_at.utc.iso8601
134
+ }
135
+ end
136
+
137
+ def register_owned_delivery!
138
+ return unless @owner_id
139
+
140
+ path = Harnex.owned_notification_path(@owner_id, @session_id)
141
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
142
+ @atomic_write.call(path, owned_identity.merge(work_state: "running"))
143
+ rescue StandardError => e
144
+ report_error("owned notification registration failed", "registration", e)
145
+ raise RegistrationError, "cannot register owned completion delivery (#{e.class})"
146
+ end
147
+
148
+ def write_owned_delivery(payload)
149
+ path = Harnex.owned_notification_path(@owner_id, @session_id)
150
+ @atomic_write.call(path, owned_identity.merge(payload))
151
+ rescue StandardError => e
152
+ # The running record remains: its owner can still reconcile with native
153
+ # watch/receipt evidence, independently of this push write or the hook.
154
+ report_error("owned notification write failed", "delivery", e)
155
+ end
156
+
79
157
  def optional_string(value)
80
158
  text = value.to_s
81
159
  text.empty? ? nil : text
82
160
  end
83
161
 
84
- def elapsed_seconds(now)
85
- [(now - @started_at).to_i, 0].max
162
+ # A later turn's elapsed time runs from that turn's start.
163
+ def elapsed_seconds(now, since = @started_at)
164
+ [(now - since).to_i, 0].max
86
165
  rescue StandardError
87
166
  0
88
167
  end
@@ -48,6 +48,19 @@ module Harnex
48
48
  return deliver_now(msg)
49
49
  end
50
50
 
51
+ # A busy agent that can queue into its running turn (Pi followUp, #79)
52
+ # takes the message now: long turns outlive any inbox TTL.
53
+ if @mutex.synchronize { @queue.empty? } && session_accepts_follow_up?
54
+ begin
55
+ result = deliver_now(msg, follow_up: true)
56
+ return result if msg.status == :delivered
57
+ rescue StandardError
58
+ # Fall through to the normal paths
59
+ end
60
+ msg.status = :queued
61
+ msg.error = nil
62
+ end
63
+
51
64
  # Fast path: prompt ready and queue empty — deliver immediately.
52
65
  # Check under lock, then release before calling deliver_now to
53
66
  # avoid recursive locking (deliver_now also acquires @mutex).
@@ -117,15 +130,20 @@ module Harnex
117
130
 
118
131
  private
119
132
 
120
- def deliver_now(msg)
121
- result = @session.inject_via_adapter(
122
- text: msg.text,
123
- submit: msg.submit,
124
- enter_only: msg.enter_only,
125
- force: msg.force
126
- )
133
+ def session_accepts_follow_up?
134
+ @session.respond_to?(:accepts_follow_up?) && @session.accepts_follow_up?
135
+ rescue StandardError
136
+ false
137
+ end
138
+
139
+ def deliver_now(msg, follow_up: false)
140
+ options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
141
+ options[:follow_up] = true if follow_up
142
+ result = @session.inject_via_adapter(**options)
127
143
  msg.status = :delivered
128
144
  msg.delivered_at = Time.now
145
+ msg.after_seq = result[:after_seq] if result.is_a?(Hash)
146
+ msg.delivery = result[:delivery] if result.is_a?(Hash)
129
147
  @mutex.synchronize do
130
148
  @delivered_total += 1
131
149
  @messages[msg.id] = msg
@@ -187,6 +205,7 @@ module Harnex
187
205
  def expire_stale_messages_locked(now = Time.now)
188
206
  while (msg = @queue.first) && stale_message?(msg, now)
189
207
  msg.status = :expired
208
+ msg.error = "expired undelivered after #{@ttl.round}s; agent busy; use --force to inject mid-turn"
190
209
  @queue.shift
191
210
  @expired_total += 1
192
211
  end
@@ -1,5 +1,6 @@
1
1
  module Harnex
2
- Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error, keyword_init: true) do
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
4
  def to_h
4
5
  {
5
6
  id: id,
@@ -7,7 +8,9 @@ module Harnex
7
8
  queued_at: queued_at&.iso8601,
8
9
  delivered_at: delivered_at&.iso8601,
9
10
  text_preview: preview_text,
10
- error: error
11
+ error: error,
12
+ after_seq: after_seq,
13
+ delivery: delivery
11
14
  }
12
15
  end
13
16
 
@@ -0,0 +1,96 @@
1
+ require "time"
2
+
3
+ module Harnex
4
+ # One fixed session deadline. Neither traffic, retries nor prompts can re-arm
5
+ # it. The callback should only schedule stop work, never wait for RPC or I/O.
6
+ class RuntimeBudget
7
+ def initialize(seconds:, monotonic_clock: nil, wall_clock: nil)
8
+ @seconds = Float(seconds)
9
+ raise ArgumentError, "runtime budget must be finite and positive" unless @seconds.finite? && @seconds.positive?
10
+
11
+ @clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
12
+ @wall_clock = wall_clock || -> { Time.now }
13
+ @mutex = Mutex.new
14
+ @condition = ConditionVariable.new
15
+ @expired = @cancelled = false
16
+ end
17
+
18
+ def start(&on_expire)
19
+ raise ArgumentError, "runtime budget requires a callback" unless on_expire
20
+
21
+ @mutex.synchronize do
22
+ return false if @deadline || @cancelled
23
+
24
+ @started_at = @wall_clock.call
25
+ @deadline = @clock.call + @seconds
26
+ @on_expire = on_expire
27
+ @thread = Thread.new do
28
+ @mutex.synchronize do
29
+ until @cancelled || @expired
30
+ remaining = @deadline - @clock.call
31
+ break unless remaining.positive?
32
+
33
+ @condition.wait(@mutex, [remaining, 60.0].min)
34
+ end
35
+ end
36
+ check!
37
+ end
38
+ end
39
+ true
40
+ end
41
+
42
+ # Also usable at a dispatch boundary so an expired timer can never admit a
43
+ # new prompt merely because its timer thread has not been scheduled yet.
44
+ def check!
45
+ callback = @mutex.synchronize do
46
+ return false if @expired || @cancelled || !deadline_reached?
47
+
48
+ @expired = true
49
+ @condition.broadcast
50
+ @on_expire
51
+ end
52
+ callback.call
53
+ true
54
+ end
55
+
56
+ def expired?
57
+ @mutex.synchronize { @expired || (!@cancelled && deadline_reached?) }
58
+ end
59
+
60
+ def cancel
61
+ thread = @mutex.synchronize do
62
+ @cancelled = true
63
+ @condition.broadcast
64
+ @thread
65
+ end
66
+ thread&.join(0.2) unless thread == Thread.current
67
+ end
68
+
69
+ def snapshot
70
+ @mutex.synchronize do
71
+ state = if @expired || (!@cancelled && deadline_reached?)
72
+ "expired"
73
+ elsif @cancelled
74
+ "cancelled"
75
+ elsif @deadline
76
+ "running"
77
+ else
78
+ "unarmed"
79
+ end
80
+ {
81
+ limit_s: @seconds,
82
+ state: state,
83
+ started_at: @started_at&.getutc&.iso8601(3),
84
+ deadline_at: @started_at ? (@started_at + @seconds).getutc.iso8601(3) : nil,
85
+ remaining_s: @deadline && state == "running" ? [@deadline - @clock.call, 0.0].max.round(3) : nil
86
+ }
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def deadline_reached?
93
+ !!@deadline && @clock.call >= @deadline
94
+ end
95
+ end
96
+ end