mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { clean, hasOwn, sessionHasConversationMessages } from './session-text.mjs';
|
|
2
|
+
import { isKnownProvider } from '../standalone/provider-admin.mjs';
|
|
3
|
+
import {
|
|
4
|
+
normalizeWorkflowRoute,
|
|
5
|
+
upsertWorkflowPreset,
|
|
6
|
+
workflowPresetId,
|
|
7
|
+
WORKFLOW_ROUTE_SLOTS,
|
|
8
|
+
FIXED_AGENT_SLOTS,
|
|
9
|
+
agentPresetSlot,
|
|
10
|
+
normalizeAgentId,
|
|
11
|
+
normalizeWorkflowId,
|
|
12
|
+
DEFAULT_WORKFLOW_ID,
|
|
13
|
+
normalizeSearchRouteConfig,
|
|
14
|
+
} from './workflow.mjs';
|
|
15
|
+
import { ONBOARDING_VERSION } from './quick-search-models.mjs';
|
|
16
|
+
import { findOutputStyle } from './output-styles.mjs';
|
|
17
|
+
import { ensureProviderEnabled } from './config-helpers.mjs';
|
|
18
|
+
import { fastCapableFor, saveModelSettings } from './model-capabilities.mjs';
|
|
19
|
+
|
|
20
|
+
// Onboarding + agents/workflows/output-style selection surface. Extracted
|
|
21
|
+
// verbatim from the runtime API object; stateless helpers are imported directly
|
|
22
|
+
// and the runtime injects live getters/setters for the mutable config/route/
|
|
23
|
+
// session locals plus the closure callbacks.
|
|
24
|
+
export function createWorkflowAgentsApi(deps) {
|
|
25
|
+
const {
|
|
26
|
+
getConfig, getRoute, setRouteState, getSession, setSession, getConfigHasSecrets,
|
|
27
|
+
cfgMod, reg, mgr, STANDALONE_DATA_DIR,
|
|
28
|
+
resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig,
|
|
29
|
+
agentRouteFromConfig, loadAgentDefinition, activeWorkflowId, listWorkflowPacks,
|
|
30
|
+
loadWorkflowPack, workflowSummary,
|
|
31
|
+
getOutputStyleStatusCached, seedOutputStyleStatusCache, scheduleOutputStyleSave,
|
|
32
|
+
recreateCurrentSessionIfReady, notifyFnForSession, invalidateContextStatusCache,
|
|
33
|
+
} = deps;
|
|
34
|
+
return {
|
|
35
|
+
async completeOnboarding(payload = {}) {
|
|
36
|
+
// Only fall back to the live runtime route when the caller actually sent a
|
|
37
|
+
// defaultRoute. The onboarding "partial save" path (Main left unset, only
|
|
38
|
+
// Search/agent picks) omits defaultRoute entirely and must NOT persist the
|
|
39
|
+
// current route as Main or recreate the session.
|
|
40
|
+
const config = getConfig();
|
|
41
|
+
const defaultRoute = hasOwn(payload, 'defaultRoute')
|
|
42
|
+
? normalizeWorkflowRoute(payload.defaultRoute, getRoute())
|
|
43
|
+
: null;
|
|
44
|
+
const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
|
|
45
|
+
? payload.workflowRoutes
|
|
46
|
+
: {};
|
|
47
|
+
const nextConfig = { ...config };
|
|
48
|
+
if (hasOwn(payload, 'defaultProvider')) {
|
|
49
|
+
const requested = clean(payload.defaultProvider);
|
|
50
|
+
if (requested) {
|
|
51
|
+
if (!isKnownProvider(requested)) throw new Error(`unknown provider "${payload.defaultProvider}"`);
|
|
52
|
+
nextConfig.defaultProvider = requested;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
|
|
56
|
+
const workflowRoutes = { ...(nextConfig.workflowRoutes || {}) };
|
|
57
|
+
const touchedWorkflowSlots = new Set();
|
|
58
|
+
|
|
59
|
+
if (defaultRoute) {
|
|
60
|
+
presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
|
|
61
|
+
workflowRoutes.lead = defaultRoute;
|
|
62
|
+
nextConfig.default = workflowPresetId('lead');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const slot of WORKFLOW_ROUTE_SLOTS) {
|
|
66
|
+
const normalized = normalizeWorkflowRoute(workflowInput[slot]);
|
|
67
|
+
if (!normalized) continue;
|
|
68
|
+
workflowRoutes[slot] = normalized;
|
|
69
|
+
presets = upsertWorkflowPreset(presets, slot, normalized);
|
|
70
|
+
touchedWorkflowSlots.add(slot);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
nextConfig.presets = presets;
|
|
74
|
+
nextConfig.workflowRoutes = workflowRoutes;
|
|
75
|
+
nextConfig.maintenance = {
|
|
76
|
+
...(nextConfig.maintenance || {}),
|
|
77
|
+
...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
|
|
78
|
+
...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
|
|
79
|
+
};
|
|
80
|
+
const agentInput = payload.agentRoutes && typeof payload.agentRoutes === 'object'
|
|
81
|
+
? payload.agentRoutes
|
|
82
|
+
: null;
|
|
83
|
+
if (agentInput) {
|
|
84
|
+
const nextAgents = { ...(nextConfig.agents || {}) };
|
|
85
|
+
const nextMaintenance = { ...(nextConfig.maintenance || {}) };
|
|
86
|
+
for (const agent of FIXED_AGENT_SLOTS) {
|
|
87
|
+
const routeToSave = normalizeWorkflowRoute(agentInput[agent.id]);
|
|
88
|
+
if (!routeToSave) continue;
|
|
89
|
+
nextAgents[agent.id] = routeToSave;
|
|
90
|
+
presets = upsertWorkflowPreset(presets, agentPresetSlot(agent.id), routeToSave);
|
|
91
|
+
if (agent.workflowSlot) {
|
|
92
|
+
workflowRoutes[agent.workflowSlot] = routeToSave;
|
|
93
|
+
presets = upsertWorkflowPreset(presets, agent.workflowSlot, routeToSave);
|
|
94
|
+
if (agent.id === 'explore') nextMaintenance.explore = routeToSave;
|
|
95
|
+
if (agent.id === 'maintainer') nextMaintenance.memory = routeToSave;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
nextConfig.agents = nextAgents;
|
|
99
|
+
nextConfig.presets = presets;
|
|
100
|
+
nextConfig.workflowRoutes = workflowRoutes;
|
|
101
|
+
nextConfig.maintenance = nextMaintenance;
|
|
102
|
+
}
|
|
103
|
+
nextConfig.onboarding = {
|
|
104
|
+
...(nextConfig.onboarding || {}),
|
|
105
|
+
completed: true,
|
|
106
|
+
version: ONBOARDING_VERSION,
|
|
107
|
+
completedAt: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (payload.searchRoute) {
|
|
111
|
+
const searchToSave = normalizeSearchRouteConfig(payload.searchRoute);
|
|
112
|
+
if (searchToSave) nextConfig.searchRoute = searchToSave;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
saveConfigAndAdopt(nextConfig);
|
|
116
|
+
if (defaultRoute) {
|
|
117
|
+
setRouteState(resolveRoute(getConfig(), { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort }));
|
|
118
|
+
const session = getSession();
|
|
119
|
+
if (session?.id) mgr.closeSession(session.id, 'cli-onboarding-complete');
|
|
120
|
+
await recreateCurrentSessionIfReady();
|
|
121
|
+
}
|
|
122
|
+
return this.getOnboardingStatus();
|
|
123
|
+
},
|
|
124
|
+
listAgents() {
|
|
125
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
126
|
+
const config = getConfig();
|
|
127
|
+
return FIXED_AGENT_SLOTS.map((agent) => ({
|
|
128
|
+
...agent,
|
|
129
|
+
locked: true,
|
|
130
|
+
route: agentRouteFromConfig(config, agent.id, dataDir),
|
|
131
|
+
definition: loadAgentDefinition(dataDir, agent.id),
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
listWorkflows() {
|
|
135
|
+
const currentConfig = displayConfig();
|
|
136
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
137
|
+
const active = activeWorkflowId(currentConfig);
|
|
138
|
+
return listWorkflowPacks(dataDir).map((workflow) => ({
|
|
139
|
+
id: workflow.id,
|
|
140
|
+
name: workflow.name,
|
|
141
|
+
description: workflow.description,
|
|
142
|
+
source: workflow.source,
|
|
143
|
+
active: workflow.id === active,
|
|
144
|
+
agents: workflow.agents,
|
|
145
|
+
}));
|
|
146
|
+
},
|
|
147
|
+
getOutputStyle() {
|
|
148
|
+
return getOutputStyleStatusCached();
|
|
149
|
+
},
|
|
150
|
+
listOutputStyles() {
|
|
151
|
+
return getOutputStyleStatusCached();
|
|
152
|
+
},
|
|
153
|
+
async setOutputStyle(value) {
|
|
154
|
+
const before = getOutputStyleStatusCached({ fresh: true });
|
|
155
|
+
const selected = findOutputStyle(value, before.styles);
|
|
156
|
+
if (!selected) {
|
|
157
|
+
const names = before.styles.map((style) => style.label || style.id).join(', ') || 'Default';
|
|
158
|
+
throw new Error(`output style must be one of ${names}`);
|
|
159
|
+
}
|
|
160
|
+
// Adopt in-memory immediately so same-tick readers see the new style;
|
|
161
|
+
// persist off the key-handler tick via the flushOutputStyleSave debounce.
|
|
162
|
+
const nextConfig = { ...getConfig(), outputStyle: selected.id };
|
|
163
|
+
if (nextConfig.agent && typeof nextConfig.agent === 'object' && !Array.isArray(nextConfig.agent)) {
|
|
164
|
+
const agent = { ...nextConfig.agent };
|
|
165
|
+
delete agent.outputStyle;
|
|
166
|
+
nextConfig.agent = agent;
|
|
167
|
+
}
|
|
168
|
+
adoptConfig(nextConfig);
|
|
169
|
+
scheduleOutputStyleSave(selected.id);
|
|
170
|
+
const freshStatus = { configured: selected.id, current: selected, styles: before.styles };
|
|
171
|
+
seedOutputStyleStatusCache(freshStatus);
|
|
172
|
+
const session = getSession();
|
|
173
|
+
const hasConversation = sessionHasConversationMessages(session);
|
|
174
|
+
let appliedToCurrentSession = !hasConversation;
|
|
175
|
+
if (session?.id && !hasConversation) {
|
|
176
|
+
const closedSessionId = session.id;
|
|
177
|
+
mgr.closeSession(closedSessionId, 'cli-output-style-switch');
|
|
178
|
+
setSession(null);
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
recreateCurrentSessionIfReady().catch((err) => {
|
|
181
|
+
try {
|
|
182
|
+
notifyFnForSession(closedSessionId)(
|
|
183
|
+
`Failed to start a new session after output style change: ${err?.message || err}`,
|
|
184
|
+
{ level: 'error' },
|
|
185
|
+
);
|
|
186
|
+
} catch {}
|
|
187
|
+
});
|
|
188
|
+
}, 0);
|
|
189
|
+
}
|
|
190
|
+
invalidateContextStatusCache();
|
|
191
|
+
return { ...freshStatus, appliedToCurrentSession };
|
|
192
|
+
},
|
|
193
|
+
async setWorkflow(workflowId) {
|
|
194
|
+
const id = normalizeWorkflowId(workflowId, DEFAULT_WORKFLOW_ID);
|
|
195
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
196
|
+
const pack = loadWorkflowPack(dataDir, id);
|
|
197
|
+
if (!pack || pack.id !== id) throw new Error(`workflow "${workflowId}" not found`);
|
|
198
|
+
const nextConfig = { ...getConfig() };
|
|
199
|
+
nextConfig.workflow = { ...(nextConfig.workflow || {}), active: id };
|
|
200
|
+
saveConfigAndAdopt(nextConfig);
|
|
201
|
+
return workflowSummary(pack);
|
|
202
|
+
},
|
|
203
|
+
async setAgentRoute(agentId, next) {
|
|
204
|
+
const id = normalizeAgentId(agentId);
|
|
205
|
+
if (!id) throw new Error(`unknown agent "${agentId}"`);
|
|
206
|
+
let selectedRoute = resolveRoute(getConfig(), { ...(next || {}) });
|
|
207
|
+
await reg.initProviders(ensureProviderEnabled(getConfig(), selectedRoute.provider));
|
|
208
|
+
const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
|
|
209
|
+
const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
|
|
210
|
+
selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
|
|
211
|
+
adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: getConfig() }), { hasSecrets: getConfigHasSecrets() });
|
|
212
|
+
|
|
213
|
+
const routeToSave = normalizeWorkflowRoute(selectedRoute);
|
|
214
|
+
if (!routeToSave) throw new Error('agent route requires provider and model');
|
|
215
|
+
const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
|
|
216
|
+
const nextConfig = { ...getConfig() };
|
|
217
|
+
nextConfig.agents = {
|
|
218
|
+
...(nextConfig.agents || {}),
|
|
219
|
+
[id]: routeToSave,
|
|
220
|
+
};
|
|
221
|
+
nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agentPresetSlot(id), routeToSave);
|
|
222
|
+
if (agent?.workflowSlot) {
|
|
223
|
+
nextConfig.workflowRoutes = {
|
|
224
|
+
...(nextConfig.workflowRoutes || {}),
|
|
225
|
+
[agent.workflowSlot]: routeToSave,
|
|
226
|
+
};
|
|
227
|
+
nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agent.workflowSlot, routeToSave);
|
|
228
|
+
nextConfig.maintenance = {
|
|
229
|
+
...(nextConfig.maintenance || {}),
|
|
230
|
+
...(id === 'explore' ? { explore: routeToSave } : {}),
|
|
231
|
+
...(id === 'maintainer' ? { memory: routeToSave } : {}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
saveConfigAndAdopt(nextConfig);
|
|
235
|
+
return routeToSave;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -99,14 +99,14 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
|
|
|
99
99
|
// Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
|
|
100
100
|
// first provider calls in the same instant, racing the server-side prompt-
|
|
101
101
|
// cache write/propagation window. Bench (parallel-10, identical-ms starts):
|
|
102
|
-
// 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%.
|
|
102
|
+
// 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 1000ms default
|
|
103
103
|
// picked as a low-cost middle ground. Chain (not a fixed lane count) so it
|
|
104
104
|
// scales to any N: each new spawn's start is pushed to at least STAGGER_MS
|
|
105
105
|
// after the previous spawn's start; sequential/non-overlapping spawns (now
|
|
106
106
|
// already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
|
|
107
107
|
// disables. Applied inside the deferred job body (see startDeferredSpawnJob)
|
|
108
108
|
// so the agent tool call itself still returns task_id immediately.
|
|
109
|
-
const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS',
|
|
109
|
+
const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 1000);
|
|
110
110
|
let lastSpawnStartAt = 0;
|
|
111
111
|
async function waitForSpawnStagger() {
|
|
112
112
|
if (SPAWN_STAGGER_MS <= 0) return;
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
6
6
|
import { startChildGuardian } from '../runtime/shared/child-guardian.mjs';
|
|
7
7
|
import { appendBuffered } from '../runtime/shared/buffered-appender.mjs';
|
|
8
8
|
import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
|
|
9
|
+
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
|
|
9
10
|
|
|
10
11
|
const CHANNEL_TOOLS = new Set([
|
|
11
12
|
'reply',
|
|
@@ -98,6 +99,9 @@ export function createStandaloneChannelWorker({
|
|
|
98
99
|
let inFlightToken = 0;
|
|
99
100
|
let inFlightWatchdog = null;
|
|
100
101
|
const logPath = join(dataDir, 'channels-worker-standalone.log');
|
|
102
|
+
// One-shot bound at own-process boot: this runtime may never pass through
|
|
103
|
+
// the channels-worker rotation path, so cap the log writer-side.
|
|
104
|
+
rotateBoundedLog(logPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
|
|
101
105
|
const useProcessWorker = process.env.MIXDOG_CHANNEL_WORKER_PROCESS !== '0';
|
|
102
106
|
const clientDir = join(runtimeRoot(), 'channel-clients');
|
|
103
107
|
const clientPath = join(clientDir, `${process.pid}.json`);
|
|
@@ -248,6 +252,13 @@ export function createStandaloneChannelWorker({
|
|
|
248
252
|
MIXDOG_STANDALONE: '1',
|
|
249
253
|
MIXDOG_WORKER_MODE: '1',
|
|
250
254
|
MIXDOG_CLI_OWNED: '0',
|
|
255
|
+
// Preserve the real terminal-lead PID (host TUI, or an outer run-mcp
|
|
256
|
+
// supervisor if one injected MIXDOG_SUPERVISOR_PID) through the fork.
|
|
257
|
+
// Without this the worker resolves getTerminalLeadPid() to its OWN pid,
|
|
258
|
+
// so the seat's ownerLeadPid tracks the headless worker instead of the
|
|
259
|
+
// terminal that owns it — and the seat can never be evicted when the
|
|
260
|
+
// owning terminal/TUI dies while the worker stays alive.
|
|
261
|
+
MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
|
|
251
262
|
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
252
263
|
},
|
|
253
264
|
windowsHide: true,
|
|
@@ -412,6 +423,12 @@ export function createStandaloneChannelWorker({
|
|
|
412
423
|
|
|
413
424
|
function uninstallParentExitHook() {
|
|
414
425
|
if (!parentExitCleanup) return;
|
|
426
|
+
// Refcount-aware: never strip the shared parent-exit protection while any
|
|
427
|
+
// owned child PID is still tracked. A newer worker may have been spawned
|
|
428
|
+
// (new PID added, hook install is a no-op because it is already present)
|
|
429
|
+
// before an older worker finishes its async teardown; letting that old
|
|
430
|
+
// teardown uninstall here would leave the live newer PID unprotected.
|
|
431
|
+
if (ownedChildPids.size > 0) return;
|
|
415
432
|
const unregister = parentExitCleanup;
|
|
416
433
|
parentExitCleanup = null;
|
|
417
434
|
unregister();
|
|
@@ -454,27 +471,70 @@ export function createStandaloneChannelWorker({
|
|
|
454
471
|
child = null;
|
|
455
472
|
if (!waitForExit) {
|
|
456
473
|
rejectPending(new Error(`channels runtime shutdown requested (${reason})`));
|
|
457
|
-
|
|
474
|
+
// Fast/detached path is still TERMINAL: the caller does not block on the
|
|
475
|
+
// full teardown, but a background escalation ladder guarantees the worker
|
|
476
|
+
// dies so no zombie survives. Exit-hook + PID tracking stay installed
|
|
477
|
+
// until the process ACTUALLY exits (not on IPC ack), so a parent that
|
|
478
|
+
// force-exits mid-grace still force-kills the tree via the exit cleanup.
|
|
479
|
+
let torn = false;
|
|
480
|
+
const teardown = () => {
|
|
481
|
+
if (torn) return;
|
|
482
|
+
torn = true;
|
|
483
|
+
clearTimeout(termTimer);
|
|
484
|
+
clearTimeout(killTimer);
|
|
485
|
+
if (targetPid) ownedChildPids.delete(targetPid);
|
|
486
|
+
unrefChildHandles(target);
|
|
487
|
+
uninstallParentExitHook();
|
|
488
|
+
};
|
|
489
|
+
// Bounded grace after IPC shutdown, then SIGTERM.
|
|
490
|
+
const termTimer = setTimeout(() => {
|
|
491
|
+
try {
|
|
492
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
493
|
+
} catch {}
|
|
494
|
+
}, 1500);
|
|
495
|
+
// Hard fallback: taskkill /T /F the whole tree, then SIGKILL the handle.
|
|
496
|
+
const killTimer = setTimeout(() => {
|
|
497
|
+
try {
|
|
498
|
+
if (target.exitCode == null) forceKillTree(targetPid);
|
|
499
|
+
} catch {}
|
|
500
|
+
try {
|
|
501
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
|
|
502
|
+
} catch {}
|
|
503
|
+
teardown();
|
|
504
|
+
}, 3000);
|
|
505
|
+
// Unref so these background escalation timers never keep the event loop
|
|
506
|
+
// (or a pending /exit) alive waiting out the grace/hard-kill window.
|
|
507
|
+
termTimer.unref?.();
|
|
508
|
+
killTimer.unref?.();
|
|
509
|
+
// Actual exit (or spawn error) is the only thing that tears down tracking.
|
|
510
|
+
target.once('exit', teardown);
|
|
511
|
+
target.once('error', teardown);
|
|
458
512
|
stopPromise = new Promise((resolve) => {
|
|
459
513
|
let settled = false;
|
|
460
|
-
const
|
|
514
|
+
const settle = (ok) => {
|
|
461
515
|
if (settled) return;
|
|
462
516
|
settled = true;
|
|
463
517
|
clearTimeout(sendTimer);
|
|
464
518
|
stopPromise = null;
|
|
465
|
-
unrefChildHandles(target);
|
|
466
|
-
uninstallParentExitHook();
|
|
467
519
|
resolve(ok);
|
|
468
520
|
};
|
|
469
|
-
|
|
521
|
+
// Resolve the caller quickly once the IPC shutdown is acked/timed out;
|
|
522
|
+
// the escalation ladder above keeps running in the background.
|
|
523
|
+
const sendTimer = setTimeout(() => settle(true), 250);
|
|
524
|
+
sendTimer.unref?.();
|
|
525
|
+
target.once('exit', () => settle(true));
|
|
470
526
|
try {
|
|
471
527
|
target.send?.({ type: 'shutdown', reason }, () => {
|
|
472
528
|
try { target.disconnect?.(); } catch {}
|
|
473
|
-
|
|
529
|
+
settle(true);
|
|
474
530
|
});
|
|
475
531
|
} catch {
|
|
476
532
|
try { target.disconnect?.(); } catch {}
|
|
477
|
-
|
|
533
|
+
// IPC unavailable: skip the grace window and escalate now.
|
|
534
|
+
try {
|
|
535
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
536
|
+
} catch {}
|
|
537
|
+
settle(false);
|
|
478
538
|
}
|
|
479
539
|
});
|
|
480
540
|
return stopPromise;
|
|
@@ -232,7 +232,10 @@ namespace Mixdog {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
'@`,
|
|
235
|
-
|
|
235
|
+
// System.Drawing is required too: GetDialogOwnerCenter uses
|
|
236
|
+
// Screen.PrimaryScreen.WorkingArea (System.Drawing.Rectangle) — without it
|
|
237
|
+
// the Add-Type compile fails and the dialog silently falls back to typing.
|
|
238
|
+
'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms,System.Drawing | Out-Null',
|
|
236
239
|
// Invisible TopMost owner anchored to the TUI console (or foreground window)
|
|
237
240
|
// so IFileOpenDialog is modal, centered, and not detached on another monitor.
|
|
238
241
|
'$owner = New-Object System.Windows.Forms.Form',
|
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { pathToFileURL } from 'node:url';
|
|
7
7
|
import { claimSingletonOwner, handoffSingletonOwner, readSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
|
|
8
8
|
import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
|
|
9
|
+
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
|
|
9
10
|
|
|
10
11
|
function logLine(path, line) {
|
|
11
12
|
try {
|
|
@@ -56,6 +57,19 @@ function delay(ms) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
const TRANSIENT_MEMORY_RPC_BACKOFF_MS = 400;
|
|
60
|
+
// A child that dies during startup with one of these signatures is a hard,
|
|
61
|
+
// deterministic failure (bad entry path, syntax/require error) — never a
|
|
62
|
+
// transient owner-lock race. Surface it fast instead of burning the ready-wait.
|
|
63
|
+
function looksLikeStartupCrash(text) {
|
|
64
|
+
// Only genuinely deterministic loader/parse failures. Runtime errors like
|
|
65
|
+
// TypeError/ReferenceError can be transient init races — matching them would
|
|
66
|
+
// poison crashState and cache a hard failure for a recoverable spawn.
|
|
67
|
+
return /Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|SyntaxError/i.test(String(text || ''));
|
|
68
|
+
}
|
|
69
|
+
// Once a spawn crashes deterministically, short-circuit re-forks for this
|
|
70
|
+
// window so a persistent crash-loop returns the cached reason immediately
|
|
71
|
+
// instead of paying spawn+ready-wait on every call.
|
|
72
|
+
const MEMORY_CRASH_COOLDOWN_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_CRASH_COOLDOWN_MS) || 30_000);
|
|
59
73
|
|
|
60
74
|
function isConnResetLikeError(err) {
|
|
61
75
|
const code = String(err?.code || '');
|
|
@@ -67,7 +81,7 @@ function isConnResetLikeError(err) {
|
|
|
67
81
|
|
|
68
82
|
function isMemoryWorkerNotReadyError(err) {
|
|
69
83
|
const msg = String(err?.message || err || '');
|
|
70
|
-
return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded/i.test(msg);
|
|
84
|
+
return /memory worker exited before ready|memory worker ready timeout|memory runtime did not become ready|memory worker degraded|memory worker draining/i.test(msg);
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
function isTransientMemoryRpcError(err) {
|
|
@@ -141,6 +155,9 @@ export function createStandaloneMemoryRuntime({
|
|
|
141
155
|
if (!dataDir) throw new Error('memory runtime dataDir is required');
|
|
142
156
|
|
|
143
157
|
const logPath = join(dataDir, 'memory-runtime-proxy.log');
|
|
158
|
+
// One-shot bound at own-process boot: this runtime may never pass through
|
|
159
|
+
// the channels-worker rotation path, so cap the log writer-side.
|
|
160
|
+
rotateBoundedLog(logPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
|
|
144
161
|
const ownerPath = join(dataDir, 'memory-runtime-owner.json');
|
|
145
162
|
const singletonEnabled = process.env.MIXDOG_MEMORY_SINGLETON !== '0';
|
|
146
163
|
const idleTtlMs = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000);
|
|
@@ -148,9 +165,68 @@ export function createStandaloneMemoryRuntime({
|
|
|
148
165
|
let startPromise = null;
|
|
149
166
|
let child = null;
|
|
150
167
|
let nextCallId = 1;
|
|
168
|
+
let crashState = null; // { reason, at } — cached deterministic spawn crash
|
|
169
|
+
// Port we have registered this proxy pid with (so the shared daemon can
|
|
170
|
+
// reap itself promptly once every client deregisters). Re-registers when
|
|
171
|
+
// the daemon respawns on a new port.
|
|
172
|
+
let registeredWithPort = null;
|
|
173
|
+
|
|
174
|
+
async function ensureClientRegistered(port) {
|
|
175
|
+
// Returns the port the pending RPC should target. When an internal respawn
|
|
176
|
+
// happens the daemon moves to a fresh port, so the caller MUST use the
|
|
177
|
+
// returned value rather than the port it captured before registering.
|
|
178
|
+
if (!port || registeredWithPort === port) return port;
|
|
179
|
+
// The register RPC is provably side-effect-free from the caller's point of
|
|
180
|
+
// view: a refused/reset connection means the daemon never received it, and
|
|
181
|
+
// a draining 503 means it refused it. So a register-phase transient is ALWAYS
|
|
182
|
+
// safe to recover from by respawning a fresh daemon and retrying — even for a
|
|
183
|
+
// pending WRITE RPC. We do that respawn-and-retry HERE, before the write RPC
|
|
184
|
+
// is ever attempted, rather than leaning on the outer retry (which must not
|
|
185
|
+
// blanket-retry refused/reset on the write RPC itself).
|
|
186
|
+
let curPort = port;
|
|
187
|
+
for (let attempt = 0; ; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
await requestJson({
|
|
190
|
+
port: curPort,
|
|
191
|
+
method: 'POST',
|
|
192
|
+
path: '/client/register',
|
|
193
|
+
body: { clientPid: process.pid },
|
|
194
|
+
timeoutMs: 2000,
|
|
195
|
+
});
|
|
196
|
+
registeredWithPort = curPort;
|
|
197
|
+
return curPort;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Benign failures (e.g. request timeout against a busy-but-live daemon)
|
|
200
|
+
// are not fatal: registration is best-effort and the idle TTL backstops
|
|
201
|
+
// a missed register. Only recover from genuine "daemon is gone/dying".
|
|
202
|
+
if (!isTransientMemoryRpcError(err)) return curPort;
|
|
203
|
+
if (attempt >= 3) throw err;
|
|
204
|
+
invalidateMemoryRuntimeAfterTransient(err);
|
|
205
|
+
await delay(TRANSIENT_MEMORY_RPC_BACKOFF_MS);
|
|
206
|
+
const started = await start();
|
|
207
|
+
curPort = started.port;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function deregisterClient() {
|
|
213
|
+
const port = registeredWithPort || portCache;
|
|
214
|
+
registeredWithPort = null;
|
|
215
|
+
if (!port) return;
|
|
216
|
+
try {
|
|
217
|
+
await requestJson({
|
|
218
|
+
port,
|
|
219
|
+
method: 'POST',
|
|
220
|
+
path: '/client/deregister',
|
|
221
|
+
body: { clientPid: process.pid },
|
|
222
|
+
timeoutMs: 1500,
|
|
223
|
+
});
|
|
224
|
+
} catch { /* best-effort; sweep + idle TTL reap us anyway */ }
|
|
225
|
+
}
|
|
151
226
|
|
|
152
227
|
function invalidateMemoryRuntimeAfterTransient(err) {
|
|
153
228
|
portCache = null;
|
|
229
|
+
registeredWithPort = null;
|
|
154
230
|
if (!isMemoryWorkerNotReadyError(err)) return;
|
|
155
231
|
startPromise = null;
|
|
156
232
|
const proc = child;
|
|
@@ -244,25 +320,61 @@ export function createStandaloneMemoryRuntime({
|
|
|
244
320
|
async function start() {
|
|
245
321
|
if (portCache) {
|
|
246
322
|
const port = await findLivePort();
|
|
247
|
-
if (port) return { running: true, port, mode: 'http-proxy' };
|
|
323
|
+
if (port) { crashState = null; return { running: true, port, mode: 'http-proxy' }; }
|
|
248
324
|
portCache = null;
|
|
249
325
|
}
|
|
250
326
|
const existing = await findLivePort();
|
|
251
|
-
if (existing) return { running: true, port: existing, mode: 'http-proxy' };
|
|
327
|
+
if (existing) { crashState = null; return { running: true, port: existing, mode: 'http-proxy' }; }
|
|
328
|
+
// Persistent crash-loop guard: no live daemon and a recent deterministic
|
|
329
|
+
// spawn crash → fail fast with the cached reason, don't re-fork per call.
|
|
330
|
+
// But a healthy singleton owner may be mid-boot and not yet advertising a
|
|
331
|
+
// port; probe/await it before trusting cached crashState so a recovering
|
|
332
|
+
// daemon isn't handed a stale hard failure.
|
|
333
|
+
if (crashState && Date.now() - crashState.at < MEMORY_CRASH_COOLDOWN_MS) {
|
|
334
|
+
if (singletonEnabled) {
|
|
335
|
+
const owner = readSingletonOwner(ownerPath);
|
|
336
|
+
if (owner.alive) {
|
|
337
|
+
const live = await waitForPort(15_000);
|
|
338
|
+
crashState = null;
|
|
339
|
+
return { running: true, port: live, mode: 'http-proxy' };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
throw new Error(crashState.reason);
|
|
343
|
+
}
|
|
252
344
|
if (startPromise) {
|
|
253
345
|
const port = await startPromise;
|
|
254
346
|
return { running: true, port, mode: 'http-proxy' };
|
|
255
347
|
}
|
|
256
348
|
|
|
257
349
|
startPromise = (async () => {
|
|
258
|
-
|
|
350
|
+
let claim = claimOwner();
|
|
259
351
|
if (!claim.owned) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
352
|
+
// Another owner holds the singleton. If it is live AND serving a
|
|
353
|
+
// healthy port, use it. If it is live but NOT healthy (a daemon that is
|
|
354
|
+
// draining/shutting down never revives — see getDraining/health in
|
|
355
|
+
// lib/http-router.mjs), poll: reclaim the instant the old owner exits so
|
|
356
|
+
// a quick restart still ends with a fresh, working daemon instead of
|
|
357
|
+
// binding to the dying one and failing the pending RPC.
|
|
358
|
+
const deadline = Date.now() + 30_000;
|
|
359
|
+
while (Date.now() < deadline) {
|
|
360
|
+
const livePort = await findLivePort({ allowStarting: true });
|
|
361
|
+
if (livePort) {
|
|
362
|
+
try {
|
|
363
|
+
const health = await requestJson({ port: livePort, path: '/health', timeoutMs: 1500 });
|
|
364
|
+
if (health?.status === 'ok') return livePort;
|
|
365
|
+
} catch { /* dying/unreachable — fall through to reclaim */ }
|
|
366
|
+
}
|
|
367
|
+
const reclaim = claimOwner();
|
|
368
|
+
if (reclaim.owned) { claim = reclaim; break; }
|
|
369
|
+
await delay(150);
|
|
370
|
+
}
|
|
371
|
+
if (!claim.owned) {
|
|
372
|
+
const owner = readSingletonOwner(ownerPath);
|
|
373
|
+
if (owner.alive) throw new Error('memory runtime did not become ready');
|
|
374
|
+
releaseOwnerIfSelf();
|
|
375
|
+
claim = claimOwner();
|
|
376
|
+
if (!claim.owned) throw new Error('memory runtime did not become ready');
|
|
264
377
|
}
|
|
265
|
-
releaseOwnerIfSelf();
|
|
266
378
|
}
|
|
267
379
|
|
|
268
380
|
const daemonEnv = { ...process.env };
|
|
@@ -309,14 +421,18 @@ export function createStandaloneMemoryRuntime({
|
|
|
309
421
|
meta: { cwd, launcherPid: process.pid },
|
|
310
422
|
});
|
|
311
423
|
}
|
|
424
|
+
let stderrTail = '';
|
|
312
425
|
child.stderr?.on('data', chunk => {
|
|
313
|
-
const text = String(chunk || '')
|
|
314
|
-
|
|
426
|
+
const text = String(chunk || '');
|
|
427
|
+
const trimmed = text.trimEnd();
|
|
428
|
+
if (trimmed) logLine(logPath, trimmed);
|
|
429
|
+
stderrTail = (stderrTail + text).slice(-4000);
|
|
315
430
|
});
|
|
316
431
|
child.on('exit', () => {
|
|
317
432
|
if (singletonEnabled && childPid) releaseSingletonOwner(ownerPath, childPid);
|
|
318
433
|
if (child?.pid === childPid) child = null;
|
|
319
434
|
portCache = null;
|
|
435
|
+
registeredWithPort = null;
|
|
320
436
|
});
|
|
321
437
|
|
|
322
438
|
const ready = new Promise((resolveReady, rejectReady) => {
|
|
@@ -332,24 +448,37 @@ export function createStandaloneMemoryRuntime({
|
|
|
332
448
|
});
|
|
333
449
|
child.once('exit', (code, signal) => {
|
|
334
450
|
clearTimeout(timer);
|
|
335
|
-
|
|
451
|
+
const tail = stderrTail.trim().split('\n').slice(-8).join('\n');
|
|
452
|
+
const detail = tail ? `: ${tail}` : '';
|
|
453
|
+
const err = new Error(`memory worker exited before ready (${signal || code || 'unknown'})${detail}`);
|
|
454
|
+
err.stderrTail = tail;
|
|
455
|
+
rejectReady(err);
|
|
336
456
|
});
|
|
337
457
|
});
|
|
338
458
|
|
|
339
459
|
try {
|
|
340
460
|
await ready;
|
|
341
461
|
} catch (err) {
|
|
462
|
+
// A deterministic startup crash (bad entry path -> MODULE_NOT_FOUND,
|
|
463
|
+
// syntax/require errors) is not an owner-lock race: cache the reason
|
|
464
|
+
// for the cooldown window and fail immediately with the stderr tail
|
|
465
|
+
// instead of burning waitForPort() on a child that will never publish.
|
|
466
|
+
const msg = String(err?.message || err || '');
|
|
467
|
+
if (looksLikeStartupCrash(err?.stderrTail || msg)) {
|
|
468
|
+
crashState = { reason: msg, at: Date.now() };
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
342
471
|
// Loser fallback: two proxies (TUI host vs channels worker) can race to
|
|
343
472
|
// fork; the child that lost the owner-lock exits before ready. Instead
|
|
344
473
|
// of propagating "exited before ready" (which would surface as no
|
|
345
474
|
// daemon), wait for the WINNER's daemon to publish a live port and use
|
|
346
475
|
// it. Only rethrow if no live daemon appears in the window.
|
|
347
|
-
const msg = String(err?.message || err || '');
|
|
348
476
|
const raceLoss = /exited before ready|degraded|ready timeout|owner lock|lock/i.test(msg);
|
|
349
477
|
if (!raceLoss) throw err;
|
|
350
478
|
return await waitForPort(30_000);
|
|
351
479
|
}
|
|
352
480
|
const port = await waitForPort(15_000);
|
|
481
|
+
crashState = null;
|
|
353
482
|
try { child.disconnect?.(); } catch {}
|
|
354
483
|
try { child.unref?.(); } catch {}
|
|
355
484
|
try { child.stderr?.unref?.(); } catch {}
|
|
@@ -367,7 +496,11 @@ export function createStandaloneMemoryRuntime({
|
|
|
367
496
|
const callId = `mem_${process.pid}_${nextCallId++}`;
|
|
368
497
|
return await withTransientMemoryRpcRetry(async () => {
|
|
369
498
|
await start();
|
|
370
|
-
|
|
499
|
+
let port = portCache || await findLivePort({ allowStarting: true });
|
|
500
|
+
if (!port) throw new Error('memory runtime is not available');
|
|
501
|
+
// ensureClientRegistered may respawn onto a fresh daemon/port; target the
|
|
502
|
+
// port it hands back so the RPC and registration always hit the same one.
|
|
503
|
+
port = await ensureClientRegistered(port);
|
|
371
504
|
if (!port) throw new Error('memory runtime is not available');
|
|
372
505
|
return await requestJson({
|
|
373
506
|
port,
|
|
@@ -383,7 +516,9 @@ export function createStandaloneMemoryRuntime({
|
|
|
383
516
|
async function buildSessionCoreMemoryPayload(sessionCwd) {
|
|
384
517
|
return await withTransientMemoryRpcRetry(async () => {
|
|
385
518
|
await start();
|
|
386
|
-
|
|
519
|
+
let port = portCache || await findLivePort({ allowStarting: true });
|
|
520
|
+
if (!port) throw new Error('memory runtime is not available');
|
|
521
|
+
port = await ensureClientRegistered(port);
|
|
387
522
|
if (!port) throw new Error('memory runtime is not available');
|
|
388
523
|
return await requestJson({
|
|
389
524
|
port,
|
|
@@ -396,8 +531,10 @@ export function createStandaloneMemoryRuntime({
|
|
|
396
531
|
}
|
|
397
532
|
|
|
398
533
|
async function stop() {
|
|
399
|
-
//
|
|
400
|
-
//
|
|
534
|
+
// Deregister this client so a shared daemon can reap itself within the
|
|
535
|
+
// seconds-scale client grace once no clients remain, then detach. We never
|
|
536
|
+
// hard-kill the daemon here — another tab/session may still be using it.
|
|
537
|
+
await deregisterClient();
|
|
401
538
|
try { child?.disconnect?.(); } catch {}
|
|
402
539
|
try { child?.unref?.(); } catch {}
|
|
403
540
|
child = null;
|