mixdog 0.9.29 → 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 +1 -1
- package/scripts/execution-pending-resume-kick-test.mjs +96 -0
- package/scripts/pending-completion-drop-test.mjs +64 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +71 -19
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +6 -61
- package/src/session-runtime/runtime-core.mjs +8 -2
- package/src/standalone/agent-tool/notify.mjs +5 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Proves deferred agent/tool *completion* notifications are DROPPED on drain
|
|
2
|
+
// (never replayed out-of-order on a later session resume) while genuine
|
|
3
|
+
// user/steering messages in the same queue survive with order preserved.
|
|
4
|
+
// Owner decision: out-of-order replay is worse than loss.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Redirect the pending-message spool to a throwaway dir before import so the
|
|
12
|
+
// real ~/.mixdog/data spool is never touched.
|
|
13
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixpend-'));
|
|
14
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
enqueuePendingMessage,
|
|
18
|
+
drainPendingMessages,
|
|
19
|
+
COMPLETION_NOTIFICATION_KIND,
|
|
20
|
+
markCompletionEntry,
|
|
21
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager/pending-messages.mjs');
|
|
22
|
+
|
|
23
|
+
function completionEntry(text) {
|
|
24
|
+
return { content: text, text, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('drain drops completion notifications, keeps user messages in order', async () => {
|
|
28
|
+
const sid = 'sess_drop_test_1';
|
|
29
|
+
enqueuePendingMessage(sid, 'user first');
|
|
30
|
+
enqueuePendingMessage(sid, completionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
|
|
31
|
+
enqueuePendingMessage(sid, 'user second');
|
|
32
|
+
|
|
33
|
+
// Let the buffered persist flush to disk so we also verify the on-disk marker
|
|
34
|
+
// (the shape a later resume would drain).
|
|
35
|
+
await new Promise((r) => setImmediate(r));
|
|
36
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
37
|
+
const store = JSON.parse(readFileSync(join(dataDir, 'session-pending-messages.json'), 'utf8'));
|
|
38
|
+
const persistedQueue = store.sessions[sid] || [];
|
|
39
|
+
const marked = persistedQueue.filter((e) => e && typeof e === 'object' && e.notificationKind === COMPLETION_NOTIFICATION_KIND);
|
|
40
|
+
assert.equal(marked.length, 1, 'completion notification persisted as a marked object');
|
|
41
|
+
assert.ok(persistedQueue.includes('user first') && persistedQueue.includes('user second'), 'user messages persisted as plain strings');
|
|
42
|
+
|
|
43
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
44
|
+
assert.deepEqual(drained, ['user first', 'user second'], 'completion dropped; user messages kept in order');
|
|
45
|
+
assert.ok(!drained.some((t) => /finished/.test(t)), 'no completion prose replayed');
|
|
46
|
+
|
|
47
|
+
// A second drain (post-resume) yields nothing — the completion was discarded,
|
|
48
|
+
// not deferred.
|
|
49
|
+
assert.deepEqual(drainPendingMessages(sid), []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test.after(() => {
|
|
53
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Proves the shared tagger (used by notify.mjs, tool-exec.mjs, runtime-core.mjs)
|
|
57
|
+
// yields entries drain drops, while a real user message survives in order.
|
|
58
|
+
test('markCompletionEntry path is dropped on drain; user message survives', () => {
|
|
59
|
+
const sid = 'sess_drop_test_2';
|
|
60
|
+
enqueuePendingMessage(sid, 'real user question');
|
|
61
|
+
enqueuePendingMessage(sid, markCompletionEntry('Async agent task abc finished.\n\nResult:\n> ok'));
|
|
62
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
63
|
+
assert.deepEqual(drained, ['real user question']);
|
|
64
|
+
});
|
|
@@ -9,7 +9,7 @@ import { executeBashSessionTool } from '../../tools/bash-session.mjs';
|
|
|
9
9
|
import { executePatchTool } from '../../tools/patch.mjs';
|
|
10
10
|
import { executeInternalTool, isInternalTool } from '../../internal-tools.mjs';
|
|
11
11
|
import { normalizeToolEnvelope, makeToolEnvelope } from '../tool-envelope.mjs';
|
|
12
|
-
import { getSessionAbortSignal, enqueuePendingMessage } from '../manager.mjs';
|
|
12
|
+
import { getSessionAbortSignal, enqueuePendingMessage, markCompletionEntry } from '../manager.mjs';
|
|
13
13
|
import { createScopedCacheOutcome } from '../cache/scoped-cache-outcome.mjs';
|
|
14
14
|
import { modelVisibleToolCompletionMessage } from '../../../../shared/tool-execution-contract.mjs';
|
|
15
15
|
import { _isScopedCacheableTool } from './tool-classify.mjs';
|
|
@@ -68,7 +68,9 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
68
68
|
if (!notificationSessionId) return;
|
|
69
69
|
try {
|
|
70
70
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
71
|
-
|
|
71
|
+
// Inherently a tool-completion notification → tag so a later
|
|
72
|
+
// resume drops it instead of replaying it as user text.
|
|
73
|
+
if (visible) enqueuePendingMessage(notificationSessionId, markCompletionEntry(visible));
|
|
72
74
|
} catch { /* best effort */ }
|
|
73
75
|
};
|
|
74
76
|
const completionToolOpts = {
|
|
@@ -11,9 +11,31 @@ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
|
11
11
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
12
12
|
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
13
|
const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
14
|
+
// Marker for deferred agent/tool *completion* notifications. Such entries must
|
|
15
|
+
// never be replayed into a later turn on session resume (out-of-order delivery
|
|
16
|
+
// is worse than loss — owner decision), so drain discards them. Genuine
|
|
17
|
+
// user/steering messages carry no marker and keep full queue + replay behavior.
|
|
18
|
+
export const COMPLETION_NOTIFICATION_KIND = 'completion_notification';
|
|
14
19
|
const _pendingPersistBuffers = new Map();
|
|
15
20
|
let _pendingPersistImmediate = null;
|
|
16
21
|
|
|
22
|
+
function isCompletionNotificationEntry(entry) {
|
|
23
|
+
return Boolean(entry) && typeof entry === 'object'
|
|
24
|
+
&& entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Canonical completion-enqueue tagger. Every deferred tool/agent completion
|
|
28
|
+
// notification MUST be enqueued through this so drain can discard it on resume
|
|
29
|
+
// (never replay out-of-order). Pass the model-visible completion text (or an
|
|
30
|
+
// existing entry); genuine user/steering messages must NOT be tagged.
|
|
31
|
+
export function markCompletionEntry(text) {
|
|
32
|
+
const value = typeof text === 'string'
|
|
33
|
+
? text
|
|
34
|
+
: (text && typeof text === 'object' ? (text.text || text.content || '') : '');
|
|
35
|
+
const content = String(value ?? '');
|
|
36
|
+
return { content, text: content, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
function pendingMessagesPath() {
|
|
18
40
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
19
41
|
}
|
|
@@ -53,13 +75,7 @@ function normalizePendingStore(raw) {
|
|
|
53
75
|
if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
|
|
54
76
|
const q = isTuiSteeringPendingKey(sid)
|
|
55
77
|
? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
|
|
56
|
-
: value
|
|
57
|
-
.map((entry) => {
|
|
58
|
-
if (typeof entry === 'string') return entry;
|
|
59
|
-
if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
|
|
60
|
-
return '';
|
|
61
|
-
})
|
|
62
|
-
.filter(Boolean);
|
|
78
|
+
: value.map(normalizePersistedEntry).filter(Boolean);
|
|
63
79
|
if (q.length > 0) {
|
|
64
80
|
out.sessions[sid] = q;
|
|
65
81
|
const touched = Number(touchedRaw[sid]);
|
|
@@ -85,16 +101,22 @@ function normalizePendingMessageEntry(entry) {
|
|
|
85
101
|
return { content: entry, text };
|
|
86
102
|
}
|
|
87
103
|
if (!entry || typeof entry !== 'object') return null;
|
|
104
|
+
const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
|
|
105
|
+
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
|
|
106
|
+
: null;
|
|
88
107
|
const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
|
|
89
108
|
if (content == null) return null;
|
|
90
109
|
const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
|
|
91
|
-
|
|
92
|
-
if (
|
|
110
|
+
let out = null;
|
|
111
|
+
if (Array.isArray(content)) out = content.length > 0 ? { content, text } : null;
|
|
112
|
+
else if (typeof content === 'string') {
|
|
93
113
|
const value = content.trim();
|
|
94
|
-
|
|
114
|
+
out = value ? { content: value, text: text || value } : null;
|
|
115
|
+
} else {
|
|
116
|
+
const fallback = promptContentText(content).trim();
|
|
117
|
+
out = fallback ? { content: fallback, text: text || fallback } : null;
|
|
95
118
|
}
|
|
96
|
-
|
|
97
|
-
return fallback ? { content: fallback, text: text || fallback } : null;
|
|
119
|
+
return out && marker ? { ...out, ...marker } : out;
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
function pendingMessageText(entry) {
|
|
@@ -105,14 +127,39 @@ function pendingMessageText(entry) {
|
|
|
105
127
|
function pendingMessageQueueEntry(entry) {
|
|
106
128
|
const normalized = normalizePendingMessageEntry(entry);
|
|
107
129
|
if (!normalized) return null;
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
const marker = isCompletionNotificationEntry(normalized)
|
|
131
|
+
? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
|
|
132
|
+
: null;
|
|
133
|
+
if (typeof normalized.content === 'string' && normalized.content === normalized.text && !marker) return normalized.content;
|
|
134
|
+
const base = { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
135
|
+
return marker ? { ...base, ...marker } : base;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Canonical persisted-queue entry: a plain string for user/steering messages,
|
|
139
|
+
// or a { message, notificationKind, enqueuedAt } object for completion/task
|
|
140
|
+
// notifications so the marker survives an on-disk round trip. Accepts either an
|
|
141
|
+
// in-memory queue entry (content/text) or an already-persisted entry
|
|
142
|
+
// (string | { message } legacy | marked object); back-compatible with both.
|
|
143
|
+
function normalizePersistedEntry(entry) {
|
|
144
|
+
if (typeof entry === 'string') { const t = entry.trim(); return t || null; }
|
|
145
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
146
|
+
if (isCompletionNotificationEntry(entry)) {
|
|
147
|
+
const message = (typeof entry.message === 'string' && entry.message.trim())
|
|
148
|
+
? entry.message.trim()
|
|
149
|
+
: pendingMessageText(entry);
|
|
150
|
+
return message
|
|
151
|
+
? { message, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
|
|
152
|
+
: null;
|
|
153
|
+
}
|
|
154
|
+
if (typeof entry.message === 'string') { const t = entry.message.trim(); return t || null; }
|
|
155
|
+
const t = pendingMessageText(entry);
|
|
156
|
+
return t || null;
|
|
110
157
|
}
|
|
111
158
|
|
|
112
159
|
function persistPendingMessages(sessionId, messages) {
|
|
113
160
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
114
161
|
const persistedMessages = (Array.isArray(messages) ? messages : [messages])
|
|
115
|
-
.map(
|
|
162
|
+
.map(normalizePersistedEntry)
|
|
116
163
|
.filter(Boolean);
|
|
117
164
|
if (persistedMessages.length === 0) return 0;
|
|
118
165
|
// Async lock wait: this runs on the lead/TUI main process (tool-exec +
|
|
@@ -159,7 +206,7 @@ function flushPendingMessagePersistsSync() {
|
|
|
159
206
|
|
|
160
207
|
function schedulePendingMessagePersist(sessionId, message) {
|
|
161
208
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
162
|
-
const persistedMessage =
|
|
209
|
+
const persistedMessage = normalizePersistedEntry(message);
|
|
163
210
|
if (!persistedMessage) return 0;
|
|
164
211
|
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
165
212
|
q.push(persistedMessage);
|
|
@@ -192,7 +239,7 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
192
239
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
193
240
|
const next = normalizePendingStore(raw);
|
|
194
241
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
195
|
-
drained = q.filter((m) => typeof m === 'string' && m.length > 0);
|
|
242
|
+
drained = q.filter((m) => (typeof m === 'string' && m.length > 0) || isCompletionNotificationEntry(m));
|
|
196
243
|
if (drained.length === 0) return undefined;
|
|
197
244
|
delete next.sessions[sessionId];
|
|
198
245
|
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
@@ -335,8 +382,13 @@ export function drainPendingMessages(sessionId) {
|
|
|
335
382
|
// first. Reversing this order (buffer before disk) delivered newer
|
|
336
383
|
// buffered sends ahead of older persisted ones after a restart.
|
|
337
384
|
const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
|
|
338
|
-
|
|
339
|
-
|
|
385
|
+
// Discard deferred completion/task notifications instead of injecting them
|
|
386
|
+
// out-of-order into a future turn on resume. Genuine user/steering messages
|
|
387
|
+
// (plain strings / content entries) carry no marker and are kept in order.
|
|
388
|
+
const memoryKept = memory.filter((m) => !isCompletionNotificationEntry(m));
|
|
389
|
+
const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m));
|
|
390
|
+
const memoryVisible = modelVisiblePendingMessages(memoryKept);
|
|
391
|
+
const persistedVisible = modelVisiblePendingMessages(persistedKept);
|
|
340
392
|
if (memoryVisible.length === 0) return persistedVisible;
|
|
341
393
|
if (persistedVisible.length === 0) return memoryVisible;
|
|
342
394
|
const persistedTexts = persistedVisible.map(pendingMessageText);
|
|
@@ -63,6 +63,8 @@ export {
|
|
|
63
63
|
_mergePendingMessageEntries,
|
|
64
64
|
enqueuePendingMessage,
|
|
65
65
|
drainPendingMessages,
|
|
66
|
+
markCompletionEntry,
|
|
67
|
+
COMPLETION_NOTIFICATION_KIND,
|
|
66
68
|
} from './manager/pending-messages.mjs';
|
|
67
69
|
export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText } from './manager/prompt-utils.mjs';
|
|
68
70
|
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import { spawn
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
4
|
import { detachedSpawnOpts } from './spawn-flags.mjs';
|
|
5
5
|
|
|
6
6
|
function positiveInt(value) {
|
|
@@ -8,64 +8,12 @@ function positiveInt(value) {
|
|
|
8
8
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
// PID alive after its terminal window is closed, so parent-liveness alone never
|
|
13
|
-
// releases the worker. When the session is genuinely interactive (a TTY), the
|
|
14
|
-
// controlling console window is owned by a host process (conhost.exe) that dies
|
|
15
|
-
// with the terminal — monitoring that PID turns "terminal closed" into a fatal
|
|
16
|
-
// orphan signal. Returns null for non-interactive/service/hidden-console starts
|
|
17
|
-
// (no TTY, or no console window) so those are never treated as terminal loss.
|
|
18
|
-
function detectControllingTerminalPid() {
|
|
19
|
-
if (process.platform !== 'win32') return null;
|
|
20
|
-
// Gate on a real TTY: a service/pipe/hidden launch has no controlling
|
|
21
|
-
// terminal, and probing one there would spawn a transient console whose host
|
|
22
|
-
// dies immediately — a false "terminal lost" that would kill a healthy worker.
|
|
23
|
-
const interactive = Boolean(
|
|
24
|
-
(process.stdin && process.stdin.isTTY) || (process.stdout && process.stdout.isTTY),
|
|
25
|
-
);
|
|
26
|
-
if (!interactive) return null;
|
|
27
|
-
try {
|
|
28
|
-
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
29
|
-
const powershell = systemRoot + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
30
|
-
const script = [
|
|
31
|
-
'Add-Type @"',
|
|
32
|
-
'using System;',
|
|
33
|
-
'using System.Runtime.InteropServices;',
|
|
34
|
-
'public class MixdogTerm {',
|
|
35
|
-
' [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();',
|
|
36
|
-
' [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);',
|
|
37
|
-
'}',
|
|
38
|
-
'"@',
|
|
39
|
-
'$h=[MixdogTerm]::GetConsoleWindow()',
|
|
40
|
-
'if($h -eq [IntPtr]::Zero){ Write-Output 0 } else { $p=0; [void][MixdogTerm]::GetWindowThreadProcessId($h,[ref]$p); Write-Output $p }',
|
|
41
|
-
].join('\n');
|
|
42
|
-
// NO windowsHide: CREATE_NO_WINDOW gives the probe its own/absent console so
|
|
43
|
-
// GetConsoleWindow() returns 0 or a foreign hwnd. A plain console app spawned
|
|
44
|
-
// from a console-attached parent (this runs at the guardian start site, in the
|
|
45
|
-
// guarded parent) inherits that console with no window flash. Under Windows
|
|
46
|
-
// Terminal/ConPTY the owner is the tab's conhost (dies on tab close), which is
|
|
47
|
-
// exactly the pid we want to monitor.
|
|
48
|
-
const res = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
49
|
-
timeout: 4000,
|
|
50
|
-
encoding: 'utf8',
|
|
51
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
-
});
|
|
53
|
-
const pid = positiveInt(String((res && res.stdout) || '').trim());
|
|
54
|
-
// Ignore our own PID: that would make the guardian its own kill trigger.
|
|
55
|
-
if (pid && pid !== process.pid) return pid;
|
|
56
|
-
return null;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function guardianScript({ parentPid, childPid, childGroupPid, terminalPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
|
|
11
|
+
function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
|
|
63
12
|
return `
|
|
64
13
|
const { spawnSync } = require('node:child_process');
|
|
65
14
|
const parentPid = ${JSON.stringify(parentPid)};
|
|
66
15
|
const childPid = ${JSON.stringify(childPid)};
|
|
67
16
|
const childGroupPid = ${JSON.stringify(childGroupPid || childPid)};
|
|
68
|
-
const terminalPid = ${JSON.stringify(terminalPid || 0)};
|
|
69
17
|
const platform = ${JSON.stringify(platform)};
|
|
70
18
|
const pollMs = ${JSON.stringify(pollMs)};
|
|
71
19
|
const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
|
|
@@ -95,12 +43,10 @@ let killing = false;
|
|
|
95
43
|
let orphanedAt = 0;
|
|
96
44
|
const timer = setInterval(() => {
|
|
97
45
|
if (!alive(childPid)) process.exit(0);
|
|
98
|
-
// Orphaned when the parent PID dies
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
const terminalLost = terminalPid > 0 && !alive(terminalPid);
|
|
103
|
-
if (alive(parentPid) && !terminalLost) {
|
|
46
|
+
// Orphaned when the parent PID dies. Matches Claude Code / pi: no console
|
|
47
|
+
// probe — a hidden shell spawn (windowsHide) plus tree-kill on cleanup, so
|
|
48
|
+
// orphan detection is pure parent-liveness.
|
|
49
|
+
if (alive(parentPid)) {
|
|
104
50
|
orphanedAt = 0;
|
|
105
51
|
return;
|
|
106
52
|
}
|
|
@@ -135,7 +81,6 @@ export function startChildGuardian({
|
|
|
135
81
|
parentPid: parent,
|
|
136
82
|
childPid: child,
|
|
137
83
|
childGroupPid: positiveInt(childGroupPid) || child,
|
|
138
|
-
terminalPid: detectControllingTerminalPid() || 0,
|
|
139
84
|
platform: process.platform,
|
|
140
85
|
pollMs: Math.max(100, Math.floor(Number(pollMs) || 750)),
|
|
141
86
|
orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../runtime/shared/markdown-frontmatter.mjs';
|
|
32
32
|
import { setConfiguredShell } from '../runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
|
|
33
33
|
import { hasUserConversationMessage } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
|
|
34
|
+
import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
|
|
34
35
|
import {
|
|
35
36
|
beginOAuthProviderLogin,
|
|
36
37
|
forgetProviderAuth,
|
|
@@ -773,7 +774,9 @@ export async function createMixdogSessionRuntime({
|
|
|
773
774
|
&& shouldPersistModelVisibleToolCompletion(text, meta)) {
|
|
774
775
|
try {
|
|
775
776
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
776
|
-
|
|
777
|
+
// Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
|
|
778
|
+
// → tag so drain discards it on resume rather than replaying out-of-order.
|
|
779
|
+
if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
|
|
777
780
|
} catch {}
|
|
778
781
|
}
|
|
779
782
|
// Headless/API listeners may exist but not consume the event; preserve
|
|
@@ -782,7 +785,10 @@ export async function createMixdogSessionRuntime({
|
|
|
782
785
|
&& typeof mgr.enqueuePendingMessage === 'function') {
|
|
783
786
|
try {
|
|
784
787
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
785
|
-
|
|
788
|
+
// modelVisibleToolCompletionMessage only returns non-empty for a
|
|
789
|
+
// persistable terminal completion, so this fallback is a completion
|
|
790
|
+
// too → tag it (genuine non-completion notifications yield '' above).
|
|
791
|
+
if (visible) enqueued = mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
|
|
786
792
|
} catch {}
|
|
787
793
|
}
|
|
788
794
|
return enqueued || handledByRuntimeListener;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Behavior-preserving: bodies identical to the originals; deps injected.
|
|
4
4
|
import { modelVisibleToolCompletionMessage } from '../../runtime/shared/tool-execution-contract.mjs';
|
|
5
5
|
import { renderBackgroundTask, sanitizeTaskMeta, setBackgroundTaskEnqueueFallback } from '../../runtime/shared/background-tasks.mjs';
|
|
6
|
+
import { markCompletionEntry } from '../../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
|
|
6
7
|
import { clean } from './helpers.mjs';
|
|
7
8
|
|
|
8
9
|
export function createNotify(mgr) {
|
|
@@ -11,7 +12,10 @@ export function createNotify(mgr) {
|
|
|
11
12
|
if (!target || typeof mgr.enqueuePendingMessage !== 'function') return false;
|
|
12
13
|
try {
|
|
13
14
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
14
|
-
|
|
15
|
+
if (!visible) return false;
|
|
16
|
+
// Mark this as a deferred completion/task notification so a later session
|
|
17
|
+
// resume drops it rather than replaying it out-of-order (owner decision).
|
|
18
|
+
return Boolean(mgr.enqueuePendingMessage(target, markCompletionEntry(visible)) > 0);
|
|
15
19
|
} catch {
|
|
16
20
|
return false;
|
|
17
21
|
}
|
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;
|