mixdog 0.9.37 → 0.9.38
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 +1 -1
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench.mjs +65 -8
- package/scripts/explore-prompt-policy-test.mjs +6 -2
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/session-sweep.mjs +266 -0
- package/src/rules/agent/30-explorer.md +35 -17
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +43 -8
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/dist/index.mjs +16 -1
- package/src/tui/engine/agent-job-feed.mjs +10 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-flow.mjs +15 -1
- package/src/tui/engine/turn.mjs +6 -1
|
@@ -12,10 +12,13 @@ Coordinate locator. Deliver WHERE as `path:line`, never WHY. You ARE
|
|
|
12
12
|
Tools: grep/find/glob/code_graph ONLY. `read`/`list` are forbidden with no
|
|
13
13
|
exception; grep/code_graph lines already carry the `path:line` answer.
|
|
14
14
|
|
|
15
|
-
Turn 1 is the
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
Turn 1 is the WHOLE search in ONE message, non-negotiable: in that single tool
|
|
16
|
+
message fire grep `pattern[]` (3-6 code-token variants) AND code_graph
|
|
17
|
+
symbol_search (identifiers) AND — for unknown/broad targets — find `query[]`
|
|
18
|
+
(path/name fragments from multiple tokens), all together. Never emit grep alone
|
|
19
|
+
and wait for its result before adding code_graph/find: serial one-tool-per-turn
|
|
20
|
+
is the top budget defect and forfeits the expected turn 1 -> answer path. A
|
|
21
|
+
single-pattern or single-tool first turn is malformed.
|
|
19
22
|
|
|
20
23
|
Broad grep must use `output_mode:"files_with_matches"`. Use
|
|
21
24
|
`content_with_context` only on a path returned earlier in THIS session and with
|
|
@@ -24,14 +27,15 @@ Broad grep must use `output_mode:"files_with_matches"`. Use
|
|
|
24
27
|
Translate natural/non-English queries to probable English identifiers first;
|
|
25
28
|
grep non-ASCII only for quoted literal strings.
|
|
26
29
|
|
|
27
|
-
Scope = session working directory.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
Scope = session working directory. Omitting `path` (project cwd default scope)
|
|
31
|
+
is always allowed. When the query or plan names an unverified path/name fragment
|
|
32
|
+
(`src`, `lib`, package/file stem, etc.), its `find query[]` rides the SAME turn-1
|
|
33
|
+
batch as the unscoped `grep pattern[]`/`code_graph` — a find-only turn is a
|
|
34
|
+
defect. A scoped `grep`/`glob` may use that fragment only via an exact
|
|
35
|
+
`find`-returned path (turn 2 recovery at the earliest), never a guess. Never use
|
|
36
|
+
`path:"."` with guessed globs (`src/**`, `lib/**`, etc.) to mask misses,
|
|
37
|
+
especially from a home or machine-wide cwd. Never invent directories; after zero
|
|
38
|
+
hits change TOKENS/scope, not wording or guessed paths.
|
|
35
39
|
|
|
36
40
|
Hit test is mechanical: any `path:line` containing a query token or obvious
|
|
37
41
|
synonym is an anchor; generic-only words (schema/handler/config/resolver…)
|
|
@@ -45,12 +49,26 @@ matched zero specific tokens); a turn spent to confirm, refine, or upgrade an
|
|
|
45
49
|
anchor you already hold is a defect.
|
|
46
50
|
|
|
47
51
|
Turns: max 3, expected 1; start tool messages with `turn N/3`. Turns 2-3 are
|
|
48
|
-
miss recovery only and must change tokens or scope.
|
|
52
|
+
miss recovery only and must change tokens or scope. BUDGET = TWO MESSAGES
|
|
53
|
+
normally: message 1 = the multi-tool batch, message 2 = your answer text. A 3rd
|
|
54
|
+
message (any extra tool call) is a defect unless message 1 returned zero
|
|
55
|
+
specific-token lines — extra code_graph/grep calls to confirm or upgrade an
|
|
56
|
+
anchor you already hold are the biggest source of overspend.
|
|
49
57
|
|
|
50
58
|
Flow/how and compound queries: first matching entry/definition anchors answer
|
|
51
|
-
the concept/value/default;
|
|
59
|
+
the concept/value/default; do not trace chains or launch extra value searches —
|
|
60
|
+
with ONE exception: when the query EXPLICITLY asks a flow/default-resolution
|
|
61
|
+
question and turn 1 produced only an entry anchor (not the resolved value),
|
|
62
|
+
turn 2 may follow a SINGLE hop to the resolving site, then stop.
|
|
52
63
|
|
|
53
64
|
Answer only: up to 3 lines `path:line — symbol — short reason` (`?` if weak),
|
|
54
|
-
choosing the most specific token matches.
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
choosing the most specific token matches. For a CODE-location answer every line
|
|
66
|
+
MUST carry a `:line` (explicit line number) — a bare filename with no `:line`
|
|
67
|
+
is a defect; with no line-anchored code evidence, return `EXPLORATION_FAILED`
|
|
68
|
+
rather than a vague file-only or prose answer. EXCEPTION — file/dir-location
|
|
69
|
+
queries (where X stores its config/logs/data on disk, which directory or file
|
|
70
|
+
holds Y): an exact verified path (the file or directory itself, no `:line`) IS
|
|
71
|
+
the valid answer; do not force a line number or fail. Emit `EXPLORATION_FAILED`
|
|
72
|
+
only after budget is spent with zero specific-token anchors; before failing,
|
|
73
|
+
re-scan prior results and prefer any weak specific-token anchor over a false
|
|
74
|
+
miss.
|
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
error origin, ...) and send them as ONE `query[]` call — facets run in
|
|
17
17
|
parallel. Never fan out rephrasings of the same target; on
|
|
18
18
|
EXPLORATION_FAILED, retry once with changed tokens.
|
|
19
|
-
- Verified
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
- Verified = user-provided, tool-returned, or the session project cwd itself.
|
|
20
|
+
Unscoped grep/glob/list from the project root needs NO find hop; find only
|
|
21
|
+
resolves a genuinely guessed path-name fragment, and it rides the SAME turn
|
|
22
|
+
as independent probes (unscoped grep, code_graph) — never a solo
|
|
23
|
+
path-verification turn. ENOENT → `find` basename, never retry a guess; don't
|
|
24
|
+
mask a miss with a guessed glob scope (path "." + `src/**`) or an invented
|
|
25
|
+
absolute path, but plain project-root searches are fine.
|
|
22
26
|
- Retrieval stops when evidence covers the deliverable: single-answer tasks
|
|
23
27
|
end at the first sufficient anchor; enumeration tasks (review, audit) end
|
|
24
28
|
when the stated scope is covered. Never re-verify a hit already on screen;
|
|
@@ -372,6 +372,27 @@ export function recoverPending(dataDir, notifyFn, { sessionId, priorSessionId, c
|
|
|
372
372
|
const entry = map[handle] || {};
|
|
373
373
|
const tool = entry.tool || 'dispatch';
|
|
374
374
|
const queries = Array.isArray(entry.queries) ? entry.queries : [];
|
|
375
|
+
// Determine the true owner session for this entry. A scoped recovery
|
|
376
|
+
// may have matched purely on clientHostPid (not on the owner session
|
|
377
|
+
// id); in that case we must NOT stamp the reconnecting filter session's
|
|
378
|
+
// id onto another session's abort — that injects an old-session abort
|
|
379
|
+
// into the wrong resumed session. Deliver to the true owner session, or
|
|
380
|
+
// leave the entry persisted when it carries no owner session to target.
|
|
381
|
+
const cid = entry.callerSessionId != null && String(entry.callerSessionId)
|
|
382
|
+
? String(entry.callerSessionId)
|
|
383
|
+
: null;
|
|
384
|
+
const ownerMatch = cid != null && (cid === filterSid || (priorSid != null && cid === priorSid));
|
|
385
|
+
if (scoped && !ownerMatch && cid == null) {
|
|
386
|
+
// hostPid-only match with no owner session id — cannot target a
|
|
387
|
+
// session safely. Leave persisted for a correctly-scoped recovery.
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// Owner match → prefer the reconnecting filter session id (the owner's
|
|
391
|
+
// new session). When only priorSessionId matched and no current
|
|
392
|
+
// sessionId was supplied, filterSid is null — keep the entry's known
|
|
393
|
+
// owner `cid` for stamping/ack scoping rather than dropping it.
|
|
394
|
+
// Non-owner matches (hostPid-only) always stamp the entry's true owner.
|
|
395
|
+
const stampSid = (ownerMatch && filterSid) ? filterSid : cid;
|
|
375
396
|
// Single recovery mode: the worker was in flight at restart. Emit the
|
|
376
397
|
// Aborted boilerplate so the Lead can retry. Completed result bodies are
|
|
377
398
|
// never persisted, so there is nothing to replay here.
|
|
@@ -383,21 +404,23 @@ export function recoverPending(dataDir, notifyFn, { sessionId, priorSessionId, c
|
|
|
383
404
|
dispatch_id: handle,
|
|
384
405
|
tool,
|
|
385
406
|
error: String(isError),
|
|
386
|
-
...(
|
|
387
|
-
? { caller_session_id: filterSid }
|
|
388
|
-
: (entry.callerSessionId ? { caller_session_id: entry.callerSessionId } : {})),
|
|
407
|
+
...(stampSid ? { caller_session_id: stampSid } : {}),
|
|
389
408
|
...(filterHostPid > 0
|
|
390
409
|
? { client_host_pid: String(filterHostPid) }
|
|
391
410
|
: (entry.clientHostPid > 0 ? { client_host_pid: String(entry.clientHostPid) } : {})),
|
|
392
411
|
instruction: `Earlier ${tool} dispatch (${handle}) was aborted by a plugin restart. Retry if the answer is still needed.`,
|
|
393
412
|
};
|
|
394
413
|
try { process.stderr.write(`[dispatch-persist] recover handle=${handle} tool=${tool} kind=abort\n`); } catch { /* best-effort */ }
|
|
395
|
-
// Entry remains on disk until notifyFn
|
|
396
|
-
//
|
|
397
|
-
//
|
|
414
|
+
// Entry remains on disk until notifyFn settles as DELIVERED. Matching
|
|
415
|
+
// notifyToolCompletion settlement semantics (tool-execution-contract),
|
|
416
|
+
// only an explicit `false`/`0` resolve counts as undelivered and keeps
|
|
417
|
+
// the entry for retry; any other resolve (including `undefined`/void
|
|
418
|
+
// from a delivered notifyFn) removes it — otherwise it re-fires until
|
|
419
|
+
// TTL. A crash between fire and ack is likewise safe: the entry survives
|
|
420
|
+
// and recoverPending re-fires it on the next restart.
|
|
398
421
|
try {
|
|
399
|
-
Promise.resolve(notifyFn(content, meta)).then(() => {
|
|
400
|
-
removePending(dataDir, handle);
|
|
422
|
+
Promise.resolve(notifyFn(content, meta)).then((ok) => {
|
|
423
|
+
if (ok !== false && ok !== 0) removePending(dataDir, handle);
|
|
401
424
|
}).catch(() => { /* best-effort — entry stays for next recoverPending */ });
|
|
402
425
|
} catch { /* best-effort */ }
|
|
403
426
|
}
|
|
@@ -411,6 +411,14 @@ export async function beginOAuthLogin() {
|
|
|
411
411
|
};
|
|
412
412
|
const url = buildUrl(OAUTH_REDIRECT_URI);
|
|
413
413
|
const manualUrl = buildUrl(OAUTH_MANUAL_REDIRECT_URI);
|
|
414
|
+
const openLoginUrl = async (targetUrl, label = 'login') => {
|
|
415
|
+
try {
|
|
416
|
+
const { openInBrowser } = await import('../../../shared/open-url.mjs');
|
|
417
|
+
openInBrowser(targetUrl.toString());
|
|
418
|
+
} catch (err) {
|
|
419
|
+
process.stderr.write(`[anthropic-oauth] browser open failed for ${label} URL: ${String(err?.message || err).slice(0, 200)}\n`);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
414
422
|
|
|
415
423
|
let server = null;
|
|
416
424
|
let timeout = null;
|
|
@@ -454,14 +462,12 @@ export async function beginOAuthLogin() {
|
|
|
454
462
|
timeout = setTimeout(() => finish(null), OAUTH_LOGIN_TIMEOUT_MS);
|
|
455
463
|
server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST, async () => {
|
|
456
464
|
process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\nIf the localhost callback cannot complete, open this manual URL and paste the shown code#state:\n${manualUrl.toString()}\n\n`);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
}
|
|
465
|
+
await openLoginUrl(url, 'callback');
|
|
466
|
+
});
|
|
467
|
+
server.on('error', async (err) => {
|
|
468
|
+
process.stderr.write(`\n[anthropic-oauth] localhost callback unavailable on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}\n[anthropic-oauth] Opening manual login URL instead. Paste the shown code#state:\n${manualUrl.toString()}\n\n`);
|
|
469
|
+
await openLoginUrl(manualUrl, 'manual');
|
|
463
470
|
});
|
|
464
|
-
server.on('error', (err) => finish(null, new Error(`[anthropic-oauth] callback server failed on ${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}: ${err?.message || err}`)));
|
|
465
471
|
});
|
|
466
472
|
|
|
467
473
|
return {
|
|
@@ -897,12 +897,12 @@ export async function sendViaWebSocket({
|
|
|
897
897
|
|
|
898
898
|
// Warmup writes the same prefix with generate:false, but the first
|
|
899
899
|
// real response must still be a FULL generating frame. Reusing the
|
|
900
|
-
// warmup response_id here
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
900
|
+
// warmup response_id here would make _computeDelta reduce the frame
|
|
901
|
+
// input to [] when the warmup input matches, and a generate:false
|
|
902
|
+
// warmup is not a chainable response to continue from — so the first
|
|
903
|
+
// real turn would generate from an empty frame. Keep the warmup state
|
|
904
|
+
// for cache/trace, but compute the main frame as cold (full input +
|
|
905
|
+
// instructions).
|
|
906
906
|
const deltaEntry = warmupResult
|
|
907
907
|
? {
|
|
908
908
|
...entry,
|
|
@@ -195,9 +195,11 @@ const TRANSPORT_ONLY_FRAME_FIELDS = new Set(['stream', 'background']);
|
|
|
195
195
|
// identical byte-for-byte: `type` always leads, then the body's codex
|
|
196
196
|
// struct-order keys follow verbatim. A delta send passes previousResponseId
|
|
197
197
|
// (inserted immediately before `input`, matching codex's refs position) and
|
|
198
|
-
// inputOverride (the stripped tail)
|
|
199
|
-
// previous_response_id
|
|
200
|
-
//
|
|
198
|
+
// inputOverride (the stripped tail). `instructions` MUST still be resent on
|
|
199
|
+
// previous_response_id frames: per the OpenAI Responses API, the previous
|
|
200
|
+
// response's top-level instructions are NOT carried over to the chained
|
|
201
|
+
// response, so dropping them here strips the system/lead prompt from every
|
|
202
|
+
// continuation turn. Only an empty instructions string is omitted.
|
|
201
203
|
// Full/warmup frames pass the body unchanged and keep every key in place.
|
|
202
204
|
// omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
|
|
203
205
|
export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
|
|
@@ -215,7 +217,6 @@ export function _buildResponseCreateFrame(body, { previousResponseId = null, inp
|
|
|
215
217
|
for (const key of Object.keys(src)) {
|
|
216
218
|
if (omitTransportFields && TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
|
|
217
219
|
if (key === 'instructions') {
|
|
218
|
-
if (previousResponseId != null) continue;
|
|
219
220
|
const instr = src.instructions;
|
|
220
221
|
if (typeof instr === 'string' && instr.length) frame.instructions = instr;
|
|
221
222
|
continue;
|
|
@@ -15,6 +15,10 @@ import { codexOriginator, codexUserAgent, codexVersionHeader } from './codex-cli
|
|
|
15
15
|
import {
|
|
16
16
|
PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
|
|
17
17
|
PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
|
|
18
|
+
PROVIDER_WS_PING_ENABLED,
|
|
19
|
+
PROVIDER_WS_PING_INTERVAL_MS,
|
|
20
|
+
PROVIDER_WS_PONG_TIMEOUT_MS,
|
|
21
|
+
PROVIDER_WS_LIVENESS_STALE_MS,
|
|
18
22
|
resolveTimeoutMs,
|
|
19
23
|
} from '../stall-policy.mjs';
|
|
20
24
|
|
|
@@ -36,6 +40,10 @@ export const WS_IDLE_MS = resolveTimeoutMs(
|
|
|
36
40
|
);
|
|
37
41
|
const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
|
|
38
42
|
const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
|
|
43
|
+
const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
|
|
44
|
+
const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
|
|
45
|
+
const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
|
|
46
|
+
const WS_PING_ENABLED = PROVIDER_WS_PING_ENABLED;
|
|
39
47
|
|
|
40
48
|
// WS socket pool buckets are keyed by `poolKey` (the per-call sessionId)
|
|
41
49
|
// to isolate parallel agent invocations — each gets its own socket so
|
|
@@ -263,6 +271,10 @@ function _getPoolArr(poolKey) {
|
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
function _removeFromPool(poolKey, entry) {
|
|
274
|
+
// Always tear down per-entry timers so evicting a socket never leaks an
|
|
275
|
+
// idle-close or liveness-ping interval.
|
|
276
|
+
_clearIdle(entry);
|
|
277
|
+
_clearLiveness(entry);
|
|
266
278
|
if (!poolKey) return;
|
|
267
279
|
const arr = _wsPool.get(poolKey);
|
|
268
280
|
if (!arr) return;
|
|
@@ -293,6 +305,70 @@ function _isOpen(entry) {
|
|
|
293
305
|
return entry?.socket?.readyState === WebSocket.OPEN;
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
function _clearLiveness(entry) {
|
|
309
|
+
if (entry?.pingTimer) {
|
|
310
|
+
clearInterval(entry.pingTimer);
|
|
311
|
+
entry.pingTimer = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Force a dead/half-open socket out of the pool. close() alone can hang on a
|
|
316
|
+
// wedged socket, so follow with terminate() to guarantee FD release.
|
|
317
|
+
function _evictDead(poolKey, entry) {
|
|
318
|
+
try { entry.socket.close(1000, 'ws_liveness_dead'); } catch {}
|
|
319
|
+
try { entry.socket.terminate?.(); } catch {}
|
|
320
|
+
_removeFromPool(poolKey, entry);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Send one ws-level ping and resolve true iff a pong lands within timeoutMs.
|
|
324
|
+
// Never rejects. On any success it refreshes lastAliveAt so the caller/loop
|
|
325
|
+
// treats the socket as fresh.
|
|
326
|
+
function _pingProbe(entry, timeoutMs) {
|
|
327
|
+
return new Promise((resolve) => {
|
|
328
|
+
const socket = entry?.socket;
|
|
329
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) { resolve(false); return; }
|
|
330
|
+
let done = false;
|
|
331
|
+
const finish = (alive) => {
|
|
332
|
+
if (done) return;
|
|
333
|
+
done = true;
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
try { socket.removeListener('pong', onPong); } catch {}
|
|
336
|
+
resolve(alive);
|
|
337
|
+
};
|
|
338
|
+
const onPong = () => { entry.lastAliveAt = Date.now(); finish(true); };
|
|
339
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
340
|
+
try { timer.unref?.(); } catch {}
|
|
341
|
+
try {
|
|
342
|
+
socket.on('pong', onPong);
|
|
343
|
+
socket.ping();
|
|
344
|
+
} catch {
|
|
345
|
+
finish(false);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// While an entry sits idle in the pool, ping it every WS_PING_INTERVAL_MS.
|
|
351
|
+
// A missed pong (or a socket that is no longer OPEN) evicts the entry so it can
|
|
352
|
+
// never be handed out dead. Busy entries are skipped — an in-flight turn has
|
|
353
|
+
// its own inter-chunk/semantic-idle watchdogs.
|
|
354
|
+
function _armLiveness(poolKey, entry) {
|
|
355
|
+
_clearLiveness(entry);
|
|
356
|
+
entry.pingTimer = setInterval(async () => {
|
|
357
|
+
if (entry.busy || entry.closing || entry.probing) return;
|
|
358
|
+
if (!_isOpen(entry)) { _evictDead(poolKey, entry); return; }
|
|
359
|
+
// Recent activity ⇒ assume live, skip the probe this tick.
|
|
360
|
+
if (Date.now() - (entry.lastAliveAt || 0) < WS_LIVENESS_STALE_MS) return;
|
|
361
|
+
entry.probing = true;
|
|
362
|
+
try {
|
|
363
|
+
const alive = await _pingProbe(entry, WS_PONG_TIMEOUT_MS);
|
|
364
|
+
if (!alive && !entry.busy) _evictDead(poolKey, entry);
|
|
365
|
+
} finally {
|
|
366
|
+
entry.probing = false;
|
|
367
|
+
}
|
|
368
|
+
}, WS_PING_INTERVAL_MS);
|
|
369
|
+
try { entry.pingTimer.unref?.(); } catch {}
|
|
370
|
+
}
|
|
371
|
+
|
|
296
372
|
// Awaited frame send. Asserts the socket is OPEN and resolves only after
|
|
297
373
|
// the underlying transport reports the buffered write succeeded (or fails)
|
|
298
374
|
// via the WebSocket send callback. Raw `socket.send(JSON.stringify(...))`
|
|
@@ -596,15 +672,36 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
596
672
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
597
673
|
if (!_isOpen(arr[i]) || arr[i].closing) {
|
|
598
674
|
_clearIdle(arr[i]);
|
|
675
|
+
_clearLiveness(arr[i]);
|
|
599
676
|
arr.splice(i, 1);
|
|
600
677
|
}
|
|
601
678
|
}
|
|
602
679
|
if (arr.length === 0) _wsPool.delete(poolKey);
|
|
603
|
-
// Reuse
|
|
604
|
-
|
|
605
|
-
|
|
680
|
+
// Reuse an idle open entry (cache-warm path). An entry with no observed
|
|
681
|
+
// activity within the freshness window is ping-probed under a short
|
|
682
|
+
// bound before hand-out; a dead one is evicted and the scan retries the
|
|
683
|
+
// next idle entry so a busy caller is never handed a wedged socket.
|
|
684
|
+
let idle;
|
|
685
|
+
while ((idle = arr.find(e => !e.busy))) {
|
|
606
686
|
_clearIdle(idle);
|
|
687
|
+
_clearLiveness(idle);
|
|
688
|
+
// Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
|
|
689
|
+
// event loop, so without this a second concurrent acquire could scan
|
|
690
|
+
// the same still-idle entry and both would take it. Marking busy up
|
|
691
|
+
// front makes the find() above skip it; on probe failure it is
|
|
692
|
+
// evicted (removed from arr) so the loop continues cleanly.
|
|
607
693
|
idle.busy = true;
|
|
694
|
+
if (WS_PING_ENABLED && Date.now() - (idle.lastAliveAt || 0) >= WS_LIVENESS_STALE_MS) {
|
|
695
|
+
const alive = await _pingProbe(idle, WS_PONG_TIMEOUT_MS);
|
|
696
|
+
if (!alive) {
|
|
697
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
698
|
+
process.stderr.write(`[agent-trace] acquire-evict-dead poolKey=${poolKey} reason=missed_pong elapsed=${Date.now() - _acqStart}ms\n`);
|
|
699
|
+
}
|
|
700
|
+
_evictDead(poolKey, idle);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
idle.lastAliveAt = Date.now();
|
|
608
705
|
// Defensive: pre-existing pooled entries created before the
|
|
609
706
|
// prefix-hash field was introduced may not have it set. Normalize
|
|
610
707
|
// to null so the first delta check reads a deterministic value
|
|
@@ -646,6 +743,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
646
743
|
ephemeral: true,
|
|
647
744
|
sessionToken: ephSessionToken,
|
|
648
745
|
};
|
|
746
|
+
entry.lastAliveAt = Date.now();
|
|
747
|
+
entry.pingTimer = null;
|
|
748
|
+
entry.probing = false;
|
|
749
|
+
socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
|
|
750
|
+
socket.on('message', () => { entry.lastAliveAt = Date.now(); });
|
|
649
751
|
socket.on('close', () => { entry.closing = true; });
|
|
650
752
|
return { entry, reused: false };
|
|
651
753
|
}
|
|
@@ -674,6 +776,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
674
776
|
ephemeral: false,
|
|
675
777
|
sessionToken,
|
|
676
778
|
};
|
|
779
|
+
entry.lastAliveAt = Date.now();
|
|
780
|
+
entry.pingTimer = null;
|
|
781
|
+
entry.probing = false;
|
|
782
|
+
socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
|
|
783
|
+
socket.on('message', () => { entry.lastAliveAt = Date.now(); });
|
|
677
784
|
if (poolKey && !forceFresh) _getPoolArr(poolKey).push(entry);
|
|
678
785
|
socket.on('close', () => {
|
|
679
786
|
entry.closing = true;
|
|
@@ -690,7 +797,12 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
|
|
|
690
797
|
_removeFromPool(poolKey, entry);
|
|
691
798
|
return;
|
|
692
799
|
}
|
|
800
|
+
// Mark activity at release, then arm both the idle-close timer and the
|
|
801
|
+
// periodic liveness ping so a socket that dies while pooled is evicted
|
|
802
|
+
// before the next acquire can hand it out.
|
|
803
|
+
entry.lastAliveAt = Date.now();
|
|
693
804
|
_scheduleIdleClose(poolKey, entry);
|
|
805
|
+
if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
|
|
694
806
|
}
|
|
695
807
|
|
|
696
808
|
// Drain-complete fence — set true once _closeAllPooledSockets runs so any
|
|
@@ -706,6 +818,10 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
|
|
|
706
818
|
_drainComplete = true;
|
|
707
819
|
for (const arr of _wsPool.values()) {
|
|
708
820
|
for (const entry of arr) {
|
|
821
|
+
// Tear down per-entry timers before dropping the map, otherwise the
|
|
822
|
+
// idle-close and liveness-ping intervals outlive the drained pool.
|
|
823
|
+
_clearIdle(entry);
|
|
824
|
+
_clearLiveness(entry);
|
|
709
825
|
try { entry.socket.close(1000, reason); } catch {}
|
|
710
826
|
}
|
|
711
827
|
}
|
|
@@ -44,14 +44,6 @@ import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
|
|
|
44
44
|
import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
|
|
45
45
|
import { _getAgentLoop } from './runtime-loaders.mjs';
|
|
46
46
|
import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
|
|
47
|
-
import { nonNegativeIntEnv } from './env-utils.mjs';
|
|
48
|
-
|
|
49
|
-
// Cap how long the terminal unwind blocks on the post-result session save.
|
|
50
|
-
// The result is already produced (and relayed for agent surfaces) before this
|
|
51
|
-
// save, so a stalled disk write must not hold askSession() open — otherwise the
|
|
52
|
-
// owning background task is stranded in `running` and its completion
|
|
53
|
-
// notification never fires. A slow write finishes in the background.
|
|
54
|
-
const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
|
|
55
47
|
|
|
56
48
|
/**
|
|
57
49
|
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
@@ -567,37 +559,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
567
559
|
// query/provider send (agentLoop pre-send), not after the previous
|
|
568
560
|
// answer. This lets queued follow-up prompts resume immediately;
|
|
569
561
|
// if they need compaction, their own spinner shows compacting first.
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
// background
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
|
|
582
|
-
saveTimer.unref?.();
|
|
583
|
-
});
|
|
584
|
-
try {
|
|
585
|
-
const outcome = await Promise.race([
|
|
586
|
-
savePromise.then(() => '__save_ok__', (err) => { throw err; }),
|
|
587
|
-
saveTimeout,
|
|
588
|
-
]);
|
|
589
|
-
if (outcome === '__save_timeout__') {
|
|
590
|
-
terminalSaveTimedOut = true;
|
|
591
|
-
try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
|
|
592
|
-
// Don't drop the write — let it settle in the background.
|
|
593
|
-
savePromise.catch((err) => {
|
|
594
|
-
try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
} finally {
|
|
598
|
-
if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
|
|
599
|
-
}
|
|
600
|
-
}
|
|
562
|
+
// Fire-and-forget terminal save. The result is already produced and
|
|
563
|
+
// (for agent surfaces) relayed via onTerminalResult above, and
|
|
564
|
+
// saveSessionAsync() has already published the in-memory snapshot via
|
|
565
|
+
// setLiveSession(), so read-your-writes holds in-process without
|
|
566
|
+
// awaiting disk. Never block the terminal unwind on the write — that
|
|
567
|
+
// would strand the owning background task in `running` and suppress
|
|
568
|
+
// its completion notification. A slow write finishes in the
|
|
569
|
+
// background.
|
|
570
|
+
saveSessionAsync(session, { expectedGeneration: askGeneration }).catch((err) => {
|
|
571
|
+
try { process.stderr.write(`[session] terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
572
|
+
});
|
|
601
573
|
activeSession = session;
|
|
602
574
|
runtime.session = session;
|
|
603
575
|
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
@@ -675,19 +647,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
675
647
|
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
676
648
|
if (_mergedTail?.content) {
|
|
677
649
|
_pendingTail.push(_mergedTail.content);
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const refreshed = loadSession(sessionId);
|
|
686
|
-
if (refreshed && refreshed.closed !== true) {
|
|
687
|
-
activeSession = refreshed;
|
|
688
|
-
runtime.session = refreshed;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
650
|
+
// Carry the just-committed in-memory session into the follow-up
|
|
651
|
+
// turn so the queued tail sees the preceding assistant/tool
|
|
652
|
+
// context. loadSession() would return this same live snapshot
|
|
653
|
+
// (setLiveSession published it), so skip the disk round-trip.
|
|
654
|
+
// NOTE: `session` (try-block const, :179) is out of scope here —
|
|
655
|
+
// `activeSession` already holds the committed session.
|
|
656
|
+
runtime.session = activeSession;
|
|
691
657
|
continue;
|
|
692
658
|
}
|
|
693
659
|
}
|