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 +1 -1
- package/scripts/arg-guard-test.mjs +9 -0
- package/scripts/execution-pending-resume-kick-test.mjs +96 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +4 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +3 -3
- package/src/runtime/memory/lib/pg/process.mjs +9 -6
- package/src/tui/dist/index.mjs +12 -6
- package/src/tui/engine/agent-job-feed.mjs +20 -8
- package/src/tui/engine/turn.mjs +8 -0
package/package.json
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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:
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
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() })
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -23060,7 +23060,9 @@ function createAgentJobFeed({
|
|
|
23060
23060
|
pushNotice
|
|
23061
23061
|
}) {
|
|
23062
23062
|
let executionResumeKickDeferred = false;
|
|
23063
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
-
|
|
126
|
-
|
|
136
|
+
const resumeBody = String(delivery.modelContent || '').trim();
|
|
137
|
+
if (resumeBody) {
|
|
138
|
+
scheduleExecutionPendingResumeKick(resumeBody);
|
|
127
139
|
}
|
|
128
140
|
return true;
|
|
129
141
|
}
|
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -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;
|