@jskit-ai/assistant-core 0.2.2 → 0.2.3

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.
Files changed (59) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +234 -29
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +13 -1
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +93 -0
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -2,6 +2,11 @@ import path from "node:path";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { CodexAppServerAgentProvider } from "./codexProvider.js";
4
4
  import { codexAppServerProjectHookTrustConfig } from "./codexConfiguration.js";
5
+ import {
6
+ CODEX_APP_SERVER_PROCESS_STATE,
7
+ readCodexAppServerMetadata,
8
+ removeCodexAppServerRuntimeDir
9
+ } from "./codexProcess.js";
5
10
  import { normalizeCodexRunText as normalizeText } from "./codexTurnState.js";
6
11
 
7
12
  // Moved from Vibe64's codexTerminal provider owner. Caller-local provider keys
@@ -10,6 +15,8 @@ import { normalizeCodexRunText as normalizeText } from "./codexTurnState.js";
10
15
  const codexAppServerProviders = new Map();
11
16
  const codexAppServerOwnedRuntimes = new Map();
12
17
  const codexAppServerRuntimeLifecycles = new Map();
18
+ // Runtimes an account change left running a turn, by native runtime identity.
19
+ const codexAppServerStaleRuntimes = new Map();
13
20
 
14
21
  export function codexAppServerOwnedRuntimeKey(providerKey = "", providerOptions = {}) {
15
22
  const runtimeDir = normalizeText(providerOptions?.runtimeDir);
@@ -30,6 +37,31 @@ function withRuntimeLifecycle(runtimeKey, operation) {
30
37
  return run;
31
38
  }
32
39
 
40
+ // A session recorded a new runtime directory (a new sign-in gets its own), so
41
+ // its previous one is no longer used. It goes once its runtime has verifiably
42
+ // stopped; a stale runtime still finishing its turn drops it when it stops.
43
+ // Helper cleanup treats an absent directory as retired, which is also what it
44
+ // does with a previous sign-in's runtime it finds stopped.
45
+ export function releaseSupersededCodexRuntime(runtimeDir = "") {
46
+ const runtimeKey = codexAppServerOwnedRuntimeKey("", { runtimeDir });
47
+ return withRuntimeLifecycle(runtimeKey, async () => {
48
+ const stale = codexAppServerStaleRuntimes.get(runtimeKey);
49
+ if (stale) {
50
+ stale.superseded = true;
51
+ return { deferred: true, removed: false };
52
+ }
53
+ if (codexAppServerOwnedRuntimes.has(runtimeKey) || [...providerRecords()].some(record =>
54
+ codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions) === runtimeKey)) {
55
+ return { removed: false };
56
+ }
57
+ const metadata = await readCodexAppServerMetadata(runtimeDir);
58
+ if (metadata?.processState !== CODEX_APP_SERVER_PROCESS_STATE.STOPPED || !metadata.processExitVerifiedAt) {
59
+ return { removed: false };
60
+ }
61
+ return { removed: await removeCodexAppServerRuntimeDir(runtimeDir) };
62
+ });
63
+ }
64
+
33
65
  export function codexAppServerRuntimeStopWasVerified(result = {}) {
34
66
  return result?.stopped === true ||
35
67
  result?.processExitVerified === true ||
@@ -51,6 +83,31 @@ function* providerRecords() {
51
83
  for (const records of codexAppServerProviders.values()) yield* records.values();
52
84
  }
53
85
 
86
+ function nativeRequest(client, method, params) {
87
+ return client.request(method, params, { signal: AbortSignal.timeout(5_000) });
88
+ }
89
+
90
+ // The loaded threads of one native runtime that have a running turn, read from
91
+ // the runtime itself so Temporary, Main and native terminal turns all count.
92
+ async function runningNativeThreads(peers) {
93
+ const client = peers.find(record => record.provider.client?.isOpen?.())?.provider.client;
94
+ if (!client) return [];
95
+ const running = [];
96
+ const seen = new Set();
97
+ let cursor;
98
+ do {
99
+ const page = await nativeRequest(client, "thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
100
+ for (const threadId of Array.isArray(page?.data) ? page.data : []) {
101
+ const { thread } = await nativeRequest(client, "thread/read", { threadId, includeTurns: false });
102
+ if ((typeof thread?.status === "string" ? thread.status : thread?.status?.type) === "active") running.push(threadId);
103
+ }
104
+ cursor = page?.nextCursor;
105
+ if (cursor && seen.has(cursor)) throw new Error("Codex's loaded-thread inventory did not finish.");
106
+ if (cursor) seen.add(cursor);
107
+ } while (cursor);
108
+ return running;
109
+ }
110
+
54
111
  /** Internal consumer view of the one native provider owner, not a server per view. */
55
112
  export function createCodexAppServerProviderOwner({
56
113
  runtimeRoot = "",
@@ -217,7 +274,14 @@ export function createCodexAppServerProviderOwner({
217
274
  current.executionRoot === identity.executionRoot &&
218
275
  current.runtimeInstanceId === identity.runtimeInstanceId &&
219
276
  current.workdir === identity.workdir) {
220
- await retireAndCloseProvider(currentKey);
277
+ const record = records.get(currentKey);
278
+ if (!record?.provider.stale) {
279
+ await retireAndCloseProvider(currentKey);
280
+ continue;
281
+ }
282
+ // A stale runtime whose turn has ended stops before the session's next
283
+ // runtime starts, so its threads are never loaded in two runtimes at once.
284
+ await retireStaleRuntime(codexAppServerOwnedRuntimeKey(currentKey, record.providerOptions)).catch(() => null);
221
285
  }
222
286
  }
223
287
  context.assertAdmission();
@@ -277,7 +341,7 @@ export function createCodexAppServerProviderOwner({
277
341
  return provider;
278
342
  }
279
343
 
280
- // A conversation, terminal or helper keeps a provider a storage read created.
344
+ // A Main conversation, terminal or helper keeps a provider a borrower created.
281
345
  // Every exported way to reach a provider for real use claims it here.
282
346
  function claimTransient(providerKey = "") {
283
347
  const key = normalizeText(providerKey);
@@ -292,35 +356,67 @@ export function createCodexAppServerProviderOwner({
292
356
  return acquireSession(context, mainThreadId);
293
357
  }
294
358
 
295
- // Native storage inventory and retirement borrow a session provider. A short
296
- // idle after the last such use lets consecutive reads share one process; the
297
- // release then stops it unless another provider still uses it.
298
- async function withTransientSession(context, operation) {
359
+ // The last borrower's return starts a short idle, so consecutive borrowers
360
+ // share one process; the release then stops it unless it was claimed.
361
+ function useBorrowed(providerKey, release) {
362
+ clearTimeout(release.timer);
363
+ release.users += 1;
364
+ let returned = false;
365
+ return () => {
366
+ if (returned) return;
367
+ returned = true;
368
+ release.users -= 1;
369
+ if (release.users > 0 || transientReleases.get(providerKey) !== release || closing) return;
370
+ release.timer = setTimeout(() => {
371
+ if (transientReleases.get(providerKey) !== release || release.users > 0) return;
372
+ transientReleases.delete(providerKey);
373
+ if (closing) return;
374
+ stopCachedProvider(providerKey).catch(error => debugLog("appServerRuntime.transientRelease.error", {
375
+ error: debugError(error), providerKey
376
+ }));
377
+ }, transientIdleMs);
378
+ release.timer.unref?.();
379
+ };
380
+ }
381
+
382
+ // Reads, storage work and Temporary chats borrow a session provider instead
383
+ // of claiming it. `release()` returns the borrow. A provider a real user
384
+ // already holds (it exists unborrowed, or a managed Main thread uses it) is
385
+ // simply used and stays that user's.
386
+ async function borrowSession(context) {
299
387
  const providerKey = context.providerKey;
300
388
  let release = transientReleases.get(providerKey);
301
- // An existing provider or a managed main thread already has a real user.
302
389
  if (!release && (records.has(providerKey) || context.runOwner?.runtimeLifecycle?.managedThreadId?.(providerKey))) {
303
- return operation(await acquireSession(context));
390
+ return { provider: await acquireSession(context), release() {} };
304
391
  }
305
392
  if (!release) transientReleases.set(providerKey, release = { users: 0, timer: null });
306
- clearTimeout(release.timer);
307
- release.users += 1;
393
+ const returnBorrow = useBorrowed(providerKey, release);
394
+ try {
395
+ return { provider: await acquireSession(context), release: returnBorrow };
396
+ } catch (error) {
397
+ returnBorrow();
398
+ throw error;
399
+ }
400
+ }
401
+
402
+ async function withTransientSession(context, operation) {
403
+ const borrowed = await borrowSession(context);
308
404
  try {
309
- return await operation(await acquireSession(context));
405
+ return await operation(borrowed.provider);
310
406
  } finally {
311
- release.users -= 1;
312
- if (release.users === 0 && transientReleases.get(providerKey) === release && !closing) {
313
- release.timer = setTimeout(() => {
314
- if (transientReleases.get(providerKey) !== release) return;
315
- transientReleases.delete(providerKey);
316
- if (closing) return;
317
- stopCachedProvider(providerKey).catch(error => debugLog("appServerRuntime.transientRelease.error", {
318
- error: debugError(error), providerKey
319
- }));
320
- }, transientIdleMs);
321
- release.timer.unref?.();
322
- }
407
+ borrowed.release();
408
+ }
409
+ }
410
+
411
+ // Native work running on a borrowed provider (a Temporary turn or goal)
412
+ // holds it until that work ends. A claimed or closed provider needs no hold.
413
+ function holdProvider(provider) {
414
+ for (const [providerKey, record] of records) {
415
+ if (record.provider !== provider) continue;
416
+ const release = transientReleases.get(providerKey);
417
+ return release ? useBorrowed(providerKey, release) : () => {};
323
418
  }
419
+ return () => {};
324
420
  }
325
421
 
326
422
  async function acquireSession(context, mainThreadId = "") {
@@ -382,6 +478,15 @@ export function createCodexAppServerProviderOwner({
382
478
  if (closeProvider) record.provider.close?.();
383
479
  records.delete(providerKey);
384
480
  if (!records.size) codexAppServerProviders.delete(api);
481
+ if (record.provider.stale) {
482
+ // Another stop (a session close or idle release) ended a stale runtime.
483
+ const runtimeKey = codexAppServerOwnedRuntimeKey(providerKey, record.providerOptions);
484
+ const stale = codexAppServerStaleRuntimes.get(runtimeKey);
485
+ if (stale && !stale.retiring && ![...providerRecords()].some(peer =>
486
+ codexAppServerOwnedRuntimeKey(peer.providerKey, peer.providerOptions) === runtimeKey)) {
487
+ codexAppServerStaleRuntimes.delete(runtimeKey);
488
+ }
489
+ }
385
490
  }
386
491
 
387
492
  async function stopOwnedRuntime(record = {}, {
@@ -717,7 +822,78 @@ export function createCodexAppServerProviderOwner({
717
822
  return { ok: failed.length === 0, failed, results, providerCount: results.length, stopped: results.reduce((sum, result) => sum + result.threadIds.length, 0) };
718
823
  }
719
824
 
825
+ // A runtime with a running turn keeps the credentials it started with until
826
+ // that turn ends. Its active goals are paused so it starts no further turn;
827
+ // retireStaleRuntime stops it once it is idle.
828
+ async function deferBusyRuntime(runtimeKey, peers) {
829
+ let running;
830
+ try {
831
+ running = await runningNativeThreads(peers);
832
+ } catch (error) {
833
+ // An unreadable runtime cannot show a running turn: it stops as before.
834
+ debugLog("appServerRuntime.staleInspection.error", { error: debugError(error), runtimeKey });
835
+ return null;
836
+ }
837
+ if (!running.length) return null;
838
+ const client = peers.find(record => record.provider.client?.isOpen?.()).provider.client;
839
+ const pausedGoals = [];
840
+ for (const threadId of running) {
841
+ try {
842
+ const { goal } = await nativeRequest(client, "thread/goal/get", { threadId });
843
+ if (goal?.status !== "active") continue;
844
+ const result = await nativeRequest(client, "thread/goal/set", { threadId, status: "paused" });
845
+ if (result?.goal?.status !== "paused") throw new Error("Codex did not confirm that the goal was paused.");
846
+ pausedGoals.push(threadId);
847
+ } catch (error) {
848
+ debugLog("appServerRuntime.staleGoalPause.error", { error: debugError(error), runtimeKey, threadId });
849
+ }
850
+ }
851
+ for (const peer of peers) peer.provider.markStale?.();
852
+ const stale = {
853
+ runtimeKey, threadIds: running, pausedGoals, retiring: null,
854
+ providerKeys: peers.map(peer => peer.providerKey)
855
+ };
856
+ codexAppServerStaleRuntimes.set(runtimeKey, stale);
857
+ return stale;
858
+ }
859
+
860
+ // Stops a stale runtime once no loaded thread is running, through the same
861
+ // verified stop an idle runtime gets at the account change.
862
+ function retireStaleRuntime(runtimeKey = "") {
863
+ const stale = codexAppServerStaleRuntimes.get(runtimeKey);
864
+ if (!stale) return Promise.resolve(null);
865
+ if (stale.retiring) return stale.retiring;
866
+ const retiring = withRuntimeLifecycle(runtimeKey, async () => {
867
+ const peers = [...providerRecords()].filter(record =>
868
+ codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions) === runtimeKey);
869
+ const running = await runningNativeThreads(peers).catch(() => []);
870
+ // A Temporary turn or goal holding a borrowed provider also keeps it.
871
+ if (running.length || peers.some(peer => peer.scope.isHeld?.(peer.providerKey))) {
872
+ return { ...stale, busy: true, stopped: false };
873
+ }
874
+ const owned = codexAppServerOwnedRuntimes.get(runtimeKey);
875
+ const target = owned || peers[0];
876
+ let result = { stopped: false };
877
+ if (target) {
878
+ result = await (target.scope || api).stopOwnedRuntime({ ...target }, {
879
+ cached: true,
880
+ // A runtime its session no longer records keeps no exit proof.
881
+ preserveProcessExitProof: target.preserveProcessExitProof === true && !stale.superseded,
882
+ requireVerifiedExit: true
883
+ });
884
+ }
885
+ for (const peer of peers) peer.scope.closeProvider(peer.providerKey);
886
+ codexAppServerStaleRuntimes.delete(runtimeKey);
887
+ return { ...stale, ...result, busy: false };
888
+ }).finally(() => {
889
+ if (stale.retiring === retiring) stale.retiring = null;
890
+ });
891
+ stale.retiring = retiring;
892
+ return retiring;
893
+ }
894
+
720
895
  async function invalidateRuntimes({
896
+ deferBusy = false,
721
897
  includeOwned = false,
722
898
  requireVerifiedExit = false,
723
899
  stopOwnedRuntimes = false,
@@ -766,6 +942,28 @@ export function createCodexAppServerProviderOwner({
766
942
  targets.sort((left, right) => Number(Boolean(left.record)) - Number(Boolean(right.record)));
767
943
  const failed = [];
768
944
  const results = [];
945
+ const deferred = [];
946
+ if (deferBusy) {
947
+ const groups = new Map();
948
+ for (const target of targets) {
949
+ const runtimeKey = codexAppServerOwnedRuntimeKey(target.providerKey, target.providerOptions);
950
+ if (!groups.has(runtimeKey)) groups.set(runtimeKey, []);
951
+ groups.get(runtimeKey).push(target);
952
+ }
953
+ for (const [runtimeKey, peers] of groups) {
954
+ if (codexAppServerStaleRuntimes.has(runtimeKey)) {
955
+ deferred.push(codexAppServerStaleRuntimes.get(runtimeKey));
956
+ } else {
957
+ const stale = await withRuntimeLifecycle(runtimeKey, () => deferBusyRuntime(runtimeKey, peers));
958
+ if (!stale) continue;
959
+ deferred.push(stale);
960
+ }
961
+ for (const peer of peers) {
962
+ targets.splice(targets.indexOf(peer), 1);
963
+ results.push({ providerKey: peer.providerKey, deferred: true, stopped: false });
964
+ }
965
+ }
966
+ }
769
967
  for (const target of targets) {
770
968
  if (!target.resources?.invalidation) continue;
771
969
  const error = Object.assign(new Error("Codex's native runtime was invalidated."), {
@@ -847,9 +1045,11 @@ export function createCodexAppServerProviderOwner({
847
1045
  }
848
1046
  const stopped = results.filter((result) => result.stopped).length;
849
1047
  return {
1048
+ deferred: deferred.map(({ runtimeKey, providerKeys, threadIds, pausedGoals }) =>
1049
+ ({ runtimeKey, providerKeys: [...providerKeys], threadIds: [...threadIds], pausedGoals: [...pausedGoals] })),
850
1050
  failed,
851
1051
  ok: failed.length === 0,
852
- providerCount: targets.length,
1052
+ providerCount: targets.length + deferred.reduce((sum, stale) => sum + stale.providerKeys.length, 0),
853
1053
  results,
854
1054
  stopped
855
1055
  };
@@ -924,7 +1124,7 @@ export function createCodexAppServerProviderOwner({
924
1124
  preserveProcessExitProof = false,
925
1125
  projectContextRoot = "",
926
1126
  reason = "",
927
- } = {}, { managed, keyFields, helperThreads }) {
1127
+ } = {}, { managed, keyFields, helperThreads, sessionKeyPrefix = "" }) {
928
1128
  const normalizedProjectContextRoot = normalizeText(projectContextRoot);
929
1129
  if (!normalizedProjectContextRoot) {
930
1130
  return {
@@ -942,11 +1142,14 @@ export function createCodexAppServerProviderOwner({
942
1142
  projectContextRoot: normalizedProjectContextRoot
943
1143
  }).map((record) => record.provider)
944
1144
  );
1145
+ // Main threads name their project; every other provider of the project's
1146
+ // sessions (Temporary chats, reads) is found by its session key.
945
1147
  const providerKeys = [...providers.keys()]
946
1148
  .filter((providerKey) => {
947
1149
  const current = managed.get(providerKey);
948
1150
  return normalizeText(current?.projectContext?.targetRoot) === normalizedProjectContextRoot ||
949
- helperProviders.has(providers.get(providerKey));
1151
+ helperProviders.has(providers.get(providerKey)) ||
1152
+ Boolean(sessionKeyPrefix && normalizeText(providerOwners.get(providerKey)?.sessionKey).startsWith(sessionKeyPrefix));
950
1153
  });
951
1154
  const failed = [];
952
1155
  const results = [];
@@ -1205,7 +1408,8 @@ export function createCodexAppServerProviderOwner({
1205
1408
  return { exitFailure };
1206
1409
  }
1207
1410
 
1208
- // A provider a storage read borrowed is not the session's conversation.
1411
+ // A borrowed provider (a read, storage work or a Temporary chat) is not the
1412
+ // session's conversation.
1209
1413
  async function readPlanUsage(sessionKey) {
1210
1414
  for (const [key, provider] of providers) {
1211
1415
  if (providerOwners.get(key)?.sessionKey === sessionKey && !transientReleases.has(key) &&
@@ -1230,13 +1434,14 @@ export function createCodexAppServerProviderOwner({
1230
1434
 
1231
1435
  const api = Object.freeze({
1232
1436
  providers, providerOwners, ownedRuntimes, lifecycleTasks, runtimeAcquisitions,
1437
+ isHeld: providerKey => (transientReleases.get(normalizeText(providerKey))?.users || 0) > 0,
1233
1438
  withLifecycle, runtimeIsShared, rememberOwnedRuntime, forgetOwnedRuntime, attachRuntimeLifecycle,
1234
1439
  acquireRuntime: options => { claimTransient(options?.providerKey); return acquireRuntime(options); },
1235
1440
  createProvider: options => { claimTransient(options?.providerKey); return createProvider(options); },
1236
1441
  providerForSession: context => { claimTransient(context.providerKey); return providerForSession(context); },
1237
- createNativeProvider, ensureSession, withTransientSession, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
1442
+ createNativeProvider, ensureSession, borrowSession, withTransientSession, holdProvider, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
1238
1443
  reportRecoveryEvent, pendingRecovery, readSessionGoal, readPlanUsage, stopCachedProvidersForSession, stopProvidersForProject, drainThreadEnvironmentTasks, stopPersistedRuntime, sessionExitProof,
1239
- unsubscribeSessionThread, releasePersistedRuntimeProof, releasePreparedRuntimeProof, releaseSession,
1444
+ unsubscribeSessionThread, releasePersistedRuntimeProof, releasePreparedRuntimeProof, releaseSession, retireStaleRuntime,
1240
1445
  beginShutdown() {
1241
1446
  closing = true;
1242
1447
  for (const release of transientReleases.values()) clearTimeout(release.timer);
@@ -25,6 +25,11 @@ export function createCodexProviderSelection({
25
25
  return providerSessions.owner.ensureSession(sessionConnection(sessionId, providerOptions), threadId);
26
26
  }
27
27
 
28
+ // `{ provider, release }`: a use that does not keep the provider running.
29
+ function borrowSessionProvider(sessionId, providerOptions) {
30
+ return providerSessions.owner.borrowSession(sessionConnection(sessionId, providerOptions));
31
+ }
32
+
28
33
  function rememberManagedSession(sessionId, providerOptions, { executionRoot, workdir, threadId } = {}) {
29
34
  const connection = sessionConnection(sessionId, providerOptions);
30
35
  connection.runOwner.runtimeLifecycle.rememberManagedSession(connection.providerKey, {
@@ -36,14 +41,18 @@ export function createCodexProviderSelection({
36
41
  });
37
42
  }
38
43
 
39
- function managedProviderKeyForThread(sessionId, workdir, threadId) {
44
+ // A provider left on the previous account (stale) serves only the turn it
45
+ // is still running: its output, steer and interrupt. Anything else goes to
46
+ // the session's provider for the current account.
47
+ function managedProviderKeyForThread(sessionId, workdir, threadId, { turnActive = false } = {}) {
40
48
  for (const [providerKey, managed] of providerSessions.managed) {
41
49
  const provider = providerSessions.owner.providers.get(providerKey);
42
50
  if (
43
51
  managed.sessionId !== sessionId ||
44
52
  managed.workdir !== workdir ||
45
53
  managed.threadId !== threadId ||
46
- provider?.isAvailable?.() !== true
54
+ provider?.isAvailable?.() !== true ||
55
+ provider.stale && !turnActive
47
56
  ) {
48
57
  continue;
49
58
  }
@@ -72,7 +81,7 @@ export function createCodexProviderSelection({
72
81
  continue;
73
82
  }
74
83
  const provider = providerSessions.owner.providers.get(providerKey);
75
- if (provider) {
84
+ if (provider && !(provider.stale && !turn.active)) {
76
85
  return {
77
86
  provider: await ensureSessionProvider(normalizedSessionId, managed.providerOptions),
78
87
  providerKey,
@@ -214,6 +223,7 @@ export function createCodexProviderSelection({
214
223
  codexAppServerSessionObserverOptions,
215
224
  sessionConnection,
216
225
  ensureSessionProvider,
226
+ borrowSessionProvider,
217
227
  rememberManagedSession
218
228
  };
219
229
  }
@@ -12,6 +12,7 @@ export function createCodexRuntimeLifecycle({
12
12
  helperLifecycle,
13
13
  captureContext,
14
14
  runInContext,
15
+ continueGoalAfterAccountChange = null,
15
16
  debugLog,
16
17
  debugError,
17
18
  closingError = null
@@ -19,6 +20,7 @@ export function createCodexRuntimeLifecycle({
19
20
  const codexAppServerSessionClosures = new Map();
20
21
  const codexAppServerWellbeingTimers = new Map();
21
22
  const codexAppServerReconcileTasks = new Set();
23
+ const staleWatches = new Set();
22
24
  let codexAppServerThreadReconcileGeneration = 0;
23
25
  let codexAppServerServerClosing = false;
24
26
  let codexAppServerShutdownPromise = null;
@@ -78,9 +80,58 @@ export function createCodexRuntimeLifecycle({
78
80
  };
79
81
  }
80
82
 
83
+ // A runtime the account change left running a turn stops after that turn
84
+ // ends, once its output is recorded. A goal it paused then resumes, on the
85
+ // session's runtime for the current account.
86
+ function retireWhenIdle(stale) {
87
+ if (staleWatches.has(stale.runtimeKey)) return;
88
+ staleWatches.add(stale.runtimeKey);
89
+ const goals = new Map();
90
+ for (const providerKey of stale.providerKeys) {
91
+ const managed = providerSessions.managed.get(providerKey);
92
+ if (managed && stale.pausedGoals.includes(managed.threadId)) goals.set(managed.threadId, managed);
93
+ }
94
+ const unsubscribes = [];
95
+ let done = false;
96
+ const attempt = async () => {
97
+ if (done || codexAppServerServerClosing) return;
98
+ await Promise.allSettled([...pendingWork.notificationTasks.values(), ...pendingWork.resultFinalizations.values()]);
99
+ const retired = await providerSessions.owner.retireStaleRuntime(stale.runtimeKey);
100
+ if (retired?.busy || done) return;
101
+ done = true;
102
+ staleWatches.delete(stale.runtimeKey);
103
+ for (const unsubscribe of unsubscribes) unsubscribe();
104
+ debugLog("appServerRuntime.stale.retired", { runtimeKey: stale.runtimeKey, stopped: retired?.stopped === true });
105
+ if (!continueGoalAfterAccountChange || codexAppServerServerClosing) return;
106
+ for (const [threadId, managed] of goals) {
107
+ try {
108
+ await runInContext(managed.projectContext, () => continueGoalAfterAccountChange(managed.sessionId, { threadId }));
109
+ } catch (error) {
110
+ debugLog("appServerRuntime.stale.goalContinue.error", { error: debugError(error), sessionId: managed.sessionId, threadId });
111
+ }
112
+ }
113
+ };
114
+ const retire = () => attempt().catch(error => debugLog("appServerRuntime.stale.retire.error", {
115
+ error: debugError(error), runtimeKey: stale.runtimeKey
116
+ }));
117
+ for (const providerKey of stale.providerKeys) {
118
+ const provider = providerSessions.owner.providers.get(providerKey);
119
+ if (!provider?.subscribe) continue;
120
+ // Subscribed after the session observer, so its completion work for
121
+ // this notification is already queued when the retirement drains it.
122
+ unsubscribes.push(provider.subscribe(notification => {
123
+ if (notification?.method === "turn/completed") void retire();
124
+ }));
125
+ }
126
+ // The turn may have ended while the account change looked at it.
127
+ void retire();
128
+ }
129
+
81
130
  async function invalidateCodexAppServerRuntimes(input = {}) {
82
131
  const result = await providerSessions.owner.invalidateRuntimes(input);
132
+ for (const stale of result.deferred || []) retireWhenIdle(stale);
83
133
  debugLog("appServerRuntime.invalidate.done", {
134
+ deferredCount: (result.deferred || []).length,
84
135
  failedCount: result.failed.length,
85
136
  providerCount: result.providerCount,
86
137
  reason: normalizeText(input.reason),
@@ -257,17 +308,18 @@ export function createCodexRuntimeLifecycle({
257
308
  return shutdownCodexAppServerRuntimes(input);
258
309
  }
259
310
  if (input.includeOwned === true) {
311
+ // An account change stops idle runtimes now and busy ones when their
312
+ // turn ends; it never stops or waits for a running turn.
260
313
  return invalidateCodexAppServerRuntimes({
261
314
  ...input,
315
+ deferBusy: true,
262
316
  includeOwned: true,
263
317
  requireVerifiedExit: true,
264
318
  stopOwnedRuntimes: true
265
319
  });
266
320
  }
267
- const helpers = await preparation.helpers;
268
- helperLifecycle.assertRestored(
269
- await helperLifecycle.restoreThreads(helpers.projectRuntimeRoot, helpers.context)
270
- );
321
+ // Saved Helper threads are not reopened to be stopped: their records stay
322
+ // for the next connection, which retires them against the new account.
271
323
  return invalidateCodexAppServerRuntimes({
272
324
  ...input,
273
325
  includeOwned: false,
@@ -9,6 +9,27 @@ export function createCodexSessionCleanup({ namespace, runOwner, providerSession
9
9
  const providerOwner = providerSessions?.owner;
10
10
  const restrictedClosures = new WeakSet();
11
11
 
12
+ // Saved Helper records are retired through the providers a close is about to
13
+ // stop. A record that cannot be restored (a removed account, a malformed or
14
+ // unreadable ledger) stays on disk for a later connection; the host publishes
15
+ // its failure, and the close still stops everything this server runs.
16
+ async function restoreSavedHelpers(readRestoration, event, identity = {}) {
17
+ let restored;
18
+ try {
19
+ const restoration = await readRestoration();
20
+ restored = await helperLifecycle.restoreThreads(restoration.projectRuntimeRoot, restoration.context);
21
+ } catch (error) {
22
+ restored = { failed: [helperLifecycle.failure(identity, error)], ok: false };
23
+ }
24
+ if (restored.ok === false) {
25
+ debugLog(event, {
26
+ ...identity,
27
+ failed: restored.failed.map(({ code, error, sessionId, threadId }) => ({ code, error, sessionId, threadId }))
28
+ });
29
+ }
30
+ return restored;
31
+ }
32
+
12
33
  async function closeSession(sessionId, cleanup) {
13
34
  const normalizedSessionId = normalizeCodexRunText(sessionId);
14
35
  const sessionKey = cleanup.sessionKey;
@@ -28,27 +49,35 @@ export function createCodexSessionCleanup({ namespace, runOwner, providerSession
28
49
  const closing = (async () => {
29
50
  await providerOwner.drainThreadEnvironmentTasks(sessionKey);
30
51
  journal.clearSessionRecoveryTimers(normalizedSessionId);
52
+ const prepareFailed = (error) => {
53
+ debugLog("appServerRuntime.closeSession.prepare.error", {
54
+ error: debugError(error),
55
+ sessionId: normalizedSessionId
56
+ });
57
+ return error;
58
+ };
31
59
  let prepared = null;
32
60
  let session = null;
33
61
  try {
34
62
  prepared = await cleanup.read();
35
- const restoration = await prepared.helpers;
36
- helperLifecycle.assertRestored(await helperLifecycle.restoreThreads(
37
- restoration.projectRuntimeRoot, restoration.context
38
- ));
63
+ session = prepared.session;
64
+ } catch (error) {
65
+ throw prepareFailed(error);
66
+ }
67
+ // Live work stops first, through the connections that observe it.
68
+ await conversations.closeCodexAppServerConversations(normalizedSessionId);
69
+ await restoreSavedHelpers(() => prepared.helpers, "appServerRuntime.closeSession.helperRestore.failed", {
70
+ sessionId: normalizedSessionId
71
+ });
72
+ try {
73
+ // Helper threads attached to a live provider must be gone before it stops.
39
74
  helperLifecycle.assertRetired(await helperLifecycle.retireAll({
40
75
  projectRuntimeRoot: prepared.projectRuntimeRoot,
41
76
  sessionId: normalizedSessionId
42
77
  }));
43
- session = prepared.session;
44
78
  } catch (error) {
45
- debugLog("appServerRuntime.closeSession.prepare.error", {
46
- error: debugError(error),
47
- sessionId: normalizedSessionId
48
- });
49
- throw error;
79
+ throw prepareFailed(error);
50
80
  }
51
- await conversations.closeCodexAppServerConversations(normalizedSessionId);
52
81
  const { exitFailure } = await providerOwner.releaseSession(sessionKey, {
53
82
  sessionId: normalizedSessionId,
54
83
  runOwner,
@@ -73,24 +102,42 @@ export function createCodexSessionCleanup({ namespace, runOwner, providerSession
73
102
  }
74
103
 
75
104
  async function closeProject(input = {}, preparation) {
76
- const restoration = await preparation.helpers;
77
- helperLifecycle.assertRestored(await helperLifecycle.restoreThreads(
78
- restoration.projectRuntimeRoot, restoration.context
79
- ));
80
105
  const sessionNamespacePrefix = namespace("");
106
+ const conversationFailures = [];
81
107
  for (const sessionKey of [...conversations.codexAppServerConversations.keys()]) {
82
108
  if (!sessionKey.startsWith(sessionNamespacePrefix)) {
83
109
  continue;
84
110
  }
85
- await conversations.closeCodexAppServerConversations(
86
- sessionKey.slice(sessionNamespacePrefix.length)
87
- );
111
+ const sessionId = sessionKey.slice(sessionNamespacePrefix.length);
112
+ try {
113
+ await conversations.closeCodexAppServerConversations(sessionId);
114
+ } catch (error) {
115
+ conversationFailures.push({
116
+ code: normalizeCodexRunText(error?.code),
117
+ error: normalizeCodexRunText(error?.message || error),
118
+ retryable: error?.retryable !== false,
119
+ sessionId
120
+ });
121
+ }
88
122
  }
89
- return providerOwner.stopProvidersForProject(input, {
123
+ const helperRestore = await restoreSavedHelpers(() => preparation.helpers,
124
+ "appServerRuntime.closeProject.helperRestore.failed",
125
+ { projectContextRoot: normalizeCodexRunText(input.projectContextRoot) });
126
+ // Every provider of the project's sessions stops, including one a
127
+ // Temporary chat or a read borrowed and no Main thread names.
128
+ const stopped = await providerOwner.stopProvidersForProject(input, {
90
129
  managed: providerSessions.managed,
91
130
  keyFields: providerSessions.keyFields,
92
- helperThreads: helperLifecycle
131
+ helperThreads: helperLifecycle,
132
+ sessionKeyPrefix: sessionNamespacePrefix
93
133
  });
134
+ const failed = [...conversationFailures, ...stopped.failed];
135
+ return {
136
+ ...stopped,
137
+ failed,
138
+ helperRestore: { failed: helperRestore.failed, ok: helperRestore.ok !== false },
139
+ ok: failed.length === 0
140
+ };
94
141
  }
95
142
 
96
143
  return { closeSession, closeProject };