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/cli/interactive.py +420 -52
- zwarm/cli/main.py +127 -14
- zwarm/cli/pilot.py +52 -4
- zwarm/core/costs.py +55 -183
- zwarm/core/environment.py +55 -1
- zwarm/core/registry.py +329 -0
- zwarm/orchestrator.py +64 -12
- zwarm/sessions/__init__.py +48 -9
- zwarm/sessions/base.py +501 -0
- zwarm/sessions/claude.py +481 -0
- zwarm/sessions/manager.py +85 -458
- zwarm/tools/delegation.py +126 -61
- {zwarm-3.4.0.dist-info → zwarm-3.7.0.dist-info}/METADATA +70 -21
- {zwarm-3.4.0.dist-info → zwarm-3.7.0.dist-info}/RECORD +16 -13
- {zwarm-3.4.0.dist-info → zwarm-3.7.0.dist-info}/WHEEL +0 -0
- {zwarm-3.4.0.dist-info → zwarm-3.7.0.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
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=
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
293
|
+
def _is_output_complete(self, session_id: str, turn: int) -> bool:
|
|
609
294
|
"""
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
Kills the process first if still running.
|
|
295
|
+
Check if output file indicates the task completed.
|
|
613
296
|
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|