zcode-acp-server 0.44.2 → 0.44.4

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.
@@ -31,7 +31,7 @@ import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, i
31
31
  import { clientConnectionRoot, log, warn } from "../utils.js";
32
32
  import { dispatchEvent } from "./dispatch.js";
33
33
  import { sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js";
34
- import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail } from "./replay.js";
34
+ import { fetchMessages, fullSlice, readTailLimit, replayMessages, sliceTail, TURN_READ, } from "./replay.js";
35
35
  import { extractPermDeniedPath, extractSandboxDenial, GENERIC_HINT_KEY, handleSandboxDenial, READ_ONLY_TOOLS, } from "./sandbox-allow.js";
36
36
  import { handleServerRequests } from "./server-requests.js";
37
37
  /** Workspace descriptor used in session create/resume calls. */
@@ -883,7 +883,7 @@ export async function resumeIntoSession(server, cx, acpSid, zcodeTarget) {
883
883
  if (current) {
884
884
  let existing;
885
885
  try {
886
- existing = await fetchMessages(server, current);
886
+ existing = await fetchMessages(server, current, TURN_READ);
887
887
  }
888
888
  catch {
889
889
  // Cannot prove the thread empty — refuse rather than orphan history.
@@ -1328,7 +1328,8 @@ export async function runOneTurn(server, opts) {
1328
1328
  // settling before the send — see drainBackendAfterCancel. EXEMPT while a
1329
1329
  // detached auto-compact runs: it legitimately holds the prompt lock, and
1330
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.
1331
+ // kill the compaction — the busy-reject below rejects the send outright
1332
+ // instead of waiting it out.
1332
1333
  const cancelledRecently = server.lastCancelledAt.get(zcodeSid) !== undefined &&
1333
1334
  Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS &&
1334
1335
  !server.autoCompactInFlight.has(zcodeSid);
@@ -1363,10 +1364,6 @@ export async function runOneTurn(server, opts) {
1363
1364
  : { sessionId: zcodeSid, content: sendText };
1364
1365
  const sendT0 = Date.now();
1365
1366
  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;
1370
1367
  while (true) {
1371
1368
  if (turn.cancelled) {
1372
1369
  stopBackendTurn(server, zcodeSid, turn);
@@ -1424,20 +1421,29 @@ export async function runOneTurn(server, opts) {
1424
1421
  const NOTIFY_TURN_STALE_MS = 600_000;
1425
1422
  const notifySince = server.notifyTurnActiveSince.get(zcodeSid);
1426
1423
  const notifyActive = notifySince !== undefined && Date.now() - notifySince < NOTIFY_TURN_STALE_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);
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());
1424
+ // A detached auto-compact is the opposite case: REJECT instead of
1425
+ // waiting. This turn's listener is already subscribed, so the
1426
+ // compaction's internal AI turn is streaming its residue into our
1427
+ // queue right now; queueing behind the lock until it releases would
1428
+ // dispatch that residue as THIS prompt's output (its turn.completed
1429
+ // ends the turn before the real reply even starts). Reject at once:
1430
+ // the notice asks the user to resend, the queued residue dies with
1431
+ // this turn's listener unregister in the finally, and
1432
+ // turn.compactRejected lets the goal-loop driver (no user to resend)
1433
+ // wait the compaction out and retry the round.
1434
+ if (isBusy && server.autoCompactInFlight.has(zcodeSid)) {
1435
+ // Goal rounds get no user-directed notice: the driver retries them
1436
+ // automatically — a "resend" instruction would point at an internal
1437
+ // dispatch prompt the user never typed (the driver announces its
1438
+ // own wait note instead).
1439
+ if (!turn.goalLoop) {
1440
+ await sendTextChunk(cx, acpSid, messages().autoCompactBusy, randomUUID());
1441
+ }
1442
+ turn.compactRejected = true;
1443
+ return { stopReason: "max_turn_requests" };
1440
1444
  }
1445
+ const busyBudget = Math.max(notifyActive ? NOTIFY_TURN_BUSY_TIMEOUT_MS : 0, SEND_RETRY_TIMEOUT_MS);
1446
+ const budget = isBusy ? busyBudget : WARMUP_RETRY_TIMEOUT_MS;
1441
1447
  if (Date.now() - sendT0 > budget) {
1442
1448
  throw new Error(`zcode send failed: ${isBusy ? "backend still busy" : "send keeps being rejected"} after ${Math.round(budget / 1000)}s (${sendResp.error.message ?? ""})`);
1443
1449
  }
@@ -1577,7 +1583,7 @@ export async function runOneTurn(server, opts) {
1577
1583
  // Start the armed auto-compact ONLY now, after deregistration and the
1578
1584
  // running:false turnState: from here on a cancel or follow-up prompt finds
1579
1585
  // no turn to preempt (the compaction is untouchable housekeeping), and a
1580
- // prompt racing it waits on the busy-retry instead of killing it.
1586
+ // prompt racing it is rejected outright instead of killing it.
1581
1587
  if (autoCompactDue) {
1582
1588
  try {
1583
1589
  const { runAutoCompactDetached } = await import("../config/auto-compact.js");
@@ -1671,17 +1677,43 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
1671
1677
  // same lock). Slash commands /auto pause|resume|stop|status were
1672
1678
  // intercepted above, so anything reaching here is conversational input.
1673
1679
  let parked;
1680
+ // Set when the compaction gate below rejected this prompt.
1681
+ let compactBusy = false;
1682
+ // A sandbox continuation has no user watching to resend it: wait the
1683
+ // compaction out instead of hitting the rejection gate. The wait happens
1684
+ // BEFORE any subscribe, so it is residue-free by construction.
1685
+ if (continuationRound && server.autoCompactInFlight.has(zcodeSid)) {
1686
+ const { waitForAutoCompactIdle } = await import("../config/auto-compact.js");
1687
+ await waitForAutoCompactIdle(server, zcodeSid);
1688
+ }
1674
1689
  await withPreemptLock(server, zcodeSid, async () => {
1675
1690
  const goalLoop = server.goalLoops?.get(zcodeSid);
1676
1691
  if (goalLoop) {
1677
1692
  parked = goalLoop.parkPrompt(sendText);
1678
1693
  return;
1679
1694
  }
1695
+ // Compaction gate: a detached auto-compact holds the backend prompt lock
1696
+ // for minutes. NEVER queue behind it — this prompt's listener subscribes
1697
+ // before the send, so waiting out the compaction would accumulate its
1698
+ // whole internal-turn stream in our queue and dispatch it as THIS
1699
+ // prompt's output once the lock releases (its turn.completed even ends
1700
+ // the turn before the real reply starts). Reject with a resend notice;
1701
+ // runOneTurn's busy-reject fallback covers the arm race between this
1702
+ // check and the send.
1703
+ if (server.autoCompactInFlight.has(zcodeSid)) {
1704
+ compactBusy = true;
1705
+ return;
1706
+ }
1680
1707
  server.pendingTurns.set(requestId, turn);
1681
1708
  preempted = preemptInFlightTurn(server, zcodeSid, requestId);
1682
1709
  });
1683
1710
  if (parked)
1684
1711
  return parked;
1712
+ if (compactBusy) {
1713
+ await sendTextChunk(cx, params.sessionId, messages().autoCompactBusy, randomUUID());
1714
+ log("session/prompt: rejected — a detached auto-compact is in flight");
1715
+ return { stopReason: "max_turn_requests" };
1716
+ }
1685
1717
  // Discovery: the session is live the moment its turn STARTS — mark it active
1686
1718
  // here instead of only at turn end, so a freshly created conversation shows
1687
1719
  // up in remote lists within one heartbeat even while its first (possibly
@@ -2427,18 +2459,23 @@ const RESUME_SETTLE_STABLE_READS = 2;
2427
2459
  * reads re-settle (fetchMessagesForReplay); a stable exit clears it.
2428
2460
  *
2429
2461
  * Re-settles (a marker left by a capped settle) fast-path on the WATERMARK:
2430
- * one non-growing read that reaches the largest length any settle has ever
2431
- * observed counts as caught-up. Demanding the full two-read plateau again
2462
+ * one non-growing read that itself reaches the largest length any settle has
2463
+ * ever observed counts as caught-up. Demanding the full two-read plateau again
2432
2464
  * meant slow-reading sessions NEVER cleared the marker — every load re-paid
2433
2465
  * a capped settle (observed 2026-09-20: 22–56s loads on a 7413-message
2434
- * session). Watermarks reset on backend respawn (ensureBackend).
2466
+ * session). The confirming READ (not this flight's running max) must reach
2467
+ * the live watermark, so a read that dips below what any settle has seen
2468
+ * falls back to the plateau rule; watermark writes are monotonic, so a
2469
+ * concurrent settle's higher observation is never traded down. Residual,
2470
+ * accepted: a hydration stalling at/above the watermark across one read+gap
2471
+ * still exits early with a prefix — bounded, and the next plain read
2472
+ * self-heals. Watermarks reset on backend respawn (ensureBackend).
2435
2473
  */
2436
2474
  export async function fetchMessagesSettled(server, zcodeSid) {
2437
2475
  let messages = await fetchMessages(server, zcodeSid);
2438
2476
  // The catch-up anchor: what a previous settle already saw of this store.
2439
2477
  const entryWatermark = server.hydrationWatermark.get(zcodeSid) ?? 0;
2440
- if (messages.length > entryWatermark)
2441
- server.hydrationWatermark.set(zcodeSid, messages.length);
2478
+ raiseWatermark(server, zcodeSid, messages.length);
2442
2479
  let stable = 0;
2443
2480
  const deadline = Date.now() + RESUME_SETTLE_CAP_MS;
2444
2481
  for (;;) {
@@ -2449,21 +2486,21 @@ export async function fetchMessagesSettled(server, zcodeSid) {
2449
2486
  // A failed read comes back EMPTY (fetchMessages swallows errors) — never
2450
2487
  // let it vouch for stability while hydration may still be running.
2451
2488
  const errored = next.length === 0 && messages.length > 0;
2452
- // Watermark fast-path: the store reached everything any settle has ever
2453
- // seen AND this read confirms it is not growing — caught up.
2454
- const reachedWatermark = entryWatermark > 0 && messages.length >= entryWatermark;
2489
+ // Watermark fast-path: the confirming read itself reached everything
2490
+ // any settle has ever seen (live value — a concurrent settle may have
2491
+ // raised it past our entry snapshot) AND the store is not growing.
2492
+ const liveWatermark = server.hydrationWatermark.get(zcodeSid) ?? 0;
2493
+ const reachedWatermark = entryWatermark > 0 && next.length >= liveWatermark;
2455
2494
  if (!errored && (reachedWatermark || ++stable >= RESUME_SETTLE_STABLE_READS)) {
2456
2495
  server.hydrationUnsettled.delete(zcodeSid);
2457
- server.hydrationWatermark.set(zcodeSid, Math.max(messages.length, entryWatermark));
2496
+ raiseWatermark(server, zcodeSid, Math.max(messages.length, entryWatermark));
2458
2497
  return messages;
2459
2498
  }
2460
2499
  }
2461
2500
  else {
2462
2501
  stable = 0;
2463
2502
  messages = next;
2464
- if (messages.length > (server.hydrationWatermark.get(zcodeSid) ?? 0)) {
2465
- server.hydrationWatermark.set(zcodeSid, messages.length);
2466
- }
2503
+ raiseWatermark(server, zcodeSid, messages.length);
2467
2504
  }
2468
2505
  if (Date.now() >= deadline) {
2469
2506
  server.hydrationUnsettled.add(zcodeSid);
@@ -2474,6 +2511,12 @@ export async function fetchMessagesSettled(server, zcodeSid) {
2474
2511
  }
2475
2512
  }
2476
2513
  }
2514
+ /** Monotonic watermark write — a concurrent settle's higher observation wins. */
2515
+ function raiseWatermark(server, zcodeSid, length) {
2516
+ if (length > (server.hydrationWatermark.get(zcodeSid) ?? 0)) {
2517
+ server.hydrationWatermark.set(zcodeSid, length);
2518
+ }
2519
+ }
2477
2520
  /**
2478
2521
  * History read for replay paths: plain unless the last settle for this
2479
2522
  * session capped out mid-hydration — then re-settle first (the marker is
@@ -3111,7 +3154,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
3111
3154
  // compaction, before the user's next send) would leave its entire output
3112
3155
  // invisible in the UI. `fetchLastReply` above only covers the last
3113
3156
  // assistant message, not the whole missing span.
3114
- const snapshot = await buildSnapshot(server, turn.zcodeSid);
3157
+ const snapshot = await buildSnapshot(server, turn.zcodeSid, TURN_READ);
3115
3158
  const completionEvents = differ.diff(snapshot);
3116
3159
  for (const iev of completionEvents) {
3117
3160
  // Per-kind dedup (see deliveredReasoningMessageIds): text and reasoning
@@ -3173,7 +3216,7 @@ function registerFetchedReply(translator, reply) {
3173
3216
  */
3174
3217
  async function fetchLastReply(server, zcodeSid, differ) {
3175
3218
  for (let attempt = 0; attempt < 4; attempt++) {
3176
- const messages = await fetchMessages(server, zcodeSid);
3219
+ const messages = await fetchMessages(server, zcodeSid, TURN_READ);
3177
3220
  for (let i = messages.length - 1; i >= 0; i--) {
3178
3221
  const m = messages[i];
3179
3222
  if (!m)
@@ -3216,10 +3259,10 @@ export function flattenTodos(todos, todoGroups) {
3216
3259
  return flat;
3217
3260
  }
3218
3261
  /** Build a {projection, messages, todos} snapshot from session/messages + session/read. */
3219
- async function buildSnapshot(server, zcodeSid) {
3262
+ async function buildSnapshot(server, zcodeSid, opts = {}) {
3220
3263
  const backend = server.ensureBackend();
3221
3264
  const [msgs, readResp] = await Promise.all([
3222
- fetchMessages(server, zcodeSid),
3265
+ fetchMessages(server, zcodeSid, opts),
3223
3266
  backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 8000),
3224
3267
  ]);
3225
3268
  const read = (readResp.result ?? {});
@@ -3263,7 +3306,7 @@ export async function emitModeIfChanged(server, cx, acpSid, zcodeSid) {
3263
3306
  * the tool seen in the differ so turn-completion diff won't re-emit it.
3264
3307
  */
3265
3308
  async function dispatchEditDiff(server, cx, acpSid, zcodeSid, callId, differ, chunkMsgId) {
3266
- const messages = await fetchMessages(server, zcodeSid);
3309
+ const messages = await fetchMessages(server, zcodeSid, TURN_READ);
3267
3310
  for (const m of messages) {
3268
3311
  for (const p of m.parts ?? []) {
3269
3312
  if (!p || typeof p !== "object")