harnex 0.11.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcf0c88485eceb630094f32cd520c12f6c3d5915f2e2b7790191c2ed98a236a5
4
- data.tar.gz: f228c15c98a47d69b5b4c7129373f88d5eb42f6413ac998995294ef3271d99e3
3
+ metadata.gz: 3ea3eab047cc2e9f086fba4cfb98c6f585ffe888fdee0fb018c35515fe6257b4
4
+ data.tar.gz: ba40b96028fd01e6ade7517374e7d26d92a5d147a7be2ec08a581cd0203dae62
5
5
  SHA512:
6
- metadata.gz: e3e6e7d4fe9b88ace781c9c9bc587316fce5e405063e6a575ddf5f46b4b76f6a05f7892e36656b7ef0b9160a5ad6890c1af5fe8e83ecd993c14d229c9a7d1468
7
- data.tar.gz: c043e50074d2100651cbc22585f7d046f5621c51a5ea6d0433d24200f174df98c7fdfd4ea1ec46633cfeae496776c82b08e177abdae7abe547d2244da4f66be0
6
+ metadata.gz: e1e85c57df13dd411b10308659b72376f9491f1c6e4d7c4e5d906f9f78096dc0f82a0ac866c01e356d75cedb5f285b71970d0ea175e5935ca8ed0e3bcf40dd88
7
+ data.tar.gz: 1283d788ceaff2e608f7e13b7f4f640d65d9f683e35cbe649cc9dd7d0223e094740df146fbe10ed6fa15685bf27671e9059c6378bb2bc8acc5c850aef5f6c9ab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-09-03 | 10:09 AM | IST
4
+
5
+ ### Added
6
+
7
+ - Registered sessions now atomically publish a typed completion marker under
8
+ `$HARNEX_STATE_DIR/done/` at the first accepted, rejected, failed, or error
9
+ work result, without requiring a `wait` or `watch` client.
10
+ - `harnex run --on-done CMD` launches one non-blocking trusted local shell hook
11
+ with typed completion, receipt, commit, and elapsed-time environment values.
12
+ The option works in foreground, detached, and tmux launches.
13
+
14
+ ### Changed
15
+
16
+ - `harnex status` now renders settled live work (`done`, `rejected`, or
17
+ `failed`) ahead of an adapter's prompt/busy state. Existing JSON fields and
18
+ the v2 dispatch envelope remain unchanged.
19
+
3
20
  ## [0.11.0] - 2026-08-14 | 01:03 PM | IST
4
21
 
5
22
  ### Fixed
@@ -87,6 +87,25 @@ for i in 1 2 3; do harnex watch --id w-$i --until done --max-wait 90m & done
87
87
  wait
88
88
  ```
89
89
 
90
+ For unattended work, add a session-owned push signal rather than depending on
91
+ one long blocking watcher call:
92
+
93
+ ```bash
94
+ harnex run pi --id pi-i-NN --tmux pi-i-NN \
95
+ --context "Read and execute /tmp/task-NN.md" --auto-stop \
96
+ --on-done 'printf "%s %s\n" "$HARNEX_ID" "$HARNEX_OUTCOME" >> koder/scratch/HARNEX_WAKE.txt'
97
+ ```
98
+
99
+ Every registered session writes
100
+ `$HARNEX_STATE_DIR/done/<repo-key>--<normalized-id>.<outcome>`, even without a
101
+ hook. `--on-done CMD` receives `HARNEX_ID`, `HARNEX_OUTCOME`,
102
+ `HARNEX_WORK_STATE`, `HARNEX_RECEIPT_PATH`, `HARNEX_END_SHA`, and
103
+ `HARNEX_ELAPSED_S`. Use a gitignored wake file. `CMD` is trusted `/bin/sh -c`
104
+ input; never embed secrets in the command line.
105
+
106
+ A push only wakes the consumer. Even `completed` requires report, artifact,
107
+ test, and Git verification plus bounded `harnex watch --until done` calls.
108
+
90
109
  Rule: when you use `--tmux`, pass the same name as `--id`. If you pass only
91
110
  `--tmux NAME`, harnex creates a random session ID and the pane name no longer
92
111
  matches `harnex status` or `harnex pane --id`.
@@ -17,12 +17,30 @@ Prefer signals in this order:
17
17
  | `harnex pane` | Live UI interpretation and prompt/error diagnosis |
18
18
  | `harnex status` | Session liveness and coarse state |
19
19
 
20
- For unattended monitors on existing visible/detached sessions, prefer
21
- `harnex watch --until done`: it returns on the work-level `task_complete` or
20
+ For unattended sessions, combine bounded watcher calls with the runner-owned
21
+ push path. Every session writes
22
+ `$HARNEX_STATE_DIR/done/<repo-key>--<id>.<outcome>` at its first work-terminal
23
+ result. `harnex run --on-done CMD` can also launch one non-blocking local hook,
24
+ even without a watcher and while the agent remains alive at a prompt:
25
+
26
+ ```bash
27
+ harnex run pi --id pi-i-NN --tmux pi-i-NN --context "Read the task brief" \
28
+ --auto-stop \
29
+ --on-done 'printf "%s %s\n" "$HARNEX_ID" "$HARNEX_OUTCOME" >> koder/scratch/HARNEX_WAKE.txt'
30
+ harnex watch --id pi-i-NN --until done --max-wait 30m
31
+ ```
32
+
33
+ Use a gitignored wake destination. `CMD` is trusted shell input; never embed
34
+ secrets. It receives `HARNEX_ID`, `HARNEX_OUTCOME`, `HARNEX_WORK_STATE`,
35
+ `HARNEX_RECEIPT_PATH`, `HARNEX_END_SHA`, and `HARNEX_ELAPSED_S`. `completed`
36
+ wakes the consumer but does not certify the prose; verify the receipt and
37
+ artifact before acting.
38
+
39
+ `harnex watch --until done` returns on the work-level `task_complete` or
22
40
  `task_failed` signal, or terminal exit, whichever comes first. Successful work
23
41
  exits `0`, failed work exits non-zero, and wall-clock caps exit `124`. For
24
42
  callers that need the lower-level primitive, `harnex wait --until done` exposes
25
- the same work fence.
43
+ the same work fence. Do not park an orchestrator in one unbounded watcher call.
26
44
 
27
45
  ## Live-Run Visibility
28
46
 
@@ -31,8 +49,10 @@ Every dispatch appends a `dispatch_start` row to the repo's dispatch stream
31
49
  completes it. Between those two rows the run is visible to every documented
32
50
  signal, from any cwd in the same repo:
33
51
 
34
- - `harnex status --id X` reports `state=running` for a live session. When the
35
- live HTTP status API is unreachable it still reports running from the
52
+ - `harnex status --id X` reports settled work as `done`, `rejected`, or
53
+ `failed` even while the adapter is back at `prompt`. When work is unsettled,
54
+ it reports the adapter input state. If the live HTTP status API is
55
+ unreachable it still reports running from the
36
56
  registry (or, failing that, from the uncompleted start row) and labels the
37
57
  row `degraded: true` with `source` set to `registry` or `dispatch_start`.
38
58
  - `harnex history` shows uncompleted dispatches as `running` (pid alive) or
@@ -208,7 +228,8 @@ interpretation.
208
228
  - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
209
229
  - Polling `state=prompt` alone and calling it done.
210
230
  - Wrapping `harnex wait` in loops that swallow non-zero `task_failed` results.
211
- - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
231
+ - Blocking orchestrators on caller-owned `/tmp/*-done.txt` as the only completion signal.
232
+ - Using one long watcher call without the runner-owned marker or `--on-done` push path.
212
233
  - Letting an unattended loop run with no wall-clock cap.
213
234
  - Reading raw tmux panes instead of `harnex pane`.
214
235
  - Using `--wait-for-idle` as acceptance proof.
@@ -33,7 +33,7 @@ module Harnex
33
33
  --id --description --detach --tmux --host --port --watch --watch-file
34
34
  --stall-after --max-resumes --preset --context --meta
35
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
36
- --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
36
+ --require-artifact-report --require-attribution --auto-stop --on-done --fast --legacy-pty
37
37
  --allow-live-parent --help
38
38
  ].concat(TELEMETRY_FLAGS.keys).freeze
39
39
 
@@ -43,7 +43,7 @@ module Harnex
43
43
  VALUE_FLAGS = %w[
44
44
  --id --description --host --port --watch --watch-file --stall-after
45
45
  --max-resumes --preset --context --meta --artifact-report
46
- --validation-report --cwd --root --timeout --inbox-ttl
46
+ --validation-report --on-done --cwd --root --timeout --inbox-ttl
47
47
  ].concat(TELEMETRY_FLAGS.keys).freeze
48
48
 
49
49
  def self.usage(program_name = "harnex run")
@@ -64,6 +64,7 @@ module Harnex
64
64
  --watch-file PATH Auto-send a file-change hook on modification
65
65
  --context TEXT Inject as the initial prompt (prepends session header)
66
66
  --auto-stop Stop after the first accepted task completion from --context
67
+ --on-done CMD Launch /bin/sh -c CMD once at the first typed work result
67
68
  --fast (codex only) Use Codex service_tier="fast".
68
69
  Default Codex runs force service_tier="flex".
69
70
  --meta JSON Attach parsed JSON metadata to the started event
@@ -124,6 +125,8 @@ module Harnex
124
125
  Bare `--watch` enables the babysitter.
125
126
  --auto-stop requires --context. Structured Codex turns only count as
126
127
  accepted completion after command/tool activity or a Git delta.
128
+ --on-done CMD is trusted local shell input. Do not put secrets in CMD;
129
+ command lines may be visible to local process inspection.
127
130
  Every dispatch gets a harness-authored receipt. Workers may write only
128
131
  optional claims to HARNEX_ARTIFACT_CLAIMS_PATH; claims never accept work.
129
132
  Explicit --stall-after/--max-resumes values override --preset defaults.
@@ -177,6 +180,7 @@ module Harnex
177
180
  cwd: nil,
178
181
  root: nil,
179
182
  auto_stop: false,
183
+ on_done: nil,
180
184
  allow_live_parent: false,
181
185
  detach: false,
182
186
  tmux: false,
@@ -267,6 +271,7 @@ module Harnex
267
271
  tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
268
272
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
269
273
  tmux_cmd << "--auto-stop" if @options[:auto_stop]
274
+ tmux_cmd += ["--on-done", @options[:on_done]] if @options[:on_done]
270
275
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
271
276
  @options[:telemetry].each do |key, value|
272
277
  flag = TELEMETRY_KEYS_TO_FLAGS[key]
@@ -457,6 +462,7 @@ module Harnex
457
462
  require_artifact_report: @options[:require_artifact_report],
458
463
  inbox_ttl: @options[:inbox_ttl],
459
464
  auto_stop: @options[:auto_stop],
465
+ on_done: @options[:on_done],
460
466
  launch_cwd: history_cwd,
461
467
  child_cwd: session_child_cwd
462
468
  )
@@ -624,6 +630,11 @@ module Harnex
624
630
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
625
631
  when "--auto-stop"
626
632
  @options[:auto_stop] = true
633
+ when "--on-done"
634
+ index += 1
635
+ @options[:on_done] = required_option_value(arg, argv[index])
636
+ when /\A--on-done=(.+)\z/
637
+ @options[:on_done] = required_option_value("--on-done", Regexp.last_match(1))
627
638
  when "--allow-live-parent"
628
639
  @options[:allow_live_parent] = true
629
640
  when "--require-attribution"
@@ -732,7 +743,7 @@ module Harnex
732
743
  nil
733
744
  when *VALUE_FLAGS
734
745
  index += 1
735
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
746
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|on-done|cwd|root|timeout|inbox-ttl)=/
736
747
  nil
737
748
  when telemetry_equals_regex
738
749
  nil
@@ -753,7 +764,7 @@ module Harnex
753
764
  arg.start_with?(
754
765
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
755
766
  "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
756
- "--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
767
+ "--artifact-report=", "--validation-report=", "--on-done=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
757
768
  *TELEMETRY_EQUALS_PREFIXES
758
769
  )
759
770
  end
@@ -8,6 +8,7 @@ module Harnex
8
8
  class Status
9
9
  DESCRIPTION_WIDTH = 30
10
10
  REPO_WIDTH = 20
11
+ REJECTED_OUTCOME_CLASSES = Session::PROOF_REJECTION_CLASSES
11
12
 
12
13
  def self.usage(program_name = "harnex status")
13
14
  <<~TEXT
@@ -30,8 +31,8 @@ module Harnex
30
31
  Use --all when supervising workers launched from sibling worktrees.
31
32
  With --id, terminal summaries can report completed/failed/unknown
32
33
  even after the live session registry is gone.
33
- `state` is process/session state; use JSON `done`/`work_state`
34
- or `harnex wait --until done` for work-level completion.
34
+ Settled work outranks prompt state in the table: done, rejected, or failed.
35
+ JSON `done`/`work_state` retains the structured work-level fields.
35
36
  A prompt-like state is not a completion signal by itself.
36
37
  TEXT
37
38
  end
@@ -217,6 +218,13 @@ module Harnex
217
218
  end
218
219
 
219
220
  def table_state(session)
221
+ failed = task_failed?(session) || session["work_state"].to_s == "failed"
222
+ completed = task_complete?(session) || session["work_state"].to_s == "completed"
223
+ if failed || completed
224
+ return "rejected" if rejected_work?(session)
225
+ return failed ? "failed" : "done"
226
+ end
227
+
220
228
  input_state = session.dig("input_state", "state").to_s
221
229
  return input_state unless input_state.empty?
222
230
 
@@ -224,6 +232,12 @@ module Harnex
224
232
  state.empty? ? "-" : state
225
233
  end
226
234
 
235
+ def rejected_work?(session)
236
+ return true if REJECTED_OUTCOME_CLASSES.include?(session["outcome_class"].to_s)
237
+
238
+ !task_failed?(session) && session["artifact_report_status"].to_s == "rejected"
239
+ end
240
+
227
241
  def timeago(timestamp)
228
242
  return "-" if timestamp.to_s.empty?
229
243
 
data/lib/harnex/core.rb CHANGED
@@ -24,6 +24,8 @@ module Harnex
24
24
  GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
25
25
  STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
26
26
  SESSIONS_DIR = File.join(STATE_DIR, "sessions")
27
+ COMPLETION_OUTCOMES = %w[completed rejected failed error].freeze
28
+ COMPLETION_MARKERS_DIR = File.join(STATE_DIR, "done")
27
29
  WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
28
30
  ID_ADJECTIVES = %w[
29
31
  bold blue calm cool dark dry fast gold gray green
@@ -395,6 +397,19 @@ module Harnex
395
397
  File.join(exit_dir, "#{session_file_slug(repo_root, id)}.json")
396
398
  end
397
399
 
400
+ def completion_marker_path(repo_root, id, outcome)
401
+ normalized_outcome = outcome.to_s
402
+ unless COMPLETION_OUTCOMES.include?(normalized_outcome)
403
+ raise ArgumentError, "unsupported completion outcome #{outcome.inspect}"
404
+ end
405
+
406
+ File.join(COMPLETION_MARKERS_DIR, "#{session_file_slug(repo_root, id)}.#{normalized_outcome}")
407
+ end
408
+
409
+ def completion_marker_paths(repo_root, id)
410
+ COMPLETION_OUTCOMES.map { |outcome| completion_marker_path(repo_root, id, outcome) }
411
+ end
412
+
398
413
  def output_log_path(repo_root, id)
399
414
  output_dir = File.join(STATE_DIR, "output")
400
415
  FileUtils.mkdir_p(output_dir)
@@ -0,0 +1,137 @@
1
+ require "time"
2
+
3
+ module Harnex
4
+ class CompletionNotifier
5
+ WORK_STATES = {
6
+ "completed" => "completed", "rejected" => "failed",
7
+ "failed" => "failed", "error" => "failed"
8
+ }.freeze
9
+
10
+ def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil,
11
+ started_at: Time.now, clock: nil, event_sink: nil, spawn: nil,
12
+ detach: nil, atomic_write: nil, remove: nil, stderr: $stderr)
13
+ @repo_root = Harnex.canonical_repo_root(repo_root)
14
+ @id = Harnex.normalize_id(id)
15
+ @session_id = session_id.to_s
16
+ @receipt_path = receipt_path.to_s
17
+ @hook_command = hook_command.to_s
18
+ @hook_command = nil if @hook_command.empty?
19
+ @started_at = started_at
20
+ @clock = clock || -> { Time.now }
21
+ @event_sink = event_sink
22
+ @spawn = spawn || method(:spawn_hook)
23
+ @detach = detach || ->(pid) { Process.detach(pid) }
24
+ @atomic_write = atomic_write || Harnex.method(:atomic_write_json)
25
+ @remove = remove || ->(path) { FileUtils.rm_f(path) }
26
+ @stderr = stderr
27
+ @mutex = Mutex.new
28
+ @registered = false
29
+ @notified = false
30
+ end
31
+
32
+ # Registration is the stale-marker boundary for a reused repo/id pair.
33
+ def register!
34
+ @mutex.synchronize do
35
+ return true if @registered
36
+
37
+ Harnex.completion_marker_paths(@repo_root, @id).each do |path|
38
+ @remove.call(path)
39
+ rescue StandardError => e
40
+ report_error("completion marker cleanup failed", "cleanup", e, path: path)
41
+ end
42
+ @registered = true
43
+ end
44
+ true
45
+ end
46
+
47
+ def notify(outcome:, work_state:, outcome_class:, artifact_report_status:,
48
+ end_sha:, terminal_signal:)
49
+ outcome = outcome.to_s
50
+ work_state = work_state.to_s
51
+ expected = WORK_STATES[outcome]
52
+ raise ArgumentError, "unsupported completion outcome #{outcome.inspect}" unless expected
53
+ raise ArgumentError, "#{outcome.inspect} completion requires work_state #{expected.inspect}" unless work_state == expected
54
+ return false unless @mutex.synchronize do
55
+ @registered && !@notified ? (@notified = true) : false
56
+ end
57
+
58
+ now = @clock.call
59
+ path = Harnex.completion_marker_path(@repo_root, @id, outcome)
60
+ payload = {
61
+ schema_version: 1, id: @id, session_id: @session_id,
62
+ repo_root: @repo_root, outcome: outcome, work_state: work_state,
63
+ outcome_class: optional_string(outcome_class),
64
+ artifact_report_status: optional_string(artifact_report_status),
65
+ 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
68
+ }
69
+ marker_written = write_marker(path, payload)
70
+ hook_launched = launch_hook(payload)
71
+ emit("completion_notification", outcome: outcome, work_state: work_state,
72
+ marker_path: path, marker_written: marker_written,
73
+ hook_configured: !@hook_command.nil?, hook_launched: hook_launched)
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def optional_string(value)
80
+ text = value.to_s
81
+ text.empty? ? nil : text
82
+ end
83
+
84
+ def elapsed_seconds(now)
85
+ [(now - @started_at).to_i, 0].max
86
+ rescue StandardError
87
+ 0
88
+ end
89
+
90
+ def write_marker(path, payload)
91
+ @atomic_write.call(path, payload)
92
+ true
93
+ rescue StandardError => e
94
+ report_error("completion marker write failed", "marker", e, path: path)
95
+ false
96
+ end
97
+
98
+ def launch_hook(payload)
99
+ return false unless @hook_command
100
+
101
+ env = {
102
+ "HARNEX_ID" => @id,
103
+ "HARNEX_OUTCOME" => payload.fetch(:outcome),
104
+ "HARNEX_WORK_STATE" => payload.fetch(:work_state),
105
+ "HARNEX_RECEIPT_PATH" => @receipt_path,
106
+ "HARNEX_END_SHA" => payload[:end_sha].to_s,
107
+ "HARNEX_ELAPSED_S" => payload.fetch(:elapsed_s).to_s
108
+ }
109
+ pid = @spawn.call(env, @hook_command, {
110
+ chdir: @repo_root, in: File::NULL, out: @stderr, err: @stderr
111
+ })
112
+ @detach.call(pid)
113
+ true
114
+ rescue StandardError => e
115
+ report_error("completion hook launch failed", "hook", e)
116
+ false
117
+ end
118
+
119
+ def spawn_hook(env, command, options)
120
+ Process.spawn(env, "/bin/sh", "-c", command, **options)
121
+ end
122
+
123
+ # Do not include exception messages: injected spawn failures can echo CMD.
124
+ def report_error(message, component, error, path: nil)
125
+ @stderr.puts("harnex: #{message} (#{error.class})") rescue nil
126
+ payload = { component: component, error_class: error.class.name }
127
+ payload[:path] = path if path
128
+ emit("completion_notification_error", **payload)
129
+ end
130
+
131
+ def emit(type, **payload)
132
+ @event_sink&.call(type, payload)
133
+ rescue StandardError => e
134
+ @stderr.puts("harnex: completion diagnostic event failed (#{e.class})") rescue nil
135
+ end
136
+ end
137
+ end
@@ -1,5 +1,6 @@
1
1
  require "io/console"
2
2
  require "json"
3
+ require "monitor"
3
4
  require "pty"
4
5
  require "shellwords"
5
6
 
@@ -39,6 +40,9 @@ module Harnex
39
40
  adapter_close real_disconnections stream_interruptions stalls force_resumes compactions recovered
40
41
  ].freeze
41
42
  SUCCESSFUL_TURN_STATUSES = %w[completed success succeeded].freeze
43
+ PROOF_REJECTION_CLASSES = %w[
44
+ completed_no_activity report_missing report_invalid report_rejected
45
+ ].freeze
42
46
  class EventCounters
43
47
  def initialize
44
48
  @counts = {
@@ -91,7 +95,7 @@ module Harnex
91
95
  :output_log_path, :events_log_path, :started_at, :ended_at, :exit_code, :term_signal,
92
96
  :require_artifact_report
93
97
 
94
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, artifact_report_path: nil, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
98
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, artifact_report_path: nil, require_artifact_report: false, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, on_done: nil, launch_cwd: nil, child_cwd: nil, completion_notifier: nil)
95
99
  @adapter = adapter
96
100
  @command = command
97
101
  @repo_root = repo_root
@@ -132,6 +136,20 @@ module Harnex
132
136
  @injected_count = 0
133
137
  @last_injected_at = nil
134
138
  @started_at = Time.now
139
+ @completion_transition_lock = Monitor.new
140
+ @completion_notification_mutex = Mutex.new
141
+ @completion_notification_registered = false
142
+ @completion_notification_sent = false
143
+ @pending_completion_notification = nil
144
+ @completion_notifier = completion_notifier || CompletionNotifier.new(
145
+ repo_root: repo_root,
146
+ id: @id,
147
+ session_id: @session_id,
148
+ receipt_path: @artifact_report_path,
149
+ hook_command: on_done,
150
+ started_at: @started_at,
151
+ event_sink: ->(type, payload) { emit_event(type, **payload) }
152
+ )
135
153
  @server = nil
136
154
  @reader = nil
137
155
  @output_log = nil
@@ -226,6 +244,7 @@ module Harnex
226
244
  @server = ApiServer.new(self)
227
245
  @server.start
228
246
  persist_registry
247
+ register_completion_notifier!
229
248
  append_dispatch_start_record
230
249
 
231
250
  stdin_state = STDIN.tty? ? STDIN.raw! : nil
@@ -506,6 +525,7 @@ module Harnex
506
525
  @server = ApiServer.new(self)
507
526
  @server.start
508
527
  persist_registry
528
+ register_completion_notifier!
509
529
  append_dispatch_start_record
510
530
 
511
531
  watch_thread = start_watch_thread
@@ -658,6 +678,10 @@ module Harnex
658
678
  end
659
679
 
660
680
  def record_successful_completion(payload)
681
+ @completion_transition_lock.synchronize { record_successful_completion_locked(payload) }
682
+ end
683
+
684
+ def record_successful_completion_locked(payload)
661
685
  assessment = completion_gate_required? ? assess_completion_proof : { accepted: true }
662
686
  unless assessment[:accepted]
663
687
  mark_task_failed(
@@ -668,7 +692,6 @@ module Harnex
668
692
  artifact_report_status: assessment[:report_status],
669
693
  diagnostics: assessment[:diagnostics]
670
694
  )
671
- enforce_required_artifact_report!
672
695
  return false
673
696
  end
674
697
 
@@ -685,13 +708,17 @@ module Harnex
685
708
  # Persist proof before publishing task_complete. A coordinator that sees
686
709
  # the completion event can immediately validate the harness-authored file.
687
710
  enforce_required_artifact_report!
688
- return false if task_failed?
711
+ if task_failed?
712
+ publish_completion_notification("task_failed")
713
+ return false
714
+ end
689
715
 
690
716
  event_payload = payload.dup
691
717
  event_payload[:outcome_class] = @completion_outcome_class if @completion_outcome_class
692
718
  event_payload[:artifact_report_status] = @completion_report_status if @completion_report_status
693
719
  event_payload[:artifact_report_path] = artifact_report_path
694
720
  emit_event("task_complete", **event_payload)
721
+ publish_completion_notification("task_complete")
695
722
  true
696
723
  end
697
724
 
@@ -757,7 +784,11 @@ module Harnex
757
784
  end
758
785
  end
759
786
 
760
- def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil, outcome_class: nil, artifact_report_status: nil, diagnostics: nil)
787
+ def mark_task_failed(**attributes)
788
+ @completion_transition_lock.synchronize { mark_task_failed_locked(**attributes) }
789
+ end
790
+
791
+ def mark_task_failed_locked(turn_id: nil, status: nil, error: nil, codex_error_info: nil, outcome_class: nil, artifact_report_status: nil, diagnostics: nil, publish: true)
761
792
  @last_completed_at = nil if outcome_class
762
793
  @last_failed_at = Time.now
763
794
  @last_failed_status = status.to_s.empty? ? "failed" : status.to_s
@@ -774,6 +805,11 @@ module Harnex
774
805
  payload[:artifact_report_status] = artifact_report_status if artifact_report_status
775
806
  payload[:diagnostics] = @completion_diagnostics unless @completion_diagnostics.empty?
776
807
  emit_event("task_failed", **payload)
808
+ if publish
809
+ enforce_required_artifact_report!
810
+ terminal_signal = @last_failed_status == "dispatch_error" ? "dispatch_error" : "task_failed"
811
+ publish_completion_notification(terminal_signal)
812
+ end
777
813
  end
778
814
 
779
815
  def extract_error_notification_message(params)
@@ -1175,6 +1211,71 @@ module Harnex
1175
1211
  Harnex.write_registry(@registry_path, payload)
1176
1212
  end
1177
1213
 
1214
+ def register_completion_notifier!
1215
+ @completion_notification_mutex.synchronize do
1216
+ @completion_notifier.register!
1217
+ @completion_notification_registered = true
1218
+ deliver_completion_notification(@pending_completion_notification) if @pending_completion_notification
1219
+ end
1220
+ rescue StandardError => e
1221
+ warn("harnex: completion marker cleanup failed (#{e.class})")
1222
+ emit_event("completion_notification_error", component: "cleanup", error_class: e.class.name)
1223
+ end
1224
+
1225
+ def publish_completion_notification(terminal_signal)
1226
+ snapshot = completion_notification_snapshot(terminal_signal)
1227
+ @completion_notification_mutex.synchronize do
1228
+ return false if @completion_notification_sent || @pending_completion_notification
1229
+ return !(@pending_completion_notification = snapshot).nil? unless @completion_notification_registered
1230
+
1231
+ deliver_completion_notification(snapshot)
1232
+ end
1233
+ rescue StandardError => e
1234
+ warn("harnex: completion notification failed (#{e.class})")
1235
+ emit_event("completion_notification_error", component: "session", error_class: e.class.name)
1236
+ false
1237
+ end
1238
+
1239
+ def deliver_completion_notification(snapshot)
1240
+ @completion_notification_sent = true
1241
+ @pending_completion_notification = nil
1242
+ @completion_notifier.notify(**snapshot)
1243
+ end
1244
+
1245
+ def completion_notification_snapshot(terminal_signal)
1246
+ outcome = completion_notification_outcome(terminal_signal)
1247
+ {
1248
+ outcome: outcome, work_state: outcome == "completed" ? "completed" : "failed",
1249
+ outcome_class: @completion_outcome_class, artifact_report_status: @completion_report_status,
1250
+ end_sha: summary_string(@git_end[:sha]), terminal_signal: terminal_signal
1251
+ }
1252
+ end
1253
+
1254
+ def completion_notification_outcome(terminal_signal)
1255
+ signal = terminal_signal.to_s
1256
+ return "error" if %w[dispatch_error finalization].include?(signal)
1257
+ return "rejected" if PROOF_REJECTION_CLASSES.include?(@completion_outcome_class.to_s)
1258
+ return "rejected" if signal == "task_complete" && observed_receipt_outcome_status == "rejected"
1259
+ return "completed" if signal == "task_complete"
1260
+ return "failed" if signal == "task_failed"
1261
+
1262
+ "error"
1263
+ end
1264
+
1265
+ def observed_receipt_outcome_status
1266
+ JSON.parse(File.read(artifact_report_path)).dig("outcome", "status")
1267
+ rescue StandardError
1268
+ nil
1269
+ end
1270
+
1271
+ def current_terminal_signal
1272
+ return "dispatch_error" if task_failed? && @last_failed_status == "dispatch_error"
1273
+ return "task_failed" if task_failed?
1274
+ return "task_complete" if task_complete?
1275
+
1276
+ nil
1277
+ end
1278
+
1178
1279
  # Post-injection registry refresh. By the time this runs the prompt has
1179
1280
  # already reached the agent, so a failed bookkeeping write must not report
1180
1281
  # the send as failed — that would make an orchestrator retry a turn the
@@ -1442,7 +1543,11 @@ module Harnex
1442
1543
  @context_summary = normalized_context_summary(nil)
1443
1544
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1444
1545
  end
1445
- enforce_required_artifact_report!
1546
+ @completion_transition_lock.synchronize do
1547
+ terminal_signal = current_terminal_signal
1548
+ enforce_required_artifact_report!
1549
+ publish_completion_notification(terminal_signal || "finalization")
1550
+ end
1446
1551
  @exit_reason ||= classify_exit
1447
1552
  record = DispatchHistory.build_record(self)
1448
1553
  append_dispatch_history_record(record)
@@ -1560,7 +1665,8 @@ module Harnex
1560
1665
  "code" => "receipt_write_error",
1561
1666
  "path" => "$",
1562
1667
  "message" => "harness could not write the observed-state receipt"
1563
- }]
1668
+ }],
1669
+ publish: false
1564
1670
  )
1565
1671
  @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1566
1672
  @term_signal = nil if @exit_code == 1
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.11.0"
3
- RELEASE_DATE = "2026-08-14"
2
+ VERSION = "0.12.0"
3
+ RELEASE_DATE = "2026-09-03"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "harnex/runtime/session_state"
19
19
  require_relative "harnex/runtime/message"
20
20
  require_relative "harnex/runtime/inbox"
21
21
  require_relative "harnex/runtime/file_change_hook"
22
+ require_relative "harnex/runtime/completion_notifier"
22
23
  require_relative "harnex/runtime/api_server"
23
24
  require_relative "harnex/runtime/session"
24
25
  require_relative "harnex/commands/watch"
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.11.0
4
+ version: 0.12.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-08-14 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
14
  and adds a control plane for discovery, messaging, and coordination.
@@ -74,6 +74,7 @@ files:
74
74
  - lib/harnex/pricing.rb
75
75
  - lib/harnex/retention.rb
76
76
  - lib/harnex/runtime/api_server.rb
77
+ - lib/harnex/runtime/completion_notifier.rb
77
78
  - lib/harnex/runtime/file_change_hook.rb
78
79
  - lib/harnex/runtime/inbox.rb
79
80
  - lib/harnex/runtime/message.rb