mixdog 0.9.30 → 0.9.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -28,6 +28,15 @@ test('grep head_limit/offset/-C as numeric strings coerce', () => {
28
28
  assert.equal(a['-C'], 2);
29
29
  });
30
30
 
31
+ test('grep empty context strings are treated as omitted', () => {
32
+ const a = { pattern: 'x', output_mode: 'content_with_context', '-A': '', '-B': '', '-C': '', context: '' };
33
+ assert.equal(validateBuiltinArgs('grep', a), null);
34
+ assert.equal(Object.prototype.hasOwnProperty.call(a, '-A'), false);
35
+ assert.equal(Object.prototype.hasOwnProperty.call(a, '-B'), false);
36
+ assert.equal(Object.prototype.hasOwnProperty.call(a, '-C'), false);
37
+ assert.equal(Object.prototype.hasOwnProperty.call(a, 'context'), false);
38
+ });
39
+
31
40
  test('list/find/glob head_limit as numeric string coerces', () => {
32
41
  const l = { path: '.', head_limit: '5' };
33
42
  assert.equal(validateBuiltinArgs('list', l), null);
@@ -0,0 +1,96 @@
1
+ // Regression for the execution-pending-resume kick (engine/agent-job-feed.mjs).
2
+ // Guards two coupled defects:
3
+ // A (body loss): parallel completions deferred while busy must each surface
4
+ // their model-visible body on resume — the old single string slot dropped
5
+ // all but the last.
6
+ // B (missed resume): a deferred kick left after a busy->false transition that
7
+ // did NOT go through drain-finally / normal-turn-end (watchdog force-release
8
+ // or stale-unwind) must still fire when flushDeferred is called.
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
13
+
14
+ // Minimal harness: a mutable busy flag, a pending queue, and a synchronous
15
+ // drain stub that "resumes" by surfacing every pending-resume entry's body.
16
+ function makeHarness() {
17
+ const pending = [];
18
+ const surfaced = [];
19
+ const state = { busy: false };
20
+ let draining = false;
21
+
22
+ function drain() {
23
+ if (draining) return Promise.resolve();
24
+ draining = true;
25
+ try {
26
+ for (let i = 0; i < pending.length;) {
27
+ const entry = pending[i];
28
+ if (entry.mode === 'pending-resume') {
29
+ surfaced.push(entry.text);
30
+ pending.splice(i, 1);
31
+ } else {
32
+ i += 1;
33
+ }
34
+ }
35
+ } finally {
36
+ draining = false;
37
+ }
38
+ return Promise.resolve();
39
+ }
40
+
41
+ const feed = createAgentJobFeed({
42
+ runtime: {},
43
+ getState: () => state,
44
+ set: () => {},
45
+ nextId: () => 'id',
46
+ getDisposed: () => false,
47
+ patchItem: () => {},
48
+ enqueue: () => {},
49
+ drain,
50
+ pushUserOrSyntheticItem: () => {},
51
+ makeQueueEntry: (text, opts = {}) => ({ text, mode: opts.mode, priority: opts.priority }),
52
+ getPending: () => pending,
53
+ agentStatusState: () => ({}),
54
+ displayedExecutionNotificationKeys: new Set(),
55
+ pushNotice: () => {},
56
+ });
57
+
58
+ return { feed, pending, surfaced, state };
59
+ }
60
+
61
+ const microtasks = () => new Promise((r) => setImmediate(r));
62
+
63
+ test('A: two completions deferred while busy both surface their bodies on resume', async () => {
64
+ const { feed, surfaced, state } = makeHarness();
65
+ state.busy = true;
66
+ feed.scheduleExecutionPendingResumeKick('body A');
67
+ feed.scheduleExecutionPendingResumeKick('body B');
68
+ await microtasks();
69
+ // Both kicks deferred while busy; nothing surfaced yet.
70
+ assert.deepEqual(surfaced, []);
71
+
72
+ // Turn settles normally -> busy false -> flush re-arms the kick.
73
+ state.busy = false;
74
+ feed.flushDeferredExecutionPendingResumeKick();
75
+ assert.equal(surfaced.length, 1, 'a single merged resume entry surfaced');
76
+ assert.match(surfaced[0], /body A/, 'first body preserved');
77
+ assert.match(surfaced[0], /body B/, 'second body preserved (not dropped)');
78
+ });
79
+
80
+ test('B: deferred kick after a non-drain busy->false transition still fires', async () => {
81
+ const { feed, surfaced, state } = makeHarness();
82
+ state.busy = true;
83
+ feed.scheduleExecutionPendingResumeKick('only body');
84
+ await microtasks();
85
+ assert.deepEqual(surfaced, [], 'deferred while busy');
86
+
87
+ // Simulate watchdog force-release / stale-unwind: busy flips to false WITHOUT
88
+ // drain-finally, then flushDeferred runs on that transition.
89
+ state.busy = false;
90
+ feed.flushDeferredExecutionPendingResumeKick();
91
+ assert.deepEqual(surfaced, ['only body'], 'resume fired on the non-drain transition');
92
+
93
+ // Idempotent: a second flush with nothing deferred is a no-op.
94
+ feed.flushDeferredExecutionPendingResumeKick();
95
+ assert.deepEqual(surfaced, ['only body']);
96
+ });
@@ -399,6 +399,10 @@ function guardGrep(a) {
399
399
  if (err) return err;
400
400
  }
401
401
  for (const k of ['-A', '-B', '-C', 'context']) {
402
+ if (hasOwn(a, k) && a[k] === '') {
403
+ delete a[k];
404
+ continue;
405
+ }
402
406
  const err = checkIntInRange(a, k, 0, GREP_CONTEXT_MAX, { clamp: true });
403
407
  if (err) return err;
404
408
  }
@@ -212,10 +212,10 @@ export async function addCore(dataDir, { element, summary, category }, projectId
212
212
  const sm = trimOrNull(summary) ?? el
213
213
  if (!el || !sm) throw new Error('add requires element and summary')
214
214
  if (el.length > CORE_ELEMENT_MAX) {
215
- throw new Error(`core element too long (${el.length} chars, max ${CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
215
+ throw new Error(`core element too long (${el.length}/${CORE_ELEMENT_MAX} chars, remove ${el.length - CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
216
216
  }
217
217
  if (sm.length > CORE_SUMMARY_MAX) {
218
- throw new Error(`core summary too long (${sm.length} chars, max ${CORE_SUMMARY_MAX}) — core memory must be 1 fact in 1-2 sentences; procedures, multi-step, or code belong in recap or docs. Compress and retry.`)
218
+ throw new Error(`core summary too long (${sm.length}/${CORE_SUMMARY_MAX} chars, remove ${sm.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
219
219
  }
220
220
  const cat = (trimOrNull(category) ?? 'fact').toLowerCase()
221
221
  if (!VALID_CAT.has(cat)) {
@@ -310,7 +310,7 @@ export async function editCore(dataDir, id, patch) {
310
310
  throw new Error('no change')
311
311
  }
312
312
  if (newSummary && newSummary.length > CORE_SUMMARY_MAX) {
313
- throw new Error(`core summary too long (${newSummary.length} chars, max ${CORE_SUMMARY_MAX}) — core memory must be 1 fact in 1-2 sentences; procedures, multi-step, or code belong in recap or docs. Compress and retry.`)
313
+ throw new Error(`core summary too long (${newSummary.length}/${CORE_SUMMARY_MAX} chars, remove ${newSummary.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
314
314
  }
315
315
  const now = Date.now()
316
316
  const textChanged = newElement !== cur.element || newSummary !== cur.summary
@@ -290,12 +290,15 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
290
290
  // so we return the instant pg_isready succeeds rather than waiting on pg_ctl.
291
291
  // Only the long-lived child handle is unref'd; poll timers stay ref'd.
292
292
  async function startAndWaitReady() {
293
- // detached on win32: give pg_ctl (and the postmaster it spawns) its own
294
- // process group / console so an ancestor `taskkill /F /T` cannot enumerate
295
- // it via the Node process tree. The orphaned postmaster survives and is
296
- // re-adopted next boot via supervisor tryReusePgInstance (postmaster.pid).
297
- const detached = process.platform === 'win32'
298
- const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached })
293
+ // NEVER set `detached` on win32: DETACHED_PROCESS makes the OS ignore
294
+ // `windowsHide`, so pg_ctl + the postmaster it launches allocate a VISIBLE
295
+ // console (see shared/spawn-flags.mjs). Detachment gave no real isolation
296
+ // here anyway: `pg_ctl start` daemonizes the postmaster and exits at once,
297
+ // so the postmaster is already reparented OUT of the Node process tree
298
+ // detached only isolated the short-lived pg_ctl. Shutdown/reuse target
299
+ // pgdata/postmaster.pid (pg_ctl stop -D, tryReusePgInstance), not a
300
+ // Node-tree taskkill, so `windowsHide` alone is correct on all platforms.
301
+ const child = spawn(pgctl, startArgs, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
299
302
  child.unref?.()
300
303
  let stdout = '', stderr = '', closed = false, exitCode = null
301
304
  child.stdout?.on('data', d => { stdout += d.toString() })
@@ -23060,7 +23060,9 @@ function createAgentJobFeed({
23060
23060
  pushNotice
23061
23061
  }) {
23062
23062
  let executionResumeKickDeferred = false;
23063
- function kickExecutionPendingResume() {
23063
+ const executionResumeKickBodies = [];
23064
+ function kickExecutionPendingResume(body = "") {
23065
+ if (body) executionResumeKickBodies.push(body);
23064
23066
  if (getDisposed()) return;
23065
23067
  if (getState().busy) {
23066
23068
  executionResumeKickDeferred = true;
@@ -23072,15 +23074,16 @@ function createAgentJobFeed({
23072
23074
  return;
23073
23075
  }
23074
23076
  executionResumeKickDeferred = false;
23075
- pending.push(makeQueueEntry("", { mode: "pending-resume", priority: "next" }));
23077
+ const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join("\n\n");
23078
+ pending.push(makeQueueEntry(resumeBody, { mode: "pending-resume", priority: "next" }));
23076
23079
  void drain();
23077
23080
  }
23078
23081
  function flushDeferredExecutionPendingResumeKick() {
23079
23082
  if (!executionResumeKickDeferred || getDisposed() || getState().busy) return;
23080
23083
  kickExecutionPendingResume();
23081
23084
  }
23082
- function scheduleExecutionPendingResumeKick() {
23083
- queueMicrotask(() => kickExecutionPendingResume());
23085
+ function scheduleExecutionPendingResumeKick(body = "") {
23086
+ queueMicrotask(() => kickExecutionPendingResume(body));
23084
23087
  }
23085
23088
  function updateAgentJobCard(itemId, text, isError = false) {
23086
23089
  const parsed = parseAgentJob(text);
@@ -23120,8 +23123,9 @@ function createAgentJobFeed({
23120
23123
  pushUserOrSyntheticItem(delivery.displayText, nextId2());
23121
23124
  }
23122
23125
  if (parsed?.taskId) set(agentStatusState({ force: true }));
23123
- if (String(delivery.modelContent || "").trim()) {
23124
- scheduleExecutionPendingResumeKick();
23126
+ const resumeBody = String(delivery.modelContent || "").trim();
23127
+ if (resumeBody) {
23128
+ scheduleExecutionPendingResumeKick(resumeBody);
23125
23129
  }
23126
23130
  return true;
23127
23131
  }
@@ -23994,6 +23998,7 @@ function createRunTurn(bag) {
23994
23998
  flags.activePromptRestore = null;
23995
23999
  if (flags.draining) flags.draining = false;
23996
24000
  if (pending.length > 0) void drain();
24001
+ flushDeferredExecutionPendingResumeKick();
23997
24002
  }, 5e3);
23998
24003
  watchdogGraceTimer.unref?.();
23999
24004
  }, LEAD_TURN_TIMEOUT_MS2);
@@ -24697,6 +24702,7 @@ function createRunTurn(bag) {
24697
24702
  if (isStaleUnwind) {
24698
24703
  if (closingItems.length) appendItemsBatch(closingItems);
24699
24704
  tuiDebug2(`runTurn STALE UNWIND turn=${turnIndex} \u2014 force-released; skipping shared-getState() writes`);
24705
+ flushDeferredExecutionPendingResumeKick();
24700
24706
  } else {
24701
24707
  if (!isNoOpTurn) {
24702
24708
  getState().stats.turns = (getState().stats.turns || 0) + 1;
@@ -56,7 +56,14 @@ export function createAgentJobFeed({
56
56
  }) {
57
57
  let executionResumeKickDeferred = false;
58
58
 
59
- function kickExecutionPendingResume() {
59
+ // FIFO accumulation of model-visible bodies from completions that arrived
60
+ // while busy (or while a pending-resume entry was already queued). A single
61
+ // string slot dropped all-but-the-last body when parallel completions landed;
62
+ // the queue preserves every body and merges them into the resume turn.
63
+ const executionResumeKickBodies = [];
64
+
65
+ function kickExecutionPendingResume(body = '') {
66
+ if (body) executionResumeKickBodies.push(body);
60
67
  if (getDisposed()) return;
61
68
  if (getState().busy) {
62
69
  executionResumeKickDeferred = true;
@@ -68,7 +75,10 @@ export function createAgentJobFeed({
68
75
  return;
69
76
  }
70
77
  executionResumeKickDeferred = false;
71
- pending.push(makeQueueEntry('', { mode: 'pending-resume', priority: 'next' }));
78
+ // Drain every accumulated body into ONE resume turn so no completion body
79
+ // is lost when several deferred while busy.
80
+ const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join('\n\n');
81
+ pending.push(makeQueueEntry(resumeBody, { mode: 'pending-resume', priority: 'next' }));
72
82
  void drain();
73
83
  }
74
84
 
@@ -77,10 +87,11 @@ export function createAgentJobFeed({
77
87
  kickExecutionPendingResume();
78
88
  }
79
89
 
80
- function scheduleExecutionPendingResumeKick() {
81
- // notifyFnForSession enqueues the model-visible body after onNotification
82
- // returns; defer the kick so askSession pre-drain sees session pending.
83
- queueMicrotask(() => kickExecutionPendingResume());
90
+ function scheduleExecutionPendingResumeKick(body = '') {
91
+ // Carry the model-visible body directly into the pending-resume entry so
92
+ // the resumed turn sends it, instead of relying on the session-pending
93
+ // completion marker (dropped by askSession pre-drain).
94
+ queueMicrotask(() => kickExecutionPendingResume(body));
84
95
  }
85
96
 
86
97
  function updateAgentJobCard(itemId, text, isError = false) {
@@ -122,8 +133,9 @@ export function createAgentJobFeed({
122
133
  pushUserOrSyntheticItem(delivery.displayText, nextId());
123
134
  }
124
135
  if (parsed?.taskId) set(agentStatusState({ force: true }));
125
- if (String(delivery.modelContent || '').trim()) {
126
- scheduleExecutionPendingResumeKick();
136
+ const resumeBody = String(delivery.modelContent || '').trim();
137
+ if (resumeBody) {
138
+ scheduleExecutionPendingResumeKick(resumeBody);
127
139
  }
128
140
  return true;
129
141
  }
@@ -84,6 +84,10 @@ export function createRunTurn(bag) {
84
84
  flags.activePromptRestore = null;
85
85
  if (flags.draining) flags.draining = false;
86
86
  if (pending.length > 0) void drain();
87
+ // busy→false here bypasses both the normal turn-end flush and the
88
+ // drain-finally flush, so a deferred completion kick would never re-arm.
89
+ // Fire it explicitly (idempotent: guarded by deferred flag + !busy).
90
+ flushDeferredExecutionPendingResumeKick();
87
91
  }, 5_000);
88
92
  watchdogGraceTimer.unref?.();
89
93
  }, LEAD_TURN_TIMEOUT_MS);
@@ -1048,6 +1052,10 @@ export function createRunTurn(bag) {
1048
1052
  // deferred cards (turn-local teardown) but writes no other shared getState().
1049
1053
  if (closingItems.length) appendItemsBatch(closingItems);
1050
1054
  tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
1055
+ // A stale unwind settles busy→false (via the watchdog release) without
1056
+ // the normal turn-end flush, so re-arm any deferred completion kick here
1057
+ // too (idempotent: no-ops unless a kick is deferred and nothing is busy).
1058
+ flushDeferredExecutionPendingResumeKick();
1051
1059
  } else {
1052
1060
  if (!isNoOpTurn) {
1053
1061
  getState().stats.turns = (getState().stats.turns || 0) + 1;