mixdog 0.9.95 → 0.9.96
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/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/MIT.txt +56 -0
- package/LICENSES/codex-NOTICE.txt +6 -0
- package/NOTICE.md +46 -9
- package/package.json +12 -9
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/shared/01-tool.md +20 -29
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
- package/src/runtime/agent/orchestrator/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
- package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/lib/config.mjs +2 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +2 -2
- package/src/runtime/memory/index.mjs +5 -38
- package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
- package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
- package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
- package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
- package/src/runtime/memory/lib/http-router.mjs +8 -0
- package/src/runtime/memory/lib/ko-morph.mjs +3 -20
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/tool-defs.mjs +1 -5
- package/src/runtime/search/tool-defs.mjs +2 -2
- package/src/runtime/shared/agent-route-config.mjs +119 -0
- package/src/runtime/shared/atomic-file.mjs +80 -4
- package/src/runtime/shared/channel-notification-routing.mjs +2 -2
- package/src/runtime/shared/child-guardian.mjs +204 -108
- package/src/runtime/shared/child-spawn-gate.mjs +60 -31
- package/src/runtime/shared/config.mjs +130 -44
- package/src/runtime/shared/resource-admission.mjs +146 -97
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-surface.mjs +0 -5
- package/src/runtime/shared/turn-snapshot.mjs +31 -5
- package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/runtime-core.mjs +11 -11
- package/src/session-runtime/runtime-tunables.mjs +4 -4
- package/src/session-runtime/session-lifecycle.mjs +9 -2
- package/src/session-runtime/session-turn-api.mjs +41 -9
- package/src/session-runtime/settings-api.mjs +5 -17
- package/src/session-runtime/tool-defs.mjs +3 -3
- package/src/session-runtime/workflow-agents-api.mjs +55 -81
- package/src/session-runtime/workflow.mjs +14 -24
- package/src/standalone/agent-dispatch-broker.mjs +198 -0
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
- package/src/standalone/agent-tool/tool-def.mjs +0 -14
- package/src/standalone/backend-daemon.mjs +97 -36
- package/src/standalone/channel-admin.mjs +12 -12
- package/src/standalone/channel-daemon-client.mjs +17 -1
- package/src/standalone/channel-daemon-transport.mjs +90 -8
- package/src/standalone/engine-daemon-client.mjs +380 -205
- package/src/standalone/engine-daemon-protocol.mjs +3 -4
- package/src/standalone/engine-daemon-service.mjs +378 -134
- package/src/standalone/engine-daemon-transport.mjs +268 -77
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/session-protocol.mjs +188 -0
- package/src/tui/App.jsx +30 -17
- package/src/tui/app/app-view.jsx +5 -2
- package/src/tui/app/core-memory-picker.mjs +19 -19
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +1 -2
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +46 -41
- package/src/tui/app/resume-picker.mjs +2 -2
- package/src/tui/app/route-pickers.mjs +3 -1
- package/src/tui/app/settings-picker.mjs +6 -8
- package/src/tui/app/transcript-window.mjs +38 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
- package/src/tui/app/use-prompt-handlers.mjs +86 -21
- package/src/tui/app/use-prompt-hint.mjs +3 -2
- package/src/tui/app/use-prompt-queue-history.mjs +10 -3
- package/src/tui/app/use-transcript-scroll.mjs +9 -2
- package/src/tui/app/use-transcript-window.mjs +50 -17
- package/src/tui/components/PromptInput.jsx +71 -38
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
- package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
- package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
- package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
- package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
- package/src/tui/dist/index.mjs +920 -1592
- package/src/tui/engine/live-share.mjs +34 -0
- package/src/tui/engine/session-api-ext.mjs +1 -4
- package/src/tui/engine/session-api.mjs +103 -33
- package/src/tui/engine/session-flow.mjs +7 -1
- package/src/tui/engine/tool-card-results.mjs +4 -2
- package/src/tui/engine/tool-result-text.mjs +10 -0
- package/src/tui/engine/turn.mjs +9 -69
- package/src/tui/engine-local-session.mjs +47 -31
- package/src/tui/index.jsx +1 -2
- package/src/tui/spinner-meta.mjs +4 -4
- package/src/tui/spinner-verbs.mjs +51 -3
- package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
// pass), and unrecoverable errors throw. Behavior identical to the inline
|
|
6
6
|
// try/catch it replaced.
|
|
7
7
|
import { appendAgentTrace } from '../agent-trace.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
classifyError,
|
|
10
|
+
isContextOverflowError,
|
|
11
|
+
isNonTerminalStreamClose,
|
|
12
|
+
jitterDelayMs,
|
|
13
|
+
} from '../providers/retry-classifier.mjs';
|
|
9
14
|
import { setTimeout as sleepMs } from 'timers/promises';
|
|
10
15
|
import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
|
|
11
16
|
import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
|
|
12
17
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
13
18
|
import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
|
|
14
19
|
import { isOutputLimitStopReason } from './loop/termination.mjs';
|
|
20
|
+
import { isVisibleStreamProgress } from '../../../shared/stream-progress.mjs';
|
|
15
21
|
|
|
16
22
|
function normalizedIncompleteUsage(raw) {
|
|
17
23
|
if (!raw || typeof raw !== 'object') return undefined;
|
|
@@ -45,9 +51,35 @@ function normalizedIncompleteUsage(raw) {
|
|
|
45
51
|
// the identical request is side-effect-free, so wait out the blip and retry
|
|
46
52
|
// the send at the loop level. Two attempts, 5s/15s — combined with the
|
|
47
53
|
// provider envelope this covers ~50s outages before failing honestly.
|
|
48
|
-
|
|
54
|
+
// Env-overridable (comma-separated ms) so tests can drive the ladder without
|
|
55
|
+
// real waits and an operator can widen the window for a flaky uplink; the
|
|
56
|
+
// LENGTH of the list is the retry budget, exactly like codex's
|
|
57
|
+
// stream_max_retries + backoff table.
|
|
58
|
+
const TRANSPORT_RETRY_BACKOFF_MS = Object.freeze((() => {
|
|
59
|
+
const raw = process.env.MIXDOG_TRANSPORT_RETRY_BACKOFF_MS;
|
|
60
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
61
|
+
const parsed = raw.split(',')
|
|
62
|
+
.map((value) => Number(value.trim()))
|
|
63
|
+
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
64
|
+
if (parsed.length) return parsed;
|
|
65
|
+
}
|
|
66
|
+
// Three steps (~50s of outage) instead of two: the provider envelope
|
|
67
|
+
// already covers short blips, so this ladder exists for the long ones.
|
|
68
|
+
// codex allows 5 stream reconnects and cc 10 request retries.
|
|
69
|
+
return [5_000, 15_000, 30_000];
|
|
70
|
+
})());
|
|
49
71
|
export const TRANSPORT_RETRY_MAX = TRANSPORT_RETRY_BACKOFF_MS.length;
|
|
50
72
|
|
|
73
|
+
// codex jitters every backoff by 0.9–1.1 so parallel workers that lose the
|
|
74
|
+
// same uplink do not resume in lockstep; our provider layers already jitter
|
|
75
|
+
// (10% WS / 20% shared), this brings the loop ladder in line.
|
|
76
|
+
const TRANSPORT_RETRY_JITTER_RATIO = 0.1;
|
|
77
|
+
|
|
78
|
+
function transportRetryWaitMs(attemptIndex) {
|
|
79
|
+
const base = TRANSPORT_RETRY_BACKOFF_MS[attemptIndex];
|
|
80
|
+
return jitterDelayMs(base, TRANSPORT_RETRY_JITTER_RATIO);
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
export async function sendWithRecovery(ctx) {
|
|
52
84
|
const {
|
|
53
85
|
provider, messages, model, sendTools, tools, opts,
|
|
@@ -56,7 +88,8 @@ export async function sendWithRecovery(ctx) {
|
|
|
56
88
|
} = ctx;
|
|
57
89
|
let response;
|
|
58
90
|
// Bench-only turn timing (MIXDOG_TURN_TIMING=1): one stderr line per
|
|
59
|
-
// provider request — TTFT (first
|
|
91
|
+
// provider request — TTFT (first visible model progress) and total stream
|
|
92
|
+
// time. Transport/header and response-created acknowledgements do not count.
|
|
60
93
|
// Inert unless the env flag is set; used to profile harness vs model
|
|
61
94
|
// latency in Terminal-Bench runs.
|
|
62
95
|
const turnT0 = process.env.MIXDOG_TURN_TIMING === '1' ? Date.now() : 0;
|
|
@@ -67,7 +100,7 @@ export async function sendWithRecovery(ctx) {
|
|
|
67
100
|
timedOpts = {
|
|
68
101
|
...(opts || {}),
|
|
69
102
|
onStreamDelta: (kind) => {
|
|
70
|
-
if (!turnFirstDelta) turnFirstDelta = Date.now();
|
|
103
|
+
if (!turnFirstDelta && isVisibleStreamProgress(kind)) turnFirstDelta = Date.now();
|
|
71
104
|
if (prevDelta) prevDelta(kind);
|
|
72
105
|
},
|
|
73
106
|
};
|
|
@@ -92,6 +125,22 @@ export async function sendWithRecovery(ctx) {
|
|
|
92
125
|
// overflow-retry branch intentionally clears opts.onToolCall, and that
|
|
93
126
|
// clear must survive the restore.
|
|
94
127
|
const relayWitness = { textEmitted: false, toolCallsDispatched: 0 };
|
|
128
|
+
// Caller-visible reconnect progress for LOOP-level replays, mirroring the
|
|
129
|
+
// transports' own emitReconnectProgress (openai-oauth-ws). Without it the
|
|
130
|
+
// 5s/15s wait rendered as a frozen turn with no explanation, while codex
|
|
131
|
+
// surfaces "Reconnecting... n/max" through notify_stream_error and cc
|
|
132
|
+
// yields a SystemAPIErrorMessage for the same situation.
|
|
133
|
+
const emitLoopReconnectProgress = (attempt, waitMs, classifier) => {
|
|
134
|
+
try {
|
|
135
|
+
opts?.onStageChange?.('reconnecting', {
|
|
136
|
+
attempt,
|
|
137
|
+
max: TRANSPORT_RETRY_MAX,
|
|
138
|
+
classifier: classifier || null,
|
|
139
|
+
waitMs,
|
|
140
|
+
message: `Reconnecting... ${attempt}/${TRANSPORT_RETRY_MAX}`,
|
|
141
|
+
});
|
|
142
|
+
} catch { /* progress reporting must never break recovery */ }
|
|
143
|
+
};
|
|
95
144
|
const prevOnTextDelta = typeof opts?.onTextDelta === 'function' ? opts.onTextDelta : null;
|
|
96
145
|
const prevOnToolCall = typeof opts?.onToolCall === 'function' ? opts.onToolCall : null;
|
|
97
146
|
const witnessedOnTextDelta = prevOnTextDelta
|
|
@@ -116,7 +165,15 @@ export async function sendWithRecovery(ctx) {
|
|
|
116
165
|
}
|
|
117
166
|
try {
|
|
118
167
|
try {
|
|
119
|
-
|
|
168
|
+
const admittedOpts = timedOpts?.admissionOwner
|
|
169
|
+
? timedOpts
|
|
170
|
+
: { ...(timedOpts || {}), admissionOwner: sessionId };
|
|
171
|
+
response = await provider.send(
|
|
172
|
+
messages,
|
|
173
|
+
model,
|
|
174
|
+
sendTools.length ? sendTools : undefined,
|
|
175
|
+
admittedOpts,
|
|
176
|
+
);
|
|
120
177
|
logTurnTiming('ok');
|
|
121
178
|
} catch (sendErr) {
|
|
122
179
|
logTurnTiming(`err:${sendErr?.code || sendErr?.name || 'unknown'}`);
|
|
@@ -220,7 +277,7 @@ export async function sendWithRecovery(ctx) {
|
|
|
220
277
|
transportRetriesUsed < TRANSPORT_RETRY_MAX
|
|
221
278
|
&& await retractExposedTextForReplay()
|
|
222
279
|
) {
|
|
223
|
-
const waitMs =
|
|
280
|
+
const waitMs = transportRetryWaitMs(transportRetriesUsed);
|
|
224
281
|
try {
|
|
225
282
|
process.stderr.write(
|
|
226
283
|
`[loop] exposed-text stall retracted (sess=${sessionId || 'unknown'} `
|
|
@@ -238,6 +295,11 @@ export async function sendWithRecovery(ctx) {
|
|
|
238
295
|
partialContentLen: sendErr.partialContent.length,
|
|
239
296
|
});
|
|
240
297
|
} catch { /* best-effort */ }
|
|
298
|
+
emitLoopReconnectProgress(
|
|
299
|
+
transportRetriesUsed + 1,
|
|
300
|
+
waitMs,
|
|
301
|
+
outcome.stallObserved ? 'stream_stalled' : 'stream_closed',
|
|
302
|
+
);
|
|
241
303
|
await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
|
|
242
304
|
return { action: 'retry_transport' };
|
|
243
305
|
}
|
|
@@ -332,12 +394,23 @@ export async function sendWithRecovery(ctx) {
|
|
|
332
394
|
&& (
|
|
333
395
|
(outcome.replaySafe === true && classifyError(sendErr) === 'transient')
|
|
334
396
|
|| (
|
|
335
|
-
|
|
397
|
+
// classifyError reports 'permanent' the moment anything
|
|
398
|
+
// was exposed, so an exposed disconnect could never
|
|
399
|
+
// reach the retraction path: name the transport
|
|
400
|
+
// symptoms directly (stall OR non-terminal close).
|
|
401
|
+
(classifyError(sendErr) === 'transient'
|
|
402
|
+
|| outcome.stallObserved === true
|
|
403
|
+
// Same inversion for the truncated-stream class
|
|
404
|
+
// (anthropic message_start-without-message_stop,
|
|
405
|
+
// gemini/compat EOF): classifyError calls it
|
|
406
|
+
// 'transient' only while nothing was exposed.
|
|
407
|
+
|| outcome.truncatedStream === true
|
|
408
|
+
|| isNonTerminalStreamClose(sendErr))
|
|
336
409
|
&& await retractExposedTextForReplay()
|
|
337
410
|
)
|
|
338
411
|
)
|
|
339
412
|
) {
|
|
340
|
-
const waitMs =
|
|
413
|
+
const waitMs = transportRetryWaitMs(transportRetriesUsed);
|
|
341
414
|
try {
|
|
342
415
|
process.stderr.write(
|
|
343
416
|
`[loop] transient send failure with no observed output (sess=${sessionId || 'unknown'} `
|
|
@@ -356,6 +429,11 @@ export async function sendWithRecovery(ctx) {
|
|
|
356
429
|
status: sendErr?.status ?? null,
|
|
357
430
|
});
|
|
358
431
|
} catch { /* best-effort */ }
|
|
432
|
+
emitLoopReconnectProgress(
|
|
433
|
+
transportRetriesUsed + 1,
|
|
434
|
+
waitMs,
|
|
435
|
+
sendErr?.retryClassifier || sendErr?.midstreamClassifier || sendErr?.code || null,
|
|
436
|
+
);
|
|
359
437
|
await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
|
|
360
438
|
return { action: 'retry_transport' };
|
|
361
439
|
} else
|
|
@@ -511,6 +511,18 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
511
511
|
return options.rebuildIfMissing === false ? [] : (scan() ?? indexRows ?? []);
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/** Exact, fail-closed existence check for a durable session address.
|
|
515
|
+
* Summary indexes and desktop metadata are presentation caches; neither may
|
|
516
|
+
* make a missing sessions/<id>.json record addressable again. */
|
|
517
|
+
export function storedSessionExists(id) {
|
|
518
|
+
const sessionId = String(id || '').trim();
|
|
519
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return false;
|
|
520
|
+
const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
|
|
521
|
+
if (read.state !== PROBE_PRESENT) return false;
|
|
522
|
+
const record = readTopLevelLifecycleRecord(read.text);
|
|
523
|
+
return !isLifecycleUnreadable(record) && record.id === sessionId;
|
|
524
|
+
}
|
|
525
|
+
|
|
514
526
|
/** Read exactly one persisted session for a visible desktop pane. This never
|
|
515
527
|
* enumerates siblings. Normal reads stay independent of runtime ownership;
|
|
516
528
|
* interrupted turns conditionally use the same durable reconnect recovery as
|
|
@@ -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,
|
|
27
|
+
_isShellTool, _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';
|
|
@@ -103,12 +103,6 @@ export async function processToolBatch(ctx) {
|
|
|
103
103
|
if (message?.toolCallId) _batchToolResultByCallId.set(message.toolCallId, message);
|
|
104
104
|
else _batchToolResultsWithoutId.push(message);
|
|
105
105
|
};
|
|
106
|
-
// Ordered-mutation batch gate. Each apply_patch is an eager-dispatch
|
|
107
|
-
// barrier: side effects overlap within the current segment, but no
|
|
108
|
-
// later segment starts before the loop crosses the patch in model
|
|
109
|
-
// order. A failed patch prevents every unstarted later side effect;
|
|
110
|
-
// known read-only calls may still complete for diagnostics.
|
|
111
|
-
let _orderedMutationFailed = null;
|
|
112
106
|
for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
|
|
113
107
|
const call = calls[callIndex];
|
|
114
108
|
if (isBuiltinTool(call.name)) {
|
|
@@ -202,22 +196,6 @@ export async function processToolBatch(ctx) {
|
|
|
202
196
|
continue;
|
|
203
197
|
}
|
|
204
198
|
}
|
|
205
|
-
// Ordered-mutation skip: an earlier apply_patch in THIS batch failed,
|
|
206
|
-
// so this later side-effect call is skipped rather than executed.
|
|
207
|
-
// Restore a later patch's full body first so it can be re-issued.
|
|
208
|
-
// `skipped` is deliberate: the call never started, so it is neither
|
|
209
|
-
// a tool failure nor a cancellation. The earlier patch remains the
|
|
210
|
-
// structural failure that drives retry/stop-hook behavior.
|
|
211
|
-
if (_orderedMutationFailed && _isOrderedGateSkippable(call.name) && !pending.has(call.id)) {
|
|
212
|
-
if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
|
|
213
|
-
_stageToolResultMessage({
|
|
214
|
-
role: 'tool',
|
|
215
|
-
content: `[prerequisite-failed] an earlier apply_patch in this same tool batch (call index ${_orderedMutationFailed.index + 1}) failed, so this later \`${call.name}\` was skipped without starting. Re-issue it after resolving the patch failure.`,
|
|
216
|
-
toolCallId: call.id,
|
|
217
|
-
toolKind: 'skipped',
|
|
218
|
-
});
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
199
|
if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
|
|
222
200
|
let result;
|
|
223
201
|
let toolStartedAt;
|
|
@@ -279,8 +257,8 @@ export async function processToolBatch(ctx) {
|
|
|
279
257
|
_executeOk = true;
|
|
280
258
|
} else {
|
|
281
259
|
// Fallback for providers that don't stream tool calls early:
|
|
282
|
-
// start the whole remaining batch in parallel
|
|
283
|
-
//
|
|
260
|
+
// start the whole remaining batch in parallel, including
|
|
261
|
+
// apply_patch and shell.
|
|
284
262
|
if (isParallelDispatchable(call.name)) {
|
|
285
263
|
startEagerRun(calls, callIndex, _duplicateCallIds);
|
|
286
264
|
}
|
|
@@ -512,14 +490,6 @@ export async function processToolBatch(ctx) {
|
|
|
512
490
|
}
|
|
513
491
|
if (_isMutationTool(call.name)) {
|
|
514
492
|
epoch.mutation += 1;
|
|
515
|
-
// Record the first failed ordered mutation in this batch so any
|
|
516
|
-
// later side effect is skipped by the gate at the top of the
|
|
517
|
-
// loop. Keyed on exec outcome (not post-processing): a mutation
|
|
518
|
-
// whose write succeeded but post-processing threw still landed
|
|
519
|
-
// on disk, so it must NOT block subsequent ordered patches.
|
|
520
|
-
if ((!_executeOk || _resultKind === 'error') && !_orderedMutationFailed) {
|
|
521
|
-
_orderedMutationFailed = { index: callIndex, callId: call.id, name: call.name };
|
|
522
|
-
}
|
|
523
493
|
}
|
|
524
494
|
// Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
|
|
525
495
|
// that throws or fails partway can still leave stale find_symbol / grep entries.
|
|
@@ -52,11 +52,10 @@ function maybeSweepStaleOffloadDirs() {
|
|
|
52
52
|
void sweepStaleOffloadDirs();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// Per-tool persistence limits
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// (CC BASH_MAX_OUTPUT_LENGTH default). Read/head/tail/diff stay inline
|
|
55
|
+
// Per-tool persistence limits are per-tool maxResultSizeChars values rather
|
|
56
|
+
// than a single global value: grep persists at 20k, glob and list/find_* at
|
|
57
|
+
// the 50k system default (deliberately tighter than the common 100k), and
|
|
58
|
+
// shell/bash_session/task at 30k. Read/head/tail/diff stay inline
|
|
60
59
|
// (Infinity) — they are self-bound by FileRead semantics and the upstream
|
|
61
60
|
// READ_MAX_SIZE_BYTES cap, so persisting to a sidecar to be re-read would be
|
|
62
61
|
// circular. These values keep context-rich IO tools from turning into "read
|
|
@@ -3,7 +3,7 @@ import { getHiddenAgent } from './internal-agents.mjs';
|
|
|
3
3
|
const SECOND_MS = 1000;
|
|
4
4
|
const MIN_PROVIDER_TIMEOUT_MS = 30_000;
|
|
5
5
|
|
|
6
|
-
const STALL_TICK_MS = 15_000;
|
|
6
|
+
export const STALL_TICK_MS = 15_000;
|
|
7
7
|
const DEFAULT_STALL_WARN_S = 300;
|
|
8
8
|
const DEFAULT_STALL_ABORT_S = 600;
|
|
9
9
|
// First-byte (no-stream-delta) abort for the agent stall watchdog. A wedged
|
|
@@ -65,7 +65,7 @@ const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, D
|
|
|
65
65
|
// JS setTimeout / PS WaitForExit(ms) are 32-bit: a delay above 2^31-1 wraps and
|
|
66
66
|
// fires immediately. Hard ceiling (~24.8 days) for an uncapped explicit timeout.
|
|
67
67
|
const TIMER_MAX_MS = 2_147_483_647;
|
|
68
|
-
const IDLE_TIMEOUT_MS =
|
|
68
|
+
const IDLE_TIMEOUT_MS = 2 * 60_000;
|
|
69
69
|
const MAX_SESSIONS = 10;
|
|
70
70
|
const STDERR_DRAIN_MS = 25;
|
|
71
71
|
const STDERR_DRAIN_MAX_MS = 250;
|
|
@@ -364,7 +364,6 @@ function _spawnSession(id, initialCwd = process.cwd(), resourceLease = null) {
|
|
|
364
364
|
childPid: proc.pid,
|
|
365
365
|
childGroupPid: proc.pid,
|
|
366
366
|
label: 'bash-session',
|
|
367
|
-
protectHostMemory: true,
|
|
368
367
|
});
|
|
369
368
|
proc.stdout.setEncoding('utf-8');
|
|
370
369
|
proc.stderr.setEncoding('utf-8');
|
|
@@ -446,6 +445,7 @@ async function _getOrCreate(sessionId, initialCwd = process.cwd(), opts = {}) {
|
|
|
446
445
|
signal: opts.signal || null,
|
|
447
446
|
label: `persistent:${id}`,
|
|
448
447
|
dependency: 'detached',
|
|
448
|
+
ownerKey: opts.sessionId || id,
|
|
449
449
|
});
|
|
450
450
|
try {
|
|
451
451
|
entry = _sessions.get(id);
|
|
@@ -849,7 +849,40 @@ const GUARDS = {
|
|
|
849
849
|
// key instead of failing the call on a field the caller never set.
|
|
850
850
|
function dropUndefinedArgs(args) {
|
|
851
851
|
for (const key of Object.keys(args)) {
|
|
852
|
-
|
|
852
|
+
// `null` gets the same treatment: it is the only way JSON can express
|
|
853
|
+
// "this optional field was not set", and failing the whole call on it
|
|
854
|
+
// was measured as a recurring, fully recoverable tool error.
|
|
855
|
+
if (args[key] === undefined || args[key] === null) delete args[key];
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// string | string[] arguments arrive nested ([["a","b"]] from a batching
|
|
860
|
+
// wrapper), padded with null/empty entries, or empty. The intent is
|
|
861
|
+
// unambiguous, so flatten one level, drop the blanks, and remove a key that
|
|
862
|
+
// ends up empty (the tool then uses its default scope) instead of rejecting
|
|
863
|
+
// the call.
|
|
864
|
+
const STRING_LIST_ARG_KEYS = [
|
|
865
|
+
'path', 'paths', 'root', 'file', 'files',
|
|
866
|
+
'pattern', 'patterns', 'query', 'regex', 'needle',
|
|
867
|
+
'glob', 'file_pattern', 'include', 'type', 'symbols',
|
|
868
|
+
];
|
|
869
|
+
function normalizeStringListArgs(args) {
|
|
870
|
+
for (const key of STRING_LIST_ARG_KEYS) {
|
|
871
|
+
if (!hasOwn(args, key) || !Array.isArray(args[key])) continue;
|
|
872
|
+
const source = args[key];
|
|
873
|
+
// Flatten ONE nesting level and drop blank entries; every other entry
|
|
874
|
+
// type is left untouched so the per-tool guards still see (and coerce
|
|
875
|
+
// or reject) it — `read`'s {path,offset,limit}[] regions must survive
|
|
876
|
+
// this pass verbatim.
|
|
877
|
+
const flat = [];
|
|
878
|
+
for (const entry of source.flat(1)) {
|
|
879
|
+
if (entry === null || entry === undefined) continue;
|
|
880
|
+
if (typeof entry === 'string' && entry.trim().length === 0) continue;
|
|
881
|
+
flat.push(entry);
|
|
882
|
+
}
|
|
883
|
+
if (flat.length === source.length && flat.every((entry, idx) => entry === source[idx])) continue;
|
|
884
|
+
if (flat.length === 0) delete args[key];
|
|
885
|
+
else args[key] = flat;
|
|
853
886
|
}
|
|
854
887
|
}
|
|
855
888
|
|
|
@@ -863,6 +896,7 @@ export function validateBuiltinArgs(toolName, args) {
|
|
|
863
896
|
return `Error: ${toolName} arguments must be an object (got ${describeType(args)})`;
|
|
864
897
|
}
|
|
865
898
|
dropUndefinedArgs(args);
|
|
899
|
+
normalizeStringListArgs(args);
|
|
866
900
|
if (toolName === 'grep') applyGrepContextLeadPolicy(args);
|
|
867
901
|
try {
|
|
868
902
|
return guard(args) || null;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
|
|
2
|
+
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join as pathJoin } from 'node:path';
|
|
5
|
+
import { isLegitimateShellExit } from '../../session/result-classification.mjs';
|
|
6
|
+
import { makeToolEnvelope } from '../../session/tool-envelope.mjs';
|
|
2
7
|
import { acquireShellLeaseBounded, execShellCommand, stripAnsi } from '../shell-command.mjs';
|
|
3
8
|
import { wrapCommandWithSnapshot } from '../shell-snapshot.mjs';
|
|
4
9
|
import { getDestructiveCommandWarning } from '../destructive-warning.mjs';
|
|
@@ -27,7 +32,9 @@ import {
|
|
|
27
32
|
detectLongForegroundReason,
|
|
28
33
|
extractShellApplyPatchInvocation,
|
|
29
34
|
foregroundLongCommandHint,
|
|
35
|
+
hasPowerShellOnlySyntax,
|
|
30
36
|
isAutobackgroundingAllowed,
|
|
37
|
+
planInlineScriptHoist,
|
|
31
38
|
preflightPowerShellHygiene,
|
|
32
39
|
shellSplitSegments,
|
|
33
40
|
shellSplitPipelineSegments,
|
|
@@ -89,10 +96,12 @@ export function _trackedDriftNoteAfter(_scope, _pre) {
|
|
|
89
96
|
|
|
90
97
|
// Search-style commands and `git diff --exit-code` use exit 1 as a SIGNAL
|
|
91
98
|
// (no match / has diff), not a failure. Benign ONLY when exitCode===1, no
|
|
92
|
-
// signal, stderr blank, AND the
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// can
|
|
99
|
+
// signal, stderr blank, AND the exit status provably comes from a search-style
|
|
100
|
+
// stage: the LAST segment of a `;`/`&&` chain (its status IS the chain's) whose
|
|
101
|
+
// last pipeline stage is a search head. `||` chains stay ambiguous (either
|
|
102
|
+
// branch can supply the status) and stay Error. Quote/comment aware via the
|
|
103
|
+
// shared shell tokenizers, so quoted/commented `;` `|` `grep` can never
|
|
104
|
+
// masquerade as a connector/command and hide a real failure.
|
|
96
105
|
const _SEARCH_HEADS = new Set(['select-string', 'sls', 'grep', 'egrep', 'fgrep', 'findstr']);
|
|
97
106
|
const _GIT_GLOBAL_VALUE_OPTS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
|
|
98
107
|
// Command/process/subshell substitution or a backslash/backtick-escaped pipe
|
|
@@ -120,9 +129,11 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
|
|
|
120
129
|
const text = _stripShellComment(String(command || ''));
|
|
121
130
|
if (_AMBIGUOUS_SYNTAX.test(text)) return false; // subshell/subst/escaped pipe → ambiguous
|
|
122
131
|
const segments = shellSplitSegments(text);
|
|
123
|
-
if (segments.length
|
|
124
|
-
|
|
125
|
-
const
|
|
132
|
+
if (segments.length === 0) return false;
|
|
133
|
+
if (segments.length > 1 && /\|\|/.test(text)) return false; // || → which branch exited 1?
|
|
134
|
+
const lastSegment = segments[segments.length - 1];
|
|
135
|
+
const stages = shellSplitPipelineSegments(lastSegment);
|
|
136
|
+
const last = stages[stages.length - 1] || lastSegment;
|
|
126
137
|
const raw = shellTokenize(last);
|
|
127
138
|
if (!raw) return false; // unbalanced quotes
|
|
128
139
|
const tokens = stripShellProbeWrappers(raw);
|
|
@@ -208,6 +219,14 @@ export function formatShellToolFailure(message) {
|
|
|
208
219
|
return `Error: [shell-tool-failed] ${text}`;
|
|
209
220
|
}
|
|
210
221
|
|
|
222
|
+
// A legitimate non-zero exit keeps its `[exit code: N]` marker but must not be
|
|
223
|
+
// CLASSIFIED as a failure downstream — the marker alone would read as an error
|
|
224
|
+
// to classifyResultKind. The explicit-success envelope is the documented
|
|
225
|
+
// channel for output that looks like an error and is not one.
|
|
226
|
+
function _finalizeShellResult(legitExit, text) {
|
|
227
|
+
return legitExit ? makeToolEnvelope(text, [], { explicitSuccess: true }) : text;
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
export function _shellFailureStatus(result, timeout) {
|
|
212
231
|
// Prefer the signal reported by the process. `killed` is only a fallback
|
|
213
232
|
// for platforms (notably taskkill on Windows) that close without one.
|
|
@@ -324,7 +343,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
324
343
|
// null spec means it is genuinely not installed — surface a clear error with
|
|
325
344
|
// NO silent fallback to the other shell.
|
|
326
345
|
const shellKind = args.shell === 'bash' || args.shell === 'powershell' ? args.shell : 'default';
|
|
327
|
-
|
|
346
|
+
let resolvedSpec = resolveShellFor(shellKind);
|
|
328
347
|
if (!resolvedSpec) {
|
|
329
348
|
if (shellKind === 'bash') {
|
|
330
349
|
return formatShellToolFailure("Git Bash not found — install Git for Windows or omit shell:'bash'.");
|
|
@@ -351,13 +370,52 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
351
370
|
shellType: resolvedSpec.shellType,
|
|
352
371
|
shellName: resolvedSpec.shell,
|
|
353
372
|
});
|
|
354
|
-
|
|
355
|
-
|
|
373
|
+
let shellRescueNote = '';
|
|
374
|
+
if (psHygiene.block) {
|
|
375
|
+
// Auto-rescue: the block fired ONLY because the command is written in
|
|
376
|
+
// bash (unix filter heads, `&&`) and it carries no PowerShell-only
|
|
377
|
+
// construct — running it in Git Bash is exactly what the caller meant,
|
|
378
|
+
// byte-for-byte. A mixed command ($env:, cmdlets, `2>$null`) or a
|
|
379
|
+
// PowerShell-specific violation stays a hard block, and an explicit
|
|
380
|
+
// shell:'powershell' is never overridden.
|
|
381
|
+
const bashSpec = psHygiene.bashOnly
|
|
382
|
+
&& shellKind === 'default'
|
|
383
|
+
&& !hasPowerShellOnlySyntax(psHygiene.original ?? command)
|
|
384
|
+
? resolveShellFor('bash')
|
|
385
|
+
: null;
|
|
386
|
+
if (!bashSpec) return formatShellToolFailure(psHygiene.block);
|
|
387
|
+
// The MSYS rewrite targets PowerShell; bash gets the original text.
|
|
388
|
+
command = psHygiene.original ?? command;
|
|
389
|
+
resolvedSpec = bashSpec;
|
|
390
|
+
shellRescueNote = "note: bash-only syntax on a PowerShell host — ran it in Git Bash (pass shell:'bash' to make that explicit).";
|
|
391
|
+
} else {
|
|
392
|
+
command = psHygiene.command;
|
|
393
|
+
}
|
|
356
394
|
|
|
357
395
|
const _execPolicyBlock = checkExecPolicyMessage(command);
|
|
358
396
|
if (_execPolicyBlock) {
|
|
359
397
|
return formatShellToolFailure(_execPolicyBlock);
|
|
360
398
|
}
|
|
399
|
+
// Inline-script hoisting (sync runs only — a background job would outlive
|
|
400
|
+
// the temp file). The body is written verbatim and the invocation becomes a
|
|
401
|
+
// file run, so the host shell never has to carry the script through its
|
|
402
|
+
// quoting layer. planInlineScriptHoist refuses every case where file
|
|
403
|
+
// semantics would differ, so this is a transport change only.
|
|
404
|
+
let _inlineHoistPath = null;
|
|
405
|
+
if (!runInBackground && args.persistent !== true) {
|
|
406
|
+
const hoist = planInlineScriptHoist(command);
|
|
407
|
+
if (hoist) {
|
|
408
|
+
try {
|
|
409
|
+
const file = pathJoin(
|
|
410
|
+
tmpdir(),
|
|
411
|
+
`mixdog-inline-${process.pid}-${Date.now().toString(36)}${hoist.extension}`,
|
|
412
|
+
);
|
|
413
|
+
writeFileSync(file, hoist.body, 'utf8');
|
|
414
|
+
_inlineHoistPath = file;
|
|
415
|
+
command = hoist.replace(file.replace(/\\/g, '/'));
|
|
416
|
+
} catch { _inlineHoistPath = null; }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
361
419
|
|
|
362
420
|
// Sleep-chain auto-promotion: a leading `sleep N && …` / `Start-Sleep N; …`
|
|
363
421
|
// used to be DENIED preflight (CC detectBlockedSleepPattern parity).
|
|
@@ -456,11 +514,30 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
456
514
|
// the value stays a soft hint clamped below `timeout` so the hard ceiling
|
|
457
515
|
// remains a separate, later bound. Never applies to run_in_background
|
|
458
516
|
// (already detached) or persistent sessions (handled far above).
|
|
459
|
-
|
|
460
|
-
|
|
517
|
+
// Promotion threshold (Codex `yield_time_ms` / CC startBackgrounding
|
|
518
|
+
// analogue). Previously 0 = OFF, so every long command held the turn open
|
|
519
|
+
// to the full foreground timeout and, at the cap, produced a wasted wait.
|
|
520
|
+
// 30 s keeps ordinary builds/tests inline (measured shell p90 ~ 18 s) while
|
|
521
|
+
// detaching the long tail (measured: 111 calls/day above 30 s, 30 of them
|
|
522
|
+
// burning the entire 120 s cap) as a tracked job with partial output.
|
|
523
|
+
// MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
|
|
524
|
+
const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
|
|
525
|
+
const _autoBgEnvMs = Number(_autoBgEnvRaw);
|
|
526
|
+
const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
|
|
527
|
+
&& Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
|
|
461
528
|
? Math.floor(_autoBgEnvMs)
|
|
462
|
-
:
|
|
463
|
-
|
|
529
|
+
: 30_000;
|
|
530
|
+
// Gate on backgroundOnTimeout rather than just `runInBackground`: it already
|
|
531
|
+
// encodes "promotion is available for this command" (not detached, background
|
|
532
|
+
// tasks enabled, command shape allows it), so the soft threshold can never
|
|
533
|
+
// promote something the hard timeout would refuse to.
|
|
534
|
+
// An EXPLICIT caller timeout is a deliberate "I intend to wait this long"
|
|
535
|
+
// (a long build/test/install), so the soft threshold never overrides it —
|
|
536
|
+
// the call stays inline until its own deadline, where backgroundOnTimeout
|
|
537
|
+
// still promotes instead of killing. Auto-promotion therefore only applies
|
|
538
|
+
// to the OMITTED-timeout default, which is exactly the case where nobody
|
|
539
|
+
// declared how long the command should be allowed to hold the turn.
|
|
540
|
+
const autoBackgroundMs = (!backgroundOnTimeout || hasExplicitTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
464
541
|
? 0
|
|
465
542
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
466
543
|
|
|
@@ -507,6 +584,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
507
584
|
abortSignal: combinedAsyncAbort.signal,
|
|
508
585
|
label: String(command).replace(/\s+/g, ' ').slice(0, 120),
|
|
509
586
|
dependency: 'detached',
|
|
587
|
+
ownerKey: options?.callerSessionId || options?.sessionId || null,
|
|
510
588
|
});
|
|
511
589
|
if (combinedAsyncAbort.signal?.aborted) {
|
|
512
590
|
throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled before spawn');
|
|
@@ -704,7 +782,17 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
704
782
|
const failureStatus = _shellFailureStatus(result, timeout);
|
|
705
783
|
const { signal, exitCode, shellToolFailed } = failureStatus;
|
|
706
784
|
const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
|
|
707
|
-
|
|
785
|
+
// Legitimate non-zero exit: the command printed its result and ended
|
|
786
|
+
// with 1 without writing a single byte to stderr (probes, reports,
|
|
787
|
+
// `git diff --exit-code`-shaped checks). It keeps its `[exit code: 1]`
|
|
788
|
+
// marker so the caller sees the code, but it is NOT framed as a tool
|
|
789
|
+
// error and NOT classified as a failure — measured as the dominant
|
|
790
|
+
// shape among non-zero exits.
|
|
791
|
+
const legitExit = !shellToolFailed
|
|
792
|
+
&& !benignExitOne
|
|
793
|
+
&& isLegitimateShellExit({ exitCode, signal, stdout, stderr });
|
|
794
|
+
const shellRunFailed = !shellToolFailed
|
|
795
|
+
&& (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne && !legitExit));
|
|
708
796
|
const isReallyErrored = shellToolFailed || shellRunFailed;
|
|
709
797
|
// Filter-swallow rescue: the tee file is ALWAYS consumed (deleted)
|
|
710
798
|
// here; its tail is attached only when the run failed with an empty
|
|
@@ -729,8 +817,16 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
729
817
|
const statusDetail = failureStatus.statusDetail;
|
|
730
818
|
const statusMarker = shellToolFailed
|
|
731
819
|
? `[shell-tool-failed] ${statusDetail}`
|
|
732
|
-
: (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : '');
|
|
820
|
+
: (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : (legitExit ? statusDetail : ''));
|
|
733
821
|
const errorPrefix = isReallyErrored ? 'Error: ' : '';
|
|
822
|
+
// Three outcomes, three labels: a TOOL error (`[shell-tool-failed]`),
|
|
823
|
+
// a command that RAN AND FAILED (`Error: [shell-run-failed]`), and a
|
|
824
|
+
// command that RAN TO COMPLETION and merely ended non-zero. The last
|
|
825
|
+
// one keeps its exit code but says so in words, so neither the model
|
|
826
|
+
// nor the failure log treats a finished report as a failure.
|
|
827
|
+
const completionNote = legitExit
|
|
828
|
+
? '\n[completed: the command finished and wrote nothing to stderr; the exit code is its own report, not a tool failure]'
|
|
829
|
+
: '';
|
|
734
830
|
if (mergeStderr) {
|
|
735
831
|
// Post-exit concatenation. True chunk-level interleaving would
|
|
736
832
|
// require shell-level `2>&1` redirection (bash) or `*>&1`
|
|
@@ -740,7 +836,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
740
836
|
// cross-stream interleaving. Acceptable for most diagnostic
|
|
741
837
|
// outputs; flag in shell-command if exact interleaving is required.
|
|
742
838
|
const merged = stdout + stderr;
|
|
743
|
-
if (statusMarker) return _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote);
|
|
839
|
+
if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}${completionNote}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote));
|
|
744
840
|
return _prependDestructiveWarning(command, smartMiddleTruncate(merged || '(no output)') + _driftNote);
|
|
745
841
|
}
|
|
746
842
|
const truncatedStdout = smartMiddleTruncate(stdout);
|
|
@@ -761,13 +857,17 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
761
857
|
}
|
|
762
858
|
const warningBlock = [
|
|
763
859
|
wmicRewrite?.note || '',
|
|
860
|
+
shellRescueNote,
|
|
764
861
|
].filter(Boolean).join('\n');
|
|
765
862
|
const payload = `${body}${stderrBlock}${spillBlock}${_rescueNote}${_driftNote}`;
|
|
766
|
-
if (statusMarker) return _prependDestructiveWarning(command, _composeShellFailure(statusMarker
|
|
863
|
+
if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, _composeShellFailure(`${statusMarker}${completionNote}`, errorPrefix, warningBlock, payload)));
|
|
767
864
|
return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
|
|
768
865
|
}
|
|
769
866
|
finally {
|
|
770
867
|
combinedBashAbort?.cleanup?.();
|
|
868
|
+
if (_inlineHoistPath) {
|
|
869
|
+
try { unlinkSync(_inlineHoistPath); } catch { /* best-effort cleanup */ }
|
|
870
|
+
}
|
|
771
871
|
if (shellEffects.mutationMode === 'paths') {
|
|
772
872
|
invalidateBuiltinResultCache(shellEffects.paths);
|
|
773
873
|
markCodeGraphDirtyPaths(shellEffects.paths);
|