harnex 0.7.5 → 0.7.7
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 +36 -0
- data/README.md +49 -13
- data/guides/01_dispatch.md +3 -2
- data/guides/03_buddy.md +2 -2
- data/guides/04_monitoring.md +20 -12
- data/lib/harnex/adapters/codex_appserver.rb +1 -1
- data/lib/harnex/codex/app_server/client.rb +1 -2
- data/lib/harnex/commands/status.rb +20 -1
- data/lib/harnex/commands/wait.rb +200 -24
- data/lib/harnex/core.rb +30 -0
- data/lib/harnex/dispatch_history.rb +1 -0
- data/lib/harnex/runtime/session.rb +148 -26
- data/lib/harnex/terminal_status.rb +22 -4
- data/lib/harnex/version.rb +2 -2
- data/recipes/01_fire_and_watch.md +5 -3
- data/recipes/03_buddy.md +3 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09276571f581a98eb6c613e88e4035ed8420bcae710c43d57e307cc5eb4d468b'
|
|
4
|
+
data.tar.gz: 886e1555b2ee4d128d992e4d35aafc716c0bc2aa16318123b053e5e128a7337a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 90f7f457f829f99424c1f2635706c3231c08bf1cea7329e47b6992fde3aa45e123c40c0d278bf2c9a5c8bc2ecd6d0f742a812320c659c5bbe68fd4ab94df6c9d
|
|
7
|
+
data.tar.gz: 65323fa96b6f5c92564b2be10cfe802f846fff1b76079f26cbaa362588dce50ae86a59c250b456058f3acad8ca31f03c9c49b2ad096c85a6aae5510797ff3abb
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.7] - 2026-06-12 | 10:48 AM | IST
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Codex app-server failed turns now emit `task_failed` instead of being
|
|
10
|
+
misreported as successful `task_complete` work. `harnex wait --until done`
|
|
11
|
+
returns non-zero for failed-turn events, dispatch history records
|
|
12
|
+
`terminal_event=task_failed`, and auto-stop terminates structured sessions
|
|
13
|
+
without sending a stale `turn/interrupt` after the turn is already complete.
|
|
14
|
+
- Codex app-server nested error notifications now preserve the real Codex error
|
|
15
|
+
message (for example missing provider credentials) without counting them as
|
|
16
|
+
transport disconnects.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Refreshed the pinned Codex app-server JSON Schema fixtures to
|
|
21
|
+
`codex-cli 0.139.0` and taught the test schema validator `minLength`.
|
|
22
|
+
|
|
23
|
+
## [0.7.6] - 2026-06-09 | 12:59 AM | IST
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `harnex status --json` now exposes work-level completion fields (`done`,
|
|
28
|
+
`work_state`, and `process_state`) so monitors can distinguish completed
|
|
29
|
+
work from a still-live interactive process.
|
|
30
|
+
- `harnex wait --until done` waits for `task_complete` or terminal exit,
|
|
31
|
+
whichever arrives first, giving queue monitors a safe default fence.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Refreshed README quick-start and monitoring guidance to emphasize
|
|
36
|
+
`--context --auto-stop`, `--until task_complete` for interactive structured
|
|
37
|
+
sessions, durable terminal summaries, and timeout/artifact verification.
|
|
38
|
+
- Monitoring, buddy, and recipe examples now gate unattended work on
|
|
39
|
+
`--until done` / `done` / `work_state` instead of `state=completed` alone.
|
|
40
|
+
|
|
5
41
|
## [0.7.5] - 2026-05-26 | 05:18 PM | IST
|
|
6
42
|
|
|
7
43
|
### Added
|
data/README.md
CHANGED
|
@@ -33,16 +33,24 @@ or project-local docs are required.
|
|
|
33
33
|
## What it does
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
# Start
|
|
37
|
-
harnex run pi --id planner --tmux planner
|
|
36
|
+
# Start a visible one-shot worker, give it a task, and stop on completion
|
|
37
|
+
harnex run pi --id planner --tmux planner \
|
|
38
|
+
--context "Write a plan to /tmp/plan.md" --auto-stop
|
|
38
39
|
|
|
39
|
-
#
|
|
40
|
-
harnex
|
|
40
|
+
# Wait for the work-level completion signal and terminal telemetry
|
|
41
|
+
harnex wait --id planner --until done --timeout 900
|
|
41
42
|
|
|
42
|
-
#
|
|
43
|
-
harnex
|
|
43
|
+
# Inspect the final state or the recent dispatch history
|
|
44
|
+
harnex status --id planner --json
|
|
45
|
+
harnex history --limit 5
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For a long-lived interactive session, keep the worker open and send work to it:
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
```bash
|
|
51
|
+
harnex run pi --id planner --tmux planner
|
|
52
|
+
harnex send --id planner --message "Write a plan to /tmp/plan.md" --wait-for-idle
|
|
53
|
+
harnex pane --id planner --lines 30
|
|
46
54
|
harnex stop --id planner
|
|
47
55
|
```
|
|
48
56
|
|
|
@@ -59,8 +67,9 @@ job, watch it work, stop it when done.
|
|
|
59
67
|
tmux-backed terminal, `harnex logs` tails the persisted transcript, and
|
|
60
68
|
`harnex events` streams structured JSONL lifecycle events.
|
|
61
69
|
|
|
62
|
-
- **You don't want to babysit.**
|
|
63
|
-
|
|
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.
|
|
64
73
|
|
|
65
74
|
- **You want local-only orchestration.** Everything runs on your
|
|
66
75
|
machine. No cloud services, no API keys beyond what the agents need.
|
|
@@ -169,6 +178,31 @@ bare `--watch` means babysitter mode.
|
|
|
169
178
|
For one-shot startup prompts, add `--auto-stop`. It requires `--context`
|
|
170
179
|
and stops the session after the first task completion or PTY prompt return.
|
|
171
180
|
|
|
181
|
+
## Completion and waiting
|
|
182
|
+
|
|
183
|
+
Choose the wait predicate that matches how you launched the worker:
|
|
184
|
+
|
|
185
|
+
- `harnex wait --id ID --until done --timeout SECS` is the safest unattended
|
|
186
|
+
work fence. It returns when Harnex sees `task_complete`, `task_failed`, or a
|
|
187
|
+
terminal exit, whichever comes first; failed work returns non-zero.
|
|
188
|
+
- `harnex wait --id ID` waits for the wrapped process to exit. This is right
|
|
189
|
+
for already-exited sessions and terminal-summary recovery, but interactive
|
|
190
|
+
agents can stay open after finishing a turn.
|
|
191
|
+
- For structured Pi RPC and Codex app-server sessions, use
|
|
192
|
+
`harnex wait --id ID --until task_complete --timeout SECS` when you need the
|
|
193
|
+
exact successful-turn event instead of terminal-exit fallback. Use
|
|
194
|
+
`--until task_failed` to wait specifically for a failed structured turn.
|
|
195
|
+
- `harnex send --wait-for-idle` is an atomic send fence for PTY-style
|
|
196
|
+
interactions. It proves the turn returned to an idle/prompt state, not that
|
|
197
|
+
your acceptance criteria passed.
|
|
198
|
+
- `harnex status --id ID --json` can report `running`, `completed`, `failed`,
|
|
199
|
+
or `unknown` from durable terminal summary rows even after the live registry
|
|
200
|
+
is gone. Treat `state` as process/session state; use `done` and `work_state`
|
|
201
|
+
as the work-level monitor contract.
|
|
202
|
+
|
|
203
|
+
Always set a timeout for unattended waits and verify the expected artifact,
|
|
204
|
+
tests, or git state after harnex reports completion.
|
|
205
|
+
|
|
172
206
|
For structured subscriptions, stream JSONL events:
|
|
173
207
|
|
|
174
208
|
```bash
|
|
@@ -183,7 +217,9 @@ Schema details and compatibility policy are documented in
|
|
|
183
217
|
Every finished `harnex run` writes dispatch records. In a git repo, the
|
|
184
218
|
default path is `<repo>/.harnex/dispatch.jsonl`; outside a git repo, the
|
|
185
219
|
compact history record falls back to `~/.local/state/harnex/dispatch.jsonl`.
|
|
186
|
-
`harnex history` reads the compact records from that location
|
|
220
|
+
`harnex history` reads the compact records from that location, and
|
|
221
|
+
`harnex status --id ID --json` / `harnex wait` can use the same durable
|
|
222
|
+
terminal summaries when the live session registry is already gone.
|
|
187
223
|
|
|
188
224
|
Use `harnex history` to inspect it:
|
|
189
225
|
|
|
@@ -274,14 +310,14 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
|
|
|
274
310
|
| Command | What it does |
|
|
275
311
|
|---------|-------------|
|
|
276
312
|
| `harnex run <cli>` | Start an agent (`--tmux` visible, `--detach` background, `--watch` built-in monitoring) |
|
|
277
|
-
| `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until
|
|
313
|
+
| `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
|
|
278
314
|
| `harnex stop --id <id>` | Send the agent's native exit sequence |
|
|
279
|
-
| `harnex status` | List running sessions
|
|
315
|
+
| `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
|
|
280
316
|
| `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
|
|
281
317
|
| `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
|
|
282
318
|
| `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
|
|
283
319
|
| `harnex history` | List completed dispatches from `.harnex/dispatch.jsonl` |
|
|
284
|
-
| `harnex wait --id <id>` | Block until exit
|
|
320
|
+
| `harnex wait --id <id>` | Block until process exit by default; use `--until done` for unattended work completion or `--until task_complete` for exact structured turn completion |
|
|
285
321
|
| `harnex doctor` | Run adapter dependency preflight checks; add `--sweep` for read-only session drift diagnostics |
|
|
286
322
|
| `harnex guide` | Getting started walkthrough |
|
|
287
323
|
| `harnex agents-guide` | Agent-facing dispatch, chain, buddy, monitoring, and naming guides |
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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 & done
|
|
76
|
+
for i in 1 2 3; do harnex wait --id w-$i --until done & done
|
|
77
77
|
wait
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -125,7 +125,8 @@ 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
|
-
|
|
|
128
|
+
| Work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
|
|
129
|
+
| Native successful-turn completion | `harnex wait --id pi-i-NN --until task_complete` |
|
|
129
130
|
|
|
130
131
|
For unattended policy-only stall recovery, use built-in watch mode:
|
|
131
132
|
|
data/guides/03_buddy.md
CHANGED
|
@@ -54,8 +54,8 @@ If the worker appears stuck at a prompt or permission dialog for more than
|
|
|
54
54
|
10 minutes with no progress, nudge it:
|
|
55
55
|
- `harnex send --id pi-i-42 --message "You appear to have stalled. Continue with your current task."`
|
|
56
56
|
|
|
57
|
-
If `harnex status --id pi-i-42 --json` reports `
|
|
58
|
-
- `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42
|
|
57
|
+
If `harnex status --id pi-i-42 --json` reports `done=true` or `work_state=failed`, report back:
|
|
58
|
+
- `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 work-level state reached. Check harnex status --id pi-i-42 --json." Enter`
|
|
59
59
|
|
|
60
60
|
Do not interfere with active work. Stop yourself after reporting completion.
|
|
61
61
|
```
|
data/guides/04_monitoring.md
CHANGED
|
@@ -17,24 +17,28 @@ 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
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
For unattended monitors, prefer `harnex wait --until done`: it returns on the
|
|
21
|
+
work-level `task_complete` or `task_failed` signal, or terminal exit, whichever
|
|
22
|
+
comes first. Failed work returns non-zero. For structured sessions (Pi RPC and
|
|
23
|
+
Codex app-server), `harnex wait --until task_complete` remains the exact
|
|
24
|
+
successful-turn fence. Neither knows your acceptance criteria; verify the
|
|
25
|
+
expected artifact or tests afterward.
|
|
24
26
|
|
|
25
27
|
## Completion Test
|
|
26
28
|
|
|
27
|
-
For unattended work, first gate on harnex
|
|
29
|
+
For unattended work, first gate on harnex work completion, then verify the task
|
|
28
30
|
artifact and repo health:
|
|
29
31
|
|
|
30
32
|
```bash
|
|
31
|
-
harnex wait --id pi-i-NN --timeout 5400 &&
|
|
33
|
+
harnex wait --id pi-i-NN --until done --timeout 5400 &&
|
|
32
34
|
test -f path/to/expected-artifact &&
|
|
33
35
|
test -z "$(git status --short)"
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
`harnex wait` succeeds from
|
|
37
|
-
`.harnex/dispatch.jsonl` / exit status),
|
|
38
|
+
`harnex wait --until done` succeeds from `task_complete` or durable successful
|
|
39
|
+
terminal telemetry (`--summary-out` / `.harnex/dispatch.jsonl` / exit status),
|
|
40
|
+
returns non-zero for `task_failed` / failed terminal telemetry, and does not use
|
|
41
|
+
tmp done markers.
|
|
38
42
|
|
|
39
43
|
Adjust the artifact path to the task. The point is to avoid declaring done while
|
|
40
44
|
a worker is between edits or between commits.
|
|
@@ -72,6 +76,8 @@ harnex events --id pi-i-NN
|
|
|
72
76
|
For task completion:
|
|
73
77
|
|
|
74
78
|
```bash
|
|
79
|
+
harnex wait --id pi-i-NN --until done --timeout 900
|
|
80
|
+
# Or, when you specifically need the structured turn event:
|
|
75
81
|
harnex wait --id pi-i-NN --until task_complete --timeout 900
|
|
76
82
|
```
|
|
77
83
|
|
|
@@ -92,12 +98,13 @@ while :; do
|
|
|
92
98
|
fi
|
|
93
99
|
|
|
94
100
|
row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
|
|
101
|
+
done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
|
|
102
|
+
work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
|
|
95
103
|
state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
|
|
96
104
|
|
|
97
|
-
case "$
|
|
98
|
-
|
|
99
|
-
failed
|
|
100
|
-
running) harnex pane --id pi-i-NN --lines 20 ;;
|
|
105
|
+
case "$done:$work_state" in
|
|
106
|
+
true:*) echo "pi-i-NN work completed"; break ;;
|
|
107
|
+
false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
|
|
101
108
|
*) harnex pane --id pi-i-NN --lines 20 ;;
|
|
102
109
|
esac
|
|
103
110
|
|
|
@@ -136,6 +143,7 @@ interpretation.
|
|
|
136
143
|
|
|
137
144
|
## Anti-Patterns
|
|
138
145
|
|
|
146
|
+
- Polling `state=completed` alone and missing live sessions with `task_complete=true`.
|
|
139
147
|
- Polling `state=prompt` alone and calling it done.
|
|
140
148
|
- Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
|
|
141
149
|
- Letting an unattended loop run with no wall-clock cap.
|
|
@@ -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(
|
|
269
|
-
signal_disconnect(message["error"])
|
|
268
|
+
pending.push(StandardError.new(err_msg))
|
|
270
269
|
else
|
|
271
270
|
pending.push(message["result"] || {})
|
|
272
271
|
end
|
|
@@ -30,6 +30,8 @@ module Harnex
|
|
|
30
30
|
Use --all when supervising workers launched from sibling worktrees.
|
|
31
31
|
With --id, terminal summaries can report completed/failed/unknown
|
|
32
32
|
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.
|
|
33
35
|
A prompt-like state is not a completion signal by itself.
|
|
34
36
|
TEXT
|
|
35
37
|
end
|
|
@@ -100,10 +102,17 @@ module Harnex
|
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
def normalize_live_status(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)
|
|
103
108
|
session.merge(
|
|
104
109
|
"state" => "running",
|
|
110
|
+
"process_state" => "running",
|
|
105
111
|
"terminal" => false,
|
|
106
|
-
"task_complete" =>
|
|
112
|
+
"task_complete" => task_complete,
|
|
113
|
+
"task_failed" => task_failed,
|
|
114
|
+
"done" => Harnex.work_done_for("running", task_complete: task_complete),
|
|
115
|
+
"work_state" => work_state,
|
|
107
116
|
"exit" => nil,
|
|
108
117
|
"exit_code" => nil,
|
|
109
118
|
"summary_out" => nil,
|
|
@@ -112,6 +121,16 @@ module Harnex
|
|
|
112
121
|
)
|
|
113
122
|
end
|
|
114
123
|
|
|
124
|
+
def task_complete?(session)
|
|
125
|
+
session["task_complete"] == true || session["task_complete"].to_s == "true" ||
|
|
126
|
+
!session["last_completed_at"].to_s.empty?
|
|
127
|
+
end
|
|
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
|
+
|
|
115
134
|
def load_live_status(session)
|
|
116
135
|
uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
|
|
117
136
|
request = Net::HTTP::Get.new(uri)
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -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,8 +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,
|
|
25
|
+
task_failed, or terminal exit,
|
|
26
|
+
whichever comes first)
|
|
24
27
|
task_complete (events JSONL — fires on
|
|
25
|
-
turn
|
|
28
|
+
successful turn completion)
|
|
29
|
+
task_failed (events JSONL — fires on
|
|
30
|
+
failed turn completion)
|
|
26
31
|
<other> (agent_state HTTP poll, e.g.
|
|
27
32
|
"prompt", "busy")
|
|
28
33
|
Without --until, waits for session exit (default).
|
|
@@ -31,12 +36,14 @@ module Harnex
|
|
|
31
36
|
-h, --help Show this help
|
|
32
37
|
|
|
33
38
|
Common patterns:
|
|
39
|
+
#{program_name} --id cx-i-42 --until done --timeout 900
|
|
34
40
|
#{program_name} --id cx-i-42 --until task_complete --timeout 900
|
|
35
41
|
#{program_name} --id cx-i-42 --until prompt --timeout 120
|
|
36
42
|
#{program_name} --id cx-i-42
|
|
37
43
|
|
|
38
44
|
Gotchas:
|
|
39
|
-
|
|
45
|
+
done is the safest work-level fence for monitors.
|
|
46
|
+
task_complete/task_failed are event predicates; prompt/busy are live state polls.
|
|
40
47
|
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
41
48
|
Exit waits can resolve from terminal summary rows when live registry/
|
|
42
49
|
exit-status files are already gone.
|
|
@@ -65,7 +72,10 @@ module Harnex
|
|
|
65
72
|
raise "--id is required for harnex wait" unless @options[:id]
|
|
66
73
|
|
|
67
74
|
if @options[:until_state]
|
|
68
|
-
|
|
75
|
+
case @options[:until_state]
|
|
76
|
+
when "done"
|
|
77
|
+
wait_until_done
|
|
78
|
+
when *EVENT_PREDICATES
|
|
69
79
|
wait_until_event(@options[:until_state])
|
|
70
80
|
else
|
|
71
81
|
wait_until_state
|
|
@@ -133,9 +143,9 @@ module Harnex
|
|
|
133
143
|
event = parse_event(line)
|
|
134
144
|
next unless event
|
|
135
145
|
|
|
136
|
-
task_complete_seen = true if event_type(event)
|
|
146
|
+
task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
|
|
137
147
|
if matches?(event, predicate, task_complete_seen)
|
|
138
|
-
return [emit_event_match(event, start_time), f.pos, task_complete_seen]
|
|
148
|
+
return [emit_event_match(event, start_time, predicate), f.pos, task_complete_seen]
|
|
139
149
|
end
|
|
140
150
|
end
|
|
141
151
|
offset = f.pos
|
|
@@ -168,6 +178,10 @@ module Harnex
|
|
|
168
178
|
case predicate
|
|
169
179
|
when "task_complete"
|
|
170
180
|
type == "task_complete"
|
|
181
|
+
when "task_failed"
|
|
182
|
+
type == "task_failed"
|
|
183
|
+
when "done"
|
|
184
|
+
%w[task_complete task_failed].include?(type)
|
|
171
185
|
when "prompt"
|
|
172
186
|
type == "task_complete" ||
|
|
173
187
|
(task_complete_seen && type == "agent_state" && event["state"] == "prompt")
|
|
@@ -176,18 +190,112 @@ module Harnex
|
|
|
176
190
|
end
|
|
177
191
|
end
|
|
178
192
|
|
|
179
|
-
def
|
|
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
|
+
|
|
200
|
+
def emit_event_match(event, start_time, predicate)
|
|
180
201
|
waited = (Time.now - start_time).round(1)
|
|
181
|
-
|
|
202
|
+
payload = {
|
|
182
203
|
ok: true,
|
|
183
204
|
id: @options[:id],
|
|
184
205
|
event: event_type(event),
|
|
185
206
|
seq: event["seq"],
|
|
186
207
|
waited_seconds: waited
|
|
187
|
-
|
|
208
|
+
}
|
|
209
|
+
if predicate == "done"
|
|
210
|
+
failed = done_event_failed?(event)
|
|
211
|
+
payload.merge!(
|
|
212
|
+
ok: !failed,
|
|
213
|
+
status: failed ? "failed" : "done",
|
|
214
|
+
state: "running",
|
|
215
|
+
process_state: "running",
|
|
216
|
+
terminal: false,
|
|
217
|
+
task_complete: !failed,
|
|
218
|
+
done: !failed,
|
|
219
|
+
work_state: failed ? "failed" : "completed"
|
|
220
|
+
)
|
|
221
|
+
payload[:last_error] = event["message"] || event["error"] if failed
|
|
222
|
+
end
|
|
223
|
+
puts JSON.generate(payload)
|
|
224
|
+
return 1 if predicate == "done" && done_event_failed?(event)
|
|
225
|
+
|
|
188
226
|
0
|
|
189
227
|
end
|
|
190
228
|
|
|
229
|
+
def wait_until_done
|
|
230
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
231
|
+
events_path = Harnex.events_log_path(repo_root, @options[:id])
|
|
232
|
+
exit_path = Harnex.exit_status_path(repo_root, @options[:id])
|
|
233
|
+
registry = Harnex.read_registry(repo_root, @options[:id])
|
|
234
|
+
start_time = Time.now
|
|
235
|
+
deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
|
|
236
|
+
|
|
237
|
+
offset = 0
|
|
238
|
+
task_complete_seen = false
|
|
239
|
+
final_event_deadline = nil
|
|
240
|
+
|
|
241
|
+
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
242
|
+
return status if status
|
|
243
|
+
|
|
244
|
+
unless registry
|
|
245
|
+
terminal = done_status(repo_root)
|
|
246
|
+
return emit_done_terminal_status(terminal) if terminal
|
|
247
|
+
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
248
|
+
|
|
249
|
+
unless File.exist?(events_path)
|
|
250
|
+
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
251
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
|
|
252
|
+
task_complete: false, done: false, work_state: "unknown", status: "unknown")
|
|
253
|
+
return 1
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
target_pid = registry && registry["pid"]
|
|
258
|
+
|
|
259
|
+
loop do
|
|
260
|
+
status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
|
|
261
|
+
return status if status
|
|
262
|
+
|
|
263
|
+
unless registry
|
|
264
|
+
terminal = done_status(repo_root)
|
|
265
|
+
return emit_done_terminal_status(terminal) if terminal
|
|
266
|
+
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if deadline && Time.now >= deadline
|
|
270
|
+
waited = (Time.now - start_time).round(1)
|
|
271
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited,
|
|
272
|
+
done: false, work_state: "running")
|
|
273
|
+
return 124
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
if target_pid && !Harnex.alive_pid?(target_pid)
|
|
277
|
+
final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
|
|
278
|
+
if Time.now >= final_event_deadline
|
|
279
|
+
await_exit_status(exit_path)
|
|
280
|
+
return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
|
|
281
|
+
|
|
282
|
+
terminal = done_status(repo_root)
|
|
283
|
+
return emit_done_terminal_status(terminal) if terminal
|
|
284
|
+
|
|
285
|
+
waited = (Time.now - start_time).round(1)
|
|
286
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
|
|
287
|
+
terminal: true, task_complete: false, done: false, work_state: "unknown",
|
|
288
|
+
waited_seconds: waited)
|
|
289
|
+
return 1
|
|
290
|
+
end
|
|
291
|
+
else
|
|
292
|
+
final_event_deadline = nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
sleep EVENT_POLL_INTERVAL
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
191
299
|
def wait_until_state
|
|
192
300
|
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
193
301
|
target_state = @options[:until_state]
|
|
@@ -244,7 +352,8 @@ module Harnex
|
|
|
244
352
|
return emit_terminal_status(terminal) if terminal
|
|
245
353
|
|
|
246
354
|
warn("harnex wait: no session found with id #{@options[:id].inspect}")
|
|
247
|
-
puts JSON.generate(ok: false, id: @options[:id], state: "unknown",
|
|
355
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
|
|
356
|
+
terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
|
|
248
357
|
return 1
|
|
249
358
|
end
|
|
250
359
|
|
|
@@ -259,7 +368,8 @@ module Harnex
|
|
|
259
368
|
terminal = terminal_status(repo_root)
|
|
260
369
|
return emit_terminal_status(terminal) if terminal
|
|
261
370
|
|
|
262
|
-
puts JSON.generate(ok: false, id: @options[:id], state: "unknown",
|
|
371
|
+
puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
|
|
372
|
+
terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
|
|
263
373
|
return 1
|
|
264
374
|
end
|
|
265
375
|
|
|
@@ -328,19 +438,62 @@ module Harnex
|
|
|
328
438
|
status
|
|
329
439
|
end
|
|
330
440
|
|
|
441
|
+
def done_status(repo_root)
|
|
442
|
+
status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
|
|
443
|
+
return nil unless status
|
|
444
|
+
return nil unless status["done"] || status["terminal"]
|
|
445
|
+
|
|
446
|
+
status
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def emit_done_exit_status(exit_path, id)
|
|
450
|
+
data = JSON.parse(File.read(exit_path))
|
|
451
|
+
exit_code = data["exit_code"]
|
|
452
|
+
task_complete = data["task_complete"] == true || data["task_complete"].to_s == "true"
|
|
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)
|
|
455
|
+
state = exit_success ? "completed" : "failed"
|
|
456
|
+
done = task_complete || exit_success
|
|
457
|
+
payload = data.merge(
|
|
458
|
+
"ok" => done,
|
|
459
|
+
"id" => id,
|
|
460
|
+
"state" => state,
|
|
461
|
+
"process_state" => "exited",
|
|
462
|
+
"terminal" => true,
|
|
463
|
+
"task_complete" => task_complete,
|
|
464
|
+
"task_failed" => task_failed,
|
|
465
|
+
"done" => done,
|
|
466
|
+
"work_state" => Harnex.work_state_for(state, task_complete: task_complete)
|
|
467
|
+
)
|
|
468
|
+
success = done
|
|
469
|
+
puts JSON.generate(payload)
|
|
470
|
+
return 0 if success
|
|
471
|
+
|
|
472
|
+
exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
|
|
473
|
+
rescue JSON::ParserError
|
|
474
|
+
puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
|
|
475
|
+
task_complete: false, done: false, work_state: "failed", status: "invalid_exit_status")
|
|
476
|
+
1
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def emit_done_terminal_status(status)
|
|
480
|
+
payload = terminal_payload(status)
|
|
481
|
+
payload[:ok] = !!payload[:done]
|
|
482
|
+
payload[:status] = payload[:done] ? "done" : status["state"]
|
|
483
|
+
puts JSON.generate(payload)
|
|
484
|
+
|
|
485
|
+
if payload[:ok]
|
|
486
|
+
0
|
|
487
|
+
elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
|
|
488
|
+
status["exit_code"]
|
|
489
|
+
else
|
|
490
|
+
1
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
331
494
|
def emit_terminal_status(status)
|
|
332
|
-
payload =
|
|
333
|
-
|
|
334
|
-
id: status["id"],
|
|
335
|
-
state: status["state"],
|
|
336
|
-
terminal: true,
|
|
337
|
-
task_complete: status["task_complete"],
|
|
338
|
-
exit: status["exit"],
|
|
339
|
-
exit_code: status["exit_code"],
|
|
340
|
-
summary_out: status["summary_out"],
|
|
341
|
-
ended_at: status["ended_at"],
|
|
342
|
-
source: status["source"]
|
|
343
|
-
}
|
|
495
|
+
payload = terminal_payload(status)
|
|
496
|
+
payload[:ok] = status["state"] == "completed"
|
|
344
497
|
puts JSON.generate(payload)
|
|
345
498
|
|
|
346
499
|
if payload[:ok]
|
|
@@ -352,6 +505,29 @@ module Harnex
|
|
|
352
505
|
end
|
|
353
506
|
end
|
|
354
507
|
|
|
508
|
+
def terminal_payload(status)
|
|
509
|
+
task_complete = !!status["task_complete"]
|
|
510
|
+
task_failed = !!status["task_failed"]
|
|
511
|
+
work_state = status["work_state"] || Harnex.work_state_for(status["state"], task_complete: task_complete)
|
|
512
|
+
done = status.key?("done") ? !!status["done"] : work_state == "completed"
|
|
513
|
+
{
|
|
514
|
+
ok: false,
|
|
515
|
+
id: status["id"],
|
|
516
|
+
state: status["state"],
|
|
517
|
+
process_state: status["process_state"] || Harnex.process_state_for(status["state"], terminal: true),
|
|
518
|
+
terminal: status.key?("terminal") ? !!status["terminal"] : true,
|
|
519
|
+
task_complete: task_complete,
|
|
520
|
+
task_failed: task_failed,
|
|
521
|
+
done: done,
|
|
522
|
+
work_state: work_state,
|
|
523
|
+
exit: status["exit"],
|
|
524
|
+
exit_code: status["exit_code"],
|
|
525
|
+
summary_out: status["summary_out"],
|
|
526
|
+
ended_at: status["ended_at"],
|
|
527
|
+
source: status["source"]
|
|
528
|
+
}
|
|
529
|
+
end
|
|
530
|
+
|
|
355
531
|
def parser
|
|
356
532
|
@parser ||= OptionParser.new do |opts|
|
|
357
533
|
opts.banner = "Usage: harnex wait [options]"
|
data/lib/harnex/core.rb
CHANGED
|
@@ -68,6 +68,36 @@ module Harnex
|
|
|
68
68
|
VERSION
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def work_state_for(session_state, task_complete: false)
|
|
72
|
+
return "completed" if task_complete
|
|
73
|
+
|
|
74
|
+
case session_state.to_s
|
|
75
|
+
when "completed"
|
|
76
|
+
"completed"
|
|
77
|
+
when "failed"
|
|
78
|
+
"failed"
|
|
79
|
+
when "running"
|
|
80
|
+
"running"
|
|
81
|
+
else
|
|
82
|
+
"unknown"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def work_done_for(session_state, task_complete: false)
|
|
87
|
+
work_state_for(session_state, task_complete: task_complete) == "completed"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def process_state_for(session_state, terminal: false)
|
|
91
|
+
return "running" unless terminal
|
|
92
|
+
|
|
93
|
+
case session_state.to_s
|
|
94
|
+
when "completed", "failed"
|
|
95
|
+
"exited"
|
|
96
|
+
else
|
|
97
|
+
"unknown"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
71
101
|
def host_info
|
|
72
102
|
{
|
|
73
103
|
host: Socket.gethostname,
|
|
@@ -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,9 +224,19 @@ module Harnex
|
|
|
221
224
|
end
|
|
222
225
|
|
|
223
226
|
payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
|
|
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)
|
|
224
230
|
payload[:agent_state] = @state_machine.to_s
|
|
231
|
+
payload[:process_state] = "running"
|
|
225
232
|
payload[:inbox] = @inbox.stats
|
|
226
233
|
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
234
|
+
payload[:last_failed_at] = @last_failed_at&.iso8601
|
|
235
|
+
payload[:task_complete] = task_complete
|
|
236
|
+
payload[:task_failed] = task_failed
|
|
237
|
+
payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
|
|
238
|
+
payload[:work_state] = work_state
|
|
239
|
+
payload[:last_error] = @last_error
|
|
227
240
|
payload[:model] = summary_model
|
|
228
241
|
payload[:effort] = meta_hash["effort"]
|
|
229
242
|
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
@@ -231,7 +244,11 @@ module Harnex
|
|
|
231
244
|
end
|
|
232
245
|
|
|
233
246
|
def task_complete?
|
|
234
|
-
!!@last_completed_at
|
|
247
|
+
!!@last_completed_at && !task_failed?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def task_failed?
|
|
251
|
+
!!@last_failed_at
|
|
235
252
|
end
|
|
236
253
|
|
|
237
254
|
def git_start
|
|
@@ -252,7 +269,7 @@ module Harnex
|
|
|
252
269
|
inject_sequence([{ text: text, newline: newline }])
|
|
253
270
|
end
|
|
254
271
|
|
|
255
|
-
def inject_stop(turn_id: nil)
|
|
272
|
+
def inject_stop(turn_id: nil, interrupt: true)
|
|
256
273
|
unless structured_transport?
|
|
257
274
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
258
275
|
end
|
|
@@ -269,15 +286,21 @@ module Harnex
|
|
|
269
286
|
end
|
|
270
287
|
end
|
|
271
288
|
end
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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!
|
|
277
297
|
end
|
|
278
|
-
|
|
298
|
+
return { ok: true, signal: "interrupt_sent" }
|
|
279
299
|
end
|
|
280
|
-
|
|
300
|
+
|
|
301
|
+
@state_machine.force_busy!
|
|
302
|
+
signal_rpc_done! unless @pid
|
|
303
|
+
return { ok: true, signal: "terminate_sent" }
|
|
281
304
|
end
|
|
282
305
|
|
|
283
306
|
@inject_mutex.synchronize do
|
|
@@ -331,7 +354,12 @@ module Harnex
|
|
|
331
354
|
|
|
332
355
|
turn_id = nil
|
|
333
356
|
@inject_mutex.synchronize do
|
|
334
|
-
|
|
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
|
|
335
363
|
@state_machine.force_busy!
|
|
336
364
|
@injected_count += 1
|
|
337
365
|
@last_injected_at = Time.now
|
|
@@ -455,14 +483,25 @@ module Harnex
|
|
|
455
483
|
@state_machine.force_busy!
|
|
456
484
|
emit_event("turn_started", turnId: params.dig("turn", "id"))
|
|
457
485
|
when "turn/completed"
|
|
458
|
-
@last_completed_at = Time.now
|
|
459
486
|
@state_machine.force_prompt!
|
|
460
487
|
turn = params["turn"] || {}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
payload
|
|
464
|
-
|
|
465
|
-
|
|
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)
|
|
466
505
|
when "item/completed"
|
|
467
506
|
emit_event("item_completed", item: params["item"])
|
|
468
507
|
@event_counters.record_item(params["item"])
|
|
@@ -482,15 +521,70 @@ module Harnex
|
|
|
482
521
|
when "account/rateLimits/updated"
|
|
483
522
|
@rate_limits = params
|
|
484
523
|
when "error"
|
|
485
|
-
|
|
524
|
+
message = extract_error_notification_message(params)
|
|
525
|
+
@last_error = message unless message.to_s.empty?
|
|
486
526
|
@state_machine.force_busy!
|
|
487
|
-
emit_event(
|
|
488
|
-
|
|
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?
|
|
489
537
|
end
|
|
490
538
|
rescue StandardError => e
|
|
491
539
|
warn("harnex: rpc notification handler error: #{e.message}")
|
|
492
540
|
end
|
|
493
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
|
+
|
|
494
588
|
def handle_jsonl_notification(message)
|
|
495
589
|
event_type = message["type"].to_s
|
|
496
590
|
|
|
@@ -504,7 +598,7 @@ module Harnex
|
|
|
504
598
|
@state_machine.force_prompt!
|
|
505
599
|
emit_event("task_complete")
|
|
506
600
|
adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
|
|
507
|
-
schedule_auto_stop("task_complete")
|
|
601
|
+
schedule_auto_stop("task_complete", interrupt: false)
|
|
508
602
|
when "message_start"
|
|
509
603
|
@pi_streamed_text_by_message[pi_message_key(message["message"])] = false
|
|
510
604
|
when "message_update"
|
|
@@ -573,12 +667,21 @@ module Harnex
|
|
|
573
667
|
|
|
574
668
|
def handle_rpc_disconnect(error)
|
|
575
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
|
+
|
|
576
675
|
@last_error = msg.to_s unless msg.to_s.empty?
|
|
577
676
|
@state_machine.force_busy!
|
|
578
677
|
emit_event("disconnected", source: "transport", message: msg) rescue nil
|
|
579
678
|
signal_rpc_done!
|
|
580
679
|
end
|
|
581
680
|
|
|
681
|
+
def normal_auto_stop_disconnect?(message)
|
|
682
|
+
message.to_s.empty? && @auto_stop_fired && (task_complete? || task_failed?)
|
|
683
|
+
end
|
|
684
|
+
|
|
582
685
|
def dispatch_initial_prompt
|
|
583
686
|
return unless adapter.respond_to?(:initial_prompt)
|
|
584
687
|
|
|
@@ -733,13 +836,22 @@ module Harnex
|
|
|
733
836
|
return unless defined?(@exit_code) && !@exit_code.nil?
|
|
734
837
|
|
|
735
838
|
exit_path = Harnex.exit_status_path(repo_root, id)
|
|
839
|
+
task_complete = task_complete?
|
|
840
|
+
task_failed = task_failed?
|
|
841
|
+
state = task_failed || @exit_code.to_i != 0 ? "failed" : "completed"
|
|
736
842
|
payload = {
|
|
737
|
-
ok:
|
|
843
|
+
ok: !task_failed && state == "completed",
|
|
738
844
|
id: id,
|
|
739
845
|
cli: adapter.key,
|
|
740
846
|
session_id: session_id,
|
|
741
847
|
repo_root: repo_root,
|
|
742
848
|
exit_code: @exit_code,
|
|
849
|
+
state: state,
|
|
850
|
+
process_state: "exited",
|
|
851
|
+
task_complete: task_complete,
|
|
852
|
+
task_failed: task_failed,
|
|
853
|
+
done: Harnex.work_done_for(state, task_complete: task_complete),
|
|
854
|
+
work_state: Harnex.work_state_for(state, task_complete: task_complete),
|
|
743
855
|
started_at: @started_at.iso8601,
|
|
744
856
|
exited_at: Time.now.iso8601,
|
|
745
857
|
injected_count: @injected_count
|
|
@@ -943,7 +1055,7 @@ module Harnex
|
|
|
943
1055
|
schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
|
|
944
1056
|
end
|
|
945
1057
|
|
|
946
|
-
def schedule_auto_stop(reason, turn_id: nil)
|
|
1058
|
+
def schedule_auto_stop(reason, turn_id: nil, interrupt: true)
|
|
947
1059
|
return unless @auto_stop
|
|
948
1060
|
|
|
949
1061
|
should_fire = @auto_stop_mutex.synchronize do
|
|
@@ -958,7 +1070,7 @@ module Harnex
|
|
|
958
1070
|
|
|
959
1071
|
thread = Thread.new do
|
|
960
1072
|
begin
|
|
961
|
-
inject_stop(turn_id: turn_id)
|
|
1073
|
+
inject_stop(turn_id: turn_id, interrupt: interrupt)
|
|
962
1074
|
rescue StandardError => e
|
|
963
1075
|
warn("harnex: auto-stop failed after #{reason}: #{e.message}")
|
|
964
1076
|
end
|
|
@@ -1004,17 +1116,26 @@ module Harnex
|
|
|
1004
1116
|
|
|
1005
1117
|
def normalize_auto_stop_exit_code!
|
|
1006
1118
|
return unless @auto_stop
|
|
1007
|
-
return unless @last_completed_at
|
|
1008
1119
|
return unless @auto_stop_fired
|
|
1009
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
|
+
|
|
1010
1129
|
@exit_code = 0
|
|
1011
1130
|
@term_signal = nil
|
|
1012
1131
|
end
|
|
1013
1132
|
|
|
1014
1133
|
def classify_exit
|
|
1015
1134
|
return "timeout" if @exit_code == 124
|
|
1016
|
-
return "success" if @exit_code == 0 && session_summary_present?
|
|
1017
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?
|
|
1018
1139
|
return "failure" unless @exit_code == 0
|
|
1019
1140
|
|
|
1020
1141
|
"disconnected"
|
|
@@ -1098,7 +1219,7 @@ module Harnex
|
|
|
1098
1219
|
files_changed: @git_end[:files_changed],
|
|
1099
1220
|
commits: @git_end[:commits],
|
|
1100
1221
|
exit: @exit_reason,
|
|
1101
|
-
task_complete:
|
|
1222
|
+
task_complete: task_complete?,
|
|
1102
1223
|
signal: @term_signal,
|
|
1103
1224
|
exit_code: @exit_code,
|
|
1104
1225
|
last_error: @last_error,
|
|
@@ -1244,6 +1365,7 @@ module Harnex
|
|
|
1244
1365
|
@event_counters.record(type)
|
|
1245
1366
|
@events_mutex.synchronize do
|
|
1246
1367
|
return unless @events_log
|
|
1368
|
+
return if @events_log.closed?
|
|
1247
1369
|
|
|
1248
1370
|
@events_log_seq += 1
|
|
1249
1371
|
event = {
|
|
@@ -42,8 +42,12 @@ module Harnex
|
|
|
42
42
|
"id" => Harnex.normalize_id(id),
|
|
43
43
|
"repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
|
|
44
44
|
"state" => "unknown",
|
|
45
|
+
"process_state" => "unknown",
|
|
45
46
|
"terminal" => false,
|
|
46
47
|
"task_complete" => false,
|
|
48
|
+
"task_failed" => false,
|
|
49
|
+
"done" => false,
|
|
50
|
+
"work_state" => "unknown",
|
|
47
51
|
"exit" => nil,
|
|
48
52
|
"exit_code" => nil,
|
|
49
53
|
"summary_out" => nil,
|
|
@@ -127,12 +131,19 @@ module Harnex
|
|
|
127
131
|
meta = record["meta"] || {}
|
|
128
132
|
actual = record["actual"] || {}
|
|
129
133
|
state = classify_summary_state(actual)
|
|
134
|
+
task_complete = !!actual["task_complete"]
|
|
135
|
+
task_failed = state == "failed" && !task_complete
|
|
136
|
+
terminal = state != "unknown"
|
|
130
137
|
{
|
|
131
138
|
"id" => meta["id"].to_s,
|
|
132
139
|
"repo_root" => meta["repo"] || fallback_repo_root,
|
|
133
140
|
"state" => state,
|
|
134
|
-
"
|
|
135
|
-
"
|
|
141
|
+
"process_state" => Harnex.process_state_for(state, terminal: terminal),
|
|
142
|
+
"terminal" => terminal,
|
|
143
|
+
"task_complete" => task_complete,
|
|
144
|
+
"task_failed" => task_failed,
|
|
145
|
+
"done" => Harnex.work_done_for(state, task_complete: task_complete),
|
|
146
|
+
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
136
147
|
"exit" => blank_to_nil(actual["exit"]),
|
|
137
148
|
"exit_code" => actual["exit_code"],
|
|
138
149
|
"summary_out" => summary_path,
|
|
@@ -164,12 +175,19 @@ module Harnex
|
|
|
164
175
|
else
|
|
165
176
|
"unknown"
|
|
166
177
|
end
|
|
178
|
+
task_complete = record["terminal_event"].to_s == "task_complete"
|
|
179
|
+
task_failed = record["terminal_event"].to_s == "task_failed" || (state == "failed" && !task_complete)
|
|
180
|
+
terminal = state != "unknown"
|
|
167
181
|
{
|
|
168
182
|
"id" => record["id"].to_s,
|
|
169
183
|
"repo_root" => fallback_repo_root,
|
|
170
184
|
"state" => state,
|
|
171
|
-
"
|
|
172
|
-
"
|
|
185
|
+
"process_state" => Harnex.process_state_for(state, terminal: terminal),
|
|
186
|
+
"terminal" => terminal,
|
|
187
|
+
"task_complete" => task_complete,
|
|
188
|
+
"task_failed" => task_failed,
|
|
189
|
+
"done" => Harnex.work_done_for(state, task_complete: task_complete),
|
|
190
|
+
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
173
191
|
"exit" => history_exit(status),
|
|
174
192
|
"exit_code" => nil,
|
|
175
193
|
"summary_out" => blank_to_nil(record["summary_out_path"]),
|
data/lib/harnex/version.rb
CHANGED
|
@@ -19,7 +19,8 @@ harnex run codex --id cx-23 --tmux
|
|
|
19
19
|
If the plan file is self-contained, reference it directly:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done."
|
|
22
|
+
harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done."
|
|
23
|
+
harnex wait --id cx-23 --until done --timeout 1200
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
For tasks that need structured output, tell the worker to write a
|
|
@@ -32,12 +33,13 @@ Run the full test suite when done.
|
|
|
32
33
|
Write a short summary to /tmp/impl-23.md.
|
|
33
34
|
EOF
|
|
34
35
|
|
|
35
|
-
harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md"
|
|
36
|
+
harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md"
|
|
37
|
+
harnex wait --id cx-23 --until done --timeout 1200
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
### 3. Watch until done
|
|
39
41
|
|
|
40
|
-
Use
|
|
42
|
+
Use `harnex wait --until done` as the work-level fence, then read the worker's screen:
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
45
|
harnex pane --id cx-23 --lines 25
|
data/recipes/03_buddy.md
CHANGED
|
@@ -45,8 +45,9 @@ Your job:
|
|
|
45
45
|
2. If the worker appears stuck at a prompt for more than 10 minutes
|
|
46
46
|
with no progress, nudge it:
|
|
47
47
|
- `harnex send --id worker-42 --message "You appear to have stalled. Continue with your current task."`
|
|
48
|
-
3. If
|
|
49
|
-
|
|
48
|
+
3. If `harnex status --id worker-42 --json` reports `done=true` or
|
|
49
|
+
`work_state=failed`, report back:
|
|
50
|
+
- `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "worker-42 reached a work-level terminal state. Check results." Enter`
|
|
50
51
|
4. Keep watching until the worker finishes or is stopped.
|
|
51
52
|
|
|
52
53
|
Do not interfere with work in progress. Only nudge when clearly stalled.
|
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.
|
|
4
|
+
version: 0.7.7
|
|
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-06-12 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.
|