@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +272 -10
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +52 -1
- package/test/conversationNativeStorage.test.js +284 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- 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
|
-
|
|
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
|
|
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() {
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 };
|