mixdog 0.9.50 → 0.9.52
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/package.json +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -15,6 +15,7 @@ import { presentErrorText, errorLine } from '../runtime/shared/err-text.mjs';
|
|
|
15
15
|
import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
16
16
|
import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
|
|
17
17
|
import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
|
|
18
|
+
import { resourceAdmission } from '../runtime/shared/resource-admission.mjs';
|
|
18
19
|
import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
|
|
19
20
|
import {
|
|
20
21
|
abortAgentProgressWatchdog,
|
|
@@ -76,20 +77,6 @@ const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)),
|
|
|
76
77
|
// cap and restore strictly-unbounded prep.
|
|
77
78
|
const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS', 120_000);
|
|
78
79
|
|
|
79
|
-
// Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
|
|
80
|
-
// first provider calls in the same instant, racing the server-side prompt-
|
|
81
|
-
// cache write/propagation window. Default 0 (off): mirrors the explore fan-out
|
|
82
|
-
// finding — the first spawn's prompt-cache write only lands after its iter1
|
|
83
|
-
// completes (~seconds), so a sub-second stagger yields ~no cross-spawn cache
|
|
84
|
-
// reads while charging every later spawn the full delay, i.e. pure fan-out
|
|
85
|
-
// latency for negligible hit-rate gain. Kept as a knob for tuning: set
|
|
86
|
-
// MIXDOG_SPAWN_STAGGER_MS>0 to re-enable. When >0 it chains (not a fixed lane
|
|
87
|
-
// count) so it scales to any N: each new spawn's start is pushed to at least
|
|
88
|
-
// STAGGER_MS after the previous spawn's start; sequential/non-overlapping
|
|
89
|
-
// spawns pay zero added latency. Applied inside the deferred job body (see
|
|
90
|
-
// startDeferredSpawnJob) so the agent tool call itself still returns task_id
|
|
91
|
-
// immediately.
|
|
92
|
-
const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
|
|
93
80
|
const TAG_TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
94
81
|
const MAX_TAG_TOMBSTONES = 500;
|
|
95
82
|
|
|
@@ -156,20 +143,6 @@ export function resolveAgentSpawnPreset(config, args = {}) {
|
|
|
156
143
|
if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
|
|
157
144
|
return { presetName, preset };
|
|
158
145
|
}
|
|
159
|
-
let lastSpawnStartAt = 0;
|
|
160
|
-
async function waitForSpawnStagger() {
|
|
161
|
-
if (SPAWN_STAGGER_MS <= 0) return;
|
|
162
|
-
const now = Date.now();
|
|
163
|
-
const myStart = Math.max(now, lastSpawnStartAt + SPAWN_STAGGER_MS);
|
|
164
|
-
lastSpawnStartAt = myStart;
|
|
165
|
-
const delay = myStart - now;
|
|
166
|
-
if (delay <= 0) return;
|
|
167
|
-
await new Promise((resolve) => {
|
|
168
|
-
const t = setTimeout(resolve, delay);
|
|
169
|
-
t.unref?.();
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
146
|
export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultCwd, onSubagentEvent }) {
|
|
174
147
|
// Optional bridge to the standard hook bus for SubagentStart / SubagentStop.
|
|
175
148
|
// Best-effort: a hook error must never affect worker spawn/finish.
|
|
@@ -1042,6 +1015,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1042
1015
|
...(clientHostPid ? { clientHostPid } : {}),
|
|
1043
1016
|
});
|
|
1044
1017
|
let task;
|
|
1018
|
+
const admissionController = new AbortController();
|
|
1045
1019
|
task = startBackgroundTask({
|
|
1046
1020
|
surface: 'agent',
|
|
1047
1021
|
operation: type,
|
|
@@ -1052,18 +1026,27 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1052
1026
|
resultType: 'agent_task_result',
|
|
1053
1027
|
renderResult: (result) => renderResult(result),
|
|
1054
1028
|
cancel: () => {
|
|
1029
|
+
try { admissionController.abort(new Error('agent task cancelled before resource admission')); } catch {}
|
|
1055
1030
|
const currentMeta = task?.meta || jobMeta;
|
|
1056
1031
|
if (currentMeta?.sessionId) {
|
|
1057
1032
|
try { mgr.closeSession(currentMeta.sessionId, 'agent-task-cancel'); } catch {}
|
|
1058
1033
|
}
|
|
1059
1034
|
},
|
|
1060
1035
|
run: async () => {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1036
|
+
const lease = await resourceAdmission.acquire('agent', {
|
|
1037
|
+
signal: admissionController.signal,
|
|
1038
|
+
label: jobMeta?.tag || type,
|
|
1039
|
+
});
|
|
1040
|
+
try {
|
|
1041
|
+
// Yield one macrotask before doing agent work. startBackgroundTask uses
|
|
1042
|
+
// a Promise microtask, which otherwise begins CPU-heavy spawn prep
|
|
1043
|
+
// before the TUI receives/render the "running" result.
|
|
1044
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1045
|
+
if (task?.status === 'cancelled') return null;
|
|
1046
|
+
return await resourceAdmission.runWithLease(lease, () => run(task));
|
|
1047
|
+
} finally {
|
|
1048
|
+
await lease.release();
|
|
1049
|
+
}
|
|
1067
1050
|
},
|
|
1068
1051
|
});
|
|
1069
1052
|
return task;
|
|
@@ -1071,10 +1054,6 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1071
1054
|
|
|
1072
1055
|
function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
|
|
1073
1056
|
return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
|
|
1074
|
-
// Stagger concurrent spawn starts before prep/model-call begins (see
|
|
1075
|
-
// SPAWN_STAGGER_MS above). Runs inside the job body, so the tool call
|
|
1076
|
-
// that queued this job already returned task_id before this awaits.
|
|
1077
|
-
await waitForSpawnStagger();
|
|
1078
1057
|
if (job?.status === 'cancelled') return null;
|
|
1079
1058
|
// prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
|
|
1080
1059
|
// installs its progress watchdog, so guard prep with an internal env-
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// worker->parent notify path arrives on a persistent SSE stream (GET /events),
|
|
7
7
|
// replacing the old node-IPC `{type:'notify'}` messages.
|
|
8
8
|
import http from 'node:http';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
9
10
|
import { readFileSync } from 'node:fs';
|
|
10
11
|
|
|
11
12
|
function parsePid(value) {
|
|
@@ -41,7 +42,29 @@ export function readDaemonDiscovery(discoveryPath) {
|
|
|
41
42
|
function request({ port, method = 'GET', path = '/', token, body = null, timeoutMs = 10_000 }) {
|
|
42
43
|
return new Promise((resolve, reject) => {
|
|
43
44
|
const payload = body == null ? null : JSON.stringify(body);
|
|
44
|
-
|
|
45
|
+
let req = null;
|
|
46
|
+
let response = null;
|
|
47
|
+
let ended = false;
|
|
48
|
+
let settled = false;
|
|
49
|
+
const finish = (fn, value) => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(deadline);
|
|
53
|
+
fn(value);
|
|
54
|
+
};
|
|
55
|
+
const fail = (error) => finish(reject, error instanceof Error ? error : new Error(String(error)));
|
|
56
|
+
const lifecycleTimeout = () => {
|
|
57
|
+
const error = new Error(`daemon request timed out: ${method} ${path}`);
|
|
58
|
+
fail(error);
|
|
59
|
+
try { response?.destroy?.(error); } catch {}
|
|
60
|
+
try { req?.destroy?.(error); } catch {}
|
|
61
|
+
};
|
|
62
|
+
// http.request's timeout is socket-idle only. This deadline covers headers
|
|
63
|
+
// and the entire response body so a truncated post-header response cannot
|
|
64
|
+
// leave reconnect registration pending forever.
|
|
65
|
+
const deadline = setTimeout(lifecycleTimeout, timeoutMs);
|
|
66
|
+
deadline.unref?.();
|
|
67
|
+
req = http.request({
|
|
45
68
|
hostname: '127.0.0.1',
|
|
46
69
|
port,
|
|
47
70
|
path,
|
|
@@ -54,23 +77,31 @@ function request({ port, method = 'GET', path = '/', token, body = null, timeout
|
|
|
54
77
|
},
|
|
55
78
|
timeout: timeoutMs,
|
|
56
79
|
}, (res) => {
|
|
80
|
+
response = res;
|
|
81
|
+
if (settled) { try { res.resume?.(); } catch {} return; }
|
|
57
82
|
let data = '';
|
|
58
83
|
res.setEncoding('utf8');
|
|
59
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
60
|
-
res.
|
|
84
|
+
res.on('data', (chunk) => { if (!settled) data += chunk; });
|
|
85
|
+
res.once('aborted', () => fail(new Error(`daemon response aborted: ${method} ${path}`)));
|
|
86
|
+
res.once('error', (error) => fail(error));
|
|
87
|
+
res.once('end', () => {
|
|
88
|
+
ended = true;
|
|
61
89
|
let parsed = null;
|
|
62
90
|
try { parsed = data ? JSON.parse(data) : null; } catch {}
|
|
63
91
|
if (res.statusCode && res.statusCode >= 400) {
|
|
64
92
|
const err = new Error(parsed?.error || data || `HTTP ${res.statusCode}`);
|
|
65
93
|
err.statusCode = res.statusCode;
|
|
66
|
-
|
|
94
|
+
fail(err);
|
|
67
95
|
return;
|
|
68
96
|
}
|
|
69
|
-
resolve
|
|
97
|
+
finish(resolve, parsed ?? {});
|
|
98
|
+
});
|
|
99
|
+
res.once('close', () => {
|
|
100
|
+
if (!ended && !settled) fail(new Error(`daemon response closed before end: ${method} ${path}`));
|
|
70
101
|
});
|
|
71
102
|
});
|
|
72
|
-
req.on('error',
|
|
73
|
-
req.on('timeout',
|
|
103
|
+
req.on('error', fail);
|
|
104
|
+
req.on('timeout', lifecycleTimeout);
|
|
74
105
|
if (payload) req.write(payload);
|
|
75
106
|
req.end();
|
|
76
107
|
});
|
|
@@ -96,29 +127,104 @@ export async function attachToDaemon({
|
|
|
96
127
|
log = () => {},
|
|
97
128
|
onFatal = () => {},
|
|
98
129
|
} = {}) {
|
|
99
|
-
|
|
130
|
+
const expectedPid = parsePid(discovery?.pid);
|
|
131
|
+
if (!discovery?.port || !discovery?.token || !expectedPid) throw new Error('daemon discovery {port, pid, token} required');
|
|
100
132
|
const { port, token: serverToken } = discovery;
|
|
133
|
+
const staleDiscoveryError = (reason) => {
|
|
134
|
+
const err = new Error(reason);
|
|
135
|
+
err.daemonDiscoveryStale = true;
|
|
136
|
+
return err;
|
|
137
|
+
};
|
|
138
|
+
const isExpectedDaemon = (health) => Number(health?.pid) === expectedPid;
|
|
101
139
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
const initialHealth = await probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 });
|
|
141
|
+
if (!isExpectedDaemon(initialHealth)) throw staleDiscoveryError('daemon discovery pid does not match health');
|
|
142
|
+
|
|
143
|
+
let reg;
|
|
144
|
+
try {
|
|
145
|
+
reg = await request({
|
|
146
|
+
port,
|
|
147
|
+
token: serverToken,
|
|
148
|
+
method: 'POST',
|
|
149
|
+
path: '/client/register',
|
|
150
|
+
body: { leadPid, cwd },
|
|
151
|
+
timeoutMs: 3000,
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err?.statusCode === 401 || err?.statusCode === 403) {
|
|
155
|
+
const stale = staleDiscoveryError(`daemon register rejected (${err.statusCode})`);
|
|
156
|
+
stale.daemonAuthRejected = true;
|
|
157
|
+
throw stale;
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
110
161
|
let clientToken = reg?.token;
|
|
111
162
|
if (!clientToken) throw new Error('daemon register returned no client token');
|
|
112
163
|
|
|
113
164
|
let sseReq = null;
|
|
114
165
|
let closed = false;
|
|
166
|
+
let fatal = false;
|
|
115
167
|
let reconnectTimer = null;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
168
|
+
let reconnectProbe = false;
|
|
169
|
+
let reconnectRegistration = null;
|
|
170
|
+
let reconnectRegistrationId = null;
|
|
171
|
+
let reconnectReplaceToken = null;
|
|
172
|
+
let lifecycle = 0;
|
|
173
|
+
let stableTimer = null;
|
|
174
|
+
let closePromise = null;
|
|
175
|
+
// Bounded reconnect is only for a verified-live daemon's transient SSE loss.
|
|
176
|
+
// A stale/dead endpoint signals onFatal immediately so the owner re-reads
|
|
177
|
+
// discovery instead of spinning against the captured port.
|
|
120
178
|
let reconnectAttempts = 0;
|
|
121
179
|
const MAX_RECONNECTS = 5;
|
|
180
|
+
const STABLE_STREAM_MS = 5_000;
|
|
181
|
+
|
|
182
|
+
async function deregister(token, { registrationId = null, replaceToken = null } = {}) {
|
|
183
|
+
if (!token) return;
|
|
184
|
+
try {
|
|
185
|
+
await request({
|
|
186
|
+
port, token: serverToken, method: 'POST', path: '/client/deregister',
|
|
187
|
+
body: {
|
|
188
|
+
token,
|
|
189
|
+
...(registrationId ? { registrationId, replaceToken, leadPid, cwd } : {}),
|
|
190
|
+
},
|
|
191
|
+
timeoutMs: 1500,
|
|
192
|
+
});
|
|
193
|
+
} catch { /* best-effort; daemon sweep reaps us */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function clearStableTimer() {
|
|
197
|
+
if (stableTimer) { try { clearTimeout(stableTimer); } catch {} stableTimer = null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function signalFatal(reason) {
|
|
201
|
+
if (closed || fatal) return;
|
|
202
|
+
fatal = true;
|
|
203
|
+
closed = true;
|
|
204
|
+
lifecycle++;
|
|
205
|
+
if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
|
|
206
|
+
clearStableTimer();
|
|
207
|
+
try { sseReq?.destroy?.(); } catch {}
|
|
208
|
+
log(`sse stale endpoint (${reason}); signalling re-attach`);
|
|
209
|
+
try { onFatal(reason); } catch {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A stream ending can be a transient connection loss, but must not leave this
|
|
213
|
+
// client retrying a dead discovery endpoint. Verify that the original daemon
|
|
214
|
+
// is still alive before spending the bounded reconnect budget on it.
|
|
215
|
+
function handleStreamLoss(reason) {
|
|
216
|
+
if (closed || fatal || reconnectTimer || reconnectProbe) return;
|
|
217
|
+
reconnectProbe = true;
|
|
218
|
+
void probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 }).then((health) => {
|
|
219
|
+
reconnectProbe = false;
|
|
220
|
+
if (closed || fatal) return;
|
|
221
|
+
if (!isExpectedDaemon(health)) {
|
|
222
|
+
signalFatal(reason);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
scheduleReconnect(reason);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
122
228
|
|
|
123
229
|
function openStream() {
|
|
124
230
|
if (closed) return;
|
|
@@ -129,15 +235,29 @@ export async function attachToDaemon({
|
|
|
129
235
|
method: 'GET',
|
|
130
236
|
headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
|
|
131
237
|
}, (res) => {
|
|
238
|
+
if (req !== sseReq || closed) { res.resume(); return; }
|
|
132
239
|
if (res.statusCode !== 200) {
|
|
133
240
|
res.resume();
|
|
134
|
-
|
|
241
|
+
// A token rejection means this port now belongs to a different daemon;
|
|
242
|
+
// re-read discovery now rather than re-registering against it.
|
|
243
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
244
|
+
signalFatal('bad sse status ' + res.statusCode);
|
|
245
|
+
} else {
|
|
246
|
+
handleStreamLoss('bad sse status ' + res.statusCode);
|
|
247
|
+
}
|
|
135
248
|
return;
|
|
136
249
|
}
|
|
137
250
|
res.setEncoding('utf8');
|
|
138
|
-
|
|
251
|
+
// A bare 200 followed by an immediate end is not a stable stream. Only
|
|
252
|
+
// reset the bounded reconnect budget after this exact stream stays live.
|
|
253
|
+
clearStableTimer();
|
|
254
|
+
stableTimer = setTimeout(() => {
|
|
255
|
+
if (!closed && !fatal && req === sseReq) reconnectAttempts = 0;
|
|
256
|
+
}, STABLE_STREAM_MS);
|
|
257
|
+
stableTimer.unref?.();
|
|
139
258
|
let buf = '';
|
|
140
259
|
res.on('data', (chunk) => {
|
|
260
|
+
if (req !== sseReq || closed) return;
|
|
141
261
|
buf += chunk;
|
|
142
262
|
let idx;
|
|
143
263
|
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
@@ -153,35 +273,67 @@ export async function attachToDaemon({
|
|
|
153
273
|
}
|
|
154
274
|
}
|
|
155
275
|
});
|
|
156
|
-
res.on('end', () =>
|
|
157
|
-
|
|
276
|
+
res.on('end', () => {
|
|
277
|
+
if (req === sseReq) {
|
|
278
|
+
clearStableTimer();
|
|
279
|
+
handleStreamLoss('sse ended');
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
res.on('error', () => {
|
|
283
|
+
if (req === sseReq) {
|
|
284
|
+
clearStableTimer();
|
|
285
|
+
handleStreamLoss('sse error');
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
req.on('error', () => {
|
|
290
|
+
if (req === sseReq) signalFatal('sse req error');
|
|
158
291
|
});
|
|
159
|
-
req.on('error', () => scheduleReconnect('sse req error'));
|
|
160
|
-
req.end();
|
|
161
292
|
sseReq = req;
|
|
293
|
+
req.end();
|
|
162
294
|
}
|
|
163
295
|
|
|
164
296
|
function scheduleReconnect(reason) {
|
|
165
297
|
if (closed || reconnectTimer) return;
|
|
166
298
|
if (++reconnectAttempts > MAX_RECONNECTS) {
|
|
167
|
-
|
|
168
|
-
closed = true;
|
|
169
|
-
try { sseReq?.destroy?.(); } catch {}
|
|
170
|
-
try { onFatal(reason); } catch {}
|
|
299
|
+
signalFatal(`giving up after ${reconnectAttempts} attempts (${reason})`);
|
|
171
300
|
return;
|
|
172
301
|
}
|
|
173
302
|
log(`sse reconnect scheduled (${reason}, attempt ${reconnectAttempts})`);
|
|
174
303
|
reconnectTimer = setTimeout(() => {
|
|
175
304
|
reconnectTimer = null;
|
|
305
|
+
if (closed || fatal) return;
|
|
176
306
|
// Re-register (the daemon may have pruned us) then reopen the stream.
|
|
177
307
|
// Re-register mints a fresh client token (the old one was pruned); adopt
|
|
178
308
|
// it so the reopened stream and subsequent calls target the live entry.
|
|
179
309
|
// reattach:true — a reconnect is NOT a new terminal, so it must NOT steal
|
|
180
310
|
// the ownership seat (last-wins is reserved for genuine fresh registers).
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
311
|
+
const generation = lifecycle;
|
|
312
|
+
const registrationId = reconnectRegistrationId ||= randomUUID();
|
|
313
|
+
const replaceToken = reconnectReplaceToken ||= clientToken;
|
|
314
|
+
const registration = request({
|
|
315
|
+
port, token: serverToken, method: 'POST', path: '/client/register',
|
|
316
|
+
body: {
|
|
317
|
+
leadPid, cwd, reattach: true, replaceToken,
|
|
318
|
+
registrationId,
|
|
319
|
+
}, timeoutMs: 3000,
|
|
320
|
+
}).then(async (r) => {
|
|
321
|
+
const freshToken = r?.token;
|
|
322
|
+
if (!freshToken) return false;
|
|
323
|
+
if (closed || fatal || generation !== lifecycle) {
|
|
324
|
+
await deregister(freshToken);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
clientToken = freshToken;
|
|
328
|
+
reconnectRegistrationId = null;
|
|
329
|
+
reconnectReplaceToken = null;
|
|
330
|
+
return true;
|
|
331
|
+
}).catch(() => false);
|
|
332
|
+
reconnectRegistration = registration;
|
|
333
|
+
void registration.finally(() => {
|
|
334
|
+
if (reconnectRegistration === registration) reconnectRegistration = null;
|
|
335
|
+
if (!closed && !fatal && generation === lifecycle) openStream();
|
|
336
|
+
});
|
|
185
337
|
}, 1000);
|
|
186
338
|
reconnectTimer.unref?.();
|
|
187
339
|
}
|
|
@@ -213,13 +365,23 @@ export async function attachToDaemon({
|
|
|
213
365
|
}
|
|
214
366
|
|
|
215
367
|
async function close(reason = 'client close') {
|
|
368
|
+
if (closePromise) return closePromise;
|
|
216
369
|
closed = true;
|
|
370
|
+
lifecycle++;
|
|
217
371
|
if (reconnectTimer) { try { clearTimeout(reconnectTimer); } catch {} reconnectTimer = null; }
|
|
372
|
+
clearStableTimer();
|
|
218
373
|
try { sseReq?.destroy?.(); } catch {}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
374
|
+
// A re-register already in flight can mint a fresh token after close().
|
|
375
|
+
// Await it so its stale branch deregisters that token before close resolves.
|
|
376
|
+
const pendingRegistration = reconnectRegistration;
|
|
377
|
+
const registrationId = reconnectRegistrationId;
|
|
378
|
+
const replaceToken = reconnectReplaceToken;
|
|
379
|
+
closePromise = (async () => {
|
|
380
|
+
if (pendingRegistration) await pendingRegistration;
|
|
381
|
+
await deregister(clientToken, { registrationId, replaceToken });
|
|
382
|
+
log(`detached (${reason})`);
|
|
383
|
+
})();
|
|
384
|
+
return closePromise;
|
|
223
385
|
}
|
|
224
386
|
|
|
225
387
|
return { call, close, clientToken, port };
|