mixdog 0.9.32 → 0.9.34

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 (51) hide show
  1. package/package.json +4 -2
  2. package/scripts/compact-trigger-migration-smoke.mjs +37 -31
  3. package/scripts/provider-toolcall-test.mjs +535 -36
  4. package/src/agents/heavy-worker/AGENT.md +3 -4
  5. package/src/agents/worker/AGENT.md +2 -3
  6. package/src/repl.mjs +9 -1
  7. package/src/rules/shared/01-tool.md +4 -2
  8. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
  9. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
  15. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  19. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
  23. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
  24. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
  25. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
  27. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
  31. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
  32. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
  33. package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
  34. package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
  35. package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
  36. package/src/runtime/memory/lib/memory-embed.mjs +89 -8
  37. package/src/session-runtime/context-status.mjs +7 -6
  38. package/src/session-runtime/mcp-glue.mjs +5 -5
  39. package/src/session-runtime/plugin-mcp.mjs +36 -1
  40. package/src/session-runtime/resource-api.mjs +14 -9
  41. package/src/session-runtime/runtime-core.mjs +0 -10
  42. package/src/tui/app/extension-pickers.mjs +1 -1
  43. package/src/tui/app/model-picker.mjs +48 -12
  44. package/src/tui/app/route-pickers.mjs +4 -0
  45. package/src/tui/app/slash-dispatch.mjs +6 -1
  46. package/src/tui/app/use-transcript-window.mjs +13 -1
  47. package/src/tui/components/StatusLine.jsx +5 -5
  48. package/src/tui/dist/index.mjs +46 -15
  49. package/src/ui/statusline.mjs +4 -10
  50. package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
  51. package/src/vendor/statusline/src/gateway/route-meta.mjs +3 -2
@@ -27,6 +27,9 @@ export const SUMMARY_OUTPUT_TOKENS = 4_096;
27
27
  // - recall-fasttrack injection cap (loop.mjs recallTokenCap)
28
28
  // Keep them in lockstep; do not fork per-consumer ratios without a decision.
29
29
  export const CONTEXT_SHARE_RATIO = 0.10;
30
+ export const DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 90;
31
+ export const COMPACT_TARGET_MIN_TOKENS = 4_000;
32
+ export const COMPACT_SAFETY_PERCENT = 1.00;
30
33
  // Floor for the recall-injection cap so small-context models still get a
31
34
  // usable recall slice (cap = max(floor, contextWindow * CONTEXT_SHARE_RATIO)).
32
35
  export const RECALL_TOKEN_CAP_FLOOR_TOKENS = 2_048;
@@ -22,6 +22,9 @@ export {
22
22
  SUMMARY_PREFIX,
23
23
  SUMMARY_OUTPUT_TOKENS,
24
24
  CONTEXT_SHARE_RATIO,
25
+ DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
26
+ COMPACT_TARGET_MIN_TOKENS,
27
+ COMPACT_SAFETY_PERCENT,
25
28
  RECALL_TOKEN_CAP_FLOOR_TOKENS,
26
29
  COMPACT_SUMMARY_MIN_ROOM_TOKENS,
27
30
  COMPACT_TYPE_SEMANTIC,
@@ -1,5 +1,6 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
+ import { isAgentOwner } from '../agent-owner.mjs';
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Conservative, Unicode-aware token estimator.
@@ -225,13 +226,45 @@ export function resolveCompactBufferTokens(boundaryTokens, cfg = {}, opts = {})
225
226
  }
226
227
 
227
228
  export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens = 0) {
228
- const boundary = positiveTokenInt(boundaryTokens);
229
- if (!boundary) return null;
229
+ return resolveSessionCompactPolicy(sessionOrConfig, boundaryTokens).triggerTokens;
230
+ }
231
+
232
+ // Single source of truth for per-session compaction policy math. Manager
233
+ // (compactTriggerForSession), the turn loop (resolveWorkerCompactPolicy), and
234
+ // the /context gauge all derive their trigger/buffer from here so the numbers
235
+ // never diverge. Rules:
236
+ // - a truly-explicit sub-boundary auto-compact limit always wins
237
+ // (trigger = limit) for every session type;
238
+ // - agent-owned semantic sessions otherwise keep the default early-trigger
239
+ // buffer (config-driven, default 10% -> compact at 90% of the boundary);
240
+ // - main/user recall-fasttrack sessions have NO default buffer and compact on
241
+ // the boundary itself (100%).
242
+ // Returns the sanitized explicit limit (null when absent/legacy full-window)
243
+ // plus triggerTokens / bufferTokens / bufferRatio for the given boundary.
244
+ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens = 0) {
230
245
  const cfg = sessionOrConfig?.compaction || sessionOrConfig || {};
231
- const autoLimit = positiveTokenInt(sessionOrConfig?.autoCompactTokenLimit ?? cfg?.autoCompactTokenLimit);
232
- if (autoLimit && autoLimit < boundary) return Math.max(1, autoLimit);
233
- const buffer = resolveCompactBufferTokens(boundary, cfg);
234
- return Math.max(1, boundary - buffer);
246
+ const boundary = positiveTokenInt(boundaryTokens);
247
+ if (!boundary) {
248
+ return {
249
+ autoCompactTokenLimit: null,
250
+ triggerTokens: null,
251
+ bufferTokens: 0,
252
+ bufferRatio: resolveCompactBufferRatio(cfg),
253
+ };
254
+ }
255
+ const rawLimit = positiveTokenInt(sessionOrConfig?.autoCompactTokenLimit ?? cfg?.autoCompactTokenLimit);
256
+ const explicitLimit = rawLimit && rawLimit < boundary ? rawLimit : null;
257
+ let triggerTokens;
258
+ if (explicitLimit) {
259
+ triggerTokens = explicitLimit;
260
+ } else if (isAgentOwner(sessionOrConfig)) {
261
+ triggerTokens = Math.max(1, boundary - resolveCompactBufferTokens(boundary, cfg));
262
+ } else {
263
+ triggerTokens = boundary;
264
+ }
265
+ const bufferTokens = Math.max(0, boundary - triggerTokens);
266
+ const bufferRatio = bufferTokens / boundary;
267
+ return { autoCompactTokenLimit: explicitLimit, triggerTokens, bufferTokens, bufferRatio };
235
268
  }
236
269
 
237
270
  function stripSystemReminder(text) {
@@ -4,8 +4,7 @@
4
4
  // against live session state).
5
5
  import {
6
6
  estimateRequestReserveTokens,
7
- resolveCompactBufferRatio,
8
- resolveCompactBufferTokens,
7
+ resolveSessionCompactPolicy,
9
8
  } from '../context-utils.mjs';
10
9
  import {
11
10
  compactTypeIsRecallFastTrack,
@@ -13,18 +12,17 @@ import {
13
12
  DEFAULT_COMPACT_TYPE,
14
13
  DEFAULT_COMPACTION_KEEP_TOKENS,
15
14
  CONTEXT_SHARE_RATIO,
15
+ COMPACT_TARGET_MIN_TOKENS,
16
+ COMPACT_SAFETY_PERCENT,
16
17
  COMPACT_TYPE_RECALL_FASTTRACK,
17
18
  } from '../compact.mjs';
18
19
  import { positiveTokenInt, envFlag, envTokenInt } from './env.mjs';
19
20
  import { isAgentOwner } from '../../agent-owner.mjs';
20
21
 
21
- const COMPACT_SAFETY_PERCENT = 1.00;
22
22
  // Unified context-share rule (compact/constants.mjs CONTEXT_SHARE_RATIO): the
23
23
  // post-compaction target is 10% of the boundary/context window — the same 10%
24
24
  // the recall-fasttrack injection cap uses (loop.mjs recallTokenCap). One
25
25
  // number governs every "share of model context" budget.
26
- const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
27
- const COMPACT_TARGET_MIN_TOKENS = 4_000;
28
26
 
29
27
  function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
30
28
  // Types are hard-locked (agent -> semantic, main/user -> recall-fasttrack).
@@ -55,9 +53,9 @@ function resolveCompactTargetRatio(cfg = {}) {
55
53
  ?? cfg.targetFraction
56
54
  ?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
57
55
  ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
58
- ?? COMPACT_TARGET_RATIO;
56
+ ?? CONTEXT_SHARE_RATIO;
59
57
  const n = Number(raw);
60
- if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
58
+ if (!Number.isFinite(n) || n <= 0) return CONTEXT_SHARE_RATIO;
61
59
  return n > 1 ? n / 100 : n;
62
60
  }
63
61
  function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
@@ -92,22 +90,16 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
92
90
  : (explicitBoundary || contextWindow || autoLimit);
93
91
  if (!boundaryTokens) return null;
94
92
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
95
- // Only an explicit auto-compact limit STRICTLY BELOW the boundary acts as
96
- // the trigger. A persisted value == boundary (legacy derived full-window
97
- // autoCompactTokenLimit) would set autoTriggerTokens == boundary and
98
- // collapse/override the default trigger, so it is ignored in favor of the
99
- // default boundary trigger.
100
- const autoTriggerTokens = autoLimit && autoLimit < compactBoundaryTokens ? Math.max(1, autoLimit) : null;
101
- // Sanitized explicit limit: only a sub-boundary value is a real auto-compact
102
- // limit. Anything >= boundary is a legacy derived full-window artifact and
103
- // is reported as null so rememberCompactTelemetry does not re-persist it
104
- // back onto the session and re-collapse the buffer on the next turn.
105
- const explicitAutoCompactTokenLimit = autoTriggerTokens;
106
- const bufferTokens = autoTriggerTokens
107
- ? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
108
- : resolveCompactBufferTokens(compactBoundaryTokens, cfg);
109
- const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
110
- const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
93
+ // Shared session-compaction policy (context-utils): agent semantic keeps the
94
+ // default early-trigger buffer (90%); main/user compact on the boundary
95
+ // (100%); a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
96
+ // is the sanitized (null when legacy full-window) value so telemetry never
97
+ // re-persists a boundary-collapsing limit.
98
+ const policy = resolveSessionCompactPolicy(sessionRef, compactBoundaryTokens);
99
+ const explicitAutoCompactTokenLimit = policy.autoCompactTokenLimit;
100
+ const bufferTokens = policy.bufferTokens;
101
+ const bufferRatio = policy.bufferRatio;
102
+ const triggerTokens = policy.triggerTokens;
111
103
  const configuredReserve = positiveTokenInt(cfg.reservedTokens)
112
104
  || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
113
105
  || 0;
@@ -36,3 +36,31 @@ export function agentContextOverflowError({ stage, sessionId, sessionRef, model,
36
36
  messageTokensEst,
37
37
  }, cause);
38
38
  }
39
+
40
+ // Distinct from AgentContextOverflowError: the compaction pipeline itself
41
+ // failed (semantic-summary error, dead memory runtime, recall-fasttrack bail,
42
+ // etc.). This is NOT "latest turn cannot fit the context budget" — masking it
43
+ // as AGENT_CONTEXT_OVERFLOW hides the real failure and misroutes downstream
44
+ // overflow handling. Genuine provider send overflow keeps AgentContextOverflowError.
45
+ export class AgentCompactFailedError extends Error {
46
+ constructor({ stage, sessionId, provider, model }, cause) {
47
+ const target = [provider, model].filter(Boolean).join('/') || 'target model';
48
+ const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
49
+ super(`agent compact failed (${target}, stage=${stage || 'compact'})${causeMsg}`);
50
+ this.name = 'AgentCompactFailedError';
51
+ this.code = 'AGENT_COMPACT_FAILED';
52
+ this.sessionId = sessionId || null;
53
+ this.provider = provider || null;
54
+ this.model = model || null;
55
+ if (cause) this.cause = cause;
56
+ }
57
+ }
58
+
59
+ export function agentCompactFailedError({ stage, sessionId, sessionRef, model }, cause) {
60
+ return new AgentCompactFailedError({
61
+ stage,
62
+ sessionId,
63
+ provider: sessionRef?.provider || null,
64
+ model: sessionRef?.model || model || null,
65
+ }, cause);
66
+ }
@@ -128,6 +128,11 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
128
128
  messages,
129
129
  cwd: sessionRef?.cwd,
130
130
  limit: hydrateLimit,
131
+ // Pre-send fast-track compaction: these rows are about to be
132
+ // summarized away, so skip the bounded synchronous embedding-flush
133
+ // wait — kick the flush fire-and-forget. Mirrors the manual/auto-clear
134
+ // runner (manager/compaction-runner.mjs) embedWait:false policy.
135
+ embedWait: false,
131
136
  }, callerCtx);
132
137
  } catch (err) {
133
138
  ingestFailed = true;
@@ -17,6 +17,31 @@ export function _isMutationTool(name) {
17
17
  const n = _stripMcpPrefix(name);
18
18
  return n === 'apply_patch';
19
19
  }
20
+ // Side-effect-free read-only tools that stay parallel even after an earlier
21
+ // ordered mutation failed in the same batch. Everything NOT in this set is
22
+ // treated as ordered-gate-skippable (see _isOrderedGateSkippable): apply_patch,
23
+ // shell/bash_session, write/edit-style tools, and any (non-mixdog) MCP tool
24
+ // whose effects are unknown. Kept separate from _isMutationTool, which stays
25
+ // apply_patch-only for epoch-mutation counting and eager-dispatch gating.
26
+ export const ORDERED_GATE_SAFE_READONLY_TOOLS = new Set([
27
+ 'read',
28
+ 'find',
29
+ 'glob',
30
+ 'list',
31
+ 'grep',
32
+ 'code_graph',
33
+ 'explore',
34
+ 'search',
35
+ 'web_fetch',
36
+ ]);
37
+ // True when a LATER call in a batch must be skipped because an earlier ordered
38
+ // mutation (apply_patch) already failed. Conservative: only the known
39
+ // side-effect-free read-only tools above keep running; shell, write tools, and
40
+ // unknown MCP tools are skipped so they cannot act on unchanged/partially
41
+ // changed state.
42
+ export function _isOrderedGateSkippable(name) {
43
+ return !ORDERED_GATE_SAFE_READONLY_TOOLS.has(_stripMcpPrefix(name));
44
+ }
20
45
  export const SCOPED_CACHEABLE_TOOLS = new Set([
21
46
  'code_graph',
22
47
  'grep',
@@ -1,8 +1,14 @@
1
1
  // Context-window sizing, compaction target math, and session context-meta
2
2
  // resolution. Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { getModelMetadataSync } from '../../providers/model-catalog.mjs';
4
- import { resolveCompactTriggerTokens } from '../context-utils.mjs';
5
- import { COMPACT_TYPE_SEMANTIC, COMPACT_TYPE_RECALL_FASTTRACK, CONTEXT_SHARE_RATIO } from '../compact.mjs';
4
+ import { resolveSessionCompactPolicy } from '../context-utils.mjs';
5
+ import {
6
+ COMPACT_TYPE_SEMANTIC,
7
+ COMPACT_TYPE_RECALL_FASTTRACK,
8
+ CONTEXT_SHARE_RATIO,
9
+ DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT,
10
+ COMPACT_TARGET_MIN_TOKENS,
11
+ } from '../compact.mjs';
6
12
  import { isAgentOwner } from '../../agent-owner.mjs';
7
13
 
8
14
  // Known context windows for the current-generation models this plugin
@@ -89,14 +95,12 @@ export function preserveBufferConfigFields(cfg = {}) {
89
95
  }
90
96
  return out;
91
97
  }
92
- const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
93
- const COMPACT_TARGET_MIN_TOKENS = 4_000;
94
98
  function compactTargetRatio() {
95
99
  const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
96
100
  ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
97
- ?? COMPACT_TARGET_RATIO;
101
+ ?? CONTEXT_SHARE_RATIO;
98
102
  const n = Number(raw);
99
- if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
103
+ if (!Number.isFinite(n) || n <= 0) return CONTEXT_SHARE_RATIO;
100
104
  return n > 1 ? n / 100 : n;
101
105
  }
102
106
  function compactTargetTokensForBoundary(boundaryTokens) {
@@ -115,7 +119,8 @@ function defaultEffectiveContextWindowPercent(provider) {
115
119
  // Gateway/statusline route metadata reserves a universal 10% headroom from
116
120
  // the raw catalog window. Keep session compaction on the same effective
117
121
  // capacity so /context, the TUI statusline, and gateway telemetry agree.
118
- return 90;
122
+ void provider;
123
+ return DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT;
119
124
  }
120
125
  const PROVIDER_SYNTHETIC_CONTEXT_DEFAULT = 1_000_000;
121
126
  function providerRawContextWindow(info, catalogInfo) {
@@ -201,7 +206,10 @@ export function resolveSessionContextMeta(provider, model, seed = {}) {
201
206
  };
202
207
  }
203
208
  export function compactTriggerForSession(session, boundaryTokens) {
204
- return resolveCompactTriggerTokens(session, boundaryTokens);
209
+ // Delegates to the shared session-compaction policy (context-utils):
210
+ // agent semantic -> 90% (default buffer), main/user -> 100% (boundary),
211
+ // truly-explicit sub-boundary limit wins.
212
+ return resolveSessionCompactPolicy(session, boundaryTokens).triggerTokens;
205
213
  }
206
214
  export function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
207
215
  const boundary = positiveContextWindow(boundaryTokens);
@@ -26,6 +26,8 @@ import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
26
26
  import { messagesArrayChanged } from './loop/tool-helpers.mjs';
27
27
  import { normalizeUsage, addUsage } from './loop/usage.mjs';
28
28
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
29
+ import { agentCompactFailedError } from './loop/context-overflow.mjs';
30
+ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
29
31
  import { traceAgentCompact, messagePrefixHash } from '../agent-trace.mjs';
30
32
  import { bumpUsageMetricsEpoch } from './manager.mjs';
31
33
 
@@ -252,6 +254,31 @@ export async function runPreSendCompactPass(state) {
252
254
  );
253
255
  } catch { /* best-effort */ }
254
256
  } else {
257
+ // A genuine cancellation/abort surfaced from the compact
258
+ // pipeline is NOT a context overflow. The recall-fasttrack
259
+ // pipeline (loop/recall-fasttrack.mjs) deliberately rethrows
260
+ // the original abort error unchanged so the session records a
261
+ // clean cancellation — and the manual/auto-clear runner
262
+ // (manager/compaction-runner.mjs) likewise never fabricates an
263
+ // AGENT_CONTEXT_OVERFLOW for an aborted compact. Mirror that
264
+ // here: preserve the real error (code/name/cause intact)
265
+ // instead of masking it as overflow. Detection is narrow on
266
+ // purpose — signal.aborted or a true AbortError — so the
267
+ // recall pipeline's SYNTHETIC "…aborted: memory … ; head
268
+ // preserved" failure (a real compact failure, message text
269
+ // aside) still escalates to overflow below.
270
+ if (signal?.aborted === true
271
+ || compactErr?.name === 'AbortError'
272
+ || compactErr?.code === 'ABORT_ERR'
273
+ || compactErr?.code === 'ABORT') {
274
+ try {
275
+ process.stderr.write(
276
+ `[loop] pre-send compact cancelled (sess=${sessionId || 'unknown'}): ` +
277
+ `${compactErr?.message || compactErr}\n`,
278
+ );
279
+ } catch { /* best-effort */ }
280
+ throw compactErr;
281
+ }
255
282
  const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
256
283
  const semanticFailMsg = semanticCompactError?.message || null;
257
284
  const recallFailMsg = recallFastTrackError?.message || null;
@@ -324,14 +351,33 @@ export async function runPreSendCompactPass(state) {
324
351
  durationMs: Date.now() - compactStartedAt,
325
352
  error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
326
353
  });
327
- throw agentContextOverflowError({
354
+ // Only a GENUINE provider context-overflow surfaced from the
355
+ // compact pipeline (e.g. the semantic-summary send itself
356
+ // overflowed the model window) deserves AGENT_CONTEXT_OVERFLOW.
357
+ // Every other compact-stage failure (dead memory runtime,
358
+ // recall-fasttrack bail, semantic summary error) is a compact
359
+ // failure, not "latest turn cannot fit" — mislabeling it as
360
+ // overflow hides the real cause and misroutes downstream
361
+ // overflow handling. Surface an explicit compact-failed error.
362
+ const genuineOverflow = compactErr?.code === 'AGENT_CONTEXT_OVERFLOW'
363
+ || compactErr?.name === 'AgentContextOverflowError'
364
+ || isContextOverflowError(compactErr);
365
+ if (genuineOverflow) {
366
+ throw agentContextOverflowError({
367
+ stage: 'pre_send',
368
+ sessionId,
369
+ sessionRef,
370
+ model,
371
+ budgetTokens: compactBudgetTokens,
372
+ reserveTokens: compactPolicy.reserveTokens,
373
+ messageTokensEst,
374
+ }, compactErr);
375
+ }
376
+ throw agentCompactFailedError({
328
377
  stage: 'pre_send',
329
378
  sessionId,
330
379
  sessionRef,
331
380
  model,
332
- budgetTokens: compactBudgetTokens,
333
- reserveTokens: compactPolicy.reserveTokens,
334
- messageTokensEst,
335
381
  }, compactErr);
336
382
  }
337
383
  }
@@ -24,7 +24,7 @@ import {
24
24
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
25
25
  import {
26
26
  _stripMcpPrefix, _isReadTool, _isMutationTool, _isScopedCacheableTool,
27
- _isShellTool, _intraTurnSig,
27
+ _isShellTool, _isOrderedGateSkippable, _intraTurnSig,
28
28
  } from './loop/tool-classify.mjs';
29
29
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
30
30
  import { executeTool } from './loop/tool-exec.mjs';
@@ -80,6 +80,17 @@ export async function processToolBatch(ctx) {
80
80
  // between two tool results of the same multi-tool turn (which would put a
81
81
  // user message between tool(A) and tool(B) and break provider pairing).
82
82
  const _batchNewMessages = [];
83
+ // Ordered-mutation batch gate. apply_patch is a mutation tool (never
84
+ // eager-dispatchable), so multiple apply_patch calls in ONE assistant
85
+ // turn already execute serially in call-index order via this loop.
86
+ // This flag records the FIRST ordered mutation whose execution failed
87
+ // in this batch; every LATER apply_patch in the same batch is then
88
+ // skipped (not executed) because its edits may depend on the failed
89
+ // one and applying them against unchanged/partially-changed files
90
+ // risks corrupt or misplaced writes. Only apply_patch is gated —
91
+ // non-mutation tools (reads/grep/shell/...) keep their normal
92
+ // parallelism and behavior. Reset per batch (per assistant turn).
93
+ let _orderedMutationFailed = null;
83
94
  for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
84
95
  const call = calls[callIndex];
85
96
  if (isBuiltinTool(call.name)) {
@@ -151,6 +162,22 @@ export async function processToolBatch(ctx) {
151
162
  continue;
152
163
  }
153
164
  }
165
+ // Ordered-mutation skip: an earlier apply_patch in THIS batch failed,
166
+ // so this later apply_patch is skipped rather than executed. Restore
167
+ // its full patch body first (this call never ran) so the model can
168
+ // re-issue it cleanly in a new turn instead of copying back a
169
+ // `[mixdog compacted …]` placeholder. Emits a matching is_error
170
+ // tool_result so the assistant tool_use is not orphaned.
171
+ if (_orderedMutationFailed && _isOrderedGateSkippable(call.name)) {
172
+ if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
173
+ pushToolResultMessage({
174
+ role: 'tool',
175
+ content: `Error: [ordered-mutation-skip] an earlier apply_patch in this same tool batch (call index ${_orderedMutationFailed.index + 1}) failed; ordered mutations in one batch are all-or-nothing after a failure, so this later \`${call.name}\` was NOT executed — it may depend on the failed mutation and running it now against unchanged/partially-changed state could corrupt files. Re-issue it in a new turn after resolving the earlier failure.`,
176
+ toolCallId: call.id,
177
+ toolKind: 'error',
178
+ });
179
+ continue;
180
+ }
154
181
  if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
155
182
  let result;
156
183
  let toolStartedAt;
@@ -416,9 +443,34 @@ export async function processToolBatch(ctx) {
416
443
  // This block runs unconditionally (not gated on _executeOk or _resultKind).
417
444
  if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
418
445
  clearReadDedupSession(sessionId);
446
+ // Scoped caches (grep/glob/list/code_graph) are refreshed only in
447
+ // the success-gated block above, so a FAILED/errored patch would
448
+ // otherwise leave later non-mutation tools in this batch reading
449
+ // stale scoped-cache entries for the (possibly partially-written)
450
+ // files. Invalidate targeted paths when the diff parses, else full
451
+ // wipe — file state is unknown after an error exit.
452
+ const _failBaseArg = call.arguments?.base_path;
453
+ const _failBase = (typeof _failBaseArg === 'string' && _failBaseArg.length > 0)
454
+ ? (isAbsolute(_failBaseArg) ? _failBaseArg : resolvePath(cwd || process.cwd(), _failBaseArg))
455
+ : (cwd || process.cwd());
456
+ const _failTouched = extractTouchedPathsFromPatch(call.arguments?.patch);
457
+ if (_failTouched.length > 0) {
458
+ clearScopedToolsForSessionPaths(sessionId, _failTouched, _failBase);
459
+ for (const _p of _failTouched) invalidatePrefetchCache(_p, _failBase);
460
+ } else {
461
+ clearScopedToolsForSession(sessionId);
462
+ }
419
463
  }
420
464
  if (_isMutationTool(call.name)) {
421
465
  epoch.mutation += 1;
466
+ // Record the first failed ordered mutation in this batch so any
467
+ // LATER apply_patch is skipped by the gate at the top of the
468
+ // loop. Keyed on exec outcome (not post-processing): a mutation
469
+ // whose write succeeded but post-processing threw still landed
470
+ // on disk, so it must NOT block subsequent ordered patches.
471
+ if ((!_executeOk || _resultKind === 'error') && !_orderedMutationFailed) {
472
+ _orderedMutationFailed = { index: callIndex, callId: call.id, name: call.name };
473
+ }
422
474
  }
423
475
  // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
424
476
  // that throws or fails partway can still leave stale find_symbol / grep entries.
@@ -567,6 +567,16 @@ async function waitForGenericTask(taskId, { timeoutMs = 30_000, pollMs = 250, co
567
567
  };
568
568
  }
569
569
 
570
+ function renderTaskCancelSuccess(taskId, task) {
571
+ const surface = task?.surface || 'task';
572
+ const operation = task?.operation || 'run';
573
+ return [
574
+ 'status: completed',
575
+ `task_id: ${taskId}`,
576
+ `cancelled: ${surface}/${operation}`,
577
+ ].join('\n');
578
+ }
579
+
570
580
  export async function executeTaskTool(args, options = {}) {
571
581
  const action = typeof args.action === 'string' ? args.action.toLowerCase() : (args.task_id ? 'wait' : 'list');
572
582
  if (action === 'list') return renderBackgroundTaskList({ context: options });
@@ -597,10 +607,10 @@ export async function executeTaskTool(args, options = {}) {
597
607
  cancelBackgroundShellJobWatch(taskId);
598
608
  clearShellJobNotifyCtx(taskId);
599
609
  cancelBackgroundTask(taskId, 'cancelled by task control');
600
- return job ? renderBackgroundTask(getBackgroundTask(taskId, { context: options }) || task, { includeResult: true }) : buildJobNotFoundMessage(taskId);
610
+ return job ? renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task) : buildJobNotFoundMessage(taskId);
601
611
  }
602
612
  cancelBackgroundTask(taskId, 'cancelled by task control');
603
- return renderBackgroundTask(getBackgroundTask(taskId, { context: options }) || task, { includeResult: true });
613
+ return renderTaskCancelSuccess(taskId, getBackgroundTask(taskId, { context: options }) || task);
604
614
  }
605
615
 
606
616
  if (action !== 'wait') {