mixdog 0.9.36 → 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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- 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/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -16,10 +16,23 @@ import { isAgentOwner } from '../../agent-owner.mjs';
|
|
|
16
16
|
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
17
17
|
// to execute during SSE parsing so tool work overlaps with the rest of the
|
|
18
18
|
// stream. Writes, bash, MCP and skills stay serial after send() returns.
|
|
19
|
+
// Memoized: the read-only name Set is built once per distinct `tools` array
|
|
20
|
+
// (keyed by identity via a module-level WeakMap) so repeated per-call lookups
|
|
21
|
+
// are O(1) instead of O(N) tools.find scans.
|
|
22
|
+
const _eagerNameSetByTools = new WeakMap();
|
|
19
23
|
export function isEagerDispatchable(name, tools) {
|
|
20
24
|
if (!Array.isArray(tools)) return false;
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
let set = _eagerNameSetByTools.get(tools);
|
|
26
|
+
if (set === undefined) {
|
|
27
|
+
set = new Set();
|
|
28
|
+
for (const t of tools) {
|
|
29
|
+
if (t?.annotations?.readOnlyHint === true && typeof t.name === 'string') {
|
|
30
|
+
set.add(t.name);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
_eagerNameSetByTools.set(tools, set);
|
|
34
|
+
}
|
|
35
|
+
return set.has(name);
|
|
23
36
|
}
|
|
24
37
|
export function messagesArrayChanged(before, after) {
|
|
25
38
|
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
@@ -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,
|
|
@@ -340,25 +332,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
340
332
|
onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
|
|
341
333
|
onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
|
|
342
334
|
onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
// whole task to finish. The post-turn _pendingTail drain
|
|
350
|
-
// below still handles "followUp" input that lands after the
|
|
351
|
-
// agent would otherwise stop. Same queue, two drain points.
|
|
352
|
-
drainSteering: (sid) => {
|
|
335
|
+
// Claude Code parity: mid-chain queued prompt/notification
|
|
336
|
+
// drain is owned by agentLoop at provider-continuation
|
|
337
|
+
// boundaries (after a tool batch, before the next send).
|
|
338
|
+
// The post-loop _pendingTail drain below still handles
|
|
339
|
+
// items that arrive after the model would otherwise stop.
|
|
340
|
+
drainSteering: (sid, drainOptions = {}) => {
|
|
353
341
|
const out = [];
|
|
354
342
|
if (typeof askOpts?.drainSteering === 'function') {
|
|
355
343
|
try {
|
|
356
|
-
const drained = askOpts.drainSteering(sid || sessionId);
|
|
344
|
+
const drained = askOpts.drainSteering(sid || sessionId, drainOptions);
|
|
357
345
|
if (Array.isArray(drained)) out.push(...drained);
|
|
358
346
|
} catch { /* best-effort steering drain */ }
|
|
359
347
|
}
|
|
360
|
-
|
|
361
|
-
|
|
348
|
+
// Do NOT drain manager/pending-messages here: those
|
|
349
|
+
// entries have no mode/priority/slash metadata, so
|
|
350
|
+
// draining them mid-chain would bypass Claude Code's
|
|
351
|
+
// queued_command filters. They are consumed by the
|
|
352
|
+
// post-loop _pendingTail drain below.
|
|
362
353
|
return out;
|
|
363
354
|
},
|
|
364
355
|
onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
|
|
@@ -568,35 +559,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
568
559
|
// query/provider send (agentLoop pre-send), not after the previous
|
|
569
560
|
// answer. This lets queued follow-up prompts resume immediately;
|
|
570
561
|
// if they need compaction, their own spinner shows compacting first.
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
// background
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
|
|
591
|
-
// Don't drop the write — let it settle in the background.
|
|
592
|
-
savePromise.catch((err) => {
|
|
593
|
-
try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
} finally {
|
|
597
|
-
if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
|
|
598
|
-
}
|
|
599
|
-
}
|
|
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
|
+
});
|
|
600
573
|
activeSession = session;
|
|
601
574
|
runtime.session = session;
|
|
602
575
|
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
@@ -674,11 +647,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
674
647
|
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
675
648
|
if (_mergedTail?.content) {
|
|
676
649
|
_pendingTail.push(_mergedTail.content);
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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;
|
|
682
657
|
continue;
|
|
683
658
|
}
|
|
684
659
|
}
|
|
@@ -73,13 +73,13 @@ function temporalPromptText(content) {
|
|
|
73
73
|
function promptNeedsDateReminder(content) {
|
|
74
74
|
const text = temporalPromptText(content);
|
|
75
75
|
if (!text) return false;
|
|
76
|
-
return /(
|
|
76
|
+
return /(?:\uC624\uB298|\uB0B4\uC77C|\uC5B4\uC81C|\uBAA8\uB808|\uADF8\uC800\uAED8|\uC694\uC998|\uCD5C\uADFC|\uBC29\uAE08|\uC544\uAE4C|\uD604\uC7AC\s*(?:\uB0A0\uC9DC|\uC2DC\uAC04|\uC2DC\uAC01)|\uC9C0\uAE08\s*(?:\uBA87\s*\uC2DC|\uC2DC\uAC04|\uB0A0\uC9DC|\uC694\uC77C)|\uBA87\s*\uC6D4\s*\uBA87\s*\uC77C|\uBA87\s*\uC2DC|\uBB34\uC2A8\s*\uC694\uC77C|\uC694\uC77C|\uB0A0\uC9DC|\uC774\uBC88\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uC9C0\uB09C\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uB2E4\uC74C\s*(?:\uC8FC|\uB2EC|\uC6D4|\uB144)|\uC62C\uD574|\uC791\uB144|\uB0B4\uB144|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function promptNeedsTimeReminder(content) {
|
|
80
80
|
const text = temporalPromptText(content);
|
|
81
81
|
if (!text) return false;
|
|
82
|
-
return /(
|
|
82
|
+
return /(?:\uD604\uC7AC\s*(?:\uC2DC\uAC04|\uC2DC\uAC01)|\uC9C0\uAE08\s*(?:\uBA87\s*\uC2DC|\uC2DC\uAC04)|\uBA87\s*\uC2DC|\uC2DC\uAC01|\uC2DC\uAC04|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function buildCurrentTimeBlock(content) {
|
|
@@ -34,6 +34,27 @@ import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent
|
|
|
34
34
|
import { mintSessionId } from './session-id.mjs';
|
|
35
35
|
import { providerCacheKey } from './provider-cache-key.mjs';
|
|
36
36
|
|
|
37
|
+
function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
|
|
38
|
+
// Keep this in sync with createSession's provider-cache policy: only
|
|
39
|
+
// explicit-breakpoint providers get BP cache opts here; OpenAI/key-prefix
|
|
40
|
+
// providers use promptCacheKey and request-time strategy instead.
|
|
41
|
+
if (cacheCapabilityForProvider(providerName) !== 'explicit-breakpoint') return null;
|
|
42
|
+
try {
|
|
43
|
+
let autoClear = null;
|
|
44
|
+
if (!agent || agent === 'lead') {
|
|
45
|
+
const loadedConfig = loadConfig({ secrets: false });
|
|
46
|
+
const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
|
|
47
|
+
autoClear = {
|
|
48
|
+
...normalizedAutoClear,
|
|
49
|
+
idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return buildProviderCacheOpts(providerName, sessionId, agent, { autoClear });
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
// --- agent spawn (createSession) ---
|
|
38
59
|
// opts can pass either a `preset` object (from config.presets) or raw provider/model.
|
|
39
60
|
// Preset shape: { name, provider, model, effort?, fast?, tools? }
|
|
@@ -107,22 +128,7 @@ export function createSession(opts) {
|
|
|
107
128
|
// cacheRetention:'24h' shape) were never exercised by createSession
|
|
108
129
|
// before this change, and are left untouched to avoid altering live
|
|
109
130
|
// OpenAI/other-provider request shape as a side effect of this fix.
|
|
110
|
-
if (!providerCacheOpts
|
|
111
|
-
try {
|
|
112
|
-
let autoClear = null;
|
|
113
|
-
if (!opts.agent || opts.agent === 'lead') {
|
|
114
|
-
const loadedConfig = loadConfig({ secrets: false });
|
|
115
|
-
const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
|
|
116
|
-
autoClear = {
|
|
117
|
-
...normalizedAutoClear,
|
|
118
|
-
idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
providerCacheOpts = buildProviderCacheOpts(providerName, id, opts.agent, { autoClear });
|
|
122
|
-
} catch {
|
|
123
|
-
providerCacheOpts = null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
131
|
+
if (!providerCacheOpts) providerCacheOpts = buildSessionProviderCacheOpts(providerName, id, opts.agent);
|
|
126
132
|
const messages = [];
|
|
127
133
|
const ownerIsAgent = isAgentOwner(opts.owner);
|
|
128
134
|
const resolvedAgent = opts.agent || opts.role || profile?.taskType || null;
|
|
@@ -425,6 +431,8 @@ export function updateSessionRoute(id, route = {}) {
|
|
|
425
431
|
|| (route.model && route.model !== previousModel);
|
|
426
432
|
if (routeChanged) {
|
|
427
433
|
const now = Date.now();
|
|
434
|
+
session.promptCacheKey = providerCacheKey(session.provider);
|
|
435
|
+
session.providerCacheOpts = buildSessionProviderCacheOpts(session.provider, session.id, session.agent) || null;
|
|
428
436
|
session.lastInputTokens = 0;
|
|
429
437
|
session.lastOutputTokens = 0;
|
|
430
438
|
session.lastCachedReadTokens = 0;
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Matches documented tool-return error conventions:
|
|
10
10
|
* "Error: ..." — Node/MCP tool errors (grep, find_symbol, read, code_graph, etc.)
|
|
11
|
+
* "Error: [shell-tool-failed] ..." — shell tool/control-plane failure
|
|
12
|
+
* "Error: [shell-run-failed] ..." — shell command process failure
|
|
11
13
|
* "Error [code N]:" — structured builtin tool errors
|
|
12
14
|
* "[error ..." — bracketed error format
|
|
13
15
|
* "[exit code: ..." — bash non-zero exit
|
|
@@ -256,7 +256,16 @@ function _flushScheduled(id) {
|
|
|
256
256
|
// Single long-lived Worker serializes all saveSessionAsync calls.
|
|
257
257
|
// The worker's message queue preserves generation-race ordering.
|
|
258
258
|
let _saveWorker = null;
|
|
259
|
-
|
|
259
|
+
// In-flight writes, keyed by reqId. Value: { id, session, opts, waiters:[{resolve,reject}] }.
|
|
260
|
+
// At most ONE entry per session id at a time (single-in-flight-per-id).
|
|
261
|
+
let _saveWorkerPending = new Map();
|
|
262
|
+
// Latest-wins queued payload per session, keyed by id. Value: { session, opts, waiters:[] }.
|
|
263
|
+
// At most ONE queued write per id: a newer saveSessionAsync while a write is in
|
|
264
|
+
// flight overwrites session/opts here and appends its resolver to waiters, so
|
|
265
|
+
// every superseded caller resolves when this single queued write finally lands.
|
|
266
|
+
let _saveAsyncQueued = new Map();
|
|
267
|
+
// id → reqId of the in-flight write for that id (enforces one-in-flight-per-id).
|
|
268
|
+
let _saveAsyncInflight = new Map();
|
|
260
269
|
let _saveWorkerReqId = 0;
|
|
261
270
|
let _saveWorkerRefCount = 0;
|
|
262
271
|
|
|
@@ -273,12 +282,37 @@ function _getOrSpawnWorker() {
|
|
|
273
282
|
// becomes unref'd again once all in-flight writes settle. _saveWorker
|
|
274
283
|
// null-check covers the error/exit race where the worker died first.
|
|
275
284
|
if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
|
|
276
|
-
|
|
277
|
-
|
|
285
|
+
const { id, waiters } = p;
|
|
286
|
+
_saveAsyncInflight.delete(id);
|
|
287
|
+
// Resolve/reject every caller whose payload this write represents
|
|
288
|
+
// (the originating call plus any that coalesced onto it before it was
|
|
289
|
+
// posted). A supersede never lands here as a rejection — only a real
|
|
290
|
+
// worker failure does.
|
|
291
|
+
if (ok) { for (const w of waiters) w.resolve(); }
|
|
292
|
+
else {
|
|
293
|
+
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
294
|
+
for (const w of waiters) w.reject(e);
|
|
295
|
+
}
|
|
296
|
+
// Promote the latest-wins queued payload (if any) into the now-free
|
|
297
|
+
// in-flight slot for this id. Runs regardless of ok: the queued write
|
|
298
|
+
// is a newer, independent payload and must still be attempted so its
|
|
299
|
+
// (possibly superseded) waiters resolve when it lands.
|
|
300
|
+
const q = _saveAsyncQueued.get(id);
|
|
301
|
+
if (q) {
|
|
302
|
+
_saveAsyncQueued.delete(id);
|
|
303
|
+
try {
|
|
304
|
+
_postAsyncWrite(id, q.session, q.opts, q.waiters);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
for (const w of q.waiters) w.reject(err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
278
309
|
});
|
|
279
310
|
_saveWorker.on('error', (err) => {
|
|
280
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
311
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
281
312
|
_saveWorkerPending.clear();
|
|
313
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
314
|
+
_saveAsyncQueued.clear();
|
|
315
|
+
_saveAsyncInflight.clear();
|
|
282
316
|
_saveWorkerRefCount = 0;
|
|
283
317
|
_saveWorker = null;
|
|
284
318
|
});
|
|
@@ -290,8 +324,11 @@ function _getOrSpawnWorker() {
|
|
|
290
324
|
// saveSessionAsync registered a resolver but before the worker
|
|
291
325
|
// received the message.
|
|
292
326
|
const err = new Error(`[session-store] save worker exited with code ${code}`);
|
|
293
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
327
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
294
328
|
_saveWorkerPending.clear();
|
|
329
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
330
|
+
_saveAsyncQueued.clear();
|
|
331
|
+
_saveAsyncInflight.clear();
|
|
295
332
|
_saveWorkerRefCount = 0;
|
|
296
333
|
_saveWorker = null;
|
|
297
334
|
});
|
|
@@ -299,14 +336,45 @@ function _getOrSpawnWorker() {
|
|
|
299
336
|
return _saveWorker;
|
|
300
337
|
}
|
|
301
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Post one in-flight write for `id` to the worker and register it as the
|
|
341
|
+
* single in-flight entry for that id. Callers guarantee no write is already
|
|
342
|
+
* in flight for `id`. Throws (after cleaning its own map entries) if the
|
|
343
|
+
* worker postMessage fails so the caller can reject the affected waiters.
|
|
344
|
+
*/
|
|
345
|
+
function _postAsyncWrite(id, session, opts, waiters) {
|
|
346
|
+
const reqId = ++_saveWorkerReqId;
|
|
347
|
+
_saveWorkerPending.set(reqId, { id, session, opts, waiters });
|
|
348
|
+
_saveAsyncInflight.set(id, reqId);
|
|
349
|
+
try {
|
|
350
|
+
const w = _getOrSpawnWorker();
|
|
351
|
+
w.postMessage({ session, opts, reqId });
|
|
352
|
+
// Ref AFTER successful postMessage so a queue/throw failure path does
|
|
353
|
+
// not leave the worker held alive with no pending message. Paired with
|
|
354
|
+
// the unref in the message handler when count hits 0.
|
|
355
|
+
if (++_saveWorkerRefCount === 1) w.ref();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
_saveWorkerPending.delete(reqId);
|
|
358
|
+
_saveAsyncInflight.delete(id);
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
302
363
|
/**
|
|
303
364
|
* Async save via a dedicated Worker thread.
|
|
304
365
|
* Errors surface as thrown Errors — callers must not silently swallow them.
|
|
366
|
+
*
|
|
367
|
+
* Per-session latest-wins coalescing: for a given id there is at most one
|
|
368
|
+
* write in flight plus one queued follow-up. N rapid saves for the same id in
|
|
369
|
+
* a turn collapse to (in-flight + one queued-latest), keeping the single
|
|
370
|
+
* worker's backlog bounded. Per-id write ORDERING is preserved (a queued write
|
|
371
|
+
* is only posted once the prior in-flight write for that id settles); different
|
|
372
|
+
* ids interleave freely as before.
|
|
305
373
|
*/
|
|
306
374
|
export function saveSessionAsync(session, opts) {
|
|
307
375
|
_ensureLifecycleFields(session);
|
|
308
376
|
setLiveSession(session);
|
|
309
|
-
const
|
|
377
|
+
const id = session.id;
|
|
310
378
|
const safeOpts = opts || null;
|
|
311
379
|
// The Worker `postMessage` below structured-clones the whole session on the
|
|
312
380
|
// main thread. `session.liveTurnMessages` (live working transcript) and
|
|
@@ -322,18 +390,28 @@ export function saveSessionAsync(session, opts) {
|
|
|
322
390
|
? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
|
|
323
391
|
: session;
|
|
324
392
|
return new Promise((resolve, reject) => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
393
|
+
const waiter = { resolve, reject };
|
|
394
|
+
if (_saveAsyncInflight.has(id)) {
|
|
395
|
+
// A write is already on disk for this id — coalesce into the single
|
|
396
|
+
// latest-wins queued slot. Existing queued waiters carry over so a
|
|
397
|
+
// superseded caller resolves when THIS newer write lands (never
|
|
398
|
+
// hang, never reject on supersede).
|
|
399
|
+
const q = _saveAsyncQueued.get(id);
|
|
400
|
+
if (q) {
|
|
401
|
+
q.session = clonePayload;
|
|
402
|
+
q.opts = safeOpts;
|
|
403
|
+
q.waiters.push(waiter);
|
|
404
|
+
} else {
|
|
405
|
+
_saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Idle for this id — post immediately as the in-flight write. The
|
|
410
|
+
// in-flight entry persists {session, opts} so drainSessionStore can
|
|
411
|
+
// sync-flush outstanding writes if process exit interrupts the queue.
|
|
328
412
|
try {
|
|
329
|
-
|
|
330
|
-
w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
|
|
331
|
-
// Ref AFTER successful postMessage so a queue/throw failure path
|
|
332
|
-
// does not leave the worker held alive with no pending message.
|
|
333
|
-
// Paired with the unref in the message handler when count hits 0.
|
|
334
|
-
if (++_saveWorkerRefCount === 1) w.ref();
|
|
413
|
+
_postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
|
|
335
414
|
} catch (err) {
|
|
336
|
-
_saveWorkerPending.delete(reqId);
|
|
337
415
|
reject(err);
|
|
338
416
|
}
|
|
339
417
|
});
|
|
@@ -431,18 +509,35 @@ export function drainSessionStore() {
|
|
|
431
509
|
// is sync-flushed directly here. The Promise is then rejected so the
|
|
432
510
|
// caller's await site does not leak unresolved (caller is at process
|
|
433
511
|
// exit so the rejection is informational, not actionable).
|
|
512
|
+
const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
|
|
513
|
+
// Flush in-flight writes FIRST, then the latest-wins queued payloads, so
|
|
514
|
+
// for any id with both an in-flight and a queued write the queued (newest)
|
|
515
|
+
// state is written LAST and wins on disk — no lost last write.
|
|
434
516
|
for (const [, pending] of _saveWorkerPending) {
|
|
435
|
-
if (
|
|
517
|
+
if (pending.session) {
|
|
518
|
+
try {
|
|
519
|
+
_saveSessionSync(pending.session, pending.opts);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const w of pending.waiters) {
|
|
525
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const [, q] of _saveAsyncQueued) {
|
|
436
529
|
try {
|
|
437
|
-
_saveSessionSync(
|
|
530
|
+
_saveSessionSync(q.session, q.opts);
|
|
438
531
|
} catch (err) {
|
|
439
532
|
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
440
533
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
534
|
+
for (const w of q.waiters) {
|
|
535
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
536
|
+
}
|
|
444
537
|
}
|
|
445
538
|
_saveWorkerPending.clear();
|
|
539
|
+
_saveAsyncQueued.clear();
|
|
540
|
+
_saveAsyncInflight.clear();
|
|
446
541
|
_saveWorkerRefCount = 0;
|
|
447
542
|
}
|
|
448
543
|
|
|
@@ -96,6 +96,9 @@ export async function processToolBatch(ctx) {
|
|
|
96
96
|
if (isBuiltinTool(call.name)) {
|
|
97
97
|
call.name = canonicalizeBuiltinToolName(call.name);
|
|
98
98
|
}
|
|
99
|
+
// Per-call cross-turn signature, computed at most once (lazily, only
|
|
100
|
+
// when the call is eager — both consumer sites are eager-gated).
|
|
101
|
+
let _ctSig = null;
|
|
99
102
|
if (_duplicateCallIds.has(call.id)) {
|
|
100
103
|
const _firstId = _dupFirstId.get(call.id);
|
|
101
104
|
const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
|
|
@@ -113,7 +116,7 @@ export async function processToolBatch(ctx) {
|
|
|
113
116
|
// escalation tail once the session has emitted 5+ dedup stubs total.
|
|
114
117
|
// Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
|
|
115
118
|
if (isEagerDispatchable(call.name, tools)) {
|
|
116
|
-
|
|
119
|
+
_ctSig = crossTurnSignature(call.name, call.arguments);
|
|
117
120
|
const _prior = crossTurnCalls.get(_ctSig);
|
|
118
121
|
if (_prior && _prior.firstIteration < iterations) {
|
|
119
122
|
_prior.count += 1;
|
|
@@ -576,7 +579,7 @@ export async function processToolBatch(ctx) {
|
|
|
576
579
|
if (_executeOk) {
|
|
577
580
|
const _isEager = isEagerDispatchable(call.name, tools);
|
|
578
581
|
if (_isEager) {
|
|
579
|
-
|
|
582
|
+
if (_ctSig === null) _ctSig = crossTurnSignature(call.name, call.arguments);
|
|
580
583
|
if (!crossTurnCalls.has(_ctSig)) {
|
|
581
584
|
crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
|
|
582
585
|
if (crossTurnCalls.size > crossTurnCap) {
|
|
@@ -191,6 +191,43 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
191
191
|
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
192
192
|
);
|
|
193
193
|
|
|
194
|
+
// WS pool liveness (ping/pong) — closes the gap between a socket going
|
|
195
|
+
// half-open (peer gone / TLS wedge) and the semantic-idle watchdog noticing
|
|
196
|
+
// (120s). Node `ws` send() is fire-and-forget, so a dead pooled socket
|
|
197
|
+
// silently blackholes response.create frames until a downstream timeout. An
|
|
198
|
+
// idle pooled socket is pinged on this cadence; a reused socket that has been
|
|
199
|
+
// quiet longer than the stale window is ping-probed before hand-out.
|
|
200
|
+
// Gated OFF by default for codex/opencode parity — neither pings model
|
|
201
|
+
// sockets. Enable with MIXDOG_PROVIDER_WS_PING_ENABLED=1. When disabled, no
|
|
202
|
+
// liveness interval is armed and the acquire-reuse ping probe is skipped (the
|
|
203
|
+
// non-OPEN eviction scan still runs); lastAliveAt stamping is harmless.
|
|
204
|
+
const _wsPingRaw = process.env.MIXDOG_PROVIDER_WS_PING_ENABLED;
|
|
205
|
+
export const PROVIDER_WS_PING_ENABLED = _wsPingRaw === '1' || _wsPingRaw === 'true' || _wsPingRaw === 'yes';
|
|
206
|
+
|
|
207
|
+
export const PROVIDER_WS_PING_INTERVAL_MS = resolveTimeoutMs(
|
|
208
|
+
'MIXDOG_PROVIDER_WS_PING_INTERVAL_MS',
|
|
209
|
+
30_000,
|
|
210
|
+
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Missed-pong grace: after a ping, the pong must land within this bound or the
|
|
214
|
+
// socket is treated as dead (closed + evicted). Doubles as the short probe
|
|
215
|
+
// bound on the acquire-reuse path so a busy caller is never handed a wedged
|
|
216
|
+
// socket.
|
|
217
|
+
export const PROVIDER_WS_PONG_TIMEOUT_MS = resolveTimeoutMs(
|
|
218
|
+
'MIXDOG_PROVIDER_WS_PONG_TIMEOUT_MS',
|
|
219
|
+
5_000,
|
|
220
|
+
{ minMs: 1_000, maxMs: 60_000 },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Activity freshness window: a pooled socket with observed activity (pong /
|
|
224
|
+
// release) newer than this is assumed live and skips the acquire-reuse probe.
|
|
225
|
+
export const PROVIDER_WS_LIVENESS_STALE_MS = resolveTimeoutMs(
|
|
226
|
+
'MIXDOG_PROVIDER_WS_LIVENESS_STALE_MS',
|
|
227
|
+
30_000,
|
|
228
|
+
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
229
|
+
);
|
|
230
|
+
|
|
194
231
|
// Single inter-chunk idle timer (300s), matching the upstream WS provider's
|
|
195
232
|
// default stream idle timeout. Mixdog resets one idle timer on every received
|
|
196
233
|
// WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
|
|
@@ -219,6 +256,24 @@ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
219
256
|
{ minMs: 10_000, maxMs: STALL_WARN_MS },
|
|
220
257
|
);
|
|
221
258
|
|
|
259
|
+
// WS-specific semantic idle window. Do NOT reuse
|
|
260
|
+
// PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS here: that shared SSE default was raised to
|
|
261
|
+
// ~285s to avoid false-aborting Anthropic effort-mode streams that legitimately
|
|
262
|
+
// produce no deltas while thinking. The WS transport is different: a silent
|
|
263
|
+
// OpenAI/xAI websocket turn can sit with only connection/metadata activity while
|
|
264
|
+
// the TUI lead-turn watchdog is also 300s. If the provider waits ~285-300s
|
|
265
|
+
// before surfacing the stall, the UI watchdog often wins and the user only sees
|
|
266
|
+
// "Turn timed out after 300s" instead of a provider retry/fallback.
|
|
267
|
+
//
|
|
268
|
+
// Keep WS hangs bounded well below the TUI watchdog while still allowing normal
|
|
269
|
+
// long reasoning streams that emit reasoning/text/tool deltas (those reset this
|
|
270
|
+
// timer). Env-overridable for experiments.
|
|
271
|
+
export const PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
|
|
272
|
+
['MIXDOG_PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_WS_OUTPUT_IDLE_TIMEOUT_MS'],
|
|
273
|
+
120_000,
|
|
274
|
+
{ minMs: 10_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
275
|
+
);
|
|
276
|
+
|
|
222
277
|
// First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
|
|
223
278
|
// a transient 5xx burst lets parallel workers thundering-herd the backend in
|
|
224
279
|
// lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
|