@yeaft/webchat-agent 1.0.197 → 1.0.198

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 +1 @@
1
- {"version":"1.0.197"}
1
+ {"version":"1.0.198"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.197",
3
+ "version": "1.0.198",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -254,7 +254,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
254
254
  const replayConversationId = yeaftConversationId;
255
255
  setTimeout(async () => {
256
256
  try {
257
- if (!replaySession) return;
257
+ if (!replaySession || session !== replaySession) return;
258
258
  try {
259
259
  await refreshLiveSessionConfig();
260
260
  } catch (err) {
@@ -265,10 +265,19 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
265
265
  try {
266
266
  const metaRoot = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
267
267
  const meta = loadSessionMeta(join(sessionsRoot(metaRoot), sessionId));
268
- projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
268
+ const workDir = normalizeSessionWorkDir(meta?.workDir);
269
+ if (workDir) {
270
+ const scheduled = scheduleProjectRuntimeLoad(workDir);
271
+ projectRuntime = scheduled && typeof scheduled.then === 'function'
272
+ ? await scheduled
273
+ : scheduled;
274
+ } else {
275
+ activateBaseRuntime(captureRuntimeOwner(replaySession));
276
+ }
269
277
  } catch { /* best-effort project metadata */ }
270
278
  }
271
- const status = mergedStatusForProjectRuntime(projectRuntime);
279
+ if (session !== replaySession) return;
280
+ const status = mergedStatusForProjectRuntime(projectRuntime, replaySession);
272
281
  hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
273
282
  sendSessionEvent({
274
283
  type: 'session_ready',
@@ -591,16 +600,73 @@ const routePromisesByMsgId = new Map();
591
600
  const projectRuntimes = new Map();
592
601
  /** @type {Map<string, Promise<any>>} */
593
602
  const baseRuntimeLoadPromises = new Map();
603
+ let baseRuntime = null;
594
604
  let activeRuntimeKey = BASE_RUNTIME_KEY;
595
605
  let skillReloadTimer = null;
596
606
  let skillReloadRunning = false;
607
+ let skillReloadOwner = null;
608
+ let runtimeGeneration = 0;
609
+ /** @type {import('./session.js').Session | null} */
610
+ let runtimeOwnerSession = null;
611
+ const disconnectedRuntimeMcpManagers = new WeakSet();
612
+
613
+ let createRuntimeSkillManager = createSkillManager;
614
+ let createRuntimeMcpManager = () => new MCPManager();
615
+ let loadRuntimeMcpConfig = loadMCPConfig;
616
+ let loadRuntimeSession = loadSession;
617
+ const runtimeLoaderOwners = new WeakMap();
618
+
619
+ function loaderBelongsToOwner(promise, owner) {
620
+ const tracked = promise ? runtimeLoaderOwners.get(promise) : null;
621
+ return !!tracked
622
+ && tracked.generation === owner?.generation
623
+ && tracked.ownerSession === owner?.ownerSession;
624
+ }
625
+
626
+ function claimRuntimeOwnership(ownerSession) {
627
+ if (!ownerSession) return null;
628
+ runtimeOwnerSession = ownerSession;
629
+ return { generation: runtimeGeneration, ownerSession };
630
+ }
631
+
632
+ function captureRuntimeOwner(ownerSession = session) {
633
+ if (!ownerSession || ownerSession !== session || ownerSession !== runtimeOwnerSession) return null;
634
+ return { generation: runtimeGeneration, ownerSession };
635
+ }
636
+
637
+ function isCurrentRuntimeOwner(owner) {
638
+ return !!owner
639
+ && owner.generation === runtimeGeneration
640
+ && owner.ownerSession === runtimeOwnerSession
641
+ && owner.ownerSession === session;
642
+ }
643
+
644
+ function invalidateRuntimeOwnership() {
645
+ runtimeGeneration += 1;
646
+ runtimeOwnerSession = null;
647
+ }
648
+
649
+ function runtimeBelongsToOwner(runtime, owner) {
650
+ return isCurrentRuntimeOwner(owner)
651
+ && runtime?.generation === owner.generation
652
+ && runtime?.ownerSession === owner.ownerSession;
653
+ }
597
654
 
598
- function replaceSessionMcpTools(mcpManager) {
599
- if (!session?.toolRegistry || typeof session.toolRegistry.replaceMcpTools !== 'function') {
655
+ async function disconnectRuntimeMcpManager(mcpManager) {
656
+ if (!mcpManager || typeof mcpManager.disconnectAll !== 'function') return;
657
+ if (disconnectedRuntimeMcpManagers.has(mcpManager)) return;
658
+ disconnectedRuntimeMcpManagers.add(mcpManager);
659
+ try { await mcpManager.disconnectAll(); } catch { /* best-effort shutdown */ }
660
+ }
661
+
662
+ function replaceSessionMcpTools(owner, mcpManager) {
663
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
664
+ const ownerSession = owner.ownerSession;
665
+ if (!ownerSession.toolRegistry || typeof ownerSession.toolRegistry.replaceMcpTools !== 'function') {
600
666
  return { removed: 0, added: 0, skipped: true };
601
667
  }
602
668
  try {
603
- const result = session.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
669
+ const result = ownerSession.toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools);
604
670
  return { ...result, skipped: false };
605
671
  } catch (err) {
606
672
  console.warn('[Yeaft] hot-swap MCP tools failed:', err?.message || err);
@@ -608,9 +674,10 @@ function replaceSessionMcpTools(mcpManager) {
608
674
  }
609
675
  }
610
676
 
611
- function retargetVpEngines({ skillManager, mcpManager }) {
677
+ function retargetVpEngines(owner, { skillManager, mcpManager }) {
678
+ if (!isCurrentRuntimeOwner(owner)) return;
612
679
  try {
613
- session?.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
680
+ owner.ownerSession.engine?.setRuntimeManagers?.({ skillManager, mcpManager });
614
681
  } catch { /* best-effort default-engine retarget */ }
615
682
  for (const eng of vpEngines.values()) {
616
683
  try {
@@ -619,49 +686,113 @@ function retargetVpEngines({ skillManager, mcpManager }) {
619
686
  }
620
687
  }
621
688
 
622
- function activateBaseRuntime() {
689
+ function reloadRuntimeSkillManager(owner, skillManager, status) {
690
+ if (!isCurrentRuntimeOwner(owner) || typeof skillManager?.load !== 'function') {
691
+ return { changed: false, loaded: 0, errors: [] };
692
+ }
693
+ let result;
694
+ try {
695
+ result = skillManager.load() || {};
696
+ } catch (err) {
697
+ result = { changed: false, loaded: 0, errors: [err?.message || String(err)] };
698
+ }
699
+ if (isCurrentRuntimeOwner(owner) && status) status.skills = skillManager.size || 0;
700
+ return {
701
+ changed: !!result.changed,
702
+ loaded: Number(result.loaded) || 0,
703
+ errors: result.errors || [],
704
+ };
705
+ }
706
+
707
+ function activateBaseRuntime(owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
708
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
709
+ const ownerSession = owner.ownerSession;
710
+ const runtime = baseRuntime && runtimeBelongsToOwner(baseRuntime, owner) ? baseRuntime : null;
711
+ const skillManager = runtime?.skillManager || ownerSession.skillManager;
712
+ const mcpManager = runtime?.mcpManager || ownerSession.mcpManager;
713
+ const status = ownerSession.status || runtime?.status;
714
+ const switchingRuntime = activeRuntimeKey !== BASE_RUNTIME_KEY;
715
+ const reload = reloadSkills && switchingRuntime
716
+ ? reloadRuntimeSkillManager(owner, skillManager, status)
717
+ : { changed: false, loaded: 0, errors: [] };
718
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
623
719
  activeRuntimeKey = BASE_RUNTIME_KEY;
624
- const swap = replaceSessionMcpTools(session?.mcpManager);
625
- retargetVpEngines({
626
- skillManager: session?.skillManager || null,
627
- mcpManager: session?.mcpManager || null,
628
- });
629
- const status = session?.status || null;
720
+ const swap = replaceSessionMcpTools(owner, mcpManager);
721
+ retargetVpEngines(owner, { skillManager: skillManager || null, mcpManager: mcpManager || null });
630
722
  if (status) {
631
- status.skills = session?.skillManager?.size || 0;
723
+ status.skills = skillManager?.size || 0;
632
724
  status.mcpServers = Array.isArray(status.mcpServers) ? status.mcpServers : [];
633
725
  status.mcpFailed = Array.isArray(status.mcpFailed) ? status.mcpFailed : [];
634
- status.tools = session?.toolRegistry?.size || status.tools || 0;
726
+ status.tools = ownerSession.toolRegistry?.size || status.tools || 0;
635
727
  }
636
- broadcastSkillSlashCommands(session);
728
+ if (!isCurrentRuntimeOwner(owner)) return { removed: 0, added: 0, skipped: true };
729
+ broadcastSkillSlashCommands({ skillManager });
730
+ if (switchingRuntime || reload.changed) {
731
+ hydrateYeaftStatusFromSession({ ...ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
732
+ }
733
+ startSkillHotReload(owner);
637
734
  return swap;
638
735
  }
639
736
 
640
- function activateProjectRuntime(runtime) {
641
- if (!runtime) return activateBaseRuntime();
642
- activeRuntimeKey = projectRuntimeKey(runtime.workDir);
643
- const swap = replaceSessionMcpTools(runtime.mcpManager);
644
- retargetVpEngines({
737
+ function activateProjectRuntime(runtime, owner = captureRuntimeOwner(), { reloadSkills = true } = {}) {
738
+ if (!runtime) return activateBaseRuntime(owner, { reloadSkills });
739
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
740
+ const runtimeKey = projectRuntimeKey(runtime.workDir);
741
+ const switchingRuntime = activeRuntimeKey !== runtimeKey;
742
+ const reload = reloadSkills && switchingRuntime
743
+ ? reloadRuntimeSkillManager(owner, runtime.skillManager, runtime.status)
744
+ : { changed: false, loaded: 0, errors: [] };
745
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
746
+ activeRuntimeKey = runtimeKey;
747
+ const swap = replaceSessionMcpTools(owner, runtime.mcpManager);
748
+ retargetVpEngines(owner, {
645
749
  skillManager: runtime.skillManager,
646
750
  mcpManager: runtime.mcpManager,
647
751
  });
648
752
  runtime.status = {
649
753
  ...runtime.status,
650
- tools: session?.toolRegistry?.size || runtime.status?.tools || 0,
754
+ skills: runtime.skillManager?.size || 0,
755
+ tools: owner.ownerSession.toolRegistry?.size || runtime.status?.tools || 0,
651
756
  };
652
- broadcastSkillSlashCommands(session, [runtime.skillManager]);
757
+ if (!runtimeBelongsToOwner(runtime, owner)) return { removed: 0, added: 0, skipped: true };
758
+ // A project manager already contains bundled, user, and project tiers.
759
+ broadcastSkillSlashCommands({ skillManager: runtime.skillManager });
760
+ if (switchingRuntime || reload.changed) {
761
+ const status = mergedStatusForProjectRuntime(runtime, owner.ownerSession);
762
+ hydrateYeaftStatusFromSession({ ...owner.ownerSession, status }, { reason: 'skills_runtime_activate', emitEvent: true });
763
+ }
764
+ startSkillHotReload(owner);
653
765
  return swap;
654
766
  }
655
767
 
656
768
  async function shutdownProjectRuntimes() {
769
+ // Invalidate before the first await so every old continuation is cleanup-only.
770
+ invalidateRuntimeOwnership();
657
771
  stopSkillHotReload();
658
- const runtimes = Array.from(projectRuntimes.values());
772
+ const runtimes = [baseRuntime, ...projectRuntimes.values()].filter(Boolean);
773
+ const loaderPromises = [
774
+ ...baseRuntimeLoadPromises.values(),
775
+ ...projectRuntimeLoadPromises.values(),
776
+ ];
777
+ for (const runtime of runtimes) {
778
+ if (runtime?.previousSkillManager && runtime.ownerSession?.skillManager === runtime.skillManager) {
779
+ runtime.ownerSession.skillManager = runtime.previousSkillManager;
780
+ }
781
+ if (runtime?.previousMcpManager && runtime.ownerSession?.mcpManager === runtime.mcpManager) {
782
+ runtime.ownerSession.mcpManager = runtime.previousMcpManager;
783
+ }
784
+ }
785
+ baseRuntime = null;
659
786
  projectRuntimes.clear();
660
787
  projectRuntimeLoadPromises.clear();
661
788
  baseRuntimeLoadPromises.clear();
662
- await Promise.all(runtimes.map(async (runtime) => {
663
- try { await runtime?.mcpManager?.disconnectAll?.(); } catch { /* best-effort shutdown */ }
664
- }));
789
+ activeRuntimeKey = BASE_RUNTIME_KEY;
790
+ const disconnects = runtimes
791
+ // A loading manager may acquire its first connection after an early
792
+ // disconnect; the stale loader performs the reliable post-connect cleanup.
793
+ .filter(runtime => !runtime?.loading)
794
+ .map(runtime => disconnectRuntimeMcpManager(runtime?.mcpManager));
795
+ await Promise.allSettled([...disconnects, ...loaderPromises]);
665
796
  }
666
797
 
667
798
  function getVpThreadMap(sessionId, vpId) {
@@ -1404,9 +1535,12 @@ export function __testResolveVpEffectiveConfig(sessionId) {
1404
1535
  * @param {{ conversationStore: object } | null} sessionLike
1405
1536
  */
1406
1537
  export function __testSetSession(sessionLike) {
1538
+ invalidateRuntimeOwnership();
1539
+ stopSkillHotReload();
1407
1540
  session = sessionLike;
1408
1541
  sessionLoadPromise = null;
1409
- if (!sessionLike) yeaftConversationId = null;
1542
+ if (sessionLike) claimRuntimeOwnership(sessionLike);
1543
+ else yeaftConversationId = null;
1410
1544
  }
1411
1545
 
1412
1546
  /**
@@ -2237,53 +2371,49 @@ function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
2237
2371
  });
2238
2372
  }
2239
2373
 
2240
- function skillManagersForHotReload() {
2241
- const managers = new Set();
2242
- if (session?.skillManager) managers.add(session.skillManager);
2243
- for (const runtime of projectRuntimes.values()) {
2244
- if (runtime?.skillManager) managers.add(runtime.skillManager);
2374
+ function activeSkillRuntime(owner) {
2375
+ if (!isCurrentRuntimeOwner(owner)) return null;
2376
+ if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2377
+ if (baseRuntime && runtimeBelongsToOwner(baseRuntime, owner)) return baseRuntime;
2378
+ return {
2379
+ generation: owner.generation,
2380
+ ownerSession: owner.ownerSession,
2381
+ skillManager: owner.ownerSession.skillManager,
2382
+ status: owner.ownerSession.status,
2383
+ };
2245
2384
  }
2246
- return [...managers];
2385
+ const runtime = projectRuntimes.get(activeRuntimeKey) || null;
2386
+ return runtimeBelongsToOwner(runtime, owner) ? runtime : null;
2247
2387
  }
2248
2388
 
2249
- function reloadActiveSkills() {
2389
+ function reloadActiveSkills(owner = skillReloadOwner || captureRuntimeOwner()) {
2390
+ if (!isCurrentRuntimeOwner(owner)) return { changed: false, loaded: 0, errors: [] };
2250
2391
  if (skillReloadRunning) return { changed: false, loaded: 0, errors: [] };
2392
+ const runtime = activeSkillRuntime(owner);
2393
+ const manager = runtime?.skillManager;
2394
+ if (typeof manager?.load !== 'function') return { changed: false, loaded: 0, errors: [] };
2395
+
2251
2396
  skillReloadRunning = true;
2252
2397
  try {
2253
- let changed = false;
2254
- let loaded = 0;
2255
- const errors = [];
2256
- const changedManagers = new Set();
2257
- const managers = skillManagersForHotReload();
2258
- for (const manager of managers) {
2259
- if (typeof manager?.load !== 'function') continue;
2260
- const result = manager.load();
2261
- if (result?.changed) {
2262
- changed = true;
2263
- changedManagers.add(manager);
2264
- }
2265
- loaded += Number(result?.loaded) || 0;
2266
- errors.push(...(result?.errors || []));
2398
+ const result = manager.load() || {};
2399
+ const currentRuntime = activeSkillRuntime(owner);
2400
+ if (!isCurrentRuntimeOwner(owner) || currentRuntime?.skillManager !== manager) {
2401
+ return { changed: false, loaded: 0, errors: [] };
2402
+ }
2403
+ const changed = !!result.changed;
2404
+ const loaded = Number(result.loaded) || 0;
2405
+ const errors = result.errors || [];
2406
+ if (runtime.status) runtime.status.skills = manager.size || 0;
2407
+ if (activeRuntimeKey === BASE_RUNTIME_KEY && owner.ownerSession.status) {
2408
+ owner.ownerSession.status.skills = manager.size || 0;
2267
2409
  }
2268
2410
  if (changed) {
2269
- if (session?.status && changedManagers.has(session.skillManager)) {
2270
- session.status.skills = session.skillManager?.size || 0;
2271
- }
2272
- for (const runtime of projectRuntimes.values()) {
2273
- if (runtime?.status && changedManagers.has(runtime.skillManager)) {
2274
- runtime.status.skills = runtime.skillManager?.size || 0;
2275
- }
2276
- }
2277
- if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2278
- broadcastSkillSlashCommands(session);
2279
- } else {
2280
- const runtime = projectRuntimes.get(activeRuntimeKey);
2281
- broadcastSkillSlashCommands(session, runtime?.skillManager ? [runtime.skillManager] : []);
2282
- }
2283
- const activeRuntime = activeRuntimeKey === BASE_RUNTIME_KEY ? null : projectRuntimes.get(activeRuntimeKey);
2284
- const activeStatus = activeRuntime ? mergedStatusForProjectRuntime(activeRuntime) : session?.status;
2411
+ broadcastSkillSlashCommands({ skillManager: manager });
2412
+ const activeStatus = activeRuntimeKey === BASE_RUNTIME_KEY
2413
+ ? runtime.status
2414
+ : mergedStatusForProjectRuntime(runtime, owner.ownerSession);
2285
2415
  hydrateYeaftStatusFromSession(
2286
- activeStatus && session ? { ...session, status: activeStatus } : session,
2416
+ activeStatus ? { ...owner.ownerSession, status: activeStatus } : owner.ownerSession,
2287
2417
  { reason: 'skills_hot_reload', emitEvent: true },
2288
2418
  );
2289
2419
  }
@@ -2296,195 +2426,277 @@ function reloadActiveSkills() {
2296
2426
  }
2297
2427
  }
2298
2428
 
2299
- function startSkillHotReload() {
2300
- if (skillReloadTimer) return;
2301
- skillReloadTimer = setInterval(() => {
2302
- try { reloadActiveSkills(); }
2429
+ function startSkillHotReload(owner = captureRuntimeOwner()) {
2430
+ if (!isCurrentRuntimeOwner(owner)) return false;
2431
+ if (skillReloadTimer && skillReloadOwner
2432
+ && skillReloadOwner.generation === owner.generation
2433
+ && skillReloadOwner.ownerSession === owner.ownerSession) {
2434
+ return false;
2435
+ }
2436
+ stopSkillHotReload();
2437
+ skillReloadOwner = owner;
2438
+ const timer = setInterval(() => {
2439
+ if (!isCurrentRuntimeOwner(owner)) {
2440
+ if (skillReloadTimer === timer) stopSkillHotReload(owner);
2441
+ return;
2442
+ }
2443
+ try { reloadActiveSkills(owner); }
2303
2444
  catch (err) { console.warn('[Yeaft] skill hot reload failed:', err?.message || err); }
2304
2445
  }, SKILL_RELOAD_INTERVAL_MS);
2305
- skillReloadTimer.unref?.();
2446
+ skillReloadTimer = timer;
2447
+ timer.unref?.();
2448
+ return true;
2306
2449
  }
2307
2450
 
2308
- function stopSkillHotReload() {
2309
- if (!skillReloadTimer) return;
2310
- clearInterval(skillReloadTimer);
2451
+ function stopSkillHotReload(owner = null) {
2452
+ if (owner && skillReloadOwner
2453
+ && (skillReloadOwner.generation !== owner.generation
2454
+ || skillReloadOwner.ownerSession !== owner.ownerSession)) {
2455
+ return false;
2456
+ }
2457
+ if (skillReloadTimer) clearInterval(skillReloadTimer);
2311
2458
  skillReloadTimer = null;
2459
+ skillReloadOwner = null;
2312
2460
  skillReloadRunning = false;
2461
+ return true;
2313
2462
  }
2314
2463
 
2315
- async function loadBaseRuntime() {
2316
- if (!session) return null;
2317
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2318
- const skillManager = createSkillManager(yeaftDir, process.cwd());
2319
- session.skillManager = skillManager;
2320
-
2321
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, process.cwd());
2322
- const mcpManager = new MCPManager();
2323
- session.mcpManager = mcpManager;
2464
+ async function loadBaseRuntime(owner = captureRuntimeOwner()) {
2465
+ if (!isCurrentRuntimeOwner(owner)) return null;
2466
+ const ownerSession = owner.ownerSession;
2467
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2468
+ const previousSkillManager = ownerSession.skillManager;
2469
+ const previousMcpManager = ownerSession.mcpManager;
2470
+ const skillManager = createRuntimeSkillManager(yeaftDir, process.cwd());
2471
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, process.cwd());
2472
+ const mcpManager = createRuntimeMcpManager();
2324
2473
  let mcpStatus = { connected: [], failed: [] };
2474
+ const runtime = {
2475
+ generation: owner.generation,
2476
+ ownerSession,
2477
+ workDir: '',
2478
+ previousSkillManager,
2479
+ previousMcpManager,
2480
+ skillManager,
2481
+ mcpManager,
2482
+ mcpStatus,
2483
+ mcpConfig,
2484
+ loading: mcpConfig.servers.length > 0,
2485
+ status: {
2486
+ skills: skillManager.size,
2487
+ mcpServers: [],
2488
+ mcpFailed: [],
2489
+ mcpSkipped: mcpConfig.skipped || [],
2490
+ tools: ownerSession.toolRegistry?.size || 0,
2491
+ },
2492
+ };
2325
2493
 
2326
- if (session.status) {
2327
- session.status.skills = skillManager.size;
2328
- session.status.mcpServers = [];
2329
- session.status.mcpFailed = [];
2330
- session.status.mcpSkipped = mcpConfig.skipped || [];
2331
- session.status.tools = session.toolRegistry?.size || session.status.tools || 0;
2494
+ if (!isCurrentRuntimeOwner(owner)) {
2495
+ await disconnectRuntimeMcpManager(mcpManager);
2496
+ return null;
2332
2497
  }
2498
+ baseRuntime = runtime;
2499
+ ownerSession.skillManager = skillManager;
2500
+ ownerSession.mcpManager = mcpManager;
2501
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2333
2502
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2334
- activateBaseRuntime();
2335
- } else {
2336
- broadcastSkillSlashCommands(session);
2503
+ activateBaseRuntime(owner, { reloadSkills: false });
2504
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_skills', emitEvent: true });
2337
2505
  }
2338
- startSkillHotReload();
2339
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_skills', emitEvent: true });
2340
2506
 
2341
2507
  if (mcpConfig.servers.length > 0) {
2342
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2343
- session.mcpManager = mcpManager;
2344
- if (session.status) {
2345
- session.status.mcpServers = mcpStatus.connected;
2346
- session.status.mcpFailed = mcpStatus.failed;
2347
- session.status.mcpSkipped = mcpConfig.skipped || [];
2508
+ try {
2509
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2510
+ } catch (err) {
2511
+ runtime.loading = false;
2512
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2513
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2514
+ await disconnectRuntimeMcpManager(mcpManager);
2515
+ if (baseRuntime === runtime) baseRuntime = null;
2516
+ throw err;
2517
+ }
2518
+ runtime.loading = false;
2519
+ if (!isCurrentRuntimeOwner(owner) || baseRuntime !== runtime) {
2520
+ if (ownerSession.skillManager === skillManager) ownerSession.skillManager = previousSkillManager;
2521
+ if (ownerSession.mcpManager === mcpManager) ownerSession.mcpManager = previousMcpManager;
2522
+ await disconnectRuntimeMcpManager(mcpManager);
2523
+ return null;
2348
2524
  }
2525
+ runtime.mcpStatus = mcpStatus;
2526
+ runtime.status = {
2527
+ ...runtime.status,
2528
+ mcpServers: mcpStatus.connected,
2529
+ mcpFailed: mcpStatus.failed,
2530
+ mcpSkipped: mcpConfig.skipped || [],
2531
+ tools: ownerSession.toolRegistry?.size || 0,
2532
+ };
2533
+ ownerSession.mcpManager = mcpManager;
2534
+ ownerSession.status = { ...ownerSession.status, ...runtime.status };
2349
2535
  if (activeRuntimeKey === BASE_RUNTIME_KEY) {
2350
- activateBaseRuntime();
2536
+ activateBaseRuntime(owner, { reloadSkills: false });
2537
+ hydrateYeaftStatusFromSession(ownerSession, { reason: 'base_runtime_mcp', emitEvent: true });
2538
+ if (isCurrentRuntimeOwner(owner)) {
2539
+ try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2540
+ }
2351
2541
  }
2352
- hydrateYeaftStatusFromSession(session, { reason: 'base_runtime_mcp', emitEvent: true });
2353
- try { broadcastMcpUpdated({ reason: 'base-runtime-load' }); } catch { /* best-effort */ }
2354
2542
  }
2355
2543
 
2356
- return { skillManager, mcpManager, mcpStatus, mcpConfig };
2544
+ return isCurrentRuntimeOwner(owner) && baseRuntime === runtime ? runtime : null;
2357
2545
  }
2358
2546
 
2359
2547
  function scheduleBaseRuntimeLoad() {
2360
- if (!session) return null;
2361
- if (baseRuntimeLoadPromises.has(BASE_RUNTIME_KEY)) return baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2362
- const promise = new Promise(resolve => setTimeout(resolve, 0))
2363
- .then(() => loadBaseRuntime())
2548
+ const owner = captureRuntimeOwner();
2549
+ if (!owner) return null;
2550
+ const current = baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY);
2551
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2552
+ let promise;
2553
+ promise = new Promise(resolve => setTimeout(resolve, 0))
2554
+ .then(() => loadBaseRuntime(owner))
2364
2555
  .catch((err) => {
2365
2556
  console.warn('[Yeaft] async base runtime load failed:', err?.message || err);
2366
2557
  return null;
2367
2558
  })
2368
- .finally(() => { baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY); });
2559
+ .finally(() => {
2560
+ if (owner.generation === runtimeGeneration
2561
+ && baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) === promise) {
2562
+ baseRuntimeLoadPromises.delete(BASE_RUNTIME_KEY);
2563
+ }
2564
+ });
2565
+ runtimeLoaderOwners.set(promise, owner);
2369
2566
  baseRuntimeLoadPromises.set(BASE_RUNTIME_KEY, promise);
2370
2567
  return promise;
2371
2568
  }
2372
2569
 
2373
- async function loadProjectRuntime(workDir) {
2374
- if (!session) return null;
2570
+ async function loadProjectRuntime(workDir, owner = captureRuntimeOwner()) {
2571
+ if (!isCurrentRuntimeOwner(owner)) return null;
2572
+ const ownerSession = owner.ownerSession;
2375
2573
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2376
2574
  if (!normalizedWorkDir) {
2377
- activateBaseRuntime();
2575
+ activateBaseRuntime(owner);
2378
2576
  return null;
2379
2577
  }
2380
2578
  const key = projectRuntimeKey(normalizedWorkDir);
2381
2579
  const cached = projectRuntimes.get(key);
2382
- if (cached) {
2383
- activateProjectRuntime(cached);
2580
+ if (runtimeBelongsToOwner(cached, owner)) {
2581
+ activateProjectRuntime(cached, owner);
2384
2582
  return cached;
2385
2583
  }
2386
2584
 
2387
- const yeaftDir = ctx.CONFIG?.yeaftDir || session.yeaftDir || DEFAULT_YEAFT_DIR;
2388
- const skillRoots = normalizedWorkDir && normalizedWorkDir !== process.cwd()
2585
+ const yeaftDir = ctx.CONFIG?.yeaftDir || ownerSession.yeaftDir || DEFAULT_YEAFT_DIR;
2586
+ const skillRoots = normalizedWorkDir !== process.cwd()
2389
2587
  ? `${process.cwd()}${delimiter}${normalizedWorkDir}`
2390
2588
  : normalizedWorkDir;
2391
- const skillManager = createSkillManager(yeaftDir, skillRoots);
2392
- const mcpConfig = loadMCPConfig(yeaftDir, undefined, normalizedWorkDir);
2393
- const mcpManager = new MCPManager();
2589
+ const skillManager = createRuntimeSkillManager(yeaftDir, skillRoots);
2590
+ const mcpConfig = loadRuntimeMcpConfig(yeaftDir, undefined, normalizedWorkDir);
2591
+ const mcpManager = createRuntimeMcpManager();
2394
2592
  let mcpStatus = { connected: [], failed: [] };
2395
2593
  const runtime = {
2594
+ generation: owner.generation,
2595
+ ownerSession,
2396
2596
  workDir: normalizedWorkDir,
2397
2597
  skillManager,
2398
2598
  mcpManager,
2399
2599
  mcpStatus,
2400
2600
  mcpConfig,
2601
+ loading: mcpConfig.servers.length > 0,
2401
2602
  status: {
2402
2603
  skills: skillManager.size,
2403
- mcpServers: mcpStatus.connected,
2404
- mcpFailed: mcpStatus.failed,
2604
+ mcpServers: [],
2605
+ mcpFailed: [],
2405
2606
  mcpSkipped: mcpConfig.skipped || [],
2406
- tools: session.toolRegistry?.size || 0,
2607
+ tools: ownerSession.toolRegistry?.size || 0,
2407
2608
  },
2408
2609
  };
2610
+ if (!isCurrentRuntimeOwner(owner)) {
2611
+ await disconnectRuntimeMcpManager(mcpManager);
2612
+ return null;
2613
+ }
2409
2614
  projectRuntimes.set(key, runtime);
2410
- // Skill metadata is available after the filesystem scan; publish it before
2411
- // any MCP server startup so autocomplete does not wait on external processes.
2412
- activateProjectRuntime(runtime);
2413
- startSkillHotReload();
2615
+ // Skill metadata is ready before external MCP startup. Activation is still
2616
+ // owner-gated so reset cannot publish this runtime into a replacement session.
2617
+ activateProjectRuntime(runtime, owner, { reloadSkills: false });
2414
2618
  if (mcpConfig.servers.length > 0) {
2415
- mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2619
+ try {
2620
+ mcpStatus = await mcpManager.connectAll(mcpConfig.servers);
2621
+ } catch (err) {
2622
+ runtime.loading = false;
2623
+ await disconnectRuntimeMcpManager(mcpManager);
2624
+ if (projectRuntimes.get(key) === runtime) projectRuntimes.delete(key);
2625
+ throw err;
2626
+ }
2627
+ runtime.loading = false;
2628
+ if (!runtimeBelongsToOwner(runtime, owner) || projectRuntimes.get(key) !== runtime) {
2629
+ await disconnectRuntimeMcpManager(mcpManager);
2630
+ return null;
2631
+ }
2416
2632
  runtime.mcpStatus = mcpStatus;
2417
2633
  runtime.status = {
2418
2634
  ...runtime.status,
2419
2635
  mcpServers: mcpStatus.connected,
2420
2636
  mcpFailed: mcpStatus.failed,
2421
2637
  mcpSkipped: mcpConfig.skipped || [],
2422
- tools: session.toolRegistry?.size || 0,
2638
+ tools: ownerSession.toolRegistry?.size || 0,
2423
2639
  };
2424
- activateProjectRuntime(runtime);
2640
+ if (activeRuntimeKey === key) activateProjectRuntime(runtime, owner, { reloadSkills: false });
2425
2641
  }
2426
- return runtime;
2642
+ return runtimeBelongsToOwner(runtime, owner) && projectRuntimes.get(key) === runtime ? runtime : null;
2427
2643
  }
2428
2644
 
2429
-
2430
2645
  function scheduleProjectRuntimeLoad(workDir) {
2646
+ const owner = captureRuntimeOwner();
2431
2647
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
2432
- if (!normalizedWorkDir || !session) return null;
2648
+ if (!normalizedWorkDir || !owner) return null;
2433
2649
  const key = projectRuntimeKey(normalizedWorkDir);
2434
- if (projectRuntimes.has(key)) return projectRuntimes.get(key);
2435
- if (projectRuntimeLoadPromises.has(key)) return projectRuntimeLoadPromises.get(key);
2436
- const promise = loadProjectRuntime(normalizedWorkDir)
2650
+ const cached = projectRuntimes.get(key);
2651
+ if (runtimeBelongsToOwner(cached, owner)) return cached;
2652
+ const current = projectRuntimeLoadPromises.get(key);
2653
+ if (current && loaderBelongsToOwner(current, owner)) return current;
2654
+ let promise;
2655
+ promise = loadProjectRuntime(normalizedWorkDir, owner)
2437
2656
  .catch((err) => {
2438
2657
  console.warn('[Yeaft] async project runtime load failed for %s: %s', normalizedWorkDir, err?.message || err);
2439
2658
  return null;
2440
2659
  })
2441
- .finally(() => { projectRuntimeLoadPromises.delete(key); });
2660
+ .finally(() => {
2661
+ if (owner.generation === runtimeGeneration
2662
+ && projectRuntimeLoadPromises.get(key) === promise) {
2663
+ projectRuntimeLoadPromises.delete(key);
2664
+ }
2665
+ });
2666
+ runtimeLoaderOwners.set(promise, owner);
2442
2667
  projectRuntimeLoadPromises.set(key, promise);
2443
2668
  return promise;
2444
2669
  }
2445
2670
 
2446
- async function ensureProjectRuntimeForSessionMeta(sessionMeta) {
2447
- const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2448
- if (!workDir) {
2449
- activateBaseRuntime();
2450
- return null;
2451
- }
2452
- try {
2453
- return await loadProjectRuntime(workDir);
2454
- } catch (err) {
2455
- console.warn('[Yeaft] project runtime load failed for %s: %s', workDir, err?.message || err);
2456
- activateBaseRuntime();
2457
- return null;
2458
- }
2459
- }
2460
-
2461
2671
  function getProjectRuntimeForTurn(sessionMeta) {
2672
+ const owner = captureRuntimeOwner();
2673
+ if (!owner) return null;
2462
2674
  const workDir = normalizeSessionWorkDir(sessionMeta?.workDir);
2463
2675
  if (!workDir) {
2464
- activateBaseRuntime();
2676
+ activateBaseRuntime(owner);
2465
2677
  return null;
2466
2678
  }
2467
2679
  const cached = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
2468
- if (cached) {
2469
- activateProjectRuntime(cached);
2680
+ if (runtimeBelongsToOwner(cached, owner)) {
2681
+ activateProjectRuntime(cached, owner);
2470
2682
  return cached;
2471
2683
  }
2472
2684
  scheduleProjectRuntimeLoad(workDir);
2473
2685
  // Do not let a previous workDir's MCP tools leak into this turn while the
2474
2686
  // requested project runtime is still loading in the background.
2475
- activateBaseRuntime();
2687
+ activateBaseRuntime(owner);
2476
2688
  return null;
2477
2689
  }
2478
2690
 
2479
- function mergedStatusForProjectRuntime(runtime) {
2480
- if (!session?.status || !runtime?.status) return session?.status || { skills: 0, mcpServers: [], tools: 0 };
2691
+ function mergedStatusForProjectRuntime(runtime, ownerSession = session) {
2692
+ if (!ownerSession?.status || !runtime?.status) return ownerSession?.status || { skills: 0, mcpServers: [], tools: 0 };
2481
2693
  return {
2482
- ...session.status,
2483
- skills: Math.max(Number(session.status.skills) || 0, Number(runtime.status.skills) || 0),
2484
- mcpServers: [...new Set([...(session.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2485
- mcpFailed: [...(session.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2486
- mcpSkipped: [...(session.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2487
- tools: Math.max(Number(session.status.tools) || 0, Number(runtime.status.tools) || 0),
2694
+ ...ownerSession.status,
2695
+ skills: Math.max(Number(ownerSession.status.skills) || 0, Number(runtime.status.skills) || 0),
2696
+ mcpServers: [...new Set([...(ownerSession.status.mcpServers || []), ...(runtime.status.mcpServers || [])])],
2697
+ mcpFailed: [...(ownerSession.status.mcpFailed || []), ...(runtime.status.mcpFailed || [])],
2698
+ mcpSkipped: [...(ownerSession.status.mcpSkipped || []), ...(runtime.status.mcpSkipped || [])],
2699
+ tools: Math.max(Number(ownerSession.status.tools) || 0, Number(runtime.status.tools) || 0),
2488
2700
  };
2489
2701
  }
2490
2702
 
@@ -4331,13 +4543,14 @@ export async function ensureSessionLoaded(opts = {}) {
4331
4543
  const bootConfigRevision = sessionConfigRefreshRevision;
4332
4544
  const yeaftDir = ctx.CONFIG?.yeaftDir;
4333
4545
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
4334
- session = await loadSession({
4546
+ session = await loadRuntimeSession({
4335
4547
  ...(yeaftDir && { dir: yeaftDir }),
4336
4548
  ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
4337
4549
  skipMCP: true,
4338
4550
  skipSkills: true,
4339
4551
  serverMode: true,
4340
4552
  });
4553
+ claimRuntimeOwnership(session);
4341
4554
 
4342
4555
  installYeaftRuntimeBridge(session);
4343
4556
  // A save may complete after loadSession read config.json but before this
@@ -4372,13 +4585,15 @@ export async function ensureSessionLoaded(opts = {}) {
4372
4585
 
4373
4586
  ensureYeaftConversationId();
4374
4587
  scheduleBaseRuntimeLoad();
4375
- const bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4588
+ let bootProjectRuntime = normalizedWorkDir ? projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null : null;
4376
4589
  if (normalizedWorkDir && !bootProjectRuntime) {
4377
4590
  scheduleProjectRuntimeLoad(normalizedWorkDir);
4591
+ bootProjectRuntime = projectRuntimes.get(projectRuntimeKey(normalizedWorkDir)) || null;
4378
4592
  }
4379
4593
  const bootStatus = mergedStatusForProjectRuntime(bootProjectRuntime);
4380
4594
  hydrateYeaftStatusFromSession({ ...session, status: bootStatus }, { reason: 'session_ready', emitEvent: true });
4381
- broadcastSkillSlashCommands(session, bootProjectRuntime ? [bootProjectRuntime.skillManager] : []);
4595
+ if (bootProjectRuntime) broadcastSkillSlashCommands({ skillManager: bootProjectRuntime.skillManager });
4596
+ else broadcastSkillSlashCommands(session);
4382
4597
 
4383
4598
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
4384
4599
  // — there's no global "all conversations" tape any more.
@@ -4415,6 +4630,7 @@ export async function ensureSessionLoaded(opts = {}) {
4415
4630
  try {
4416
4631
  return await sessionLoadPromise;
4417
4632
  } catch (err) {
4633
+ await shutdownProjectRuntimes();
4418
4634
  session = null;
4419
4635
  throw err;
4420
4636
  } finally {
@@ -6453,7 +6669,10 @@ export async function handleYeaftLoadMoreHistory(msg) {
6453
6669
  * session, then re-initialises so the frontend gets fresh config.
6454
6670
  */
6455
6671
  export async function resetYeaftSession() {
6456
- await shutdownProjectRuntimes();
6672
+ // Runtime ownership must be revoked before reset reaches its first await.
6673
+ const oldSession = session;
6674
+ const oldRuntimesShutdown = shutdownProjectRuntimes();
6675
+ await oldRuntimesShutdown;
6457
6676
  if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
6458
6677
  try { currentAbortCtrl.abort(); } catch { /* ignore */ }
6459
6678
  }
@@ -6462,9 +6681,9 @@ export async function resetYeaftSession() {
6462
6681
  try { _vpUnsubscribe(); } catch { /* ignore */ }
6463
6682
  _vpUnsubscribe = null;
6464
6683
  }
6465
- if (session) {
6466
- await session.shutdown();
6467
- session = null;
6684
+ if (oldSession) {
6685
+ await oldSession.shutdown();
6686
+ if (session === oldSession) session = null;
6468
6687
  }
6469
6688
  yeaftConversationId = null;
6470
6689
  // Per-group histories live on sessionContexts entries — clearing the
@@ -6508,12 +6727,13 @@ export async function resetYeaftSession() {
6508
6727
 
6509
6728
  try {
6510
6729
  const yeaftDir = ctx.CONFIG?.yeaftDir;
6511
- session = await loadSession({
6730
+ session = await loadRuntimeSession({
6512
6731
  ...(yeaftDir && { dir: yeaftDir }),
6513
6732
  skipMCP: true,
6514
6733
  skipSkills: true,
6515
6734
  serverMode: true,
6516
6735
  });
6736
+ claimRuntimeOwnership(session);
6517
6737
  installYeaftRuntimeBridge(session);
6518
6738
 
6519
6739
  yeaftConversationId = `yeaft-${Date.now()}`;
@@ -6600,7 +6820,7 @@ function mcpRuntimeSnapshot() {
6600
6820
  * pick up the change.
6601
6821
  */
6602
6822
  function hotSwapMcpTools() {
6603
- return replaceSessionMcpTools(session?.mcpManager);
6823
+ return replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6604
6824
  }
6605
6825
 
6606
6826
  /**
@@ -6659,7 +6879,7 @@ export async function handleYeaftMcpAdd(msg = {}) {
6659
6879
  }
6660
6880
  }
6661
6881
 
6662
- const swap = replaceSessionMcpTools(session?.mcpManager);
6882
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6663
6883
 
6664
6884
  sendToServer({
6665
6885
  type: 'yeaft_mcp_add_result',
@@ -6696,7 +6916,7 @@ export async function handleYeaftMcpRemove(msg = {}) {
6696
6916
  }
6697
6917
  }
6698
6918
 
6699
- const swap = replaceSessionMcpTools(session?.mcpManager);
6919
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6700
6920
 
6701
6921
  sendToServer({
6702
6922
  type: 'yeaft_mcp_remove_result',
@@ -6754,7 +6974,7 @@ export async function handleYeaftMcpReload(msg = {}) {
6754
6974
  console.warn('[Yeaft] MCP reload failed:', err?.message || err);
6755
6975
  }
6756
6976
 
6757
- const swap = replaceSessionMcpTools(session?.mcpManager);
6977
+ const swap = replaceSessionMcpTools(captureRuntimeOwner(), session?.mcpManager);
6758
6978
 
6759
6979
  sendToServer({
6760
6980
  type: 'yeaft_mcp_reload_result',
@@ -6777,8 +6997,56 @@ export const __testHooks = {
6777
6997
  return runYeaftSessionSend(msg);
6778
6998
  },
6779
6999
  setSessionForTest(nextSession) {
6780
- session = nextSession || null;
6781
- sessionLoadPromise = null;
7000
+ __testSetSession(nextSession || null);
7001
+ },
7002
+ setRuntimeFactoriesForTest({
7003
+ createSkillManager: nextCreateSkillManager,
7004
+ createMcpManager: nextCreateMcpManager,
7005
+ loadMcpConfig: nextLoadMcpConfig,
7006
+ loadSession: nextLoadSession,
7007
+ } = {}) {
7008
+ if (typeof nextCreateSkillManager === 'function') createRuntimeSkillManager = nextCreateSkillManager;
7009
+ if (typeof nextCreateMcpManager === 'function') createRuntimeMcpManager = nextCreateMcpManager;
7010
+ if (typeof nextLoadMcpConfig === 'function') loadRuntimeMcpConfig = nextLoadMcpConfig;
7011
+ if (typeof nextLoadSession === 'function') loadRuntimeSession = nextLoadSession;
7012
+ },
7013
+ resetRuntimeFactoriesForTest() {
7014
+ createRuntimeSkillManager = createSkillManager;
7015
+ createRuntimeMcpManager = () => new MCPManager();
7016
+ loadRuntimeMcpConfig = loadMCPConfig;
7017
+ loadRuntimeSession = loadSession;
7018
+ },
7019
+ scheduleBaseRuntimeLoadForTest() {
7020
+ return scheduleBaseRuntimeLoad();
7021
+ },
7022
+ scheduleProjectRuntimeLoadForTest(workDir) {
7023
+ return scheduleProjectRuntimeLoad(workDir);
7024
+ },
7025
+ activateBaseRuntimeForTest() {
7026
+ return activateBaseRuntime();
7027
+ },
7028
+ activateProjectRuntimeForTest(workDir) {
7029
+ const runtime = projectRuntimes.get(projectRuntimeKey(workDir)) || null;
7030
+ return activateProjectRuntime(runtime);
7031
+ },
7032
+ startSkillHotReloadForTest() {
7033
+ return startSkillHotReload();
7034
+ },
7035
+ runtimeLifecycleSnapshotForTest(workDir = '') {
7036
+ const key = workDir ? projectRuntimeKey(workDir) : null;
7037
+ const owner = captureRuntimeOwner();
7038
+ return {
7039
+ generation: runtimeGeneration,
7040
+ ownerSession: runtimeOwnerSession,
7041
+ activeRuntimeKey,
7042
+ timerActive: !!skillReloadTimer,
7043
+ timerOwnerGeneration: skillReloadOwner?.generation ?? null,
7044
+ timerOwnerSession: skillReloadOwner?.ownerSession || null,
7045
+ basePromise: baseRuntimeLoadPromises.get(BASE_RUNTIME_KEY) || null,
7046
+ projectPromise: key ? projectRuntimeLoadPromises.get(key) || null : null,
7047
+ projectRuntime: key ? projectRuntimes.get(key) || null : null,
7048
+ activeSkillManager: activeSkillRuntime(owner)?.skillManager || null,
7049
+ };
6782
7050
  },
6783
7051
  ensureYeaftConversationIdForTest() {
6784
7052
  return ensureYeaftConversationId();
@@ -6852,7 +7120,10 @@ export const __testHooks = {
6852
7120
  },
6853
7121
  seedProjectRuntime(workDir, runtime) {
6854
7122
  const normalizedWorkDir = normalizeSessionWorkDir(workDir);
7123
+ const owner = captureRuntimeOwner();
6855
7124
  const seeded = {
7125
+ generation: owner?.generation ?? runtimeGeneration,
7126
+ ownerSession: owner?.ownerSession || session,
6856
7127
  workDir: normalizedWorkDir,
6857
7128
  skillManager: runtime?.skillManager || { list: () => [] },
6858
7129
  mcpManager: runtime?.mcpManager || { listTools: () => [], disconnectAll: async () => {} },