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/manager.py CHANGED
@@ -1,189 +1,54 @@
1
1
  """
2
2
  Codex Session Manager - Background process management for Codex agents.
3
3
 
4
- Architecture:
5
- - Each session runs `codex exec --json` in a background subprocess
6
- - Output is streamed to .zwarm/sessions/<session_id>/output.jsonl
7
- - Session metadata stored in meta.json
8
- - Can inject follow-up messages by starting new turns with context
9
- - Config is read from .zwarm/codex.toml and passed via -c overrides
4
+ This module implements the CodexSessionManager, which handles:
5
+ - Spawning `codex exec --json` subprocesses
6
+ - Parsing Codex's JSONL output format
7
+ - Loading config from .zwarm/codex.toml
8
+
9
+ Inherits shared functionality from BaseSessionManager.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
- import os
16
- import signal
17
15
  import subprocess
18
- import time
19
16
  import tomllib
20
- from dataclasses import dataclass, field
21
17
  from datetime import datetime
22
- from enum import Enum
23
18
  from pathlib import Path
24
19
  from typing import Any
25
20
  from uuid import uuid4
26
21
 
22
+ from .base import (
23
+ BaseSessionManager,
24
+ Session,
25
+ SessionMessage,
26
+ SessionStatus,
27
+ )
27
28
 
28
- class SessionStatus(str, Enum):
29
- """Status of a codex session."""
30
-
31
- PENDING = "pending" # Created but not started
32
- RUNNING = "running" # Process is running
33
- COMPLETED = "completed" # Process exited successfully
34
- FAILED = "failed" # Process exited with error
35
- KILLED = "killed" # Manually killed
36
-
37
-
38
- @dataclass
39
- class SessionMessage:
40
- """A message in a session's history."""
41
-
42
- role: str # "user", "assistant", "system", "tool"
43
- content: str
44
- timestamp: str = ""
45
- metadata: dict[str, Any] = field(default_factory=dict)
46
-
47
- def to_dict(self) -> dict:
48
- return {
49
- "role": self.role,
50
- "content": self.content,
51
- "timestamp": self.timestamp,
52
- "metadata": self.metadata,
53
- }
54
-
55
- @classmethod
56
- def from_dict(cls, data: dict) -> "SessionMessage":
57
- return cls(
58
- role=data.get("role", "unknown"),
59
- content=data.get("content", ""),
60
- timestamp=data.get("timestamp", ""),
61
- metadata=data.get("metadata", {}),
62
- )
63
-
64
-
65
- @dataclass
66
- class CodexSession:
67
- """A managed Codex session."""
68
-
69
- id: str
70
- task: str
71
- status: SessionStatus
72
- working_dir: Path
73
- created_at: str
74
- updated_at: str
75
- pid: int | None = None
76
- exit_code: int | None = None
77
- model: str = "gpt-5.1-codex-mini"
78
- turn: int = 1
79
- messages: list[SessionMessage] = field(default_factory=list)
80
- token_usage: dict[str, int] = field(default_factory=dict)
81
- error: str | None = None
82
- # Source tracking: "user" for direct spawns, "orchestrator:<instance_id>" for delegated
83
- source: str = "user"
84
- # Adapter used: "codex", "claude_code", etc.
85
- adapter: str = "codex"
86
-
87
- def to_dict(self) -> dict:
88
- return {
89
- "id": self.id,
90
- "task": self.task,
91
- "status": self.status.value,
92
- "working_dir": str(self.working_dir),
93
- "created_at": self.created_at,
94
- "updated_at": self.updated_at,
95
- "pid": self.pid,
96
- "exit_code": self.exit_code,
97
- "model": self.model,
98
- "turn": self.turn,
99
- "messages": [m.to_dict() for m in self.messages],
100
- "token_usage": self.token_usage,
101
- "error": self.error,
102
- "source": self.source,
103
- "adapter": self.adapter,
104
- }
105
-
106
- @classmethod
107
- def from_dict(cls, data: dict) -> "CodexSession":
108
- return cls(
109
- id=data["id"],
110
- task=data["task"],
111
- status=SessionStatus(data["status"]),
112
- working_dir=Path(data["working_dir"]),
113
- created_at=data["created_at"],
114
- updated_at=data["updated_at"],
115
- pid=data.get("pid"),
116
- exit_code=data.get("exit_code"),
117
- model=data.get("model", "gpt-5.1-codex-mini"),
118
- turn=data.get("turn", 1),
119
- messages=[SessionMessage.from_dict(m) for m in data.get("messages", [])],
120
- token_usage=data.get("token_usage", {}),
121
- error=data.get("error"),
122
- source=data.get("source", "user"),
123
- adapter=data.get("adapter", "codex"),
124
- )
29
+ # Re-export for backwards compatibility
30
+ CodexSession = Session
125
31
 
126
- @property
127
- def is_running(self) -> bool:
128
- """Check if the session process is still running."""
129
- if self.pid is None:
130
- return False
131
- try:
132
- os.kill(self.pid, 0) # Signal 0 just checks if process exists
133
- return True
134
- except OSError:
135
- return False
136
32
 
137
- @property
138
- def short_id(self) -> str:
139
- """Get first 8 chars of ID for display."""
140
- return self.id[:8]
141
-
142
- @property
143
- def runtime(self) -> str:
144
- """Get human-readable runtime."""
145
- created = datetime.fromisoformat(self.created_at)
146
- now = datetime.now()
147
- delta = now - created
148
-
149
- if delta.total_seconds() < 60:
150
- return f"{int(delta.total_seconds())}s"
151
- elif delta.total_seconds() < 3600:
152
- return f"{int(delta.total_seconds() / 60)}m"
153
- else:
154
- return f"{delta.total_seconds() / 3600:.1f}h"
155
-
156
- @property
157
- def source_display(self) -> str:
158
- """Get short display string for source."""
159
- if self.source == "user":
160
- return "you"
161
- elif self.source.startswith("orchestrator:"):
162
- # Extract instance ID and shorten it
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
- class CodexSessionManager:
33
+ class CodexSessionManager(BaseSessionManager):
170
34
  """
171
35
  Manages background Codex sessions.
172
36
 
173
37
  Sessions are stored in:
174
38
  .zwarm/sessions/<session_id>/
175
39
  meta.json - Session metadata
176
- output.jsonl - Raw JSONL output from codex exec
177
40
  turns/
178
41
  turn_1.jsonl
179
42
  turn_2.jsonl
180
43
  ...
181
44
  """
182
45
 
183
- def __init__(self, state_dir: Path | str = ".zwarm"):
184
- self.state_dir = Path(state_dir)
185
- self.sessions_dir = self.state_dir / "sessions"
186
- self.sessions_dir.mkdir(parents=True, exist_ok=True)
46
+ adapter_name = "codex"
47
+ default_model = "gpt-5.1-codex-mini"
48
+
49
+ # =========================================================================
50
+ # Codex-specific config handling
51
+ # =========================================================================
187
52
 
188
53
  def _load_codex_config(self) -> dict[str, Any]:
189
54
  """
@@ -227,160 +92,9 @@ class CodexSessionManager:
227
92
 
228
93
  return overrides
229
94
 
230
- def _session_dir(self, session_id: str) -> Path:
231
- """Get the directory for a session."""
232
- return self.sessions_dir / session_id
233
-
234
- def _meta_path(self, session_id: str) -> Path:
235
- """Get the metadata file path for a session."""
236
- return self._session_dir(session_id) / "meta.json"
237
-
238
- def _output_path(self, session_id: str, turn: int = 1) -> Path:
239
- """Get the output file path for a session turn."""
240
- session_dir = self._session_dir(session_id)
241
- turns_dir = session_dir / "turns"
242
- turns_dir.mkdir(parents=True, exist_ok=True)
243
- return turns_dir / f"turn_{turn}.jsonl"
244
-
245
- def _save_session(self, session: CodexSession) -> None:
246
- """Save session metadata."""
247
- session.updated_at = datetime.now().isoformat()
248
- meta_path = self._meta_path(session.id)
249
- meta_path.parent.mkdir(parents=True, exist_ok=True)
250
- meta_path.write_text(json.dumps(session.to_dict(), indent=2))
251
-
252
- def _load_session(self, session_id: str) -> CodexSession | None:
253
- """Load session from disk."""
254
- meta_path = self._meta_path(session_id)
255
- if not meta_path.exists():
256
- return None
257
- try:
258
- data = json.loads(meta_path.read_text())
259
- return CodexSession.from_dict(data)
260
- except (json.JSONDecodeError, KeyError) as e:
261
- print(f"Error loading session {session_id}: {e}")
262
- return None
263
-
264
- def list_sessions(self, status: SessionStatus | None = None) -> list[CodexSession]:
265
- """List all sessions, optionally filtered by status."""
266
- sessions = []
267
- if not self.sessions_dir.exists():
268
- return sessions
269
-
270
- for session_dir in self.sessions_dir.iterdir():
271
- if not session_dir.is_dir():
272
- continue
273
- session = self._load_session(session_dir.name)
274
- if session:
275
- # Update status if process died OR output indicates completion
276
- # (output check is more reliable than PID check due to PID reuse)
277
- if session.status == SessionStatus.RUNNING:
278
- if (
279
- self._is_output_complete(session.id, session.turn)
280
- or not session.is_running
281
- ):
282
- self._update_session_status(session)
283
-
284
- if status is None or session.status == status:
285
- sessions.append(session)
286
-
287
- # Sort by created_at descending (newest first)
288
- sessions.sort(key=lambda s: s.created_at, reverse=True)
289
- return sessions
290
-
291
- def get_session(self, session_id: str) -> CodexSession | None:
292
- """Get a session by ID (supports partial ID matching)."""
293
- # Try exact match first
294
- session = self._load_session(session_id)
295
- if session:
296
- if session.status == SessionStatus.RUNNING:
297
- if (
298
- self._is_output_complete(session.id, session.turn)
299
- or not session.is_running
300
- ):
301
- self._update_session_status(session)
302
- return session
303
-
304
- # Try partial match
305
- for session_dir in self.sessions_dir.iterdir():
306
- if session_dir.name.startswith(session_id):
307
- session = self._load_session(session_dir.name)
308
- if session:
309
- if session.status == SessionStatus.RUNNING:
310
- if (
311
- self._is_output_complete(session.id, session.turn)
312
- or not session.is_running
313
- ):
314
- self._update_session_status(session)
315
- return session
316
-
317
- return None
318
-
319
- def _is_output_complete(self, session_id: str, turn: int) -> bool:
320
- """
321
- Check if output file indicates the task completed.
322
-
323
- Looks for completion markers like 'turn.completed' or 'task.completed'
324
- in the JSONL output. This is more reliable than PID checking.
325
- """
326
- output_path = self._output_path(session_id, turn)
327
- if not output_path.exists():
328
- return False
329
-
330
- try:
331
- content = output_path.read_text()
332
- for line in content.strip().split("\n"):
333
- if not line.strip():
334
- continue
335
- try:
336
- event = json.loads(line)
337
- event_type = event.get("type", "")
338
- # Check for any completion marker
339
- if event_type in (
340
- "turn.completed",
341
- "task.completed",
342
- "completed",
343
- "done",
344
- ):
345
- return True
346
- # Also check for error as a form of completion
347
- if event_type == "error":
348
- return True
349
- except json.JSONDecodeError:
350
- continue
351
- except Exception:
352
- pass
353
-
354
- return False
355
-
356
- def _update_session_status(self, session: CodexSession) -> None:
357
- """Update session status after process completion."""
358
- # Parse output to determine status
359
- output_path = self._output_path(session.id, session.turn)
360
- if output_path.exists():
361
- messages, usage, error = self._parse_output(output_path)
362
- session.messages = messages
363
- session.token_usage = usage
364
-
365
- # Check if we got actual assistant responses
366
- has_response = any(m.role == "assistant" for m in messages)
367
-
368
- if error and not has_response:
369
- # Only mark as failed if we have an error AND no response
370
- session.status = SessionStatus.FAILED
371
- session.error = error
372
- elif error and has_response:
373
- # Got response but also an error (e.g., network disconnect at end)
374
- # Treat as completed but note the error
375
- session.status = SessionStatus.COMPLETED
376
- session.error = f"Completed with error: {error}"
377
- else:
378
- session.status = SessionStatus.COMPLETED
379
- else:
380
- session.status = SessionStatus.FAILED
381
- session.error = "No output file found"
382
-
383
- self._save_session(session)
95
+ # =========================================================================
96
+ # Session lifecycle (Codex-specific implementation)
97
+ # =========================================================================
384
98
 
385
99
  def start_session(
386
100
  self,
@@ -389,8 +103,7 @@ class CodexSessionManager:
389
103
  model: str | None = None,
390
104
  sandbox: str = "workspace-write",
391
105
  source: str = "user",
392
- adapter: str = "codex",
393
- ) -> CodexSession:
106
+ ) -> Session:
394
107
  """
395
108
  Start a new Codex session in the background.
396
109
 
@@ -400,7 +113,6 @@ class CodexSessionManager:
400
113
  model: Model override (default: from codex.toml or gpt-5.1-codex-mini)
401
114
  sandbox: Sandbox mode (ignored if full_danger=true in codex.toml)
402
115
  source: Who spawned this session ("user" or "orchestrator:<id>")
403
- adapter: Which adapter to use ("codex", "claude_code")
404
116
 
405
117
  Returns:
406
118
  The created session
@@ -417,12 +129,12 @@ class CodexSessionManager:
417
129
  codex_config = self._load_codex_config()
418
130
 
419
131
  # Get model from config or use default
420
- effective_model = model or codex_config.get("model", "gpt-5.1-codex-mini")
132
+ effective_model = model or codex_config.get("model", self.default_model)
421
133
 
422
134
  # Check if full_danger mode is enabled
423
135
  full_danger = codex_config.get("full_danger", False)
424
136
 
425
- session = CodexSession(
137
+ session = Session(
426
138
  id=session_id,
427
139
  task=task,
428
140
  status=SessionStatus.PENDING,
@@ -433,7 +145,7 @@ class CodexSessionManager:
433
145
  turn=1,
434
146
  messages=[SessionMessage(role="user", content=task, timestamp=now)],
435
147
  source=source,
436
- adapter=adapter,
148
+ adapter=self.adapter_name,
437
149
  )
438
150
 
439
151
  # Create session directory
@@ -441,7 +153,6 @@ class CodexSessionManager:
441
153
  session_dir.mkdir(parents=True, exist_ok=True)
442
154
 
443
155
  # Build command with -c overrides from codex.toml
444
- # This ensures each .zwarm dir has its own codex config
445
156
  cmd = ["codex"]
446
157
 
447
158
  # Add -c overrides from codex.toml (excluding special keys we handle separately)
@@ -487,7 +198,7 @@ class CodexSessionManager:
487
198
  self,
488
199
  session_id: str,
489
200
  message: str,
490
- ) -> CodexSession | None:
201
+ ) -> Session | None:
491
202
  """
492
203
  Inject a follow-up message into a completed session.
493
204
 
@@ -505,8 +216,6 @@ class CodexSessionManager:
505
216
  return None
506
217
 
507
218
  if session.status == SessionStatus.RUNNING:
508
- # Can't inject while running - would need to implement
509
- # a more complex IPC mechanism for that
510
219
  return None
511
220
 
512
221
  # Build context from previous messages
@@ -533,25 +242,31 @@ Continue from where you left off, addressing the user's new message."""
533
242
  SessionMessage(role="user", content=message, timestamp=now)
534
243
  )
535
244
 
536
- # Build command
537
- # Note: --search is a global flag that must come before 'exec'
245
+ # Build command with -c overrides from codex.toml
246
+ codex_config = self._load_codex_config()
247
+ full_danger = codex_config.get("full_danger", False)
248
+
538
249
  cmd = ["codex"]
539
- if session.web_search:
540
- cmd.append("--search")
541
- cmd.extend(
542
- [
543
- "exec",
544
- "--json",
545
- "--full-auto",
546
- "--skip-git-repo-check",
547
- "--model",
548
- session.model,
549
- "-C",
550
- str(session.working_dir.absolute()),
551
- "--",
552
- augmented_task,
553
- ]
554
- )
250
+
251
+ # Add -c overrides from codex.toml (excluding special keys)
252
+ config_for_overrides = {k: v for k, v in codex_config.items() if k not in ("model", "full_danger")}
253
+ cmd.extend(self._build_codex_overrides(config_for_overrides))
254
+
255
+ cmd.extend([
256
+ "exec",
257
+ "--json",
258
+ "--skip-git-repo-check",
259
+ "--model",
260
+ session.model,
261
+ "-C",
262
+ str(session.working_dir.absolute()),
263
+ ])
264
+
265
+ # Full danger mode bypasses all safety controls
266
+ if full_danger:
267
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
268
+
269
+ cmd.extend(["--", augmented_task])
555
270
 
556
271
  # Start process
557
272
  output_path = self._output_path(session.id, session.turn)
@@ -571,104 +286,47 @@ Continue from where you left off, addressing the user's new message."""
571
286
 
572
287
  return session
573
288
 
574
- def kill_session(self, session_id: str, delete: bool = False) -> bool:
575
- """
576
- Kill a running session.
577
-
578
- Args:
579
- session_id: Session to kill
580
- delete: If True, also delete session data entirely
581
-
582
- Returns True if killed, False if not found or not running.
583
- """
584
- session = self.get_session(session_id)
585
- if not session:
586
- return False
587
-
588
- if session.pid and session.is_running:
589
- try:
590
- # Kill the entire process group
591
- os.killpg(os.getpgid(session.pid), signal.SIGTERM)
592
- time.sleep(0.5)
593
-
594
- # Force kill if still running
595
- if session.is_running:
596
- os.killpg(os.getpgid(session.pid), signal.SIGKILL)
597
- except (OSError, ProcessLookupError):
598
- pass
599
-
600
- if delete:
601
- return self.delete_session(session.id)
602
-
603
- session.status = SessionStatus.KILLED
604
- session.error = "Manually killed"
605
- self._save_session(session)
606
- return True
289
+ # =========================================================================
290
+ # Output parsing (Codex-specific JSONL format)
291
+ # =========================================================================
607
292
 
608
- def delete_session(self, session_id: str) -> bool:
293
+ def _is_output_complete(self, session_id: str, turn: int) -> bool:
609
294
  """
610
- Delete a session entirely (removes from disk).
611
-
612
- Kills the process first if still running.
295
+ Check if output file indicates the task completed.
613
296
 
614
- Returns True if deleted, False if not found.
297
+ Looks for completion markers like 'turn.completed' or 'task.completed'
298
+ in the JSONL output. This is more reliable than PID checking.
615
299
  """
616
- import shutil
617
-
618
- session = self.get_session(session_id)
619
- if not session:
300
+ output_path = self._output_path(session_id, turn)
301
+ if not output_path.exists():
620
302
  return False
621
303
 
622
- # Kill if running
623
- if session.pid and session.is_running:
624
- try:
625
- os.killpg(os.getpgid(session.pid), signal.SIGTERM)
626
- time.sleep(0.3)
627
- if session.is_running:
628
- os.killpg(os.getpgid(session.pid), signal.SIGKILL)
629
- except (OSError, ProcessLookupError):
630
- pass
631
-
632
- # Remove session directory
633
- session_dir = self._session_dir(session.id)
634
- if session_dir.exists():
635
- shutil.rmtree(session_dir)
636
- return True
304
+ try:
305
+ content = output_path.read_text()
306
+ for line in content.strip().split("\n"):
307
+ if not line.strip():
308
+ continue
309
+ try:
310
+ event = json.loads(line)
311
+ event_type = event.get("type", "")
312
+ # Check for any completion marker
313
+ if event_type in (
314
+ "turn.completed",
315
+ "task.completed",
316
+ "completed",
317
+ "done",
318
+ ):
319
+ return True
320
+ # Also check for error as a form of completion
321
+ if event_type == "error":
322
+ return True
323
+ except json.JSONDecodeError:
324
+ continue
325
+ except Exception:
326
+ pass
637
327
 
638
328
  return False
639
329
 
640
- def get_output(self, session_id: str, turn: int | None = None) -> str:
641
- """Get raw JSONL output for a session."""
642
- session = self.get_session(session_id)
643
- if not session:
644
- return ""
645
-
646
- if turn is None:
647
- turn = session.turn
648
-
649
- output_path = self._output_path(session.id, turn)
650
- if not output_path.exists():
651
- return ""
652
-
653
- return output_path.read_text()
654
-
655
- def get_messages(self, session_id: str) -> list[SessionMessage]:
656
- """Get parsed messages for a session across all turns."""
657
- session = self.get_session(session_id)
658
- if not session:
659
- return []
660
-
661
- all_messages = []
662
-
663
- # Get messages from each turn
664
- for turn in range(1, session.turn + 1):
665
- output_path = self._output_path(session.id, turn)
666
- if output_path.exists():
667
- messages, _, _ = self._parse_output(output_path)
668
- all_messages.extend(messages)
669
-
670
- return all_messages
671
-
672
330
  def _parse_output(
673
331
  self, output_path: Path
674
332
  ) -> tuple[list[SessionMessage], dict[str, int], str | None]:
@@ -879,34 +537,3 @@ Continue from where you left off, addressing the user's new message."""
879
537
  )
880
538
 
881
539
  return trajectory
882
-
883
- def cleanup_completed(self, keep_days: int = 7) -> int:
884
- """
885
- Remove old completed/failed/killed sessions.
886
-
887
- Args:
888
- keep_days: Keep sessions newer than this many days
889
-
890
- Returns:
891
- Number of sessions cleaned up
892
- """
893
- import shutil
894
- from datetime import timedelta
895
-
896
- cutoff = datetime.now() - timedelta(days=keep_days)
897
- cleaned = 0
898
-
899
- for session in self.list_sessions():
900
- if session.status in (
901
- SessionStatus.COMPLETED,
902
- SessionStatus.FAILED,
903
- SessionStatus.KILLED,
904
- ):
905
- created = datetime.fromisoformat(session.created_at)
906
- if created < cutoff:
907
- session_dir = self._session_dir(session.id)
908
- if session_dir.exists():
909
- shutil.rmtree(session_dir)
910
- cleaned += 1
911
-
912
- return cleaned