mixdog 0.9.134 → 0.9.135
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/src/headless-exec.mjs +26 -9
- package/src/headless-exec.test.mjs +55 -2
- package/src/rules/shared/01-tool.md +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +30 -7
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +26 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +80 -3
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +3 -19
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +87 -5
- package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +131 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +24 -3
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/session-runtime/lifecycle-api.mjs +9 -2
- package/src/session-runtime/prewarm.mjs +9 -0
- package/src/session-runtime/runtime-core.mjs +14 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +4 -0
- package/src/standalone/channel-transport.mjs +13 -0
- package/src/standalone/daemon.mjs +14 -1
package/package.json
CHANGED
package/src/headless-exec.mjs
CHANGED
|
@@ -583,6 +583,7 @@ export async function runHeadlessExec({
|
|
|
583
583
|
let runtime = null;
|
|
584
584
|
let signalCleanup = null;
|
|
585
585
|
let unsubscribeNotification = null;
|
|
586
|
+
let completionPending = false;
|
|
586
587
|
let cleanupPromise = null;
|
|
587
588
|
let result = null;
|
|
588
589
|
let resultText = '';
|
|
@@ -644,18 +645,25 @@ export async function runHeadlessExec({
|
|
|
644
645
|
toolMode: 'full',
|
|
645
646
|
approvalMode: 'implicit',
|
|
646
647
|
disallowDelegation: true,
|
|
648
|
+
autoWakeCompletions: false,
|
|
647
649
|
initialConfig: boundary.loadConfig(),
|
|
648
650
|
});
|
|
649
651
|
if (lifecycle && !clean(runtime?.id) && typeof runtime?.reserveSessionId === 'function') {
|
|
650
652
|
runtime.reserveSessionId(lifecycle.threadId);
|
|
651
653
|
}
|
|
652
654
|
lifecycle?.start(runtime);
|
|
653
|
-
if (
|
|
655
|
+
if (typeof runtime?.onNotification === 'function') {
|
|
654
656
|
unsubscribeNotification = runtime.onNotification(
|
|
655
|
-
(event) =>
|
|
657
|
+
(event) => {
|
|
658
|
+
lifecycle?.onNotification(event);
|
|
659
|
+
const status = clean(event?.meta?.status).toLowerCase();
|
|
660
|
+
if (['completed', 'failed', 'cancelled', 'canceled', 'timed_out'].includes(status)) {
|
|
661
|
+
completionPending = true;
|
|
662
|
+
}
|
|
663
|
+
},
|
|
656
664
|
);
|
|
657
665
|
}
|
|
658
|
-
|
|
666
|
+
const askOptions = {
|
|
659
667
|
onTextReset: () => true,
|
|
660
668
|
onUsageDelta: (delta) => {
|
|
661
669
|
applyUsageDelta(stats, delta);
|
|
@@ -672,13 +680,22 @@ export async function runHeadlessExec({
|
|
|
672
680
|
onToolPhaseCompleted: (detail) => lifecycle.onToolBatchCompleted(detail),
|
|
673
681
|
onStageChange: (stage, detail) => lifecycle.onStageChange(stage, detail),
|
|
674
682
|
} : {}),
|
|
675
|
-
}
|
|
676
|
-
await
|
|
677
|
-
|
|
683
|
+
};
|
|
684
|
+
({ result } = await runtime.ask(prompt, askOptions));
|
|
685
|
+
const taskScope = {
|
|
686
|
+
...(clean(runtime.id) ? { callerSessionId: clean(runtime.id) } : {}),
|
|
678
687
|
clientHostPid: runtime.clientHostPid,
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
688
|
+
};
|
|
689
|
+
while (completionPending || hasActiveTasks(taskScope)) {
|
|
690
|
+
await waitForTrackedTasks({
|
|
691
|
+
sessionId: runtime.id,
|
|
692
|
+
clientHostPid: runtime.clientHostPid,
|
|
693
|
+
hasActiveTasks,
|
|
694
|
+
pollMs: idlePollMs,
|
|
695
|
+
});
|
|
696
|
+
completionPending = false;
|
|
697
|
+
({ result } = await runtime.ask('', askOptions));
|
|
698
|
+
}
|
|
682
699
|
resultText = String(result?.content ?? result?.text ?? '');
|
|
683
700
|
if (!json && resultText) {
|
|
684
701
|
write(resultText.endsWith('\n') ? resultText : `${resultText}\n`);
|
|
@@ -90,7 +90,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
|
|
|
90
90
|
hasActiveTasks: (scope) => {
|
|
91
91
|
activeScopes.push(scope);
|
|
92
92
|
activeChecks += 1;
|
|
93
|
-
return
|
|
93
|
+
return false;
|
|
94
94
|
},
|
|
95
95
|
installSignalCleanupFn: () => ({ uninstall() {} }),
|
|
96
96
|
});
|
|
@@ -100,6 +100,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
|
|
|
100
100
|
assert.deepEqual(errors, []);
|
|
101
101
|
assert.equal(runtimeOptions[0].approvalMode, 'implicit');
|
|
102
102
|
assert.equal(runtimeOptions[0].disallowDelegation, true);
|
|
103
|
+
assert.equal(runtimeOptions[0].autoWakeCompletions, false);
|
|
103
104
|
assert.equal(runtimeOptions[0].toolMode, 'full');
|
|
104
105
|
assert.deepEqual(activeScopes[0], {
|
|
105
106
|
callerSessionId: 'sess_exec_test',
|
|
@@ -121,6 +122,58 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
|
|
|
121
122
|
}
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
test('headless exec drains tracked work and returns the completion follow-up answer', async () => {
|
|
126
|
+
const output = [];
|
|
127
|
+
const errors = [];
|
|
128
|
+
const prompts = [];
|
|
129
|
+
let active = true;
|
|
130
|
+
let notificationListener = null;
|
|
131
|
+
const code = await runHeadlessExec({
|
|
132
|
+
message: 'start the long task',
|
|
133
|
+
provider: 'openai-oauth',
|
|
134
|
+
model: 'gpt-test',
|
|
135
|
+
usageLogPath: '',
|
|
136
|
+
idlePollMs: 1,
|
|
137
|
+
write: (text) => output.push(text),
|
|
138
|
+
writeErr: (text) => errors.push(text),
|
|
139
|
+
boundaryFactory: () => ({
|
|
140
|
+
loadConfig: () => ({ providers: { 'openai-oauth': { enabled: true } } }),
|
|
141
|
+
cleanup() {},
|
|
142
|
+
}),
|
|
143
|
+
runtimeFactory: async () => ({
|
|
144
|
+
id: 'sess_headless_drain',
|
|
145
|
+
model: 'gpt-test',
|
|
146
|
+
clientHostPid: 123,
|
|
147
|
+
onNotification(listener) {
|
|
148
|
+
notificationListener = listener;
|
|
149
|
+
return () => { notificationListener = null; };
|
|
150
|
+
},
|
|
151
|
+
async ask(prompt) {
|
|
152
|
+
prompts.push(prompt);
|
|
153
|
+
if (prompts.length === 1) {
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
active = false;
|
|
156
|
+
notificationListener?.({
|
|
157
|
+
content: 'background task completed',
|
|
158
|
+
meta: { status: 'completed', execution_id: 'job_drain' },
|
|
159
|
+
});
|
|
160
|
+
}, 10);
|
|
161
|
+
return { result: { content: 'task started' } };
|
|
162
|
+
}
|
|
163
|
+
return { result: { content: 'final result' } };
|
|
164
|
+
},
|
|
165
|
+
async close() {},
|
|
166
|
+
}),
|
|
167
|
+
hasActiveTasks: () => active,
|
|
168
|
+
installSignalCleanupFn: () => ({ uninstall() {} }),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.equal(code, 0);
|
|
172
|
+
assert.deepEqual(errors, []);
|
|
173
|
+
assert.deepEqual(prompts, ['start the long task', '']);
|
|
174
|
+
assert.deepEqual(output, ['final result\n']);
|
|
175
|
+
});
|
|
176
|
+
|
|
124
177
|
test('headless exec emits a timestamped JSONL lifecycle and exact tool count', async () => {
|
|
125
178
|
const root = mkdtempSync(join(tmpdir(), 'mixdog-headless-json-test-'));
|
|
126
179
|
const usageLogPath = join(root, 'usage.json');
|
|
@@ -199,7 +252,7 @@ test('headless exec emits a timestamped JSONL lifecycle and exact tool count', a
|
|
|
199
252
|
options.onToolPhaseCompleted({ iteration: 1, calls: 1, elapsedMs: 2 });
|
|
200
253
|
notificationListener?.({
|
|
201
254
|
content: 'background task completed',
|
|
202
|
-
meta: { status: '
|
|
255
|
+
meta: { status: 'progress' },
|
|
203
256
|
});
|
|
204
257
|
options.onProviderSendStarted();
|
|
205
258
|
options.onAssistantText('done');
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Tool Use
|
|
2
2
|
|
|
3
|
+
- When an internal Mixdog rule conflicts with the user's latest explicit
|
|
4
|
+
request, follow the user's request.
|
|
3
5
|
- Baseline routing assigns each facet directly by the evidence needed to
|
|
4
6
|
determine the complete answer or edit:
|
|
5
7
|
path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
|
|
@@ -655,10 +655,10 @@ function guardRead(a) {
|
|
|
655
655
|
}
|
|
656
656
|
|
|
657
657
|
function guardShell(a) {
|
|
658
|
-
const allowed = new Set(['command', 'timeout_ms']);
|
|
658
|
+
const allowed = new Set(['command', 'timeout_ms', 'run_in_background']);
|
|
659
659
|
const unsupported = Object.keys(a).find((key) => !allowed.has(key));
|
|
660
660
|
if (unsupported) {
|
|
661
|
-
return `Error: shell arg "${unsupported}" is unsupported; use only command and
|
|
661
|
+
return `Error: shell arg "${unsupported}" is unsupported; use only command, timeout_ms, and run_in_background`;
|
|
662
662
|
}
|
|
663
663
|
if (!hasOwn(a, 'command')) {
|
|
664
664
|
return 'Error: shell requires "command"';
|
|
@@ -672,6 +672,9 @@ function guardShell(a) {
|
|
|
672
672
|
if (hasOwn(a, 'timeout_ms') && (typeof a.timeout_ms !== 'number' || !Number.isFinite(a.timeout_ms) || a.timeout_ms < 0)) {
|
|
673
673
|
return `Error: shell arg "timeout_ms" must be a non-negative number (got ${describeType(a.timeout_ms)})`;
|
|
674
674
|
}
|
|
675
|
+
if (hasOwn(a, 'run_in_background') && typeof a.run_in_background !== 'boolean') {
|
|
676
|
+
return `Error: shell arg "run_in_background" must be a boolean (got ${describeType(a.run_in_background)})`;
|
|
677
|
+
}
|
|
675
678
|
return null;
|
|
676
679
|
}
|
|
677
680
|
|
|
@@ -469,6 +469,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
469
469
|
const _bgTasksDisabled = /^(1|true|yes|on)$/i.test(
|
|
470
470
|
String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
|
|
471
471
|
);
|
|
472
|
+
const runInBackground = args.run_in_background === true;
|
|
473
|
+
if (runInBackground && _bgTasksDisabled) {
|
|
474
|
+
return formatShellToolFailure('background tasks are disabled for this process');
|
|
475
|
+
}
|
|
472
476
|
|
|
473
477
|
let shellEffects;
|
|
474
478
|
let combinedBashAbort = null;
|
|
@@ -603,6 +607,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
603
607
|
timeoutMs: timeout,
|
|
604
608
|
abortSignal: combinedBashAbort.signal,
|
|
605
609
|
autoBackgroundMs,
|
|
610
|
+
startInBackground: runInBackground,
|
|
606
611
|
// On a foreground timeout, promote the still-running child to a
|
|
607
612
|
// tracked background job only when an explicit deadline has
|
|
608
613
|
// remaining budget; omitted deadlines may stay unlimited.
|
|
@@ -14,6 +14,13 @@ const _shellSyntaxCheat =
|
|
|
14
14
|
const _shellToolRouting = process.platform === 'win32'
|
|
15
15
|
? 'Use read, NOT cat/Get-Content/head/tail; list, NOT ls/dir; find/glob, NOT find; grep, NOT grep/rg/Select-String; edit/apply_patch, NOT sed/awk/heredocs/echo/Set-Content.'
|
|
16
16
|
: 'Use read, NOT cat/head/tail; list, NOT ls; find/glob, NOT find; grep, NOT grep/rg; edit/apply_patch, NOT sed/awk/heredocs/echo.';
|
|
17
|
+
// CC parity: when background tasks are disabled for this process, drop the
|
|
18
|
+
// run_in_background field from the schema entirely so the model cannot burn a
|
|
19
|
+
// failure turn attempting it. Mirrors bash-tool's runtime guard, which stays
|
|
20
|
+
// as defense in depth. Process-stable env, evaluated once at module load.
|
|
21
|
+
const _shellBackgroundDisabled = /^(1|true|yes|on)$/i.test(
|
|
22
|
+
String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
|
|
23
|
+
);
|
|
17
24
|
|
|
18
25
|
export const BUILTIN_TOOLS = [
|
|
19
26
|
{
|
|
@@ -77,7 +84,7 @@ export const BUILTIN_TOOLS = [
|
|
|
77
84
|
name: 'shell',
|
|
78
85
|
title: 'Shell',
|
|
79
86
|
annotations: { title: 'Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
80
|
-
description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground; after 15s, a still-running command continues as a tracked task_id and completes by notification.`,
|
|
87
|
+
description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. ${_shellToolRouting} Commands start in the foreground${_shellBackgroundDisabled ? '' : ' unless run_in_background is true'}; after 15s, a still-running foreground command continues as a tracked task_id and completes by notification.`,
|
|
81
88
|
inputSchema: {
|
|
82
89
|
type: 'object',
|
|
83
90
|
properties: {
|
|
@@ -87,6 +94,13 @@ export const BUILTIN_TOOLS = [
|
|
|
87
94
|
minimum: 0,
|
|
88
95
|
description: 'Hard total deadline in milliseconds; omit or use 0 to allow unlimited runtime after task promotion.',
|
|
89
96
|
},
|
|
97
|
+
...(_shellBackgroundDisabled ? {} : {
|
|
98
|
+
run_in_background: {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
default: false,
|
|
101
|
+
description: 'Start as a tracked background task immediately; use only when the result is not needed for the next step — completion arrives by notification. Default false.',
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
90
104
|
},
|
|
91
105
|
required: ['command'],
|
|
92
106
|
additionalProperties: false,
|
|
@@ -191,6 +191,7 @@ async function subscribeResultCacheInFlight(entry, signal) {
|
|
|
191
191
|
|
|
192
192
|
export async function runResultCacheInFlight(key, compute, options = {}) {
|
|
193
193
|
const subscriberSignal = options?.signal || options?.abortSignal || null;
|
|
194
|
+
let invalidationRetries = 0;
|
|
194
195
|
for (;;) {
|
|
195
196
|
const cached = cacheGet(key);
|
|
196
197
|
if (cached !== null) return cached;
|
|
@@ -233,6 +234,13 @@ export async function runResultCacheInFlight(key, compute, options = {}) {
|
|
|
233
234
|
// freshness retry, not a user-visible abort: start a new generation
|
|
234
235
|
// unless this subscriber itself was cancelled.
|
|
235
236
|
if (!entry.invalidated || subscriberSignal?.aborted) throw error;
|
|
237
|
+
// Continuous invalidation (watcher churn, eviction broadcasts)
|
|
238
|
+
// must not spin forever: after a few generations run the compute
|
|
239
|
+
// directly — uncached and no longer abortable by invalidation —
|
|
240
|
+
// mirroring the server-side MAX_WALK_RESTARTS cap.
|
|
241
|
+
if (++invalidationRetries >= 3) {
|
|
242
|
+
return await compute({ signal: subscriberSignal });
|
|
243
|
+
}
|
|
236
244
|
}
|
|
237
245
|
}
|
|
238
246
|
}
|
|
@@ -11,6 +11,7 @@ import { runRgWindowedLines } from '../native-search-runner.mjs';
|
|
|
11
11
|
import { statReachable } from '../fs-reachability.mjs';
|
|
12
12
|
import { dedupeFanoutMatchLines, formatGrepOutput } from './grep-output.mjs';
|
|
13
13
|
import { expandGrepAnchorContextOutput } from './grep-context-expander.mjs';
|
|
14
|
+
import { markScopedCacheIncomplete } from '../../../session/cache/scoped-cache-outcome.mjs';
|
|
14
15
|
|
|
15
16
|
export async function runGrepPatternFanout({
|
|
16
17
|
args,
|
|
@@ -77,7 +78,9 @@ export async function runGrepPatternFanout({
|
|
|
77
78
|
const pre = await runRgWindowedLines(
|
|
78
79
|
prefilterArgs,
|
|
79
80
|
{ cwd: preSpawnCwd, signal: options.signal },
|
|
80
|
-
|
|
81
|
+
// Speculative whole-scope pass: bulk lane keeps it from
|
|
82
|
+
// competing with interactive searches for disk bandwidth.
|
|
83
|
+
{ offset: 0, limit: GREP_FANOUT_PREFILTER_FILE_CAP, summaryLimit: 0, bulkHint: true },
|
|
81
84
|
);
|
|
82
85
|
return pre.complete && !pre.partial ? pre.lines : null;
|
|
83
86
|
} catch { return null; }
|
|
@@ -130,15 +133,34 @@ export async function runGrepPatternFanout({
|
|
|
130
133
|
});
|
|
131
134
|
const perPatternWindow = headLimit === Infinity ? 300 : (offset + headLimit + 4);
|
|
132
135
|
const combinedCap = Math.min(4000, Math.max(400, perPatternWindow * patterns.length));
|
|
136
|
+
// Unfiltered multi-pattern directory scans are the broad-scope shape
|
|
137
|
+
// that saturated the interactive pool; route them to the bulk lane.
|
|
138
|
+
const combinedBulkHint = normalizedGlobPatterns.length === 0 && !fileType;
|
|
133
139
|
let streamed;
|
|
134
140
|
try {
|
|
135
141
|
streamed = await runRgWindowedLines(
|
|
136
142
|
combinedArgs,
|
|
137
143
|
{ cwd: rgCwd, signal: options.signal },
|
|
138
|
-
{ offset: 0, limit: combinedCap, summaryLimit: 0 },
|
|
144
|
+
{ offset: 0, limit: combinedCap, summaryLimit: 0, bulkHint: combinedBulkHint },
|
|
139
145
|
);
|
|
140
146
|
} catch { break combined; }
|
|
141
|
-
|
|
147
|
+
// Cap overflow (complete:false without partial) still falls back: the
|
|
148
|
+
// per-pattern rescan restores correct per-pattern windows. Timeout and
|
|
149
|
+
// scan-error partials keep their collected lines instead — the legacy
|
|
150
|
+
// fallback would rescan the same scope from scratch and usually time
|
|
151
|
+
// out again, discarding everything the first pass already found.
|
|
152
|
+
if (streamed.partial ? streamed.lines.length === 0 : !streamed.complete) break combined;
|
|
153
|
+
const combinedPartial = streamed.partial === true;
|
|
154
|
+
const combinedPartialSuffix = !combinedPartial
|
|
155
|
+
? ''
|
|
156
|
+
: streamed.timeout
|
|
157
|
+
? '\n[warning] rg timed out; partial results shown. Narrow path/glob/pattern for a complete result.'
|
|
158
|
+
: streamed.rgStderr
|
|
159
|
+
? `\n[warning] rg exit 2 (partial results): ${String(streamed.rgStderr).trim().slice(0, 300)}`
|
|
160
|
+
: '\n[warning] rg exit 2 (partial results)';
|
|
161
|
+
if (combinedPartial && options?.scopedCacheOutcome) {
|
|
162
|
+
markScopedCacheIncomplete(options.scopedCacheOutcome);
|
|
163
|
+
}
|
|
142
164
|
const adaptive = contextN > 0 && !(beforeN > 0) && !(afterN > 0);
|
|
143
165
|
const byPattern = patterns.map(() => []);
|
|
144
166
|
const residual = [];
|
|
@@ -175,7 +197,7 @@ export async function runGrepPatternFanout({
|
|
|
175
197
|
filenameOmitted: false,
|
|
176
198
|
headLimit,
|
|
177
199
|
offset,
|
|
178
|
-
totalKnown:
|
|
200
|
+
totalKnown: !combinedPartial,
|
|
179
201
|
requestedContext: contextN,
|
|
180
202
|
maxContext: GREP_AUTO_CONTEXT_LINES,
|
|
181
203
|
patterns: [p],
|
|
@@ -190,7 +212,7 @@ export async function runGrepPatternFanout({
|
|
|
190
212
|
body = formatGrepOutput({
|
|
191
213
|
windowed: windowedLines,
|
|
192
214
|
totalWindowed: post.length,
|
|
193
|
-
totalKnown:
|
|
215
|
+
totalKnown: !combinedPartial,
|
|
194
216
|
headLimit,
|
|
195
217
|
offset,
|
|
196
218
|
outputMode,
|
|
@@ -211,14 +233,15 @@ export async function runGrepPatternFanout({
|
|
|
211
233
|
sections.push(`# grep pattern:${JSON.stringify(p)}\n${dedupeFanoutMatchLines(body, seenCombined)}`);
|
|
212
234
|
}
|
|
213
235
|
if (noMatchPatterns.length > 0) {
|
|
214
|
-
|
|
236
|
+
// Under a partial scan a zero-hit pattern is NOT a proven no-match.
|
|
237
|
+
sections.push(`(no matches${combinedPartial ? ' in partial results' : ''}) pattern=${JSON.stringify(noMatchPatterns)} path=${searchPath}${globStr}; path exists`);
|
|
215
238
|
}
|
|
216
239
|
if (residual.length > 0) {
|
|
217
240
|
// Rust/JS regex divergence or --max-columns truncation left
|
|
218
241
|
// matches no pattern claimed; surface them rather than drop.
|
|
219
242
|
sections.push(`# grep (unattributed matches)\n${residual.slice(0, 40).join('\n')}`);
|
|
220
243
|
}
|
|
221
|
-
return patternCapNote + sections.join('\n\n');
|
|
244
|
+
return patternCapNote + sections.join('\n\n') + combinedPartialSuffix;
|
|
222
245
|
}
|
|
223
246
|
// Prefilter result (started above, overlapped with the combined
|
|
224
247
|
// attempt): when it completed under the cap, each per-pattern grep
|
|
@@ -33,7 +33,7 @@ function dataDir() {
|
|
|
33
33
|
|| join(process.env.MIXDOG_HOME || join(homedir(), '.mixdog'), 'data');
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function pidAlive(pid) {
|
|
36
|
+
export function pidAlive(pid) {
|
|
37
37
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
38
38
|
try { process.kill(pid, 0); return true; }
|
|
39
39
|
catch (error) { return error?.code === 'EPERM'; } // EPERM = alive, not ours
|
|
@@ -194,6 +194,31 @@ function _teardown(error, { countFailure = true, detail = '' } = {}) {
|
|
|
194
194
|
try { server.child.kill(); } catch {}
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
export async function shutdownNativeSearchServer(reason = 'process-exit', timeoutMs = 1_000) {
|
|
198
|
+
const server = _server;
|
|
199
|
+
if (!server) return true;
|
|
200
|
+
const child = server.child;
|
|
201
|
+
const exited = new Promise((resolve) => {
|
|
202
|
+
child.once('exit', () => resolve(true));
|
|
203
|
+
child.once('error', () => resolve(true));
|
|
204
|
+
});
|
|
205
|
+
try { child.stdin?.end?.(); } catch {}
|
|
206
|
+
_teardown(new Error(`native search server shutdown (${reason})`), { countFailure: false });
|
|
207
|
+
_warmPromise = null;
|
|
208
|
+
let timer;
|
|
209
|
+
const stopped = await Promise.race([
|
|
210
|
+
exited,
|
|
211
|
+
new Promise((resolve) => {
|
|
212
|
+
timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
if (timer) clearTimeout(timer);
|
|
216
|
+
if (!stopped) {
|
|
217
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
218
|
+
}
|
|
219
|
+
return stopped;
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
export function _bindNativeSearchServerLifecycle(child, { onError, onExit } = {}) {
|
|
198
223
|
if (!child?.on) return;
|
|
199
224
|
child.on('error', onError);
|
|
@@ -541,6 +566,7 @@ export async function tryServeSearch(argsList, execOptions = {}, opts = {}) {
|
|
|
541
566
|
: 0,
|
|
542
567
|
deadlineMs: softDeadlineMs(remaining),
|
|
543
568
|
keepWarm: opts.keepWarm === true,
|
|
569
|
+
...(opts.bulkHint === true ? { bulkHint: true } : {}),
|
|
544
570
|
});
|
|
545
571
|
let response = await requestNativeWithRestart(buildRequest, execOptions, deadlineMs);
|
|
546
572
|
// Deadline-swallow defense (binaries before the serve_search response-level
|
|
@@ -342,7 +342,20 @@ export async function executeGlobTool(args, workDir, options = {}) {
|
|
|
342
342
|
const canWindowNatural = sortMode === 'natural' && headLimit !== Infinity;
|
|
343
343
|
const groupRuns = await Promise.all(globGroups.map(async ([root, rels]) => {
|
|
344
344
|
const rgArgs = ['--files', '--hidden'];
|
|
345
|
-
|
|
345
|
+
// Explicit literal basenames (no glob magic in the final segment)
|
|
346
|
+
// name a concrete file: honor rg's later-glob-wins contract and let
|
|
347
|
+
// the lookup descend dependency-noise dirs, which the native walker
|
|
348
|
+
// would otherwise prune before the pattern could ever match. Wildcard
|
|
349
|
+
// basenames keep the noise prunes; device-name globs (no trailing
|
|
350
|
+
// /**) always apply.
|
|
351
|
+
const explicitBasenames = rels.length > 0 && rels.every((rel) => {
|
|
352
|
+
const base = String(rel).split('/').filter(Boolean).pop() || '';
|
|
353
|
+
return base !== '' && !hasGlobMagic(base);
|
|
354
|
+
});
|
|
355
|
+
for (const ex of DEFAULT_IGNORE_GLOBS) {
|
|
356
|
+
if (explicitBasenames && /^!\*\*\/[^/]+\/\*\*$/.test(ex)) continue;
|
|
357
|
+
rgArgs.push('--glob', ex);
|
|
358
|
+
}
|
|
346
359
|
for (const ex of extraIgnoreGlobs) rgArgs.push('--glob', ex);
|
|
347
360
|
for (const rel of rels) rgArgs.push('--glob', rel);
|
|
348
361
|
const rgCwd = resolvedForSearchRoot(root);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import {
|
|
3
|
+
modelVisibleToolCompletionMessage,
|
|
3
4
|
normalizeToolNotifyContext,
|
|
4
5
|
notifyToolCompletion,
|
|
5
6
|
} from '../../../../shared/tool-execution-contract.mjs';
|
|
7
|
+
// Runtime-only bindings (used inside completion delivery, never at module
|
|
8
|
+
// eval), so the static cycle through session/manager.mjs is safe — same
|
|
9
|
+
// pattern as loop/tool-exec.mjs.
|
|
10
|
+
import {
|
|
11
|
+
enqueuePendingMessage,
|
|
12
|
+
markCompletionEntry,
|
|
13
|
+
} from '../../session/manager.mjs';
|
|
6
14
|
import {
|
|
7
15
|
completeBackgroundTask,
|
|
8
16
|
} from '../../../../shared/background-tasks.mjs';
|
|
@@ -22,6 +30,8 @@ import {
|
|
|
22
30
|
} from './lib/shell-job-insights.mjs';
|
|
23
31
|
import {
|
|
24
32
|
completeShellJobRecord,
|
|
33
|
+
listShellJobRecords,
|
|
34
|
+
pidAlive,
|
|
25
35
|
publishShellJobRecord,
|
|
26
36
|
} from './lib/shell-job-records.mjs';
|
|
27
37
|
import {
|
|
@@ -86,7 +96,7 @@ function shellJobTaskStatus(status) {
|
|
|
86
96
|
return 'failed';
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
function buildShellCompletion(jobId, detail) {
|
|
99
|
+
export function buildShellCompletion(jobId, detail) {
|
|
90
100
|
const startedAtMs = Date.parse(detail?.startedAt || '');
|
|
91
101
|
const finishedAtMs = Date.parse(detail?.finishedAt || '') || Date.now();
|
|
92
102
|
const elapsedMs = Number.isFinite(startedAtMs)
|
|
@@ -247,7 +257,12 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
|
|
|
247
257
|
instruction: completion.instruction,
|
|
248
258
|
terminalReason: 'shell-native-event',
|
|
249
259
|
});
|
|
250
|
-
if (!completedTask
|
|
260
|
+
if (!completedTask) {
|
|
261
|
+
// Delivery order: owner notifyFn when present, else the owner
|
|
262
|
+
// session's pending queue. A ctx-less finish (daemon restarted
|
|
263
|
+
// between job start and completion, or a watcher armed without a
|
|
264
|
+
// notify context) previously dropped the completion silently.
|
|
265
|
+
const owner = String(ctx?.callerSessionId || detail.ownerSessionId || '');
|
|
251
266
|
notifyToolCompletion({
|
|
252
267
|
surface: 'shell',
|
|
253
268
|
id: jobId,
|
|
@@ -255,7 +270,19 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
|
|
|
255
270
|
text: completion.body,
|
|
256
271
|
resultType: 'shell_task_result',
|
|
257
272
|
instruction: completion.instruction,
|
|
258
|
-
context: ctx,
|
|
273
|
+
context: ctx || { callerSessionId: owner },
|
|
274
|
+
enqueueFallback: (sessionId, message, meta) => {
|
|
275
|
+
let visible = modelVisibleToolCompletionMessage(message, meta);
|
|
276
|
+
// Bodyless envelopes (a finished command with no output)
|
|
277
|
+
// fail the persistence gate's result-body requirement;
|
|
278
|
+
// retry with an explicit "(no output)" section rather
|
|
279
|
+
// than dropping the completion.
|
|
280
|
+
if (!visible && !/\n\s*\n/.test(String(message || ''))) {
|
|
281
|
+
visible = modelVisibleToolCompletionMessage(`${message}\n\n(no output)`, meta);
|
|
282
|
+
}
|
|
283
|
+
if (!visible) return false;
|
|
284
|
+
return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
|
|
285
|
+
},
|
|
259
286
|
logPrefix: 'shell-jobs',
|
|
260
287
|
});
|
|
261
288
|
}
|
|
@@ -287,6 +314,56 @@ export async function adoptForegroundShellJob({
|
|
|
287
314
|
return native;
|
|
288
315
|
}
|
|
289
316
|
|
|
317
|
+
// Daemon-restart recovery: records left 'running' by a dead daemon can never
|
|
318
|
+
// finish through the live watcher path — their completion (and output) used to
|
|
319
|
+
// vanish silently. Finalize each dead-pid record and push one completion
|
|
320
|
+
// notice to the owner session's pending queue. Content-addressed completion
|
|
321
|
+
// ids keep multi-shard reconciliation idempotent.
|
|
322
|
+
let _recoveredCompletionsReconciled = false;
|
|
323
|
+
export async function reconcileRecoveredShellJobCompletions() {
|
|
324
|
+
if (_recoveredCompletionsReconciled) return 0;
|
|
325
|
+
_recoveredCompletionsReconciled = true;
|
|
326
|
+
let records = [];
|
|
327
|
+
try { records = await listShellJobRecords(); } catch { return 0; }
|
|
328
|
+
let notified = 0;
|
|
329
|
+
for (const record of records) {
|
|
330
|
+
if (!record || record.terminal) continue;
|
|
331
|
+
if (getNativeTask(record.jobId)) continue; // live in this daemon
|
|
332
|
+
if (pidAlive(Number(record.pid) || 0)) continue; // survived the restart
|
|
333
|
+
const detail = {
|
|
334
|
+
...record,
|
|
335
|
+
status: 'failed',
|
|
336
|
+
error: 'daemon restarted while this shell task was running; its outcome was lost. Re-run the command if the result is still needed.',
|
|
337
|
+
};
|
|
338
|
+
try { await completeShellJobRecord(record.jobId, detail); } catch { /* best-effort */ }
|
|
339
|
+
const owner = String(record.ownerSessionId || '');
|
|
340
|
+
if (!owner) continue;
|
|
341
|
+
try {
|
|
342
|
+
const completion = buildShellCompletion(record.jobId, detail);
|
|
343
|
+
const delivered = notifyToolCompletion({
|
|
344
|
+
surface: 'shell',
|
|
345
|
+
id: record.jobId,
|
|
346
|
+
status: completion.taskStatus,
|
|
347
|
+
// The pending-queue persistence gate requires a blank-line
|
|
348
|
+
// separated result body; a bodyless bracketed envelope would
|
|
349
|
+
// be dropped. The restart notice IS the result here.
|
|
350
|
+
text: `${completion.body}\n\n${detail.error}`,
|
|
351
|
+
resultType: 'shell_task_result',
|
|
352
|
+
instruction: completion.instruction,
|
|
353
|
+
context: { callerSessionId: owner },
|
|
354
|
+
enqueueFallback: (sessionId, message, meta) => {
|
|
355
|
+
const visible = modelVisibleToolCompletionMessage(message, meta);
|
|
356
|
+
if (!visible) return false;
|
|
357
|
+
return enqueuePendingMessage(sessionId, markCompletionEntry(visible)) > 0;
|
|
358
|
+
},
|
|
359
|
+
logPrefix: 'shell-jobs-recovery',
|
|
360
|
+
});
|
|
361
|
+
if (delivered) notified += 1;
|
|
362
|
+
} catch { /* best-effort */ }
|
|
363
|
+
}
|
|
364
|
+
return notified;
|
|
365
|
+
}
|
|
366
|
+
|
|
290
367
|
export async function shutdownShellJobs(_reason = 'runtime-close', { scope = null } = {}) {
|
|
291
368
|
const scoped = Boolean(scope);
|
|
292
369
|
const ownerSessionId = scoped ? String(scope.ownerSessionId || '') : '';
|
|
@@ -78,26 +78,10 @@ export function scrubLoaderVars(env) {
|
|
|
78
78
|
return _continueScrubLoaderVars(env);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// at a dead loopback endpoint. curl/wget/git-http/pip/apt honor these and
|
|
85
|
-
// fail fast on public hosts, while NO_PROXY keeps loopback traffic
|
|
86
|
-
// (task-local servers) working. Raw-socket clients bypass proxy variables —
|
|
87
|
-
// this is a uniform egress policy for the common tooling path, not a sandbox.
|
|
88
|
-
// The runtime's own provider calls are unaffected: this mutates only the
|
|
89
|
-
// child spawn env, never process.env.
|
|
90
|
-
const SHELL_EGRESS_DEAD_PROXY = 'http://127.0.0.1:1';
|
|
91
|
-
const SHELL_EGRESS_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'FTP_PROXY', 'RSYNC_PROXY'];
|
|
81
|
+
// Web-search availability is independent from ordinary shell networking.
|
|
82
|
+
// Keep this boundary explicit so disabling the search tool never blocks
|
|
83
|
+
// package managers, source-control clients, or user-configured proxies.
|
|
92
84
|
export function applyShellEgressPolicy(env) {
|
|
93
|
-
if (!env || typeof env !== 'object') return env;
|
|
94
|
-
if (String(process.env.MIXDOG_FEATURE_WEB_SEARCH ?? '') !== '0') return env;
|
|
95
|
-
for (const name of SHELL_EGRESS_PROXY_VARS) {
|
|
96
|
-
env[name] = SHELL_EGRESS_DEAD_PROXY;
|
|
97
|
-
env[name.toLowerCase()] = SHELL_EGRESS_DEAD_PROXY;
|
|
98
|
-
}
|
|
99
|
-
env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
|
100
|
-
env.no_proxy = env.NO_PROXY;
|
|
101
85
|
return env;
|
|
102
86
|
}
|
|
103
87
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { applyShellEgressPolicy } from './env-scrub.mjs';
|
|
5
|
+
|
|
6
|
+
test('web-search disabled preserves ordinary shell network environment', () => {
|
|
7
|
+
const previous = process.env.MIXDOG_FEATURE_WEB_SEARCH;
|
|
8
|
+
process.env.MIXDOG_FEATURE_WEB_SEARCH = '0';
|
|
9
|
+
try {
|
|
10
|
+
const env = {
|
|
11
|
+
HTTP_PROXY: 'http://proxy.example:8080',
|
|
12
|
+
HTTPS_PROXY: 'http://proxy.example:8080',
|
|
13
|
+
NO_PROXY: 'localhost',
|
|
14
|
+
};
|
|
15
|
+
assert.equal(applyShellEgressPolicy(env), env);
|
|
16
|
+
assert.deepEqual(env, {
|
|
17
|
+
HTTP_PROXY: 'http://proxy.example:8080',
|
|
18
|
+
HTTPS_PROXY: 'http://proxy.example:8080',
|
|
19
|
+
NO_PROXY: 'localhost',
|
|
20
|
+
});
|
|
21
|
+
} finally {
|
|
22
|
+
if (previous === undefined) delete process.env.MIXDOG_FEATURE_WEB_SEARCH;
|
|
23
|
+
else process.env.MIXDOG_FEATURE_WEB_SEARCH = previous;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.17",
|
|
3
3
|
"_comment": "Synced from immutable graph-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.17/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "3b8850422ad267b45572ceef7993fb57c2b7b9c71cc4c026056335828a28a65a"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.17/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "0d793977ddd1d84851d1a9e3244415e7dc5b35f2f636a43f17af4745ea4485a8"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.17/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "036dcc5f16a5cfc72e95de198de8b5d2ad56d62e88677bb90222206624268a6a"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.17/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "7bd4325dc1761f3fcf77cc8926a45e007f85d1b6954bfea819fafb6625635904"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/graph-v0.1.17/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "c0441052d0a09ddb3a3c291c7e7bffcf767dac9b6353e2f2e3e6954be2994712"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -79,7 +79,7 @@ class LineStream extends EventEmitter {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export class NativeSpawnChild extends EventEmitter {
|
|
82
|
-
constructor(id, cancel) {
|
|
82
|
+
constructor(id, cancel, send = null) {
|
|
83
83
|
super();
|
|
84
84
|
this.pid = undefined;
|
|
85
85
|
this.killed = false;
|
|
@@ -91,9 +91,27 @@ export class NativeSpawnChild extends EventEmitter {
|
|
|
91
91
|
this.__nativeSpawn = true;
|
|
92
92
|
this._id = id;
|
|
93
93
|
this._cancel = cancel;
|
|
94
|
+
this._send = send;
|
|
94
95
|
this._closed = false;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// stdinPipe children only (warm shell standby): forward script text to the
|
|
99
|
+
// child's stdin via the spawn server. No-ops (false) when the child was
|
|
100
|
+
// spawned without stdinPipe or the server link is gone.
|
|
101
|
+
writeStdin(text, { close = false } = {}) {
|
|
102
|
+
if (this._closed || this.killed || typeof this._send !== 'function') return false;
|
|
103
|
+
return this._send({
|
|
104
|
+
stdinWrite: this._id,
|
|
105
|
+
data: String(text ?? ''),
|
|
106
|
+
...(close ? { close: true } : {}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
endStdin() {
|
|
111
|
+
if (typeof this._send !== 'function') return false;
|
|
112
|
+
return this._send({ stdinClose: this._id });
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
kill() {
|
|
98
116
|
this.killed = true;
|
|
99
117
|
this._cancel?.(this._id);
|
|
@@ -140,6 +158,28 @@ function _setServerReferenced(server, referenced) {
|
|
|
140
158
|
try { server?.child?.stdout?.[method]?.(); } catch {}
|
|
141
159
|
}
|
|
142
160
|
|
|
161
|
+
// Referenced only while a NON-idle request is pending. Idle requests (parked
|
|
162
|
+
// warm shell standbys) live for minutes-to-hours; counting them would pin the
|
|
163
|
+
// host event loop — a one-shot CLI or test runner could never exit.
|
|
164
|
+
function _recomputeServerRef(server) {
|
|
165
|
+
let active = false;
|
|
166
|
+
for (const entry of server.pending.values()) {
|
|
167
|
+
if (!entry.idle) { active = true; break; }
|
|
168
|
+
}
|
|
169
|
+
_setServerReferenced(server, active);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Mark a pending spawn request idle (parked) or active. Idle requests do not
|
|
173
|
+
* keep the host process alive. Returns false when the request is gone. */
|
|
174
|
+
export function setNativeSpawnRequestIdle(child, idle = true) {
|
|
175
|
+
const server = _server;
|
|
176
|
+
const entry = server?.pending?.get?.(Number(child?._id));
|
|
177
|
+
if (!entry) return false;
|
|
178
|
+
entry.idle = idle === true;
|
|
179
|
+
_recomputeServerRef(server);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
143
183
|
function _teardown(error) {
|
|
144
184
|
const server = _server;
|
|
145
185
|
_server = null;
|
|
@@ -164,6 +204,30 @@ function _teardown(error) {
|
|
|
164
204
|
try { server.child.kill(); } catch {}
|
|
165
205
|
}
|
|
166
206
|
|
|
207
|
+
export async function shutdownNativeSpawnServer(reason = 'process-exit', timeoutMs = 1_000) {
|
|
208
|
+
const server = _server;
|
|
209
|
+
if (!server) return true;
|
|
210
|
+
const child = server.child;
|
|
211
|
+
const exited = new Promise((resolve) => {
|
|
212
|
+
child.once('exit', () => resolve(true));
|
|
213
|
+
child.once('error', () => resolve(true));
|
|
214
|
+
});
|
|
215
|
+
try { child.stdin?.end?.(); } catch {}
|
|
216
|
+
_teardown(new Error(`native spawn server shutdown (${reason})`));
|
|
217
|
+
let timer;
|
|
218
|
+
const stopped = await Promise.race([
|
|
219
|
+
exited,
|
|
220
|
+
new Promise((resolve) => {
|
|
221
|
+
timer = setTimeout(() => resolve(false), Math.max(10, Number(timeoutMs) || 1_000));
|
|
222
|
+
}),
|
|
223
|
+
]);
|
|
224
|
+
if (timer) clearTimeout(timer);
|
|
225
|
+
if (!stopped) {
|
|
226
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
227
|
+
}
|
|
228
|
+
return stopped;
|
|
229
|
+
}
|
|
230
|
+
|
|
167
231
|
function _ensureServer() {
|
|
168
232
|
if (_server) return _server;
|
|
169
233
|
if (Date.now() - _lastFailureAt < RESTART_BACKOFF_MS) return null;
|
|
@@ -184,7 +248,10 @@ function _ensureServer() {
|
|
|
184
248
|
lines.on('line', (line) => {
|
|
185
249
|
let message;
|
|
186
250
|
try { message = JSON.parse(line); } catch { return; }
|
|
187
|
-
if (message?.ready === true)
|
|
251
|
+
if (message?.ready === true) {
|
|
252
|
+
server.caps = (message.caps && typeof message.caps === 'object') ? message.caps : {};
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
188
255
|
if (String(message?.event || '').startsWith('task_')) acceptTask(message);
|
|
189
256
|
const pending = server.pending.get(Number(message?.id));
|
|
190
257
|
if (!pending) return;
|
|
@@ -227,6 +294,11 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
227
294
|
const id = ++server.sequence;
|
|
228
295
|
const fake = new NativeSpawnChild(id, (cancelId) => {
|
|
229
296
|
try { server.child.stdin.write(`${JSON.stringify({ cancel: cancelId })}\n`); } catch {}
|
|
297
|
+
}, (payload) => {
|
|
298
|
+
try {
|
|
299
|
+
server.child.stdin.write(`${JSON.stringify({ id: ++server.sequence, ...payload })}\n`);
|
|
300
|
+
return true;
|
|
301
|
+
} catch { return false; }
|
|
230
302
|
});
|
|
231
303
|
const request = {
|
|
232
304
|
id,
|
|
@@ -239,6 +311,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
239
311
|
timeoutMs: Math.max(0, Number(spawnOptions.timeoutMs) || 0),
|
|
240
312
|
outputLimit: Math.max(0, Number(spawnOptions.outputLimit) || 0),
|
|
241
313
|
mergeStderr: spawnOptions.mergeStderr === true,
|
|
314
|
+
...(spawnOptions.stdinPipe === true ? { stdinPipe: true } : {}),
|
|
242
315
|
command: spawnOptions.command || undefined,
|
|
243
316
|
shellType: spawnOptions.shellType || undefined,
|
|
244
317
|
ownerSessionId: spawnOptions.ownerSessionId || undefined,
|
|
@@ -255,7 +328,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
255
328
|
fake.emit('error', err);
|
|
256
329
|
fake.emit('close', 1, null);
|
|
257
330
|
}
|
|
258
|
-
|
|
331
|
+
_recomputeServerRef(server);
|
|
259
332
|
},
|
|
260
333
|
onMessage(message) {
|
|
261
334
|
const event = String(message?.event || '');
|
|
@@ -279,7 +352,7 @@ export function tryNativeSpawn({ shell, argv, spawnOptions = {}, cwd } = {}) {
|
|
|
279
352
|
fake.signalCode = message.signal || null;
|
|
280
353
|
fake._closed = true;
|
|
281
354
|
fake.emit('close', fake.exitCode, fake.signalCode);
|
|
282
|
-
|
|
355
|
+
_recomputeServerRef(server);
|
|
283
356
|
} else if (event === 'error') {
|
|
284
357
|
const detail = String(message.message || 'native spawn failed');
|
|
285
358
|
const err = new Error(message.code ? `${message.code}: ${detail}` : detail);
|
|
@@ -425,7 +498,7 @@ export function adoptNativeTaskByPid({
|
|
|
425
498
|
const finish = (task, error = null) => {
|
|
426
499
|
if (timer) clearTimeout(timer);
|
|
427
500
|
server.pending.delete(id);
|
|
428
|
-
|
|
501
|
+
_recomputeServerRef(server);
|
|
429
502
|
if (error) reject(error);
|
|
430
503
|
else resolve(task);
|
|
431
504
|
};
|
|
@@ -468,6 +541,15 @@ export function adoptNativeTaskByPid({
|
|
|
468
541
|
});
|
|
469
542
|
}
|
|
470
543
|
|
|
544
|
+
// Capability handshake: true only after the connected spawn server announced
|
|
545
|
+
// stdinPipe support in its ready line. An older binary silently ignores the
|
|
546
|
+
// unknown stdinPipe field and gives the child a null stdin — a standby taken
|
|
547
|
+
// in that state would run an EMPTY script and report exit 0 without ever
|
|
548
|
+
// executing the command. Gate the warm-standby feature on this.
|
|
549
|
+
export function nativeSpawnSupportsStdinPipe() {
|
|
550
|
+
return _server?.caps?.stdinPipe === true;
|
|
551
|
+
}
|
|
552
|
+
|
|
471
553
|
export function _resetNativeSpawnClientForTest() {
|
|
472
554
|
_teardown(new Error('test reset'));
|
|
473
555
|
_binaryPath = undefined;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Warm pwsh standby: one pre-spawned PowerShell per (shell path, env
|
|
2
|
+
// signature) waits with a stdin-reading bootstrap. Taking it skips
|
|
3
|
+
// CreateProcess + Defender scan (~300ms warm, ~1.8s cold) for the next shell
|
|
4
|
+
// call; the script arrives via the spawn server's stdinWrite message and runs
|
|
5
|
+
// with identical exit-code/stdout/stderr semantics. Any miss falls back to
|
|
6
|
+
// the regular gated spawn. MIXDOG_SHELL_WARM_STANDBY=0 disables.
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { nativeSpawnSupportsStdinPipe, setNativeSpawnRequestIdle, tryNativeSpawn } from './native-spawn-client.mjs';
|
|
9
|
+
import { SHELL_OUTPUT_DISK_CAP } from '../shell-exec-output.mjs';
|
|
10
|
+
|
|
11
|
+
// Bootstrap: pre-warm scriptblock compilation while parked, re-decode stdin
|
|
12
|
+
// as UTF-8 (console input encoding would mangle non-ASCII), then dot-source
|
|
13
|
+
// the fed script so exit codes, $LASTEXITCODE and terminating errors behave
|
|
14
|
+
// exactly like `-Command <script>`.
|
|
15
|
+
const STANDBY_BOOTSTRAP = "$null = . ([scriptblock]::Create('$null')); [Console]::InputEncoding=[System.Text.UTF8Encoding]::new($false); . ([scriptblock]::Create([Console]::In.ReadToEnd()))";
|
|
16
|
+
const STANDBY_ARGS = ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', STANDBY_BOOTSTRAP];
|
|
17
|
+
const STANDBY_TTL_MS = 10 * 60_000;
|
|
18
|
+
|
|
19
|
+
let _slot = null; // { native, shell, envSig, createdAt }
|
|
20
|
+
|
|
21
|
+
function _disabled() {
|
|
22
|
+
return process.env.MIXDOG_SHELL_WARM_STANDBY === '0';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envSignature(env) {
|
|
26
|
+
const entries = Object.entries(env || {})
|
|
27
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
28
|
+
.sort()
|
|
29
|
+
.join('\n');
|
|
30
|
+
return createHash('sha256').update(entries).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _slotAlive(slot) {
|
|
34
|
+
return Boolean(slot
|
|
35
|
+
&& slot.native?.child
|
|
36
|
+
&& slot.native.child.exitCode == null
|
|
37
|
+
&& !slot.native.child.killed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function psQuote(value) {
|
|
41
|
+
return String(value).replace(/'/g, "''");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pre-spawn the next standby (fire-and-forget; a live one is kept). */
|
|
45
|
+
export function ensureWarmShellStandby({ shell, env }) {
|
|
46
|
+
if (_disabled() || !shell) return;
|
|
47
|
+
// Old spawn binaries ignore stdinPipe (null stdin → instant-EOF standby
|
|
48
|
+
// that would ack commands without running them); require the handshake.
|
|
49
|
+
if (!nativeSpawnSupportsStdinPipe()) return;
|
|
50
|
+
if (_slotAlive(_slot)) return;
|
|
51
|
+
let native = null;
|
|
52
|
+
try {
|
|
53
|
+
native = tryNativeSpawn({
|
|
54
|
+
shell: String(shell),
|
|
55
|
+
argv: STANDBY_ARGS,
|
|
56
|
+
spawnOptions: {
|
|
57
|
+
env,
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
outputLimit: SHELL_OUTPUT_DISK_CAP,
|
|
60
|
+
stdinPipe: true,
|
|
61
|
+
shellType: 'powershell',
|
|
62
|
+
command: '(warm shell standby)',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} catch { native = null; }
|
|
66
|
+
if (!native?.child) return;
|
|
67
|
+
// Parked standby must not pin the host event loop (a one-shot CLI or test
|
|
68
|
+
// runner would otherwise never exit); reactivated at take time.
|
|
69
|
+
setNativeSpawnRequestIdle(native.child, true);
|
|
70
|
+
const slot = {
|
|
71
|
+
native,
|
|
72
|
+
shell: String(shell),
|
|
73
|
+
envSig: envSignature(env),
|
|
74
|
+
createdAt: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
native.child.once('close', () => { if (_slot === slot) _slot = null; });
|
|
77
|
+
native.child.once('error', () => { if (_slot === slot) _slot = null; });
|
|
78
|
+
_slot = slot;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Take the parked standby for immediate use, or null on any mismatch.
|
|
82
|
+
* Always refills so the NEXT call finds a warm one. */
|
|
83
|
+
export function takeWarmShellStandby({ shell, env, cwd }) {
|
|
84
|
+
if (_disabled() || !shell) return null;
|
|
85
|
+
const slot = _slot;
|
|
86
|
+
const refill = () => { try { ensureWarmShellStandby({ shell, env }); } catch { /* best-effort */ } };
|
|
87
|
+
const usable = _slotAlive(slot)
|
|
88
|
+
&& slot.shell === String(shell)
|
|
89
|
+
&& typeof slot.native.child.writeStdin === 'function'
|
|
90
|
+
&& Number.isFinite(slot.native.child.pid) && slot.native.child.pid > 0
|
|
91
|
+
&& Date.now() - slot.createdAt <= STANDBY_TTL_MS
|
|
92
|
+
&& slot.envSig === envSignature(env);
|
|
93
|
+
if (!usable) {
|
|
94
|
+
// Stale (TTL/env/shell drift): kill so it cannot linger; a still-
|
|
95
|
+
// warming slot (no pid yet) is left in place for a later call.
|
|
96
|
+
if (_slotAlive(slot) && Number.isFinite(slot.native.child.pid)
|
|
97
|
+
&& (slot.shell !== String(shell)
|
|
98
|
+
|| Date.now() - slot.createdAt > STANDBY_TTL_MS
|
|
99
|
+
|| slot.envSig !== envSignature(env))) {
|
|
100
|
+
_slot = null;
|
|
101
|
+
try { slot.native.child.kill(); } catch { /* best-effort */ }
|
|
102
|
+
}
|
|
103
|
+
refill();
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
_slot = null;
|
|
107
|
+
// Back to active: the caller is about to run a real command on it and the
|
|
108
|
+
// host must stay alive until that command settles.
|
|
109
|
+
setNativeSpawnRequestIdle(slot.native.child, false);
|
|
110
|
+
refill();
|
|
111
|
+
const feed = (commandText, workDir) => {
|
|
112
|
+
// cwd prelude: the standby was spawned in the daemon cwd. Set-Location
|
|
113
|
+
// fixes $PWD and native-child working dirs; Environment.CurrentDirectory
|
|
114
|
+
// fixes .NET relative-path APIs.
|
|
115
|
+
const prelude = workDir
|
|
116
|
+
? `Set-Location -LiteralPath '${psQuote(workDir)}'; [System.Environment]::CurrentDirectory = '${psQuote(workDir)}'; `
|
|
117
|
+
: '';
|
|
118
|
+
// Single atomic write+EOF: a separate close message can race ahead
|
|
119
|
+
// of the server's async write thread and feed an empty script.
|
|
120
|
+
slot.native.child.writeStdin(prelude + String(commandText ?? ''), { close: true });
|
|
121
|
+
};
|
|
122
|
+
return { spawned: slot.native, feed };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function _resetWarmShellStandbyForTest() {
|
|
126
|
+
const slot = _slot;
|
|
127
|
+
_slot = null;
|
|
128
|
+
if (slot?.native?.child) {
|
|
129
|
+
try { slot.native.child.kill(); } catch { /* best-effort */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
extractPowerShellCommandInner,
|
|
25
25
|
} from './shell-powershell.mjs';
|
|
26
26
|
import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
|
|
27
|
+
import { takeWarmShellStandby } from './lib/shell-warm-standby.mjs';
|
|
27
28
|
|
|
28
29
|
export {
|
|
29
30
|
_maybeEncodePowerShellCommand,
|
|
@@ -199,6 +200,7 @@ export function execShellCommand({
|
|
|
199
200
|
timeoutMs,
|
|
200
201
|
abortSignal,
|
|
201
202
|
autoBackgroundMs,
|
|
203
|
+
startInBackground = false,
|
|
202
204
|
onProgress,
|
|
203
205
|
onOutputTail,
|
|
204
206
|
clientHostPid,
|
|
@@ -340,11 +342,23 @@ export function execShellCommand({
|
|
|
340
342
|
else pendingChildError = pendingChildError || err;
|
|
341
343
|
};
|
|
342
344
|
_onChildErrorRef = _onChildError;
|
|
345
|
+
// Warm-standby fast path (pwsh only): a pre-spawned bootstrap pwsh
|
|
346
|
+
// reads the script from stdin, skipping CreateProcess + Defender scan
|
|
347
|
+
// for this call. Any miss (env drift, TTL, dead/warming standby,
|
|
348
|
+
// MIXDOG_SHELL_WARM_STANDBY=0) falls through to the gated spawn below.
|
|
349
|
+
let _standby = null;
|
|
350
|
+
if (!_useDirectArgv && shellArg === '-Command') {
|
|
351
|
+
try { _standby = takeWarmShellStandby({ shell, env, cwd }); } catch { _standby = null; }
|
|
352
|
+
}
|
|
343
353
|
// Spawn-burst gate: hold a 'process-spawn' slot only across process
|
|
344
354
|
// creation (CreateProcess + AV scan + EPERM retries), released the
|
|
345
355
|
// moment the child exists. Bounds the Defender convoy a shell burst
|
|
346
356
|
// creates without limiting how many commands RUN concurrently — the
|
|
347
357
|
// full-lifetime gating concern in the note below stays true.
|
|
358
|
+
let spawned = null;
|
|
359
|
+
if (_standby) {
|
|
360
|
+
spawned = _standby.spawned;
|
|
361
|
+
} else {
|
|
348
362
|
const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
|
|
349
363
|
ownerKey: ownerSessionId,
|
|
350
364
|
});
|
|
@@ -355,7 +369,6 @@ export function execShellCommand({
|
|
|
355
369
|
try { _releaseSpawnSlot(); } catch { /* idempotent */ }
|
|
356
370
|
throw abortSignal.reason || new Error('aborted');
|
|
357
371
|
}
|
|
358
|
-
let spawned;
|
|
359
372
|
try {
|
|
360
373
|
spawned = await _spawnShellWithRetry({
|
|
361
374
|
shell,
|
|
@@ -378,8 +391,12 @@ export function execShellCommand({
|
|
|
378
391
|
} finally {
|
|
379
392
|
try { _releaseSpawnSlot(); } catch { /* idempotent */ }
|
|
380
393
|
}
|
|
394
|
+
}
|
|
381
395
|
child = spawned.child;
|
|
382
396
|
spawned.adoptErrorHandler(_onChildError);
|
|
397
|
+
// Feed the standby only after the error handler is attached; server
|
|
398
|
+
// messages cannot be processed before this synchronous block yields.
|
|
399
|
+
if (_standby) _standby.feed(_spawnCommand, cwd);
|
|
383
400
|
} catch (err) {
|
|
384
401
|
const cleanupError = await releaseResourceLease();
|
|
385
402
|
const spawnText = String((err && err.message) || err);
|
|
@@ -684,7 +701,9 @@ export function execShellCommand({
|
|
|
684
701
|
const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
|
|
685
702
|
const _verb = reason === 'timeout'
|
|
686
703
|
? `moved to background at timeout after ${secs}s`
|
|
687
|
-
:
|
|
704
|
+
: (reason === 'explicit'
|
|
705
|
+
? 'started in background'
|
|
706
|
+
: `auto-backgrounded after ${secs}s`);
|
|
688
707
|
resolveResult(
|
|
689
708
|
new ExecResult({
|
|
690
709
|
stdout,
|
|
@@ -795,7 +814,9 @@ export function execShellCommand({
|
|
|
795
814
|
// Arm the auto-background timer only for the genuine foreground one-shot
|
|
796
815
|
// path: a positive threshold strictly below the hard timeout, and not a
|
|
797
816
|
// trailing-`&` background command (those already detach + settle on exit).
|
|
798
|
-
if (
|
|
817
|
+
if (startInBackground && !_isBackground) {
|
|
818
|
+
setImmediate(() => { fireAutoBackground({ reason: 'explicit' }); });
|
|
819
|
+
} else if (
|
|
799
820
|
typeof autoBackgroundMs === 'number' &&
|
|
800
821
|
autoBackgroundMs > 0 &&
|
|
801
822
|
!_isBackground &&
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.2",
|
|
3
3
|
"_comment": "Synced from immutable spawn-v release assets.",
|
|
4
4
|
"assets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.
|
|
7
|
-
"sha256": "
|
|
6
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-darwin-arm64",
|
|
7
|
+
"sha256": "3c2eb5ecf47dccb4aeea113f422ede24f6dde22dceab7c9c77f6963ef9aded7d"
|
|
8
8
|
},
|
|
9
9
|
"darwin-x64": {
|
|
10
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.
|
|
11
|
-
"sha256": "
|
|
10
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-darwin-x64",
|
|
11
|
+
"sha256": "7288b073b940d82b64e9e888e723ae95204a28472053676fb6650d5c68362836"
|
|
12
12
|
},
|
|
13
13
|
"linux-arm64": {
|
|
14
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.
|
|
15
|
-
"sha256": "
|
|
14
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-linux-arm64",
|
|
15
|
+
"sha256": "308a53b7c9dbae5ce8c36dc98ab35ac869f0251671829cbdcb09162261b063bf"
|
|
16
16
|
},
|
|
17
17
|
"linux-x64": {
|
|
18
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.
|
|
19
|
-
"sha256": "
|
|
18
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-linux-x64",
|
|
19
|
+
"sha256": "9f619c17d13e2786e38a2a6017a5e9b1e2f88370132d2c0ff8ee80245b64d0c3"
|
|
20
20
|
},
|
|
21
21
|
"win32-x64": {
|
|
22
|
-
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.
|
|
23
|
-
"sha256": "
|
|
22
|
+
"url": "https://github.com/tribgames/mixdog/releases/download/spawn-v0.1.2/mixdog-spawn-win32-x64.exe",
|
|
23
|
+
"sha256": "ee229f2ee2066b2c1a7c984b2ac4cd5bcfed5758173115de366defb1f97c7b51"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -47,7 +47,8 @@ export function createLifecycleApi(deps) {
|
|
|
47
47
|
hooks, hookCommonPayload, mgr, statusRoutes, channels, agentTool, mcpClient,
|
|
48
48
|
warmupTimers, prewarmTimers,
|
|
49
49
|
flushAllConfigSavesAsync,
|
|
50
|
-
withTeardownDeadline, closePatchRuntimeIfLoaded,
|
|
50
|
+
withTeardownDeadline, closePatchRuntimeIfLoaded, closeNativeToolTransports,
|
|
51
|
+
stopSelfUpdateBootCheck,
|
|
51
52
|
createCurrentSession, refreshRouteEffort,
|
|
52
53
|
invalidateContextStatusCache, invalidatePreSessionToolSurface,
|
|
53
54
|
applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
|
|
@@ -328,11 +329,16 @@ export function createLifecycleApi(deps) {
|
|
|
328
329
|
}))
|
|
329
330
|
.catch(() => {})
|
|
330
331
|
: null;
|
|
332
|
+
const nativeToolStop = isProcessExit
|
|
333
|
+
? Promise.resolve(shellJobsStop)
|
|
334
|
+
.then(() => closeNativeToolTransports?.(reason))
|
|
335
|
+
.catch(() => {})
|
|
336
|
+
: null;
|
|
331
337
|
if (detach) {
|
|
332
338
|
try { await withTeardownDeadline(channelStop, 300, false); } catch {}
|
|
333
339
|
try { await withTeardownDeadline(shellJobsStop, 300, false); } catch {}
|
|
334
340
|
try { await withTeardownDeadline(memoryStop, 1500, false); } catch {}
|
|
335
|
-
for (const stop of [mcpStop, openaiWsStop, patchStop]) {
|
|
341
|
+
for (const stop of [mcpStop, openaiWsStop, patchStop, nativeToolStop]) {
|
|
336
342
|
Promise.resolve(stop).catch(() => {});
|
|
337
343
|
}
|
|
338
344
|
onProcessExit();
|
|
@@ -345,6 +351,7 @@ export function createLifecycleApi(deps) {
|
|
|
345
351
|
withTeardownDeadline(patchStop, 1500, false),
|
|
346
352
|
withTeardownDeadline(memoryStop, 5500, false),
|
|
347
353
|
withTeardownDeadline(shellJobsStop, 1500, false),
|
|
354
|
+
withTeardownDeadline(nativeToolStop, 1500, false),
|
|
348
355
|
]);
|
|
349
356
|
onProcessExit();
|
|
350
357
|
return ok;
|
|
@@ -103,6 +103,15 @@ export function createPrewarmSchedulers({
|
|
|
103
103
|
} catch (error) {
|
|
104
104
|
bootProfile('tool-runtime:native-shell-failed', { error: error?.message || String(error) });
|
|
105
105
|
}
|
|
106
|
+
try {
|
|
107
|
+
// Shell jobs orphaned by a daemon restart: finalize their records and
|
|
108
|
+
// deliver one completion notice to each owner session so the outcome
|
|
109
|
+
// is never silently dropped.
|
|
110
|
+
const { reconcileRecoveredShellJobCompletions } = await import('../runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs');
|
|
111
|
+
bootProfile('tool-runtime:shell-job-recovery', { notified: await reconcileRecoveredShellJobCompletions() });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
bootProfile('tool-runtime:shell-job-recovery-failed', { error: error?.message || String(error) });
|
|
114
|
+
}
|
|
106
115
|
try {
|
|
107
116
|
const { prewarmTokenEstimator } = await import('../runtime/agent/orchestrator/session/context-utils.mjs');
|
|
108
117
|
bootProfile('tool-runtime:token-estimator', { warmed: prewarmTokenEstimator() === true });
|
|
@@ -326,6 +326,7 @@ export async function createMixdogSessionRuntime({
|
|
|
326
326
|
toolMode = 'full',
|
|
327
327
|
approvalMode = null,
|
|
328
328
|
disallowDelegation = false,
|
|
329
|
+
autoWakeCompletions = true,
|
|
329
330
|
initialConfig = null,
|
|
330
331
|
remote = false,
|
|
331
332
|
desktopSession: initialDesktopSession = null,
|
|
@@ -510,6 +511,17 @@ export async function createMixdogSessionRuntime({
|
|
|
510
511
|
}
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
async function closeNativeToolTransports(reason = 'process-exit') {
|
|
515
|
+
const [spawnClient, searchClient] = await Promise.all([
|
|
516
|
+
import('../runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs'),
|
|
517
|
+
import('../runtime/agent/orchestrator/tools/builtin/native-search-client.mjs'),
|
|
518
|
+
]);
|
|
519
|
+
await Promise.allSettled([
|
|
520
|
+
spawnClient.shutdownNativeSpawnServer?.(reason),
|
|
521
|
+
searchClient.shutdownNativeSearchServer?.(reason),
|
|
522
|
+
]);
|
|
523
|
+
}
|
|
524
|
+
|
|
513
525
|
const configStartedAt = performance.now();
|
|
514
526
|
rt.config = initialConfig && typeof initialConfig === 'object'
|
|
515
527
|
? initialConfig
|
|
@@ -726,7 +738,7 @@ export async function createMixdogSessionRuntime({
|
|
|
726
738
|
} = createNotificationBus({
|
|
727
739
|
listeners: notificationListeners,
|
|
728
740
|
mgr,
|
|
729
|
-
onCompletionQueued: wakeQueuedCompletion,
|
|
741
|
+
onCompletionQueued: autoWakeCompletions ? wakeQueuedCompletion : null,
|
|
730
742
|
});
|
|
731
743
|
// Adopt a session as this runtime's identity wherever setSession is
|
|
732
744
|
// injected (lifecycle resume, model-route swap, workflow swap, turn api).
|
|
@@ -1585,6 +1597,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1585
1597
|
flushAllConfigSavesAsync,
|
|
1586
1598
|
withTeardownDeadline,
|
|
1587
1599
|
closePatchRuntimeIfLoaded,
|
|
1600
|
+
closeNativeToolTransports,
|
|
1588
1601
|
stopSelfUpdateBootCheck: () => selfUpdate.stopBootCheck(),
|
|
1589
1602
|
createCurrentSession,
|
|
1590
1603
|
refreshRouteEffort,
|
|
@@ -35,6 +35,10 @@ test('omitToolRoutes drops search and memory clauses independently', () => {
|
|
|
35
35
|
test('shared tool rules omit disabled search and memory routes', () => {
|
|
36
36
|
const pluginRoot = join(process.cwd(), 'src');
|
|
37
37
|
const full = buildSharedToolContent({ PLUGIN_ROOT: pluginRoot });
|
|
38
|
+
assert.match(
|
|
39
|
+
full,
|
|
40
|
+
/^# Tool Use\s+- When an internal Mixdog rule conflicts with the user's latest explicit\s+request, follow the user's request\./,
|
|
41
|
+
);
|
|
38
42
|
assert.match(full, /`search`/);
|
|
39
43
|
assert.match(full, /`memory`/);
|
|
40
44
|
const omitted = buildSharedToolContent({
|
|
@@ -365,6 +365,9 @@ export function createChannelTransport({
|
|
|
365
365
|
return 'cancelled';
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
let emptyFireBackoffMs = 0;
|
|
369
|
+
let nextEmptyFireAt = 0;
|
|
370
|
+
|
|
368
371
|
function cancelGrace() {
|
|
369
372
|
if (graceTimer) { try { clearTimeout(graceTimer); } catch {} graceTimer = null; }
|
|
370
373
|
}
|
|
@@ -373,10 +376,18 @@ export function createChannelTransport({
|
|
|
373
376
|
if (closed || graceTimer) return;
|
|
374
377
|
if (!everHadClient || clients.size > 0) return;
|
|
375
378
|
if (typeof onClientsEmpty !== 'function' || clientGraceMs <= 0) return;
|
|
379
|
+
// No-op-fire backoff: when onClientsEmpty() repeatedly declines to shut
|
|
380
|
+
// the daemon down (a session client is still alive on the other front
|
|
381
|
+
// door), the sweep would otherwise re-fire every grace period and spam
|
|
382
|
+
// the log with an elapsed→deferred pair for hours. Double the re-fire
|
|
383
|
+
// interval up to 10 minutes; any client registration resets it.
|
|
384
|
+
if (Date.now() < nextEmptyFireAt) return;
|
|
376
385
|
graceTimer = setTimeout(() => {
|
|
377
386
|
graceTimer = null;
|
|
378
387
|
pruneDeadClients();
|
|
379
388
|
if (clients.size > 0) return;
|
|
389
|
+
emptyFireBackoffMs = Math.min(Math.max(clientGraceMs, emptyFireBackoffMs * 2), 600_000);
|
|
390
|
+
nextEmptyFireAt = Date.now() + emptyFireBackoffMs;
|
|
380
391
|
log(`client grace elapsed (${reason}); no live clients — self-shutdown`);
|
|
381
392
|
try { onClientsEmpty(); } catch {}
|
|
382
393
|
}, clientGraceMs);
|
|
@@ -553,6 +564,8 @@ export function createChannelTransport({
|
|
|
553
564
|
}
|
|
554
565
|
everHadClient = true;
|
|
555
566
|
cancelGrace();
|
|
567
|
+
emptyFireBackoffMs = 0;
|
|
568
|
+
nextEmptyFireAt = 0;
|
|
556
569
|
startSweep();
|
|
557
570
|
log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
|
|
558
571
|
// The unified daemon starts the channels runtime (automation, webhooks,
|
|
@@ -309,6 +309,7 @@ async function shutdown(reason, code = 0) {
|
|
|
309
309
|
|
|
310
310
|
/** One process, two front doors (channels + sessions): an idle side must never
|
|
311
311
|
* evict a busy one, so every self-shutdown trigger checks BOTH registries. */
|
|
312
|
+
let _lastDeferredLog = { key: '', at: 0, suppressed: 0 };
|
|
312
313
|
function maybeSelfShutdown(reason) {
|
|
313
314
|
const channelClients = transport?.clientCount ?? 0;
|
|
314
315
|
const sessionClients = sessionTransport?.clientCount ?? 0;
|
|
@@ -319,7 +320,19 @@ function maybeSelfShutdown(reason) {
|
|
|
319
320
|
clearTimeout(shutdownRecheckTimer);
|
|
320
321
|
shutdownRecheckTimer = null;
|
|
321
322
|
}
|
|
322
|
-
|
|
323
|
+
// Identical defers repeat for hours while one front door stays occupied;
|
|
324
|
+
// log the first occurrence, then one summary line per minute.
|
|
325
|
+
const deferKey = `${reason}|${channelClients}|${sessionClients}|${remoteClients}`;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (deferKey === _lastDeferredLog.key && now - _lastDeferredLog.at < 60_000) {
|
|
328
|
+
_lastDeferredLog.suppressed += 1;
|
|
329
|
+
} else {
|
|
330
|
+
const suffix = _lastDeferredLog.suppressed > 0
|
|
331
|
+
? ` (+${_lastDeferredLog.suppressed} identical defers suppressed)`
|
|
332
|
+
: '';
|
|
333
|
+
log(`shutdown deferred (${reason}): channels=${channelClients} sessionClients=${sessionClients} remote=${remoteClients}${suffix}`);
|
|
334
|
+
_lastDeferredLog = { key: deferKey, at: now, suppressed: 0 };
|
|
335
|
+
}
|
|
323
336
|
return;
|
|
324
337
|
}
|
|
325
338
|
const activeCalls = (transport?.activeCount ?? 0) + (sessionTransport?.activeCount ?? 0);
|