@jskit-ai/assistant-core 0.2.14 → 0.2.15

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.14",
11
+ "@jskit-ai/assistant-core": "0.2.15",
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.14",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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),
@@ -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
- async function lifecycleFixture(t) {
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) { if (event.type === "save") await store.save(entry); }
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; the chats are idle now.
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, "idle chats were not written again");
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 => {