mixdog 0.9.39 → 0.9.41
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 +2 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
- package/scripts/compact-pressure-test.mjs +128 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/internal-comms-smoke.mjs +66 -25
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +222 -0
- package/scripts/provider-toolcall-test.mjs +32 -0
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
- package/src/workflows/bench/WORKFLOW.md +51 -27
- package/src/workflows/default/WORKFLOW.md +29 -24
|
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
4
4
|
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
5
5
|
|
|
6
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
|
|
6
8
|
// Minimal bag: drainPendingSteering only touches pending, the queue helpers,
|
|
7
9
|
// and commitSteeringQueueEntries (which no-ops on disk when runtime.id is not
|
|
8
10
|
// a valid session key). No provider/runTurn wiring needed.
|
|
@@ -71,7 +73,7 @@ test('drainPendingSteering accepts runtime callback signature', () => {
|
|
|
71
73
|
assert.equal(bag.pending.length, 0);
|
|
72
74
|
});
|
|
73
75
|
|
|
74
|
-
test('drained notification key
|
|
76
|
+
test('drained notification key is released while displayed key is not cleared', () => {
|
|
75
77
|
const { flow, bag } = makeFlow();
|
|
76
78
|
bag.getState().busy = true;
|
|
77
79
|
assert.equal(flow.enqueue('task finished', { mode: 'task-notification', key: 'task-1' }), true);
|
|
@@ -80,7 +82,7 @@ test('drained notification key remains deduped and displayed key is not cleared'
|
|
|
80
82
|
const out = flow.drainPendingSteering({ maxPriority: 'later' });
|
|
81
83
|
|
|
82
84
|
assert.deepEqual(out, ['task finished']);
|
|
83
|
-
assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }),
|
|
85
|
+
assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), true);
|
|
84
86
|
assert.equal(bag.displayedExecutionNotificationKeys.has('task-1'), true);
|
|
85
87
|
});
|
|
86
88
|
|
|
@@ -123,7 +125,11 @@ test('post-turn drain does not send queued slash command to model', async () =>
|
|
|
123
125
|
// Minimal store bag for createRunTurn: only the surface the streaming/steering
|
|
124
126
|
// finalize path touches. runtime.ask is a caller-supplied mock that drives the
|
|
125
127
|
// text-delta / steer-message callbacks.
|
|
126
|
-
function makeTurnBag(ask
|
|
128
|
+
function makeTurnBag(ask, {
|
|
129
|
+
timeoutMs = 300000,
|
|
130
|
+
getTurnLiveness,
|
|
131
|
+
abort,
|
|
132
|
+
} = {}) {
|
|
127
133
|
let seq = 0;
|
|
128
134
|
const state = {
|
|
129
135
|
items: [],
|
|
@@ -134,11 +140,13 @@ function makeTurnBag(ask) {
|
|
|
134
140
|
};
|
|
135
141
|
const itemIndexById = new Map();
|
|
136
142
|
const findIndexById = (id) => state.items.findIndex((it) => it.id === id);
|
|
143
|
+
const runtime = { id: null, toolMode: 'auto', ask, abort: abort || (() => {}) };
|
|
144
|
+
if (typeof getTurnLiveness === 'function') runtime.getTurnLiveness = getTurnLiveness;
|
|
137
145
|
const bag = {
|
|
138
|
-
runtime
|
|
146
|
+
runtime,
|
|
139
147
|
nextId: () => `id_${++seq}`,
|
|
140
148
|
tuiDebug: () => {},
|
|
141
|
-
LEAD_TURN_TIMEOUT_MS:
|
|
149
|
+
LEAD_TURN_TIMEOUT_MS: timeoutMs,
|
|
142
150
|
flags: { leadTurnEpoch: 0 },
|
|
143
151
|
pending: [],
|
|
144
152
|
itemIndexById,
|
|
@@ -176,6 +184,133 @@ function makeTurnBag(ask) {
|
|
|
176
184
|
return { bag, getState: () => state };
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
test('watchdog accepts fresh runtime liveness without tripping', async () => {
|
|
188
|
+
let aborts = 0;
|
|
189
|
+
const ask = async () => {
|
|
190
|
+
await wait(45);
|
|
191
|
+
return { result: { content: '' }, session: { messages: [] } };
|
|
192
|
+
};
|
|
193
|
+
const { bag } = makeTurnBag(ask, {
|
|
194
|
+
timeoutMs: 15,
|
|
195
|
+
abort: () => { aborts += 1; },
|
|
196
|
+
getTurnLiveness: () => ({
|
|
197
|
+
stage: 'tool_running',
|
|
198
|
+
lastProgressAt: Date.now(),
|
|
199
|
+
toolStartedAt: Date.now(),
|
|
200
|
+
toolSelfDeadlineMs: 0,
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
assert.equal(await createRunTurn(bag)('do a thing'), 'done');
|
|
205
|
+
assert.equal(aborts, 0, 'fresh orchestrator heartbeats defer the watchdog');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('watchdog trips at the tool ceiling despite fresh liveness', async () => {
|
|
209
|
+
const previousMax = process.env.MIXDOG_LEAD_TOOL_MAX_MS;
|
|
210
|
+
const timeoutMs = 30;
|
|
211
|
+
const ceilingMs = 95;
|
|
212
|
+
process.env.MIXDOG_LEAD_TOOL_MAX_MS = String(ceilingMs);
|
|
213
|
+
let rejectAsk;
|
|
214
|
+
let heartbeatTimer = null;
|
|
215
|
+
let heartbeatStartTimer = null;
|
|
216
|
+
let aborts = 0;
|
|
217
|
+
let abortAt = 0;
|
|
218
|
+
let livenessCalls = 0;
|
|
219
|
+
const toolStartedAt = Date.now();
|
|
220
|
+
const ask = (_userText, options) => new Promise((_resolve, reject) => {
|
|
221
|
+
rejectAsk = reject;
|
|
222
|
+
// Let the first watchdog probe defer from orchestrator liveness, then keep
|
|
223
|
+
// the TUI's local progress fresh so the ceiling-bounded re-arm is exercised.
|
|
224
|
+
heartbeatStartTimer = setTimeout(() => {
|
|
225
|
+
heartbeatTimer = setInterval(() => options.onStreamDelta(), 5);
|
|
226
|
+
}, 40);
|
|
227
|
+
});
|
|
228
|
+
const { bag } = makeTurnBag(ask, {
|
|
229
|
+
timeoutMs,
|
|
230
|
+
abort: () => {
|
|
231
|
+
aborts += 1;
|
|
232
|
+
abortAt = Date.now();
|
|
233
|
+
clearTimeout(heartbeatStartTimer);
|
|
234
|
+
clearInterval(heartbeatTimer);
|
|
235
|
+
const error = new Error('interrupted');
|
|
236
|
+
error.name = 'SessionClosedError';
|
|
237
|
+
rejectAsk(error);
|
|
238
|
+
},
|
|
239
|
+
getTurnLiveness: () => {
|
|
240
|
+
livenessCalls += 1;
|
|
241
|
+
return {
|
|
242
|
+
stage: 'tool_running',
|
|
243
|
+
lastProgressAt: Date.now(),
|
|
244
|
+
toolStartedAt,
|
|
245
|
+
toolSelfDeadlineMs: 0,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const result = await Promise.race([
|
|
252
|
+
createRunTurn(bag)('do a thing'),
|
|
253
|
+
wait(180).then(() => 'watchdog did not trip at the ceiling'),
|
|
254
|
+
]);
|
|
255
|
+
assert.equal(result, 'cancelled');
|
|
256
|
+
assert.equal(aborts, 1);
|
|
257
|
+
assert.ok(livenessCalls >= 2, 'first probe deferred, then the ceiling probe rechecked liveness');
|
|
258
|
+
assert.ok(abortAt - toolStartedAt < ceilingMs + timeoutMs, 'ceiling trips before an unbounded watchdog interval');
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(heartbeatStartTimer);
|
|
261
|
+
clearInterval(heartbeatTimer);
|
|
262
|
+
if (previousMax === undefined) delete process.env.MIXDOG_LEAD_TOOL_MAX_MS;
|
|
263
|
+
else process.env.MIXDOG_LEAD_TOOL_MAX_MS = previousMax;
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
for (const [name, getTurnLiveness] of [
|
|
268
|
+
['throws', () => { throw new Error('liveness unavailable'); }],
|
|
269
|
+
['returns null', () => null],
|
|
270
|
+
]) {
|
|
271
|
+
test(`watchdog ${name} probe falls back to abort`, async () => {
|
|
272
|
+
let rejectAsk;
|
|
273
|
+
let aborts = 0;
|
|
274
|
+
const ask = () => new Promise((_resolve, reject) => { rejectAsk = reject; });
|
|
275
|
+
const { bag } = makeTurnBag(ask, {
|
|
276
|
+
timeoutMs: 15,
|
|
277
|
+
getTurnLiveness,
|
|
278
|
+
abort: () => {
|
|
279
|
+
aborts += 1;
|
|
280
|
+
const error = new Error('interrupted');
|
|
281
|
+
error.name = 'SessionClosedError';
|
|
282
|
+
rejectAsk(error);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const [result] = await Promise.all([createRunTurn(bag)('do a thing'), wait(40)]);
|
|
287
|
+
assert.equal(result, 'cancelled');
|
|
288
|
+
assert.equal(aborts, 1);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
test('stream-delta progress keeps the watchdog alive', async () => {
|
|
293
|
+
let aborts = 0;
|
|
294
|
+
let streamDeltas = 0;
|
|
295
|
+
const ask = async (_userText, options) => {
|
|
296
|
+
options.onStreamDelta();
|
|
297
|
+
streamDeltas += 1;
|
|
298
|
+
await wait(12);
|
|
299
|
+
options.onStreamDelta();
|
|
300
|
+
streamDeltas += 1;
|
|
301
|
+
await wait(12);
|
|
302
|
+
return { result: { content: '' }, session: { messages: [] } };
|
|
303
|
+
};
|
|
304
|
+
const { bag } = makeTurnBag(ask, {
|
|
305
|
+
timeoutMs: 20,
|
|
306
|
+
abort: () => { aborts += 1; },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
assert.equal(await createRunTurn(bag)('do a thing'), 'done');
|
|
310
|
+
assert.equal(streamDeltas, 2);
|
|
311
|
+
assert.equal(aborts, 0);
|
|
312
|
+
});
|
|
313
|
+
|
|
179
314
|
test('onSteerMessage commits a streamed no-newline assistant tail into items', async () => {
|
|
180
315
|
// A terminal no-tool response streams a single line WITHOUT a trailing '\n',
|
|
181
316
|
// so no assistant row/currentAssistantId exists yet. A steering injection
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/engine.mjs';
|
|
5
|
+
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
6
|
+
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
7
|
+
import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
|
|
8
|
+
|
|
9
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
|
|
11
|
+
function makeTurnHarness(ask) {
|
|
12
|
+
let seq = 0;
|
|
13
|
+
let state = {
|
|
14
|
+
items: [],
|
|
15
|
+
structureRevision: 0,
|
|
16
|
+
streamingTail: null,
|
|
17
|
+
stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
|
|
18
|
+
busy: false,
|
|
19
|
+
spinner: null,
|
|
20
|
+
thinking: null,
|
|
21
|
+
};
|
|
22
|
+
const itemIndexById = new Map();
|
|
23
|
+
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
24
|
+
const pushItem = (item) => {
|
|
25
|
+
const items = [...state.items, item];
|
|
26
|
+
if (item?.id != null) itemIndexById.set(item.id, items.length - 1);
|
|
27
|
+
set({ items, structureRevision: state.structureRevision + 1 });
|
|
28
|
+
};
|
|
29
|
+
const updateStreamingTail = (id, patch) => set({
|
|
30
|
+
streamingTail: {
|
|
31
|
+
...(state.streamingTail?.id === id ? state.streamingTail : {}),
|
|
32
|
+
...patch,
|
|
33
|
+
kind: 'assistant',
|
|
34
|
+
id,
|
|
35
|
+
streaming: true,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const { patchItem, settleStreamingTail } = createEngineItemMutators({
|
|
39
|
+
getState: () => state,
|
|
40
|
+
set,
|
|
41
|
+
itemIndexById,
|
|
42
|
+
});
|
|
43
|
+
const clearStreamingTail = (id = null) => {
|
|
44
|
+
if (id == null || state.streamingTail?.id === id) set({ streamingTail: null });
|
|
45
|
+
};
|
|
46
|
+
const bag = {
|
|
47
|
+
runtime: { id: null, toolMode: 'auto', ask, abort: () => true },
|
|
48
|
+
nextId: () => `id_${++seq}`,
|
|
49
|
+
tuiDebug: () => {},
|
|
50
|
+
LEAD_TURN_TIMEOUT_MS: 300_000,
|
|
51
|
+
flags: { leadTurnEpoch: 0, disposed: false },
|
|
52
|
+
pending: [],
|
|
53
|
+
itemIndexById,
|
|
54
|
+
getState: () => state,
|
|
55
|
+
set,
|
|
56
|
+
pushItem,
|
|
57
|
+
patchItem,
|
|
58
|
+
updateStreamingTail,
|
|
59
|
+
settleStreamingTail,
|
|
60
|
+
clearStreamingTail,
|
|
61
|
+
pushNotice: () => {},
|
|
62
|
+
pushUserOrSyntheticItem: () => {},
|
|
63
|
+
markToolCallActive: () => {},
|
|
64
|
+
markToolCallDone: () => {},
|
|
65
|
+
clearActiveToolSummary: () => {},
|
|
66
|
+
agentStatusState: () => ({}),
|
|
67
|
+
routeState: () => ({}),
|
|
68
|
+
syncContextStats: () => {},
|
|
69
|
+
denyAllToolApprovals: () => {},
|
|
70
|
+
requestToolApproval: async () => ({ approved: false }),
|
|
71
|
+
patchToolCardResult: () => {},
|
|
72
|
+
flushToolResults: () => {},
|
|
73
|
+
flushDeferredExecutionPendingResumeKick: () => {},
|
|
74
|
+
drain: async () => {},
|
|
75
|
+
drainPendingSteering: () => [],
|
|
76
|
+
};
|
|
77
|
+
return { runTurn: createRunTurn(bag), getState: () => state };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
|
|
81
|
+
let identityStable = false;
|
|
82
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
83
|
+
const before = harness.getState().items;
|
|
84
|
+
options.onTextDelta('settled line\n');
|
|
85
|
+
await wait(30);
|
|
86
|
+
identityStable = harness.getState().items === before;
|
|
87
|
+
assert.match(harness.getState().streamingTail?.text || '', /settled line/);
|
|
88
|
+
return { result: { content: 'settled line\n' }, session: { messages: [] } };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(await harness.runTurn('go'), 'done');
|
|
92
|
+
assert.equal(identityStable, true, 'tail flush must not swap settled items');
|
|
93
|
+
assert.equal(harness.getState().streamingTail, null);
|
|
94
|
+
const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
|
|
95
|
+
assert.equal(assistants.length, 1);
|
|
96
|
+
assert.equal(assistants[0].streaming, false);
|
|
97
|
+
assert.equal(assistants[0].text, 'settled line\n');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('abort after stream progress settles the tail and leaves no orphan', async () => {
|
|
101
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
102
|
+
options.onTextDelta('partial line\n');
|
|
103
|
+
await wait(30);
|
|
104
|
+
const error = new Error('interrupted');
|
|
105
|
+
error.name = 'SessionClosedError';
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.equal(await harness.runTurn('go'), 'cancelled');
|
|
110
|
+
assert.equal(harness.getState().streamingTail, null);
|
|
111
|
+
const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
|
|
112
|
+
assert.equal(assistants.length, 1);
|
|
113
|
+
assert.equal(assistants[0].streaming, false);
|
|
114
|
+
assert.match(assistants[0].text, /partial line/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Codifies pre-existing parity: text had no item/currentAssistantId before its first completed line.
|
|
118
|
+
test('abort before a newline has no tail id and preserves prior text-drop parity', async () => {
|
|
119
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
120
|
+
options.onTextDelta('partial without newline');
|
|
121
|
+
await wait(30);
|
|
122
|
+
const error = new Error('interrupted');
|
|
123
|
+
error.name = 'SessionClosedError';
|
|
124
|
+
throw error;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
assert.equal(await harness.runTurn('go'), 'cancelled');
|
|
128
|
+
assert.equal(harness.getState().streamingTail, null);
|
|
129
|
+
assert.equal(
|
|
130
|
+
harness.getState().items.filter((item) => item.kind === 'assistant').length,
|
|
131
|
+
0,
|
|
132
|
+
'without a visible tail id, pre-newline text keeps the existing drop-on-abort behavior',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Codifies pre-existing parity: the old in-items completed-line snapshot survived starved recovery.
|
|
137
|
+
test('starved Esc recovery settles a non-empty tail exactly once', async () => {
|
|
138
|
+
let state = {
|
|
139
|
+
items: [],
|
|
140
|
+
structureRevision: 0,
|
|
141
|
+
streamingTail: { id: 'tail_abort', kind: 'assistant', text: 'visible partial\n', streaming: true },
|
|
142
|
+
queued: [],
|
|
143
|
+
busy: true,
|
|
144
|
+
commandBusy: false,
|
|
145
|
+
spinner: { active: true },
|
|
146
|
+
thinking: null,
|
|
147
|
+
lastTurn: null,
|
|
148
|
+
};
|
|
149
|
+
const itemIndexById = new Map();
|
|
150
|
+
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
151
|
+
const { patchItem, settleStreamingTail } = createEngineItemMutators({
|
|
152
|
+
getState: () => state,
|
|
153
|
+
set,
|
|
154
|
+
itemIndexById,
|
|
155
|
+
});
|
|
156
|
+
const bag = {
|
|
157
|
+
runtime: { abort: () => true },
|
|
158
|
+
nextId: () => 'notice',
|
|
159
|
+
flags: { leadTurnEpoch: 1, disposed: false, draining: false, manualAbortRecoveryMs: 10 },
|
|
160
|
+
pending: [],
|
|
161
|
+
listeners: new Set(),
|
|
162
|
+
getState: () => state,
|
|
163
|
+
set,
|
|
164
|
+
pushItem: () => {},
|
|
165
|
+
patchItem,
|
|
166
|
+
replaceItems: (items) => items,
|
|
167
|
+
settleStreamingTail,
|
|
168
|
+
clearStreamingTail: () => set({ streamingTail: null }),
|
|
169
|
+
pushNotice: () => {},
|
|
170
|
+
autoClearState: () => ({}),
|
|
171
|
+
agentStatusState: () => ({}),
|
|
172
|
+
routeState: () => ({}),
|
|
173
|
+
syncContextStats: () => {},
|
|
174
|
+
denyAllToolApprovals: () => {},
|
|
175
|
+
updateAgentJobCard: () => {},
|
|
176
|
+
requeueEntriesFront: () => {},
|
|
177
|
+
enqueue: () => {},
|
|
178
|
+
autoClearBeforeSubmit: async () => {},
|
|
179
|
+
restoreQueued: () => {},
|
|
180
|
+
resetStatsAndSyncContext: () => {},
|
|
181
|
+
drain: async () => {},
|
|
182
|
+
flushDeferredExecutionPendingResumeKick: () => {},
|
|
183
|
+
discardExecutionPendingResume: () => {},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
createEngineApiA(bag).abort();
|
|
187
|
+
await wait(30);
|
|
188
|
+
assert.equal(state.streamingTail, null);
|
|
189
|
+
const assistants = state.items.filter((item) => item.kind === 'assistant');
|
|
190
|
+
assert.equal(assistants.length, 1);
|
|
191
|
+
assert.equal(assistants[0].id, 'tail_abort');
|
|
192
|
+
assert.equal(assistants[0].streaming, false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('removeNotice-style replacement preserves the live tail and later settles once', () => {
|
|
196
|
+
let state = {
|
|
197
|
+
items: [{ id: 'notice', kind: 'notice', text: 'temporary' }],
|
|
198
|
+
structureRevision: 4,
|
|
199
|
+
streamingTail: { id: 'tail_notice', kind: 'assistant', text: 'visible\n', streaming: true },
|
|
200
|
+
};
|
|
201
|
+
const itemIndexById = new Map([['notice', 0]]);
|
|
202
|
+
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
203
|
+
const { settleStreamingTail } = createEngineItemMutators({
|
|
204
|
+
getState: () => state,
|
|
205
|
+
set,
|
|
206
|
+
itemIndexById,
|
|
207
|
+
});
|
|
208
|
+
state = replaceEngineItemsState({
|
|
209
|
+
state,
|
|
210
|
+
items: state.items.filter((item) => item.id !== 'notice'),
|
|
211
|
+
itemIndexById,
|
|
212
|
+
preserveStreamingTail: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
assert.equal(state.streamingTail?.id, 'tail_notice');
|
|
216
|
+
assert.equal(settleStreamingTail('tail_notice', { text: 'complete final text' }), true);
|
|
217
|
+
assert.equal(state.items.length, 1);
|
|
218
|
+
assert.equal(state.items[0].text, 'complete final text');
|
|
219
|
+
assert.equal(state.items[0].streaming, false);
|
|
220
|
+
assert.equal(settleStreamingTail('tail_notice', { text: 'duplicate' }), false);
|
|
221
|
+
assert.equal(state.items.length, 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('bulk replacement clears the tail and stale settle cannot append into reset transcript', () => {
|
|
225
|
+
let state = {
|
|
226
|
+
items: [{ id: 'old', kind: 'user', text: 'old prompt' }],
|
|
227
|
+
structureRevision: 8,
|
|
228
|
+
streamingTail: { id: 'stale_tail', kind: 'assistant', text: 'old response\n', streaming: true },
|
|
229
|
+
};
|
|
230
|
+
const itemIndexById = new Map([['old', 0]]);
|
|
231
|
+
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
232
|
+
const { settleStreamingTail } = createEngineItemMutators({
|
|
233
|
+
getState: () => state,
|
|
234
|
+
set,
|
|
235
|
+
itemIndexById,
|
|
236
|
+
});
|
|
237
|
+
state = replaceEngineItemsState({ state, items: [], itemIndexById });
|
|
238
|
+
|
|
239
|
+
assert.equal(state.streamingTail, null);
|
|
240
|
+
assert.equal(settleStreamingTail('stale_tail', { text: 'must not reappear' }), false);
|
|
241
|
+
assert.deepEqual(state.items, []);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('tool-card revision invalidates the incremental prefix cache', () => {
|
|
245
|
+
const cacheRef = { current: null };
|
|
246
|
+
const tail = { id: 'tail', kind: 'assistant', text: 'live\n', streaming: true };
|
|
247
|
+
let state = {
|
|
248
|
+
items: [{ id: 'tool', kind: 'tool', name: 'shell', text: 'ok', result: 'ok' }],
|
|
249
|
+
structureRevision: 1,
|
|
250
|
+
streamingTail: tail,
|
|
251
|
+
};
|
|
252
|
+
const itemIndexById = new Map([['tool', 0]]);
|
|
253
|
+
const set = (patch) => { state = { ...state, ...patch }; return true; };
|
|
254
|
+
const { patchItem } = createEngineItemMutators({
|
|
255
|
+
getState: () => state,
|
|
256
|
+
set,
|
|
257
|
+
itemIndexById,
|
|
258
|
+
});
|
|
259
|
+
const before = buildTranscriptRowIndexIncremental([...state.items, state.streamingTail], {
|
|
260
|
+
columns: 24,
|
|
261
|
+
cacheRef,
|
|
262
|
+
prefixRevision: state.structureRevision,
|
|
263
|
+
});
|
|
264
|
+
const cachedBeforePatch = cacheRef.current;
|
|
265
|
+
assert.equal(patchItem('tool', {
|
|
266
|
+
text: 'a much longer tool result '.repeat(20),
|
|
267
|
+
result: 'a much longer tool result '.repeat(20),
|
|
268
|
+
}), true);
|
|
269
|
+
assert.equal(state.structureRevision, 2, 'real engine patchItem bumps revision');
|
|
270
|
+
const after = buildTranscriptRowIndexIncremental([...state.items, state.streamingTail], {
|
|
271
|
+
columns: 24,
|
|
272
|
+
cacheRef,
|
|
273
|
+
prefixRevision: state.structureRevision,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
assert.notEqual(cacheRef.current, cachedBeforePatch, 'revision bump rebuilds the prefix cache');
|
|
277
|
+
assert.equal(after.rows.length, before.rows.length);
|
|
278
|
+
assert.equal(cacheRef.current.prefixRevision, 2);
|
|
279
|
+
});
|
|
@@ -11,3 +11,7 @@ actionable correctness, regression, security, and verification risks; inspect
|
|
|
11
11
|
affected boundaries. Do not reimplement the change or report non-risky nits.
|
|
12
12
|
Report findings first, severity-ordered, with one line per `file:line`. If clean,
|
|
13
13
|
say so in one line and include only material residual risk.
|
|
14
|
+
|
|
15
|
+
When the work comes with stated criteria or reference material for judging
|
|
16
|
+
it, verify against those as given — substituting your own interpretation or
|
|
17
|
+
a self-built check is a verification risk to report.
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# Lead Brief
|
|
2
2
|
|
|
3
|
-
- One-line fragments
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
- One-line fragments. `Task:` is mandatory and lossless: include the intent,
|
|
4
|
+
required outcomes, negative outcomes, and completion/stop boundary. Minimum
|
|
5
|
+
chars, maximum info: omit role-known rules and repeated context/facts; split
|
|
6
|
+
scope, don't pad or discard task requirements.
|
|
7
|
+
- Preserve user-supplied exact targets and exact replacements/outputs in
|
|
8
|
+
`Task:`. Never infer exactness from a task name, file count, or perceived
|
|
9
|
+
difficulty.
|
|
10
|
+
- All other fields are optional task-specific deltas: `Anchors:`
|
|
11
|
+
`Allow/Forbid:` `Deliver:` `Verify:`. Omit any that add no information.
|
|
6
12
|
- Anchors: `file:line` + one-line conclusion; never log/code bodies. Specify
|
|
7
13
|
outcome, not method unless required. `Deliver:` gives shape/size, never a long
|
|
8
14
|
handoff. Referenced spec/test beats its summary.
|
|
15
|
+
- Preserve the exact same `Task:` across Worker -> Debugger -> Reviewer. Never
|
|
16
|
+
summarize, rewrite, or replace it when handing the scope to the next role.
|
|
9
17
|
- Full brief only for fresh spawn/`respawned: true`; live follow-ups are delta.
|
|
10
18
|
Dead-tag send is cold: re-supply anchors.
|
|
11
19
|
- Never `send` mid-run; batch one follow-up after completion; interrupt only to
|
|
@@ -3,5 +3,3 @@
|
|
|
3
3
|
- Write-role agents self-verify via `shell`; Lead runs cross-scope
|
|
4
4
|
verification, benches, and all git via it.
|
|
5
5
|
- Use current project/workspace unless user request/tool need.
|
|
6
|
-
- If workflow permits delegation, use `agent` for scoped
|
|
7
|
-
implementation/research/review/debugging; a no-delegation workflow controls.
|
|
@@ -44,3 +44,7 @@
|
|
|
44
44
|
reread returned spans. A third window means the first was too narrow.
|
|
45
45
|
- One carve-out to parallel-first: apply the edit, then verify it in a
|
|
46
46
|
separate shell turn and consume results in order. Otherwise stay parallel.
|
|
47
|
+
- A long-running command promoted to background is a decision point, not a
|
|
48
|
+
cue to wait: estimate from its observed progress whether it can finish
|
|
49
|
+
within the remaining budget; if not, diagnose the bottleneck and switch to
|
|
50
|
+
an alternative route. Waiting is chosen, never assumed.
|
|
@@ -194,6 +194,10 @@ export const DEFAULT_FIRST_RESPONSE_TIMEOUT_MS = envTimeoutMs(
|
|
|
194
194
|
'MIXDOG_AGENT_FIRST_RESPONSE_TIMEOUT_MS',
|
|
195
195
|
120_000,
|
|
196
196
|
);
|
|
197
|
+
export const DEFAULT_FIRST_VISIBLE_CEILING_MS = envTimeoutMs(
|
|
198
|
+
'MIXDOG_AGENT_FIRST_VISIBLE_TIMEOUT_MS',
|
|
199
|
+
600_000,
|
|
200
|
+
);
|
|
197
201
|
export const DEFAULT_STALE_TIMEOUT_MS = envTimeoutMs(
|
|
198
202
|
'MIXDOG_AGENT_STALE_TIMEOUT_MS',
|
|
199
203
|
30 * 60_000,
|
|
@@ -209,6 +213,10 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
|
|
|
209
213
|
overrides.firstResponseTimeoutMs,
|
|
210
214
|
DEFAULT_FIRST_RESPONSE_TIMEOUT_MS,
|
|
211
215
|
);
|
|
216
|
+
const firstVisibleCeilingMs = resolveExplicitMs(
|
|
217
|
+
overrides.firstVisibleTimeoutMs,
|
|
218
|
+
DEFAULT_FIRST_VISIBLE_CEILING_MS,
|
|
219
|
+
);
|
|
212
220
|
|
|
213
221
|
let idleStaleMs;
|
|
214
222
|
if (Number.isFinite(overrides.idleTimeoutMs) && overrides.idleTimeoutMs >= 0) {
|
|
@@ -238,6 +246,7 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
|
|
|
238
246
|
|
|
239
247
|
return {
|
|
240
248
|
firstResponseMs,
|
|
249
|
+
firstVisibleCeilingMs,
|
|
241
250
|
idleStaleMs,
|
|
242
251
|
toolRunningMs,
|
|
243
252
|
};
|
|
@@ -248,8 +257,16 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
|
248
257
|
|
|
249
258
|
if (snapshot.waitingForFirstActivity) {
|
|
250
259
|
const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
const hasTransport = Boolean(
|
|
261
|
+
startedAt
|
|
262
|
+
&& snapshot.lastTransportAt
|
|
263
|
+
&& snapshot.lastTransportAt > startedAt
|
|
264
|
+
);
|
|
265
|
+
const ceilingMs = hasTransport
|
|
266
|
+
? policy.firstVisibleCeilingMs
|
|
267
|
+
: policy.firstResponseMs;
|
|
268
|
+
if (ceilingMs > 0 && startedAt && now - startedAt > ceilingMs) {
|
|
269
|
+
return new AgentStallAbortError(`agent first response stale (${ceilingMs}ms)`);
|
|
253
270
|
}
|
|
254
271
|
return null;
|
|
255
272
|
}
|
|
@@ -293,6 +310,7 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
|
293
310
|
export function agentWatchdogPolicyActive(policy) {
|
|
294
311
|
if (!policy) return false;
|
|
295
312
|
return (policy.firstResponseMs > 0)
|
|
313
|
+
|| (policy.firstVisibleCeilingMs > 0)
|
|
296
314
|
|| (policy.idleStaleMs > 0)
|
|
297
315
|
|| (policy.toolRunningMs > 0);
|
|
298
316
|
}
|
|
@@ -309,6 +309,12 @@ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, tool
|
|
|
309
309
|
const errorFirstLine = resultKind === 'error'
|
|
310
310
|
? _firstNonEmptyLine(_redactLogText(String(resultText ?? ''))).slice(0, 200) || null
|
|
311
311
|
: null;
|
|
312
|
+
// Failure taxonomy on the tool row itself (mirrors the failure log's
|
|
313
|
+
// `category`) so trace-level aggregation can exclude expected command
|
|
314
|
+
// exits (`command-exit`) without joining tool-failures.jsonl.
|
|
315
|
+
const errorCategory = resultKind === 'error'
|
|
316
|
+
? classifyToolFailure(String(resultText ?? ''), toolName)
|
|
317
|
+
: null;
|
|
312
318
|
// Flat shape — fields named exactly as the agent_calls PG columns so
|
|
313
319
|
// insertAgentCalls can pick them up by direct property access without
|
|
314
320
|
// a payload-unwrap step. result_kind has no column and rides as plain
|
|
@@ -327,6 +333,7 @@ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, tool
|
|
|
327
333
|
tool_args_summary: summarizedArgs,
|
|
328
334
|
result_kind: resultKind || null,
|
|
329
335
|
result_error_first_line: errorFirstLine,
|
|
336
|
+
result_error_category: errorCategory,
|
|
330
337
|
result_has_next_call: nextCallCount > 0,
|
|
331
338
|
result_next_call_count: nextCallCount,
|
|
332
339
|
result_bytes_est: resultBytesEst,
|
|
@@ -784,6 +784,13 @@ export function composeSystemPrompt(opts) {
|
|
|
784
784
|
? ''
|
|
785
785
|
: loadScopedRoleInstructions(opts.agent || null, opts.provider || null);
|
|
786
786
|
const stableSystemParts = [];
|
|
787
|
+
// Active workflow contract leads the role layer: it must outrank the
|
|
788
|
+
// generic role/tool guidance below it, not trail the profile/meta block
|
|
789
|
+
// in BP3 (observed: leads deprioritized delegation rules that sat behind
|
|
790
|
+
// ~3KB of profile/output-style text).
|
|
791
|
+
if (opts.workflowContext && typeof opts.workflowContext === 'string' && opts.workflowContext.trim()) {
|
|
792
|
+
stableSystemParts.push(opts.workflowContext.trim());
|
|
793
|
+
}
|
|
787
794
|
if (opts.roleRules) stableSystemParts.push(opts.roleRules);
|
|
788
795
|
if (opts.userPrompt) stableSystemParts.push(opts.userPrompt);
|
|
789
796
|
if (roleInstructionContext) stableSystemParts.push(roleInstructionContext);
|
|
@@ -798,9 +805,6 @@ export function composeSystemPrompt(opts) {
|
|
|
798
805
|
if (opts.metaContext && typeof opts.metaContext === 'string' && opts.metaContext.trim()) {
|
|
799
806
|
sessionMarkerParts.push(opts.metaContext.trim());
|
|
800
807
|
}
|
|
801
|
-
if (opts.workflowContext && typeof opts.workflowContext === 'string' && opts.workflowContext.trim()) {
|
|
802
|
-
sessionMarkerParts.push(opts.workflowContext.trim());
|
|
803
|
-
}
|
|
804
808
|
if (!_skip.memory && opts.coreMemoryContext && typeof opts.coreMemoryContext === 'string' && opts.coreMemoryContext.trim()) {
|
|
805
809
|
sessionMarkerParts.push('# Core Memory\n' + opts.coreMemoryContext.trim());
|
|
806
810
|
}
|