zwarm 1.3.10__py3-none-any.whl → 2.0.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/tools/delegation.py CHANGED
@@ -1,20 +1,23 @@
1
1
  """
2
2
  Delegation tools for the orchestrator.
3
3
 
4
- These are the core tools that orchestrators use to delegate work to executors:
5
- - delegate: Start a new session with an executor
6
- - converse: Continue a sync conversation
7
- - check_session: Check status of an async session
8
- - end_session: End a session
9
-
10
- Sessions are also registered with CodexSessionManager for unified visibility
11
- in `zwarm interactive`.
4
+ These are the core tools that orchestrators use to delegate work to executors.
5
+ They use the SAME CodexSessionManager that `zwarm interactive` uses - no special
6
+ MCP integration, no separate code path.
7
+
8
+ The orchestrator LLM has access to the exact same tools a human would use.
9
+
10
+ Tools:
11
+ - delegate: Start a new codex session
12
+ - converse: Continue a conversation (inject follow-up message)
13
+ - check_session: Check status of a session
14
+ - end_session: End/kill a session
15
+ - list_sessions: List all sessions
12
16
  """
13
17
 
14
18
  from __future__ import annotations
15
19
 
16
- import asyncio
17
- from datetime import datetime
20
+ import time
18
21
  from pathlib import Path
19
22
  from typing import TYPE_CHECKING, Any, Literal
20
23
 
@@ -25,103 +28,44 @@ if TYPE_CHECKING:
25
28
 
26
29
 
27
30
  def _get_session_manager(orchestrator: "Orchestrator"):
28
- """Get or create the CodexSessionManager for unified session tracking."""
31
+ """
32
+ Get the CodexSessionManager - the SINGLE source of truth for sessions.
33
+
34
+ Both `zwarm interactive` and `zwarm orchestrate` use the same session manager.
35
+ The orchestrator is just another user that happens to be an LLM.
36
+ """
29
37
  if not hasattr(orchestrator, "_session_manager"):
30
38
  from zwarm.sessions import CodexSessionManager
31
39
  orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
32
40
  return orchestrator._session_manager
33
41
 
34
42
 
35
- def _register_session_for_visibility(
36
- orchestrator: "Orchestrator",
37
- session_id: str,
38
- task: str,
39
- adapter: str,
40
- model: str,
41
- working_dir: Path,
42
- status: str = "running",
43
- pid: int | None = None,
44
- ):
45
- """
46
- Register an orchestrator session with CodexSessionManager for visibility.
47
-
48
- This allows `zwarm interactive` to show orchestrator-delegated sessions
49
- in its unified dashboard.
43
+ def _wait_for_completion(manager, session_id: str, timeout: float = 300.0, poll_interval: float = 1.0) -> bool:
50
44
  """
51
- from zwarm.sessions import CodexSession, SessionStatus, SessionMessage
52
-
53
- manager = _get_session_manager(orchestrator)
54
- now = datetime.now().isoformat()
55
-
56
- # Map status
57
- status_map = {
58
- "running": SessionStatus.RUNNING,
59
- "active": SessionStatus.RUNNING,
60
- "completed": SessionStatus.COMPLETED,
61
- "failed": SessionStatus.FAILED,
62
- }
63
-
64
- session = CodexSession(
65
- id=session_id,
66
- task=task,
67
- status=status_map.get(status, SessionStatus.RUNNING),
68
- working_dir=working_dir,
69
- created_at=now,
70
- updated_at=now,
71
- model=model or "unknown",
72
- pid=pid,
73
- source=f"orchestrator:{orchestrator.instance_id or 'unknown'}",
74
- adapter=adapter,
75
- messages=[SessionMessage(role="user", content=task, timestamp=now)],
76
- )
77
-
78
- # Save to disk
79
- manager._save_session(session)
80
- return session
81
-
45
+ Wait for a session to complete.
82
46
 
83
- def _update_session_visibility(
84
- orchestrator: "Orchestrator",
85
- session_id: str,
86
- status: str | None = None,
87
- messages: list | None = None,
88
- token_usage: dict | None = None,
89
- error: str | None = None,
90
- ):
91
- """Update a session's visibility record."""
92
- manager = _get_session_manager(orchestrator)
93
- session = manager._load_session(session_id)
94
-
95
- if not session:
96
- return
97
-
98
- from zwarm.sessions import SessionStatus, SessionMessage
99
-
100
- if status:
101
- status_map = {
102
- "running": SessionStatus.RUNNING,
103
- "active": SessionStatus.RUNNING,
104
- "completed": SessionStatus.COMPLETED,
105
- "failed": SessionStatus.FAILED,
106
- }
107
- session.status = status_map.get(status, session.status)
47
+ Args:
48
+ manager: CodexSessionManager
49
+ session_id: Session to wait for
50
+ timeout: Max seconds to wait
51
+ poll_interval: Seconds between polls
108
52
 
109
- if messages:
110
- for msg in messages:
111
- if hasattr(msg, "role") and hasattr(msg, "content"):
112
- session.messages.append(SessionMessage(
113
- role=msg.role,
114
- content=msg.content,
115
- timestamp=datetime.now().isoformat(),
116
- ))
53
+ Returns:
54
+ True if completed, False if timed out
55
+ """
56
+ start = time.time()
57
+ while time.time() - start < timeout:
58
+ session = manager.get_session(session_id)
59
+ if not session:
60
+ return False
117
61
 
118
- if token_usage:
119
- session.token_usage = token_usage
62
+ # Check if process is still running
63
+ if not session.is_running:
64
+ return True
120
65
 
121
- if error:
122
- session.error = error
66
+ time.sleep(poll_interval)
123
67
 
124
- manager._save_session(session)
68
+ return False
125
69
 
126
70
 
127
71
  def _truncate(text: str, max_len: int = 200) -> str:
@@ -131,9 +75,9 @@ def _truncate(text: str, max_len: int = 200) -> str:
131
75
  return text[:max_len - 3] + "..."
132
76
 
133
77
 
134
- def _format_session_header(session_id: str, adapter: str, mode: str) -> str:
78
+ def _format_session_header(session) -> str:
135
79
  """Format a nice session header."""
136
- return f"[{session_id[:8]}] {adapter} ({mode})"
80
+ return f"[{session.short_id}] codex ({session.status.value})"
137
81
 
138
82
 
139
83
  def _validate_working_dir(
@@ -200,30 +144,27 @@ def delegate(
200
144
  self: "Orchestrator",
201
145
  task: str,
202
146
  mode: Literal["sync", "async"] = "sync",
203
- adapter: str | None = None,
204
147
  model: str | None = None,
205
148
  working_dir: str | None = None,
206
149
  ) -> dict[str, Any]:
207
150
  """
208
- Delegate work to an executor agent.
151
+ Delegate work to a Codex agent.
209
152
 
210
- Use this to assign coding tasks to an executor. Two modes available:
153
+ This spawns a codex session - the exact same way `zwarm interactive` does.
154
+ Two modes available:
211
155
 
212
- **sync** (default): Start a conversation with the executor.
213
- You can iteratively refine requirements using converse().
214
- Best for: ambiguous tasks, complex requirements, tasks needing guidance.
156
+ **sync** (default): Wait for codex to complete, then return the response.
157
+ Best for: most tasks - you get the full response immediately.
215
158
 
216
159
  **async**: Fire-and-forget execution.
217
160
  Check progress later with check_session().
218
- Best for: clear self-contained tasks, parallel work.
161
+ Best for: long-running tasks, parallel work.
219
162
 
220
163
  Args:
221
164
  task: Clear description of what to do. Be specific about requirements.
222
- mode: "sync" for conversational, "async" for fire-and-forget.
223
- adapter: Which executor adapter to use (default: config setting).
224
- model: Model override for the executor.
225
- working_dir: Directory for the executor to work in (default: orchestrator's dir).
226
- NOTE: May be restricted by orchestrator.allowed_dirs config.
165
+ mode: "sync" to wait for completion, "async" for fire-and-forget.
166
+ model: Model override (default: gpt-5.1-codex-mini).
167
+ working_dir: Directory for codex to work in (default: orchestrator's dir).
227
168
 
228
169
  Returns:
229
170
  {session_id, status, response (if sync)}
@@ -232,7 +173,7 @@ def delegate(
232
173
  delegate(task="Add a logout button to the navbar", mode="sync")
233
174
  # Then use converse() to refine: "Also add a confirmation dialog"
234
175
  """
235
- # Validate working directory against allowed_dirs config
176
+ # Validate working directory
236
177
  effective_dir, dir_error = _validate_working_dir(
237
178
  working_dir,
238
179
  self.working_dir,
@@ -246,92 +187,69 @@ def delegate(
246
187
  "hint": "Use the default working directory or ask user to update allowed_dirs config",
247
188
  }
248
189
 
249
- # Get adapter (use default from config if not specified)
250
- adapter_name = adapter or self.config.executor.adapter
251
- executor = self._get_adapter(adapter_name)
252
-
253
- # Run async start_session
254
- session = asyncio.run(
255
- executor.start_session(
256
- task=task,
257
- working_dir=effective_dir,
258
- mode=mode,
259
- model=model or self.config.executor.model,
260
- sandbox=self.config.executor.sandbox,
261
- )
262
- )
190
+ # Get the session manager (same one zwarm interactive uses)
191
+ manager = _get_session_manager(self)
263
192
 
264
- # Track session
265
- self._sessions[session.id] = session
266
- self.state.add_session(session)
193
+ # Determine model
194
+ effective_model = model or self.config.executor.model or "gpt-5.1-codex-mini"
267
195
 
268
- # Register for unified visibility in zwarm interactive
269
- _register_session_for_visibility(
270
- orchestrator=self,
271
- session_id=session.id,
196
+ # Determine sandbox mode
197
+ sandbox = self.config.executor.sandbox or "workspace-write"
198
+
199
+ # Start the session using CodexSessionManager
200
+ # This is the SAME method that `zwarm interactive` uses
201
+ session = manager.start_session(
272
202
  task=task,
273
- adapter=adapter_name,
274
- model=model or self.config.executor.model,
275
203
  working_dir=effective_dir,
276
- status="running" if mode == "async" else "active",
277
- pid=getattr(session, "process", None) and session.process.pid,
204
+ model=effective_model,
205
+ sandbox=sandbox,
206
+ source=f"orchestrator:{self.instance_id or 'default'}",
207
+ adapter="codex",
278
208
  )
279
209
 
280
- # Log events
281
- from zwarm.core.models import event_session_started, event_message_sent, Message
282
- self.state.log_event(event_session_started(session))
283
- self.state.log_event(event_message_sent(session, Message(role="user", content=task)))
284
-
285
- # Get response for sync mode
286
- response_text = ""
287
- if mode == "sync" and session.messages:
288
- response_text = session.messages[-1].content
289
- # Log the assistant response too
290
- self.state.log_event(event_message_sent(
291
- session,
292
- Message(role="assistant", content=response_text)
293
- ))
294
-
295
- # Log delegation result for debugging
296
- from zwarm.core.models import Event
297
- self.state.log_event(Event(
298
- kind="delegation_result",
299
- payload={
300
- "session_id": session.id,
301
- "mode": mode,
302
- "adapter": adapter_name,
303
- "response_length": len(response_text),
304
- "response_preview": response_text[:500] if response_text else "(empty)",
305
- "message_count": len(session.messages),
306
- },
307
- ))
210
+ # For sync mode, wait for completion
211
+ if mode == "sync":
212
+ completed = _wait_for_completion(
213
+ manager,
214
+ session.id,
215
+ timeout=self.config.executor.timeout or 300.0,
216
+ )
308
217
 
309
- # Build nice result
310
- header = _format_session_header(session.id, adapter_name, mode)
218
+ # Refresh session to get updated status and messages
219
+ session = manager.get_session(session.id)
220
+
221
+ if not completed:
222
+ return {
223
+ "success": False,
224
+ "session_id": session.id,
225
+ "status": "timeout",
226
+ "error": "Session timed out waiting for codex to complete",
227
+ "hint": "Use check_session() to monitor progress, or use async mode for long tasks",
228
+ }
229
+
230
+ # Get the response from messages
231
+ response_text = ""
232
+ messages = manager.get_messages(session.id)
233
+ for msg in messages:
234
+ if msg.role == "assistant":
235
+ response_text = msg.content
236
+ break # Take first assistant message
311
237
 
312
- if mode == "sync":
313
- result = {
238
+ return {
314
239
  "success": True,
315
- "session": header,
240
+ "session": _format_session_header(session),
316
241
  "session_id": session.id,
317
- "status": "active",
242
+ "status": session.status.value,
318
243
  "task": _truncate(task, 100),
319
- "response": response_text,
244
+ "response": response_text or "(no response captured)",
320
245
  "tokens": session.token_usage.get("total_tokens", 0),
321
- "hint": "Use converse(session_id, message) to continue this conversation",
246
+ "hint": "Use converse(session_id, message) to send follow-up messages",
322
247
  }
323
- # Warn if no conversation ID - converse() won't work
324
- if not session.conversation_id:
325
- result["warning"] = "no_conversation_id"
326
- result["hint"] = (
327
- "WARNING: MCP didn't return a conversation ID. "
328
- "You cannot use converse() - send all instructions upfront or use async mode."
329
- )
330
- return result
331
248
  else:
249
+ # Async mode - return immediately
332
250
  return {
333
251
  "success": True,
334
- "session": header,
252
+ "session": _format_session_header(session),
335
253
  "session_id": session.id,
336
254
  "status": "running",
337
255
  "task": _truncate(task, 100),
@@ -344,28 +262,41 @@ def converse(
344
262
  self: "Orchestrator",
345
263
  session_id: str,
346
264
  message: str,
265
+ wait: bool = True,
347
266
  ) -> dict[str, Any]:
348
267
  """
349
- Continue a sync conversation with an executor.
268
+ Continue a conversation with a codex session.
269
+
270
+ This injects a follow-up message into the session, providing the
271
+ conversation history as context. Like chatting with a developer.
350
272
 
351
- Use this to iteratively refine requirements, ask for changes,
352
- or guide the executor step-by-step. Like chatting with a developer.
273
+ Two modes:
274
+ - **wait=True** (default): Wait for codex to respond before returning.
275
+ - **wait=False**: Fire-and-forget. Message sent, codex runs in background.
276
+ Use check_session() later to see the response.
353
277
 
354
278
  Args:
355
279
  session_id: The session to continue (from delegate() result).
356
- message: Your next message to the executor.
280
+ message: Your next message to codex.
281
+ wait: If True, wait for response. If False, return immediately.
357
282
 
358
283
  Returns:
359
- {session_id, response, turn}
284
+ {session_id, response (if wait=True), turn}
360
285
 
361
- Example:
286
+ Example (sync):
362
287
  result = delegate(task="Add user authentication")
363
- # Executor responds with initial plan
364
- converse(session_id=result["session_id"], message="Use JWT, not sessions")
365
- # Executor adjusts approach
366
- converse(session_id=result["session_id"], message="Now add tests")
288
+ converse(session_id=result["session_id"], message="Use JWT")
289
+ # Returns with response
290
+
291
+ Example (async - managing multiple sessions):
292
+ converse(session_id="abc123", message="Add tests", wait=False)
293
+ converse(session_id="def456", message="Fix bug", wait=False)
294
+ # Both running in parallel, check later with check_session()
367
295
  """
368
- session = self._sessions.get(session_id)
296
+ manager = _get_session_manager(self)
297
+
298
+ # Get current session
299
+ session = manager.get_session(session_id)
369
300
  if not session:
370
301
  return {
371
302
  "success": False,
@@ -373,91 +304,84 @@ def converse(
373
304
  "hint": "Use list_sessions() to see available sessions",
374
305
  }
375
306
 
376
- if session.mode.value != "sync":
307
+ # Check if session is in a conversable state
308
+ from zwarm.sessions import SessionStatus
309
+ if session.status == SessionStatus.RUNNING:
377
310
  return {
378
311
  "success": False,
379
- "error": "Cannot converse with async session",
380
- "hint": "Use check_session() for async sessions instead",
312
+ "error": "Session is still running",
313
+ "hint": "Wait for the current task to complete, or use check_session() to monitor",
381
314
  }
382
315
 
383
- if session.status.value != "active":
316
+ if session.status == SessionStatus.KILLED:
384
317
  return {
385
318
  "success": False,
386
- "error": f"Session is {session.status.value}, not active",
319
+ "error": "Session was killed",
387
320
  "hint": "Start a new session with delegate()",
388
321
  }
389
322
 
390
- # Check for stale/missing conversation_id (common after resume)
391
- if not session.conversation_id:
323
+ # Inject the follow-up message
324
+ # This uses CodexSessionManager.inject_message() which:
325
+ # 1. Builds context from previous messages
326
+ # 2. Starts a new turn with the context + new message (background process)
327
+ updated_session = manager.inject_message(session_id, message)
328
+
329
+ if not updated_session:
392
330
  return {
393
331
  "success": False,
394
- "error": "Session has no conversation ID (likely stale after resume)",
395
- "hint": (
396
- "This session's conversation was lost (MCP server restarted). "
397
- "Use end_session() to close it, then delegate() a new task."
398
- ),
332
+ "error": "Failed to inject message",
399
333
  "session_id": session_id,
400
334
  }
401
335
 
402
- # Get adapter and send message
403
- executor = self._get_adapter(session.adapter)
404
- try:
405
- response = asyncio.run(
406
- executor.send_message(session, message)
407
- )
408
- except Exception as e:
336
+ if not wait:
337
+ # Async mode - return immediately
409
338
  return {
410
- "success": False,
411
- "error": str(e),
339
+ "success": True,
340
+ "session": _format_session_header(updated_session),
412
341
  "session_id": session_id,
342
+ "turn": updated_session.turn,
343
+ "status": "running",
344
+ "you_said": _truncate(message, 100),
345
+ "hint": "Use check_session(session_id) to see the response when ready",
413
346
  }
414
347
 
415
- # Update state
416
- self.state.update_session(session)
417
-
418
- # Update visibility record
419
- from zwarm.core.models import Message
420
- _update_session_visibility(
421
- orchestrator=self,
422
- session_id=session_id,
423
- messages=[Message(role="user", content=message), Message(role="assistant", content=response)],
424
- token_usage=session.token_usage,
348
+ # Sync mode - wait for completion
349
+ completed = _wait_for_completion(
350
+ manager,
351
+ session_id,
352
+ timeout=self.config.executor.timeout or 300.0,
425
353
  )
426
354
 
427
- # Log both messages
428
- from zwarm.core.models import event_message_sent
429
- self.state.log_event(event_message_sent(session, Message(role="user", content=message)))
430
- self.state.log_event(event_message_sent(session, Message(role="assistant", content=response)))
355
+ # Refresh session
356
+ session = manager.get_session(session_id)
431
357
 
432
- # Calculate turn number
433
- turn = len([m for m in session.messages if m.role == "user"])
434
- header = _format_session_header(session.id, session.adapter, session.mode.value)
358
+ if not completed:
359
+ return {
360
+ "success": False,
361
+ "session_id": session_id,
362
+ "status": "timeout",
363
+ "error": "Session timed out waiting for response",
364
+ "hint": "Use check_session() to monitor progress",
365
+ }
435
366
 
436
- # Check for conversation loss (indicated by error in response)
437
- conversation_lost = (
438
- "[ERROR] Conversation lost" in response
439
- or session.conversation_id is None
440
- )
367
+ # Get the response (last assistant message)
368
+ response_text = ""
369
+ messages = manager.get_messages(session_id)
370
+ for msg in reversed(messages):
371
+ if msg.role == "assistant":
372
+ response_text = msg.content
373
+ break
441
374
 
442
- result = {
375
+ return {
443
376
  "success": True,
444
- "session": header,
377
+ "session": _format_session_header(session),
445
378
  "session_id": session_id,
446
- "turn": turn,
379
+ "turn": session.turn,
447
380
  "you_said": _truncate(message, 100),
448
- "response": response,
381
+ "response": response_text or "(no response captured)",
449
382
  "tokens": session.token_usage.get("total_tokens", 0),
450
383
  }
451
384
 
452
- if conversation_lost:
453
- result["warning"] = "conversation_lost"
454
- result["hint"] = (
455
- "The MCP server lost this conversation. You should end_session() "
456
- "and delegate() a new task with the full context."
457
- )
458
-
459
- return result
460
-
461
385
 
462
386
  @weaveTool
463
387
  def check_session(
@@ -467,16 +391,20 @@ def check_session(
467
391
  """
468
392
  Check the status of a session.
469
393
 
470
- For async sessions: Check if the executor has finished.
471
- For sync sessions: Get current status and message count.
394
+ Use this to:
395
+ - Check if an async session has finished
396
+ - Get current status and message count
397
+ - View the latest response
472
398
 
473
399
  Args:
474
400
  session_id: The session to check.
475
401
 
476
402
  Returns:
477
- {session_id, status, ...}
403
+ {session_id, status, messages, response}
478
404
  """
479
- session = self._sessions.get(session_id)
405
+ manager = _get_session_manager(self)
406
+
407
+ session = manager.get_session(session_id)
480
408
  if not session:
481
409
  return {
482
410
  "success": False,
@@ -484,33 +412,65 @@ def check_session(
484
412
  "hint": "Use list_sessions() to see available sessions",
485
413
  }
486
414
 
487
- executor = self._get_adapter(session.adapter)
488
- status = asyncio.run(
489
- executor.check_status(session)
490
- )
415
+ # Get latest response
416
+ response_text = ""
417
+ messages = manager.get_messages(session_id)
418
+ for msg in reversed(messages):
419
+ if msg.role == "assistant":
420
+ response_text = msg.content
421
+ break
422
+
423
+ return {
424
+ "success": True,
425
+ "session": _format_session_header(session),
426
+ "session_id": session_id,
427
+ "status": session.status.value,
428
+ "is_running": session.is_running,
429
+ "turn": session.turn,
430
+ "message_count": len(messages),
431
+ "task": _truncate(session.task, 80),
432
+ "response": _truncate(response_text, 500) if response_text else "(no response yet)",
433
+ "tokens": session.token_usage.get("total_tokens", 0),
434
+ "runtime": session.runtime,
435
+ }
491
436
 
492
- # Update state if status changed
493
- self.state.update_session(session)
494
437
 
495
- # Sync visibility record
496
- _update_session_visibility(
497
- orchestrator=self,
498
- session_id=session_id,
499
- status=session.status.value,
500
- token_usage=session.token_usage,
501
- )
438
+ @weaveTool
439
+ def peek_session(
440
+ self: "Orchestrator",
441
+ session_id: str,
442
+ ) -> dict[str, Any]:
443
+ """
444
+ Quick peek at a session - minimal info for fast polling.
445
+
446
+ Returns just status and latest message. Use check_session() for full details.
447
+
448
+ Args:
449
+ session_id: The session to peek at.
450
+
451
+ Returns:
452
+ {session_id, status, latest_message}
453
+ """
454
+ manager = _get_session_manager(self)
455
+
456
+ session = manager.get_session(session_id)
457
+ if not session:
458
+ return {"success": False, "error": f"Unknown session: {session_id}"}
502
459
 
503
- header = _format_session_header(session.id, session.adapter, session.mode.value)
460
+ # Get latest assistant message only
461
+ latest = ""
462
+ messages = manager.get_messages(session_id)
463
+ for msg in reversed(messages):
464
+ if msg.role == "assistant":
465
+ latest = msg.content.replace("\n", " ")
466
+ break
504
467
 
505
468
  return {
506
469
  "success": True,
507
- "session": header,
508
- "session_id": session_id,
509
- "mode": session.mode.value,
470
+ "session_id": session.short_id,
510
471
  "status": session.status.value,
511
- "messages": len(session.messages),
512
- "task": _truncate(session.task_description, 80),
513
- **status,
472
+ "is_running": session.status.value == "running",
473
+ "latest_message": _truncate(latest, 150) if latest else None,
514
474
  }
515
475
 
516
476
 
@@ -518,72 +478,65 @@ def check_session(
518
478
  def end_session(
519
479
  self: "Orchestrator",
520
480
  session_id: str,
521
- verdict: Literal["completed", "failed", "cancelled"] = "completed",
522
- summary: str | None = None,
481
+ reason: str | None = None,
482
+ delete: bool = False,
523
483
  ) -> dict[str, Any]:
524
484
  """
525
- End a session with a verdict.
485
+ End/kill a session.
526
486
 
527
487
  Call this when:
528
- - Task is done (verdict="completed")
529
- - Task failed and you're giving up (verdict="failed")
530
- - You want to stop early (verdict="cancelled")
488
+ - You want to stop a running session
489
+ - Clean up a completed session
490
+ - Cancel a task
531
491
 
532
492
  Args:
533
493
  session_id: The session to end.
534
- verdict: How the session ended.
535
- summary: Optional summary of what was accomplished.
494
+ reason: Optional reason for ending.
495
+ delete: If True, delete session entirely (removes from list_sessions).
536
496
 
537
497
  Returns:
538
- {session_id, status, summary}
498
+ {session_id, status}
539
499
  """
540
- session = self._sessions.get(session_id)
500
+ manager = _get_session_manager(self)
501
+
502
+ session = manager.get_session(session_id)
541
503
  if not session:
542
504
  return {
543
505
  "success": False,
544
506
  "error": f"Unknown session: {session_id}",
545
507
  }
546
508
 
547
- # Stop the session if still running
548
- if session.status.value == "active":
549
- executor = self._get_adapter(session.adapter)
550
- if verdict == "completed":
551
- session.complete(summary)
552
- else:
553
- asyncio.run(executor.stop(session))
554
- if verdict == "failed":
555
- session.fail(summary)
556
- else:
557
- session.fail(f"Cancelled: {summary}" if summary else "Cancelled")
558
-
559
- # Update state
560
- self.state.update_session(session)
561
-
562
- # Update visibility record
563
- _update_session_visibility(
564
- orchestrator=self,
565
- session_id=session_id,
566
- status=verdict,
567
- token_usage=session.token_usage,
568
- error=summary if verdict == "failed" else None,
569
- )
509
+ # If delete requested, remove entirely
510
+ if delete:
511
+ deleted = manager.delete_session(session_id)
512
+ return {
513
+ "success": deleted,
514
+ "session_id": session_id,
515
+ "status": "deleted",
516
+ "reason": reason or "deleted by orchestrator",
517
+ }
570
518
 
571
- # Log event
572
- from zwarm.core.models import event_session_completed
573
- self.state.log_event(event_session_completed(session))
519
+ # Kill if still running
520
+ if session.is_running:
521
+ killed = manager.kill_session(session_id)
522
+ if not killed:
523
+ return {
524
+ "success": False,
525
+ "error": "Failed to kill session",
526
+ "session_id": session_id,
527
+ }
574
528
 
575
- header = _format_session_header(session.id, session.adapter, session.mode.value)
576
- verdict_icon = {"completed": "✓", "failed": "✗", "cancelled": "○"}.get(verdict, "?")
529
+ # Refresh
530
+ session = manager.get_session(session_id)
577
531
 
578
532
  return {
579
533
  "success": True,
580
- "session": header,
534
+ "session": _format_session_header(session),
581
535
  "session_id": session_id,
582
- "verdict": f"{verdict_icon} {verdict}",
583
- "summary": session.exit_message or "(no summary)",
584
- "total_turns": len([m for m in session.messages if m.role == "user"]),
585
- "total_tokens": session.token_usage.get("total_tokens", 0),
586
- "token_usage": session.token_usage,
536
+ "status": session.status.value,
537
+ "reason": reason or "ended by orchestrator",
538
+ "turn": session.turn,
539
+ "tokens": session.token_usage.get("total_tokens", 0),
587
540
  }
588
541
 
589
542
 
@@ -595,36 +548,115 @@ def list_sessions(
595
548
  """
596
549
  List all sessions, optionally filtered by status.
597
550
 
551
+ Returns rich information about each session including:
552
+ - Status (running/completed/failed)
553
+ - Last update time (for detecting changes)
554
+ - Last message preview (quick peek at response)
555
+ - Whether it's recently updated (needs_attention flag)
556
+
557
+ Use this to monitor multiple parallel sessions and see which
558
+ ones have new responses.
559
+
598
560
  Args:
599
- status: Filter by status ("active", "completed", "failed").
561
+ status: Filter by status ("running", "completed", "failed", "killed").
600
562
 
601
563
  Returns:
602
- {sessions: [...], count}
564
+ {sessions: [...], count, running, completed, needs_attention}
603
565
  """
604
- sessions = self.state.list_sessions(status=status)
566
+ from datetime import datetime
567
+
568
+ manager = _get_session_manager(self)
569
+
570
+ # Map string status to enum
571
+ from zwarm.sessions import SessionStatus
572
+ status_filter = None
573
+ if status:
574
+ status_map = {
575
+ "running": SessionStatus.RUNNING,
576
+ "completed": SessionStatus.COMPLETED,
577
+ "failed": SessionStatus.FAILED,
578
+ "killed": SessionStatus.KILLED,
579
+ "pending": SessionStatus.PENDING,
580
+ }
581
+ status_filter = status_map.get(status.lower())
582
+
583
+ sessions = manager.list_sessions(status=status_filter)
584
+
585
+ def time_ago(iso_str: str) -> tuple[str, float]:
586
+ """Convert ISO timestamp to ('Xm ago', seconds)."""
587
+ try:
588
+ dt = datetime.fromisoformat(iso_str)
589
+ delta = datetime.now() - dt
590
+ secs = delta.total_seconds()
591
+ if secs < 60:
592
+ return f"{int(secs)}s ago", secs
593
+ elif secs < 3600:
594
+ return f"{int(secs/60)}m ago", secs
595
+ elif secs < 86400:
596
+ return f"{secs/3600:.1f}h ago", secs
597
+ else:
598
+ return f"{secs/86400:.1f}d ago", secs
599
+ except:
600
+ return "?", 999999
605
601
 
606
602
  session_list = []
603
+ needs_attention_count = 0
604
+
607
605
  for s in sessions:
608
606
  status_icon = {
609
- "active": "●",
607
+ "running": "●",
610
608
  "completed": "✓",
611
609
  "failed": "✗",
610
+ "killed": "○",
611
+ "pending": "◌",
612
612
  }.get(s.status.value, "?")
613
613
 
614
+ updated_str, updated_secs = time_ago(s.updated_at)
615
+
616
+ # Get last assistant message
617
+ messages = manager.get_messages(s.id)
618
+ last_message = ""
619
+ for msg in reversed(messages):
620
+ if msg.role == "assistant":
621
+ last_message = msg.content.replace("\n", " ")
622
+ break
623
+
624
+ # Flag sessions that need attention:
625
+ # - Recently completed (< 60s)
626
+ # - Failed
627
+ is_recent = updated_secs < 60
628
+ needs_attention = (
629
+ (s.status == SessionStatus.COMPLETED and is_recent) or
630
+ s.status == SessionStatus.FAILED
631
+ )
632
+ if needs_attention:
633
+ needs_attention_count += 1
634
+
614
635
  session_list.append({
615
- "id": s.id[:8] + "...",
636
+ "id": s.short_id,
616
637
  "full_id": s.id,
617
638
  "status": f"{status_icon} {s.status.value}",
618
- "adapter": s.adapter,
619
- "mode": s.mode.value,
620
- "task": _truncate(s.task_description, 60),
621
- "turns": len([m for m in s.messages if m.role == "user"]),
639
+ "is_running": s.status == SessionStatus.RUNNING,
640
+ "task": _truncate(s.task, 50),
641
+ "turn": s.turn,
642
+ "updated": updated_str,
643
+ "updated_secs": int(updated_secs),
644
+ "last_message": _truncate(last_message, 100) if last_message else "(no response yet)",
645
+ "needs_attention": needs_attention,
622
646
  "tokens": s.token_usage.get("total_tokens", 0),
623
647
  })
624
648
 
649
+ # Summary counts
650
+ running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
651
+ completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
652
+
625
653
  return {
626
654
  "success": True,
627
655
  "sessions": session_list,
628
656
  "count": len(sessions),
657
+ "running": running_count,
658
+ "completed": completed_count,
659
+ "needs_attention": needs_attention_count,
629
660
  "filter": status or "all",
661
+ "hint": "Sessions with needs_attention=True have new responses to review" if needs_attention_count else None,
630
662
  }