Skip to content

Commit 1ded0a6

Browse files
committed
fix(lifecycle): additive-scene entry point ownership handoff (F-14)
A second CoreAIGameEntryPoint in an additive scene now registers as a standby owner; when the owning scope is destroyed the next live standby is promoted (its facade initialization re-runs) instead of the static CoreAI facade being reset with a live scope still present. Reset only happens when no standby remains. +PlayMode handoff test (two entry points, destroy first, facade still resolves).
1 parent 4c6ed23 commit 1ded0a6

2 files changed

Lines changed: 249 additions & 8 deletions

File tree

Assets/CoreAiUnity/Runtime/Source/Composition/CoreAIGameEntryPoint.cs

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using CoreAI.Ai;
34
using CoreAI.Logging;
45
using VContainer.Unity;
@@ -8,9 +9,16 @@ namespace CoreAI.Composition
89
/// <summary>
910
/// VContainer entry point that initializes and resets the global CoreAI facade.
1011
/// </summary>
12+
/// <remarks>
13+
/// When an additive scene spins up a second entry point, it is registered as a standby
14+
/// owner rather than dropped: if the current owner is disposed (e.g. its scope/scene is
15+
/// unloaded), ownership hands off to the next live standby so the CoreAI facade keeps
16+
/// resolving instead of going stale until someone re-initializes it manually.
17+
/// </remarks>
1118
public sealed class CoreAIGameEntryPoint : IStartable, IDisposable
1219
{
1320
private static readonly object StartGate = new();
21+
private static readonly List<CoreAIGameEntryPoint> StandbyInstances = new();
1422
private static bool _isInitialized;
1523

1624
/// <summary>
@@ -23,6 +31,7 @@ public sealed class CoreAIGameEntryPoint : IStartable, IDisposable
2331
private readonly AgentMemoryPolicy _policy;
2432
private readonly IAgentMemoryStore _memoryStore;
2533
private bool _started;
34+
private bool _isOwner;
2635

2736
/// <summary>Initializes a new instance of CoreAIGameEntryPoint.</summary>
2837
public CoreAIGameEntryPoint(ILog logger, IAiOrchestrationService orchestrator, AgentMemoryPolicy policy,
@@ -37,25 +46,45 @@ public CoreAIGameEntryPoint(ILog logger, IAiOrchestrationService orchestrator, A
3746
/// <summary>Starts the entry point and registers CoreAI services for runtime use.</summary>
3847
public void Start()
3948
{
49+
bool becomeOwner;
50+
4051
lock (StartGate)
4152
{
4253
if (_started)
4354
{
4455
return;
4556
}
4657

58+
_started = true;
59+
4760
if (_isInitialized)
4861
{
49-
_logger.Debug(
50-
"CoreAI already initialized in this process. Duplicate CoreAIGameEntryPoint start skipped.",
51-
LogTag.Composition);
52-
return;
62+
// Another entry point already owns the facade; register as a standby owner
63+
// so we can take over if that owner is later disposed (e.g. additive scene unload).
64+
StandbyInstances.Add(this);
65+
becomeOwner = false;
5366
}
67+
else
68+
{
69+
_isInitialized = true;
70+
_isOwner = true;
71+
becomeOwner = true;
72+
}
73+
}
5474

55-
_isInitialized = true;
56-
_started = true;
75+
if (!becomeOwner)
76+
{
77+
_logger.Debug(
78+
"CoreAI already initialized in this process. This CoreAIGameEntryPoint is registered as a standby owner.",
79+
LogTag.Composition);
80+
return;
5781
}
5882

83+
InitializeAsOwner();
84+
}
85+
86+
private void InitializeAsOwner()
87+
{
5988
CoreAIAgent.Initialize(_orchestrator, _policy, _memoryStore);
6089

6190
_logger.Info(
@@ -76,6 +105,9 @@ public void Start()
76105

77106
public void Dispose()
78107
{
108+
bool wasOwner;
109+
CoreAIGameEntryPoint promoted = null;
110+
79111
lock (StartGate)
80112
{
81113
if (!_started)
@@ -84,17 +116,57 @@ public void Dispose()
84116
}
85117

86118
_started = false;
87-
_isInitialized = false;
119+
wasOwner = _isOwner;
120+
121+
if (wasOwner)
122+
{
123+
_isOwner = false;
124+
_isInitialized = false;
125+
126+
while (StandbyInstances.Count > 0)
127+
{
128+
CoreAIGameEntryPoint candidate = StandbyInstances[0];
129+
StandbyInstances.RemoveAt(0);
130+
131+
// Skip standbys that were disposed while still waiting (never became owner).
132+
if (!candidate._started)
133+
{
134+
continue;
135+
}
136+
137+
candidate._isOwner = true;
138+
_isInitialized = true;
139+
promoted = candidate;
140+
break;
141+
}
142+
}
143+
else
144+
{
145+
StandbyInstances.Remove(this);
146+
}
88147
}
89148

90-
CoreAIAgent.Reset();
149+
if (!wasOwner)
150+
{
151+
return;
152+
}
153+
154+
if (promoted != null)
155+
{
156+
promoted.InitializeAsOwner();
157+
}
158+
else
159+
{
160+
CoreAIAgent.Reset();
161+
}
91162
}
92163

93164
internal static void ResetInitializationGuardForTests()
94165
{
95166
lock (StartGate)
96167
{
97168
_isInitialized = false;
169+
StandbyInstances.Clear();
98170
}
99171
}
100172

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using System.Collections;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using CoreAI.Ai;
6+
using CoreAI.Composition;
7+
using CoreAI.Logging;
8+
using NUnit.Framework;
9+
using UnityEngine;
10+
using UnityEngine.TestTools;
11+
12+
namespace CoreAI.Tests.PlayModeTest
13+
{
14+
/// <summary>
15+
/// Covers F-14: when the owning CoreAIGameEntryPoint is destroyed (e.g. its additive scene is
16+
/// unloaded), a standby entry point registered earlier must be promoted so the CoreAI facade
17+
/// keeps resolving instead of going stale.
18+
/// </summary>
19+
[TestFixture]
20+
public sealed class CoreAIGameEntryPointOwnershipHandoffPlayModeTests
21+
{
22+
private readonly List<GameObject> _spawned = new();
23+
24+
[SetUp]
25+
public void SetUp()
26+
{
27+
CoreAIAgent.Reset();
28+
CoreAIGameEntryPoint.ResetInitializationGuardForTests();
29+
CoreAIGameEntryPoint.AutoBootstrap = false;
30+
}
31+
32+
[TearDown]
33+
public void TearDown()
34+
{
35+
foreach (GameObject go in _spawned)
36+
{
37+
if (go != null)
38+
{
39+
Object.Destroy(go);
40+
}
41+
}
42+
43+
_spawned.Clear();
44+
45+
CoreAIAgent.Reset();
46+
CoreAIGameEntryPoint.ResetInitializationGuardForTests();
47+
CoreAIGameEntryPoint.AutoBootstrap = false;
48+
}
49+
50+
[UnityTest]
51+
public IEnumerator DestroyingOwner_PromotesStandbyEntryPoint_FacadeStaysResolvable()
52+
{
53+
yield return null;
54+
55+
GameObject firstOwnerObject = CreateOwnerGameObject("FirstAdditiveSceneEntryPoint");
56+
GameObject secondOwnerObject = CreateOwnerGameObject("SecondAdditiveSceneEntryPoint");
57+
58+
StubOrchestrator orchestrator1 = new();
59+
AgentMemoryPolicy policy1 = new();
60+
StubMemoryStore memoryStore1 = new();
61+
CoreAIGameEntryPoint first = new(new TestLogger(), orchestrator1, policy1, memoryStore1);
62+
63+
StubOrchestrator orchestrator2 = new();
64+
AgentMemoryPolicy policy2 = new();
65+
StubMemoryStore memoryStore2 = new();
66+
CoreAIGameEntryPoint second = new(new TestLogger(), orchestrator2, policy2, memoryStore2);
67+
68+
// Simulate two additive scenes each spinning up their own composition root.
69+
first.Start();
70+
second.Start();
71+
72+
Assert.AreSame(orchestrator1, CoreAIAgent.Orchestrator,
73+
"The first entry point should own the facade after both are started.");
74+
75+
yield return null;
76+
77+
// Simulate the first additive scene being unloaded: its GameObject and entry point are destroyed.
78+
Object.Destroy(firstOwnerObject);
79+
first.Dispose();
80+
81+
yield return null;
82+
83+
Assert.AreSame(orchestrator2, CoreAIAgent.Orchestrator,
84+
"The facade must hand off to the standby second entry point instead of going stale.");
85+
Assert.AreSame(policy2, CoreAIAgent.Policy);
86+
Assert.AreSame(memoryStore2, CoreAIAgent.MemoryStore);
87+
88+
second.Dispose();
89+
Object.Destroy(secondOwnerObject);
90+
}
91+
92+
private GameObject CreateOwnerGameObject(string name)
93+
{
94+
GameObject go = new(name);
95+
_spawned.Add(go);
96+
return go;
97+
}
98+
99+
private sealed class TestLogger : ILog
100+
{
101+
public void Debug(string message, string tag = null)
102+
{
103+
}
104+
105+
public void Info(string message, string tag = null)
106+
{
107+
}
108+
109+
public void Warn(string message, string tag = null)
110+
{
111+
}
112+
113+
public void Error(string message, string tag = null)
114+
{
115+
}
116+
}
117+
118+
private sealed class StubOrchestrator : IAiOrchestrationService
119+
{
120+
public Task<string> RunTaskAsync(AiTaskRequest task, CancellationToken cancellationToken = default)
121+
{
122+
return Task.FromResult(string.Empty);
123+
}
124+
125+
public async IAsyncEnumerable<LlmStreamChunk> RunStreamingAsync(
126+
AiTaskRequest task,
127+
[System.Runtime.CompilerServices.EnumeratorCancellation]
128+
CancellationToken cancellationToken = default)
129+
{
130+
yield return new LlmStreamChunk { IsDone = true };
131+
await Task.CompletedTask;
132+
}
133+
134+
public void CancelTasks(string cancellationScope)
135+
{
136+
}
137+
}
138+
139+
private sealed class StubMemoryStore : IAgentMemoryStore
140+
{
141+
public bool TryLoad(string roleId, out AgentMemoryState state)
142+
{
143+
state = null;
144+
return false;
145+
}
146+
147+
public void Save(string roleId, AgentMemoryState state)
148+
{
149+
}
150+
151+
public void Clear(string roleId)
152+
{
153+
}
154+
155+
public void ClearChatHistory(string roleId)
156+
{
157+
}
158+
159+
public void AppendChatMessage(string roleId, string role, string content, bool persistToDisk = true)
160+
{
161+
}
162+
163+
public ChatMessage[] GetChatHistory(string roleId, int maxMessages = 0)
164+
{
165+
return System.Array.Empty<ChatMessage>();
166+
}
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)