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 +4 -4
- data/CHANGELOG.md +17 -0
- data/guides/01_dispatch.md +19 -0
- data/guides/04_monitoring.md +27 -6
- data/lib/harnex/commands/run.rb +15 -4
- data/lib/harnex/commands/status.rb +16 -2
- data/lib/harnex/core.rb +15 -0
- data/lib/harnex/runtime/completion_notifier.rb +137 -0
- data/lib/harnex/runtime/session.rb +112 -6
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ea3eab047cc2e9f086fba4cfb98c6f585ffe888fdee0fb018c35515fe6257b4
|
|
4
|
+
data.tar.gz: ba40b96028fd01e6ade7517374e7d26d92a5d147a7be2ec08a581cd0203dae62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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`.
|
data/guides/04_monitoring.md
CHANGED
|
@@ -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
|
|
21
|
-
|
|
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
|
|
35
|
-
|
|
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.
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/harnex/version.rb
CHANGED
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.
|
|
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-
|
|
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
|