subagent-cli 0.1.1__py3-none-any.whl
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.
- subagent/__init__.py +7 -0
- subagent/acp_client.py +366 -0
- subagent/approval_utils.py +57 -0
- subagent/cli.py +1125 -0
- subagent/config.py +305 -0
- subagent/constants.py +21 -0
- subagent/controller_service.py +267 -0
- subagent/daemon.py +133 -0
- subagent/errors.py +24 -0
- subagent/handoff_service.py +354 -0
- subagent/hints.py +36 -0
- subagent/input_contract.py +121 -0
- subagent/launcher_service.py +30 -0
- subagent/output.py +41 -0
- subagent/paths.py +63 -0
- subagent/prompt_service.py +114 -0
- subagent/runtime_service.py +342 -0
- subagent/simple_yaml.py +202 -0
- subagent/state.py +1049 -0
- subagent/turn_service.py +558 -0
- subagent/worker_runtime.py +758 -0
- subagent/worker_service.py +362 -0
- subagent_cli-0.1.1.dist-info/METADATA +98 -0
- subagent_cli-0.1.1.dist-info/RECORD +27 -0
- subagent_cli-0.1.1.dist-info/WHEEL +4 -0
- subagent_cli-0.1.1.dist-info/entry_points.txt +3 -0
- subagent_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
subagent/output.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Output helpers for human and JSON responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, NoReturn
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .constants import SCHEMA_VERSION
|
|
11
|
+
from .errors import SubagentError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ok_envelope(event_type: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
return {
|
|
16
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
17
|
+
"ok": True,
|
|
18
|
+
"type": event_type,
|
|
19
|
+
"data": data,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def error_envelope(error: SubagentError) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
26
|
+
"ok": False,
|
|
27
|
+
"type": "error",
|
|
28
|
+
"error": error.to_dict(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit_json(payload: dict[str, Any]) -> None:
|
|
33
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit_error_and_exit(error: SubagentError, json_output: bool = False) -> NoReturn:
|
|
37
|
+
if json_output:
|
|
38
|
+
emit_json(error_envelope(error))
|
|
39
|
+
else:
|
|
40
|
+
typer.echo(f"{error.code}: {error.message}", err=True)
|
|
41
|
+
raise typer.Exit(code=1)
|
subagent/paths.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Filesystem path resolution helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .constants import (
|
|
9
|
+
DEFAULT_CONFIG_PATH,
|
|
10
|
+
DEFAULT_HANDOFFS_DIR,
|
|
11
|
+
DEFAULT_STATE_DB_PATH,
|
|
12
|
+
DEFAULT_STATE_DIR,
|
|
13
|
+
ENV_CONFIG_PATH,
|
|
14
|
+
ENV_STATE_DIR,
|
|
15
|
+
PROJECT_HINT_DIRNAME,
|
|
16
|
+
PROJECT_HINT_FILENAME,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_workspace_path(cwd: Path | None = None) -> Path:
|
|
21
|
+
return (cwd or Path.cwd()).expanduser().resolve()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_config_path(config_path: Path | None = None) -> Path:
|
|
25
|
+
if config_path is not None:
|
|
26
|
+
return config_path.expanduser().resolve()
|
|
27
|
+
env_value = os.environ.get(ENV_CONFIG_PATH)
|
|
28
|
+
if env_value:
|
|
29
|
+
return Path(env_value).expanduser().resolve()
|
|
30
|
+
return DEFAULT_CONFIG_PATH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_state_dir(state_dir: Path | None = None) -> Path:
|
|
34
|
+
if state_dir is not None:
|
|
35
|
+
return state_dir.expanduser().resolve()
|
|
36
|
+
env_value = os.environ.get(ENV_STATE_DIR)
|
|
37
|
+
if env_value:
|
|
38
|
+
return Path(env_value).expanduser().resolve()
|
|
39
|
+
return DEFAULT_STATE_DIR
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_state_db_path(state_dir: Path | None = None) -> Path:
|
|
43
|
+
resolved_state_dir = resolve_state_dir(state_dir)
|
|
44
|
+
default_parent = DEFAULT_STATE_DB_PATH.parent
|
|
45
|
+
if resolved_state_dir == default_parent:
|
|
46
|
+
return DEFAULT_STATE_DB_PATH
|
|
47
|
+
return resolved_state_dir / "state.db"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_handoffs_dir(state_dir: Path | None = None) -> Path:
|
|
51
|
+
resolved_state_dir = resolve_state_dir(state_dir)
|
|
52
|
+
default_parent = DEFAULT_HANDOFFS_DIR.parent
|
|
53
|
+
if resolved_state_dir == default_parent:
|
|
54
|
+
return DEFAULT_HANDOFFS_DIR
|
|
55
|
+
return resolved_state_dir / "handoffs"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ensure_parent_dir(path: Path) -> None:
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def project_hint_path(workspace: Path) -> Path:
|
|
63
|
+
return workspace / PROJECT_HINT_DIRNAME / PROJECT_HINT_FILENAME
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Prompt rendering helpers for manager/worker targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .config import Pack, Profile, SubagentConfig
|
|
8
|
+
from .errors import SubagentError
|
|
9
|
+
|
|
10
|
+
MANAGER_PROMPT_BASE = """You are a manager agent coordinating worker subagents with `subagent` CLI.
|
|
11
|
+
|
|
12
|
+
Read this quick workflow first:
|
|
13
|
+
1. Initialize controller in the workspace:
|
|
14
|
+
subagent controller init --cwd <workspace>
|
|
15
|
+
2. Start a worker:
|
|
16
|
+
subagent worker start --cwd <workspace>
|
|
17
|
+
3. Send work:
|
|
18
|
+
subagent send --worker <worker-id> --text "<instruction>"
|
|
19
|
+
4. Monitor progress:
|
|
20
|
+
subagent watch --worker <worker-id> --ndjson
|
|
21
|
+
5. If approval is requested:
|
|
22
|
+
subagent approve --worker <worker-id> --request <request-id> --option-id <option-id>
|
|
23
|
+
|
|
24
|
+
Operational rules:
|
|
25
|
+
- Keep instructions short, concrete, and outcome-oriented.
|
|
26
|
+
- Use `--json` for machine-readable responses and `--input` for JSON-driven calls.
|
|
27
|
+
- Treat `waiting_approval` as a blocking state; resolve via `approve` or `cancel`.
|
|
28
|
+
- Use handoff flow for continuation: `worker handoff` -> `worker continue`.
|
|
29
|
+
- Prefer strict mode for production; use `--debug-mode` only for local simulation/testing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _render_worker_prompt(profile: Profile, packs: list[Pack]) -> str:
|
|
34
|
+
lines: list[str] = []
|
|
35
|
+
if profile.bootstrap.strip():
|
|
36
|
+
lines.append(profile.bootstrap.strip())
|
|
37
|
+
else:
|
|
38
|
+
lines.append("You are a worker subagent.")
|
|
39
|
+
lines.append("Use STATUS:, ASK:, BLOCKED:, and DONE: prefixes when helpful.")
|
|
40
|
+
lines.append("Keep updates concise and action-oriented.")
|
|
41
|
+
if packs:
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append("Additional pack instructions:")
|
|
44
|
+
for pack in packs:
|
|
45
|
+
lines.append(f"- Pack `{pack.name}`: {pack.description or '(no description)'}")
|
|
46
|
+
if pack.prompt.strip():
|
|
47
|
+
lines.append(pack.prompt.strip())
|
|
48
|
+
return "\n".join(lines).strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_prompt(
|
|
52
|
+
config: SubagentConfig,
|
|
53
|
+
*,
|
|
54
|
+
target: str,
|
|
55
|
+
profile_name: str | None = None,
|
|
56
|
+
pack_names: list[str] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
if target not in {"manager", "worker"}:
|
|
59
|
+
raise SubagentError(
|
|
60
|
+
code="INVALID_ARGUMENT",
|
|
61
|
+
message=f"Unknown prompt target: {target}",
|
|
62
|
+
details={"target": target},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if target == "manager":
|
|
66
|
+
return {
|
|
67
|
+
"target": "manager",
|
|
68
|
+
"prompt": MANAGER_PROMPT_BASE,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
selected_profile_name = profile_name
|
|
72
|
+
if selected_profile_name is None:
|
|
73
|
+
default_profile = config.defaults.get("profile")
|
|
74
|
+
if isinstance(default_profile, str) and default_profile:
|
|
75
|
+
selected_profile_name = default_profile
|
|
76
|
+
if selected_profile_name is None:
|
|
77
|
+
raise SubagentError(
|
|
78
|
+
code="PROFILE_NOT_FOUND",
|
|
79
|
+
message="Profile is required for worker prompt rendering.",
|
|
80
|
+
)
|
|
81
|
+
profile = config.profiles.get(selected_profile_name)
|
|
82
|
+
if profile is None:
|
|
83
|
+
raise SubagentError(
|
|
84
|
+
code="PROFILE_NOT_FOUND",
|
|
85
|
+
message=f"Profile not found: {selected_profile_name}",
|
|
86
|
+
details={"profile": selected_profile_name},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
selected_pack_names = list(pack_names or [])
|
|
90
|
+
if not selected_pack_names:
|
|
91
|
+
selected_pack_names = list(profile.default_packs)
|
|
92
|
+
if not selected_pack_names:
|
|
93
|
+
defaults_packs = config.defaults.get("packs")
|
|
94
|
+
if isinstance(defaults_packs, list):
|
|
95
|
+
selected_pack_names = [str(item) for item in defaults_packs]
|
|
96
|
+
|
|
97
|
+
packs: list[Pack] = []
|
|
98
|
+
for pack_name in selected_pack_names:
|
|
99
|
+
pack = config.packs.get(pack_name)
|
|
100
|
+
if pack is None:
|
|
101
|
+
raise SubagentError(
|
|
102
|
+
code="PACK_NOT_FOUND",
|
|
103
|
+
message=f"Pack not found: {pack_name}",
|
|
104
|
+
details={"pack": pack_name},
|
|
105
|
+
)
|
|
106
|
+
packs.append(pack)
|
|
107
|
+
|
|
108
|
+
prompt = _render_worker_prompt(profile, packs)
|
|
109
|
+
return {
|
|
110
|
+
"target": "worker",
|
|
111
|
+
"profile": profile.name,
|
|
112
|
+
"packs": [pack.name for pack in packs],
|
|
113
|
+
"prompt": prompt,
|
|
114
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Worker runtime process management and IPC helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .config import Launcher, SubagentConfig
|
|
17
|
+
from .errors import SubagentError
|
|
18
|
+
from .state import StateStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def runtime_socket_path(store: StateStore, worker_id: str) -> Path:
|
|
22
|
+
digest = hashlib.sha1(f"{store.db_path}:{worker_id}".encode("utf-8")).hexdigest()[:16]
|
|
23
|
+
return Path("/tmp") / f"subagent-rt-{digest}.sock"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def runtime_log_path(store: StateStore, worker_id: str) -> Path:
|
|
27
|
+
return store.db_path.parent / "runtimes" / f"{worker_id}.log"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _send_socket_request(
|
|
31
|
+
socket_path: Path,
|
|
32
|
+
*,
|
|
33
|
+
method: str,
|
|
34
|
+
params: dict[str, Any],
|
|
35
|
+
timeout_seconds: float,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
39
|
+
client.settimeout(timeout_seconds)
|
|
40
|
+
client.connect(str(socket_path))
|
|
41
|
+
payload = {
|
|
42
|
+
"method": method,
|
|
43
|
+
"params": params,
|
|
44
|
+
}
|
|
45
|
+
request_data = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
46
|
+
client.sendall(request_data)
|
|
47
|
+
chunks: list[bytes] = []
|
|
48
|
+
while True:
|
|
49
|
+
block = client.recv(4096)
|
|
50
|
+
if not block:
|
|
51
|
+
break
|
|
52
|
+
chunks.append(block)
|
|
53
|
+
if b"\n" in block:
|
|
54
|
+
break
|
|
55
|
+
except (FileNotFoundError, ConnectionRefusedError, socket.timeout, OSError) as exc:
|
|
56
|
+
raise SubagentError(
|
|
57
|
+
code="BACKEND_UNAVAILABLE",
|
|
58
|
+
message="Worker runtime is not reachable.",
|
|
59
|
+
details={
|
|
60
|
+
"socketPath": str(socket_path),
|
|
61
|
+
"method": method,
|
|
62
|
+
"error": str(exc),
|
|
63
|
+
},
|
|
64
|
+
) from exc
|
|
65
|
+
raw = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
|
66
|
+
if not raw:
|
|
67
|
+
raise SubagentError(
|
|
68
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
69
|
+
message="Worker runtime returned an empty response.",
|
|
70
|
+
details={"socketPath": str(socket_path), "method": method},
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
parsed = json.loads(raw)
|
|
74
|
+
except json.JSONDecodeError as exc:
|
|
75
|
+
raise SubagentError(
|
|
76
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
77
|
+
message="Worker runtime returned invalid JSON.",
|
|
78
|
+
details={"socketPath": str(socket_path), "method": method, "response": raw},
|
|
79
|
+
) from exc
|
|
80
|
+
if not isinstance(parsed, dict):
|
|
81
|
+
raise SubagentError(
|
|
82
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
83
|
+
message="Worker runtime returned non-object response.",
|
|
84
|
+
details={"socketPath": str(socket_path), "method": method, "response": parsed},
|
|
85
|
+
)
|
|
86
|
+
return parsed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def runtime_request(
|
|
90
|
+
store: StateStore,
|
|
91
|
+
*,
|
|
92
|
+
worker_id: str,
|
|
93
|
+
method: str,
|
|
94
|
+
params: dict[str, Any],
|
|
95
|
+
timeout_seconds: float = 30.0,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
worker = store.get_worker(worker_id)
|
|
98
|
+
if worker is None:
|
|
99
|
+
raise SubagentError(
|
|
100
|
+
code="WORKER_NOT_FOUND",
|
|
101
|
+
message=f"Worker not found: {worker_id}",
|
|
102
|
+
details={"workerId": worker_id},
|
|
103
|
+
)
|
|
104
|
+
socket_path_raw = worker.get("runtime_socket")
|
|
105
|
+
if not isinstance(socket_path_raw, str) or not socket_path_raw:
|
|
106
|
+
raise SubagentError(
|
|
107
|
+
code="BACKEND_UNAVAILABLE",
|
|
108
|
+
message="Worker runtime is not initialized.",
|
|
109
|
+
details={"workerId": worker_id},
|
|
110
|
+
)
|
|
111
|
+
socket_path = Path(socket_path_raw)
|
|
112
|
+
response = _send_socket_request(
|
|
113
|
+
socket_path,
|
|
114
|
+
method=method,
|
|
115
|
+
params=params,
|
|
116
|
+
timeout_seconds=timeout_seconds,
|
|
117
|
+
)
|
|
118
|
+
ok = response.get("ok")
|
|
119
|
+
if ok is not True:
|
|
120
|
+
error_payload = response.get("error")
|
|
121
|
+
if isinstance(error_payload, dict):
|
|
122
|
+
raise SubagentError(
|
|
123
|
+
code=str(error_payload.get("code", "BACKEND_RPC_ERROR")),
|
|
124
|
+
message=str(error_payload.get("message", "Worker runtime request failed.")),
|
|
125
|
+
retryable=bool(error_payload.get("retryable", False)),
|
|
126
|
+
details=error_payload.get("details")
|
|
127
|
+
if isinstance(error_payload.get("details"), dict)
|
|
128
|
+
else {},
|
|
129
|
+
)
|
|
130
|
+
raise SubagentError(
|
|
131
|
+
code="BACKEND_RPC_ERROR",
|
|
132
|
+
message="Worker runtime request failed.",
|
|
133
|
+
details={"response": response},
|
|
134
|
+
)
|
|
135
|
+
result = response.get("result")
|
|
136
|
+
if not isinstance(result, dict):
|
|
137
|
+
raise SubagentError(
|
|
138
|
+
code="BACKEND_PROTOCOL_ERROR",
|
|
139
|
+
message="Worker runtime response missing result object.",
|
|
140
|
+
details={"response": response},
|
|
141
|
+
)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _runtime_launch_command(
|
|
146
|
+
*,
|
|
147
|
+
store: StateStore,
|
|
148
|
+
worker_id: str,
|
|
149
|
+
socket_path: Path,
|
|
150
|
+
launcher: Launcher,
|
|
151
|
+
cwd: str,
|
|
152
|
+
) -> list[str]:
|
|
153
|
+
return [
|
|
154
|
+
sys.executable,
|
|
155
|
+
"-m",
|
|
156
|
+
"subagent.worker_runtime",
|
|
157
|
+
"--db-path",
|
|
158
|
+
str(store.db_path),
|
|
159
|
+
"--worker-id",
|
|
160
|
+
worker_id,
|
|
161
|
+
"--socket-path",
|
|
162
|
+
str(socket_path),
|
|
163
|
+
"--launcher-command",
|
|
164
|
+
launcher.command,
|
|
165
|
+
"--launcher-args-json",
|
|
166
|
+
json.dumps(launcher.args, ensure_ascii=False),
|
|
167
|
+
"--launcher-env-json",
|
|
168
|
+
json.dumps(launcher.env, ensure_ascii=False),
|
|
169
|
+
"--cwd",
|
|
170
|
+
cwd,
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def launch_worker_runtime(
|
|
175
|
+
store: StateStore,
|
|
176
|
+
*,
|
|
177
|
+
worker_id: str,
|
|
178
|
+
launcher: Launcher,
|
|
179
|
+
cwd: str,
|
|
180
|
+
timeout_seconds: float = 10.0,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
socket_path = runtime_socket_path(store, worker_id)
|
|
183
|
+
if socket_path.exists():
|
|
184
|
+
socket_path.unlink()
|
|
185
|
+
|
|
186
|
+
log_path = runtime_log_path(store, worker_id)
|
|
187
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
command = _runtime_launch_command(
|
|
189
|
+
store=store,
|
|
190
|
+
worker_id=worker_id,
|
|
191
|
+
socket_path=socket_path,
|
|
192
|
+
launcher=launcher,
|
|
193
|
+
cwd=cwd,
|
|
194
|
+
)
|
|
195
|
+
with log_path.open("a", encoding="utf-8") as log_file:
|
|
196
|
+
process = subprocess.Popen(
|
|
197
|
+
command,
|
|
198
|
+
stdout=log_file,
|
|
199
|
+
stderr=log_file,
|
|
200
|
+
stdin=subprocess.DEVNULL,
|
|
201
|
+
start_new_session=True,
|
|
202
|
+
text=True,
|
|
203
|
+
env=dict(os.environ),
|
|
204
|
+
)
|
|
205
|
+
store.set_worker_runtime_endpoint(
|
|
206
|
+
worker_id,
|
|
207
|
+
runtime_pid=process.pid,
|
|
208
|
+
runtime_socket=str(socket_path),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
deadline = time.monotonic() + timeout_seconds
|
|
212
|
+
last_error: str | None = None
|
|
213
|
+
while time.monotonic() < deadline:
|
|
214
|
+
if process.poll() is not None:
|
|
215
|
+
last_error = f"runtime exited with code {process.returncode}"
|
|
216
|
+
break
|
|
217
|
+
if socket_path.exists():
|
|
218
|
+
try:
|
|
219
|
+
response = _send_socket_request(
|
|
220
|
+
socket_path,
|
|
221
|
+
method="ping",
|
|
222
|
+
params={},
|
|
223
|
+
timeout_seconds=1.0,
|
|
224
|
+
)
|
|
225
|
+
except SubagentError as error:
|
|
226
|
+
last_error = error.message
|
|
227
|
+
else:
|
|
228
|
+
if response.get("ok") is True:
|
|
229
|
+
return {
|
|
230
|
+
"pid": process.pid,
|
|
231
|
+
"socketPath": str(socket_path),
|
|
232
|
+
"logPath": str(log_path),
|
|
233
|
+
}
|
|
234
|
+
time.sleep(0.1)
|
|
235
|
+
try:
|
|
236
|
+
process.terminate()
|
|
237
|
+
process.wait(timeout=1.0)
|
|
238
|
+
except Exception: # pragma: no cover - cleanup best effort
|
|
239
|
+
try:
|
|
240
|
+
process.kill()
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
store.clear_worker_runtime_endpoint(worker_id)
|
|
244
|
+
raise SubagentError(
|
|
245
|
+
code="BACKEND_UNAVAILABLE",
|
|
246
|
+
message="Failed to start worker runtime.",
|
|
247
|
+
details={
|
|
248
|
+
"workerId": worker_id,
|
|
249
|
+
"command": command,
|
|
250
|
+
"socketPath": str(socket_path),
|
|
251
|
+
"logPath": str(log_path),
|
|
252
|
+
"error": last_error,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def restart_worker_runtime(
|
|
258
|
+
store: StateStore,
|
|
259
|
+
config: SubagentConfig,
|
|
260
|
+
*,
|
|
261
|
+
worker_id: str,
|
|
262
|
+
timeout_seconds: float = 10.0,
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
worker = store.get_worker(worker_id)
|
|
265
|
+
if worker is None:
|
|
266
|
+
raise SubagentError(
|
|
267
|
+
code="WORKER_NOT_FOUND",
|
|
268
|
+
message=f"Worker not found: {worker_id}",
|
|
269
|
+
details={"workerId": worker_id},
|
|
270
|
+
)
|
|
271
|
+
launcher_name = worker.get("launcher")
|
|
272
|
+
if not isinstance(launcher_name, str) or not launcher_name:
|
|
273
|
+
raise SubagentError(
|
|
274
|
+
code="BACKEND_UNAVAILABLE",
|
|
275
|
+
message="Worker launcher is not set.",
|
|
276
|
+
details={"workerId": worker_id},
|
|
277
|
+
)
|
|
278
|
+
launcher = config.launchers.get(launcher_name)
|
|
279
|
+
if launcher is None:
|
|
280
|
+
raise SubagentError(
|
|
281
|
+
code="LAUNCHER_NOT_FOUND",
|
|
282
|
+
message=f"Launcher not found: {launcher_name}",
|
|
283
|
+
details={"workerId": worker_id, "launcher": launcher_name},
|
|
284
|
+
)
|
|
285
|
+
if launcher.backend_kind != "acp-stdio":
|
|
286
|
+
raise SubagentError(
|
|
287
|
+
code="BACKEND_UNAVAILABLE",
|
|
288
|
+
message=f"Unsupported backend kind for runtime: {launcher.backend_kind}",
|
|
289
|
+
details={"workerId": worker_id, "launcher": launcher_name, "backendKind": launcher.backend_kind},
|
|
290
|
+
)
|
|
291
|
+
command = launcher.command
|
|
292
|
+
if shutil.which(command) is None and "/" not in command:
|
|
293
|
+
raise SubagentError(
|
|
294
|
+
code="BACKEND_UNAVAILABLE",
|
|
295
|
+
message=f"Launcher command not available: {command}",
|
|
296
|
+
details={"workerId": worker_id, "launcher": launcher_name, "command": command},
|
|
297
|
+
)
|
|
298
|
+
if "/" in command and not Path(command).expanduser().exists():
|
|
299
|
+
raise SubagentError(
|
|
300
|
+
code="BACKEND_UNAVAILABLE",
|
|
301
|
+
message=f"Launcher command not available: {command}",
|
|
302
|
+
details={"workerId": worker_id, "launcher": launcher_name, "command": command},
|
|
303
|
+
)
|
|
304
|
+
worker_cwd = worker.get("cwd")
|
|
305
|
+
if not isinstance(worker_cwd, str) or not worker_cwd:
|
|
306
|
+
raise SubagentError(
|
|
307
|
+
code="BACKEND_UNAVAILABLE",
|
|
308
|
+
message="Worker cwd is not available.",
|
|
309
|
+
details={"workerId": worker_id},
|
|
310
|
+
)
|
|
311
|
+
return launch_worker_runtime(
|
|
312
|
+
store,
|
|
313
|
+
worker_id=worker_id,
|
|
314
|
+
launcher=launcher,
|
|
315
|
+
cwd=worker_cwd,
|
|
316
|
+
timeout_seconds=timeout_seconds,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def stop_worker_runtime(
|
|
321
|
+
store: StateStore,
|
|
322
|
+
*,
|
|
323
|
+
worker_id: str,
|
|
324
|
+
reason: str = "worker stopped",
|
|
325
|
+
) -> None:
|
|
326
|
+
worker = store.get_worker(worker_id)
|
|
327
|
+
if worker is None:
|
|
328
|
+
return
|
|
329
|
+
runtime_socket = worker.get("runtime_socket")
|
|
330
|
+
if isinstance(runtime_socket, str) and runtime_socket:
|
|
331
|
+
try:
|
|
332
|
+
runtime_request(
|
|
333
|
+
store,
|
|
334
|
+
worker_id=worker_id,
|
|
335
|
+
method="stop",
|
|
336
|
+
params={"reason": reason},
|
|
337
|
+
timeout_seconds=5.0,
|
|
338
|
+
)
|
|
339
|
+
except SubagentError:
|
|
340
|
+
# Stopping should be best-effort.
|
|
341
|
+
pass
|
|
342
|
+
store.clear_worker_runtime_endpoint(worker_id)
|