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.
Files changed (53) hide show
  1. package/README.md +28 -11
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +16 -3
  4. package/src/headless-exec.test.mjs +44 -1
  5. package/src/rules/shared/30-exploration.md +2 -1
  6. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  7. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  8. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  9. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
  11. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  12. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
  17. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  19. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  20. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  21. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
  22. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  23. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
  25. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
  26. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  31. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  32. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  36. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  39. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  40. package/src/runtime/memory/index.mjs +2 -4
  41. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  42. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  43. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  44. package/src/runtime/memory/lib/query-handlers.mjs +7 -6
  45. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  46. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  47. package/src/runtime/shared/pristine-execution.mjs +5 -2
  48. package/src/session-runtime/lifecycle-api.mjs +1 -0
  49. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  50. package/src/standalone/channel-worker.mjs +31 -1
  51. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  52. package/src/standalone/session-client.mjs +23 -3
  53. 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
- const needsJsCodec = (fullPath, kind) => !engineContractOk || codecNeedsJs(fullPath, kind);
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
- && !needsJsCodec(entry.fullPath, entry.kind),
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
- || needsJsCodec(entry.fullPath, entry.kind),
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
- && !needsJsCodec(parsedEntryResolvedPath(entry, basePath), classifyEntry(entry)),
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
- if (!section.hunks?.length) {
602
- return `apply_patch: V4A rename for ${normalizeOutputPath(section.path)} has no update hunks`;
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
- try {
695
- const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
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({ ...row, tempRoot: root, minUptimeSec })) continue
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
- ? Math.min(1000, Math.max(limit, limit * 10))
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
- LIMIT $${params.length}
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 && !compactHandoff) {
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, limit)
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, limit = 100) {
403
- const cap = Math.max(1, Math.floor(Number(limit) || 100))
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
- const { members: _members, ...summaryRow } = row
411
- roots.push(summaryRow)
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 endpoints = []
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 compactDigestRows(selected, cap)
429
+ return selected
448
430
  }
449
431
 
450
432
  // Compact session label for group headers: keep short ids verbatim, shorten