@triflux/remote 10.30.1 → 10.31.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.
package/hub/server.mjs CHANGED
@@ -2054,6 +2054,16 @@ export async function startHub({
2054
2054
  }, 60000);
2055
2055
  sessionTimer.unref();
2056
2056
 
2057
+ const synapsePruneTimer = setInterval(() => {
2058
+ try {
2059
+ const { count } = synapseRegistry.pruneExpired();
2060
+ if (count > 0) hubLog.info({ count }, "synapse.prune");
2061
+ } catch (err) {
2062
+ hubLog.warn({ err }, "synapse.prune_failed");
2063
+ }
2064
+ }, 60000);
2065
+ synapsePruneTimer.unref();
2066
+
2057
2067
  // 고아 node.exe 프로세스 + stale spawn 세션 주기적 정리 (5분마다)
2058
2068
  // TFX_DISABLE_ORPHAN_CLEANUP=1 로 비활성 (active SSH/swarm 세션 보호 회피용 hotfix gate)
2059
2069
  const orphanCleanupTimer = setInterval(
@@ -2288,6 +2298,7 @@ export async function startHub({
2288
2298
  router.stopSweeper();
2289
2299
  clearInterval(hitlTimer);
2290
2300
  clearInterval(sessionTimer);
2301
+ clearInterval(synapsePruneTimer);
2291
2302
  clearInterval(rateLimitTimer);
2292
2303
  clearInterval(orphanCleanupTimer);
2293
2304
  if (idleTimer) {
@@ -5,6 +5,7 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
5
5
  const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
6
6
  const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
7
7
  const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
8
+ const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
8
9
  // Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
9
10
  // timeout produces stale false-positives. 5-minute TTL + an `idle` state
10
11
  // distinguishes "alive but inactive" from "presumed dead".
@@ -133,6 +134,7 @@ export function createSynapseRegistry(opts = {}) {
133
134
  remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
134
135
  interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
135
136
  interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
137
+ expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
136
138
  } = opts;
137
139
 
138
140
  const sessions = new Map();
@@ -355,10 +357,89 @@ export function createSynapseRegistry(opts = {}) {
355
357
  return true;
356
358
  }
357
359
 
360
+ // stale/expired 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
361
+ // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
362
+ function pruneExpired(opts2 = {}) {
363
+ if (destroyed) return { removed: [], count: 0 };
364
+
365
+ const cutoff =
366
+ typeof opts2.olderThanMs === "number"
367
+ ? opts2.olderThanMs
368
+ : expireTimeoutMs;
369
+ const removed = [];
370
+ const currentTime = now();
371
+
372
+ for (const [sessionId, session] of sessions) {
373
+ if (
374
+ isLiveStatus(session.status) ||
375
+ currentTime - session.lastHeartbeat <= cutoff
376
+ ) {
377
+ continue;
378
+ }
379
+
380
+ stopMonitor(sessionId);
381
+ sessions.delete(sessionId);
382
+ removed.push(sessionId);
383
+ notifyRemoved(session);
384
+ }
385
+
386
+ if (removed.length > 0) persist();
387
+ return { removed, count: removed.length };
388
+ }
389
+
358
390
  function heartbeat(sessionId, partialMeta = null) {
359
391
  const normalized = normalizeSessionId(sessionId);
360
392
  const session = sessions.get(normalized);
361
- if (!session) return false;
393
+ if (!session) {
394
+ // self-heal은 위치 정보(worktreePath 또는 cwd)가 있는 heartbeat에서만
395
+ // 발동한다. UserPromptSubmit heartbeat는 항상 worktreePath를 보내므로
396
+ // SessionStart register POST drop 복구 경로는 커버되고, taskSummary만 담은
397
+ // 빈약한 heartbeat로 위치 없는 가비지 세션이 register 되는 것은 막는다
398
+ // (그 경우는 기존대로 false → 400/heartbeat failed).
399
+ const hasLocator =
400
+ partialMeta &&
401
+ typeof partialMeta === "object" &&
402
+ !Array.isArray(partialMeta) &&
403
+ ((typeof partialMeta.worktreePath === "string" &&
404
+ partialMeta.worktreePath.length > 0) ||
405
+ (typeof partialMeta.cwd === "string" && partialMeta.cwd.length > 0));
406
+ const canSelfHeal = Boolean(normalized) && hasLocator;
407
+ if (!canSelfHeal) return false;
408
+
409
+ // 미등록 세션 heartbeat = SessionStart register POST가 drop된 경우.
410
+ // self-heal로 register 복구(영구 미등록 방지).
411
+ //
412
+ // sessionKind는 명시값이 없으면 "interactive"로 본다. 실제 복구 경로인
413
+ // heartbeatInteractiveSession은 worktreePath/host/taskSummary만 보내고
414
+ // sessionKind는 생략하는데, sanitizeSession 기본값("headless")으로 떨어지면
415
+ // 복구된 Claude 세션이 30초 local timeout을 받아 idle/5분 TTL을 잃고 곧장
416
+ // stale로 전이돼 getActive()/peer discovery에서 사라진다(이 PRD가 고치려던
417
+ // 원래 증상의 재현). heartbeat 기반 liveness로 살아나는 세션은 interactive다.
418
+ const sanitizedKind =
419
+ typeof partialMeta.sessionKind === "string"
420
+ ? partialMeta.sessionKind
421
+ : "interactive";
422
+ const healed = sanitizeSession(
423
+ {
424
+ ...partialMeta,
425
+ sessionId: normalized,
426
+ sessionKind: sanitizedKind,
427
+ status: "active",
428
+ lastHeartbeat: now(),
429
+ },
430
+ normalized,
431
+ );
432
+ if (!healed) return false;
433
+
434
+ sessions.set(normalized, healed);
435
+ startMonitor(normalized);
436
+ persist();
437
+ emitter?.emit("synapse.session.started", {
438
+ sessionId: normalized,
439
+ session: cloneSession(healed),
440
+ });
441
+ return true;
442
+ }
362
443
 
363
444
  const wasRemote = session.isRemote;
364
445
  const updated = { ...session, lastHeartbeat: now(), status: "active" };
@@ -492,6 +573,7 @@ export function createSynapseRegistry(opts = {}) {
492
573
  return Object.freeze({
493
574
  register,
494
575
  unregister,
576
+ pruneExpired,
495
577
  heartbeat,
496
578
  getActive,
497
579
  getAll,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.30.1",
3
+ "version": "10.31.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",