zwarm 3.4.0__py3-none-any.whl → 3.7.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.
zwarm/sessions/base.py ADDED
@@ -0,0 +1,501 @@
1
+ """
2
+ Base Session Manager - Abstract interface for executor adapters.
3
+
4
+ This module defines the shared interface and data structures that all
5
+ session managers (Codex, Claude, etc.) must implement.
6
+
7
+ Architecture:
8
+ - Each session runs an executor CLI in a background subprocess
9
+ - Output is streamed to .zwarm/sessions/<session_id>/turns/turn_N.jsonl
10
+ - Session metadata stored in meta.json
11
+ - Adapters implement CLI-specific command building and output parsing
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import signal
19
+ import time
20
+ from abc import ABC, abstractmethod
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import Any
26
+ from uuid import uuid4
27
+
28
+
29
+ class SessionStatus(str, Enum):
30
+ """Status of a session."""
31
+
32
+ PENDING = "pending" # Created but not started
33
+ RUNNING = "running" # Process is running
34
+ COMPLETED = "completed" # Process exited successfully
35
+ FAILED = "failed" # Process exited with error
36
+ KILLED = "killed" # Manually killed
37
+
38
+
39
+ @dataclass
40
+ class SessionMessage:
41
+ """A message in a session's history."""
42
+
43
+ role: str # "user", "assistant", "system", "tool"
44
+ content: str
45
+ timestamp: str = ""
46
+ metadata: dict[str, Any] = field(default_factory=dict)
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "role": self.role,
51
+ "content": self.content,
52
+ "timestamp": self.timestamp,
53
+ "metadata": self.metadata,
54
+ }
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict) -> "SessionMessage":
58
+ return cls(
59
+ role=data.get("role", "unknown"),
60
+ content=data.get("content", ""),
61
+ timestamp=data.get("timestamp", ""),
62
+ metadata=data.get("metadata", {}),
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class Session:
68
+ """A managed executor session."""
69
+
70
+ id: str
71
+ task: str
72
+ status: SessionStatus
73
+ working_dir: Path
74
+ created_at: str
75
+ updated_at: str
76
+ pid: int | None = None
77
+ exit_code: int | None = None
78
+ model: str = ""
79
+ turn: int = 1
80
+ messages: list[SessionMessage] = field(default_factory=list)
81
+ token_usage: dict[str, int] = field(default_factory=dict)
82
+ error: str | None = None
83
+ # Source tracking: "user" for direct spawns, "orchestrator:<instance_id>" for delegated
84
+ source: str = "user"
85
+ # Adapter used: "codex", "claude", etc.
86
+ adapter: str = "codex"
87
+
88
+ def to_dict(self) -> dict:
89
+ return {
90
+ "id": self.id,
91
+ "task": self.task,
92
+ "status": self.status.value,
93
+ "working_dir": str(self.working_dir),
94
+ "created_at": self.created_at,
95
+ "updated_at": self.updated_at,
96
+ "pid": self.pid,
97
+ "exit_code": self.exit_code,
98
+ "model": self.model,
99
+ "turn": self.turn,
100
+ "messages": [m.to_dict() for m in self.messages],
101
+ "token_usage": self.token_usage,
102
+ "error": self.error,
103
+ "source": self.source,
104
+ "adapter": self.adapter,
105
+ }
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: dict) -> "Session":
109
+ return cls(
110
+ id=data["id"],
111
+ task=data["task"],
112
+ status=SessionStatus(data["status"]),
113
+ working_dir=Path(data["working_dir"]),
114
+ created_at=data["created_at"],
115
+ updated_at=data["updated_at"],
116
+ pid=data.get("pid"),
117
+ exit_code=data.get("exit_code"),
118
+ model=data.get("model", ""),
119
+ turn=data.get("turn", 1),
120
+ messages=[SessionMessage.from_dict(m) for m in data.get("messages", [])],
121
+ token_usage=data.get("token_usage", {}),
122
+ error=data.get("error"),
123
+ source=data.get("source", "user"),
124
+ adapter=data.get("adapter", "codex"),
125
+ )
126
+
127
+ @property
128
+ def is_running(self) -> bool:
129
+ """Check if the session process is still running."""
130
+ if self.pid is None:
131
+ return False
132
+ try:
133
+ os.kill(self.pid, 0) # Signal 0 just checks if process exists
134
+ return True
135
+ except OSError:
136
+ return False
137
+
138
+ @property
139
+ def short_id(self) -> str:
140
+ """Get first 8 chars of ID for display."""
141
+ return self.id[:8]
142
+
143
+ @property
144
+ def runtime(self) -> str:
145
+ """Get human-readable runtime."""
146
+ created = datetime.fromisoformat(self.created_at)
147
+ now = datetime.now()
148
+ delta = now - created
149
+
150
+ if delta.total_seconds() < 60:
151
+ return f"{int(delta.total_seconds())}s"
152
+ elif delta.total_seconds() < 3600:
153
+ return f"{int(delta.total_seconds() / 60)}m"
154
+ else:
155
+ return f"{delta.total_seconds() / 3600:.1f}h"
156
+
157
+ @property
158
+ def source_display(self) -> str:
159
+ """Get short display string for source."""
160
+ if self.source == "user":
161
+ return "you"
162
+ elif self.source.startswith("orchestrator:"):
163
+ instance_id = self.source.split(":", 1)[1]
164
+ return f"orch:{instance_id[:4]}"
165
+ else:
166
+ return self.source[:8]
167
+
168
+
169
+ # Type alias for backwards compatibility
170
+ CodexSession = Session
171
+
172
+
173
+ class BaseSessionManager(ABC):
174
+ """
175
+ Abstract base class for session managers.
176
+
177
+ Manages background executor sessions with:
178
+ - Session lifecycle (start, inject, kill, delete)
179
+ - State persistence (.zwarm/sessions/<id>/)
180
+ - Output parsing (JSONL → messages, trajectory)
181
+
182
+ Subclasses implement adapter-specific logic:
183
+ - Command building (CLI flags, config handling)
184
+ - Output parsing (different JSONL formats)
185
+ """
186
+
187
+ # Adapter identifier (override in subclasses)
188
+ adapter_name: str = "base"
189
+
190
+ # Default model (override in subclasses)
191
+ default_model: str = ""
192
+
193
+ def __init__(self, state_dir: Path | str = ".zwarm"):
194
+ self.state_dir = Path(state_dir)
195
+ self.sessions_dir = self.state_dir / "sessions"
196
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
197
+
198
+ # =========================================================================
199
+ # Path helpers (shared)
200
+ # =========================================================================
201
+
202
+ def _session_dir(self, session_id: str) -> Path:
203
+ """Get the directory for a session."""
204
+ return self.sessions_dir / session_id
205
+
206
+ def _meta_path(self, session_id: str) -> Path:
207
+ """Get the metadata file path for a session."""
208
+ return self._session_dir(session_id) / "meta.json"
209
+
210
+ def _output_path(self, session_id: str, turn: int = 1) -> Path:
211
+ """Get the output file path for a session turn."""
212
+ session_dir = self._session_dir(session_id)
213
+ turns_dir = session_dir / "turns"
214
+ turns_dir.mkdir(parents=True, exist_ok=True)
215
+ return turns_dir / f"turn_{turn}.jsonl"
216
+
217
+ # =========================================================================
218
+ # State persistence (shared)
219
+ # =========================================================================
220
+
221
+ def _save_session(self, session: Session) -> None:
222
+ """Save session metadata."""
223
+ session.updated_at = datetime.now().isoformat()
224
+ meta_path = self._meta_path(session.id)
225
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
226
+ meta_path.write_text(json.dumps(session.to_dict(), indent=2))
227
+
228
+ def _load_session(self, session_id: str) -> Session | None:
229
+ """Load session from disk."""
230
+ meta_path = self._meta_path(session_id)
231
+ if not meta_path.exists():
232
+ return None
233
+ try:
234
+ data = json.loads(meta_path.read_text())
235
+ return Session.from_dict(data)
236
+ except (json.JSONDecodeError, KeyError) as e:
237
+ print(f"Error loading session {session_id}: {e}")
238
+ return None
239
+
240
+ # =========================================================================
241
+ # Session retrieval (shared with adapter hooks)
242
+ # =========================================================================
243
+
244
+ def get_session(self, session_id: str) -> Session | None:
245
+ """Get a session by ID (supports partial ID matching)."""
246
+ # Try exact match first
247
+ session = self._load_session(session_id)
248
+ if session:
249
+ self._maybe_update_status(session)
250
+ return session
251
+
252
+ # Try partial match
253
+ for session_dir in self.sessions_dir.iterdir():
254
+ if session_dir.name.startswith(session_id):
255
+ session = self._load_session(session_dir.name)
256
+ if session:
257
+ self._maybe_update_status(session)
258
+ return session
259
+
260
+ return None
261
+
262
+ def list_sessions(self, status: SessionStatus | None = None) -> list[Session]:
263
+ """List all sessions, optionally filtered by status."""
264
+ sessions = []
265
+ if not self.sessions_dir.exists():
266
+ return sessions
267
+
268
+ for session_dir in self.sessions_dir.iterdir():
269
+ if not session_dir.is_dir():
270
+ continue
271
+ session = self._load_session(session_dir.name)
272
+ if session:
273
+ self._maybe_update_status(session)
274
+ if status is None or session.status == status:
275
+ sessions.append(session)
276
+
277
+ # Sort by created_at descending (newest first)
278
+ sessions.sort(key=lambda s: s.created_at, reverse=True)
279
+ return sessions
280
+
281
+ def _maybe_update_status(self, session: Session) -> None:
282
+ """Update session status if process completed."""
283
+ if session.status == SessionStatus.RUNNING:
284
+ if self._is_output_complete(session.id, session.turn) or not session.is_running:
285
+ self._update_session_status(session)
286
+
287
+ def _update_session_status(self, session: Session) -> None:
288
+ """Update session status after process completion."""
289
+ output_path = self._output_path(session.id, session.turn)
290
+ if output_path.exists():
291
+ messages, usage, error = self._parse_output(output_path)
292
+ session.messages = messages
293
+ session.token_usage = usage
294
+
295
+ has_response = any(m.role == "assistant" for m in messages)
296
+
297
+ if error and not has_response:
298
+ session.status = SessionStatus.FAILED
299
+ session.error = error
300
+ elif error and has_response:
301
+ session.status = SessionStatus.COMPLETED
302
+ session.error = f"Completed with error: {error}"
303
+ else:
304
+ session.status = SessionStatus.COMPLETED
305
+ else:
306
+ session.status = SessionStatus.FAILED
307
+ session.error = "No output file found"
308
+
309
+ self._save_session(session)
310
+
311
+ # =========================================================================
312
+ # Process management (shared)
313
+ # =========================================================================
314
+
315
+ def kill_session(self, session_id: str, delete: bool = False) -> bool:
316
+ """Kill a running session."""
317
+ session = self.get_session(session_id)
318
+ if not session:
319
+ return False
320
+
321
+ if session.pid and session.is_running:
322
+ try:
323
+ os.killpg(os.getpgid(session.pid), signal.SIGTERM)
324
+ time.sleep(0.5)
325
+ if session.is_running:
326
+ os.killpg(os.getpgid(session.pid), signal.SIGKILL)
327
+ except (OSError, ProcessLookupError):
328
+ pass
329
+
330
+ if delete:
331
+ return self.delete_session(session.id)
332
+
333
+ session.status = SessionStatus.KILLED
334
+ session.error = "Manually killed"
335
+ self._save_session(session)
336
+ return True
337
+
338
+ def delete_session(self, session_id: str) -> bool:
339
+ """Delete a session entirely (removes from disk)."""
340
+ import shutil
341
+
342
+ session = self.get_session(session_id)
343
+ if not session:
344
+ return False
345
+
346
+ if session.pid and session.is_running:
347
+ try:
348
+ os.killpg(os.getpgid(session.pid), signal.SIGTERM)
349
+ time.sleep(0.3)
350
+ if session.is_running:
351
+ os.killpg(os.getpgid(session.pid), signal.SIGKILL)
352
+ except (OSError, ProcessLookupError):
353
+ pass
354
+
355
+ session_dir = self._session_dir(session.id)
356
+ if session_dir.exists():
357
+ shutil.rmtree(session_dir)
358
+ return True
359
+
360
+ return False
361
+
362
+ def get_output(self, session_id: str, turn: int | None = None) -> str:
363
+ """Get raw JSONL output for a session."""
364
+ session = self.get_session(session_id)
365
+ if not session:
366
+ return ""
367
+
368
+ if turn is None:
369
+ turn = session.turn
370
+
371
+ output_path = self._output_path(session.id, turn)
372
+ if not output_path.exists():
373
+ return ""
374
+
375
+ return output_path.read_text()
376
+
377
+ def get_messages(self, session_id: str) -> list[SessionMessage]:
378
+ """Get parsed messages for a session across all turns."""
379
+ session = self.get_session(session_id)
380
+ if not session:
381
+ return []
382
+
383
+ all_messages = []
384
+ for turn in range(1, session.turn + 1):
385
+ output_path = self._output_path(session.id, turn)
386
+ if output_path.exists():
387
+ messages, _, _ = self._parse_output(output_path)
388
+ all_messages.extend(messages)
389
+
390
+ return all_messages
391
+
392
+ def cleanup_completed(self, keep_days: int = 7) -> int:
393
+ """Remove old completed/failed/killed sessions."""
394
+ import shutil
395
+ from datetime import timedelta
396
+
397
+ cutoff = datetime.now() - timedelta(days=keep_days)
398
+ cleaned = 0
399
+
400
+ for session in self.list_sessions():
401
+ if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
402
+ created = datetime.fromisoformat(session.created_at)
403
+ if created < cutoff:
404
+ session_dir = self._session_dir(session.id)
405
+ if session_dir.exists():
406
+ shutil.rmtree(session_dir)
407
+ cleaned += 1
408
+
409
+ return cleaned
410
+
411
+ # =========================================================================
412
+ # Abstract methods (adapter-specific)
413
+ # =========================================================================
414
+
415
+ @abstractmethod
416
+ def start_session(
417
+ self,
418
+ task: str,
419
+ working_dir: Path | None = None,
420
+ model: str | None = None,
421
+ sandbox: str = "workspace-write",
422
+ source: str = "user",
423
+ ) -> Session:
424
+ """
425
+ Start a new session in the background.
426
+
427
+ Args:
428
+ task: The task description
429
+ working_dir: Working directory for the executor
430
+ model: Model override
431
+ sandbox: Sandbox mode
432
+ source: Who spawned this session
433
+
434
+ Returns:
435
+ The created session
436
+ """
437
+ pass
438
+
439
+ @abstractmethod
440
+ def inject_message(
441
+ self,
442
+ session_id: str,
443
+ message: str,
444
+ ) -> Session | None:
445
+ """
446
+ Inject a follow-up message into a completed session.
447
+
448
+ Args:
449
+ session_id: Session to continue
450
+ message: The follow-up message
451
+
452
+ Returns:
453
+ Updated session or None if not found/not ready
454
+ """
455
+ pass
456
+
457
+ @abstractmethod
458
+ def _is_output_complete(self, session_id: str, turn: int) -> bool:
459
+ """
460
+ Check if output file indicates the task completed.
461
+
462
+ Args:
463
+ session_id: Session ID
464
+ turn: Turn number
465
+
466
+ Returns:
467
+ True if output indicates completion
468
+ """
469
+ pass
470
+
471
+ @abstractmethod
472
+ def _parse_output(
473
+ self, output_path: Path
474
+ ) -> tuple[list[SessionMessage], dict[str, int], str | None]:
475
+ """
476
+ Parse JSONL output from the executor.
477
+
478
+ Args:
479
+ output_path: Path to the JSONL file
480
+
481
+ Returns:
482
+ (messages, token_usage, error)
483
+ """
484
+ pass
485
+
486
+ @abstractmethod
487
+ def get_trajectory(
488
+ self, session_id: str, full: bool = False, max_output_len: int = 200
489
+ ) -> list[dict]:
490
+ """
491
+ Get the full trajectory of a session.
492
+
493
+ Args:
494
+ session_id: Session to get trajectory for
495
+ full: If True, include full untruncated content
496
+ max_output_len: Max length for outputs when full=False
497
+
498
+ Returns:
499
+ List of step dicts with type, summary, and details
500
+ """
501
+ pass