@jskit-ai/assistant-core 0.2.16 → 0.2.18
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/CHANGELOG.md +1 -0
- package/examples/conversation/package.json +5 -5
- package/package.json +6 -6
- package/src/server/conversation/claudeNativeTurn.js +9 -1
- package/src/server/conversation/claudeSessionCleanup.js +9 -1
- package/src/server/conversation/claudeTurn.js +1 -0
- package/src/server/conversation/codexConversations.js +65 -6
- package/src/server/conversation/codexRunJournal.js +33 -1
- package/src/server/conversation/codexRuntimeLifecycle.js +20 -1
- package/src/server/conversation/codexTurn.js +41 -4
- package/src/server/conversation/codexTurnEndings.js +9 -6
- package/src/server/conversation/nativeConversationLifecycle.js +22 -0
- package/src/server/conversation/openCodeProcessOwner.js +11 -5
- package/src/server/conversation/openCodeRuntime.js +1 -0
- package/src/server/conversation/openCodeTurn.js +10 -5
- package/src/server/conversation/openCodeTurnMonitor.js +4 -2
- package/src/server/conversation/providers/claudeDriver.js +5 -0
- package/src/server/conversation/providers/codexDriver.js +5 -0
- package/src/server/conversation/providers/opencodeDriver.js +5 -0
- package/src/server/conversation/runtime.js +1 -0
- package/test/apiConversationDriver.test.js +8 -4
- package/test/claudeLifecycle.test.js +28 -0
- package/test/codexConversationsCheckpoint.test.js +2 -0
- package/test/codexLifecycleIsolation.test.js +2 -0
- package/test/codexShutdownStop.test.js +168 -0
- package/test/codexTemporaryProviderRelease.test.js +2 -0
- package/test/codexTurn.test.js +57 -0
- package/test/conversationApiFacilities.test.js +4 -3
- package/test/conversationAttachments.test.js +4 -4
- package/test/conversationReplacement.test.js +1 -1
- package/test/conversationSession.test.js +40 -10
- package/test/conversationTools.test.js +2 -2
- package/test/openCodeLifecycle.test.js +55 -3
|
@@ -478,9 +478,10 @@ export async function waitForOpenCodeFinalResponse(client, conversationId, turn,
|
|
|
478
478
|
/**
|
|
479
479
|
* Production detached-turn lifetime. Applications project the completed native
|
|
480
480
|
* result before observation is closed, then publish/checkpoint their own state.
|
|
481
|
-
* A turn the server's shutdown stopped (`turn.stoppedByShutdown`)
|
|
482
|
-
*
|
|
483
|
-
* application leaves its checkpoint to
|
|
481
|
+
* A turn the server's shutdown stopped (`turn.stoppedByShutdown`), or that
|
|
482
|
+
* ends on its own once shutdown has begun, ends interrupted however its wait
|
|
483
|
+
* ended, and `afterClose` is told so: the application leaves its checkpoint to
|
|
484
|
+
* the next server.
|
|
484
485
|
* @param {import("./openCodeProcessOwner.js").OpenCodeProcessOwner} processOwner
|
|
485
486
|
*/
|
|
486
487
|
export async function observeOpenCodeTurnCompletion(processOwner, target, conversationId, turn, {
|
|
@@ -489,6 +490,10 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
|
|
|
489
490
|
const turnAbort = turn.abortController;
|
|
490
491
|
let stopped = true;
|
|
491
492
|
let outcome = "completed";
|
|
493
|
+
const leftToNextServer = () => {
|
|
494
|
+
if (processOwner.closed) turn.stoppedByShutdown = true;
|
|
495
|
+
return turn.stoppedByShutdown === true;
|
|
496
|
+
};
|
|
492
497
|
try {
|
|
493
498
|
const options = { ...waitOptions, signal: AbortSignal.any([
|
|
494
499
|
signal, ...(timeoutMs ? [AbortSignal.timeout(timeoutMs)] : [])
|
|
@@ -496,14 +501,14 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
|
|
|
496
501
|
const completion = finalResponse
|
|
497
502
|
? await waitForOpenCodeFinalResponse(target.server.client, conversationId, turn, { ...finalResponse, ...options })
|
|
498
503
|
: await waitForOpenCodeMessages(target.server.client, conversationId, () => turn.inputMessageId, options);
|
|
499
|
-
if (
|
|
504
|
+
if (leftToNextServer()) {
|
|
500
505
|
outcome = "interrupted";
|
|
501
506
|
return { interrupted: true };
|
|
502
507
|
}
|
|
503
508
|
return await projectResult(completion);
|
|
504
509
|
} catch (error) {
|
|
505
510
|
const failure = signal.aborted ? signal.reason : error;
|
|
506
|
-
outcome =
|
|
511
|
+
outcome = leftToNextServer() || turn.interrupted ? "interrupted" : "failed";
|
|
507
512
|
if (outcome === "interrupted") return { interrupted: true };
|
|
508
513
|
if (!target.abortController.signal.aborted) {
|
|
509
514
|
try {
|
|
@@ -355,8 +355,10 @@ export function createOpenCodeTurnMonitor({ processes, monitors, turns, processO
|
|
|
355
355
|
// server: its saved run stays active and carries the shutdown's mark,
|
|
356
356
|
// so that server's recovery records the turn interrupted and runs the
|
|
357
357
|
// host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
|
|
358
|
-
//
|
|
359
|
-
//
|
|
358
|
+
// could be refused or hold the host's bounded shutdown for as long as
|
|
359
|
+
// it takes. A turn that ends on its own once shutdown has begun is left
|
|
360
|
+
// the same way. A user's Stop outside shutdown ends its turn here.
|
|
361
|
+
if (processOwner.closed && finalState !== "active") turn.stoppedByShutdown = true;
|
|
360
362
|
const nextServerEnds = turn.stoppedByShutdown === true && finalState !== "active";
|
|
361
363
|
// Claim retirement before awaited cleanup/checkpointing. A new Send
|
|
362
364
|
// joins this monitor's cleanup instead of steering an unobserved turn.
|
|
@@ -346,6 +346,10 @@ export function createClaudeConversationDriver({
|
|
|
346
346
|
return native.owner.closeProject(input, native.application);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
function beginShutdown({ native }) {
|
|
350
|
+
return native.owner.beginShutdown();
|
|
351
|
+
}
|
|
352
|
+
|
|
349
353
|
function invalidateRuntimes({ native, input }) {
|
|
350
354
|
return native.owner.invalidateRuntimes(input, native.application, native.account);
|
|
351
355
|
}
|
|
@@ -568,6 +572,7 @@ export function createClaudeConversationDriver({
|
|
|
568
572
|
listConversationStorage,
|
|
569
573
|
retireConversationHistory,
|
|
570
574
|
closeProject,
|
|
575
|
+
beginShutdown,
|
|
571
576
|
invalidateRuntimes,
|
|
572
577
|
reconcileSessions,
|
|
573
578
|
createConversation,
|
|
@@ -63,6 +63,10 @@ function nativeGoalChanged() {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function beginShutdown({ native }) {
|
|
67
|
+
return native.runOwner.beginShutdown();
|
|
68
|
+
}
|
|
69
|
+
|
|
66
70
|
/**
|
|
67
71
|
* Conversations own threads and observers on the account's shared native runtime.
|
|
68
72
|
* @param {object} dependencies
|
|
@@ -653,6 +657,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
653
657
|
deleteDetachedConversation,
|
|
654
658
|
listConversationStorage,
|
|
655
659
|
retireConversationHistory,
|
|
660
|
+
beginShutdown,
|
|
656
661
|
invalidateRuntimes,
|
|
657
662
|
reconcileSessions,
|
|
658
663
|
unsubscribeSessions,
|
|
@@ -126,6 +126,10 @@ export function createOpenCodeConversationDriver({
|
|
|
126
126
|
return native.owner.closeProject(input, native.preparation.projectCleanup());
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
function beginShutdown({ native }) {
|
|
130
|
+
return native.owner.beginShutdown();
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
function invalidateRuntimes({ native, input }) {
|
|
130
134
|
return native.owner.invalidateRuntimes(input, native.preparation);
|
|
131
135
|
}
|
|
@@ -375,6 +379,7 @@ export function createOpenCodeConversationDriver({
|
|
|
375
379
|
listConversationStorage,
|
|
376
380
|
retireConversationHistory,
|
|
377
381
|
closeProject,
|
|
382
|
+
beginShutdown,
|
|
378
383
|
invalidateRuntimes,
|
|
379
384
|
reconcileSessions,
|
|
380
385
|
createConversation,
|
|
@@ -227,6 +227,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
227
227
|
runNativeDetachedConversation: lifecycle.runNativeDetachedConversation,
|
|
228
228
|
open: handles.open,
|
|
229
229
|
closeNativeProject: lifecycle.closeNativeProject,
|
|
230
|
+
beginNativeShutdown: lifecycle.beginNativeShutdown,
|
|
230
231
|
invalidateNativeRuntimes: lifecycle.invalidateNativeRuntimes,
|
|
231
232
|
reconcileNativeSessions: lifecycle.reconcileNativeSessions,
|
|
232
233
|
unsubscribeNativeSessions: lifecycle.unsubscribeNativeSessions,
|
|
@@ -6,7 +6,8 @@ import { createApiConversationDriver } from "../src/server/conversation/provider
|
|
|
6
6
|
const CHECKS = checkTexts("the app");
|
|
7
7
|
const frame = (delta, finish_reason) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
|
|
8
8
|
const schema = tool => ({ type: "function", function: { name: tool.name, parameters: tool.parameters } });
|
|
9
|
-
|
|
9
|
+
// The engine's state note ends every request's conversation; what the app sent last comes before it.
|
|
10
|
+
const lastText = body => body.messages.at(-2).content;
|
|
10
11
|
|
|
11
12
|
// One step of a streamed reply: words, an action call or both. Calls the engine
|
|
12
13
|
// makes without streaming (accounts, checks, summaries) answer with `once`.
|
|
@@ -113,7 +114,10 @@ test("admission precedes every model call, the state note gives the time, and a
|
|
|
113
114
|
await f.run("What day is it today?");
|
|
114
115
|
assert.deepEqual(f.order, ["accept", "model"]);
|
|
115
116
|
assert.deepEqual(f.executions, [], "No action runs before the model asks for one");
|
|
116
|
-
assert.
|
|
117
|
+
assert.equal(f.requests[0].messages[0].content, "Use the supplied actions.", "The state note is not in the system prompt");
|
|
118
|
+
assert.equal(f.requests[0].messages.at(-1).role, "user");
|
|
119
|
+
assert.match(f.requests[0].messages.at(-1).content, /^Current state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\. The person may be in another time zone\.$/u,
|
|
120
|
+
"The state note follows the conversation, so the history before it stays in the provider's prompt cache");
|
|
117
121
|
const denied = fixture({ authorize: () => { throw new Error("Connection access revoked"); } });
|
|
118
122
|
await assert.rejects(denied.run(), /Connection access revoked/);
|
|
119
123
|
assert.deepEqual(denied.requests, []);
|
|
@@ -223,7 +227,7 @@ test("a long history is compacted: the summary and its last turn are saved, and
|
|
|
223
227
|
assert.ok(kept.length > 0 && kept.length < turns.length);
|
|
224
228
|
const [request] = f.streamed;
|
|
225
229
|
assert.match(request.messages[0].content, /Summary of the earlier part of this conversation[^]*The person asked six long questions\./u);
|
|
226
|
-
assert.deepEqual(request.messages.filter(message => message.role === "user").map(message => message.content),
|
|
230
|
+
assert.deepEqual(request.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
|
|
227
231
|
[...kept.map(turn => turn.user.text), "Next question"]);
|
|
228
232
|
assert.deepEqual(phases, ["compacting", "working"]);
|
|
229
233
|
});
|
|
@@ -248,5 +252,5 @@ test("a short history is read whole with its stored summary, and nothing is save
|
|
|
248
252
|
assert.deepEqual(saved, []);
|
|
249
253
|
assert.equal(f.requests.length, 1);
|
|
250
254
|
assert.match(f.requests[0].messages[0].content, /Summary of the earlier part of this conversation[^]*Earlier: hello\./u);
|
|
251
|
-
assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), ["Hi", "Hello", "Next question"]);
|
|
255
|
+
assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), ["Hi", "Hello", "Next question"]);
|
|
252
256
|
});
|
|
@@ -242,6 +242,34 @@ test("Claude shutdown leaves a running turn's end and its checkpoint to the next
|
|
|
242
242
|
assert.deepEqual(custody(JSON.parse(f.session("a").records.get(MAIN_IDS.a))), { executionId: "", state: "interrupted" });
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
test("once Claude's shutdown has begun, a turn that ends on its own is left to the next server, which runs its one checkpoint", { timeout: 5_000 }, async t => {
|
|
246
|
+
const f = await lifecycleFixture(t);
|
|
247
|
+
const a = await f.running("a");
|
|
248
|
+
const saved = f.session("a").records.get(MAIN_IDS.a);
|
|
249
|
+
f.owner.beginShutdown();
|
|
250
|
+
assert.deepEqual(f.calls.stops, [], "beginning the shutdown stopped nothing");
|
|
251
|
+
// The turn ends on its own.
|
|
252
|
+
await f.processes[0].options.onEvent({ type: "result", subtype: "success", result: "Done." });
|
|
253
|
+
assert.deepEqual(f.calls.checkpoints, [], "the turn's end ran no checkpoint");
|
|
254
|
+
assert.equal(f.session("a").records.get(MAIN_IDS.a), saved, "the saved record keeps the active turn");
|
|
255
|
+
assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
|
|
256
|
+
assert.equal(a.process, null, "the full stop still stopped the process");
|
|
257
|
+
assert.deepEqual(f.calls.checkpoints, []);
|
|
258
|
+
// The next server's close proves that exit, then interrupts the turn through its checkpoint, once.
|
|
259
|
+
f.restart();
|
|
260
|
+
assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
|
|
261
|
+
assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
|
|
262
|
+
assert.deepEqual(f.calls.checkpoints, ["a:interrupted"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("outside shutdown a Claude turn that ends on its own still runs its checkpoint at once", { timeout: 5_000 }, async t => {
|
|
266
|
+
const f = await lifecycleFixture(t);
|
|
267
|
+
await f.running("a");
|
|
268
|
+
await f.processes[0].options.onEvent({ type: "result", subtype: "success", result: "Done." });
|
|
269
|
+
assert.deepEqual(f.calls.checkpoints, ["a:completed"]);
|
|
270
|
+
assert.equal(JSON.parse(f.session("a").records.get(MAIN_IDS.a)).state, "completed");
|
|
271
|
+
});
|
|
272
|
+
|
|
245
273
|
test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
|
|
246
274
|
const f = await lifecycleFixture(t);
|
|
247
275
|
await f.idle("a");
|
|
@@ -20,6 +20,8 @@ function persistentConversationFixture() {
|
|
|
20
20
|
};
|
|
21
21
|
const commands = createCodexConversationCommands({
|
|
22
22
|
async checkpoint(_sessionId, input) { checkpoints.push(input); },
|
|
23
|
+
// Outside shutdown, with no request contexts.
|
|
24
|
+
isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
|
|
23
25
|
conversationPreparation: { async context() { return { result: { provider } }; } },
|
|
24
26
|
namespace: (sessionId) => sessionId,
|
|
25
27
|
// The provider owner still holds the provider observing the conversation;
|
|
@@ -231,6 +231,8 @@ function conversationCommands({ context = async () => ({ result: { ok: false, co
|
|
|
231
231
|
providerSessions = {} } = {}) {
|
|
232
232
|
return createCodexConversationCommands({
|
|
233
233
|
checkpoint: async () => {},
|
|
234
|
+
// Outside shutdown, with no request contexts.
|
|
235
|
+
isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
|
|
234
236
|
conversationPreparation: {
|
|
235
237
|
context,
|
|
236
238
|
scope,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
|
|
4
|
+
import { createCodexRuntimeLifecycle } from "../src/server/conversation/codexRuntimeLifecycle.js";
|
|
5
|
+
|
|
6
|
+
// A Temporary chat's Codex turn that the server's shutdown stops, or that ends
|
|
7
|
+
// while the server is stopping, leaves its checkpoint to the next server: the
|
|
8
|
+
// application records it as owed, and that server's next read makes it once.
|
|
9
|
+
|
|
10
|
+
const turnOutcomes = { USER_CANCELLED: "user_cancelled", SERVICE_RESTART: "service_restart" };
|
|
11
|
+
|
|
12
|
+
// One server's observed conversation. `latest` is the turn Codex's history
|
|
13
|
+
// reports to a read; turn notifications reach the conversation's observer.
|
|
14
|
+
function server({ closing = false, latest = { id: "turn-1", status: "inProgress", items: [] }, threadError = null } = {}) {
|
|
15
|
+
const checkpoints = [];
|
|
16
|
+
const deferred = [];
|
|
17
|
+
const listeners = new Set();
|
|
18
|
+
const emit = notification => listeners.forEach(listener => listener(notification));
|
|
19
|
+
const provider = {
|
|
20
|
+
subscribe(listener) {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => listeners.delete(listener);
|
|
23
|
+
},
|
|
24
|
+
async readThreadStatus(threadId) {
|
|
25
|
+
return { raw: { id: threadId, historyMode: "paginated", status: "idle", ...(threadError ? { error: threadError } : {}) } };
|
|
26
|
+
},
|
|
27
|
+
async listThreadTurns() { return { data: [latest] }; },
|
|
28
|
+
async readGoal() { return { goal: null }; },
|
|
29
|
+
async stopThreadForObservationLoss() {}
|
|
30
|
+
};
|
|
31
|
+
const lifecycle = { closing };
|
|
32
|
+
const commands = createCodexConversationCommands({
|
|
33
|
+
async checkpoint(sessionId, input) { checkpoints.push({ sessionId, ...input }); },
|
|
34
|
+
async deferCheckpoint(sessionId, input) { deferred.push({ sessionId, ...input }); },
|
|
35
|
+
isClosing: () => lifecycle.closing,
|
|
36
|
+
captureContext: () => null,
|
|
37
|
+
runInContext: (_context, operation) => operation(),
|
|
38
|
+
codexAppServerConversationMessageId: (...parts) => parts.join(":"),
|
|
39
|
+
conversationPreparation: { async context() { return { result: { provider } }; } },
|
|
40
|
+
namespace: sessionId => sessionId,
|
|
41
|
+
providerSessions: { owner: { providers: new Map([["provider", provider]]), holdProvider: () => () => {} } },
|
|
42
|
+
turnOutcomes
|
|
43
|
+
});
|
|
44
|
+
const startTurn = () => {
|
|
45
|
+
commands.observeCodexConversation("session", "thread", provider);
|
|
46
|
+
emit({ method: "turn/started", params: { threadId: "thread", turn: { id: "turn-1", status: "inProgress" } } });
|
|
47
|
+
};
|
|
48
|
+
const complete = status => emit({ method: "turn/completed", params: { threadId: "thread", turn: { id: "turn-1", status } } });
|
|
49
|
+
const read = stoppedByShutdown => commands.readPersistentCodexConversation("session", { conversationId: "thread", stoppedByShutdown },
|
|
50
|
+
{ provider }).then(({ status, error, runId }) => ({ status, error, runId }));
|
|
51
|
+
return { checkpoints, deferred, commands, lifecycle, startTurn, complete, read };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const OWED = { sessionId: "session", outerTurnId: "codex:thread:turn-1", threadId: "thread", turnId: "turn-1" };
|
|
55
|
+
|
|
56
|
+
test("shutdown leaves a running Temporary turn's interrupted checkpoint to the next server, which makes it once on its read", async () => {
|
|
57
|
+
const first = server();
|
|
58
|
+
first.startTurn();
|
|
59
|
+
first.lifecycle.closing = true;
|
|
60
|
+
await first.commands.leaveRunningTurnsToNextServer();
|
|
61
|
+
assert.deepEqual(first.checkpoints, [], "shutdown made no checkpoint");
|
|
62
|
+
assert.deepEqual(first.deferred, [{ ...OWED, status: "interrupted", turnOutcome: "service_restart" }]);
|
|
63
|
+
|
|
64
|
+
// The stopped process ends the turn natively as interrupted.
|
|
65
|
+
const next = server({ latest: { id: "turn-1", status: "interrupted", items: [] }, threadError: { message: "Turn aborted." } });
|
|
66
|
+
const ended = [];
|
|
67
|
+
const mark = runId => ({ runId, async ended() { ended.push(runId); } });
|
|
68
|
+
assert.deepEqual(await next.read(mark("turn-1")), { status: "interrupted", error: "", runId: "turn-1" });
|
|
69
|
+
assert.deepEqual(ended, ["turn-1"], "recording the stopped run lets the application make its checkpoint");
|
|
70
|
+
assert.deepEqual(await next.read(undefined), { status: "interrupted", error: "Turn aborted.", runId: "turn-1" },
|
|
71
|
+
"an unmarked interruption keeps Codex's own report");
|
|
72
|
+
await next.read(mark("turn-0"));
|
|
73
|
+
assert.deepEqual(ended, ["turn-1"], "only the marked run is recorded as ended");
|
|
74
|
+
assert.deepEqual(next.checkpoints, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a Temporary turn that ends on its own while the server stops leaves its checkpoint, with its own outcome, to the next server", async () => {
|
|
78
|
+
const f = server();
|
|
79
|
+
f.startTurn();
|
|
80
|
+
f.lifecycle.closing = true;
|
|
81
|
+
f.complete("completed");
|
|
82
|
+
await Promise.all(f.commands.deferredCheckpoints);
|
|
83
|
+
assert.deepEqual(f.checkpoints, []);
|
|
84
|
+
assert.deepEqual(f.deferred, [{ ...OWED, status: "completed", turnOutcome: "" }]);
|
|
85
|
+
assert.equal(f.commands.deferredCheckpoints.size, 0, "shutdown awaits each deferred record");
|
|
86
|
+
|
|
87
|
+
// The next server reads the completed turn as completed, and makes its checkpoint.
|
|
88
|
+
const next = server({ latest: { id: "turn-1", status: "completed", items: [] } });
|
|
89
|
+
const ended = [];
|
|
90
|
+
assert.equal((await next.read({ runId: "turn-1", async ended() { ended.push("turn-1"); } })).status, "completed");
|
|
91
|
+
assert.deepEqual(ended, ["turn-1"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("outside shutdown a Temporary turn's end still makes its checkpoint at once", async () => {
|
|
95
|
+
const f = server();
|
|
96
|
+
f.startTurn();
|
|
97
|
+
f.complete("interrupted");
|
|
98
|
+
assert.deepEqual(f.checkpoints, [{ ...OWED, status: "interrupted", turnOutcome: "" }]);
|
|
99
|
+
assert.deepEqual(f.deferred, []);
|
|
100
|
+
await f.commands.leaveRunningTurnsToNextServer();
|
|
101
|
+
assert.deepEqual(f.deferred, [], "a finished turn owes nothing at shutdown");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The run owner's runtime lifecycle, with the owner calls it makes recorded.
|
|
105
|
+
function runtimeLifecycle(calls, leaveTurnsToNextServer) {
|
|
106
|
+
return createCodexRuntimeLifecycle({
|
|
107
|
+
pendingWork: { conversationTurnStarts: new Map(), messageDeliveries: new Map(), reasoningPersistQueues: new Map(),
|
|
108
|
+
resultFinalizations: new Map(), threadReconciliations: new Map(), notificationTasks: new Map(), deferredCheckpoints: new Set() },
|
|
109
|
+
observer: { eventSubscriptions: new Map() },
|
|
110
|
+
checkManagedConnection: async () => {},
|
|
111
|
+
providerSessions: { managed: new Map(), assertOpen() {}, owner: {
|
|
112
|
+
lifecycleTasks: new Set(), runtimeAcquisitions: new Set(),
|
|
113
|
+
beginShutdown() { calls.push("owner-shutdown"); },
|
|
114
|
+
async invalidateRuntimes() { calls.push("stop-runtimes"); return { failed: [], providerCount: 0, results: [], stopped: 0 }; }
|
|
115
|
+
} },
|
|
116
|
+
helperLifecycle: { projectOperations: new Map(), cleanups: new Map(), mutations: new Map(), restores: new Map(), turnStarts: new Map() },
|
|
117
|
+
captureContext: () => null,
|
|
118
|
+
runInContext: (_context, operation) => operation(),
|
|
119
|
+
debugLog() {},
|
|
120
|
+
debugError: error => error?.message,
|
|
121
|
+
leaveTurnsToNextServer
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("a Codex shutdown leaves the running turns to the next server before any runtime stops, and waits for their records", async () => {
|
|
126
|
+
const calls = [];
|
|
127
|
+
const recorded = Promise.withResolvers();
|
|
128
|
+
const lifecycle = runtimeLifecycle(calls, async () => {
|
|
129
|
+
calls.push("leave-turns");
|
|
130
|
+
await recorded.promise;
|
|
131
|
+
calls.push("turns-recorded");
|
|
132
|
+
});
|
|
133
|
+
const shutdown = lifecycle.invalidateRuntimes({ reason: "server-shutdown" }, { enabled: true });
|
|
134
|
+
assert.equal(lifecycle.closing, true);
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
recorded.resolve();
|
|
137
|
+
await shutdown;
|
|
138
|
+
assert.equal(calls[0], "leave-turns");
|
|
139
|
+
assert.ok(calls.indexOf("turns-recorded") < calls.lastIndexOf("stop-runtimes"), "shutdown waited for the records");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a begun Codex shutdown stops nothing, and a Temporary turn that then ends on its own leaves its checkpoint to the next server", async () => {
|
|
143
|
+
const f = server();
|
|
144
|
+
f.startTurn();
|
|
145
|
+
const calls = [];
|
|
146
|
+
const lifecycle = runtimeLifecycle(calls, () => f.commands.leaveRunningTurnsToNextServer());
|
|
147
|
+
Object.defineProperty(f.lifecycle, "closing", { get: () => lifecycle.closing });
|
|
148
|
+
lifecycle.beginShutdown();
|
|
149
|
+
assert.equal(lifecycle.closing, true, "the closing fence is set before the call returns");
|
|
150
|
+
assert.deepEqual(calls, ["owner-shutdown"], "beginning the shutdown stopped nothing");
|
|
151
|
+
f.complete("completed");
|
|
152
|
+
await Promise.all(f.commands.deferredCheckpoints);
|
|
153
|
+
assert.deepEqual(f.checkpoints, [], "the turn's end made no checkpoint");
|
|
154
|
+
assert.deepEqual(f.deferred.at(-1), { ...OWED, status: "completed", turnOutcome: "" }, "its own outcome is owed");
|
|
155
|
+
|
|
156
|
+
// The full stop that follows still stops the runtimes, and leaves the turns only once.
|
|
157
|
+
const deferred = f.deferred.length;
|
|
158
|
+
await lifecycle.invalidateRuntimes({ reason: "server-shutdown" }, { enabled: true });
|
|
159
|
+
assert.ok(calls.includes("stop-runtimes"));
|
|
160
|
+
assert.equal(f.deferred.length, deferred);
|
|
161
|
+
assert.deepEqual(f.checkpoints, []);
|
|
162
|
+
|
|
163
|
+
// The next server's read makes the owed checkpoint once.
|
|
164
|
+
const next = server({ latest: { id: "turn-1", status: "completed", items: [] } });
|
|
165
|
+
const ended = [];
|
|
166
|
+
assert.equal((await next.read({ runId: "turn-1", async ended() { ended.push("turn-1"); } })).status, "completed");
|
|
167
|
+
assert.deepEqual(ended, ["turn-1"]);
|
|
168
|
+
});
|
|
@@ -53,6 +53,8 @@ function codexFixture(t, { idleMs = 20 } = {}) {
|
|
|
53
53
|
});
|
|
54
54
|
const commands = createCodexConversationCommands({
|
|
55
55
|
async checkpoint() {},
|
|
56
|
+
// Outside shutdown, with no request contexts.
|
|
57
|
+
isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
|
|
56
58
|
codexAppServerConversationMessageId: (...parts) => parts.join(":"),
|
|
57
59
|
codexAppServerStreamMessage: () => ({}),
|
|
58
60
|
conversationPreparation: {
|
package/test/codexTurn.test.js
CHANGED
|
@@ -3856,3 +3856,60 @@ test("native history budget rejects unavailable dynamic limits before pending de
|
|
|
3856
3856
|
assert.equal(sends, 0);
|
|
3857
3857
|
}
|
|
3858
3858
|
});
|
|
3859
|
+
|
|
3860
|
+
test("a Main turn the previous server's shutdown stopped reads as interrupted by a restart, with one interrupted checkpoint", async () => {
|
|
3861
|
+
const notices = [];
|
|
3862
|
+
const checkpoints = [];
|
|
3863
|
+
const f = await receiptFixture({
|
|
3864
|
+
outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); },
|
|
3865
|
+
checkpoint(_sessionId, input) { checkpoints.push(input); }
|
|
3866
|
+
});
|
|
3867
|
+
f.provider.interruptionOutcome = () => "";
|
|
3868
|
+
// The shutdown marks the running turn and leaves the rest of its saved run as it was.
|
|
3869
|
+
assert.deepEqual(await f.owner.markTurnStoppedByShutdown(f.sessionId, f.threadId), { marked: true });
|
|
3870
|
+
assert.deepEqual({ state: f.run.state, stoppedByShutdownTurnId: f.run.stoppedByShutdownTurnId },
|
|
3871
|
+
{ state: "active", stoppedByShutdownTurnId: f.turnId });
|
|
3872
|
+
assert.deepEqual(await f.owner.markTurnStoppedByShutdown(f.sessionId, "another-thread"), { marked: false });
|
|
3873
|
+
// The next server reads the native turn as interrupted.
|
|
3874
|
+
const result = await f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
|
|
3875
|
+
status: "interrupted", provider: f.provider, verifyInactive: false
|
|
3876
|
+
});
|
|
3877
|
+
assert.equal(result.status, "interrupted");
|
|
3878
|
+
assert.match(result.error, /interrupted by a restart/u);
|
|
3879
|
+
assert.deepEqual(notices, ["service_restart"]);
|
|
3880
|
+
assert.deepEqual(checkpoints.map(({ status, turnOutcome }) => ({ status, turnOutcome })),
|
|
3881
|
+
[{ status: "interrupted", turnOutcome: "service_restart" }]);
|
|
3882
|
+
assert.equal(f.run.state, "interrupted");
|
|
3883
|
+
});
|
|
3884
|
+
|
|
3885
|
+
test("an interrupted Main turn without the shutdown's mark keeps the plain interrupted outcome", async () => {
|
|
3886
|
+
const notices = [];
|
|
3887
|
+
const checkpoints = [];
|
|
3888
|
+
const f = await receiptFixture({
|
|
3889
|
+
outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); },
|
|
3890
|
+
checkpoint(_sessionId, input) { checkpoints.push(input); }
|
|
3891
|
+
});
|
|
3892
|
+
f.provider.interruptionOutcome = () => "";
|
|
3893
|
+
// A mark left by an earlier turn does not apply to this one.
|
|
3894
|
+
f.run = { ...f.run, stoppedByShutdownTurnId: "an-earlier-turn" };
|
|
3895
|
+
await f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
|
|
3896
|
+
status: "interrupted", provider: f.provider, verifyInactive: false
|
|
3897
|
+
});
|
|
3898
|
+
assert.deepEqual(notices, ["interrupted"]);
|
|
3899
|
+
assert.deepEqual(checkpoints.map(({ turnOutcome }) => turnOutcome), ["interrupted"]);
|
|
3900
|
+
});
|
|
3901
|
+
|
|
3902
|
+
test("a Main turn that ends once shutdown has begun is left to the next server, with no checkpoint now", async () => {
|
|
3903
|
+
const f = await settlementFixture();
|
|
3904
|
+
f.owner.runtimeLifecycle = { closing: true };
|
|
3905
|
+
const before = structuredClone(f.run);
|
|
3906
|
+
assert.deepEqual(await f.owner.markTurnIdle(f.sessionId, { status: "completed", threadId: f.threadId, turnId: f.turnId }),
|
|
3907
|
+
{ ok: true, processed: false, reason: "left_to_next_server" });
|
|
3908
|
+
assert.deepEqual(f.run, before, "the saved run is unchanged");
|
|
3909
|
+
assert.deepEqual(f.checkpoints, []);
|
|
3910
|
+
// Outside shutdown the same end is recorded and checkpointed at once.
|
|
3911
|
+
f.owner.runtimeLifecycle = { closing: false };
|
|
3912
|
+
await f.owner.markTurnIdle(f.sessionId, { status: "completed", threadId: f.threadId, turnId: f.turnId });
|
|
3913
|
+
assert.equal(f.run.state, "completed");
|
|
3914
|
+
assert.deepEqual(f.checkpoints.map(({ status }) => status), ["completed"]);
|
|
3915
|
+
});
|
|
@@ -65,7 +65,7 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
|
|
|
65
65
|
assert.deepEqual(requests[0][1], { role: "user", content: [
|
|
66
66
|
{ type: "text", text: "Selected prior context" }, { type: "text", text: "Authorized historical bytes" }
|
|
67
67
|
] });
|
|
68
|
-
assert.deepEqual(requests[0].at(-
|
|
68
|
+
assert.deepEqual(requests[0].at(-2), { role: "user", content: [
|
|
69
69
|
{ type: "text", text: "Current question" }, { type: "text", text: "Authorized current bytes" }
|
|
70
70
|
] });
|
|
71
71
|
const files = await readdir(directory);
|
|
@@ -84,8 +84,9 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
|
|
|
84
84
|
context.history = [];
|
|
85
85
|
await conversation.send({ messageId: "empty-context", text: "No prior model context" });
|
|
86
86
|
const last = await conversation.wait();
|
|
87
|
-
assert.
|
|
88
|
-
assert.deepEqual(requests[2].slice(1).map(message => message.content), ["No prior model context"]);
|
|
87
|
+
assert.equal(requests[2][0].content, "Existing instructions");
|
|
88
|
+
assert.deepEqual(requests[2].slice(1, -1).map(message => message.content), ["No prior model context"]);
|
|
89
|
+
assert.match(requests[2].at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
|
|
89
90
|
assert.equal(last.conversationLog.length, 3, "Explicitly empty model context never removes saved conversation history");
|
|
90
91
|
await assert.rejects(conversation.send({ messageId: "x".repeat(129), text: "Must not dispatch" }), { code: "conversation_invalid_message" });
|
|
91
92
|
assert.equal(requests.length, 3);
|
|
@@ -147,7 +147,7 @@ test("authorized image bytes reach the API once and only safe receipts enter sto
|
|
|
147
147
|
assert.equal(f.reads[0].context, context);
|
|
148
148
|
assert.equal(f.reads[0].conversationId, "one");
|
|
149
149
|
assert.deepEqual(f.reads[0].attachmentIds, [receipt.attachmentId]);
|
|
150
|
-
assert.deepEqual(f.requests[0].messages.at(-
|
|
150
|
+
assert.deepEqual(f.requests[0].messages.at(-2).content, [
|
|
151
151
|
{ type: "text", text: input.text }, { type: "image_url", image_url: { url: `data:image/png;base64,${image.toString("base64")}` } }
|
|
152
152
|
]);
|
|
153
153
|
assert.deepEqual(state.conversationLog[0].user.attachments, [receipt]);
|
|
@@ -174,7 +174,7 @@ test("API history and continuity reauthorize files after reopen and renewal", as
|
|
|
174
174
|
await resumed.conversation.replace({ operationId: "renew", reason: "renewal", expectedSegmentId: first.segmentId });
|
|
175
175
|
await resumed.conversation.send({ messageId: "third", text: "Look again" });
|
|
176
176
|
await resumed.conversation.wait();
|
|
177
|
-
assert.equal(resumed.requests[1].messages.at(-
|
|
177
|
+
assert.equal(resumed.requests[1].messages.at(-2).content[1].type, "image_url");
|
|
178
178
|
allowed = false;
|
|
179
179
|
await assert.rejects(resumed.conversation.send({ messageId: "denied", text: "Look again" }), /attachment access/);
|
|
180
180
|
assert.equal(resumed.requests.length, 2);
|
|
@@ -185,8 +185,8 @@ test("an image-only request is accepted without invented user text", async t =>
|
|
|
185
185
|
const f = await fixture(t);
|
|
186
186
|
await f.conversation.send({ ...input, text: "" });
|
|
187
187
|
const state = await f.conversation.wait();
|
|
188
|
-
assert.equal(f.requests[0].messages.at(-
|
|
189
|
-
assert.equal(f.requests[0].messages.at(-
|
|
188
|
+
assert.equal(f.requests[0].messages.at(-2).content.length, 1);
|
|
189
|
+
assert.equal(f.requests[0].messages.at(-2).content[0].type, "image_url");
|
|
190
190
|
assert.equal(state.conversationLog[0].user.text, "");
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -60,7 +60,7 @@ test("renewal preserves logical history and receipts while subsequent inference
|
|
|
60
60
|
assert.equal(f.requests.length, 1, "Replacement itself does not perform inference");
|
|
61
61
|
await f.say("Continue", "second");
|
|
62
62
|
const sent = f.requests[1].messages;
|
|
63
|
-
assert.equal(sent.at(-
|
|
63
|
+
assert.equal(sent.at(-2).content, "Continue", "The request comes last, before the engine's state note");
|
|
64
64
|
assert.match(sent[1].content, /Retain the agreed colour: blue/);
|
|
65
65
|
assert.match(sent[1].content, /Keep my context/);
|
|
66
66
|
assert.match(sent[1].content, /Reply 1/);
|
|
@@ -82,9 +82,9 @@ test("saved history and current instructions use the existing authorized model c
|
|
|
82
82
|
await f.conversation.configure({ systemPrompt: "Use the new instructions." });
|
|
83
83
|
await f.conversation.send({ messageId: "two", text: "Continue" });
|
|
84
84
|
await f.conversation.wait();
|
|
85
|
-
assert.
|
|
86
|
-
|
|
87
|
-
assert.
|
|
85
|
+
assert.equal(f.requests[1].messages[0].content, "Use the new instructions.", "The current instructions are the system prompt");
|
|
86
|
+
assert.deepEqual(f.requests[1].messages.slice(1, -1).map(message => message.content), ["Hello", "Hello there.", "Continue"]);
|
|
87
|
+
assert.match(f.requests[1].messages.at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
|
|
88
88
|
f.state.modelAllowed = false;
|
|
89
89
|
await assert.rejects(f.conversation.send({ messageId: "three", text: "No longer authorized" }));
|
|
90
90
|
assert.equal((await f.conversation.read()).conversationLog.length, 2);
|
|
@@ -114,7 +114,7 @@ test("opening an existing application transcript retains its context before and
|
|
|
114
114
|
const f = await fixture(t, { storage });
|
|
115
115
|
await f.conversation.send(input);
|
|
116
116
|
const first = await f.conversation.wait();
|
|
117
|
-
assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), [
|
|
117
|
+
assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), [
|
|
118
118
|
"Our chosen colour is blue.", "I will remember blue.", "Hello"
|
|
119
119
|
]);
|
|
120
120
|
assert.equal((await f.conversation.send({ messageId: "earlier", text: "Our chosen colour is blue." })).duplicate, true);
|
|
@@ -125,7 +125,7 @@ test("opening an existing application transcript retains its context before and
|
|
|
125
125
|
assert.match(messages[1].content, /Our chosen colour is blue/);
|
|
126
126
|
assert.match(messages[1].content, /I will remember blue/);
|
|
127
127
|
assert.equal(messages.filter(message => message.content.includes("Our chosen colour is blue")).length, 1);
|
|
128
|
-
assert.equal(messages.at(-
|
|
128
|
+
assert.equal(messages.at(-2).content, "Continue", "The request comes last, before the engine's state note");
|
|
129
129
|
assert.equal((await transcript.readConversationLog("one"))[0].metadata?.runtime, undefined);
|
|
130
130
|
});
|
|
131
131
|
|
|
@@ -143,7 +143,7 @@ test("application wakes retain their origin, reuse receipts and remain quoted da
|
|
|
143
143
|
assert.equal(snapshot.conversationLog[0].metadata.runtime.origin, "application");
|
|
144
144
|
assert.ok(operations.includes("wake"));
|
|
145
145
|
assert.ok(events.filter(event => event.type === "message").every(event => event.origin === "application"));
|
|
146
|
-
assert.match(f.requests[0].messages.at(-
|
|
146
|
+
assert.match(f.requests[0].messages.at(-2).content, /\[Application event\]/);
|
|
147
147
|
assert.equal((await f.conversation.wake(message)).duplicate, true);
|
|
148
148
|
await assert.rejects(f.conversation.send(message), { code: "conversation_message_id_content_conflict" });
|
|
149
149
|
await f.conversation.send(input);
|
|
@@ -170,7 +170,7 @@ test("per-request application data stays separate from visible text and particip
|
|
|
170
170
|
const first = await f.conversation.wait();
|
|
171
171
|
assert.equal(first.conversationLog[0].user.text, "Hello");
|
|
172
172
|
assert.deepEqual(first.conversationLog[0].user.data, request.data);
|
|
173
|
-
const native = f.requests[0].messages.at(-
|
|
173
|
+
const native = f.requests[0].messages.at(-2).content;
|
|
174
174
|
assert.match(native, /\[Application data\]/);
|
|
175
175
|
assert.match(native, /not additional instructions or authorization/);
|
|
176
176
|
assert.ok(native.endsWith("Hello"));
|
|
@@ -180,7 +180,7 @@ test("per-request application data stays separate from visible text and particip
|
|
|
180
180
|
const next = await f.conversation.wait();
|
|
181
181
|
assert.equal(next.conversationLog[1].system.text, "A watched session changed.");
|
|
182
182
|
assert.equal(f.requests[1].messages[1].content, native);
|
|
183
|
-
assert.match(f.requests[1].messages.at(-
|
|
183
|
+
assert.match(f.requests[1].messages.at(-2).content, /\[Application event\]/);
|
|
184
184
|
assert.deepEqual(next.configuration, configuration, "Application data never replaces the stable system prompt");
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -219,7 +219,7 @@ test("API compaction stores its summary with the last turn it covers, and later
|
|
|
219
219
|
const second = await f.conversation.wait();
|
|
220
220
|
const last = f.requests.at(-1);
|
|
221
221
|
assert.match(last.messages[0].content, /Summary of the earlier part of this conversation[^]*Six long questions so far\./u);
|
|
222
|
-
assert.deepEqual(last.messages.filter(message => message.role === "user").map(message => message.content),
|
|
222
|
+
assert.deepEqual(last.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
|
|
223
223
|
[...second.conversationLog.filter(turn => after.includes(turn.turnId)).map(turn => turn.user.text), "Continue"]);
|
|
224
224
|
assert.deepEqual(await storage.read("one", transaction => transaction.readSummary()), summary, "A short remainder is not compacted again");
|
|
225
225
|
const later = (await storage.read("one", transaction => transaction.readMetadata())).runtime.seen;
|
|
@@ -265,7 +265,8 @@ test("cancellation aborts inference and leaves another conversation usable", asy
|
|
|
265
265
|
const started = Promise.withResolvers();
|
|
266
266
|
let aborted = false;
|
|
267
267
|
const f = await fixture(t, { fetch: (_url, init) => {
|
|
268
|
-
|
|
268
|
+
// The request comes last, before the engine's state note.
|
|
269
|
+
if (JSON.parse(init.body).messages.at(-2).content === "Other") return response();
|
|
269
270
|
started.resolve();
|
|
270
271
|
return new Promise((_resolve, reject) => init.signal.addEventListener("abort", () => {
|
|
271
272
|
aborted = true; reject(init.signal.reason);
|
|
@@ -706,6 +707,35 @@ test("native invalidation preserves synchronous fences and failures without open
|
|
|
706
707
|
await result;
|
|
707
708
|
});
|
|
708
709
|
|
|
710
|
+
test("beginning native shutdown reaches the configured owner synchronously and opens no conversation", async t => {
|
|
711
|
+
const input = { reason: "server-shutdown" };
|
|
712
|
+
const calls = [];
|
|
713
|
+
let asynchronousHost = true;
|
|
714
|
+
const native = { runOwner: {
|
|
715
|
+
beginShutdown() { calls.push("begin"); return "begun"; },
|
|
716
|
+
invalidateRuntimes() { assert.fail("Beginning shutdown stops nothing."); }
|
|
717
|
+
} };
|
|
718
|
+
const runtime = createConversationRuntime({
|
|
719
|
+
authorize() { assert.fail("Trusted shutdown must not acquire session authority."); },
|
|
720
|
+
storage: {
|
|
721
|
+
read() { assert.fail("Shutdown must not read a conversation."); },
|
|
722
|
+
write() { assert.fail("Shutdown must not allocate a conversation."); }
|
|
723
|
+
},
|
|
724
|
+
host: { conversation(request) {
|
|
725
|
+
calls.push("host");
|
|
726
|
+
assert.deepEqual(request, { context, input, operation: "beginShutdown" });
|
|
727
|
+
const prepared = { engine: "codex", native, context, input };
|
|
728
|
+
return asynchronousHost ? Promise.resolve(prepared) : prepared;
|
|
729
|
+
} }
|
|
730
|
+
});
|
|
731
|
+
t.after(() => runtime.close());
|
|
732
|
+
assert.throws(() => runtime.beginNativeShutdown({ context, input }), /synchronous configured host/);
|
|
733
|
+
assert.deepEqual(calls, ["host"]);
|
|
734
|
+
asynchronousHost = false;
|
|
735
|
+
assert.equal(runtime.beginNativeShutdown({ context, input }), "begun");
|
|
736
|
+
assert.deepEqual(calls, ["host", "host", "begin"], "the owner's fence is set before the call returns");
|
|
737
|
+
});
|
|
738
|
+
|
|
709
739
|
test("renewal proof cleanup preserves OpenCode failure, consumption and retry through the configured owner", async t => {
|
|
710
740
|
const owner = createOpenCodeSharedRuntime({});
|
|
711
741
|
const calls = [];
|