Claude Code commited on
Commit
ea70674
·
1 Parent(s): 502fbd9

Claude Code: Implement session archival strategy for Cain's agent system:

Browse files
.openclaw/agents/logs/session-archive.jsonl ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ {"timestamp": "2026-03-14T04:45:10.399081+00:00", "threshold_days": 7, "dry_run": true, "action": "archive_sessions", "total_files": 0, "archived_count": 0, "skipped_count": 0, "error_count": 0, "status": "no_sessions_found"}
2
+ {"timestamp": "2026-03-14T04:45:28.188361+00:00", "threshold_days": 7, "dry_run": true, "action": "archive_sessions", "total_files": 0, "archived_count": 0, "skipped_count": 0, "error_count": 0, "status": "no_sessions_found"}
.openclaw/agents/main/sessions/__pycache__/archive_manager.cpython-311.pyc ADDED
Binary file (21.2 kB). View file
 
.openclaw/agents/main/sessions/archive_manager.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Session Archive Manager for Cain's Agent System
4
+
5
+ Manages archival and restoration of agent sessions:
6
+ - Automatically archives sessions older than a configurable threshold
7
+ - Maintains an index of archived sessions
8
+ - Provides restore functionality for recovering archived sessions
9
+ """
10
+ import json
11
+ import os
12
+ import shutil
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Any
16
+
17
+
18
+ # Configuration
19
+ SESSIONS_DIR = Path(__file__).parent
20
+ ARCHIVED_DIR = SESSIONS_DIR / "archived"
21
+ SESSIONS_INDEX = SESSIONS_DIR / "sessions.json"
22
+ ARCHIVED_INDEX = ARCHIVED_DIR / "archived_sessions.json"
23
+ LOG_FILE = Path(__file__).parent.parent.parent / "logs" / "session-archive.jsonl"
24
+
25
+ # Default archival threshold (7 days)
26
+ DEFAULT_ARCHIVE_THRESHOLD_DAYS = 7
27
+
28
+
29
+ class SessionArchiveManager:
30
+ """Manages session archival and restoration"""
31
+
32
+ def __init__(self, threshold_days: int = DEFAULT_ARCHIVE_THRESHOLD_DAYS, dry_run: bool = False):
33
+ """
34
+ Initialize the archive manager.
35
+
36
+ Args:
37
+ threshold_days: Number of days after which a session should be archived
38
+ dry_run: If True, simulate archival without making changes
39
+ """
40
+ self.threshold_days = threshold_days
41
+ self.dry_run = dry_run
42
+ self.cutoff_date = datetime.now(timezone.utc) - timedelta(days=threshold_days)
43
+
44
+ def log(self, event_data: Dict[str, Any]):
45
+ """Log an archival event"""
46
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ event = {
49
+ "timestamp": datetime.now(timezone.utc).isoformat(),
50
+ "threshold_days": self.threshold_days,
51
+ "dry_run": self.dry_run,
52
+ **event_data
53
+ }
54
+
55
+ with open(LOG_FILE, "a") as f:
56
+ f.write(json.dumps(event) + "\n")
57
+
58
+ def load_sessions_index(self) -> Dict[str, Any]:
59
+ """Load the main sessions index file"""
60
+ if not SESSIONS_INDEX.exists():
61
+ return {"sessions": [], "metadata": {"created_at": datetime.now(timezone.utc).isoformat()}}
62
+
63
+ with open(SESSIONS_INDEX) as f:
64
+ return json.load(f)
65
+
66
+ def save_sessions_index(self, data: Dict[str, Any]):
67
+ """Save the main sessions index file (unless dry run)"""
68
+ if self.dry_run:
69
+ return
70
+
71
+ SESSIONS_INDEX.parent.mkdir(parents=True, exist_ok=True)
72
+ with open(SESSIONS_INDEX, "w") as f:
73
+ json.dump(data, f, indent=2)
74
+
75
+ def load_archived_index(self) -> Dict[str, Any]:
76
+ """Load the archived sessions index file"""
77
+ if not ARCHIVED_INDEX.exists():
78
+ return {"archived_sessions": [], "metadata": {"created_at": datetime.now(timezone.utc).isoformat()}}
79
+
80
+ with open(ARCHIVED_INDEX) as f:
81
+ return json.load(f)
82
+
83
+ def save_archived_index(self, data: Dict[str, Any]):
84
+ """Save the archived sessions index file (unless dry run)"""
85
+ if self.dry_run:
86
+ return
87
+
88
+ ARCHIVED_DIR.mkdir(parents=True, exist_ok=True)
89
+ with open(ARCHIVED_INDEX, "w") as f:
90
+ json.dump(data, f, indent=2)
91
+
92
+ def get_session_files(self) -> List[Path]:
93
+ """Get all session .jsonl files in the sessions directory"""
94
+ return list(SESSIONS_DIR.glob("*.jsonl"))
95
+
96
+ def parse_session_timestamp(self, session_file: Path) -> Optional[datetime]:
97
+ """
98
+ Parse the timestamp from a session file.
99
+ First tries to read the first line to get the session timestamp,
100
+ otherwise falls back to file modification time.
101
+ """
102
+ try:
103
+ with open(session_file) as f:
104
+ first_line = f.readline().strip()
105
+ if first_line:
106
+ session_data = json.loads(first_line)
107
+ if "timestamp" in session_data:
108
+ return datetime.fromisoformat(session_data["timestamp"].replace("Z", "+00:00"))
109
+ except (json.JSONDecodeError, KeyError, ValueError):
110
+ pass
111
+
112
+ # Fall back to file modification time
113
+ return datetime.fromtimestamp(session_file.stat().st_mtime, tz=timezone.utc)
114
+
115
+ def archive_session(self, session_file: Path) -> Optional[Dict[str, Any]]:
116
+ """
117
+ Archive a single session file.
118
+
119
+ Returns:
120
+ Archive info dict if successful, None otherwise
121
+ """
122
+ try:
123
+ session_id = session_file.stem
124
+ timestamp = self.parse_session_timestamp(session_file)
125
+
126
+ if timestamp and timestamp >= self.cutoff_date:
127
+ return None # Session is too recent, don't archive
128
+
129
+ archive_path = ARCHIVED_DIR / session_file.name
130
+
131
+ # Check if already archived
132
+ if archive_path.exists():
133
+ return {
134
+ "session_id": session_id,
135
+ "status": "already_archived",
136
+ "timestamp": timestamp.isoformat() if timestamp else "unknown"
137
+ }
138
+
139
+ if not self.dry_run:
140
+ shutil.move(str(session_file), str(archive_path))
141
+
142
+ return {
143
+ "session_id": session_id,
144
+ "status": "archived",
145
+ "timestamp": timestamp.isoformat() if timestamp else "unknown",
146
+ "original_path": str(session_file),
147
+ "archive_path": str(archive_path)
148
+ }
149
+
150
+ except Exception as e:
151
+ return {
152
+ "session_id": session_file.stem,
153
+ "status": "error",
154
+ "error": str(e)
155
+ }
156
+
157
+ def update_main_index(self, archived_sessions: List[Dict[str, Any]]):
158
+ """Remove archived sessions from the main index"""
159
+ index = self.load_sessions_index()
160
+ archived_ids = {s["session_id"] for s in archived_sessions if s.get("status") == "archived"}
161
+
162
+ original_count = len(index.get("sessions", []))
163
+ index["sessions"] = [
164
+ s for s in index.get("sessions", [])
165
+ if s.get("id") not in archived_ids and s.get("session_id") not in archived_ids
166
+ ]
167
+
168
+ # Add metadata about archival
169
+ index.setdefault("metadata", {})["last_archived_at"] = datetime.now(timezone.utc).isoformat()
170
+ index["metadata"]["archived_count"] = len(archived_sessions)
171
+
172
+ self.save_sessions_index(index)
173
+
174
+ return original_count - len(index.get("sessions", []))
175
+
176
+ def update_archived_index(self, archived_sessions: List[Dict[str, Any]]):
177
+ """Add newly archived sessions to the archived index"""
178
+ index = self.load_archived_index()
179
+
180
+ for session_info in archived_sessions:
181
+ if session_info.get("status") == "archived":
182
+ index.setdefault("archived_sessions", []).append({
183
+ "session_id": session_info["session_id"],
184
+ "timestamp": session_info["timestamp"],
185
+ "archived_at": datetime.now(timezone.utc).isoformat(),
186
+ "archive_path": session_info.get("archive_path")
187
+ })
188
+
189
+ index.setdefault("metadata", {})["last_updated"] = datetime.now(timezone.utc).isoformat()
190
+
191
+ self.save_archived_index(index)
192
+
193
+ def archive_sessions(self) -> Dict[str, Any]:
194
+ """
195
+ Main archival function - finds and archives old sessions.
196
+
197
+ Returns:
198
+ Summary of archival operation
199
+ """
200
+ session_files = self.get_session_files()
201
+
202
+ if not session_files:
203
+ result = {
204
+ "action": "archive_sessions",
205
+ "total_files": 0,
206
+ "archived_count": 0,
207
+ "skipped_count": 0,
208
+ "error_count": 0,
209
+ "status": "no_sessions_found"
210
+ }
211
+ self.log(result)
212
+ return result
213
+
214
+ archived_sessions = []
215
+ skipped = 0
216
+ errors = 0
217
+
218
+ for session_file in session_files:
219
+ result = self.archive_session(session_file)
220
+ if result:
221
+ if result.get("status") == "archived":
222
+ archived_sessions.append(result)
223
+ elif result.get("status") == "already_archived":
224
+ skipped += 1
225
+ elif result.get("status") == "error":
226
+ errors += 1
227
+
228
+ # Update indices
229
+ if archived_sessions:
230
+ removed_from_index = self.update_main_index(archived_sessions)
231
+ self.update_archived_index(archived_sessions)
232
+
233
+ result = {
234
+ "action": "archive_sessions",
235
+ "total_files": len(session_files),
236
+ "archived_count": len(archived_sessions),
237
+ "skipped_count": skipped,
238
+ "error_count": errors,
239
+ "removed_from_index": removed_from_index if archived_sessions else 0,
240
+ "status": "success" if archived_sessions or skipped > 0 else "no_sessions_to_archive"
241
+ }
242
+
243
+ self.log(result)
244
+ return result
245
+
246
+ def restore_session(self, session_id: str) -> Dict[str, Any]:
247
+ """
248
+ Restore an archived session back to the main sessions directory.
249
+
250
+ Args:
251
+ session_id: The ID of the session to restore (without .jsonl extension)
252
+
253
+ Returns:
254
+ Result of the restore operation
255
+ """
256
+ archive_file = ARCHIVED_DIR / f"{session_id}.jsonl"
257
+ target_file = SESSIONS_DIR / f"{session_id}.jsonl"
258
+
259
+ if not archive_file.exists():
260
+ result = {
261
+ "action": "restore_session",
262
+ "session_id": session_id,
263
+ "status": "not_found",
264
+ "error": f"Archived session {session_id} not found"
265
+ }
266
+ self.log(result)
267
+ return result
268
+
269
+ if target_file.exists():
270
+ result = {
271
+ "action": "restore_session",
272
+ "session_id": session_id,
273
+ "status": "conflict",
274
+ "error": f"Session file {session_id}.jsonl already exists in main directory"
275
+ }
276
+ self.log(result)
277
+ return result
278
+
279
+ if not self.dry_run:
280
+ shutil.copy(str(archive_file), str(target_file))
281
+
282
+ # Update archived index to mark as restored
283
+ index = self.load_archived_index()
284
+ for session in index.get("archived_sessions", []):
285
+ if session.get("session_id") == session_id:
286
+ session["restored_at"] = datetime.now(timezone.utc).isoformat()
287
+ session["status"] = "restored"
288
+ break
289
+ self.save_archived_index(index)
290
+
291
+ result = {
292
+ "action": "restore_session",
293
+ "session_id": session_id,
294
+ "status": "success",
295
+ "restored_from": str(archive_file),
296
+ "restored_to": str(target_file)
297
+ }
298
+ self.log(result)
299
+ return result
300
+
301
+ def list_archived_sessions(self) -> List[Dict[str, Any]]:
302
+ """List all archived sessions"""
303
+ index = self.load_archived_index()
304
+ return index.get("archived_sessions", [])
305
+
306
+ def get_stats(self) -> Dict[str, Any]:
307
+ """Get statistics about session archival"""
308
+ session_files = self.get_session_files()
309
+ archived_files = list(ARCHIVED_DIR.glob("*.jsonl")) if ARCHIVED_DIR.exists() else []
310
+
311
+ # Count active vs archived
312
+ active_count = len(session_files)
313
+ archived_count = len(archived_files)
314
+
315
+ # Get index stats
316
+ main_index = self.load_sessions_index()
317
+ archived_index = self.load_archived_index()
318
+
319
+ return {
320
+ "active_sessions": active_count,
321
+ "archived_sessions": archived_count,
322
+ "main_index_entries": len(main_index.get("sessions", [])),
323
+ "archived_index_entries": len(archived_index.get("archived_sessions", [])),
324
+ "threshold_days": self.threshold_days,
325
+ "cutoff_date": self.cutoff_date.isoformat(),
326
+ "sessions_dir": str(SESSIONS_DIR),
327
+ "archived_dir": str(ARCHIVED_DIR)
328
+ }
329
+
330
+
331
+ def main():
332
+ """CLI entry point for session archival"""
333
+ import argparse
334
+
335
+ parser = argparse.ArgumentParser(description="Cain Session Archive Manager")
336
+ parser.add_argument("--archive", action="store_true", help="Run archival process")
337
+ parser.add_argument("--restore", metavar="SESSION_ID", help="Restore a session by ID")
338
+ parser.add_argument("--list", action="store_true", help="List archived sessions")
339
+ parser.add_argument("--stats", action="store_true", help="Show archival statistics")
340
+ parser.add_argument("--dry-run", action="store_true", help="Simulate without making changes")
341
+ parser.add_argument("--threshold", type=int, default=DEFAULT_ARCHIVE_THRESHOLD_DAYS,
342
+ help=f"Archive threshold in days (default: {DEFAULT_ARCHIVE_THRESHOLD_DAYS})")
343
+
344
+ args = parser.parse_args()
345
+
346
+ manager = SessionArchiveManager(threshold_days=args.threshold, dry_run=args.dry_run)
347
+
348
+ if args.archive:
349
+ print(f"Running session archival (threshold: {args.threshold} days, dry_run: {args.dry_run})")
350
+ result = manager.archive_sessions()
351
+ print(json.dumps(result, indent=2))
352
+
353
+ elif args.restore:
354
+ print(f"Restoring session: {args.restore} (dry_run: {args.dry_run})")
355
+ result = manager.restore_session(args.restore)
356
+ print(json.dumps(result, indent=2))
357
+
358
+ elif args.list:
359
+ sessions = manager.list_archived_sessions()
360
+ print(f"Archived sessions: {len(sessions)}")
361
+ for session in sessions:
362
+ print(f" - {session.get('session_id')}: {session.get('timestamp')}")
363
+
364
+ elif args.stats:
365
+ stats = manager.get_stats()
366
+ print(json.dumps(stats, indent=2))
367
+
368
+ else:
369
+ parser.print_help()
370
+
371
+
372
+ if __name__ == "__main__":
373
+ main()
.openclaw/cron/README.md CHANGED
@@ -37,6 +37,82 @@ Located in `.openclaw/cron/jobs.json`, following the OpenClaw cron schema:
37
  - **Auto-recovery**: Restarts space if in RUNTIME_ERROR or BUILDING state for >10 minutes
38
  - **Logs**: `.openclaw/logs/health-check.jsonl`
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ## Log Format
41
 
42
  Health checks are logged in JSONL format:
 
37
  - **Auto-recovery**: Restarts space if in RUNTIME_ERROR or BUILDING state for >10 minutes
38
  - **Logs**: `.openclaw/logs/health-check.jsonl`
39
 
40
+ ### session-archive
41
+ - **Schedule**: Weekly on Sunday at 2:00 AM
42
+ - **Purpose**: Automatically archive sessions older than 7 days
43
+ - **Tool**: `session_archive`
44
+ - **Parameters**: `threshold_days: 7`, `dry_run: false`
45
+ - **Logs**: `.openclaw/logs/session-archive.jsonl`
46
+
47
+ ## Session Archival
48
+
49
+ Cain includes an automatic session archival system to keep the active sessions directory clean and performant.
50
+
51
+ ### How It Works
52
+
53
+ 1. **Automatic Archival**: Sessions older than 7 days (configurable) are automatically moved to the archived directory
54
+ 2. **Scheduled Job**: Runs weekly by default (Sunday at 2:00 AM)
55
+ 3. **Index Updates**: Both the main and archived session indices are updated
56
+ 4. **Restore Capability**: Archived sessions can be restored if needed
57
+
58
+ ### Directory Structure
59
+
60
+ ```
61
+ .openclaw/agents/main/sessions/
62
+ ├── sessions.json # Main sessions index (active sessions only)
63
+ ├── session_*.jsonl # Active session files
64
+ └── archived/
65
+ ├── archived_sessions.json # Index of archived sessions
66
+ └── session_*.jsonl # Archived session files
67
+ ```
68
+
69
+ ### Manual Archival Operations
70
+
71
+ Run the archive manager directly:
72
+
73
+ ```bash
74
+ # Archive sessions (dry-run first)
75
+ python3 .openclaw/agents/main/sessions/archive_manager.py --archive --dry-run
76
+
77
+ # Actually archive sessions
78
+ python3 .openclaw/agents/main/sessions/archive_manager.py --archive
79
+
80
+ # List archived sessions
81
+ python3 .openclaw/agents/main/sessions/archive_manager.py --list
82
+
83
+ # Restore a session
84
+ python3 .openclaw/agents/main/sessions/archive_manager.py --restore SESSION_ID
85
+
86
+ # Show statistics
87
+ python3 .openclaw/agents/main/sessions/archive_manager.py --stats
88
+
89
+ # Custom threshold (e.g., 14 days)
90
+ python3 .openclaw/agents/main/sessions/archive_manager.py --archive --threshold 14
91
+ ```
92
+
93
+ ### Archival Log Format
94
+
95
+ ```json
96
+ {"timestamp":"2026-03-14T02:00:00Z","action":"archive_sessions","threshold_days":7,"dry_run":false,"total_files":50,"archived_count":35,"skipped_count":10,"error_count":0,"status":"success"}
97
+ {"timestamp":"2026-03-14T02:00:01Z","action":"restore_session","session_id":"session_abc123","status":"success","restored_from":"/path/to/archived/session_abc123.jsonl"}
98
+ ```
99
+
100
+ ### Configuration
101
+
102
+ To modify the archival behavior, edit `.openclaw/cron/jobs/session-archive.json`:
103
+
104
+ ```json
105
+ {
106
+ "id": "session-archive",
107
+ "schedule": "0 2 * * 0", // Adjust cron schedule
108
+ "enabled": true,
109
+ "params": {
110
+ "threshold_days": 7, // Days before archival
111
+ "dry_run": false // Set true for testing
112
+ }
113
+ }
114
+ ```
115
+
116
  ## Log Format
117
 
118
  Health checks are logged in JSONL format:
.openclaw/cron/__pycache__/executor.cpython-311.pyc ADDED
Binary file (15 kB). View file
 
.openclaw/cron/executor.py CHANGED
@@ -17,12 +17,25 @@ except ImportError:
17
  print("Error: huggingface_hub not installed. Run: pip install huggingface_hub")
18
  sys.exit(1)
19
 
 
 
 
 
 
 
 
 
 
20
  JOBS_FILE = Path(__file__).parent / "jobs.json"
 
21
  LOGS_DIR = Path(__file__).parent.parent / "logs"
22
 
23
  # Space configuration
24
  SPACE_ID = "tao-shen/HuggingClaw-Cain"
25
 
 
 
 
26
 
27
  def get_hf_api():
28
  """Get HuggingFace API instance"""
@@ -87,6 +100,52 @@ def hf_restart_space(space_id: str = SPACE_ID) -> dict:
87
  }
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def log_event(job_id: str, event_data: dict):
91
  """Log an event to the appropriate log file"""
92
  log_file = LOGS_DIR / f"{job_id}.jsonl"
@@ -103,10 +162,26 @@ def log_event(job_id: str, event_data: dict):
103
 
104
 
105
  def load_jobs() -> list:
106
- """Load jobs from configuration file"""
107
- with open(JOBS_FILE) as f:
108
- config = json.load(f)
109
- return config.get("jobs", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
 
112
  def check_condition(status: str, condition: str) -> bool:
@@ -128,49 +203,78 @@ def run_job(job: dict):
128
  print(f"[{datetime.utcnow().isoformat()}] Running job: {job_id}")
129
 
130
  try:
131
- # Get health status
132
- result = hf_space_status(space_id="tao-shen/HuggingClaw-Cain")
133
-
134
- status = result.get("stage", "UNKNOWN")
135
- detail = result.get("detail", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
- log_event(job_id, {
138
- "status": status,
139
- "stage": result.get("stage"),
140
- "detail": detail,
141
- "action": "check"
142
- })
143
 
144
- print(f" Status: {status} - {detail}")
 
145
 
146
- # Check failure condition
147
- if "on_failure" in job:
148
- failure_config = job["on_failure"]
149
- condition = failure_config.get("condition", "")
 
 
150
 
151
- if check_condition(status, condition):
152
- print(f" Failure condition met: {condition}")
153
- log_event(job_id, {
154
- "status": status,
155
- "action": "recovery_triggered",
156
- "condition": condition
157
- })
158
 
159
- # Execute recovery action
160
- if failure_config.get("tool") == "hf_restart_space":
161
- print(" Attempting recovery via hf_restart_space...")
162
- recovery_result = hf_restart_space(space_id="tao-shen/HuggingClaw-Cain")
163
 
 
 
164
  log_event(job_id, {
165
- "action": "recovery_attempt",
166
- "success": recovery_result.get("success", False),
167
- "detail": recovery_result.get("detail", "")
168
  })
169
 
170
- if recovery_result.get("success"):
171
- print(" Recovery initiated successfully")
172
- else:
173
- print(f" Recovery failed: {recovery_result.get('detail')}")
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  except Exception as e:
176
  print(f" Error: {e}")
 
17
  print("Error: huggingface_hub not installed. Run: pip install huggingface_hub")
18
  sys.exit(1)
19
 
20
+ # Add archive manager module path
21
+ sys.path.insert(0, str(Path(__file__).parent.parent / "agents" / "main" / "sessions"))
22
+
23
+ try:
24
+ from archive_manager import SessionArchiveManager
25
+ except ImportError:
26
+ SessionArchiveManager = None
27
+ print("Warning: archive_manager not found. Session archival will be skipped.")
28
+
29
  JOBS_FILE = Path(__file__).parent / "jobs.json"
30
+ JOBS_DIR = Path(__file__).parent / "jobs"
31
  LOGS_DIR = Path(__file__).parent.parent / "logs"
32
 
33
  # Space configuration
34
  SPACE_ID = "tao-shen/HuggingClaw-Cain"
35
 
36
+ # Session archive configuration
37
+ ARCHIVE_MANAGER_PATH = Path(__file__).parent.parent / "agents" / "main" / "sessions" / "archive_manager.py"
38
+
39
 
40
  def get_hf_api():
41
  """Get HuggingFace API instance"""
 
100
  }
101
 
102
 
103
+ def run_session_archive(threshold_days: int = 7, dry_run: bool = False) -> dict:
104
+ """Run session archival process"""
105
+ if SessionArchiveManager is None:
106
+ return {
107
+ "success": False,
108
+ "detail": "SessionArchiveManager not available"
109
+ }
110
+
111
+ try:
112
+ manager = SessionArchiveManager(threshold_days=threshold_days, dry_run=dry_run)
113
+ result = manager.archive_sessions()
114
+
115
+ return {
116
+ "success": result.get("status") in ["success", "no_sessions_to_archive", "no_sessions_found"],
117
+ "detail": result
118
+ }
119
+ except Exception as e:
120
+ return {
121
+ "success": False,
122
+ "detail": str(e)
123
+ }
124
+
125
+
126
+ def restore_session(session_id: str) -> dict:
127
+ """Restore an archived session"""
128
+ if SessionArchiveManager is None:
129
+ return {
130
+ "success": False,
131
+ "detail": "SessionArchiveManager not available"
132
+ }
133
+
134
+ try:
135
+ manager = SessionArchiveManager(dry_run=False)
136
+ result = manager.restore_session(session_id)
137
+
138
+ return {
139
+ "success": result.get("status") == "success",
140
+ "detail": result
141
+ }
142
+ except Exception as e:
143
+ return {
144
+ "success": False,
145
+ "detail": str(e)
146
+ }
147
+
148
+
149
  def log_event(job_id: str, event_data: dict):
150
  """Log an event to the appropriate log file"""
151
  log_file = LOGS_DIR / f"{job_id}.jsonl"
 
162
 
163
 
164
  def load_jobs() -> list:
165
+ """Load jobs from configuration files (both jobs.json and jobs/*.json)"""
166
+ jobs = []
167
+
168
+ # Load from main jobs.json
169
+ if JOBS_FILE.exists():
170
+ with open(JOBS_FILE) as f:
171
+ config = json.load(f)
172
+ jobs.extend(config.get("jobs", []))
173
+
174
+ # Load from jobs/ directory
175
+ if JOBS_DIR.exists():
176
+ for job_file in JOBS_DIR.glob("*.json"):
177
+ try:
178
+ with open(job_file) as f:
179
+ job_config = json.load(f)
180
+ jobs.append(job_config)
181
+ except Exception as e:
182
+ print(f"Warning: Failed to load job from {job_file}: {e}")
183
+
184
+ return jobs
185
 
186
 
187
  def check_condition(status: str, condition: str) -> bool:
 
203
  print(f"[{datetime.utcnow().isoformat()}] Running job: {job_id}")
204
 
205
  try:
206
+ # Check job type and execute accordingly
207
+ tool = job.get("tool")
208
+
209
+ if tool == "session_archive":
210
+ # Session archival job
211
+ params = job.get("params", {})
212
+ threshold_days = params.get("threshold_days", 7)
213
+ dry_run = params.get("dry_run", False)
214
+
215
+ print(f" Archiving sessions older than {threshold_days} days (dry_run: {dry_run})")
216
+ result = run_session_archive(threshold_days=threshold_days, dry_run=dry_run)
217
+
218
+ log_event(job_id, {
219
+ "action": "session_archive",
220
+ "success": result.get("success", False),
221
+ "detail": result.get("detail", {})
222
+ })
223
+
224
+ print(f" Result: {result.get('detail', {}).get('status', 'unknown')}")
225
+
226
+ if result.get("success"):
227
+ detail = result.get("detail", {})
228
+ print(f" Archived: {detail.get('archived_count', 0)}, "
229
+ f"Skipped: {detail.get('skipped_count', 0)}, "
230
+ f"Errors: {detail.get('error_count', 0)}")
231
+ else:
232
+ print(f" Error: {result.get('detail')}")
233
 
234
+ else:
235
+ # Default: HF space status check
236
+ result = hf_space_status(space_id="tao-shen/HuggingClaw-Cain")
 
 
 
237
 
238
+ status = result.get("stage", "UNKNOWN")
239
+ detail = result.get("detail", "")
240
 
241
+ log_event(job_id, {
242
+ "status": status,
243
+ "stage": result.get("stage"),
244
+ "detail": detail,
245
+ "action": "check"
246
+ })
247
 
248
+ print(f" Status: {status} - {detail}")
 
 
 
 
 
 
249
 
250
+ # Check failure condition
251
+ if "on_failure" in job:
252
+ failure_config = job["on_failure"]
253
+ condition = failure_config.get("condition", "")
254
 
255
+ if check_condition(status, condition):
256
+ print(f" Failure condition met: {condition}")
257
  log_event(job_id, {
258
+ "status": status,
259
+ "action": "recovery_triggered",
260
+ "condition": condition
261
  })
262
 
263
+ # Execute recovery action
264
+ if failure_config.get("tool") == "hf_restart_space":
265
+ print(" Attempting recovery via hf_restart_space...")
266
+ recovery_result = hf_restart_space(space_id="tao-shen/HuggingClaw-Cain")
267
+
268
+ log_event(job_id, {
269
+ "action": "recovery_attempt",
270
+ "success": recovery_result.get("success", False),
271
+ "detail": recovery_result.get("detail", "")
272
+ })
273
+
274
+ if recovery_result.get("success"):
275
+ print(" Recovery initiated successfully")
276
+ else:
277
+ print(f" Recovery failed: {recovery_result.get('detail')}")
278
 
279
  except Exception as e:
280
  print(f" Error: {e}")
.openclaw/cron/jobs/session-archive.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "session-archive",
3
+ "schedule": "0 2 * * 0",
4
+ "enabled": true,
5
+ "description": "Weekly archival of sessions older than 7 days to the archived directory",
6
+ "tool": "session_archive",
7
+ "params": {
8
+ "threshold_days": 7,
9
+ "dry_run": false
10
+ },
11
+ "logging": {
12
+ "file": ".openclaw/logs/session-archive.jsonl",
13
+ "format": "jsonl",
14
+ "retention_days": 90
15
+ },
16
+ "on_failure": {
17
+ "notify": true,
18
+ "retry": {
19
+ "max_attempts": 3,
20
+ "delay_minutes": 60
21
+ }
22
+ },
23
+ "metadata": {
24
+ "created_at": "2026-03-14T00:00:00Z",
25
+ "version": "1.0"
26
+ }
27
+ }