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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|