mixdog 0.9.83 → 0.9.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/channel-daemon-stub.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +48 -32
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +25 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +8 -2
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +38 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
- 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/worker-ipc.mjs +17 -4
- 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 +51 -24
- package/src/runtime/shared/err-text.mjs +1 -0
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/session-runtime/lifecycle-api.mjs +9 -3
- package/src/session-runtime/runtime-core.mjs +5 -1
- package/src/standalone/channel-daemon.mjs +2 -1
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- package/src/tui/app/slash-dispatch.mjs +23 -13
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/dist/index.mjs +115 -57
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +17 -1
|
@@ -3,6 +3,7 @@ import { loadConfig, createBackend } from "./config.mjs";
|
|
|
3
3
|
import { WebhookServer } from "./webhook.mjs";
|
|
4
4
|
import { EventPipeline } from "./event-pipeline.mjs";
|
|
5
5
|
import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
|
|
6
|
+
import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
|
|
6
7
|
import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
|
|
7
8
|
import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
|
|
8
9
|
import {
|
|
@@ -423,14 +424,11 @@ function armBridgeOwnershipTimer() {}
|
|
|
423
424
|
// error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
|
|
424
425
|
// connected check and delivery). Log-and-continue — never crash the worker.
|
|
425
426
|
function sendToParent(message) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
} catch (err) {
|
|
432
|
-
process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
|
|
433
|
-
}
|
|
427
|
+
safeIpcSend(process, message, {
|
|
428
|
+
onError: (err) => {
|
|
429
|
+
try { process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`); } catch {}
|
|
430
|
+
},
|
|
431
|
+
});
|
|
434
432
|
}
|
|
435
433
|
// Tell the parent session this worker ACQUIRED the bridge so it flips remote
|
|
436
434
|
// mode ON (badge/transcript writer). Callers fire this only on a genuine
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// (behavior-preserving). Groups the notify-to-parent path and the
|
|
3
3
|
// worker → parent → memory call bridge. Bound to live getters
|
|
4
4
|
// (getInstanceId) so runtime identity stays consistent.
|
|
5
|
+
import { safeIpcSend } from '../../shared/safe-ipc-send.mjs';
|
|
6
|
+
|
|
5
7
|
function normalizeChannelNotifyParams(method, params) {
|
|
6
8
|
if (method === 'notifications/claude/channel' && params && params.meta) {
|
|
7
9
|
const m = {};
|
|
@@ -24,7 +26,7 @@ function setChannelNotifySink(fn) {
|
|
|
24
26
|
_notifySink = typeof fn === 'function' ? fn : null;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function createParentBridge({ getInstanceId }) {
|
|
29
|
+
function createParentBridge({ getInstanceId, ipcProcess = process }) {
|
|
28
30
|
function sendNotifyToParent(method, params) {
|
|
29
31
|
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
30
32
|
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
@@ -37,15 +39,15 @@ function createParentBridge({ getInstanceId }) {
|
|
|
37
39
|
catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
if (!
|
|
42
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
41
43
|
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
46
|
+
safeIpcSend(ipcProcess, { type: 'notify', method, params: outParams }, {
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// ── Memory worker bridge (worker → parent → memory) ─────────────────
|
|
@@ -56,10 +58,24 @@ function createParentBridge({ getInstanceId }) {
|
|
|
56
58
|
// integrated into the main IPC handler below (not a second listener).
|
|
57
59
|
const _memoryCallPending = new Map();
|
|
58
60
|
let _memoryCallSeq = 0;
|
|
61
|
+
const failMemoryCall = (callId, error) => {
|
|
62
|
+
const pending = _memoryCallPending.get(callId);
|
|
63
|
+
if (!pending) return;
|
|
64
|
+
_memoryCallPending.delete(callId);
|
|
65
|
+
pending.reject(error instanceof Error ? error : new Error(String(error || 'memory_call IPC failed')));
|
|
66
|
+
};
|
|
67
|
+
const rejectAllMemoryCalls = (error) => {
|
|
68
|
+
for (const callId of Array.from(_memoryCallPending.keys())) failMemoryCall(callId, error);
|
|
69
|
+
};
|
|
70
|
+
ipcProcess?.once?.('disconnect', () => {
|
|
71
|
+
rejectAllMemoryCalls(new Error('memory_call parent IPC disconnected'));
|
|
72
|
+
});
|
|
59
73
|
|
|
60
74
|
function callMemoryAction(action, args, timeoutMs) {
|
|
61
75
|
return new Promise((resolve, reject) => {
|
|
62
|
-
if (!
|
|
76
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
77
|
+
return reject(new Error('not a connected worker process'));
|
|
78
|
+
}
|
|
63
79
|
const callId = `mc_${getInstanceId()}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
64
80
|
const timer = setTimeout(() => {
|
|
65
81
|
_memoryCallPending.delete(callId);
|
|
@@ -69,13 +85,9 @@ function createParentBridge({ getInstanceId }) {
|
|
|
69
85
|
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
70
86
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
71
87
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
_memoryCallPending.delete(callId);
|
|
76
|
-
clearTimeout(timer);
|
|
77
|
-
reject(e);
|
|
78
|
-
}
|
|
88
|
+
safeIpcSend(ipcProcess, { type: 'memory_call_request', callId, action, args: args || {} }, {
|
|
89
|
+
onError: (error) => failMemoryCall(callId, error),
|
|
90
|
+
});
|
|
79
91
|
});
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { performance } from "perf_hooks";
|
|
2
|
+
import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
|
|
2
3
|
// IPC worker-mode message loop extracted from channels/index.mjs
|
|
3
4
|
// (behavior-preserving). Installs shutdown handlers, the parent->worker message
|
|
4
5
|
// router, and the retrying start() bootstrap. Call once from the worker entry
|
|
@@ -19,6 +20,11 @@ export function runWorkerIpc({
|
|
|
19
20
|
handleToolCallWithBridgeRetry,
|
|
20
21
|
bootProfile,
|
|
21
22
|
}) {
|
|
23
|
+
const sendToParent = (message) => safeIpcSend(process, message, {
|
|
24
|
+
onError: (error) => {
|
|
25
|
+
try { process.stderr.write(`[channels-worker] parent IPC send failed: ${error?.message || error}\n`); } catch {}
|
|
26
|
+
},
|
|
27
|
+
});
|
|
22
28
|
// SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
|
|
23
29
|
// Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
|
|
24
30
|
let _channelsStopInFlight = false
|
|
@@ -29,6 +35,10 @@ export function runWorkerIpc({
|
|
|
29
35
|
return
|
|
30
36
|
}
|
|
31
37
|
_channelsStopInFlight = true
|
|
38
|
+
for (const controller of _inFlightChannelCalls.values()) {
|
|
39
|
+
try { controller.abort() } catch {}
|
|
40
|
+
}
|
|
41
|
+
_inFlightChannelCalls.clear()
|
|
32
42
|
process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
|
|
33
43
|
_channelsForceExitTimer = setTimeout(() => {
|
|
34
44
|
process.stderr.write(`[channels-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
|
|
@@ -47,6 +57,7 @@ export function runWorkerIpc({
|
|
|
47
57
|
}
|
|
48
58
|
process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
|
|
49
59
|
process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
|
|
60
|
+
process.once('disconnect', () => _channelsShutdownHandler('IPC:disconnect'))
|
|
50
61
|
|
|
51
62
|
// Map of callId → AbortController for in-flight IPC calls.
|
|
52
63
|
const _inFlightChannelCalls = new Map()
|
|
@@ -144,7 +155,7 @@ export function runWorkerIpc({
|
|
|
144
155
|
entry.abort()
|
|
145
156
|
_inFlightChannelCalls.delete(msg.callId)
|
|
146
157
|
}
|
|
147
|
-
|
|
158
|
+
sendToParent({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
148
159
|
return
|
|
149
160
|
}
|
|
150
161
|
if (msg.type !== 'call' || !msg.callId) return
|
|
@@ -157,9 +168,9 @@ export function runWorkerIpc({
|
|
|
157
168
|
} finally {
|
|
158
169
|
_inFlightChannelCalls.delete(msg.callId)
|
|
159
170
|
}
|
|
160
|
-
|
|
171
|
+
sendToParent({ type: 'result', callId: msg.callId, result })
|
|
161
172
|
} catch (e) {
|
|
162
|
-
|
|
173
|
+
sendToParent({ type: 'result', callId: msg.callId, error: e.message })
|
|
163
174
|
}
|
|
164
175
|
})
|
|
165
176
|
void (async () => {
|
|
@@ -174,7 +185,9 @@ export function runWorkerIpc({
|
|
|
174
185
|
try {
|
|
175
186
|
await start()
|
|
176
187
|
bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
|
|
177
|
-
|
|
188
|
+
if (!sendToParent({ type: 'ready' })) {
|
|
189
|
+
void _channelsShutdownHandler('IPC:ready-send-failed')
|
|
190
|
+
}
|
|
178
191
|
return
|
|
179
192
|
} catch (e) {
|
|
180
193
|
lastErr = e
|
|
@@ -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;
|
|
@@ -585,62 +613,60 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
|
|
|
585
613
|
}, opts);
|
|
586
614
|
}
|
|
587
615
|
|
|
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.
|
|
616
|
+
// ── Async atomic file write ─────────────────────────────────────────
|
|
617
|
+
// Mirror of writeFileAtomicSync using promise-based filesystem calls and async
|
|
618
|
+
// ACL enforcement. Large summary/pending-message JSON writes therefore never
|
|
619
|
+
// block the host event loop on read/write/fsync/rename.
|
|
594
620
|
// The truncate renameFallback branch is intentionally omitted: it only ever
|
|
595
621
|
// applied to non-secret writes (opts.secret !== true), and every async caller
|
|
596
622
|
// here is a secret config write, so behavior is identical.
|
|
597
623
|
export async function writeFileAtomicAsync(filePath, data, opts = {}) {
|
|
598
624
|
const run = async () => {
|
|
599
625
|
const dir = dirname(filePath);
|
|
600
|
-
|
|
626
|
+
await mkdirAsync(dir, { recursive: true });
|
|
601
627
|
const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
|
|
602
628
|
try {
|
|
603
629
|
const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
|
|
604
|
-
|
|
630
|
+
await writeFileAsync(tmp, data, writeOpts);
|
|
605
631
|
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
|
|
606
632
|
if (opts.fsync !== false) {
|
|
607
633
|
let fd = null;
|
|
608
634
|
try {
|
|
609
|
-
fd =
|
|
610
|
-
|
|
635
|
+
fd = await openAsync(tmp, 'r');
|
|
636
|
+
await fd.sync();
|
|
611
637
|
} catch (err) {
|
|
612
638
|
if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
|
|
613
639
|
} finally {
|
|
614
|
-
try { if (fd !== null)
|
|
640
|
+
try { if (fd !== null) await fd.close(); } catch {}
|
|
615
641
|
}
|
|
616
642
|
}
|
|
617
643
|
if (opts.createOnly === true) {
|
|
618
644
|
try {
|
|
619
|
-
|
|
645
|
+
await linkAsync(tmp, filePath);
|
|
620
646
|
} catch (err) {
|
|
621
|
-
try {
|
|
647
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
622
648
|
if (err?.code === 'EEXIST') return false;
|
|
623
649
|
throw err;
|
|
624
650
|
}
|
|
625
|
-
try {
|
|
651
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
626
652
|
} else {
|
|
627
|
-
|
|
653
|
+
await renameWithRetry(tmp, filePath, opts);
|
|
628
654
|
}
|
|
629
655
|
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
|
|
630
656
|
if (opts.fsyncDir === true) {
|
|
631
657
|
let dfd = null;
|
|
632
658
|
try {
|
|
633
|
-
dfd =
|
|
634
|
-
|
|
659
|
+
dfd = await openAsync(dir, 'r');
|
|
660
|
+
await dfd.sync();
|
|
635
661
|
} catch (err) {
|
|
636
662
|
if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
|
|
637
663
|
} finally {
|
|
638
|
-
try { if (dfd !== null)
|
|
664
|
+
try { if (dfd !== null) await dfd.close(); } catch {}
|
|
639
665
|
}
|
|
640
666
|
}
|
|
641
667
|
return true;
|
|
642
668
|
} catch (err) {
|
|
643
|
-
try {
|
|
669
|
+
try { await unlinkAsync(tmp); } catch {}
|
|
644
670
|
throw err;
|
|
645
671
|
}
|
|
646
672
|
};
|
|
@@ -656,20 +682,21 @@ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
|
|
|
656
682
|
|
|
657
683
|
// Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
|
|
658
684
|
// as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
|
|
659
|
-
// The critical section
|
|
660
|
-
//
|
|
685
|
+
// The lock wait and filesystem critical section are both asynchronous. JSON
|
|
686
|
+
// parse/mutation/stringify remain synchronous CPU work, but no disk operation
|
|
687
|
+
// or rename retry sleeps on the host event loop.
|
|
661
688
|
export async function updateJsonAtomic(filePath, mutator, opts = {}) {
|
|
662
689
|
const { lock: _lock, ...writeOpts } = opts;
|
|
663
|
-
return withFileLock(`${filePath}.lock`, () => {
|
|
690
|
+
return withFileLock(`${filePath}.lock`, async () => {
|
|
664
691
|
let cur = null;
|
|
665
692
|
try {
|
|
666
|
-
cur = JSON.parse(
|
|
693
|
+
cur = JSON.parse(await readFileAsync(filePath, 'utf8'));
|
|
667
694
|
} catch {
|
|
668
695
|
cur = null;
|
|
669
696
|
}
|
|
670
697
|
const next = mutator(cur);
|
|
671
698
|
if (next === undefined) return cur;
|
|
672
|
-
|
|
699
|
+
await writeJsonAtomicAsync(filePath, next, { ...writeOpts, lock: false });
|
|
673
700
|
return next;
|
|
674
701
|
}, opts);
|
|
675
702
|
}
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -158,6 +158,12 @@ export function createLifecycleApi(deps) {
|
|
|
158
158
|
return {
|
|
159
159
|
async close(reason = 'cli-exit', options = {}) {
|
|
160
160
|
const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
|
|
161
|
+
// Desktop multi-engine hosts dispose idle engines while other sessions
|
|
162
|
+
// keep working in the SAME process. The background-work registries
|
|
163
|
+
// (shared background tasks, shell jobs, bash sessions) are
|
|
164
|
+
// process-global, so a non-exit dispose must leave them running instead
|
|
165
|
+
// of reaping every session's jobs. CLI exit paths never set this.
|
|
166
|
+
const keepBackgroundWork = options?.keepBackgroundWork === true;
|
|
161
167
|
setCloseRequested(true);
|
|
162
168
|
// Self-update now stages in the background and swaps on the next clean
|
|
163
169
|
// launch (see staged-update.mjs) — nothing installs at shutdown. On a
|
|
@@ -218,7 +224,7 @@ export function createLifecycleApi(deps) {
|
|
|
218
224
|
warmupTimers[timerKey] = null;
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
|
-
try { cancelBackgroundTasks({ reason, notify: false }); } catch {}
|
|
227
|
+
try { if (!keepBackgroundWork) cancelBackgroundTasks({ reason, notify: false }); } catch {}
|
|
222
228
|
const channelStop = channels.stop(reason, detach ? { waitForExit: false } : undefined);
|
|
223
229
|
try { agentTool.closeAll(reason); } catch {}
|
|
224
230
|
let mcpStop = null;
|
|
@@ -260,12 +266,12 @@ export function createLifecycleApi(deps) {
|
|
|
260
266
|
invalidateContextStatusCache();
|
|
261
267
|
notificationListeners?.clear?.();
|
|
262
268
|
remoteStateListeners?.clear?.();
|
|
263
|
-
const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
|
|
269
|
+
const shellJobsStop = !keepBackgroundWork && globalThis.__mixdogShellJobsRuntimeLoaded === true
|
|
264
270
|
? import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs')
|
|
265
271
|
.then((mod) => mod?.shutdownShellJobs?.(reason, { sync: !detach }))
|
|
266
272
|
.catch(() => {})
|
|
267
273
|
: null;
|
|
268
|
-
const bashSessionsStop = globalThis.__mixdogBashSessionRuntimeLoaded === true
|
|
274
|
+
const bashSessionsStop = !keepBackgroundWork && globalThis.__mixdogBashSessionRuntimeLoaded === true
|
|
269
275
|
? import('../runtime/agent/orchestrator/tools/bash-session.mjs')
|
|
270
276
|
.then((mod) => mod?.shutdownBashSessions?.(reason))
|
|
271
277
|
.catch(() => {})
|
|
@@ -845,7 +845,11 @@ export async function createMixdogSessionRuntime({
|
|
|
845
845
|
if (name === 'recall' || name === 'memory' || name === 'search_memories') {
|
|
846
846
|
const memoryMod = await getMemoryModule();
|
|
847
847
|
if (!memoryMod?.handleToolCall) throw new Error('memory runtime is not available');
|
|
848
|
-
return await memoryMod.handleToolCall(
|
|
848
|
+
return await memoryMod.handleToolCall(
|
|
849
|
+
name,
|
|
850
|
+
memoryToolArgsForCaller(args, callerCwd),
|
|
851
|
+
callerCtx?.signal || session?.controller?.signal || null,
|
|
852
|
+
);
|
|
849
853
|
}
|
|
850
854
|
if (name === 'code_graph') {
|
|
851
855
|
const codeGraphMod = await getCodeGraphModule();
|
|
@@ -23,6 +23,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
23
23
|
import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
24
24
|
import { setChannelNotifySink } from '../runtime/channels/lib/parent-bridge.mjs';
|
|
25
25
|
import { setOwnerContext } from '../runtime/channels/lib/runtime-paths.mjs';
|
|
26
|
+
import { safeIpcSend } from '../runtime/shared/safe-ipc-send.mjs';
|
|
26
27
|
import { createChannelDaemonTransport } from './channel-daemon-transport.mjs';
|
|
27
28
|
import { createStandaloneMemoryRuntime } from './memory-runtime-proxy.mjs';
|
|
28
29
|
|
|
@@ -183,7 +184,7 @@ async function main() {
|
|
|
183
184
|
// forever. process.connected gates the obvious-dead case; the send callback
|
|
184
185
|
// swallows the async delivery error so it never reaches uncaughtException.
|
|
185
186
|
if (process.connected && process.send) {
|
|
186
|
-
|
|
187
|
+
safeIpcSend(process, { type: 'ready', port, token });
|
|
187
188
|
}
|
|
188
189
|
log(`ready port=${port} pid=${process.pid} in ${(performance.now() - startedAt).toFixed(0)}ms`);
|
|
189
190
|
|
|
@@ -10,6 +10,7 @@ import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.m
|
|
|
10
10
|
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
|
|
11
11
|
import { attachToDaemon, readDaemonDiscovery, probeDaemonHealth } from './channel-daemon-client.mjs';
|
|
12
12
|
import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { safeIpcSend } from '../runtime/shared/safe-ipc-send.mjs';
|
|
13
14
|
import { randomUUID } from 'node:crypto';
|
|
14
15
|
|
|
15
16
|
const CHANNEL_TOOLS = new Set([
|
|
@@ -244,7 +245,9 @@ export function createStandaloneChannelWorker({
|
|
|
244
245
|
// stuck send can't wedge the queue forever.
|
|
245
246
|
inFlightWatchdog = setTimeout(() => finish(new Error('channels worker send timed out')), SEND_FLUSH_TIMEOUT_MS);
|
|
246
247
|
inFlightWatchdog.unref?.();
|
|
247
|
-
child
|
|
248
|
+
safeIpcSend(child, item.msg, {
|
|
249
|
+
onComplete: (error) => finish(error),
|
|
250
|
+
});
|
|
248
251
|
}
|
|
249
252
|
|
|
250
253
|
function sendToChild(msg, cb) {
|
|
@@ -804,19 +807,20 @@ export function createStandaloneChannelWorker({
|
|
|
804
807
|
const sendTimer = setTimeout(() => settle(true), 250);
|
|
805
808
|
sendTimer.unref?.();
|
|
806
809
|
target.once('exit', () => settle(true));
|
|
807
|
-
|
|
808
|
-
|
|
810
|
+
safeIpcSend(target, { type: 'shutdown', reason }, {
|
|
811
|
+
onComplete: (error) => {
|
|
809
812
|
try { target.disconnect?.(); } catch {}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
813
|
+
if (!error) {
|
|
814
|
+
settle(true);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
// IPC unavailable: skip the grace window and escalate now.
|
|
818
|
+
try {
|
|
819
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
820
|
+
} catch {}
|
|
821
|
+
settle(false);
|
|
822
|
+
},
|
|
823
|
+
});
|
|
820
824
|
});
|
|
821
825
|
return stopPromise;
|
|
822
826
|
}
|
|
@@ -847,13 +851,11 @@ export function createStandaloneChannelWorker({
|
|
|
847
851
|
}, 5000);
|
|
848
852
|
target.once('exit', () => finish(true));
|
|
849
853
|
target.once('error', () => finish(false));
|
|
850
|
-
|
|
851
|
-
|
|
854
|
+
safeIpcSend(target, { type: 'shutdown', reason }, {
|
|
855
|
+
onComplete: () => {
|
|
852
856
|
try { target.disconnect?.(); } catch {}
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
try { target.disconnect?.(); } catch {}
|
|
856
|
-
}
|
|
857
|
+
},
|
|
858
|
+
});
|
|
857
859
|
});
|
|
858
860
|
return stopPromise;
|
|
859
861
|
}
|