mixdog 0.9.84 → 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/scripts/channel-daemon-stub.mjs +2 -1
- 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/agent-loop.mjs +30 -3
- 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/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +183 -41
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -2
- 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/builtin/shell-runtime.mjs +44 -73
- 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/owned-runtime.mjs +6 -8
- package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
- 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/memory/index.mjs +14 -5
- package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
- package/src/runtime/shared/atomic-file.mjs +76 -38
- 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/err-text.mjs +1 -0
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +43 -12
- 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 +42 -86
- 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 +11 -11
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- 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 +23 -58
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +358 -380
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +24 -9
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +24 -2
- 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
|
@@ -30,7 +30,7 @@ const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
|
|
|
30
30
|
const POSTER_TIMEOUT_MS = 20_000;
|
|
31
31
|
const MAX_POSTER_BYTES = 32 * 1024 * 1024;
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
const MEDIA_VARIANTS = Object.keys(SPECS);
|
|
34
34
|
|
|
35
35
|
/** Spec for a variant name, or null when the caller asked for the original. */
|
|
36
36
|
export function renditionSpec(variant) {
|
|
@@ -222,7 +222,7 @@ async function prewarmThumbs(assets) {
|
|
|
222
222
|
await Promise.all(workers).catch(() => {});
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
function getMediaAsset(id) {
|
|
226
226
|
ensureDirs();
|
|
227
227
|
return readIndex().find((entry) => entry.id === id) || null;
|
|
228
228
|
}
|
|
@@ -73,6 +73,7 @@ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandid
|
|
|
73
73
|
import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
|
|
74
74
|
import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
|
|
75
75
|
import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
|
|
76
|
+
import { safeIpcSend } from '../shared/safe-ipc-send.mjs'
|
|
76
77
|
import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
|
|
77
78
|
import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines, renderSessionGroupedLines } from './lib/recall-format.mjs'
|
|
78
79
|
import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
|
|
@@ -646,6 +647,14 @@ export { TOOL_DEFS, handleToolCall, buildSessionCoreMemoryPayload }
|
|
|
646
647
|
export { MEMORY_INSTRUCTIONS_TEXT as instructions }
|
|
647
648
|
export { acquireLock, releaseLock }
|
|
648
649
|
export { cwdFromTranscriptPath }
|
|
650
|
+
function sendMemoryWorkerMessage(message) {
|
|
651
|
+
return safeIpcSend(process, message, {
|
|
652
|
+
onError: (error) => {
|
|
653
|
+
const code = error?.code ? ` code=${error.code}` : ''
|
|
654
|
+
__mixdogMemoryLog(`[memory-worker] IPC send failed${code}: ${error?.message || error}\n`)
|
|
655
|
+
},
|
|
656
|
+
})
|
|
657
|
+
}
|
|
649
658
|
export async function init() {
|
|
650
659
|
if (_initialized) return
|
|
651
660
|
__mixdogMemoryLog(`[boot-time] tag=memory-init-start tMs=${Date.now()}\n`)
|
|
@@ -683,7 +692,7 @@ export async function init() {
|
|
|
683
692
|
}
|
|
684
693
|
if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
685
694
|
__mixdogMemoryLog(`[boot-time] tag=memory-ready tMs=${Date.now()}\n`)
|
|
686
|
-
|
|
695
|
+
sendMemoryWorkerMessage({ type: 'ready', port: boundPort })
|
|
687
696
|
}
|
|
688
697
|
__mixdogMemoryLog(`[memory-service] init() complete (entries unified mode, version=${PLUGIN_VERSION})\n`)
|
|
689
698
|
touchDaemonIdleTimer('init')
|
|
@@ -888,7 +897,7 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
|
888
897
|
entry.cancelled = true
|
|
889
898
|
entry.ac.abort()
|
|
890
899
|
_inFlightCalls.delete(msg.callId)
|
|
891
|
-
|
|
900
|
+
sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
892
901
|
}
|
|
893
902
|
return
|
|
894
903
|
}
|
|
@@ -902,9 +911,9 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
|
902
911
|
} finally {
|
|
903
912
|
_inFlightCalls.delete(msg.callId)
|
|
904
913
|
}
|
|
905
|
-
if (!entry.cancelled)
|
|
914
|
+
if (!entry.cancelled) sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, result })
|
|
906
915
|
} catch (e) {
|
|
907
|
-
if (!entry.cancelled)
|
|
916
|
+
if (!entry.cancelled) sendMemoryWorkerMessage({ type: 'result', callId: msg.callId, error: e.message })
|
|
908
917
|
}
|
|
909
918
|
})
|
|
910
919
|
init().catch(e => {
|
|
@@ -929,7 +938,7 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
|
929
938
|
__mixdogMemoryLog(`[memory-worker] init failed: ${detail}\n`)
|
|
930
939
|
// Signal degraded state to parent before exiting so it records the failure
|
|
931
940
|
// rather than treating this as a normal pre-ready crash.
|
|
932
|
-
|
|
941
|
+
sendMemoryWorkerMessage({ type: 'ready', degraded: true, error: detail.slice(0, 800) })
|
|
933
942
|
process.exit(1)
|
|
934
943
|
})
|
|
935
944
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Uses a module-level pending map keyed by callId. Parent-side handler
|
|
13
13
|
* lives in server.mjs spawnWorker's message listener.
|
|
14
14
|
*/
|
|
15
|
+
import { safeIpcSend } from '../../shared/safe-ipc-send.mjs'
|
|
15
16
|
|
|
16
17
|
const pending = new Map()
|
|
17
18
|
let listenerInstalled = false
|
|
@@ -28,6 +29,11 @@ function installListener() {
|
|
|
28
29
|
if (msg.ok) entry.resolve(msg.result)
|
|
29
30
|
else entry.reject(new Error(msg.error || 'agent_ipc_response error'))
|
|
30
31
|
})
|
|
32
|
+
process.once('disconnect', () => {
|
|
33
|
+
const error = new Error('agent-ipc: parent IPC disconnected')
|
|
34
|
+
for (const [, entry] of pending) entry.reject(error)
|
|
35
|
+
pending.clear()
|
|
36
|
+
})
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
function nextCallId() {
|
|
@@ -61,9 +67,7 @@ export function callAgentDispatch(opts = {}, prompt) {
|
|
|
61
67
|
if (!pending.has(callId)) return
|
|
62
68
|
pending.delete(callId)
|
|
63
69
|
if (process.connected) {
|
|
64
|
-
|
|
65
|
-
process.send({ type: 'agent_ipc_cancel', callId }, undefined, {}, () => {})
|
|
66
|
-
} catch {}
|
|
70
|
+
safeIpcSend(process, { type: 'agent_ipc_cancel', callId })
|
|
67
71
|
}
|
|
68
72
|
reject(new Error(`agent-ipc: timed out after ${timeoutMs}ms`))
|
|
69
73
|
}, timeoutMs)
|
|
@@ -80,8 +84,7 @@ export function callAgentDispatch(opts = {}, prompt) {
|
|
|
80
84
|
// Pass a write callback so a closed-channel write surfaces as an async
|
|
81
85
|
// error here (rejecting this Promise) instead of bubbling to the process
|
|
82
86
|
// 'error' event and crashing the daemon with ERR_IPC_CHANNEL_CLOSED.
|
|
83
|
-
|
|
84
|
-
process.send({
|
|
87
|
+
safeIpcSend(process, {
|
|
85
88
|
type: 'agent_ipc_request',
|
|
86
89
|
callId,
|
|
87
90
|
tool: 'agent_dispatch',
|
|
@@ -94,17 +97,13 @@ export function callAgentDispatch(opts = {}, prompt) {
|
|
|
94
97
|
prompt: String(prompt ?? ''),
|
|
95
98
|
timeout: timeoutMs,
|
|
96
99
|
},
|
|
97
|
-
},
|
|
98
|
-
|
|
100
|
+
}, {
|
|
101
|
+
onError: (err) => {
|
|
99
102
|
if (!pending.has(callId)) return
|
|
100
103
|
pending.delete(callId)
|
|
101
104
|
clearTimeout(timer)
|
|
102
105
|
reject(err)
|
|
106
|
+
},
|
|
103
107
|
})
|
|
104
|
-
} catch (e) {
|
|
105
|
-
pending.delete(callId)
|
|
106
|
-
clearTimeout(timer)
|
|
107
|
-
reject(e)
|
|
108
|
-
}
|
|
109
108
|
})
|
|
110
109
|
}
|
|
@@ -11,6 +11,15 @@ import {
|
|
|
11
11
|
unlinkSync,
|
|
12
12
|
writeFileSync,
|
|
13
13
|
} from 'fs';
|
|
14
|
+
import {
|
|
15
|
+
link as linkAsync,
|
|
16
|
+
mkdir as mkdirAsync,
|
|
17
|
+
open as openAsync,
|
|
18
|
+
readFile as readFileAsync,
|
|
19
|
+
rename as renameAsync,
|
|
20
|
+
unlink as unlinkAsync,
|
|
21
|
+
writeFile as writeFileAsync,
|
|
22
|
+
} from 'node:fs/promises';
|
|
14
23
|
import { dirname, basename, join } from 'path';
|
|
15
24
|
import { randomBytes } from 'crypto';
|
|
16
25
|
import { execFile, execFileSync } from 'child_process';
|
|
@@ -76,6 +85,25 @@ export function renameWithRetrySync(src, dst, opts = {}) {
|
|
|
76
85
|
throw lastErr;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
export async function renameWithRetry(src, dst, opts = {}) {
|
|
89
|
+
const backoffs = Array.isArray(opts.backoffs) && opts.backoffs.length > 0
|
|
90
|
+
? opts.backoffs
|
|
91
|
+
: DEFAULT_BACKOFFS_MS;
|
|
92
|
+
let lastErr = null;
|
|
93
|
+
for (let attempt = 0; attempt <= backoffs.length; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
await renameAsync(src, dst);
|
|
96
|
+
return true;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
lastErr = err;
|
|
99
|
+
if (!RETRY_CODES.has(err?.code) || attempt >= backoffs.length) break;
|
|
100
|
+
const jitter = Math.floor(Math.random() * Math.min(50, Math.max(1, backoffs[attempt])));
|
|
101
|
+
await _asyncSleep(backoffs[attempt] + jitter);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw lastErr;
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
80
108
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_LOCK_TIMEOUT_MS;
|
|
81
109
|
const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : 30000;
|
|
@@ -90,10 +118,15 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
|
90
118
|
} catch (err) {
|
|
91
119
|
lastErr = err;
|
|
92
120
|
if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
|
|
93
|
-
// try-once
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
121
|
+
// Reclaim is itself try-once: the guard is acquired atomically and never
|
|
122
|
+
// waited on. This lets timeoutMs:0 recover a dead/pidless corpse without
|
|
123
|
+
// weakening its no-sleep contract.
|
|
124
|
+
try {
|
|
125
|
+
if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
|
|
126
|
+
} catch {}
|
|
127
|
+
// try-once semantics: timeoutMs:0 makes one guarded reclaim attempt and
|
|
128
|
+
// never sleeps — live contention throws ELOCKCONTENDED immediately so
|
|
129
|
+
// the main loop is never blocked by Atomics.wait.
|
|
97
130
|
if (timeoutMs <= 0) {
|
|
98
131
|
const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
|
|
99
132
|
contErr.code = 'ELOCKCONTENDED';
|
|
@@ -102,9 +135,6 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
|
|
|
102
135
|
}
|
|
103
136
|
// Reclaim only when owner death is positively established. Age, an
|
|
104
137
|
// unreadable owner, or a same-pid foreign token remain contended.
|
|
105
|
-
try {
|
|
106
|
-
if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
|
|
107
|
-
} catch {}
|
|
108
138
|
if (Date.now() >= deadline) break;
|
|
109
139
|
const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
|
|
110
140
|
const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
|
|
@@ -195,14 +225,19 @@ function _lockOwnedBySelf(lockPath) {
|
|
|
195
225
|
//
|
|
196
226
|
// Reclaim decision table (owner = identity recorded in the lock file):
|
|
197
227
|
// owner pid DEAD (ESRCH) → reclaim
|
|
198
|
-
// owner pid NULL/uncertain
|
|
228
|
+
// owner pid NULL/uncertain and lock age >= staleMs → reclaim
|
|
229
|
+
// owner pid NULL/uncertain and lock age < staleMs → back off
|
|
230
|
+
// same-pid foreign token → back off
|
|
199
231
|
// owner token MISMATCH on guarded re-read (new holder took over) → back off
|
|
200
232
|
// owner LIVE → back off regardless of age
|
|
201
233
|
function _tryReclaimStaleLock(lockPath, staleMs) {
|
|
202
|
-
|
|
234
|
+
let initial;
|
|
235
|
+
try { initial = statSync(lockPath); } catch { return false; }
|
|
203
236
|
const owner = _readLockOwner(lockPath);
|
|
204
237
|
const dead = owner.pid !== null && !_ownerIsLive(owner);
|
|
205
|
-
|
|
238
|
+
const pidlessStale = owner.pid === null
|
|
239
|
+
&& Date.now() - (Number(initial.mtimeMs) || 0) >= Math.max(0, staleMs);
|
|
240
|
+
if (!dead && !pidlessStale) return false;
|
|
206
241
|
const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
|
|
207
242
|
if (reclaim === null) return false;
|
|
208
243
|
try {
|
|
@@ -215,7 +250,9 @@ function _tryReclaimStaleLock(lockPath, staleMs) {
|
|
|
215
250
|
// corpse — so we never yank a freshly-acquired live lock.
|
|
216
251
|
if (curOwner.pid !== owner.pid || curOwner.token !== owner.token) return false;
|
|
217
252
|
const curDead = curOwner.pid !== null && !_ownerIsLive(curOwner);
|
|
218
|
-
|
|
253
|
+
const curPidlessStale = curOwner.pid === null
|
|
254
|
+
&& Date.now() - (Number(cur.mtimeMs) || 0) >= Math.max(0, staleMs);
|
|
255
|
+
if (curDead || curPidlessStale) {
|
|
219
256
|
try { unlinkSync(lockPath); return true; } catch { return false; }
|
|
220
257
|
}
|
|
221
258
|
return false;
|
|
@@ -279,6 +316,11 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
279
316
|
} catch (err) {
|
|
280
317
|
lastErr = err;
|
|
281
318
|
if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
|
|
319
|
+
// The guarded reclaim is non-blocking, so try-once callers can recover
|
|
320
|
+
// stale corpses while still returning immediately for a live holder.
|
|
321
|
+
try {
|
|
322
|
+
if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
|
|
323
|
+
} catch {}
|
|
282
324
|
if (timeoutMs <= 0) {
|
|
283
325
|
const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
|
|
284
326
|
contErr.code = 'ELOCKCONTENDED';
|
|
@@ -288,9 +330,6 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
288
330
|
// Stale-lock reclaim (shared with the sync variant): dead owner pid,
|
|
289
331
|
// a same-pid-but-foreign-token corpse from a reused pid, or a
|
|
290
332
|
// pidless/empty corpse lock. A live foreign holder is never stolen.
|
|
291
|
-
try {
|
|
292
|
-
if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
|
|
293
|
-
} catch {}
|
|
294
333
|
if (Date.now() >= deadline) break;
|
|
295
334
|
const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
|
|
296
335
|
const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
|
|
@@ -585,62 +624,60 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
|
|
|
585
624
|
}, opts);
|
|
586
625
|
}
|
|
587
626
|
|
|
588
|
-
// ── Async atomic file write
|
|
589
|
-
// Mirror of writeFileAtomicSync
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
// writeFileSync/renameSync of a small JSON payload stay synchronous (they are
|
|
593
|
-
// not the hitch); only the icacls calls — the actual blockers — are awaited.
|
|
627
|
+
// ── Async atomic file write ─────────────────────────────────────────
|
|
628
|
+
// Mirror of writeFileAtomicSync using promise-based filesystem calls and async
|
|
629
|
+
// ACL enforcement. Large summary/pending-message JSON writes therefore never
|
|
630
|
+
// block the host event loop on read/write/fsync/rename.
|
|
594
631
|
// The truncate renameFallback branch is intentionally omitted: it only ever
|
|
595
632
|
// applied to non-secret writes (opts.secret !== true), and every async caller
|
|
596
633
|
// here is a secret config write, so behavior is identical.
|
|
597
634
|
export async function writeFileAtomicAsync(filePath, data, opts = {}) {
|
|
598
635
|
const run = async () => {
|
|
599
636
|
const dir = dirname(filePath);
|
|
600
|
-
|
|
637
|
+
await mkdirAsync(dir, { recursive: true });
|
|
601
638
|
const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
|
|
602
639
|
try {
|
|
603
640
|
const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
|
|
604
|
-
|
|
641
|
+
await writeFileAsync(tmp, data, writeOpts);
|
|
605
642
|
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
|
|
606
643
|
if (opts.fsync !== false) {
|
|
607
644
|
let fd = null;
|
|
608
645
|
try {
|
|
609
|
-
fd =
|
|
610
|
-
|
|
646
|
+
fd = await openAsync(tmp, 'r');
|
|
647
|
+
await fd.sync();
|
|
611
648
|
} catch (err) {
|
|
612
649
|
if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
|
|
613
650
|
} finally {
|
|
614
|
-
try { if (fd !== null)
|
|
651
|
+
try { if (fd !== null) await fd.close(); } catch {}
|
|
615
652
|
}
|
|
616
653
|
}
|
|
617
654
|
if (opts.createOnly === true) {
|
|
618
655
|
try {
|
|
619
|
-
|
|
656
|
+
await linkAsync(tmp, filePath);
|
|
620
657
|
} catch (err) {
|
|
621
|
-
try {
|
|
658
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
622
659
|
if (err?.code === 'EEXIST') return false;
|
|
623
660
|
throw err;
|
|
624
661
|
}
|
|
625
|
-
try {
|
|
662
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
626
663
|
} else {
|
|
627
|
-
|
|
664
|
+
await renameWithRetry(tmp, filePath, opts);
|
|
628
665
|
}
|
|
629
666
|
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
|
|
630
667
|
if (opts.fsyncDir === true) {
|
|
631
668
|
let dfd = null;
|
|
632
669
|
try {
|
|
633
|
-
dfd =
|
|
634
|
-
|
|
670
|
+
dfd = await openAsync(dir, 'r');
|
|
671
|
+
await dfd.sync();
|
|
635
672
|
} catch (err) {
|
|
636
673
|
if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
|
|
637
674
|
} finally {
|
|
638
|
-
try { if (dfd !== null)
|
|
675
|
+
try { if (dfd !== null) await dfd.close(); } catch {}
|
|
639
676
|
}
|
|
640
677
|
}
|
|
641
678
|
return true;
|
|
642
679
|
} catch (err) {
|
|
643
|
-
try {
|
|
680
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
644
681
|
throw err;
|
|
645
682
|
}
|
|
646
683
|
};
|
|
@@ -656,20 +693,21 @@ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
|
|
|
656
693
|
|
|
657
694
|
// Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
|
|
658
695
|
// as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
|
|
659
|
-
// The critical section
|
|
660
|
-
//
|
|
696
|
+
// The lock wait and filesystem critical section are both asynchronous. JSON
|
|
697
|
+
// parse/mutation/stringify remain synchronous CPU work, but no disk operation
|
|
698
|
+
// or rename retry sleeps on the host event loop.
|
|
661
699
|
export async function updateJsonAtomic(filePath, mutator, opts = {}) {
|
|
662
700
|
const { lock: _lock, ...writeOpts } = opts;
|
|
663
|
-
return withFileLock(`${filePath}.lock`, () => {
|
|
701
|
+
return withFileLock(`${filePath}.lock`, async () => {
|
|
664
702
|
let cur = null;
|
|
665
703
|
try {
|
|
666
|
-
cur = JSON.parse(
|
|
704
|
+
cur = JSON.parse(await readFileAsync(filePath, 'utf8'));
|
|
667
705
|
} catch {
|
|
668
706
|
cur = null;
|
|
669
707
|
}
|
|
670
708
|
const next = mutator(cur);
|
|
671
709
|
if (next === undefined) return cur;
|
|
672
|
-
|
|
710
|
+
await writeJsonAtomicAsync(filePath, next, { ...writeOpts, lock: false });
|
|
673
711
|
return next;
|
|
674
712
|
}, opts);
|
|
675
713
|
}
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* {type:'file'}), so provider media normalization treats them identically.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
|
|
11
11
|
export const MAX_AUTOMATION_ATTACHMENTS = 8;
|
|
12
12
|
// Base64 total across image/pdf items; text totals separately.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
|
|
14
|
+
const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
|
|
15
15
|
|
|
16
16
|
/** Validate + strip a stored/list attachments value to the persisted shape (or null). */
|
|
17
17
|
export function normalizeAutomationAttachments(value) {
|
|
@@ -8,6 +8,20 @@ function positiveInt(value) {
|
|
|
8
8
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function childGuardianSpawnEnv(env = process.env) {
|
|
12
|
+
return {
|
|
13
|
+
PATH: env.PATH || '',
|
|
14
|
+
SystemRoot: env.SystemRoot || env.WINDIR || '',
|
|
15
|
+
WINDIR: env.WINDIR || env.SystemRoot || '',
|
|
16
|
+
// Desktop's engine runs inside an Electron utility process, where
|
|
17
|
+
// process.execPath points at electron.exe. Without Node mode Electron
|
|
18
|
+
// ignores --eval, starts a full app process, and the intended guardian
|
|
19
|
+
// loop never runs — every completed shell then leaks ~100 MB until the
|
|
20
|
+
// desktop exits.
|
|
21
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
|
|
12
26
|
return `
|
|
13
27
|
const { spawnSync } = require('node:child_process');
|
|
@@ -88,11 +102,7 @@ export function startChildGuardian({
|
|
|
88
102
|
}),
|
|
89
103
|
], {
|
|
90
104
|
stdio: 'ignore',
|
|
91
|
-
env:
|
|
92
|
-
PATH: process.env.PATH || '',
|
|
93
|
-
SystemRoot: process.env.SystemRoot || process.env.WINDIR || '',
|
|
94
|
-
WINDIR: process.env.WINDIR || process.env.SystemRoot || '',
|
|
95
|
-
},
|
|
105
|
+
env: childGuardianSpawnEnv(),
|
|
96
106
|
...detachedSpawnOpts,
|
|
97
107
|
});
|
|
98
108
|
guardian.unref?.();
|
|
@@ -3,25 +3,19 @@
|
|
|
3
3
|
// ── Module-global child-spawn semaphore ──────────────────────────────────
|
|
4
4
|
//
|
|
5
5
|
// Single-daemon premise: tool execution is in-process, so a module-level
|
|
6
|
-
// singleton semaphore
|
|
7
|
-
// mixdog-graph, …) across ALL agents/workers in this daemon.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// deadline stops tripping en masse — WITHOUT serializing: up to the cap run
|
|
11
|
-
// concurrently, only the overflow queues and drains as slots free.
|
|
6
|
+
// singleton semaphore CAN bound the number of concurrent child processes (rg,
|
|
7
|
+
// mixdog-graph, …) across ALL agents/workers in this daemon. By default the
|
|
8
|
+
// gate is UNBOUNDED — parallel waves must never queue behind it — and it only
|
|
9
|
+
// becomes a real cap when MIXDOG_CHILD_SPAWN_MAX_INFLIGHT is set explicitly.
|
|
12
10
|
//
|
|
13
11
|
// IMPORTANT: this is a resource-control knob and is deliberately NOT exposed
|
|
14
12
|
// on any tool JSON schema / tool parameter surface. The only tuning surface
|
|
15
|
-
// is the
|
|
16
|
-
|
|
17
|
-
import os from 'node:os';
|
|
13
|
+
// is the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
|
|
18
14
|
|
|
19
15
|
function _defaultMaxInflight() {
|
|
20
16
|
const override = Number(process.env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
|
|
21
17
|
if (Number.isFinite(override) && override >= 1) return Math.floor(override);
|
|
22
|
-
|
|
23
|
-
try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
|
|
24
|
-
return Math.max(4, cpus || 4);
|
|
18
|
+
return Infinity;
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
const MAX_INFLIGHT = _defaultMaxInflight();
|
|
@@ -146,6 +146,7 @@ export function presentErrorText(error, options = {}) {
|
|
|
146
146
|
return 'Context too large.';
|
|
147
147
|
}
|
|
148
148
|
if (/\bcompact(?:ion)?\b.*\b(?:failed|error|overflow)\b|\b(?:failed|error)\b.*\bcompact(?:ion)?\b/i.test(text)) {
|
|
149
|
+
if (String(surface).toLowerCase().includes('compact')) return capText(text, max);
|
|
149
150
|
return 'Compact failed.';
|
|
150
151
|
}
|
|
151
152
|
|
|
@@ -4,10 +4,14 @@ import { requestMemoryPressureSnapshot } from './memory-snapshot.mjs';
|
|
|
4
4
|
|
|
5
5
|
const MB = 1024 * 1024;
|
|
6
6
|
|
|
7
|
+
// Concurrency defaults are UNBOUNDED by design: parallel agent/shell waves
|
|
8
|
+
// must never serialize behind an admission queue. The only default guards are
|
|
9
|
+
// the memory-pressure checks below; hard caps exist solely as opt-in env
|
|
10
|
+
// overrides (MIXDOG_MAX_CONCURRENT_AGENTS / _SHELLS / _HIGH_LOAD).
|
|
7
11
|
export const RESOURCE_ADMISSION_DEFAULTS = Object.freeze({
|
|
8
|
-
maxAgents:
|
|
9
|
-
maxShells:
|
|
10
|
-
maxHighLoad:
|
|
12
|
+
maxAgents: Infinity,
|
|
13
|
+
maxShells: Infinity,
|
|
14
|
+
maxHighLoad: Infinity,
|
|
11
15
|
maxQueue: 32,
|
|
12
16
|
minFreeMemoryMb: 1024,
|
|
13
17
|
maxRssMb: 3072,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function ipcUnavailableError() {
|
|
2
|
+
return Object.assign(new Error('IPC channel unavailable'), {
|
|
3
|
+
code: 'ERR_IPC_CHANNEL_CLOSED',
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function safeIpcSend(proc, message, { onError, onComplete } = {}) {
|
|
8
|
+
const report = (error) => {
|
|
9
|
+
if (!error) return;
|
|
10
|
+
try { onError?.(error); } catch {}
|
|
11
|
+
};
|
|
12
|
+
const complete = (error = null) => {
|
|
13
|
+
try { onComplete?.(error); } catch {}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (!proc || typeof proc.send !== 'function' || proc.connected !== true) {
|
|
17
|
+
const error = ipcUnavailableError();
|
|
18
|
+
report(error);
|
|
19
|
+
complete(error);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
proc.send(message, undefined, {}, (error) => {
|
|
25
|
+
report(error);
|
|
26
|
+
complete(error || null);
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
report(error);
|
|
31
|
+
complete(error);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -47,9 +47,6 @@ function publicAgentReviews(sessionId) {
|
|
|
47
47
|
return [...turn.agents.values()].map((review) => ({ ...review }));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** Compatibility no-op: apply_patch tracking has no project warmup cost. */
|
|
51
|
-
export function prewarmTurnSnapshot() {}
|
|
52
|
-
|
|
53
50
|
/** Start a new user turn and invalidate the prior turn's child review group. */
|
|
54
51
|
export async function beginTurnSnapshot(_worktree, sessionId) {
|
|
55
52
|
const ownerSessionId = clean(sessionId);
|