@jskit-ai/assistant-core 0.2.15 → 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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.15",
11
+ "@jskit-ai/assistant-core": "0.2.16",
12
12
  "@jskit-ai/http-runtime": "0.2.6",
13
13
  "@jskit-ai/kernel": "0.2.6",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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
- if (turns.get(target.key)?.active) activeThreads.add(target.upstreamSessionId);
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?.observationError) {
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.observationError);
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.observationError && !monitors.has(key));
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: tracked?.inputMessageId || [...rows].reverse().find((row) => row.type === "user")?.id || "",
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 (turn.interrupted) return { interrupted: true };
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(() => {
@@ -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
+ });