@jskit-ai/assistant-core 0.1.224 → 0.1.225
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/fixtures/responsive-assistant/DeliveryFixture.vue +3 -2
- package/package.json +1 -1
- package/src/client/conversation/AssistantTranscript.vue +6 -4
- package/src/server/conversation/agentRun.js +12 -0
- package/src/server/conversation/index.js +2 -2
- package/src/server/conversation/openCodeRuntime.js +8 -4
- package/src/server/conversation/runtimeStateUpgrade.js +42 -0
- package/test/conversationRuntimeStateUpgrade.test.js +69 -1
- package/test/messageDelivery.browser.test.js +15 -0
- package/test/openCodeRuntime.test.js +71 -1
|
@@ -26,8 +26,9 @@ const delivery = createAssistantMessageDelivery({ deliver(payload) {
|
|
|
26
26
|
} });
|
|
27
27
|
function accept() {
|
|
28
28
|
const payload = requests.value.at(-1);
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const at = new Date().toISOString();
|
|
30
|
+
turns.value = [...turns.value, { turnId: payload.messageId, user: { messageId: payload.messageId, text: payload.message, at },
|
|
31
|
+
assistant: { text: "Accepted once.", at } }];
|
|
31
32
|
pendingRequest.resolve({ ok: true });
|
|
32
33
|
}
|
|
33
34
|
if (recoveryFixture) {
|
package/package.json
CHANGED
|
@@ -608,16 +608,18 @@ const displayEntries = computed(() => {
|
|
|
608
608
|
}, key);
|
|
609
609
|
if (!displayed) continue;
|
|
610
610
|
candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
|
|
611
|
+
sortAt: turn.optimistic && turn.optimistic.id === message.messageId ? NaN : Date.parse(message.at),
|
|
611
612
|
delivery: role === "user" ? userDelivery : null });
|
|
612
613
|
}
|
|
613
614
|
}
|
|
614
615
|
// Native streaming turns and saved authored turns can arrive independently.
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
616
|
+
// Local queued timestamps can precede an earlier message's native admission.
|
|
617
|
+
// Keep their supplied positions, along with undated notices.
|
|
618
|
+
const dated = candidates.filter(entry => Number.isFinite(entry.sortAt))
|
|
619
|
+
.sort((a, b) => a.sortAt - b.sortAt)[Symbol.iterator]();
|
|
618
620
|
const entries = [];
|
|
619
621
|
for (const candidate of candidates) {
|
|
620
|
-
const entry = Number.isFinite(
|
|
622
|
+
const entry = Number.isFinite(candidate.sortAt) ? dated.next().value : candidate;
|
|
621
623
|
const previous = entries.at(-1);
|
|
622
624
|
if (entry.role === "thinking" && previous?.role === "thinking") previous.messages.push({ ...entry.message, key: entry.key });
|
|
623
625
|
else entries.push({ ...entry, ...(entry.role === "thinking" ? { messages: [{ ...entry.message, key: entry.key }] } : {}) });
|
|
@@ -63,6 +63,18 @@ export function conversationAgentRunEvent(previous, normalizedRunId, {
|
|
|
63
63
|
return record;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** Explicit offline copy only: the destination has no inherited execution. */
|
|
67
|
+
export function prepareCopiedConversationAgentRun(run, { at }) {
|
|
68
|
+
const active = runStateIsActive(run.state);
|
|
69
|
+
if (!active && run.providerGoalStatus !== "active") return structuredClone(run);
|
|
70
|
+
return conversationAgentRunEvent(run, run.id, {
|
|
71
|
+
event: { at, kind: "development-copy", message: "Copied into a stopped development environment." },
|
|
72
|
+
patch: { state: active ? "interrupted" : run.state, providerPhase: "",
|
|
73
|
+
...(active ? { providerStatus: "interrupted", pendingUserMessageClientIds: [], finishedAt: at } : {}),
|
|
74
|
+
...(run.providerGoalStatus === "active" ? { providerGoalStatus: "paused" } : {}) }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
/** The original native run occupies its bound conversation segment. Reading a
|
|
67
79
|
* binding without a run never constructs historical ownership or writes state.
|
|
68
80
|
*/
|
|
@@ -3,13 +3,13 @@ export { conversationMessageIdentity, conversationMessageVersion, conversationHi
|
|
|
3
3
|
export { createMemoryConversationStorage } from "./memoryStorage.js";
|
|
4
4
|
export { createConversationStorage, createReentrantConversationStorage,
|
|
5
5
|
createConversationOperationLease, beginConversationOperation, finishConversationOperation } from "./storage.js";
|
|
6
|
-
export { conversationAgentRunRecord, conversationAgentRunEvent } from "./agentRun.js";
|
|
6
|
+
export { conversationAgentRunRecord, conversationAgentRunEvent, prepareCopiedConversationAgentRun } from "./agentRun.js";
|
|
7
7
|
export { createFileConversationStorage } from "./fileStorage.js";
|
|
8
8
|
export { createConversationStreams } from "./streams.js";
|
|
9
9
|
export { createConversationSystemPrompt } from "./systemPrompt.js";
|
|
10
10
|
export { createConversationRuntime } from "./runtime.js";
|
|
11
11
|
export { normalizeConversationConfiguration } from "./configuration.js";
|
|
12
|
-
export { upgradeConversationRuntimeState } from "./runtimeStateUpgrade.js";
|
|
12
|
+
export { upgradeConversationRuntimeState, prepareConversationRuntimeCopy } from "./runtimeStateUpgrade.js";
|
|
13
13
|
export { createConversationHookBridge } from "./hookBridge.js";
|
|
14
14
|
export { nativeConversationStoragePolicy } from "./nativeStorage.js";
|
|
15
15
|
export { createConversationProcessIdentity } from "./localExecution.js";
|
|
@@ -248,10 +248,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
248
248
|
currentConnection?.fingerprint === selected.fingerprint &&
|
|
249
249
|
currentConnection?.endpointCode === selected.endpointCode
|
|
250
250
|
)) {
|
|
251
|
+
const probe = AbortSignal.timeout(1_000);
|
|
251
252
|
try {
|
|
252
|
-
await sharedProcess.server.client.health({ signal:
|
|
253
|
+
await sharedProcess.server.client.health({ signal: probe });
|
|
253
254
|
return sharedProcess;
|
|
254
|
-
} catch {
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// A slow probe is not proof that the shared server stopped working.
|
|
257
|
+
if (probe.aborted) throw error;
|
|
255
258
|
requireConnectionAvailable();
|
|
256
259
|
await stop("opencode-health-check-failed");
|
|
257
260
|
}
|
|
@@ -1567,12 +1570,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
|
|
|
1567
1570
|
const observedProcess = sharedProcess;
|
|
1568
1571
|
const observedServer = target.server;
|
|
1569
1572
|
let healthy = false;
|
|
1573
|
+
const probe = AbortSignal.timeout(1_000);
|
|
1570
1574
|
try {
|
|
1571
|
-
await target.server.client.health({ signal:
|
|
1575
|
+
await target.server.client.health({ signal: probe });
|
|
1572
1576
|
healthy = true;
|
|
1573
1577
|
ready = Boolean(await target.server.client.readSession(target.upstreamSessionId));
|
|
1574
1578
|
} catch (error) {
|
|
1575
|
-
if (healthy && error?.statusCode !== 404) {
|
|
1579
|
+
if ((!healthy && probe.aborted) || (healthy && error?.statusCode !== 404)) {
|
|
1576
1580
|
throw error;
|
|
1577
1581
|
}
|
|
1578
1582
|
if (!healthy) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
|
+
import { prepareCopiedConversationAgentRun } from "./agentRun.js";
|
|
2
3
|
|
|
3
4
|
const engines = new Set(["api", "claude", "codex", "opencode"]);
|
|
4
5
|
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -198,3 +199,44 @@ export function upgradeConversationRuntimeState({ metadata, conversationLog, ret
|
|
|
198
199
|
runtime.lastEngine = lastEngine;
|
|
199
200
|
return { metadata: { ...metadata, runtime }, changed: true, warnings };
|
|
200
201
|
}
|
|
202
|
+
|
|
203
|
+
/** Explicit destination-only operation. The caller first stops all copied
|
|
204
|
+
* processes and pauses native goals through their owners; this makes their
|
|
205
|
+
* saved projections inert without opening a provider or replaying requests.
|
|
206
|
+
*/
|
|
207
|
+
export async function prepareConversationRuntimeCopy(transaction, { at }) {
|
|
208
|
+
if (typeof at !== "string" || !Number.isFinite(Date.parse(at))) throw new TypeError("A copy timestamp is required.");
|
|
209
|
+
const metadata = structuredClone(await transaction.readMetadata());
|
|
210
|
+
const turns = await Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id)));
|
|
211
|
+
const runtime = metadata.runtime;
|
|
212
|
+
if (runtime) {
|
|
213
|
+
if (runtime.version !== 3) invalid("Upgrade the parent conversation before copying its runtime state.");
|
|
214
|
+
upgradeConversationRuntimeState({ metadata, conversationLog: turns });
|
|
215
|
+
if (runtime.replacement) invalid("Finish the parent conversation changeover before copying it.");
|
|
216
|
+
for (const segment of [...runtime.predecessors, runtime]) {
|
|
217
|
+
if (segment.binding) {
|
|
218
|
+
// Native history identity and paths remain valid in the identical guest
|
|
219
|
+
// layout; process handles belong exclusively to the original machine.
|
|
220
|
+
if (Object.hasOwn(segment.binding, "executionId")) segment.binding.executionId = "";
|
|
221
|
+
if (Object.hasOwn(segment.binding, "processDirectory")) segment.binding.processDirectory = "";
|
|
222
|
+
if (segment.binding.codexAppServerRun) {
|
|
223
|
+
segment.binding.codexAppServerRun = prepareCopiedConversationAgentRun(segment.binding.codexAppServerRun, { at });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (segment.nativeTurn) {
|
|
227
|
+
if (segment.nativeTurn.active) Object.assign(segment.nativeTurn, {
|
|
228
|
+
active: false, state: "interrupted", status: "interrupted", runState: "interrupted", phase: "", completedAt: at, updatedAt: at
|
|
229
|
+
});
|
|
230
|
+
if (segment.nativeTurn.goalStatus === "active") segment.nativeTurn.goalStatus = "paused";
|
|
231
|
+
}
|
|
232
|
+
if (segment.request) segment.request.error = "Copied pending input was not replayed. Check its delivery before submitting again.";
|
|
233
|
+
}
|
|
234
|
+
await transaction.writeMetadata(metadata);
|
|
235
|
+
}
|
|
236
|
+
for (const turn of turns) {
|
|
237
|
+
if (turn.metadata?.runtime?.status === "running") {
|
|
238
|
+
await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime,
|
|
239
|
+
status: "interrupted", error: "Copied into a stopped development environment. This request was not replayed." } });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { upgradeConversationRuntimeState } from "../src/server/conversation/runtimeStateUpgrade.js";
|
|
3
|
+
import { upgradeConversationRuntimeState, prepareConversationRuntimeCopy } from "../src/server/conversation/runtimeStateUpgrade.js";
|
|
4
|
+
import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
|
|
5
|
+
import { codexAppServerTurnStateFromAgentRun } from "../src/server/conversation/codexTurnState.js";
|
|
4
6
|
|
|
5
7
|
const configuration = { systemPrompt: "Retained instructions", model: "saved-model" };
|
|
6
8
|
const request = { messageId: "uncertain", text: "Authored text", origin: "user", attachments: [{ attachmentId: "file" }],
|
|
@@ -19,6 +21,72 @@ const record = (engine = "claude", extra = {}) => ({
|
|
|
19
21
|
messages: [{ role: "user", text: "Accepted before model selection", messageId: "old-message" }] }]
|
|
20
22
|
});
|
|
21
23
|
|
|
24
|
+
test("a stopped development copy preserves native identity, history and uncertain input without retaining active execution", async () => {
|
|
25
|
+
const at = "2026-10-15T00:00:00.000Z";
|
|
26
|
+
for (const engine of ["api", "codex", "claude", "opencode"]) {
|
|
27
|
+
const value = record(engine, { version: 3, lastEngine: engine,
|
|
28
|
+
...(engine === "api" ? {} : { request: { ...request, inspectionOnly: true,
|
|
29
|
+
threadId: engine === "codex" ? "saved-thread" : engine === "claude" ? "saved-conversation" : "saved-session" } }),
|
|
30
|
+
nativeTurn: { active: true, state: "active", phase: "working", goalStatus: "active" } });
|
|
31
|
+
if (value.metadata.runtime.binding) Object.assign(value.metadata.runtime.binding = { ...value.metadata.runtime.binding }, {
|
|
32
|
+
executionId: "parent-process", processDirectory: "/parent/process" });
|
|
33
|
+
if (engine === "codex") value.metadata.runtime.binding.codexAppServerRun = {
|
|
34
|
+
id: "codex_app_server", state: "active", providerThreadId: "saved-thread", providerTurnId: "native-turn",
|
|
35
|
+
providerStatus: "inProgress", providerGoalStatus: "active", pendingUserMessageClientIds: ["pending"], events: []
|
|
36
|
+
};
|
|
37
|
+
const before = structuredClone(value);
|
|
38
|
+
const storage = createMemoryConversationStorage();
|
|
39
|
+
await storage.write("copy", async transaction => {
|
|
40
|
+
await transaction.writeMetadata(value.metadata);
|
|
41
|
+
await transaction.appendMessage("turn", { role: "user", text: "Existing question", messageId: "authored", at,
|
|
42
|
+
turnMetadata: { runtime: { engine, segmentId: `${engine}-segment`, status: "running" } } });
|
|
43
|
+
});
|
|
44
|
+
await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
|
|
45
|
+
const copied = await storage.read("copy", async transaction => ({
|
|
46
|
+
metadata: await transaction.readMetadata(), turn: await transaction.readTurn("turn")
|
|
47
|
+
}));
|
|
48
|
+
const saved = copied.metadata.runtime;
|
|
49
|
+
assert.deepEqual(value, before, "The parent input is never mutated");
|
|
50
|
+
assert.equal(saved.segmentId, before.metadata.runtime.segmentId);
|
|
51
|
+
assert.deepEqual(saved.configuration, configuration);
|
|
52
|
+
assert.equal(saved.nativeTurn.active, false);
|
|
53
|
+
assert.equal(saved.nativeTurn.goalStatus, "paused");
|
|
54
|
+
assert.equal(copied.turn.metadata.runtime.status, "interrupted");
|
|
55
|
+
assert.equal(copied.turn.messages[0].text, "Existing question");
|
|
56
|
+
if (engine !== "api") {
|
|
57
|
+
assert.equal(saved.binding.executionId, "");
|
|
58
|
+
assert.equal(saved.binding.processDirectory, "");
|
|
59
|
+
assert.equal(saved.request.text, request.text);
|
|
60
|
+
assert.equal(saved.request.inspectionOnly, true);
|
|
61
|
+
assert.deepEqual(saved.request.attachments, request.attachments);
|
|
62
|
+
assert.match(saved.request.error, /not replayed/);
|
|
63
|
+
}
|
|
64
|
+
if (engine === "codex") {
|
|
65
|
+
assert.equal(saved.binding.threadId, "saved-thread");
|
|
66
|
+
const turn = codexAppServerTurnStateFromAgentRun(saved.binding.codexAppServerRun);
|
|
67
|
+
assert.equal(turn.active, false);
|
|
68
|
+
assert.equal(turn.goalStatus, "paused");
|
|
69
|
+
assert.equal(turn.turnId, "native-turn");
|
|
70
|
+
assert.deepEqual(saved.binding.codexAppServerRun.pendingUserMessageClientIds, []);
|
|
71
|
+
}
|
|
72
|
+
await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
|
|
73
|
+
assert.deepEqual(await storage.read("copy", transaction => transaction.readMetadata()), copied.metadata);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("development copy rejects incomplete changeover and older runtime before writing", async () => {
|
|
78
|
+
for (const metadata of [record().metadata, record("claude", {
|
|
79
|
+
version: 3, lastEngine: "claude", replacement: { ...segment("claude"), request: { operationId: "pending" } }
|
|
80
|
+
}).metadata]) {
|
|
81
|
+
let wrote = false;
|
|
82
|
+
await assert.rejects(prepareConversationRuntimeCopy({
|
|
83
|
+
readMetadata: async () => metadata, listTurnIds: async () => [],
|
|
84
|
+
writeMetadata: async () => { wrote = true; }
|
|
85
|
+
}, { at: "2026-10-15T00:00:00Z" }), /Upgrade the parent|Finish the parent/);
|
|
86
|
+
assert.equal(wrote, false);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
22
90
|
test("offline conversion preserves exact native bindings and authored requests without inventing a prompt", () => {
|
|
23
91
|
const value = record("claude", { predecessors: [segment("codex", { request: structuredClone(request) })] });
|
|
24
92
|
const before = structuredClone(value);
|
|
@@ -54,6 +54,21 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
|
|
|
54
54
|
await expect(input).toHaveValue("Retain this.");
|
|
55
55
|
await expect(page.getByText("Cancel this question.", { exact: true })).toHaveCount(0);
|
|
56
56
|
await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
|
|
57
|
+
await send("Admit this first.");
|
|
58
|
+
await send("Keep this queued second.");
|
|
59
|
+
const userRows = page.locator(".assistant-transcript__message--user");
|
|
60
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
61
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
62
|
+
await page.getByRole("button", { name: "Accept request" }).click();
|
|
63
|
+
await expect(page.getByText("Pending", { exact: true })).toHaveCount(1);
|
|
64
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
65
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
66
|
+
await expect.poll(async () => JSON.parse(await page.locator("[data-requests]").textContent()).length).toBe(2);
|
|
67
|
+
await page.getByRole("button", { name: "Accept request" }).click();
|
|
68
|
+
await expect(page.getByText("Pending", { exact: true })).toHaveCount(0);
|
|
69
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
70
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
71
|
+
await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
|
|
57
72
|
await send("First steer.");
|
|
58
73
|
await send("Second steer.");
|
|
59
74
|
await send("Third steer.");
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
7
|
import { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory, readOpenCodeEnvironments } from "../src/server/conversation/openCodeRuntime.js";
|
|
7
8
|
import { createOpenCodeServerClient } from "../src/server/conversation/openCodeClient.js";
|
|
8
9
|
|
|
@@ -13,7 +14,7 @@ function fixture(options = {}) {
|
|
|
13
14
|
const start = async selected => {
|
|
14
15
|
const server = {
|
|
15
16
|
client: {
|
|
16
|
-
async health() { return { healthy: true }; },
|
|
17
|
+
async health(input) { return options.health ? options.health(input) : { healthy: true }; },
|
|
17
18
|
async interrupt(id) { return options.interrupt ? options.interrupt(id) : true; },
|
|
18
19
|
async sessionStatus() { return { type: "idle" }; },
|
|
19
20
|
forDirectory(directory) { return { ...this, directory }; }
|
|
@@ -252,6 +253,75 @@ test("a pending OpenCode session start retains the shared server while another s
|
|
|
252
253
|
assert.equal(f.stops.length, 1);
|
|
253
254
|
});
|
|
254
255
|
|
|
256
|
+
test("a timed-out shared health probe preserves active conversations and allows retry", async () => {
|
|
257
|
+
let slow = false;
|
|
258
|
+
const f = fixture({ health: async ({ signal }) => {
|
|
259
|
+
if (slow) await delay(5_000, undefined, { signal });
|
|
260
|
+
return { healthy: true };
|
|
261
|
+
} });
|
|
262
|
+
const main = await f.acquire("main");
|
|
263
|
+
const helper = await f.acquire("helper");
|
|
264
|
+
const turn = { active: true, id: "turn", threadId: "main-thread" };
|
|
265
|
+
const temporary = { active: true, target: helper, conversationId: "helper-thread" };
|
|
266
|
+
f.runtime.turns.set(main.key, turn);
|
|
267
|
+
f.runtime.temporaryConversations.set("helper-thread", temporary);
|
|
268
|
+
const shared = f.runtime.current;
|
|
269
|
+
slow = true;
|
|
270
|
+
await assert.rejects(f.acquire("next"), { name: "AbortError" });
|
|
271
|
+
assert.equal(f.runtime.current, shared);
|
|
272
|
+
assert.equal(f.stops.length, 0);
|
|
273
|
+
assert.equal(f.starts.length, 1);
|
|
274
|
+
assert.equal(main.abortController.signal.aborted, false);
|
|
275
|
+
assert.equal(helper.abortController.signal.aborted, false);
|
|
276
|
+
assert.equal(f.runtime.turns.get(main.key), turn);
|
|
277
|
+
assert.equal(f.runtime.temporaryConversations.get("helper-thread"), temporary);
|
|
278
|
+
assert.equal(turn.active, true);
|
|
279
|
+
assert.equal(temporary.active, true);
|
|
280
|
+
assert.equal(f.runtime.processStarts.size, 0);
|
|
281
|
+
slow = false;
|
|
282
|
+
await f.acquire("next");
|
|
283
|
+
assert.equal(f.starts.length, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a timed-out readiness probe does not classify the shared process as unhealthy", async () => {
|
|
287
|
+
let slow = false;
|
|
288
|
+
const f = fixture({ health: async ({ signal }) => {
|
|
289
|
+
if (slow) await delay(5_000, undefined, { signal });
|
|
290
|
+
return { healthy: true };
|
|
291
|
+
} });
|
|
292
|
+
const target = await f.acquire("main");
|
|
293
|
+
const selection = { modelProviderId: "provider", modelId: "model", catalogRevision: "catalog" };
|
|
294
|
+
Object.assign(target, { upstream: { id: "thread" }, upstreamSessionId: "thread", selection,
|
|
295
|
+
upstreamSelection: selection, canonicalUrl: f.connection.canonicalUrl,
|
|
296
|
+
connectionFingerprint: f.connection.fingerprint, endpointCode: f.connection.endpointCode });
|
|
297
|
+
target.server.client.readSession = async () => ({ id: "thread" });
|
|
298
|
+
const input = { selection, workdir: target.workdir, connection: f.connection,
|
|
299
|
+
refresh: async () => target, changed: () => new Error("Changed") };
|
|
300
|
+
slow = true;
|
|
301
|
+
await assert.rejects(f.runtime.inspectSessionReadiness(target, input), { name: "AbortError" });
|
|
302
|
+
assert.equal(f.stops.length, 0);
|
|
303
|
+
assert.equal(target.abortController.signal.aborted, false);
|
|
304
|
+
slow = false;
|
|
305
|
+
assert.deepEqual(await f.runtime.inspectSessionReadiness(target, input), {
|
|
306
|
+
ready: true, unhealthyProcess: null, missingUpstream: false
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a refused shared health connection still replaces the stopped server", async () => {
|
|
311
|
+
let refused = false;
|
|
312
|
+
const f = fixture({ health: async () => {
|
|
313
|
+
if (refused) throw new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } });
|
|
314
|
+
return { healthy: true };
|
|
315
|
+
} });
|
|
316
|
+
await f.acquire("first");
|
|
317
|
+
const original = f.runtime.current;
|
|
318
|
+
refused = true;
|
|
319
|
+
await f.acquire("next");
|
|
320
|
+
assert.equal(f.stops.length, 1);
|
|
321
|
+
assert.equal(f.starts.length, 2);
|
|
322
|
+
assert.notEqual(f.runtime.current, original);
|
|
323
|
+
});
|
|
324
|
+
|
|
255
325
|
test("OpenCode failed acquisition cleans application state once before stopping an unused server", async () => {
|
|
256
326
|
const f = fixture();
|
|
257
327
|
const ready = Promise.withResolvers();
|