harnex 0.6.5 → 0.7.4
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 +158 -0
- data/README.md +102 -33
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +24 -21
- data/guides/02_chain.md +6 -3
- data/guides/03_buddy.md +12 -11
- data/guides/04_monitoring.md +17 -16
- data/guides/05_naming.md +16 -15
- data/lib/harnex/adapters/base.rb +36 -2
- data/lib/harnex/adapters/claude.rb +4 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +56 -230
- data/lib/harnex/adapters/opencode.rb +132 -0
- data/lib/harnex/adapters/pi.rb +512 -0
- data/lib/harnex/adapters.rb +5 -1
- data/lib/harnex/cli.rb +9 -2
- data/lib/harnex/codex/app_server/client.rb +348 -0
- data/lib/harnex/commands/doctor.rb +95 -2
- data/lib/harnex/commands/history.rb +149 -0
- data/lib/harnex/commands/run.rb +47 -9
- data/lib/harnex/commands/wait.rb +77 -36
- data/lib/harnex/core.rb +3 -3
- data/lib/harnex/dispatch_history.rb +112 -0
- data/lib/harnex/runtime/session.rb +326 -44
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +9 -4
data/guides/03_buddy.md
CHANGED
|
@@ -7,7 +7,7 @@ needs interpretation that simple stall policy cannot provide.
|
|
|
7
7
|
For simple inactivity recovery, prefer built-in watch mode:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
harnex run
|
|
10
|
+
harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Use a buddy when you need reasoning over pane contents, semantic checks, or
|
|
@@ -28,14 +28,15 @@ Use a buddy for:
|
|
|
28
28
|
Spawn the worker first, then spawn the buddy:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
harnex run
|
|
31
|
+
harnex run pi --id pi-i-42 --tmux pi-i-42 \
|
|
32
32
|
--context "Read and execute /tmp/task-impl-42.md"
|
|
33
33
|
|
|
34
|
-
harnex run
|
|
34
|
+
harnex run pi --id buddy-42 --tmux buddy-42
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
The buddy is just another harnex session. Inspect it, stop it, and read its
|
|
38
|
-
logs with the same commands as any worker.
|
|
38
|
+
logs with the same commands as any worker. Use Pi by default; swap to another
|
|
39
|
+
adapter if your environment or policy requires it.
|
|
39
40
|
|
|
40
41
|
## Buddy Prompt
|
|
41
42
|
|
|
@@ -43,18 +44,18 @@ Give the buddy an explicit polling loop, stall threshold, nudge rule, return
|
|
|
43
44
|
channel, and cleanup rule:
|
|
44
45
|
|
|
45
46
|
```text
|
|
46
|
-
You are an accountability partner for harnex session `
|
|
47
|
+
You are an accountability partner for harnex session `pi-i-42`.
|
|
47
48
|
|
|
48
49
|
Every 5 minutes:
|
|
49
|
-
- Run `harnex pane --id
|
|
50
|
-
- Run `harnex status --id
|
|
50
|
+
- Run `harnex pane --id pi-i-42 --lines 30`.
|
|
51
|
+
- Run `harnex status --id pi-i-42 --json`.
|
|
51
52
|
|
|
52
53
|
If the worker appears stuck at a prompt or permission dialog for more than
|
|
53
54
|
10 minutes with no progress, nudge it:
|
|
54
|
-
- `harnex send --id
|
|
55
|
+
- `harnex send --id pi-i-42 --message "You appear to have stalled. Continue with your current task."`
|
|
55
56
|
|
|
56
|
-
If the worker exits or writes `/tmp/
|
|
57
|
-
- `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "
|
|
57
|
+
If the worker exits or writes `/tmp/pi-i-42-done.txt`, report back:
|
|
58
|
+
- `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished. Check /tmp/pi-i-42-done.txt." Enter`
|
|
58
59
|
|
|
59
60
|
Do not interfere with active work. Stop yourself after reporting completion.
|
|
60
61
|
```
|
|
@@ -73,7 +74,7 @@ harnex session:
|
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
76
|
tmux capture-pane -t "$HARNEX_SPAWNER_PANE" -p
|
|
76
|
-
tmux send-keys -t "$HARNEX_SPAWNER_PANE" "
|
|
77
|
+
tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished" Enter
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
If no tmux return pane is available, require the buddy to write a file and tell
|
data/guides/04_monitoring.md
CHANGED
|
@@ -17,16 +17,17 @@ 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
|
-
turn-level fence. It still
|
|
22
|
-
expected artifact or tests
|
|
20
|
+
For structured sessions (Pi RPC and Codex app-server),
|
|
21
|
+
`harnex wait --until task_complete` is a strong turn-level fence. It still
|
|
22
|
+
does not know your acceptance criteria; verify the expected artifact or tests
|
|
23
|
+
afterward.
|
|
23
24
|
|
|
24
25
|
## Completion Test
|
|
25
26
|
|
|
26
27
|
For unattended work, declare done with a conjunction of work-level facts:
|
|
27
28
|
|
|
28
29
|
```bash
|
|
29
|
-
test -f /tmp/
|
|
30
|
+
test -f /tmp/pi-i-NN-done.txt &&
|
|
30
31
|
test -z "$(git status --short)" &&
|
|
31
32
|
test "$(git log -1 --format=%ct)" -lt "$(($(date +%s) - 600))"
|
|
32
33
|
```
|
|
@@ -51,23 +52,23 @@ where to look.
|
|
|
51
52
|
For active supervision:
|
|
52
53
|
|
|
53
54
|
```bash
|
|
54
|
-
harnex pane --id
|
|
55
|
-
harnex events --id
|
|
56
|
-
harnex logs --id
|
|
55
|
+
harnex pane --id pi-i-NN --lines 40
|
|
56
|
+
harnex events --id pi-i-NN --snapshot
|
|
57
|
+
harnex logs --id pi-i-NN --lines 80
|
|
57
58
|
```
|
|
58
59
|
|
|
59
60
|
For continuous viewing:
|
|
60
61
|
|
|
61
62
|
```bash
|
|
62
|
-
harnex pane --id
|
|
63
|
-
harnex logs --id
|
|
64
|
-
harnex events --id
|
|
63
|
+
harnex pane --id pi-i-NN --follow --interval 2
|
|
64
|
+
harnex logs --id pi-i-NN --follow
|
|
65
|
+
harnex events --id pi-i-NN
|
|
65
66
|
```
|
|
66
67
|
|
|
67
68
|
For task completion:
|
|
68
69
|
|
|
69
70
|
```bash
|
|
70
|
-
harnex wait --id
|
|
71
|
+
harnex wait --id pi-i-NN --until task_complete --timeout 900
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
## Background Sweeper
|
|
@@ -80,14 +81,14 @@ pipeline cannot wait forever:
|
|
|
80
81
|
start=$(date +%s)
|
|
81
82
|
max_wait=5400
|
|
82
83
|
|
|
83
|
-
until test -f /tmp/
|
|
84
|
+
until test -f /tmp/pi-i-NN-done.txt; do
|
|
84
85
|
if test "$(($(date +%s) - start))" -gt "$max_wait"; then
|
|
85
|
-
echo "wall-clock cap hit for
|
|
86
|
+
echo "wall-clock cap hit for pi-i-NN" >&2
|
|
86
87
|
exit 2
|
|
87
88
|
fi
|
|
88
89
|
|
|
89
|
-
harnex status --id
|
|
90
|
-
harnex pane --id
|
|
90
|
+
harnex status --id pi-i-NN --json
|
|
91
|
+
harnex pane --id pi-i-NN --lines 20
|
|
91
92
|
sleep 60
|
|
92
93
|
done
|
|
93
94
|
```
|
|
@@ -106,7 +107,7 @@ Use `harnex run --watch` when one foreground process should launch the worker
|
|
|
106
107
|
and apply bounded stall recovery:
|
|
107
108
|
|
|
108
109
|
```bash
|
|
109
|
-
harnex run
|
|
110
|
+
harnex run pi --id pi-i-NN --watch --preset impl \
|
|
110
111
|
--context "Read /tmp/task-impl-NN.md"
|
|
111
112
|
```
|
|
112
113
|
|
data/guides/05_naming.md
CHANGED
|
@@ -15,6 +15,7 @@ Common prefixes:
|
|
|
15
15
|
|
|
16
16
|
| Prefix | Meaning |
|
|
17
17
|
| --- | --- |
|
|
18
|
+
| `pi` | Pi worker (default) |
|
|
18
19
|
| `cx` | Codex worker |
|
|
19
20
|
| `cl` | Claude worker |
|
|
20
21
|
| `buddy` | Buddy monitor |
|
|
@@ -34,13 +35,13 @@ Common phases:
|
|
|
34
35
|
Examples:
|
|
35
36
|
|
|
36
37
|
```text
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
pi-m-42 Pi maps task 42
|
|
39
|
+
pi-p-42 Pi writes plan 42
|
|
40
|
+
pi-r-42 Pi reviews plan 42
|
|
41
|
+
pi-f-42 Pi fixes plan 42
|
|
42
|
+
pi-i-42 Pi implements plan 42
|
|
43
|
+
pi-cr-42 Pi reviews implementation 42
|
|
44
|
+
pi-cf-42 Pi fixes implementation 42
|
|
44
45
|
buddy-42 Buddy monitors task 42
|
|
45
46
|
```
|
|
46
47
|
|
|
@@ -52,13 +53,13 @@ short, and present in every artifact.
|
|
|
52
53
|
Always pass both and keep them identical:
|
|
53
54
|
|
|
54
55
|
```bash
|
|
55
|
-
harnex run
|
|
56
|
+
harnex run pi --id pi-i-42 --tmux pi-i-42
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
Avoid this:
|
|
59
60
|
|
|
60
61
|
```bash
|
|
61
|
-
harnex run
|
|
62
|
+
harnex run pi --tmux pi-i-42
|
|
62
63
|
```
|
|
63
64
|
|
|
64
65
|
If `--id` is missing, harnex generates a random session ID. The tmux window may
|
|
@@ -70,9 +71,9 @@ ID.
|
|
|
70
71
|
If a session fails and you dispatch a fresh attempt, append a suffix:
|
|
71
72
|
|
|
72
73
|
```text
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
pi-i-42 first attempt
|
|
75
|
+
pi-i-42b second attempt
|
|
76
|
+
pi-i-42c third attempt
|
|
76
77
|
```
|
|
77
78
|
|
|
78
79
|
Keep the old session's logs. They are useful for diagnosis.
|
|
@@ -97,9 +98,9 @@ session ID.
|
|
|
97
98
|
Derive done markers from the session ID:
|
|
98
99
|
|
|
99
100
|
```text
|
|
100
|
-
/tmp/
|
|
101
|
-
/tmp/
|
|
102
|
-
/tmp/
|
|
101
|
+
/tmp/pi-p-42-done.txt
|
|
102
|
+
/tmp/pi-i-42-done.txt
|
|
103
|
+
/tmp/pi-cr-42-done.txt
|
|
103
104
|
```
|
|
104
105
|
|
|
105
106
|
When a brief asks for a completion marker, make it one line and include the
|
data/lib/harnex/adapters/base.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
1
4
|
module Harnex
|
|
2
5
|
module Adapters
|
|
3
6
|
class Base
|
|
4
7
|
PROMPT_PREFIXES = [">", "\u203A", "\u276F"].freeze
|
|
8
|
+
AGENT_VERSION_TIMEOUT_SECONDS = 2.0
|
|
5
9
|
|
|
6
10
|
# Adapter contract — subclasses MUST implement:
|
|
7
11
|
# base_command -> Array[String] CLI args to spawn
|
|
@@ -20,12 +24,28 @@ module Harnex
|
|
|
20
24
|
@extra_args = extra_args.dup
|
|
21
25
|
end
|
|
22
26
|
|
|
23
|
-
# Default transport.
|
|
24
|
-
# :
|
|
27
|
+
# Default transport. Structured adapters override to :stdio_jsonrpc
|
|
28
|
+
# (Codex app-server) or :stdio_jsonl_rpc (Pi RPC); Session#run uses
|
|
29
|
+
# this to pick the I/O path.
|
|
25
30
|
def transport
|
|
26
31
|
:pty
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
# Vendor of the underlying agent — populates DISPATCH meta.agent_provider.
|
|
35
|
+
# Subclasses override (claude → "anthropic", codex → "openai").
|
|
36
|
+
def provider
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Probes `<base_command.first> --version` with a short timeout and
|
|
41
|
+
# memoizes the result for the adapter's lifetime. Returns nil when
|
|
42
|
+
# the binary is missing, exits non-zero, or stalls past the timeout.
|
|
43
|
+
def agent_version
|
|
44
|
+
return @agent_version if defined?(@agent_version)
|
|
45
|
+
|
|
46
|
+
@agent_version = probe_agent_version
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
def describe
|
|
30
50
|
{ transport: transport }
|
|
31
51
|
end
|
|
@@ -108,6 +128,20 @@ module Harnex
|
|
|
108
128
|
|
|
109
129
|
protected
|
|
110
130
|
|
|
131
|
+
def probe_agent_version
|
|
132
|
+
cli = base_command.first
|
|
133
|
+
return nil unless cli
|
|
134
|
+
|
|
135
|
+
Timeout.timeout(AGENT_VERSION_TIMEOUT_SECONDS) do
|
|
136
|
+
stdout, status = Open3.capture2(cli, "--version", err: File::NULL, in: File::NULL)
|
|
137
|
+
return nil unless status.success?
|
|
138
|
+
|
|
139
|
+
stdout.to_s.lines.first&.strip
|
|
140
|
+
end
|
|
141
|
+
rescue Errno::ENOENT, Timeout::Error, StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
111
145
|
def submit_bytes
|
|
112
146
|
"\r"
|
|
113
147
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "open3"
|
|
3
|
+
require_relative "../codex/app_server/client"
|
|
3
4
|
|
|
4
5
|
module Harnex
|
|
5
6
|
module Adapters
|
|
@@ -69,6 +70,10 @@ module Harnex
|
|
|
69
70
|
:stdio_jsonrpc
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
def provider
|
|
74
|
+
"openai"
|
|
75
|
+
end
|
|
76
|
+
|
|
72
77
|
def base_command
|
|
73
78
|
["codex", "app-server"]
|
|
74
79
|
end
|
|
@@ -135,10 +140,10 @@ module Harnex
|
|
|
135
140
|
# subprocess. In tests, callers may pass pre-built IO objects.
|
|
136
141
|
def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
|
|
137
142
|
if read_io && write_io
|
|
138
|
-
@client =
|
|
143
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: read_io, write_io: write_io, pid: pid)
|
|
139
144
|
else
|
|
140
145
|
spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
|
|
141
|
-
@client =
|
|
146
|
+
@client = Harnex::Codex::AppServer::Client.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
|
|
142
147
|
end
|
|
143
148
|
|
|
144
149
|
@client.on_notification { |msg| handle_notification(msg) }
|
|
@@ -189,6 +194,48 @@ module Harnex
|
|
|
189
194
|
result
|
|
190
195
|
end
|
|
191
196
|
|
|
197
|
+
# Plan 30 Phase 2 — subprocess-restart primitive for deployment
|
|
198
|
+
# fallback. Stops the current JSON-RPC subprocess, spawns a new one
|
|
199
|
+
# against the supplied deployment_config, and resumes the same
|
|
200
|
+
# threadId so conversation state carries across. Thin orchestrator:
|
|
201
|
+
# counter snapshots, the `fallback_triggered` event, and any
|
|
202
|
+
# Session-level signaling land in plan 30 Phases 3–4 alongside the
|
|
203
|
+
# per-arm telemetry split. CLI flags land in Phase 5.
|
|
204
|
+
#
|
|
205
|
+
# deployment_config: { command: [...argv], env: {...}, cwd: nil }
|
|
206
|
+
def switch_deployment(deployment_config:,
|
|
207
|
+
term_grace_seconds: STOP_TERM_GRACE_SECONDS,
|
|
208
|
+
kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
|
|
209
|
+
raise "codex_appserver: client not started" unless @client
|
|
210
|
+
raise "codex_appserver: no thread to resume" if @thread_id.nil? || @thread_id.to_s.empty?
|
|
211
|
+
|
|
212
|
+
prior_thread_id = @thread_id
|
|
213
|
+
in_flight =
|
|
214
|
+
if @current_turn_id
|
|
215
|
+
{ threadId: prior_thread_id, turnId: @current_turn_id }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@client.stop_for_fallback(
|
|
219
|
+
in_flight_turn: in_flight,
|
|
220
|
+
term_grace_seconds: term_grace_seconds,
|
|
221
|
+
kill_grace_seconds: kill_grace_seconds
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@client = Harnex::Codex::AppServer::Client.spawn_with_fallback(
|
|
225
|
+
prior_thread_id: prior_thread_id,
|
|
226
|
+
deployment_config: deployment_config,
|
|
227
|
+
handshake_params: handshake_initialize_params,
|
|
228
|
+
notification_handler: ->(msg) { handle_notification(msg) },
|
|
229
|
+
request_handler: ->(method, params) { handle_server_request(method, params) },
|
|
230
|
+
disconnect_handler: ->(err) { handle_disconnect(err) }
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@thread_id = prior_thread_id
|
|
234
|
+
@current_turn_id = nil
|
|
235
|
+
@state = :prompt
|
|
236
|
+
self
|
|
237
|
+
end
|
|
238
|
+
|
|
192
239
|
def close
|
|
193
240
|
return unless @client
|
|
194
241
|
|
|
@@ -259,7 +306,12 @@ module Harnex
|
|
|
259
306
|
end
|
|
260
307
|
|
|
261
308
|
def perform_handshake
|
|
262
|
-
@client.request("initialize",
|
|
309
|
+
@client.request("initialize", handshake_initialize_params)
|
|
310
|
+
@client.notify("initialized", {})
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handshake_initialize_params
|
|
314
|
+
{
|
|
263
315
|
clientInfo: {
|
|
264
316
|
title: CLIENT_TITLE,
|
|
265
317
|
name: CLIENT_NAME,
|
|
@@ -269,8 +321,7 @@ module Harnex
|
|
|
269
321
|
experimentalApi: false,
|
|
270
322
|
optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
|
|
271
323
|
}
|
|
272
|
-
}
|
|
273
|
-
@client.notify("initialized", {})
|
|
324
|
+
}
|
|
274
325
|
end
|
|
275
326
|
|
|
276
327
|
def handle_notification(message)
|
|
@@ -314,231 +365,6 @@ module Harnex
|
|
|
314
365
|
"Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
|
|
315
366
|
end
|
|
316
367
|
|
|
317
|
-
# Minimal JSON-RPC 2.0 client. One JSON object per line.
|
|
318
|
-
# Responses keyed by id; everything else is a notification.
|
|
319
|
-
class JsonRpcClient
|
|
320
|
-
attr_reader :pid
|
|
321
|
-
|
|
322
|
-
def initialize(read_io:, write_io:, pid: nil)
|
|
323
|
-
@read_io = read_io
|
|
324
|
-
@write_io = write_io
|
|
325
|
-
@pid = pid
|
|
326
|
-
@next_id = 1
|
|
327
|
-
@pending = {}
|
|
328
|
-
@id_mutex = Mutex.new
|
|
329
|
-
@write_mutex = Mutex.new
|
|
330
|
-
@notification_handler = nil
|
|
331
|
-
@request_handler = nil
|
|
332
|
-
@disconnect_handler = nil
|
|
333
|
-
@disconnect_signaled = false
|
|
334
|
-
@closed = false
|
|
335
|
-
@reader_thread = nil
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def on_notification(&block)
|
|
339
|
-
@notification_handler = block
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
# Handler for server-initiated requests (id + method). The block
|
|
343
|
-
# receives (method, params) and returns the response body for the
|
|
344
|
-
# JSON-RPC `result` field, or nil to reject with -32601.
|
|
345
|
-
def on_request(&block)
|
|
346
|
-
@request_handler = block
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
def on_disconnect(&block)
|
|
350
|
-
@disconnect_handler = block
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def start
|
|
354
|
-
@reader_thread = Thread.new { read_loop }
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
def request(method, params = {})
|
|
358
|
-
raise "codex_appserver client is closed" if @closed
|
|
359
|
-
|
|
360
|
-
queue = Queue.new
|
|
361
|
-
id = @id_mutex.synchronize do
|
|
362
|
-
assigned = @next_id
|
|
363
|
-
@next_id += 1
|
|
364
|
-
@pending[assigned] = queue
|
|
365
|
-
assigned
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
369
|
-
result = queue.pop
|
|
370
|
-
raise result if result.is_a?(Exception)
|
|
371
|
-
|
|
372
|
-
result
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def notify(method, params = {})
|
|
376
|
-
return if @closed
|
|
377
|
-
|
|
378
|
-
write_line({ jsonrpc: "2.0", method: method, params: params })
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def close
|
|
382
|
-
return if @closed
|
|
383
|
-
|
|
384
|
-
@closed = true
|
|
385
|
-
|
|
386
|
-
@id_mutex.synchronize do
|
|
387
|
-
@pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
|
|
388
|
-
@pending.clear
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
begin
|
|
392
|
-
@write_io.close unless @write_io.closed?
|
|
393
|
-
rescue IOError
|
|
394
|
-
nil
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
if @pid && process_alive?(@pid)
|
|
398
|
-
sleep 0.05
|
|
399
|
-
begin
|
|
400
|
-
Process.kill("TERM", @pid)
|
|
401
|
-
rescue Errno::ESRCH
|
|
402
|
-
nil
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
@reader_thread&.join(2)
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
def terminate_process(term_grace_seconds:, kill_grace_seconds:)
|
|
410
|
-
return false unless @pid
|
|
411
|
-
|
|
412
|
-
begin
|
|
413
|
-
Process.kill("TERM", @pid)
|
|
414
|
-
rescue Errno::ESRCH
|
|
415
|
-
return true
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
return true if wait_for_process_exit(@pid, term_grace_seconds)
|
|
419
|
-
|
|
420
|
-
begin
|
|
421
|
-
Process.kill("KILL", @pid)
|
|
422
|
-
rescue Errno::ESRCH
|
|
423
|
-
return true
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
wait_for_process_exit(@pid, kill_grace_seconds)
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
private
|
|
430
|
-
|
|
431
|
-
def write_line(message)
|
|
432
|
-
@write_mutex.synchronize do
|
|
433
|
-
@write_io.write(JSON.generate(message))
|
|
434
|
-
@write_io.write("\n")
|
|
435
|
-
@write_io.flush
|
|
436
|
-
end
|
|
437
|
-
rescue Errno::EPIPE, IOError
|
|
438
|
-
signal_disconnect(nil)
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
def read_loop
|
|
442
|
-
buffer = +""
|
|
443
|
-
loop do
|
|
444
|
-
chunk = @read_io.readpartial(4096)
|
|
445
|
-
buffer << chunk
|
|
446
|
-
while (idx = buffer.index("\n"))
|
|
447
|
-
line = buffer.slice!(0, idx + 1).chomp
|
|
448
|
-
next if line.strip.empty?
|
|
449
|
-
|
|
450
|
-
handle_line(line)
|
|
451
|
-
end
|
|
452
|
-
end
|
|
453
|
-
rescue EOFError, IOError, Errno::EIO
|
|
454
|
-
nil
|
|
455
|
-
ensure
|
|
456
|
-
signal_disconnect(nil)
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
def handle_line(line)
|
|
460
|
-
message = JSON.parse(line)
|
|
461
|
-
rescue JSON::ParserError => e
|
|
462
|
-
signal_disconnect(e)
|
|
463
|
-
return
|
|
464
|
-
else
|
|
465
|
-
dispatch_message(message)
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def dispatch_message(message)
|
|
469
|
-
if message["id"] && message["method"]
|
|
470
|
-
handle_server_request(message)
|
|
471
|
-
return
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
if message.key?("id")
|
|
475
|
-
pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
|
|
476
|
-
return unless pending
|
|
477
|
-
|
|
478
|
-
if message["error"]
|
|
479
|
-
err_msg = message.dig("error", "message") || "RPC error"
|
|
480
|
-
pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
|
|
481
|
-
signal_disconnect(message["error"])
|
|
482
|
-
else
|
|
483
|
-
pending.push(message["result"] || {})
|
|
484
|
-
end
|
|
485
|
-
return
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
@notification_handler&.call(message) if message["method"]
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
def handle_server_request(message)
|
|
492
|
-
result =
|
|
493
|
-
begin
|
|
494
|
-
@request_handler&.call(message["method"], message["params"] || {})
|
|
495
|
-
rescue StandardError
|
|
496
|
-
nil
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
if result.nil?
|
|
500
|
-
write_line({
|
|
501
|
-
jsonrpc: "2.0",
|
|
502
|
-
id: message["id"],
|
|
503
|
-
error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
|
|
504
|
-
})
|
|
505
|
-
else
|
|
506
|
-
write_line({
|
|
507
|
-
jsonrpc: "2.0",
|
|
508
|
-
id: message["id"],
|
|
509
|
-
result: result
|
|
510
|
-
})
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
def signal_disconnect(error)
|
|
515
|
-
return if @disconnect_signaled
|
|
516
|
-
|
|
517
|
-
@disconnect_signaled = true
|
|
518
|
-
@disconnect_handler&.call(error)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
def process_alive?(pid)
|
|
522
|
-
Process.kill(0, pid)
|
|
523
|
-
true
|
|
524
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
525
|
-
false
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def wait_for_process_exit(pid, timeout_seconds)
|
|
529
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
|
|
530
|
-
loop do
|
|
531
|
-
return true unless process_alive?(pid)
|
|
532
|
-
|
|
533
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
534
|
-
break if remaining <= 0
|
|
535
|
-
|
|
536
|
-
sleep([0.05, remaining].min)
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
!process_alive?(pid)
|
|
540
|
-
end
|
|
541
|
-
end
|
|
542
368
|
end
|
|
543
369
|
end
|
|
544
370
|
end
|