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.
Files changed (57) hide show
  1. package/README.md +43 -1
  2. package/README.zh-CN.md +39 -0
  3. package/dist/backend/client.d.ts +11 -0
  4. package/dist/backend/client.d.ts.map +1 -1
  5. package/dist/backend/client.js +16 -0
  6. package/dist/backend/client.js.map +1 -1
  7. package/dist/backend/sandbox.d.ts +9 -6
  8. package/dist/backend/sandbox.d.ts.map +1 -1
  9. package/dist/backend/sandbox.js +19 -22
  10. package/dist/backend/sandbox.js.map +1 -1
  11. package/dist/code-fingerprint.json +2 -2
  12. package/dist/config/auto-compact.d.ts +22 -8
  13. package/dist/config/auto-compact.d.ts.map +1 -1
  14. package/dist/config/auto-compact.js +32 -11
  15. package/dist/config/auto-compact.js.map +1 -1
  16. package/dist/config/settings.d.ts +35 -0
  17. package/dist/config/settings.d.ts.map +1 -0
  18. package/dist/config/settings.js +88 -0
  19. package/dist/config/settings.js.map +1 -0
  20. package/dist/config/user-config.d.ts +39 -1
  21. package/dist/config/user-config.d.ts.map +1 -1
  22. package/dist/config/user-config.js +123 -6
  23. package/dist/config/user-config.js.map +1 -1
  24. package/dist/goal-loop/driver.d.ts +1 -1
  25. package/dist/goal-loop/driver.d.ts.map +1 -1
  26. package/dist/goal-loop/driver.js +5 -5
  27. package/dist/goal-loop/driver.js.map +1 -1
  28. package/dist/handlers/extensions.d.ts.map +1 -1
  29. package/dist/handlers/extensions.js +8 -0
  30. package/dist/handlers/extensions.js.map +1 -1
  31. package/dist/handlers/replay.d.ts +8 -0
  32. package/dist/handlers/replay.d.ts.map +1 -1
  33. package/dist/handlers/replay.js +20 -9
  34. package/dist/handlers/replay.js.map +1 -1
  35. package/dist/handlers/server-requests.d.ts +14 -0
  36. package/dist/handlers/server-requests.d.ts.map +1 -1
  37. package/dist/handlers/server-requests.js +54 -49
  38. package/dist/handlers/server-requests.js.map +1 -1
  39. package/dist/handlers/session.d.ts +3 -3
  40. package/dist/handlers/session.d.ts.map +1 -1
  41. package/dist/handlers/session.js +130 -47
  42. package/dist/handlers/session.js.map +1 -1
  43. package/dist/handlers/slash.d.ts.map +1 -1
  44. package/dist/handlers/slash.js +4 -3
  45. package/dist/handlers/slash.js.map +1 -1
  46. package/dist/i18n.d.ts +5 -2
  47. package/dist/i18n.d.ts.map +1 -1
  48. package/dist/i18n.js +7 -3
  49. package/dist/i18n.js.map +1 -1
  50. package/dist/server.d.ts +23 -0
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +48 -2
  53. package/dist/server.js.map +1 -1
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js +5 -3
  56. package/dist/utils.js.map +1 -1
  57. package/package.json +1 -1
@@ -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
- // ZCODE_ACP_MODE picks the mode a new session starts in; the default
516
- // stays "yolo" (unrestricted), which is what the bridge always used.
517
- mode: process.env.ZCODE_ACP_MODE || "yolo",
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
- // Plain fetch (callers only invoke this after a resume flight settled the
726
- // store — see resumePreservingModel) INSIDE the batch: the guard must be
727
- // held across the session/messages RPC. Fetched outside, a prompt landing
728
- // in that window dispatches live turn updates through the lock-free fast
729
- // path (enqueueSessionSend) and the TUI renders the new turn ABOVE the
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 fetchMessages(server, zcodeSid);
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
- // Plain fetch, no settle: any resume flight already settled the store
840
- // before this session/resume returned (the settle rides the flight).
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 fetchMessages(server, zcodeTarget));
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 — its store is stable.
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 fetchMessages(server, zcodeSid));
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 fetchMessages(server, zcodeSid));
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
- const baselineMsgs = await fetchMessages(server, zcodeSid);
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.foregroundExecutionId);
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.foregroundExecutionId);
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.foregroundExecutionId);
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
- const busyBudget = notifyActive ? NOTIFY_TURN_BUSY_TIMEOUT_MS : SEND_RETRY_TIMEOUT_MS;
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
- // returning so the next prompt has room. Configured via
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
- // Best-effort: failures are logged inside maybeAutoCompact, never thrown.
1438
- if (opts.autoCompact !== false &&
1439
- result.stopReason === "end_turn" &&
1440
- !turn.stallRecovered) {
1441
- const { maybeAutoCompact } = await import("../config/auto-compact.js");
1442
- await maybeAutoCompact(server, cx, acpSid, zcodeSid);
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.foregroundExecutionId);
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, foregroundExecutionId) {
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.foregroundExecutionId);
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.foregroundExecutionId);
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 = 2000;
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. Only fresh
2359
- * resumes pay the extra round-trips — an already-live session's store is
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
- if (++stable >= RESUME_SETTLE_STABLE_READS)
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.foregroundExecutionId);
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.foregroundExecutionId);
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.foregroundExecutionId);
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.foregroundExecutionId);
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.foregroundExecutionId);
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.