@jskit-ai/assistant-core 0.2.1 → 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 (61) 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 +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  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 +272 -10
  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 +34 -14
  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 +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. 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 = "",
@@ -63,7 +120,8 @@ export function createCodexAppServerProviderOwner({
63
120
  runtimeBusyCode = "codex_app_server_runtime_busy",
64
121
  exitUnverifiedCode = "codex_runtime_exit_unverified",
65
122
  requiredStopCode = "codex_runtime_exit_unverified",
66
- runtimeCloseError = "Codex app-server runtime close failed."
123
+ runtimeCloseError = "Codex app-server runtime close failed.",
124
+ transientIdleMs = 60_000
67
125
  } = {}) {
68
126
  const scopeRoot = normalizeText(runtimeRoot) ? path.resolve(runtimeRoot) : "";
69
127
  const records = new Map();
@@ -73,6 +131,8 @@ export function createCodexAppServerProviderOwner({
73
131
  let closing = false;
74
132
  let runtimeLifecycle = null;
75
133
  let codexAppServerChatModelCatalog = null;
134
+ // Providers borrowed by work that is not a conversation, terminal or helper.
135
+ const transientReleases = new Map();
76
136
 
77
137
  const providers = {
78
138
  get: key => records.get(key)?.provider,
@@ -214,7 +274,14 @@ export function createCodexAppServerProviderOwner({
214
274
  current.executionRoot === identity.executionRoot &&
215
275
  current.runtimeInstanceId === identity.runtimeInstanceId &&
216
276
  current.workdir === identity.workdir) {
217
- 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);
218
285
  }
219
286
  }
220
287
  context.assertAdmission();
@@ -274,7 +341,85 @@ export function createCodexAppServerProviderOwner({
274
341
  return provider;
275
342
  }
276
343
 
344
+ // A Main conversation, terminal or helper keeps a provider a borrower created.
345
+ // Every exported way to reach a provider for real use claims it here.
346
+ function claimTransient(providerKey = "") {
347
+ const key = normalizeText(providerKey);
348
+ const release = transientReleases.get(key);
349
+ if (!release) return;
350
+ clearTimeout(release.timer);
351
+ transientReleases.delete(key);
352
+ }
353
+
277
354
  async function ensureSession(context, mainThreadId = "") {
355
+ claimTransient(context.providerKey);
356
+ return acquireSession(context, mainThreadId);
357
+ }
358
+
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) {
387
+ const providerKey = context.providerKey;
388
+ let release = transientReleases.get(providerKey);
389
+ if (!release && (records.has(providerKey) || context.runOwner?.runtimeLifecycle?.managedThreadId?.(providerKey))) {
390
+ return { provider: await acquireSession(context), release() {} };
391
+ }
392
+ if (!release) transientReleases.set(providerKey, release = { users: 0, timer: null });
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);
404
+ try {
405
+ return await operation(borrowed.provider);
406
+ } finally {
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) : () => {};
418
+ }
419
+ return () => {};
420
+ }
421
+
422
+ async function acquireSession(context, mainThreadId = "") {
278
423
  const sessionId = context.sessionId;
279
424
  const providerOptions = context.providerOptions;
280
425
  const providerKey = context.providerKey;
@@ -333,6 +478,15 @@ export function createCodexAppServerProviderOwner({
333
478
  if (closeProvider) record.provider.close?.();
334
479
  records.delete(providerKey);
335
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
+ }
336
490
  }
337
491
 
338
492
  async function stopOwnedRuntime(record = {}, {
@@ -668,7 +822,78 @@ export function createCodexAppServerProviderOwner({
668
822
  return { ok: failed.length === 0, failed, results, providerCount: results.length, stopped: results.reduce((sum, result) => sum + result.threadIds.length, 0) };
669
823
  }
670
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
+
671
895
  async function invalidateRuntimes({
896
+ deferBusy = false,
672
897
  includeOwned = false,
673
898
  requireVerifiedExit = false,
674
899
  stopOwnedRuntimes = false,
@@ -717,6 +942,28 @@ export function createCodexAppServerProviderOwner({
717
942
  targets.sort((left, right) => Number(Boolean(left.record)) - Number(Boolean(right.record)));
718
943
  const failed = [];
719
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
+ }
720
967
  for (const target of targets) {
721
968
  if (!target.resources?.invalidation) continue;
722
969
  const error = Object.assign(new Error("Codex's native runtime was invalidated."), {
@@ -798,9 +1045,11 @@ export function createCodexAppServerProviderOwner({
798
1045
  }
799
1046
  const stopped = results.filter((result) => result.stopped).length;
800
1047
  return {
1048
+ deferred: deferred.map(({ runtimeKey, providerKeys, threadIds, pausedGoals }) =>
1049
+ ({ runtimeKey, providerKeys: [...providerKeys], threadIds: [...threadIds], pausedGoals: [...pausedGoals] })),
801
1050
  failed,
802
1051
  ok: failed.length === 0,
803
- providerCount: targets.length,
1052
+ providerCount: targets.length + deferred.reduce((sum, stale) => sum + stale.providerKeys.length, 0),
804
1053
  results,
805
1054
  stopped
806
1055
  };
@@ -875,7 +1124,7 @@ export function createCodexAppServerProviderOwner({
875
1124
  preserveProcessExitProof = false,
876
1125
  projectContextRoot = "",
877
1126
  reason = "",
878
- } = {}, { managed, keyFields, helperThreads }) {
1127
+ } = {}, { managed, keyFields, helperThreads, sessionKeyPrefix = "" }) {
879
1128
  const normalizedProjectContextRoot = normalizeText(projectContextRoot);
880
1129
  if (!normalizedProjectContextRoot) {
881
1130
  return {
@@ -893,11 +1142,14 @@ export function createCodexAppServerProviderOwner({
893
1142
  projectContextRoot: normalizedProjectContextRoot
894
1143
  }).map((record) => record.provider)
895
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.
896
1147
  const providerKeys = [...providers.keys()]
897
1148
  .filter((providerKey) => {
898
1149
  const current = managed.get(providerKey);
899
1150
  return normalizeText(current?.projectContext?.targetRoot) === normalizedProjectContextRoot ||
900
- helperProviders.has(providers.get(providerKey));
1151
+ helperProviders.has(providers.get(providerKey)) ||
1152
+ Boolean(sessionKeyPrefix && normalizeText(providerOwners.get(providerKey)?.sessionKey).startsWith(sessionKeyPrefix));
901
1153
  });
902
1154
  const failed = [];
903
1155
  const results = [];
@@ -1156,9 +1408,11 @@ export function createCodexAppServerProviderOwner({
1156
1408
  return { exitFailure };
1157
1409
  }
1158
1410
 
1411
+ // A borrowed provider (a read, storage work or a Temporary chat) is not the
1412
+ // session's conversation.
1159
1413
  async function readPlanUsage(sessionKey) {
1160
1414
  for (const [key, provider] of providers) {
1161
- if (providerOwners.get(key)?.sessionKey === sessionKey &&
1415
+ if (providerOwners.get(key)?.sessionKey === sessionKey && !transientReleases.has(key) &&
1162
1416
  !provider.isHelperProvider() && provider.isAvailable()) {
1163
1417
  return provider.readPlanUsage();
1164
1418
  }
@@ -1169,7 +1423,7 @@ export function createCodexAppServerProviderOwner({
1169
1423
  async function readSessionGoal(sessionKey, threadId) {
1170
1424
  if (!threadId) return { status: "available", threadId: "", goal: null };
1171
1425
  for (const [key, provider] of providers) {
1172
- if (providerOwners.get(key)?.sessionKey === sessionKey &&
1426
+ if (providerOwners.get(key)?.sessionKey === sessionKey && !transientReleases.has(key) &&
1173
1427
  !provider.isHelperProvider() && provider.isAvailable()) {
1174
1428
  const result = await provider.readGoal(threadId);
1175
1429
  return { status: "available", threadId, goal: result.goal || null };
@@ -1180,11 +1434,19 @@ export function createCodexAppServerProviderOwner({
1180
1434
 
1181
1435
  const api = Object.freeze({
1182
1436
  providers, providerOwners, ownedRuntimes, lifecycleTasks, runtimeAcquisitions,
1437
+ isHeld: providerKey => (transientReleases.get(normalizeText(providerKey))?.users || 0) > 0,
1183
1438
  withLifecycle, runtimeIsShared, rememberOwnedRuntime, forgetOwnedRuntime, attachRuntimeLifecycle,
1184
- acquireRuntime, createProvider, createNativeProvider, providerForSession, ensureSession, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
1439
+ acquireRuntime: options => { claimTransient(options?.providerKey); return acquireRuntime(options); },
1440
+ createProvider: options => { claimTransient(options?.providerKey); return createProvider(options); },
1441
+ providerForSession: context => { claimTransient(context.providerKey); return providerForSession(context); },
1442
+ createNativeProvider, ensureSession, borrowSession, withTransientSession, holdProvider, retireAndCloseProvider, closeProvider, stopOwnedRuntime, stopCachedProvider, stopProvider, stopThreadBeforeRelease, readModelCatalog, invalidateRuntimes, recover,
1185
1443
  reportRecoveryEvent, pendingRecovery, readSessionGoal, readPlanUsage, stopCachedProvidersForSession, stopProvidersForProject, drainThreadEnvironmentTasks, stopPersistedRuntime, sessionExitProof,
1186
- unsubscribeSessionThread, releasePersistedRuntimeProof, releasePreparedRuntimeProof, releaseSession,
1187
- beginShutdown() { closing = true; }
1444
+ unsubscribeSessionThread, releasePersistedRuntimeProof, releasePreparedRuntimeProof, releaseSession, retireStaleRuntime,
1445
+ beginShutdown() {
1446
+ closing = true;
1447
+ for (const release of transientReleases.values()) clearTimeout(release.timer);
1448
+ transientReleases.clear();
1449
+ }
1188
1450
  });
1189
1451
  return api;
1190
1452
  }
@@ -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 };