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.
Files changed (113) hide show
  1. package/README.md +28 -12
  2. package/package.json +1 -1
  3. package/src/headless-exec.mjs +40 -4
  4. package/src/headless-exec.test.mjs +116 -1
  5. package/src/rules/lead/01-general.md +3 -2
  6. package/src/rules/shared/00-general.md +1 -0
  7. package/src/rules/shared/10-tool-workflow.md +11 -12
  8. package/src/rules/shared/30-exploration.md +4 -3
  9. package/src/rules/shared/60-verification.md +4 -3
  10. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
  16. package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
  17. package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
  18. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
  19. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +50 -8
  22. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
  23. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
  24. package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
  25. package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
  26. package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
  28. package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
  34. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +50 -23
  35. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
  36. package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -0
  37. package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
  38. package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
  39. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +2 -1
  40. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
  42. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
  44. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
  45. package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
  46. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
  47. package/src/runtime/agent/orchestrator/tools/builtin/read-glob-survey.test.mjs +71 -0
  48. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
  50. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
  52. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
  54. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
  55. package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -1
  56. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
  57. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +49 -24
  59. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
  61. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
  62. package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
  63. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
  64. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +16 -0
  65. package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
  66. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  67. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
  68. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
  69. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
  70. package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
  71. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
  72. package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
  73. package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +42 -23
  75. package/src/runtime/media/renditions.mjs +7 -1
  76. package/src/runtime/memory/index.mjs +2 -4
  77. package/src/runtime/memory/lib/pg/process.mjs +29 -2
  78. package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
  79. package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
  80. package/src/runtime/memory/lib/query-handlers.mjs +13 -19
  81. package/src/runtime/memory/lib/recall-format.mjs +13 -31
  82. package/src/runtime/memory/tool-defs.mjs +2 -2
  83. package/src/runtime/shared/memory-snapshot.mjs +57 -4
  84. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  85. package/src/runtime/shared/pristine-execution.mjs +6 -3
  86. package/src/runtime/shared/tool-surface.mjs +6 -0
  87. package/src/runtime/shared/tool-surface.test.mjs +8 -0
  88. package/src/session-runtime/lifecycle-api.mjs +1 -0
  89. package/src/session-runtime/session-turn-api.mjs +5 -0
  90. package/src/standalone/agent-dispatch-broker.mjs +25 -80
  91. package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
  92. package/src/standalone/channel-worker.mjs +31 -1
  93. package/src/standalone/daemon.mjs +9 -9
  94. package/src/standalone/memory-runtime-proxy.mjs +5 -2
  95. package/src/standalone/session-client.mjs +23 -3
  96. package/src/standalone/session-runtime-host-health.test.mjs +36 -0
  97. package/src/standalone/session-runtime-host.mjs +26 -76
  98. package/src/standalone/session-runtime-worker.mjs +97 -0
  99. package/src/tui/App.jsx +3 -2
  100. package/src/tui/app/use-global-key-input.mjs +9 -0
  101. package/src/tui/app/use-mouse-input.mjs +77 -14
  102. package/src/tui/components/Spinner.jsx +1 -1
  103. package/src/tui/dist/index.mjs +83 -23
  104. package/src/tui/lib/voice-setup.mjs +3 -2
  105. package/src/tui/session/completion-card-restore.test.mjs +18 -0
  106. package/src/tui/session/session-api-ext.mjs +2 -1
  107. package/src/tui/session/session-api.mjs +8 -1
  108. package/src/tui/session/task-wait-submit.test.mjs +80 -0
  109. package/src/tui/session/turn.mjs +58 -6
  110. package/src/tui/spinner-verbs.mjs +1 -0
  111. package/src/workflows/default/WORKFLOW.md +12 -19
  112. package/src/workflows/headless/WORKFLOW.md +11 -0
  113. 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(fullPath, content, {
518
+ await atomicWrite(writePath, content, {
486
519
  sessionId: readStateScope,
487
- expectedTargetSnapshot: preMutationStat ? {
520
+ expectedTargetSnapshot: snapshotStat ? {
488
521
  exists: true,
489
- size: preMutationStat.size,
490
- mtimeMs: preMutationStat.mtimeMs,
491
- ctimeMs: preMutationStat.ctimeMs,
492
- ino: preMutationStat.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
- invalidateBuiltinResultCache([fullPath]);
502
- markCodeGraphDirtyPaths([fullPath]);
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
- 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
+ });
@@ -821,28 +821,42 @@ export function execShellCommand({
821
821
  : (backgroundDeadlineMs > 0
822
822
  ? Math.max(1, backgroundDeadlineMs - elapsedMs)
823
823
  : 0);
824
- try {
825
- job = await promoteForegroundShellJob({
826
- command,
827
- cwd,
828
- pid: child.pid,
829
- jobId: _foregroundRecordId,
830
- timeoutMs: remainingBackgroundTimeoutMs,
831
- // Carry the command's own start moment into the job: promotion time
832
- // and a standby's process-creation time are both wrong.
833
- startedAtMs: _commandStartedAtMs,
834
- mergeStderr: false,
835
- stdoutPath,
836
- stderrPath,
837
- // Stamp the promoted job with the dispatching terminal's claude.exe
838
- // pid so the statusline scopes it to the owning session.
839
- clientHostPid,
840
- // …and with the dispatching SESSION, so a pooled host (desktop) can
841
- // show the job on its own pane only.
842
- ownerSessionId,
843
- });
844
- } catch {
845
- job = null;
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
- _treeKillForceSettle('background-promotion-failed');
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) => mod?.default || mod || null, () => null);
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
- 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
  }