@opengsd/gsd-pi 1.4.0-dev.5fc22c6f → 1.4.0-dev.c18009cd
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.
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/stream-adapter.js +58 -4
- package/dist/resources/extensions/gsd/artifact-verification.js +17 -5
- package/dist/resources/extensions/gsd/auto/closeout.js +45 -42
- package/dist/resources/extensions/gsd/auto/finalize.js +9 -0
- package/dist/resources/extensions/gsd/auto/orchestrator.js +1 -1
- package/dist/resources/extensions/gsd/auto/run-unit.js +5 -2
- package/dist/resources/extensions/gsd/auto/schedule-wakeup.js +3 -0
- package/dist/resources/extensions/gsd/auto.js +10 -3
- package/dist/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.js +58 -9
- package/dist/resources/extensions/gsd/doctor-engine-checks.js +174 -16
- package/dist/resources/extensions/gsd/gsd-db.js +52 -0
- package/dist/resources/extensions/gsd/migrate-external.js +13 -4
- package/dist/resources/extensions/gsd/milestone-merge-transaction.js +3 -3
- package/dist/resources/extensions/gsd/repo-identity.js +16 -0
- package/dist/resources/extensions/gsd/root-write-leak-guard.js +4 -2
- package/dist/resources/extensions/gsd/unit-registry.js +27 -2
- package/dist/resources/extensions/gsd/worktree-lifecycle.js +67 -5
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/integrations/hermes/CONTEXT.md +47 -0
- package/integrations/hermes/README.md +21 -0
- package/integrations/hermes/docs/issue-1162-grilling.md +197 -0
- package/integrations/hermes/docs/setup.md +4 -0
- package/integrations/hermes/open_gsd_hermes/commands.py +141 -0
- package/integrations/hermes/open_gsd_hermes/config.py +2 -0
- package/integrations/hermes/open_gsd_hermes/gsd_client.py +594 -31
- package/integrations/hermes/open_gsd_hermes/notifications.py +10 -1
- package/integrations/hermes/plugin.yaml +4 -0
- package/integrations/hermes/tests/test_config.py +6 -0
- package/integrations/hermes/tests/test_gsd_client_cache.py +3 -0
- package/integrations/hermes/tests/test_gsd_client_execute.py +35 -0
- package/integrations/hermes/tests/test_gsd_client_process_cwd.py +22 -0
- package/integrations/hermes/tests/test_gsd_client_timeout.py +98 -0
- package/integrations/hermes/tests/test_milestone_client.py +447 -0
- package/integrations/hermes/tests/test_new_milestone_command.py +313 -0
- package/integrations/hermes/tests/test_supervisor_fsm.py +40 -0
- package/package.json +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/dist/session-manager.js +1 -1
- package/packages/daemon/dist/session-manager.js.map +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.d.ts.map +1 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js +4 -1
- package/packages/gsd-agent-core/dist/session/agent-session-navigation.js.map +1 -1
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/server.d.ts +18 -5
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +46 -5
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/session-manager.js +1 -1
- package/packages/mcp-server/dist/session-manager.js.map +1 -1
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts +1 -0
- package/packages/pi-coding-agent/dist/core/session-manager-types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager-types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/session-manager.js +3 -0
- package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/claude-code-cli/stream-adapter.ts +64 -3
- package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +212 -39
- package/src/resources/extensions/gsd/artifact-verification.ts +16 -4
- package/src/resources/extensions/gsd/auto/closeout.ts +54 -56
- package/src/resources/extensions/gsd/auto/finalize.ts +8 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
- package/src/resources/extensions/gsd/auto/orchestrator.ts +1 -1
- package/src/resources/extensions/gsd/auto/run-unit.ts +5 -2
- package/src/resources/extensions/gsd/auto/schedule-wakeup.ts +8 -0
- package/src/resources/extensions/gsd/auto.ts +9 -3
- package/src/resources/extensions/gsd/bootstrap/schedule-wakeup-tool.ts +80 -12
- package/src/resources/extensions/gsd/doctor-engine-checks.ts +230 -21
- package/src/resources/extensions/gsd/gsd-db.ts +72 -0
- package/src/resources/extensions/gsd/migrate-external.ts +14 -4
- package/src/resources/extensions/gsd/milestone-merge-transaction.ts +4 -4
- package/src/resources/extensions/gsd/repo-identity.ts +17 -0
- package/src/resources/extensions/gsd/root-write-leak-guard.ts +3 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +131 -5
- package/src/resources/extensions/gsd/tests/auto-phases-lifecycle.test.ts +43 -1
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +54 -0
- package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +335 -0
- package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +116 -0
- package/src/resources/extensions/gsd/tests/milestone-merge-stash-restore.test.ts +152 -22
- package/src/resources/extensions/gsd/tests/milestone-merge-transaction.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +50 -6
- package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +31 -9
- package/src/resources/extensions/gsd/tests/schedule-wakeup-tool.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/state-corruption-2945.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/unit-registry.test.ts +56 -0
- package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +2 -2
- package/src/resources/extensions/gsd/unit-registry.ts +34 -2
- package/src/resources/extensions/gsd/worktree-lifecycle.ts +135 -11
- /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{kIEFsCryyGy6RzifULbJl → L2UMqXELiDH3oz-7yguXp}/_ssgManifest.js +0 -0
|
@@ -8,7 +8,7 @@ import subprocess
|
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any, Callable
|
|
12
12
|
|
|
13
13
|
from packaging import version
|
|
14
14
|
|
|
@@ -36,10 +36,29 @@ class GsdMcpClient:
|
|
|
36
36
|
def __init__(self, config: GsdConfig) -> None:
|
|
37
37
|
self._config = config
|
|
38
38
|
self._proc: subprocess.Popen[bytes] | None = None
|
|
39
|
+
self._proc_cwd: str | None = None
|
|
40
|
+
self._stdout_buffer = b""
|
|
39
41
|
self._lock = threading.Lock()
|
|
40
42
|
self._request_id = 0
|
|
41
43
|
self._cache: dict[str, _CacheEntry] = {}
|
|
42
44
|
self._version_checked = False
|
|
45
|
+
# Milestone subprocess state (issue #1162). The plugin owns the
|
|
46
|
+
# `gsd headless --supervised new-milestone` process directly, so the
|
|
47
|
+
# MCP session manager cannot see or reach it. This client is the sole
|
|
48
|
+
# owner of its lifecycle (cancel, blocker replies, terminal events).
|
|
49
|
+
self._milestone_proc: subprocess.Popen[bytes] | None = None
|
|
50
|
+
self._milestone_session_id: str | None = None
|
|
51
|
+
self._milestone_pending_blocker_id: str | None = None
|
|
52
|
+
self._milestone_pending_blocker_method: str | None = None
|
|
53
|
+
self._milestone_command_block_failure: str | None = None
|
|
54
|
+
self._milestone_notifications: Any = None # NotificationService | None
|
|
55
|
+
self._milestone_project_dir: str | None = None
|
|
56
|
+
self._milestone_on_terminal: Callable[[str], None] | None = None
|
|
57
|
+
self._milestone_notified_terminal: bool = False
|
|
58
|
+
self._milestone_thread: threading.Thread | None = None
|
|
59
|
+
self._milestone_stderr_thread: threading.Thread | None = None
|
|
60
|
+
self._milestone_stderr_lock = threading.Lock()
|
|
61
|
+
self._milestone_stderr_tail = bytearray()
|
|
43
62
|
|
|
44
63
|
def _env(self) -> dict[str, str]:
|
|
45
64
|
env = os.environ.copy()
|
|
@@ -72,42 +91,129 @@ class GsdMcpClient:
|
|
|
72
91
|
)
|
|
73
92
|
self._version_checked = True
|
|
74
93
|
|
|
75
|
-
def _ensure_process(self) -> subprocess.Popen[bytes]:
|
|
94
|
+
def _ensure_process(self, project_dir: str | None = None) -> subprocess.Popen[bytes]:
|
|
95
|
+
cwd = (
|
|
96
|
+
os.path.abspath(project_dir)
|
|
97
|
+
if project_dir is not None
|
|
98
|
+
else self._proc_cwd
|
|
99
|
+
)
|
|
76
100
|
if self._proc is not None and self._proc.poll() is None:
|
|
77
|
-
|
|
101
|
+
if cwd is None or self._proc_cwd == cwd:
|
|
102
|
+
return self._proc
|
|
103
|
+
self._terminate_process()
|
|
104
|
+
self._stdout_buffer = b""
|
|
105
|
+
self._proc_cwd = cwd
|
|
78
106
|
self._proc = subprocess.Popen(
|
|
79
107
|
[self._config.mcp_server_path],
|
|
80
108
|
stdin=subprocess.PIPE,
|
|
81
109
|
stdout=subprocess.PIPE,
|
|
82
110
|
stderr=subprocess.PIPE,
|
|
83
111
|
env=self._env(),
|
|
112
|
+
cwd=cwd,
|
|
84
113
|
)
|
|
85
|
-
|
|
114
|
+
try:
|
|
115
|
+
self._initialize()
|
|
116
|
+
except Exception:
|
|
117
|
+
self._terminate_process()
|
|
118
|
+
raise
|
|
86
119
|
return self._proc
|
|
87
120
|
|
|
88
|
-
def
|
|
89
|
-
proc = self.
|
|
121
|
+
def _terminate_process(self) -> None:
|
|
122
|
+
proc = self._proc
|
|
123
|
+
self._proc = None
|
|
124
|
+
self._proc_cwd = None
|
|
125
|
+
self._stdout_buffer = b""
|
|
126
|
+
if proc is None or proc.poll() is not None:
|
|
127
|
+
return
|
|
128
|
+
proc.terminate()
|
|
129
|
+
try:
|
|
130
|
+
proc.wait(timeout=5)
|
|
131
|
+
except subprocess.TimeoutExpired:
|
|
132
|
+
proc.kill()
|
|
133
|
+
|
|
134
|
+
def _send(self, payload: dict[str, Any], *, project_dir: str | None = None) -> None:
|
|
135
|
+
proc = self._ensure_process(project_dir)
|
|
90
136
|
assert proc.stdin is not None
|
|
91
137
|
body = json.dumps(payload).encode("utf-8")
|
|
92
138
|
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
93
139
|
proc.stdin.write(header + body)
|
|
94
140
|
proc.stdin.flush()
|
|
95
141
|
|
|
96
|
-
def
|
|
97
|
-
proc = self.
|
|
142
|
+
def _read_stdout_chunk(self, deadline: float) -> None:
|
|
143
|
+
proc = self._proc
|
|
144
|
+
if proc is None or proc.poll() is not None:
|
|
145
|
+
self._terminate_process()
|
|
146
|
+
raise McpProtocolError("MCP server closed stdout")
|
|
98
147
|
assert proc.stdout is not None
|
|
99
|
-
|
|
148
|
+
remaining = deadline - time.monotonic()
|
|
149
|
+
if remaining <= 0:
|
|
150
|
+
self._raise_read_timeout()
|
|
151
|
+
|
|
152
|
+
chunks: list[bytes] = []
|
|
153
|
+
errors: list[BaseException] = []
|
|
154
|
+
|
|
155
|
+
def read_chunk() -> None:
|
|
156
|
+
try:
|
|
157
|
+
chunks.append(os.read(proc.stdout.fileno(), 65536))
|
|
158
|
+
except BaseException as e:
|
|
159
|
+
errors.append(e)
|
|
160
|
+
|
|
161
|
+
reader = threading.Thread(target=read_chunk, daemon=True)
|
|
162
|
+
reader.start()
|
|
163
|
+
reader.join(remaining)
|
|
164
|
+
if reader.is_alive():
|
|
165
|
+
self._raise_read_timeout()
|
|
166
|
+
if errors:
|
|
167
|
+
self._terminate_process()
|
|
168
|
+
raise McpProtocolError(f"MCP server stdout read failed: {errors[0]}")
|
|
169
|
+
chunk = chunks[0] if chunks else b""
|
|
170
|
+
if not chunk:
|
|
171
|
+
self._terminate_process()
|
|
172
|
+
raise McpProtocolError("MCP server closed stdout")
|
|
173
|
+
self._stdout_buffer += chunk
|
|
174
|
+
|
|
175
|
+
def _raise_read_timeout(self) -> None:
|
|
176
|
+
self._terminate_process()
|
|
177
|
+
raise McpProtocolError(
|
|
178
|
+
f"MCP server response timed out after "
|
|
179
|
+
f"{self._config.mcp_read_timeout_seconds:g}s"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _read_header(self, deadline: float) -> bytes:
|
|
100
183
|
while True:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
184
|
+
crlf_idx = self._stdout_buffer.find(b"\r\n\r\n")
|
|
185
|
+
lf_idx = self._stdout_buffer.find(b"\n\n")
|
|
186
|
+
candidates = [
|
|
187
|
+
(idx, sep_len)
|
|
188
|
+
for idx, sep_len in ((crlf_idx, 4), (lf_idx, 2))
|
|
189
|
+
if idx >= 0
|
|
190
|
+
]
|
|
191
|
+
if candidates:
|
|
192
|
+
idx, sep_len = min(candidates)
|
|
193
|
+
header = self._stdout_buffer[:idx]
|
|
194
|
+
self._stdout_buffer = self._stdout_buffer[idx + sep_len :]
|
|
195
|
+
return header
|
|
196
|
+
self._read_stdout_chunk(deadline)
|
|
197
|
+
|
|
198
|
+
def _read_exact(self, length: int, deadline: float) -> bytes:
|
|
199
|
+
while len(self._stdout_buffer) < length:
|
|
200
|
+
self._read_stdout_chunk(deadline)
|
|
201
|
+
body = self._stdout_buffer[:length]
|
|
202
|
+
self._stdout_buffer = self._stdout_buffer[length:]
|
|
203
|
+
return body
|
|
204
|
+
|
|
205
|
+
def _read_message(self) -> dict[str, Any]:
|
|
206
|
+
deadline = time.monotonic() + self._config.mcp_read_timeout_seconds
|
|
207
|
+
header = self._read_header(deadline)
|
|
208
|
+
headers: dict[str, str] = {}
|
|
209
|
+
for raw_line in header.splitlines():
|
|
210
|
+
decoded = raw_line.decode("utf-8").strip()
|
|
211
|
+
if not decoded:
|
|
212
|
+
continue
|
|
107
213
|
key, _, val = decoded.partition(":")
|
|
108
214
|
headers[key.strip().lower()] = val.strip()
|
|
109
215
|
length = int(headers.get("content-length", "0"))
|
|
110
|
-
body =
|
|
216
|
+
body = self._read_exact(length, deadline)
|
|
111
217
|
return json.loads(body.decode("utf-8"))
|
|
112
218
|
|
|
113
219
|
def _initialize(self) -> None:
|
|
@@ -128,7 +234,12 @@ class GsdMcpClient:
|
|
|
128
234
|
self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
129
235
|
|
|
130
236
|
def _call_tool(
|
|
131
|
-
self,
|
|
237
|
+
self,
|
|
238
|
+
name: str,
|
|
239
|
+
arguments: dict[str, Any],
|
|
240
|
+
*,
|
|
241
|
+
check_version: bool = True,
|
|
242
|
+
project_dir: str | None = None,
|
|
132
243
|
) -> dict[str, Any]:
|
|
133
244
|
with self._lock:
|
|
134
245
|
if check_version:
|
|
@@ -141,7 +252,8 @@ class GsdMcpClient:
|
|
|
141
252
|
"id": req_id,
|
|
142
253
|
"method": "tools/call",
|
|
143
254
|
"params": {"name": name, "arguments": arguments},
|
|
144
|
-
}
|
|
255
|
+
},
|
|
256
|
+
project_dir=project_dir,
|
|
145
257
|
)
|
|
146
258
|
while True:
|
|
147
259
|
msg = self._read_message()
|
|
@@ -219,6 +331,7 @@ class GsdMcpClient:
|
|
|
219
331
|
"gsd_progress",
|
|
220
332
|
{"projectDir": project_dir},
|
|
221
333
|
check_version=False,
|
|
334
|
+
project_dir=project_dir,
|
|
222
335
|
)
|
|
223
336
|
return ProgressSnapshot.from_mcp(data)
|
|
224
337
|
|
|
@@ -231,21 +344,36 @@ class GsdMcpClient:
|
|
|
231
344
|
|
|
232
345
|
def execute(self, project_dir: str, *, command: str = "/gsd auto") -> dict[str, Any]:
|
|
233
346
|
self.invalidate_cache(project_dir)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
347
|
+
args = {"projectDir": project_dir, "command": command}
|
|
348
|
+
try:
|
|
349
|
+
return self._call_tool("gsd_execute", args, project_dir=project_dir)
|
|
350
|
+
except GsdVersionError:
|
|
351
|
+
return self._call_tool(
|
|
352
|
+
"gsd_execute",
|
|
353
|
+
args,
|
|
354
|
+
check_version=False,
|
|
355
|
+
project_dir=project_dir,
|
|
356
|
+
)
|
|
238
357
|
|
|
239
|
-
def cancel(
|
|
358
|
+
def cancel(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
session_id: str | None = None,
|
|
362
|
+
project_dir: str | None = None,
|
|
363
|
+
) -> dict[str, Any]:
|
|
240
364
|
args: dict[str, Any] = {}
|
|
241
365
|
if session_id:
|
|
242
366
|
args["sessionId"] = session_id
|
|
243
367
|
if project_dir:
|
|
244
368
|
args["projectDir"] = project_dir
|
|
245
|
-
return self._call_tool("gsd_cancel", args)
|
|
369
|
+
return self._call_tool("gsd_cancel", args, project_dir=project_dir)
|
|
246
370
|
|
|
247
371
|
def cancel_by_project(self, project_dir: str) -> dict[str, Any]:
|
|
248
|
-
return self._call_tool(
|
|
372
|
+
return self._call_tool(
|
|
373
|
+
"gsd_cancel_by_project",
|
|
374
|
+
{"projectDir": project_dir},
|
|
375
|
+
project_dir=project_dir,
|
|
376
|
+
)
|
|
249
377
|
|
|
250
378
|
def resolve_blocker(self, session_id: str, response: str) -> dict[str, Any]:
|
|
251
379
|
return self._call_tool(
|
|
@@ -257,13 +385,448 @@ class GsdMcpClient:
|
|
|
257
385
|
return self._call_tool(
|
|
258
386
|
"gsd_memory_query",
|
|
259
387
|
{"projectDir": project_dir, "query": query},
|
|
388
|
+
project_dir=project_dir,
|
|
260
389
|
)
|
|
261
390
|
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
|
|
391
|
+
def project_query(self, project_dir: str, query: str) -> dict[str, Any]:
|
|
392
|
+
return self._call_tool(
|
|
393
|
+
"gsd_query",
|
|
394
|
+
{"projectDir": project_dir, "query": query},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# Milestone creation (issue #1162)
|
|
399
|
+
#
|
|
400
|
+
# The plugin spawns `gsd headless --supervised new-milestone` directly
|
|
401
|
+
# and owns the subprocess. The MCP session manager cannot see it (it
|
|
402
|
+
# only knows sessions it started via gsd_execute), and there is no
|
|
403
|
+
# auto.lock pid backstop during the planning phase. So this client is
|
|
404
|
+
# the sole owner of the subprocess's lifecycle: cancel (SIGTERM),
|
|
405
|
+
# blocker replies (stdin), and transition/terminal notifications
|
|
406
|
+
# (stdout stream). See integrations/hermes/docs/issue-1162-grilling.md.
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
# Stop-notice vocabulary mirrored from src/resources/extensions/gsd/stop-notice.ts.
|
|
410
|
+
# Keep both sides in lockstep; the canonical prefixes the headless event
|
|
411
|
+
# loop emits in notify messages. Terminal detection is handled by process
|
|
412
|
+
# exit (the stream reader emits notify_terminal on stdout EOF), so only the
|
|
413
|
+
# blocked-notice classifier is needed here.
|
|
414
|
+
_PAUSED_NOTICE_PREFIXES = ("auto-mode paused", "step-mode paused")
|
|
415
|
+
_FIRE_AND_FORGET_UI_METHODS = frozenset(
|
|
416
|
+
{"notify", "setStatus", "setWidget", "setTitle", "set_editor_text"}
|
|
417
|
+
)
|
|
418
|
+
_EXIT_BLOCKED = 10
|
|
419
|
+
_STDERR_TAIL_LIMIT = 64 * 1024
|
|
420
|
+
# Supervised headless defaults to 30s; chat /gsd reply needs much longer.
|
|
421
|
+
_SUPERVISED_RESPONSE_TIMEOUT_MS = 3_600_000
|
|
422
|
+
|
|
423
|
+
def _is_blocked_notice(self, message: str) -> bool:
|
|
424
|
+
"""Mirror of stop-notice.ts isBlockedNoticeMessage (lowercased input)."""
|
|
425
|
+
if "blocked:" in message:
|
|
426
|
+
return True
|
|
427
|
+
if any(message.startswith(p) for p in self._PAUSED_NOTICE_PREFIXES):
|
|
428
|
+
if "idempotent advance: unit already active" not in message:
|
|
429
|
+
return True
|
|
430
|
+
return (
|
|
431
|
+
"resolve manually and re-run /gsd auto" in message
|
|
432
|
+
or "resolve conflicts manually and run /gsd auto to resume" in message
|
|
433
|
+
or "resolve and run /gsd auto to resume" in message
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def _is_milestone_blocker_event(self, event: dict[str, Any]) -> bool:
|
|
437
|
+
"""True for supervised interactive UI requests (select/input/confirm/editor)."""
|
|
438
|
+
method = str(event.get("method") or "")
|
|
439
|
+
if method in self._FIRE_AND_FORGET_UI_METHODS:
|
|
440
|
+
return False
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
def _get_command_block_content(self, event: dict[str, Any]) -> str | None:
|
|
444
|
+
"""Extract gsd-command-block message content from stream events."""
|
|
445
|
+
etype = event.get("type")
|
|
446
|
+
if etype not in ("message_start", "message_end"):
|
|
447
|
+
return None
|
|
448
|
+
message = event.get("message")
|
|
449
|
+
if not isinstance(message, dict):
|
|
450
|
+
return None
|
|
451
|
+
if message.get("customType") != "gsd-command-block":
|
|
452
|
+
return None
|
|
453
|
+
return str(message.get("content") or "")
|
|
454
|
+
|
|
455
|
+
def _is_blocking_command_block(self, event: dict[str, Any]) -> bool:
|
|
456
|
+
"""Mirror headless-events isBlockingCommandBlock."""
|
|
457
|
+
content = self._get_command_block_content(event)
|
|
458
|
+
if not content:
|
|
459
|
+
return False
|
|
460
|
+
lowered = content.lower()
|
|
461
|
+
return (
|
|
462
|
+
(
|
|
463
|
+
"cannot start new workflow work" in lowered
|
|
464
|
+
and "complete but not merged" in lowered
|
|
465
|
+
)
|
|
466
|
+
or "cannot run because the active milestone is blocked by validation"
|
|
467
|
+
in lowered
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _parse_confirm_response(self, response: str) -> bool:
|
|
471
|
+
normalized = response.strip().lower()
|
|
472
|
+
if normalized in ("no", "n", "false", "0", "cancel", "decline"):
|
|
473
|
+
return False
|
|
474
|
+
return True
|
|
475
|
+
|
|
476
|
+
def create_milestone(
|
|
477
|
+
self,
|
|
478
|
+
project_dir: str,
|
|
479
|
+
*,
|
|
480
|
+
context_text: str | None = None,
|
|
481
|
+
context_file: str | None = None,
|
|
482
|
+
notifications: Any = None,
|
|
483
|
+
on_terminal: Callable[[str], None] | None = None,
|
|
484
|
+
) -> str:
|
|
485
|
+
"""Spawn `gsd headless --supervised new-milestone` and return a session id.
|
|
486
|
+
|
|
487
|
+
The subprocess is started detached; a background thread reads its
|
|
488
|
+
stream-json stdout and drives the supplied NotificationService on
|
|
489
|
+
blocker/terminal events. Returns a local session id immediately
|
|
490
|
+
(headless does not emit init_result on stdout).
|
|
491
|
+
|
|
492
|
+
Raises ValueError if neither (or both) of context_text/context_file
|
|
493
|
+
is supplied.
|
|
494
|
+
"""
|
|
495
|
+
if bool(context_text) == bool(context_file):
|
|
496
|
+
raise ValueError(
|
|
497
|
+
"Provide exactly one milestone spec: context_text or context_file"
|
|
498
|
+
)
|
|
499
|
+
self.ensure_version()
|
|
500
|
+
if self._milestone_proc is not None and self._milestone_proc.poll() is None:
|
|
501
|
+
raise RuntimeError("Milestone creation already in progress.")
|
|
502
|
+
|
|
503
|
+
args: list[str] = [
|
|
504
|
+
self._config.cli_path,
|
|
505
|
+
"headless",
|
|
506
|
+
"--supervised",
|
|
507
|
+
"--response-timeout",
|
|
508
|
+
str(self._SUPERVISED_RESPONSE_TIMEOUT_MS),
|
|
509
|
+
"--output-format",
|
|
510
|
+
"stream-json",
|
|
511
|
+
]
|
|
512
|
+
if context_text:
|
|
513
|
+
args += ["--context-text", context_text]
|
|
514
|
+
else:
|
|
515
|
+
assert context_file is not None
|
|
516
|
+
args += ["--context", context_file]
|
|
517
|
+
args.append("new-milestone")
|
|
518
|
+
|
|
519
|
+
proc = subprocess.Popen( # noqa: S603 - args list, not shell
|
|
520
|
+
args,
|
|
521
|
+
stdin=subprocess.PIPE,
|
|
522
|
+
stdout=subprocess.PIPE,
|
|
523
|
+
stderr=subprocess.PIPE,
|
|
524
|
+
cwd=project_dir,
|
|
525
|
+
env=self._env(),
|
|
526
|
+
)
|
|
527
|
+
self._milestone_proc = proc
|
|
528
|
+
self._milestone_session_id = None
|
|
529
|
+
self._milestone_pending_blocker_id = None
|
|
530
|
+
self._milestone_pending_blocker_method = None
|
|
531
|
+
self._milestone_command_block_failure = None
|
|
532
|
+
self._milestone_notifications = notifications
|
|
533
|
+
self._milestone_project_dir = project_dir
|
|
534
|
+
self._milestone_on_terminal = on_terminal
|
|
535
|
+
self._milestone_notified_terminal = False
|
|
536
|
+
self._clear_milestone_stderr()
|
|
537
|
+
self._milestone_stderr_thread = threading.Thread(
|
|
538
|
+
target=self._drain_milestone_stderr,
|
|
539
|
+
args=(proc,),
|
|
540
|
+
daemon=True,
|
|
541
|
+
)
|
|
542
|
+
self._milestone_stderr_thread.start()
|
|
543
|
+
|
|
544
|
+
# Headless stream-json does not emit init_result on stdout (init is an
|
|
545
|
+
# internal RPC handshake). Use a local session id for ack/status only;
|
|
546
|
+
# milestone lifecycle is keyed on the held subprocess, not MCP.
|
|
547
|
+
session_id = f"milestone-{proc.pid}"
|
|
548
|
+
self._milestone_session_id = session_id
|
|
549
|
+
|
|
550
|
+
self._milestone_thread = threading.Thread(
|
|
551
|
+
target=self._milestone_stream_loop,
|
|
552
|
+
args=(proc,),
|
|
553
|
+
daemon=True,
|
|
554
|
+
)
|
|
555
|
+
self._milestone_thread.start()
|
|
556
|
+
return session_id
|
|
557
|
+
|
|
558
|
+
def _milestone_stream_loop(self, proc: subprocess.Popen[bytes]) -> None:
|
|
559
|
+
"""Background reader: capture blocker/terminal events and notify.
|
|
560
|
+
|
|
561
|
+
Best-effort. On stream loss we surface FAILED via notify_terminal so
|
|
562
|
+
the user can `/gsd cancel` to clean up (no watchdog; the cancel
|
|
563
|
+
escape hatch is the safety net).
|
|
564
|
+
"""
|
|
565
|
+
assert proc.stdout is not None
|
|
566
|
+
try:
|
|
567
|
+
while True:
|
|
568
|
+
line = proc.stdout.readline()
|
|
569
|
+
if not line:
|
|
570
|
+
break
|
|
571
|
+
try:
|
|
572
|
+
event = json.loads(line.decode("utf-8"))
|
|
573
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
574
|
+
continue
|
|
575
|
+
if self._milestone_proc is not proc:
|
|
576
|
+
return
|
|
577
|
+
self._handle_milestone_event(event)
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
if self._milestone_proc is not proc:
|
|
581
|
+
return
|
|
582
|
+
if self._milestone_notified_terminal:
|
|
583
|
+
if proc.poll() is not None:
|
|
584
|
+
self._clear_milestone_state()
|
|
585
|
+
return
|
|
586
|
+
exit_code = proc.poll()
|
|
587
|
+
if exit_code is None:
|
|
265
588
|
try:
|
|
266
|
-
|
|
589
|
+
exit_code = proc.wait(timeout=5)
|
|
267
590
|
except subprocess.TimeoutExpired:
|
|
268
|
-
|
|
269
|
-
self.
|
|
591
|
+
exit_code = None
|
|
592
|
+
if exit_code == self._EXIT_BLOCKED:
|
|
593
|
+
if not self._milestone_notified_terminal:
|
|
594
|
+
if self._milestone_notifications is not None:
|
|
595
|
+
self._milestone_notifications.notify_terminal(
|
|
596
|
+
"failed", "Planning blocked"
|
|
597
|
+
)
|
|
598
|
+
if self._milestone_on_terminal is not None:
|
|
599
|
+
self._milestone_on_terminal("failed")
|
|
600
|
+
self._milestone_notified_terminal = True
|
|
601
|
+
self._clear_milestone_state()
|
|
602
|
+
return
|
|
603
|
+
if exit_code == 0:
|
|
604
|
+
if self._milestone_command_block_failure:
|
|
605
|
+
status = "failed"
|
|
606
|
+
error = self._milestone_command_block_failure
|
|
607
|
+
else:
|
|
608
|
+
status = "complete"
|
|
609
|
+
error = None
|
|
610
|
+
elif exit_code is not None:
|
|
611
|
+
status = "failed"
|
|
612
|
+
self._join_milestone_stderr_thread()
|
|
613
|
+
error = self._milestone_stderr_text(proc) or f"exit code {exit_code}"
|
|
614
|
+
else:
|
|
615
|
+
status = "failed"
|
|
616
|
+
error = "stream lost — run /gsd cancel"
|
|
617
|
+
if self._milestone_notifications is not None:
|
|
618
|
+
if status == "complete":
|
|
619
|
+
summary = self._build_milestone_completion_message(
|
|
620
|
+
self._milestone_project_dir or ""
|
|
621
|
+
)
|
|
622
|
+
self._milestone_notifications.notify_milestone_complete(summary)
|
|
623
|
+
else:
|
|
624
|
+
self._milestone_notifications.notify_terminal(status, error)
|
|
625
|
+
if self._milestone_on_terminal is not None:
|
|
626
|
+
self._milestone_on_terminal(status)
|
|
627
|
+
self._milestone_notified_terminal = True
|
|
628
|
+
if exit_code is not None:
|
|
629
|
+
self._clear_milestone_state()
|
|
630
|
+
else:
|
|
631
|
+
self._milestone_pending_blocker_id = None
|
|
632
|
+
self._milestone_pending_blocker_method = None
|
|
633
|
+
|
|
634
|
+
def _drain_milestone_stderr(self, proc: subprocess.Popen[bytes]) -> None:
|
|
635
|
+
"""Drain stderr so a full pipe cannot stall the child process."""
|
|
636
|
+
if proc.stderr is None:
|
|
637
|
+
return
|
|
638
|
+
try:
|
|
639
|
+
while True:
|
|
640
|
+
chunk = proc.stderr.read(4096)
|
|
641
|
+
if not chunk:
|
|
642
|
+
break
|
|
643
|
+
self._append_milestone_stderr(chunk)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
def _append_milestone_stderr(self, chunk: bytes) -> None:
|
|
648
|
+
with self._milestone_stderr_lock:
|
|
649
|
+
self._milestone_stderr_tail.extend(chunk)
|
|
650
|
+
overflow = len(self._milestone_stderr_tail) - self._STDERR_TAIL_LIMIT
|
|
651
|
+
if overflow > 0:
|
|
652
|
+
del self._milestone_stderr_tail[:overflow]
|
|
653
|
+
|
|
654
|
+
def _clear_milestone_stderr(self) -> None:
|
|
655
|
+
with self._milestone_stderr_lock:
|
|
656
|
+
self._milestone_stderr_tail.clear()
|
|
657
|
+
|
|
658
|
+
def _join_milestone_stderr_thread(self) -> None:
|
|
659
|
+
thread = self._milestone_stderr_thread
|
|
660
|
+
if thread and thread is not threading.current_thread():
|
|
661
|
+
thread.join(timeout=1)
|
|
662
|
+
|
|
663
|
+
def _milestone_stderr_text(self, proc: subprocess.Popen[bytes]) -> str:
|
|
664
|
+
with self._milestone_stderr_lock:
|
|
665
|
+
err_bytes = bytes(self._milestone_stderr_tail)
|
|
666
|
+
if not err_bytes and proc.stderr is not None:
|
|
667
|
+
try:
|
|
668
|
+
err_bytes = proc.stderr.read()
|
|
669
|
+
except Exception:
|
|
670
|
+
err_bytes = b""
|
|
671
|
+
return err_bytes.decode("utf-8", errors="replace").strip()
|
|
672
|
+
|
|
673
|
+
def _handle_milestone_event(self, event: dict[str, Any]) -> None:
|
|
674
|
+
etype = event.get("type")
|
|
675
|
+
if etype == "init_result":
|
|
676
|
+
sid = event.get("sessionId")
|
|
677
|
+
if sid:
|
|
678
|
+
self._milestone_session_id = str(sid)
|
|
679
|
+
return
|
|
680
|
+
if self._is_blocking_command_block(event):
|
|
681
|
+
failure = self._get_command_block_content(event) or "Planning blocked"
|
|
682
|
+
self._milestone_command_block_failure = failure
|
|
683
|
+
if not self._milestone_notified_terminal:
|
|
684
|
+
if self._milestone_notifications is not None:
|
|
685
|
+
self._milestone_notifications.notify_terminal("failed", failure)
|
|
686
|
+
if self._milestone_on_terminal is not None:
|
|
687
|
+
self._milestone_on_terminal("failed")
|
|
688
|
+
self._milestone_notified_terminal = True
|
|
689
|
+
return
|
|
690
|
+
if etype != "extension_ui_request":
|
|
691
|
+
return
|
|
692
|
+
if not self._is_milestone_blocker_event(event):
|
|
693
|
+
return
|
|
694
|
+
event_id = str(event.get("id") or "")
|
|
695
|
+
self._milestone_pending_blocker_id = event_id or None
|
|
696
|
+
self._milestone_pending_blocker_method = str(event.get("method") or "") or None
|
|
697
|
+
if self._milestone_notifications is not None:
|
|
698
|
+
self._milestone_notifications.notify_blocker(
|
|
699
|
+
SessionStatus(
|
|
700
|
+
status="blocked",
|
|
701
|
+
pending_blocker=event,
|
|
702
|
+
session_id=self._milestone_session_id,
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def cancel_milestone(self) -> None:
|
|
707
|
+
"""SIGTERM the held milestone subprocess and clear state."""
|
|
708
|
+
proc = self._milestone_proc
|
|
709
|
+
if proc is None:
|
|
710
|
+
return
|
|
711
|
+
self._milestone_notified_terminal = True
|
|
712
|
+
try:
|
|
713
|
+
proc.terminate()
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
try:
|
|
717
|
+
proc.wait(timeout=5)
|
|
718
|
+
except subprocess.TimeoutExpired:
|
|
719
|
+
try:
|
|
720
|
+
proc.kill()
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
if self._milestone_proc is proc:
|
|
724
|
+
self._clear_milestone_state()
|
|
725
|
+
|
|
726
|
+
def _release_milestone_proc(self) -> None:
|
|
727
|
+
self._milestone_proc = None
|
|
728
|
+
self._milestone_notifications = None
|
|
729
|
+
self._milestone_on_terminal = None
|
|
730
|
+
self._milestone_stderr_thread = None
|
|
731
|
+
self._clear_milestone_stderr()
|
|
732
|
+
|
|
733
|
+
def _build_milestone_completion_message(self, project_dir: str) -> str:
|
|
734
|
+
fallback = "✅ Milestone created. Run `/gsd status` for details."
|
|
735
|
+
if not project_dir:
|
|
736
|
+
return fallback
|
|
737
|
+
try:
|
|
738
|
+
query_data = self.project_query(project_dir, "milestones")
|
|
739
|
+
except Exception:
|
|
740
|
+
return fallback
|
|
741
|
+
milestones = query_data.get("milestones")
|
|
742
|
+
if not milestones:
|
|
743
|
+
return fallback
|
|
744
|
+
try:
|
|
745
|
+
self.invalidate_cache(project_dir)
|
|
746
|
+
snap = self.progress(project_dir)
|
|
747
|
+
except Exception:
|
|
748
|
+
snap = None
|
|
749
|
+
milestone_id: str | None = None
|
|
750
|
+
if snap and snap.active_milestone:
|
|
751
|
+
milestone_id = snap.active_milestone.get("id")
|
|
752
|
+
if not milestone_id:
|
|
753
|
+
last = milestones[-1]
|
|
754
|
+
if isinstance(last, dict):
|
|
755
|
+
milestone_id = last.get("id")
|
|
756
|
+
if not milestone_id:
|
|
757
|
+
return fallback
|
|
758
|
+
slice_total = snap.slices.get("total", 0) if snap else 0
|
|
759
|
+
task_total = snap.tasks.get("total", 0) if snap else 0
|
|
760
|
+
msg = f"✅ Milestone {milestone_id} ready"
|
|
761
|
+
counts: list[str] = []
|
|
762
|
+
if slice_total:
|
|
763
|
+
counts.append(f"{slice_total} slice{'s' if slice_total != 1 else ''}")
|
|
764
|
+
if task_total:
|
|
765
|
+
counts.append(f"{task_total} task{'s' if task_total != 1 else ''}")
|
|
766
|
+
if counts:
|
|
767
|
+
msg += f" — {', '.join(counts)}"
|
|
768
|
+
return f"{msg}. Run `/gsd auto` to start."
|
|
769
|
+
|
|
770
|
+
def _clear_milestone_state(self) -> None:
|
|
771
|
+
self._release_milestone_proc()
|
|
772
|
+
self._milestone_session_id = None
|
|
773
|
+
self._milestone_project_dir = None
|
|
774
|
+
self._milestone_pending_blocker_id = None
|
|
775
|
+
self._milestone_pending_blocker_method = None
|
|
776
|
+
self._milestone_command_block_failure = None
|
|
777
|
+
|
|
778
|
+
def respond_to_milestone_blocker(self, response: str) -> None:
|
|
779
|
+
"""Write an extension_ui_response to the milestone subprocess stdin.
|
|
780
|
+
|
|
781
|
+
Uses the supervised-mode JSONL protocol (see startSupervisedStdinReader
|
|
782
|
+
in src/headless-ui.ts): {"type":"extension_ui_response","id":...,"value":...}
|
|
783
|
+
or {"type":"extension_ui_response","id":...,"confirmed":...} for confirm prompts.
|
|
784
|
+
"""
|
|
785
|
+
proc = self._milestone_proc
|
|
786
|
+
blocker_id = self._milestone_pending_blocker_id
|
|
787
|
+
if self._milestone_notified_terminal:
|
|
788
|
+
raise RuntimeError(
|
|
789
|
+
"Milestone session is no longer accepting replies. "
|
|
790
|
+
"Run `/gsd cancel` to clean up."
|
|
791
|
+
)
|
|
792
|
+
if proc is None or not blocker_id:
|
|
793
|
+
raise RuntimeError(
|
|
794
|
+
"No pending blocker for the active milestone session."
|
|
795
|
+
)
|
|
796
|
+
assert proc.stdin is not None
|
|
797
|
+
response_payload: dict[str, Any] = {
|
|
798
|
+
"type": "extension_ui_response",
|
|
799
|
+
"id": blocker_id,
|
|
800
|
+
}
|
|
801
|
+
if self._milestone_pending_blocker_method == "confirm":
|
|
802
|
+
response_payload["confirmed"] = self._parse_confirm_response(response)
|
|
803
|
+
else:
|
|
804
|
+
response_payload["value"] = response
|
|
805
|
+
payload = json.dumps(response_payload).encode("utf-8")
|
|
806
|
+
proc.stdin.write(payload + b"\n")
|
|
807
|
+
proc.stdin.flush()
|
|
808
|
+
self._milestone_pending_blocker_id = None
|
|
809
|
+
self._milestone_pending_blocker_method = None
|
|
810
|
+
|
|
811
|
+
def milestone_active(self) -> bool:
|
|
812
|
+
proc = self._milestone_proc
|
|
813
|
+
if proc is None:
|
|
814
|
+
return False
|
|
815
|
+
thread = self._milestone_thread
|
|
816
|
+
if thread is not None and thread.is_alive():
|
|
817
|
+
return True
|
|
818
|
+
if proc.poll() is None:
|
|
819
|
+
return True
|
|
820
|
+
self._clear_milestone_state()
|
|
821
|
+
return False
|
|
822
|
+
|
|
823
|
+
def milestone_session_id(self) -> str | None:
|
|
824
|
+
return self._milestone_session_id
|
|
825
|
+
|
|
826
|
+
def milestone_pending_blocker_id(self) -> str | None:
|
|
827
|
+
return self._milestone_pending_blocker_id
|
|
828
|
+
|
|
829
|
+
def close(self) -> None:
|
|
830
|
+
self._terminate_process()
|
|
831
|
+
if self._milestone_proc is not None:
|
|
832
|
+
self.cancel_milestone()
|