zcode-acp-server 0.11.9 → 0.14.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 +144 -32
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +10 -0
- package/dist/backend/client.js.map +1 -1
- package/dist/backend/listener.d.ts.map +1 -1
- package/dist/backend/listener.js +6 -5
- package/dist/backend/listener.js.map +1 -1
- package/dist/backend/resolve.d.ts.map +1 -1
- package/dist/backend/resolve.js +35 -1
- package/dist/backend/resolve.js.map +1 -1
- package/dist/bin/hub.d.ts +3 -3
- package/dist/bin/hub.d.ts.map +1 -1
- package/dist/bin/hub.js +22 -12
- package/dist/bin/hub.js.map +1 -1
- package/dist/bin/quota.d.ts +14 -6
- package/dist/bin/quota.d.ts.map +1 -1
- package/dist/bin/quota.js +27 -19
- package/dist/bin/quota.js.map +1 -1
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +129 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/runtime-model.d.ts +10 -10
- package/dist/config/runtime-model.js +10 -10
- package/dist/handlers/account.d.ts +1 -1
- package/dist/handlers/account.js +1 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +42 -36
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/io.d.ts.map +1 -1
- package/dist/handlers/io.js +19 -13
- package/dist/handlers/io.js.map +1 -1
- package/dist/handlers/session.d.ts +58 -4
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +288 -56
- package/dist/handlers/session.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -8
- package/dist/index.js.map +1 -1
- package/dist/interaction/adapter.d.ts +1 -0
- package/dist/interaction/adapter.d.ts.map +1 -1
- package/dist/interaction/adapter.js +5 -1
- package/dist/interaction/adapter.js.map +1 -1
- package/dist/quota/color.d.ts +4 -4
- package/dist/quota/color.d.ts.map +1 -1
- package/dist/quota/color.js +7 -5
- package/dist/quota/color.js.map +1 -1
- package/dist/quota/combined.d.ts +1 -1
- package/dist/quota/combined.js +4 -4
- package/dist/quota/combined.js.map +1 -1
- package/dist/quota/format.d.ts +1 -1
- package/dist/quota/opencode-go/format.d.ts.map +1 -1
- package/dist/quota/opencode-go/format.js +5 -1
- package/dist/quota/opencode-go/format.js.map +1 -1
- package/dist/quota/opencode-go/index.d.ts +1 -1
- package/dist/quota/opencode-go/index.js +1 -1
- package/dist/quota/opencode-go/types.d.ts +1 -1
- package/dist/quota/opencode-go/types.js +1 -1
- package/dist/quota/rounding.d.ts +8 -0
- package/dist/quota/rounding.d.ts.map +1 -0
- package/dist/quota/rounding.js +10 -0
- package/dist/quota/rounding.js.map +1 -0
- package/dist/remote/config.d.ts +1 -1
- package/dist/remote/config.d.ts.map +1 -1
- package/dist/remote/config.js +1 -1
- package/dist/remote/config.js.map +1 -1
- package/dist/remote/endpoint.d.ts +1 -1
- package/dist/remote/endpoint.js +1 -1
- package/dist/remote/hub-server.d.ts +1 -1
- package/dist/remote/hub-server.js +1 -1
- package/dist/repl/App.d.ts +108 -0
- package/dist/repl/App.d.ts.map +1 -0
- package/dist/repl/App.js +706 -0
- package/dist/repl/App.js.map +1 -0
- package/dist/repl/history.d.ts +25 -0
- package/dist/repl/history.d.ts.map +1 -0
- package/dist/repl/history.js +73 -0
- package/dist/repl/history.js.map +1 -0
- package/dist/repl/input-buffer.d.ts +77 -0
- package/dist/repl/input-buffer.d.ts.map +1 -0
- package/dist/repl/input-buffer.js +174 -0
- package/dist/repl/input-buffer.js.map +1 -0
- package/dist/repl/model.d.ts +275 -0
- package/dist/repl/model.d.ts.map +1 -0
- package/dist/repl/model.js +596 -0
- package/dist/repl/model.js.map +1 -0
- package/dist/repl/mouse.d.ts +49 -0
- package/dist/repl/mouse.d.ts.map +1 -0
- package/dist/repl/mouse.js +165 -0
- package/dist/repl/mouse.js.map +1 -0
- package/dist/repl/run.d.ts +12 -0
- package/dist/repl/run.d.ts.map +1 -0
- package/dist/repl/run.js +902 -0
- package/dist/repl/run.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +31 -3
- package/dist/server.js.map +1 -1
- package/dist/translators/tool-helpers.d.ts.map +1 -1
- package/dist/translators/tool-helpers.js +3 -0
- package/dist/translators/tool-helpers.js.map +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/BACKLOG.md +40 -21
- package/docs/REMOTE-CLIENTS.md +2 -2
- package/docs/TROUBLESHOOTING.md +1 -1
- package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +40 -0
- package/package.json +9 -4
package/dist/handlers/session.js
CHANGED
|
@@ -14,7 +14,7 @@ import process from "node:process";
|
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { RequestError } from "@agentclientprotocol/sdk";
|
|
16
16
|
import { EventStreamListener, TurnMonitor } from "../backend/listener.js";
|
|
17
|
-
import { buildModes, buildConfigOptions } from "../config/options.js";
|
|
17
|
+
import { buildModes, buildConfigOptions, formatModelValue, loadAllModels, } from "../config/options.js";
|
|
18
18
|
import { emitInitialUsage } from "../config/model-cache.js";
|
|
19
19
|
import { buildProviderRegistry } from "../config/provider-registry.js";
|
|
20
20
|
import { buildResumeRuntimeModel } from "../config/runtime-model.js";
|
|
@@ -349,11 +349,11 @@ export async function resumeSession(server, params, cx) {
|
|
|
349
349
|
// in this backend subprocess.
|
|
350
350
|
const { zcodeSid, alreadyLive } = await resolveResumeTarget(server, acpSid);
|
|
351
351
|
if (!alreadyLive) {
|
|
352
|
-
// runtimeModel
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
352
|
+
// No runtimeModel pinning here: the session keeps its own persisted
|
|
353
|
+
// model (see resumePreservingModel — an overlay is a FALLBACK only, when
|
|
354
|
+
// the faithful resume fails outright). The params deliberately carry NO
|
|
355
|
+
// apiKey either (the backend's schema rejects it; it resolves auth from
|
|
356
|
+
// its own config/OAuth store).
|
|
357
357
|
const zcParams = {
|
|
358
358
|
sessionId: zcodeSid,
|
|
359
359
|
workspace: workspaceFor(cwd),
|
|
@@ -364,16 +364,15 @@ export async function resumeSession(server, params, cx) {
|
|
|
364
364
|
if (params.mcpServers && params.mcpServers.length > 0) {
|
|
365
365
|
zcParams.mcpServers = params.mcpServers;
|
|
366
366
|
}
|
|
367
|
-
const runtimeModel = buildResumeRuntimeModel();
|
|
368
|
-
if (runtimeModel !== null)
|
|
369
|
-
zcParams.runtimeModel = runtimeModel;
|
|
370
367
|
// Push the provider registry BEFORE resume: a resumed session may carry a
|
|
371
368
|
// third-party model in its history, and the backend needs the provider
|
|
372
369
|
// registered to even process the resume turn.
|
|
373
370
|
await syncProviderRegistry(server, cwd);
|
|
374
|
-
const resumeResult = await
|
|
371
|
+
const resumeResult = await resumePreservingModel(server, zcParams);
|
|
375
372
|
// The resume RPC succeeded — the session is now loaded in this backend.
|
|
376
373
|
server.markBackendLoaded(acpSid);
|
|
374
|
+
// The session kept its own model — repair it only if it's no longer enabled.
|
|
375
|
+
await repairUnavailableModel(server, zcodeSid);
|
|
377
376
|
// The backend's session record is the root authority: adopt its
|
|
378
377
|
// workspace as the session root (heals any stale/polluted entry).
|
|
379
378
|
const backendWs = workspaceFromResumeResult(resumeResult);
|
|
@@ -418,16 +417,15 @@ export async function loadSession(server, params, cx) {
|
|
|
418
417
|
sessionId: zcodeSid,
|
|
419
418
|
workspace: workspaceFor(cwd),
|
|
420
419
|
};
|
|
421
|
-
const runtimeModel = buildResumeRuntimeModel();
|
|
422
|
-
if (runtimeModel !== null)
|
|
423
|
-
zcParams.runtimeModel = runtimeModel;
|
|
424
420
|
// Push the provider registry BEFORE resume: a loaded session may carry a
|
|
425
421
|
// third-party model in its history, and the backend needs the provider
|
|
426
422
|
// registered to process it.
|
|
427
423
|
await syncProviderRegistry(server, cwd);
|
|
428
|
-
const resumeResult = await
|
|
424
|
+
const resumeResult = await resumePreservingModel(server, zcParams);
|
|
429
425
|
// The resume RPC succeeded — the session is now loaded in this backend.
|
|
430
426
|
server.markBackendLoaded(acpSid);
|
|
427
|
+
// The session kept its own model — repair it only if it's no longer enabled.
|
|
428
|
+
await repairUnavailableModel(server, zcodeSid);
|
|
431
429
|
// The backend's session record is the root authority: adopt its
|
|
432
430
|
// workspace as the session root (heals any stale/polluted entry).
|
|
433
431
|
const backendWs = workspaceFromResumeResult(resumeResult);
|
|
@@ -572,11 +570,20 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
572
570
|
}
|
|
573
571
|
// Out-of-band running indicator: clients that did not send this prompt
|
|
574
572
|
// (re-attached mobile, second editor) learn the turn started here — the
|
|
575
|
-
// session/load replayMeta only snapshots attach time.
|
|
573
|
+
// session/load replayMeta only snapshots attach time. Emitted per attached
|
|
574
|
+
// alias (see server.sessionAliases) so a client holding this conversation
|
|
575
|
+
// under a different ACP id opens its live turn too. Best-effort: a dead
|
|
576
576
|
// client must not fail the turn.
|
|
577
|
-
const emitTurnState = (running) =>
|
|
578
|
-
|
|
579
|
-
|
|
577
|
+
const emitTurnState = async (running) => {
|
|
578
|
+
const results = await Promise.allSettled(server
|
|
579
|
+
.sessionAliases(params.sessionId)
|
|
580
|
+
.map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
|
|
581
|
+
for (const r of results) {
|
|
582
|
+
if (r.status === "rejected") {
|
|
583
|
+
log(`turnState notify failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
580
587
|
await emitTurnState(true);
|
|
581
588
|
const listener = new EventStreamListener(backend, zcodeSid);
|
|
582
589
|
const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
|
|
@@ -648,7 +655,7 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
648
655
|
// reconcile the differ baseline so the retried turn's new messages
|
|
649
656
|
// aren't treated as already-seen, surface a retry hint, then back off.
|
|
650
657
|
if (turn.cancelled) {
|
|
651
|
-
stopBackendTurn(server, zcodeSid);
|
|
658
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
652
659
|
return { stopReason: "cancelled" };
|
|
653
660
|
}
|
|
654
661
|
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
@@ -657,6 +664,23 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
657
664
|
await sleep(backoffMs(attempt - 1));
|
|
658
665
|
}
|
|
659
666
|
const chunkMsgId = randomUUID();
|
|
667
|
+
// Drain gate: a recent cancel/preempt means the backend side needs
|
|
668
|
+
// settling before the send — see drainBackendAfterCancel.
|
|
669
|
+
const cancelledRecently = server.lastCancelledAt.get(zcodeSid) !== undefined &&
|
|
670
|
+
Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS;
|
|
671
|
+
if (cancelledRecently) {
|
|
672
|
+
const drained = await drainBackendAfterCancel(server, {
|
|
673
|
+
acpSid: params.sessionId,
|
|
674
|
+
zcodeSid,
|
|
675
|
+
turn,
|
|
676
|
+
listener,
|
|
677
|
+
monitor: new TurnMonitor(backend, zcodeSid, () => server.nextId()),
|
|
678
|
+
differ,
|
|
679
|
+
cx,
|
|
680
|
+
});
|
|
681
|
+
if (drained === "cancelled")
|
|
682
|
+
return { stopReason: "cancelled" };
|
|
683
|
+
}
|
|
660
684
|
// Send the prompt, retrying while the backend reports it's still busy.
|
|
661
685
|
// The backend's prompt lock is the single authoritative readiness signal:
|
|
662
686
|
// a rejected send (code 1308 "prompt is running") means a previous turn
|
|
@@ -674,7 +698,7 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
674
698
|
let sendAttempt = 0;
|
|
675
699
|
while (true) {
|
|
676
700
|
if (turn.cancelled) {
|
|
677
|
-
stopBackendTurn(server, zcodeSid);
|
|
701
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
678
702
|
return { stopReason: "cancelled" };
|
|
679
703
|
}
|
|
680
704
|
sendAttempt++;
|
|
@@ -688,7 +712,7 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
688
712
|
if (expectBusy) {
|
|
689
713
|
await sleep(SEND_RETRY_INTERVAL_MS);
|
|
690
714
|
if (turn.cancelled) {
|
|
691
|
-
stopBackendTurn(server, zcodeSid);
|
|
715
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
692
716
|
return { stopReason: "cancelled" };
|
|
693
717
|
}
|
|
694
718
|
}
|
|
@@ -715,7 +739,13 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
715
739
|
}
|
|
716
740
|
try {
|
|
717
741
|
// Event-driven turn loop: translate events via EventTranslator + dispatch.
|
|
718
|
-
|
|
742
|
+
// Arm the attribution gate also on a recent cancel: the abandoned turn
|
|
743
|
+
// is still finalising in the backend (session/stop is not honored —
|
|
744
|
+
// verified 0.16.5), and its leftover deltas stream past the subscribe
|
|
745
|
+
// of this new prompt (see the gate comment in runEventTurn).
|
|
746
|
+
const gateArmed = preempted ||
|
|
747
|
+
Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
|
|
748
|
+
const result = await runEventTurn(server, listener, monitor, differ, cx, params.sessionId, chunkMsgId, turn, gateArmed);
|
|
719
749
|
// (Session title: already set once at the FIRST prompt, before the
|
|
720
750
|
// turn loop — nothing here may change it again.)
|
|
721
751
|
// Auto-compact: if context usage exceeds the threshold, compact before
|
|
@@ -769,6 +799,13 @@ export async function prompt(server, params, cx, requestId) {
|
|
|
769
799
|
await emitTurnState(stillBusy);
|
|
770
800
|
}
|
|
771
801
|
}
|
|
802
|
+
/** How long after a cancel a new prompt's attribution gate stays armed (the
|
|
803
|
+
* abandoned turn may still be streaming its finalisation into the backend). */
|
|
804
|
+
const CANCEL_RESIDUE_WINDOW_MS = 120_000;
|
|
805
|
+
/** How long after a cancel/preempt a new prompt still runs the drain gate
|
|
806
|
+
* (drainBackendAfterCancel) before sending — same bound as the drain wait
|
|
807
|
+
* itself, so the gate never waits twice its window. */
|
|
808
|
+
const DRAIN_WINDOW_MS = 90_000;
|
|
772
809
|
/**
|
|
773
810
|
* `session/set_config_option` → dispatch model/mode/thought and emit the
|
|
774
811
|
* resulting config_option_update (+ current_mode_update for mode).
|
|
@@ -803,9 +840,11 @@ export async function setConfigOptionHandler(server, params, cx) {
|
|
|
803
840
|
* on a session with no active turn, and on a turn already aborted), so firing
|
|
804
841
|
* it eagerly is safe; the loop's `stopSent` guard prevents a second send.
|
|
805
842
|
*
|
|
806
|
-
* `turn.cancelled` is still set so the turn loop
|
|
807
|
-
*
|
|
808
|
-
*
|
|
843
|
+
* `turn.cancelled` is still set so the turn loop returns at once (the backend
|
|
844
|
+
* ignores session/stop — verified 0.16.5, the model stream runs to its natural
|
|
845
|
+
* end — so waiting for a terminal event would hang the stop for the whole
|
|
846
|
+
* remaining generation). The loop's return resolves session/prompt with
|
|
847
|
+
* stopReason "cancelled" immediately.
|
|
809
848
|
*/
|
|
810
849
|
export async function cancel(server, params) {
|
|
811
850
|
const zcodeSid = server.resolveSid(params.sessionId);
|
|
@@ -815,13 +854,13 @@ export async function cancel(server, params) {
|
|
|
815
854
|
// prior turn is still finalising, pendingTurns holds both it and any newer
|
|
816
855
|
// prompt waiting on the backend's prompt lock; breaking on the first match
|
|
817
856
|
// could leave the live one running. Each turn guards its own stopSent, so
|
|
818
|
-
// multiple matching turns may each fire
|
|
819
|
-
// treats
|
|
857
|
+
// multiple matching turns may each fire the stop pair once — the backend
|
|
858
|
+
// treats both as idempotent, so the duplicate is harmless.
|
|
820
859
|
for (const [, turn] of server.pendingTurns) {
|
|
821
860
|
if (turn.zcodeSid === zcodeSid) {
|
|
822
861
|
turn.cancelled = true;
|
|
823
862
|
if (!turn.stopSent) {
|
|
824
|
-
stopBackendTurn(server, zcodeSid);
|
|
863
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
825
864
|
turn.stopSent = true;
|
|
826
865
|
}
|
|
827
866
|
// Record cancel time so a prompt arriving in the backend's ~20s
|
|
@@ -855,13 +894,131 @@ class TurnFailedError extends Error {
|
|
|
855
894
|
* backend's prompt lock releases when ITS finalisation completes — that,
|
|
856
895
|
* not any bridge-side signal, is what the next prompt's send-retry waits on.
|
|
857
896
|
*/
|
|
858
|
-
function stopBackendTurn(server, zcodeSid) {
|
|
897
|
+
function stopBackendTurn(server, zcodeSid, foregroundExecutionId) {
|
|
859
898
|
try {
|
|
860
899
|
server.ensureBackend().send("session/stop", { sessionId: zcodeSid });
|
|
861
900
|
}
|
|
862
901
|
catch (e) {
|
|
863
902
|
log(` [stop] session/stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
|
|
864
903
|
}
|
|
904
|
+
// The official stop path (this is what the desktop app's stop button uses —
|
|
905
|
+
// found in the app bundle): a v4 command that asks the runtime to stop the
|
|
906
|
+
// active foreground execution. session/stop alone is a no-op on the Aug-28
|
|
907
|
+
// app-server (its abort controller is never registered; backend log shows
|
|
908
|
+
// `hadActivePrompt: false`), while this kills the generation instantly —
|
|
909
|
+
// verified: turn.completed arrives the same instant the command lands.
|
|
910
|
+
// expectedForegroundExecutionId is passed when known — it is captured from
|
|
911
|
+
// the turn's own turn.started, so it names the execution that is foreground
|
|
912
|
+
// at cancel time, letting the backend guard against stopping a newer one.
|
|
913
|
+
// Omitted when unknown, targeting whatever is currently foreground.
|
|
914
|
+
try {
|
|
915
|
+
server.ensureBackend().send("v4/command", {
|
|
916
|
+
commandId: randomUUID(),
|
|
917
|
+
clientId: "zcode-acp-server",
|
|
918
|
+
sessionId: zcodeSid,
|
|
919
|
+
type: "stop",
|
|
920
|
+
payload: foregroundExecutionId
|
|
921
|
+
? { expectedForegroundExecutionId: foregroundExecutionId }
|
|
922
|
+
: {},
|
|
923
|
+
issuedAt: Date.now(),
|
|
924
|
+
});
|
|
925
|
+
log(` [stop] v4/command stop sent for ${zcodeSid}`);
|
|
926
|
+
}
|
|
927
|
+
catch (e) {
|
|
928
|
+
log(` [stop] v4/command stop send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Last-resort stop: tear down the session's resident runtime, killing any
|
|
933
|
+
* generation that survived the stop pair (session/stop + v4/command stop).
|
|
934
|
+
*
|
|
935
|
+
* The primary path is stopBackendTurn's v4/command stop — the official one —
|
|
936
|
+
* which kills the generation instantly. This close is the escalation when
|
|
937
|
+
* both stops are ignored (drain gate, 5s grace): `session/close` closes the
|
|
938
|
+
* runtime itself, which kills the generation immediately; the conversation
|
|
939
|
+
* is persisted in the backend's session store, so `session/resume` restores
|
|
940
|
+
* it (verified live: resume succeeds and the partial reply is in the
|
|
941
|
+
* history). Callers reload the session on next use — prompt()'s subscribe
|
|
942
|
+
* recovery and the drain gate's reload both handle the closed window.
|
|
943
|
+
*/
|
|
944
|
+
function closeBackendSession(server, zcodeSid) {
|
|
945
|
+
try {
|
|
946
|
+
server.ensureBackend().send("session/close", { sessionId: zcodeSid });
|
|
947
|
+
log(` [stop] session/close fired for ${zcodeSid} (backend ignores session/stop)`);
|
|
948
|
+
}
|
|
949
|
+
catch (e) {
|
|
950
|
+
log(` [stop] session/close send failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Drain gate: a recent cancel/preempt means the backend side needs settling
|
|
955
|
+
* before the next send. Primary path: stopBackendTurn's v4/command stop kills
|
|
956
|
+
* the generation at once, so the first probe here already sees idle.
|
|
957
|
+
* Fallbacks: on a backend that honours session/stop we poll the projection
|
|
958
|
+
* until idle (a send that lands mid-generation is accepted as a steer whose
|
|
959
|
+
* input the backend silently DROPS when the old turn finishes); if the
|
|
960
|
+
* generation is STILL running after a grace period — both stops ignored —
|
|
961
|
+
* escalate to session/close, which tears down the runtime and kills it
|
|
962
|
+
* outright (the probe then fails into the reload branch). A visible chunk
|
|
963
|
+
* tells the user why the send waits. Bounded: on timeout send anyway — the
|
|
964
|
+
* steer-drop risk returns (the turn.steerQueued guard in runEventTurn reports
|
|
965
|
+
* it), but blocking the prompt forever is worse.
|
|
966
|
+
*
|
|
967
|
+
* Two post-drain repairs, both mirroring established patterns (prompt's
|
|
968
|
+
* eviction recovery / transient-retry re-baseline):
|
|
969
|
+
* - resubscribe: session/close killed the runtime this prompt subscribed to;
|
|
970
|
+
* the reload revives the session but not the event push, so re-arm it —
|
|
971
|
+
* without resubscribe the next turn runs deaf (no events at all, and stall
|
|
972
|
+
* recovery can't engage because it needs turn.started).
|
|
973
|
+
* - re-baseline: the abandoned turn committed messages to the session history
|
|
974
|
+
* while we waited (and close persisted its partial output); without markSeen
|
|
975
|
+
* the completion diff replays that residue as this turn's output.
|
|
976
|
+
*
|
|
977
|
+
* Returns "cancelled" when the turn was flagged cancelled during the drain
|
|
978
|
+
* (stop pair fired; caller resolves session/prompt at once).
|
|
979
|
+
*/
|
|
980
|
+
export async function drainBackendAfterCancel(server, deps) {
|
|
981
|
+
const { acpSid, zcodeSid, turn, listener, monitor, differ, cx } = deps;
|
|
982
|
+
const DRAIN_TIMEOUT_MS = 90_000;
|
|
983
|
+
const DRAIN_POLL_MS = 1000;
|
|
984
|
+
const escalateAfterMs = deps.escalateAfterMs ?? 5_000;
|
|
985
|
+
const drainT0 = Date.now();
|
|
986
|
+
let noticed = false;
|
|
987
|
+
let escalated = false;
|
|
988
|
+
while (Date.now() - drainT0 < DRAIN_TIMEOUT_MS) {
|
|
989
|
+
if (turn.cancelled) {
|
|
990
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
991
|
+
return "cancelled";
|
|
992
|
+
}
|
|
993
|
+
const proj = await monitor.pollOnce();
|
|
994
|
+
if (!proj) {
|
|
995
|
+
// Probe failed — most likely the session was just closed by the
|
|
996
|
+
// escalation (close tears down the runtime). Reload it so the send
|
|
997
|
+
// below doesn't die on "session is not active", then re-arm the event
|
|
998
|
+
// push (see docstring).
|
|
999
|
+
try {
|
|
1000
|
+
await reloadBackendSession(server, acpSid, zcodeSid);
|
|
1001
|
+
await listener.resubscribe(() => server.nextId());
|
|
1002
|
+
}
|
|
1003
|
+
catch (e) {
|
|
1004
|
+
warn(`drain gate: reload after close failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1005
|
+
}
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
if (proj.status === "idle")
|
|
1009
|
+
break;
|
|
1010
|
+
if (proj.status === "running" && !escalated && Date.now() - drainT0 > escalateAfterMs) {
|
|
1011
|
+
escalated = true;
|
|
1012
|
+
closeBackendSession(server, zcodeSid);
|
|
1013
|
+
}
|
|
1014
|
+
if (!noticed) {
|
|
1015
|
+
noticed = true;
|
|
1016
|
+
await sendTextChunk(cx, acpSid, "[上一个回复仍在生成,等待结束后发送…]", randomUUID());
|
|
1017
|
+
}
|
|
1018
|
+
await sleep(DRAIN_POLL_MS);
|
|
1019
|
+
}
|
|
1020
|
+
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
1021
|
+
return "drained";
|
|
865
1022
|
}
|
|
866
1023
|
/**
|
|
867
1024
|
* Serialize a per-session critical section. Each section awaits the previous
|
|
@@ -931,7 +1088,7 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
|
|
|
931
1088
|
continue;
|
|
932
1089
|
turn.cancelled = true; // signal the old turn to stop its retry loops
|
|
933
1090
|
if (!turn.stopSent) {
|
|
934
|
-
stopBackendTurn(server, zcodeSid);
|
|
1091
|
+
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
935
1092
|
turn.stopSent = true;
|
|
936
1093
|
}
|
|
937
1094
|
// Record cancel time so the prompt()'s send-retry can use the recovery
|
|
@@ -1087,20 +1244,69 @@ async function resumeBackendSession(server, zcParams) {
|
|
|
1087
1244
|
* Reload a session into the backend subprocess via `session/resume` — the
|
|
1088
1245
|
* recovery path after the backend evicted the resident runtime (idle timeout
|
|
1089
1246
|
* / LRU). Same param shape as session/load·resume (workspace from the
|
|
1090
|
-
* recorded session cwd,
|
|
1091
|
-
* Marks the session backend-loaded on success.
|
|
1247
|
+
* recorded session cwd, default-model overlay only if a faithful resume
|
|
1248
|
+
* fails). Marks the session backend-loaded on success.
|
|
1092
1249
|
*/
|
|
1093
1250
|
async function reloadBackendSession(server, acpSid, zcodeSid) {
|
|
1094
1251
|
const zcParams = {
|
|
1095
1252
|
sessionId: zcodeSid,
|
|
1096
1253
|
workspace: workspaceFor(server.sessionCwds.get(acpSid) ?? process.cwd()),
|
|
1097
1254
|
};
|
|
1098
|
-
|
|
1099
|
-
if (runtimeModel !== null)
|
|
1100
|
-
zcParams.runtimeModel = runtimeModel;
|
|
1101
|
-
await resumeBackendSession(server, zcParams);
|
|
1255
|
+
await resumePreservingModel(server, zcParams);
|
|
1102
1256
|
server.markBackendLoaded(acpSid);
|
|
1103
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Resume WITHOUT pinning a model, so the session keeps its own selection (the
|
|
1260
|
+
* backend persists it per session — sessions the user ran on GLM-5.3-Flash
|
|
1261
|
+
* used to be silently re-pinned to the first config model by an unconditional
|
|
1262
|
+
* runtimeModel overlay). The overlay is now a FALLBACK repair only: when the
|
|
1263
|
+
* faithful resume fails outright (history carrying a stale/revoked model),
|
|
1264
|
+
* retry once pinned to the first enabled provider's first model.
|
|
1265
|
+
*/
|
|
1266
|
+
async function resumePreservingModel(server, zcParams) {
|
|
1267
|
+
try {
|
|
1268
|
+
return await resumeBackendSession(server, zcParams);
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
const overlay = buildResumeRuntimeModel();
|
|
1272
|
+
if (overlay === null)
|
|
1273
|
+
throw err;
|
|
1274
|
+
warn(`resume failed (${err instanceof Error ? err.message : String(err)}); retrying with default-model overlay`);
|
|
1275
|
+
return resumeBackendSession(server, { ...zcParams, runtimeModel: overlay });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* After a faithful resume the session keeps its own last model; when that
|
|
1280
|
+
* model no longer belongs to an enabled provider in config.json (deleted or
|
|
1281
|
+
* revoked elsewhere), the first send would fail with the backend's
|
|
1282
|
+
* stale-history-model error. Repair proactively: switch to the default
|
|
1283
|
+
* (first enabled) model. Best-effort — a failed check leaves the model
|
|
1284
|
+
* untouched.
|
|
1285
|
+
*/
|
|
1286
|
+
async function repairUnavailableModel(server, zcodeSid) {
|
|
1287
|
+
try {
|
|
1288
|
+
const backend = server.ensureBackend();
|
|
1289
|
+
const resp = await backend.request(server.nextId(), "session/read", { sessionId: zcodeSid }, 5000);
|
|
1290
|
+
if (resp.error)
|
|
1291
|
+
return;
|
|
1292
|
+
const settings = (resp.result ?? {}).settings;
|
|
1293
|
+
const cur = settings?.model?.current;
|
|
1294
|
+
if (!cur?.providerId || !cur.modelId)
|
|
1295
|
+
return;
|
|
1296
|
+
const available = loadAllModels();
|
|
1297
|
+
if (available.some((m) => m.providerId === cur.providerId && m.modelId === cur.modelId))
|
|
1298
|
+
return;
|
|
1299
|
+
const fallback = available[0];
|
|
1300
|
+
if (!fallback)
|
|
1301
|
+
return;
|
|
1302
|
+
warn(`session ${zcodeSid} model ${cur.providerId}/${cur.modelId} is no longer enabled; switching to ${fallback.providerId}/${fallback.modelId}`);
|
|
1303
|
+
const { applyModelSwitch } = await import("../config/runtime-model.js");
|
|
1304
|
+
await applyModelSwitch(server, zcodeSid, formatModelValue(fallback.providerId, fallback.modelId));
|
|
1305
|
+
}
|
|
1306
|
+
catch (e) {
|
|
1307
|
+
warn(`model repair check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1104
1310
|
/** Get or create the session-level ProjectionDiffer (persists across turns). */
|
|
1105
1311
|
function getOrCreateDiffer(server, zcodeSid) {
|
|
1106
1312
|
let d = server.differs.get(zcodeSid);
|
|
@@ -1121,7 +1327,7 @@ function getOrCreateDiffer(server, zcodeSid) {
|
|
|
1121
1327
|
* handling (requestPermission / ExitPlanMode / AskUserQuestion) lands in
|
|
1122
1328
|
* Commit 6 — for now they're polled to keep the inbox clear.
|
|
1123
1329
|
*/
|
|
1124
|
-
async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn,
|
|
1330
|
+
export async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed) {
|
|
1125
1331
|
const backend = server.ensureBackend();
|
|
1126
1332
|
const translator = new EventTranslator();
|
|
1127
1333
|
differ.resetTurn();
|
|
@@ -1157,18 +1363,22 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1157
1363
|
lastProgress = Date.now();
|
|
1158
1364
|
}
|
|
1159
1365
|
if (turn.cancelled) {
|
|
1160
|
-
// Cancel requested:
|
|
1161
|
-
// this
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1366
|
+
// Cancel requested: fire the stop (cancel()/preempt normally already
|
|
1367
|
+
// did — this is a guard) and END THE TURN AT ONCE. The backend's
|
|
1368
|
+
// session/stop is fire-and-forget and, as verified against app-server
|
|
1369
|
+
// 0.16.5, does NOT abort the in-flight model stream — waiting for the
|
|
1370
|
+
// backend's terminal event used to keep the turn streaming for the
|
|
1371
|
+
// full remaining generation (10s+ past the stop) while the user stared
|
|
1372
|
+
// at a live spinner. Returning here resolves session/prompt with
|
|
1373
|
+
// stopReason "cancelled" immediately; the finally below unregisters
|
|
1374
|
+
// the turn listener, so events the backend still pushes are delivered
|
|
1375
|
+
// to no turn listener, and the next turn's turn-attribution gate
|
|
1376
|
+
// discards any residue that slipped into the queue meanwhile.
|
|
1168
1377
|
if (!turn.stopSent) {
|
|
1169
|
-
stopBackendTurn(server, turn.zcodeSid);
|
|
1378
|
+
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
1170
1379
|
turn.stopSent = true;
|
|
1171
1380
|
}
|
|
1381
|
+
return { stopReason: "cancelled" };
|
|
1172
1382
|
}
|
|
1173
1383
|
const ev = await listener.pollEvent(500);
|
|
1174
1384
|
if (ev === null) {
|
|
@@ -1224,7 +1434,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1224
1434
|
}
|
|
1225
1435
|
else if (!emittedOutput) {
|
|
1226
1436
|
// No text and no output → suspected failure.
|
|
1227
|
-
stopBackendTurn(server, turn.zcodeSid);
|
|
1437
|
+
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
1228
1438
|
throw new RequestError(-32603, "turn produced no output");
|
|
1229
1439
|
}
|
|
1230
1440
|
}
|
|
@@ -1255,6 +1465,17 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1255
1465
|
continue;
|
|
1256
1466
|
}
|
|
1257
1467
|
lastProgress = Date.now();
|
|
1468
|
+
// Steer-swallow guard: a send accepted while the previous turn is still
|
|
1469
|
+
// generating is queued as steer input, which the backend silently DROPS
|
|
1470
|
+
// when that turn ends — no new turn ever starts (no turn.started), and
|
|
1471
|
+
// the attribution gate below would discard the steerQueued event like
|
|
1472
|
+
// any other residue, leaving this prompt to hang until the 120s watchdog
|
|
1473
|
+
// with the message lost. turn.steerQueued is definitive proof of the
|
|
1474
|
+
// swallow: report it at once so the user can resend immediately.
|
|
1475
|
+
if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
|
|
1476
|
+
await sendTextChunk(cx, acpSid, "[消息被并入仍在生成的回合,将被丢弃,请重新发送]", chunkMsgId);
|
|
1477
|
+
return { stopReason: "max_turn_requests" };
|
|
1478
|
+
}
|
|
1258
1479
|
// Turn-attribution gate: before this turn's own turn.started arrives, any
|
|
1259
1480
|
// event is leftover from a prior turn (cancelled/preempted but still
|
|
1260
1481
|
// finalising) that landed in the queue while send was retrying on a busy
|
|
@@ -1266,15 +1487,26 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1266
1487
|
// terminal event here would flip them and make THIS turn exit prematurely
|
|
1267
1488
|
// at the first check after its own turn.started passes the gate.
|
|
1268
1489
|
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
|
|
1490
|
+
// Armed when this send preempted another prompt, OR when a cancel is
|
|
1491
|
+
// recent: the backend ignores session/stop, so an abandoned turn keeps
|
|
1492
|
+
// streaming until its natural end while the turn loop has already
|
|
1493
|
+
// returned — a prompt sent in that window reaches subscribe while the
|
|
1494
|
+
// residue is still arriving. Backend serialisation bounds the exposure:
|
|
1495
|
+
// a send is accepted only after the prior turn released the lock, so the
|
|
1496
|
+
// residue can only arrive BEFORE this turn's turn.started. (A send that
|
|
1497
|
+
// lands mid-generation instead is a steer whose input is dropped — the
|
|
1498
|
+
// turn.steerQueued guard above reports that at once.)
|
|
1499
|
+
if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, gateArmed)) {
|
|
1276
1500
|
continue;
|
|
1277
1501
|
}
|
|
1502
|
+
if (ev.type === "turn.started") {
|
|
1503
|
+
// Remember the runtime's foreground execution id: the v4/command stop
|
|
1504
|
+
// (see stopBackendTurn) targets it if the user cancels mid-turn.
|
|
1505
|
+
const fge = ev.payload
|
|
1506
|
+
?.foregroundExecutionId;
|
|
1507
|
+
if (fge)
|
|
1508
|
+
turn.foregroundExecutionId = fge;
|
|
1509
|
+
}
|
|
1278
1510
|
const internalEvents = translator.translate(ev);
|
|
1279
1511
|
// Capture the turn-start timestamp for the thinking-phase hint above.
|
|
1280
1512
|
// Done after translate so the flag flip on the turn.started event is
|
|
@@ -1340,7 +1572,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1340
1572
|
}
|
|
1341
1573
|
if (translator.turnFailed) {
|
|
1342
1574
|
// Best-effort stop in case the failed turn left a residual lock.
|
|
1343
|
-
stopBackendTurn(server, turn.zcodeSid);
|
|
1575
|
+
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
1344
1576
|
// Throw a TurnFailedError carrying the structured error so the caller
|
|
1345
1577
|
// (prompt's retry loop) can classify transient vs fatal. The error
|
|
1346
1578
|
// message is formatted for display when it ultimately reaches the user.
|
|
@@ -1391,7 +1623,7 @@ async function runEventTurn(server, listener, monitor, differ, cx, acpSid, chunk
|
|
|
1391
1623
|
}
|
|
1392
1624
|
}
|
|
1393
1625
|
// 120s no progress: abandon.
|
|
1394
|
-
stopBackendTurn(server, turn.zcodeSid);
|
|
1626
|
+
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
1395
1627
|
return { stopReason: "max_turn_requests" };
|
|
1396
1628
|
}
|
|
1397
1629
|
/**
|