windcode 0.1.0__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.
Files changed (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,714 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from time import monotonic
9
+ from typing import Any, cast
10
+ from uuid import uuid4
11
+
12
+ from windcode.config import PermissionMode, SubagentConfig
13
+ from windcode.domain.events import (
14
+ AgentEventType,
15
+ ApprovalRequested,
16
+ ReasoningStatus,
17
+ SubagentBlocked,
18
+ SubagentCancelled,
19
+ SubagentCleanup,
20
+ SubagentCompleted,
21
+ SubagentConflict,
22
+ SubagentEvent,
23
+ SubagentFailed,
24
+ SubagentIntegrated,
25
+ SubagentProgress,
26
+ SubagentQueued,
27
+ SubagentStarted,
28
+ ToolStarted,
29
+ UsageUpdated,
30
+ )
31
+ from windcode.domain.models import Usage
32
+ from windcode.domain.subagents import (
33
+ SubagentRecord,
34
+ SubagentResult,
35
+ SubagentStatus,
36
+ SubagentTaskKind,
37
+ SubagentTaskSpec,
38
+ VerificationResult,
39
+ sort_subagent_records,
40
+ subagent_record_from_dict,
41
+ subagent_record_to_dict,
42
+ transition_subagent,
43
+ )
44
+ from windcode.runtime.event_bus import EventBus
45
+ from windcode.runtime.subagents.approvals import ApprovalRouter
46
+ from windcode.runtime.subagents.budgets import AggregateBudget
47
+ from windcode.runtime.subagents.factory import ChildRuntime, ChildRuntimeFactory
48
+ from windcode.runtime.subagents.verification import VerificationRunner
49
+ from windcode.worktrees import GitBaseline, WorktreeLease, WorktreeManager
50
+
51
+
52
+ class SubagentCoordinatorError(RuntimeError):
53
+ def __init__(self, category: str, message: str) -> None:
54
+ self.category = category
55
+ super().__init__(message)
56
+
57
+
58
+ class SubagentCoordinator:
59
+ def __init__(
60
+ self,
61
+ *,
62
+ parent_session_id: str,
63
+ parent_run_id: str,
64
+ workspace: Path,
65
+ permission_mode: PermissionMode,
66
+ config: SubagentConfig,
67
+ event_bus: EventBus,
68
+ factory: ChildRuntimeFactory,
69
+ worktrees: WorktreeManager,
70
+ verification: VerificationRunner,
71
+ network_enabled: bool = False,
72
+ event_observer: Callable[[SubagentEvent], Awaitable[None]] | None = None,
73
+ ) -> None:
74
+ self.parent_session_id = parent_session_id
75
+ self.parent_run_id = parent_run_id
76
+ self.workspace = workspace
77
+ self.permission_mode = permission_mode
78
+ self.config = config
79
+ self.event_bus = event_bus
80
+ self.factory = factory
81
+ self.worktrees = worktrees
82
+ self.verification = verification
83
+ self.network_enabled = network_enabled
84
+ self.event_observer = event_observer
85
+ self.aggregate_budget = AggregateBudget(
86
+ max_model_steps=config.max_total_model_steps,
87
+ max_tool_calls=config.max_total_tool_calls,
88
+ max_runtime_seconds=config.max_runtime_seconds,
89
+ )
90
+ self.approvals = ApprovalRouter(
91
+ parent_session_id=parent_session_id,
92
+ parent_run_id=parent_run_id,
93
+ publish=self._publish_approval,
94
+ )
95
+ self._records: dict[str, SubagentRecord] = {}
96
+ self._results: dict[str, SubagentResult] = {}
97
+ self._leases: dict[str, WorktreeLease] = {}
98
+ self._runtimes: dict[str, ChildRuntime] = {}
99
+ self._tasks: dict[str, asyncio.Task[None]] = {}
100
+ self._completion: dict[str, asyncio.Future[SubagentResult]] = {}
101
+ self._usage: dict[str, Usage] = {}
102
+ self._queue: deque[str] = deque()
103
+ self._active = 0
104
+ self._closed = False
105
+ self._lock = asyncio.Lock()
106
+
107
+ def set_permission_mode(self, mode: PermissionMode) -> None:
108
+ previous = self.permission_mode
109
+ self.permission_mode = mode
110
+ for runtime in self._runtimes.values():
111
+ policy = runtime.loop.scheduler.policy
112
+ if runtime.record.spec.kind is SubagentTaskKind.READ and (
113
+ not self.factory.config.sandbox.enabled or not policy.sandbox_available
114
+ ):
115
+ effective = PermissionMode.PLAN
116
+ else:
117
+ effective = mode
118
+ policy.set_mode(effective)
119
+ runtime.loop.system_prompt = runtime.loop.system_prompt.replace(
120
+ f"权限模式: {previous.value}.",
121
+ f"权限模式: {effective.value}.",
122
+ )
123
+
124
+ async def _publish_approval(self, event: ApprovalRequested) -> None:
125
+ await self.event_bus.publish(event, durable=True)
126
+
127
+ def list(self) -> tuple[SubagentRecord, ...]:
128
+ return sort_subagent_records(tuple(self._records.values()))
129
+
130
+ def result(self, subagent_id: str) -> SubagentResult | None:
131
+ return self._results.get(subagent_id)
132
+
133
+ async def wait(self, subagent_id: str) -> SubagentResult:
134
+ try:
135
+ future = self._completion[subagent_id]
136
+ except KeyError as exc:
137
+ raise SubagentCoordinatorError("unknown_subagent", subagent_id) from exc
138
+ return await asyncio.shield(future)
139
+
140
+ async def spawn(self, specs: tuple[SubagentTaskSpec, ...]) -> tuple[SubagentRecord, ...]:
141
+ if not specs:
142
+ raise SubagentCoordinatorError("invalid_task", "at least one subagent task is required")
143
+ async with self._lock:
144
+ if self._closed:
145
+ raise SubagentCoordinatorError("closed", "subagent coordinator is closed")
146
+ if len(self._records) + len(specs) > self.config.max_tasks:
147
+ raise SubagentCoordinatorError(
148
+ "capacity_exceeded",
149
+ f"subagent task limit is {self.config.max_tasks}",
150
+ )
151
+ names = [spec.task_name for spec in specs]
152
+ existing_names = {record.spec.task_name for record in self._records.values()}
153
+ if len(set(names)) != len(names) or existing_names.intersection(names):
154
+ raise SubagentCoordinatorError(
155
+ "duplicate_task_name", "task names must be unique within a parent run"
156
+ )
157
+ unavailable_network = [
158
+ spec.task_name
159
+ for spec in specs
160
+ if spec.requires_network and not self.network_enabled
161
+ ]
162
+ if unavailable_network:
163
+ raise SubagentCoordinatorError(
164
+ "capability_unavailable",
165
+ "external network is unavailable for subagent tasks: "
166
+ + ", ".join(unavailable_network),
167
+ )
168
+ baseline: GitBaseline | None = None
169
+ if any(spec.kind is SubagentTaskKind.WRITE for spec in specs):
170
+ try:
171
+ baseline = await self.worktrees.validate_parent(self.workspace)
172
+ except Exception as exc:
173
+ raise SubagentCoordinatorError("write_workspace_blocked", str(exc)) from exc
174
+
175
+ start_index = len(self._records)
176
+ created: list[SubagentRecord] = []
177
+ loop = asyncio.get_running_loop()
178
+ for offset, spec in enumerate(specs):
179
+ record = SubagentRecord(
180
+ subagent_id=uuid4().hex,
181
+ parent_session_id=self.parent_session_id,
182
+ parent_run_id=self.parent_run_id,
183
+ task_index=start_index + offset,
184
+ spec=spec,
185
+ base_commit=(
186
+ baseline.commit
187
+ if baseline is not None and spec.kind is SubagentTaskKind.WRITE
188
+ else None
189
+ ),
190
+ )
191
+ self._records[record.subagent_id] = record
192
+ self._completion[record.subagent_id] = loop.create_future()
193
+ self._queue.append(record.subagent_id)
194
+ await self._persist(record)
195
+ await self._publish_record_event(record, SubagentQueued)
196
+ created.append(record)
197
+ self._schedule_locked()
198
+ return tuple(created)
199
+
200
+ def _schedule_locked(self) -> None:
201
+ while self._queue and self._active < self.config.max_concurrent:
202
+ subagent_id = self._queue.popleft()
203
+ if self._records[subagent_id].status is not SubagentStatus.QUEUED:
204
+ continue
205
+ self._active += 1
206
+ task = asyncio.create_task(self._execute(subagent_id))
207
+ self._tasks[subagent_id] = task
208
+
209
+ async def _persist(self, record: SubagentRecord) -> None:
210
+ self.event_bus.session_store.append(
211
+ "subagent_record", subagent_record_to_dict(record), durable=True
212
+ )
213
+
214
+ def _event_common(self, record: SubagentRecord) -> dict[str, Any]:
215
+ return {
216
+ "event_id": uuid4().hex,
217
+ "session_id": self.parent_session_id,
218
+ "run_id": self.parent_run_id,
219
+ "turn": 0,
220
+ "parent_run_id": self.parent_run_id,
221
+ "subagent_id": record.subagent_id,
222
+ "task_index": record.task_index,
223
+ "role": record.spec.role.value,
224
+ "task_name": record.spec.task_name,
225
+ }
226
+
227
+ async def _publish_record_event(
228
+ self,
229
+ record: SubagentRecord,
230
+ event_type: type[SubagentEvent],
231
+ **values: Any,
232
+ ) -> None:
233
+ event = cast(AgentEventType, event_type(**self._event_common(record), **values))
234
+ await self.event_bus.publish(event, durable=True)
235
+ if self.event_observer is not None and isinstance(event, SubagentEvent):
236
+ await self.event_observer(event)
237
+
238
+ async def _replace_record(self, record: SubagentRecord) -> None:
239
+ self._records[record.subagent_id] = record
240
+ await self._persist(record)
241
+
242
+ async def _transition(
243
+ self,
244
+ record: SubagentRecord,
245
+ status: SubagentStatus,
246
+ *,
247
+ error_category: str | None = None,
248
+ error_message: str | None = None,
249
+ ) -> SubagentRecord:
250
+ updated = transition_subagent(
251
+ record,
252
+ status,
253
+ error_category=error_category,
254
+ error_message=error_message,
255
+ )
256
+ await self._replace_record(updated)
257
+ return updated
258
+
259
+ async def _forward_child_events(self, runtime: ChildRuntime) -> None:
260
+ reasoning = ""
261
+ last_reasoning_publish = monotonic()
262
+ async for event in runtime.event_bus.subscribe():
263
+ record = self._records[runtime.record.subagent_id]
264
+ if isinstance(event, UsageUpdated):
265
+ self._usage[record.subagent_id] = event.usage
266
+ await self._publish_record_event(
267
+ record,
268
+ SubagentProgress,
269
+ summary="usage updated",
270
+ activity="model usage",
271
+ usage=event.usage,
272
+ )
273
+ elif isinstance(event, ToolStarted):
274
+ await self._publish_record_event(
275
+ record,
276
+ SubagentProgress,
277
+ summary="tool started",
278
+ activity=event.tool_name,
279
+ )
280
+ elif isinstance(event, ReasoningStatus):
281
+ reasoning += event.status
282
+ if monotonic() - last_reasoning_publish >= 0.5:
283
+ await self._publish_record_event(
284
+ record,
285
+ SubagentProgress,
286
+ summary="reasoning",
287
+ activity=reasoning[-500:],
288
+ )
289
+ reasoning = ""
290
+ last_reasoning_publish = monotonic()
291
+ if reasoning:
292
+ record = self._records[runtime.record.subagent_id]
293
+ await self._publish_record_event(
294
+ record,
295
+ SubagentProgress,
296
+ summary="reasoning",
297
+ activity=reasoning[-500:],
298
+ )
299
+
300
+ async def _execute(self, subagent_id: str) -> None:
301
+ try:
302
+ record = self._records[subagent_id]
303
+ workspace = self.workspace
304
+ if record.spec.kind is SubagentTaskKind.WRITE:
305
+ if record.base_commit is None:
306
+ raise SubagentCoordinatorError("missing_baseline", "write task has no baseline")
307
+ baseline = GitBaseline(
308
+ self.workspace,
309
+ "",
310
+ record.base_commit,
311
+ )
312
+ validated = await self.worktrees.validate_parent(self.workspace)
313
+ baseline = replace(
314
+ baseline, repository=validated.repository, branch=validated.branch
315
+ )
316
+ lease = await self.worktrees.create(
317
+ record.subagent_id,
318
+ record.spec.task_name,
319
+ baseline,
320
+ parent_run_id=self.parent_run_id,
321
+ )
322
+ self._leases[subagent_id] = lease
323
+ workspace = lease.path
324
+ record = replace(record, branch=lease.branch, worktree_path=lease.path)
325
+ await self._replace_record(record)
326
+
327
+ runtime = self.factory.create(
328
+ record,
329
+ workspace=workspace,
330
+ parent_permission=self.permission_mode,
331
+ aggregate_budget=self.aggregate_budget,
332
+ approval_router=self.approvals,
333
+ )
334
+ self._runtimes[subagent_id] = runtime
335
+ record = await self._transition(runtime.record, SubagentStatus.RUNNING)
336
+ await self._publish_record_event(
337
+ record,
338
+ SubagentStarted,
339
+ summary="subagent started",
340
+ workspace=str(workspace),
341
+ )
342
+ forwarding = asyncio.create_task(self._forward_child_events(runtime))
343
+ try:
344
+ run_result = await runtime.loop.run(runtime.prompt, runtime.workspace)
345
+ finally:
346
+ await forwarding
347
+
348
+ if runtime.control.cancelled or run_result.status == "cancelled":
349
+ await self._finish_cancelled(record, "child run cancelled", usage=run_result.usage)
350
+ return
351
+ if run_result.status == "blocked":
352
+ blocked = await self._transition(
353
+ record,
354
+ SubagentStatus.BLOCKED,
355
+ error_category="clarification_required",
356
+ error_message=run_result.final_text,
357
+ )
358
+ result = SubagentResult(
359
+ subagent_id,
360
+ record.spec.task_name,
361
+ SubagentStatus.BLOCKED,
362
+ run_result.final_text,
363
+ usage=run_result.usage,
364
+ error_category="clarification_required",
365
+ error_message=run_result.final_text,
366
+ )
367
+ await self._complete(result)
368
+ await self._publish_record_event(
369
+ blocked,
370
+ SubagentBlocked,
371
+ summary="clarification required",
372
+ reason=run_result.final_text,
373
+ )
374
+ return
375
+ if run_result.status == "failed":
376
+ await self._finish_failed(
377
+ record,
378
+ "child_run_failed",
379
+ run_result.final_text,
380
+ usage=run_result.usage,
381
+ )
382
+ return
383
+
384
+ changed_files: tuple[str, ...] = ()
385
+ commit: str | None = None
386
+ if record.spec.kind is SubagentTaskKind.WRITE:
387
+ inspected = await self.worktrees.inspect(self._leases[subagent_id])
388
+ if not inspected.clean or inspected.commit is None:
389
+ await self._finish_failed(
390
+ record,
391
+ "invalid_write_delivery",
392
+ "write task must finish with a clean new commit",
393
+ )
394
+ return
395
+ changed_files = inspected.changed_files
396
+ commit = inspected.commit
397
+ record = replace(record, commit=commit)
398
+ record = await self._transition(record, SubagentStatus.COMPLETED)
399
+ verification = tuple(
400
+ VerificationResult(item, None, "reported by child", True)
401
+ for item in run_result.verification
402
+ )
403
+ result = SubagentResult(
404
+ subagent_id,
405
+ record.spec.task_name,
406
+ SubagentStatus.COMPLETED,
407
+ run_result.final_text,
408
+ changed_files,
409
+ commit,
410
+ verification,
411
+ run_result.usage,
412
+ )
413
+ await self._complete(result)
414
+ await self._publish_record_event(
415
+ record,
416
+ SubagentCompleted,
417
+ summary=run_result.final_text[:500],
418
+ commit=commit,
419
+ changed_files=changed_files,
420
+ verification=run_result.verification,
421
+ usage=run_result.usage,
422
+ )
423
+ except asyncio.CancelledError:
424
+ record = self._records[subagent_id]
425
+ if record.status in {SubagentStatus.QUEUED, SubagentStatus.RUNNING}:
426
+ await self._finish_cancelled(record, "subagent cancelled")
427
+ except Exception as exc:
428
+ record = self._records[subagent_id]
429
+ if record.status in {SubagentStatus.QUEUED, SubagentStatus.RUNNING}:
430
+ await self._finish_failed(record, type(exc).__name__, str(exc))
431
+ finally:
432
+ self._runtimes.pop(subagent_id, None)
433
+ self.approvals.cancel(subagent_id)
434
+ async with self._lock:
435
+ self._active -= 1
436
+ self._tasks.pop(subagent_id, None)
437
+ self._schedule_locked()
438
+
439
+ async def _complete(self, result: SubagentResult) -> None:
440
+ self._results[result.subagent_id] = result
441
+ await self._persist_result(result)
442
+ future = self._completion[result.subagent_id]
443
+ if not future.done():
444
+ future.set_result(result)
445
+
446
+ async def _persist_result(self, result: SubagentResult) -> None:
447
+ self.event_bus.session_store.append(
448
+ "subagent_result",
449
+ {
450
+ "subagent_id": result.subagent_id,
451
+ "task_name": result.task_name,
452
+ "status": result.status.value,
453
+ "summary": result.summary,
454
+ "commit": result.commit,
455
+ "error_category": result.error_category,
456
+ "error_message": result.error_message,
457
+ "usage": {
458
+ "input_tokens": result.usage.input_tokens,
459
+ "output_tokens": result.usage.output_tokens,
460
+ "cache_read_tokens": result.usage.cache_read_tokens,
461
+ "cache_write_tokens": result.usage.cache_write_tokens,
462
+ },
463
+ },
464
+ durable=True,
465
+ )
466
+
467
+ async def _finish_failed(
468
+ self,
469
+ record: SubagentRecord,
470
+ category: str,
471
+ message: str,
472
+ *,
473
+ usage: Usage | None = None,
474
+ ) -> None:
475
+ final_usage = usage or self._usage.get(record.subagent_id, Usage())
476
+ failed = await self._transition(
477
+ record,
478
+ SubagentStatus.FAILED,
479
+ error_category=category,
480
+ error_message=message,
481
+ )
482
+ result = SubagentResult(
483
+ record.subagent_id,
484
+ record.spec.task_name,
485
+ SubagentStatus.FAILED,
486
+ message,
487
+ usage=final_usage,
488
+ error_category=category,
489
+ error_message=message,
490
+ )
491
+ await self._complete(result)
492
+ await self._publish_record_event(
493
+ failed,
494
+ SubagentFailed,
495
+ summary="subagent failed",
496
+ message=message,
497
+ category=category,
498
+ usage=final_usage,
499
+ )
500
+
501
+ async def _finish_cancelled(
502
+ self,
503
+ record: SubagentRecord,
504
+ reason: str,
505
+ *,
506
+ usage: Usage | None = None,
507
+ ) -> None:
508
+ final_usage = usage or self._usage.get(record.subagent_id, Usage())
509
+ cancelled = await self._transition(
510
+ record,
511
+ SubagentStatus.CANCELLED,
512
+ error_category="cancelled",
513
+ error_message=reason,
514
+ )
515
+ result = SubagentResult(
516
+ record.subagent_id,
517
+ record.spec.task_name,
518
+ SubagentStatus.CANCELLED,
519
+ reason,
520
+ usage=final_usage,
521
+ error_category="cancelled",
522
+ error_message=reason,
523
+ )
524
+ await self._complete(result)
525
+ await self._publish_record_event(
526
+ cancelled,
527
+ SubagentCancelled,
528
+ summary="subagent cancelled",
529
+ reason=reason,
530
+ usage=final_usage,
531
+ )
532
+
533
+ async def cancel(self, subagent_id: str) -> SubagentRecord:
534
+ try:
535
+ record = self._records[subagent_id]
536
+ except KeyError as exc:
537
+ raise SubagentCoordinatorError("unknown_subagent", subagent_id) from exc
538
+ if record.status is SubagentStatus.QUEUED:
539
+ await self._finish_cancelled(record, "cancelled while queued")
540
+ return self._records[subagent_id]
541
+ if record.status is SubagentStatus.RUNNING:
542
+ runtime = self._runtimes.get(subagent_id)
543
+ if runtime is not None:
544
+ runtime.control.cancel()
545
+ self.approvals.cancel(subagent_id)
546
+ task = self._tasks.get(subagent_id)
547
+ if task is not None:
548
+ task.cancel()
549
+ await asyncio.gather(task, return_exceptions=True)
550
+ return self._records[subagent_id]
551
+ return record
552
+
553
+ async def integrate(
554
+ self,
555
+ subagent_id: str,
556
+ verification_commands: tuple[str, ...] = (),
557
+ ) -> SubagentResult:
558
+ record = self._records.get(subagent_id)
559
+ if (
560
+ record is None
561
+ or record.status is not SubagentStatus.COMPLETED
562
+ or record.spec.kind is not SubagentTaskKind.WRITE
563
+ or record.commit is None
564
+ or subagent_id not in self._leases
565
+ ):
566
+ raise SubagentCoordinatorError(
567
+ "not_integratable", "subagent must be a completed clean write task"
568
+ )
569
+ integration = await self.worktrees.integrate(self._leases[subagent_id], self.workspace)
570
+ if not integration.integrated:
571
+ conflict = await self._transition(record, SubagentStatus.CONFLICT)
572
+ result = replace(
573
+ self._results[subagent_id],
574
+ status=SubagentStatus.CONFLICT,
575
+ error_category="integration_conflict",
576
+ error_message=integration.error_message,
577
+ )
578
+ self._results[subagent_id] = result
579
+ await self._persist_result(result)
580
+ await self._publish_record_event(
581
+ conflict,
582
+ SubagentConflict,
583
+ summary="integration conflict",
584
+ conflict_files=integration.conflict_files,
585
+ message=integration.error_message or "cherry-pick conflict",
586
+ )
587
+ return result
588
+
589
+ verification = await self.verification.run(
590
+ verification_commands,
591
+ workspace=self.workspace,
592
+ run_id=self.parent_run_id,
593
+ )
594
+ if any(not item.passed for item in verification):
595
+ failed = await self._transition(record, SubagentStatus.INTEGRATION_FAILED)
596
+ result = replace(
597
+ self._results[subagent_id],
598
+ status=SubagentStatus.INTEGRATION_FAILED,
599
+ verification=verification,
600
+ error_category="parent_verification_failed",
601
+ error_message="parent verification failed after integration",
602
+ )
603
+ self._results[subagent_id] = result
604
+ await self._persist_result(result)
605
+ await self._publish_record_event(
606
+ failed,
607
+ SubagentFailed,
608
+ summary="parent verification failed",
609
+ message="parent verification failed after integration",
610
+ category="parent_verification_failed",
611
+ usage=result.usage,
612
+ )
613
+ return result
614
+
615
+ integrated = await self._transition(record, SubagentStatus.INTEGRATED)
616
+ result = replace(
617
+ self._results[subagent_id],
618
+ status=SubagentStatus.INTEGRATED,
619
+ verification=verification,
620
+ )
621
+ self._results[subagent_id] = result
622
+ await self._persist_result(result)
623
+ await self._publish_record_event(
624
+ integrated,
625
+ SubagentIntegrated,
626
+ summary="subagent commit integrated",
627
+ commit=integration.parent_commit_after,
628
+ verification=tuple(item.command for item in verification),
629
+ )
630
+ cleanup = await self.worktrees.cleanup(
631
+ self._leases[subagent_id],
632
+ self.workspace,
633
+ integrated=True,
634
+ )
635
+ await self._publish_record_event(
636
+ integrated,
637
+ SubagentCleanup,
638
+ summary="Worktree cleanup",
639
+ removed=cleanup.removed,
640
+ retained_path=None if cleanup.retained_path is None else str(cleanup.retained_path),
641
+ reason=cleanup.reason,
642
+ )
643
+ return result
644
+
645
+ async def shutdown(self, reason: str) -> None:
646
+ self._closed = True
647
+ active = [
648
+ record.subagent_id
649
+ for record in self.list()
650
+ if record.status in {SubagentStatus.QUEUED, SubagentStatus.RUNNING}
651
+ ]
652
+ await asyncio.gather(*(self.cancel(subagent_id) for subagent_id in active))
653
+
654
+ async def recover(self) -> tuple[SubagentRecord, ...]:
655
+ latest: dict[str, SubagentRecord] = {}
656
+ persisted_results: dict[str, dict[str, object]] = {}
657
+ for stored in self.event_bus.session_store.load_records():
658
+ if stored.record_type == "subagent_record":
659
+ record = subagent_record_from_dict(stored.payload)
660
+ latest[record.subagent_id] = record
661
+ elif stored.record_type == "subagent_result":
662
+ persisted_results[str(stored.payload.get("subagent_id"))] = stored.payload
663
+ self._records = latest
664
+ for record in self.list():
665
+ self._completion.setdefault(
666
+ record.subagent_id, asyncio.get_running_loop().create_future()
667
+ )
668
+ if record.status in {SubagentStatus.QUEUED, SubagentStatus.RUNNING}:
669
+ await self._finish_cancelled(record, "interrupted before recovery")
670
+ continue
671
+ raw_result = persisted_results.get(record.subagent_id)
672
+ if raw_result is not None:
673
+ raw_usage = raw_result.get("usage")
674
+ usage_values: Mapping[str, object] = (
675
+ cast(Mapping[str, object], raw_usage)
676
+ if isinstance(raw_usage, Mapping)
677
+ else dict[str, object]()
678
+ )
679
+ usage = Usage(
680
+ input_tokens=int(str(usage_values.get("input_tokens", 0))),
681
+ output_tokens=int(str(usage_values.get("output_tokens", 0))),
682
+ cache_read_tokens=int(str(usage_values.get("cache_read_tokens", 0))),
683
+ cache_write_tokens=int(str(usage_values.get("cache_write_tokens", 0))),
684
+ )
685
+ result = SubagentResult(
686
+ record.subagent_id,
687
+ record.spec.task_name,
688
+ record.status,
689
+ str(raw_result.get("summary", "")),
690
+ commit=record.commit,
691
+ usage=usage,
692
+ error_category=(
693
+ None
694
+ if raw_result.get("error_category") is None
695
+ else str(raw_result.get("error_category"))
696
+ ),
697
+ error_message=(
698
+ None
699
+ if raw_result.get("error_message") is None
700
+ else str(raw_result.get("error_message"))
701
+ ),
702
+ )
703
+ self._results[record.subagent_id] = result
704
+ future = self._completion[record.subagent_id]
705
+ if not future.done():
706
+ future.set_result(result)
707
+ if record.worktree_path is not None:
708
+ try:
709
+ lease = await self.worktrees.recover(record)
710
+ except Exception:
711
+ lease = None
712
+ if lease is not None:
713
+ self._leases[record.subagent_id] = lease
714
+ return self.list()