zcode-acp-server 0.43.4 → 0.44.1
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/README.md +43 -1
- package/README.zh-CN.md +39 -0
- package/dist/backend/client.d.ts +11 -0
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +16 -0
- package/dist/backend/client.js.map +1 -1
- package/dist/backend/sandbox.d.ts +9 -6
- package/dist/backend/sandbox.d.ts.map +1 -1
- package/dist/backend/sandbox.js +19 -22
- package/dist/backend/sandbox.js.map +1 -1
- package/dist/code-fingerprint.json +2 -2
- package/dist/config/auto-compact.d.ts +22 -8
- package/dist/config/auto-compact.d.ts.map +1 -1
- package/dist/config/auto-compact.js +32 -11
- package/dist/config/auto-compact.js.map +1 -1
- package/dist/config/settings.d.ts +35 -0
- package/dist/config/settings.d.ts.map +1 -0
- package/dist/config/settings.js +88 -0
- package/dist/config/settings.js.map +1 -0
- package/dist/config/user-config.d.ts +39 -1
- package/dist/config/user-config.d.ts.map +1 -1
- package/dist/config/user-config.js +123 -6
- package/dist/config/user-config.js.map +1 -1
- package/dist/goal-loop/driver.d.ts +1 -1
- package/dist/goal-loop/driver.d.ts.map +1 -1
- package/dist/goal-loop/driver.js +5 -5
- package/dist/goal-loop/driver.js.map +1 -1
- package/dist/handlers/extensions.d.ts.map +1 -1
- package/dist/handlers/extensions.js +8 -0
- package/dist/handlers/extensions.js.map +1 -1
- package/dist/handlers/replay.d.ts +8 -0
- package/dist/handlers/replay.d.ts.map +1 -1
- package/dist/handlers/replay.js +20 -9
- package/dist/handlers/replay.js.map +1 -1
- package/dist/handlers/server-requests.d.ts +14 -0
- package/dist/handlers/server-requests.d.ts.map +1 -1
- package/dist/handlers/server-requests.js +54 -49
- package/dist/handlers/server-requests.js.map +1 -1
- package/dist/handlers/session.d.ts +3 -3
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +130 -47
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +4 -3
- package/dist/handlers/slash.js.map +1 -1
- package/dist/i18n.d.ts +5 -2
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +7 -3
- package/dist/i18n.js.map +1 -1
- package/dist/server.d.ts +23 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +48 -2
- package/dist/server.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -3
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/handlers/session.js
CHANGED
|
@@ -22,6 +22,7 @@ import { currentModelCached, emitInitialUsage } from "../config/model-cache.js";
|
|
|
22
22
|
import { forceRefreshQuota, scheduleQuotaDockBackstop, startQuotaRefresher, } from "../quota/live.js";
|
|
23
23
|
import { buildProviderRegistry } from "../config/provider-registry.js";
|
|
24
24
|
import { pushAccountProviderConfig } from "../config/account-provider.js";
|
|
25
|
+
import { initialSessionMode } from "../config/settings.js";
|
|
25
26
|
import { applyModelSwitch, buildResumeRuntimeModel } from "../config/runtime-model.js";
|
|
26
27
|
import { messages } from "../i18n.js";
|
|
27
28
|
import { lookupLazySession, recordMaterializedSession, rememberLazySession, } from "../lazy-sessions.js";
|
|
@@ -512,9 +513,10 @@ export async function ensureRealSession(server, acpSid, opts = {}) {
|
|
|
512
513
|
// clash behaviour is the backend's own and unasserted here.
|
|
513
514
|
const createParams = {
|
|
514
515
|
workspace: workspaceFor(pending.cwd),
|
|
515
|
-
//
|
|
516
|
-
// stays "yolo" (unrestricted), which is
|
|
517
|
-
|
|
516
|
+
// The initial mode comes from the user config (session.mode) or
|
|
517
|
+
// ZCODE_ACP_MODE; the default stays "yolo" (unrestricted), which is
|
|
518
|
+
// what the bridge always used.
|
|
519
|
+
mode: initialSessionMode(),
|
|
518
520
|
};
|
|
519
521
|
if (pending.mcpServers && pending.mcpServers.length > 0) {
|
|
520
522
|
createParams.mcpServers = pending.mcpServers;
|
|
@@ -722,14 +724,14 @@ function isMarttyClient(server) {
|
|
|
722
724
|
* session/load and would double-render a resume replay.
|
|
723
725
|
*/
|
|
724
726
|
async function replayResumeHistory(server, cx, acpSid, zcodeSid) {
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
// held across the session/messages RPC.
|
|
728
|
-
// in that window dispatches live turn
|
|
729
|
-
// path (enqueueSessionSend) and the TUI
|
|
730
|
-
// history that arrives afterwards.
|
|
727
|
+
// Guarded fetch (a resume flight settles the store, but a capped flight
|
|
728
|
+
// leaves hydration running — fetchMessagesForReplay re-settles then) INSIDE
|
|
729
|
+
// the batch: the guard must be held across the session/messages RPC.
|
|
730
|
+
// Fetched outside, a prompt landing in that window dispatches live turn
|
|
731
|
+
// updates through the lock-free fast path (enqueueSessionSend) and the TUI
|
|
732
|
+
// renders the new turn ABOVE the history that arrives afterwards.
|
|
731
733
|
await withReplayBatch(acpSid, async () => {
|
|
732
|
-
const messages = await
|
|
734
|
+
const messages = await fetchMessagesForReplay(server, zcodeSid);
|
|
733
735
|
if (messages.length === 0)
|
|
734
736
|
return;
|
|
735
737
|
const slice = sliceTail(messages, MARTTY_RESUME_TAIL);
|
|
@@ -836,8 +838,9 @@ export async function resumeSession(server, params, cx) {
|
|
|
836
838
|
// first: microtasks (the SDK's response send) drain before immediates.
|
|
837
839
|
if (isMarttyClient(server)) {
|
|
838
840
|
setImmediate(() => {
|
|
839
|
-
//
|
|
840
|
-
// before this session/resume returned
|
|
841
|
+
// The replay's fetch is marker-guarded (see fetchMessagesForReplay): a
|
|
842
|
+
// resume flight settles the store before this session/resume returned,
|
|
843
|
+
// and a capped flight leaves the marker for the replay to re-settle.
|
|
841
844
|
replayResumeHistory(server, cx, acpSid, zcodeSid).catch((e) => {
|
|
842
845
|
warn(`session/resume: TUI history replay failed (non-fatal): ` +
|
|
843
846
|
`${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -947,7 +950,7 @@ export async function resumeIntoSession(server, cx, acpSid, zcodeTarget) {
|
|
|
947
950
|
// Fetch inside the batch (see replayResumeHistory): the guard must cover
|
|
948
951
|
// the history RPC, or a concurrent prompt renders above the replay.
|
|
949
952
|
await withReplayBatch(acpSid, async () => {
|
|
950
|
-
const history = settledHistory ?? (await
|
|
953
|
+
const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeTarget));
|
|
951
954
|
if (history.length > 0)
|
|
952
955
|
server.markSessionActive(acpSid);
|
|
953
956
|
const slice = fullSlice(history);
|
|
@@ -1067,7 +1070,8 @@ export async function loadSession(server, params, cx, opts = {}) {
|
|
|
1067
1070
|
// Named `history` — a local `messages` would shadow the i18n `messages()`
|
|
1068
1071
|
// helper (TDZ crash from a catch block). A resume flight's SETTLED snapshot
|
|
1069
1072
|
// is the mid-hydration guard; only an already-live session (no flight) falls
|
|
1070
|
-
// back to a plain read —
|
|
1073
|
+
// back to a plain read — which fetchMessagesForReplay upgrades to a settle
|
|
1074
|
+
// poll when the last flight capped out mid-hydration.
|
|
1071
1075
|
let slice;
|
|
1072
1076
|
if (opts.replayHistory === false) {
|
|
1073
1077
|
// Boot-resume interception (session/new): the terminal TUI client does not
|
|
@@ -1075,7 +1079,7 @@ export async function loadSession(server, params, cx, opts = {}) {
|
|
|
1075
1079
|
// and blocks on the response until the replay finishes — so skip the
|
|
1076
1080
|
// dispatch. The differ baseline below still runs, so the next turn's
|
|
1077
1081
|
// completion diff does not re-emit the historical messages.
|
|
1078
|
-
const history = settledHistory ?? (await
|
|
1082
|
+
const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeSid));
|
|
1079
1083
|
// History on disk = real interaction (covers untitled sessions resumed from
|
|
1080
1084
|
// a previous bridge lifetime) — make the session discoverable remotely.
|
|
1081
1085
|
if (history.length > 0)
|
|
@@ -1088,7 +1092,7 @@ export async function loadSession(server, params, cx, opts = {}) {
|
|
|
1088
1092
|
// a prompt landing while the history RPC is in flight must queue behind
|
|
1089
1093
|
// the batch, not dispatch above the replayed history.
|
|
1090
1094
|
slice = await withReplayBatch(acpSid, async () => {
|
|
1091
|
-
const history = settledHistory ?? (await
|
|
1095
|
+
const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeSid));
|
|
1092
1096
|
if (history.length > 0)
|
|
1093
1097
|
server.markSessionActive(acpSid);
|
|
1094
1098
|
const batchSlice = limit === null ? fullSlice(history) : sliceTail(history, limit);
|
|
@@ -1234,7 +1238,9 @@ export async function runOneTurn(server, opts) {
|
|
|
1234
1238
|
// Per-session ProjectionDiffer (persists across turns). The baseline mark_seen
|
|
1235
1239
|
// prevents the differ from re-emitting history at turn completion.
|
|
1236
1240
|
const differ = getOrCreateDiffer(server, zcodeSid);
|
|
1237
|
-
|
|
1241
|
+
// Marker-guarded read: a prefix baseline during armed hydration would make
|
|
1242
|
+
// the completion diff re-emit the messages hydration later appends.
|
|
1243
|
+
const baselineMsgs = await fetchMessagesForReplay(server, zcodeSid);
|
|
1238
1244
|
differ.markSeen(baselineMsgs);
|
|
1239
1245
|
// Subscribe BEFORE send so we don't lose early turn.completed on short turns.
|
|
1240
1246
|
// subscribe() throws on failure, surfacing the backend's real error (reader
|
|
@@ -1278,6 +1284,9 @@ export async function runOneTurn(server, opts) {
|
|
|
1278
1284
|
// above. Kept as a binding only so the call fits the Promise-returning shape.
|
|
1279
1285
|
void snapshot;
|
|
1280
1286
|
backend.registerEventListener(zcodeSid, listener);
|
|
1287
|
+
// Armed (not run) on a successful end_turn — the finally starts the detached
|
|
1288
|
+
// compaction AFTER this turn's cleanup (see runAutoCompactDetached).
|
|
1289
|
+
let autoCompactDue = false;
|
|
1281
1290
|
try {
|
|
1282
1291
|
// Transient turn failures (e.g. provider network blips surfaced as
|
|
1283
1292
|
// turn.failed with cause code model_request_failed) are retried by
|
|
@@ -1303,7 +1312,7 @@ export async function runOneTurn(server, opts) {
|
|
|
1303
1312
|
// reconcile the differ baseline so the retried turn's new messages
|
|
1304
1313
|
// aren't treated as already-seen, surface a retry hint, then back off.
|
|
1305
1314
|
if (turn.cancelled) {
|
|
1306
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
1315
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
1307
1316
|
return { stopReason: "cancelled" };
|
|
1308
1317
|
}
|
|
1309
1318
|
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
@@ -1316,9 +1325,13 @@ export async function runOneTurn(server, opts) {
|
|
|
1316
1325
|
}
|
|
1317
1326
|
const chunkMsgId = randomUUID();
|
|
1318
1327
|
// Drain gate: a recent cancel/preempt means the backend side needs
|
|
1319
|
-
// settling before the send — see drainBackendAfterCancel.
|
|
1328
|
+
// settling before the send — see drainBackendAfterCancel. EXEMPT while a
|
|
1329
|
+
// detached auto-compact runs: it legitimately holds the prompt lock, and
|
|
1330
|
+
// the gate's close escalation would tear down the resident runtime and
|
|
1331
|
+
// kill the compaction — the busy-retry below waits it out instead.
|
|
1320
1332
|
const cancelledRecently = server.lastCancelledAt.get(zcodeSid) !== undefined &&
|
|
1321
|
-
Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS
|
|
1333
|
+
Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS &&
|
|
1334
|
+
!server.autoCompactInFlight.has(zcodeSid);
|
|
1322
1335
|
if (cancelledRecently) {
|
|
1323
1336
|
const drained = await drainBackendAfterCancel(server, {
|
|
1324
1337
|
acpSid,
|
|
@@ -1350,9 +1363,13 @@ export async function runOneTurn(server, opts) {
|
|
|
1350
1363
|
: { sessionId: zcodeSid, content: sendText };
|
|
1351
1364
|
const sendT0 = Date.now();
|
|
1352
1365
|
let sendAttempt = 0;
|
|
1366
|
+
// One-shot "waiting for compaction" notice — a minutes-long busy window
|
|
1367
|
+
// with no explanation reads as a hang (the very thing that made users
|
|
1368
|
+
// ESC out of the old blocking auto-compact).
|
|
1369
|
+
let compactWaitNotified = false;
|
|
1353
1370
|
while (true) {
|
|
1354
1371
|
if (turn.cancelled) {
|
|
1355
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
1372
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
1356
1373
|
return { stopReason: "cancelled" };
|
|
1357
1374
|
}
|
|
1358
1375
|
sendAttempt++;
|
|
@@ -1366,15 +1383,19 @@ export async function runOneTurn(server, opts) {
|
|
|
1366
1383
|
if (expectBusy) {
|
|
1367
1384
|
await sleep(SEND_RETRY_INTERVAL_MS);
|
|
1368
1385
|
if (turn.cancelled) {
|
|
1369
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
1386
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
1370
1387
|
return { stopReason: "cancelled" };
|
|
1371
1388
|
}
|
|
1372
1389
|
}
|
|
1373
1390
|
const sendResp = await backend.request(server.nextId(), "session/send", sendParams, 15000);
|
|
1374
1391
|
if (!sendResp.error) {
|
|
1375
1392
|
const accepted = (sendResp.result ?? {});
|
|
1376
|
-
if (accepted.accepted)
|
|
1393
|
+
if (accepted.accepted) {
|
|
1394
|
+
// From this moment the turn may own a running generation —
|
|
1395
|
+
// stopBackendTurn's compaction guard no longer spares it.
|
|
1396
|
+
turn.sendAccepted = true;
|
|
1377
1397
|
break; // backend took it → turn starts
|
|
1398
|
+
}
|
|
1378
1399
|
throw new Error("zcode send not accepted");
|
|
1379
1400
|
}
|
|
1380
1401
|
const sendErrCode = sendResp.error.code;
|
|
@@ -1403,8 +1424,20 @@ export async function runOneTurn(server, opts) {
|
|
|
1403
1424
|
const NOTIFY_TURN_STALE_MS = 600_000;
|
|
1404
1425
|
const notifySince = server.notifyTurnActiveSince.get(zcodeSid);
|
|
1405
1426
|
const notifyActive = notifySince !== undefined && Date.now() - notifySince < NOTIFY_TURN_STALE_MS;
|
|
1406
|
-
|
|
1427
|
+
// A detached auto-compact is the same shape: a real model turn holding
|
|
1428
|
+
// the lock for minutes. Extend the budget past its full worst case
|
|
1429
|
+
// (settle cap 300s + startup + probe gaps) and tell the user once.
|
|
1430
|
+
// Both flags can be true at once (a background notify turn AND a
|
|
1431
|
+
// compaction queued behind it) — take the LARGER budget, never the
|
|
1432
|
+
// smaller: the smaller fails the user's prompt mid-wait.
|
|
1433
|
+
const COMPACT_BUSY_TIMEOUT_MS = 360_000;
|
|
1434
|
+
const compacting = server.autoCompactInFlight.has(zcodeSid);
|
|
1435
|
+
const busyBudget = Math.max(notifyActive ? NOTIFY_TURN_BUSY_TIMEOUT_MS : 0, compacting ? COMPACT_BUSY_TIMEOUT_MS : 0, SEND_RETRY_TIMEOUT_MS);
|
|
1407
1436
|
const budget = isBusy ? busyBudget : WARMUP_RETRY_TIMEOUT_MS;
|
|
1437
|
+
if (isBusy && compacting && !compactWaitNotified) {
|
|
1438
|
+
compactWaitNotified = true;
|
|
1439
|
+
await sendTextChunk(cx, acpSid, messages().autoCompactWaiting, randomUUID());
|
|
1440
|
+
}
|
|
1408
1441
|
if (Date.now() - sendT0 > budget) {
|
|
1409
1442
|
throw new Error(`zcode send failed: ${isBusy ? "backend still busy" : "send keeps being rejected"} after ${Math.round(budget / 1000)}s (${sendResp.error.message ?? ""})`);
|
|
1410
1443
|
}
|
|
@@ -1428,19 +1461,21 @@ export async function runOneTurn(server, opts) {
|
|
|
1428
1461
|
Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
|
|
1429
1462
|
const result = await runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed);
|
|
1430
1463
|
// Auto-compact: if context usage exceeds the threshold, compact before
|
|
1431
|
-
//
|
|
1464
|
+
// the NEXT prompt needs the room. Configured via
|
|
1432
1465
|
// ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
|
|
1433
1466
|
// disabled). Only on end_turn — cancelled/max_turn_requests skips
|
|
1434
1467
|
// compaction, as does a stall-recovered end_turn (the completion was
|
|
1435
1468
|
// inferred by the stall heuristic, not confirmed by turn.completed —
|
|
1436
1469
|
// compressing an in-flight task's would destroy the work).
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1470
|
+
// ARMED here, RUN detached from the finally: awaiting it inside the
|
|
1471
|
+
// turn kept the finished turn in pendingTurns for the whole
|
|
1472
|
+
// compaction, so any cancel/follow-up preempt killed the compaction's
|
|
1473
|
+
// internal turn (stop + drain close-escalation) while waitForTurnIdle
|
|
1474
|
+
// still reported a false "✓ compressed". Detached, the response
|
|
1475
|
+
// returns at once and the running indicator settles before the
|
|
1476
|
+
// compaction too.
|
|
1477
|
+
autoCompactDue =
|
|
1478
|
+
opts.autoCompact !== false && result.stopReason === "end_turn" && !turn.stallRecovered;
|
|
1444
1479
|
return result;
|
|
1445
1480
|
}
|
|
1446
1481
|
catch (e) {
|
|
@@ -1539,6 +1574,20 @@ export async function runOneTurn(server, opts) {
|
|
|
1539
1574
|
// (preempt): the preempting turn's own running:true must survive.
|
|
1540
1575
|
const stillBusy = [...server.pendingTurns.values()].some((t) => t.zcodeSid === zcodeSid);
|
|
1541
1576
|
await emitTurnState(stillBusy);
|
|
1577
|
+
// Start the armed auto-compact ONLY now, after deregistration and the
|
|
1578
|
+
// running:false turnState: from here on a cancel or follow-up prompt finds
|
|
1579
|
+
// no turn to preempt (the compaction is untouchable housekeeping), and a
|
|
1580
|
+
// prompt racing it waits on the busy-retry instead of killing it.
|
|
1581
|
+
if (autoCompactDue) {
|
|
1582
|
+
try {
|
|
1583
|
+
const { runAutoCompactDetached } = await import("../config/auto-compact.js");
|
|
1584
|
+
runAutoCompactDetached(server, cx, acpSid, zcodeSid);
|
|
1585
|
+
}
|
|
1586
|
+
catch (e) {
|
|
1587
|
+
// Never let the arming failure mask the completed turn's result.
|
|
1588
|
+
warn(`auto-compact arming failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1542
1591
|
}
|
|
1543
1592
|
}
|
|
1544
1593
|
/** One prompt round: subscribe-before-send, run the event-driven turn loop. */
|
|
@@ -1769,7 +1818,7 @@ export async function cancel(server, params) {
|
|
|
1769
1818
|
matched = true;
|
|
1770
1819
|
turn.cancelled = true;
|
|
1771
1820
|
if (!turn.stopSent) {
|
|
1772
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
1821
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
1773
1822
|
turn.stopSent = true;
|
|
1774
1823
|
}
|
|
1775
1824
|
// Record cancel time so a prompt arriving in the backend's ~20s
|
|
@@ -1876,7 +1925,20 @@ export function isBackendLostRequestError(e) {
|
|
|
1876
1925
|
* backend's prompt lock releases when ITS finalisation completes — that,
|
|
1877
1926
|
* not any bridge-side signal, is what the next prompt's send-retry waits on.
|
|
1878
1927
|
*/
|
|
1879
|
-
function stopBackendTurn(server, zcodeSid,
|
|
1928
|
+
function stopBackendTurn(server, zcodeSid, turn) {
|
|
1929
|
+
const foregroundExecutionId = turn.foregroundExecutionId;
|
|
1930
|
+
// A turn whose send was NEVER accepted owns no generation of its own; while
|
|
1931
|
+
// our detached compaction runs, the only thing foreground on this session
|
|
1932
|
+
// is the compaction's internal turn — an unguarded stop here (ESPECIALLY
|
|
1933
|
+
// the v4 stop, which "targets whatever is currently foreground") would
|
|
1934
|
+
// kill it. ESC on a prompt parked in the compaction wait is exactly how
|
|
1935
|
+
// that happens. Once the send WAS accepted the turn may own a generation
|
|
1936
|
+
// (turn.started — and with it the execution id — can lag, or never arrive
|
|
1937
|
+
// on a deaf stream), so the stop pair always fires for accepted turns.
|
|
1938
|
+
if (!foregroundExecutionId && !turn.sendAccepted && server.autoCompactInFlight.has(zcodeSid)) {
|
|
1939
|
+
log(` [stop] turn never started and a detached compaction is running for ${zcodeSid} — skipping the stop pair`);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1880
1942
|
try {
|
|
1881
1943
|
server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
|
|
1882
1944
|
}
|
|
@@ -1969,7 +2031,7 @@ export async function drainBackendAfterCancel(server, deps) {
|
|
|
1969
2031
|
let escalated = false;
|
|
1970
2032
|
while (Date.now() - drainT0 < DRAIN_TIMEOUT_MS) {
|
|
1971
2033
|
if (turn.cancelled) {
|
|
1972
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
2034
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
1973
2035
|
return "cancelled";
|
|
1974
2036
|
}
|
|
1975
2037
|
const proj = await monitor.pollOnce();
|
|
@@ -2076,7 +2138,7 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
|
|
|
2076
2138
|
continue;
|
|
2077
2139
|
turn.cancelled = true; // signal the old turn to stop its retry loops
|
|
2078
2140
|
if (!turn.stopSent) {
|
|
2079
|
-
stopBackendTurn(server, zcodeSid, turn
|
|
2141
|
+
stopBackendTurn(server, zcodeSid, turn);
|
|
2080
2142
|
turn.stopSent = true;
|
|
2081
2143
|
}
|
|
2082
2144
|
// Record cancel time so the prompt()'s send-retry can use the recovery
|
|
@@ -2344,7 +2406,7 @@ async function resumePreservingModel(server, zcParams) {
|
|
|
2344
2406
|
/** Settle-poll gap after a fresh resume (see fetchMessagesSettled). */
|
|
2345
2407
|
const RESUME_SETTLE_GAP_MS = 300;
|
|
2346
2408
|
/** Cap for the settle poll — past this the largest snapshot seen wins. */
|
|
2347
|
-
const RESUME_SETTLE_CAP_MS =
|
|
2409
|
+
const RESUME_SETTLE_CAP_MS = 10000;
|
|
2348
2410
|
/** Non-growing reads required to call the store settled (plateau guard). */
|
|
2349
2411
|
const RESUME_SETTLE_STABLE_READS = 2;
|
|
2350
2412
|
/**
|
|
@@ -2355,9 +2417,9 @@ const RESUME_SETTLE_STABLE_READS = 2;
|
|
|
2355
2417
|
* is fine once hydration finished; big sessions hydrate slowly, hence
|
|
2356
2418
|
* "often but not always"). Poll until the count has stopped growing for TWO
|
|
2357
2419
|
* consecutive reads (a single equal pair can be a >gap plateau inside a slow
|
|
2358
|
-
* hydration), capped; on the cap the largest snapshot seen wins.
|
|
2359
|
-
*
|
|
2360
|
-
* stable.
|
|
2420
|
+
* hydration), capped; on the cap the largest snapshot seen wins. A capped
|
|
2421
|
+
* exit records the session in server.hydrationUnsettled so later replay
|
|
2422
|
+
* reads re-settle (fetchMessagesForReplay); a stable exit clears it.
|
|
2361
2423
|
*/
|
|
2362
2424
|
export async function fetchMessagesSettled(server, zcodeSid) {
|
|
2363
2425
|
let messages = await fetchMessages(server, zcodeSid);
|
|
@@ -2368,17 +2430,38 @@ export async function fetchMessagesSettled(server, zcodeSid) {
|
|
|
2368
2430
|
const next = await fetchMessages(server, zcodeSid);
|
|
2369
2431
|
// Shrinking should not happen; never trade down either way.
|
|
2370
2432
|
if (next.length <= messages.length) {
|
|
2371
|
-
|
|
2433
|
+
// A failed read comes back EMPTY (fetchMessages swallows errors) — never
|
|
2434
|
+
// let it vouch for stability while hydration may still be running.
|
|
2435
|
+
const errored = next.length === 0 && messages.length > 0;
|
|
2436
|
+
if (!errored && ++stable >= RESUME_SETTLE_STABLE_READS) {
|
|
2437
|
+
server.hydrationUnsettled.delete(zcodeSid);
|
|
2372
2438
|
return messages;
|
|
2439
|
+
}
|
|
2373
2440
|
}
|
|
2374
2441
|
else {
|
|
2375
2442
|
stable = 0;
|
|
2376
2443
|
messages = next;
|
|
2377
2444
|
}
|
|
2378
|
-
if (Date.now() >= deadline)
|
|
2445
|
+
if (Date.now() >= deadline) {
|
|
2446
|
+
server.hydrationUnsettled.add(zcodeSid);
|
|
2447
|
+
warn(`settle capped at ${RESUME_SETTLE_CAP_MS}ms for ${zcodeSid} still growing ` +
|
|
2448
|
+
`(${messages.length} messages) — replaying the largest snapshot, ` +
|
|
2449
|
+
`next load re-settles`);
|
|
2379
2450
|
return messages;
|
|
2451
|
+
}
|
|
2380
2452
|
}
|
|
2381
2453
|
}
|
|
2454
|
+
/**
|
|
2455
|
+
* History read for replay paths: plain unless the last settle for this
|
|
2456
|
+
* session capped out mid-hydration — then re-settle first (the marker is
|
|
2457
|
+
* maintained by fetchMessagesSettled). Stable sessions pay zero extra reads;
|
|
2458
|
+
* a still-hydrating session gets the settle poll instead of a prefix.
|
|
2459
|
+
*/
|
|
2460
|
+
async function fetchMessagesForReplay(server, zcodeSid) {
|
|
2461
|
+
if (!server.hydrationUnsettled.has(zcodeSid))
|
|
2462
|
+
return fetchMessages(server, zcodeSid);
|
|
2463
|
+
return fetchMessagesSettled(server, zcodeSid);
|
|
2464
|
+
}
|
|
2382
2465
|
/**
|
|
2383
2466
|
* After a faithful resume the session keeps its own last model; when that
|
|
2384
2467
|
* model no longer belongs to an enabled provider in config.json (deleted or
|
|
@@ -2627,7 +2710,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2627
2710
|
else if (turn.cancelled) {
|
|
2628
2711
|
// Preserve the pre-existing bounded cancel behaviour. A stuck prompt
|
|
2629
2712
|
// lock after stop must not keep a user-cancelled turn alive forever.
|
|
2630
|
-
stopBackendTurn(server, turn.zcodeSid, turn
|
|
2713
|
+
stopBackendTurn(server, turn.zcodeSid, turn);
|
|
2631
2714
|
return turnResult(translator, "max_turn_requests");
|
|
2632
2715
|
}
|
|
2633
2716
|
else {
|
|
@@ -2668,7 +2751,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2668
2751
|
return turnResult(translator, "end_turn");
|
|
2669
2752
|
}
|
|
2670
2753
|
log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s with no output; stopping backend turn`);
|
|
2671
|
-
stopBackendTurn(server, turn.zcodeSid, turn
|
|
2754
|
+
stopBackendTurn(server, turn.zcodeSid, turn);
|
|
2672
2755
|
return turnResult(translator, "max_turn_requests");
|
|
2673
2756
|
}
|
|
2674
2757
|
}
|
|
@@ -2701,7 +2784,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2701
2784
|
// to no turn listener, and the next turn's turn-attribution gate
|
|
2702
2785
|
// discards any residue that slipped into the queue meanwhile.
|
|
2703
2786
|
if (!turn.stopSent) {
|
|
2704
|
-
stopBackendTurn(server, turn.zcodeSid, turn
|
|
2787
|
+
stopBackendTurn(server, turn.zcodeSid, turn);
|
|
2705
2788
|
turn.stopSent = true;
|
|
2706
2789
|
}
|
|
2707
2790
|
return turnResult(translator, "cancelled");
|
|
@@ -2776,7 +2859,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2776
2859
|
}
|
|
2777
2860
|
else if (!emittedOutput) {
|
|
2778
2861
|
// No text and no output → suspected failure.
|
|
2779
|
-
stopBackendTurn(server, turn.zcodeSid, turn
|
|
2862
|
+
stopBackendTurn(server, turn.zcodeSid, turn);
|
|
2780
2863
|
throw new RequestError(-32603, "turn produced no output");
|
|
2781
2864
|
}
|
|
2782
2865
|
}
|
|
@@ -2974,7 +3057,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2974
3057
|
}
|
|
2975
3058
|
if (translator.turnFailed) {
|
|
2976
3059
|
// Best-effort stop in case the failed turn left a residual lock.
|
|
2977
|
-
stopBackendTurn(server, turn.zcodeSid, turn
|
|
3060
|
+
stopBackendTurn(server, turn.zcodeSid, turn);
|
|
2978
3061
|
// Throw a TurnFailedError carrying the structured error so the caller
|
|
2979
3062
|
// (prompt's retry loop) can classify transient vs fatal. The error
|
|
2980
3063
|
// message is formatted for display when it ultimately reaches the user.
|