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.
Files changed (59) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  4. package/scripts/compact-pressure-test.mjs +128 -0
  5. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  6. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  7. package/scripts/internal-comms-smoke.mjs +66 -25
  8. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  9. package/scripts/max-output-recovery-test.mjs +222 -0
  10. package/scripts/provider-toolcall-test.mjs +32 -0
  11. package/scripts/steering-drain-buckets-test.mjs +140 -5
  12. package/scripts/tui-transcript-perf-test.mjs +279 -0
  13. package/src/agents/reviewer/AGENT.md +4 -0
  14. package/src/rules/lead/lead-brief.md +11 -3
  15. package/src/rules/lead/lead-tool.md +0 -2
  16. package/src/rules/shared/01-tool.md +4 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  18. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  20. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  25. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  28. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  29. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  32. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  33. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  34. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  36. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  38. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
  39. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  41. package/src/runtime/shared/buffered-appender.mjs +13 -2
  42. package/src/session-runtime/config-helpers.mjs +6 -6
  43. package/src/session-runtime/lifecycle-api.mjs +4 -0
  44. package/src/session-runtime/runtime-core.mjs +1 -0
  45. package/src/session-runtime/session-turn-api.mjs +12 -0
  46. package/src/standalone/agent-tool.mjs +8 -1
  47. package/src/tui/App.jsx +2 -1
  48. package/src/tui/app/transcript-window.mjs +9 -10
  49. package/src/tui/app/use-transcript-window.mjs +38 -56
  50. package/src/tui/dist/index.mjs +354 -163
  51. package/src/tui/engine/agent-job-feed.mjs +76 -6
  52. package/src/tui/engine/session-api.mjs +7 -1
  53. package/src/tui/engine/session-flow.mjs +3 -1
  54. package/src/tui/engine/tool-card-results.mjs +10 -5
  55. package/src/tui/engine/turn.mjs +96 -36
  56. package/src/tui/engine.mjs +136 -37
  57. package/src/tui/index.jsx +2 -2
  58. package/src/workflows/bench/WORKFLOW.md +51 -27
  59. 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 remains deduped and displayed key is not cleared', () => {
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' }), false);
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: { id: null, toolMode: 'auto', ask, abort: () => {} },
146
+ runtime,
139
147
  nextId: () => `id_${++seq}`,
140
148
  tuiDebug: () => {},
141
- LEAD_TURN_TIMEOUT_MS: 300000,
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: `Goal:` `Anchors:` `Allow/Forbid:` `Deliver:` `Verify:`
4
- (+`Stop:` for heavy-worker). Minimum chars, maximum info: omit background,
5
- motivation, role-known rules, repeated context/facts; split scope, don't pad.
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
- if (policy.firstResponseMs > 0 && startedAt && now - startedAt > policy.firstResponseMs) {
252
- return new AgentStallAbortError(`agent first response stale (${policy.firstResponseMs}ms)`);
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
  }