@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.
- 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 +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- 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 +234 -29
- 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 +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +93 -0
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -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 +21 -1
- package/test/conversationNativeStorage.test.js +57 -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 = "",
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
390
|
+
return { provider: await acquireSession(context), release() {} };
|
|
304
391
|
}
|
|
305
392
|
if (!release) transientReleases.set(providerKey, release = { users: 0, timer: null });
|
|
306
|
-
|
|
307
|
-
|
|
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(
|
|
405
|
+
return await operation(borrowed.provider);
|
|
310
406
|
} finally {
|
|
311
|
-
release
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|