mixdog 0.9.95 → 0.9.96
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/package.json +12 -9
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/shared/01-tool.md +20 -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/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
- 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/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 +28 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -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 +1 -27
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
- 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/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-reader.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -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 +118 -18
- 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/lib/grep-context-expander.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -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 +87 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -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/orchestrator.mjs +29 -1
- 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 +10 -10
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/lib/config.mjs +2 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +2 -2
- package/src/runtime/memory/index.mjs +5 -38
- package/src/runtime/memory/lib/agent-ipc.mjs +145 -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 +12 -33
- 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 +8 -0
- 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-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-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/tool-defs.mjs +1 -5
- package/src/runtime/search/tool-defs.mjs +2 -2
- 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 +60 -31
- package/src/runtime/shared/config.mjs +130 -44
- package/src/runtime/shared/resource-admission.mjs +146 -97
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-surface.mjs +0 -5
- package/src/runtime/shared/turn-snapshot.mjs +31 -5
- package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/runtime-core.mjs +11 -11
- package/src/session-runtime/runtime-tunables.mjs +4 -4
- package/src/session-runtime/session-lifecycle.mjs +9 -2
- package/src/session-runtime/session-turn-api.mjs +41 -9
- package/src/session-runtime/settings-api.mjs +5 -17
- 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/backend-daemon.mjs +97 -36
- package/src/standalone/channel-admin.mjs +12 -12
- package/src/standalone/channel-daemon-client.mjs +17 -1
- package/src/standalone/channel-daemon-transport.mjs +90 -8
- package/src/standalone/engine-daemon-client.mjs +380 -205
- package/src/standalone/engine-daemon-protocol.mjs +3 -4
- package/src/standalone/engine-daemon-service.mjs +378 -134
- package/src/standalone/engine-daemon-transport.mjs +268 -77
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/session-protocol.mjs +188 -0
- package/src/tui/App.jsx +30 -17
- package/src/tui/app/app-view.jsx +5 -2
- package/src/tui/app/core-memory-picker.mjs +19 -19
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +1 -2
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +46 -41
- 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 +6 -8
- 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 +9 -2
- 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/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/dist/index.mjs +920 -1592
- package/src/tui/engine/live-share.mjs +34 -0
- package/src/tui/engine/session-api-ext.mjs +1 -4
- package/src/tui/engine/session-api.mjs +103 -33
- package/src/tui/engine/session-flow.mjs +7 -1
- package/src/tui/engine/tool-card-results.mjs +4 -2
- package/src/tui/engine/tool-result-text.mjs +10 -0
- package/src/tui/engine/turn.mjs +9 -69
- package/src/tui/engine-local-session.mjs +47 -31
- package/src/tui/index.jsx +1 -2
- package/src/tui/spinner-meta.mjs +4 -4
- package/src/tui/spinner-verbs.mjs +51 -3
- package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
|
@@ -17,18 +17,29 @@ import { rmSync } from 'node:fs';
|
|
|
17
17
|
import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
18
18
|
import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
|
|
19
19
|
import { ENGINE_DAEMON_PROTOCOL, engineRuntimeVersion } from './engine-daemon-protocol.mjs';
|
|
20
|
+
import { createFairCallScheduler } from './fair-call-scheduler.mjs';
|
|
20
21
|
|
|
21
22
|
// A loopback front door still buffers whatever a client sends before it can be
|
|
22
23
|
// 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.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) ||
|
|
24
|
+
const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) || 32)
|
|
24
25
|
* 1024 * 1024;
|
|
25
26
|
|
|
26
|
-
function readLimitedBody(req
|
|
27
|
+
function readLimitedBody(req, {
|
|
28
|
+
reserve = () => true,
|
|
29
|
+
release = () => {},
|
|
30
|
+
} = {}) {
|
|
27
31
|
return new Promise((resolve, reject) => {
|
|
28
32
|
let settled = false;
|
|
33
|
+
let reserved = 0;
|
|
34
|
+
const releaseReserved = () => {
|
|
35
|
+
if (reserved <= 0) return;
|
|
36
|
+
release(reserved);
|
|
37
|
+
reserved = 0;
|
|
38
|
+
};
|
|
29
39
|
const fail = (message, statusCode) => {
|
|
30
40
|
if (settled) return;
|
|
31
41
|
settled = true;
|
|
42
|
+
releaseReserved();
|
|
32
43
|
const error = new Error(message);
|
|
33
44
|
error.statusCode = statusCode;
|
|
34
45
|
try { req.destroy(); } catch {}
|
|
@@ -45,21 +56,32 @@ function readLimitedBody(req) {
|
|
|
45
56
|
if (settled) return;
|
|
46
57
|
size += chunk.length;
|
|
47
58
|
if (size > MAX_BODY_BYTES) { fail('request body too large', 413); return; }
|
|
59
|
+
if (!reserve(chunk.length)) { fail('daemon request memory budget is busy', 503); return; }
|
|
60
|
+
reserved += chunk.length;
|
|
48
61
|
chunks.push(chunk);
|
|
49
62
|
});
|
|
50
63
|
req.on('end', () => {
|
|
51
64
|
if (settled) return;
|
|
52
65
|
settled = true;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
try {
|
|
67
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
68
|
+
if (!raw) { resolve({}); return; }
|
|
69
|
+
resolve(JSON.parse(raw));
|
|
70
|
+
}
|
|
56
71
|
catch (error) {
|
|
57
72
|
const err = new Error(`invalid JSON body: ${error.message}`);
|
|
58
73
|
err.statusCode = 400;
|
|
59
74
|
reject(err);
|
|
60
75
|
}
|
|
76
|
+
finally { releaseReserved(); }
|
|
77
|
+
});
|
|
78
|
+
req.on('error', (error) => {
|
|
79
|
+
if (!settled) {
|
|
80
|
+
settled = true;
|
|
81
|
+
releaseReserved();
|
|
82
|
+
reject(error);
|
|
83
|
+
}
|
|
61
84
|
});
|
|
62
|
-
req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
|
|
63
85
|
});
|
|
64
86
|
}
|
|
65
87
|
|
|
@@ -89,6 +111,10 @@ function isPidAlive(pid) {
|
|
|
89
111
|
// terminal frames therefore concatenate, bounded so a wedged reader can never
|
|
90
112
|
// grow the daemon's heap without limit.
|
|
91
113
|
const TERMINAL_BACKLOG_MAX_CHARS = 256 * 1024;
|
|
114
|
+
const SSE_PENDING_MAX_BYTES = Math.max(
|
|
115
|
+
256 * 1024,
|
|
116
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_SSE_PENDING_MB) || 8) * 1024 * 1024,
|
|
117
|
+
);
|
|
92
118
|
|
|
93
119
|
function terminalDataFrame(frame) {
|
|
94
120
|
return frame?.type === 'desktop-event'
|
|
@@ -136,23 +162,98 @@ export function createEngineDaemonTransport({
|
|
|
136
162
|
let sweepTimer = null;
|
|
137
163
|
let everHadLifecycleClient = false;
|
|
138
164
|
let closed = false;
|
|
165
|
+
const BODY_INFLIGHT_MAX_BYTES = Math.max(
|
|
166
|
+
MAX_BODY_BYTES,
|
|
167
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_BODY_INFLIGHT_MB) || 64) * 1024 * 1024,
|
|
168
|
+
);
|
|
169
|
+
let bodyBytesInFlight = 0;
|
|
170
|
+
const readBody = (req) => readLimitedBody(req, {
|
|
171
|
+
reserve(bytes) {
|
|
172
|
+
if (bodyBytesInFlight + bytes > BODY_INFLIGHT_MAX_BYTES) return false;
|
|
173
|
+
bodyBytesInFlight += bytes;
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
release(bytes) {
|
|
177
|
+
bodyBytesInFlight = Math.max(0, bodyBytesInFlight - bytes);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
139
180
|
// Idempotency cache: a transport retry of the SAME callId must never run a
|
|
140
181
|
// second engine mutation (submit/abort are not idempotent).
|
|
141
182
|
const callCache = new Map();
|
|
183
|
+
let callCacheBytes = 0;
|
|
142
184
|
const CALL_CACHE_TTL_MS = 60_000;
|
|
143
185
|
// Bound the dedup table: a call that never settles would otherwise pin its
|
|
144
186
|
// entry for the daemon's whole life.
|
|
145
187
|
const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE) || 4096);
|
|
188
|
+
const CALL_CACHE_MAX_BYTES = Math.max(
|
|
189
|
+
1024 * 1024,
|
|
190
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE_MB) || 8) * 1024 * 1024,
|
|
191
|
+
);
|
|
146
192
|
// A burst of panes used to enqueue every handleCall() as a microtask. Node
|
|
147
193
|
// drains the whole microtask queue before returning to HTTP, so enough cheap
|
|
148
194
|
// calls could starve /health and /client/register even though no explicit
|
|
149
195
|
// mutex existed. Admit one call per event-loop turn while allowing admitted
|
|
150
196
|
// async calls to overlap; control-plane routes never enter this queue.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
197
|
+
// No default concurrency gate: independent session work starts in parallel.
|
|
198
|
+
// The scheduler still launches one item per check phase so synchronous setup
|
|
199
|
+
// cannot monopolize the HTTP/control-plane turn. Operators may explicitly
|
|
200
|
+
// set finite lane limits for constrained hosts.
|
|
201
|
+
const configuredLaneLimit = (name) => {
|
|
202
|
+
const parsed = Math.floor(Number(process.env[name]));
|
|
203
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
|
|
204
|
+
};
|
|
205
|
+
const CALL_QUEUE_MAX = Math.max(1024, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_QUEUE) || 1024);
|
|
206
|
+
const CRITICAL_CALLS = new Set([
|
|
207
|
+
'session.abort',
|
|
208
|
+
'session.approve',
|
|
209
|
+
'session.unsubscribe',
|
|
210
|
+
'desktop.control',
|
|
211
|
+
'desktop.unsubscribe',
|
|
212
|
+
]);
|
|
213
|
+
const INTERACTIVE_CALLS = new Set([
|
|
214
|
+
'session.create',
|
|
215
|
+
'session.read',
|
|
216
|
+
'session.subscribe',
|
|
217
|
+
'session.submit',
|
|
218
|
+
'session.configure',
|
|
219
|
+
'project.list',
|
|
220
|
+
'project.inspect',
|
|
221
|
+
'project.add',
|
|
222
|
+
'project.touch',
|
|
223
|
+
'project.rename',
|
|
224
|
+
'project.remove',
|
|
225
|
+
'project.ensureDirectory',
|
|
226
|
+
'desktop.init',
|
|
227
|
+
]);
|
|
228
|
+
const INTERACTIVE_DESKTOP_METHODS = new Set([
|
|
229
|
+
'termEnsure',
|
|
230
|
+
'termWrite',
|
|
231
|
+
'termResize',
|
|
232
|
+
'termDispose',
|
|
233
|
+
'termProfiles',
|
|
234
|
+
]);
|
|
235
|
+
const normalCalls = createFairCallScheduler({
|
|
236
|
+
name: 'engine daemon call',
|
|
237
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_ACTIVE_CALLS'),
|
|
238
|
+
queueMax: CALL_QUEUE_MAX,
|
|
239
|
+
minOwnerQueue: Math.max(8, Math.floor(CALL_QUEUE_MAX / 16)),
|
|
240
|
+
dispatchBurst: 1,
|
|
241
|
+
yieldUnbounded: true,
|
|
242
|
+
});
|
|
243
|
+
const criticalCalls = createFairCallScheduler({
|
|
244
|
+
name: 'engine daemon critical call',
|
|
245
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_URGENT_RESERVE'),
|
|
246
|
+
queueMax: Math.max(32, Math.min(256, CALL_QUEUE_MAX)),
|
|
247
|
+
minOwnerQueue: 8,
|
|
248
|
+
});
|
|
249
|
+
const interactiveCalls = createFairCallScheduler({
|
|
250
|
+
name: 'engine daemon interactive call',
|
|
251
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_INTERACTIVE_RESERVE'),
|
|
252
|
+
queueMax: Math.max(64, Math.min(512, CALL_QUEUE_MAX)),
|
|
253
|
+
minOwnerQueue: 8,
|
|
254
|
+
dispatchBurst: 1,
|
|
255
|
+
yieldUnbounded: true,
|
|
256
|
+
});
|
|
156
257
|
// A timed-out registration may already have committed server-side. Replaying
|
|
157
258
|
// the same registrationId returns that token instead of leaking a second
|
|
158
259
|
// client record and another lifecycle reference.
|
|
@@ -160,58 +261,65 @@ export function createEngineDaemonTransport({
|
|
|
160
261
|
const REGISTRATION_REPLAY_TTL_MS = 30_000;
|
|
161
262
|
const REGISTRATION_REPLAY_MAX = 256;
|
|
162
263
|
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
264
|
+
function deleteCallCacheEntry(key, record = callCache.get(key)) {
|
|
265
|
+
if (!record || callCache.get(key) !== record) return false;
|
|
266
|
+
callCache.delete(key);
|
|
267
|
+
callCacheBytes = Math.max(0, callCacheBytes - (record.bytes || 0));
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function estimateRetainedBytes(value, limit = CALL_CACHE_MAX_BYTES + 1, seen = new Set()) {
|
|
272
|
+
if (typeof value === 'string') return Math.min(limit, value.length * 2 + 16);
|
|
273
|
+
if (typeof value === 'number' || typeof value === 'bigint') return 8;
|
|
274
|
+
if (typeof value === 'boolean' || value == null) return 4;
|
|
275
|
+
if (typeof value !== 'object' || seen.has(value)) return 0;
|
|
276
|
+
seen.add(value);
|
|
277
|
+
let bytes = Array.isArray(value) ? 32 : 64;
|
|
278
|
+
const entries = Array.isArray(value) ? value : Object.entries(value);
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (Array.isArray(value)) bytes += 8 + estimateRetainedBytes(entry, limit - bytes, seen);
|
|
281
|
+
else bytes += String(entry[0]).length * 2 + 16
|
|
282
|
+
+ estimateRetainedBytes(entry[1], limit - bytes, seen);
|
|
283
|
+
if (bytes >= limit) break;
|
|
168
284
|
}
|
|
285
|
+
seen.delete(value);
|
|
286
|
+
return Math.min(limit, bytes);
|
|
169
287
|
}
|
|
170
288
|
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return;
|
|
289
|
+
function pruneCallCache() {
|
|
290
|
+
while (callCache.size > CALL_CACHE_MAX || callCacheBytes > CALL_CACHE_MAX_BYTES) {
|
|
291
|
+
let removed = false;
|
|
292
|
+
for (const [key, record] of callCache) {
|
|
293
|
+
// Never lose the dedup identity of an in-flight mutation.
|
|
294
|
+
if (!record.settled) continue;
|
|
295
|
+
deleteCallCacheEntry(key, record);
|
|
296
|
+
removed = true;
|
|
297
|
+
break;
|
|
181
298
|
}
|
|
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
|
-
});
|
|
299
|
+
if (!removed) return;
|
|
300
|
+
}
|
|
197
301
|
}
|
|
198
302
|
|
|
199
|
-
function
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return Promise.reject(error);
|
|
303
|
+
function callLane(name, args = {}) {
|
|
304
|
+
if (CRITICAL_CALLS.has(name)) return 'critical';
|
|
305
|
+
if (INTERACTIVE_CALLS.has(name)) return 'interactive';
|
|
306
|
+
if (name === 'desktop.invoke') {
|
|
307
|
+
const adapterMethod = String(args?.method || '');
|
|
308
|
+
const backendMethod = adapterMethod === 'backendInvoke'
|
|
309
|
+
? String(args?.args?.[0] || '')
|
|
310
|
+
: adapterMethod;
|
|
311
|
+
if (INTERACTIVE_DESKTOP_METHODS.has(backendMethod)) return 'interactive';
|
|
209
312
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
313
|
+
return 'normal';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function dispatchCall(ownerKey, run, { lane = 'normal', signal = null } = {}) {
|
|
317
|
+
const scheduler = lane === 'critical'
|
|
318
|
+
? criticalCalls
|
|
319
|
+
: lane === 'interactive'
|
|
320
|
+
? interactiveCalls
|
|
321
|
+
: normalCalls;
|
|
322
|
+
return scheduler.enqueue(ownerKey, run, { signal });
|
|
215
323
|
}
|
|
216
324
|
|
|
217
325
|
function nowMs() { return Date.now(); }
|
|
@@ -315,6 +423,7 @@ export function createEngineDaemonTransport({
|
|
|
315
423
|
// Frames are latest-wins per key while a client has no stream: a
|
|
316
424
|
// reconnecting viewer wants the CURRENT snapshot, never a backlog.
|
|
317
425
|
pending: new Map(),
|
|
426
|
+
pendingBytes: 0,
|
|
318
427
|
lastSeen: nowMs(),
|
|
319
428
|
});
|
|
320
429
|
if (ownsLifecycle) {
|
|
@@ -352,30 +461,71 @@ export function createEngineDaemonTransport({
|
|
|
352
461
|
while (client.sse && !client.paused && client.pending.size > 0) {
|
|
353
462
|
const [key, entry] = client.pending.entries().next().value;
|
|
354
463
|
client.pending.delete(key);
|
|
464
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
|
|
355
465
|
try {
|
|
356
466
|
if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
|
|
357
467
|
} catch {
|
|
358
468
|
client.sse = null;
|
|
359
469
|
client.pending.set(key, entry);
|
|
470
|
+
client.pendingBytes += entry.bytes || 0;
|
|
360
471
|
return;
|
|
361
472
|
}
|
|
362
473
|
}
|
|
363
474
|
}
|
|
364
475
|
|
|
476
|
+
function pendingEntry(frame, json) {
|
|
477
|
+
return { frame, json, bytes: Buffer.byteLength(json) + 8 };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function resyncEntry(frame) {
|
|
481
|
+
if (frame?.type !== 'session-state' || !frame.sessionId) return null;
|
|
482
|
+
const marker = {
|
|
483
|
+
type: 'session-state',
|
|
484
|
+
key: frame.key,
|
|
485
|
+
sessionId: frame.sessionId,
|
|
486
|
+
revision: -1,
|
|
487
|
+
baseRevision: -2,
|
|
488
|
+
patch: { set: {}, remove: [], itemsAppend: null },
|
|
489
|
+
resyncRequired: true,
|
|
490
|
+
};
|
|
491
|
+
return pendingEntry(marker, JSON.stringify(marker));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function setPending(client, key, entry) {
|
|
495
|
+
const previous = client.pending.get(key);
|
|
496
|
+
if (previous) client.pendingBytes = Math.max(0, client.pendingBytes - (previous.bytes || 0));
|
|
497
|
+
client.pending.delete(key);
|
|
498
|
+
client.pending.set(key, entry);
|
|
499
|
+
client.pendingBytes += entry.bytes || 0;
|
|
500
|
+
while (client.pendingBytes > SSE_PENDING_MAX_BYTES && client.pending.size > 0) {
|
|
501
|
+
const [oldestKey, oldest] = client.pending.entries().next().value;
|
|
502
|
+
const marker = resyncEntry(oldest.frame);
|
|
503
|
+
if (marker && marker.bytes < oldest.bytes) {
|
|
504
|
+
client.pending.set(oldestKey, marker);
|
|
505
|
+
client.pendingBytes += marker.bytes - oldest.bytes;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
client.pending.delete(oldestKey);
|
|
509
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (oldest.bytes || 0));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
365
513
|
function writeFrame(client, frame, json) {
|
|
366
514
|
const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
|
|
367
515
|
// A stalled reader must never make the daemon buffer a whole stream in
|
|
368
516
|
// Node's socket queue: while the socket is full the backlog collapses to
|
|
369
517
|
// one frame per key, exactly like a client with no stream at all.
|
|
370
518
|
if (!client.sse || client.paused) {
|
|
371
|
-
|
|
519
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
520
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
372
521
|
return;
|
|
373
522
|
}
|
|
374
523
|
try {
|
|
375
524
|
if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
|
|
376
525
|
} catch {
|
|
377
526
|
client.sse = null;
|
|
378
|
-
|
|
527
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
528
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
379
529
|
}
|
|
380
530
|
}
|
|
381
531
|
|
|
@@ -456,8 +606,25 @@ export function createEngineDaemonTransport({
|
|
|
456
606
|
protocol: ENGINE_DAEMON_PROTOCOL,
|
|
457
607
|
version: engineRuntimeVersion(),
|
|
458
608
|
...getStatus(),
|
|
459
|
-
activeCalls,
|
|
460
|
-
queuedCalls:
|
|
609
|
+
activeCalls: normalCalls.active + interactiveCalls.active + criticalCalls.active,
|
|
610
|
+
queuedCalls: normalCalls.queued + interactiveCalls.queued + criticalCalls.queued,
|
|
611
|
+
queuedUrgentCalls: criticalCalls.queued,
|
|
612
|
+
queuedInteractiveCalls: interactiveCalls.queued,
|
|
613
|
+
callOwners: normalCalls.snapshot().owners,
|
|
614
|
+
callQueues: {
|
|
615
|
+
critical: criticalCalls.snapshot(),
|
|
616
|
+
interactive: interactiveCalls.snapshot(),
|
|
617
|
+
normal: normalCalls.snapshot(),
|
|
618
|
+
},
|
|
619
|
+
transportMemory: {
|
|
620
|
+
bodyBytesInFlight,
|
|
621
|
+
bodyBytesMax: BODY_INFLIGHT_MAX_BYTES,
|
|
622
|
+
callCacheEntries: callCache.size,
|
|
623
|
+
callCacheBytes,
|
|
624
|
+
callCacheMaxBytes: CALL_CACHE_MAX_BYTES,
|
|
625
|
+
ssePendingBytes: [...clients.values()]
|
|
626
|
+
.reduce((sum, client) => sum + (client.pendingBytes || 0), 0),
|
|
627
|
+
},
|
|
461
628
|
});
|
|
462
629
|
return;
|
|
463
630
|
}
|
|
@@ -465,7 +632,7 @@ export function createEngineDaemonTransport({
|
|
|
465
632
|
if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
|
|
466
633
|
|
|
467
634
|
if (req.method === 'POST' && pathName === '/client/register') {
|
|
468
|
-
const body = await
|
|
635
|
+
const body = await readBody(req);
|
|
469
636
|
const clientToken = registerClient({
|
|
470
637
|
leadPid: body.leadPid,
|
|
471
638
|
cwd: body.cwd,
|
|
@@ -476,7 +643,7 @@ export function createEngineDaemonTransport({
|
|
|
476
643
|
return;
|
|
477
644
|
}
|
|
478
645
|
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
479
|
-
const body = await
|
|
646
|
+
const body = await readBody(req);
|
|
480
647
|
if (body.token) dropClient(String(body.token), 'deregister');
|
|
481
648
|
sendJson(res, { ok: true });
|
|
482
649
|
return;
|
|
@@ -487,36 +654,60 @@ export function createEngineDaemonTransport({
|
|
|
487
654
|
return; // stream stays open
|
|
488
655
|
}
|
|
489
656
|
if (req.method === 'POST' && pathName === '/call') {
|
|
490
|
-
const body = await
|
|
657
|
+
const body = await readBody(req);
|
|
491
658
|
const clientToken = body.token ? String(body.token) : null;
|
|
492
659
|
const c = clientToken ? clients.get(clientToken) : null;
|
|
493
660
|
if (!c) { sendError(res, 'unknown client token', 404); return; }
|
|
494
661
|
c.lastSeen = nowMs();
|
|
495
662
|
const name = String(body.name || '');
|
|
496
663
|
const callId = body.callId ? String(body.callId) : null;
|
|
664
|
+
const addressedSessionId = String(body.args?.sessionId || '').trim();
|
|
665
|
+
const ownerKey = addressedSessionId
|
|
666
|
+
? `session:${addressedSessionId}`
|
|
667
|
+
: c.leadPid
|
|
668
|
+
? `pid:${c.leadPid}`
|
|
669
|
+
: `client:${clientToken}`;
|
|
670
|
+
// Scheduling fairness follows the addressed session, but idempotency
|
|
671
|
+
// belongs to the CALLING PROCESS. Two clients legitimately issuing the
|
|
672
|
+
// same callId against one shared session must not dedupe each other.
|
|
673
|
+
const cacheOwnerKey = c.leadPid
|
|
674
|
+
? `pid:${c.leadPid}`
|
|
675
|
+
: `client:${clientToken}`;
|
|
676
|
+
const cacheKey = callId ? `${cacheOwnerKey}\u0000${callId}` : null;
|
|
497
677
|
// A retry is the SAME payload under the same id. A different payload
|
|
498
678
|
// that reuses an id (submission ids are caller-supplied) is a NEW call
|
|
499
679
|
// and must never be answered out of another call's result.
|
|
500
680
|
const signature = callId ? callSignature(name, body.args) : null;
|
|
501
|
-
const cached =
|
|
681
|
+
const cached = cacheKey ? callCache.get(cacheKey) : null;
|
|
502
682
|
let dispatch;
|
|
503
683
|
if (cached && (!cached.signature || !signature || cached.signature === signature)) {
|
|
504
684
|
dispatch = cached.promise;
|
|
505
685
|
} else {
|
|
506
|
-
dispatch = dispatchCall(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
686
|
+
dispatch = dispatchCall(
|
|
687
|
+
ownerKey,
|
|
688
|
+
() => handleCall(name, body.args || {}, {
|
|
689
|
+
clientToken,
|
|
690
|
+
leadPid: c.leadPid ?? null,
|
|
691
|
+
cwd: c.cwd ?? null,
|
|
692
|
+
}),
|
|
693
|
+
{ lane: callLane(name, body.args || {}) },
|
|
694
|
+
);
|
|
695
|
+
if (cacheKey) {
|
|
696
|
+
const record = {
|
|
697
|
+
promise: dispatch, at: nowMs(), signature, settled: false, bytes: 0,
|
|
698
|
+
};
|
|
699
|
+
callCache.set(cacheKey, record);
|
|
514
700
|
pruneCallCache();
|
|
515
701
|
// TTL starts at SETTLE: a long-running submit must not expire its
|
|
516
702
|
// dedup entry mid-flight and let a retry run the turn twice.
|
|
517
|
-
dispatch.then(() => {
|
|
703
|
+
dispatch.then((result) => {
|
|
704
|
+
record.bytes = estimateRetainedBytes(result);
|
|
705
|
+
callCacheBytes += record.bytes;
|
|
706
|
+
}, () => {}).then(() => {
|
|
707
|
+
record.settled = true;
|
|
708
|
+
pruneCallCache();
|
|
518
709
|
const t = setTimeout(() => {
|
|
519
|
-
|
|
710
|
+
deleteCallCacheEntry(cacheKey, record);
|
|
520
711
|
}, CALL_CACHE_TTL_MS);
|
|
521
712
|
t.unref?.();
|
|
522
713
|
});
|
|
@@ -578,9 +769,9 @@ export function createEngineDaemonTransport({
|
|
|
578
769
|
closed = true;
|
|
579
770
|
cancelGrace();
|
|
580
771
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
772
|
+
normalCalls.close('engine daemon transport is closed');
|
|
773
|
+
interactiveCalls.close('engine daemon transport is closed');
|
|
774
|
+
criticalCalls.close('engine daemon transport is closed');
|
|
584
775
|
for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
|
|
585
776
|
registrationReplays.clear();
|
|
586
777
|
for (const token of [...clients.keys()]) removeClientRecord(token);
|