mixdog 0.9.30 → 0.9.31

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.31",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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
+ });
@@ -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
@@ -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;