mixdog 0.9.149 → 0.9.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -12
- package/package.json +1 -1
- package/src/headless-exec.mjs +40 -4
- package/src/headless-exec.test.mjs +116 -1
- package/src/rules/lead/01-general.md +3 -2
- package/src/rules/shared/00-general.md +1 -0
- package/src/rules/shared/10-tool-workflow.md +11 -12
- package/src/rules/shared/30-exploration.md +4 -3
- package/src/rules/shared/60-verification.md +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
- package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
- package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
- package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +13 -19
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/memory-snapshot.mjs +57 -4
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +6 -3
- package/src/runtime/shared/tool-surface.mjs +6 -0
- package/src/runtime/shared/tool-surface.test.mjs +8 -0
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/agent-dispatch-broker.mjs +25 -80
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/daemon.mjs +9 -9
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/standalone/session-runtime-host-health.test.mjs +36 -0
- package/src/standalone/session-runtime-host.mjs +26 -76
- package/src/standalone/session-runtime-worker.mjs +97 -0
- package/src/tui/App.jsx +3 -2
- package/src/tui/app/use-global-key-input.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +77 -14
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/dist/index.mjs +83 -23
- package/src/tui/lib/voice-setup.mjs +3 -2
- package/src/tui/session/completion-card-restore.test.mjs +18 -0
- package/src/tui/session/session-api-ext.mjs +2 -1
- package/src/tui/session/session-api.mjs +8 -1
- package/src/tui/session/task-wait-submit.test.mjs +80 -0
- package/src/tui/session/turn.mjs +58 -6
- package/src/tui/spinner-verbs.mjs +1 -0
- package/src/workflows/default/WORKFLOW.md +12 -19
- package/src/workflows/headless/WORKFLOW.md +11 -0
- package/src/workflows/solo/WORKFLOW.md +6 -11
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* thin executor; search-tool.mjs re-exports these for unchanged importers.
|
|
5
5
|
*/
|
|
6
6
|
import { statSync } from 'fs';
|
|
7
|
-
import { basename, isAbsolute, join, resolve } from 'path';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
8
8
|
import { findBySuffixStrip, findDirectoryByBasename, findFileByBasename, listSiblings } from './path-diagnostics.mjs';
|
|
9
9
|
import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
|
|
10
10
|
|
|
@@ -18,6 +18,9 @@ import { normalizeOutputPath, resolveAgainstCwd } from './path-utils.mjs';
|
|
|
18
18
|
// indexed file exists or the glob child is unavailable.
|
|
19
19
|
export async function _suggestIndexedPaths(missingPath, executeChildBuiltinTool, workDir) {
|
|
20
20
|
if (typeof executeChildBuiltinTool !== 'function') return '';
|
|
21
|
+
// An outside path has no indexed twin worth proposing; the project-wide
|
|
22
|
+
// glob would only spend the turn.
|
|
23
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) return '';
|
|
21
24
|
const base = String(missingPath).replace(/\\/g, '/').split('/').pop();
|
|
22
25
|
// Skip when there is no usable basename or it carries glob magic (the
|
|
23
26
|
// pattern, not a literal filename, would not map to a real file).
|
|
@@ -90,6 +93,50 @@ function cachedFileByBasename(workDir, missingPath, cache) {
|
|
|
90
93
|
return cache.fileHits;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
// A missing ABSOLUTE path that resolves outside the project cannot be
|
|
97
|
+
// corrected by anything inside it: `/sys/block/sd*/size` is not a misplaced
|
|
98
|
+
// project file, and scanning the tree for a file named `size` only spends the
|
|
99
|
+
// turn (measured: three such reads burned 17.5s each and left the shared
|
|
100
|
+
// search server saturated for the calls behind them). The honest recovery for
|
|
101
|
+
// an outside path is its own filesystem: name the nearest directory that does
|
|
102
|
+
// exist and what it holds.
|
|
103
|
+
function missingPathIsOutsideProject(workDir, missingPath) {
|
|
104
|
+
const raw = String(missingPath || '');
|
|
105
|
+
if (!raw || !isAbsolute(raw)) return false;
|
|
106
|
+
if (typeof workDir !== 'string' || !workDir) return false;
|
|
107
|
+
const rel = relative(resolve(workDir), resolve(raw));
|
|
108
|
+
return !rel || rel === '..' || rel.startsWith(`..${sep}`) || rel.startsWith('../') || isAbsolute(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nearestExistingRealParent(missingPath) {
|
|
112
|
+
try {
|
|
113
|
+
let dir = dirname(resolve(String(missingPath)));
|
|
114
|
+
for (let depth = 0; depth < 24; depth += 1) {
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(dir).isDirectory()) return dir;
|
|
117
|
+
} catch { /* keep climbing */ }
|
|
118
|
+
const parent = dirname(dir);
|
|
119
|
+
if (!parent || parent === dir) break;
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
} catch { /* fall through */ }
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function outsideProjectNotFoundHint(missingPath, actionVerb) {
|
|
127
|
+
const parent = nearestExistingRealParent(missingPath);
|
|
128
|
+
if (!parent) return ' Not found, and no parent directory of it exists either.';
|
|
129
|
+
const entries = listSiblings(parent, 12).slice(0, 5);
|
|
130
|
+
const where = `"${normalizeOutputPath(parent)}"`;
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
return ` Not found; ${where} exists but is empty — nothing under it to ${actionVerb.toLowerCase()}.`;
|
|
133
|
+
}
|
|
134
|
+
const magic = /[*?[\]{}]/.test(String(missingPath))
|
|
135
|
+
? ' The path carries a glob pattern; name one of those entries directly.'
|
|
136
|
+
: '';
|
|
137
|
+
return ` Not found; the nearest existing directory ${where} holds: ${entries.map((n) => `"${n}"`).join(', ')}.${magic}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
function isDirectoryPathGuess(missingPath) {
|
|
94
141
|
const base = basename(String(missingPath || '').replace(/\\/g, '/'));
|
|
95
142
|
if (!base || /[*?[\]{}]/.test(base)) return false;
|
|
@@ -137,6 +184,9 @@ export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENO
|
|
|
137
184
|
|
|
138
185
|
function resolveUniqueEnoentRedirect(workDir, missingPath, errCode = 'ENOENT', cache = null) {
|
|
139
186
|
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return null;
|
|
187
|
+
// Redirecting an outside path INTO the project would answer a different
|
|
188
|
+
// question than the one asked.
|
|
189
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) return null;
|
|
140
190
|
const suffixHit = cachedSuffixStrip(workDir, missingPath, cache);
|
|
141
191
|
if (suffixHit) return suffixHit;
|
|
142
192
|
const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
|
|
@@ -183,6 +233,9 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
|
|
|
183
233
|
if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing.replace(/\\/g, '\\'))) {
|
|
184
234
|
return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
|
|
185
235
|
}
|
|
236
|
+
if (missingPathIsOutsideProject(workDir, missingPath)) {
|
|
237
|
+
return outsideProjectNotFoundHint(missingPath, actionVerb);
|
|
238
|
+
}
|
|
186
239
|
if (resolveUniqueEnoentRedirect(workDir, missingPath, errCode, cache)) return '';
|
|
187
240
|
const elsewhere = cachedFileByBasename(workDir, missingPath, cache);
|
|
188
241
|
if (elsewhere.length) {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
TASK_WAIT_TIMEOUT_MIN_MS,
|
|
21
21
|
} from './builtin-tools.mjs';
|
|
22
22
|
import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
|
|
23
|
+
import { beginInterruptibleTaskWait } from '../../session/task-wait-control.mjs';
|
|
23
24
|
import {
|
|
24
25
|
acknowledgeBackgroundTaskCompletion,
|
|
25
26
|
cancelBackgroundTask,
|
|
@@ -160,39 +161,63 @@ export async function executeTaskTool(args, options = {}) {
|
|
|
160
161
|
const isShellTask = task.surface === 'shell';
|
|
161
162
|
|
|
162
163
|
if (action === 'read' || action === 'wait') {
|
|
164
|
+
let waitInterruptedByUser = false;
|
|
163
165
|
if (action === 'wait') {
|
|
164
166
|
if (!isShellTask) return `Error: task wait is only available for shell tasks: ${taskId}`;
|
|
165
167
|
// The turn's abort signal must cut the wait short; a cancelled turn
|
|
166
168
|
// cannot sit out the remaining ceiling.
|
|
167
|
-
let
|
|
168
|
-
try {
|
|
169
|
-
catch {
|
|
169
|
+
let turnSignal = null;
|
|
170
|
+
try { turnSignal = (await getAbortSignalForSession(options?.sessionId)) || null; }
|
|
171
|
+
catch { turnSignal = null; }
|
|
172
|
+
const waitControl = beginInterruptibleTaskWait(options?.sessionId, turnSignal);
|
|
173
|
+
const waitSignal = waitControl.signal;
|
|
170
174
|
const waitTimeoutMs = resolveTaskWaitTimeoutMs(args.timeout_ms);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
try {
|
|
176
|
+
// A shell task whose descendants outlived the shell process has no
|
|
177
|
+
// native job to subscribe to — the process it belonged to is gone.
|
|
178
|
+
// Its registry promise (resolved by the descendant observer) is the
|
|
179
|
+
// completion event in that case.
|
|
180
|
+
if (!peekShellJob(taskId) && task.status === 'running' && task.promise) {
|
|
181
|
+
await new Promise((resolve) => {
|
|
182
|
+
const timer = setTimeout(resolve, waitTimeoutMs);
|
|
183
|
+
timer.unref?.();
|
|
184
|
+
const done = () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
if (waitSignal) {
|
|
187
|
+
try { waitSignal.removeEventListener('abort', done); } catch {}
|
|
188
|
+
}
|
|
189
|
+
resolve();
|
|
190
|
+
};
|
|
191
|
+
task.promise.then(done, done);
|
|
192
|
+
if (waitSignal) {
|
|
193
|
+
if (waitSignal.aborted) done();
|
|
194
|
+
else waitSignal.addEventListener('abort', done, { once: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
await waitForShellJob(taskId, {
|
|
199
|
+
timeoutMs: waitTimeoutMs,
|
|
200
|
+
signal: waitSignal,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
waitInterruptedByUser = waitControl.interruptedByUser;
|
|
205
|
+
waitControl.dispose();
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
208
|
if (isShellTask) refreshShellTask(taskId, { includeRunning: true });
|
|
194
209
|
const latest = getBackgroundTask(taskId, { context: options }) || task;
|
|
195
|
-
|
|
210
|
+
let rendered = renderBackgroundTask(latest, { includeResult: true });
|
|
211
|
+
if (action === 'wait' && latest.status === 'running') {
|
|
212
|
+
if (waitInterruptedByUser) {
|
|
213
|
+
rendered += '\n\nWait interrupted by new user input; task still running. Handle the new message now and call task wait again only when its result is needed.';
|
|
214
|
+
} else {
|
|
215
|
+
// In-band report cue: the static "periodic reports" rule does not
|
|
216
|
+
// survive a long tool loop, so a ceiling-hit wait carries the
|
|
217
|
+
// protocol reminder itself.
|
|
218
|
+
rendered += '\n\nWait ceiling reached; task still running. If periodic reports were requested, write the user-facing report now, then call task wait for the next interval; otherwise continue independent work or end the turn.';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
196
221
|
if (acknowledgeBackgroundTaskCompletion(taskId, { context: options })) {
|
|
197
222
|
recordDeliveredCompletion({ executionId: taskId });
|
|
198
223
|
}
|
|
@@ -9,6 +9,7 @@ function _envInt(name, fallback) {
|
|
|
9
9
|
}
|
|
10
10
|
export const TOOL_OUTPUT_MAX_BYTES = _envInt('MIXDOG_TOOL_OUTPUT_MAX_BYTES', 50 * 1024);
|
|
11
11
|
export const LOCATOR_OUTPUT_MAX_BYTES = _envInt('MIXDOG_LOCATOR_OUTPUT_MAX_BYTES', 20 * 1024);
|
|
12
|
+
export const GREP_OUTPUT_MAX_BYTES = _envInt('MIXDOG_GREP_OUTPUT_MAX_BYTES', 10 * 1024);
|
|
12
13
|
export const CODE_GRAPH_OUTPUT_MAX_BYTES = _envInt('MIXDOG_CODE_GRAPH_OUTPUT_MAX_BYTES', 30 * 1024);
|
|
13
14
|
// recall/memory search returns model-facing prose blocks; uncapped multi-query
|
|
14
15
|
// browses were observed injecting 42KB in one call (2026-08-05 audit). Same
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Writing through a symlink must land on the file the link points at and leave
|
|
2
|
+
// the link in place. The atomic rename would otherwise replace the link with a
|
|
3
|
+
// regular file and silently detach layouts like nginx's sites-enabled/default
|
|
4
|
+
// from sites-available/default.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
symlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
|
|
21
|
+
import { tryExecuteExternalToolAdapter } from './external-tool-adapters.mjs';
|
|
22
|
+
|
|
23
|
+
function makeDir() {
|
|
24
|
+
return mkdtempSync(join(tmpdir(), 'mixdog-write-symlink-'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Windows without Developer Mode / admin rights refuses symlink creation
|
|
28
|
+
// (EPERM), so the host capability decides whether these cases can run.
|
|
29
|
+
function symlinkSupported(dir) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(join(dir, 'probe.txt'), 'x');
|
|
32
|
+
symlinkSync(join(dir, 'probe.txt'), join(dir, 'probe.link'));
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('symlinkWriteTarget resolves a live link and nothing else', (t) => {
|
|
40
|
+
const dir = makeDir();
|
|
41
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
42
|
+
if (!symlinkSupported(dir)) {
|
|
43
|
+
t.skip('symlink creation is not permitted on this host');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const real = join(dir, 'real.txt');
|
|
47
|
+
const link = join(dir, 'link.txt');
|
|
48
|
+
writeFileSync(real, 'a\n');
|
|
49
|
+
symlinkSync(real, link);
|
|
50
|
+
|
|
51
|
+
assert.equal(symlinkWriteTarget(link), realpathSync(real));
|
|
52
|
+
assert.equal(symlinkWriteTarget(real), null);
|
|
53
|
+
assert.equal(symlinkWriteTarget(join(dir, 'missing.txt')), null);
|
|
54
|
+
// A dangling link has no target to resolve, so the caller keeps its path.
|
|
55
|
+
const dangling = join(dir, 'dangling.link');
|
|
56
|
+
symlinkSync(join(dir, 'gone.txt'), dangling);
|
|
57
|
+
assert.equal(symlinkWriteTarget(dangling), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('atomicWrite writes through a symlink and keeps the link', async (t) => {
|
|
61
|
+
const dir = makeDir();
|
|
62
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
63
|
+
if (!symlinkSupported(dir)) {
|
|
64
|
+
t.skip('symlink creation is not permitted on this host');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const real = join(dir, 'real.conf');
|
|
68
|
+
const link = join(dir, 'link.conf');
|
|
69
|
+
writeFileSync(real, 'old\n');
|
|
70
|
+
symlinkSync(real, link);
|
|
71
|
+
|
|
72
|
+
await atomicWrite(link, 'new\n');
|
|
73
|
+
|
|
74
|
+
assert.equal(readFileSync(real, 'utf8'), 'new\n');
|
|
75
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the expected-target guard still fires when the real file moved', async (t) => {
|
|
79
|
+
const dir = makeDir();
|
|
80
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
81
|
+
if (!symlinkSupported(dir)) {
|
|
82
|
+
t.skip('symlink creation is not permitted on this host');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const real = join(dir, 'guard.conf');
|
|
86
|
+
const link = join(dir, 'guard.link');
|
|
87
|
+
writeFileSync(real, 'old\n');
|
|
88
|
+
symlinkSync(real, link);
|
|
89
|
+
const before = statSync(link);
|
|
90
|
+
|
|
91
|
+
// Somebody else rewrites the real file after the snapshot was taken.
|
|
92
|
+
writeFileSync(real, 'rewritten by another writer\n');
|
|
93
|
+
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => atomicWrite(link, 'mine\n', {
|
|
96
|
+
expectedTargetSnapshot: {
|
|
97
|
+
exists: true,
|
|
98
|
+
size: before.size,
|
|
99
|
+
mtimeMs: before.mtimeMs,
|
|
100
|
+
ctimeMs: before.ctimeMs,
|
|
101
|
+
ino: before.ino,
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
(err) => err?.code === 'ESTALE_TARGET',
|
|
105
|
+
);
|
|
106
|
+
assert.equal(readFileSync(real, 'utf8'), 'rewritten by another writer\n');
|
|
107
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('edit updates through a symlink and keeps the link', async (t) => {
|
|
111
|
+
const dir = makeDir();
|
|
112
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
113
|
+
if (!symlinkSupported(dir)) {
|
|
114
|
+
t.skip('symlink creation is not permitted on this host');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const real = join(dir, 'sites-available.conf');
|
|
118
|
+
const link = join(dir, 'sites-enabled.conf');
|
|
119
|
+
writeFileSync(real, 'root /var/www/html;\nindex index.html;\n');
|
|
120
|
+
symlinkSync(real, link);
|
|
121
|
+
|
|
122
|
+
const result = String(await tryExecuteExternalToolAdapter('edit', {
|
|
123
|
+
file_path: link,
|
|
124
|
+
old_string: 'root /var/www/html;',
|
|
125
|
+
new_string: 'root /usr/share/novnc;',
|
|
126
|
+
}, dir, {}));
|
|
127
|
+
|
|
128
|
+
assert.match(result, /^Updated /);
|
|
129
|
+
assert.equal(readFileSync(real, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
|
|
130
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('write replaces content through a symlink and keeps the link', async (t) => {
|
|
134
|
+
const dir = makeDir();
|
|
135
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
136
|
+
if (!symlinkSupported(dir)) {
|
|
137
|
+
t.skip('symlink creation is not permitted on this host');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const real = join(dir, 'payload.txt');
|
|
141
|
+
const link = join(dir, 'payload.link');
|
|
142
|
+
writeFileSync(real, 'old\n');
|
|
143
|
+
symlinkSync(real, link);
|
|
144
|
+
|
|
145
|
+
const result = String(await tryExecuteExternalToolAdapter('write', {
|
|
146
|
+
file_path: link,
|
|
147
|
+
contents: 'fresh\n',
|
|
148
|
+
}, dir, {}));
|
|
149
|
+
|
|
150
|
+
assert.match(result, /^Updated /);
|
|
151
|
+
assert.equal(readFileSync(real, 'utf8'), 'fresh\n');
|
|
152
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
153
|
+
});
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
READ_STREAM_RANGE_MIN_BYTES,
|
|
76
76
|
} from './builtin/read-constants.mjs';
|
|
77
77
|
import {
|
|
78
|
+
GREP_OUTPUT_MAX_BYTES,
|
|
78
79
|
LOCATOR_OUTPUT_MAX_BYTES,
|
|
79
80
|
capLineOrientedToolOutput,
|
|
80
81
|
} from './builtin/tool-output-limit.mjs';
|
|
@@ -495,7 +496,16 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
|
|
|
495
496
|
const _locatorCap = _explicitCap
|
|
496
497
|
? Math.min(_explicitCap, LOCATOR_OUTPUT_MAX_BYTES)
|
|
497
498
|
: LOCATOR_OUTPUT_MAX_BYTES;
|
|
498
|
-
const
|
|
499
|
+
const _grepCap = _explicitCap
|
|
500
|
+
? Math.min(_explicitCap, GREP_OUTPUT_MAX_BYTES)
|
|
501
|
+
: GREP_OUTPUT_MAX_BYTES;
|
|
502
|
+
const _budgetedResult = toolName === 'grep'
|
|
503
|
+
? capLineOrientedToolOutput(
|
|
504
|
+
_withNotices,
|
|
505
|
+
_grepCap,
|
|
506
|
+
(kept) => `[grep output capped at ${_grepCap} bytes; ${kept.length} line(s) shown, remainder omitted]`,
|
|
507
|
+
)
|
|
508
|
+
: _LOCATOR_BUDGET_TOOLS.has(toolName)
|
|
499
509
|
? capLineOrientedToolOutput(
|
|
500
510
|
_withNotices,
|
|
501
511
|
_locatorCap,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A sentinel-free cwd that HOLDS one project answers an anchored call by
|
|
2
|
+
// adopting that project; a genuinely ambiguous tree still refuses, but names
|
|
3
|
+
// where to anchor instead.
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
import { executeCodeGraphTool } from './dispatch.mjs';
|
|
11
|
+
|
|
12
|
+
const PY = 'class Mean:\n def compute(self):\n return 42\n';
|
|
13
|
+
|
|
14
|
+
function makeRepo(root, name) {
|
|
15
|
+
const repo = join(root, name);
|
|
16
|
+
mkdirSync(join(repo, '.git'), { recursive: true });
|
|
17
|
+
mkdirSync(join(repo, 'tools'), { recursive: true });
|
|
18
|
+
writeFileSync(join(repo, 'tools', 'compute_mean.py'), PY);
|
|
19
|
+
return repo;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('a relative anchor held by exactly one child project adopts that project', async (t) => {
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-one-'));
|
|
24
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
25
|
+
makeRepo(root, 'caffe');
|
|
26
|
+
|
|
27
|
+
const out = String(await executeCodeGraphTool(
|
|
28
|
+
'code_graph',
|
|
29
|
+
{ mode: 'symbols', files: 'tools/compute_mean.py' },
|
|
30
|
+
root,
|
|
31
|
+
));
|
|
32
|
+
assert.doesNotMatch(out, /Refusing to index an arbitrary tree/);
|
|
33
|
+
assert.match(out, /compute/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('two child projects holding the same anchor stay refused, with the roots named', async (t) => {
|
|
37
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-anchor-many-'));
|
|
38
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
39
|
+
makeRepo(root, 'caffe');
|
|
40
|
+
makeRepo(root, 'caffe-fork');
|
|
41
|
+
|
|
42
|
+
let message = '';
|
|
43
|
+
try {
|
|
44
|
+
await executeCodeGraphTool(
|
|
45
|
+
'code_graph',
|
|
46
|
+
{ mode: 'symbols', files: 'tools/compute_mean.py' },
|
|
47
|
+
root,
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
message = String(error?.message || error);
|
|
51
|
+
}
|
|
52
|
+
assert.match(message, /Refusing to index an arbitrary tree/);
|
|
53
|
+
assert.match(message, /Project roots under this cwd/);
|
|
54
|
+
assert.match(message, /"caffe"/);
|
|
55
|
+
});
|
|
@@ -365,6 +365,47 @@ function _boundedExactFileAggregateRoot(args, baseCwd) {
|
|
|
365
365
|
return roots.size === 1 ? [...roots][0] : null;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
// A sentinel-free working directory that HOLDS a project (a container's /app
|
|
369
|
+
// with one cloned repo under it) answered every anchored call with a refusal:
|
|
370
|
+
// the caller wrote the anchor the way the repo's own docs do
|
|
371
|
+
// (`tools/compute_image_mean.cpp`), it does not exist under /app, and no root
|
|
372
|
+
// could be derived from a path that resolves nowhere. The relative anchor is
|
|
373
|
+
// still unambiguous whenever exactly ONE child project holds it — that project
|
|
374
|
+
// is the answer, and adopting it is the same recovery read and grep already
|
|
375
|
+
// perform for a misplaced path. Ambiguity (several holders, none, an absolute
|
|
376
|
+
// anchor, a wildcard) keeps refusing.
|
|
377
|
+
function _relocateAggregateAnchorsUnderChildProject(args, baseCwd) {
|
|
378
|
+
if (!_hasAggregateFileArgs(args)) return null;
|
|
379
|
+
const files = _collectGraphFileList(args);
|
|
380
|
+
if (files.length === 0) return null;
|
|
381
|
+
for (const file of files) {
|
|
382
|
+
if (_AGGREGATE_FILE_WILDCARD_RE.test(file) || isAbsolute(file)) return null;
|
|
383
|
+
if (existsSync(pathResolve(baseCwd, file))) return null;
|
|
384
|
+
}
|
|
385
|
+
const holders = _childProjectRoots(baseCwd, { cap: 8 })
|
|
386
|
+
.filter((root) => pathResolve(root) !== pathResolve(baseCwd))
|
|
387
|
+
.filter((root) => files.every((file) => {
|
|
388
|
+
try {
|
|
389
|
+
return statSync(pathResolve(root, file)).isFile();
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}));
|
|
394
|
+
if (holders.length !== 1) return null;
|
|
395
|
+
const root = pathResolve(holders[0]);
|
|
396
|
+
const remap = (file) => pathResolve(root, String(file || '').trim());
|
|
397
|
+
return {
|
|
398
|
+
root,
|
|
399
|
+
args: {
|
|
400
|
+
...args,
|
|
401
|
+
file: typeof args?.file === 'string' && args.file.trim() ? remap(args.file) : args?.file,
|
|
402
|
+
files: Array.isArray(args?.files)
|
|
403
|
+
? args.files.map(remap)
|
|
404
|
+
: (typeof args?.files === 'string' && args.files.trim() ? remap(args.files) : args?.files),
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
368
409
|
export function _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd) {
|
|
369
410
|
if (!_hasAggregateFileArgs(args)) return null;
|
|
370
411
|
const base = pathResolve(baseCwd);
|
|
@@ -1045,13 +1086,23 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
|
|
|
1045
1086
|
stopAtUserBoundary: !explicitCwdArg,
|
|
1046
1087
|
}) || _resolveBoundedSentinelFreeAggregateRootForTest(args, baseCwd)
|
|
1047
1088
|
|| _boundedExactFileAggregateRoot(args, baseCwd);
|
|
1048
|
-
|
|
1089
|
+
const relocated = aggregateRoot ? null : _relocateAggregateAnchorsUnderChildProject(args, baseCwd);
|
|
1090
|
+
if (!aggregateRoot && !relocated) {
|
|
1091
|
+
// Name the projects that DO sit under this cwd: the refusal is only
|
|
1092
|
+
// actionable if the caller learns where to anchor instead.
|
|
1093
|
+
const candidates = _childProjectRoots(baseCwd, { cap: 5 })
|
|
1094
|
+
.filter((root) => pathResolve(root) !== pathResolve(baseCwd))
|
|
1095
|
+
.map((root) => `"${pathBasename(root)}"`);
|
|
1049
1096
|
throw new Error(
|
|
1050
1097
|
`${name}: cwd '${baseCwd}' is not inside a project and aggregate file anchors do not all `
|
|
1051
|
-
+ `exist under exactly one detectable project root. Refusing to index an arbitrary tree
|
|
1098
|
+
+ `exist under exactly one detectable project root. Refusing to index an arbitrary tree.`
|
|
1099
|
+
+ (candidates.length
|
|
1100
|
+
? ` Project roots under this cwd: ${candidates.join(', ')} — anchor the call inside one of them.`
|
|
1101
|
+
: ''),
|
|
1052
1102
|
);
|
|
1053
1103
|
}
|
|
1054
|
-
effectiveCwd = aggregateRoot;
|
|
1104
|
+
effectiveCwd = relocated ? relocated.root : aggregateRoot;
|
|
1105
|
+
if (relocated) args = relocated.args;
|
|
1055
1106
|
args = _absolutizeAggregateFileArgs(args, baseCwd);
|
|
1056
1107
|
}
|
|
1057
1108
|
if (fileArg && !hasAggregateFileArgs) {
|
|
@@ -135,5 +135,21 @@ export function scrubRuntimeRootVars(env) {
|
|
|
135
135
|
delete env.MIXDOG_WORKER_MODE;
|
|
136
136
|
delete env.MIXDOG_DAEMON_SPAWNED_FOR;
|
|
137
137
|
delete env.MIXDOG_SUPERVISOR_PID;
|
|
138
|
+
// Same identity family as MIXDOG_SUPERVISOR_PID above, and the last one
|
|
139
|
+
// missing: MIXDOG_SERVER_PID is the DAEMON's own pid. A child that
|
|
140
|
+
// inherits it advertises the daemon as its server in active-instance.json,
|
|
141
|
+
// so the liveness/staleness checks in runtime-paths.mjs judge an unrelated
|
|
142
|
+
// process. Internal forks blank or re-set it explicitly after this
|
|
143
|
+
// boundary (see memory-runtime-proxy.mjs).
|
|
144
|
+
delete env.MIXDOG_SERVER_PID;
|
|
145
|
+
// Electron execution-MODE leak: a packaged desktop/daemon host runs its
|
|
146
|
+
// node entrypoints with ELECTRON_RUN_AS_NODE=1. Inherited by a child, it
|
|
147
|
+
// silently converts every Electron app that child launches into a bare
|
|
148
|
+
// node interpreter — Unity Hub, VS Code, Slack and friends stop booting
|
|
149
|
+
// their own CLI and hand the argv to node instead, which eats a leading
|
|
150
|
+
// `--` as its end-of-options marker and rejects app flags as bad node
|
|
151
|
+
// options. Internal daemon/worker forks that genuinely need node mode set
|
|
152
|
+
// this explicitly after the boundary and are unaffected.
|
|
153
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
138
154
|
return env;
|
|
139
155
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
|
|
4
|
-
import { applyShellEgressPolicy } from './env-scrub.mjs';
|
|
4
|
+
import { applyShellEgressPolicy, scrubRuntimeRootVars } from './env-scrub.mjs';
|
|
5
5
|
|
|
6
6
|
test('web-search disabled preserves ordinary shell network environment', () => {
|
|
7
7
|
const previous = process.env.MIXDOG_FEATURE_WEB_SEARCH;
|
|
@@ -23,3 +23,35 @@ test('web-search disabled preserves ordinary shell network environment', () => {
|
|
|
23
23
|
else process.env.MIXDOG_FEATURE_WEB_SEARCH = previous;
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
test('spawned children never inherit the Electron execution-mode switch', () => {
|
|
28
|
+
// ELECTRON_RUN_AS_NODE=1 turns any Electron app the child launches into a
|
|
29
|
+
// bare node interpreter, which swallows a leading `--` as its
|
|
30
|
+
// end-of-options marker and rejects the app's own CLI flags.
|
|
31
|
+
const env = {
|
|
32
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
33
|
+
PATH: '/usr/bin',
|
|
34
|
+
MIXDOG_DATA_DIR: '/home/u/.mixdog/data',
|
|
35
|
+
};
|
|
36
|
+
scrubRuntimeRootVars(env);
|
|
37
|
+
assert.equal(env.ELECTRON_RUN_AS_NODE, undefined);
|
|
38
|
+
// The child still needs to locate its tools and user data.
|
|
39
|
+
assert.equal(env.PATH, '/usr/bin');
|
|
40
|
+
assert.equal(env.MIXDOG_DATA_DIR, '/home/u/.mixdog/data');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('runtime-root scrub clears host identity and leaves user environment intact', () => {
|
|
44
|
+
const env = {
|
|
45
|
+
MIXDOG_ROOT: '/app/resources/runtime.asar',
|
|
46
|
+
NODE_ENV: 'production',
|
|
47
|
+
MIXDOG_DAEMON_HOST: '1',
|
|
48
|
+
MIXDOG_WORKER_MODE: '1',
|
|
49
|
+
MIXDOG_DAEMON_SPAWNED_FOR: 'session',
|
|
50
|
+
MIXDOG_SUPERVISOR_PID: '4242',
|
|
51
|
+
MIXDOG_SERVER_PID: '74976',
|
|
52
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
53
|
+
HOME: '/home/u',
|
|
54
|
+
};
|
|
55
|
+
assert.equal(scrubRuntimeRootVars(env), env);
|
|
56
|
+
assert.deepEqual(env, { HOME: '/home/u' });
|
|
57
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.20",
|
|
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.20/mixdog-graph-darwin-arm64",
|
|
7
|
+
"sha256": "d059f2ab33cb7f97b7c96b4e21e3d16880cc44baf1400ce81374a1031a11ddd9"
|
|
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.20/mixdog-graph-darwin-x64",
|
|
11
|
+
"sha256": "c91873322ce6390a7103daa4b872e31de1abbe7e922967727f41b5b7b2f21373"
|
|
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.20/mixdog-graph-linux-arm64",
|
|
15
|
+
"sha256": "e8183e1a36aae1887245bd55b94fae6d3fa874bb350559de95ea6ca65a14c6a0"
|
|
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.20/mixdog-graph-linux-x64",
|
|
19
|
+
"sha256": "40142ad17359cc2eb490f639de7c358c4449ca36dc7bbb966bff9a21f81df40f"
|
|
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.20/mixdog-graph-win32-x64.exe",
|
|
23
|
+
"sha256": "a727ca6ea385d715232ce246c2519037ccd74b509e57d97ad7c1b4a61c26aade"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -861,7 +861,15 @@ export function promoteNativeTask({
|
|
|
861
861
|
} = {}) {
|
|
862
862
|
const key = String(jobId || '').trim();
|
|
863
863
|
const server = _server;
|
|
864
|
-
|
|
864
|
+
// Distinguish the two promotion-unavailable causes: a silent null here used
|
|
865
|
+
// to surface as an unexplained SIGKILL ("background-promotion-failed") with
|
|
866
|
+
// no way to tell a capability gap from a missing task record afterwards.
|
|
867
|
+
if (server?.caps?.promoteTask !== true) {
|
|
868
|
+
throw new Error('native runner lacks promoteTask capability');
|
|
869
|
+
}
|
|
870
|
+
if (!getNativeTask(key)) {
|
|
871
|
+
throw new Error(`no native task record for jobId ${key || '(empty)'}`);
|
|
872
|
+
}
|
|
865
873
|
return requestNativeTaskState(key, {
|
|
866
874
|
promoteTask: key,
|
|
867
875
|
timeoutMs,
|