harnex 0.7.6 → 0.7.8

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: f29114f723ccfc61ade344c784cb6a11144c25e79392bf136f5b722473b47f0b
4
- data.tar.gz: 810686487788509887bb8bfb5f9a4c45a8a1b8bca645530b1bf178f1435cee40
3
+ metadata.gz: 64a1ddf83ef070cc2b418c1a70ae3e1fc2c8b5fd671e8fa3e5a30536184728ca
4
+ data.tar.gz: 6a076faed04db3eddbaf4bf2fefbee95426cd8ee8008499e3474fbfa1b5c62ed
5
5
  SHA512:
6
- metadata.gz: 90c68832ab9218716fd2e6181f006a12b996a23c28b120a769411618aee35e507c38df43bcf9293a98fb3902923e70eb1be9e08a90795e9ec970cb5b51ca2b54
7
- data.tar.gz: 64ebab38e3cf716bfe69fd7f8d1b28c0d114ef72e81f8028cb87f0b83f6a75cb555e1036a7831a41b3dddd156c0b9f71e51fa9550271f5be529dcea1fd23c9e1
6
+ metadata.gz: 3508afcbddc0e9afaf17372ca98cb3146d5edc128fb54c6217f8b80be7f81baae0ab7f50feba4b513a9ba46165323f8fb95ddd243df5bd19921b198beb89bbc6
7
+ data.tar.gz: 25419186825ac14d2f6cd844497d6dc356e88785f23784f30492284b80c384781ab834011f394d27c93078821fc394fed3ac97b8bff818f7b60fc280be83a560
data/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.8] - 2026-06-13 | 08:45 PM | IST
6
+
7
+ ### Added
8
+
9
+ - `harnex watch --id <id> --until done` now provides a native work-terminal
10
+ watcher for existing visible/detached sessions. It exits `0` for successful
11
+ work, non-zero for `task_failed` / failed terminal telemetry, `124` for
12
+ wall-clock timeout, and can write optional done/fail marker files for legacy
13
+ queue integrations.
14
+
15
+ ### Changed
16
+
17
+ - Monitoring docs now recommend native `harnex watch` for unattended
18
+ single-dispatch monitoring and reserve `harnex run --watch` for foreground
19
+ launch-and-stall-recovery.
20
+
21
+ ## [0.7.7] - 2026-06-12 | 10:48 AM | IST
22
+
23
+ ### Fixed
24
+
25
+ - Codex app-server failed turns now emit `task_failed` instead of being
26
+ misreported as successful `task_complete` work. `harnex wait --until done`
27
+ returns non-zero for failed-turn events, dispatch history records
28
+ `terminal_event=task_failed`, and auto-stop terminates structured sessions
29
+ without sending a stale `turn/interrupt` after the turn is already complete.
30
+ - Codex app-server nested error notifications now preserve the real Codex error
31
+ message (for example missing provider credentials) without counting them as
32
+ transport disconnects.
33
+
34
+ ### Changed
35
+
36
+ - Refreshed the pinned Codex app-server JSON Schema fixtures to
37
+ `codex-cli 0.139.0` and taught the test schema validator `minLength`.
38
+
5
39
  ## [0.7.6] - 2026-06-09 | 12:59 AM | IST
6
40
 
7
41
  ### Added
data/README.md CHANGED
@@ -68,8 +68,9 @@ job, watch it work, stop it when done.
68
68
  `harnex events` streams structured JSONL lifecycle events.
69
69
 
70
70
  - **You don't want to babysit.** Use `--context --auto-stop` for
71
- one-shot work, `--watch` for bounded stall recovery, or
72
- `--wait-for-idle` as a send fence.
71
+ one-shot work, `harnex watch` for existing visible/detached dispatches,
72
+ `run --watch` for bounded foreground stall recovery, or `--wait-for-idle`
73
+ as a send fence.
73
74
 
74
75
  - **You want local-only orchestration.** Everything runs on your
75
76
  machine. No cloud services, no API keys beyond what the agents need.
@@ -150,18 +151,27 @@ harnex agents-guide monitoring
150
151
 
151
152
  ## Built-in dispatch monitoring
152
153
 
153
- For unattended dispatches, use `--watch` instead of writing a bash poll loop:
154
+ For unattended visible/background dispatches, use `harnex watch` instead of
155
+ writing a bash poll loop around `harnex wait`:
154
156
 
155
157
  ```bash
156
- harnex run pi --id pi-impl-42 --watch --preset impl \
158
+ harnex run pi --id pi-impl-42 --tmux pi-impl-42 \
157
159
  --context "Implement koder/plans/42_plan.md. Run tests and commit when done."
160
+ harnex watch --id pi-impl-42 --until done --max-wait 90m \
161
+ --done-marker /tmp/pi-impl-42-done.json \
162
+ --fail-marker /tmp/pi-impl-42-failed.json
158
163
  ```
159
164
 
160
- `--watch` runs a foreground babysitter that checks session activity every 60s,
161
- force-resumes on stall up to a cap, and exits when the target session exits or
162
- the resume cap is reached. It is foreground-only; use `--tmux` or `--detach`
163
- for visible/background sessions, and `--watch` when the current command should
164
- block as the monitor.
165
+ `harnex watch --until done` is the safe work-terminal watcher for existing
166
+ `--tmux` or detached sessions. It exits `0` for `task_complete`/done, non-zero
167
+ for `task_failed` or failed terminal summaries, and `124` for `--max-wait`
168
+ timeouts. It does not keep pane/status polling after a terminal failure signal.
169
+
170
+ `harnex run --watch` is a separate foreground babysitter that checks session
171
+ activity every 60s, force-resumes on stall up to a cap, and exits when the
172
+ target session exits or the resume cap is reached. It is foreground-only; use
173
+ `--tmux` or `--detach` for visible/background sessions, and `run --watch` when
174
+ the current command should launch and monitor one worker.
165
175
 
166
176
  Presets map to stall policy defaults:
167
177
 
@@ -180,17 +190,22 @@ and stops the session after the first task completion or PTY prompt return.
180
190
 
181
191
  ## Completion and waiting
182
192
 
183
- Choose the wait predicate that matches how you launched the worker:
193
+ Choose the wait/watch predicate that matches how you launched the worker:
184
194
 
185
- - `harnex wait --id ID --until done --timeout SECS` is the safest unattended
186
- work fence. It returns when Harnex sees `task_complete` or a terminal exit,
187
- whichever comes first.
195
+ - `harnex watch --id ID --until done --max-wait DUR` is the safest unattended
196
+ monitor for an existing visible or detached dispatch. It wraps the work-level
197
+ fence, preserves the timeout/failure distinction, and can write done/fail
198
+ marker files for legacy queue integrations.
199
+ - `harnex wait --id ID --until done --timeout SECS` is the primitive work fence.
200
+ It returns when Harnex sees `task_complete`, `task_failed`, or a terminal
201
+ exit, whichever comes first; failed work returns non-zero.
188
202
  - `harnex wait --id ID` waits for the wrapped process to exit. This is right
189
203
  for already-exited sessions and terminal-summary recovery, but interactive
190
204
  agents can stay open after finishing a turn.
191
205
  - For structured Pi RPC and Codex app-server sessions, use
192
206
  `harnex wait --id ID --until task_complete --timeout SECS` when you need the
193
- exact turn-completion event instead of terminal-exit fallback.
207
+ exact successful-turn event instead of terminal-exit fallback. Use
208
+ `--until task_failed` to wait specifically for a failed structured turn.
194
209
  - `harnex send --wait-for-idle` is an atomic send fence for PTY-style
195
210
  interactions. It proves the turn returned to an idle/prompt state, not that
196
211
  your acceptance criteria passed.
@@ -312,6 +327,7 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
312
327
  | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
313
328
  | `harnex stop --id <id>` | Send the agent's native exit sequence |
314
329
  | `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
330
+ | `harnex watch --id <id>` | Safely monitor existing visible/detached work until `done`, `task_failed`, or timeout; optional done/fail markers |
315
331
  | `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
316
332
  | `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
317
333
  | `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
@@ -73,7 +73,7 @@ for i in 1 2 3; do
73
73
  harnex run pi --id w-$i --tmux w-$i --detach \
74
74
  --context "Read and execute /tmp/task-$i.md" --auto-stop &
75
75
  done
76
- for i in 1 2 3; do harnex wait --id w-$i --until done & done
76
+ for i in 1 2 3; do harnex watch --id w-$i --until done --max-wait 90m & done
77
77
  wait
78
78
  ```
79
79
 
@@ -125,18 +125,24 @@ Use the lightest primitive that gives the signal you need:
125
125
  | Continuous pane view | `harnex pane --id pi-i-NN --follow` |
126
126
  | Transcript tail | `harnex logs --id pi-i-NN --lines 80` |
127
127
  | Structured events | `harnex events --id pi-i-NN --snapshot` |
128
- | Work completion fence | `harnex wait --id pi-i-NN --until done` |
129
- | Native turn completion | `harnex wait --id pi-i-NN --until task_complete` |
128
+ | Existing-session work monitor | `harnex watch --id pi-i-NN --until done --max-wait 90m` |
129
+ | Primitive work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
130
+ | Native successful-turn completion | `harnex wait --id pi-i-NN --until task_complete` |
130
131
 
131
- For unattended policy-only stall recovery, use built-in watch mode:
132
+ For visible `--tmux` or detached dispatches, prefer `harnex watch --id`: it
133
+ returns `0` on done, non-zero on `task_failed`/failed terminal summaries, and
134
+ `124` on `--max-wait` timeout. Use `--done-marker` / `--fail-marker` only as
135
+ compatibility outputs for older queue scripts.
136
+
137
+ For foreground launch-and-stall-recovery, use `harnex run --watch`:
132
138
 
133
139
  ```bash
134
140
  harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
135
141
  ```
136
142
 
137
- `--watch` is foreground-blocking. Use it when a single process should launch
138
- and monitor the worker. Use pane/log/event polling or a buddy when you need
139
- interpretation, multiple sessions, or a separate watcher.
143
+ `run --watch` is foreground-blocking. Use it when a single process should
144
+ launch and monitor the worker. Use pane/log/event polling or a buddy when you
145
+ need interpretation across multiple sessions.
140
146
 
141
147
  ## Verify And Stop
142
148
 
data/guides/03_buddy.md CHANGED
@@ -4,7 +4,14 @@ A buddy is a second harnex session that watches one or more workers and nudges
4
4
  them if they stall. Use a buddy when the work is long-running, unattended, or
5
5
  needs interpretation that simple stall policy cannot provide.
6
6
 
7
- For simple inactivity recovery, prefer built-in watch mode:
7
+ For simple work-terminal monitoring of an existing visible/detached session,
8
+ prefer the native watcher:
9
+
10
+ ```bash
11
+ harnex watch --id pi-i-NN --until done --max-wait 90m
12
+ ```
13
+
14
+ For foreground launch-and-inactivity recovery, use built-in run watch mode:
8
15
 
9
16
  ```bash
10
17
  harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
@@ -17,11 +17,15 @@ 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, prefer `harnex wait --until done`: it returns on the
21
- work-level `task_complete` signal or terminal exit, whichever comes first. For
22
- structured sessions (Pi RPC and Codex app-server), `harnex wait --until
23
- task_complete` remains the exact turn-level fence. Neither knows your acceptance
24
- criteria; verify the expected artifact or tests afterward.
20
+ For unattended monitors on existing visible/detached sessions, prefer
21
+ `harnex watch --until done`: it returns on the work-level `task_complete` or
22
+ `task_failed` signal, or terminal exit, whichever comes first. Successful work
23
+ exits `0`, failed work exits non-zero, and wall-clock caps exit `124`. For
24
+ callers that need the lower-level primitive, `harnex wait --until done` exposes
25
+ the same work fence. For structured sessions (Pi RPC and Codex app-server),
26
+ `harnex wait --until task_complete` remains the exact successful-turn fence.
27
+ None of these know your acceptance criteria; verify the expected artifact or
28
+ tests afterward.
25
29
 
26
30
  ## Completion Test
27
31
 
@@ -29,14 +33,19 @@ For unattended work, first gate on harnex work completion, then verify the task
29
33
  artifact and repo health:
30
34
 
31
35
  ```bash
32
- harnex wait --id pi-i-NN --until done --timeout 5400 &&
36
+ harnex watch --id pi-i-NN --until done --max-wait 90m \
37
+ --done-marker /tmp/pi-i-NN-done.json \
38
+ --fail-marker /tmp/pi-i-NN-failed.json &&
33
39
  test -f path/to/expected-artifact &&
34
40
  test -z "$(git status --short)"
35
41
  ```
36
42
 
37
- `harnex wait --until done` succeeds from `task_complete` or durable terminal
38
- telemetry (`--summary-out` / `.harnex/dispatch.jsonl` / exit status), not from
39
- tmp done markers.
43
+ `harnex watch --until done` wraps the `harnex wait --until done` work fence:
44
+ it succeeds from `task_complete` or durable successful terminal telemetry
45
+ (`--summary-out` / `.harnex/dispatch.jsonl` / exit status), returns non-zero for
46
+ `task_failed` / failed terminal telemetry, returns `124` for `--max-wait`, and
47
+ only writes done/fail markers as compatibility outputs after harnex has seen a
48
+ terminal work signal.
40
49
 
41
50
  Adjust the artifact path to the task. The point is to avoid declaring done while
42
51
  a worker is between edits or between commits.
@@ -74,42 +83,29 @@ harnex events --id pi-i-NN
74
83
  For task completion:
75
84
 
76
85
  ```bash
86
+ harnex watch --id pi-i-NN --until done --max-wait 15m
87
+ # Primitive equivalent when a script wants raw wait semantics:
77
88
  harnex wait --id pi-i-NN --until done --timeout 900
78
- # Or, when you specifically need the structured turn event:
89
+ # Or, when you specifically need the structured successful-turn event:
79
90
  harnex wait --id pi-i-NN --until task_complete --timeout 900
80
91
  ```
81
92
 
82
93
  ## Background Sweeper
83
94
 
84
- Consumers often run a small shell loop that checks terminal state, then drops
85
- to pane diagnostics only while work is still running. Keep a hard wall-clock cap
86
- so an unattended pipeline cannot wait forever:
95
+ Avoid custom shell loops that repeatedly call `harnex wait`/`harnex status` and
96
+ then accidentally swallow a failed work result. For a single unattended
97
+ visible/detached dispatch, use the native watcher with a hard wall-clock cap:
87
98
 
88
99
  ```bash
89
- start=$(date +%s)
90
- max_wait=5400
91
-
92
- while :; do
93
- if test "$(($(date +%s) - start))" -gt "$max_wait"; then
94
- echo "wall-clock cap hit for pi-i-NN" >&2
95
- exit 2
96
- fi
97
-
98
- row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
99
- done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
100
- work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
101
- state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
102
-
103
- case "$done:$work_state" in
104
- true:*) echo "pi-i-NN work completed"; break ;;
105
- false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
106
- *) harnex pane --id pi-i-NN --lines 20 ;;
107
- esac
108
-
109
- sleep 60
110
- done
100
+ harnex watch --id pi-i-NN --until done --max-wait 90m \
101
+ --done-marker /tmp/pi-i-NN-done.json \
102
+ --fail-marker /tmp/pi-i-NN-failed.json
111
103
  ```
112
104
 
105
+ If that exits `124`, inspect the pane/logs/events and decide whether to nudge,
106
+ stop, or continue. If it exits any other non-zero code, treat the work as
107
+ failed; do not continue polling the same task as though it were still running.
108
+
113
109
  Recommended caps:
114
110
 
115
111
  | Work type | Cap |
@@ -118,17 +114,18 @@ Recommended caps:
118
114
  | Medium implementation | 90 minutes |
119
115
  | Large unattended phase | 3 hours |
120
116
 
121
- ## Built-In Watch Mode
117
+ ## Built-In Stall Babysitter
122
118
 
123
119
  Use `harnex run --watch` when one foreground process should launch the worker
124
- and apply bounded stall recovery:
120
+ and apply bounded stall recovery. This is different from `harnex watch --id`,
121
+ which watches an existing session's work-terminal state:
125
122
 
126
123
  ```bash
127
124
  harnex run pi --id pi-i-NN --watch --preset impl \
128
125
  --context "Read /tmp/task-impl-NN.md"
129
126
  ```
130
127
 
131
- `--watch` exits with:
128
+ `run --watch` exits with:
132
129
 
133
130
  | Code | Meaning |
134
131
  | --- | --- |
@@ -143,6 +140,7 @@ interpretation.
143
140
 
144
141
  - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
145
142
  - Polling `state=prompt` alone and calling it done.
143
+ - Wrapping `harnex wait` in loops that swallow non-zero `task_failed` results.
146
144
  - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
147
145
  - Letting an unattended loop run with no wall-clock cap.
148
146
  - Reading raw tmux panes instead of `harnex pane`.
@@ -339,7 +339,7 @@ module Harnex
339
339
  @current_turn_id = nil
340
340
  @state = :prompt
341
341
  when "error"
342
- @state = :disconnected
342
+ @state = :busy
343
343
  end
344
344
 
345
345
  @notification_handler&.call(message)
data/lib/harnex/cli.rb CHANGED
@@ -15,6 +15,8 @@ module Harnex
15
15
  Sender.new(@argv.drop(1)).run
16
16
  when "wait"
17
17
  Waiter.new(@argv.drop(1)).run
18
+ when "watch"
19
+ WatchCommand.new(@argv.drop(1)).run
18
20
  when "stop"
19
21
  Stopper.new(@argv.drop(1)).run
20
22
  when "status"
@@ -59,6 +61,8 @@ module Harnex
59
61
  Sender.usage
60
62
  when "wait"
61
63
  Waiter.usage
64
+ when "watch"
65
+ WatchCommand.usage
62
66
  when "stop"
63
67
  Stopper.usage
64
68
  when "status"
@@ -90,6 +94,7 @@ module Harnex
90
94
  harnex run <cli> [options] [--] [cli-args...]
91
95
  harnex send --id ID [options] [text...]
92
96
  harnex wait --id ID [options]
97
+ harnex watch --id ID [options]
93
98
  harnex stop --id ID [options]
94
99
  harnex status [options]
95
100
  harnex logs --id ID [options]
@@ -104,6 +109,7 @@ module Harnex
104
109
  run Start a wrapped interactive session and local API
105
110
  send Send text to an active session
106
111
  wait Block until a session exits or reaches a state
112
+ watch Safely watch existing work until done/task_failed/timeout
107
113
  stop Send the adapter stop sequence to a session
108
114
  status List live sessions
109
115
  logs Read session output transcripts
@@ -129,6 +135,7 @@ module Harnex
129
135
  harnex run aider --id blue-cat
130
136
  harnex run codex -- --cd /path/to/repo
131
137
  harnex status
138
+ harnex watch --id main --until done --max-wait 15m
132
139
  harnex logs --id main --follow
133
140
  harnex events --id main --snapshot
134
141
  harnex history --limit 20
@@ -265,8 +265,7 @@ module Harnex
265
265
 
266
266
  if message["error"]
267
267
  err_msg = message.dig("error", "message") || "RPC error"
268
- pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
269
- signal_disconnect(message["error"])
268
+ pending.push(StandardError.new(err_msg))
270
269
  else
271
270
  pending.push(message["result"] || {})
272
271
  end
@@ -102,14 +102,17 @@ module Harnex
102
102
  end
103
103
 
104
104
  def normalize_live_status(session)
105
- task_complete = task_complete?(session)
105
+ task_failed = task_failed?(session)
106
+ task_complete = task_complete?(session) && !task_failed
107
+ work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
106
108
  session.merge(
107
109
  "state" => "running",
108
110
  "process_state" => "running",
109
111
  "terminal" => false,
110
112
  "task_complete" => task_complete,
113
+ "task_failed" => task_failed,
111
114
  "done" => Harnex.work_done_for("running", task_complete: task_complete),
112
- "work_state" => Harnex.work_state_for("running", task_complete: task_complete),
115
+ "work_state" => work_state,
113
116
  "exit" => nil,
114
117
  "exit_code" => nil,
115
118
  "summary_out" => nil,
@@ -123,6 +126,11 @@ module Harnex
123
126
  !session["last_completed_at"].to_s.empty?
124
127
  end
125
128
 
129
+ def task_failed?(session)
130
+ session["task_failed"] == true || session["task_failed"].to_s == "true" ||
131
+ !session["last_failed_at"].to_s.empty?
132
+ end
133
+
126
134
  def load_live_status(session)
127
135
  uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
128
136
  request = Net::HTTP::Get.new(uri)
@@ -11,8 +11,8 @@ module Harnex
11
11
  EXIT_STATUS_GRACE_POLL_INTERVAL = 0.05
12
12
  FINAL_EVENT_GRACE_SECONDS = 5.0
13
13
 
14
- EVENT_PREDICATES = %w[task_complete].freeze
15
- LEGACY_EVENT_TYPES = %w[agent_state exited task_complete].freeze
14
+ EVENT_PREDICATES = %w[task_complete task_failed].freeze
15
+ LEGACY_EVENT_TYPES = %w[agent_state exited task_complete task_failed].freeze
16
16
 
17
17
  def self.usage(program_name = "harnex wait")
18
18
  <<~TEXT
@@ -21,10 +21,13 @@ module Harnex
21
21
  Options:
22
22
  --id ID Session ID to wait for (required)
23
23
  --until STATE Wait until session reaches STATE. Supported:
24
- done (work fence — task_complete or
25
- terminal exit, whichever comes first)
24
+ done (work fence — task_complete,
25
+ task_failed, or terminal exit,
26
+ whichever comes first)
26
27
  task_complete (events JSONL — fires on
27
- turn/completed; adapter-agnostic)
28
+ successful turn completion)
29
+ task_failed (events JSONL — fires on
30
+ failed turn completion)
28
31
  <other> (agent_state HTTP poll, e.g.
29
32
  "prompt", "busy")
30
33
  Without --until, waits for session exit (default).
@@ -40,7 +43,7 @@ module Harnex
40
43
 
41
44
  Gotchas:
42
45
  done is the safest work-level fence for monitors.
43
- task_complete is an event predicate; prompt/busy are live state polls.
46
+ task_complete/task_failed are event predicates; prompt/busy are live state polls.
44
47
  Prompt state alone does not prove work acceptance. Verify artifacts/tests.
45
48
  Exit waits can resolve from terminal summary rows when live registry/
46
49
  exit-status files are already gone.
@@ -140,7 +143,7 @@ module Harnex
140
143
  event = parse_event(line)
141
144
  next unless event
142
145
 
143
- task_complete_seen = true if event_type(event) == "task_complete"
146
+ task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
144
147
  if matches?(event, predicate, task_complete_seen)
145
148
  return [emit_event_match(event, start_time, predicate), f.pos, task_complete_seen]
146
149
  end
@@ -173,8 +176,12 @@ module Harnex
173
176
  def matches?(event, predicate, task_complete_seen)
174
177
  type = event_type(event)
175
178
  case predicate
176
- when "task_complete", "done"
179
+ when "task_complete"
177
180
  type == "task_complete"
181
+ when "task_failed"
182
+ type == "task_failed"
183
+ when "done"
184
+ %w[task_complete task_failed].include?(type)
178
185
  when "prompt"
179
186
  type == "task_complete" ||
180
187
  (task_complete_seen && type == "agent_state" && event["state"] == "prompt")
@@ -183,6 +190,13 @@ module Harnex
183
190
  end
184
191
  end
185
192
 
193
+ def done_event_failed?(event)
194
+ return true if event_type(event) == "task_failed"
195
+
196
+ status = event["status"].to_s
197
+ !status.empty? && !%w[completed success succeeded].include?(status)
198
+ end
199
+
186
200
  def emit_event_match(event, start_time, predicate)
187
201
  waited = (Time.now - start_time).round(1)
188
202
  payload = {
@@ -193,17 +207,22 @@ module Harnex
193
207
  waited_seconds: waited
194
208
  }
195
209
  if predicate == "done"
210
+ failed = done_event_failed?(event)
196
211
  payload.merge!(
197
- status: "done",
212
+ ok: !failed,
213
+ status: failed ? "failed" : "done",
198
214
  state: "running",
199
215
  process_state: "running",
200
216
  terminal: false,
201
- task_complete: true,
202
- done: true,
203
- work_state: "completed"
217
+ task_complete: !failed,
218
+ done: !failed,
219
+ work_state: failed ? "failed" : "completed"
204
220
  )
221
+ payload[:last_error] = event["message"] || event["error"] if failed
205
222
  end
206
223
  puts JSON.generate(payload)
224
+ return 1 if predicate == "done" && done_event_failed?(event)
225
+
207
226
  0
208
227
  end
209
228
 
@@ -431,7 +450,8 @@ module Harnex
431
450
  data = JSON.parse(File.read(exit_path))
432
451
  exit_code = data["exit_code"]
433
452
  task_complete = data["task_complete"] == true || data["task_complete"].to_s == "true"
434
- exit_success = exit_code.nil? || exit_code.to_i == 0
453
+ task_failed = data["task_failed"] == true || data["task_failed"].to_s == "true"
454
+ exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
435
455
  state = exit_success ? "completed" : "failed"
436
456
  done = task_complete || exit_success
437
457
  payload = data.merge(
@@ -441,6 +461,7 @@ module Harnex
441
461
  "process_state" => "exited",
442
462
  "terminal" => true,
443
463
  "task_complete" => task_complete,
464
+ "task_failed" => task_failed,
444
465
  "done" => done,
445
466
  "work_state" => Harnex.work_state_for(state, task_complete: task_complete)
446
467
  )
@@ -486,6 +507,7 @@ module Harnex
486
507
 
487
508
  def terminal_payload(status)
488
509
  task_complete = !!status["task_complete"]
510
+ task_failed = !!status["task_failed"]
489
511
  work_state = status["work_state"] || Harnex.work_state_for(status["state"], task_complete: task_complete)
490
512
  done = status.key?("done") ? !!status["done"] : work_state == "completed"
491
513
  {
@@ -495,6 +517,7 @@ module Harnex
495
517
  process_state: status["process_state"] || Harnex.process_state_for(status["state"], terminal: true),
496
518
  terminal: status.key?("terminal") ? !!status["terminal"] : true,
497
519
  task_complete: task_complete,
520
+ task_failed: task_failed,
498
521
  done: done,
499
522
  work_state: work_state,
500
523
  exit: status["exit"],
@@ -1,5 +1,8 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "net/http"
4
+ require "optparse"
5
+ require "stringio"
3
6
  require "uri"
4
7
 
5
8
  module Harnex
@@ -206,4 +209,205 @@ module Harnex
206
209
  @monotonic_clock.call
207
210
  end
208
211
  end
212
+
213
+ class TerminalWatcher
214
+ TIMEOUT_EXIT_CODE = 124
215
+
216
+ def initialize(
217
+ id:,
218
+ repo_path: Dir.pwd,
219
+ until_state: "done",
220
+ max_wait: nil,
221
+ done_marker: nil,
222
+ fail_marker: nil,
223
+ stop_on_terminal: false,
224
+ out: $stdout,
225
+ err: $stderr
226
+ )
227
+ @id = Harnex.normalize_id(id)
228
+ @repo_path = repo_path
229
+ @until_state = until_state.to_s.strip.empty? ? "done" : until_state.to_s
230
+ @max_wait = max_wait
231
+ @done_marker = done_marker
232
+ @fail_marker = fail_marker
233
+ @stop_on_terminal = stop_on_terminal
234
+ @out = out
235
+ @err = err
236
+ end
237
+
238
+ def run
239
+ raise "harnex watch: only --until done is supported" unless @until_state == "done"
240
+
241
+ output, warnings, exit_code = capture_wait
242
+ @err.write(warnings) unless warnings.empty?
243
+ @out.write(output) unless output.empty?
244
+
245
+ payload = parse_payload(output)
246
+ outcome = classify(exit_code, payload)
247
+ case outcome
248
+ when :success
249
+ write_marker(@done_marker, payload, outcome: outcome, exit_code: exit_code)
250
+ when :failed
251
+ write_marker(@fail_marker, payload, outcome: outcome, exit_code: exit_code)
252
+ end
253
+
254
+ stop_session if @stop_on_terminal && outcome != :timeout
255
+ exit_code
256
+ end
257
+
258
+ private
259
+
260
+ def capture_wait
261
+ argv = ["--id", @id, "--repo", @repo_path, "--until", @until_state]
262
+ argv += ["--timeout", @max_wait.to_s] if @max_wait
263
+
264
+ out_buffer = StringIO.new
265
+ err_buffer = StringIO.new
266
+ original_stdout = $stdout
267
+ original_stderr = $stderr
268
+ $stdout = out_buffer
269
+ $stderr = err_buffer
270
+ exit_code = Waiter.new(argv).run
271
+ [out_buffer.string, err_buffer.string, exit_code]
272
+ ensure
273
+ $stdout = original_stdout
274
+ $stderr = original_stderr
275
+ end
276
+
277
+ def parse_payload(output)
278
+ line = output.to_s.lines.reverse.find { |candidate| !candidate.strip.empty? }
279
+ return {} unless line
280
+
281
+ parsed = JSON.parse(line)
282
+ parsed.is_a?(Hash) ? parsed : {}
283
+ rescue JSON::ParserError
284
+ {}
285
+ end
286
+
287
+ def classify(exit_code, payload)
288
+ return :timeout if exit_code == TIMEOUT_EXIT_CODE || payload["status"].to_s == "timeout"
289
+ return :success if exit_code.to_i.zero? && (payload.empty? || payload["ok"] != false)
290
+
291
+ :failed
292
+ end
293
+
294
+ def write_marker(path, payload, outcome:, exit_code:)
295
+ marker_path = path.to_s.strip
296
+ return if marker_path.empty?
297
+
298
+ expanded_path = File.expand_path(marker_path)
299
+ FileUtils.mkdir_p(File.dirname(expanded_path))
300
+ marker_payload = {
301
+ ok: outcome == :success,
302
+ id: @id,
303
+ outcome: outcome.to_s,
304
+ exit_code: exit_code,
305
+ status: payload["status"],
306
+ work_state: payload["work_state"],
307
+ task_complete: payload["task_complete"] || payload["event"] == "task_complete",
308
+ task_failed: payload["task_failed"] || payload["event"] == "task_failed",
309
+ done: payload["done"],
310
+ terminal: payload["terminal"],
311
+ source: "harnex watch"
312
+ }.compact
313
+ File.write(expanded_path, JSON.generate(marker_payload) + "\n")
314
+ end
315
+
316
+ def stop_session
317
+ repo_root = Harnex.resolve_repo_root(@repo_path)
318
+ registry = Harnex.read_registry(repo_root, @id)
319
+ return unless registry
320
+
321
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/stop")
322
+ request = Net::HTTP::Post.new(uri)
323
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
324
+
325
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 2) do |http|
326
+ http.request(request)
327
+ end
328
+ @err.puts("harnex watch: stop-on-terminal failed with HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess)
329
+ rescue StandardError => e
330
+ @err.puts("harnex watch: stop-on-terminal failed: #{e.message}")
331
+ end
332
+ end
333
+
334
+ class WatchCommand
335
+ def self.usage(program_name = "harnex watch")
336
+ <<~TEXT
337
+ Usage: #{program_name} --id ID [options]
338
+
339
+ Options:
340
+ --id ID Existing session ID to watch (required)
341
+ --until done Watch work-level terminal state (default: done)
342
+ --repo PATH Resolve session using PATH's repo root (default: current repo)
343
+ --max-wait DUR Wall-clock cap before returning timeout (examples: 900, 15m, 2h)
344
+ --timeout DUR Alias for --max-wait
345
+ --done-marker PATH Write a JSON marker when work completes successfully
346
+ --fail-marker PATH Write a JSON marker when work fails
347
+ --stop-on-terminal Stop the live session after success/failure (not on timeout)
348
+ -h, --help Show this help
349
+
350
+ `harnex watch` is the safe watcher for existing --tmux or detached
351
+ dispatches. It exits 0 for task_complete/done, non-zero for task_failed
352
+ or failed terminal summaries, and 124 for --max-wait timeouts.
353
+
354
+ For launch-and-babysit stall recovery, use `harnex run --watch`.
355
+ TEXT
356
+ end
357
+
358
+ def initialize(argv)
359
+ @argv = argv.dup
360
+ @options = {
361
+ id: nil,
362
+ repo_path: Dir.pwd,
363
+ until_state: "done",
364
+ max_wait: nil,
365
+ done_marker: nil,
366
+ fail_marker: nil,
367
+ stop_on_terminal: false,
368
+ help: false
369
+ }
370
+ end
371
+
372
+ def run
373
+ parser.parse!(@argv)
374
+ if @options[:help]
375
+ puts self.class.usage
376
+ return 0
377
+ end
378
+
379
+ raise "--id is required for harnex watch" unless @options[:id]
380
+
381
+ TerminalWatcher.new(
382
+ id: @options[:id],
383
+ repo_path: @options[:repo_path],
384
+ until_state: @options[:until_state],
385
+ max_wait: @options[:max_wait],
386
+ done_marker: @options[:done_marker],
387
+ fail_marker: @options[:fail_marker],
388
+ stop_on_terminal: @options[:stop_on_terminal]
389
+ ).run
390
+ end
391
+
392
+ private
393
+
394
+ def parser
395
+ @parser ||= OptionParser.new do |opts|
396
+ opts.banner = "Usage: harnex watch --id ID [options]"
397
+ opts.on("--id ID", "Existing session ID to watch") { |value| @options[:id] = Harnex.normalize_id(value) }
398
+ opts.on("--until STATE", "Watch until terminal state") { |value| @options[:until_state] = value }
399
+ opts.on("--repo PATH", "Resolve session using PATH's repo root") { |value| @options[:repo_path] = value }
400
+ opts.on("--max-wait DUR", "Wall-clock cap") do |value|
401
+ @options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--max-wait")
402
+ end
403
+ opts.on("--timeout DUR", "Alias for --max-wait") do |value|
404
+ @options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--timeout")
405
+ end
406
+ opts.on("--done-marker PATH", "Write marker on successful completion") { |value| @options[:done_marker] = value }
407
+ opts.on("--fail-marker PATH", "Write marker on failed completion") { |value| @options[:fail_marker] = value }
408
+ opts.on("--stop-on-terminal", "Stop live session after success/failure") { @options[:stop_on_terminal] = true }
409
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
410
+ end
411
+ end
412
+ end
209
413
  end
@@ -85,6 +85,7 @@ module Harnex
85
85
  end
86
86
 
87
87
  def classify(session)
88
+ return ["failed", "task_failed"] if session.respond_to?(:task_failed?) && session.task_failed?
88
89
  return ["completed", "task_complete"] if session.task_complete?
89
90
  return ["timeout", "timeout"] if session.exit_code == 124
90
91
  return ["killed", "process_kill"] if session.term_signal
@@ -16,6 +16,7 @@ module Harnex
16
16
  agent_session_id cost_usd
17
17
  ].freeze
18
18
  BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
19
+ SUCCESSFUL_TURN_STATUSES = %w[completed success succeeded].freeze
19
20
  class EventCounters
20
21
  def initialize
21
22
  @counts = {
@@ -103,6 +104,8 @@ module Harnex
103
104
  @session_finalized = false
104
105
  @turn_started_seen = false
105
106
  @last_completed_at = nil
107
+ @last_failed_at = nil
108
+ @last_failed_status = nil
106
109
  @pi_streamed_text_by_message = {}
107
110
  @auto_stop = !!auto_stop
108
111
  @auto_stop_fired = false
@@ -221,14 +224,19 @@ module Harnex
221
224
  end
222
225
 
223
226
  payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
224
- task_complete = !!@last_completed_at
227
+ task_complete = task_complete?
228
+ task_failed = task_failed?
229
+ work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
225
230
  payload[:agent_state] = @state_machine.to_s
226
231
  payload[:process_state] = "running"
227
232
  payload[:inbox] = @inbox.stats
228
233
  payload[:last_completed_at] = @last_completed_at&.iso8601
234
+ payload[:last_failed_at] = @last_failed_at&.iso8601
229
235
  payload[:task_complete] = task_complete
236
+ payload[:task_failed] = task_failed
230
237
  payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
231
- payload[:work_state] = Harnex.work_state_for("running", task_complete: task_complete)
238
+ payload[:work_state] = work_state
239
+ payload[:last_error] = @last_error
232
240
  payload[:model] = summary_model
233
241
  payload[:effort] = meta_hash["effort"]
234
242
  payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
@@ -236,7 +244,11 @@ module Harnex
236
244
  end
237
245
 
238
246
  def task_complete?
239
- !!@last_completed_at
247
+ !!@last_completed_at && !task_failed?
248
+ end
249
+
250
+ def task_failed?
251
+ !!@last_failed_at
240
252
  end
241
253
 
242
254
  def git_start
@@ -257,7 +269,7 @@ module Harnex
257
269
  inject_sequence([{ text: text, newline: newline }])
258
270
  end
259
271
 
260
- def inject_stop(turn_id: nil)
272
+ def inject_stop(turn_id: nil, interrupt: true)
261
273
  unless structured_transport?
262
274
  raise "session is not running" unless pid && Harnex.alive_pid?(pid)
263
275
  end
@@ -274,15 +286,21 @@ module Harnex
274
286
  end
275
287
  end
276
288
  end
277
- @inject_mutex.synchronize do
278
- begin
279
- adapter.interrupt(turn_id: turn_id)
280
- rescue StandardError
281
- nil
289
+ if interrupt
290
+ @inject_mutex.synchronize do
291
+ begin
292
+ adapter.interrupt(turn_id: turn_id)
293
+ rescue StandardError
294
+ nil
295
+ end
296
+ @state_machine.force_busy!
282
297
  end
283
- @state_machine.force_busy!
298
+ return { ok: true, signal: "interrupt_sent" }
284
299
  end
285
- return { ok: true, signal: "interrupt_sent" }
300
+
301
+ @state_machine.force_busy!
302
+ signal_rpc_done! unless @pid
303
+ return { ok: true, signal: "terminate_sent" }
286
304
  end
287
305
 
288
306
  @inject_mutex.synchronize do
@@ -336,7 +354,12 @@ module Harnex
336
354
 
337
355
  turn_id = nil
338
356
  @inject_mutex.synchronize do
339
- turn_id = adapter.dispatch(**dispatch)
357
+ begin
358
+ turn_id = adapter.dispatch(**dispatch)
359
+ rescue StandardError => e
360
+ mark_task_failed(status: "dispatch_error", error: e.message)
361
+ raise
362
+ end
340
363
  @state_machine.force_busy!
341
364
  @injected_count += 1
342
365
  @last_injected_at = Time.now
@@ -460,14 +483,25 @@ module Harnex
460
483
  @state_machine.force_busy!
461
484
  emit_event("turn_started", turnId: params.dig("turn", "id"))
462
485
  when "turn/completed"
463
- @last_completed_at = Time.now
464
486
  @state_machine.force_prompt!
465
487
  turn = params["turn"] || {}
466
- payload = { turnId: turn["id"] }
467
- payload[:status] = turn["status"] if turn["status"]
468
- payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
469
- emit_event("task_complete", **payload)
470
- schedule_auto_stop("task_complete", turn_id: payload[:turnId])
488
+ status = turn["status"]
489
+ turn_id = turn["id"] || params["turnId"]
490
+ payload = { turnId: turn_id }
491
+ payload[:status] = status if status
492
+ payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
493
+ if successful_turn_status?(status)
494
+ @last_completed_at = Time.now
495
+ emit_event("task_complete", **payload)
496
+ else
497
+ mark_task_failed(
498
+ turn_id: turn_id,
499
+ status: status,
500
+ error: extract_turn_error_message(turn),
501
+ codex_error_info: extract_turn_error_info(turn)
502
+ )
503
+ end
504
+ schedule_auto_stop("turn_completed", interrupt: false)
471
505
  when "item/completed"
472
506
  emit_event("item_completed", item: params["item"])
473
507
  @event_counters.record_item(params["item"])
@@ -487,15 +521,70 @@ module Harnex
487
521
  when "account/rateLimits/updated"
488
522
  @rate_limits = params
489
523
  when "error"
490
- @last_error = params["message"].to_s unless params["message"].to_s.empty?
524
+ message = extract_error_notification_message(params)
525
+ @last_error = message unless message.to_s.empty?
491
526
  @state_machine.force_busy!
492
- emit_event("disconnected", source: "error_notification", message: params["message"])
493
- signal_rpc_done!
527
+ emit_event(
528
+ "error",
529
+ source: "error_notification",
530
+ message: message,
531
+ codex_error_info: extract_error_notification_info(params),
532
+ will_retry: params["willRetry"],
533
+ threadId: params["threadId"],
534
+ turnId: params["turnId"]
535
+ )
536
+ signal_rpc_done! if params["turnId"].to_s.empty?
494
537
  end
495
538
  rescue StandardError => e
496
539
  warn("harnex: rpc notification handler error: #{e.message}")
497
540
  end
498
541
 
542
+ def successful_turn_status?(status)
543
+ text = status.to_s
544
+ return true if text.empty?
545
+
546
+ SUCCESSFUL_TURN_STATUSES.include?(text)
547
+ end
548
+
549
+ def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil)
550
+ @last_failed_at = Time.now
551
+ @last_failed_status = status.to_s.empty? ? "failed" : status.to_s
552
+ @last_error = error.to_s unless error.to_s.empty?
553
+
554
+ payload = { status: @last_failed_status }
555
+ payload[:turnId] = turn_id if turn_id
556
+ payload[:message] = error unless error.to_s.empty?
557
+ payload[:codex_error_info] = codex_error_info if codex_error_info
558
+ emit_event("task_failed", **payload)
559
+ end
560
+
561
+ def extract_error_notification_message(params)
562
+ error = params["error"]
563
+ if error.is_a?(Hash)
564
+ error["message"] || error.dig("error", "message") || params["message"]
565
+ else
566
+ params["message"]
567
+ end
568
+ end
569
+
570
+ def extract_error_notification_info(params)
571
+ error = params["error"]
572
+ error.is_a?(Hash) ? error["codexErrorInfo"] : nil
573
+ end
574
+
575
+ def extract_turn_error_message(turn)
576
+ error = turn["error"]
577
+ return error["message"] if error.is_a?(Hash)
578
+ return error if error.is_a?(String)
579
+
580
+ nil
581
+ end
582
+
583
+ def extract_turn_error_info(turn)
584
+ error = turn["error"]
585
+ error.is_a?(Hash) ? error["codexErrorInfo"] : nil
586
+ end
587
+
499
588
  def handle_jsonl_notification(message)
500
589
  event_type = message["type"].to_s
501
590
 
@@ -509,7 +598,7 @@ module Harnex
509
598
  @state_machine.force_prompt!
510
599
  emit_event("task_complete")
511
600
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
512
- schedule_auto_stop("task_complete")
601
+ schedule_auto_stop("task_complete", interrupt: false)
513
602
  when "message_start"
514
603
  @pi_streamed_text_by_message[pi_message_key(message["message"])] = false
515
604
  when "message_update"
@@ -578,12 +667,21 @@ module Harnex
578
667
 
579
668
  def handle_rpc_disconnect(error)
580
669
  msg = error.is_a?(Hash) ? error["message"] : error&.message
670
+ if normal_auto_stop_disconnect?(msg)
671
+ signal_rpc_done!
672
+ return
673
+ end
674
+
581
675
  @last_error = msg.to_s unless msg.to_s.empty?
582
676
  @state_machine.force_busy!
583
677
  emit_event("disconnected", source: "transport", message: msg) rescue nil
584
678
  signal_rpc_done!
585
679
  end
586
680
 
681
+ def normal_auto_stop_disconnect?(message)
682
+ message.to_s.empty? && @auto_stop_fired && (task_complete? || task_failed?)
683
+ end
684
+
587
685
  def dispatch_initial_prompt
588
686
  return unless adapter.respond_to?(:initial_prompt)
589
687
 
@@ -738,10 +836,11 @@ module Harnex
738
836
  return unless defined?(@exit_code) && !@exit_code.nil?
739
837
 
740
838
  exit_path = Harnex.exit_status_path(repo_root, id)
741
- task_complete = !!@last_completed_at
742
- state = @exit_code.to_i == 0 ? "completed" : "failed"
839
+ task_complete = task_complete?
840
+ task_failed = task_failed?
841
+ state = task_failed || @exit_code.to_i != 0 ? "failed" : "completed"
743
842
  payload = {
744
- ok: true,
843
+ ok: !task_failed && state == "completed",
745
844
  id: id,
746
845
  cli: adapter.key,
747
846
  session_id: session_id,
@@ -750,6 +849,7 @@ module Harnex
750
849
  state: state,
751
850
  process_state: "exited",
752
851
  task_complete: task_complete,
852
+ task_failed: task_failed,
753
853
  done: Harnex.work_done_for(state, task_complete: task_complete),
754
854
  work_state: Harnex.work_state_for(state, task_complete: task_complete),
755
855
  started_at: @started_at.iso8601,
@@ -955,7 +1055,7 @@ module Harnex
955
1055
  schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
956
1056
  end
957
1057
 
958
- def schedule_auto_stop(reason, turn_id: nil)
1058
+ def schedule_auto_stop(reason, turn_id: nil, interrupt: true)
959
1059
  return unless @auto_stop
960
1060
 
961
1061
  should_fire = @auto_stop_mutex.synchronize do
@@ -970,7 +1070,7 @@ module Harnex
970
1070
 
971
1071
  thread = Thread.new do
972
1072
  begin
973
- inject_stop(turn_id: turn_id)
1073
+ inject_stop(turn_id: turn_id, interrupt: interrupt)
974
1074
  rescue StandardError => e
975
1075
  warn("harnex: auto-stop failed after #{reason}: #{e.message}")
976
1076
  end
@@ -1016,17 +1116,26 @@ module Harnex
1016
1116
 
1017
1117
  def normalize_auto_stop_exit_code!
1018
1118
  return unless @auto_stop
1019
- return unless @last_completed_at
1020
1119
  return unless @auto_stop_fired
1021
1120
 
1121
+ if task_failed?
1122
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1123
+ @term_signal = nil if @exit_code == 1
1124
+ return
1125
+ end
1126
+
1127
+ return unless task_complete?
1128
+
1022
1129
  @exit_code = 0
1023
1130
  @term_signal = nil
1024
1131
  end
1025
1132
 
1026
1133
  def classify_exit
1027
1134
  return "timeout" if @exit_code == 124
1028
- return "success" if @exit_code == 0 && session_summary_present?
1029
1135
  return "boot_failure" if boot_failure_exit?
1136
+ return "failure" if task_failed?
1137
+ return "success" if @exit_code == 0 && task_complete?
1138
+ return "success" if @exit_code == 0 && session_summary_present?
1030
1139
  return "failure" unless @exit_code == 0
1031
1140
 
1032
1141
  "disconnected"
@@ -1110,7 +1219,7 @@ module Harnex
1110
1219
  files_changed: @git_end[:files_changed],
1111
1220
  commits: @git_end[:commits],
1112
1221
  exit: @exit_reason,
1113
- task_complete: !!@last_completed_at,
1222
+ task_complete: task_complete?,
1114
1223
  signal: @term_signal,
1115
1224
  exit_code: @exit_code,
1116
1225
  last_error: @last_error,
@@ -1256,6 +1365,7 @@ module Harnex
1256
1365
  @event_counters.record(type)
1257
1366
  @events_mutex.synchronize do
1258
1367
  return unless @events_log
1368
+ return if @events_log.closed?
1259
1369
 
1260
1370
  @events_log_seq += 1
1261
1371
  event = {
@@ -45,6 +45,7 @@ module Harnex
45
45
  "process_state" => "unknown",
46
46
  "terminal" => false,
47
47
  "task_complete" => false,
48
+ "task_failed" => false,
48
49
  "done" => false,
49
50
  "work_state" => "unknown",
50
51
  "exit" => nil,
@@ -131,6 +132,7 @@ module Harnex
131
132
  actual = record["actual"] || {}
132
133
  state = classify_summary_state(actual)
133
134
  task_complete = !!actual["task_complete"]
135
+ task_failed = state == "failed" && !task_complete
134
136
  terminal = state != "unknown"
135
137
  {
136
138
  "id" => meta["id"].to_s,
@@ -139,6 +141,7 @@ module Harnex
139
141
  "process_state" => Harnex.process_state_for(state, terminal: terminal),
140
142
  "terminal" => terminal,
141
143
  "task_complete" => task_complete,
144
+ "task_failed" => task_failed,
142
145
  "done" => Harnex.work_done_for(state, task_complete: task_complete),
143
146
  "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
144
147
  "exit" => blank_to_nil(actual["exit"]),
@@ -173,6 +176,7 @@ module Harnex
173
176
  "unknown"
174
177
  end
175
178
  task_complete = record["terminal_event"].to_s == "task_complete"
179
+ task_failed = record["terminal_event"].to_s == "task_failed" || (state == "failed" && !task_complete)
176
180
  terminal = state != "unknown"
177
181
  {
178
182
  "id" => record["id"].to_s,
@@ -181,6 +185,7 @@ module Harnex
181
185
  "process_state" => Harnex.process_state_for(state, terminal: terminal),
182
186
  "terminal" => terminal,
183
187
  "task_complete" => task_complete,
188
+ "task_failed" => task_failed,
184
189
  "done" => Harnex.work_done_for(state, task_complete: task_complete),
185
190
  "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
186
191
  "exit" => history_exit(status),
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.6"
3
- RELEASE_DATE = "2026-06-09"
2
+ VERSION = "0.7.8"
3
+ RELEASE_DATE = "2026-06-13"
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.7.6
4
+ version: 0.7.8
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-06-08 00:00:00.000000000 Z
11
+ date: 2026-06-13 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.