zwarm 1.2.1__py3-none-any.whl → 1.3.2__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/adapters/codex_mcp.py +42 -2
- zwarm/cli/main.py +97 -2
- zwarm/core/state.py +143 -12
- zwarm/orchestrator.py +44 -7
- zwarm/tools/delegation.py +37 -2
- {zwarm-1.2.1.dist-info → zwarm-1.3.2.dist-info}/METADATA +1 -1
- {zwarm-1.2.1.dist-info → zwarm-1.3.2.dist-info}/RECORD +9 -9
- {zwarm-1.2.1.dist-info → zwarm-1.3.2.dist-info}/WHEEL +0 -0
- {zwarm-1.2.1.dist-info → zwarm-1.3.2.dist-info}/entry_points.txt +0 -0
zwarm/adapters/codex_mcp.py
CHANGED
|
@@ -549,20 +549,33 @@ class CodexMCPAdapter(ExecutorAdapter):
|
|
|
549
549
|
"""
|
|
550
550
|
client = self._ensure_client()
|
|
551
551
|
|
|
552
|
+
logger.debug(f"Calling codex-reply with conversation_id={conversation_id}")
|
|
553
|
+
|
|
552
554
|
result = client.call_tool("codex-reply", {
|
|
553
555
|
"conversationId": conversation_id,
|
|
554
556
|
"prompt": message,
|
|
555
557
|
})
|
|
556
558
|
|
|
559
|
+
# Check for conversation loss - MCP returns empty result when session not found
|
|
560
|
+
if not result.get("messages") and not result.get("output"):
|
|
561
|
+
logger.error(
|
|
562
|
+
f"codex-reply returned empty result for conversation_id={conversation_id}. "
|
|
563
|
+
f"The MCP server may have lost the conversation state. Result: {result}"
|
|
564
|
+
)
|
|
565
|
+
|
|
557
566
|
# Track usage
|
|
558
567
|
usage = result.get("usage", {})
|
|
559
568
|
self._accumulate_usage(usage)
|
|
560
569
|
|
|
570
|
+
response = self._extract_response(result)
|
|
571
|
+
logger.debug(f"codex-reply response length: {len(response)} chars")
|
|
572
|
+
|
|
561
573
|
return {
|
|
562
|
-
"response":
|
|
574
|
+
"response": response,
|
|
563
575
|
"raw_messages": result.get("messages", []),
|
|
564
576
|
"usage": usage,
|
|
565
577
|
"total_usage": self.total_usage,
|
|
578
|
+
"conversation_lost": not result.get("messages") and not result.get("output"),
|
|
566
579
|
}
|
|
567
580
|
|
|
568
581
|
@weave.op()
|
|
@@ -598,6 +611,13 @@ class CodexMCPAdapter(ExecutorAdapter):
|
|
|
598
611
|
session.conversation_id = result["conversation_id"]
|
|
599
612
|
if session.conversation_id:
|
|
600
613
|
self._sessions[session.id] = session.conversation_id
|
|
614
|
+
logger.debug(f"Session {session.id[:8]} mapped to conversation {session.conversation_id}")
|
|
615
|
+
else:
|
|
616
|
+
# This is bad - we won't be able to continue this conversation
|
|
617
|
+
logger.warning(
|
|
618
|
+
f"Session {session.id[:8]} started but MCP didn't return a conversation ID. "
|
|
619
|
+
"Further converse() calls will fail."
|
|
620
|
+
)
|
|
601
621
|
|
|
602
622
|
session.add_message("user", task)
|
|
603
623
|
session.add_message("assistant", result["response"])
|
|
@@ -652,6 +672,16 @@ class CodexMCPAdapter(ExecutorAdapter):
|
|
|
652
672
|
)
|
|
653
673
|
|
|
654
674
|
response_text = result["response"]
|
|
675
|
+
|
|
676
|
+
# Check if conversation was lost
|
|
677
|
+
if result.get("conversation_lost"):
|
|
678
|
+
logger.warning(
|
|
679
|
+
f"Conversation {session.conversation_id} was lost. "
|
|
680
|
+
f"Session {session.id} will be marked as needing re-delegation."
|
|
681
|
+
)
|
|
682
|
+
# Mark the session as having a lost conversation so orchestrator can handle it
|
|
683
|
+
session.conversation_id = None # Clear the stale ID
|
|
684
|
+
|
|
655
685
|
session.add_message("user", message)
|
|
656
686
|
session.add_message("assistant", response_text)
|
|
657
687
|
|
|
@@ -797,6 +827,15 @@ class CodexMCPAdapter(ExecutorAdapter):
|
|
|
797
827
|
|
|
798
828
|
def _extract_response(self, result: dict) -> str:
|
|
799
829
|
"""Extract response text from MCP result."""
|
|
830
|
+
# Check for error indicators - empty result suggests lost conversation
|
|
831
|
+
if (
|
|
832
|
+
result.get("conversationId") is None
|
|
833
|
+
and not result.get("messages")
|
|
834
|
+
and not result.get("output")
|
|
835
|
+
):
|
|
836
|
+
logger.warning(f"MCP returned empty result - conversation may be lost: {result}")
|
|
837
|
+
return "[ERROR] Conversation lost - the MCP server no longer has this session. Please re-delegate the task."
|
|
838
|
+
|
|
800
839
|
# First check for our collected output
|
|
801
840
|
if result.get("output"):
|
|
802
841
|
return result["output"]
|
|
@@ -823,5 +862,6 @@ class CodexMCPAdapter(ExecutorAdapter):
|
|
|
823
862
|
if "text" in result:
|
|
824
863
|
return result["text"]
|
|
825
864
|
|
|
826
|
-
# Fallback: stringify the result
|
|
865
|
+
# Fallback: stringify the result (but log it as unexpected)
|
|
866
|
+
logger.warning(f"Unexpected MCP result format, returning raw: {list(result.keys())}")
|
|
827
867
|
return json.dumps(result, indent=2)
|
zwarm/cli/main.py
CHANGED
|
@@ -141,6 +141,8 @@ def orchestrate(
|
|
|
141
141
|
resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
|
|
142
142
|
max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
|
|
143
143
|
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
|
|
144
|
+
instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
|
|
145
|
+
instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
|
|
144
146
|
):
|
|
145
147
|
"""
|
|
146
148
|
Start an orchestrator session.
|
|
@@ -149,6 +151,9 @@ def orchestrate(
|
|
|
149
151
|
(Codex, Claude Code). It can have sync conversations or fire-and-forget
|
|
150
152
|
async delegations.
|
|
151
153
|
|
|
154
|
+
Each run creates an isolated instance to prevent conflicts when running
|
|
155
|
+
multiple orchestrators in the same directory.
|
|
156
|
+
|
|
152
157
|
[bold]Examples:[/]
|
|
153
158
|
[dim]# Simple task[/]
|
|
154
159
|
$ zwarm orchestrate --task "Add a logout button to the navbar"
|
|
@@ -166,8 +171,14 @@ def orchestrate(
|
|
|
166
171
|
[dim]# Override settings[/]
|
|
167
172
|
$ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
|
|
168
173
|
|
|
169
|
-
[dim]#
|
|
170
|
-
$ zwarm orchestrate --task "
|
|
174
|
+
[dim]# Named instance (easier to track)[/]
|
|
175
|
+
$ zwarm orchestrate --task "Add tests" --name test-work
|
|
176
|
+
|
|
177
|
+
[dim]# Resume a specific instance[/]
|
|
178
|
+
$ zwarm orchestrate --resume --instance abc123
|
|
179
|
+
|
|
180
|
+
[dim]# List all instances[/]
|
|
181
|
+
$ zwarm instances
|
|
171
182
|
"""
|
|
172
183
|
from zwarm.orchestrator import build_orchestrator
|
|
173
184
|
|
|
@@ -187,6 +198,8 @@ def orchestrate(
|
|
|
187
198
|
console.print(f"[bold]Starting orchestrator...[/]")
|
|
188
199
|
console.print(f" Task: {task}")
|
|
189
200
|
console.print(f" Working dir: {working_dir.absolute()}")
|
|
201
|
+
if instance:
|
|
202
|
+
console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
|
|
190
203
|
console.print()
|
|
191
204
|
|
|
192
205
|
# Output handler to show orchestrator messages
|
|
@@ -203,11 +216,17 @@ def orchestrate(
|
|
|
203
216
|
overrides=override_list,
|
|
204
217
|
resume=resume,
|
|
205
218
|
output_handler=output_handler,
|
|
219
|
+
instance_id=instance,
|
|
220
|
+
instance_name=instance_name,
|
|
206
221
|
)
|
|
207
222
|
|
|
208
223
|
if resume:
|
|
209
224
|
console.print(" [dim]Resuming from previous state...[/]")
|
|
210
225
|
|
|
226
|
+
# Show instance ID if auto-generated
|
|
227
|
+
if orchestrator.instance_id and not instance:
|
|
228
|
+
console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
|
|
229
|
+
|
|
211
230
|
# Run the orchestrator loop
|
|
212
231
|
console.print("[bold]--- Orchestrator running ---[/]\n")
|
|
213
232
|
result = orchestrator.run(task=task)
|
|
@@ -223,16 +242,35 @@ def orchestrate(
|
|
|
223
242
|
# Save state for potential resume
|
|
224
243
|
orchestrator.save_state()
|
|
225
244
|
|
|
245
|
+
# Update instance status
|
|
246
|
+
if orchestrator.instance_id:
|
|
247
|
+
from zwarm.core.state import update_instance_status
|
|
248
|
+
update_instance_status(
|
|
249
|
+
orchestrator.instance_id,
|
|
250
|
+
"completed",
|
|
251
|
+
working_dir / ".zwarm",
|
|
252
|
+
)
|
|
253
|
+
console.print(f" [dim]Instance {orchestrator.instance_id[:8]} marked completed[/]")
|
|
254
|
+
|
|
226
255
|
except KeyboardInterrupt:
|
|
227
256
|
console.print("\n\n[yellow]Interrupted.[/]")
|
|
228
257
|
if orchestrator:
|
|
229
258
|
orchestrator.save_state()
|
|
230
259
|
console.print("[dim]State saved. Use --resume to continue.[/]")
|
|
260
|
+
# Keep instance as "active" so it can be resumed
|
|
231
261
|
sys.exit(1)
|
|
232
262
|
except Exception as e:
|
|
233
263
|
console.print(f"\n[red]Error:[/] {e}")
|
|
234
264
|
if verbose:
|
|
235
265
|
console.print_exception()
|
|
266
|
+
# Update instance status to failed
|
|
267
|
+
if orchestrator and orchestrator.instance_id:
|
|
268
|
+
from zwarm.core.state import update_instance_status
|
|
269
|
+
update_instance_status(
|
|
270
|
+
orchestrator.instance_id,
|
|
271
|
+
"failed",
|
|
272
|
+
working_dir / ".zwarm",
|
|
273
|
+
)
|
|
236
274
|
sys.exit(1)
|
|
237
275
|
|
|
238
276
|
|
|
@@ -384,6 +422,63 @@ def status(
|
|
|
384
422
|
console.print(" [dim](none)[/]")
|
|
385
423
|
|
|
386
424
|
|
|
425
|
+
@app.command()
|
|
426
|
+
def instances(
|
|
427
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
428
|
+
all_instances: Annotated[bool, typer.Option("--all", "-a", help="Show all instances (including completed)")] = False,
|
|
429
|
+
):
|
|
430
|
+
"""
|
|
431
|
+
List all orchestrator instances.
|
|
432
|
+
|
|
433
|
+
Shows instances that have been run in this directory. Use --all to include
|
|
434
|
+
completed instances.
|
|
435
|
+
|
|
436
|
+
[bold]Examples:[/]
|
|
437
|
+
[dim]# List active instances[/]
|
|
438
|
+
$ zwarm instances
|
|
439
|
+
|
|
440
|
+
[dim]# List all instances[/]
|
|
441
|
+
$ zwarm instances --all
|
|
442
|
+
"""
|
|
443
|
+
from zwarm.core.state import list_instances as get_instances
|
|
444
|
+
|
|
445
|
+
state_dir = working_dir / ".zwarm"
|
|
446
|
+
all_inst = get_instances(state_dir)
|
|
447
|
+
|
|
448
|
+
if not all_inst:
|
|
449
|
+
console.print("[dim]No instances found.[/]")
|
|
450
|
+
console.print("[dim]Run 'zwarm orchestrate' to start a new instance.[/]")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
# Filter if not showing all
|
|
454
|
+
if not all_instances:
|
|
455
|
+
all_inst = [i for i in all_inst if i.get("status") == "active"]
|
|
456
|
+
|
|
457
|
+
if not all_inst:
|
|
458
|
+
console.print("[dim]No active instances. Use --all to see completed ones.[/]")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
console.print(f"[bold]Instances[/] ({len(all_inst)} total)\n")
|
|
462
|
+
|
|
463
|
+
for inst in all_inst:
|
|
464
|
+
status = inst.get("status", "unknown")
|
|
465
|
+
status_icon = {"active": "[green]●[/]", "completed": "[dim]✓[/]", "failed": "[red]✗[/]"}.get(status, "[dim]?[/]")
|
|
466
|
+
|
|
467
|
+
inst_id = inst.get("id", "unknown")[:8]
|
|
468
|
+
name = inst.get("name", "")
|
|
469
|
+
task = (inst.get("task") or "")[:60]
|
|
470
|
+
updated = inst.get("updated_at", "")[:19] if inst.get("updated_at") else ""
|
|
471
|
+
|
|
472
|
+
console.print(f" {status_icon} [bold]{inst_id}[/]" + (f" ({name})" if name and name != inst_id else ""))
|
|
473
|
+
if task:
|
|
474
|
+
console.print(f" [dim]{task}[/]")
|
|
475
|
+
if updated:
|
|
476
|
+
console.print(f" [dim]Updated: {updated}[/]")
|
|
477
|
+
console.print()
|
|
478
|
+
|
|
479
|
+
console.print("[dim]Use --instance <id> with 'orchestrate --resume' to resume an instance.[/]")
|
|
480
|
+
|
|
481
|
+
|
|
387
482
|
@app.command()
|
|
388
483
|
def history(
|
|
389
484
|
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
zwarm/core/state.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Flat-file state management for zwarm.
|
|
3
3
|
|
|
4
|
-
State structure:
|
|
4
|
+
State structure (with instance isolation):
|
|
5
5
|
.zwarm/
|
|
6
|
-
├──
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
└──
|
|
13
|
-
|
|
6
|
+
├── instances.json # Registry of all instances
|
|
7
|
+
└── instances/
|
|
8
|
+
└── <instance-id>/
|
|
9
|
+
├── state.json # Current state (sessions, tasks)
|
|
10
|
+
├── events.jsonl # Append-only event log
|
|
11
|
+
├── sessions/
|
|
12
|
+
│ └── <session-id>/
|
|
13
|
+
│ ├── messages.json
|
|
14
|
+
│ └── output.log
|
|
15
|
+
└── orchestrator/
|
|
16
|
+
└── messages.json # Orchestrator's message history (for resume)
|
|
17
|
+
|
|
18
|
+
Legacy structure (single instance, for backwards compat):
|
|
19
|
+
.zwarm/
|
|
20
|
+
├── state.json
|
|
21
|
+
├── events.jsonl
|
|
22
|
+
└── ...
|
|
14
23
|
"""
|
|
15
24
|
|
|
16
25
|
from __future__ import annotations
|
|
@@ -19,10 +28,116 @@ import json
|
|
|
19
28
|
from datetime import datetime
|
|
20
29
|
from pathlib import Path
|
|
21
30
|
from typing import Any
|
|
31
|
+
from uuid import uuid4
|
|
22
32
|
|
|
23
33
|
from .models import ConversationSession, Event, Task
|
|
24
34
|
|
|
25
35
|
|
|
36
|
+
# --- Instance Registry ---
|
|
37
|
+
|
|
38
|
+
def get_instances_registry_path(base_dir: Path | str = ".zwarm") -> Path:
|
|
39
|
+
"""Get path to the instances registry file."""
|
|
40
|
+
return Path(base_dir) / "instances.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_instances(base_dir: Path | str = ".zwarm") -> list[dict[str, Any]]:
|
|
44
|
+
"""List all registered instances."""
|
|
45
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
46
|
+
if not registry_path.exists():
|
|
47
|
+
return []
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(registry_path.read_text()).get("instances", [])
|
|
50
|
+
except (json.JSONDecodeError, KeyError):
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def register_instance(
|
|
55
|
+
instance_id: str,
|
|
56
|
+
name: str | None = None,
|
|
57
|
+
task: str | None = None,
|
|
58
|
+
base_dir: Path | str = ".zwarm",
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Register an instance in the global registry."""
|
|
61
|
+
base = Path(base_dir)
|
|
62
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
65
|
+
|
|
66
|
+
# Load existing registry
|
|
67
|
+
if registry_path.exists():
|
|
68
|
+
try:
|
|
69
|
+
registry = json.loads(registry_path.read_text())
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
registry = {"instances": []}
|
|
72
|
+
else:
|
|
73
|
+
registry = {"instances": []}
|
|
74
|
+
|
|
75
|
+
# Check if instance already registered
|
|
76
|
+
existing_ids = {inst["id"] for inst in registry["instances"]}
|
|
77
|
+
if instance_id in existing_ids:
|
|
78
|
+
# Update existing entry
|
|
79
|
+
for inst in registry["instances"]:
|
|
80
|
+
if inst["id"] == instance_id:
|
|
81
|
+
inst["updated_at"] = datetime.now().isoformat()
|
|
82
|
+
inst["status"] = "active"
|
|
83
|
+
if name:
|
|
84
|
+
inst["name"] = name
|
|
85
|
+
if task:
|
|
86
|
+
inst["task"] = task[:100] # Truncate
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
# Add new entry
|
|
90
|
+
registry["instances"].append({
|
|
91
|
+
"id": instance_id,
|
|
92
|
+
"name": name or instance_id[:8],
|
|
93
|
+
"task": (task[:100] if task else None),
|
|
94
|
+
"created_at": datetime.now().isoformat(),
|
|
95
|
+
"updated_at": datetime.now().isoformat(),
|
|
96
|
+
"status": "active",
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
registry_path.write_text(json.dumps(registry, indent=2))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def update_instance_status(
|
|
103
|
+
instance_id: str,
|
|
104
|
+
status: str,
|
|
105
|
+
base_dir: Path | str = ".zwarm",
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Update an instance's status in the registry."""
|
|
108
|
+
registry_path = get_instances_registry_path(base_dir)
|
|
109
|
+
if not registry_path.exists():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
registry = json.loads(registry_path.read_text())
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
for inst in registry.get("instances", []):
|
|
118
|
+
if inst["id"] == instance_id:
|
|
119
|
+
inst["status"] = status
|
|
120
|
+
inst["updated_at"] = datetime.now().isoformat()
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
registry_path.write_text(json.dumps(registry, indent=2))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_instance_state_dir(
|
|
127
|
+
instance_id: str | None = None,
|
|
128
|
+
base_dir: Path | str = ".zwarm",
|
|
129
|
+
) -> Path:
|
|
130
|
+
"""
|
|
131
|
+
Get the state directory for an instance.
|
|
132
|
+
|
|
133
|
+
If instance_id is None, returns the legacy path for backwards compat.
|
|
134
|
+
"""
|
|
135
|
+
base = Path(base_dir)
|
|
136
|
+
if instance_id is None:
|
|
137
|
+
return base # Legacy: .zwarm/
|
|
138
|
+
return base / "instances" / instance_id
|
|
139
|
+
|
|
140
|
+
|
|
26
141
|
def _json_serializer(obj: Any) -> Any:
|
|
27
142
|
"""Custom JSON serializer for non-standard types."""
|
|
28
143
|
# Handle pydantic models
|
|
@@ -42,15 +157,31 @@ class StateManager:
|
|
|
42
157
|
"""
|
|
43
158
|
Manages flat-file state for zwarm.
|
|
44
159
|
|
|
45
|
-
All state is stored as JSON files in a directory
|
|
160
|
+
All state is stored as JSON files in a directory.
|
|
161
|
+
With instance isolation: .zwarm/instances/<instance-id>/
|
|
162
|
+
Legacy (no instance): .zwarm/
|
|
163
|
+
|
|
46
164
|
This enables:
|
|
47
165
|
- Git-backed history
|
|
48
166
|
- Easy debugging (just read the files)
|
|
49
167
|
- Resume from previous state
|
|
168
|
+
- Multiple concurrent orchestrators (with instance isolation)
|
|
50
169
|
"""
|
|
51
170
|
|
|
52
|
-
def __init__(
|
|
53
|
-
self
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
state_dir: Path | str = ".zwarm",
|
|
174
|
+
instance_id: str | None = None,
|
|
175
|
+
):
|
|
176
|
+
self.base_dir = Path(state_dir)
|
|
177
|
+
self.instance_id = instance_id
|
|
178
|
+
|
|
179
|
+
# Resolve actual state directory
|
|
180
|
+
if instance_id:
|
|
181
|
+
self.state_dir = get_instance_state_dir(instance_id, self.base_dir)
|
|
182
|
+
else:
|
|
183
|
+
self.state_dir = self.base_dir
|
|
184
|
+
|
|
54
185
|
self._sessions: dict[str, ConversationSession] = {}
|
|
55
186
|
self._tasks: dict[str, Task] = {}
|
|
56
187
|
self._orchestrator_messages: list[dict[str, Any]] = []
|
zwarm/orchestrator.py
CHANGED
|
@@ -52,6 +52,10 @@ class Orchestrator(YamlAgent):
|
|
|
52
52
|
config: ZwarmConfig = Field(default_factory=ZwarmConfig)
|
|
53
53
|
working_dir: Path = Field(default_factory=Path.cwd)
|
|
54
54
|
|
|
55
|
+
# Instance identification (for multi-orchestrator isolation)
|
|
56
|
+
instance_id: str | None = Field(default=None)
|
|
57
|
+
instance_name: str | None = Field(default=None)
|
|
58
|
+
|
|
55
59
|
# Load tools from modules (delegation + bash for verification)
|
|
56
60
|
agent_tool_modules: list[str] = Field(
|
|
57
61
|
default=[
|
|
@@ -77,11 +81,25 @@ class Orchestrator(YamlAgent):
|
|
|
77
81
|
"""Initialize state and adapters after model creation."""
|
|
78
82
|
super().model_post_init(__context)
|
|
79
83
|
|
|
80
|
-
# Initialize state manager
|
|
81
|
-
|
|
84
|
+
# Initialize state manager with instance isolation
|
|
85
|
+
base_state_dir = self.working_dir / self.config.state_dir
|
|
86
|
+
self._state = StateManager(
|
|
87
|
+
state_dir=base_state_dir,
|
|
88
|
+
instance_id=self.instance_id,
|
|
89
|
+
)
|
|
82
90
|
self._state.init()
|
|
83
91
|
self._state.load()
|
|
84
92
|
|
|
93
|
+
# Register instance if using instance isolation
|
|
94
|
+
if self.instance_id:
|
|
95
|
+
from zwarm.core.state import register_instance
|
|
96
|
+
register_instance(
|
|
97
|
+
instance_id=self.instance_id,
|
|
98
|
+
name=self.instance_name,
|
|
99
|
+
task=None, # Will be updated when task is set
|
|
100
|
+
base_dir=base_state_dir,
|
|
101
|
+
)
|
|
102
|
+
|
|
85
103
|
# Load existing sessions
|
|
86
104
|
for session in self._state.list_sessions():
|
|
87
105
|
self._sessions[session.id] = session
|
|
@@ -215,12 +233,18 @@ class Orchestrator(YamlAgent):
|
|
|
215
233
|
if not self._resumed:
|
|
216
234
|
return
|
|
217
235
|
|
|
218
|
-
# Build list of old sessions
|
|
236
|
+
# Build list of old sessions and INVALIDATE their conversation IDs
|
|
237
|
+
# The MCP server was restarted, so all conversation IDs are now stale
|
|
219
238
|
old_sessions = []
|
|
239
|
+
invalidated_count = 0
|
|
220
240
|
for sid, session in self._sessions.items():
|
|
221
241
|
old_sessions.append(
|
|
222
242
|
f" - {sid[:8]}... ({session.adapter}, {session.status.value})"
|
|
223
243
|
)
|
|
244
|
+
# Clear stale conversation_id to prevent converse() from trying to use it
|
|
245
|
+
if session.conversation_id:
|
|
246
|
+
session.conversation_id = None
|
|
247
|
+
invalidated_count += 1
|
|
224
248
|
|
|
225
249
|
session_info = "\n".join(old_sessions) if old_sessions else " (none)"
|
|
226
250
|
|
|
@@ -228,14 +252,14 @@ class Orchestrator(YamlAgent):
|
|
|
228
252
|
"role": "user",
|
|
229
253
|
"content": f"""[SYSTEM NOTICE] You have been resumed from a previous session.
|
|
230
254
|
|
|
231
|
-
|
|
255
|
+
CRITICAL: Your previous executor sessions are NO LONGER USABLE. The MCP server was restarted, so all conversation state was lost. {invalidated_count} conversation ID(s) have been invalidated.
|
|
232
256
|
|
|
233
|
-
Previous sessions (
|
|
257
|
+
Previous sessions (conversation IDs cleared):
|
|
234
258
|
{session_info}
|
|
235
259
|
|
|
236
|
-
You
|
|
260
|
+
You MUST start NEW sessions with delegate() to continue any work. The converse() tool will fail on these old sessions because they have no active conversation.
|
|
237
261
|
|
|
238
|
-
|
|
262
|
+
Review what was accomplished in the previous session and delegate new tasks as needed.""",
|
|
239
263
|
}
|
|
240
264
|
|
|
241
265
|
self.messages.append(resume_msg)
|
|
@@ -521,6 +545,8 @@ def build_orchestrator(
|
|
|
521
545
|
overrides: list[str] | None = None,
|
|
522
546
|
resume: bool = False,
|
|
523
547
|
output_handler: Callable[[str], None] | None = None,
|
|
548
|
+
instance_id: str | None = None,
|
|
549
|
+
instance_name: str | None = None,
|
|
524
550
|
) -> Orchestrator:
|
|
525
551
|
"""
|
|
526
552
|
Build an orchestrator from configuration.
|
|
@@ -532,10 +558,14 @@ def build_orchestrator(
|
|
|
532
558
|
overrides: CLI overrides (--set key=value)
|
|
533
559
|
resume: Whether to resume from previous state
|
|
534
560
|
output_handler: Function to handle orchestrator output
|
|
561
|
+
instance_id: Unique ID for this instance (enables multi-orchestrator isolation)
|
|
562
|
+
instance_name: Human-readable name for this instance
|
|
535
563
|
|
|
536
564
|
Returns:
|
|
537
565
|
Configured Orchestrator instance
|
|
538
566
|
"""
|
|
567
|
+
from uuid import uuid4
|
|
568
|
+
|
|
539
569
|
# Load configuration
|
|
540
570
|
config = load_config(
|
|
541
571
|
config_path=config_path,
|
|
@@ -545,6 +575,11 @@ def build_orchestrator(
|
|
|
545
575
|
# Resolve working directory
|
|
546
576
|
working_dir = working_dir or Path.cwd()
|
|
547
577
|
|
|
578
|
+
# Generate instance ID if not provided (enables isolation by default for new runs)
|
|
579
|
+
# For resume, instance_id should be provided explicitly
|
|
580
|
+
if instance_id is None and not resume:
|
|
581
|
+
instance_id = str(uuid4())
|
|
582
|
+
|
|
548
583
|
# Build system prompt
|
|
549
584
|
system_prompt = _build_system_prompt(config, working_dir)
|
|
550
585
|
|
|
@@ -565,6 +600,8 @@ def build_orchestrator(
|
|
|
565
600
|
system_prompt=system_prompt,
|
|
566
601
|
maxSteps=config.orchestrator.max_steps,
|
|
567
602
|
env=env,
|
|
603
|
+
instance_id=instance_id,
|
|
604
|
+
instance_name=instance_name,
|
|
568
605
|
)
|
|
569
606
|
|
|
570
607
|
# Resume if requested
|
zwarm/tools/delegation.py
CHANGED
|
@@ -194,7 +194,7 @@ def delegate(
|
|
|
194
194
|
header = _format_session_header(session.id, adapter_name, mode)
|
|
195
195
|
|
|
196
196
|
if mode == "sync":
|
|
197
|
-
|
|
197
|
+
result = {
|
|
198
198
|
"success": True,
|
|
199
199
|
"session": header,
|
|
200
200
|
"session_id": session.id,
|
|
@@ -204,6 +204,14 @@ def delegate(
|
|
|
204
204
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
205
205
|
"hint": "Use converse(session_id, message) to continue this conversation",
|
|
206
206
|
}
|
|
207
|
+
# Warn if no conversation ID - converse() won't work
|
|
208
|
+
if not session.conversation_id:
|
|
209
|
+
result["warning"] = "no_conversation_id"
|
|
210
|
+
result["hint"] = (
|
|
211
|
+
"WARNING: MCP didn't return a conversation ID. "
|
|
212
|
+
"You cannot use converse() - send all instructions upfront or use async mode."
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
207
215
|
else:
|
|
208
216
|
return {
|
|
209
217
|
"success": True,
|
|
@@ -263,6 +271,18 @@ def converse(
|
|
|
263
271
|
"hint": "Start a new session with delegate()",
|
|
264
272
|
}
|
|
265
273
|
|
|
274
|
+
# Check for stale/missing conversation_id (common after resume)
|
|
275
|
+
if not session.conversation_id:
|
|
276
|
+
return {
|
|
277
|
+
"success": False,
|
|
278
|
+
"error": "Session has no conversation ID (likely stale after resume)",
|
|
279
|
+
"hint": (
|
|
280
|
+
"This session's conversation was lost (MCP server restarted). "
|
|
281
|
+
"Use end_session() to close it, then delegate() a new task."
|
|
282
|
+
),
|
|
283
|
+
"session_id": session_id,
|
|
284
|
+
}
|
|
285
|
+
|
|
266
286
|
# Get adapter and send message
|
|
267
287
|
executor = self._get_adapter(session.adapter)
|
|
268
288
|
try:
|
|
@@ -288,7 +308,13 @@ def converse(
|
|
|
288
308
|
turn = len([m for m in session.messages if m.role == "user"])
|
|
289
309
|
header = _format_session_header(session.id, session.adapter, session.mode.value)
|
|
290
310
|
|
|
291
|
-
|
|
311
|
+
# Check for conversation loss (indicated by error in response)
|
|
312
|
+
conversation_lost = (
|
|
313
|
+
"[ERROR] Conversation lost" in response
|
|
314
|
+
or session.conversation_id is None
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
result = {
|
|
292
318
|
"success": True,
|
|
293
319
|
"session": header,
|
|
294
320
|
"session_id": session_id,
|
|
@@ -298,6 +324,15 @@ def converse(
|
|
|
298
324
|
"tokens": session.token_usage.get("total_tokens", 0),
|
|
299
325
|
}
|
|
300
326
|
|
|
327
|
+
if conversation_lost:
|
|
328
|
+
result["warning"] = "conversation_lost"
|
|
329
|
+
result["hint"] = (
|
|
330
|
+
"The MCP server lost this conversation. You should end_session() "
|
|
331
|
+
"and delegate() a new task with the full context."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return result
|
|
335
|
+
|
|
301
336
|
|
|
302
337
|
@weaveTool
|
|
303
338
|
def check_session(
|
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
zwarm/__init__.py,sha256=3i3LMjHwIzE-LFIS2aUrwv3EZmpkvVMe-xj1h97rcSM,837
|
|
2
|
-
zwarm/orchestrator.py,sha256=
|
|
2
|
+
zwarm/orchestrator.py,sha256=wCvK4RVBDC6T_vrJSJBhPfaDpTorwLAXpOUYHDjowrs,21112
|
|
3
3
|
zwarm/test_orchestrator_watchers.py,sha256=QpoaehPU7ekT4XshbTOWnJ2H0wRveV3QOZjxbgyJJLY,807
|
|
4
4
|
zwarm/adapters/__init__.py,sha256=O0b-SfZpb6txeNqFkXZ2aaf34yLFYreznyrAV25jF_Q,656
|
|
5
5
|
zwarm/adapters/base.py,sha256=fZlQviTgVvOcwnxduTla6WuM6FzQJ_yoHMW5SxwVgQg,2527
|
|
6
6
|
zwarm/adapters/claude_code.py,sha256=vAjsjD-_JjARmC4_FBSILQZmQCBrk_oNHo18a9ubuqk,11481
|
|
7
|
-
zwarm/adapters/codex_mcp.py,sha256=
|
|
7
|
+
zwarm/adapters/codex_mcp.py,sha256=olgR8mfgjn0cbrD8Gc11DX4yVzWXqE64fQHsMAq_xAQ,32771
|
|
8
8
|
zwarm/adapters/registry.py,sha256=EdyHECaNA5Kv1od64pYFBJyA_r_6I1r_eJTNP1XYLr4,1781
|
|
9
9
|
zwarm/adapters/test_codex_mcp.py,sha256=0qhVzxn_KF-XUS30gXSJKwMdR3kWGsDY9iPk1Ihqn3w,10698
|
|
10
10
|
zwarm/adapters/test_registry.py,sha256=otxcVDONwFCMisyANToF3iy7Y8dSbCL8bTmZNhxNuF4,2383
|
|
11
11
|
zwarm/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
zwarm/cli/main.py,sha256=
|
|
12
|
+
zwarm/cli/main.py,sha256=9kHUw897580fQtBii1BqoJrUBYMaKmYjC6CM4AuOsdQ,59013
|
|
13
13
|
zwarm/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
14
|
zwarm/core/compact.py,sha256=Y8C7Gs-5-WOU43WRvQ863Qzd5xtuEqR6Aw3r2p8_-i8,10907
|
|
15
15
|
zwarm/core/config.py,sha256=J-TAwIQFLY4x_G9qdvP_16DAYndqg_zhakCW5TLEMN4,11059
|
|
16
16
|
zwarm/core/environment.py,sha256=HVDpDZEpDSfyh9-wHZMzMKVUPKvioBkPVWeiME2JmFo,5435
|
|
17
17
|
zwarm/core/models.py,sha256=PrC3okRBVJxISUa1Fax4KkagqLT6Xub-kTxC9drN0sY,10083
|
|
18
|
-
zwarm/core/state.py,sha256=
|
|
18
|
+
zwarm/core/state.py,sha256=MzrvODKEiJovI7YI1jajW4uukineZ3ezmW5oQinMgjg,11563
|
|
19
19
|
zwarm/core/test_compact.py,sha256=WSdjCB5t4YMcknsrkmJIUsVOPY28s4y9GnDmu3Z4BFw,11878
|
|
20
20
|
zwarm/core/test_config.py,sha256=26ozyiFOdjFF2c9Q-HDfFM6GOLfgw_5FZ55nTDMNYA8,4888
|
|
21
21
|
zwarm/core/test_models.py,sha256=sWTIhMZvuLP5AooGR6y8OR2EyWydqVfhmGrE7NPBBnk,8450
|
|
22
22
|
zwarm/prompts/__init__.py,sha256=FiaIOniLrIyfD3_osxT6I7FfyKjtctbf8jNs5QTPs_s,213
|
|
23
23
|
zwarm/prompts/orchestrator.py,sha256=R-ym3nlspYNspf085Qwwfi96Yh3K6-Csb6vfBg29K2c,14228
|
|
24
24
|
zwarm/tools/__init__.py,sha256=FpqxwXJA6-fQ7C-oLj30jjK_0qqcE7MbI0dQuaB56kU,290
|
|
25
|
-
zwarm/tools/delegation.py,sha256=
|
|
25
|
+
zwarm/tools/delegation.py,sha256=PeB0W7x2TcGCZ9rGBYDlIIFr7AT1TOdc1SLe7BKCUoM,15332
|
|
26
26
|
zwarm/watchers/__init__.py,sha256=yYGTbhuImQLESUdtfrYbHYBJNvCNX3B-Ei-vY5BizX8,760
|
|
27
27
|
zwarm/watchers/base.py,sha256=r1GoPlj06nOT2xp4fghfSjxbRyFFFQUB6HpZbEyO2OY,3834
|
|
28
28
|
zwarm/watchers/builtin.py,sha256=52hyRREYYDsSuG-YKElXViSTyMmGySZaFreHc0pz-A4,12482
|
|
29
29
|
zwarm/watchers/manager.py,sha256=XZjBVeHjgCUlkTUeHqdvBvHoBC862U1ik0fG6nlRGog,5587
|
|
30
30
|
zwarm/watchers/registry.py,sha256=A9iBIVIFNtO7KPX0kLpUaP8dAK7ozqWLA44ocJGnOw4,1219
|
|
31
31
|
zwarm/watchers/test_watchers.py,sha256=zOsxumBqKfR5ZVGxrNlxz6KcWjkcdp0QhW9WB0_20zM,7855
|
|
32
|
-
zwarm-1.2.
|
|
33
|
-
zwarm-1.2.
|
|
34
|
-
zwarm-1.2.
|
|
35
|
-
zwarm-1.2.
|
|
32
|
+
zwarm-1.3.2.dist-info/METADATA,sha256=Xf0dFQrOuhMhlbVQsRWw1uh65uCIVWMB-Hv99-rZ7Xc,15174
|
|
33
|
+
zwarm-1.3.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
34
|
+
zwarm-1.3.2.dist-info/entry_points.txt,sha256=u0OXq4q8d3yJ3EkUXwZfkS-Y8Lcy0F8cWrcQfoRxM6Q,46
|
|
35
|
+
zwarm-1.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|