mixdog 0.9.51 → 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/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 +324 -3
- 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 +320 -1
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +5 -7
- 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/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 +130 -47
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
- 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 +122 -63
- 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/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/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -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/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 +6 -3
- 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/tui/dist/index.mjs +73 -278
- 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
|
@@ -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 };
|
|
@@ -46,6 +46,7 @@ export function createChannelDaemonTransport({
|
|
|
46
46
|
onClientsEmpty = null,
|
|
47
47
|
getStatus = () => ({}),
|
|
48
48
|
dispatchBind = null,
|
|
49
|
+
registrationReplayTtlMs = 60_000,
|
|
49
50
|
} = {}) {
|
|
50
51
|
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
51
52
|
|
|
@@ -72,6 +73,11 @@ export function createChannelDaemonTransport({
|
|
|
72
73
|
// retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
|
|
73
74
|
const callCache = new Map();
|
|
74
75
|
const CALL_CACHE_TTL_MS = 60_000;
|
|
76
|
+
// Reconnect register replay: a server may commit replacement just before its
|
|
77
|
+
// HTTP response is lost. The retry supplies this stable id and receives the
|
|
78
|
+
// already-created fresh token instead of creating an orphan replacement.
|
|
79
|
+
const registrationReplays = new Map(); // registrationId -> { token, leadPid, cwd, replaceToken, responseFinished }
|
|
80
|
+
const registrationReplayTtl = Math.max(1, Number(registrationReplayTtlMs) || 60_000);
|
|
75
81
|
let server = null;
|
|
76
82
|
let graceTimer = null;
|
|
77
83
|
let sweepTimer = null;
|
|
@@ -103,8 +109,7 @@ export function createChannelDaemonTransport({
|
|
|
103
109
|
const c = clients.get(token);
|
|
104
110
|
if (!c) return;
|
|
105
111
|
const wasPointer = pointerToken === token;
|
|
106
|
-
|
|
107
|
-
try { c.sse?.end?.(); } catch {}
|
|
112
|
+
removeClientRecord(token);
|
|
108
113
|
if (pointerToken === token) pointerToken = null;
|
|
109
114
|
log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
|
|
110
115
|
// Pointer client death → hand the seat to a survivor (last-wins among the
|
|
@@ -115,6 +120,62 @@ export function createChannelDaemonTransport({
|
|
|
115
120
|
maybeArmGrace('client removed');
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
function removeRegistrationReplay(registrationId, replay = registrationReplays.get(registrationId)) {
|
|
124
|
+
if (!replay || registrationReplays.get(registrationId) !== replay) return;
|
|
125
|
+
registrationReplays.delete(registrationId);
|
|
126
|
+
try { clearTimeout(replay.timer); } catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Every removal path uses this primitive, including replacement's deliberate
|
|
130
|
+
// no-failover retirement, so replay records/timers never target absent tokens.
|
|
131
|
+
function removeClientRecord(token) {
|
|
132
|
+
const c = clients.get(token);
|
|
133
|
+
if (!c) return null;
|
|
134
|
+
clients.delete(token);
|
|
135
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
136
|
+
if (replay.token === token) removeRegistrationReplay(registrationId, replay);
|
|
137
|
+
}
|
|
138
|
+
try { c.sse?.end?.(); } catch {}
|
|
139
|
+
return c;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearRegistrationReplays() {
|
|
143
|
+
for (const [registrationId, replay] of registrationReplays) removeRegistrationReplay(registrationId, replay);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function armRegistrationReplay(replayId, replay) {
|
|
147
|
+
try { clearTimeout(replay.timer); } catch {}
|
|
148
|
+
replay.timer = setTimeout(() => {
|
|
149
|
+
if (registrationReplays.get(replayId) !== replay) return;
|
|
150
|
+
// A successfully flushed register response creates a valid client even
|
|
151
|
+
// if its SSE/call is delayed. TTL only bounds cancellation metadata.
|
|
152
|
+
removeRegistrationReplay(replayId, replay);
|
|
153
|
+
if (!replay.responseFinished && clients.has(replay.token)) {
|
|
154
|
+
dropClient(replay.token, 'unflushed registration replay expired');
|
|
155
|
+
}
|
|
156
|
+
}, registrationReplayTtl);
|
|
157
|
+
replay.timer.unref?.();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function markRegistrationResponseFinished(registrationId, token) {
|
|
161
|
+
const replay = registrationId ? registrationReplays.get(registrationId) : null;
|
|
162
|
+
if (replay && replay.token === token) replay.responseFinished = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A response-loss close knows only its retired token + stable registration id.
|
|
166
|
+
// Bind cancellation to every logical-client field before retiring the fresh
|
|
167
|
+
// token; malformed/mismatched cancellation can never affect another client.
|
|
168
|
+
function cancelReplacementRegistration({ token, registrationId, replaceToken, leadPid, cwd }) {
|
|
169
|
+
const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
|
|
170
|
+
const replay = replayId ? registrationReplays.get(replayId) : null;
|
|
171
|
+
if (!replay) return 'missing';
|
|
172
|
+
const retiredToken = token ? String(token) : null;
|
|
173
|
+
if (retiredToken !== replay.replaceToken || String(replaceToken || '') !== replay.replaceToken ||
|
|
174
|
+
parsePid(leadPid) !== replay.leadPid || (cwd || null) !== replay.cwd) return 'forbidden';
|
|
175
|
+
dropClient(replay.token, 'replacement deregister');
|
|
176
|
+
return 'cancelled';
|
|
177
|
+
}
|
|
178
|
+
|
|
118
179
|
// Pointer failover on owner death. Move the pointer to the most-recently-seen
|
|
119
180
|
// LIVE client (reason 'failover'), deliver the sticky 'acquired' badge to it
|
|
120
181
|
// (pending-buffered if it has no SSE yet), and re-dispatch ITS stored bind
|
|
@@ -304,8 +365,25 @@ export function createChannelDaemonTransport({
|
|
|
304
365
|
}
|
|
305
366
|
}
|
|
306
367
|
|
|
307
|
-
function registerClient({ leadPid, cwd, reattach = false }) {
|
|
368
|
+
function registerClient({ leadPid, cwd, reattach = false, replaceToken = null, registrationId = null }) {
|
|
308
369
|
const pid = parsePid(leadPid) ?? 0;
|
|
370
|
+
const replacementToken = replaceToken ? String(replaceToken) : null;
|
|
371
|
+
const replayId = reattach && registrationId ? String(registrationId).slice(0, 200) : null;
|
|
372
|
+
const existingReplay = replayId ? registrationReplays.get(replayId) : null;
|
|
373
|
+
if (existingReplay) {
|
|
374
|
+
if (existingReplay.leadPid === pid && existingReplay.cwd === (cwd || null) &&
|
|
375
|
+
existingReplay.replaceToken === replacementToken && clients.has(existingReplay.token)) {
|
|
376
|
+
armRegistrationReplay(replayId, existingReplay);
|
|
377
|
+
log(`client reconnect replay token=${existingReplay.token} lead=${pid}`);
|
|
378
|
+
return existingReplay.token;
|
|
379
|
+
}
|
|
380
|
+
if (clients.has(existingReplay.token)) {
|
|
381
|
+
const err = new Error('registration replay identity mismatch');
|
|
382
|
+
err.statusCode = 409;
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
removeRegistrationReplay(replayId, existingReplay);
|
|
386
|
+
}
|
|
309
387
|
const token = randomUUID();
|
|
310
388
|
clients.set(token, {
|
|
311
389
|
token,
|
|
@@ -320,6 +398,34 @@ export function createChannelDaemonTransport({
|
|
|
320
398
|
cancelGrace();
|
|
321
399
|
startSweep();
|
|
322
400
|
log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
|
|
401
|
+
const rememberReplacement = (freshToken) => {
|
|
402
|
+
if (!replayId) return freshToken;
|
|
403
|
+
const replay = {
|
|
404
|
+
token: freshToken, leadPid: pid, cwd: cwd || null, replaceToken: replacementToken,
|
|
405
|
+
responseFinished: false, timer: null,
|
|
406
|
+
};
|
|
407
|
+
registrationReplays.set(replayId, replay);
|
|
408
|
+
armRegistrationReplay(replayId, replay);
|
|
409
|
+
return freshToken;
|
|
410
|
+
};
|
|
411
|
+
// A reconnect names the exact token it replaces. Retire it even when it is
|
|
412
|
+
// not the pointer: retaining a non-owner old token would let it later call,
|
|
413
|
+
// steal ownership, or accumulate a buffered frame after the fresh client
|
|
414
|
+
// has gone away. Token replacement never crosses leadPid boundaries.
|
|
415
|
+
const replaced = reattach && replacementToken ? clients.get(replacementToken) : null;
|
|
416
|
+
if (replaced && replaced.leadPid === pid) {
|
|
417
|
+
const fresh = clients.get(token);
|
|
418
|
+
const replacedWasPointer = pointerToken === replaced.token;
|
|
419
|
+
// Token-scoped state belongs to the logical client, not only the owner.
|
|
420
|
+
// A non-owner can hold a buffered superseded frame or stored bind intent.
|
|
421
|
+
if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
|
|
422
|
+
if (replaced.lastBind) fresh.lastBind = replaced.lastBind;
|
|
423
|
+
if (replacedWasPointer) pointerToken = token;
|
|
424
|
+
removeClientRecord(replaced.token);
|
|
425
|
+
log(`client reconnect replaced token=${replaced.token} -> ${token} lead=${pid}`);
|
|
426
|
+
if (replacedWasPointer) redispatchPointerBind(fresh, 'reconnect');
|
|
427
|
+
return rememberReplacement(token);
|
|
428
|
+
}
|
|
323
429
|
// Pointer-follows-pid: if the current pointer client shares this leadPid AND
|
|
324
430
|
// has no live SSE, it is the SAME terminal re-registering after its stream
|
|
325
431
|
// dropped. That old entry is a dead-stream blackhole — pid-prune never reaps
|
|
@@ -335,15 +441,14 @@ export function createChannelDaemonTransport({
|
|
|
335
441
|
const fresh = clients.get(token);
|
|
336
442
|
if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
|
|
337
443
|
if (old.lastBind) fresh.lastBind = old.lastBind;
|
|
338
|
-
|
|
339
|
-
try { old.sse?.end?.(); } catch {}
|
|
444
|
+
removeClientRecord(pointerToken);
|
|
340
445
|
pointerToken = token;
|
|
341
446
|
log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
|
|
342
447
|
// The fresh token inherited the old entry's bind intent but never went
|
|
343
448
|
// through movePointer — re-dispatch it so the forwarder rebinds to this
|
|
344
449
|
// reconnected terminal's transcript (guarded/no-op when no lastBind).
|
|
345
450
|
redispatchPointerBind(fresh, 'reconnect');
|
|
346
|
-
return token;
|
|
451
|
+
return rememberReplacement(token);
|
|
347
452
|
}
|
|
348
453
|
}
|
|
349
454
|
if (reattach) {
|
|
@@ -356,12 +461,17 @@ export function createChannelDaemonTransport({
|
|
|
356
461
|
// ownership seat, and the displaced owner is told it lost (superseded).
|
|
357
462
|
movePointer(token, 'register');
|
|
358
463
|
}
|
|
359
|
-
return token;
|
|
464
|
+
return rememberReplacement(token);
|
|
360
465
|
}
|
|
361
466
|
|
|
362
467
|
function attachSse(token, res) {
|
|
363
468
|
const c = clients.get(token);
|
|
364
469
|
if (!c) return false;
|
|
470
|
+
// A live stream proves the client learned its fresh token, so response-loss
|
|
471
|
+
// cancellation is no longer needed for this logical registration.
|
|
472
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
473
|
+
if (replay.token === token) removeRegistrationReplay(registrationId, replay);
|
|
474
|
+
}
|
|
365
475
|
res.writeHead(200, {
|
|
366
476
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
367
477
|
'Cache-Control': 'no-cache, no-transform',
|
|
@@ -417,12 +527,23 @@ export function createChannelDaemonTransport({
|
|
|
417
527
|
|
|
418
528
|
if (req.method === 'POST' && pathName === '/client/register') {
|
|
419
529
|
const body = await readBody(req);
|
|
420
|
-
const clientToken = registerClient({
|
|
530
|
+
const clientToken = registerClient({
|
|
531
|
+
leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true,
|
|
532
|
+
replaceToken: body.replaceToken, registrationId: body.registrationId,
|
|
533
|
+
});
|
|
534
|
+
const replayId = body.reattach === true && body.registrationId ? String(body.registrationId).slice(0, 200) : null;
|
|
535
|
+
res.once('finish', () => markRegistrationResponseFinished(replayId, clientToken));
|
|
421
536
|
sendJson(res, { token: clientToken, pid: process.pid });
|
|
422
537
|
return;
|
|
423
538
|
}
|
|
424
539
|
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
425
540
|
const body = await readBody(req);
|
|
541
|
+
if (body.registrationId) {
|
|
542
|
+
const cancelled = cancelReplacementRegistration(body);
|
|
543
|
+
if (cancelled === 'forbidden') { sendError(res, 'forbidden replacement deregister', 403); return; }
|
|
544
|
+
sendJson(res, { ok: true, cancelled: cancelled === 'cancelled' });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
426
547
|
if (body.token) dropClient(body.token, 'deregister');
|
|
427
548
|
sendJson(res, { ok: true });
|
|
428
549
|
return;
|
|
@@ -436,7 +557,11 @@ export function createChannelDaemonTransport({
|
|
|
436
557
|
const body = await readBody(req);
|
|
437
558
|
const clientToken = body.token || null;
|
|
438
559
|
const c = clientToken ? clients.get(clientToken) : null;
|
|
439
|
-
if (c)
|
|
560
|
+
if (!c) { sendError(res, 'unknown client token', 404); return; }
|
|
561
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
562
|
+
if (replay.token === clientToken) removeRegistrationReplay(registrationId, replay);
|
|
563
|
+
}
|
|
564
|
+
c.lastSeen = nowMs();
|
|
440
565
|
const name = String(body.name || '');
|
|
441
566
|
// Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
|
|
442
567
|
// notify emitted synchronously during the call already targets it.
|
|
@@ -526,6 +651,7 @@ export function createChannelDaemonTransport({
|
|
|
526
651
|
cancelGrace();
|
|
527
652
|
if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
|
|
528
653
|
for (const [token] of clients) dropClient(token, 'transport stop');
|
|
654
|
+
clearRegistrationReplays();
|
|
529
655
|
if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
|
|
530
656
|
if (server) {
|
|
531
657
|
await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
|
|
@@ -540,6 +666,7 @@ export function createChannelDaemonTransport({
|
|
|
540
666
|
get port() { return boundPort; },
|
|
541
667
|
get token() { return serverToken; },
|
|
542
668
|
_clientsForTest: clients,
|
|
669
|
+
_registrationReplaysForTest: registrationReplays,
|
|
543
670
|
_resolveTargetForTest: resolveTarget,
|
|
544
671
|
};
|
|
545
672
|
}
|