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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// verbatim from patch.mjs; native protocol, cache/snapshot side effects, and
|
|
3
3
|
// output formatting are unchanged.
|
|
4
4
|
|
|
5
|
-
import { readFileSync, lstatSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, lstatSync, statSync, realpathSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { unlink } from 'node:fs/promises';
|
|
7
7
|
import { dirname as pathDirname } from 'node:path';
|
|
8
8
|
import { performance } from 'node:perf_hooks';
|
|
@@ -393,6 +393,38 @@ function lstatRegularPatchFile(fullPath, displayPath) {
|
|
|
393
393
|
return st;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// A symlinked target is patched THROUGH the link. atomicWrite compares the
|
|
397
|
+
// live target with statSync, which FOLLOWS a leaf symlink, so the expected
|
|
398
|
+
// snapshot must be taken the same way: an lstat describes the LINK inode
|
|
399
|
+
// (size = target path length, its own mtime/ino) and can never equal the
|
|
400
|
+
// followed stat, which failed every patch aimed at a symlinked file with
|
|
401
|
+
// "changed on disk during the patch". Writing to the resolved real path also
|
|
402
|
+
// keeps the link intact — the atomic rename would otherwise replace the
|
|
403
|
+
// symlink itself with a regular file.
|
|
404
|
+
function resolvePatchWriteTarget(fullPath, displayPath, lst) {
|
|
405
|
+
if (!lst?.isSymbolicLink?.()) return { writePath: fullPath, snapshotStat: lst };
|
|
406
|
+
let writePath;
|
|
407
|
+
let snapshotStat;
|
|
408
|
+
try {
|
|
409
|
+
writePath = realpathSync(fullPath);
|
|
410
|
+
snapshotStat = statSync(fullPath);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`apply_patch: symlink target missing or unreadable: ${normalizeOutputPath(displayPath)}`
|
|
414
|
+
+ ` (${err?.code || err?.message || String(err)})`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (isSpecialFileStat(snapshotStat)) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`apply_patch: cannot patch special file (FIFO / character / block device / socket): ${normalizeOutputPath(displayPath)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (!snapshotStat.isFile()) {
|
|
423
|
+
throw new Error(`apply_patch: symlink target is not a regular file: ${normalizeOutputPath(displayPath)}`);
|
|
424
|
+
}
|
|
425
|
+
return { writePath, snapshotStat };
|
|
426
|
+
}
|
|
427
|
+
|
|
396
428
|
function assertAddTargetAbsent(fullPath, displayPath) {
|
|
397
429
|
try {
|
|
398
430
|
lstatSync(fullPath);
|
|
@@ -466,6 +498,7 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
466
498
|
}
|
|
467
499
|
|
|
468
500
|
const preMutationStat = lstatRegularPatchFile(fullPath, displayPath);
|
|
501
|
+
const { writePath, snapshotStat } = resolvePatchWriteTarget(fullPath, displayPath, preMutationStat);
|
|
469
502
|
// Rewrite path: decode by BOM, re-encode into the same codec, and keep every
|
|
470
503
|
// untouched line's own terminator (spliceTextLinesForPatch).
|
|
471
504
|
// `toString('utf8')` here transcoded non-UTF-8 files and mangled UTF-16.
|
|
@@ -482,14 +515,14 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
482
515
|
// rewrote this file after we read it, the write fails instead of silently
|
|
483
516
|
// clobbering it (and instead of inheriting a stale "body delivered" claim).
|
|
484
517
|
try {
|
|
485
|
-
await atomicWrite(
|
|
518
|
+
await atomicWrite(writePath, content, {
|
|
486
519
|
sessionId: readStateScope,
|
|
487
|
-
expectedTargetSnapshot:
|
|
520
|
+
expectedTargetSnapshot: snapshotStat ? {
|
|
488
521
|
exists: true,
|
|
489
|
-
size:
|
|
490
|
-
mtimeMs:
|
|
491
|
-
ctimeMs:
|
|
492
|
-
ino:
|
|
522
|
+
size: snapshotStat.size,
|
|
523
|
+
mtimeMs: snapshotStat.mtimeMs,
|
|
524
|
+
ctimeMs: snapshotStat.ctimeMs,
|
|
525
|
+
ino: snapshotStat.ino,
|
|
493
526
|
} : undefined,
|
|
494
527
|
});
|
|
495
528
|
} catch (err) {
|
|
@@ -498,12 +531,13 @@ async function applyJsParsedEntry(entry, basePath, { dryRun, fuzzy, readStateSco
|
|
|
498
531
|
}
|
|
499
532
|
throw err;
|
|
500
533
|
}
|
|
501
|
-
|
|
502
|
-
|
|
534
|
+
const touchedPaths = writePath === fullPath ? [fullPath] : [fullPath, writePath];
|
|
535
|
+
invalidateBuiltinResultCache(touchedPaths);
|
|
536
|
+
markCodeGraphDirtyPaths(touchedPaths);
|
|
503
537
|
recordReadSnapshotForPath(fullPath, readStateScope, {
|
|
504
538
|
source: 'apply_patch_js',
|
|
505
539
|
isPartialView: false,
|
|
506
|
-
preMutationStat,
|
|
540
|
+
preMutationStat: snapshotStat,
|
|
507
541
|
});
|
|
508
542
|
}
|
|
509
543
|
const { added, removed } = countHunkChanges(entry.hunks);
|
|
@@ -33,10 +33,11 @@ import {
|
|
|
33
33
|
} from './paths.mjs';
|
|
34
34
|
import { ensureNativePatchBinaryAvailable } from './native-server.mjs';
|
|
35
35
|
import { assertPathReachable } from '../builtin/fs-reachability.mjs';
|
|
36
|
+
import { symlinkWriteTarget } from '../builtin/atomic-write.mjs';
|
|
36
37
|
import { findBySuffixStrip, findFileByBasename } from '../builtin/path-diagnostics.mjs';
|
|
37
38
|
import { resolveReadPathRedirect } from '../builtin/snapshot-store.mjs';
|
|
38
39
|
import { dispatchNativePatch, dispatchJsPatchEntries } from './dispatch.mjs';
|
|
39
|
-
import { patchTargetUsesUtf16 } from './matcher.mjs';
|
|
40
|
+
import { patchTargetUsesUtf16, splitTextLinesForPatch } from './matcher.mjs';
|
|
40
41
|
import { nativePatchSessionSatisfiesContract } from './native-server.mjs';
|
|
41
42
|
import {
|
|
42
43
|
planV4ARenameSections,
|
|
@@ -202,21 +203,29 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
|
|
|
202
203
|
// included — so the whole wave takes the JS writer.
|
|
203
204
|
const codecNeedsJs = (fullPath, kind) => kind !== 'create' && kind !== 'delete'
|
|
204
205
|
&& patchTargetUsesUtf16(fullPath);
|
|
205
|
-
|
|
206
|
+
// A symlinked target takes the JS writer too: the native engine renames its
|
|
207
|
+
// output over the path it was handed, which would replace the link with a
|
|
208
|
+
// regular file. The JS writer resolves the link and rewrites the file it
|
|
209
|
+
// points at. Delete is exempt — removing the link itself is correct there.
|
|
210
|
+
const symlinkNeedsJs = (fullPath, kind) => kind !== 'create' && kind !== 'delete'
|
|
211
|
+
&& symlinkWriteTarget(fullPath) !== null;
|
|
212
|
+
const needsJsWriter = (fullPath, kind) => !engineContractOk
|
|
213
|
+
|| codecNeedsJs(fullPath, kind)
|
|
214
|
+
|| symlinkNeedsJs(fullPath, kind);
|
|
206
215
|
const nativeEntries = wentries.filter(
|
|
207
216
|
(entry) => entry.kind !== 'create'
|
|
208
217
|
&& !isResolvedPathOutsideBase(entry.fullPath, basePath)
|
|
209
|
-
&& !
|
|
218
|
+
&& !needsJsWriter(entry.fullPath, entry.kind),
|
|
210
219
|
);
|
|
211
220
|
const jsEntries = wentries.filter(
|
|
212
221
|
(entry) => entry.kind === 'create'
|
|
213
222
|
|| isResolvedPathOutsideBase(entry.fullPath, basePath)
|
|
214
|
-
||
|
|
223
|
+
|| needsJsWriter(entry.fullPath, entry.kind),
|
|
215
224
|
);
|
|
216
225
|
const parsedInside = (wparsed || []).filter(
|
|
217
226
|
(entry) => classifyEntry(entry) !== 'create'
|
|
218
227
|
&& !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath)
|
|
219
|
-
&& !
|
|
228
|
+
&& !needsJsWriter(parsedEntryResolvedPath(entry, basePath), classifyEntry(entry)),
|
|
220
229
|
);
|
|
221
230
|
const executor = jsEntries.length > 0
|
|
222
231
|
? (nativeEntries.length > 0 ? 'native+js-patch' : 'js-patch')
|
|
@@ -802,6 +811,44 @@ function salvageShatteredV4APatchArgs(args) {
|
|
|
802
811
|
return cleaned;
|
|
803
812
|
}
|
|
804
813
|
|
|
814
|
+
// A whole-file rewrite is naturally written as "delete the old file, add the
|
|
815
|
+
// new one", and that pair has exactly ONE possible outcome: the file ends up
|
|
816
|
+
// holding the Add body. Rejecting it as a conflicting target cost a full turn
|
|
817
|
+
// for a patch that was never ambiguous. Collapse it instead — into a full-file
|
|
818
|
+
// Update when the target exists, so every ordinary update guard (special
|
|
819
|
+
// files, reachability, verification, write locks) still runs, or into the
|
|
820
|
+
// plain Add when it does not exist and the Delete had nothing to remove.
|
|
821
|
+
// Everything else (Update+Delete, Add+Add, renames) keeps rejecting: those
|
|
822
|
+
// pairs genuinely describe two different end states.
|
|
823
|
+
function collapseDeleteThenAddSection(prior, section, fullPath) {
|
|
824
|
+
if (prior?.kind !== 'delete' || section?.kind !== 'add') return null;
|
|
825
|
+
if (prior.movePath || section.movePath) return null;
|
|
826
|
+
const added = Array.isArray(section.lines) ? [...section.lines] : [];
|
|
827
|
+
let raw;
|
|
828
|
+
try {
|
|
829
|
+
raw = readFileSync(fullPath);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error?.code !== 'ENOENT') return null;
|
|
832
|
+
return { ...section, lines: added, hunks: [] };
|
|
833
|
+
}
|
|
834
|
+
// A binary target has no line model to rewrite; leave it to the existing
|
|
835
|
+
// conflict error rather than inventing one.
|
|
836
|
+
if (raw.includes(0)) return null;
|
|
837
|
+
const current = splitTextLinesForPatch(raw.toString('utf8'));
|
|
838
|
+
return {
|
|
839
|
+
kind: 'update',
|
|
840
|
+
path: section.path,
|
|
841
|
+
lines: [],
|
|
842
|
+
hunks: [{
|
|
843
|
+
anchors: [],
|
|
844
|
+
lines: [
|
|
845
|
+
...[...current].map((line) => `-${line}`),
|
|
846
|
+
...added.map((line) => `+${line}`),
|
|
847
|
+
],
|
|
848
|
+
}],
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
805
852
|
function coalesceCompatibleV4ASections(sections, basePath) {
|
|
806
853
|
const out = [];
|
|
807
854
|
const indexByPath = new Map();
|
|
@@ -823,6 +870,11 @@ function coalesceCompatibleV4ASections(sections, basePath) {
|
|
|
823
870
|
continue;
|
|
824
871
|
}
|
|
825
872
|
const prior = out[priorIndex];
|
|
873
|
+
const replaced = collapseDeleteThenAddSection(prior, section, fullPath);
|
|
874
|
+
if (replaced) {
|
|
875
|
+
out[priorIndex] = replaced;
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
826
878
|
const mergeable = prior?.kind === 'update'
|
|
827
879
|
&& section.kind === 'update'
|
|
828
880
|
&& !prior.movePath
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// apply_patch through a symlink. The TOCTOU snapshot has to follow the link
|
|
2
|
+
// exactly the way atomicWrite does; an lstat of the link inode never matches
|
|
3
|
+
// the followed stat, which rejected every such patch as "changed on disk".
|
|
4
|
+
// The link itself must also survive the atomic rename.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
symlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { executePatchTool } from '../patch.mjs';
|
|
20
|
+
import { closeNativePatchServerForTests } from './native-server.mjs';
|
|
21
|
+
|
|
22
|
+
// Windows without Developer Mode / admin rights refuses symlink creation
|
|
23
|
+
// (EPERM), so the host capability decides whether this case can run at all.
|
|
24
|
+
function symlinkSupported(dir) {
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(join(dir, 'probe.txt'), 'x');
|
|
27
|
+
symlinkSync(join(dir, 'probe.txt'), join(dir, 'probe.link'));
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const asHeaderPath = (p) => p.replace(/\\/g, '/');
|
|
35
|
+
|
|
36
|
+
test('apply_patch updates a symlinked file through the link', async (t) => {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-patch-symlink-'));
|
|
38
|
+
t.after(() => {
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
void closeNativePatchServerForTests?.();
|
|
41
|
+
});
|
|
42
|
+
if (!symlinkSupported(dir)) {
|
|
43
|
+
t.skip('symlink creation is not permitted on this host');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Target outside base_path — the shape that routes to the JS writer, and
|
|
47
|
+
// the exact shape of the nginx sites-enabled/default case from the bench.
|
|
48
|
+
const base = join(dir, 'app');
|
|
49
|
+
mkdirSync(base);
|
|
50
|
+
const real = join(dir, 'sites-available.conf');
|
|
51
|
+
const link = join(dir, 'sites-enabled.conf');
|
|
52
|
+
writeFileSync(real, 'root /var/www/html;\nindex index.html;\n');
|
|
53
|
+
symlinkSync(real, link);
|
|
54
|
+
|
|
55
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
56
|
+
base_path: base,
|
|
57
|
+
patch: `*** Begin Patch
|
|
58
|
+
*** Update File: ${asHeaderPath(link)}
|
|
59
|
+
@@
|
|
60
|
+
-root /var/www/html;
|
|
61
|
+
+root /usr/share/novnc;
|
|
62
|
+
index index.html;
|
|
63
|
+
*** End Patch
|
|
64
|
+
`,
|
|
65
|
+
}, base, {}));
|
|
66
|
+
|
|
67
|
+
assert.doesNotMatch(result, /^Error/);
|
|
68
|
+
assert.doesNotMatch(result, /changed on disk/);
|
|
69
|
+
assert.equal(readFileSync(real, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
|
|
70
|
+
assert.equal(readFileSync(link, 'utf8'), 'root /usr/share/novnc;\nindex index.html;\n');
|
|
71
|
+
// The link is still a link: the write landed on its target, not on top of it.
|
|
72
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an in-base symlinked target is patched through the link', async (t) => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-patch-symlink-'));
|
|
77
|
+
t.after(() => {
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
void closeNativePatchServerForTests?.();
|
|
80
|
+
});
|
|
81
|
+
if (!symlinkSupported(dir)) {
|
|
82
|
+
t.skip('symlink creation is not permitted on this host');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Inside base_path the native engine would normally take this entry; a
|
|
86
|
+
// symlinked target has to be routed to the JS writer instead, because the
|
|
87
|
+
// engine renames its output over the path it was handed.
|
|
88
|
+
const real = join(dir, 'real.conf');
|
|
89
|
+
const link = join(dir, 'link.conf');
|
|
90
|
+
writeFileSync(real, 'keep\nold\n');
|
|
91
|
+
symlinkSync(real, link);
|
|
92
|
+
|
|
93
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
94
|
+
base_path: dir,
|
|
95
|
+
patch: `*** Begin Patch
|
|
96
|
+
*** Update File: link.conf
|
|
97
|
+
@@
|
|
98
|
+
keep
|
|
99
|
+
-old
|
|
100
|
+
+new
|
|
101
|
+
*** End Patch
|
|
102
|
+
`,
|
|
103
|
+
}, dir, {}));
|
|
104
|
+
|
|
105
|
+
assert.doesNotMatch(result, /^Error/);
|
|
106
|
+
assert.equal(readFileSync(real, 'utf8'), 'keep\nnew\n');
|
|
107
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a second patch through the same link still sees a fresh snapshot', async (t) => {
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), 'mixdog-patch-symlink-'));
|
|
112
|
+
t.after(() => {
|
|
113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
void closeNativePatchServerForTests?.();
|
|
115
|
+
});
|
|
116
|
+
if (!symlinkSupported(dir)) {
|
|
117
|
+
t.skip('symlink creation is not permitted on this host');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const base = join(dir, 'app');
|
|
121
|
+
mkdirSync(base);
|
|
122
|
+
const real = join(dir, 'real.conf');
|
|
123
|
+
const link = join(dir, 'link.conf');
|
|
124
|
+
writeFileSync(real, 'a\nb\n');
|
|
125
|
+
symlinkSync(real, link);
|
|
126
|
+
|
|
127
|
+
const patch = (from, to) => `*** Begin Patch
|
|
128
|
+
*** Update File: ${asHeaderPath(link)}
|
|
129
|
+
@@
|
|
130
|
+
-${from}
|
|
131
|
+
+${to}
|
|
132
|
+
b
|
|
133
|
+
*** End Patch
|
|
134
|
+
`;
|
|
135
|
+
const first = String(await executePatchTool('apply_patch', { base_path: base, patch: patch('a', 'A') }, base, {}));
|
|
136
|
+
assert.doesNotMatch(first, /^Error/);
|
|
137
|
+
const second = String(await executePatchTool('apply_patch', { base_path: base, patch: patch('A', 'AA') }, base, {}));
|
|
138
|
+
assert.doesNotMatch(second, /^Error/);
|
|
139
|
+
assert.equal(readFileSync(real, 'utf8'), 'AA\nb\n');
|
|
140
|
+
assert.equal(lstatSync(link).isSymbolicLink(), true);
|
|
141
|
+
});
|
|
@@ -598,9 +598,13 @@ export function validateV4ARenameSection(section, basePath, seenDestKeys) {
|
|
|
598
598
|
return `apply_patch: V4A rename destination unreadable: ${normalizeOutputPath(section.movePath)} (${err?.code || err?.message || String(err)})`;
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
601
|
+
// A rename with no hunks is a PURE MOVE — `git mv` in V4A form — and its
|
|
602
|
+
// outcome is fully specified by the two paths: the file arrives at the
|
|
603
|
+
// destination byte-identical and leaves the source. Demanding an edit made
|
|
604
|
+
// the one unambiguous shape the only rejected one (measured: a Makefile.config
|
|
605
|
+
// move cost a turn and a fallback shell `mv`). The hunk applier already
|
|
606
|
+
// treats an empty hunk list as "no change", so the move runs through the
|
|
607
|
+
// same guarded write/unlink path as any other rename.
|
|
604
608
|
return null;
|
|
605
609
|
}
|
|
606
610
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// `*** Update File: X` + `*** Move to: Y` with no hunks is a pure move — the
|
|
2
|
+
// V4A spelling of `git mv` — and its outcome is fully specified by the two
|
|
3
|
+
// paths.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { executePatchTool } from '../patch.mjs';
|
|
11
|
+
import { closeNativePatchServerForTests } from './native-server.mjs';
|
|
12
|
+
|
|
13
|
+
function makeDir() {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), 'mixdog-v4a-move-'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('a hunkless rename moves the file byte-identically', async (t) => {
|
|
18
|
+
const dir = makeDir();
|
|
19
|
+
t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
|
|
20
|
+
const before = 'CPU_ONLY := 1\r\nBLAS := atlas\n';
|
|
21
|
+
writeFileSync(join(dir, 'Makefile.config'), before);
|
|
22
|
+
mkdirSync(join(dir, 'caffe'), { recursive: true });
|
|
23
|
+
|
|
24
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
25
|
+
base_path: dir,
|
|
26
|
+
patch: `*** Begin Patch
|
|
27
|
+
*** Update File: Makefile.config
|
|
28
|
+
*** Move to: caffe/Makefile.config
|
|
29
|
+
*** End Patch
|
|
30
|
+
`,
|
|
31
|
+
}, dir, {}));
|
|
32
|
+
|
|
33
|
+
assert.doesNotMatch(result, /no update hunks/);
|
|
34
|
+
assert.doesNotMatch(result, /^Error/);
|
|
35
|
+
assert.equal(existsSync(join(dir, 'Makefile.config')), false);
|
|
36
|
+
assert.equal(readFileSync(join(dir, 'caffe', 'Makefile.config'), 'utf8'), before);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a move onto an existing file is still refused', async (t) => {
|
|
40
|
+
const dir = makeDir();
|
|
41
|
+
t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
|
|
42
|
+
writeFileSync(join(dir, 'a.txt'), 'source\n');
|
|
43
|
+
writeFileSync(join(dir, 'b.txt'), 'occupied\n');
|
|
44
|
+
|
|
45
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
46
|
+
base_path: dir,
|
|
47
|
+
patch: `*** Begin Patch
|
|
48
|
+
*** Update File: a.txt
|
|
49
|
+
*** Move to: b.txt
|
|
50
|
+
*** End Patch
|
|
51
|
+
`,
|
|
52
|
+
}, dir, {}));
|
|
53
|
+
|
|
54
|
+
assert.match(result, /destination already exists/);
|
|
55
|
+
assert.equal(readFileSync(join(dir, 'a.txt'), 'utf8'), 'source\n');
|
|
56
|
+
assert.equal(readFileSync(join(dir, 'b.txt'), 'utf8'), 'occupied\n');
|
|
57
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A whole-file rewrite written as "Delete File + Add File" for ONE path has a
|
|
2
|
+
// single possible outcome, so it applies instead of being rejected as a
|
|
3
|
+
// conflicting target. Genuinely ambiguous pairs keep rejecting.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { executePatchTool } from '../patch.mjs';
|
|
11
|
+
import { closeNativePatchServerForTests } from './native-server.mjs';
|
|
12
|
+
|
|
13
|
+
function makeDir() {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), 'mixdog-v4a-coalesce-'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('Delete + Add on one existing path rewrites the file', async (t) => {
|
|
18
|
+
const dir = makeDir();
|
|
19
|
+
t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
|
|
20
|
+
const file = join(dir, 'algo.py');
|
|
21
|
+
writeFileSync(file, 'def map(grid):\n return grid\n');
|
|
22
|
+
|
|
23
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
24
|
+
base_path: dir,
|
|
25
|
+
patch: `*** Begin Patch
|
|
26
|
+
*** Delete File: algo.py
|
|
27
|
+
*** Add File: algo.py
|
|
28
|
+
+def map(grid):
|
|
29
|
+
+ return [row[::-1] for row in grid]
|
|
30
|
+
*** End Patch
|
|
31
|
+
`,
|
|
32
|
+
}, dir, {}));
|
|
33
|
+
|
|
34
|
+
assert.doesNotMatch(result, /conflicting operations target/);
|
|
35
|
+
assert.doesNotMatch(result, /^Error/);
|
|
36
|
+
assert.equal(
|
|
37
|
+
readFileSync(file, 'utf8'),
|
|
38
|
+
'def map(grid):\n return [row[::-1] for row in grid]\n',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('Delete + Add for a path that does not exist still creates the file', async (t) => {
|
|
43
|
+
const dir = makeDir();
|
|
44
|
+
t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
|
|
45
|
+
const file = join(dir, 'fresh.txt');
|
|
46
|
+
|
|
47
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
48
|
+
base_path: dir,
|
|
49
|
+
patch: `*** Begin Patch
|
|
50
|
+
*** Delete File: fresh.txt
|
|
51
|
+
*** Add File: fresh.txt
|
|
52
|
+
+created
|
|
53
|
+
*** End Patch
|
|
54
|
+
`,
|
|
55
|
+
}, dir, {}));
|
|
56
|
+
|
|
57
|
+
assert.doesNotMatch(result, /^Error/);
|
|
58
|
+
assert.equal(readFileSync(file, 'utf8'), 'created\n');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Update + Delete on one path is still refused without touching the file', async (t) => {
|
|
62
|
+
const dir = makeDir();
|
|
63
|
+
t.after(() => { rmSync(dir, { recursive: true, force: true }); void closeNativePatchServerForTests?.(); });
|
|
64
|
+
const file = join(dir, 'target.txt');
|
|
65
|
+
writeFileSync(file, 'aleph\nbravo\ndelta\n');
|
|
66
|
+
|
|
67
|
+
const result = String(await executePatchTool('apply_patch', {
|
|
68
|
+
base_path: dir,
|
|
69
|
+
patch: `*** Begin Patch
|
|
70
|
+
*** Update File: target.txt
|
|
71
|
+
@@
|
|
72
|
+
-bravo
|
|
73
|
+
+temporary
|
|
74
|
+
*** Delete File: target.txt
|
|
75
|
+
*** End Patch
|
|
76
|
+
`,
|
|
77
|
+
}, dir, {}));
|
|
78
|
+
|
|
79
|
+
assert.match(result, /conflicting operations target/);
|
|
80
|
+
assert.equal(readFileSync(file, 'utf8'), 'aleph\nbravo\ndelta\n');
|
|
81
|
+
});
|
|
@@ -821,28 +821,42 @@ export function execShellCommand({
|
|
|
821
821
|
: (backgroundDeadlineMs > 0
|
|
822
822
|
? Math.max(1, backgroundDeadlineMs - elapsedMs)
|
|
823
823
|
: 0);
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
824
|
+
let promotionFailure = '';
|
|
825
|
+
const _tryPromote = async () => {
|
|
826
|
+
try {
|
|
827
|
+
const promoted = await promoteForegroundShellJob({
|
|
828
|
+
command,
|
|
829
|
+
cwd,
|
|
830
|
+
pid: child.pid,
|
|
831
|
+
jobId: _foregroundRecordId,
|
|
832
|
+
timeoutMs: remainingBackgroundTimeoutMs,
|
|
833
|
+
// Carry the command's own start moment into the job: promotion time
|
|
834
|
+
// and a standby's process-creation time are both wrong.
|
|
835
|
+
startedAtMs: _commandStartedAtMs,
|
|
836
|
+
mergeStderr: false,
|
|
837
|
+
stdoutPath,
|
|
838
|
+
stderrPath,
|
|
839
|
+
// Stamp the promoted job with the dispatching terminal's claude.exe
|
|
840
|
+
// pid so the statusline scopes it to the owning session.
|
|
841
|
+
clientHostPid,
|
|
842
|
+
// …and with the dispatching SESSION, so a pooled host (desktop) can
|
|
843
|
+
// show the job on its own pane only.
|
|
844
|
+
ownerSessionId,
|
|
845
|
+
});
|
|
846
|
+
if (!promoted) promotionFailure = promotionFailure || 'promotion unavailable';
|
|
847
|
+
return promoted;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
promotionFailure = err?.message || String(err);
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
job = await _tryPromote();
|
|
854
|
+
// One bounded retry: a promotion that failed on a transient registry/IPC
|
|
855
|
+
// race heals within a beat, while a capability gap fails identically and
|
|
856
|
+
// falls through to the kill path with its reason attached below.
|
|
857
|
+
if (!job && !settled && child.exitCode == null && child.signalCode == null) {
|
|
858
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
859
|
+
job = await _tryPromote();
|
|
846
860
|
}
|
|
847
861
|
// Adoption failed AFTER the foreground timers/size-watchdog were already
|
|
848
862
|
// torn down. Do NOT resolve as backgrounded — that would leave the child
|
|
@@ -856,7 +870,12 @@ export function execShellCommand({
|
|
|
856
870
|
timedOut = true;
|
|
857
871
|
_treeKillForceSettle('timeout');
|
|
858
872
|
} else {
|
|
859
|
-
|
|
873
|
+
// Keep the stable cause tag first (trace classifiers match on it)
|
|
874
|
+
// and attach the captured reason so the next occurrence is
|
|
875
|
+
// diagnosable from the transcript alone.
|
|
876
|
+
_treeKillForceSettle(promotionFailure
|
|
877
|
+
? `background-promotion-failed (${promotionFailure.slice(0, 120)})`
|
|
878
|
+
: 'background-promotion-failed');
|
|
860
879
|
}
|
|
861
880
|
return;
|
|
862
881
|
}
|
|
@@ -60,7 +60,13 @@ export function renditionSpec(variant) {
|
|
|
60
60
|
|
|
61
61
|
let sharpPromise;
|
|
62
62
|
async function loadSharp() {
|
|
63
|
-
sharpPromise ??= import('sharp').then((mod) =>
|
|
63
|
+
sharpPromise ??= import('sharp').then((mod) => {
|
|
64
|
+
const sharp = mod?.default || mod || null;
|
|
65
|
+
// Renditions are cached on disk; libvips' in-memory operation cache
|
|
66
|
+
// (default ~50MB per process) would only duplicate them in native memory.
|
|
67
|
+
try { sharp?.cache(false); } catch { /* cache stays default */ }
|
|
68
|
+
return sharp;
|
|
69
|
+
}, () => null);
|
|
64
70
|
return sharpPromise;
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -691,10 +691,8 @@ export async function stop() {
|
|
|
691
691
|
// closeDatabase() only ends the client pool; without this the child
|
|
692
692
|
// postmaster keeps running after the unified daemon exits.
|
|
693
693
|
if (!memorySecondaryMode()) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
await stopPgForShutdown()
|
|
697
|
-
} catch {}
|
|
694
|
+
const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
|
|
695
|
+
await stopPgForShutdown()
|
|
698
696
|
} else {
|
|
699
697
|
__mixdogMemoryLog('[memory-service] secondary mode; leaving shared PG running\n')
|
|
700
698
|
}
|