botmux 3.16.0 → 3.16.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/dist/.runtime-build-id +1 -1
- package/dist/core/dashboard-url.d.ts +10 -1
- package/dist/core/dashboard-url.d.ts.map +1 -1
- package/dist/core/dashboard-url.js +12 -2
- package/dist/core/dashboard-url.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.d.ts +27 -3
- package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +272 -26
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/mcp/host.d.ts +18 -0
- package/dist/core/plugins/mcp/host.d.ts.map +1 -1
- package/dist/core/plugins/mcp/host.js +80 -16
- package/dist/core/plugins/mcp/host.js.map +1 -1
- package/dist/core/plugins/mcp/launch-record.d.ts +14 -0
- package/dist/core/plugins/mcp/launch-record.d.ts.map +1 -0
- package/dist/core/plugins/mcp/launch-record.js +63 -0
- package/dist/core/plugins/mcp/launch-record.js.map +1 -0
- package/dist/core/worker-pool.d.ts +10 -2
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +30 -3
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/dashboard/control-csrf.d.ts.map +1 -1
- package/dist/dashboard/control-csrf.js +6 -1
- package/dist/dashboard/control-csrf.js.map +1 -1
- package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -1
- package/dist/dashboard/terminal-front-proxy.js +19 -1
- package/dist/dashboard/terminal-front-proxy.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +1 -0
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +65 -4
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/platform/binding.d.ts +27 -0
- package/dist/platform/binding.d.ts.map +1 -1
- package/dist/platform/binding.js +40 -0
- package/dist/platform/binding.js.map +1 -1
- package/dist/services/bridge-turn-journal.d.ts +63 -0
- package/dist/services/bridge-turn-journal.d.ts.map +1 -0
- package/dist/services/bridge-turn-journal.js +149 -0
- package/dist/services/bridge-turn-journal.js.map +1 -0
- package/dist/services/bridge-turn-queue.d.ts +8 -1
- package/dist/services/bridge-turn-queue.d.ts.map +1 -1
- package/dist/services/bridge-turn-queue.js +2 -1
- package/dist/services/bridge-turn-queue.js.map +1 -1
- package/dist/worker.js +270 -19
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/worker.js
CHANGED
|
@@ -49,8 +49,10 @@ import { sanitizePerBotEnv } from './core/per-bot-env.js';
|
|
|
49
49
|
import { resolveChildBotsConfig } from './core/config-dir.js';
|
|
50
50
|
import { evaluateVcMeetingManagedSend, } from './services/vc-meeting-send-policy.js';
|
|
51
51
|
import { TurnTerminalDeduper } from './services/turn-terminal-deduper.js';
|
|
52
|
+
import { appendBridgeTurnJournalEntry, BridgeRestoreGate, clearBridgeTurnJournal, readBridgeTurnJournal, removeBridgeTurnJournalEntry, selectRestorableBridgeTurns, writeBridgeTurnJournal, } from './services/bridge-turn-journal.js';
|
|
52
53
|
import { defaultGatewayEntry, ensureGatewayEntry } from './core/plugins/mcp/gateway-installer.js';
|
|
53
|
-
import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
|
|
54
|
+
import { sessionMcpGatewayPathRegex, sessionMcpGatewaySocketPath, startSessionMcpGatewayHost, } from './core/plugins/mcp/host.js';
|
|
55
|
+
import { clearMcpGatewayLaunchRecord, mcpGatewayPaneReattachSafe, readMcpGatewayLaunchRecord, writeMcpGatewayLaunchRecord, } from './core/plugins/mcp/launch-record.js';
|
|
54
56
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
55
57
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
56
58
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
@@ -60,6 +62,7 @@ import { deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminal
|
|
|
60
62
|
import { deriveWorkerViewGeneration, looksLikeTerminalControlGrant, TERMINAL_VIEW_FORWARD_HEADER, verifyTerminalControlGrant, verifyTerminalViewForward, } from './core/terminal-control-grant.js';
|
|
61
63
|
import { appendControlAudit, controlAuditRecord } from './dashboard/control-audit.js';
|
|
62
64
|
import { readPlatformBinding } from './platform/binding.js';
|
|
65
|
+
import { buildPlatformDashboardLoginUrl } from './core/dashboard-url.js';
|
|
63
66
|
import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
|
|
64
67
|
import { InflightInputTracker } from './core/inflight-input-tracker.js';
|
|
65
68
|
import { InputTurnDeduper } from './core/input-turn-dedupe.js';
|
|
@@ -1172,16 +1175,38 @@ async function prepareCliPluginGenerationAndGateway(cfg, adapter) {
|
|
|
1172
1175
|
const manifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
|
|
1173
1176
|
stopSessionMcpGatewayHost();
|
|
1174
1177
|
if (adapter.mcpGateway && manifest?.entries.length) {
|
|
1175
|
-
|
|
1176
|
-
sessionId: cfg.sessionId,
|
|
1177
|
-
dataDir: config.session.dataDir,
|
|
1178
|
-
trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
|
|
1179
|
-
onError: error => log(`[mcp-gateway] host error: ${error.message}`),
|
|
1180
|
-
});
|
|
1178
|
+
await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
|
|
1181
1179
|
log(`[mcp-gateway] trusted host listening for ${manifest.entries.length} plugin server(s)`);
|
|
1182
1180
|
}
|
|
1181
|
+
else {
|
|
1182
|
+
clearMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId);
|
|
1183
|
+
}
|
|
1183
1184
|
return manifest;
|
|
1184
1185
|
}
|
|
1186
|
+
/** Bind the trusted MCP Gateway host at the session's DETERMINISTIC socket path
|
|
1187
|
+
* and persist its launch record. Deliberately does NOT refresh the plugin/Skill
|
|
1188
|
+
* generation: prepareCliPluginGenerationAndGateway does that for fresh/resumed
|
|
1189
|
+
* spawns, whereas a warm reattach to a surviving pane keeps the CLI's existing
|
|
1190
|
+
* catalog untouched — but the host itself died with the previous worker, so the
|
|
1191
|
+
* reattach path must still re-serve it here so the pane's surviving relay can
|
|
1192
|
+
* reconnect to the same path (see spawnCli's paneRelayReattachSafe branch).
|
|
1193
|
+
* The caller owns stopping any prior host first (prepare*) or gating on absence
|
|
1194
|
+
* (reattach). Token rotation + stale-socket removal are handled inside
|
|
1195
|
+
* startSessionMcpGatewayHost. The trusted-turn-identity provider (#917) is
|
|
1196
|
+
* wired here so BOTH the fresh/resumed host and the re-served reattach host
|
|
1197
|
+
* inject the per-turn host-signed caller — the reattach path must not lose it. */
|
|
1198
|
+
async function startAndRecordSessionMcpGatewayHost(sessionId) {
|
|
1199
|
+
sessionMcpGatewayHost = await startSessionMcpGatewayHost({
|
|
1200
|
+
sessionId,
|
|
1201
|
+
dataDir: config.session.dataDir,
|
|
1202
|
+
trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
|
|
1203
|
+
onError: error => log(`[mcp-gateway] host error: ${error.message}`),
|
|
1204
|
+
});
|
|
1205
|
+
// The NEXT worker generation reads this record to decide whether a surviving
|
|
1206
|
+
// persistent pane can be reattached (relay reconnects to the same path)
|
|
1207
|
+
// instead of killed for a cold-resume.
|
|
1208
|
+
writeMcpGatewayLaunchRecord(config.session.dataDir, sessionId, sessionMcpGatewayHost.socketPath);
|
|
1209
|
+
}
|
|
1185
1210
|
/** v2 read isolation — provision a bot's PER-BOT config dir under its BOT_HOME so the
|
|
1186
1211
|
* CLI (redirected there via CLAUDE_CONFIG_DIR/CODEX_HOME) starts fully set up despite
|
|
1187
1212
|
* the global ~/.claude|~/.codex being Seatbelt-denied. Idempotent (guards on
|
|
@@ -1534,6 +1559,18 @@ function refreshTerminalWriteToken() {
|
|
|
1534
1559
|
if (secret && sessionId)
|
|
1535
1560
|
writeToken = deriveTerminalWriteToken(secret, sessionId);
|
|
1536
1561
|
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Owner-login URL for THIS session's read-only web terminal, or '' when the
|
|
1564
|
+
* machine is unbound / remote access is off (banner then shows non-link text).
|
|
1565
|
+
* `next=/s/<sessionId>` is what makes the round-trip land the owner back on a
|
|
1566
|
+
* writable terminal: the platform routes a `/s/`-prefixed next to the terminal
|
|
1567
|
+
* subdomain surface and mints the host-only proxy-session cookie there, which
|
|
1568
|
+
* is the credential the owner write-check reads (the cross-subdomain SSO cookie
|
|
1569
|
+
* alone does not). See buildPlatformDashboardLoginUrl.
|
|
1570
|
+
*/
|
|
1571
|
+
function deriveTerminalLoginUrl(sid) {
|
|
1572
|
+
return buildPlatformDashboardLoginUrl(`/s/${encodeURIComponent(sid)}`) ?? '';
|
|
1573
|
+
}
|
|
1537
1574
|
function resolveTerminalAccessForReq(req, url) {
|
|
1538
1575
|
// P1-6 invariant: an explicit, matching write-link `?token=` is an
|
|
1539
1576
|
// independent capability with the HIGHEST priority. It wins outright — before
|
|
@@ -3505,6 +3542,19 @@ const bridgeSecondaryPaths = new Map(); // path → offset
|
|
|
3505
3542
|
let bridgeOffset = 0;
|
|
3506
3543
|
let bridgePendingTail = '';
|
|
3507
3544
|
const bridgeQueue = new BridgeTurnQueue();
|
|
3545
|
+
/** Journal-restore of interrupted Lark turns is allowed AT MOST ONCE per worker
|
|
3546
|
+
* process, and only on the FIRST baseline this process runs. Rationale: the
|
|
3547
|
+
* journal exists to recover a turn that a *cross-process* death (daemon
|
|
3548
|
+
* restart / worker crash) orphaned — there the whole worker is new and no
|
|
3549
|
+
* other recovery owner survives. An *in-worker* CLI restart (crash-respawn,
|
|
3550
|
+
* cwd-move, durable lease expiry) keeps this process alive, so its
|
|
3551
|
+
* InflightInputTracker carryover already re-delivers the interrupted plain-IM
|
|
3552
|
+
* input (a full fresh answer) — letting the journal ALSO restore that turn
|
|
3553
|
+
* would post the recovered partial AND the fresh answer (same turnId, different
|
|
3554
|
+
* lastUuid → daemon dedupe can't collapse them). Armed on every watcher
|
|
3555
|
+
* teardown so restore is confined to the one baseline with no competing
|
|
3556
|
+
* recovery owner. */
|
|
3557
|
+
const bridgeRestoreGate = new BridgeRestoreGate();
|
|
3508
3558
|
/** uuids of structured transcript rate-limit records already turned into a
|
|
3509
3559
|
* `limited` emit. Readers may re-read from offset 0 after rotation, so the
|
|
3510
3560
|
* stable record uuid keeps the notification idempotent. */
|
|
@@ -3666,6 +3716,46 @@ function bridgeMarkerPath() {
|
|
|
3666
3716
|
return undefined;
|
|
3667
3717
|
return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
|
|
3668
3718
|
}
|
|
3719
|
+
/** Durable journal of pending Lark bridge turns (services/bridge-turn-journal).
|
|
3720
|
+
* Written at mark time, cleared at every turn-terminal outcome; consulted by
|
|
3721
|
+
* bridgeAbsorbBaseline after a restart so an interrupted turn's output can
|
|
3722
|
+
* still reach Lark. Sibling of the turn-sends marker file. */
|
|
3723
|
+
function bridgeTurnJournalFilePath() {
|
|
3724
|
+
if (!process.env.SESSION_DATA_DIR || !sessionId)
|
|
3725
|
+
return undefined;
|
|
3726
|
+
return join(process.env.SESSION_DATA_DIR, 'turn-marks', `${sessionId}.json`);
|
|
3727
|
+
}
|
|
3728
|
+
function journalBridgeTurnMark(entry) {
|
|
3729
|
+
const path = bridgeTurnJournalFilePath();
|
|
3730
|
+
if (!path)
|
|
3731
|
+
return;
|
|
3732
|
+
try {
|
|
3733
|
+
appendBridgeTurnJournalEntry(path, entry);
|
|
3734
|
+
}
|
|
3735
|
+
catch (err) {
|
|
3736
|
+
log(`Bridge turn journal write failed (${err.message}) — restart recovery unavailable for turn ${entry.turnId.substring(0, 8)}`);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
function journalBridgeTurnClear(turnId, dispatchAttempt) {
|
|
3740
|
+
const path = bridgeTurnJournalFilePath();
|
|
3741
|
+
if (!path)
|
|
3742
|
+
return;
|
|
3743
|
+
try {
|
|
3744
|
+
removeBridgeTurnJournalEntry(path, turnId, dispatchAttempt);
|
|
3745
|
+
}
|
|
3746
|
+
catch (err) {
|
|
3747
|
+
log(`Bridge turn journal clear failed (${err.message}) for turn ${turnId.substring(0, 8)}`);
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
function clearBridgeTurnJournalFile() {
|
|
3751
|
+
const path = bridgeTurnJournalFilePath();
|
|
3752
|
+
if (!path)
|
|
3753
|
+
return;
|
|
3754
|
+
try {
|
|
3755
|
+
clearBridgeTurnJournal(path);
|
|
3756
|
+
}
|
|
3757
|
+
catch { /* best-effort — session is closing */ }
|
|
3758
|
+
}
|
|
3669
3759
|
function readSendMarkers() {
|
|
3670
3760
|
if (closeRequested)
|
|
3671
3761
|
return [];
|
|
@@ -3916,6 +4006,13 @@ function bridgeAbsorbBaseline() {
|
|
|
3916
4006
|
if (!bridgeJsonlPath)
|
|
3917
4007
|
return;
|
|
3918
4008
|
if (!lastInitConfig?.adoptMode) {
|
|
4009
|
+
// Restart recovery: if the previous generation left pending Lark turns in
|
|
4010
|
+
// the durable journal (worker/daemon died mid-turn), re-mark them and
|
|
4011
|
+
// baseline BEHIND their recorded offsets instead of skipping to EOF — the
|
|
4012
|
+
// interrupted turn's user line and partial assistant output are then
|
|
4013
|
+
// re-attributed and delivered through the normal fallback gate.
|
|
4014
|
+
if (tryRestoreInterruptedBridgeTurns())
|
|
4015
|
+
return;
|
|
3919
4016
|
const cursor = baselineJsonlCursor(bridgeJsonlPath);
|
|
3920
4017
|
bridgeOffset = cursor.newOffset;
|
|
3921
4018
|
bridgePendingTail = cursor.pendingTail;
|
|
@@ -3935,6 +4032,72 @@ function bridgeAbsorbBaseline() {
|
|
|
3935
4032
|
if (lastInitConfig?.adoptMode)
|
|
3936
4033
|
maybeEmitAdoptPreamble(result.events);
|
|
3937
4034
|
}
|
|
4035
|
+
/** Restore journal-persisted pending turns after a restart interrupted them.
|
|
4036
|
+
*
|
|
4037
|
+
* Returns true when it established the bridge cursor itself (restore mode);
|
|
4038
|
+
* false when there is nothing to restore and the caller should run the
|
|
4039
|
+
* normal baseline-to-EOF.
|
|
4040
|
+
*
|
|
4041
|
+
* Scope guards (all deliberate):
|
|
4042
|
+
* - non-adopt only (caller gates);
|
|
4043
|
+
* - same transcript file only — a sessionId rotation between generations
|
|
4044
|
+
* invalidates offsets and risks cross-file mis-attribution;
|
|
4045
|
+
* - bounded age (BRIDGE_TURN_RESTORE_MAX_AGE_MS) — a partial answer from
|
|
4046
|
+
* days ago is noise, not recovery.
|
|
4047
|
+
*
|
|
4048
|
+
* Safety properties:
|
|
4049
|
+
* - events before the earliest mark (minus the same 5s guard the
|
|
4050
|
+
* fingerprint switch uses) are ABSORBED as history, so old turns are
|
|
4051
|
+
* never re-emitted;
|
|
4052
|
+
* - a stray prior-turn assistant tail after the cutoff can only synthesize
|
|
4053
|
+
* a local turn, which the non-adopt emit gate always suppresses;
|
|
4054
|
+
* - a restored mark whose user line never landed in the jsonl simply
|
|
4055
|
+
* never starts, and pruneExpired clears it (journal entry included) on
|
|
4056
|
+
* the first idle tick;
|
|
4057
|
+
* - the send-marker gate still applies at emit — turn-sends markers
|
|
4058
|
+
* persist across restarts, so an answer the model already `botmux
|
|
4059
|
+
* send`-ed before the kill stays suppressed. */
|
|
4060
|
+
function tryRestoreInterruptedBridgeTurns() {
|
|
4061
|
+
// At-most-once, and only on this worker process's FIRST bridge baseline. Any
|
|
4062
|
+
// baseline that runs after a watcher teardown is an in-worker CLI restart
|
|
4063
|
+
// whose InflightInputTracker carryover already re-delivers the interrupted
|
|
4064
|
+
// turn — restoring here too would double-deliver (see bridgeRestoreGate,
|
|
4065
|
+
// armed in stopBridgeWatcher).
|
|
4066
|
+
if (!bridgeRestoreGate.mayRestoreOnThisBaseline())
|
|
4067
|
+
return false;
|
|
4068
|
+
const journalPath = bridgeTurnJournalFilePath();
|
|
4069
|
+
if (!journalPath || !bridgeJsonlPath)
|
|
4070
|
+
return false;
|
|
4071
|
+
const entries = readBridgeTurnJournal(journalPath);
|
|
4072
|
+
if (entries.length === 0)
|
|
4073
|
+
return false;
|
|
4074
|
+
const restorable = selectRestorableBridgeTurns(entries, { currentJsonlPath: bridgeJsonlPath });
|
|
4075
|
+
if (restorable.length !== entries.length) {
|
|
4076
|
+
// Entries for rotated-away files or beyond the age cap can never restore —
|
|
4077
|
+
// drop them now so they don't linger across future generations.
|
|
4078
|
+
try {
|
|
4079
|
+
writeBridgeTurnJournal(journalPath, restorable);
|
|
4080
|
+
}
|
|
4081
|
+
catch { /* best-effort */ }
|
|
4082
|
+
}
|
|
4083
|
+
if (restorable.length === 0)
|
|
4084
|
+
return false;
|
|
4085
|
+
for (const entry of restorable) {
|
|
4086
|
+
bridgeQueue.mark(entry.turnId, entry.fingerprint, entry.markTimeMs, entry.contentNormalized, entry.dispatchAttempt, { restoredFromJournal: true });
|
|
4087
|
+
}
|
|
4088
|
+
const fromOffset = Math.min(...restorable.map(entry => entry.offsetAtMark));
|
|
4089
|
+
const drained = drainTranscript(bridgeJsonlPath, fromOffset);
|
|
4090
|
+
bridgeOffset = drained.newOffset;
|
|
4091
|
+
bridgePendingTail = drained.pendingTail;
|
|
4092
|
+
const cutoffMs = Math.min(...restorable.map(entry => entry.markTimeMs)) - 5_000;
|
|
4093
|
+
const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
|
|
4094
|
+
bridgeQueue.absorb(history);
|
|
4095
|
+
if (live.length > 0)
|
|
4096
|
+
bridgeQueue.ingest(live, bridgeJsonlPath);
|
|
4097
|
+
bridgeBaselineDone = true;
|
|
4098
|
+
log(`Bridge restored ${restorable.length} interrupted turn(s) from journal (drain ${fromOffset}→${bridgeOffset}, absorbed=${history.length}, live=${live.length})`);
|
|
4099
|
+
return true;
|
|
4100
|
+
}
|
|
3938
4101
|
/** Record `bridgeStalePidStateSessionId` if the pid file's current sid
|
|
3939
4102
|
* disagrees with the just-accepted candidate's sid. Stops the next pid
|
|
3940
4103
|
* resolver tick from pulling the watcher back to the stale spawn-time
|
|
@@ -4501,6 +4664,7 @@ function bridgeIngest() {
|
|
|
4501
4664
|
for (const t of expired) {
|
|
4502
4665
|
if (t.contentFingerprint)
|
|
4503
4666
|
bridgeFingerprintScanLastMs.delete(t.contentFingerprint);
|
|
4667
|
+
journalBridgeTurnClear(t.turnId, t.dispatchAttempt);
|
|
4504
4668
|
log(`Bridge mark expired after ${Math.round(BRIDGE_PENDING_TURN_TTL_MS / 1000)}s without matching a jsonl user line (turnId=${t.turnId}) — dropped to prevent rotation-fallback scan loop.`);
|
|
4505
4669
|
}
|
|
4506
4670
|
// Drain secondary paths first so any trailing assistant text on an old
|
|
@@ -4755,6 +4919,12 @@ function stopBridgeWatcher() {
|
|
|
4755
4919
|
// A fresh backend must not inherit a terminal/fingerprint from the exited
|
|
4756
4920
|
// process; durable replay is owned by the receiver with a new attempt.
|
|
4757
4921
|
bridgeQueue.clearPending();
|
|
4922
|
+
// A watcher teardown means this worker process has now had at least one CLI
|
|
4923
|
+
// generation, so any FUTURE baseline is an in-worker restart — never the
|
|
4924
|
+
// first-baseline cross-process recovery the journal is for. Disarm journal
|
|
4925
|
+
// restore: the restart's InflightInputTracker carryover already re-delivers
|
|
4926
|
+
// the interrupted turn, and restoring on top would double-deliver.
|
|
4927
|
+
bridgeRestoreGate.markGenerationRetired();
|
|
4758
4928
|
bridgePreambleSent = false;
|
|
4759
4929
|
}
|
|
4760
4930
|
/**
|
|
@@ -4816,7 +4986,30 @@ function bridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
|
|
|
4816
4986
|
const normalised = normaliseForFingerprint(messageText);
|
|
4817
4987
|
const contentNormalized = normalised.length > 0 ? normalised : undefined;
|
|
4818
4988
|
const turnId = preferredTurnId ?? randomBytes(8).toString('hex');
|
|
4819
|
-
|
|
4989
|
+
const markTimeMs = Date.now();
|
|
4990
|
+
bridgeQueue.mark(turnId, fingerprint, markTimeMs, contentNormalized, dispatchAttempt);
|
|
4991
|
+
// Journal the mark so a worker/daemon restart mid-turn can restore it
|
|
4992
|
+
// (bridgeAbsorbBaseline). Excluded:
|
|
4993
|
+
// - adopt mode: its baseline absorbs the whole transcript with different
|
|
4994
|
+
// semantics and never suppresses — restore marks would double-attribute;
|
|
4995
|
+
// - durable turns (dispatchAttempt !== undefined): a durable delivery has
|
|
4996
|
+
// its OWN cross-restart recovery owner (the daemon's persisted
|
|
4997
|
+
// queuedActivation* write-ahead + receipt auto-redispatch), so a journal
|
|
4998
|
+
// restore is always redundant for it and — because the recovered partial
|
|
4999
|
+
// and the re-dispatched attempt carry the same turnId but different
|
|
5000
|
+
// lastUuid — would double-deliver. Only PLAIN Lark IM turns (no other
|
|
5001
|
+
// recovery owner) actually need the journal.
|
|
5002
|
+
if (!lastInitConfig?.adoptMode && bridgeJsonlPath && dispatchAttempt === undefined) {
|
|
5003
|
+
journalBridgeTurnMark({
|
|
5004
|
+
turnId,
|
|
5005
|
+
dispatchAttempt,
|
|
5006
|
+
markTimeMs,
|
|
5007
|
+
fingerprint,
|
|
5008
|
+
contentNormalized,
|
|
5009
|
+
jsonlPath: bridgeJsonlPath,
|
|
5010
|
+
offsetAtMark: bridgeOffset,
|
|
5011
|
+
});
|
|
5012
|
+
}
|
|
4820
5013
|
return turnId;
|
|
4821
5014
|
}
|
|
4822
5015
|
function bridgeDrainAndMaybeEmit() {
|
|
@@ -4846,6 +5039,14 @@ function emitReadyTurns(opts = {}) {
|
|
|
4846
5039
|
});
|
|
4847
5040
|
if (ready.length === 0)
|
|
4848
5041
|
return;
|
|
5042
|
+
// Every popped turn is terminal (emitted or suppressed below) — retire its
|
|
5043
|
+
// durable journal entry NOW, before the final_output IPC. Clear-before-send:
|
|
5044
|
+
// if the worker dies in between, the answer is lost rather than duplicated
|
|
5045
|
+
// (the daemon-side dedupe key does not survive restarts).
|
|
5046
|
+
for (const turn of ready) {
|
|
5047
|
+
if (!turn.isLocal)
|
|
5048
|
+
journalBridgeTurnClear(turn.turnId, turn.dispatchAttempt);
|
|
5049
|
+
}
|
|
4849
5050
|
const adoptMode = lastInitConfig?.adoptMode === true;
|
|
4850
5051
|
// Send markers (`botmux send` landed in own thread) + the queue's first
|
|
4851
5052
|
// still-unready turn. The latter caps the LAST ready turn's window —
|
|
@@ -4956,9 +5157,16 @@ function emitReadyTurns(opts = {}) {
|
|
|
4956
5157
|
});
|
|
4957
5158
|
continue;
|
|
4958
5159
|
}
|
|
5160
|
+
// A restored turn's fallback is a RECOVERED partial/final produced around
|
|
5161
|
+
// a worker restart — label it so the user can tell it from a live answer.
|
|
5162
|
+
// Prefix is applied AFTER the suppress gate so dedup compares the model's
|
|
5163
|
+
// own text, never the notice.
|
|
5164
|
+
const deliveredText = turn.restoredFromJournal
|
|
5165
|
+
? `${t('worker.bridge_restored_turn_notice')}\n\n${postText}`
|
|
5166
|
+
: postText;
|
|
4959
5167
|
send({
|
|
4960
5168
|
type: 'final_output',
|
|
4961
|
-
content:
|
|
5169
|
+
content: deliveredText,
|
|
4962
5170
|
lastUuid,
|
|
4963
5171
|
turnId: turn.turnId,
|
|
4964
5172
|
...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
|
|
@@ -9329,6 +9537,7 @@ function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
|
|
|
9329
9537
|
if (dropped) {
|
|
9330
9538
|
if (dropped.contentFingerprint)
|
|
9331
9539
|
bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
|
|
9540
|
+
journalBridgeTurnClear(bridgeTurnId, dispatchAttempt);
|
|
9332
9541
|
log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
|
|
9333
9542
|
}
|
|
9334
9543
|
if (droppedStructured) {
|
|
@@ -11927,11 +12136,35 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11927
12136
|
if (effectiveBackendType === 'zmx') {
|
|
11928
12137
|
resolvedZmxSessionProbe = paneProbe;
|
|
11929
12138
|
}
|
|
11930
|
-
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
//
|
|
11934
|
-
|
|
12139
|
+
const paneRelayReattachSafe = paneProbe === 'exists'
|
|
12140
|
+
&& mcpGatewayPaneReattachSafe(readMcpGatewayLaunchRecord(config.session.dataDir, cfg.sessionId), sessionMcpGatewaySocketPath(cfg.sessionId, config.session.dataDir));
|
|
12141
|
+
if (paneRelayReattachSafe) {
|
|
12142
|
+
// The pane's CLI was launched against the deterministic Gateway socket
|
|
12143
|
+
// path by a reconnect-capable relay. Reattaching preserves whatever turn
|
|
12144
|
+
// the CLI is executing — the whole point of the persistent backend — but
|
|
12145
|
+
// the trusted host died with the previous worker, so we must RE-SERVE it
|
|
12146
|
+
// here at the same deterministic path for the pane's surviving relay to
|
|
12147
|
+
// reconnect to. This reattach path skips prepareCliPluginGenerationAndGateway
|
|
12148
|
+
// (it must not refresh the catalog on a warm reattach), which is the only
|
|
12149
|
+
// other host starter; without this the relay would reconnect forever to a
|
|
12150
|
+
// socket nothing binds. Start only when this fresh worker has no live host
|
|
12151
|
+
// yet — never stomp a host an in-worker restart already brought up.
|
|
12152
|
+
if (!sessionMcpGatewayHost) {
|
|
12153
|
+
await startAndRecordSessionMcpGatewayHost(cfg.sessionId);
|
|
12154
|
+
if (spawnGeneration !== cliSpawnGeneration)
|
|
12155
|
+
throw new CliSpawnSupersededError();
|
|
12156
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — re-served trusted host at the same path; relay reconnects on its own`);
|
|
12157
|
+
}
|
|
12158
|
+
else {
|
|
12159
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} keeps its relay socket path — reattaching; live host already bound, relay stays connected`);
|
|
12160
|
+
}
|
|
12161
|
+
}
|
|
12162
|
+
else if (paneProbe === 'exists') {
|
|
12163
|
+
// Legacy pane (mkdtemp-random socket path, or a relay predating
|
|
12164
|
+
// reconnect support): its MCP client can never reach the replacement
|
|
12165
|
+
// host. Cold-resume the CLI so it gets a fresh relay socket instead of
|
|
12166
|
+
// reattaching to a dead connection.
|
|
12167
|
+
log(`[mcp-gateway] persistent pane ${cfg.sessionId} has plugin MCP state without a reconnect-capable relay — cold-resuming with a fresh host`);
|
|
11935
12168
|
const persistentBackendType = effectiveBackendType;
|
|
11936
12169
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
11937
12170
|
if (effectiveBackendType === 'zmx') {
|
|
@@ -14719,7 +14952,22 @@ function startWebServer(host, preferredPort) {
|
|
|
14719
14952
|
return;
|
|
14720
14953
|
}
|
|
14721
14954
|
const loginHdr = req.headers['x-botmux-login-url'];
|
|
14722
|
-
|
|
14955
|
+
// The central front proxy only injects X-Botmux-Login-Url on the SPA 401
|
|
14956
|
+
// path, never on `/s/` terminal requests — so a read-only web terminal
|
|
14957
|
+
// showed an owner-login banner that was a dead <div> (no href). When the
|
|
14958
|
+
// header is absent, self-derive the platform owner-login URL for THIS
|
|
14959
|
+
// session's terminal path: `/open/<machineId>?next=/s/<sessionId>`. The
|
|
14960
|
+
// `/s/` next routes the platform to the terminal subdomain surface and,
|
|
14961
|
+
// after login, lands the browser back on this terminal WITH the host-only
|
|
14962
|
+
// proxy-session cookie the owner check requires (the cross-subdomain SSO
|
|
14963
|
+
// cookie alone does not make the request writable). Returns '' when this
|
|
14964
|
+
// machine is unbound or remote access is off, so the banner falls back to
|
|
14965
|
+
// its non-link text form rather than a broken link.
|
|
14966
|
+
const headerLoginUrl = typeof loginHdr === 'string' && /^https?:\/\/[^"'<>\s]+$/.test(loginHdr)
|
|
14967
|
+
? loginHdr
|
|
14968
|
+
: '';
|
|
14969
|
+
const loginUrl = headerLoginUrl
|
|
14970
|
+
|| (sessionId ? deriveTerminalLoginUrl(sessionId) : '');
|
|
14723
14971
|
// Herdr snapshots contain screen cells but not terminal mode state. On a
|
|
14724
14972
|
// refreshed page an alt-screen CLI would otherwise look like an empty
|
|
14725
14973
|
// normal buffer until resize causes a redraw. Preserve that mode so
|
|
@@ -17359,11 +17607,13 @@ process.on('message', async (raw) => {
|
|
|
17359
17607
|
catch { /* logged by backend */ }
|
|
17360
17608
|
}
|
|
17361
17609
|
killCli();
|
|
17362
|
-
// Bridge marker
|
|
17363
|
-
// restarts so a mid-flight send is still credited
|
|
17364
|
-
//
|
|
17365
|
-
// same sessionId starts
|
|
17610
|
+
// Bridge marker + turn-journal files outlive a single CLI process (kept
|
|
17611
|
+
// across restarts so a mid-flight send is still credited and an
|
|
17612
|
+
// interrupted turn can be restored), but a real close tears down the
|
|
17613
|
+
// session — purge both so a future re-use of the same sessionId starts
|
|
17614
|
+
// clean.
|
|
17366
17615
|
clearSendMarkers();
|
|
17616
|
+
clearBridgeTurnJournalFile();
|
|
17367
17617
|
cleanup();
|
|
17368
17618
|
process.exit(0);
|
|
17369
17619
|
}
|
|
@@ -17536,6 +17786,7 @@ process.on('message', async (raw) => {
|
|
|
17536
17786
|
stopCodexBridge();
|
|
17537
17787
|
killCli();
|
|
17538
17788
|
clearSendMarkers();
|
|
17789
|
+
clearBridgeTurnJournalFile();
|
|
17539
17790
|
cleanup();
|
|
17540
17791
|
process.exit(0);
|
|
17541
17792
|
}
|