personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add running tasks table to service manager

Add second table showing ephemeral processes spawned via runner (logs tract).
Tracks tasks from logs/exec events, displays real-time output via logs/line,
and auto-removes on logs/exit. Shows Runtime (not Uptime), MB, CPU%, Last log
age, and truncated log output with stderr color coding.

Tasks sorted by start time (oldest first). Table only appears when tasks are
active. Useful for monitoring observe pipeline handlers (describe, transcribe,
reduce) and ad-hoc processing tasks as they run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+135 -7
+135 -7
think/manage.py
··· 30 30 self.last_log_lines = {} # Maps ref -> (timestamp, stream, line) for most recent log 31 31 self.cpu_cache = {} # Maps pid -> last cpu_percent value 32 32 self.cpu_procs = {} # Maps pid -> Process object for cpu tracking 33 + self.running_tasks = {} # Maps ref -> task info from logs tract 33 34 34 35 def handle_event(self, message: dict) -> None: 35 36 """Process Callosum events. ··· 46 47 self.crashed = message.get("crashed", []) 47 48 self.tasks = message.get("tasks", []) 48 49 49 - # Poll CPU for current services 50 - for svc in self.services: 51 - pid = svc["pid"] 50 + # Poll CPU for current services and tasks 51 + all_pids = [svc["pid"] for svc in self.services] 52 + all_pids.extend([task["pid"] for task in self.running_tasks.values()]) 53 + 54 + for pid in all_pids: 52 55 try: 53 56 if pid not in self.cpu_procs: 54 57 # First time seeing this PID - initialize tracking ··· 84 87 self.status_message = f"Stopped {service} (exit {exit_code})" 85 88 86 89 elif tract == "logs": 87 - if event == "line": 90 + if event == "exec": 91 + # New process started via runner 92 + ref = message.get("ref") 93 + name = message.get("name") 94 + pid = message.get("pid") 95 + cmd = message.get("cmd", []) 96 + if ref and name and pid: 97 + self.running_tasks[ref] = { 98 + "ref": ref, 99 + "name": name, 100 + "pid": pid, 101 + "cmd": cmd, 102 + "start_time": datetime.now(), 103 + } 104 + # Initialize CPU tracking for this task 105 + try: 106 + self.cpu_procs[pid] = psutil.Process(pid) 107 + self.cpu_procs[pid].cpu_percent(interval=None) # Start tracking 108 + except (psutil.NoSuchProcess, psutil.AccessDenied): 109 + pass 110 + 111 + elif event == "line": 88 112 ref = message.get("ref") 89 113 line = message.get("line", "") 90 114 stream = message.get("stream", "stdout") ··· 92 116 self.last_log_lines[ref] = (datetime.now(), stream, line) 93 117 94 118 elif event == "exit": 95 - # Clean up log lines for exited processes 119 + # Process exited - clean up 96 120 ref = message.get("ref") 97 - if ref and ref in self.last_log_lines: 98 - del self.last_log_lines[ref] 121 + if ref: 122 + # Remove from running tasks 123 + if ref in self.running_tasks: 124 + # Clean up CPU tracking for this task's PID 125 + task = self.running_tasks[ref] 126 + pid = task["pid"] 127 + if pid in self.cpu_procs: 128 + del self.cpu_procs[pid] 129 + if pid in self.cpu_cache: 130 + del self.cpu_cache[pid] 131 + del self.running_tasks[ref] 132 + 133 + # Clean up log lines 134 + if ref in self.last_log_lines: 135 + del self.last_log_lines[ref] 99 136 100 137 def format_uptime(self, seconds: int) -> str: 101 138 """Format uptime in human-readable format. ··· 141 178 else: 142 179 return f"{total_seconds // 86400}d" 143 180 181 + def format_runtime(self, start_time: datetime) -> str: 182 + """Format task runtime in human-readable format. 183 + 184 + Args: 185 + start_time: When the task started 186 + 187 + Returns: 188 + Formatted string like "45s", "2m 15s", "1h 5m" 189 + """ 190 + delta = datetime.now() - start_time 191 + total_seconds = int(delta.total_seconds()) 192 + 193 + if total_seconds < 60: 194 + return f"{total_seconds}s" 195 + elif total_seconds < 3600: # Less than 1 hour 196 + mins = total_seconds // 60 197 + secs = total_seconds % 60 198 + return f"{mins}m {secs}s" 199 + else: # 1 hour or more 200 + hours = total_seconds // 3600 201 + mins = (total_seconds % 3600) // 60 202 + return f"{hours}h {mins}m" 203 + 144 204 def get_memory_mb(self, pid: int) -> str: 145 205 """Get process memory in MB, or '-' if unavailable. 146 206 ··· 171 231 return f"{self.cpu_cache[pid]:.0f}" 172 232 return "-" 173 233 234 + def render_tasks_table(self) -> list[str]: 235 + """Render the running tasks table. 236 + 237 + Returns: 238 + List of output lines for the tasks table 239 + """ 240 + if not self.running_tasks: 241 + return [] 242 + 243 + t = self.term 244 + output = [] 245 + 246 + # Section header 247 + count = len(self.running_tasks) 248 + output.append("") 249 + output.append(t.bold + f"Running Tasks ({count})" + t.normal) 250 + output.append("─" * min(80, t.width)) 251 + 252 + # Table header 253 + header = f" {'Task':<15} {'PID':<8} {'Runtime':<12} {'MB':<8} {'%':<6} {'Last':<6} {'Log'}" 254 + output.append(t.bold + header + t.normal) 255 + 256 + # Task rows (sorted by start time, oldest first) 257 + tasks_sorted = sorted( 258 + self.running_tasks.values(), key=lambda x: x["start_time"] 259 + ) 260 + 261 + for task in tasks_sorted: 262 + name = task["name"][:14] 263 + pid = str(task["pid"]) 264 + runtime = self.format_runtime(task["start_time"]) 265 + memory = self.get_memory_mb(task["pid"]) 266 + cpu = self.get_cpu_percent(task["pid"]) 267 + 268 + # Get log line for this task 269 + log_display = "" 270 + log_color = "" 271 + log_age = "" 272 + ref = task["ref"] 273 + if ref in self.last_log_lines: 274 + timestamp, stream, log_line = self.last_log_lines[ref] 275 + log_age = self.format_log_age(timestamp) 276 + # Calculate available width for log text 277 + # Fixed: " " (2) + name (15) + pid (8) + runtime (12) + memory (8) + cpu (6) + age (6) + spaces (6) 278 + fixed_width = 63 279 + available = max(0, t.width - fixed_width) 280 + 281 + if available > 0: 282 + if len(log_line) > available: 283 + log_display = log_line[: available - 3] + "..." 284 + else: 285 + log_display = log_line 286 + 287 + # Color code based on stream 288 + if stream == "stderr": 289 + log_color = t.red 290 + else: 291 + log_color = t.normal 292 + 293 + line = f" {name:<15} {pid:<8} {runtime:<12} {memory:>7} {cpu:>5} {log_age:>5} " 294 + output.append(line + log_color + log_display + t.normal) 295 + 296 + return output 297 + 174 298 def render(self) -> str: 175 299 """Render the entire UI. 176 300 ··· 238 362 output.append(t.dim + " No services running" + t.normal) 239 363 240 364 output.append("") 365 + 366 + # Running tasks table (from logs tract) 367 + tasks_output = self.render_tasks_table() 368 + output.extend(tasks_output) 241 369 242 370 # Crashed services (if any) 243 371 if self.crashed: