@jskit-ai/assistant-core 0.2.14 → 0.2.16
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 +1 -1
- package/package.json +1 -1
- package/src/server/conversation/claudeNativeTurn.js +12 -1
- package/src/server/conversation/claudeTurn.js +1 -0
- package/src/server/conversation/openCodeProcessOwner.js +12 -1
- package/src/server/conversation/openCodeSessionTurns.js +10 -3
- package/src/server/conversation/openCodeTemporaryConversations.js +10 -3
- package/src/server/conversation/openCodeTurn.js +10 -3
- package/src/server/conversation/openCodeTurnMonitor.js +9 -2
- package/test/claudeLifecycle.test.js +43 -5
- package/test/openCodeLifecycle.test.js +286 -0
package/package.json
CHANGED
|
@@ -110,7 +110,8 @@ function retainStoppedClaudeMessages(messages, pendingAssistantMessages) {
|
|
|
110
110
|
* The owner's control of one native process, as a native turn uses it.
|
|
111
111
|
* @typedef {import("./providers/claude.js").ClaudeProcessControl & {
|
|
112
112
|
* stopExecution?: (executionId: string, options?: object) => Promise<object>,
|
|
113
|
-
* releaseExecution?: () => Promise<unknown
|
|
113
|
+
* releaseExecution?: () => Promise<unknown>,
|
|
114
|
+
* ownerClosing?: () => boolean
|
|
114
115
|
* }} ClaudeTurnProcess
|
|
115
116
|
*/
|
|
116
117
|
|
|
@@ -276,6 +277,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
276
277
|
if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
|
|
277
278
|
return;
|
|
278
279
|
}
|
|
280
|
+
if (entry.turn?.active && process.ownerClosing()) {
|
|
281
|
+
// At shutdown the turn's end belongs to the next server, as after a
|
|
282
|
+
// crash: its saved record keeps the execution and the active turn, so
|
|
283
|
+
// that server's recovery proves this exit, interrupts the turn and runs
|
|
284
|
+
// the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
|
|
285
|
+
// would hold the host's bounded shutdown for as long as it takes.
|
|
286
|
+
resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
|
|
287
|
+
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
288
|
+
return entry.exitProof;
|
|
289
|
+
}
|
|
279
290
|
if (entry.turn?.active) {
|
|
280
291
|
try { await updateState("interrupted", reason); }
|
|
281
292
|
catch (error) {
|
|
@@ -368,6 +368,7 @@ export function createClaudeConversationOwner({
|
|
|
368
368
|
process: {
|
|
369
369
|
stopExecution: process.stopExecution,
|
|
370
370
|
...(bound ? { releaseExecution: () => store.releaseExecution(entry) } : {}),
|
|
371
|
+
ownerClosing: () => cleanup.closing,
|
|
371
372
|
getProcess: () => entry.stopping ? null : entry.process,
|
|
372
373
|
isActive: () => isTurnActive(entry),
|
|
373
374
|
startProcess: input => processStart.startProcess(entry, input),
|
|
@@ -349,9 +349,20 @@ export function createOpenCodeProcessOwner({ processes, processStarts, monitors,
|
|
|
349
349
|
|
|
350
350
|
async function releaseTarget(target, { retainSharedProcess = false, onRemoved } = {}) {
|
|
351
351
|
const activeThreads = new Set();
|
|
352
|
-
|
|
352
|
+
// A turn the server's shutdown stops carries that mark: its observer
|
|
353
|
+
// leaves the turn's end to the next server, which records it interrupted,
|
|
354
|
+
// since the native abort this stop causes is not the turn's failure.
|
|
355
|
+
const stopping = turn => {
|
|
356
|
+
if (closed) turn.stoppedByShutdown = true;
|
|
357
|
+
};
|
|
358
|
+
const mainTurn = turns.get(target.key);
|
|
359
|
+
if (mainTurn?.active) {
|
|
360
|
+
stopping(mainTurn);
|
|
361
|
+
activeThreads.add(target.upstreamSessionId);
|
|
362
|
+
}
|
|
353
363
|
for (const entry of temporaryConversations.values()) {
|
|
354
364
|
if (entry.active && entry.target?.abortController === target.abortController) {
|
|
365
|
+
stopping(entry);
|
|
355
366
|
activeThreads.add(entry.conversationId);
|
|
356
367
|
}
|
|
357
368
|
}
|
|
@@ -333,7 +333,7 @@ export function createOpenCodeSessionTurns({ processes, monitors, turns, process
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
async function reconcileSessionTurn(key, target, activeRun, options, application) {
|
|
336
|
-
if (activeRun
|
|
336
|
+
if (endedOnPreviousServer(activeRun)) {
|
|
337
337
|
await processOwner.stopUnobservedSession(target, activeRun.threadId);
|
|
338
338
|
await application.writeRun({ ...activeRun, id: activeRun.turnId }, "interrupted", activeRun.observationError);
|
|
339
339
|
} else if (activeRun) {
|
|
@@ -344,7 +344,7 @@ export function createOpenCodeSessionTurns({ processes, monitors, turns, process
|
|
|
344
344
|
startedAt: normalizeNullableText(activeRun.startedAt)
|
|
345
345
|
}, options, application.monitor);
|
|
346
346
|
}
|
|
347
|
-
return Boolean(activeRun && !activeRun
|
|
347
|
+
return Boolean(activeRun && !endedOnPreviousServer(activeRun));
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
async function ensurePreparedSessionReadiness(prepared) {
|
|
@@ -443,8 +443,15 @@ export function createOpenCodeSessionTurns({ processes, monitors, turns, process
|
|
|
443
443
|
return prepare();
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
// A saved active run whose observation already ended: lost, or stopped by the
|
|
447
|
+
// previous server's shutdown. Recovery records it interrupted once native
|
|
448
|
+
// work is idle; neither is the turn's own failure.
|
|
449
|
+
function endedOnPreviousServer(run) {
|
|
450
|
+
return Boolean(run?.observationError || run?.stoppedByShutdown === true);
|
|
451
|
+
}
|
|
452
|
+
|
|
446
453
|
function needsObservationRecovery(key, run) {
|
|
447
|
-
return Boolean(run?.active && run
|
|
454
|
+
return Boolean(run?.active && endedOnPreviousServer(run) && !monitors.has(key));
|
|
448
455
|
}
|
|
449
456
|
|
|
450
457
|
async function recordStoppedObservation(key, run, threadId, writeRun) {
|
|
@@ -172,18 +172,25 @@ export function createOpenCodeTemporaryConversations({ processes, temporaryConve
|
|
|
172
172
|
const rows = openCodeMessageRows(await target.server.client.messages(conversationId));
|
|
173
173
|
const result = openCodeLastAssistantResult(rows, { readError });
|
|
174
174
|
const messages = openCodePersistentConversationMessages(rows, projection.messageId);
|
|
175
|
+
const runId = tracked?.inputMessageId || [...rows].reverse().find((row) => row.type === "user")?.id || "";
|
|
176
|
+
// The application marks the run a previous server's shutdown stopped: that
|
|
177
|
+
// run's native abort is the shutdown's interruption, not its failure, and
|
|
178
|
+
// recording it lets the application make the checkpoint it still owes.
|
|
179
|
+
const shutdownStop = projection.stoppedByShutdown;
|
|
180
|
+
const stoppedByShutdown = !tracked && Boolean(runId) && shutdownStop?.runId === runId;
|
|
175
181
|
let conversationStatus = "completed";
|
|
176
182
|
if (status.type !== "idle" || tracked?.active) conversationStatus = "inProgress";
|
|
177
|
-
else if (tracked?.interrupted) conversationStatus = "interrupted";
|
|
183
|
+
else if (tracked?.interrupted || stoppedByShutdown) conversationStatus = "interrupted";
|
|
178
184
|
else if (tracked?.error || result.error) conversationStatus = "failed";
|
|
185
|
+
if (stoppedByShutdown) await shutdownStop.ended();
|
|
179
186
|
return {
|
|
180
187
|
conversationId,
|
|
181
188
|
ok: true,
|
|
182
189
|
messages,
|
|
183
190
|
text: result.text,
|
|
184
191
|
admitted: Boolean(projection.inputMessageId && rows.some((row) => row.id === projection.inputMessageId)),
|
|
185
|
-
error: normalizeNullableText(tracked?.error?.message) || result.error,
|
|
186
|
-
runId
|
|
192
|
+
error: stoppedByShutdown ? "" : normalizeNullableText(tracked?.error?.message) || result.error,
|
|
193
|
+
runId,
|
|
187
194
|
status: conversationStatus
|
|
188
195
|
};
|
|
189
196
|
}
|
|
@@ -478,6 +478,9 @@ 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`) ends
|
|
482
|
+
* interrupted however its wait ended, and `afterClose` is told so: the
|
|
483
|
+
* application leaves its checkpoint to the next server.
|
|
481
484
|
* @param {import("./openCodeProcessOwner.js").OpenCodeProcessOwner} processOwner
|
|
482
485
|
*/
|
|
483
486
|
export async function observeOpenCodeTurnCompletion(processOwner, target, conversationId, turn, {
|
|
@@ -493,11 +496,15 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
|
|
|
493
496
|
const completion = finalResponse
|
|
494
497
|
? await waitForOpenCodeFinalResponse(target.server.client, conversationId, turn, { ...finalResponse, ...options })
|
|
495
498
|
: await waitForOpenCodeMessages(target.server.client, conversationId, () => turn.inputMessageId, options);
|
|
499
|
+
if (turn.stoppedByShutdown) {
|
|
500
|
+
outcome = "interrupted";
|
|
501
|
+
return { interrupted: true };
|
|
502
|
+
}
|
|
496
503
|
return await projectResult(completion);
|
|
497
504
|
} catch (error) {
|
|
498
505
|
const failure = signal.aborted ? signal.reason : error;
|
|
499
|
-
outcome = turn.interrupted ? "interrupted" : "failed";
|
|
500
|
-
if (
|
|
506
|
+
outcome = turn.interrupted || turn.stoppedByShutdown ? "interrupted" : "failed";
|
|
507
|
+
if (outcome === "interrupted") return { interrupted: true };
|
|
501
508
|
if (!target.abortController.signal.aborted) {
|
|
502
509
|
try {
|
|
503
510
|
await processOwner.stopUnobservedSession(target, conversationId);
|
|
@@ -513,7 +520,7 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
|
|
|
513
520
|
turn.active = !stopped;
|
|
514
521
|
turn.mayBeRunning = !stopped;
|
|
515
522
|
}
|
|
516
|
-
await afterClose({ stopped, outcome });
|
|
523
|
+
await afterClose({ stopped, outcome, stoppedByShutdown: turn.stoppedByShutdown === true });
|
|
517
524
|
}
|
|
518
525
|
}
|
|
519
526
|
|
|
@@ -351,11 +351,18 @@ export function createOpenCodeTurnMonitor({ processes, monitors, turns, processO
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
} finally {
|
|
354
|
+
// The end of a turn the server's shutdown stopped belongs to the next
|
|
355
|
+
// server: its saved run stays active and carries the shutdown's mark,
|
|
356
|
+
// so that server's recovery records the turn interrupted and runs the
|
|
357
|
+
// host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
|
|
358
|
+
// would hold the host's bounded shutdown for as long as it takes. A
|
|
359
|
+
// user's Stop ends its turn as interrupted here.
|
|
360
|
+
const nextServerEnds = turn.stoppedByShutdown === true && finalState !== "active";
|
|
354
361
|
// Claim retirement before awaited cleanup/checkpointing. A new Send
|
|
355
362
|
// joins this monitor's cleanup instead of steering an unobserved turn.
|
|
356
363
|
turn.active = finalState === "active";
|
|
357
364
|
await events.close();
|
|
358
|
-
if (credentialFailure || finalState === "failed") {
|
|
365
|
+
if (!nextServerEnds && (credentialFailure || finalState === "failed")) {
|
|
359
366
|
failure = await completeResult(turn, { credentialFailure, providerApiFailure, finalState, failure });
|
|
360
367
|
}
|
|
361
368
|
turn.error = failure;
|
|
@@ -364,7 +371,7 @@ export function createOpenCodeTurnMonitor({ processes, monitors, turns, processO
|
|
|
364
371
|
turn.finalAssistantResultTarget = turn.finalAssistantResult ? finalAssistantResultTarget : null;
|
|
365
372
|
turn.finalAssistantResultProcess = turn.finalAssistantResult ? finalAssistantResultProcess : null;
|
|
366
373
|
turn.updatedAt = new Date().toISOString();
|
|
367
|
-
await writeRun(turn, finalState, failure).catch(() => null);
|
|
374
|
+
await (nextServerEnds ? writeRun(turn, "active", "") : writeRun(turn, finalState, failure)).catch(() => null);
|
|
368
375
|
}
|
|
369
376
|
return openCodeTurnSnapshot(turn, target.upstreamSessionId);
|
|
370
377
|
}).finally(() => {
|
|
@@ -18,10 +18,11 @@ const IDLE_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
|
|
18
18
|
// A host with Vibe64's storage rules: every save writes the session record, an
|
|
19
19
|
// archived session refuses writes, saved conversations are re-read from storage
|
|
20
20
|
// and a session whose workspace is gone cannot prepare a conversation context.
|
|
21
|
-
|
|
21
|
+
// A running turn's end first runs the host's checkpoint, a Git snapshot there.
|
|
22
|
+
async function lifecycleFixture(t, { turnEndCheckpoint = async () => {} } = {}) {
|
|
22
23
|
const root = await mkdtemp(path.join(os.tmpdir(), "claude-lifecycle-"));
|
|
23
24
|
const sessions = new Map();
|
|
24
|
-
const calls = { saves: [], reads: 0, stops: [] };
|
|
25
|
+
const calls = { saves: [], reads: 0, stops: [], checkpoints: [] };
|
|
25
26
|
const processes = [];
|
|
26
27
|
const session = sessionId => {
|
|
27
28
|
if (!sessions.has(sessionId)) {
|
|
@@ -83,7 +84,13 @@ async function lifecycleFixture(t) {
|
|
|
83
84
|
}
|
|
84
85
|
},
|
|
85
86
|
// As in Vibe64, the native turn's own checkpoints write the session record.
|
|
86
|
-
async onEvent(entry, event) {
|
|
87
|
+
async onEvent(entry, event) {
|
|
88
|
+
if (event.type === "before-state" && !event.active && entry.turn?.active) {
|
|
89
|
+
calls.checkpoints.push(`${entry.context.sessionId}:${event.state}`);
|
|
90
|
+
await turnEndCheckpoint();
|
|
91
|
+
}
|
|
92
|
+
if (event.type === "save") await store.save(entry);
|
|
93
|
+
}
|
|
87
94
|
});
|
|
88
95
|
owner = create();
|
|
89
96
|
const terminals = [];
|
|
@@ -199,9 +206,40 @@ test("Claude shutdown stops sessions together and never rewrites an idle chat",
|
|
|
199
206
|
assert.equal(result.ok, true);
|
|
200
207
|
assert.deepEqual(running.map(entry => entry.process), [null, null, null]);
|
|
201
208
|
const before = f.calls.saves.length;
|
|
202
|
-
// Shutdown invalidates more than once;
|
|
209
|
+
// Shutdown invalidates more than once; nothing is left to stop or write.
|
|
203
210
|
assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
|
|
204
|
-
assert.equal(f.calls.saves.length, before, "
|
|
211
|
+
assert.equal(f.calls.saves.length, before, "the chats were not written again");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("Claude shutdown leaves a running turn's end and its checkpoint to the next server", { timeout: 5_000 }, async t => {
|
|
215
|
+
// The host's checkpoint can take longer than the server has to stop.
|
|
216
|
+
const checkpoint = Promise.withResolvers();
|
|
217
|
+
let checkpointWaits = true;
|
|
218
|
+
const f = await lifecycleFixture(t, { turnEndCheckpoint: () => checkpointWaits ? checkpoint.promise : undefined });
|
|
219
|
+
const a = await f.running("a");
|
|
220
|
+
const saved = f.session("a").records.get(MAIN_IDS.a);
|
|
221
|
+
let timer;
|
|
222
|
+
const result = await Promise.race([
|
|
223
|
+
f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account),
|
|
224
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Shutdown waited for the turn's checkpoint")), 1_000); })
|
|
225
|
+
]).finally(() => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
checkpoint.resolve();
|
|
228
|
+
});
|
|
229
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
230
|
+
assert.equal(a.process, null, "the process stopped");
|
|
231
|
+
assert.deepEqual(f.calls.checkpoints, [], "shutdown ran no checkpoint");
|
|
232
|
+
assert.equal(f.session("a").records.get(MAIN_IDS.a), saved, "the saved record was not rewritten");
|
|
233
|
+
const custody = ({ executionId, state }) => ({ executionId, state });
|
|
234
|
+
assert.deepEqual(custody(JSON.parse(saved)), { executionId: "execution-0", state: "active" });
|
|
235
|
+
// The next server's close proves that exit, then interrupts the turn through its checkpoint.
|
|
236
|
+
checkpointWaits = false;
|
|
237
|
+
f.restart();
|
|
238
|
+
const stops = f.calls.stops.length;
|
|
239
|
+
assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
|
|
240
|
+
assert.deepEqual(f.calls.stops.slice(stops), ["execution-0"]);
|
|
241
|
+
assert.deepEqual(f.calls.checkpoints, ["a:interrupted"]);
|
|
242
|
+
assert.deepEqual(custody(JSON.parse(f.session("a").records.get(MAIN_IDS.a))), { executionId: "", state: "interrupted" });
|
|
205
243
|
});
|
|
206
244
|
|
|
207
245
|
test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createOpenCodeSharedRuntime } from "../src/server/conversation/openCodeRuntime.js";
|
|
4
|
+
import { observeOpenCodeTurnCompletion, openCodeMessageError } from "../src/server/conversation/openCodeTurn.js";
|
|
5
|
+
|
|
6
|
+
const SESSION_ID = "session-a";
|
|
7
|
+
const KEY = `project\0${SESSION_ID}`;
|
|
8
|
+
const THREAD_ID = "ses_native";
|
|
9
|
+
const INPUT_ID = "msg_input";
|
|
10
|
+
const CONNECTION = { modelProviderId: "provider", canonicalUrl: "https://provider.invalid", fingerprint: "account", endpointCode: "api" };
|
|
11
|
+
const SELECTION = { agentId: "build", modelId: "model", modelProviderId: "provider", variantId: "" };
|
|
12
|
+
const ABORTED = "The operation was aborted.";
|
|
13
|
+
const PROVIDER_FAILURE = "The provider refused this request.";
|
|
14
|
+
|
|
15
|
+
// OpenCode's native history outlives each server that runs it. The turn's reply
|
|
16
|
+
// is still being written until an interrupt ends it as aborted, or its provider
|
|
17
|
+
// fails it.
|
|
18
|
+
function nativeOpenCode() {
|
|
19
|
+
const rows = [
|
|
20
|
+
{ id: INPUT_ID, type: "user", time: { created: 1 } },
|
|
21
|
+
{ id: "msg_reply", type: "assistant", text: "", time: { created: 2 } }
|
|
22
|
+
];
|
|
23
|
+
let busy = true;
|
|
24
|
+
const calls = { interrupts: [], starts: 0, stops: 0 };
|
|
25
|
+
function create() {
|
|
26
|
+
calls.starts += 1;
|
|
27
|
+
const client = {
|
|
28
|
+
async health() { return { healthy: true }; },
|
|
29
|
+
async prepareDirectory() {},
|
|
30
|
+
async readSession(id) { return { id }; },
|
|
31
|
+
async switchModel() {},
|
|
32
|
+
async switchAgent() {},
|
|
33
|
+
async messages() { return rows; },
|
|
34
|
+
async sessionStatus() { return { type: busy ? "busy" : "idle" }; },
|
|
35
|
+
async interrupt(id) {
|
|
36
|
+
calls.interrupts.push(id);
|
|
37
|
+
if (busy) rows[1] = { ...rows[1], error: { name: "MessageAbortedError", data: { message: ABORTED } }, time: { created: 2, completed: 3 } };
|
|
38
|
+
busy = false;
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
forDirectory() { return client; }
|
|
42
|
+
};
|
|
43
|
+
return { client, async stop() { calls.stops += 1; return { exited: true }; } };
|
|
44
|
+
}
|
|
45
|
+
function fail() {
|
|
46
|
+
rows[1] = { ...rows[1], error: { name: "APIError", data: { message: PROVIDER_FAILURE } }, time: { created: 2, completed: 3 } };
|
|
47
|
+
busy = false;
|
|
48
|
+
}
|
|
49
|
+
return { calls, create, fail };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One server with Vibe64's run rules: the session record keeps the OpenCode run
|
|
53
|
+
// with the shutdown's mark, and a turn's end first runs the host's checkpoint, a
|
|
54
|
+
// Git snapshot there.
|
|
55
|
+
function server(native, saved, { turnEndCheckpoint = async () => {} } = {}) {
|
|
56
|
+
const runtime = createOpenCodeSharedRuntime({});
|
|
57
|
+
const calls = { checkpoints: [], writes: [] };
|
|
58
|
+
const active = Promise.withResolvers();
|
|
59
|
+
async function writeRun(turn, state, error = "") {
|
|
60
|
+
const running = state === "starting" || state === "active";
|
|
61
|
+
if (!running && turn.id) {
|
|
62
|
+
calls.checkpoints.push(`${turn.id}:${state}`);
|
|
63
|
+
await turnEndCheckpoint();
|
|
64
|
+
}
|
|
65
|
+
calls.writes.push(state);
|
|
66
|
+
saved.run = { active: running, state, error, observationError: turn.observationError || "",
|
|
67
|
+
stoppedByShutdown: turn.stoppedByShutdown === true, threadId: turn.threadId, turnId: turn.id, startedAt: turn.startedAt };
|
|
68
|
+
if (state === "active") active.resolve();
|
|
69
|
+
}
|
|
70
|
+
const projection = {
|
|
71
|
+
messageId: (id, role) => `${id}:${role}`, readError: openCodeMessageError, reasoning: async () => {},
|
|
72
|
+
store: {
|
|
73
|
+
async writeConversationAssistantMessage(_sessionId, message) { return { assistant: message }; },
|
|
74
|
+
updateConversationStream() {}, completeConversationStreamMessage() {}
|
|
75
|
+
},
|
|
76
|
+
publishTurn: async () => {}, publishStream: async () => {}
|
|
77
|
+
};
|
|
78
|
+
const monitor = {
|
|
79
|
+
prepare() { return { fields: {} }; },
|
|
80
|
+
create() {
|
|
81
|
+
return {
|
|
82
|
+
observe: () => ({ readFailure: () => null, close: async () => {} }),
|
|
83
|
+
finalResponse: { readError: openCodeMessageError },
|
|
84
|
+
writeRun,
|
|
85
|
+
projectMessages: (messages, options) => runtime.writeConversationProjection(SESSION_ID, messages, options, projection),
|
|
86
|
+
completeResult: async (_turn, { failure }) => failure,
|
|
87
|
+
onRetired() {}
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
onError() {}
|
|
91
|
+
};
|
|
92
|
+
const failure = error => { throw error; };
|
|
93
|
+
const release = {
|
|
94
|
+
ownsTarget: () => true, async beforeRelease() {}, async onRemoved() {}, async closeTerminals() {}, async afterRelease() {}, failure
|
|
95
|
+
};
|
|
96
|
+
const processPreparation = {
|
|
97
|
+
key: KEY, connection: CONNECTION, failure, async onFailure() {},
|
|
98
|
+
async configure() {
|
|
99
|
+
return {
|
|
100
|
+
target: { sessionId: SESSION_ID, upstreamSessionId: THREAD_ID, workdir: "/work/a", projectContextRoot: "/work",
|
|
101
|
+
connection: CONNECTION, selection: SELECTION, helperModelId: "" },
|
|
102
|
+
async prepareServer() { return { create: async () => native.create(), options: {}, connections: [CONNECTION] }; }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const session = { selection: SELECTION, model: { id: SELECTION.modelId, providerID: SELECTION.modelProviderId },
|
|
107
|
+
identity: { async write() {}, async publish() {} } };
|
|
108
|
+
return {
|
|
109
|
+
runtime, calls, release,
|
|
110
|
+
shutdown: () => runtime.invalidateRuntimes({ reason: "server-shutdown" }, { release }),
|
|
111
|
+
async running() {
|
|
112
|
+
const target = await runtime.acquirePrepared(processPreparation);
|
|
113
|
+
void runtime.beginMessageMonitor(KEY, target, { id: INPUT_ID, eventStartedAt: 1, startedAt: "2026-11-05T00:00:00.000Z" }, {}, monitor);
|
|
114
|
+
await active.promise;
|
|
115
|
+
return target;
|
|
116
|
+
},
|
|
117
|
+
stop: () => runtime.interruptPreparedTurn({ key: KEY, threadId: THREAD_ID, writeRun, failure }),
|
|
118
|
+
// A temporary conversation's turn, observed as the application's detached chats are.
|
|
119
|
+
async temporary(conversationId) {
|
|
120
|
+
const target = await runtime.acquirePrepared(processPreparation);
|
|
121
|
+
const closes = [];
|
|
122
|
+
const entry = { active: true, target, conversationId, inputMessageId: INPUT_ID, abortController: new AbortController() };
|
|
123
|
+
runtime.temporaryConversations.set(`${KEY}\0${conversationId}`, entry);
|
|
124
|
+
entry.completion = observeOpenCodeTurnCompletion(runtime, { ...target, upstreamSessionId: conversationId }, conversationId, entry, {
|
|
125
|
+
events: { async close() {} },
|
|
126
|
+
signal: AbortSignal.any([target.abortController.signal, entry.abortController.signal]),
|
|
127
|
+
afterClose: closed => { closes.push(closed); }
|
|
128
|
+
});
|
|
129
|
+
return { entry, closes };
|
|
130
|
+
},
|
|
131
|
+
// A later read of that conversation, as the application reads a saved chat.
|
|
132
|
+
async readTemporary(conversationId, stoppedByShutdown) {
|
|
133
|
+
const target = await runtime.acquirePrepared(processPreparation);
|
|
134
|
+
return runtime.readPersistentConversation(target, conversationId, null, {
|
|
135
|
+
readError: openCodeMessageError, messageId: (id, part) => `${id}:${part}`, inputMessageId: "", stoppedByShutdown
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
// Opening the chat: its readiness recovers a run the previous server left.
|
|
139
|
+
ready: () => runtime.ensurePreparedSessionReadiness({
|
|
140
|
+
key: KEY, selection: SELECTION, workdir: "/work/a", changed: () => new Error("The session changed."),
|
|
141
|
+
connection: async () => CONNECTION, refresh: async () => ({ key: KEY, workdir: "/work/a", selection: SELECTION }),
|
|
142
|
+
prepare: async operation => ({
|
|
143
|
+
value: await operation({ key: KEY, configuration: () => ({ process: async () => processPreparation, session }) })
|
|
144
|
+
}),
|
|
145
|
+
observation: {
|
|
146
|
+
get run() { return saved.run; },
|
|
147
|
+
async write(operation) { await operation(saved.run, apply => apply(writeRun)); }
|
|
148
|
+
}
|
|
149
|
+
}),
|
|
150
|
+
async recover() {
|
|
151
|
+
const result = await runtime.reconcileSessions([{ sessionId: SESSION_ID }], {}, {
|
|
152
|
+
session: async () => ({
|
|
153
|
+
key: KEY, activeRun: saved.run?.active ? saved.run : undefined, failure,
|
|
154
|
+
process: async () => processPreparation, session: () => session,
|
|
155
|
+
observation: { writeRun, monitor, fallbackTurnId: "msg_fallback" }
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
await runtime.monitors.get(KEY);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runOf(saved) {
|
|
165
|
+
const { active, state, error, stoppedByShutdown, turnId } = saved.run;
|
|
166
|
+
return { active, state, error, stoppedByShutdown, turnId };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Shutdown with a host checkpoint that can take longer than the server has to stop.
|
|
170
|
+
async function shutdownRunning(native, saved) {
|
|
171
|
+
const checkpoint = Promise.withResolvers();
|
|
172
|
+
const first = server(native, saved, { turnEndCheckpoint: () => checkpoint.promise });
|
|
173
|
+
await first.running();
|
|
174
|
+
let timer;
|
|
175
|
+
const result = await Promise.race([
|
|
176
|
+
first.shutdown(),
|
|
177
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Shutdown waited for the turn's checkpoint")), 1_000); })
|
|
178
|
+
]).finally(() => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
checkpoint.resolve();
|
|
181
|
+
});
|
|
182
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
183
|
+
assert.deepEqual(native.calls.interrupts, [THREAD_ID], "shutdown stopped the native turn");
|
|
184
|
+
assert.equal(native.calls.stops, 1, "shutdown proved the OpenCode process exit");
|
|
185
|
+
assert.deepEqual(first.calls.checkpoints, [], "shutdown ran no checkpoint");
|
|
186
|
+
assert.deepEqual(first.calls.writes, ["active", "active"], "shutdown only marked the saved run");
|
|
187
|
+
assert.deepEqual(runOf(saved), { active: true, state: "active", error: "", stoppedByShutdown: true, turnId: INPUT_ID });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test("a goal's recovery on the next server records an OpenCode turn stopped by shutdown as interrupted, with one checkpoint", { timeout: 5_000 }, async t => {
|
|
191
|
+
const native = nativeOpenCode();
|
|
192
|
+
const saved = { run: null };
|
|
193
|
+
await shutdownRunning(native, saved);
|
|
194
|
+
const next = server(native, saved);
|
|
195
|
+
t.after(() => next.shutdown());
|
|
196
|
+
const recovered = await next.recover();
|
|
197
|
+
assert.deepEqual(recovered.results, [{ ok: true, resumed: false, sessionId: SESSION_ID }]);
|
|
198
|
+
assert.deepEqual(next.calls.checkpoints, [`${INPUT_ID}:interrupted`]);
|
|
199
|
+
assert.deepEqual(runOf(saved), { active: false, state: "interrupted", error: "", stoppedByShutdown: true, turnId: INPUT_ID });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("opening the chat on the next server records an OpenCode turn stopped by shutdown as interrupted, with one checkpoint", { timeout: 5_000 }, async t => {
|
|
203
|
+
const native = nativeOpenCode();
|
|
204
|
+
const saved = { run: null };
|
|
205
|
+
await shutdownRunning(native, saved);
|
|
206
|
+
const next = server(native, saved);
|
|
207
|
+
t.after(() => next.shutdown());
|
|
208
|
+
assert.equal((await next.ready()).ok, true);
|
|
209
|
+
assert.deepEqual(next.calls.checkpoints, [`${INPUT_ID}:interrupted`]);
|
|
210
|
+
assert.deepEqual(runOf(saved), { active: false, state: "interrupted", error: "", stoppedByShutdown: true, turnId: INPUT_ID });
|
|
211
|
+
assert.equal((await next.ready()).ok, true);
|
|
212
|
+
assert.deepEqual(next.calls.checkpoints, [`${INPUT_ID}:interrupted`], "a second opening changes nothing");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a genuine OpenCode failure still records failed, and a native abort without the shutdown's mark is no interruption", { timeout: 5_000 }, async t => {
|
|
216
|
+
const failing = nativeOpenCode();
|
|
217
|
+
const failed = { run: null };
|
|
218
|
+
const running = server(failing, failed);
|
|
219
|
+
t.after(() => running.shutdown());
|
|
220
|
+
await running.running();
|
|
221
|
+
failing.fail();
|
|
222
|
+
await running.runtime.monitors.get(KEY);
|
|
223
|
+
assert.deepEqual(running.calls.checkpoints, [`${INPUT_ID}:failed`]);
|
|
224
|
+
assert.equal(failed.run.state, "failed");
|
|
225
|
+
assert.match(failed.run.error, /provider refused/u);
|
|
226
|
+
|
|
227
|
+
// A run left active without the mark (a crash, say) whose native turn was aborted.
|
|
228
|
+
const native = nativeOpenCode();
|
|
229
|
+
const saved = { run: { active: true, state: "active", error: "", observationError: "", stoppedByShutdown: false,
|
|
230
|
+
threadId: THREAD_ID, turnId: INPUT_ID, startedAt: "2026-11-05T00:00:00.000Z" } };
|
|
231
|
+
await native.create().client.interrupt(THREAD_ID);
|
|
232
|
+
const next = server(native, saved);
|
|
233
|
+
t.after(() => next.shutdown());
|
|
234
|
+
assert.deepEqual((await next.recover()).results, [{ ok: true, resumed: true, sessionId: SESSION_ID }]);
|
|
235
|
+
assert.deepEqual(next.calls.checkpoints, [`${INPUT_ID}:failed`]);
|
|
236
|
+
assert.deepEqual(runOf(saved), { active: false, state: "failed", error: ABORTED, stoppedByShutdown: false, turnId: INPUT_ID });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("OpenCode Stop and session close still end a running turn through the host's checkpoint", async t => {
|
|
240
|
+
const stopped = { run: null };
|
|
241
|
+
const stopping = server(nativeOpenCode(), stopped);
|
|
242
|
+
t.after(() => stopping.shutdown());
|
|
243
|
+
await stopping.running();
|
|
244
|
+
assert.equal((await stopping.stop()).interrupted, true);
|
|
245
|
+
assert.deepEqual(stopping.calls.checkpoints, [`${INPUT_ID}:interrupted`]);
|
|
246
|
+
assert.deepEqual({ active: stopped.run.active, state: stopped.run.state }, { active: false, state: "interrupted" });
|
|
247
|
+
|
|
248
|
+
const closed = { run: null };
|
|
249
|
+
const closing = server(nativeOpenCode(), closed);
|
|
250
|
+
t.after(() => closing.shutdown());
|
|
251
|
+
await closing.running();
|
|
252
|
+
assert.equal((await closing.runtime.closeSession(SESSION_ID, {}, closing.release)).ok, true);
|
|
253
|
+
assert.deepEqual(closing.calls.checkpoints, [`${INPUT_ID}:cancelled`]);
|
|
254
|
+
assert.deepEqual({ active: closed.run.active, state: closed.run.state }, { active: false, state: "cancelled" });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("OpenCode shutdown ends a temporary conversation's turn as interrupted, and only the shutdown's mark reads it so on the next server", { timeout: 5_000 }, async t => {
|
|
258
|
+
const native = nativeOpenCode();
|
|
259
|
+
const first = server(native, { run: null });
|
|
260
|
+
const { entry, closes } = await first.temporary("ses_temp");
|
|
261
|
+
assert.equal((await first.shutdown()).ok, true);
|
|
262
|
+
assert.deepEqual(await entry.completion, { interrupted: true });
|
|
263
|
+
assert.deepEqual(native.calls.interrupts, ["ses_temp"], "shutdown stopped the native turn");
|
|
264
|
+
assert.deepEqual(closes, [{ stopped: true, outcome: "interrupted", stoppedByShutdown: true }],
|
|
265
|
+
"the application is told to leave the turn's checkpoint to the next server");
|
|
266
|
+
|
|
267
|
+
const next = server(native, { run: null });
|
|
268
|
+
t.after(() => next.shutdown());
|
|
269
|
+
const ended = [];
|
|
270
|
+
const read = mark => next.readTemporary("ses_temp", mark).then(({ status, error, runId }) => ({ status, error, runId }));
|
|
271
|
+
assert.deepEqual(await read({ runId: INPUT_ID, async ended() { ended.push(INPUT_ID); } }),
|
|
272
|
+
{ status: "interrupted", error: "", runId: INPUT_ID });
|
|
273
|
+
assert.deepEqual(ended, [INPUT_ID], "recording the stopped run lets the application make its checkpoint");
|
|
274
|
+
assert.deepEqual(await read(undefined), { status: "failed", error: ABORTED, runId: INPUT_ID });
|
|
275
|
+
assert.deepEqual(await read({ runId: "msg_other", async ended() { ended.push("msg_other"); } }),
|
|
276
|
+
{ status: "failed", error: ABORTED, runId: INPUT_ID });
|
|
277
|
+
assert.deepEqual(ended, [INPUT_ID]);
|
|
278
|
+
|
|
279
|
+
// Outside shutdown a session close still ends the turn now, through the application's checkpoint.
|
|
280
|
+
const closing = server(nativeOpenCode(), { run: null });
|
|
281
|
+
t.after(() => closing.shutdown());
|
|
282
|
+
const closed = await closing.temporary("ses_closed");
|
|
283
|
+
assert.equal((await closing.runtime.closeSession(SESSION_ID, {}, closing.release)).ok, true);
|
|
284
|
+
await closed.entry.completion.catch(() => null);
|
|
285
|
+
assert.deepEqual(closed.closes, [{ stopped: true, outcome: "failed", stoppedByShutdown: false }]);
|
|
286
|
+
});
|