zcode-acp-server 0.44.0 → 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.
@@ -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
@@ -2346,7 +2406,7 @@ async function resumePreservingModel(server, zcParams) {
2346
2406
  /** Settle-poll gap after a fresh resume (see fetchMessagesSettled). */
2347
2407
  const RESUME_SETTLE_GAP_MS = 300;
2348
2408
  /** Cap for the settle poll — past this the largest snapshot seen wins. */
2349
- const RESUME_SETTLE_CAP_MS = 2000;
2409
+ const RESUME_SETTLE_CAP_MS = 10000;
2350
2410
  /** Non-growing reads required to call the store settled (plateau guard). */
2351
2411
  const RESUME_SETTLE_STABLE_READS = 2;
2352
2412
  /**
@@ -2357,9 +2417,9 @@ const RESUME_SETTLE_STABLE_READS = 2;
2357
2417
  * is fine once hydration finished; big sessions hydrate slowly, hence
2358
2418
  * "often but not always"). Poll until the count has stopped growing for TWO
2359
2419
  * 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.
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.
2363
2423
  */
2364
2424
  export async function fetchMessagesSettled(server, zcodeSid) {
2365
2425
  let messages = await fetchMessages(server, zcodeSid);
@@ -2370,17 +2430,38 @@ export async function fetchMessagesSettled(server, zcodeSid) {
2370
2430
  const next = await fetchMessages(server, zcodeSid);
2371
2431
  // Shrinking should not happen; never trade down either way.
2372
2432
  if (next.length <= messages.length) {
2373
- 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);
2374
2438
  return messages;
2439
+ }
2375
2440
  }
2376
2441
  else {
2377
2442
  stable = 0;
2378
2443
  messages = next;
2379
2444
  }
2380
- 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`);
2381
2450
  return messages;
2451
+ }
2382
2452
  }
2383
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
+ }
2384
2465
  /**
2385
2466
  * After a faithful resume the session keeps its own last model; when that
2386
2467
  * model no longer belongs to an enabled provider in config.json (deleted or
@@ -2629,7 +2710,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2629
2710
  else if (turn.cancelled) {
2630
2711
  // Preserve the pre-existing bounded cancel behaviour. A stuck prompt
2631
2712
  // lock after stop must not keep a user-cancelled turn alive forever.
2632
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2713
+ stopBackendTurn(server, turn.zcodeSid, turn);
2633
2714
  return turnResult(translator, "max_turn_requests");
2634
2715
  }
2635
2716
  else {
@@ -2670,7 +2751,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2670
2751
  return turnResult(translator, "end_turn");
2671
2752
  }
2672
2753
  log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s with no output; stopping backend turn`);
2673
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2754
+ stopBackendTurn(server, turn.zcodeSid, turn);
2674
2755
  return turnResult(translator, "max_turn_requests");
2675
2756
  }
2676
2757
  }
@@ -2703,7 +2784,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2703
2784
  // to no turn listener, and the next turn's turn-attribution gate
2704
2785
  // discards any residue that slipped into the queue meanwhile.
2705
2786
  if (!turn.stopSent) {
2706
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2787
+ stopBackendTurn(server, turn.zcodeSid, turn);
2707
2788
  turn.stopSent = true;
2708
2789
  }
2709
2790
  return turnResult(translator, "cancelled");
@@ -2778,7 +2859,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2778
2859
  }
2779
2860
  else if (!emittedOutput) {
2780
2861
  // No text and no output → suspected failure.
2781
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
2862
+ stopBackendTurn(server, turn.zcodeSid, turn);
2782
2863
  throw new RequestError(-32603, "turn produced no output");
2783
2864
  }
2784
2865
  }
@@ -2976,7 +3057,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
2976
3057
  }
2977
3058
  if (translator.turnFailed) {
2978
3059
  // Best-effort stop in case the failed turn left a residual lock.
2979
- stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
3060
+ stopBackendTurn(server, turn.zcodeSid, turn);
2980
3061
  // Throw a TurnFailedError carrying the structured error so the caller
2981
3062
  // (prompt's retry loop) can classify transient vs fatal. The error
2982
3063
  // message is formatted for display when it ultimately reaches the user.