@team-agent/installer 0.1.11 → 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.
Files changed (110) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/src/team_agent/approvals/__init__.py +65 -0
  5. package/src/team_agent/approvals/constants.py +6 -0
  6. package/src/team_agent/approvals/parsing.py +176 -0
  7. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  8. package/src/team_agent/approvals/status.py +165 -0
  9. package/src/team_agent/cli/__init__.py +135 -0
  10. package/src/team_agent/cli/commands.py +335 -0
  11. package/src/team_agent/cli/e2e.py +202 -0
  12. package/src/team_agent/cli/helpers.py +137 -0
  13. package/src/team_agent/cli/parser.py +470 -0
  14. package/src/team_agent/compiler.py +98 -33
  15. package/src/team_agent/coordinator/__init__.py +53 -0
  16. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  17. package/src/team_agent/coordinator/lifecycle.py +319 -0
  18. package/src/team_agent/coordinator/metadata.py +61 -0
  19. package/src/team_agent/coordinator/paths.py +17 -0
  20. package/src/team_agent/diagnose/__init__.py +48 -0
  21. package/src/team_agent/diagnose/checks.py +101 -0
  22. package/src/team_agent/diagnose/health.py +241 -0
  23. package/src/team_agent/diagnose/preflight.py +194 -0
  24. package/src/team_agent/diagnose/quick_start.py +233 -0
  25. package/src/team_agent/display/__init__.py +61 -0
  26. package/src/team_agent/display/close.py +147 -0
  27. package/src/team_agent/display/ghostty.py +77 -0
  28. package/src/team_agent/display/worker_window.py +110 -0
  29. package/src/team_agent/display/workspace.py +473 -0
  30. package/src/team_agent/launch/__init__.py +41 -0
  31. package/src/team_agent/launch/bootstrap.py +85 -0
  32. package/src/team_agent/launch/config.py +106 -0
  33. package/src/team_agent/launch/core.py +291 -0
  34. package/src/team_agent/launch/requirements.py +57 -0
  35. package/src/team_agent/leader/__init__.py +320 -0
  36. package/src/team_agent/lifecycle/__init__.py +5 -0
  37. package/src/team_agent/lifecycle/agents.py +226 -0
  38. package/src/team_agent/lifecycle/operations.py +321 -0
  39. package/src/team_agent/lifecycle/start.py +360 -0
  40. package/src/team_agent/mcp_server/__init__.py +42 -0
  41. package/src/team_agent/mcp_server/__main__.py +7 -0
  42. package/src/team_agent/mcp_server/contracts.py +148 -0
  43. package/src/team_agent/mcp_server/normalize.py +257 -0
  44. package/src/team_agent/mcp_server/server.py +150 -0
  45. package/src/team_agent/mcp_server/tools.py +205 -0
  46. package/src/team_agent/message_store/__init__.py +23 -0
  47. package/src/team_agent/message_store/agent_health.py +109 -0
  48. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  49. package/src/team_agent/message_store/result_watchers.py +102 -0
  50. package/src/team_agent/message_store/schema.py +266 -0
  51. package/src/team_agent/messaging/__init__.py +1 -0
  52. package/src/team_agent/messaging/activity_detector.py +190 -0
  53. package/src/team_agent/messaging/delivery.py +128 -0
  54. package/src/team_agent/messaging/deps.py +263 -0
  55. package/src/team_agent/messaging/idle_alerts.py +217 -0
  56. package/src/team_agent/messaging/internal_delivery.py +46 -0
  57. package/src/team_agent/messaging/leader.py +317 -0
  58. package/src/team_agent/messaging/leader_panes.py +343 -0
  59. package/src/team_agent/messaging/result_delivery.py +300 -0
  60. package/src/team_agent/messaging/results.py +456 -0
  61. package/src/team_agent/messaging/scheduler.py +418 -0
  62. package/src/team_agent/messaging/send.py +493 -0
  63. package/src/team_agent/messaging/tmux_io.py +337 -0
  64. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  65. package/src/team_agent/orchestrator/__init__.py +376 -0
  66. package/src/team_agent/orchestrator/plan.py +122 -0
  67. package/src/team_agent/orchestrator/state.py +128 -0
  68. package/src/team_agent/profiles/__init__.py +82 -0
  69. package/src/team_agent/profiles/constants.py +19 -0
  70. package/src/team_agent/profiles/core.py +407 -0
  71. package/src/team_agent/profiles/helpers.py +69 -0
  72. package/src/team_agent/profiles/provider_env.py +188 -0
  73. package/src/team_agent/profiles/smoke.py +201 -0
  74. package/src/team_agent/provider_cli/__init__.py +43 -0
  75. package/src/team_agent/provider_cli/adapter.py +167 -0
  76. package/src/team_agent/provider_cli/base.py +48 -0
  77. package/src/team_agent/provider_cli/claude.py +457 -0
  78. package/src/team_agent/provider_cli/codex.py +319 -0
  79. package/src/team_agent/provider_cli/copilot.py +8 -0
  80. package/src/team_agent/provider_cli/fake.py +39 -0
  81. package/src/team_agent/provider_cli/gemini.py +95 -0
  82. package/src/team_agent/provider_cli/opencode.py +8 -0
  83. package/src/team_agent/provider_cli/prompt.py +62 -0
  84. package/src/team_agent/provider_cli/registry.py +18 -0
  85. package/src/team_agent/provider_cli/unsupported.py +32 -0
  86. package/src/team_agent/providers.py +67 -949
  87. package/src/team_agent/quality_gates.py +104 -0
  88. package/src/team_agent/restart/__init__.py +34 -0
  89. package/src/team_agent/restart/orchestration.py +328 -0
  90. package/src/team_agent/restart/selection.py +89 -0
  91. package/src/team_agent/restart/snapshot.py +70 -0
  92. package/src/team_agent/runtime.py +802 -5893
  93. package/src/team_agent/rust_core.py +22 -5
  94. package/src/team_agent/sessions/__init__.py +25 -0
  95. package/src/team_agent/sessions/capture.py +93 -0
  96. package/src/team_agent/sessions/inventory.py +44 -0
  97. package/src/team_agent/sessions/resume.py +135 -0
  98. package/src/team_agent/spec.py +3 -1
  99. package/src/team_agent/state.py +204 -4
  100. package/src/team_agent/status/__init__.py +63 -0
  101. package/src/team_agent/status/approvals.py +52 -0
  102. package/src/team_agent/status/compact.py +158 -0
  103. package/src/team_agent/status/constants.py +18 -0
  104. package/src/team_agent/status/inbox.py +28 -0
  105. package/src/team_agent/status/peek.py +117 -0
  106. package/src/team_agent/status/queries.py +168 -0
  107. package/src/team_agent/terminal.py +57 -0
  108. package/src/team_agent/cli.py +0 -858
  109. package/src/team_agent/mcp_server.py +0 -579
  110. 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
+ ]