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/config.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Config model and loader for launchers / profiles / packs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .errors import SubagentError
|
|
11
|
+
from .paths import resolve_config_path
|
|
12
|
+
from .simple_yaml import ParseError as SimpleYamlParseError
|
|
13
|
+
from .simple_yaml import parse_yaml_subset
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ensure_mapping(value: Any, *, field_name: str) -> Mapping[str, Any]:
|
|
17
|
+
if isinstance(value, Mapping):
|
|
18
|
+
return value
|
|
19
|
+
raise SubagentError(
|
|
20
|
+
code="CONFIG_PARSE_ERROR",
|
|
21
|
+
message=f"`{field_name}` must be a mapping",
|
|
22
|
+
details={"field": field_name},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ensure_string_list(value: Any, *, field_name: str) -> list[str]:
|
|
27
|
+
if value is None:
|
|
28
|
+
return []
|
|
29
|
+
if not isinstance(value, list):
|
|
30
|
+
raise SubagentError(
|
|
31
|
+
code="CONFIG_PARSE_ERROR",
|
|
32
|
+
message=f"`{field_name}` must be a list",
|
|
33
|
+
details={"field": field_name},
|
|
34
|
+
)
|
|
35
|
+
string_values: list[str] = []
|
|
36
|
+
for idx, item in enumerate(value):
|
|
37
|
+
if not isinstance(item, str):
|
|
38
|
+
raise SubagentError(
|
|
39
|
+
code="CONFIG_PARSE_ERROR",
|
|
40
|
+
message=f"`{field_name}[{idx}]` must be a string",
|
|
41
|
+
details={"field": field_name, "index": idx},
|
|
42
|
+
)
|
|
43
|
+
string_values.append(item)
|
|
44
|
+
return string_values
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ensure_string_map(value: Any, *, field_name: str) -> dict[str, str]:
|
|
48
|
+
if value is None:
|
|
49
|
+
return {}
|
|
50
|
+
mapping = _ensure_mapping(value, field_name=field_name)
|
|
51
|
+
string_map: dict[str, str] = {}
|
|
52
|
+
for key, item in mapping.items():
|
|
53
|
+
if not isinstance(key, str):
|
|
54
|
+
raise SubagentError(
|
|
55
|
+
code="CONFIG_PARSE_ERROR",
|
|
56
|
+
message=f"`{field_name}` key must be string",
|
|
57
|
+
details={"field": field_name},
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(item, str):
|
|
60
|
+
raise SubagentError(
|
|
61
|
+
code="CONFIG_PARSE_ERROR",
|
|
62
|
+
message=f"`{field_name}.{key}` must be string",
|
|
63
|
+
details={"field": field_name, "key": key},
|
|
64
|
+
)
|
|
65
|
+
string_map[key] = item
|
|
66
|
+
return string_map
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(slots=True)
|
|
70
|
+
class Launcher:
|
|
71
|
+
name: str
|
|
72
|
+
backend_kind: str
|
|
73
|
+
command: str
|
|
74
|
+
args: list[str] = field(default_factory=list)
|
|
75
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"name": self.name,
|
|
80
|
+
"backend": {"kind": self.backend_kind},
|
|
81
|
+
"command": self.command,
|
|
82
|
+
"args": self.args,
|
|
83
|
+
"env": self.env,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class Profile:
|
|
89
|
+
name: str
|
|
90
|
+
prompt_language: str = "en"
|
|
91
|
+
response_language: str = "same_as_manager"
|
|
92
|
+
auto_handoff: str | None = None
|
|
93
|
+
policy_preset: str | None = None
|
|
94
|
+
default_packs: list[str] = field(default_factory=list)
|
|
95
|
+
bootstrap: str = ""
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"name": self.name,
|
|
100
|
+
"promptLanguage": self.prompt_language,
|
|
101
|
+
"responseLanguage": self.response_language,
|
|
102
|
+
"autoHandoff": self.auto_handoff,
|
|
103
|
+
"policyPreset": self.policy_preset,
|
|
104
|
+
"defaultPacks": self.default_packs,
|
|
105
|
+
"bootstrap": self.bootstrap,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class Pack:
|
|
111
|
+
name: str
|
|
112
|
+
description: str = ""
|
|
113
|
+
prompt: str = ""
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"name": self.name,
|
|
118
|
+
"description": self.description,
|
|
119
|
+
"prompt": self.prompt,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(slots=True)
|
|
124
|
+
class SubagentConfig:
|
|
125
|
+
path: Path
|
|
126
|
+
loaded: bool
|
|
127
|
+
launchers: dict[str, Launcher] = field(default_factory=dict)
|
|
128
|
+
profiles: dict[str, Profile] = field(default_factory=dict)
|
|
129
|
+
packs: dict[str, Pack] = field(default_factory=dict)
|
|
130
|
+
policy_presets: dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
defaults: dict[str, Any] = field(default_factory=dict)
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
return {
|
|
135
|
+
"path": str(self.path),
|
|
136
|
+
"loaded": self.loaded,
|
|
137
|
+
"launchers": {name: launcher.to_dict() for name, launcher in self.launchers.items()},
|
|
138
|
+
"profiles": {name: profile.to_dict() for name, profile in self.profiles.items()},
|
|
139
|
+
"packs": {name: pack.to_dict() for name, pack in self.packs.items()},
|
|
140
|
+
"policyPresets": self.policy_presets,
|
|
141
|
+
"defaults": self.defaults,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_raw_config(config_path: Path) -> Mapping[str, Any]:
|
|
146
|
+
contents = config_path.read_text(encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
# Use YAML if available; fallback to JSON for sandbox/offline bootstrap.
|
|
149
|
+
try:
|
|
150
|
+
import yaml # type: ignore[import-not-found]
|
|
151
|
+
except ModuleNotFoundError:
|
|
152
|
+
try:
|
|
153
|
+
parsed = json.loads(contents)
|
|
154
|
+
except json.JSONDecodeError as exc:
|
|
155
|
+
try:
|
|
156
|
+
parsed = parse_yaml_subset(contents)
|
|
157
|
+
except SimpleYamlParseError as yaml_exc:
|
|
158
|
+
raise SubagentError(
|
|
159
|
+
code="CONFIG_PARSE_ERROR",
|
|
160
|
+
message=(
|
|
161
|
+
"Failed to parse config as JSON or YAML subset. "
|
|
162
|
+
"Install PyYAML for full YAML support."
|
|
163
|
+
),
|
|
164
|
+
details={
|
|
165
|
+
"path": str(config_path),
|
|
166
|
+
"jsonError": str(exc),
|
|
167
|
+
"yamlError": str(yaml_exc),
|
|
168
|
+
},
|
|
169
|
+
) from yaml_exc
|
|
170
|
+
else:
|
|
171
|
+
try:
|
|
172
|
+
parsed = yaml.safe_load(contents)
|
|
173
|
+
except Exception as exc: # pragma: no cover - parser raises varied exceptions
|
|
174
|
+
raise SubagentError(
|
|
175
|
+
code="CONFIG_PARSE_ERROR",
|
|
176
|
+
message=f"Failed to parse config file: {config_path}",
|
|
177
|
+
details={"path": str(config_path), "error": str(exc)},
|
|
178
|
+
) from exc
|
|
179
|
+
|
|
180
|
+
if parsed is None:
|
|
181
|
+
parsed = {}
|
|
182
|
+
return _ensure_mapping(parsed, field_name="root")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_launchers(raw: Any) -> dict[str, Launcher]:
|
|
186
|
+
mapping = _ensure_mapping(raw or {}, field_name="launchers")
|
|
187
|
+
launchers: dict[str, Launcher] = {}
|
|
188
|
+
for name, payload in mapping.items():
|
|
189
|
+
entry = _ensure_mapping(payload, field_name=f"launchers.{name}")
|
|
190
|
+
backend = _ensure_mapping(entry.get("backend", {}), field_name=f"launchers.{name}.backend")
|
|
191
|
+
command = entry.get("command")
|
|
192
|
+
if not isinstance(command, str) or not command.strip():
|
|
193
|
+
raise SubagentError(
|
|
194
|
+
code="CONFIG_PARSE_ERROR",
|
|
195
|
+
message=f"`launchers.{name}.command` must be a non-empty string",
|
|
196
|
+
details={"launcher": name},
|
|
197
|
+
)
|
|
198
|
+
launchers[name] = Launcher(
|
|
199
|
+
name=name,
|
|
200
|
+
backend_kind=str(backend.get("kind", "acp-stdio")),
|
|
201
|
+
command=command,
|
|
202
|
+
args=_ensure_string_list(entry.get("args"), field_name=f"launchers.{name}.args"),
|
|
203
|
+
env=_ensure_string_map(entry.get("env"), field_name=f"launchers.{name}.env"),
|
|
204
|
+
)
|
|
205
|
+
return launchers
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_profiles(raw: Any) -> dict[str, Profile]:
|
|
209
|
+
mapping = _ensure_mapping(raw or {}, field_name="profiles")
|
|
210
|
+
profiles: dict[str, Profile] = {}
|
|
211
|
+
for name, payload in mapping.items():
|
|
212
|
+
entry = _ensure_mapping(payload, field_name=f"profiles.{name}")
|
|
213
|
+
prompt_language = entry.get("promptLanguage", "en")
|
|
214
|
+
response_language = entry.get("responseLanguage", "same_as_manager")
|
|
215
|
+
auto_handoff = entry.get("autoHandoff")
|
|
216
|
+
policy_preset = entry.get("policyPreset")
|
|
217
|
+
bootstrap = entry.get("bootstrap", "")
|
|
218
|
+
if not isinstance(prompt_language, str) or not isinstance(response_language, str):
|
|
219
|
+
raise SubagentError(
|
|
220
|
+
code="CONFIG_PARSE_ERROR",
|
|
221
|
+
message=f"`profiles.{name}` language fields must be strings",
|
|
222
|
+
details={"profile": name},
|
|
223
|
+
)
|
|
224
|
+
if auto_handoff is not None and not isinstance(auto_handoff, str):
|
|
225
|
+
raise SubagentError(
|
|
226
|
+
code="CONFIG_PARSE_ERROR",
|
|
227
|
+
message=f"`profiles.{name}.autoHandoff` must be string or null",
|
|
228
|
+
details={"profile": name},
|
|
229
|
+
)
|
|
230
|
+
if policy_preset is not None and not isinstance(policy_preset, str):
|
|
231
|
+
raise SubagentError(
|
|
232
|
+
code="CONFIG_PARSE_ERROR",
|
|
233
|
+
message=f"`profiles.{name}.policyPreset` must be string or null",
|
|
234
|
+
details={"profile": name},
|
|
235
|
+
)
|
|
236
|
+
if not isinstance(bootstrap, str):
|
|
237
|
+
raise SubagentError(
|
|
238
|
+
code="CONFIG_PARSE_ERROR",
|
|
239
|
+
message=f"`profiles.{name}.bootstrap` must be a string",
|
|
240
|
+
details={"profile": name},
|
|
241
|
+
)
|
|
242
|
+
profiles[name] = Profile(
|
|
243
|
+
name=name,
|
|
244
|
+
prompt_language=prompt_language,
|
|
245
|
+
response_language=response_language,
|
|
246
|
+
auto_handoff=auto_handoff,
|
|
247
|
+
policy_preset=policy_preset,
|
|
248
|
+
default_packs=_ensure_string_list(
|
|
249
|
+
entry.get("defaultPacks"),
|
|
250
|
+
field_name=f"profiles.{name}.defaultPacks",
|
|
251
|
+
),
|
|
252
|
+
bootstrap=bootstrap,
|
|
253
|
+
)
|
|
254
|
+
return profiles
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_packs(raw: Any) -> dict[str, Pack]:
|
|
258
|
+
mapping = _ensure_mapping(raw or {}, field_name="packs")
|
|
259
|
+
packs: dict[str, Pack] = {}
|
|
260
|
+
for name, payload in mapping.items():
|
|
261
|
+
entry = _ensure_mapping(payload, field_name=f"packs.{name}")
|
|
262
|
+
description = entry.get("description", "")
|
|
263
|
+
prompt = entry.get("prompt", "")
|
|
264
|
+
if not isinstance(description, str) or not isinstance(prompt, str):
|
|
265
|
+
raise SubagentError(
|
|
266
|
+
code="CONFIG_PARSE_ERROR",
|
|
267
|
+
message=f"`packs.{name}` fields must be strings",
|
|
268
|
+
details={"pack": name},
|
|
269
|
+
)
|
|
270
|
+
packs[name] = Pack(name=name, description=description, prompt=prompt)
|
|
271
|
+
return packs
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_config(config_path: Path | None = None) -> SubagentConfig:
|
|
275
|
+
resolved_path = resolve_config_path(config_path)
|
|
276
|
+
if not resolved_path.exists():
|
|
277
|
+
return SubagentConfig(path=resolved_path, loaded=False)
|
|
278
|
+
raw = _load_raw_config(resolved_path)
|
|
279
|
+
policy_presets = raw.get("policyPresets", {})
|
|
280
|
+
defaults = raw.get("defaults", {})
|
|
281
|
+
if policy_presets is None:
|
|
282
|
+
policy_presets = {}
|
|
283
|
+
if defaults is None:
|
|
284
|
+
defaults = {}
|
|
285
|
+
if not isinstance(policy_presets, dict):
|
|
286
|
+
raise SubagentError(
|
|
287
|
+
code="CONFIG_PARSE_ERROR",
|
|
288
|
+
message="`policyPresets` must be a mapping",
|
|
289
|
+
details={"field": "policyPresets"},
|
|
290
|
+
)
|
|
291
|
+
if not isinstance(defaults, dict):
|
|
292
|
+
raise SubagentError(
|
|
293
|
+
code="CONFIG_PARSE_ERROR",
|
|
294
|
+
message="`defaults` must be a mapping",
|
|
295
|
+
details={"field": "defaults"},
|
|
296
|
+
)
|
|
297
|
+
return SubagentConfig(
|
|
298
|
+
path=resolved_path,
|
|
299
|
+
loaded=True,
|
|
300
|
+
launchers=_parse_launchers(raw.get("launchers")),
|
|
301
|
+
profiles=_parse_profiles(raw.get("profiles")),
|
|
302
|
+
packs=_parse_packs(raw.get("packs")),
|
|
303
|
+
policy_presets=policy_presets,
|
|
304
|
+
defaults=defaults,
|
|
305
|
+
)
|
subagent/constants.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared constants used across the subagent CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
APP_NAME = "subagent"
|
|
6
|
+
SCHEMA_VERSION = "v1"
|
|
7
|
+
|
|
8
|
+
ENV_CONFIG_PATH = "SUBAGENT_CONFIG"
|
|
9
|
+
ENV_STATE_DIR = "SUBAGENT_STATE_DIR"
|
|
10
|
+
ENV_CTL_ID = "SUBAGENT_CTL_ID"
|
|
11
|
+
ENV_CTL_EPOCH = "SUBAGENT_CTL_EPOCH"
|
|
12
|
+
ENV_CTL_TOKEN = "SUBAGENT_CTL_TOKEN"
|
|
13
|
+
|
|
14
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / APP_NAME / "config.yaml"
|
|
15
|
+
DEFAULT_STATE_DIR = Path.home() / ".local" / "share" / APP_NAME
|
|
16
|
+
DEFAULT_STATE_DB_PATH = DEFAULT_STATE_DIR / "state.db"
|
|
17
|
+
DAEMON_STATUS_PATH = DEFAULT_STATE_DIR / "subagentd-status.json"
|
|
18
|
+
DEFAULT_HANDOFFS_DIR = DEFAULT_STATE_DIR / "handoffs"
|
|
19
|
+
|
|
20
|
+
PROJECT_HINT_DIRNAME = ".subagent"
|
|
21
|
+
PROJECT_HINT_FILENAME = "controller.json"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Controller ownership orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .constants import ENV_CTL_EPOCH, ENV_CTL_ID, ENV_CTL_TOKEN
|
|
12
|
+
from .errors import SubagentError
|
|
13
|
+
from .hints import read_project_hint, write_project_hint
|
|
14
|
+
from .paths import resolve_workspace_path
|
|
15
|
+
from .state import ControllerHandle, StateStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _generate_controller_id() -> str:
|
|
19
|
+
return f"ctl_{uuid.uuid4().hex[:10]}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class InitializedController:
|
|
24
|
+
controller_id: str
|
|
25
|
+
label: str
|
|
26
|
+
workspace_key: str
|
|
27
|
+
hint_path: str
|
|
28
|
+
owner: ControllerHandle
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"controllerId": self.controller_id,
|
|
33
|
+
"label": self.label,
|
|
34
|
+
"workspaceKey": self.workspace_key,
|
|
35
|
+
"hintPath": self.hint_path,
|
|
36
|
+
"owner": self.owner.to_dict(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_controller_id(
|
|
41
|
+
store: StateStore,
|
|
42
|
+
workspace: Path,
|
|
43
|
+
*,
|
|
44
|
+
explicit_controller_id: str | None = None,
|
|
45
|
+
) -> str | None:
|
|
46
|
+
if explicit_controller_id:
|
|
47
|
+
return explicit_controller_id
|
|
48
|
+
hint = read_project_hint(workspace)
|
|
49
|
+
if hint and isinstance(hint.get("controllerId"), str):
|
|
50
|
+
return str(hint["controllerId"])
|
|
51
|
+
row = store.get_controller_by_workspace(str(workspace))
|
|
52
|
+
if row:
|
|
53
|
+
return str(row["controller_id"])
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_controller(
|
|
58
|
+
store: StateStore,
|
|
59
|
+
*,
|
|
60
|
+
workspace: Path,
|
|
61
|
+
controller_id: str | None,
|
|
62
|
+
label: str,
|
|
63
|
+
pid: int | None = None,
|
|
64
|
+
) -> InitializedController:
|
|
65
|
+
resolved_workspace = resolve_workspace_path(workspace)
|
|
66
|
+
existing_id = resolve_controller_id(
|
|
67
|
+
store,
|
|
68
|
+
resolved_workspace,
|
|
69
|
+
explicit_controller_id=controller_id,
|
|
70
|
+
)
|
|
71
|
+
target_controller_id = existing_id or _generate_controller_id()
|
|
72
|
+
controller_row = store.register_controller(
|
|
73
|
+
controller_id=target_controller_id,
|
|
74
|
+
label=label,
|
|
75
|
+
workspace_key=str(resolved_workspace),
|
|
76
|
+
)
|
|
77
|
+
owner = store.acquire_owner_handle(
|
|
78
|
+
target_controller_id,
|
|
79
|
+
takeover=False,
|
|
80
|
+
pid=pid if pid is not None else os.getpid(),
|
|
81
|
+
)
|
|
82
|
+
hint_path = write_project_hint(
|
|
83
|
+
resolved_workspace,
|
|
84
|
+
controller_id=target_controller_id,
|
|
85
|
+
label=str(controller_row["label"]),
|
|
86
|
+
)
|
|
87
|
+
return InitializedController(
|
|
88
|
+
controller_id=target_controller_id,
|
|
89
|
+
label=str(controller_row["label"]),
|
|
90
|
+
workspace_key=str(resolved_workspace),
|
|
91
|
+
hint_path=str(hint_path),
|
|
92
|
+
owner=owner,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def attach_controller(
|
|
97
|
+
store: StateStore,
|
|
98
|
+
*,
|
|
99
|
+
workspace: Path,
|
|
100
|
+
controller_id: str | None,
|
|
101
|
+
takeover: bool,
|
|
102
|
+
pid: int | None = None,
|
|
103
|
+
) -> InitializedController:
|
|
104
|
+
resolved_workspace = resolve_workspace_path(workspace)
|
|
105
|
+
target_controller_id = resolve_controller_id(
|
|
106
|
+
store,
|
|
107
|
+
resolved_workspace,
|
|
108
|
+
explicit_controller_id=controller_id,
|
|
109
|
+
)
|
|
110
|
+
if target_controller_id is None:
|
|
111
|
+
raise SubagentError(
|
|
112
|
+
code="CONTROLLER_NOT_FOUND",
|
|
113
|
+
message=(
|
|
114
|
+
"Controller could not be resolved. Specify --controller-id "
|
|
115
|
+
"or run `subagent controller init` first."
|
|
116
|
+
),
|
|
117
|
+
details={"workspaceKey": str(resolved_workspace)},
|
|
118
|
+
)
|
|
119
|
+
controller_row = store.get_controller(target_controller_id)
|
|
120
|
+
if controller_row is None:
|
|
121
|
+
raise SubagentError(
|
|
122
|
+
code="CONTROLLER_NOT_FOUND",
|
|
123
|
+
message=f"Controller not found: {target_controller_id}",
|
|
124
|
+
details={"controllerId": target_controller_id},
|
|
125
|
+
)
|
|
126
|
+
owner = store.acquire_owner_handle(
|
|
127
|
+
target_controller_id,
|
|
128
|
+
takeover=takeover,
|
|
129
|
+
pid=pid if pid is not None else os.getpid(),
|
|
130
|
+
)
|
|
131
|
+
hint_path = write_project_hint(
|
|
132
|
+
resolved_workspace,
|
|
133
|
+
controller_id=target_controller_id,
|
|
134
|
+
label=str(controller_row["label"]),
|
|
135
|
+
)
|
|
136
|
+
return InitializedController(
|
|
137
|
+
controller_id=target_controller_id,
|
|
138
|
+
label=str(controller_row["label"]),
|
|
139
|
+
workspace_key=str(resolved_workspace),
|
|
140
|
+
hint_path=str(hint_path),
|
|
141
|
+
owner=owner,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_env_handle() -> dict[str, Any] | None:
|
|
146
|
+
controller_id = os.environ.get(ENV_CTL_ID)
|
|
147
|
+
epoch_text = os.environ.get(ENV_CTL_EPOCH)
|
|
148
|
+
token = os.environ.get(ENV_CTL_TOKEN)
|
|
149
|
+
if not controller_id or not epoch_text or not token:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
epoch = int(epoch_text)
|
|
153
|
+
except ValueError:
|
|
154
|
+
return {
|
|
155
|
+
"controllerId": controller_id,
|
|
156
|
+
"epochRaw": epoch_text,
|
|
157
|
+
"token": token,
|
|
158
|
+
"valid": False,
|
|
159
|
+
"reason": "ENV_EPOCH_NOT_INTEGER",
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
"controllerId": controller_id,
|
|
163
|
+
"epoch": epoch,
|
|
164
|
+
"token": token,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def shell_env_exports(handle: ControllerHandle) -> list[str]:
|
|
169
|
+
return [
|
|
170
|
+
f"export {ENV_CTL_ID}={handle.controller_id}",
|
|
171
|
+
f"export {ENV_CTL_EPOCH}={handle.epoch}",
|
|
172
|
+
f"export {ENV_CTL_TOKEN}={handle.token}",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def release_controller(
|
|
177
|
+
store: StateStore,
|
|
178
|
+
*,
|
|
179
|
+
workspace: Path,
|
|
180
|
+
controller_id: str | None,
|
|
181
|
+
force: bool,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
resolved_workspace = resolve_workspace_path(workspace)
|
|
184
|
+
target_controller_id = resolve_controller_id(
|
|
185
|
+
store,
|
|
186
|
+
resolved_workspace,
|
|
187
|
+
explicit_controller_id=controller_id,
|
|
188
|
+
)
|
|
189
|
+
if target_controller_id is None:
|
|
190
|
+
raise SubagentError(
|
|
191
|
+
code="CONTROLLER_NOT_FOUND",
|
|
192
|
+
message="Controller could not be resolved for release.",
|
|
193
|
+
details={"workspaceKey": str(resolved_workspace)},
|
|
194
|
+
)
|
|
195
|
+
env_handle = read_env_handle()
|
|
196
|
+
epoch: int | None = None
|
|
197
|
+
token: str | None = None
|
|
198
|
+
if env_handle is not None and "epoch" in env_handle and "token" in env_handle:
|
|
199
|
+
if str(env_handle.get("controllerId")) == target_controller_id:
|
|
200
|
+
epoch = int(env_handle["epoch"])
|
|
201
|
+
token = str(env_handle["token"])
|
|
202
|
+
return store.release_owner_handle(
|
|
203
|
+
controller_id=target_controller_id,
|
|
204
|
+
epoch=epoch,
|
|
205
|
+
token=token,
|
|
206
|
+
force=force,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def recover_controllers(store: StateStore, *, workspace: Path | None = None) -> dict[str, Any]:
|
|
211
|
+
workspace_key = str(resolve_workspace_path(workspace)) if workspace is not None else None
|
|
212
|
+
controllers = store.list_controllers()
|
|
213
|
+
active_instances = store.list_active_instances()
|
|
214
|
+
active_by_controller = {str(item["controller_id"]): item for item in active_instances}
|
|
215
|
+
|
|
216
|
+
recovered: list[dict[str, Any]] = []
|
|
217
|
+
for controller in controllers:
|
|
218
|
+
controller_id = str(controller["controller_id"])
|
|
219
|
+
if workspace_key and str(controller["workspace_key"]) != workspace_key:
|
|
220
|
+
continue
|
|
221
|
+
active = active_by_controller.get(controller_id)
|
|
222
|
+
owner_alive: bool | None = None
|
|
223
|
+
if active is not None and active.get("pid") is not None:
|
|
224
|
+
pid = int(active["pid"])
|
|
225
|
+
try:
|
|
226
|
+
os.kill(pid, 0)
|
|
227
|
+
except OSError:
|
|
228
|
+
owner_alive = False
|
|
229
|
+
else:
|
|
230
|
+
owner_alive = True
|
|
231
|
+
if active is None:
|
|
232
|
+
state = "dormant"
|
|
233
|
+
elif owner_alive is True:
|
|
234
|
+
state = "active"
|
|
235
|
+
elif owner_alive is False:
|
|
236
|
+
state = "orphaned"
|
|
237
|
+
else:
|
|
238
|
+
state = "conflicted"
|
|
239
|
+
recovered.append(
|
|
240
|
+
{
|
|
241
|
+
"controllerId": controller_id,
|
|
242
|
+
"label": controller["label"],
|
|
243
|
+
"workspaceKey": controller["workspace_key"],
|
|
244
|
+
"state": state,
|
|
245
|
+
"activeOwner": (
|
|
246
|
+
{
|
|
247
|
+
"instanceId": active["instance_id"],
|
|
248
|
+
"epoch": active["epoch"],
|
|
249
|
+
"pid": active["pid"],
|
|
250
|
+
"createdAt": active["created_at"],
|
|
251
|
+
"alive": owner_alive,
|
|
252
|
+
}
|
|
253
|
+
if active is not None
|
|
254
|
+
else None
|
|
255
|
+
),
|
|
256
|
+
"suggestedAction": (
|
|
257
|
+
f"subagent controller attach --cwd {controller['workspace_key']} --takeover"
|
|
258
|
+
if state in {"orphaned", "conflicted"}
|
|
259
|
+
else None
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
return {
|
|
264
|
+
"count": len(recovered),
|
|
265
|
+
"items": recovered,
|
|
266
|
+
"workspaceFilter": workspace_key,
|
|
267
|
+
}
|