mixdog 0.9.95 → 0.9.97
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/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/MIT.txt +56 -0
- package/LICENSES/codex-NOTICE.txt +6 -0
- package/NOTICE.md +46 -9
- package/README.md +0 -17
- package/package.json +16 -12
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/defaults/skills/setup/SKILL.md +1 -1
- package/src/lib/keychain-cjs.cjs +1 -1
- package/src/lib/mixdog-debug.cjs +2 -3
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-tool.md +0 -3
- package/src/rules/shared/01-tool.md +21 -29
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
- package/src/runtime/agent/orchestrator/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
- package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
- package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
- package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
- package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/lib/config.mjs +34 -34
- package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
- package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
- package/src/runtime/channels/lib/memory-client.mjs +31 -157
- package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
- package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
- package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
- package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
- package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
- package/src/runtime/channels/lib/scheduler.mjs +2 -2
- package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
- package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
- package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
- package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
- package/src/runtime/channels/lib/worker-main.mjs +38 -38
- package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
- package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +76 -248
- package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
- package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
- package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
- package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
- package/src/runtime/memory/lib/http-router.mjs +9 -6
- package/src/runtime/memory/lib/ko-morph.mjs +3 -20
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
- package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
- package/src/runtime/memory/lib/memory-log.mjs +1 -1
- package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/memory/tool-defs.mjs +1 -5
- package/src/runtime/search/index.mjs +2 -2
- package/src/runtime/search/tool-defs.mjs +2 -2
- package/src/runtime/shared/abort-race.mjs +70 -0
- package/src/runtime/shared/agent-route-config.mjs +119 -0
- package/src/runtime/shared/atomic-file.mjs +80 -4
- package/src/runtime/shared/channel-notification-routing.mjs +2 -2
- package/src/runtime/shared/child-guardian.mjs +204 -108
- package/src/runtime/shared/child-spawn-gate.mjs +139 -72
- package/src/runtime/shared/config.mjs +120 -47
- package/src/runtime/shared/llm/index.mjs +1 -1
- package/src/runtime/shared/owner-fair-gate.mjs +134 -0
- package/src/runtime/shared/resource-admission.mjs +150 -100
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-execution-owner.mjs +12 -0
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +91 -27
- package/src/runtime/shared/tool-workload-gates.mjs +29 -0
- package/src/runtime/shared/turn-snapshot.mjs +47 -6
- package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
- package/src/session-runtime/channel-config-api.mjs +5 -5
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/config-lifecycle.mjs +27 -27
- package/src/session-runtime/lifecycle-api.mjs +22 -3
- package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/prewarm.mjs +1 -1
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/provider-models.mjs +1 -1
- package/src/session-runtime/remote-control.mjs +6 -7
- package/src/session-runtime/runtime-core.mjs +63 -39
- package/src/session-runtime/runtime-tunables.mjs +4 -4
- package/src/session-runtime/self-update.mjs +1 -1
- package/src/session-runtime/session-lifecycle.mjs +45 -17
- package/src/session-runtime/session-title.mjs +1 -1
- package/src/session-runtime/session-turn-api.mjs +96 -24
- package/src/session-runtime/settings-api.mjs +12 -24
- package/src/session-runtime/tool-defs.mjs +3 -3
- package/src/session-runtime/workflow-agents-api.mjs +55 -81
- package/src/session-runtime/workflow.mjs +14 -24
- package/src/standalone/agent-dispatch-broker.mjs +198 -0
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
- package/src/standalone/agent-tool/tool-def.mjs +0 -14
- package/src/standalone/channel-admin.mjs +53 -64
- package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
- package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
- package/src/standalone/channel-worker.mjs +124 -647
- package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/memory-runtime-proxy.mjs +58 -1
- package/src/standalone/session-client.mjs +1134 -0
- package/src/standalone/session-protocol.mjs +192 -0
- package/src/standalone/session-runtime-pool.mjs +393 -0
- package/src/standalone/session-runtime-record.mjs +20 -0
- package/src/standalone/session-runtime-worker.mjs +203 -0
- package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
- package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
- package/src/standalone/session-wire.mjs +55 -0
- package/src/tui/App.jsx +34 -21
- package/src/tui/app/app-format.mjs +1 -1
- package/src/tui/app/app-view.jsx +5 -2
- package/src/tui/app/channel-pickers.mjs +41 -41
- package/src/tui/app/core-memory-picker.mjs +19 -19
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/input-parsers.mjs +2 -2
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +3 -4
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +48 -43
- package/src/tui/app/resume-picker.mjs +2 -2
- package/src/tui/app/route-pickers.mjs +3 -1
- package/src/tui/app/settings-picker.mjs +31 -33
- package/src/tui/app/transcript-window.mjs +38 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
- package/src/tui/app/use-prompt-handlers.mjs +86 -21
- package/src/tui/app/use-prompt-hint.mjs +3 -2
- package/src/tui/app/use-prompt-queue-history.mjs +10 -3
- package/src/tui/app/use-transcript-scroll.mjs +10 -3
- package/src/tui/app/use-transcript-window.mjs +50 -17
- package/src/tui/components/PromptInput.jsx +71 -38
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/components/ToolExecution.jsx +1 -1
- package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
- package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
- package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
- package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
- package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
- package/src/tui/dist/index.mjs +1031 -1676
- package/src/tui/hooks/useSession.mjs +16 -0
- package/src/tui/index.jsx +12 -13
- package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
- package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
- package/src/tui/{engine → session}/boot-profile.mjs +2 -2
- package/src/tui/{engine → session}/context-state.mjs +3 -3
- package/src/tui/{engine → session}/labels.mjs +2 -2
- package/src/tui/{engine → session}/live-share.mjs +38 -4
- package/src/tui/{engine → session}/notice-text.mjs +2 -2
- package/src/tui/{engine → session}/notification-plan.mjs +3 -3
- package/src/tui/session/oauth-flows.mjs +140 -0
- package/src/tui/{engine → session}/prompt-history.mjs +3 -3
- package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
- package/src/tui/{engine → session}/render-timing.mjs +2 -2
- package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
- package/src/tui/{engine → session}/session-api.mjs +110 -93
- package/src/tui/{engine → session}/session-flow.mjs +8 -2
- package/src/tui/{engine → session}/session-stats.mjs +3 -3
- package/src/tui/{engine → session}/tool-approval.mjs +3 -3
- package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
- package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
- package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
- package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
- package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
- package/src/tui/{engine → session}/turn.mjs +38 -176
- package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
- package/src/tui/session.mjs +22 -0
- package/src/tui/spinner-meta.mjs +4 -4
- package/src/tui/spinner-verbs.mjs +51 -3
- package/src/ui/statusline-segments.mjs +1 -1
- package/src/ui/statusline.mjs +1 -1
- package/vendor/ink/build/components/App.js +10 -1
- package/src/session-runtime/memory-daemon-probe.mjs +0 -61
- package/src/standalone/engine-daemon-client.mjs +0 -894
- package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
- package/src/standalone/engine-daemon-protocol.mjs +0 -33
- package/src/tui/engine/turn-watchdog.mjs +0 -92
- package/src/tui/engine.mjs +0 -22
- package/src/tui/hooks/useEngine.mjs +0 -16
- /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
- /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
- /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
- /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
- /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
|
@@ -1,34 +1,49 @@
|
|
|
1
|
-
// Machine-global
|
|
1
|
+
// Machine-global session HTTP + SSE transport (server side).
|
|
2
2
|
//
|
|
3
|
-
// Sibling of channel-
|
|
3
|
+
// Sibling of channel-transport.mjs, deliberately separate: the channels
|
|
4
4
|
// transport routes notifies to ONE pointer client (remote ownership), while
|
|
5
|
-
//
|
|
5
|
+
// session frames are shared state — every attached client (terminal TUI and the
|
|
6
6
|
// desktop app at the same time) must observe the same snapshot stream. Mixing
|
|
7
7
|
// the two routing rules into one server would put the pointer semantics on a
|
|
8
8
|
// path that must never target a single client.
|
|
9
9
|
//
|
|
10
10
|
// This module owns ONLY the transport (sockets, client registry, frame fan-out,
|
|
11
|
-
//
|
|
11
|
+
// lifecycle). The session pool is injected via `handleCall`, so
|
|
12
12
|
// the same transport is exercised by the real daemon entry AND by the smoke
|
|
13
|
-
// harness with a stub
|
|
13
|
+
// harness with a stub session runtime (no provider, no model download).
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
-
import { rmSync } from 'node:fs';
|
|
17
|
-
import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
18
16
|
import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
|
|
19
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
compareRuntimeVersions,
|
|
19
|
+
SESSION_CAPABILITY_FINGERPRINT,
|
|
20
|
+
SESSION_PROTOCOL,
|
|
21
|
+
SESSION_REVISION,
|
|
22
|
+
runtimeVersion,
|
|
23
|
+
} from './session-wire.mjs';
|
|
24
|
+
import { createFairCallScheduler } from './fair-call-scheduler.mjs';
|
|
20
25
|
|
|
21
26
|
// A loopback front door still buffers whatever a client sends before it can be
|
|
22
27
|
// parsed, so the body has an explicit ceiling instead of the client's memory.
|
|
23
|
-
const MAX_BODY_BYTES = Math.max(1, Number(process.env.
|
|
28
|
+
const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_SESSION_MAX_BODY_MB) || 32)
|
|
24
29
|
* 1024 * 1024;
|
|
25
30
|
|
|
26
|
-
function readLimitedBody(req
|
|
31
|
+
function readLimitedBody(req, {
|
|
32
|
+
reserve = () => true,
|
|
33
|
+
release = () => {},
|
|
34
|
+
} = {}) {
|
|
27
35
|
return new Promise((resolve, reject) => {
|
|
28
36
|
let settled = false;
|
|
37
|
+
let reserved = 0;
|
|
38
|
+
const releaseReserved = () => {
|
|
39
|
+
if (reserved <= 0) return;
|
|
40
|
+
release(reserved);
|
|
41
|
+
reserved = 0;
|
|
42
|
+
};
|
|
29
43
|
const fail = (message, statusCode) => {
|
|
30
44
|
if (settled) return;
|
|
31
45
|
settled = true;
|
|
46
|
+
releaseReserved();
|
|
32
47
|
const error = new Error(message);
|
|
33
48
|
error.statusCode = statusCode;
|
|
34
49
|
try { req.destroy(); } catch {}
|
|
@@ -45,21 +60,32 @@ function readLimitedBody(req) {
|
|
|
45
60
|
if (settled) return;
|
|
46
61
|
size += chunk.length;
|
|
47
62
|
if (size > MAX_BODY_BYTES) { fail('request body too large', 413); return; }
|
|
63
|
+
if (!reserve(chunk.length)) { fail('daemon request memory budget is busy', 503); return; }
|
|
64
|
+
reserved += chunk.length;
|
|
48
65
|
chunks.push(chunk);
|
|
49
66
|
});
|
|
50
67
|
req.on('end', () => {
|
|
51
68
|
if (settled) return;
|
|
52
69
|
settled = true;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
try {
|
|
71
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
72
|
+
if (!raw) { resolve({}); return; }
|
|
73
|
+
resolve(JSON.parse(raw));
|
|
74
|
+
}
|
|
56
75
|
catch (error) {
|
|
57
76
|
const err = new Error(`invalid JSON body: ${error.message}`);
|
|
58
77
|
err.statusCode = 400;
|
|
59
78
|
reject(err);
|
|
60
79
|
}
|
|
80
|
+
finally { releaseReserved(); }
|
|
81
|
+
});
|
|
82
|
+
req.on('error', (error) => {
|
|
83
|
+
if (!settled) {
|
|
84
|
+
settled = true;
|
|
85
|
+
releaseReserved();
|
|
86
|
+
reject(error);
|
|
87
|
+
}
|
|
61
88
|
});
|
|
62
|
-
req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
|
|
63
89
|
});
|
|
64
90
|
}
|
|
65
91
|
|
|
@@ -89,10 +115,15 @@ function isPidAlive(pid) {
|
|
|
89
115
|
// terminal frames therefore concatenate, bounded so a wedged reader can never
|
|
90
116
|
// grow the daemon's heap without limit.
|
|
91
117
|
const TERMINAL_BACKLOG_MAX_CHARS = 256 * 1024;
|
|
118
|
+
const SSE_PENDING_MAX_BYTES = Math.max(
|
|
119
|
+
256 * 1024,
|
|
120
|
+
(Number(process.env.MIXDOG_SESSION_SSE_PENDING_MB) || 8)
|
|
121
|
+
* 1024 * 1024,
|
|
122
|
+
);
|
|
92
123
|
|
|
93
124
|
function terminalDataFrame(frame) {
|
|
94
125
|
return frame?.type === 'desktop-event'
|
|
95
|
-
&& frame.message?.kind === '
|
|
126
|
+
&& frame.message?.kind === 'desktop-event'
|
|
96
127
|
&& frame.message?.name === 'terminal-data'
|
|
97
128
|
&& typeof frame.message?.value?.data === 'string'
|
|
98
129
|
? frame
|
|
@@ -114,9 +145,8 @@ function mergePendingFrame(existing, frame, json) {
|
|
|
114
145
|
return { frame: merged, json: JSON.stringify(merged) };
|
|
115
146
|
}
|
|
116
147
|
|
|
117
|
-
export function
|
|
148
|
+
export function createSessionTransport({
|
|
118
149
|
handleCall,
|
|
119
|
-
discoveryPath,
|
|
120
150
|
serverToken = randomUUID(),
|
|
121
151
|
log = () => {},
|
|
122
152
|
clientGraceMs = 10_000,
|
|
@@ -124,6 +154,7 @@ export function createEngineDaemonTransport({
|
|
|
124
154
|
onClientsEmpty = null,
|
|
125
155
|
onClientRegistered = null,
|
|
126
156
|
onClientDropped = null,
|
|
157
|
+
onUpgradeRequested = null,
|
|
127
158
|
getStatus = () => ({}),
|
|
128
159
|
} = {}) {
|
|
129
160
|
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
@@ -136,23 +167,112 @@ export function createEngineDaemonTransport({
|
|
|
136
167
|
let sweepTimer = null;
|
|
137
168
|
let everHadLifecycleClient = false;
|
|
138
169
|
let closed = false;
|
|
170
|
+
let drainingReason = '';
|
|
171
|
+
const BODY_INFLIGHT_MAX_BYTES = Math.max(
|
|
172
|
+
MAX_BODY_BYTES,
|
|
173
|
+
(Number(process.env.MIXDOG_SESSION_BODY_INFLIGHT_MB) || 64)
|
|
174
|
+
* 1024 * 1024,
|
|
175
|
+
);
|
|
176
|
+
let bodyBytesInFlight = 0;
|
|
177
|
+
const readBody = (req) => readLimitedBody(req, {
|
|
178
|
+
reserve(bytes) {
|
|
179
|
+
if (bodyBytesInFlight + bytes > BODY_INFLIGHT_MAX_BYTES) return false;
|
|
180
|
+
bodyBytesInFlight += bytes;
|
|
181
|
+
return true;
|
|
182
|
+
},
|
|
183
|
+
release(bytes) {
|
|
184
|
+
bodyBytesInFlight = Math.max(0, bodyBytesInFlight - bytes);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
139
187
|
// Idempotency cache: a transport retry of the SAME callId must never run a
|
|
140
|
-
// second
|
|
188
|
+
// second session mutation (submit/abort are not idempotent).
|
|
141
189
|
const callCache = new Map();
|
|
190
|
+
let callCacheBytes = 0;
|
|
191
|
+
// These routes are safe to replay and can return large snapshots. Keeping
|
|
192
|
+
// them in the mutation-dedup cache retained multiple transcript copies for
|
|
193
|
+
// a full minute under review polling and pane reconciliation.
|
|
194
|
+
const REPLAY_SAFE_CALLS = new Set([
|
|
195
|
+
'project.list',
|
|
196
|
+
'project.inspect',
|
|
197
|
+
'session.list',
|
|
198
|
+
'session.read',
|
|
199
|
+
'session.subscribe',
|
|
200
|
+
'session.unsubscribe',
|
|
201
|
+
]);
|
|
142
202
|
const CALL_CACHE_TTL_MS = 60_000;
|
|
143
203
|
// Bound the dedup table: a call that never settles would otherwise pin its
|
|
144
204
|
// entry for the daemon's whole life.
|
|
145
|
-
const CALL_CACHE_MAX = Math.max(512, Number(process.env.
|
|
205
|
+
const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_SESSION_CALL_CACHE) || 4096);
|
|
206
|
+
const CALL_CACHE_MAX_BYTES = Math.max(
|
|
207
|
+
1024 * 1024,
|
|
208
|
+
(Number(process.env.MIXDOG_SESSION_CALL_CACHE_MB) || 8)
|
|
209
|
+
* 1024 * 1024,
|
|
210
|
+
);
|
|
146
211
|
// A burst of panes used to enqueue every handleCall() as a microtask. Node
|
|
147
212
|
// drains the whole microtask queue before returning to HTTP, so enough cheap
|
|
148
213
|
// calls could starve /health and /client/register even though no explicit
|
|
149
214
|
// mutex existed. Admit one call per event-loop turn while allowing admitted
|
|
150
215
|
// async calls to overlap; control-plane routes never enter this queue.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
// No default concurrency gate: independent session work starts in parallel.
|
|
217
|
+
// The scheduler still launches one item per check phase so synchronous setup
|
|
218
|
+
// cannot monopolize the HTTP/control-plane turn. Operators may explicitly
|
|
219
|
+
// set finite lane limits for constrained hosts.
|
|
220
|
+
const configuredLaneLimit = (name) => {
|
|
221
|
+
const parsed = Math.floor(Number(process.env[name]));
|
|
222
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
|
|
223
|
+
};
|
|
224
|
+
const CALL_QUEUE_MAX = Math.max(1024, Number(process.env.MIXDOG_SESSION_CALL_QUEUE) || 1024);
|
|
225
|
+
const CRITICAL_CALLS = new Set([
|
|
226
|
+
'session.abort',
|
|
227
|
+
'session.approve',
|
|
228
|
+
'session.unsubscribe',
|
|
229
|
+
'desktop.control',
|
|
230
|
+
'desktop.unsubscribe',
|
|
231
|
+
]);
|
|
232
|
+
const INTERACTIVE_CALLS = new Set([
|
|
233
|
+
'session.create',
|
|
234
|
+
'session.read',
|
|
235
|
+
'session.subscribe',
|
|
236
|
+
'session.submit',
|
|
237
|
+
'session.configure',
|
|
238
|
+
'project.list',
|
|
239
|
+
'project.inspect',
|
|
240
|
+
'project.add',
|
|
241
|
+
'project.touch',
|
|
242
|
+
'project.rename',
|
|
243
|
+
'project.remove',
|
|
244
|
+
'project.ensureDirectory',
|
|
245
|
+
'desktop.init',
|
|
246
|
+
]);
|
|
247
|
+
const INTERACTIVE_DESKTOP_METHODS = new Set([
|
|
248
|
+
'termEnsure',
|
|
249
|
+
'termWrite',
|
|
250
|
+
'termResize',
|
|
251
|
+
'termDispose',
|
|
252
|
+
'termProfiles',
|
|
253
|
+
]);
|
|
254
|
+
const normalCalls = createFairCallScheduler({
|
|
255
|
+
name: 'session service call',
|
|
256
|
+
activeMax: configuredLaneLimit('MIXDOG_SESSION_ACTIVE_CALLS'),
|
|
257
|
+
queueMax: CALL_QUEUE_MAX,
|
|
258
|
+
minOwnerQueue: Math.max(8, Math.floor(CALL_QUEUE_MAX / 16)),
|
|
259
|
+
dispatchBurst: 1,
|
|
260
|
+
yieldUnbounded: true,
|
|
261
|
+
});
|
|
262
|
+
const criticalCalls = createFairCallScheduler({
|
|
263
|
+
name: 'session service critical call',
|
|
264
|
+
activeMax: configuredLaneLimit('MIXDOG_SESSION_URGENT_RESERVE'),
|
|
265
|
+
queueMax: Math.max(32, Math.min(256, CALL_QUEUE_MAX)),
|
|
266
|
+
minOwnerQueue: 8,
|
|
267
|
+
});
|
|
268
|
+
const interactiveCalls = createFairCallScheduler({
|
|
269
|
+
name: 'session service interactive call',
|
|
270
|
+
activeMax: configuredLaneLimit('MIXDOG_SESSION_INTERACTIVE_RESERVE'),
|
|
271
|
+
queueMax: Math.max(64, Math.min(512, CALL_QUEUE_MAX)),
|
|
272
|
+
minOwnerQueue: 8,
|
|
273
|
+
dispatchBurst: 1,
|
|
274
|
+
yieldUnbounded: true,
|
|
275
|
+
});
|
|
156
276
|
// A timed-out registration may already have committed server-side. Replaying
|
|
157
277
|
// the same registrationId returns that token instead of leaking a second
|
|
158
278
|
// client record and another lifecycle reference.
|
|
@@ -160,58 +280,65 @@ export function createEngineDaemonTransport({
|
|
|
160
280
|
const REGISTRATION_REPLAY_TTL_MS = 30_000;
|
|
161
281
|
const REGISTRATION_REPLAY_MAX = 256;
|
|
162
282
|
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
283
|
+
function deleteCallCacheEntry(key, record = callCache.get(key)) {
|
|
284
|
+
if (!record || callCache.get(key) !== record) return false;
|
|
285
|
+
callCache.delete(key);
|
|
286
|
+
callCacheBytes = Math.max(0, callCacheBytes - (record.bytes || 0));
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function estimateRetainedBytes(value, limit = CALL_CACHE_MAX_BYTES + 1, seen = new Set()) {
|
|
291
|
+
if (typeof value === 'string') return Math.min(limit, value.length * 2 + 16);
|
|
292
|
+
if (typeof value === 'number' || typeof value === 'bigint') return 8;
|
|
293
|
+
if (typeof value === 'boolean' || value == null) return 4;
|
|
294
|
+
if (typeof value !== 'object' || seen.has(value)) return 0;
|
|
295
|
+
seen.add(value);
|
|
296
|
+
let bytes = Array.isArray(value) ? 32 : 64;
|
|
297
|
+
const entries = Array.isArray(value) ? value : Object.entries(value);
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (Array.isArray(value)) bytes += 8 + estimateRetainedBytes(entry, limit - bytes, seen);
|
|
300
|
+
else bytes += String(entry[0]).length * 2 + 16
|
|
301
|
+
+ estimateRetainedBytes(entry[1], limit - bytes, seen);
|
|
302
|
+
if (bytes >= limit) break;
|
|
168
303
|
}
|
|
304
|
+
seen.delete(value);
|
|
305
|
+
return Math.min(limit, bytes);
|
|
169
306
|
}
|
|
170
307
|
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return;
|
|
308
|
+
function pruneCallCache() {
|
|
309
|
+
while (callCache.size > CALL_CACHE_MAX || callCacheBytes > CALL_CACHE_MAX_BYTES) {
|
|
310
|
+
let removed = false;
|
|
311
|
+
for (const [key, record] of callCache) {
|
|
312
|
+
// Never lose the dedup identity of an in-flight mutation.
|
|
313
|
+
if (!record.settled) continue;
|
|
314
|
+
deleteCallCacheEntry(key, record);
|
|
315
|
+
removed = true;
|
|
316
|
+
break;
|
|
181
317
|
}
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
if (!queued) return;
|
|
185
|
-
activeCalls += 1;
|
|
186
|
-
Promise.resolve()
|
|
187
|
-
.then(queued.run)
|
|
188
|
-
.then(queued.resolve, queued.reject)
|
|
189
|
-
.finally(() => {
|
|
190
|
-
activeCalls = Math.max(0, activeCalls - 1);
|
|
191
|
-
scheduleCallDispatch();
|
|
192
|
-
});
|
|
193
|
-
// Start at most ONE handler in this check phase. A fresh immediate gives
|
|
194
|
-
// socket I/O (especially health/register) a poll phase between starts.
|
|
195
|
-
scheduleCallDispatch();
|
|
196
|
-
});
|
|
318
|
+
if (!removed) return;
|
|
319
|
+
}
|
|
197
320
|
}
|
|
198
321
|
|
|
199
|
-
function
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return Promise.reject(error);
|
|
322
|
+
function callLane(name, args = {}) {
|
|
323
|
+
if (CRITICAL_CALLS.has(name)) return 'critical';
|
|
324
|
+
if (INTERACTIVE_CALLS.has(name)) return 'interactive';
|
|
325
|
+
if (name === 'desktop.invoke') {
|
|
326
|
+
const adapterMethod = String(args?.method || '');
|
|
327
|
+
const desktopMethod = adapterMethod === 'invokeDesktopOperation'
|
|
328
|
+
? String(args?.args?.[0] || '')
|
|
329
|
+
: adapterMethod;
|
|
330
|
+
if (INTERACTIVE_DESKTOP_METHODS.has(desktopMethod)) return 'interactive';
|
|
209
331
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
332
|
+
return 'normal';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function dispatchCall(ownerKey, run, { lane = 'normal', signal = null } = {}) {
|
|
336
|
+
const scheduler = lane === 'critical'
|
|
337
|
+
? criticalCalls
|
|
338
|
+
: lane === 'interactive'
|
|
339
|
+
? interactiveCalls
|
|
340
|
+
: normalCalls;
|
|
341
|
+
return scheduler.enqueue(ownerKey, run, { signal });
|
|
215
342
|
}
|
|
216
343
|
|
|
217
344
|
function nowMs() { return Date.now(); }
|
|
@@ -233,7 +360,7 @@ export function createEngineDaemonTransport({
|
|
|
233
360
|
if (lifecycleClientCount() > 0) return;
|
|
234
361
|
// Never re-arm an ALREADY armed grace: the 5s sweep also calls this, and
|
|
235
362
|
// cancel+rearm on every tick pushed the 10s deadline out forever — the
|
|
236
|
-
// daemon could never self-shut down through the
|
|
363
|
+
// daemon could never self-shut down through the session front door.
|
|
237
364
|
if (graceTimer) return;
|
|
238
365
|
graceTimer = setTimeout(() => {
|
|
239
366
|
graceTimer = null;
|
|
@@ -260,9 +387,9 @@ export function createEngineDaemonTransport({
|
|
|
260
387
|
function dropClient(token, reason) {
|
|
261
388
|
if (!clients.has(token)) return;
|
|
262
389
|
removeClientRecord(token);
|
|
263
|
-
// The
|
|
264
|
-
// must stop holding
|
|
265
|
-
// reason another client's
|
|
390
|
+
// The session runtime pool refcounts VIEWS by client token: a client that is gone
|
|
391
|
+
// must stop holding session runtimes open (and must stop being counted as the
|
|
392
|
+
// reason another client's session runtime survives).
|
|
266
393
|
if (typeof onClientDropped === 'function') {
|
|
267
394
|
try { onClientDropped(token, reason); } catch {}
|
|
268
395
|
}
|
|
@@ -270,6 +397,14 @@ export function createEngineDaemonTransport({
|
|
|
270
397
|
maybeArmGrace(reason);
|
|
271
398
|
}
|
|
272
399
|
|
|
400
|
+
function beginDrain(reason = 'daemon replacement') {
|
|
401
|
+
if (drainingReason) return false;
|
|
402
|
+
drainingReason = String(reason || 'daemon replacement');
|
|
403
|
+
cancelGrace();
|
|
404
|
+
for (const token of [...clients.keys()]) dropClient(token, drainingReason);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
273
408
|
function startSweep() {
|
|
274
409
|
if (sweepTimer || closed) return;
|
|
275
410
|
sweepTimer = setInterval(() => {
|
|
@@ -282,7 +417,7 @@ export function createEngineDaemonTransport({
|
|
|
282
417
|
sweepTimer.unref?.();
|
|
283
418
|
}
|
|
284
419
|
|
|
285
|
-
function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null } = {}) {
|
|
420
|
+
function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null, revision = 0 } = {}) {
|
|
286
421
|
const pid = parsePid(leadPid) || 0;
|
|
287
422
|
const ownsLifecycle = lifecycle !== false;
|
|
288
423
|
const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
|
|
@@ -290,7 +425,8 @@ export function createEngineDaemonTransport({
|
|
|
290
425
|
if (replay) {
|
|
291
426
|
const sameIdentity = replay.leadPid === pid
|
|
292
427
|
&& replay.cwd === (cwd || null)
|
|
293
|
-
&& replay.lifecycle === ownsLifecycle
|
|
428
|
+
&& replay.lifecycle === ownsLifecycle
|
|
429
|
+
&& replay.revision === Math.max(0, Number(revision) || 0);
|
|
294
430
|
if (sameIdentity && clients.has(replay.token)) {
|
|
295
431
|
log(`client registration replay token=${replay.token} lead=${pid}`);
|
|
296
432
|
return replay.token;
|
|
@@ -309,12 +445,14 @@ export function createEngineDaemonTransport({
|
|
|
309
445
|
leadPid: pid,
|
|
310
446
|
cwd: cwd || null,
|
|
311
447
|
lifecycle: ownsLifecycle,
|
|
448
|
+
revision: Math.max(0, Number(revision) || 0),
|
|
312
449
|
sse: null,
|
|
313
450
|
// True while the socket asked us to stop writing (see writeFrame).
|
|
314
451
|
paused: false,
|
|
315
452
|
// Frames are latest-wins per key while a client has no stream: a
|
|
316
453
|
// reconnecting viewer wants the CURRENT snapshot, never a backlog.
|
|
317
454
|
pending: new Map(),
|
|
455
|
+
pendingBytes: 0,
|
|
318
456
|
lastSeen: nowMs(),
|
|
319
457
|
});
|
|
320
458
|
if (ownsLifecycle) {
|
|
@@ -341,6 +479,7 @@ export function createEngineDaemonTransport({
|
|
|
341
479
|
leadPid: pid,
|
|
342
480
|
cwd: cwd || null,
|
|
343
481
|
lifecycle: ownsLifecycle,
|
|
482
|
+
revision: Math.max(0, Number(revision) || 0),
|
|
344
483
|
timer,
|
|
345
484
|
});
|
|
346
485
|
}
|
|
@@ -352,36 +491,76 @@ export function createEngineDaemonTransport({
|
|
|
352
491
|
while (client.sse && !client.paused && client.pending.size > 0) {
|
|
353
492
|
const [key, entry] = client.pending.entries().next().value;
|
|
354
493
|
client.pending.delete(key);
|
|
494
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
|
|
355
495
|
try {
|
|
356
496
|
if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
|
|
357
497
|
} catch {
|
|
358
498
|
client.sse = null;
|
|
359
499
|
client.pending.set(key, entry);
|
|
500
|
+
client.pendingBytes += entry.bytes || 0;
|
|
360
501
|
return;
|
|
361
502
|
}
|
|
362
503
|
}
|
|
363
504
|
}
|
|
364
505
|
|
|
506
|
+
function pendingEntry(frame, json) {
|
|
507
|
+
return { frame, json, bytes: Buffer.byteLength(json) + 8 };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function resyncEntry(frame) {
|
|
511
|
+
if (frame?.type !== 'session-state' || !frame.sessionId) return null;
|
|
512
|
+
const marker = {
|
|
513
|
+
type: 'session-state',
|
|
514
|
+
key: frame.key,
|
|
515
|
+
sessionId: frame.sessionId,
|
|
516
|
+
revision: -1,
|
|
517
|
+
baseRevision: -2,
|
|
518
|
+
patch: { set: {}, remove: [], itemsAppend: null },
|
|
519
|
+
resyncRequired: true,
|
|
520
|
+
};
|
|
521
|
+
return pendingEntry(marker, JSON.stringify(marker));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function setPending(client, key, entry) {
|
|
525
|
+
const previous = client.pending.get(key);
|
|
526
|
+
if (previous) client.pendingBytes = Math.max(0, client.pendingBytes - (previous.bytes || 0));
|
|
527
|
+
client.pending.delete(key);
|
|
528
|
+
client.pending.set(key, entry);
|
|
529
|
+
client.pendingBytes += entry.bytes || 0;
|
|
530
|
+
while (client.pendingBytes > SSE_PENDING_MAX_BYTES && client.pending.size > 0) {
|
|
531
|
+
const [oldestKey, oldest] = client.pending.entries().next().value;
|
|
532
|
+
const marker = resyncEntry(oldest.frame);
|
|
533
|
+
if (marker && marker.bytes < oldest.bytes) {
|
|
534
|
+
client.pending.set(oldestKey, marker);
|
|
535
|
+
client.pendingBytes += marker.bytes - oldest.bytes;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
client.pending.delete(oldestKey);
|
|
539
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (oldest.bytes || 0));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
365
543
|
function writeFrame(client, frame, json) {
|
|
366
544
|
const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
|
|
367
545
|
// A stalled reader must never make the daemon buffer a whole stream in
|
|
368
546
|
// Node's socket queue: while the socket is full the backlog collapses to
|
|
369
547
|
// one frame per key, exactly like a client with no stream at all.
|
|
370
548
|
if (!client.sse || client.paused) {
|
|
371
|
-
|
|
549
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
550
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
372
551
|
return;
|
|
373
552
|
}
|
|
374
553
|
try {
|
|
375
554
|
if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
|
|
376
555
|
} catch {
|
|
377
556
|
client.sse = null;
|
|
378
|
-
|
|
557
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
558
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
379
559
|
}
|
|
380
560
|
}
|
|
381
561
|
|
|
382
562
|
/** Session and desktop state reaches only subscribed client tokens. Calls
|
|
383
|
-
* without a target set retain the transport-level broadcast
|
|
384
|
-
* diagnostics and compatibility tests. */
|
|
563
|
+
* without a target set retain the transport-level diagnostic broadcast. */
|
|
385
564
|
function broadcast(frame, targetTokens = null) {
|
|
386
565
|
const json = JSON.stringify(frame);
|
|
387
566
|
if (targetTokens) {
|
|
@@ -432,7 +611,7 @@ export function createEngineDaemonTransport({
|
|
|
432
611
|
clearInterval(ka);
|
|
433
612
|
if (c.sse === res) { c.sse = null; c.paused = false; }
|
|
434
613
|
// Stream loss alone never drops the client: a desktop reload or a TUI
|
|
435
|
-
// resize can bounce the stream while the
|
|
614
|
+
// resize can bounce the stream while the session runtime keeps running.
|
|
436
615
|
maybeArmGrace('sse closed');
|
|
437
616
|
};
|
|
438
617
|
res.on('close', cleanup);
|
|
@@ -453,11 +632,31 @@ export function createEngineDaemonTransport({
|
|
|
453
632
|
pid: process.pid,
|
|
454
633
|
clients: lifecycleClientCount(),
|
|
455
634
|
connections: clients.size,
|
|
456
|
-
protocol:
|
|
457
|
-
|
|
635
|
+
protocol: SESSION_PROTOCOL,
|
|
636
|
+
revision: SESSION_REVISION,
|
|
637
|
+
capabilityFingerprint: SESSION_CAPABILITY_FINGERPRINT,
|
|
638
|
+
draining: drainingReason || null,
|
|
639
|
+
version: runtimeVersion(),
|
|
458
640
|
...getStatus(),
|
|
459
|
-
activeCalls,
|
|
460
|
-
queuedCalls:
|
|
641
|
+
activeCalls: normalCalls.active + interactiveCalls.active + criticalCalls.active,
|
|
642
|
+
queuedCalls: normalCalls.queued + interactiveCalls.queued + criticalCalls.queued,
|
|
643
|
+
queuedUrgentCalls: criticalCalls.queued,
|
|
644
|
+
queuedInteractiveCalls: interactiveCalls.queued,
|
|
645
|
+
callOwners: normalCalls.snapshot().owners,
|
|
646
|
+
callQueues: {
|
|
647
|
+
critical: criticalCalls.snapshot(),
|
|
648
|
+
interactive: interactiveCalls.snapshot(),
|
|
649
|
+
normal: normalCalls.snapshot(),
|
|
650
|
+
},
|
|
651
|
+
transportMemory: {
|
|
652
|
+
bodyBytesInFlight,
|
|
653
|
+
bodyBytesMax: BODY_INFLIGHT_MAX_BYTES,
|
|
654
|
+
callCacheEntries: callCache.size,
|
|
655
|
+
callCacheBytes,
|
|
656
|
+
callCacheMaxBytes: CALL_CACHE_MAX_BYTES,
|
|
657
|
+
ssePendingBytes: [...clients.values()]
|
|
658
|
+
.reduce((sum, client) => sum + (client.pendingBytes || 0), 0),
|
|
659
|
+
},
|
|
461
660
|
});
|
|
462
661
|
return;
|
|
463
662
|
}
|
|
@@ -465,18 +664,33 @@ export function createEngineDaemonTransport({
|
|
|
465
664
|
if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
|
|
466
665
|
|
|
467
666
|
if (req.method === 'POST' && pathName === '/client/register') {
|
|
468
|
-
const body = await
|
|
667
|
+
const body = await readBody(req);
|
|
668
|
+
if (drainingReason) {
|
|
669
|
+
sendError(res, `daemon is draining: ${drainingReason}`, 503);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const clientProtocol = Number(body.protocol);
|
|
673
|
+
if (clientProtocol !== SESSION_PROTOCOL) {
|
|
674
|
+
sendError(res, `session protocol ${SESSION_PROTOCOL} required`, 409);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
469
677
|
const clientToken = registerClient({
|
|
470
678
|
leadPid: body.leadPid,
|
|
471
679
|
cwd: body.cwd,
|
|
472
680
|
lifecycle: body.lifecycle !== false,
|
|
473
681
|
registrationId: body.registrationId,
|
|
682
|
+
revision: body.revision,
|
|
683
|
+
});
|
|
684
|
+
sendJson(res, {
|
|
685
|
+
token: clientToken,
|
|
686
|
+
pid: process.pid,
|
|
687
|
+
protocol: SESSION_PROTOCOL,
|
|
688
|
+
revision: SESSION_REVISION,
|
|
474
689
|
});
|
|
475
|
-
sendJson(res, { token: clientToken, pid: process.pid });
|
|
476
690
|
return;
|
|
477
691
|
}
|
|
478
692
|
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
479
|
-
const body = await
|
|
693
|
+
const body = await readBody(req);
|
|
480
694
|
if (body.token) dropClient(String(body.token), 'deregister');
|
|
481
695
|
sendJson(res, { ok: true });
|
|
482
696
|
return;
|
|
@@ -487,36 +701,63 @@ export function createEngineDaemonTransport({
|
|
|
487
701
|
return; // stream stays open
|
|
488
702
|
}
|
|
489
703
|
if (req.method === 'POST' && pathName === '/call') {
|
|
490
|
-
const body = await
|
|
704
|
+
const body = await readBody(req);
|
|
491
705
|
const clientToken = body.token ? String(body.token) : null;
|
|
492
706
|
const c = clientToken ? clients.get(clientToken) : null;
|
|
493
707
|
if (!c) { sendError(res, 'unknown client token', 404); return; }
|
|
494
708
|
c.lastSeen = nowMs();
|
|
495
709
|
const name = String(body.name || '');
|
|
496
710
|
const callId = body.callId ? String(body.callId) : null;
|
|
711
|
+
const addressedSessionId = String(body.args?.sessionId || '').trim();
|
|
712
|
+
const ownerKey = addressedSessionId
|
|
713
|
+
? `session:${addressedSessionId}`
|
|
714
|
+
: c.leadPid
|
|
715
|
+
? `pid:${c.leadPid}`
|
|
716
|
+
: `client:${clientToken}`;
|
|
717
|
+
// Scheduling fairness follows the addressed session, but idempotency
|
|
718
|
+
// belongs to the CALLING PROCESS. Two clients legitimately issuing the
|
|
719
|
+
// same callId against one shared session must not dedupe each other.
|
|
720
|
+
const cacheOwnerKey = c.leadPid
|
|
721
|
+
? `pid:${c.leadPid}`
|
|
722
|
+
: `client:${clientToken}`;
|
|
723
|
+
const cacheKey = callId && !REPLAY_SAFE_CALLS.has(name)
|
|
724
|
+
? `${cacheOwnerKey}\u0000${callId}`
|
|
725
|
+
: null;
|
|
497
726
|
// A retry is the SAME payload under the same id. A different payload
|
|
498
727
|
// that reuses an id (submission ids are caller-supplied) is a NEW call
|
|
499
728
|
// and must never be answered out of another call's result.
|
|
500
729
|
const signature = callId ? callSignature(name, body.args) : null;
|
|
501
|
-
const cached =
|
|
730
|
+
const cached = cacheKey ? callCache.get(cacheKey) : null;
|
|
502
731
|
let dispatch;
|
|
503
732
|
if (cached && (!cached.signature || !signature || cached.signature === signature)) {
|
|
504
733
|
dispatch = cached.promise;
|
|
505
734
|
} else {
|
|
506
|
-
dispatch = dispatchCall(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
735
|
+
dispatch = dispatchCall(
|
|
736
|
+
ownerKey,
|
|
737
|
+
() => handleCall(name, body.args || {}, {
|
|
738
|
+
clientToken,
|
|
739
|
+
leadPid: c.leadPid ?? null,
|
|
740
|
+
cwd: c.cwd ?? null,
|
|
741
|
+
revision: c.revision ?? 0,
|
|
742
|
+
}),
|
|
743
|
+
{ lane: callLane(name, body.args || {}) },
|
|
744
|
+
);
|
|
745
|
+
if (cacheKey) {
|
|
746
|
+
const record = {
|
|
747
|
+
promise: dispatch, at: nowMs(), signature, settled: false, bytes: 0,
|
|
748
|
+
};
|
|
749
|
+
callCache.set(cacheKey, record);
|
|
514
750
|
pruneCallCache();
|
|
515
751
|
// TTL starts at SETTLE: a long-running submit must not expire its
|
|
516
752
|
// dedup entry mid-flight and let a retry run the turn twice.
|
|
517
|
-
dispatch.then(() => {
|
|
753
|
+
dispatch.then((result) => {
|
|
754
|
+
record.bytes = estimateRetainedBytes(result);
|
|
755
|
+
callCacheBytes += record.bytes;
|
|
756
|
+
}, () => {}).then(() => {
|
|
757
|
+
record.settled = true;
|
|
758
|
+
pruneCallCache();
|
|
518
759
|
const t = setTimeout(() => {
|
|
519
|
-
|
|
760
|
+
deleteCallCacheEntry(cacheKey, record);
|
|
520
761
|
}, CALL_CACHE_TTL_MS);
|
|
521
762
|
t.unref?.();
|
|
522
763
|
});
|
|
@@ -526,7 +767,7 @@ export function createEngineDaemonTransport({
|
|
|
526
767
|
const result = await dispatch;
|
|
527
768
|
sendJson(res, { result });
|
|
528
769
|
} catch (err) {
|
|
529
|
-
//
|
|
770
|
+
// Session call errors travel as a 200 {error} envelope so the client can
|
|
530
771
|
// tell a failed CALL from a dead TRANSPORT (which must re-attach).
|
|
531
772
|
sendJson(res, { error: err?.message || String(err) }, 200);
|
|
532
773
|
}
|
|
@@ -537,38 +778,59 @@ export function createEngineDaemonTransport({
|
|
|
537
778
|
if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
|
|
538
779
|
return;
|
|
539
780
|
}
|
|
781
|
+
if (req.method === 'POST' && pathName === '/upgrade') {
|
|
782
|
+
const body = await readBody(req);
|
|
783
|
+
const requestedProtocol = Number(body.protocol);
|
|
784
|
+
const requestedRevision = Math.max(0, Number(body.revision) || 0);
|
|
785
|
+
const requestedVersion = String(body.version || '0.0.0');
|
|
786
|
+
const revisionOrder = requestedRevision - SESSION_REVISION;
|
|
787
|
+
const versionOrder = compareRuntimeVersions(requestedVersion, runtimeVersion());
|
|
788
|
+
const newerBuild = requestedProtocol === SESSION_PROTOCOL
|
|
789
|
+
&& (revisionOrder > 0 || (revisionOrder === 0 && versionOrder > 0));
|
|
790
|
+
if (!newerBuild) {
|
|
791
|
+
sendError(
|
|
792
|
+
res,
|
|
793
|
+
`replacement must use protocol ${SESSION_PROTOCOL} with a revision/build newer than ${SESSION_REVISION}/${runtimeVersion()}`,
|
|
794
|
+
409,
|
|
795
|
+
);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
sendJson(res, {
|
|
799
|
+
accepted: true,
|
|
800
|
+
protocol: SESSION_PROTOCOL,
|
|
801
|
+
currentRevision: SESSION_REVISION,
|
|
802
|
+
currentVersion: runtimeVersion(),
|
|
803
|
+
requestedRevision,
|
|
804
|
+
requestedVersion,
|
|
805
|
+
});
|
|
806
|
+
queueMicrotask(() => {
|
|
807
|
+
try {
|
|
808
|
+
onUpgradeRequested?.({
|
|
809
|
+
protocol: SESSION_PROTOCOL,
|
|
810
|
+
revision: requestedRevision,
|
|
811
|
+
version: requestedVersion,
|
|
812
|
+
});
|
|
813
|
+
} catch {}
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
540
817
|
sendError(res, 'not found', 404);
|
|
541
818
|
} catch (err) {
|
|
542
819
|
try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
|
|
543
820
|
}
|
|
544
821
|
}
|
|
545
822
|
|
|
546
|
-
function writeDiscovery() {
|
|
547
|
-
if (!discoveryPath) return;
|
|
548
|
-
try {
|
|
549
|
-
writeJsonAtomicSync(discoveryPath, {
|
|
550
|
-
pid: process.pid,
|
|
551
|
-
port: boundPort,
|
|
552
|
-
token: serverToken,
|
|
553
|
-
startedAt: Date.now(),
|
|
554
|
-
}, { compact: true });
|
|
555
|
-
} catch (err) {
|
|
556
|
-
log(`discovery write failed: ${err?.message || err}`);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
823
|
function start() {
|
|
561
824
|
return new Promise((resolve, reject) => {
|
|
562
825
|
server = http.createServer(handleRequest);
|
|
563
826
|
server.on('error', reject);
|
|
564
|
-
// 127.0.0.1 ONLY — the
|
|
827
|
+
// 127.0.0.1 ONLY — the session service executes tools; it must never be
|
|
565
828
|
// reachable off-box.
|
|
566
829
|
server.listen(0, '127.0.0.1', () => {
|
|
567
830
|
server.removeListener('error', reject);
|
|
568
831
|
boundPort = server.address().port;
|
|
569
832
|
server.on('error', (err) => log(`server error: ${err?.message || err}`));
|
|
570
|
-
|
|
571
|
-
log(`engine daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
|
|
833
|
+
log(`session service transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
|
|
572
834
|
resolve({ port: boundPort, token: serverToken });
|
|
573
835
|
});
|
|
574
836
|
});
|
|
@@ -578,13 +840,12 @@ export function createEngineDaemonTransport({
|
|
|
578
840
|
closed = true;
|
|
579
841
|
cancelGrace();
|
|
580
842
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
843
|
+
normalCalls.close('session service transport is closed');
|
|
844
|
+
interactiveCalls.close('session service transport is closed');
|
|
845
|
+
criticalCalls.close('session service transport is closed');
|
|
584
846
|
for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
|
|
585
847
|
registrationReplays.clear();
|
|
586
848
|
for (const token of [...clients.keys()]) removeClientRecord(token);
|
|
587
|
-
if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
|
|
588
849
|
await new Promise((resolve) => {
|
|
589
850
|
if (!server) { resolve(); return; }
|
|
590
851
|
server.close(() => resolve());
|
|
@@ -596,8 +857,16 @@ export function createEngineDaemonTransport({
|
|
|
596
857
|
start,
|
|
597
858
|
stop,
|
|
598
859
|
broadcast,
|
|
860
|
+
beginDrain,
|
|
599
861
|
get port() { return boundPort; },
|
|
600
862
|
get clientCount() { return lifecycleClientCount(); },
|
|
601
863
|
get connectionCount() { return clients.size; },
|
|
864
|
+
get activeCount() {
|
|
865
|
+
return normalCalls.active + interactiveCalls.active + criticalCalls.active;
|
|
866
|
+
},
|
|
867
|
+
get queuedCount() {
|
|
868
|
+
return normalCalls.queued + interactiveCalls.queued + criticalCalls.queued;
|
|
869
|
+
},
|
|
870
|
+
get draining() { return Boolean(drainingReason); },
|
|
602
871
|
};
|
|
603
872
|
}
|