mixdog 0.9.85 → 0.9.86
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/README.md +7 -1
- package/package.json +3 -2
- package/src/defaults/agents.json +12 -0
- package/src/defaults/skills/setup/SKILL.md +44 -12
- package/src/help.mjs +40 -9
- package/src/lib/keychain-cjs.cjs +11 -1
- package/src/rules/agent/43-title-agent.md +22 -0
- package/src/rules/shared/01-tool.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
- package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
- package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/media/adapters/gemini-video.mjs +1 -1
- package/src/runtime/media/renditions.mjs +1 -1
- package/src/runtime/media/store.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +25 -14
- package/src/runtime/shared/automation-attachments.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +15 -5
- package/src/runtime/shared/child-spawn-gate.mjs +6 -12
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +34 -9
- package/src/session-runtime/prewarm.mjs +35 -0
- package/src/session-runtime/remote-control.mjs +8 -47
- package/src/session-runtime/remote-transcript.mjs +3 -7
- package/src/session-runtime/route-preparation.mjs +24 -0
- package/src/session-runtime/runtime-core.mjs +37 -85
- package/src/session-runtime/session-turn-api.mjs +9 -2
- package/src/session-runtime/workflow-agents-api.mjs +3 -0
- package/src/standalone/agent-shard/shard-child.mjs +300 -0
- package/src/standalone/agent-shard/shard-pool.mjs +442 -0
- package/src/standalone/agent-tool.mjs +222 -0
- package/src/standalone/channel-daemon-transport.mjs +126 -185
- package/src/standalone/channel-daemon.mjs +9 -10
- package/src/tui/App.jsx +16 -9
- package/src/tui/app/channel-pickers.mjs +3 -8
- package/src/tui/app/route-pickers.mjs +117 -152
- package/src/tui/app/settings-picker.mjs +90 -89
- package/src/tui/app/slash-commands.mjs +7 -7
- package/src/tui/app/slash-dispatch.mjs +0 -45
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +243 -323
- package/src/tui/engine/session-api-ext.mjs +24 -9
- package/src/tui/engine.mjs +7 -1
- package/src/tui/figures.mjs +0 -1
- package/src/ui/statusline-format.mjs +0 -1
- package/src/hooks/lib/permission-evaluator.cjs +0 -24
- package/src/lib/config-cjs.cjs +0 -61
- package/src/runtime/shared/launcher-control.mjs +0 -258
- package/src/runtime/shared/workspace-router.mjs +0 -259
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Lead-side agent shard pool (P2/P3 — see docs/agent-shard-design.md).
|
|
2
|
+
//
|
|
3
|
+
// Topology: up to `MIXDOG_AGENT_SHARD_POOL` lazily-forked shard children
|
|
4
|
+
// (default max(2, cpus/4)); worker spawns go to the LEAST-LOADED live shard
|
|
5
|
+
// (a fresh slot opens once every live shard already runs a spawn) and a
|
|
6
|
+
// shard process is REUSED across spawns (runtime import in the child costs
|
|
7
|
+
// ~1-2s and must not be paid per spawn). A codex-style execution window
|
|
8
|
+
// (`MIXDOG_AGENT_SHARD_WINDOW`, default 16, 0=off) caps CONCURRENT shard
|
|
9
|
+
// spawns — overflow queues FIFO in the lead and stays cancellable by
|
|
10
|
+
// tag/sessionId. Shards idle past `MIXDOG_AGENT_SHARD_IDLE_MS` (default 5m)
|
|
11
|
+
// are reaped (~174MB each) and re-fork on demand. cancel/enqueue route to
|
|
12
|
+
// the shard that owns the tag/session; provider 429 cooldowns and credential
|
|
13
|
+
// resets are bridged both ways so one account's quota window parks every
|
|
14
|
+
// process. Flag-gated: MIXDOG_AGENT_SHARD=1 opts in; default keeps the
|
|
15
|
+
// in-process path byte-identical.
|
|
16
|
+
import { fork } from 'node:child_process';
|
|
17
|
+
import { cpus } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { startChildGuardian } from '../../runtime/shared/child-guardian.mjs';
|
|
20
|
+
import { scrubLoaderVars } from '../../runtime/agent/orchestrator/tools/env-scrub.mjs';
|
|
21
|
+
import { providerAdmissionScheduler } from '../../runtime/agent/orchestrator/providers/admission-scheduler.mjs';
|
|
22
|
+
import { getMcpTools, executeMcpTool } from '../../runtime/agent/orchestrator/mcp/client.mjs';
|
|
23
|
+
import { getInternalTools, executeInternalTool } from '../../runtime/agent/orchestrator/internal-tools.mjs';
|
|
24
|
+
|
|
25
|
+
const CHILD_ENTRY = fileURLToPath(new URL('./shard-child.mjs', import.meta.url));
|
|
26
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
27
|
+
const MAX_SHARDS = 16;
|
|
28
|
+
const IDLE_REAP_SWEEP_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
export function agentShardEnabled() {
|
|
31
|
+
const value = process.env.MIXDOG_AGENT_SHARD;
|
|
32
|
+
if (value === '1') return true;
|
|
33
|
+
if (value === '0') return false;
|
|
34
|
+
// P3 default: ON for lead-like hosts. Worker-mode children (channels
|
|
35
|
+
// worker, shard child itself) never recursively shard their own spawns
|
|
36
|
+
// unless explicitly opted in.
|
|
37
|
+
return process.env.MIXDOG_WORKER_MODE !== '1';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agentShardPoolSize() {
|
|
41
|
+
const configured = Math.floor(Number(process.env.MIXDOG_AGENT_SHARD_POOL));
|
|
42
|
+
if (Number.isFinite(configured) && configured >= 1) return Math.min(configured, MAX_SHARDS);
|
|
43
|
+
let cores = 4;
|
|
44
|
+
try { cores = cpus()?.length || 4; } catch { /* keep fallback */ }
|
|
45
|
+
return Math.max(2, Math.min(MAX_SHARDS, Math.floor(cores / 4)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Concurrent shard-spawn window — REACTIVE brake, not a preemptive cap.
|
|
49
|
+
// Concurrent shard-spawn window. UNBOUNDED unless explicitly configured
|
|
50
|
+
// (workstream rule: no local concurrency limits and no auto-brake heuristics
|
|
51
|
+
// — provider 429 cooldown parking in the admission scheduler is the ONLY
|
|
52
|
+
// limiter). `MIXDOG_AGENT_SHARD_WINDOW=N` is a pure operator opt-in to a
|
|
53
|
+
// fixed window with a bounded FIFO overflow queue.
|
|
54
|
+
function _currentWindow() {
|
|
55
|
+
const configured = Math.floor(Number(process.env.MIXDOG_AGENT_SHARD_WINDOW));
|
|
56
|
+
if (Number.isFinite(configured) && configured >= 1) return configured;
|
|
57
|
+
return 0; // unbounded
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function agentShardIdleMs() {
|
|
61
|
+
const configured = Math.floor(Number(process.env.MIXDOG_AGENT_SHARD_IDLE_MS));
|
|
62
|
+
if (Number.isFinite(configured) && configured >= 0) return configured;
|
|
63
|
+
return 300_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Overflow-queue bound (provider-admission parity: fail fast with a visible
|
|
67
|
+
// error instead of silently accumulating unbounded deferred spawns).
|
|
68
|
+
function agentShardQueueMax() {
|
|
69
|
+
const configured = Math.floor(Number(process.env.MIXDOG_AGENT_SHARD_QUEUE_MAX));
|
|
70
|
+
if (Number.isFinite(configured) && configured >= 1) return configured;
|
|
71
|
+
return 1_024;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const _shards = new Map(); // slot -> shard record
|
|
75
|
+
let _callSeq = 0;
|
|
76
|
+
// tag / sessionId -> owning shard, so cancel + tag-reuse enqueue reach the
|
|
77
|
+
// process that hosts the live session. Entries are optimistic at spawn
|
|
78
|
+
// dispatch (tag), confirmed by the `spawned` progress event (sessionId), and
|
|
79
|
+
// dropped when the spawn settles (shard sessions are one-shot) or the shard
|
|
80
|
+
// exits.
|
|
81
|
+
const _ownerByKey = new Map();
|
|
82
|
+
let _cooldownUnsub = null;
|
|
83
|
+
// Execution-window state: dispatched-but-unsettled spawn count + FIFO
|
|
84
|
+
// overflow queue of { payload, onProgress, resolve, reject, tag }.
|
|
85
|
+
let _activeSpawns = 0;
|
|
86
|
+
const _spawnQueue = [];
|
|
87
|
+
let _idleReapTimer = null;
|
|
88
|
+
|
|
89
|
+
function _liveShards() {
|
|
90
|
+
return [..._shards.values()].filter((shard) => !shard.closed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _broadcast(msg, { except = null } = {}) {
|
|
94
|
+
for (const shard of _liveShards()) {
|
|
95
|
+
if (shard === except) continue;
|
|
96
|
+
try { shard.child.send(msg); } catch { /* exit handler reaps */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Lead-recorded 429/reset → every shard. Registered once, on first fork.
|
|
101
|
+
function _ensureCooldownBridge() {
|
|
102
|
+
if (_cooldownUnsub || typeof providerAdmissionScheduler.onCooldownEvent !== 'function') return;
|
|
103
|
+
_cooldownUnsub = providerAdmissionScheduler.onCooldownEvent((event) => {
|
|
104
|
+
_broadcast({ type: 'cooldown', payload: event });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Shard-reported 429/reset → lead scheduler (apply, no re-emit) + sibling
|
|
109
|
+
// shards. applyExternalCooldown never emits, so there is no feedback loop;
|
|
110
|
+
// resets are loop-terminated by the resetCount>0 emit gate.
|
|
111
|
+
function _applyShardCooldownReport(payload, sourceShard) {
|
|
112
|
+
try {
|
|
113
|
+
if (payload?.type === 'reset') {
|
|
114
|
+
providerAdmissionScheduler.resetCooldowns(payload.provider || null);
|
|
115
|
+
} else if (payload?.key) {
|
|
116
|
+
providerAdmissionScheduler.applyExternalCooldown?.(payload.key, payload.cooldownUntil);
|
|
117
|
+
_broadcast({ type: 'cooldown', payload }, { except: sourceShard });
|
|
118
|
+
}
|
|
119
|
+
} catch { /* best-effort */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _dropOwnerKeysFor(shard) {
|
|
123
|
+
for (const [key, owner] of [..._ownerByKey.entries()]) {
|
|
124
|
+
if (owner === shard) _ownerByKey.delete(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Lead-tool proxy (P2 MCP proxy, generalized): shard children have neither
|
|
129
|
+
// MCP connections nor the internal-tool executor (both are lead singletons),
|
|
130
|
+
// so worker calls to those surfaces arrive here over IPC and execute in the
|
|
131
|
+
// lead. The child receives the tool DEFINITIONS with each spawn payload
|
|
132
|
+
// (below) so its session schema stays byte-identical to an in-process worker.
|
|
133
|
+
function _leadToolDefs() {
|
|
134
|
+
let mcp = [];
|
|
135
|
+
let internal = [];
|
|
136
|
+
try { mcp = getMcpTools() || []; } catch { /* defs best-effort */ }
|
|
137
|
+
try { internal = getInternalTools() || []; } catch { /* defs best-effort */ }
|
|
138
|
+
return { mcp, internal };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function _handleLeadToolCall(shard, msg) {
|
|
142
|
+
const { kind, name, args, callerCtx } = msg.payload || {};
|
|
143
|
+
let reply;
|
|
144
|
+
try {
|
|
145
|
+
const value = kind === 'mcp'
|
|
146
|
+
? await executeMcpTool(name, args)
|
|
147
|
+
: await executeInternalTool(name, args, callerCtx || {});
|
|
148
|
+
reply = { type: 'lead-tool-result', callId: msg.callId, result: value };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
reply = { type: 'lead-tool-result', callId: msg.callId, error: error?.message || String(error) };
|
|
151
|
+
}
|
|
152
|
+
try { shard.child.send(reply); } catch { /* child gone — its timeout reaps */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _ownerFor(payload) {
|
|
156
|
+
const bySession = _ownerByKey.get(String(payload?.sessionId || '').trim());
|
|
157
|
+
if (bySession && !bySession.closed) return bySession;
|
|
158
|
+
const byTag = _ownerByKey.get(String(payload?.tag || '').trim());
|
|
159
|
+
return byTag && !byTag.closed ? byTag : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _ensureShardAt(slot) {
|
|
163
|
+
const existing = _shards.get(slot);
|
|
164
|
+
if (existing && !existing.closed && existing.child.exitCode === null && existing.child.signalCode === null) {
|
|
165
|
+
return existing;
|
|
166
|
+
}
|
|
167
|
+
const env = { ...process.env };
|
|
168
|
+
scrubLoaderVars(env);
|
|
169
|
+
const child = fork(CHILD_ENTRY, [], {
|
|
170
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
171
|
+
// Never inherit the parent's execArgv (a driver started with
|
|
172
|
+
// --input-type/--test would poison the child's module resolution).
|
|
173
|
+
execArgv: [],
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
detached: false,
|
|
176
|
+
env: {
|
|
177
|
+
...env,
|
|
178
|
+
MIXDOG_STANDALONE: '1',
|
|
179
|
+
MIXDOG_WORKER_MODE: '1',
|
|
180
|
+
MIXDOG_CLI_OWNED: '0',
|
|
181
|
+
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
182
|
+
// The shard must never recursively shard its own nested spawns.
|
|
183
|
+
MIXDOG_AGENT_SHARD: '0',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const shard = {
|
|
187
|
+
slot,
|
|
188
|
+
child,
|
|
189
|
+
closed: false,
|
|
190
|
+
pending: new Map(),
|
|
191
|
+
inflight: 0,
|
|
192
|
+
lastActiveAt: Date.now(),
|
|
193
|
+
readyResolve: null,
|
|
194
|
+
ready: null,
|
|
195
|
+
};
|
|
196
|
+
shard.ready = new Promise((resolve, reject) => {
|
|
197
|
+
shard.readyResolve = resolve;
|
|
198
|
+
const t = setTimeout(() => reject(new Error('agent shard did not become ready within 30s')), READY_TIMEOUT_MS);
|
|
199
|
+
t.unref?.();
|
|
200
|
+
});
|
|
201
|
+
shard.ready.catch(() => {});
|
|
202
|
+
child.stderr?.on('data', (chunk) => {
|
|
203
|
+
const text = String(chunk || '').trimEnd();
|
|
204
|
+
if (text) { try { process.stderr.write(`[agent-shard:${slot}] ${text}\n`); } catch { /* best-effort */ } }
|
|
205
|
+
});
|
|
206
|
+
child.on('message', (msg) => {
|
|
207
|
+
if (!msg || typeof msg !== 'object') return;
|
|
208
|
+
if (msg.type === 'ready') {
|
|
209
|
+
shard.readyResolve?.(true);
|
|
210
|
+
shard.readyResolve = null;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (msg.type === 'rate-limit') {
|
|
214
|
+
_applyShardCooldownReport(msg.payload, shard);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (msg.type === 'lead-tool' && msg.callId) {
|
|
218
|
+
void _handleLeadToolCall(shard, msg);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (msg.type === 'progress' && msg.callId) {
|
|
222
|
+
if (msg.event === 'spawned' && msg.payload) {
|
|
223
|
+
// Confirm ownership under the sessionId (cancel-by-id) in
|
|
224
|
+
// addition to the optimistic tag mapping set at dispatch.
|
|
225
|
+
const sessionId = String(msg.payload.sessionId || '').trim();
|
|
226
|
+
if (sessionId) _ownerByKey.set(sessionId, shard);
|
|
227
|
+
const tag = String(msg.payload.tag || '').trim();
|
|
228
|
+
if (tag) _ownerByKey.set(tag, shard);
|
|
229
|
+
}
|
|
230
|
+
const item = shard.pending.get(msg.callId);
|
|
231
|
+
if (item?.onProgress) {
|
|
232
|
+
// A lead-side hook error must never kill the IPC pump.
|
|
233
|
+
try { item.onProgress(msg.event, msg.payload); } catch { /* best-effort */ }
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (msg.type === 'result' && msg.callId) {
|
|
238
|
+
const item = shard.pending.get(msg.callId);
|
|
239
|
+
if (!item) return;
|
|
240
|
+
shard.pending.delete(msg.callId);
|
|
241
|
+
if (item.cleanupKeys) {
|
|
242
|
+
for (const key of item.cleanupKeys) {
|
|
243
|
+
if (_ownerByKey.get(key) === shard) _ownerByKey.delete(key);
|
|
244
|
+
}
|
|
245
|
+
const settledSessionId = String(msg.result?.sessionId || '').trim();
|
|
246
|
+
if (settledSessionId && _ownerByKey.get(settledSessionId) === shard) {
|
|
247
|
+
_ownerByKey.delete(settledSessionId);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (msg.error) item.reject(new Error(msg.error));
|
|
251
|
+
else item.resolve(msg.result);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
child.on('exit', (code, signal) => {
|
|
255
|
+
shard.closed = true;
|
|
256
|
+
const error = new Error(`agent shard exited (${signal || (code ?? 'unknown')})`);
|
|
257
|
+
for (const [, item] of shard.pending) item.reject(error);
|
|
258
|
+
shard.pending.clear();
|
|
259
|
+
_dropOwnerKeysFor(shard);
|
|
260
|
+
if (_shards.get(slot) === shard) _shards.delete(slot);
|
|
261
|
+
});
|
|
262
|
+
child.on('error', () => { /* exit handler owns cleanup */ });
|
|
263
|
+
startChildGuardian({ childPid: child.pid, label: `agent-shard-${slot}`, orphanGraceMs: 8000, forceGraceMs: 3000 });
|
|
264
|
+
_shards.set(slot, shard);
|
|
265
|
+
_ensureCooldownBridge();
|
|
266
|
+
_ensureIdleReapTimer();
|
|
267
|
+
return shard;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Idle shard reap ─────────────────────────────────────────────────────────
|
|
271
|
+
// A warmed shard holds ~174MB of session runtime. One shared sweep timer
|
|
272
|
+
// kills shards with no inflight work past the idle window; the next spawn
|
|
273
|
+
// re-forks on demand (~1-2s boot, acceptable after minutes of idleness).
|
|
274
|
+
function _ensureIdleReapTimer() {
|
|
275
|
+
if (_idleReapTimer) return;
|
|
276
|
+
// Sweep at half the idle window (floor 5s, cap 60s) so short windows
|
|
277
|
+
// (tests, aggressive configs) actually observe the reap.
|
|
278
|
+
const idleMsNow = agentShardIdleMs();
|
|
279
|
+
const sweepMs = idleMsNow > 0
|
|
280
|
+
? Math.max(5_000, Math.min(IDLE_REAP_SWEEP_MS, Math.floor(idleMsNow / 2)))
|
|
281
|
+
: IDLE_REAP_SWEEP_MS;
|
|
282
|
+
_idleReapTimer = setInterval(() => {
|
|
283
|
+
const idleMs = agentShardIdleMs();
|
|
284
|
+
if (idleMs <= 0) return;
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
for (const shard of _liveShards()) {
|
|
287
|
+
if (shard.inflight > 0 || shard.pending.size > 0) continue;
|
|
288
|
+
if (now - shard.lastActiveAt < idleMs) continue;
|
|
289
|
+
shard.closed = true;
|
|
290
|
+
try { shard.child.kill(); } catch { /* exit handler reaps */ }
|
|
291
|
+
}
|
|
292
|
+
if (_shards.size === 0 && _idleReapTimer) {
|
|
293
|
+
clearInterval(_idleReapTimer);
|
|
294
|
+
_idleReapTimer = null;
|
|
295
|
+
}
|
|
296
|
+
}, sweepMs);
|
|
297
|
+
_idleReapTimer.unref?.();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Least-loaded assignment: reuse the emptiest live shard; open a fresh slot
|
|
301
|
+
// only when every live shard is already busy and the pool has headroom.
|
|
302
|
+
function _shardForSpawn() {
|
|
303
|
+
const poolSize = agentShardPoolSize();
|
|
304
|
+
const live = _liveShards();
|
|
305
|
+
let best = null;
|
|
306
|
+
for (const shard of live) {
|
|
307
|
+
if (!best || shard.inflight < best.inflight) best = shard;
|
|
308
|
+
}
|
|
309
|
+
if (best && best.inflight === 0) return best;
|
|
310
|
+
if (live.length < poolSize) {
|
|
311
|
+
for (let slot = 0; slot < poolSize; slot += 1) {
|
|
312
|
+
const existing = _shards.get(slot);
|
|
313
|
+
if (!existing || existing.closed) return _ensureShardAt(slot);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return best || _ensureShardAt(0);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function _requestOn(shard, type, payload, onProgress) {
|
|
320
|
+
await shard.ready;
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const callId = `c${++_callSeq}`;
|
|
323
|
+
// One-shot shard sessions: when the spawn settles, its routing keys
|
|
324
|
+
// are stale (the child closes the session) and must not capture later
|
|
325
|
+
// same-tag traffic once the tag is reused on another shard.
|
|
326
|
+
const cleanupKeys = type === 'spawn'
|
|
327
|
+
? [String(payload?.tag || '').trim()].filter(Boolean)
|
|
328
|
+
: null;
|
|
329
|
+
shard.pending.set(callId, { resolve, reject, onProgress, cleanupKeys });
|
|
330
|
+
try {
|
|
331
|
+
shard.child.send({ type, callId, payload });
|
|
332
|
+
} catch (error) {
|
|
333
|
+
shard.pending.delete(callId);
|
|
334
|
+
reject(error);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function _startSpawn(entry) {
|
|
340
|
+
const shard = _shardForSpawn();
|
|
341
|
+
if (entry.tag) _ownerByKey.set(entry.tag, shard);
|
|
342
|
+
// Fresh lead-tool snapshot per spawn: MCP servers reconnect/toggle at
|
|
343
|
+
// runtime and the child rebuilds its proxy surface from this payload.
|
|
344
|
+
const payload = { ...entry.payload, leadTools: _leadToolDefs() };
|
|
345
|
+
_activeSpawns += 1;
|
|
346
|
+
shard.inflight += 1;
|
|
347
|
+
shard.lastActiveAt = Date.now();
|
|
348
|
+
_requestOn(shard, 'spawn', payload, entry.onProgress)
|
|
349
|
+
.then(entry.resolve, entry.reject)
|
|
350
|
+
.finally(() => {
|
|
351
|
+
_activeSpawns = Math.max(0, _activeSpawns - 1);
|
|
352
|
+
shard.inflight = Math.max(0, shard.inflight - 1);
|
|
353
|
+
shard.lastActiveAt = Date.now();
|
|
354
|
+
_drainSpawnQueue();
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function _drainSpawnQueue() {
|
|
359
|
+
const window = _currentWindow();
|
|
360
|
+
while (_spawnQueue.length > 0 && (window === 0 || _activeSpawns < window)) {
|
|
361
|
+
_startSpawn(_spawnQueue.shift());
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// A cancel for a spawn still WAITING in the window queue must not fan out to
|
|
366
|
+
// the shards (nothing was dispatched): drop the entry and fail its spawn
|
|
367
|
+
// promise the same way a closed session fails a live one.
|
|
368
|
+
function _cancelQueuedSpawn(payload) {
|
|
369
|
+
const tag = String(payload?.tag || '').trim();
|
|
370
|
+
const sessionId = String(payload?.sessionId || '').trim();
|
|
371
|
+
if (!tag && !sessionId) return null;
|
|
372
|
+
const index = _spawnQueue.findIndex((entry) => (tag && entry.tag === tag));
|
|
373
|
+
if (index < 0) return null;
|
|
374
|
+
const [entry] = _spawnQueue.splice(index, 1);
|
|
375
|
+
entry.reject(new Error('Session closed: agent shard spawn cancelled while queued'));
|
|
376
|
+
return { cancelled: true, queued: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One request/response round-trip to the shard pool. `spawn` goes to the
|
|
381
|
+
* least-loaded shard, bounded by the execution window (overflow queues FIFO,
|
|
382
|
+
* cancellable); `cancel`/`enqueue` route to the owning shard (fan-out to
|
|
383
|
+
* every live shard when ownership is unknown); `ping` warms shard 0.
|
|
384
|
+
* `onProgress` receives interim `progress` messages (e.g. `spawned`).
|
|
385
|
+
*/
|
|
386
|
+
export async function shardRequest(type, payload = {}, { onProgress } = {}) {
|
|
387
|
+
if (type === 'cancel' || type === 'enqueue') {
|
|
388
|
+
const fallback = type === 'cancel' ? { cancelled: false } : { queued: false };
|
|
389
|
+
if (type === 'cancel') {
|
|
390
|
+
const queuedCancel = _cancelQueuedSpawn(payload);
|
|
391
|
+
if (queuedCancel) return queuedCancel;
|
|
392
|
+
}
|
|
393
|
+
const owner = _ownerFor(payload);
|
|
394
|
+
if (owner) return _requestOn(owner, type, payload, onProgress);
|
|
395
|
+
const live = _liveShards();
|
|
396
|
+
if (live.length === 0) return fallback;
|
|
397
|
+
const settled = await Promise.allSettled(live.map((shard) => _requestOn(shard, type, payload, onProgress)));
|
|
398
|
+
const values = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
399
|
+
return values.find((v) => v?.cancelled === true || v?.queued === true) || values[0] || fallback;
|
|
400
|
+
}
|
|
401
|
+
if (type === 'spawn') {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const entry = {
|
|
404
|
+
payload,
|
|
405
|
+
onProgress,
|
|
406
|
+
resolve,
|
|
407
|
+
reject,
|
|
408
|
+
tag: String(payload?.tag || '').trim() || null,
|
|
409
|
+
};
|
|
410
|
+
const window = _currentWindow();
|
|
411
|
+
if (window > 0 && _activeSpawns >= window) {
|
|
412
|
+
const queueMax = agentShardQueueMax();
|
|
413
|
+
if (_spawnQueue.length >= queueMax) {
|
|
414
|
+
reject(new Error(`agent shard spawn queue full (${queueMax} deferred spawns; window=${window}) — `
|
|
415
|
+
+ 'wait for running workers to finish, or raise MIXDOG_AGENT_SHARD_QUEUE_MAX / MIXDOG_AGENT_SHARD_WINDOW'));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
_spawnQueue.push(entry);
|
|
419
|
+
} else {
|
|
420
|
+
_startSpawn(entry);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const shard = _ensureShardAt(0);
|
|
425
|
+
shard.lastActiveAt = Date.now();
|
|
426
|
+
return _requestOn(shard, type, payload, onProgress);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Boot prewarm: fork the shard child now and let it import the session
|
|
431
|
+
* runtime, so the FIRST sharded spawn skips the ~1-2s child boot. No-op when
|
|
432
|
+
* sharding is disabled. Best-effort; resolves true when the shard answered.
|
|
433
|
+
*/
|
|
434
|
+
export async function prewarmAgentShard() {
|
|
435
|
+
if (!agentShardEnabled()) return false;
|
|
436
|
+
try {
|
|
437
|
+
const pong = await shardRequest('ping', {});
|
|
438
|
+
return pong?.pong === true;
|
|
439
|
+
} catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|