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.
@@ -0,0 +1,758 @@
1
+ """Long-lived per-worker ACP runtime process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import socket
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import __version__
14
+ from .acp_client import AcpStdioClient
15
+ from .approval_utils import resolve_option
16
+ from .errors import SubagentError
17
+ from .state import (
18
+ WORKER_STATE_IDLE,
19
+ WORKER_STATE_RUNNING,
20
+ WORKER_STATE_WAITING_APPROVAL,
21
+ StateStore,
22
+ )
23
+
24
+
25
+ def _extract_session_id(payload: Any) -> str:
26
+ if not isinstance(payload, dict):
27
+ raise SubagentError(
28
+ code="BACKEND_PROTOCOL_ERROR",
29
+ message="Backend returned invalid session response.",
30
+ details={"response": payload},
31
+ )
32
+ session_id = payload.get("sessionId")
33
+ if isinstance(session_id, str) and session_id:
34
+ return session_id
35
+ raise SubagentError(
36
+ code="BACKEND_PROTOCOL_ERROR",
37
+ message="Backend response is missing sessionId.",
38
+ details={"response": payload},
39
+ )
40
+
41
+
42
+ def _extract_text_chunks(payload: Any) -> list[str]:
43
+ chunks: list[str] = []
44
+
45
+ def walk(node: Any) -> None:
46
+ if isinstance(node, dict):
47
+ node_type = node.get("type")
48
+ text = node.get("text")
49
+ if isinstance(text, str) and (not isinstance(node_type, str) or node_type == "text"):
50
+ chunks.append(text)
51
+ for value in node.values():
52
+ walk(value)
53
+ return
54
+ if isinstance(node, list):
55
+ for value in node:
56
+ walk(value)
57
+
58
+ walk(payload)
59
+ return chunks
60
+
61
+
62
+ def _normalize_permission_options(params: dict[str, Any]) -> list[dict[str, Any]]:
63
+ options_payload = params.get("options")
64
+ if not isinstance(options_payload, list):
65
+ return []
66
+ options: list[dict[str, Any]] = []
67
+ for item in options_payload:
68
+ if not isinstance(item, dict):
69
+ continue
70
+ option_id = item.get("optionId")
71
+ if not isinstance(option_id, str) or not option_id:
72
+ option_id = item.get("id")
73
+ if not isinstance(option_id, str) or not option_id:
74
+ continue
75
+ label = item.get("name")
76
+ options.append(
77
+ {
78
+ "id": option_id,
79
+ "alias": option_id.lower(),
80
+ "label": str(label) if isinstance(label, str) and label else option_id,
81
+ "kind": item.get("kind"),
82
+ }
83
+ )
84
+ return options
85
+
86
+
87
+ def _build_prompt_blocks(text: str, blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
88
+ output: list[dict[str, Any]] = []
89
+ if text.strip():
90
+ output.append({"type": "text", "text": text})
91
+ output.extend(blocks)
92
+ return output
93
+
94
+
95
+ class WorkerRuntime:
96
+ def __init__(
97
+ self,
98
+ *,
99
+ db_path: Path,
100
+ worker_id: str,
101
+ socket_path: Path,
102
+ launcher_command: str,
103
+ launcher_args: list[str],
104
+ launcher_env: dict[str, str],
105
+ cwd: Path,
106
+ ) -> None:
107
+ self.store = StateStore(db_path)
108
+ self.worker_id = worker_id
109
+ self.socket_path = socket_path
110
+ self.launcher_command = launcher_command
111
+ self.launcher_args = launcher_args
112
+ self.launcher_env = launcher_env
113
+ self.cwd = cwd
114
+
115
+ self.client: AcpStdioClient | None = None
116
+ self.session_id: str = ""
117
+ self.server_socket: socket.socket | None = None
118
+ self._shutdown_requested = False
119
+
120
+ self._lock = threading.RLock()
121
+ self._cv = threading.Condition(self._lock)
122
+ self._turn_thread: threading.Thread | None = None
123
+ self._active_turn_id: str | None = None
124
+ self._turn_result: dict[str, Any] | None = None
125
+ self._turn_error: SubagentError | None = None
126
+ self._pending_permission: dict[str, Any] | None = None
127
+ self._cancel_requested = False
128
+ self._cancel_reason = "canceled by manager"
129
+
130
+ def run(self) -> int:
131
+ self.store.bootstrap()
132
+ worker = self.store.get_worker(self.worker_id)
133
+ if worker is None:
134
+ raise SubagentError(
135
+ code="WORKER_NOT_FOUND",
136
+ message=f"Worker not found: {self.worker_id}",
137
+ details={"workerId": self.worker_id},
138
+ )
139
+ self.client = AcpStdioClient(
140
+ command=self.launcher_command,
141
+ args=self.launcher_args,
142
+ cwd=self.cwd,
143
+ env=self.launcher_env,
144
+ )
145
+ try:
146
+ self.client.request(
147
+ "initialize",
148
+ {
149
+ "protocolVersion": 1,
150
+ "clientInfo": {"name": "subagent-cli", "version": __version__},
151
+ "clientCapabilities": {
152
+ "fs": {"readTextFile": False, "writeTextFile": False},
153
+ "terminal": False,
154
+ },
155
+ },
156
+ timeout_seconds=10.0,
157
+ )
158
+ existing_session_id = worker.get("session_id")
159
+ resumed_session = False
160
+ if isinstance(existing_session_id, str) and existing_session_id:
161
+ try:
162
+ session_payload = self.client.request(
163
+ "session/load",
164
+ {"sessionId": existing_session_id},
165
+ timeout_seconds=15.0,
166
+ )
167
+ except SubagentError:
168
+ session_payload = self.client.request(
169
+ "session/new",
170
+ {"cwd": str(self.cwd), "mcpServers": []},
171
+ timeout_seconds=15.0,
172
+ )
173
+ else:
174
+ resumed_session = True
175
+ else:
176
+ session_payload = self.client.request(
177
+ "session/new",
178
+ {"cwd": str(self.cwd), "mcpServers": []},
179
+ timeout_seconds=15.0,
180
+ )
181
+ self.session_id = _extract_session_id(session_payload)
182
+ self.store.set_worker_session_id(self.worker_id, self.session_id)
183
+ self.store.append_worker_event(
184
+ self.worker_id,
185
+ event_type="progress.update",
186
+ turn_id=None,
187
+ data={"method": "session.load" if resumed_session else "session.new"},
188
+ raw={
189
+ "runtime": "acp-stdio",
190
+ "phase": "session.start",
191
+ "sessionId": self.session_id,
192
+ },
193
+ )
194
+ self.store.set_worker_runtime_endpoint(
195
+ self.worker_id,
196
+ runtime_pid=os.getpid(),
197
+ runtime_socket=str(self.socket_path),
198
+ )
199
+ self.store.update_worker_state(
200
+ self.worker_id,
201
+ next_state=WORKER_STATE_IDLE,
202
+ allow_any_transition=True,
203
+ )
204
+ return self._serve()
205
+ finally:
206
+ try:
207
+ self.store.clear_worker_runtime_endpoint(self.worker_id)
208
+ except Exception:
209
+ pass
210
+ if self.client is not None:
211
+ self.client.close()
212
+ if self.server_socket is not None:
213
+ try:
214
+ self.server_socket.close()
215
+ except Exception:
216
+ pass
217
+ if self.socket_path.exists():
218
+ try:
219
+ self.socket_path.unlink()
220
+ except OSError:
221
+ pass
222
+
223
+ def _serve(self) -> int:
224
+ if self.socket_path.exists():
225
+ self.socket_path.unlink()
226
+ self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
227
+ self.server_socket.bind(str(self.socket_path))
228
+ self.server_socket.listen(8)
229
+ while not self._shutdown_requested:
230
+ try:
231
+ conn, _ = self.server_socket.accept()
232
+ except OSError:
233
+ if self._shutdown_requested:
234
+ break
235
+ raise
236
+ thread = threading.Thread(
237
+ target=self._handle_connection_socket,
238
+ args=(conn,),
239
+ daemon=True,
240
+ name=f"worker-runtime-socket-{self.worker_id}",
241
+ )
242
+ thread.start()
243
+ return 0
244
+
245
+ def _handle_connection_socket(self, conn: socket.socket) -> None:
246
+ with conn:
247
+ response = self._handle_connection(conn)
248
+ try:
249
+ conn.sendall((json.dumps(response, ensure_ascii=False) + "\n").encode("utf-8"))
250
+ except OSError:
251
+ # The client may disconnect while runtime is shutting down.
252
+ return
253
+
254
+ def _handle_connection(self, conn: socket.socket) -> dict[str, Any]:
255
+ try:
256
+ data = self._read_line(conn)
257
+ request = json.loads(data)
258
+ if not isinstance(request, dict):
259
+ raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request must be an object.")
260
+ method = request.get("method")
261
+ params = request.get("params")
262
+ if not isinstance(method, str) or not method:
263
+ raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request missing method.")
264
+ if not isinstance(params, dict):
265
+ params = {}
266
+ result = self._dispatch(method, params)
267
+ return {"ok": True, "result": result}
268
+ except SubagentError as error:
269
+ return {"ok": False, "error": error.to_dict()}
270
+ except Exception as error: # pragma: no cover - defensive
271
+ err = SubagentError(
272
+ code="BACKEND_RUNTIME_ERROR",
273
+ message=f"Runtime internal error: {error}",
274
+ )
275
+ return {"ok": False, "error": err.to_dict()}
276
+
277
+ def _read_line(self, conn: socket.socket) -> str:
278
+ chunks: list[bytes] = []
279
+ while True:
280
+ block = conn.recv(4096)
281
+ if not block:
282
+ break
283
+ chunks.append(block)
284
+ if b"\n" in block:
285
+ break
286
+ if not chunks:
287
+ raise SubagentError(code="INVALID_ARGUMENT", message="Runtime request is empty.")
288
+ return b"".join(chunks).decode("utf-8", errors="replace").strip()
289
+
290
+ def _dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
291
+ if method == "ping":
292
+ return {"workerId": self.worker_id, "sessionId": self.session_id}
293
+ if method == "start_turn":
294
+ return self._handle_start_turn(params)
295
+ if method == "approve":
296
+ return self._handle_approve(params)
297
+ if method == "cancel_turn":
298
+ return self._handle_cancel_turn(params)
299
+ if method == "stop":
300
+ return self._handle_stop(params)
301
+ raise SubagentError(
302
+ code="INVALID_ARGUMENT",
303
+ message=f"Unknown runtime method: {method}",
304
+ details={"method": method},
305
+ )
306
+
307
+ def _handle_start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
308
+ turn_id = params.get("turnId")
309
+ text = params.get("text")
310
+ blocks = params.get("blocks")
311
+ if not isinstance(turn_id, str) or not turn_id:
312
+ raise SubagentError(code="INVALID_ARGUMENT", message="`turnId` is required.")
313
+ if not isinstance(text, str):
314
+ raise SubagentError(code="INVALID_ARGUMENT", message="`text` must be a string.")
315
+ if not isinstance(blocks, list):
316
+ raise SubagentError(code="INVALID_ARGUMENT", message="`blocks` must be a list.")
317
+ normalized_blocks: list[dict[str, Any]] = []
318
+ for index, item in enumerate(blocks):
319
+ if not isinstance(item, dict):
320
+ raise SubagentError(
321
+ code="INVALID_ARGUMENT",
322
+ message=f"`blocks[{index}]` must be an object.",
323
+ )
324
+ normalized_blocks.append(item)
325
+
326
+ with self._cv:
327
+ if self._active_turn_id is not None:
328
+ raise SubagentError(
329
+ code="WORKER_BUSY",
330
+ message="worker has an active turn",
331
+ details={"workerId": self.worker_id, "turnId": self._active_turn_id},
332
+ )
333
+ self._active_turn_id = turn_id
334
+ self._turn_result = None
335
+ self._turn_error = None
336
+ self._pending_permission = None
337
+ self._cancel_requested = False
338
+ self._cancel_reason = "canceled by manager"
339
+ self._turn_thread = threading.Thread(
340
+ target=self._run_turn,
341
+ args=(turn_id, text, normalized_blocks),
342
+ daemon=True,
343
+ name=f"worker-turn-{self.worker_id}",
344
+ )
345
+ self._turn_thread.start()
346
+
347
+ while True:
348
+ if self._turn_error is not None:
349
+ raise self._turn_error
350
+ if self._pending_permission is not None:
351
+ return {
352
+ "workerId": self.worker_id,
353
+ "turnId": turn_id,
354
+ "state": WORKER_STATE_WAITING_APPROVAL,
355
+ "requestId": self._pending_permission["request_id"],
356
+ }
357
+ if self._turn_result is not None:
358
+ return self._turn_result
359
+ self._cv.wait(timeout=0.1)
360
+
361
+ def _run_turn(self, turn_id: str, text: str, blocks: list[dict[str, Any]]) -> None:
362
+ assert self.client is not None
363
+
364
+ def on_notification(method: str, params: dict[str, Any]) -> None:
365
+ if method != "session/update":
366
+ self.store.append_worker_event(
367
+ self.worker_id,
368
+ event_type="progress.update",
369
+ turn_id=turn_id,
370
+ data={"method": method},
371
+ raw={
372
+ "runtime": "acp-stdio",
373
+ "phase": "notification",
374
+ "method": method,
375
+ "params": params,
376
+ },
377
+ )
378
+ return
379
+ update = params.get("update")
380
+ text_chunks = _extract_text_chunks(update)
381
+ if not text_chunks:
382
+ self.store.append_worker_event(
383
+ self.worker_id,
384
+ event_type="progress.update",
385
+ turn_id=turn_id,
386
+ data={"method": "session/update"},
387
+ raw={"runtime": "acp-stdio", "phase": "session.update", "update": update},
388
+ )
389
+ return
390
+ for chunk in text_chunks:
391
+ self.store.append_worker_event(
392
+ self.worker_id,
393
+ event_type="progress.message",
394
+ turn_id=turn_id,
395
+ data={"role": "assistant", "text": chunk},
396
+ raw={"runtime": "acp-stdio", "phase": "session.update", "update": update},
397
+ )
398
+
399
+ def on_request(method: str, params: dict[str, Any]) -> dict[str, Any]:
400
+ if method != "session/request_permission":
401
+ raise SubagentError(
402
+ code="BACKEND_PROTOCOL_ERROR",
403
+ message=f"Unsupported backend request method: {method}",
404
+ details={"method": method},
405
+ )
406
+ options = _normalize_permission_options(params)
407
+ message = "Backend requested permission for a tool call."
408
+ tool_call = params.get("toolCall")
409
+ if isinstance(tool_call, dict):
410
+ kind = tool_call.get("kind")
411
+ if isinstance(kind, str) and kind:
412
+ message = f"Backend requested permission for `{kind}`."
413
+ request = self.store.create_approval_request(
414
+ self.worker_id,
415
+ turn_id=turn_id,
416
+ message=message,
417
+ kind="tool.call",
418
+ options=options or None,
419
+ )
420
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_WAITING_APPROVAL)
421
+ self.store.append_worker_event(
422
+ self.worker_id,
423
+ event_type="approval.requested",
424
+ turn_id=turn_id,
425
+ data={
426
+ "requestId": request["request_id"],
427
+ "kind": request["kind"],
428
+ "message": request["message"],
429
+ "options": request["options"],
430
+ },
431
+ raw={
432
+ "runtime": "acp-stdio",
433
+ "phase": "approval.requested",
434
+ "request": params,
435
+ },
436
+ )
437
+ with self._cv:
438
+ self._pending_permission = {
439
+ "request_id": request["request_id"],
440
+ "response": None,
441
+ }
442
+ self._cv.notify_all()
443
+ while True:
444
+ pending = self._pending_permission
445
+ if pending is None:
446
+ raise SubagentError(
447
+ code="BACKEND_RUNTIME_ERROR",
448
+ message="Pending permission state was lost.",
449
+ )
450
+ response = pending.get("response")
451
+ if isinstance(response, dict):
452
+ self._pending_permission = None
453
+ break
454
+ self._cv.wait(timeout=0.1)
455
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
456
+ return response
457
+
458
+ try:
459
+ response = self.client.request(
460
+ "session/prompt",
461
+ {
462
+ "sessionId": self.session_id,
463
+ "prompt": _build_prompt_blocks(text, blocks),
464
+ },
465
+ timeout_seconds=3600.0,
466
+ on_notification=on_notification,
467
+ on_request=on_request,
468
+ )
469
+ stop_reason = "completed"
470
+ if isinstance(response, dict):
471
+ raw_reason = response.get("stopReason")
472
+ if isinstance(raw_reason, str) and raw_reason:
473
+ stop_reason = raw_reason
474
+
475
+ with self._cv:
476
+ canceled = self._cancel_requested
477
+ cancel_reason = self._cancel_reason
478
+ if canceled:
479
+ canceled_event = self.store.append_worker_event(
480
+ self.worker_id,
481
+ event_type="turn.canceled",
482
+ turn_id=turn_id,
483
+ data={"turnId": turn_id, "reason": cancel_reason},
484
+ raw={"runtime": "acp-stdio", "phase": "turn.canceled"},
485
+ )
486
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_IDLE)
487
+ result = {
488
+ "workerId": self.worker_id,
489
+ "turnId": turn_id,
490
+ "state": WORKER_STATE_IDLE,
491
+ "eventId": canceled_event["event_id"],
492
+ }
493
+ else:
494
+ completed_event = self.store.append_worker_event(
495
+ self.worker_id,
496
+ event_type="turn.completed",
497
+ turn_id=turn_id,
498
+ data={
499
+ "turnId": turn_id,
500
+ "outcome": "completed",
501
+ "state": WORKER_STATE_IDLE,
502
+ "stopReason": stop_reason,
503
+ },
504
+ raw={"runtime": "acp-stdio", "phase": "turn.completed"},
505
+ )
506
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_IDLE)
507
+ result = {
508
+ "workerId": self.worker_id,
509
+ "turnId": turn_id,
510
+ "state": WORKER_STATE_IDLE,
511
+ "eventId": completed_event["event_id"],
512
+ "stopReason": stop_reason,
513
+ }
514
+ with self._cv:
515
+ self._active_turn_id = None
516
+ self._turn_result = result
517
+ self._cv.notify_all()
518
+ except SubagentError as error:
519
+ self.store.append_worker_event(
520
+ self.worker_id,
521
+ event_type="turn.failed",
522
+ turn_id=turn_id,
523
+ data={"turnId": turn_id, "error": error.to_dict()},
524
+ raw={"runtime": "acp-stdio", "phase": "turn.failed"},
525
+ )
526
+ self.store.update_worker_state(
527
+ self.worker_id,
528
+ next_state=WORKER_STATE_IDLE,
529
+ last_error=error.message,
530
+ )
531
+ with self._cv:
532
+ self._active_turn_id = None
533
+ self._turn_error = error
534
+ self._cv.notify_all()
535
+
536
+ def _wait_for_turn_terminal(self) -> dict[str, Any]:
537
+ with self._cv:
538
+ while True:
539
+ if self._turn_error is not None:
540
+ raise self._turn_error
541
+ if self._turn_result is not None:
542
+ return self._turn_result
543
+ self._cv.wait(timeout=0.1)
544
+
545
+ def _handle_approve(self, params: dict[str, Any]) -> dict[str, Any]:
546
+ request_id = params.get("requestId")
547
+ decision = params.get("decision")
548
+ option_id = params.get("optionId")
549
+ alias = params.get("alias")
550
+ note = params.get("note")
551
+ if not isinstance(request_id, str) or not request_id:
552
+ raise SubagentError(code="INVALID_ARGUMENT", message="`requestId` is required.")
553
+ if decision is not None and not isinstance(decision, str):
554
+ raise SubagentError(code="INVALID_ARGUMENT", message="`decision` must be a string.")
555
+ if option_id is not None and not isinstance(option_id, str):
556
+ raise SubagentError(code="INVALID_ARGUMENT", message="`optionId` must be a string.")
557
+ if alias is not None and not isinstance(alias, str):
558
+ raise SubagentError(code="INVALID_ARGUMENT", message="`alias` must be a string.")
559
+ if note is not None and not isinstance(note, str):
560
+ raise SubagentError(code="INVALID_ARGUMENT", message="`note` must be a string.")
561
+
562
+ with self._cv:
563
+ pending = self._pending_permission
564
+ if pending is None:
565
+ raise SubagentError(
566
+ code="APPROVAL_NOT_FOUND",
567
+ message=f"Approval request not found: {request_id}",
568
+ details={"workerId": self.worker_id, "requestId": request_id},
569
+ )
570
+ pending_id = pending.get("request_id")
571
+ if str(pending_id) != request_id:
572
+ raise SubagentError(
573
+ code="APPROVAL_NOT_FOUND",
574
+ message=f"Approval request not found: {request_id}",
575
+ details={"workerId": self.worker_id, "requestId": request_id, "pendingRequestId": pending_id},
576
+ )
577
+
578
+ request = self.store.get_approval_request(self.worker_id, request_id)
579
+ if request is None:
580
+ raise SubagentError(
581
+ code="APPROVAL_NOT_FOUND",
582
+ message=f"Approval request not found: {request_id}",
583
+ details={"workerId": self.worker_id, "requestId": request_id},
584
+ )
585
+ selected_option_id, selected_alias, resolved_decision = resolve_option(
586
+ request,
587
+ decision=decision,
588
+ option_id=option_id,
589
+ alias=alias,
590
+ )
591
+ decided = self.store.decide_approval_request(
592
+ self.worker_id,
593
+ request_id,
594
+ decision=resolved_decision,
595
+ selected_option_id=selected_option_id,
596
+ selected_alias=selected_alias,
597
+ note=note,
598
+ )
599
+ turn_id = request.get("turn_id")
600
+ self.store.append_worker_event(
601
+ self.worker_id,
602
+ event_type="approval.decided",
603
+ turn_id=str(turn_id) if isinstance(turn_id, str) else None,
604
+ data={
605
+ "requestId": request_id,
606
+ "decision": decided["decision"],
607
+ "optionId": decided["selected_option_id"],
608
+ "alias": decided["selected_alias"],
609
+ "note": note,
610
+ },
611
+ raw={"runtime": "acp-stdio", "phase": "approval.decided"},
612
+ )
613
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
614
+
615
+ response_payload: dict[str, Any] = {"outcome": {"outcome": "selected", "optionId": selected_option_id}}
616
+ if selected_option_id in {"cancel", "cancelled"} or selected_alias in {"cancel", "cancelled"}:
617
+ response_payload = {"outcome": {"outcome": "cancelled"}}
618
+
619
+ with self._cv:
620
+ pending = self._pending_permission
621
+ if pending is None:
622
+ raise SubagentError(
623
+ code="APPROVAL_NOT_FOUND",
624
+ message=f"Approval request not found: {request_id}",
625
+ details={"workerId": self.worker_id, "requestId": request_id},
626
+ )
627
+ pending["response"] = response_payload
628
+ self._cv.notify_all()
629
+
630
+ result = self._wait_for_turn_terminal()
631
+ return {
632
+ "workerId": self.worker_id,
633
+ "requestId": request_id,
634
+ "decision": decided["decision"],
635
+ "optionId": decided["selected_option_id"],
636
+ "alias": decided["selected_alias"],
637
+ "state": result["state"],
638
+ "eventId": result["eventId"],
639
+ }
640
+
641
+ def _handle_cancel_turn(self, params: dict[str, Any]) -> dict[str, Any]:
642
+ reason = params.get("reason")
643
+ if reason is None:
644
+ reason = "canceled by manager"
645
+ if not isinstance(reason, str):
646
+ raise SubagentError(code="INVALID_ARGUMENT", message="`reason` must be a string.")
647
+
648
+ with self._cv:
649
+ if self._active_turn_id is None:
650
+ raise SubagentError(
651
+ code="WORKER_NOT_RUNNING",
652
+ message="worker has no active turn to cancel",
653
+ details={"workerId": self.worker_id},
654
+ )
655
+ self._cancel_requested = True
656
+ self._cancel_reason = reason
657
+ pending = self._pending_permission
658
+
659
+ if pending is not None:
660
+ request_id = str(pending["request_id"])
661
+ request = self.store.get_approval_request(self.worker_id, request_id)
662
+ if request is not None:
663
+ self.store.decide_approval_request(
664
+ self.worker_id,
665
+ request_id,
666
+ decision="cancelled",
667
+ selected_option_id="cancelled",
668
+ selected_alias="cancelled",
669
+ note=reason,
670
+ )
671
+ turn_id = request.get("turn_id")
672
+ self.store.append_worker_event(
673
+ self.worker_id,
674
+ event_type="approval.decided",
675
+ turn_id=str(turn_id) if isinstance(turn_id, str) else None,
676
+ data={
677
+ "requestId": request_id,
678
+ "decision": "cancelled",
679
+ "optionId": "cancelled",
680
+ "alias": "cancelled",
681
+ "note": reason,
682
+ },
683
+ raw={"runtime": "acp-stdio", "phase": "approval.decided"},
684
+ )
685
+ self.store.update_worker_state(self.worker_id, next_state=WORKER_STATE_RUNNING)
686
+ with self._cv:
687
+ if self._pending_permission is not None:
688
+ self._pending_permission["response"] = {"outcome": {"outcome": "cancelled"}}
689
+ self._cv.notify_all()
690
+ else:
691
+ assert self.client is not None
692
+ self.client.notify(
693
+ "session/cancel",
694
+ {"sessionId": self.session_id},
695
+ )
696
+
697
+ result = self._wait_for_turn_terminal()
698
+ return {
699
+ "workerId": self.worker_id,
700
+ "state": result["state"],
701
+ "eventId": result["eventId"],
702
+ "turnId": result["turnId"],
703
+ }
704
+
705
+ def _handle_stop(self, params: dict[str, Any]) -> dict[str, Any]:
706
+ reason = params.get("reason")
707
+ if reason is None:
708
+ reason = "worker stopped"
709
+ if not isinstance(reason, str):
710
+ raise SubagentError(code="INVALID_ARGUMENT", message="`reason` must be a string.")
711
+ with self._cv:
712
+ active_turn_id = self._active_turn_id
713
+ if active_turn_id is not None:
714
+ self._handle_cancel_turn({"reason": reason})
715
+ self._shutdown_requested = True
716
+ server_socket = self.server_socket
717
+ if server_socket is not None:
718
+ try:
719
+ server_socket.close()
720
+ except OSError:
721
+ pass
722
+ return {"workerId": self.worker_id, "stopped": True}
723
+
724
+
725
+ def _parse_args() -> argparse.Namespace:
726
+ parser = argparse.ArgumentParser(description="subagent worker runtime")
727
+ parser.add_argument("--db-path", required=True)
728
+ parser.add_argument("--worker-id", required=True)
729
+ parser.add_argument("--socket-path", required=True)
730
+ parser.add_argument("--launcher-command", required=True)
731
+ parser.add_argument("--launcher-args-json", required=True)
732
+ parser.add_argument("--launcher-env-json", required=True)
733
+ parser.add_argument("--cwd", required=True)
734
+ return parser.parse_args()
735
+
736
+
737
+ def main() -> None:
738
+ args = _parse_args()
739
+ launcher_args = json.loads(args.launcher_args_json)
740
+ launcher_env = json.loads(args.launcher_env_json)
741
+ if not isinstance(launcher_args, list):
742
+ raise SystemExit(2)
743
+ if not isinstance(launcher_env, dict):
744
+ raise SystemExit(2)
745
+ runtime = WorkerRuntime(
746
+ db_path=Path(args.db_path).expanduser().resolve(),
747
+ worker_id=str(args.worker_id),
748
+ socket_path=Path(args.socket_path),
749
+ launcher_command=str(args.launcher_command),
750
+ launcher_args=[str(item) for item in launcher_args],
751
+ launcher_env={str(key): str(value) for key, value in launcher_env.items()},
752
+ cwd=Path(args.cwd).expanduser().resolve(),
753
+ )
754
+ raise SystemExit(runtime.run())
755
+
756
+
757
+ if __name__ == "__main__":
758
+ main()