zcode-acp-server 0.44.0 → 0.44.2

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.
@@ -724,14 +724,14 @@ function isMarttyClient(server) {
724
724
  * session/load and would double-render a resume replay.
725
725
  */
726
726
  async function replayResumeHistory(server, cx, acpSid, zcodeSid) {
727
- // Plain fetch (callers only invoke this after a resume flight settled the
728
- // store — see resumePreservingModel) INSIDE the batch: the guard must be
729
- // held across the session/messages RPC. Fetched outside, a prompt landing
730
- // in that window dispatches live turn updates through the lock-free fast
731
- // path (enqueueSessionSend) and the TUI renders the new turn ABOVE the
732
- // 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.
733
733
  await withReplayBatch(acpSid, async () => {
734
- const messages = await fetchMessages(server, zcodeSid);
734
+ const messages = await fetchMessagesForReplay(server, zcodeSid);
735
735
  if (messages.length === 0)
736
736
  return;
737
737
  const slice = sliceTail(messages, MARTTY_RESUME_TAIL);
@@ -838,8 +838,9 @@ export async function resumeSession(server, params, cx) {
838
838
  // first: microtasks (the SDK's response send) drain before immediates.
839
839
  if (isMarttyClient(server)) {
840
840
  setImmediate(() => {
841
- // Plain fetch, no settle: any resume flight already settled the store
842
- // 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.
843
844
  replayResumeHistory(server, cx, acpSid, zcodeSid).catch((e) => {
844
845
  warn(`session/resume: TUI history replay failed (non-fatal): ` +
845
846
  `${e instanceof Error ? e.message : String(e)}`);
@@ -949,7 +950,7 @@ export async function resumeIntoSession(server, cx, acpSid, zcodeTarget) {
949
950
  // Fetch inside the batch (see replayResumeHistory): the guard must cover
950
951
  // the history RPC, or a concurrent prompt renders above the replay.
951
952
  await withReplayBatch(acpSid, async () => {
952
- const history = settledHistory ?? (await fetchMessages(server, zcodeTarget));
953
+ const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeTarget));
953
954
  if (history.length > 0)
954
955
  server.markSessionActive(acpSid);
955
956
  const slice = fullSlice(history);
@@ -1069,7 +1070,8 @@ export async function loadSession(server, params, cx, opts = {}) {
1069
1070
  // Named `history` — a local `messages` would shadow the i18n `messages()`
1070
1071
  // helper (TDZ crash from a catch block). A resume flight's SETTLED snapshot
1071
1072
  // is the mid-hydration guard; only an already-live session (no flight) falls
1072
- // 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.
1073
1075
  let slice;
1074
1076
  if (opts.replayHistory === false) {
1075
1077
  // Boot-resume interception (session/new): the terminal TUI client does not
@@ -1077,7 +1079,7 @@ export async function loadSession(server, params, cx, opts = {}) {
1077
1079
  // and blocks on the response until the replay finishes — so skip the
1078
1080
  // dispatch. The differ baseline below still runs, so the next turn's
1079
1081
  // completion diff does not re-emit the historical messages.
1080
- const history = settledHistory ?? (await fetchMessages(server, zcodeSid));
1082
+ const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeSid));
1081
1083
  // History on disk = real interaction (covers untitled sessions resumed from
1082
1084
  // a previous bridge lifetime) — make the session discoverable remotely.
1083
1085
  if (history.length > 0)
@@ -1090,7 +1092,7 @@ export async function loadSession(server, params, cx, opts = {}) {
1090
1092
  // a prompt landing while the history RPC is in flight must queue behind
1091
1093
  // the batch, not dispatch above the replayed history.
1092
1094
  slice = await withReplayBatch(acpSid, async () => {
1093
- const history = settledHistory ?? (await fetchMessages(server, zcodeSid));
1095
+ const history = settledHistory ?? (await fetchMessagesForReplay(server, zcodeSid));
1094
1096
  if (history.length > 0)
1095
1097
  server.markSessionActive(acpSid);
1096
1098
  const batchSlice = limit === null ? fullSlice(history) : sliceTail(history, limit);
@@ -1236,7 +1238,9 @@ export async function runOneTurn(server, opts) {
1236
1238
  // Per-session ProjectionDiffer (persists across turns). The baseline mark_seen
1237
1239
  // prevents the differ from re-emitting history at turn completion.
1238
1240
  const differ = getOrCreateDiffer(server, zcodeSid);
1239
- 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);
1240
1244
  differ.markSeen(baselineMsgs);
1241
1245
  // Subscribe BEFORE send so we don't lose early turn.completed on short turns.
1242
1246
  // subscribe() throws on failure, surfacing the backend's real error (reader
@@ -1280,6 +1284,9 @@ export async function runOneTurn(server, opts) {
1280
1284
  // above. Kept as a binding only so the call fits the Promise-returning shape.
1281
1285
  void snapshot;
1282
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;
1283
1290
  try {
1284
1291
  // Transient turn failures (e.g. provider network blips surfaced as
1285
1292
  // turn.failed with cause code model_request_failed) are retried by
@@ -1305,7 +1312,7 @@ export async function runOneTurn(server, opts) {
1305
1312
  // reconcile the differ baseline so the retried turn's new messages
1306
1313
  // aren't treated as already-seen, surface a retry hint, then back off.
1307
1314
  if (turn.cancelled) {
1308
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
1315
+ stopBackendTurn(server, zcodeSid, turn);
1309
1316
  return { stopReason: "cancelled" };
1310
1317
  }
1311
1318
  differ.markSeen(await fetchMessages(server, zcodeSid));
@@ -1318,9 +1325,13 @@ export async function runOneTurn(server, opts) {
1318
1325
  }
1319
1326
  const chunkMsgId = randomUUID();
1320
1327
  // Drain gate: a recent cancel/preempt means the backend side needs
1321
- // 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.
1322
1332
  const cancelledRecently = server.lastCancelledAt.get(zcodeSid) !== undefined &&
1323
- Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS;
1333
+ Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS &&
1334
+ !server.autoCompactInFlight.has(zcodeSid);
1324
1335
  if (cancelledRecently) {
1325
1336
  const drained = await drainBackendAfterCancel(server, {
1326
1337
  acpSid,
@@ -1352,9 +1363,13 @@ export async function runOneTurn(server, opts) {
1352
1363
  : { sessionId: zcodeSid, content: sendText };
1353
1364
  const sendT0 = Date.now();
1354
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;
1355
1370
  while (true) {
1356
1371
  if (turn.cancelled) {
1357
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
1372
+ stopBackendTurn(server, zcodeSid, turn);
1358
1373
  return { stopReason: "cancelled" };
1359
1374
  }
1360
1375
  sendAttempt++;
@@ -1368,15 +1383,19 @@ export async function runOneTurn(server, opts) {
1368
1383
  if (expectBusy) {
1369
1384
  await sleep(SEND_RETRY_INTERVAL_MS);
1370
1385
  if (turn.cancelled) {
1371
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
1386
+ stopBackendTurn(server, zcodeSid, turn);
1372
1387
  return { stopReason: "cancelled" };
1373
1388
  }
1374
1389
  }
1375
1390
  const sendResp = await backend.request(server.nextId(), "session/send", sendParams, 15000);
1376
1391
  if (!sendResp.error) {
1377
1392
  const accepted = (sendResp.result ?? {});
1378
- 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;
1379
1397
  break; // backend took it → turn starts
1398
+ }
1380
1399
  throw new Error("zcode send not accepted");
1381
1400
  }
1382
1401
  const sendErrCode = sendResp.error.code;
@@ -1405,8 +1424,20 @@ export async function runOneTurn(server, opts) {
1405
1424
  const NOTIFY_TURN_STALE_MS = 600_000;
1406
1425
  const notifySince = server.notifyTurnActiveSince.get(zcodeSid);
1407
1426
  const notifyActive = notifySince !== undefined && Date.now() - notifySince < NOTIFY_TURN_STALE_MS;
1408
- 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);
1409
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
+ }
1410
1441
  if (Date.now() - sendT0 > budget) {
1411
1442
  throw new Error(`zcode send failed: ${isBusy ? "backend still busy" : "send keeps being rejected"} after ${Math.round(budget / 1000)}s (${sendResp.error.message ?? ""})`);
1412
1443
  }
@@ -1430,19 +1461,21 @@ export async function runOneTurn(server, opts) {
1430
1461
  Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
1431
1462
  const result = await runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed);
1432
1463
  // Auto-compact: if context usage exceeds the threshold, compact before
1433
- // returning so the next prompt has room. Configured via
1464
+ // the NEXT prompt needs the room. Configured via
1434
1465
  // ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
1435
1466
  // disabled). Only on end_turn — cancelled/max_turn_requests skips
1436
1467
  // compaction, as does a stall-recovered end_turn (the completion was
1437
1468
  // inferred by the stall heuristic, not confirmed by turn.completed —
1438
1469
  // compressing an in-flight task's would destroy the work).
1439
- // Best-effort: failures are logged inside maybeAutoCompact, never thrown.
1440
- if (opts.autoCompact !== false &&
1441
- result.stopReason === "end_turn" &&
1442
- !turn.stallRecovered) {
1443
- const { maybeAutoCompact } = await import("../config/auto-compact.js");
1444
- await maybeAutoCompact(server, cx, acpSid, zcodeSid);
1445
- }
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;
1446
1479
  return result;
1447
1480
  }
1448
1481
  catch (e) {
@@ -1541,6 +1574,20 @@ export async function runOneTurn(server, opts) {
1541
1574
  // (preempt): the preempting turn's own running:true must survive.
1542
1575
  const stillBusy = [...server.pendingTurns.values()].some((t) => t.zcodeSid === zcodeSid);
1543
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
+ }
1544
1591
  }
1545
1592
  }
1546
1593
  /** One prompt round: subscribe-before-send, run the event-driven turn loop. */
@@ -1771,7 +1818,7 @@ export async function cancel(server, params) {
1771
1818
  matched = true;
1772
1819
  turn.cancelled = true;
1773
1820
  if (!turn.stopSent) {
1774
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
1821
+ stopBackendTurn(server, zcodeSid, turn);
1775
1822
  turn.stopSent = true;
1776
1823
  }
1777
1824
  // Record cancel time so a prompt arriving in the backend's ~20s
@@ -1878,7 +1925,20 @@ export function isBackendLostRequestError(e) {
1878
1925
  * backend's prompt lock releases when ITS finalisation completes — that,
1879
1926
  * not any bridge-side signal, is what the next prompt's send-retry waits on.
1880
1927
  */
1881
- 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
+ }
1882
1942
  try {
1883
1943
  server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
1884
1944
  }
@@ -1971,7 +2031,7 @@ export async function drainBackendAfterCancel(server, deps) {
1971
2031
  let escalated = false;
1972
2032
  while (Date.now() - drainT0 < DRAIN_TIMEOUT_MS) {
1973
2033
  if (turn.cancelled) {
1974
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
2034
+ stopBackendTurn(server, zcodeSid, turn);
1975
2035
  return "cancelled";
1976
2036
  }
1977
2037
  const proj = await monitor.pollOnce();
@@ -2078,7 +2138,7 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
2078
2138
  continue;
2079
2139
  turn.cancelled = true; // signal the old turn to stop its retry loops
2080
2140
  if (!turn.stopSent) {
2081
- stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
2141
+ stopBackendTurn(server, zcodeSid, turn);
2082
2142
  turn.stopSent = true;
2083
2143
  }
2084
2144
  // Record cancel time so the prompt()'s send-retry can use the recovery
@@ -2345,8 +2405,13 @@ async function resumePreservingModel(server, zcParams) {
2345
2405
  }
2346
2406
  /** Settle-poll gap after a fresh resume (see fetchMessagesSettled). */
2347
2407
  const RESUME_SETTLE_GAP_MS = 300;
2348
- /** Cap for the settle poll — past this the largest snapshot seen wins. */
2349
- const RESUME_SETTLE_CAP_MS = 2000;
2408
+ /**
2409
+ * Cap for the settle poll — past this the largest snapshot seen wins. Large
2410
+ * enough for the full two-stable-read plateau on slow readers (big sessions'
2411
+ * reads take seconds each — 3–5 reads must fit, observed 2.4–6.3s/read on a
2412
+ * 7413-message session), while still bounding a genuinely stuck hydration.
2413
+ */
2414
+ const RESUME_SETTLE_CAP_MS = 30_000;
2350
2415
  /** Non-growing reads required to call the store settled (plateau guard). */
2351
2416
  const RESUME_SETTLE_STABLE_READS = 2;
2352
2417
  /**
@@ -2357,12 +2422,23 @@ const RESUME_SETTLE_STABLE_READS = 2;
2357
2422
  * is fine once hydration finished; big sessions hydrate slowly, hence
2358
2423
  * "often but not always"). Poll until the count has stopped growing for TWO
2359
2424
  * consecutive reads (a single equal pair can be a >gap plateau inside a slow
2360
- * hydration), capped; on the cap the largest snapshot seen wins. Only fresh
2361
- * resumes pay the extra round-trips — an already-live session's store is
2362
- * stable.
2425
+ * hydration), capped; on the cap the largest snapshot seen wins. A capped
2426
+ * exit records the session in server.hydrationUnsettled so later replay
2427
+ * reads re-settle (fetchMessagesForReplay); a stable exit clears it.
2428
+ *
2429
+ * 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
2432
+ * meant slow-reading sessions NEVER cleared the marker — every load re-paid
2433
+ * a capped settle (observed 2026-09-20: 22–56s loads on a 7413-message
2434
+ * session). Watermarks reset on backend respawn (ensureBackend).
2363
2435
  */
2364
2436
  export async function fetchMessagesSettled(server, zcodeSid) {
2365
2437
  let messages = await fetchMessages(server, zcodeSid);
2438
+ // The catch-up anchor: what a previous settle already saw of this store.
2439
+ const entryWatermark = server.hydrationWatermark.get(zcodeSid) ?? 0;
2440
+ if (messages.length > entryWatermark)
2441
+ server.hydrationWatermark.set(zcodeSid, messages.length);
2366
2442
  let stable = 0;
2367
2443
  const deadline = Date.now() + RESUME_SETTLE_CAP_MS;
2368
2444
  for (;;) {
@@ -2370,17 +2446,45 @@ export async function fetchMessagesSettled(server, zcodeSid) {
2370
2446
  const next = await fetchMessages(server, zcodeSid);
2371
2447
  // Shrinking should not happen; never trade down either way.
2372
2448
  if (next.length <= messages.length) {
2373
- if (++stable >= RESUME_SETTLE_STABLE_READS)
2449
+ // A failed read comes back EMPTY (fetchMessages swallows errors) — never
2450
+ // let it vouch for stability while hydration may still be running.
2451
+ 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;
2455
+ if (!errored && (reachedWatermark || ++stable >= RESUME_SETTLE_STABLE_READS)) {
2456
+ server.hydrationUnsettled.delete(zcodeSid);
2457
+ server.hydrationWatermark.set(zcodeSid, Math.max(messages.length, entryWatermark));
2374
2458
  return messages;
2459
+ }
2375
2460
  }
2376
2461
  else {
2377
2462
  stable = 0;
2378
2463
  messages = next;
2464
+ if (messages.length > (server.hydrationWatermark.get(zcodeSid) ?? 0)) {
2465
+ server.hydrationWatermark.set(zcodeSid, messages.length);
2466
+ }
2379
2467
  }
2380
- if (Date.now() >= deadline)
2468
+ if (Date.now() >= deadline) {
2469
+ server.hydrationUnsettled.add(zcodeSid);
2470
+ warn(`settle capped at ${RESUME_SETTLE_CAP_MS}ms for ${zcodeSid} still growing ` +
2471
+ `(${messages.length} messages) — replaying the largest snapshot, ` +
2472
+ `next load re-settles`);
2381
2473
  return messages;
2474
+ }
2382
2475
  }
2383
2476
  }
2477
+ /**
2478
+ * History read for replay paths: plain unless the last settle for this
2479
+ * session capped out mid-hydration — then re-settle first (the marker is
2480
+ * maintained by fetchMessagesSettled). Stable sessions pay zero extra reads;
2481
+ * a still-hydrating session gets the settle poll instead of a prefix.
2482
+ */
2483
+ async function fetchMessagesForReplay(server, zcodeSid) {
2484
+ if (!server.hydrationUnsettled.has(zcodeSid))
2485
+ return fetchMessages(server, zcodeSid);
2486
+ return fetchMessagesSettled(server, zcodeSid);
2487
+ }
2384
2488
  /**
2385
2489
  * After a faithful resume the session keeps its own last model; when that
2386
2490
  * model no longer belongs to an enabled provider in config.json (deleted or
@@ -2629,7 +2733,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2629
2733
  else if (turn.cancelled) {
2630
2734
  // Preserve the pre-existing bounded cancel behaviour. A stuck prompt
2631
2735
  // lock after stop must not keep a user-cancelled turn alive forever.
2632
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2736
+ stopBackendTurn(server, turn.zcodeSid, turn);
2633
2737
  return turnResult(translator, "max_turn_requests");
2634
2738
  }
2635
2739
  else {
@@ -2670,7 +2774,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2670
2774
  return turnResult(translator, "end_turn");
2671
2775
  }
2672
2776
  log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s with no output; stopping backend turn`);
2673
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2777
+ stopBackendTurn(server, turn.zcodeSid, turn);
2674
2778
  return turnResult(translator, "max_turn_requests");
2675
2779
  }
2676
2780
  }
@@ -2703,7 +2807,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2703
2807
  // to no turn listener, and the next turn's turn-attribution gate
2704
2808
  // discards any residue that slipped into the queue meanwhile.
2705
2809
  if (!turn.stopSent) {
2706
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2810
+ stopBackendTurn(server, turn.zcodeSid, turn);
2707
2811
  turn.stopSent = true;
2708
2812
  }
2709
2813
  return turnResult(translator, "cancelled");
@@ -2778,7 +2882,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2778
2882
  }
2779
2883
  else if (!emittedOutput) {
2780
2884
  // No text and no output → suspected failure.
2781
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2885
+ stopBackendTurn(server, turn.zcodeSid, turn);
2782
2886
  throw new RequestError(-32603, "turn produced no output");
2783
2887
  }
2784
2888
  }
@@ -2976,7 +3080,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2976
3080
  }
2977
3081
  if (translator.turnFailed) {
2978
3082
  // Best-effort stop in case the failed turn left a residual lock.
2979
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
3083
+ stopBackendTurn(server, turn.zcodeSid, turn);
2980
3084
  // Throw a TurnFailedError carrying the structured error so the caller
2981
3085
  // (prompt's retry loop) can classify transient vs fatal. The error
2982
3086
  // message is formatted for display when it ultimately reaches the user.