network-ai 3.3.0 → 3.3.2

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.
@@ -1,852 +1,852 @@
1
- #!/usr/bin/env python3
2
- """
3
- Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)
4
-
5
- A markdown-based shared state system for multi-agent coordination.
6
- Stores key-value pairs with optional TTL (time-to-live) expiration.
7
-
8
- FEATURES:
9
- - File Locking: Prevents race conditions in multi-agent environments
10
- - Staging Area: propose → validate → commit workflow
11
- - Atomic Commits: Changes are all-or-nothing
12
-
13
- Usage:
14
- python blackboard.py write KEY VALUE [--ttl SECONDS]
15
- python blackboard.py read KEY
16
- python blackboard.py delete KEY
17
- python blackboard.py list
18
- python blackboard.py snapshot
19
-
20
- # Atomic commit workflow:
21
- python blackboard.py propose CHANGE_ID KEY VALUE [--ttl SECONDS]
22
- python blackboard.py validate CHANGE_ID
23
- python blackboard.py commit CHANGE_ID
24
- python blackboard.py abort CHANGE_ID
25
- python blackboard.py list-pending
26
-
27
- Examples:
28
- python blackboard.py write "task:analysis" '{"status": "running"}'
29
- python blackboard.py write "cache:data" '{"value": 123}' --ttl 3600
30
- python blackboard.py read "task:analysis"
31
- python blackboard.py list
32
-
33
- # Safe multi-agent update:
34
- python blackboard.py propose "chg_001" "order:123" '{"status": "approved"}'
35
- python blackboard.py validate "chg_001" # Orchestrator checks for conflicts
36
- python blackboard.py commit "chg_001" # Apply atomically
37
- """
38
-
39
- import argparse
40
- import json
41
- import os
42
- import re
43
- import sys
44
- import time
45
- import hashlib
46
- from datetime import datetime, timezone
47
- from pathlib import Path
48
- from typing import Any, Optional
49
- from contextlib import contextmanager
50
-
51
- # Try to import fcntl (Unix only), fall back to file-based locking on Windows
52
- _fcntl: Any = None
53
- try:
54
- import fcntl as _fcntl_import
55
- _fcntl = _fcntl_import
56
- except ImportError:
57
- pass # fcntl unavailable on Windows; file-based lock fallback is used instead
58
-
59
- # Default blackboard location
60
- BLACKBOARD_PATH = Path(__file__).parent.parent / "swarm-blackboard.md"
61
- LOCK_PATH = Path(__file__).parent.parent / "data" / ".blackboard.lock"
62
- PENDING_DIR = Path(__file__).parent.parent / "data" / "pending_changes"
63
-
64
- # Lock timeout settings
65
- LOCK_TIMEOUT_SECONDS = 10
66
- LOCK_RETRY_INTERVAL = 0.1
67
-
68
-
69
- class FileLock:
70
- """
71
- Cross-platform file lock for preventing race conditions.
72
- Uses fcntl on Unix, fallback to lock file on Windows.
73
- """
74
-
75
- def __init__(self, lock_path: Path, timeout: float = LOCK_TIMEOUT_SECONDS):
76
- self.lock_path = lock_path
77
- self.timeout = timeout
78
- self.lock_file: Optional[Any] = None
79
- self.lock_marker: Optional[Path] = None
80
- self.lock_path.parent.mkdir(parents=True, exist_ok=True)
81
-
82
- def acquire(self) -> bool:
83
- """Acquire the lock with timeout."""
84
- start_time = time.time()
85
-
86
- while True:
87
- try:
88
- self.lock_file = open(self.lock_path, 'w')
89
-
90
- if _fcntl is not None:
91
- # Unix/Linux/Mac - use fcntl
92
- _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
93
- else:
94
- # Windows fallback: use lock marker file
95
- self.lock_marker = self.lock_path.with_suffix('.locked')
96
- if self.lock_marker.exists():
97
- # Check if stale (older than timeout)
98
- age = time.time() - self.lock_marker.stat().st_mtime
99
- if age < self.timeout:
100
- self.lock_file.close()
101
- raise BlockingIOError("Lock held by another process")
102
- # Stale lock, remove it
103
- self.lock_marker.unlink()
104
- self.lock_marker.write_text(str(time.time()))
105
-
106
- # Write lock holder info
107
- self.lock_file.write(json.dumps({
108
- "pid": os.getpid(),
109
- "acquired_at": datetime.now(timezone.utc).isoformat()
110
- }))
111
- self.lock_file.flush()
112
- return True
113
-
114
- except (BlockingIOError, OSError):
115
- if self.lock_file:
116
- self.lock_file.close()
117
- self.lock_file = None
118
- if time.time() - start_time > self.timeout:
119
- return False
120
- time.sleep(LOCK_RETRY_INTERVAL)
121
-
122
- def release(self) -> None:
123
- """Release the lock."""
124
- if self.lock_file:
125
- try:
126
- if _fcntl is not None:
127
- _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_UN)
128
- elif self.lock_marker and self.lock_marker.exists():
129
- self.lock_marker.unlink()
130
-
131
- self.lock_file.close()
132
- except Exception:
133
- pass # best-effort unlock; fd and marker cleaned up in finally
134
- finally:
135
- self.lock_file = None
136
- self.lock_marker = None
137
-
138
-
139
- @contextmanager
140
- def blackboard_lock(lock_path: Path = LOCK_PATH):
141
- """Context manager for atomic blackboard access."""
142
- lock = FileLock(lock_path)
143
- if not lock.acquire():
144
- raise TimeoutError(
145
- f"Could not acquire blackboard lock within {LOCK_TIMEOUT_SECONDS}s. "
146
- "Another agent may be holding it. Retry later."
147
- )
148
- try:
149
- yield
150
- finally:
151
- lock.release()
152
-
153
-
154
- class SharedBlackboard:
155
- """Markdown-based shared state for agent coordination with atomic commits."""
156
-
157
- def __init__(self, path: Path = BLACKBOARD_PATH):
158
- self.path = path
159
- self.cache: dict[str, dict[str, Any]] = {}
160
- self.pending_dir = PENDING_DIR
161
- self.pending_dir.mkdir(parents=True, exist_ok=True)
162
- self._initialize()
163
- self._load_from_disk()
164
-
165
- def _initialize(self):
166
- """Create blackboard file if it doesn't exist."""
167
- if not self.path.exists():
168
- self.path.parent.mkdir(parents=True, exist_ok=True)
169
- initial_content = f"""# Swarm Blackboard
170
- Last Updated: {datetime.now(timezone.utc).isoformat()}
171
-
172
- ## Active Tasks
173
- | TaskID | Agent | Status | Started | Description |
174
- |--------|-------|--------|---------|-------------|
175
-
176
- ## Knowledge Cache
177
- <!-- Cached results from agent operations -->
178
-
179
- ## Coordination Signals
180
- <!-- Agent availability status -->
181
-
182
- ## Execution History
183
- <!-- Chronological log of completed tasks -->
184
- """
185
- self.path.write_text(initial_content, encoding="utf-8")
186
-
187
- def _load_from_disk(self):
188
- """Load entries from the markdown blackboard."""
189
- try:
190
- content = self.path.read_text(encoding="utf-8")
191
-
192
- # Parse Knowledge Cache section
193
- cache_match = re.search(
194
- r'## Knowledge Cache\n([\s\S]*?)(?=\n## |$)',
195
- content
196
- )
197
-
198
- if cache_match:
199
- cache_section = cache_match.group(1)
200
- # Find all entries: ### key\n{json}
201
- entries = re.findall(
202
- r'### (\S+)\n([\s\S]*?)(?=\n### |$)',
203
- cache_section
204
- )
205
-
206
- for key, value_str in entries:
207
- try:
208
- entry = json.loads(value_str.strip())
209
- self.cache[key] = entry
210
- except json.JSONDecodeError:
211
- # Skip malformed entries
212
- pass
213
- except Exception as e:
214
- print(f"Warning: Failed to load blackboard: {e}", file=sys.stderr)
215
-
216
- def _persist_to_disk(self):
217
- """Save entries to the markdown blackboard."""
218
- sections = [
219
- "# Swarm Blackboard",
220
- f"Last Updated: {datetime.now(timezone.utc).isoformat()}",
221
- "",
222
- "## Active Tasks",
223
- "| TaskID | Agent | Status | Started | Description |",
224
- "|--------|-------|--------|---------|-------------|",
225
- "",
226
- "## Knowledge Cache",
227
- ]
228
-
229
- # Clean expired entries and write valid ones
230
- for key, entry in list(self.cache.items()):
231
- if self._is_expired(entry):
232
- del self.cache[key]
233
- continue
234
-
235
- sections.append(f"### {key}")
236
- sections.append(json.dumps(entry, indent=2))
237
- sections.append("")
238
-
239
- sections.extend([
240
- "## Coordination Signals",
241
- "",
242
- "## Execution History",
243
- ])
244
-
245
- self.path.write_text("\n".join(sections), encoding="utf-8")
246
-
247
- def _is_expired(self, entry: dict[str, Any]) -> bool:
248
- """Check if an entry has expired based on TTL."""
249
- ttl = entry.get("ttl")
250
- if ttl is None:
251
- return False
252
-
253
- timestamp = entry.get("timestamp")
254
- if not timestamp:
255
- return False
256
-
257
- try:
258
- created = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
259
- now = datetime.now(timezone.utc)
260
- elapsed = (now - created).total_seconds()
261
- return elapsed > ttl
262
- except Exception:
263
- return False
264
-
265
- def read(self, key: str) -> Optional[dict[str, Any]]:
266
- """Read an entry from the blackboard."""
267
- entry = self.cache.get(key)
268
- if entry is None:
269
- return None
270
-
271
- if self._is_expired(entry):
272
- del self.cache[key]
273
- self._persist_to_disk()
274
- return None
275
-
276
- return entry
277
-
278
- def write(self, key: str, value: Any, source_agent: str = "unknown",
279
- ttl: Optional[int] = None) -> dict[str, Any]:
280
- """Write an entry to the blackboard (with file locking)."""
281
- entry: dict[str, Any] = {
282
- "key": key,
283
- "value": value,
284
- "source_agent": source_agent,
285
- "timestamp": datetime.now(timezone.utc).isoformat(),
286
- "ttl": ttl,
287
- }
288
-
289
- with blackboard_lock():
290
- # Reload to get latest state
291
- self._load_from_disk()
292
- self.cache[key] = entry
293
- self._persist_to_disk()
294
-
295
- return entry
296
-
297
- def delete(self, key: str) -> bool:
298
- """Delete an entry from the blackboard (with file locking)."""
299
- with blackboard_lock():
300
- self._load_from_disk()
301
- if key in self.cache:
302
- del self.cache[key]
303
- self._persist_to_disk()
304
- return True
305
- return False
306
-
307
- # ========================================================================
308
- # ATOMIC COMMIT WORKFLOW: propose → validate → commit
309
- # ========================================================================
310
-
311
- @staticmethod
312
- def _sanitize_change_id(change_id: str) -> str:
313
- """
314
- Sanitize change_id to prevent path traversal attacks.
315
- Only allows alphanumeric characters, hyphens, underscores, and dots.
316
- Rejects any path separators or parent directory references.
317
- """
318
- if not change_id:
319
- raise ValueError("change_id must be a non-empty string")
320
- # Strip whitespace
321
- sanitized = change_id.strip()
322
- # Reject path separators and parent directory traversal
323
- if any(c in sanitized for c in ('/', '\\', '..')):
324
- raise ValueError(
325
- f"Invalid change_id '{change_id}': must not contain path separators or '..'"
326
- )
327
- # Only allow safe characters: alphanumeric, hyphen, underscore, dot
328
- if not re.match(r'^[a-zA-Z0-9_\-\.]+$', sanitized):
329
- raise ValueError(
330
- f"Invalid change_id '{change_id}': only alphanumeric, hyphen, underscore, and dot allowed"
331
- )
332
- return sanitized
333
-
334
- def _safe_pending_path(self, change_id: str, suffix: str = ".pending.json") -> Path:
335
- """Build a pending-file path and verify it stays inside pending_dir."""
336
- safe_id = self._sanitize_change_id(change_id)
337
- target = (self.pending_dir / f"{safe_id}{suffix}").resolve()
338
- if not str(target).startswith(str(self.pending_dir.resolve())):
339
- raise ValueError(f"Path traversal blocked for change_id '{change_id}'")
340
- return target
341
-
342
- def propose_change(self, change_id: str, key: str, value: Any,
343
- source_agent: str = "unknown", ttl: Optional[int] = None,
344
- operation: str = "write") -> dict[str, Any]:
345
- """
346
- Stage a change without applying it (Step 1 of atomic commit).
347
-
348
- The change is written to a .pending file and must be validated
349
- and committed by the orchestrator before it takes effect.
350
- """
351
- pending_file = self._safe_pending_path(change_id)
352
-
353
- # Check for duplicate change_id
354
- if pending_file.exists():
355
- return {
356
- "success": False,
357
- "error": f"Change ID '{change_id}' already exists. Use a unique ID."
358
- }
359
-
360
- # Get current value for conflict detection
361
- current_entry = self.cache.get(key)
362
- current_hash = None
363
- if current_entry:
364
- current_hash = hashlib.sha256(
365
- json.dumps(current_entry, sort_keys=True).encode()
366
- ).hexdigest()[:16]
367
-
368
- change_set: dict[str, Any] = {
369
- "change_id": change_id,
370
- "operation": operation, # "write" or "delete"
371
- "key": key,
372
- "value": value,
373
- "source_agent": source_agent,
374
- "ttl": ttl,
375
- "proposed_at": datetime.now(timezone.utc).isoformat(),
376
- "status": "pending",
377
- "base_hash": current_hash, # For conflict detection
378
- }
379
-
380
- pending_file.write_text(json.dumps(change_set, indent=2))
381
-
382
- return {
383
- "success": True,
384
- "change_id": change_id,
385
- "status": "proposed",
386
- "pending_file": str(pending_file),
387
- "message": "Change staged. Run 'validate' then 'commit' to apply."
388
- }
389
-
390
- def validate_change(self, change_id: str) -> dict[str, Any]:
391
- """
392
- Validate a pending change for conflicts (Step 2 of atomic commit).
393
-
394
- Checks:
395
- - Change exists
396
- - No conflicting changes to the same key
397
- - Base hash matches (data hasn't changed since proposal)
398
- """
399
- pending_file = self._safe_pending_path(change_id)
400
-
401
- if not pending_file.exists():
402
- return {
403
- "valid": False,
404
- "error": f"Change '{change_id}' not found. Was it proposed?"
405
- }
406
-
407
- change_set = json.loads(pending_file.read_text())
408
-
409
- if change_set["status"] != "pending":
410
- return {
411
- "valid": False,
412
- "error": f"Change is in '{change_set['status']}' state, not 'pending'"
413
- }
414
-
415
- key = change_set["key"]
416
- base_hash = change_set["base_hash"]
417
-
418
- # Check for conflicts: has the key changed since we proposed?
419
- with blackboard_lock():
420
- self._load_from_disk()
421
- current_entry = self.cache.get(key)
422
-
423
- current_hash = None
424
- if current_entry:
425
- current_hash = hashlib.sha256(
426
- json.dumps(current_entry, sort_keys=True).encode()
427
- ).hexdigest()[:16]
428
-
429
- if base_hash != current_hash:
430
- return {
431
- "valid": False,
432
- "conflict": True,
433
- "error": f"CONFLICT: Key '{key}' was modified since proposal. "
434
- f"Expected hash {base_hash}, got {current_hash}. "
435
- "Abort and re-propose with fresh data.",
436
- "current_value": current_entry
437
- }
438
-
439
- # Check for other pending changes to the same key
440
- conflicts: list[str] = []
441
- for other_file in self.pending_dir.glob("*.pending.json"):
442
- if other_file.name == pending_file.name:
443
- continue
444
- other_change = json.loads(other_file.read_text())
445
- if other_change["key"] == key and other_change["status"] == "pending":
446
- conflicts.append(other_change["change_id"])
447
-
448
- if conflicts:
449
- return {
450
- "valid": False,
451
- "conflict": True,
452
- "error": f"CONFLICT: Other pending changes affect key '{key}': {conflicts}. "
453
- "Resolve conflicts before committing."
454
- }
455
-
456
- # Mark as validated
457
- change_set["status"] = "validated"
458
- change_set["validated_at"] = datetime.now(timezone.utc).isoformat()
459
- pending_file.write_text(json.dumps(change_set, indent=2))
460
-
461
- return {
462
- "valid": True,
463
- "change_id": change_id,
464
- "key": key,
465
- "status": "validated",
466
- "message": "No conflicts detected. Ready to commit."
467
- }
468
-
469
- def commit_change(self, change_id: str) -> dict[str, Any]:
470
- """
471
- Apply a validated change atomically (Step 3 of atomic commit).
472
- """
473
- pending_file = self._safe_pending_path(change_id)
474
-
475
- if not pending_file.exists():
476
- return {
477
- "committed": False,
478
- "error": f"Change '{change_id}' not found."
479
- }
480
-
481
- change_set = json.loads(pending_file.read_text())
482
-
483
- if change_set["status"] != "validated":
484
- return {
485
- "committed": False,
486
- "error": f"Change must be validated first. Current status: {change_set['status']}"
487
- }
488
-
489
- # Apply the change atomically
490
- with blackboard_lock():
491
- self._load_from_disk()
492
-
493
- if change_set["operation"] == "delete":
494
- if change_set["key"] in self.cache:
495
- del self.cache[change_set["key"]]
496
- else:
497
- entry: dict[str, Any] = {
498
- "key": change_set["key"],
499
- "value": change_set["value"],
500
- "source_agent": change_set["source_agent"],
501
- "timestamp": datetime.now(timezone.utc).isoformat(),
502
- "ttl": change_set["ttl"],
503
- "committed_from": change_id
504
- }
505
- self.cache[change_set["key"]] = entry
506
-
507
- self._persist_to_disk()
508
-
509
- # Archive the committed change
510
- change_set["status"] = "committed"
511
- change_set["committed_at"] = datetime.now(timezone.utc).isoformat()
512
-
513
- safe_id = self._sanitize_change_id(change_id)
514
- archive_dir = self.pending_dir / "archive"
515
- archive_dir.mkdir(exist_ok=True)
516
- archive_file = archive_dir / f"{safe_id}.committed.json"
517
- archive_file.write_text(json.dumps(change_set, indent=2))
518
-
519
- # Remove pending file
520
- pending_file.unlink()
521
-
522
- return {
523
- "committed": True,
524
- "change_id": change_id,
525
- "key": change_set["key"],
526
- "operation": change_set["operation"],
527
- "message": "Change committed atomically."
528
- }
529
-
530
- def abort_change(self, change_id: str) -> dict[str, Any]:
531
- """Abort a pending change without applying it."""
532
- pending_file = self._safe_pending_path(change_id)
533
-
534
- if not pending_file.exists():
535
- return {
536
- "aborted": False,
537
- "error": f"Change '{change_id}' not found."
538
- }
539
-
540
- change_set = json.loads(pending_file.read_text())
541
- change_set["status"] = "aborted"
542
- change_set["aborted_at"] = datetime.now(timezone.utc).isoformat()
543
-
544
- # Archive the aborted change
545
- safe_id = self._sanitize_change_id(change_id)
546
- archive_dir = self.pending_dir / "archive"
547
- archive_dir.mkdir(exist_ok=True)
548
- archive_file = archive_dir / f"{safe_id}.aborted.json"
549
- archive_file.write_text(json.dumps(change_set, indent=2))
550
-
551
- pending_file.unlink()
552
-
553
- return {
554
- "aborted": True,
555
- "change_id": change_id,
556
- "key": change_set["key"]
557
- }
558
-
559
- def list_pending_changes(self) -> list[dict[str, Any]]:
560
- """List all pending changes awaiting commit."""
561
- pending: list[dict[str, Any]] = []
562
- for pending_file in self.pending_dir.glob("*.pending.json"):
563
- change_set = json.loads(pending_file.read_text())
564
- pending.append({
565
- "change_id": change_set["change_id"],
566
- "key": change_set["key"],
567
- "operation": change_set["operation"],
568
- "source_agent": change_set["source_agent"],
569
- "status": change_set["status"],
570
- "proposed_at": change_set["proposed_at"]
571
- })
572
- return pending
573
-
574
- def exists(self, key: str) -> bool:
575
- """Check if a key exists (and is not expired)."""
576
- return self.read(key) is not None
577
-
578
- def list_keys(self) -> list[str]:
579
- """List all valid (non-expired) keys."""
580
- valid_keys: list[str] = []
581
- for key in list(self.cache.keys()):
582
- if self.read(key) is not None:
583
- valid_keys.append(key)
584
- return valid_keys
585
-
586
- def get_snapshot(self) -> dict[str, dict[str, Any]]:
587
- """Get a snapshot of all valid entries."""
588
- snapshot: dict[str, dict[str, Any]] = {}
589
- for key in list(self.cache.keys()):
590
- entry = self.read(key)
591
- if entry is not None:
592
- snapshot[key] = entry
593
- return snapshot
594
-
595
-
596
- def main():
597
- parser = argparse.ArgumentParser(
598
- description="Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)",
599
- formatter_class=argparse.RawDescriptionHelpFormatter,
600
- epilog="""
601
- Commands:
602
- write KEY VALUE [--ttl SECONDS] Write a value (with file locking)
603
- read KEY Read a value
604
- delete KEY Delete a key
605
- list List all keys
606
- snapshot Get full snapshot as JSON
607
-
608
- Atomic Commit Workflow (for multi-agent safety):
609
- propose CHANGE_ID KEY VALUE Stage a change (Step 1)
610
- validate CHANGE_ID Check for conflicts (Step 2)
611
- commit CHANGE_ID Apply atomically (Step 3)
612
- abort CHANGE_ID Cancel a pending change
613
- list-pending Show all pending changes
614
-
615
- Examples:
616
- %(prog)s write "task:analysis" '{"status": "running"}'
617
- %(prog)s write "cache:temp" '{"data": [1,2,3]}' --ttl 3600
618
-
619
- # Safe multi-agent update:
620
- %(prog)s propose "chg_001" "order:123" '{"status": "approved"}'
621
- %(prog)s validate "chg_001"
622
- %(prog)s commit "chg_001"
623
- """
624
- )
625
-
626
- parser.add_argument(
627
- "command",
628
- choices=["write", "read", "delete", "list", "snapshot",
629
- "propose", "validate", "commit", "abort", "list-pending"],
630
- help="Command to execute"
631
- )
632
- parser.add_argument(
633
- "key",
634
- nargs="?",
635
- help="Key name or Change ID (depending on command)"
636
- )
637
- parser.add_argument(
638
- "value",
639
- nargs="?",
640
- help="JSON value (required for write/propose)"
641
- )
642
- parser.add_argument(
643
- "--ttl",
644
- type=int,
645
- help="Time-to-live in seconds (for write/propose)"
646
- )
647
- parser.add_argument(
648
- "--agent",
649
- default="cli",
650
- help="Source agent ID (for write/propose)"
651
- )
652
- parser.add_argument(
653
- "--json",
654
- action="store_true",
655
- help="Output as JSON"
656
- )
657
- parser.add_argument(
658
- "--path",
659
- type=Path,
660
- default=BLACKBOARD_PATH,
661
- help="Path to blackboard file"
662
- )
663
-
664
- args = parser.parse_args()
665
- bb = SharedBlackboard(args.path)
666
-
667
- try:
668
- if args.command == "write":
669
- if not args.key or not args.value:
670
- print("Error: write requires KEY and VALUE", file=sys.stderr)
671
- sys.exit(1)
672
-
673
- try:
674
- value = json.loads(args.value)
675
- except json.JSONDecodeError:
676
- value = args.value
677
-
678
- entry = bb.write(args.key, value, args.agent, args.ttl)
679
-
680
- if args.json:
681
- print(json.dumps(entry, indent=2))
682
- else:
683
- print(f"✅ Written: {args.key} (with lock)")
684
- if args.ttl:
685
- print(f" TTL: {args.ttl} seconds")
686
-
687
- elif args.command == "read":
688
- if not args.key:
689
- print("Error: read requires KEY", file=sys.stderr)
690
- sys.exit(1)
691
-
692
- entry = bb.read(args.key)
693
-
694
- if entry is None:
695
- if args.json:
696
- print("null")
697
- else:
698
- print(f"❌ Key not found or expired: {args.key}")
699
- sys.exit(1)
700
-
701
- if args.json:
702
- print(json.dumps(entry, indent=2))
703
- else:
704
- print(f"📖 {args.key}:")
705
- print(f" Value: {json.dumps(entry.get('value'))}")
706
- print(f" Source: {entry.get('source_agent')}")
707
- print(f" Timestamp: {entry.get('timestamp')}")
708
- if entry.get('ttl'):
709
- print(f" TTL: {entry['ttl']} seconds")
710
-
711
- elif args.command == "delete":
712
- if not args.key:
713
- print("Error: delete requires KEY", file=sys.stderr)
714
- sys.exit(1)
715
-
716
- if bb.delete(args.key):
717
- print(f"✅ Deleted: {args.key}")
718
- else:
719
- print(f"❌ Key not found: {args.key}")
720
- sys.exit(1)
721
-
722
- elif args.command == "list":
723
- keys = bb.list_keys()
724
-
725
- if args.json:
726
- print(json.dumps(keys, indent=2))
727
- else:
728
- if keys:
729
- print(f"📋 Blackboard keys ({len(keys)}):")
730
- for key in sorted(keys):
731
- entry = bb.read(key)
732
- ttl_info = f" [TTL: {entry['ttl']}s]" if entry and entry.get('ttl') else ""
733
- print(f" • {key}{ttl_info}")
734
- else:
735
- print("📋 Blackboard is empty")
736
-
737
- elif args.command == "snapshot":
738
- snapshot = bb.get_snapshot()
739
- print(json.dumps(snapshot, indent=2))
740
-
741
- # === ATOMIC COMMIT COMMANDS ===
742
-
743
- elif args.command == "propose":
744
- if not args.key or not args.value:
745
- print("Error: propose requires CHANGE_ID and KEY VALUE", file=sys.stderr)
746
- print("Usage: propose CHANGE_ID KEY VALUE", file=sys.stderr)
747
- sys.exit(1)
748
-
749
- # Parse: propose CHANGE_ID KEY VALUE (key is actually change_id, value is "KEY VALUE")
750
- parts = args.value.split(" ", 1)
751
- if len(parts) < 2:
752
- print("Error: propose requires CHANGE_ID KEY VALUE", file=sys.stderr)
753
- sys.exit(1)
754
-
755
- change_id = args.key
756
- actual_key = parts[0]
757
- actual_value_str = parts[1] if len(parts) > 1 else "{}"
758
-
759
- try:
760
- actual_value = json.loads(actual_value_str)
761
- except json.JSONDecodeError:
762
- actual_value = actual_value_str
763
-
764
- result = bb.propose_change(change_id, actual_key, actual_value, args.agent, args.ttl)
765
-
766
- if args.json:
767
- print(json.dumps(result, indent=2))
768
- else:
769
- if result["success"]:
770
- print(f"📝 Change PROPOSED: {change_id}")
771
- print(f" Key: {actual_key}")
772
- print(f" Status: pending validation")
773
- print(f" Next: run 'validate {change_id}'")
774
- else:
775
- print(f"❌ Proposal FAILED: {result['error']}")
776
- sys.exit(1)
777
-
778
- elif args.command == "validate":
779
- if not args.key:
780
- print("Error: validate requires CHANGE_ID", file=sys.stderr)
781
- sys.exit(1)
782
-
783
- result = bb.validate_change(args.key)
784
-
785
- if args.json:
786
- print(json.dumps(result, indent=2))
787
- else:
788
- if result["valid"]:
789
- print(f"✅ Change VALIDATED: {args.key}")
790
- print(f" Key: {result['key']}")
791
- print(f" No conflicts detected")
792
- print(f" Next: run 'commit {args.key}'")
793
- else:
794
- print(f"❌ Validation FAILED: {result['error']}")
795
- sys.exit(1)
796
-
797
- elif args.command == "commit":
798
- if not args.key:
799
- print("Error: commit requires CHANGE_ID", file=sys.stderr)
800
- sys.exit(1)
801
-
802
- result = bb.commit_change(args.key)
803
-
804
- if args.json:
805
- print(json.dumps(result, indent=2))
806
- else:
807
- if result["committed"]:
808
- print(f"🎉 Change COMMITTED: {args.key}")
809
- print(f" Key: {result['key']}")
810
- print(f" Operation: {result['operation']}")
811
- else:
812
- print(f"❌ Commit FAILED: {result['error']}")
813
- sys.exit(1)
814
-
815
- elif args.command == "abort":
816
- if not args.key:
817
- print("Error: abort requires CHANGE_ID", file=sys.stderr)
818
- sys.exit(1)
819
-
820
- result = bb.abort_change(args.key)
821
-
822
- if args.json:
823
- print(json.dumps(result, indent=2))
824
- else:
825
- if result["aborted"]:
826
- print(f"🚫 Change ABORTED: {args.key}")
827
- else:
828
- print(f"❌ Abort FAILED: {result['error']}")
829
- sys.exit(1)
830
-
831
- elif args.command == "list-pending":
832
- pending = bb.list_pending_changes()
833
-
834
- if args.json:
835
- print(json.dumps(pending, indent=2))
836
- else:
837
- if pending:
838
- print(f"📋 Pending changes ({len(pending)}):")
839
- for p in pending:
840
- status_icon = "🟡" if p["status"] == "pending" else "🟢"
841
- print(f" {status_icon} {p['change_id']}: {p['operation']} '{p['key']}'")
842
- print(f" Agent: {p['source_agent']} | Status: {p['status']}")
843
- else:
844
- print("📋 No pending changes")
845
-
846
- except TimeoutError as e:
847
- print(f"🔒 LOCK TIMEOUT: {e}", file=sys.stderr)
848
- sys.exit(1)
849
-
850
-
851
- if __name__ == "__main__":
852
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)
4
+
5
+ A markdown-based shared state system for multi-agent coordination.
6
+ Stores key-value pairs with optional TTL (time-to-live) expiration.
7
+
8
+ FEATURES:
9
+ - File Locking: Prevents race conditions in multi-agent environments
10
+ - Staging Area: propose → validate → commit workflow
11
+ - Atomic Commits: Changes are all-or-nothing
12
+
13
+ Usage:
14
+ python blackboard.py write KEY VALUE [--ttl SECONDS]
15
+ python blackboard.py read KEY
16
+ python blackboard.py delete KEY
17
+ python blackboard.py list
18
+ python blackboard.py snapshot
19
+
20
+ # Atomic commit workflow:
21
+ python blackboard.py propose CHANGE_ID KEY VALUE [--ttl SECONDS]
22
+ python blackboard.py validate CHANGE_ID
23
+ python blackboard.py commit CHANGE_ID
24
+ python blackboard.py abort CHANGE_ID
25
+ python blackboard.py list-pending
26
+
27
+ Examples:
28
+ python blackboard.py write "task:analysis" '{"status": "running"}'
29
+ python blackboard.py write "cache:data" '{"value": 123}' --ttl 3600
30
+ python blackboard.py read "task:analysis"
31
+ python blackboard.py list
32
+
33
+ # Safe multi-agent update:
34
+ python blackboard.py propose "chg_001" "order:123" '{"status": "approved"}'
35
+ python blackboard.py validate "chg_001" # Orchestrator checks for conflicts
36
+ python blackboard.py commit "chg_001" # Apply atomically
37
+ """
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import re
43
+ import sys
44
+ import time
45
+ import hashlib
46
+ from datetime import datetime, timezone
47
+ from pathlib import Path
48
+ from typing import Any, Optional
49
+ from contextlib import contextmanager
50
+
51
+ # Try to import fcntl (Unix only), fall back to file-based locking on Windows
52
+ _fcntl: Any = None
53
+ try:
54
+ import fcntl as _fcntl_import
55
+ _fcntl = _fcntl_import
56
+ except ImportError:
57
+ pass # fcntl unavailable on Windows; file-based lock fallback is used instead
58
+
59
+ # Default blackboard location
60
+ BLACKBOARD_PATH = Path(__file__).parent.parent / "swarm-blackboard.md"
61
+ LOCK_PATH = Path(__file__).parent.parent / "data" / ".blackboard.lock"
62
+ PENDING_DIR = Path(__file__).parent.parent / "data" / "pending_changes"
63
+
64
+ # Lock timeout settings
65
+ LOCK_TIMEOUT_SECONDS = 10
66
+ LOCK_RETRY_INTERVAL = 0.1
67
+
68
+
69
+ class FileLock:
70
+ """
71
+ Cross-platform file lock for preventing race conditions.
72
+ Uses fcntl on Unix, fallback to lock file on Windows.
73
+ """
74
+
75
+ def __init__(self, lock_path: Path, timeout: float = LOCK_TIMEOUT_SECONDS):
76
+ self.lock_path = lock_path
77
+ self.timeout = timeout
78
+ self.lock_file: Optional[Any] = None
79
+ self.lock_marker: Optional[Path] = None
80
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
81
+
82
+ def acquire(self) -> bool:
83
+ """Acquire the lock with timeout."""
84
+ start_time = time.time()
85
+
86
+ while True:
87
+ try:
88
+ self.lock_file = open(self.lock_path, 'w')
89
+
90
+ if _fcntl is not None:
91
+ # Unix/Linux/Mac - use fcntl
92
+ _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
93
+ else:
94
+ # Windows fallback: use lock marker file
95
+ self.lock_marker = self.lock_path.with_suffix('.locked')
96
+ if self.lock_marker.exists():
97
+ # Check if stale (older than timeout)
98
+ age = time.time() - self.lock_marker.stat().st_mtime
99
+ if age < self.timeout:
100
+ self.lock_file.close()
101
+ raise BlockingIOError("Lock held by another process")
102
+ # Stale lock, remove it
103
+ self.lock_marker.unlink()
104
+ self.lock_marker.write_text(str(time.time()))
105
+
106
+ # Write lock holder info
107
+ self.lock_file.write(json.dumps({
108
+ "pid": os.getpid(),
109
+ "acquired_at": datetime.now(timezone.utc).isoformat()
110
+ }))
111
+ self.lock_file.flush()
112
+ return True
113
+
114
+ except (BlockingIOError, OSError):
115
+ if self.lock_file:
116
+ self.lock_file.close()
117
+ self.lock_file = None
118
+ if time.time() - start_time > self.timeout:
119
+ return False
120
+ time.sleep(LOCK_RETRY_INTERVAL)
121
+
122
+ def release(self) -> None:
123
+ """Release the lock."""
124
+ if self.lock_file:
125
+ try:
126
+ if _fcntl is not None:
127
+ _fcntl.flock(self.lock_file.fileno(), _fcntl.LOCK_UN)
128
+ elif self.lock_marker and self.lock_marker.exists():
129
+ self.lock_marker.unlink()
130
+
131
+ self.lock_file.close()
132
+ except Exception:
133
+ pass # best-effort unlock; fd and marker cleaned up in finally
134
+ finally:
135
+ self.lock_file = None
136
+ self.lock_marker = None
137
+
138
+
139
+ @contextmanager
140
+ def blackboard_lock(lock_path: Path = LOCK_PATH):
141
+ """Context manager for atomic blackboard access."""
142
+ lock = FileLock(lock_path)
143
+ if not lock.acquire():
144
+ raise TimeoutError(
145
+ f"Could not acquire blackboard lock within {LOCK_TIMEOUT_SECONDS}s. "
146
+ "Another agent may be holding it. Retry later."
147
+ )
148
+ try:
149
+ yield
150
+ finally:
151
+ lock.release()
152
+
153
+
154
+ class SharedBlackboard:
155
+ """Markdown-based shared state for agent coordination with atomic commits."""
156
+
157
+ def __init__(self, path: Path = BLACKBOARD_PATH):
158
+ self.path = path
159
+ self.cache: dict[str, dict[str, Any]] = {}
160
+ self.pending_dir = PENDING_DIR
161
+ self.pending_dir.mkdir(parents=True, exist_ok=True)
162
+ self._initialize()
163
+ self._load_from_disk()
164
+
165
+ def _initialize(self):
166
+ """Create blackboard file if it doesn't exist."""
167
+ if not self.path.exists():
168
+ self.path.parent.mkdir(parents=True, exist_ok=True)
169
+ initial_content = f"""# Swarm Blackboard
170
+ Last Updated: {datetime.now(timezone.utc).isoformat()}
171
+
172
+ ## Active Tasks
173
+ | TaskID | Agent | Status | Started | Description |
174
+ |--------|-------|--------|---------|-------------|
175
+
176
+ ## Knowledge Cache
177
+ <!-- Cached results from agent operations -->
178
+
179
+ ## Coordination Signals
180
+ <!-- Agent availability status -->
181
+
182
+ ## Execution History
183
+ <!-- Chronological log of completed tasks -->
184
+ """
185
+ self.path.write_text(initial_content, encoding="utf-8")
186
+
187
+ def _load_from_disk(self):
188
+ """Load entries from the markdown blackboard."""
189
+ try:
190
+ content = self.path.read_text(encoding="utf-8")
191
+
192
+ # Parse Knowledge Cache section
193
+ cache_match = re.search(
194
+ r'## Knowledge Cache\n([\s\S]*?)(?=\n## |$)',
195
+ content
196
+ )
197
+
198
+ if cache_match:
199
+ cache_section = cache_match.group(1)
200
+ # Find all entries: ### key\n{json}
201
+ entries = re.findall(
202
+ r'### (\S+)\n([\s\S]*?)(?=\n### |$)',
203
+ cache_section
204
+ )
205
+
206
+ for key, value_str in entries:
207
+ try:
208
+ entry = json.loads(value_str.strip())
209
+ self.cache[key] = entry
210
+ except json.JSONDecodeError:
211
+ # Skip malformed entries
212
+ pass
213
+ except Exception as e:
214
+ print(f"Warning: Failed to load blackboard: {e}", file=sys.stderr)
215
+
216
+ def _persist_to_disk(self):
217
+ """Save entries to the markdown blackboard."""
218
+ sections = [
219
+ "# Swarm Blackboard",
220
+ f"Last Updated: {datetime.now(timezone.utc).isoformat()}",
221
+ "",
222
+ "## Active Tasks",
223
+ "| TaskID | Agent | Status | Started | Description |",
224
+ "|--------|-------|--------|---------|-------------|",
225
+ "",
226
+ "## Knowledge Cache",
227
+ ]
228
+
229
+ # Clean expired entries and write valid ones
230
+ for key, entry in list(self.cache.items()):
231
+ if self._is_expired(entry):
232
+ del self.cache[key]
233
+ continue
234
+
235
+ sections.append(f"### {key}")
236
+ sections.append(json.dumps(entry, indent=2))
237
+ sections.append("")
238
+
239
+ sections.extend([
240
+ "## Coordination Signals",
241
+ "",
242
+ "## Execution History",
243
+ ])
244
+
245
+ self.path.write_text("\n".join(sections), encoding="utf-8")
246
+
247
+ def _is_expired(self, entry: dict[str, Any]) -> bool:
248
+ """Check if an entry has expired based on TTL."""
249
+ ttl = entry.get("ttl")
250
+ if ttl is None:
251
+ return False
252
+
253
+ timestamp = entry.get("timestamp")
254
+ if not timestamp:
255
+ return False
256
+
257
+ try:
258
+ created = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
259
+ now = datetime.now(timezone.utc)
260
+ elapsed = (now - created).total_seconds()
261
+ return elapsed > ttl
262
+ except Exception:
263
+ return False
264
+
265
+ def read(self, key: str) -> Optional[dict[str, Any]]:
266
+ """Read an entry from the blackboard."""
267
+ entry = self.cache.get(key)
268
+ if entry is None:
269
+ return None
270
+
271
+ if self._is_expired(entry):
272
+ del self.cache[key]
273
+ self._persist_to_disk()
274
+ return None
275
+
276
+ return entry
277
+
278
+ def write(self, key: str, value: Any, source_agent: str = "unknown",
279
+ ttl: Optional[int] = None) -> dict[str, Any]:
280
+ """Write an entry to the blackboard (with file locking)."""
281
+ entry: dict[str, Any] = {
282
+ "key": key,
283
+ "value": value,
284
+ "source_agent": source_agent,
285
+ "timestamp": datetime.now(timezone.utc).isoformat(),
286
+ "ttl": ttl,
287
+ }
288
+
289
+ with blackboard_lock():
290
+ # Reload to get latest state
291
+ self._load_from_disk()
292
+ self.cache[key] = entry
293
+ self._persist_to_disk()
294
+
295
+ return entry
296
+
297
+ def delete(self, key: str) -> bool:
298
+ """Delete an entry from the blackboard (with file locking)."""
299
+ with blackboard_lock():
300
+ self._load_from_disk()
301
+ if key in self.cache:
302
+ del self.cache[key]
303
+ self._persist_to_disk()
304
+ return True
305
+ return False
306
+
307
+ # ========================================================================
308
+ # ATOMIC COMMIT WORKFLOW: propose → validate → commit
309
+ # ========================================================================
310
+
311
+ @staticmethod
312
+ def _sanitize_change_id(change_id: str) -> str:
313
+ """
314
+ Sanitize change_id to prevent path traversal attacks.
315
+ Only allows alphanumeric characters, hyphens, underscores, and dots.
316
+ Rejects any path separators or parent directory references.
317
+ """
318
+ if not change_id:
319
+ raise ValueError("change_id must be a non-empty string")
320
+ # Strip whitespace
321
+ sanitized = change_id.strip()
322
+ # Reject path separators and parent directory traversal
323
+ if any(c in sanitized for c in ('/', '\\', '..')):
324
+ raise ValueError(
325
+ f"Invalid change_id '{change_id}': must not contain path separators or '..'"
326
+ )
327
+ # Only allow safe characters: alphanumeric, hyphen, underscore, dot
328
+ if not re.match(r'^[a-zA-Z0-9_\-\.]+$', sanitized):
329
+ raise ValueError(
330
+ f"Invalid change_id '{change_id}': only alphanumeric, hyphen, underscore, and dot allowed"
331
+ )
332
+ return sanitized
333
+
334
+ def _safe_pending_path(self, change_id: str, suffix: str = ".pending.json") -> Path:
335
+ """Build a pending-file path and verify it stays inside pending_dir."""
336
+ safe_id = self._sanitize_change_id(change_id)
337
+ target = (self.pending_dir / f"{safe_id}{suffix}").resolve()
338
+ if not str(target).startswith(str(self.pending_dir.resolve())):
339
+ raise ValueError(f"Path traversal blocked for change_id '{change_id}'")
340
+ return target
341
+
342
+ def propose_change(self, change_id: str, key: str, value: Any,
343
+ source_agent: str = "unknown", ttl: Optional[int] = None,
344
+ operation: str = "write") -> dict[str, Any]:
345
+ """
346
+ Stage a change without applying it (Step 1 of atomic commit).
347
+
348
+ The change is written to a .pending file and must be validated
349
+ and committed by the orchestrator before it takes effect.
350
+ """
351
+ pending_file = self._safe_pending_path(change_id)
352
+
353
+ # Check for duplicate change_id
354
+ if pending_file.exists():
355
+ return {
356
+ "success": False,
357
+ "error": f"Change ID '{change_id}' already exists. Use a unique ID."
358
+ }
359
+
360
+ # Get current value for conflict detection
361
+ current_entry = self.cache.get(key)
362
+ current_hash = None
363
+ if current_entry:
364
+ current_hash = hashlib.sha256(
365
+ json.dumps(current_entry, sort_keys=True).encode()
366
+ ).hexdigest()[:16]
367
+
368
+ change_set: dict[str, Any] = {
369
+ "change_id": change_id,
370
+ "operation": operation, # "write" or "delete"
371
+ "key": key,
372
+ "value": value,
373
+ "source_agent": source_agent,
374
+ "ttl": ttl,
375
+ "proposed_at": datetime.now(timezone.utc).isoformat(),
376
+ "status": "pending",
377
+ "base_hash": current_hash, # For conflict detection
378
+ }
379
+
380
+ pending_file.write_text(json.dumps(change_set, indent=2))
381
+
382
+ return {
383
+ "success": True,
384
+ "change_id": change_id,
385
+ "status": "proposed",
386
+ "pending_file": str(pending_file),
387
+ "message": "Change staged. Run 'validate' then 'commit' to apply."
388
+ }
389
+
390
+ def validate_change(self, change_id: str) -> dict[str, Any]:
391
+ """
392
+ Validate a pending change for conflicts (Step 2 of atomic commit).
393
+
394
+ Checks:
395
+ - Change exists
396
+ - No conflicting changes to the same key
397
+ - Base hash matches (data hasn't changed since proposal)
398
+ """
399
+ pending_file = self._safe_pending_path(change_id)
400
+
401
+ if not pending_file.exists():
402
+ return {
403
+ "valid": False,
404
+ "error": f"Change '{change_id}' not found. Was it proposed?"
405
+ }
406
+
407
+ change_set = json.loads(pending_file.read_text())
408
+
409
+ if change_set["status"] != "pending":
410
+ return {
411
+ "valid": False,
412
+ "error": f"Change is in '{change_set['status']}' state, not 'pending'"
413
+ }
414
+
415
+ key = change_set["key"]
416
+ base_hash = change_set["base_hash"]
417
+
418
+ # Check for conflicts: has the key changed since we proposed?
419
+ with blackboard_lock():
420
+ self._load_from_disk()
421
+ current_entry = self.cache.get(key)
422
+
423
+ current_hash = None
424
+ if current_entry:
425
+ current_hash = hashlib.sha256(
426
+ json.dumps(current_entry, sort_keys=True).encode()
427
+ ).hexdigest()[:16]
428
+
429
+ if base_hash != current_hash:
430
+ return {
431
+ "valid": False,
432
+ "conflict": True,
433
+ "error": f"CONFLICT: Key '{key}' was modified since proposal. "
434
+ f"Expected hash {base_hash}, got {current_hash}. "
435
+ "Abort and re-propose with fresh data.",
436
+ "current_value": current_entry
437
+ }
438
+
439
+ # Check for other pending changes to the same key
440
+ conflicts: list[str] = []
441
+ for other_file in self.pending_dir.glob("*.pending.json"):
442
+ if other_file.name == pending_file.name:
443
+ continue
444
+ other_change = json.loads(other_file.read_text())
445
+ if other_change["key"] == key and other_change["status"] == "pending":
446
+ conflicts.append(other_change["change_id"])
447
+
448
+ if conflicts:
449
+ return {
450
+ "valid": False,
451
+ "conflict": True,
452
+ "error": f"CONFLICT: Other pending changes affect key '{key}': {conflicts}. "
453
+ "Resolve conflicts before committing."
454
+ }
455
+
456
+ # Mark as validated
457
+ change_set["status"] = "validated"
458
+ change_set["validated_at"] = datetime.now(timezone.utc).isoformat()
459
+ pending_file.write_text(json.dumps(change_set, indent=2))
460
+
461
+ return {
462
+ "valid": True,
463
+ "change_id": change_id,
464
+ "key": key,
465
+ "status": "validated",
466
+ "message": "No conflicts detected. Ready to commit."
467
+ }
468
+
469
+ def commit_change(self, change_id: str) -> dict[str, Any]:
470
+ """
471
+ Apply a validated change atomically (Step 3 of atomic commit).
472
+ """
473
+ pending_file = self._safe_pending_path(change_id)
474
+
475
+ if not pending_file.exists():
476
+ return {
477
+ "committed": False,
478
+ "error": f"Change '{change_id}' not found."
479
+ }
480
+
481
+ change_set = json.loads(pending_file.read_text())
482
+
483
+ if change_set["status"] != "validated":
484
+ return {
485
+ "committed": False,
486
+ "error": f"Change must be validated first. Current status: {change_set['status']}"
487
+ }
488
+
489
+ # Apply the change atomically
490
+ with blackboard_lock():
491
+ self._load_from_disk()
492
+
493
+ if change_set["operation"] == "delete":
494
+ if change_set["key"] in self.cache:
495
+ del self.cache[change_set["key"]]
496
+ else:
497
+ entry: dict[str, Any] = {
498
+ "key": change_set["key"],
499
+ "value": change_set["value"],
500
+ "source_agent": change_set["source_agent"],
501
+ "timestamp": datetime.now(timezone.utc).isoformat(),
502
+ "ttl": change_set["ttl"],
503
+ "committed_from": change_id
504
+ }
505
+ self.cache[change_set["key"]] = entry
506
+
507
+ self._persist_to_disk()
508
+
509
+ # Archive the committed change
510
+ change_set["status"] = "committed"
511
+ change_set["committed_at"] = datetime.now(timezone.utc).isoformat()
512
+
513
+ safe_id = self._sanitize_change_id(change_id)
514
+ archive_dir = self.pending_dir / "archive"
515
+ archive_dir.mkdir(exist_ok=True)
516
+ archive_file = archive_dir / f"{safe_id}.committed.json"
517
+ archive_file.write_text(json.dumps(change_set, indent=2))
518
+
519
+ # Remove pending file
520
+ pending_file.unlink()
521
+
522
+ return {
523
+ "committed": True,
524
+ "change_id": change_id,
525
+ "key": change_set["key"],
526
+ "operation": change_set["operation"],
527
+ "message": "Change committed atomically."
528
+ }
529
+
530
+ def abort_change(self, change_id: str) -> dict[str, Any]:
531
+ """Abort a pending change without applying it."""
532
+ pending_file = self._safe_pending_path(change_id)
533
+
534
+ if not pending_file.exists():
535
+ return {
536
+ "aborted": False,
537
+ "error": f"Change '{change_id}' not found."
538
+ }
539
+
540
+ change_set = json.loads(pending_file.read_text())
541
+ change_set["status"] = "aborted"
542
+ change_set["aborted_at"] = datetime.now(timezone.utc).isoformat()
543
+
544
+ # Archive the aborted change
545
+ safe_id = self._sanitize_change_id(change_id)
546
+ archive_dir = self.pending_dir / "archive"
547
+ archive_dir.mkdir(exist_ok=True)
548
+ archive_file = archive_dir / f"{safe_id}.aborted.json"
549
+ archive_file.write_text(json.dumps(change_set, indent=2))
550
+
551
+ pending_file.unlink()
552
+
553
+ return {
554
+ "aborted": True,
555
+ "change_id": change_id,
556
+ "key": change_set["key"]
557
+ }
558
+
559
+ def list_pending_changes(self) -> list[dict[str, Any]]:
560
+ """List all pending changes awaiting commit."""
561
+ pending: list[dict[str, Any]] = []
562
+ for pending_file in self.pending_dir.glob("*.pending.json"):
563
+ change_set = json.loads(pending_file.read_text())
564
+ pending.append({
565
+ "change_id": change_set["change_id"],
566
+ "key": change_set["key"],
567
+ "operation": change_set["operation"],
568
+ "source_agent": change_set["source_agent"],
569
+ "status": change_set["status"],
570
+ "proposed_at": change_set["proposed_at"]
571
+ })
572
+ return pending
573
+
574
+ def exists(self, key: str) -> bool:
575
+ """Check if a key exists (and is not expired)."""
576
+ return self.read(key) is not None
577
+
578
+ def list_keys(self) -> list[str]:
579
+ """List all valid (non-expired) keys."""
580
+ valid_keys: list[str] = []
581
+ for key in list(self.cache.keys()):
582
+ if self.read(key) is not None:
583
+ valid_keys.append(key)
584
+ return valid_keys
585
+
586
+ def get_snapshot(self) -> dict[str, dict[str, Any]]:
587
+ """Get a snapshot of all valid entries."""
588
+ snapshot: dict[str, dict[str, Any]] = {}
589
+ for key in list(self.cache.keys()):
590
+ entry = self.read(key)
591
+ if entry is not None:
592
+ snapshot[key] = entry
593
+ return snapshot
594
+
595
+
596
+ def main():
597
+ parser = argparse.ArgumentParser(
598
+ description="Shared Blackboard - Agent Coordination State Manager (Atomic Commit Edition)",
599
+ formatter_class=argparse.RawDescriptionHelpFormatter,
600
+ epilog="""
601
+ Commands:
602
+ write KEY VALUE [--ttl SECONDS] Write a value (with file locking)
603
+ read KEY Read a value
604
+ delete KEY Delete a key
605
+ list List all keys
606
+ snapshot Get full snapshot as JSON
607
+
608
+ Atomic Commit Workflow (for multi-agent safety):
609
+ propose CHANGE_ID KEY VALUE Stage a change (Step 1)
610
+ validate CHANGE_ID Check for conflicts (Step 2)
611
+ commit CHANGE_ID Apply atomically (Step 3)
612
+ abort CHANGE_ID Cancel a pending change
613
+ list-pending Show all pending changes
614
+
615
+ Examples:
616
+ %(prog)s write "task:analysis" '{"status": "running"}'
617
+ %(prog)s write "cache:temp" '{"data": [1,2,3]}' --ttl 3600
618
+
619
+ # Safe multi-agent update:
620
+ %(prog)s propose "chg_001" "order:123" '{"status": "approved"}'
621
+ %(prog)s validate "chg_001"
622
+ %(prog)s commit "chg_001"
623
+ """
624
+ )
625
+
626
+ parser.add_argument(
627
+ "command",
628
+ choices=["write", "read", "delete", "list", "snapshot",
629
+ "propose", "validate", "commit", "abort", "list-pending"],
630
+ help="Command to execute"
631
+ )
632
+ parser.add_argument(
633
+ "key",
634
+ nargs="?",
635
+ help="Key name or Change ID (depending on command)"
636
+ )
637
+ parser.add_argument(
638
+ "value",
639
+ nargs="?",
640
+ help="JSON value (required for write/propose)"
641
+ )
642
+ parser.add_argument(
643
+ "--ttl",
644
+ type=int,
645
+ help="Time-to-live in seconds (for write/propose)"
646
+ )
647
+ parser.add_argument(
648
+ "--agent",
649
+ default="cli",
650
+ help="Source agent ID (for write/propose)"
651
+ )
652
+ parser.add_argument(
653
+ "--json",
654
+ action="store_true",
655
+ help="Output as JSON"
656
+ )
657
+ parser.add_argument(
658
+ "--path",
659
+ type=Path,
660
+ default=BLACKBOARD_PATH,
661
+ help="Path to blackboard file"
662
+ )
663
+
664
+ args = parser.parse_args()
665
+ bb = SharedBlackboard(args.path)
666
+
667
+ try:
668
+ if args.command == "write":
669
+ if not args.key or not args.value:
670
+ print("Error: write requires KEY and VALUE", file=sys.stderr)
671
+ sys.exit(1)
672
+
673
+ try:
674
+ value = json.loads(args.value)
675
+ except json.JSONDecodeError:
676
+ value = args.value
677
+
678
+ entry = bb.write(args.key, value, args.agent, args.ttl)
679
+
680
+ if args.json:
681
+ print(json.dumps(entry, indent=2))
682
+ else:
683
+ print(f"✅ Written: {args.key} (with lock)")
684
+ if args.ttl:
685
+ print(f" TTL: {args.ttl} seconds")
686
+
687
+ elif args.command == "read":
688
+ if not args.key:
689
+ print("Error: read requires KEY", file=sys.stderr)
690
+ sys.exit(1)
691
+
692
+ entry = bb.read(args.key)
693
+
694
+ if entry is None:
695
+ if args.json:
696
+ print("null")
697
+ else:
698
+ print(f"❌ Key not found or expired: {args.key}")
699
+ sys.exit(1)
700
+
701
+ if args.json:
702
+ print(json.dumps(entry, indent=2))
703
+ else:
704
+ print(f"📖 {args.key}:")
705
+ print(f" Value: {json.dumps(entry.get('value'))}")
706
+ print(f" Source: {entry.get('source_agent')}")
707
+ print(f" Timestamp: {entry.get('timestamp')}")
708
+ if entry.get('ttl'):
709
+ print(f" TTL: {entry['ttl']} seconds")
710
+
711
+ elif args.command == "delete":
712
+ if not args.key:
713
+ print("Error: delete requires KEY", file=sys.stderr)
714
+ sys.exit(1)
715
+
716
+ if bb.delete(args.key):
717
+ print(f"✅ Deleted: {args.key}")
718
+ else:
719
+ print(f"❌ Key not found: {args.key}")
720
+ sys.exit(1)
721
+
722
+ elif args.command == "list":
723
+ keys = bb.list_keys()
724
+
725
+ if args.json:
726
+ print(json.dumps(keys, indent=2))
727
+ else:
728
+ if keys:
729
+ print(f"📋 Blackboard keys ({len(keys)}):")
730
+ for key in sorted(keys):
731
+ entry = bb.read(key)
732
+ ttl_info = f" [TTL: {entry['ttl']}s]" if entry and entry.get('ttl') else ""
733
+ print(f" • {key}{ttl_info}")
734
+ else:
735
+ print("📋 Blackboard is empty")
736
+
737
+ elif args.command == "snapshot":
738
+ snapshot = bb.get_snapshot()
739
+ print(json.dumps(snapshot, indent=2))
740
+
741
+ # === ATOMIC COMMIT COMMANDS ===
742
+
743
+ elif args.command == "propose":
744
+ if not args.key or not args.value:
745
+ print("Error: propose requires CHANGE_ID and KEY VALUE", file=sys.stderr)
746
+ print("Usage: propose CHANGE_ID KEY VALUE", file=sys.stderr)
747
+ sys.exit(1)
748
+
749
+ # Parse: propose CHANGE_ID KEY VALUE (key is actually change_id, value is "KEY VALUE")
750
+ parts = args.value.split(" ", 1)
751
+ if len(parts) < 2:
752
+ print("Error: propose requires CHANGE_ID KEY VALUE", file=sys.stderr)
753
+ sys.exit(1)
754
+
755
+ change_id = args.key
756
+ actual_key = parts[0]
757
+ actual_value_str = parts[1] if len(parts) > 1 else "{}"
758
+
759
+ try:
760
+ actual_value = json.loads(actual_value_str)
761
+ except json.JSONDecodeError:
762
+ actual_value = actual_value_str
763
+
764
+ result = bb.propose_change(change_id, actual_key, actual_value, args.agent, args.ttl)
765
+
766
+ if args.json:
767
+ print(json.dumps(result, indent=2))
768
+ else:
769
+ if result["success"]:
770
+ print(f"📝 Change PROPOSED: {change_id}")
771
+ print(f" Key: {actual_key}")
772
+ print(f" Status: pending validation")
773
+ print(f" Next: run 'validate {change_id}'")
774
+ else:
775
+ print(f"❌ Proposal FAILED: {result['error']}")
776
+ sys.exit(1)
777
+
778
+ elif args.command == "validate":
779
+ if not args.key:
780
+ print("Error: validate requires CHANGE_ID", file=sys.stderr)
781
+ sys.exit(1)
782
+
783
+ result = bb.validate_change(args.key)
784
+
785
+ if args.json:
786
+ print(json.dumps(result, indent=2))
787
+ else:
788
+ if result["valid"]:
789
+ print(f"✅ Change VALIDATED: {args.key}")
790
+ print(f" Key: {result['key']}")
791
+ print(f" No conflicts detected")
792
+ print(f" Next: run 'commit {args.key}'")
793
+ else:
794
+ print(f"❌ Validation FAILED: {result['error']}")
795
+ sys.exit(1)
796
+
797
+ elif args.command == "commit":
798
+ if not args.key:
799
+ print("Error: commit requires CHANGE_ID", file=sys.stderr)
800
+ sys.exit(1)
801
+
802
+ result = bb.commit_change(args.key)
803
+
804
+ if args.json:
805
+ print(json.dumps(result, indent=2))
806
+ else:
807
+ if result["committed"]:
808
+ print(f"🎉 Change COMMITTED: {args.key}")
809
+ print(f" Key: {result['key']}")
810
+ print(f" Operation: {result['operation']}")
811
+ else:
812
+ print(f"❌ Commit FAILED: {result['error']}")
813
+ sys.exit(1)
814
+
815
+ elif args.command == "abort":
816
+ if not args.key:
817
+ print("Error: abort requires CHANGE_ID", file=sys.stderr)
818
+ sys.exit(1)
819
+
820
+ result = bb.abort_change(args.key)
821
+
822
+ if args.json:
823
+ print(json.dumps(result, indent=2))
824
+ else:
825
+ if result["aborted"]:
826
+ print(f"🚫 Change ABORTED: {args.key}")
827
+ else:
828
+ print(f"❌ Abort FAILED: {result['error']}")
829
+ sys.exit(1)
830
+
831
+ elif args.command == "list-pending":
832
+ pending = bb.list_pending_changes()
833
+
834
+ if args.json:
835
+ print(json.dumps(pending, indent=2))
836
+ else:
837
+ if pending:
838
+ print(f"📋 Pending changes ({len(pending)}):")
839
+ for p in pending:
840
+ status_icon = "🟡" if p["status"] == "pending" else "🟢"
841
+ print(f" {status_icon} {p['change_id']}: {p['operation']} '{p['key']}'")
842
+ print(f" Agent: {p['source_agent']} | Status: {p['status']}")
843
+ else:
844
+ print("📋 No pending changes")
845
+
846
+ except TimeoutError as e:
847
+ print(f"🔒 LOCK TIMEOUT: {e}", file=sys.stderr)
848
+ sys.exit(1)
849
+
850
+
851
+ if __name__ == "__main__":
852
+ main()