@team-agent/installer 0.1.10 → 0.2.0
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/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/skills/team-agent/SKILL.md +1 -1
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +135 -0
- package/src/team_agent/cli/commands.py +335 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +470 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +319 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/start.py +360 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +128 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +217 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +418 -0
- package/src/team_agent/messaging/send.py +493 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +802 -5740
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +204 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -857
- package/src/team_agent/mcp_server.py +0 -579
- package/src/team_agent/profiles.py +0 -882
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from team_agent.events import EventLog
|
|
9
|
+
from team_agent.orchestrator.plan import (
|
|
10
|
+
InvalidPlanError,
|
|
11
|
+
evaluate_condition,
|
|
12
|
+
load_plan,
|
|
13
|
+
stage_matches_result,
|
|
14
|
+
)
|
|
15
|
+
from team_agent.orchestrator.state import (
|
|
16
|
+
InvalidPlanIdError,
|
|
17
|
+
artifact_path,
|
|
18
|
+
list_plan_states,
|
|
19
|
+
load_plan_state,
|
|
20
|
+
sanitize_plan_id,
|
|
21
|
+
save_plan_state,
|
|
22
|
+
state_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def start_plan(workspace: Path, plan_path: Path, *, start: bool = True) -> dict[str, Any]:
|
|
27
|
+
workspace = Path(workspace)
|
|
28
|
+
try:
|
|
29
|
+
plan = load_plan(plan_path)
|
|
30
|
+
except InvalidPlanError as exc:
|
|
31
|
+
return {"ok": False, "error": str(exc), "reason": exc.reason, "plan_path": str(plan_path)}
|
|
32
|
+
try:
|
|
33
|
+
plan_id = sanitize_plan_id(plan.get("id"))
|
|
34
|
+
except InvalidPlanIdError as exc:
|
|
35
|
+
return {"ok": False, "error": str(exc), "reason": "invalid_plan_id", "plan_path": str(plan_path)}
|
|
36
|
+
stages = list(plan.get("stages") or [])
|
|
37
|
+
if not stages:
|
|
38
|
+
return {"ok": False, "error": "plan has no stages", "plan_id": plan_id}
|
|
39
|
+
plan_team = _text_or_none(plan.get("team"))
|
|
40
|
+
event_log = EventLog(workspace)
|
|
41
|
+
existing = load_plan_state(workspace, plan_id)
|
|
42
|
+
if existing and existing.get("status") in {"running", "halted", "completed"}:
|
|
43
|
+
return {
|
|
44
|
+
"ok": True,
|
|
45
|
+
"status": existing.get("status"),
|
|
46
|
+
"plan_id": plan_id,
|
|
47
|
+
"current_stage": existing.get("current_stage"),
|
|
48
|
+
"already_started": True,
|
|
49
|
+
"state_path": str(state_path(workspace, plan_id)),
|
|
50
|
+
}
|
|
51
|
+
state: dict[str, Any] = {
|
|
52
|
+
"plan_id": plan_id,
|
|
53
|
+
"plan_path": str(plan_path),
|
|
54
|
+
"team": plan_team,
|
|
55
|
+
"current_stage": 1,
|
|
56
|
+
"completed_stages": [],
|
|
57
|
+
"status": "running",
|
|
58
|
+
"halt_reason": None,
|
|
59
|
+
"halt_artifact": None,
|
|
60
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
61
|
+
"stages": stages,
|
|
62
|
+
"current_dispatch": None,
|
|
63
|
+
}
|
|
64
|
+
save_plan_state(workspace, state)
|
|
65
|
+
event_log.write("orchestrator.plan_started", plan_id=plan_id, stage_count=len(stages), team=plan_team)
|
|
66
|
+
if start:
|
|
67
|
+
outcome = _dispatch_stage(workspace, state, stages[0], event_log)
|
|
68
|
+
if outcome.get("status") == "halted":
|
|
69
|
+
return outcome
|
|
70
|
+
return {
|
|
71
|
+
"ok": True,
|
|
72
|
+
"status": state.get("status", "running"),
|
|
73
|
+
"plan_id": plan_id,
|
|
74
|
+
"current_stage": state["current_stage"],
|
|
75
|
+
"state_path": str(state_path(workspace, plan_id)),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def handle_report_result(workspace: Path, envelope: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
workspace = Path(workspace)
|
|
81
|
+
event_log = EventLog(workspace)
|
|
82
|
+
matched = None
|
|
83
|
+
for state in list_plan_states(workspace):
|
|
84
|
+
if state.get("status") != "running":
|
|
85
|
+
continue
|
|
86
|
+
idx = int(state.get("current_stage") or 1) - 1
|
|
87
|
+
stages = state.get("stages") or []
|
|
88
|
+
if idx < 0 or idx >= len(stages):
|
|
89
|
+
continue
|
|
90
|
+
stage = stages[idx]
|
|
91
|
+
if not stage_matches_result(stage, envelope, current_dispatch=state.get("current_dispatch")):
|
|
92
|
+
continue
|
|
93
|
+
matched = state
|
|
94
|
+
break
|
|
95
|
+
if matched is None:
|
|
96
|
+
return {"ok": True, "status": "no_match", "matched": False}
|
|
97
|
+
idx = int(matched["current_stage"]) - 1
|
|
98
|
+
stages = matched["stages"]
|
|
99
|
+
stage = stages[idx]
|
|
100
|
+
halt_expr = stage.get("halt_on")
|
|
101
|
+
advance_expr = stage.get("advance_on")
|
|
102
|
+
try:
|
|
103
|
+
halt_hit = bool(halt_expr) and evaluate_condition(str(halt_expr), envelope)
|
|
104
|
+
except InvalidPlanError as exc:
|
|
105
|
+
return _halt_plan(workspace, matched, stage, envelope, f"invalid_condition: halt_on {exc.expr!r}", event_log)
|
|
106
|
+
if halt_hit:
|
|
107
|
+
return _halt_plan(workspace, matched, stage, envelope, str(halt_expr), event_log)
|
|
108
|
+
try:
|
|
109
|
+
advance_hit = bool(advance_expr) and evaluate_condition(str(advance_expr), envelope)
|
|
110
|
+
except InvalidPlanError as exc:
|
|
111
|
+
return _halt_plan(workspace, matched, stage, envelope, f"invalid_condition: advance_on {exc.expr!r}", event_log)
|
|
112
|
+
if advance_hit:
|
|
113
|
+
matched["completed_stages"].append(matched["current_stage"])
|
|
114
|
+
matched["current_stage"] += 1
|
|
115
|
+
matched["current_dispatch"] = None
|
|
116
|
+
if matched["current_stage"] > len(stages):
|
|
117
|
+
matched["status"] = "completed"
|
|
118
|
+
matched["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
save_plan_state(workspace, matched)
|
|
120
|
+
event_log.write("orchestrator.plan_completed", plan_id=matched["plan_id"])
|
|
121
|
+
return {
|
|
122
|
+
"ok": True,
|
|
123
|
+
"status": "completed",
|
|
124
|
+
"plan_id": matched["plan_id"],
|
|
125
|
+
"current_stage": matched["current_stage"],
|
|
126
|
+
}
|
|
127
|
+
save_plan_state(workspace, matched)
|
|
128
|
+
next_stage = stages[matched["current_stage"] - 1]
|
|
129
|
+
outcome = _dispatch_stage(workspace, matched, next_stage, event_log)
|
|
130
|
+
if outcome.get("status") == "halted":
|
|
131
|
+
return outcome
|
|
132
|
+
return {
|
|
133
|
+
"ok": True,
|
|
134
|
+
"status": "running",
|
|
135
|
+
"plan_id": matched["plan_id"],
|
|
136
|
+
"current_stage": matched["current_stage"],
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
"ok": True,
|
|
140
|
+
"status": "waiting",
|
|
141
|
+
"plan_id": matched["plan_id"],
|
|
142
|
+
"current_stage": matched["current_stage"],
|
|
143
|
+
"matched": True,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resume_plans(workspace: Path) -> dict[str, Any]:
|
|
148
|
+
workspace = Path(workspace)
|
|
149
|
+
return {"ok": True, "plans": list_plan_states(workspace)}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def halt_plan(workspace: Path, plan_id: str, *, reason: str = "user_requested") -> dict[str, Any]:
|
|
153
|
+
workspace = Path(workspace)
|
|
154
|
+
try:
|
|
155
|
+
safe_id = sanitize_plan_id(plan_id)
|
|
156
|
+
except InvalidPlanIdError as exc:
|
|
157
|
+
return {"ok": False, "error": str(exc), "reason": "invalid_plan_id", "plan_id": str(plan_id)}
|
|
158
|
+
state = load_plan_state(workspace, safe_id)
|
|
159
|
+
if state is None:
|
|
160
|
+
return {"ok": False, "error": "plan not found", "plan_id": safe_id}
|
|
161
|
+
if state.get("status") != "running":
|
|
162
|
+
return {
|
|
163
|
+
"ok": True,
|
|
164
|
+
"plan_id": safe_id,
|
|
165
|
+
"status": state.get("status"),
|
|
166
|
+
"halt_reason": state.get("halt_reason"),
|
|
167
|
+
"halt_artifact": state.get("halt_artifact"),
|
|
168
|
+
"already_terminal": True,
|
|
169
|
+
}
|
|
170
|
+
event_log = EventLog(workspace)
|
|
171
|
+
idx = int(state.get("current_stage") or 1) - 1
|
|
172
|
+
stages = state.get("stages") or []
|
|
173
|
+
stage = stages[idx] if 0 <= idx < len(stages) else {"id": "unknown"}
|
|
174
|
+
return _halt_plan(workspace, state, stage, {"reason": reason}, reason, event_log)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def plan_status(workspace: Path, plan_id: str | None = None) -> dict[str, Any]:
|
|
178
|
+
workspace = Path(workspace)
|
|
179
|
+
plans = list_plan_states(workspace)
|
|
180
|
+
if plan_id is not None:
|
|
181
|
+
try:
|
|
182
|
+
safe_id = sanitize_plan_id(plan_id)
|
|
183
|
+
except InvalidPlanIdError as exc:
|
|
184
|
+
return {"ok": False, "error": str(exc), "reason": "invalid_plan_id", "plan_id": str(plan_id)}
|
|
185
|
+
match = next((state for state in plans if state.get("plan_id") == safe_id), None)
|
|
186
|
+
if match is None:
|
|
187
|
+
return {"ok": False, "error": "plan not found", "plan_id": safe_id}
|
|
188
|
+
return {"ok": True, "plan": match}
|
|
189
|
+
return {"ok": True, "plans": plans}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _text_or_none(value: Any) -> str | None:
|
|
193
|
+
if value is None:
|
|
194
|
+
return None
|
|
195
|
+
text = str(value).strip()
|
|
196
|
+
return text or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _dispatch_stage(
|
|
200
|
+
workspace: Path,
|
|
201
|
+
state: dict[str, Any],
|
|
202
|
+
stage: dict[str, Any],
|
|
203
|
+
event_log: EventLog,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
from team_agent.messaging.internal_delivery import deliver_stored_message
|
|
206
|
+
dispatch = stage.get("dispatch") or {}
|
|
207
|
+
to = dispatch.get("to")
|
|
208
|
+
content = dispatch.get("content")
|
|
209
|
+
if not to or content is None:
|
|
210
|
+
event_log.write(
|
|
211
|
+
"orchestrator.stage_dispatch_skipped",
|
|
212
|
+
plan_id=state["plan_id"],
|
|
213
|
+
stage_id=stage.get("id"),
|
|
214
|
+
reason="missing dispatch fields",
|
|
215
|
+
)
|
|
216
|
+
return _halt_plan(
|
|
217
|
+
workspace,
|
|
218
|
+
state,
|
|
219
|
+
stage,
|
|
220
|
+
{"reason": "missing dispatch fields"},
|
|
221
|
+
"dispatch_misconfigured",
|
|
222
|
+
event_log,
|
|
223
|
+
)
|
|
224
|
+
stage_team = _text_or_none(stage.get("team")) or _text_or_none(state.get("team"))
|
|
225
|
+
dispatch_task_id = _text_or_none(dispatch.get("task_id"))
|
|
226
|
+
event_log.write(
|
|
227
|
+
"orchestrator.stage_dispatch_internal",
|
|
228
|
+
plan_id=state["plan_id"],
|
|
229
|
+
stage_id=stage.get("id"),
|
|
230
|
+
to=to,
|
|
231
|
+
team=stage_team,
|
|
232
|
+
delivery="internal_delivery.deliver_stored_message",
|
|
233
|
+
owner_gate="bypassed_framework_internal",
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
result = deliver_stored_message(
|
|
237
|
+
workspace,
|
|
238
|
+
to,
|
|
239
|
+
str(content),
|
|
240
|
+
task_id=dispatch_task_id,
|
|
241
|
+
sender="orchestrator",
|
|
242
|
+
requires_ack=False,
|
|
243
|
+
wait_visible=False,
|
|
244
|
+
team=stage_team,
|
|
245
|
+
)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
event_log.write(
|
|
248
|
+
"orchestrator.stage_dispatch_failed",
|
|
249
|
+
plan_id=state["plan_id"],
|
|
250
|
+
stage_id=stage.get("id"),
|
|
251
|
+
error=str(exc),
|
|
252
|
+
)
|
|
253
|
+
return _halt_plan(
|
|
254
|
+
workspace,
|
|
255
|
+
state,
|
|
256
|
+
stage,
|
|
257
|
+
{"error": str(exc)},
|
|
258
|
+
"dispatch_failed",
|
|
259
|
+
event_log,
|
|
260
|
+
)
|
|
261
|
+
if not result.get("ok"):
|
|
262
|
+
event_log.write(
|
|
263
|
+
"orchestrator.stage_dispatch_refused",
|
|
264
|
+
plan_id=state["plan_id"],
|
|
265
|
+
stage_id=stage.get("id"),
|
|
266
|
+
send_status=result.get("status"),
|
|
267
|
+
send_reason=result.get("reason"),
|
|
268
|
+
)
|
|
269
|
+
return _halt_plan(
|
|
270
|
+
workspace,
|
|
271
|
+
state,
|
|
272
|
+
stage,
|
|
273
|
+
{"send_result": result},
|
|
274
|
+
f"dispatch_refused:{result.get('reason') or result.get('status') or 'unknown'}",
|
|
275
|
+
event_log,
|
|
276
|
+
)
|
|
277
|
+
state["current_dispatch"] = {
|
|
278
|
+
"stage_id": stage.get("id"),
|
|
279
|
+
"to": to,
|
|
280
|
+
"message_id": result.get("message_id"),
|
|
281
|
+
"task_id": dispatch.get("task_id"),
|
|
282
|
+
"team": stage_team,
|
|
283
|
+
"dispatched_at": datetime.now(timezone.utc).isoformat(),
|
|
284
|
+
"send_status": result.get("status"),
|
|
285
|
+
}
|
|
286
|
+
save_plan_state(workspace, state)
|
|
287
|
+
event_log.write(
|
|
288
|
+
"orchestrator.stage_dispatched",
|
|
289
|
+
plan_id=state["plan_id"],
|
|
290
|
+
stage_id=stage.get("id"),
|
|
291
|
+
to=to,
|
|
292
|
+
team=stage_team,
|
|
293
|
+
message_id=result.get("message_id"),
|
|
294
|
+
status=result.get("status"),
|
|
295
|
+
)
|
|
296
|
+
return {
|
|
297
|
+
"ok": True,
|
|
298
|
+
"status": "running",
|
|
299
|
+
"plan_id": state["plan_id"],
|
|
300
|
+
"current_stage": state["current_stage"],
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _halt_plan(
|
|
305
|
+
workspace: Path,
|
|
306
|
+
state: dict[str, Any],
|
|
307
|
+
stage: dict[str, Any],
|
|
308
|
+
envelope: dict[str, Any],
|
|
309
|
+
halt_reason: str,
|
|
310
|
+
event_log: EventLog,
|
|
311
|
+
) -> dict[str, Any]:
|
|
312
|
+
now = datetime.now(timezone.utc)
|
|
313
|
+
ts = now.strftime("%Y%m%dT%H%M%SZ")
|
|
314
|
+
artifact = artifact_path(workspace, state["plan_id"], ts)
|
|
315
|
+
artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
artifact.write_text(_format_halt_artifact(state, stage, envelope, halt_reason, now), encoding="utf-8")
|
|
317
|
+
state["status"] = "halted"
|
|
318
|
+
state["halt_reason"] = halt_reason
|
|
319
|
+
state["halt_artifact"] = str(artifact)
|
|
320
|
+
state["halted_at"] = now.isoformat()
|
|
321
|
+
state["halt_envelope"] = envelope
|
|
322
|
+
save_plan_state(workspace, state)
|
|
323
|
+
event_log.write(
|
|
324
|
+
"orchestrator.plan_halted",
|
|
325
|
+
plan_id=state["plan_id"],
|
|
326
|
+
stage_id=stage.get("id"),
|
|
327
|
+
halt_reason=halt_reason,
|
|
328
|
+
artifact=str(artifact),
|
|
329
|
+
)
|
|
330
|
+
return {
|
|
331
|
+
"ok": True,
|
|
332
|
+
"status": "halted",
|
|
333
|
+
"plan_id": state["plan_id"],
|
|
334
|
+
"current_stage": state["current_stage"],
|
|
335
|
+
"halt_reason": halt_reason,
|
|
336
|
+
"halt_artifact": str(artifact),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _format_halt_artifact(
|
|
341
|
+
state: dict[str, Any],
|
|
342
|
+
stage: dict[str, Any],
|
|
343
|
+
envelope: dict[str, Any],
|
|
344
|
+
halt_reason: str,
|
|
345
|
+
now: datetime,
|
|
346
|
+
) -> str:
|
|
347
|
+
lines = [
|
|
348
|
+
f"# Plan halt: {state['plan_id']}",
|
|
349
|
+
"",
|
|
350
|
+
f"Stage: {stage.get('id', state['current_stage'])}",
|
|
351
|
+
f"Halt reason: {halt_reason}",
|
|
352
|
+
f"Halted at: {now.isoformat()}",
|
|
353
|
+
"",
|
|
354
|
+
"## Stage definition",
|
|
355
|
+
"",
|
|
356
|
+
"```json",
|
|
357
|
+
json.dumps(stage, indent=2, ensure_ascii=False),
|
|
358
|
+
"```",
|
|
359
|
+
"",
|
|
360
|
+
"## Report envelope",
|
|
361
|
+
"",
|
|
362
|
+
"```json",
|
|
363
|
+
json.dumps(envelope, indent=2, ensure_ascii=False),
|
|
364
|
+
"```",
|
|
365
|
+
"",
|
|
366
|
+
]
|
|
367
|
+
return "\n".join(lines)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
__all__ = [
|
|
371
|
+
"halt_plan",
|
|
372
|
+
"handle_report_result",
|
|
373
|
+
"plan_status",
|
|
374
|
+
"resume_plans",
|
|
375
|
+
"start_plan",
|
|
376
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from team_agent import simple_yaml
|
|
8
|
+
|
|
9
|
+
_CONDITION_RE = re.compile(
|
|
10
|
+
r"^\s*report_result\.(\w+)\s*==\s*['\"]([^'\"]+)['\"]\s*$"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidPlanError(ValueError):
|
|
15
|
+
def __init__(self, reason: str, *, plan_id: str | None = None, stage_id: str | None = None, expr: str | None = None) -> None:
|
|
16
|
+
self.reason = reason
|
|
17
|
+
self.plan_id = plan_id
|
|
18
|
+
self.stage_id = stage_id
|
|
19
|
+
self.expr = expr
|
|
20
|
+
parts = [reason]
|
|
21
|
+
if plan_id:
|
|
22
|
+
parts.append(f"plan_id={plan_id}")
|
|
23
|
+
if stage_id:
|
|
24
|
+
parts.append(f"stage_id={stage_id}")
|
|
25
|
+
if expr is not None:
|
|
26
|
+
parts.append(f"expr={expr!r}")
|
|
27
|
+
super().__init__(" ".join(parts))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_plan(plan_path: Path) -> dict[str, Any]:
|
|
31
|
+
text = Path(plan_path).read_text(encoding="utf-8")
|
|
32
|
+
data = simple_yaml.loads(text)
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise InvalidPlanError("invalid_plan: top-level must be a YAML mapping")
|
|
35
|
+
plan_id = str(data.get("id") or "").strip()
|
|
36
|
+
if not plan_id:
|
|
37
|
+
raise InvalidPlanError("invalid_plan: missing 'id'")
|
|
38
|
+
stages = data.get("stages")
|
|
39
|
+
if not isinstance(stages, list) or not stages:
|
|
40
|
+
raise InvalidPlanError("invalid_plan: stages must be a non-empty list", plan_id=plan_id)
|
|
41
|
+
for stage in stages:
|
|
42
|
+
if not isinstance(stage, dict):
|
|
43
|
+
raise InvalidPlanError("invalid_plan: each stage must be a mapping", plan_id=plan_id)
|
|
44
|
+
stage_id = str(stage.get("id") or "").strip() or None
|
|
45
|
+
for key in ("advance_on", "halt_on"):
|
|
46
|
+
expr = stage.get(key)
|
|
47
|
+
if expr is None:
|
|
48
|
+
continue
|
|
49
|
+
text_expr = str(expr).strip()
|
|
50
|
+
if not _is_supported_condition(text_expr):
|
|
51
|
+
raise InvalidPlanError(
|
|
52
|
+
f"invalid_condition: {key} grammar must be \"any\" or "
|
|
53
|
+
"\"report_result.<field> == '<value>'\"",
|
|
54
|
+
plan_id=plan_id,
|
|
55
|
+
stage_id=stage_id,
|
|
56
|
+
expr=text_expr,
|
|
57
|
+
)
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stage_matches_result(
|
|
62
|
+
stage: dict[str, Any],
|
|
63
|
+
envelope: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
current_dispatch: dict[str, Any] | None = None,
|
|
66
|
+
) -> bool:
|
|
67
|
+
agent_id = str(envelope.get("agent_id") or "").strip()
|
|
68
|
+
task_id = str(envelope.get("task_id") or "").strip()
|
|
69
|
+
if current_dispatch:
|
|
70
|
+
expected_agent = str(current_dispatch.get("to") or "").strip()
|
|
71
|
+
expected_task = str(current_dispatch.get("task_id") or "").strip()
|
|
72
|
+
if expected_agent and expected_agent != agent_id:
|
|
73
|
+
return False
|
|
74
|
+
if expected_task:
|
|
75
|
+
return bool(task_id) and expected_task == task_id
|
|
76
|
+
if expected_agent:
|
|
77
|
+
stage_id = str(stage.get("id") or "").strip()
|
|
78
|
+
if stage_id and task_id and stage_id != task_id:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
dispatch = stage.get("dispatch") or {}
|
|
83
|
+
expected_to = str(dispatch.get("to") or "").strip()
|
|
84
|
+
stage_id = str(stage.get("id") or "").strip()
|
|
85
|
+
if expected_to and agent_id and expected_to == agent_id:
|
|
86
|
+
return True
|
|
87
|
+
if stage_id and task_id and stage_id == task_id:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_supported_condition(expr: str) -> bool:
|
|
93
|
+
text = (expr or "").strip()
|
|
94
|
+
if not text:
|
|
95
|
+
return False
|
|
96
|
+
if text.lower() == "any":
|
|
97
|
+
return True
|
|
98
|
+
return bool(_CONDITION_RE.match(text))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def evaluate_condition(expr: str, envelope: dict[str, Any]) -> bool:
|
|
102
|
+
text = (expr or "").strip()
|
|
103
|
+
if not text:
|
|
104
|
+
return False
|
|
105
|
+
if text.lower() == "any":
|
|
106
|
+
return True
|
|
107
|
+
match = _CONDITION_RE.match(text)
|
|
108
|
+
if not match:
|
|
109
|
+
raise InvalidPlanError("invalid_condition", expr=text)
|
|
110
|
+
field, expected = match.group(1), match.group(2)
|
|
111
|
+
actual = envelope.get(field)
|
|
112
|
+
if actual is None:
|
|
113
|
+
return False
|
|
114
|
+
return str(actual) == expected
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = [
|
|
118
|
+
"InvalidPlanError",
|
|
119
|
+
"evaluate_condition",
|
|
120
|
+
"load_plan",
|
|
121
|
+
"stage_matches_result",
|
|
122
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_PLAN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidPlanIdError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize_plan_id(plan_id: Any) -> str:
|
|
19
|
+
text = "" if plan_id is None else str(plan_id).strip()
|
|
20
|
+
if not _PLAN_ID_RE.match(text):
|
|
21
|
+
raise InvalidPlanIdError(
|
|
22
|
+
f"invalid plan id {text!r}: must match [A-Za-z0-9][A-Za-z0-9_.-]{{0,63}} "
|
|
23
|
+
"(no slashes, no spaces, no path-traversal characters)"
|
|
24
|
+
)
|
|
25
|
+
return text
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _orchestrator_runtime_dir(workspace: Path) -> Path:
|
|
29
|
+
return Path(workspace) / ".team" / "runtime" / "orchestrator"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _orchestrator_artifact_dir(workspace: Path) -> Path:
|
|
33
|
+
return Path(workspace) / ".team" / "artifacts" / "orchestrator"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def state_path(workspace: Path, plan_id: str) -> Path:
|
|
37
|
+
safe = sanitize_plan_id(plan_id)
|
|
38
|
+
return _orchestrator_runtime_dir(workspace) / f"plan-{safe}.state.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def artifact_path(workspace: Path, plan_id: str, ts: str) -> Path:
|
|
42
|
+
safe = sanitize_plan_id(plan_id)
|
|
43
|
+
return _orchestrator_artifact_dir(workspace) / f"halt-{safe}-{ts}.md"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def _plan_state_lock(workspace: Path, plan_id: str, timeout: float = 5.0):
|
|
48
|
+
import fcntl
|
|
49
|
+
import time
|
|
50
|
+
safe = sanitize_plan_id(plan_id)
|
|
51
|
+
lock_dir = _orchestrator_runtime_dir(workspace)
|
|
52
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
lock_path = lock_dir / f"plan-{safe}.lock"
|
|
54
|
+
deadline = time.monotonic() + timeout
|
|
55
|
+
with lock_path.open("w", encoding="utf-8") as lock_file:
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
59
|
+
break
|
|
60
|
+
except BlockingIOError:
|
|
61
|
+
if time.monotonic() >= deadline:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"orchestrator plan {safe} state file is locked by another process; retry"
|
|
64
|
+
)
|
|
65
|
+
time.sleep(0.05)
|
|
66
|
+
try:
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_plan_state(workspace: Path, plan_id: str) -> dict[str, Any] | None:
|
|
73
|
+
path = state_path(workspace, plan_id)
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return None
|
|
76
|
+
with _plan_state_lock(workspace, plan_id):
|
|
77
|
+
try:
|
|
78
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_plan_state(workspace: Path, state: dict[str, Any]) -> Path:
|
|
84
|
+
plan_id = sanitize_plan_id(state.get("plan_id"))
|
|
85
|
+
path = state_path(workspace, plan_id)
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
payload = json.dumps(state, indent=2, ensure_ascii=False, sort_keys=True)
|
|
88
|
+
with _plan_state_lock(workspace, plan_id):
|
|
89
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
90
|
+
prefix=f".plan-{plan_id}.", suffix=".tmp", dir=str(path.parent)
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
|
|
94
|
+
tmp_file.write(payload)
|
|
95
|
+
tmp_file.flush()
|
|
96
|
+
os.fsync(tmp_file.fileno())
|
|
97
|
+
os.replace(tmp_name, path)
|
|
98
|
+
except Exception:
|
|
99
|
+
try:
|
|
100
|
+
os.unlink(tmp_name)
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
pass
|
|
103
|
+
raise
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_plan_states(workspace: Path) -> list[dict[str, Any]]:
|
|
108
|
+
directory = _orchestrator_runtime_dir(workspace)
|
|
109
|
+
if not directory.exists():
|
|
110
|
+
return []
|
|
111
|
+
out: list[dict[str, Any]] = []
|
|
112
|
+
for path in sorted(directory.glob("plan-*.state.json")):
|
|
113
|
+
try:
|
|
114
|
+
out.append(json.loads(path.read_text(encoding="utf-8")))
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
continue
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"InvalidPlanIdError",
|
|
122
|
+
"artifact_path",
|
|
123
|
+
"list_plan_states",
|
|
124
|
+
"load_plan_state",
|
|
125
|
+
"sanitize_plan_id",
|
|
126
|
+
"save_plan_state",
|
|
127
|
+
"state_path",
|
|
128
|
+
]
|