@orgloop/agentctl 1.2.1 → 1.4.0

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.
@@ -60,17 +60,25 @@ export async function startDaemon(opts = {}) {
60
60
  emitter,
61
61
  });
62
62
  const sessionTracker = new SessionTracker(state, { adapters });
63
- const metrics = new MetricsRegistry(sessionTracker, lockManager, fuseEngine);
63
+ const metrics = new MetricsRegistry(lockManager, fuseEngine);
64
64
  // Wire up events
65
- emitter.on("fuse.fired", () => {
66
- metrics.recordFuseFired();
65
+ emitter.on("fuse.expired", () => {
66
+ metrics.recordFuseExpired();
67
67
  });
68
- // 9. Validate all sessions on startup mark dead ones as stopped (#40)
69
- sessionTracker.validateAllSessions();
68
+ // 9. Initial PID liveness cleanup for daemon-launched sessions
69
+ // (replaces the old validateAllSessions — much simpler, only checks launches)
70
+ const initialDead = sessionTracker.cleanupDeadLaunches();
71
+ if (initialDead.length > 0) {
72
+ for (const id of initialDead)
73
+ lockManager.autoUnlock(id);
74
+ console.error(`Startup cleanup: marked ${initialDead.length} dead launches as stopped`);
75
+ }
70
76
  // 10. Resume fuse timers
71
77
  fuseEngine.resumeTimers();
72
- // 11. Start session polling
73
- sessionTracker.startPolling();
78
+ // 11. Start periodic PID liveness check for lock cleanup (30s interval)
79
+ sessionTracker.startLaunchCleanup((deadId) => {
80
+ lockManager.autoUnlock(deadId);
81
+ });
74
82
  // 12. Create request handler
75
83
  const handleRequest = createRequestHandler({
76
84
  sessionTracker,
@@ -140,7 +148,7 @@ export async function startDaemon(opts = {}) {
140
148
  });
141
149
  // Shutdown function
142
150
  const shutdown = async () => {
143
- sessionTracker.stopPolling();
151
+ sessionTracker.stopLaunchCleanup();
144
152
  fuseEngine.shutdown();
145
153
  state.flush();
146
154
  await state.persist();
@@ -247,20 +255,104 @@ function createRequestHandler(ctx) {
247
255
  const params = (req.params || {});
248
256
  switch (req.method) {
249
257
  case "session.list": {
250
- let sessions = ctx.sessionTracker.listSessions({
251
- status: params.status,
252
- all: params.all,
253
- });
254
- if (params.group) {
255
- sessions = sessions.filter((s) => s.group === params.group);
258
+ const adapterFilter = params.adapter;
259
+ const statusFilter = params.status;
260
+ const showAll = params.all;
261
+ const groupFilter = params.group;
262
+ // Fan out discover() to adapters (or just one if filtered)
263
+ const adapterEntries = adapterFilter
264
+ ? Object.entries(ctx.adapters).filter(([name]) => name === adapterFilter)
265
+ : Object.entries(ctx.adapters);
266
+ const ADAPTER_TIMEOUT_MS = 5000;
267
+ const succeededAdapters = new Set();
268
+ const results = await Promise.allSettled(adapterEntries.map(([name, adapter]) => Promise.race([
269
+ adapter.discover().then((sessions) => {
270
+ succeededAdapters.add(name);
271
+ return sessions.map((s) => ({ ...s, adapter: name }));
272
+ }),
273
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Adapter ${name} timed out`)), ADAPTER_TIMEOUT_MS)),
274
+ ])));
275
+ // Merge fulfilled results, skip failed adapters
276
+ const discovered = results
277
+ .filter((r) => r.status === "fulfilled")
278
+ .flatMap((r) => r.value);
279
+ // Reconcile with launch metadata and enrich
280
+ const { sessions: allSessions, stoppedLaunchIds } = ctx.sessionTracker.reconcileAndEnrich(discovered, succeededAdapters);
281
+ // Release locks for sessions that disappeared from adapter results
282
+ for (const id of stoppedLaunchIds) {
283
+ ctx.lockManager.autoUnlock(id);
284
+ }
285
+ // Apply filters
286
+ let sessions = allSessions;
287
+ if (statusFilter) {
288
+ sessions = sessions.filter((s) => s.status === statusFilter);
256
289
  }
290
+ else if (!showAll) {
291
+ sessions = sessions.filter((s) => s.status === "running" || s.status === "idle");
292
+ }
293
+ if (groupFilter) {
294
+ sessions = sessions.filter((s) => s.group === groupFilter);
295
+ }
296
+ // Sort: running first, then by most recent
297
+ sessions.sort((a, b) => {
298
+ if (a.status === "running" && b.status !== "running")
299
+ return -1;
300
+ if (b.status === "running" && a.status !== "running")
301
+ return 1;
302
+ return (new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
303
+ });
304
+ // Update metrics gauge
305
+ ctx.metrics.setActiveSessionCount(allSessions.filter((s) => s.status === "running" || s.status === "idle").length);
257
306
  return sessions;
258
307
  }
259
308
  case "session.status": {
260
- const session = ctx.sessionTracker.getSession(params.id);
261
- if (!session)
262
- throw new Error(`Session not found: ${params.id}`);
263
- return session;
309
+ const id = params.id;
310
+ // Check launch metadata to determine adapter
311
+ const launchRecord = ctx.sessionTracker.getSession(id);
312
+ const adapterName = params.adapter || launchRecord?.adapter;
313
+ // Determine which adapters to search
314
+ const adaptersToSearch = adapterName
315
+ ? Object.entries(ctx.adapters).filter(([name]) => name === adapterName)
316
+ : Object.entries(ctx.adapters);
317
+ // Search adapters for the session
318
+ for (const [name, adapter] of adaptersToSearch) {
319
+ try {
320
+ const discovered = await adapter.discover();
321
+ let match = discovered.find((d) => d.id === id);
322
+ // Prefix match
323
+ if (!match) {
324
+ const prefixMatches = discovered.filter((d) => d.id.startsWith(id));
325
+ if (prefixMatches.length === 1)
326
+ match = prefixMatches[0];
327
+ }
328
+ if (match) {
329
+ const meta = ctx.sessionTracker.getSession(match.id);
330
+ return {
331
+ id: match.id,
332
+ adapter: name,
333
+ status: match.status,
334
+ startedAt: match.startedAt?.toISOString() ?? new Date().toISOString(),
335
+ stoppedAt: match.stoppedAt?.toISOString(),
336
+ cwd: match.cwd ?? meta?.cwd,
337
+ model: match.model ?? meta?.model,
338
+ prompt: match.prompt ?? meta?.prompt,
339
+ tokens: match.tokens,
340
+ cost: match.cost,
341
+ pid: match.pid,
342
+ spec: meta?.spec,
343
+ group: meta?.group,
344
+ meta: match.nativeMetadata ?? meta?.meta ?? {},
345
+ };
346
+ }
347
+ }
348
+ catch {
349
+ // Adapter failed — try next
350
+ }
351
+ }
352
+ // Fall back to launch metadata if adapters didn't find it
353
+ if (launchRecord)
354
+ return launchRecord;
355
+ throw new Error(`Session not found: ${id}`);
264
356
  }
265
357
  case "session.peek": {
266
358
  // Auto-detect adapter from tracked session, fall back to param or claude-code
@@ -315,48 +407,57 @@ function createRequestHandler(ctx) {
315
407
  return record;
316
408
  }
317
409
  case "session.stop": {
318
- const session = ctx.sessionTracker.getSession(params.id);
319
- if (!session)
320
- throw new Error(`Session not found: ${params.id}`);
410
+ const id = params.id;
411
+ const launchRecord = ctx.sessionTracker.getSession(id);
321
412
  // Ghost pending entry with dead PID: remove from state with --force
322
- if (session.id.startsWith("pending-") &&
413
+ if (launchRecord?.id.startsWith("pending-") &&
323
414
  params.force &&
324
- session.pid &&
325
- !isProcessAlive(session.pid)) {
326
- ctx.lockManager.autoUnlock(session.id);
327
- ctx.sessionTracker.removeSession(session.id);
415
+ launchRecord.pid &&
416
+ !isProcessAlive(launchRecord.pid)) {
417
+ ctx.lockManager.autoUnlock(launchRecord.id);
418
+ ctx.sessionTracker.removeSession(launchRecord.id);
328
419
  return null;
329
420
  }
330
- const adapter = ctx.adapters[session.adapter];
421
+ const adapterName = params.adapter || launchRecord?.adapter;
422
+ if (!adapterName)
423
+ throw new Error(`Session not found: ${id}. Specify --adapter to stop a non-daemon session.`);
424
+ const adapter = ctx.adapters[adapterName];
331
425
  if (!adapter)
332
- throw new Error(`Unknown adapter: ${session.adapter}`);
333
- await adapter.stop(session.id, {
426
+ throw new Error(`Unknown adapter: ${adapterName}`);
427
+ const sessionId = launchRecord?.id || id;
428
+ await adapter.stop(sessionId, {
334
429
  force: params.force,
335
430
  });
336
431
  // Remove auto-lock
337
- ctx.lockManager.autoUnlock(session.id);
338
- // Mark stopped and start fuse if applicable
339
- const stopped = ctx.sessionTracker.onSessionExit(session.id);
432
+ ctx.lockManager.autoUnlock(sessionId);
433
+ // Mark stopped in launch metadata
434
+ const stopped = ctx.sessionTracker.onSessionExit(sessionId);
340
435
  if (stopped) {
341
- ctx.fuseEngine.onSessionExit(stopped);
342
436
  ctx.metrics.recordSessionStopped();
343
437
  }
344
438
  return null;
345
439
  }
346
440
  case "session.resume": {
347
- const session = ctx.sessionTracker.getSession(params.id);
348
- if (!session)
349
- throw new Error(`Session not found: ${params.id}`);
350
- const adapter = ctx.adapters[session.adapter];
441
+ const id = params.id;
442
+ const launchRecord = ctx.sessionTracker.getSession(id);
443
+ const adapterName = params.adapter || launchRecord?.adapter;
444
+ if (!adapterName)
445
+ throw new Error(`Session not found: ${id}. Specify --adapter to resume a non-daemon session.`);
446
+ const adapter = ctx.adapters[adapterName];
351
447
  if (!adapter)
352
- throw new Error(`Unknown adapter: ${session.adapter}`);
353
- await adapter.resume(session.id, params.message);
448
+ throw new Error(`Unknown adapter: ${adapterName}`);
449
+ await adapter.resume(launchRecord?.id || id, params.message);
354
450
  return null;
355
451
  }
356
- // --- Prune command (#40) ---
452
+ // --- Prune command (#40) --- kept for CLI backward compat
357
453
  case "session.prune": {
358
- const pruned = ctx.sessionTracker.pruneDeadSessions();
359
- return { pruned };
454
+ // In the stateless model, there's no session registry to prune.
455
+ // Clean up dead launches (PID liveness check) as a best-effort action.
456
+ const deadIds = ctx.sessionTracker.cleanupDeadLaunches();
457
+ for (const id of deadIds) {
458
+ ctx.lockManager.autoUnlock(id);
459
+ }
460
+ return { pruned: deadIds.length };
360
461
  }
361
462
  case "lock.list":
362
463
  return ctx.lockManager.listAll();
@@ -367,6 +468,21 @@ function createRequestHandler(ctx) {
367
468
  return null;
368
469
  case "fuse.list":
369
470
  return ctx.fuseEngine.listActive();
471
+ case "fuse.set":
472
+ ctx.fuseEngine.setFuse({
473
+ directory: params.directory,
474
+ sessionId: params.sessionId,
475
+ ttlMs: params.ttlMs,
476
+ onExpire: params.onExpire,
477
+ label: params.label,
478
+ });
479
+ return null;
480
+ case "fuse.extend": {
481
+ const extended = ctx.fuseEngine.extendFuse(params.directory, params.ttlMs);
482
+ if (!extended)
483
+ throw new Error(`No active fuse for directory: ${params.directory}`);
484
+ return null;
485
+ }
370
486
  case "fuse.cancel":
371
487
  ctx.fuseEngine.cancelFuse(params.directory);
372
488
  return null;
@@ -374,7 +490,7 @@ function createRequestHandler(ctx) {
374
490
  return {
375
491
  pid: process.pid,
376
492
  uptime: Date.now() - startTime,
377
- sessions: ctx.sessionTracker.activeCount(),
493
+ sessions: ctx.metrics.activeSessionCount,
378
494
  locks: ctx.lockManager.listAll().length,
379
495
  fuses: ctx.fuseEngine.listActive().length,
380
496
  };
@@ -1,61 +1,60 @@
1
- import type { AgentAdapter, AgentSession } from "../core/types.js";
1
+ import type { AgentAdapter, AgentSession, DiscoveredSession } from "../core/types.js";
2
2
  import type { SessionRecord, StateManager } from "./state.js";
3
3
  export interface SessionTrackerOpts {
4
4
  adapters: Record<string, AgentAdapter>;
5
- pollIntervalMs?: number;
6
5
  /** Override PID liveness check for testing (default: process.kill(pid, 0)) */
7
6
  isProcessAlive?: (pid: number) => boolean;
8
7
  }
8
+ /**
9
+ * Simplified session tracker for the stateless daemon core (ADR 004).
10
+ *
11
+ * Adapters own session truth. The daemon only tracks:
12
+ * - Launch metadata (prompt, group, spec, cwd) for sessions launched via agentctl
13
+ * - Locks and fuses (handled by LockManager / FuseEngine)
14
+ *
15
+ * The old polling loop, pruning, and state-based session registry are removed.
16
+ * session.list now fans out adapter.discover() at call time.
17
+ */
9
18
  export declare class SessionTracker {
10
19
  private state;
11
20
  private adapters;
12
- private pollIntervalMs;
13
- private pollHandle;
14
- private polling;
15
21
  private readonly isProcessAlive;
22
+ private cleanupHandle;
16
23
  constructor(state: StateManager, opts: SessionTrackerOpts);
17
- startPolling(): void;
18
- /** Run poll() with a guard to skip if the previous cycle is still running */
19
- private guardedPoll;
20
- stopPolling(): void;
21
- private poll;
22
24
  /**
23
- * Clean up ghost sessions in the daemon state:
24
- * - pending-* entries whose PID matches a resolved session remove pending
25
- * - Any "running"/"idle" session in state whose PID is dead → mark stopped
25
+ * Start periodic PID liveness check for daemon-launched sessions.
26
+ * This is a lightweight check (no adapter fan-out) that runs every 30s
27
+ * to detect dead sessions and return their IDs for lock cleanup.
26
28
  */
27
- private reapStaleEntries;
28
- /**
29
- * Validate all sessions on daemon startup (#40).
30
- * Any session marked as "running" or "idle" whose PID is dead gets
31
- * immediately marked as "stopped". This prevents unbounded growth of
32
- * ghost sessions across daemon restarts.
33
- */
34
- validateAllSessions(): void;
35
- /**
36
- * Aggressively prune all clearly-dead sessions (#40).
37
- * Returns the number of sessions pruned.
38
- * Called via `agentctl prune` command.
39
- */
40
- pruneDeadSessions(): number;
41
- /**
42
- * Remove stopped sessions from state that have been stopped for more than 7 days.
43
- * This reduces overhead from accumulating hundreds of historical sessions.
44
- */
45
- private pruneOldSessions;
46
- /** Track a newly launched session */
29
+ startLaunchCleanup(onDead?: (sessionId: string) => void): void;
30
+ stopLaunchCleanup(): void;
31
+ /** Track a newly launched session (stores launch metadata in state) */
47
32
  track(session: AgentSession, adapterName: string): SessionRecord;
48
- /** Get session record by id (exact or prefix) */
33
+ /** Get session launch metadata by id (exact or prefix match) */
49
34
  getSession(id: string): SessionRecord | undefined;
50
- /** List all tracked sessions */
51
- listSessions(opts?: {
52
- status?: string;
53
- all?: boolean;
54
- adapter?: string;
55
- }): SessionRecord[];
56
- activeCount(): number;
57
- /** Remove a session from state entirely (used for ghost cleanup) */
35
+ /** Remove a session from launch metadata */
58
36
  removeSession(sessionId: string): void;
59
- /** Called when a session stops — returns the cwd for fuse/lock processing */
37
+ /** Called when a session stops — marks it in launch metadata, returns the record */
60
38
  onSessionExit(sessionId: string): SessionRecord | undefined;
39
+ /**
40
+ * Merge adapter-discovered sessions with daemon launch metadata.
41
+ *
42
+ * 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
43
+ * 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
44
+ * succeeded but didn't return them (and they're past the grace period).
45
+ * 3. Include recently-launched sessions that adapters haven't discovered yet.
46
+ *
47
+ * Returns the merged session list and IDs of sessions that were marked stopped
48
+ * (for lock cleanup by the caller).
49
+ */
50
+ reconcileAndEnrich(discovered: DiscoveredSession[], succeededAdapters: Set<string>): {
51
+ sessions: SessionRecord[];
52
+ stoppedLaunchIds: string[];
53
+ };
54
+ /**
55
+ * Check PID liveness for daemon-launched sessions.
56
+ * Returns IDs of sessions whose PIDs have died.
57
+ * This is a lightweight check (no adapter fan-out) for lock cleanup.
58
+ */
59
+ cleanupDeadLaunches(): string[];
61
60
  }