@pie-players/pie-assessment-toolkit 0.3.69 → 0.3.70

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.
@@ -29,6 +29,18 @@ import { ToolRegistry } from "./ToolRegistry.js";
29
29
  import { ToolRequestRegistry } from "./tool-request.js";
30
30
  import { ToolPolicyEngine, } from "../policy/engine.js";
31
31
  import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
32
+ class ToolkitCoordinatorDisposedError extends Error {
33
+ constructor() {
34
+ super("ToolkitCoordinator has been disposed.");
35
+ this.name = "ToolkitCoordinatorDisposedError";
36
+ }
37
+ }
38
+ class SectionControllerRetiredError extends Error {
39
+ constructor() {
40
+ super("Section controller acquisition was retired during initialization.");
41
+ this.name = "SectionControllerRetiredError";
42
+ }
43
+ }
32
44
  const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
33
45
  const mergeToolConfigUpdate = (toolId, current, updates) => {
34
46
  const next = { ...current, ...updates };
@@ -120,7 +132,8 @@ export class ToolkitCoordinator {
120
132
  toolRequests = new ToolRequestRegistry();
121
133
  sectionControllers = new Map();
122
134
  sectionControllerKeys = new Map();
123
- sectionControllerInitPromises = new Map();
135
+ sectionControllerInitEntries = new Map();
136
+ sectionControllerDisposePromises = new Map();
124
137
  sectionPersistenceStrategies = new Map();
125
138
  sectionControllerLifecycleListeners = new Set();
126
139
  /**
@@ -153,14 +166,10 @@ export class ToolkitCoordinator {
153
166
  telemetryListeners = new Set();
154
167
  frameworkErrorBus;
155
168
  ownsFrameworkErrorBus;
169
+ frameworkErrorHookUnsubscribe = null;
170
+ disposePromise = null;
156
171
  /**
157
- * Unified Tool Policy Engine. Owned by the coordinator and lives
158
- * for the lifetime of the coordinator instance — there is no
159
- * explicit teardown path today; the engine and its listener set
160
- * are reclaimed by GC when the coordinator becomes unreachable.
161
- * Subscribers attached via {@link onPolicyChange} must therefore
162
- * detach via the unsubscribe function the engine returns; do not
163
- * rely on a `disposed` event being emitted on coordinator teardown.
172
+ * Unified Tool Policy Engine. Owned by the coordinator and disposed with it.
164
173
  *
165
174
  * Hosts read decisions via {@link decideToolPolicy} or subscribe
166
175
  * to changes via {@link onPolicyChange}.
@@ -298,11 +307,18 @@ export class ToolkitCoordinator {
298
307
  });
299
308
  if (!this.lazyInit) {
300
309
  void this.waitUntilReady().catch((err) => {
310
+ if (err instanceof ToolkitCoordinatorDisposedError)
311
+ return;
301
312
  console.error("[ToolkitCoordinator] Failed eager initialization:", err);
302
313
  this.handleError(err, { phase: "coordinator-ready" });
303
314
  });
304
315
  }
305
316
  }
317
+ assertNotDisposed() {
318
+ if (this.disposePromise !== null) {
319
+ throw new ToolkitCoordinatorDisposedError();
320
+ }
321
+ }
306
322
  /**
307
323
  * Subscribe the canonical `onFrameworkError` hook adapter to the
308
324
  * framework-error bus.
@@ -312,17 +328,18 @@ export class ToolkitCoordinator {
312
328
  * is picked up automatically without re-subscribing.
313
329
  */
314
330
  subscribeFrameworkErrorHookAdapters() {
315
- this.frameworkErrorBus.subscribeFrameworkErrors((model) => {
316
- const hook = this.hooks.onFrameworkError;
317
- if (!hook)
318
- return;
319
- try {
320
- hook(model);
321
- }
322
- catch (hookError) {
323
- console.warn("[ToolkitCoordinator] onFrameworkError hook failed:", hookError);
324
- }
325
- });
331
+ this.frameworkErrorHookUnsubscribe =
332
+ this.frameworkErrorBus.subscribeFrameworkErrors((model) => {
333
+ const hook = this.hooks.onFrameworkError;
334
+ if (!hook)
335
+ return;
336
+ try {
337
+ hook(model);
338
+ }
339
+ catch (hookError) {
340
+ console.warn("[ToolkitCoordinator] onFrameworkError hook failed:", hookError);
341
+ }
342
+ });
326
343
  }
327
344
  /**
328
345
  * Emit a telemetry event to `onTelemetry` hook + all `subscribeTelemetry`
@@ -491,6 +508,7 @@ export class ToolkitCoordinator {
491
508
  }
492
509
  try {
493
510
  const state = await loader();
511
+ this.assertNotDisposed();
494
512
  if (state && typeof state === "object") {
495
513
  this.elementToolStateStore.loadState(state);
496
514
  }
@@ -500,6 +518,8 @@ export class ToolkitCoordinator {
500
518
  });
501
519
  }
502
520
  catch (err) {
521
+ if (err instanceof ToolkitCoordinatorDisposedError)
522
+ throw err;
503
523
  this.handleError(err, { phase: "state-load" });
504
524
  }
505
525
  })().finally(() => {
@@ -601,23 +621,31 @@ export class ToolkitCoordinator {
601
621
  }
602
622
  }
603
623
  async ensureProviderReady(providerId) {
624
+ this.assertNotDisposed();
604
625
  const existing = this.providerInitPromises.get(providerId);
605
626
  if (existing)
606
627
  return existing;
607
628
  const promise = (async () => {
608
629
  const provider = await this.toolProviderRegistry.getProvider(providerId, false);
630
+ this.assertNotDisposed();
609
631
  const meta = {
610
632
  providerId,
611
633
  providerName: provider.providerName,
612
634
  };
613
635
  try {
614
636
  await this.hooks.onProviderInitStart?.(providerId, meta);
637
+ this.assertNotDisposed();
615
638
  await this.toolProviderRegistry.initialize(providerId);
639
+ this.assertNotDisposed();
616
640
  await this.hooks.onProviderReady?.(providerId, meta);
641
+ this.assertNotDisposed();
617
642
  await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
643
+ this.assertNotDisposed();
618
644
  return provider;
619
645
  }
620
646
  catch (err) {
647
+ if (err instanceof ToolkitCoordinatorDisposedError)
648
+ throw err;
621
649
  const error = err instanceof Error ? err : new Error(String(err));
622
650
  this.handleError(error, { phase: "provider-init", providerId });
623
651
  throw error;
@@ -631,7 +659,9 @@ export class ToolkitCoordinator {
631
659
  setHooks(hooks) {
632
660
  Object.assign(this.hooks, hooks);
633
661
  this.setupStatePersistenceHooks();
634
- if (hooks.onCoordinatorReady && this.isReady()) {
662
+ if (this.disposePromise === null &&
663
+ hooks.onCoordinatorReady &&
664
+ this.isReady()) {
635
665
  void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
636
666
  this.handleError(err, { phase: "coordinator-ready" });
637
667
  });
@@ -939,6 +969,9 @@ export class ToolkitCoordinator {
939
969
  };
940
970
  }
941
971
  async getOrCreateSectionController(args) {
972
+ if (this.disposePromise !== null) {
973
+ throw new ToolkitCoordinatorDisposedError();
974
+ }
942
975
  const key = {
943
976
  assessmentId: this.assessmentId,
944
977
  sectionId: args.sectionId,
@@ -953,24 +986,50 @@ export class ToolkitCoordinator {
953
986
  input: args.input,
954
987
  updateExisting: args.updateExisting,
955
988
  });
956
- if (existingController)
989
+ if (existingController) {
990
+ if (this.disposePromise !== null) {
991
+ throw new ToolkitCoordinatorDisposedError();
992
+ }
957
993
  return existingController;
958
- const existingPromise = this.sectionControllerInitPromises.get(mapKey);
959
- if (existingPromise)
960
- return existingPromise;
994
+ }
995
+ const pendingDisposal = this.sectionControllerDisposePromises.get(mapKey);
996
+ if (pendingDisposal) {
997
+ // Persistence and hydration share the cohort's durable state. Keep the
998
+ // retired controller out of the cache immediately, but do not let its
999
+ // replacement hydrate until that exact cohort's save/dispose pipeline has
1000
+ // settled. Other cohorts remain independent.
1001
+ await Promise.allSettled([pendingDisposal]);
1002
+ }
1003
+ if (this.disposePromise !== null) {
1004
+ throw new ToolkitCoordinatorDisposedError();
1005
+ }
1006
+ const existingEntry = this.sectionControllerInitEntries.get(mapKey);
1007
+ if (existingEntry)
1008
+ return existingEntry.promise;
1009
+ const token = {
1010
+ retired: false,
1011
+ candidateClaimed: false,
1012
+ };
961
1013
  const initPromise = this.initializeNewSectionController({
962
1014
  args,
963
1015
  key,
964
1016
  mapKey,
1017
+ token,
965
1018
  })
966
1019
  .catch((err) => {
967
1020
  this.handleSectionControllerInitError(err, args);
968
1021
  throw err;
969
1022
  })
970
1023
  .finally(() => {
971
- this.sectionControllerInitPromises.delete(mapKey);
1024
+ if (this.sectionControllerInitEntries.get(mapKey)?.token === token) {
1025
+ this.sectionControllerInitEntries.delete(mapKey);
1026
+ }
1027
+ });
1028
+ this.sectionControllerInitEntries.set(mapKey, {
1029
+ key,
1030
+ token,
1031
+ promise: initPromise,
972
1032
  });
973
- this.sectionControllerInitPromises.set(mapKey, initPromise);
974
1033
  return initPromise;
975
1034
  }
976
1035
  async resolveExistingSectionController(args) {
@@ -984,6 +1043,11 @@ export class ToolkitCoordinator {
984
1043
  // without resetting responses.
985
1044
  await existingController.updateInput?.(args.input);
986
1045
  }
1046
+ // `updateInput` may yield to a teardown. Never hand a controller back
1047
+ // after its exact cache entry has been removed or replaced.
1048
+ if (this.sectionControllers.get(args.mapKey) !== existingController) {
1049
+ return undefined;
1050
+ }
987
1051
  // PIE-512 Phase D: a `getOrCreateSectionController` call that
988
1052
  // resolves to a previously-created controller still represents a
989
1053
  // cohort transition from the toolkit's perspective (same-cohort
@@ -1007,28 +1071,94 @@ export class ToolkitCoordinator {
1007
1071
  input: args.args.input,
1008
1072
  });
1009
1073
  const persistence = await this.resolveSectionPersistence(context);
1074
+ if (this.disposePromise !== null || args.token.retired) {
1075
+ if (this.sectionPersistenceStrategies.get(args.mapKey) === persistence) {
1076
+ this.sectionPersistenceStrategies.delete(args.mapKey);
1077
+ }
1078
+ throw this.createSectionControllerRetirementError();
1079
+ }
1010
1080
  const defaults = {
1011
1081
  createDefaultController: args.args.createDefaultController,
1012
1082
  };
1013
1083
  const controller = (await this.hooks.createSectionController?.(context, defaults)) ??
1014
1084
  (await defaults.createDefaultController());
1015
- await controller.configureSessionPersistence?.({
1016
- strategy: persistence,
1017
- context,
1018
- });
1019
- await controller.initialize?.(args.args.input);
1020
- await controller.hydrate?.();
1021
- await this.finalizeSectionControllerReady({
1085
+ const candidate = {
1022
1086
  mapKey: args.mapKey,
1023
1087
  key: args.key,
1024
- context,
1025
1088
  controller,
1026
- });
1027
- return controller;
1089
+ persistence,
1090
+ token: args.token,
1091
+ };
1092
+ try {
1093
+ await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1094
+ await controller.configureSessionPersistence?.({
1095
+ strategy: persistence,
1096
+ context,
1097
+ });
1098
+ await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1099
+ await controller.initialize?.(args.args.input);
1100
+ await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1101
+ await controller.hydrate?.();
1102
+ await this.finalizeSectionControllerReady({
1103
+ ...candidate,
1104
+ context,
1105
+ });
1106
+ return controller;
1107
+ }
1108
+ catch (error) {
1109
+ await this.cleanupUnpublishedSectionController(candidate);
1110
+ throw error;
1111
+ }
1112
+ }
1113
+ createSectionControllerRetirementError() {
1114
+ return this.disposePromise !== null
1115
+ ? new ToolkitCoordinatorDisposedError()
1116
+ : new SectionControllerRetiredError();
1117
+ }
1118
+ async retireUnpublishedSectionControllerIfNeeded(args) {
1119
+ if (this.disposePromise === null && !args.token.retired)
1120
+ return;
1121
+ await this.cleanupUnpublishedSectionController(args);
1122
+ throw this.createSectionControllerRetirementError();
1123
+ }
1124
+ async cleanupUnpublishedSectionController(args) {
1125
+ if (args.token.candidateClaimed)
1126
+ return;
1127
+ args.token.candidateClaimed = true;
1128
+ try {
1129
+ await args.controller.dispose?.();
1130
+ }
1131
+ catch (error) {
1132
+ this.handleError(error, {
1133
+ phase: "section-controller-dispose",
1134
+ details: {
1135
+ sectionId: args.key.sectionId,
1136
+ attemptId: args.key.attemptId,
1137
+ },
1138
+ });
1139
+ }
1140
+ finally {
1141
+ if (this.sectionPersistenceStrategies.get(args.mapKey) === args.persistence) {
1142
+ this.sectionPersistenceStrategies.delete(args.mapKey);
1143
+ }
1144
+ }
1145
+ }
1146
+ retirePublishedSectionControllerIfNeeded(args) {
1147
+ if (this.disposePromise !== null && !args.token.retired) {
1148
+ void this.disposeSectionController({
1149
+ sectionId: args.key.sectionId,
1150
+ attemptId: args.key.attemptId,
1151
+ });
1152
+ }
1153
+ if (args.token.retired) {
1154
+ throw this.createSectionControllerRetirementError();
1155
+ }
1028
1156
  }
1029
1157
  async finalizeSectionControllerReady(args) {
1158
+ await this.retireUnpublishedSectionControllerIfNeeded(args);
1030
1159
  this.sectionControllers.set(args.mapKey, args.controller);
1031
1160
  this.sectionControllerKeys.set(args.mapKey, args.key);
1161
+ args.token.candidateClaimed = true;
1032
1162
  // PIE-512 Phase D: a freshly-resolved controller becomes the
1033
1163
  // active cohort. Active subscriptions migrate to it before the
1034
1164
  // `ready` lifecycle event and `onSectionControllerReady` hook
@@ -1041,13 +1171,19 @@ export class ToolkitCoordinator {
1041
1171
  controller: args.controller,
1042
1172
  });
1043
1173
  await this.hooks.onSectionControllerReady?.(args.context, args.controller);
1174
+ this.retirePublishedSectionControllerIfNeeded(args);
1044
1175
  await this.emitTelemetry("pie-toolkit-section-controller-ready", {
1045
1176
  assessmentId: args.key.assessmentId,
1046
1177
  sectionId: args.key.sectionId,
1047
1178
  attemptId: args.key.attemptId,
1048
1179
  });
1180
+ this.retirePublishedSectionControllerIfNeeded(args);
1049
1181
  }
1050
1182
  handleSectionControllerInitError(err, args) {
1183
+ if (err instanceof ToolkitCoordinatorDisposedError ||
1184
+ err instanceof SectionControllerRetiredError) {
1185
+ return;
1186
+ }
1051
1187
  this.handleError(err, {
1052
1188
  phase: "section-controller-init",
1053
1189
  details: {
@@ -1056,57 +1192,122 @@ export class ToolkitCoordinator {
1056
1192
  },
1057
1193
  });
1058
1194
  }
1059
- async disposeSectionController(args) {
1195
+ disposeSectionController(args) {
1060
1196
  const key = {
1061
1197
  assessmentId: this.assessmentId,
1062
1198
  sectionId: args.sectionId,
1063
1199
  attemptId: args.attemptId,
1064
1200
  };
1065
1201
  const mapKey = this.getSectionControllerMapKey(key);
1202
+ const initEntry = this.sectionControllerInitEntries.get(mapKey);
1203
+ if (initEntry) {
1204
+ initEntry.token.retired = true;
1205
+ }
1066
1206
  const controller = this.sectionControllers.get(mapKey);
1067
- if (!controller)
1068
- return;
1207
+ if (!controller) {
1208
+ const existingDisposal = this.sectionControllerDisposePromises.get(mapKey);
1209
+ if (existingDisposal)
1210
+ return existingDisposal;
1211
+ if (!initEntry)
1212
+ return Promise.resolve();
1213
+ const retirementBarrier = Promise.allSettled([initEntry.promise]).then(() => undefined);
1214
+ return this.trackSectionControllerDisposal(mapKey, retirementBarrier);
1215
+ }
1216
+ const persistenceStrategy = this.sectionPersistenceStrategies.get(mapKey);
1069
1217
  // PIE-512 Phase D: if the disposing cohort is the active one,
1070
1218
  // detach all listener-controller bindings before the controller
1071
1219
  // itself disposes. The subscription registry stays intact so a
1072
1220
  // later `getOrCreateSectionController(...)` re-binds the same
1073
1221
  // listeners to the new controller.
1074
1222
  this.clearActiveCohortIfMatches(mapKey);
1223
+ // Relinquish this exact controller before the first asynchronous step.
1224
+ // A new mount for the same cohort must create a fresh controller rather
1225
+ // than reacquire the one whose persistence/disposal is still in flight.
1226
+ if (this.sectionControllers.get(mapKey) === controller) {
1227
+ this.sectionControllers.delete(mapKey);
1228
+ this.sectionControllerKeys.delete(mapKey);
1229
+ this.sectionPersistenceStrategies.delete(mapKey);
1230
+ // Lifecycle consumers match by cohort key rather than controller identity.
1231
+ // Publish retirement now so a replacement can only produce
1232
+ // disposed -> ready, never ready -> stale disposed.
1233
+ this.emitSectionControllerLifecycle({
1234
+ type: "disposed",
1235
+ key,
1236
+ });
1237
+ }
1075
1238
  const context = this.createSectionControllerContext({
1076
1239
  key,
1077
1240
  input: undefined,
1078
1241
  });
1242
+ const controllerDisposalPromise = this.disposeSectionControllerEntry({
1243
+ args,
1244
+ key,
1245
+ context,
1246
+ controller,
1247
+ persistenceStrategy,
1248
+ });
1249
+ const disposalBarrier = initEntry
1250
+ ? Promise.allSettled([initEntry.promise]).then(() => controllerDisposalPromise)
1251
+ : controllerDisposalPromise;
1252
+ return this.trackSectionControllerDisposal(mapKey, disposalBarrier);
1253
+ }
1254
+ trackSectionControllerDisposal(mapKey, disposePromise) {
1255
+ this.sectionControllerDisposePromises.set(mapKey, disposePromise);
1256
+ const forgetDisposePromise = () => {
1257
+ if (this.sectionControllerDisposePromises.get(mapKey) === disposePromise) {
1258
+ this.sectionControllerDisposePromises.delete(mapKey);
1259
+ }
1260
+ };
1261
+ void disposePromise.then(forgetDisposePromise, forgetDisposePromise);
1262
+ return disposePromise;
1263
+ }
1264
+ async disposeSectionControllerEntry(args) {
1079
1265
  try {
1080
1266
  await this.runSectionControllerDisposePipeline({
1081
- key,
1082
- context,
1083
- controller,
1084
- persistBeforeDispose: args.persistBeforeDispose,
1267
+ key: args.key,
1268
+ context: args.context,
1269
+ controller: args.controller,
1270
+ persistBeforeDispose: args.args.persistBeforeDispose,
1085
1271
  });
1086
1272
  }
1087
1273
  catch (err) {
1088
1274
  this.handleError(err, {
1089
1275
  phase: "section-controller-dispose",
1090
1276
  details: {
1091
- sectionId: args.sectionId,
1092
- attemptId: args.attemptId,
1277
+ sectionId: args.args.sectionId,
1278
+ attemptId: args.args.attemptId,
1093
1279
  },
1094
1280
  });
1095
1281
  }
1096
1282
  finally {
1097
1283
  await this.finalizeSectionControllerDispose({
1098
- mapKey,
1099
- key,
1100
- context,
1101
- clearPersistence: args.clearPersistence,
1284
+ context: args.context,
1285
+ persistenceStrategy: args.persistenceStrategy,
1286
+ clearPersistence: args.args.clearPersistence,
1102
1287
  });
1103
1288
  }
1104
1289
  }
1105
1290
  async runSectionControllerDisposePipeline(args) {
1291
+ const failures = [];
1106
1292
  if (args.persistBeforeDispose !== false) {
1107
- await args.controller.persist?.();
1293
+ try {
1294
+ await args.controller.persist?.();
1295
+ }
1296
+ catch (error) {
1297
+ failures.push(error);
1298
+ }
1299
+ }
1300
+ try {
1301
+ await args.controller.dispose?.();
1302
+ }
1303
+ catch (error) {
1304
+ failures.push(error);
1305
+ }
1306
+ if (failures.length === 1)
1307
+ throw failures[0];
1308
+ if (failures.length > 1) {
1309
+ throw new AggregateError(failures, "Section controller persistence and disposal both failed.");
1108
1310
  }
1109
- await args.controller.dispose?.();
1110
1311
  await this.hooks.onSectionControllerDispose?.(args.context, args.controller);
1111
1312
  await this.emitTelemetry("pie-toolkit-section-controller-disposed", {
1112
1313
  assessmentId: args.key.assessmentId,
@@ -1115,28 +1316,119 @@ export class ToolkitCoordinator {
1115
1316
  });
1116
1317
  }
1117
1318
  async finalizeSectionControllerDispose(args) {
1118
- this.sectionControllers.delete(args.mapKey);
1119
- this.sectionControllerKeys.delete(args.mapKey);
1120
- this.emitSectionControllerLifecycle({
1121
- type: "disposed",
1122
- key: args.key,
1123
- });
1124
1319
  if (args.clearPersistence) {
1125
- const strategy = this.sectionPersistenceStrategies.get(args.mapKey);
1126
- await strategy?.clearSession?.(args.context);
1320
+ await args.persistenceStrategy?.clearSession?.(args.context);
1321
+ }
1322
+ }
1323
+ /**
1324
+ * Release every resource whose lifetime is owned by this coordinator.
1325
+ * Borrowed constructor inputs, including a host framework-error bus and tool
1326
+ * registry, remain owned by their caller.
1327
+ */
1328
+ dispose() {
1329
+ if (this.disposePromise)
1330
+ return this.disposePromise;
1331
+ // Defer cleanup until after this promise is assigned. The promise itself is
1332
+ // the single lifetime flag observed by every admission guard.
1333
+ this.disposePromise = Promise.resolve().then(() => this.disposeOwnedResources());
1334
+ return this.disposePromise;
1335
+ }
1336
+ async disposeOwnedResources() {
1337
+ const errors = [];
1338
+ const cleanup = async (action) => {
1339
+ try {
1340
+ await action();
1341
+ }
1342
+ catch (error) {
1343
+ errors.push(error);
1344
+ }
1345
+ };
1346
+ // Work admitted before `dispose()` may still be inside a host hook or a
1347
+ // provider/service initializer. Let it observe the disposal promise and
1348
+ // settle before destroying the owned registries and services it is using.
1349
+ await this.waitForAdmittedInitialization();
1350
+ // Retire every admitted section initialization before waiting. A section
1351
+ // with no cached controller still gets a per-key barrier; a controller that
1352
+ // reached `ready` while its init hook was pending is removed and disposed by
1353
+ // the same path as any other cached controller.
1354
+ const sectionKeys = new Map();
1355
+ for (const [mapKey, entry] of this.sectionControllerInitEntries) {
1356
+ sectionKeys.set(mapKey, entry.key);
1357
+ }
1358
+ for (const [mapKey, key] of this.sectionControllerKeys) {
1359
+ sectionKeys.set(mapKey, key);
1360
+ }
1361
+ for (const key of sectionKeys.values()) {
1362
+ void this.disposeSectionController({
1363
+ sectionId: key.sectionId,
1364
+ attemptId: key.attemptId,
1365
+ });
1366
+ }
1367
+ await Promise.allSettled(Array.from(this.sectionControllerDisposePromises.values()));
1368
+ this.sectionControllers.clear();
1369
+ this.sectionControllerKeys.clear();
1370
+ this.sectionControllerInitEntries.clear();
1371
+ this.sectionPersistenceStrategies.clear();
1372
+ for (const subscription of this.activeSubscriptions.values()) {
1373
+ await cleanup(() => subscription.unsubscribeCurrent?.());
1374
+ subscription.unsubscribeCurrent = null;
1375
+ }
1376
+ this.activeSubscriptions.clear();
1377
+ this.activeCohortMapKey = null;
1378
+ this.latestRequestedActiveCohortMapKey = null;
1379
+ await cleanup(() => this.ttsService.stop());
1380
+ await cleanup(() => this.toolProviderRegistry.destroy());
1381
+ await cleanup(() => this.highlightCoordinator.destroy());
1382
+ for (const toolId of this.toolCoordinator.getRegisteredTools()) {
1383
+ await cleanup(() => this.toolCoordinator.releaseTool(toolId));
1384
+ }
1385
+ await cleanup(() => this.catalogResolver.destroy());
1386
+ await cleanup(() => this.toolRequests.dispose());
1387
+ await cleanup(() => this.policyEngine.dispose());
1388
+ this.toolContextResolvers.clear();
1389
+ this.toolContextResolverChangeListeners.clear();
1390
+ this.sectionControllerLifecycleListeners.clear();
1391
+ this.telemetryListeners.clear();
1392
+ this.frameworkErrorHookUnsubscribe?.();
1393
+ this.frameworkErrorHookUnsubscribe = null;
1394
+ if (this.ownsFrameworkErrorBus) {
1395
+ this.frameworkErrorBus.dispose();
1396
+ }
1397
+ if (errors.length === 1)
1398
+ throw errors[0];
1399
+ if (errors.length > 1) {
1400
+ throw new AggregateError(errors, "ToolkitCoordinator cleanup failed for multiple owned resources.");
1127
1401
  }
1128
- this.sectionPersistenceStrategies.delete(args.mapKey);
1402
+ }
1403
+ async waitForAdmittedInitialization() {
1404
+ const pending = new Set();
1405
+ if (this.coordinatorReadyPromise) {
1406
+ pending.add(this.coordinatorReadyPromise);
1407
+ }
1408
+ if (this.ttsInitPromise)
1409
+ pending.add(this.ttsInitPromise);
1410
+ if (this.ttsReconfigurePromise) {
1411
+ pending.add(this.ttsReconfigurePromise);
1412
+ }
1413
+ for (const promise of this.providerInitPromises.values()) {
1414
+ pending.add(promise);
1415
+ }
1416
+ await Promise.allSettled(pending);
1129
1417
  }
1130
1418
  /**
1131
1419
  * Initialize TTS service with provider
1132
1420
  */
1133
1421
  async ensureTTSReady(config) {
1422
+ this.assertNotDisposed();
1134
1423
  await this.waitForPendingTTSReconfigure();
1424
+ this.assertNotDisposed();
1135
1425
  if (this.ttsInitialized)
1136
1426
  return;
1137
1427
  if (this.ttsInitPromise)
1138
1428
  return this.ttsInitPromise;
1139
- this.ttsInitPromise = this._initializeTTS(config).finally(() => {
1429
+ this.ttsInitPromise = this._initializeTTS(config)
1430
+ .then(() => this.assertNotDisposed())
1431
+ .finally(() => {
1140
1432
  this.ttsInitPromise = undefined;
1141
1433
  });
1142
1434
  return this.ttsInitPromise;
@@ -1161,6 +1453,7 @@ export class ToolkitCoordinator {
1161
1453
  backend: resolvedBackend,
1162
1454
  },
1163
1455
  });
1456
+ this.assertNotDisposed();
1164
1457
  await this.emitTelemetry("pie-toolkit-tts-init-start", {
1165
1458
  backend: resolvedBackend,
1166
1459
  });
@@ -1188,6 +1481,8 @@ export class ToolkitCoordinator {
1188
1481
  return;
1189
1482
  }
1190
1483
  catch (error) {
1484
+ if (error instanceof ToolkitCoordinatorDisposedError)
1485
+ throw error;
1191
1486
  const normalized = error instanceof Error ? error : new Error(String(error));
1192
1487
  await this.emitTelemetry("pie-tool-init-error", {
1193
1488
  toolId: "textToSpeech",
@@ -1225,6 +1520,8 @@ export class ToolkitCoordinator {
1225
1520
  });
1226
1521
  }
1227
1522
  catch (error) {
1523
+ if (error instanceof ToolkitCoordinatorDisposedError)
1524
+ throw error;
1228
1525
  const normalized = error instanceof Error ? error : new Error(String(error));
1229
1526
  this.handleError(normalized, { phase: "tts-init" });
1230
1527
  await this.emitTelemetry("pie-toolkit-tts-init-error", {
@@ -1259,9 +1556,11 @@ export class ToolkitCoordinator {
1259
1556
  };
1260
1557
  await this.ttsService.initialize(provider, nextConfig);
1261
1558
  await this.ensureBrowserVoicesReady(provider);
1559
+ this.assertNotDisposed();
1262
1560
  this.ttsService.setCatalogResolver(this.catalogResolver);
1263
1561
  this.ttsInitialized = true;
1264
1562
  await this.hooks.onTTSReady?.();
1563
+ this.assertNotDisposed();
1265
1564
  }
1266
1565
  async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
1267
1566
  if (provider.providerId !== "browser")
@@ -1344,19 +1643,23 @@ export class ToolkitCoordinator {
1344
1643
  return this.ensureProviderReady(providerId);
1345
1644
  }
1346
1645
  async waitUntilReady() {
1646
+ this.assertNotDisposed();
1347
1647
  if (this.isReady())
1348
1648
  return;
1349
1649
  if (this.coordinatorReadyPromise)
1350
1650
  return this.coordinatorReadyPromise;
1351
1651
  this.coordinatorReadyPromise = (async () => {
1352
1652
  await this.ensureStateLoaded();
1653
+ this.assertNotDisposed();
1353
1654
  const ttsConfig = this.getTTSConfigFromProviders();
1354
1655
  if (ttsConfig?.enabled !== false) {
1355
1656
  await this.ensureTTSReady(ttsConfig);
1356
1657
  }
1658
+ this.assertNotDisposed();
1357
1659
  if (!this.coordinatorReadyNotified) {
1358
1660
  this.coordinatorReadyNotified = true;
1359
1661
  await this.hooks.onCoordinatorReady?.(this);
1662
+ this.assertNotDisposed();
1360
1663
  await this.emitTelemetry("pie-toolkit-coordinator-ready", {
1361
1664
  assessmentId: this.assessmentId,
1362
1665
  });
@@ -1585,11 +1888,9 @@ export class ToolkitCoordinator {
1585
1888
  * that want the new visible tool set should call
1586
1889
  * {@link decideToolPolicy} with their level / scope.
1587
1890
  *
1588
- * Note: the engine itself can also emit `reason: "disposed"`, but
1589
- * the coordinator does not dispose its engine on teardown today,
1590
- * so subscribers attached via this method will not observe that
1591
- * reason. Detach via the returned unsubscribe function instead of
1592
- * relying on a `disposed` event.
1891
+ * The owned engine emits `reason: "disposed"` during coordinator teardown.
1892
+ * Callers should still detach through the returned unsubscribe function when
1893
+ * their own lifetime ends before the coordinator's.
1593
1894
  */
1594
1895
  onPolicyChange(listener) {
1595
1896
  return this.policyEngine.onPolicyChange(listener);
@@ -1811,6 +2112,8 @@ export class ToolkitCoordinator {
1811
2112
  * Called after updateToolConfig().
1812
2113
  */
1813
2114
  _applyToolConfigChange(toolId, _updates) {
2115
+ if (this.disposePromise !== null)
2116
+ return;
1814
2117
  // Apply configuration changes based on tool
1815
2118
  switch (toolId) {
1816
2119
  case "textToSpeech": {
@@ -1822,6 +2125,8 @@ export class ToolkitCoordinator {
1822
2125
  }
1823
2126
  });
1824
2127
  void reconfigurePromise.then(async () => {
2128
+ if (this.disposePromise !== null)
2129
+ return;
1825
2130
  const ttsConfig = this.getTTSConfigFromProviders();
1826
2131
  if (!this.lazyInit && ttsConfig?.enabled !== false) {
1827
2132
  await this.ensureTTSReady(ttsConfig);
@@ -1847,6 +2152,8 @@ export class ToolkitCoordinator {
1847
2152
  if (this.toolProviderRegistry.has("tts")) {
1848
2153
  await this.toolProviderRegistry.unregister("tts");
1849
2154
  }
2155
+ if (this.disposePromise !== null)
2156
+ return;
1850
2157
  const ttsRegistration = this.getProviderDescriptorTools().find((tool) => tool.toolId === "textToSpeech");
1851
2158
  if (!ttsRegistration)
1852
2159
  return;