mixdog 0.9.150 → 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 -11
- package/package.json +1 -1
- package/src/headless-exec.mjs +16 -3
- package/src/headless-exec.test.mjs +44 -1
- package/src/rules/shared/30-exploration.md +2 -1
- 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 +8 -20
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- 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 +32 -16
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -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-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
- 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-tool.mjs +26 -6
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -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/write-symlink.test.mjs +153 -0
- 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/graph-manifest.json +11 -11
- 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/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 +7 -6
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +5 -2
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/tui/session/turn.mjs +12 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
}
|
|
@@ -444,7 +444,7 @@ export async function stopPg({ runtimeDir, pgdataDir }) {
|
|
|
444
444
|
})
|
|
445
445
|
|
|
446
446
|
if (r.status !== 0) {
|
|
447
|
-
const msg = r.stderr?.toString() || r.stdout?.toString() || ''
|
|
447
|
+
const msg = r.error?.message || r.stderr?.toString() || r.stdout?.toString() || ''
|
|
448
448
|
// Stale postmaster.pid — PG is already down; clean up and continue.
|
|
449
449
|
if (
|
|
450
450
|
msg.includes('no server running') ||
|
|
@@ -453,10 +453,16 @@ export async function stopPg({ runtimeDir, pgdataDir }) {
|
|
|
453
453
|
) {
|
|
454
454
|
__mixdogMemoryLog(`[pg-process] stopPg: already stopped (${msg.slice(0, 80)})\n`)
|
|
455
455
|
try { rmSync(join(pgdataDir, 'postmaster.pid'), { force: true }) } catch {}
|
|
456
|
+
return
|
|
456
457
|
} else {
|
|
457
458
|
__mixdogMemoryLog(`[pg-process] pg_ctl stop warning: ${msg}\n`)
|
|
459
|
+
throw new Error(`pg_ctl stop failed${msg ? `: ${msg.trim()}` : ''}`)
|
|
458
460
|
}
|
|
459
461
|
}
|
|
462
|
+
const remaining = readPostmasterInfo(pgdataDir)
|
|
463
|
+
if (remaining.pid && isPidAlive(remaining.pid)) {
|
|
464
|
+
throw new Error(`postgres postmaster pid=${remaining.pid} survived pg_ctl stop`)
|
|
465
|
+
}
|
|
460
466
|
}
|
|
461
467
|
|
|
462
468
|
// Synchronous best-effort variant for a process 'exit' hook (only sync work is
|
|
@@ -494,6 +500,7 @@ export function classifyOrphanTempPostmaster({
|
|
|
494
500
|
args,
|
|
495
501
|
uptimeSec,
|
|
496
502
|
tempRoot,
|
|
503
|
+
ownerAlive = false,
|
|
497
504
|
minUptimeSec = ORPHAN_TEMP_PG_MIN_UPTIME_SEC,
|
|
498
505
|
}) {
|
|
499
506
|
const commandLine = String(args || '')
|
|
@@ -506,9 +513,24 @@ export function classifyOrphanTempPostmaster({
|
|
|
506
513
|
const root = normalizePathForMatch(tempRoot).replace(/\/+$/, '')
|
|
507
514
|
if (!root || !dataDir.startsWith(`${root}/`)) return false
|
|
508
515
|
if (!dataDir.includes('mixdog')) return false
|
|
516
|
+
if (ownerAlive) return false
|
|
509
517
|
return Number(uptimeSec) >= Math.max(60, Number(minUptimeSec) || ORPHAN_TEMP_PG_MIN_UPTIME_SEC)
|
|
510
518
|
}
|
|
511
519
|
|
|
520
|
+
function tempPostmasterOwnerAlive(args) {
|
|
521
|
+
const commandLine = String(args || '')
|
|
522
|
+
const dataDirMatch = commandLine.match(/(?:^|\s)-D\s+"?([^"]+?)"?(?:\s|$)/)
|
|
523
|
+
if (!dataDirMatch) return false
|
|
524
|
+
const dataRoot = join(dataDirMatch[1], '..')
|
|
525
|
+
for (const name of ['memory-runtime-owner.json', 'daemon-owner.json']) {
|
|
526
|
+
try {
|
|
527
|
+
const owner = JSON.parse(readFileSync(join(dataRoot, name), 'utf8'))
|
|
528
|
+
if (Number(owner?.pid) > 0 && isPidAlive(Number(owner.pid))) return true
|
|
529
|
+
} catch {}
|
|
530
|
+
}
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
|
|
512
534
|
function listPostmasterRows() {
|
|
513
535
|
if (process.platform === 'win32') {
|
|
514
536
|
const result = spawnSync('powershell', ['-NoProfile', '-Command',
|
|
@@ -545,7 +567,12 @@ export function sweepOrphanTempPostmasters({ tempRoot, minUptimeSec } = {}) {
|
|
|
545
567
|
const root = tempRoot || os.tmpdir()
|
|
546
568
|
for (const row of listPostmasterRows()) {
|
|
547
569
|
if (!row.pid || row.pid === process.pid) continue
|
|
548
|
-
if (!classifyOrphanTempPostmaster({
|
|
570
|
+
if (!classifyOrphanTempPostmaster({
|
|
571
|
+
...row,
|
|
572
|
+
tempRoot: root,
|
|
573
|
+
ownerAlive: tempPostmasterOwnerAlive(row.args),
|
|
574
|
+
minUptimeSec,
|
|
575
|
+
})) continue
|
|
549
576
|
try {
|
|
550
577
|
process.kill(row.pid)
|
|
551
578
|
reaped += 1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { classifyOrphanTempPostmaster } from './process.mjs';
|
|
5
|
+
|
|
6
|
+
const tempRoot = process.platform === 'win32'
|
|
7
|
+
? 'C:/Users/test/AppData/Local/Temp'
|
|
8
|
+
: '/tmp';
|
|
9
|
+
const args = `"${tempRoot}/mixdog-headless-pristine-test/data/runtime/bin/postgres" `
|
|
10
|
+
+ `-D "${tempRoot}/mixdog-headless-pristine-test/data/pgdata"`;
|
|
11
|
+
|
|
12
|
+
test('old unowned temp postmasters are sweep candidates', () => {
|
|
13
|
+
assert.equal(classifyOrphanTempPostmaster({
|
|
14
|
+
args,
|
|
15
|
+
uptimeSec: 3600,
|
|
16
|
+
tempRoot,
|
|
17
|
+
ownerAlive: false,
|
|
18
|
+
}), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a live owner protects a long-running temp postmaster', () => {
|
|
22
|
+
assert.equal(classifyOrphanTempPostmaster({
|
|
23
|
+
args,
|
|
24
|
+
uptimeSec: 3600,
|
|
25
|
+
tempRoot,
|
|
26
|
+
ownerAlive: true,
|
|
27
|
+
}), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the official data directory is never a sweep candidate', () => {
|
|
31
|
+
assert.equal(classifyOrphanTempPostmaster({
|
|
32
|
+
args: 'postgres -D "/home/test/.mixdog/data/pgdata"',
|
|
33
|
+
uptimeSec: 3600,
|
|
34
|
+
tempRoot,
|
|
35
|
+
}), false);
|
|
36
|
+
});
|
|
@@ -458,6 +458,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
|
|
|
458
458
|
// patchActiveInstance) so a concurrent restart/owner change is not
|
|
459
459
|
// clobbered by this reuse-time snapshot.
|
|
460
460
|
_restampReuseOwner({ pgdata, port: existingPort });
|
|
461
|
+
scheduleOrphanTempPostmasterSweep();
|
|
461
462
|
return { host: '127.0.0.1', port: existingPort, runtimeDir: existingRtDir, pgdataDir: pgdata };
|
|
462
463
|
}
|
|
463
464
|
} catch {}
|
|
@@ -475,6 +476,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
|
|
|
475
476
|
pg_pgdata: pgdata,
|
|
476
477
|
pg_runtime_dir: runtimeDir,
|
|
477
478
|
}, { timeoutMs: 1000, background: true });
|
|
479
|
+
scheduleOrphanTempPostmasterSweep();
|
|
478
480
|
return { host: '127.0.0.1', port: pm.port, runtimeDir, pgdataDir: pgdata };
|
|
479
481
|
}
|
|
480
482
|
} catch {}
|
|
@@ -674,6 +676,7 @@ export async function stopPgForShutdown() {
|
|
|
674
676
|
__mixdogMemoryLog(`[supervisor-pg] PG stopped (via active-instance.json) on shutdown\n`);
|
|
675
677
|
} catch (e) {
|
|
676
678
|
__mixdogMemoryLog(`[supervisor-pg] stopPg error on shutdown (no _live): ${e?.message}\n`);
|
|
679
|
+
throw e;
|
|
677
680
|
}
|
|
678
681
|
// Bounded sync clear (<=4s: 2s + one 2s retry). A residual stale pg_port is
|
|
679
682
|
// tolerated — readers healthcheck-verify the port before reuse.
|
|
@@ -681,14 +684,15 @@ export async function stopPgForShutdown() {
|
|
|
681
684
|
return;
|
|
682
685
|
}
|
|
683
686
|
const snap = _live;
|
|
684
|
-
_live = null;
|
|
685
687
|
_ensureInFlight = null;
|
|
686
688
|
try {
|
|
687
689
|
const { stopPg } = await _getPgProc();
|
|
688
690
|
await stopPg({ runtimeDir: snap.runtimeDir, pgdataDir: snap.pgdata });
|
|
691
|
+
_live = null;
|
|
689
692
|
__mixdogMemoryLog(`[supervisor-pg] PG stopped gracefully on shutdown\n`);
|
|
690
693
|
} catch (e) {
|
|
691
694
|
__mixdogMemoryLog(`[supervisor-pg] stopPg error on shutdown: ${e?.message}\n`);
|
|
695
|
+
throw e;
|
|
692
696
|
}
|
|
693
697
|
// Bounded sync clear (<=4s: 2s + one 2s retry). A residual stale pg_port is
|
|
694
698
|
// tolerated — readers healthcheck-verify the port before reuse.
|
|
@@ -136,7 +136,7 @@ export function createQueryHandlers({
|
|
|
136
136
|
// Over-fetch before compact-only dedupe so duplicated legacy rows cannot
|
|
137
137
|
// consume the requested page and hide distinct older context.
|
|
138
138
|
const fetchLimit = compactHandoff
|
|
139
|
-
?
|
|
139
|
+
? null
|
|
140
140
|
: compactDigest
|
|
141
141
|
? Math.min(100, Math.max(limit, limit * 4))
|
|
142
142
|
: limit
|
|
@@ -189,16 +189,17 @@ export function createQueryHandlers({
|
|
|
189
189
|
})
|
|
190
190
|
where.push(`(${clauses.join(' OR ')})`)
|
|
191
191
|
}
|
|
192
|
-
params.push(fetchLimit)
|
|
192
|
+
if (fetchLimit != null) params.push(fetchLimit)
|
|
193
|
+
const limitClause = fetchLimit == null ? '' : `LIMIT $${params.length}`
|
|
193
194
|
let rows = (await db.query(`
|
|
194
195
|
SELECT id, ts, role, content, source_ref, session_id, source_turn, time_source, chunk_root, is_root,
|
|
195
196
|
element, category, summary, status, score, last_seen_at, project_id
|
|
196
197
|
FROM entries
|
|
197
198
|
WHERE ${where.join(' AND ')}
|
|
198
199
|
ORDER BY ts DESC, source_turn DESC NULLS LAST, id DESC
|
|
199
|
-
|
|
200
|
+
${limitClause}
|
|
200
201
|
`, params)).rows
|
|
201
|
-
if (rows.length < fetchLimit) {
|
|
202
|
+
if (fetchLimit != null && rows.length < fetchLimit) {
|
|
202
203
|
const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
|
|
203
204
|
const fillLimit = Math.max(0, fetchLimit - rows.length)
|
|
204
205
|
const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
|
|
@@ -220,7 +221,7 @@ export function createQueryHandlers({
|
|
|
220
221
|
: []
|
|
221
222
|
if (fillRows.length > 0) rows = [...rows, ...fillRows]
|
|
222
223
|
}
|
|
223
|
-
if (args.includeMembers === true
|
|
224
|
+
if (args.includeMembers === true) {
|
|
224
225
|
const rootIds = rows
|
|
225
226
|
.filter((row) => Number(row.is_root) === 1)
|
|
226
227
|
.map((row) => Number(row.id))
|
|
@@ -243,7 +244,7 @@ export function createQueryHandlers({
|
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
|
-
if (compactHandoff) rows = compactHandoffRows(rows
|
|
247
|
+
if (compactHandoff) rows = compactHandoffRows(rows)
|
|
247
248
|
else if (compactDigest) rows = compactDigestRows(rows, limit)
|
|
248
249
|
return { text: renderEntryLines(rows, { pendingMarks: !compactDigest && !compactHandoff }) }
|
|
249
250
|
}
|
|
@@ -399,16 +399,21 @@ export function compactDigestRows(rows, limit = 30) {
|
|
|
399
399
|
.slice(0, cap)
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
export function compactHandoffRows(rows
|
|
403
|
-
const
|
|
404
|
-
const deduped = collapseNearDuplicateRows(collapseExactDuplicateRows(Array.isArray(rows) ? rows : []))
|
|
405
|
-
.filter((row) => !row?._dupStub)
|
|
402
|
+
export function compactHandoffRows(rows) {
|
|
403
|
+
const deduped = Array.isArray(rows) ? rows : []
|
|
406
404
|
const roots = []
|
|
407
405
|
const raw = []
|
|
408
406
|
for (const row of deduped) {
|
|
409
407
|
if (Number(row?.is_root) === 1) {
|
|
410
|
-
|
|
411
|
-
|
|
408
|
+
if (String(row?.summary ?? '').trim()) {
|
|
409
|
+
const { members: _members, ...summaryRow } = row
|
|
410
|
+
roots.push(summaryRow)
|
|
411
|
+
} else if (Array.isArray(row?.members) && row.members.length > 0) {
|
|
412
|
+
raw.push(...row.members)
|
|
413
|
+
} else {
|
|
414
|
+
const { members: _members, ...rawRoot } = row
|
|
415
|
+
raw.push(rawRoot)
|
|
416
|
+
}
|
|
412
417
|
} else if (row?.chunk_root == null || Number(row.chunk_root) === Number(row.id)) {
|
|
413
418
|
raw.push(row)
|
|
414
419
|
}
|
|
@@ -419,32 +424,9 @@ export function compactHandoffRows(rows, limit = 100) {
|
|
|
419
424
|
|| (Number(a?.ts) || 0) - (Number(b?.ts) || 0)
|
|
420
425
|
|| (Number(a?.id) || 0) - (Number(b?.id) || 0)
|
|
421
426
|
))
|
|
422
|
-
const
|
|
423
|
-
let userRow = null
|
|
424
|
-
let assistantRow = null
|
|
425
|
-
let fallbackRow = null
|
|
426
|
-
const flushEvent = () => {
|
|
427
|
-
if (assistantRow || fallbackRow) endpoints.push(assistantRow || fallbackRow)
|
|
428
|
-
if (userRow) endpoints.push(userRow)
|
|
429
|
-
userRow = null
|
|
430
|
-
assistantRow = null
|
|
431
|
-
fallbackRow = null
|
|
432
|
-
}
|
|
433
|
-
for (const row of raw) {
|
|
434
|
-
if (row?.role === 'user') {
|
|
435
|
-
flushEvent()
|
|
436
|
-
userRow = row
|
|
437
|
-
} else if (row?.role === 'assistant') {
|
|
438
|
-
assistantRow = row
|
|
439
|
-
} else {
|
|
440
|
-
fallbackRow = row
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
flushEvent()
|
|
444
|
-
|
|
445
|
-
const selected = [...roots, ...endpoints]
|
|
427
|
+
const selected = [...roots, ...raw]
|
|
446
428
|
selected.sort(compareRecallNewestFirst)
|
|
447
|
-
return
|
|
429
|
+
return selected
|
|
448
430
|
}
|
|
449
431
|
|
|
450
432
|
// Compact session label for group headers: keep short ids verbatim, shorten
|