mixdog 0.9.91 → 0.9.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/tool-overhead-microbench.mjs +60 -0
- package/src/agents/debugger/agent.json +1 -1
- package/src/agents/explore/agent.json +1 -1
- package/src/agents/heavy-worker/agent.json +1 -1
- package/src/agents/maintainer/agent.json +1 -1
- package/src/agents/reviewer/agent.json +1 -1
- package/src/agents/worker/agent.json +1 -1
- package/src/lib/rules-builder.cjs +5 -5
- package/src/output-styles/simple.md +1 -1
- package/src/rules/shared/01-tool.md +3 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +61 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +5 -0
- package/src/session-runtime/output-styles.mjs +1 -4
- package/src/session-runtime/workflow.mjs +18 -8
- package/src/tui/dist/index.mjs +32 -1
- package/src/tui/engine/session-api.mjs +17 -0
- package/src/tui/engine/tui-steering-persist.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +7 -18
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Micro-bench: fixed per-call overhead of the shell tool path vs raw spawn.
|
|
2
|
+
// Usage: node scripts/tool-overhead-microbench.mjs [bash|powershell] [N]
|
|
3
|
+
// Prints per-call ms for executeBashTool('echo hi') and raw child spawn,
|
|
4
|
+
// so (tool - raw) isolates our tool-layer overhead (policy, wrappers, I/O).
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { executeBashTool } from '../src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs';
|
|
7
|
+
|
|
8
|
+
const shell = process.argv[2] || (process.platform === 'win32' ? 'powershell' : 'bash');
|
|
9
|
+
const N = Number(process.argv[3] || 15);
|
|
10
|
+
|
|
11
|
+
const stats = (arr) => {
|
|
12
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
13
|
+
const sum = s.reduce((t, v) => t + v, 0);
|
|
14
|
+
return {
|
|
15
|
+
mean: (sum / s.length).toFixed(1),
|
|
16
|
+
p50: s[Math.floor(s.length / 2)].toFixed(1),
|
|
17
|
+
min: s[0].toFixed(1),
|
|
18
|
+
max: s[s.length - 1].toFixed(1),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const rawOnce = () => new Promise((resolveDone, reject) => {
|
|
23
|
+
const child = shell === 'powershell'
|
|
24
|
+
? spawn('pwsh', ['-NoProfile', '-NonInteractive', '-Command', 'echo hi'])
|
|
25
|
+
: spawn('bash', ['-c', 'echo hi']);
|
|
26
|
+
let out = '';
|
|
27
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', () => resolveDone(out));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const toolOnce = async () => {
|
|
33
|
+
const out = await executeBashTool({ command: 'echo hi', shell }, process.cwd(), {});
|
|
34
|
+
return String(out);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Warm-up both paths (module init, shell resolution cache, standbys).
|
|
38
|
+
await toolOnce(); await toolOnce();
|
|
39
|
+
await rawOnce(); await rawOnce();
|
|
40
|
+
|
|
41
|
+
const toolMs = [];
|
|
42
|
+
for (let i = 0; i < N; i++) {
|
|
43
|
+
const t0 = performance.now();
|
|
44
|
+
await toolOnce();
|
|
45
|
+
toolMs.push(performance.now() - t0);
|
|
46
|
+
}
|
|
47
|
+
const rawMs = [];
|
|
48
|
+
for (let i = 0; i < N; i++) {
|
|
49
|
+
const t0 = performance.now();
|
|
50
|
+
await rawOnce();
|
|
51
|
+
rawMs.push(performance.now() - t0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const t = stats(toolMs);
|
|
55
|
+
const r = stats(rawMs);
|
|
56
|
+
console.log(`shell=${shell} n=${N}`);
|
|
57
|
+
console.log(`tool mean=${t.mean}ms p50=${t.p50}ms min=${t.min}ms max=${t.max}ms`);
|
|
58
|
+
console.log(`raw mean=${r.mean}ms p50=${r.p50}ms min=${r.min}ms max=${r.max}ms`);
|
|
59
|
+
console.log(`overhead(mean tool-raw)=${(Number(t.mean) - Number(r.mean)).toFixed(1)}ms`);
|
|
60
|
+
process.exit(0);
|
|
@@ -156,8 +156,8 @@ function stripFrontmatter(markdown) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
function normalizeOutputStyleName(value) {
|
|
159
|
-
const name = String(value || '
|
|
160
|
-
return /^[A-Za-z0-9_.-]+$/.test(name) ? name : '
|
|
159
|
+
const name = String(value || 'simple').trim();
|
|
160
|
+
return /^[A-Za-z0-9_.-]+$/.test(name) ? name : 'simple';
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
function loadOutputStyle({ PLUGIN_ROOT, DATA_DIR }) {
|
|
@@ -172,10 +172,10 @@ function loadOutputStyle({ PLUGIN_ROOT, DATA_DIR }) {
|
|
|
172
172
|
const body = stripFrontmatter(readOptional(candidate));
|
|
173
173
|
if (body) return body;
|
|
174
174
|
}
|
|
175
|
-
if (styleName !== '
|
|
175
|
+
if (styleName !== 'simple') {
|
|
176
176
|
const fallback = [
|
|
177
|
-
path.join(DATA_DIR, 'output-styles', '
|
|
178
|
-
path.join(PLUGIN_ROOT, 'output-styles', '
|
|
177
|
+
path.join(DATA_DIR, 'output-styles', 'simple.md'),
|
|
178
|
+
path.join(PLUGIN_ROOT, 'output-styles', 'simple.md'),
|
|
179
179
|
];
|
|
180
180
|
for (const candidate of fallback) {
|
|
181
181
|
const body = stripFrontmatter(readOptional(candidate));
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
`{path,offset,limit}` array; graph targets as arrays; `explore` facets in
|
|
15
15
|
one `query[]` (max 8, no rephrased duplicates); all new edits in one patch.
|
|
16
16
|
Distinct facets, not alternative routes; sequential singles only for a
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
step whose arguments require the previous result — fixed follow-ups
|
|
18
|
+
(pinned installs, known writes) go in the same batch; only apply_patch
|
|
19
|
+
executes in order.
|
|
19
20
|
- Verified paths: project root, session cwd, user-provided, tool-returned.
|
|
20
21
|
`find` first for guessed path/name fragments; on ENOENT, find the basename.
|
|
21
22
|
Retry `EXPLORATION_FAILED` once with changed tokens.
|
|
@@ -50,8 +50,10 @@ import {
|
|
|
50
50
|
anthropicRequestTimeoutMs,
|
|
51
51
|
classifyError,
|
|
52
52
|
anthropicMaxAttempts,
|
|
53
|
+
createStallRetryBudget,
|
|
53
54
|
midstreamBackoffFor,
|
|
54
55
|
retryAfterMsFromError,
|
|
56
|
+
STREAM_STALL_RETRY_BUDGET_MS,
|
|
55
57
|
withRetry,
|
|
56
58
|
} from './retry-classifier.mjs';
|
|
57
59
|
import {
|
|
@@ -774,6 +776,9 @@ export class AnthropicOAuthProvider {
|
|
|
774
776
|
const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
|
|
775
777
|
let firstAttemptError = null;
|
|
776
778
|
let firstAttemptClassifier = null;
|
|
779
|
+
// Send-scoped stall window: in-place stall retries share one wall
|
|
780
|
+
// clock starting at the first stall (see createStallRetryBudget).
|
|
781
|
+
const stallRetryBudget = createStallRetryBudget();
|
|
777
782
|
|
|
778
783
|
const recoverNonStreaming = async (midState, streamingError, controller) => {
|
|
779
784
|
const exposedChars = Number(midState?.emittedTextChars) || 0;
|
|
@@ -1052,6 +1057,11 @@ export class AnthropicOAuthProvider {
|
|
|
1052
1057
|
continue;
|
|
1053
1058
|
}
|
|
1054
1059
|
const classifier = _classifyMidstreamError(err, midState);
|
|
1060
|
+
if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
|
|
1061
|
+
try { process.stderr.write(`[anthropic-oauth] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`); } catch {}
|
|
1062
|
+
try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
1055
1065
|
if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
|
|
1056
1066
|
firstAttemptError = err;
|
|
1057
1067
|
firstAttemptClassifier = classifier;
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
anthropicMaxAttempts,
|
|
9
9
|
anthropicRequestTimeoutMs,
|
|
10
10
|
classifyError,
|
|
11
|
+
createStallRetryBudget,
|
|
11
12
|
midstreamBackoffFor,
|
|
12
13
|
sleepWithAbort,
|
|
14
|
+
STREAM_STALL_RETRY_BUDGET_MS,
|
|
13
15
|
withRetry,
|
|
14
16
|
retryAfterMsFromError,
|
|
15
17
|
} from './retry-classifier.mjs';
|
|
@@ -249,6 +251,9 @@ export class AnthropicProvider {
|
|
|
249
251
|
const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
|
|
250
252
|
let firstAttemptError = null;
|
|
251
253
|
let firstAttemptClassifier = null;
|
|
254
|
+
// Send-scoped stall window: in-place stall retries share one wall
|
|
255
|
+
// clock starting at the first stall (see createStallRetryBudget).
|
|
256
|
+
const stallRetryBudget = createStallRetryBudget();
|
|
252
257
|
|
|
253
258
|
const buildReturnFromParse = (parseResult) => {
|
|
254
259
|
const usageRaw = parseResult.usage?.raw || null;
|
|
@@ -592,6 +597,15 @@ export class AnthropicProvider {
|
|
|
592
597
|
continue;
|
|
593
598
|
}
|
|
594
599
|
const classifier = _classifyMidstreamError(err, midState);
|
|
600
|
+
if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
|
|
601
|
+
try {
|
|
602
|
+
process.stderr.write(
|
|
603
|
+
`[${this.name}] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`,
|
|
604
|
+
);
|
|
605
|
+
} catch {}
|
|
606
|
+
try { streamController.abort?.(err); } catch {}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
595
609
|
if (classifier && attemptIndex < MAX_MIDSTREAM_RETRIES) {
|
|
596
610
|
firstAttemptError = err;
|
|
597
611
|
firstAttemptClassifier = classifier;
|
|
@@ -36,9 +36,11 @@ import {
|
|
|
36
36
|
classifyHandshakeError,
|
|
37
37
|
classifyMidstreamError,
|
|
38
38
|
createStreamSafetyStamps,
|
|
39
|
+
createStallRetryBudget,
|
|
39
40
|
jitterDelayMs,
|
|
40
41
|
MIDSTREAM_RETRY_POLICY,
|
|
41
42
|
sleepWithAbort,
|
|
43
|
+
STREAM_STALL_RETRY_BUDGET_MS,
|
|
42
44
|
} from './retry-classifier.mjs';
|
|
43
45
|
import { stampStreamOutcome, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs';
|
|
44
46
|
import {
|
|
@@ -459,6 +461,9 @@ export async function sendViaWebSocket({
|
|
|
459
461
|
const MAX_MIDSTREAM_RETRIES = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT;
|
|
460
462
|
let firstAttemptError = null;
|
|
461
463
|
let firstAttemptClassifier = null;
|
|
464
|
+
// Send-scoped stall window: in-place stall retries share one wall clock
|
|
465
|
+
// starting at the first stall (see createStallRetryBudget).
|
|
466
|
+
const stallRetryBudget = createStallRetryBudget();
|
|
462
467
|
// A generate:false prewarm is billable even if its main request later
|
|
463
468
|
// retries on a fresh socket or falls back to HTTP. Retain one completed
|
|
464
469
|
// result across the whole logical send and attach it to terminal errors.
|
|
@@ -1006,6 +1011,11 @@ export async function sendViaWebSocket({
|
|
|
1006
1011
|
const classifier = err?.unsafeToRetry === true
|
|
1007
1012
|
? null
|
|
1008
1013
|
: _classifyMidstreamError(err, midState);
|
|
1014
|
+
if (classifier === 'stream_stalled' && !stallRetryBudget.allowStallRetry()) {
|
|
1015
|
+
try { process.stderr.write(`[openai-oauth] stall retry budget exhausted (${STREAM_STALL_RETRY_BUDGET_MS}ms since first stall) — surfacing for fresh-request retry\n`); } catch {}
|
|
1016
|
+
emitSendSpan('error');
|
|
1017
|
+
throw _stampTool(_stampLiveText(err));
|
|
1018
|
+
}
|
|
1009
1019
|
const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
|
|
1010
1020
|
if (classifier && attemptIndex < retryLimit) {
|
|
1011
1021
|
// Retry-eligible: stash the first-attempt error, emit progress,
|
|
@@ -314,6 +314,41 @@ export function jitterDelayMs(ms, ratio = PROVIDER_RETRY_JITTER_RATIO, mode = 's
|
|
|
314
314
|
return Math.max(0, Math.round(base + offset))
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// ── Stall-retry wall-clock budget (send-scoped) ──────────────────────────────
|
|
318
|
+
// Mid-stream 'stream_stalled' recoveries retry in place, which is right for a
|
|
319
|
+
// one-off blip but lets a chronically dying stream burn a whole task budget
|
|
320
|
+
// slowly (observed live: one send stretched 149s→298s→556s across stall
|
|
321
|
+
// retries before the agent deadline killed the task). Reference stacks bound
|
|
322
|
+
// this instead of retrying forever: Claude Code caps each request at ~300s
|
|
323
|
+
// wall clock (API_TIMEOUT_MS) and Codex kills a stream after one 300s silent
|
|
324
|
+
// gap (stream_idle_timeout). This guard is the equivalent for our in-place
|
|
325
|
+
// recovery: the clock starts at the FIRST stall of a send, and stall-classified
|
|
326
|
+
// retries are allowed only inside that window; past it the stall error
|
|
327
|
+
// surfaces so loop-level transport retry issues a FRESH request. Healthy
|
|
328
|
+
// streams never consult the clock (no stall → no budget reads), so long
|
|
329
|
+
// thinking/output can never trip it.
|
|
330
|
+
export const STREAM_STALL_RETRY_BUDGET_MS = (() => {
|
|
331
|
+
const v = Number(process.env.MIXDOG_STREAM_STALL_BUDGET_MS)
|
|
332
|
+
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 300_000
|
|
333
|
+
})()
|
|
334
|
+
|
|
335
|
+
// One instance per provider send() call (NOT per attempt — the whole point is
|
|
336
|
+
// bounding the cross-attempt stall window). `now` is injectable for tests.
|
|
337
|
+
export function createStallRetryBudget(budgetMs = STREAM_STALL_RETRY_BUDGET_MS, now = Date.now) {
|
|
338
|
+
let firstStallAt = 0
|
|
339
|
+
return {
|
|
340
|
+
// Record a stall-classified retry candidate. Returns true while the
|
|
341
|
+
// send's stall window still has budget; false once exhausted (the caller
|
|
342
|
+
// surfaces the error instead of retrying in place).
|
|
343
|
+
allowStallRetry() {
|
|
344
|
+
const t = now()
|
|
345
|
+
if (!firstStallAt) firstStallAt = t
|
|
346
|
+
return (t - firstStallAt) <= budgetMs
|
|
347
|
+
},
|
|
348
|
+
get firstStallAt() { return firstStallAt },
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
317
352
|
// ── Shared network-resilience interface ──────────────────────────────────────
|
|
318
353
|
// One home for the logic shared across providers: mid-stream classifier
|
|
319
354
|
// (WS + SSE), transport fallback predicate, stream-safety stamp latches,
|
|
@@ -444,6 +444,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
444
444
|
Math.floor(maxLoopIterations * 0.9),
|
|
445
445
|
];
|
|
446
446
|
while (true) {
|
|
447
|
+
const _iterT0 = Date.now();
|
|
447
448
|
throwIfAborted();
|
|
448
449
|
if (iterations >= maxLoopIterations) {
|
|
449
450
|
// Final-answer turn: instead of breaking mid-transcript (which
|
|
@@ -642,6 +643,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
642
643
|
transportRetriesUsed: _transportRetriesUsed, signal,
|
|
643
644
|
}),
|
|
644
645
|
);
|
|
646
|
+
const _sendEndedAt = Date.now();
|
|
645
647
|
if (_sendResult.action === 'retry') {
|
|
646
648
|
delete opts.cacheBreakIntent;
|
|
647
649
|
contextOverflowRetryUsed = true;
|
|
@@ -1038,6 +1040,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1038
1040
|
continue;
|
|
1039
1041
|
}
|
|
1040
1042
|
try { opts.onToolPhaseStarted?.(); } catch {}
|
|
1043
|
+
const _toolsT0 = Date.now();
|
|
1041
1044
|
({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
|
|
1042
1045
|
calls: _callsToExecute, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
1043
1046
|
iterations, assistantTurnMsg: _assistantTurnMsg,
|
|
@@ -1050,6 +1053,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1050
1053
|
}));
|
|
1051
1054
|
// Settle the stop hook on the batch that actually executed.
|
|
1052
1055
|
_toolFailureStopHook.endBatch(_callsToExecute);
|
|
1056
|
+
// Loop-phase timing (diagnostics): where non-model time goes per
|
|
1057
|
+
// iteration — presend (repair/compact/snapshot), send (provider
|
|
1058
|
+
// round-trip incl. streaming), tools (batch execution). Gated by the
|
|
1059
|
+
// same env as [turn-timing] so bench runs opt in via -AgentEnv.
|
|
1060
|
+
if (process.env.MIXDOG_TURN_TIMING === '1') {
|
|
1061
|
+
try {
|
|
1062
|
+
process.stderr.write(`[loop-timing] iter=${nextIteration} presend=${sendStartedAt - _iterT0}ms send=${_sendEndedAt - sendStartedAt}ms tools=${Date.now() - _toolsT0}ms calls=${_callsToExecute.length}\n`);
|
|
1063
|
+
} catch { /* diagnostics only */ }
|
|
1064
|
+
}
|
|
1053
1065
|
_toolBatchJustCompleted = true;
|
|
1054
1066
|
_continuationsSinceToolBatch = 0;
|
|
1055
1067
|
_lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
export const STOP_HOOK_SOURCE = 'tool-failure-stop-hook';
|
|
16
16
|
|
|
17
|
+
import { isInformationalShellExitOne } from '../result-classification.mjs';
|
|
18
|
+
|
|
17
19
|
// Only a genuinely EXECUTED result resolves a failure, i.e. kind 'normal'.
|
|
18
20
|
// Cache hits ('cache-hit' / 'scoped-cache-hit') replay an earlier result
|
|
19
21
|
// without running anything, and dedup/guard skips ('skipped') execute nothing
|
|
@@ -59,6 +61,13 @@ export function createToolFailureStopHook() {
|
|
|
59
61
|
// nothing was dispatched — they must not arm the hook.
|
|
60
62
|
if (message.guardSkip === true) return;
|
|
61
63
|
if (message.toolKind === 'error') {
|
|
64
|
+
// Informational exit-1 probes (grep-family no-match inside a
|
|
65
|
+
// compound command: useful stdout, blank stderr) stay 'error'
|
|
66
|
+
// for display/history, but blocking the terminal message over
|
|
67
|
+
// them forces a pointless re-verify turn — observed live
|
|
68
|
+
// (kv-store-grpc: /proc PID scan exit 1 → hook misfire, +2
|
|
69
|
+
// turns). Neutral here: neither arms nor clears.
|
|
70
|
+
if (isInformationalShellExitOne(message.content)) return;
|
|
62
71
|
batchFailure = true;
|
|
63
72
|
if (message.toolCallId) failedCallIds.add(message.toolCallId);
|
|
64
73
|
} else if (EXECUTED_SUCCESS_TOOL_KINDS.has(message.toolKind)) {
|
|
@@ -68,3 +68,31 @@ export function classifyResultKind(result, explicitSuccess = false) {
|
|
|
68
68
|
}
|
|
69
69
|
return 'normal';
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Informational shell exit-1: `Error: [shell-run-failed] [exit code: 1]`
|
|
74
|
+
* with a non-empty stdout body and NO stderr evidence (neither an inline
|
|
75
|
+
* `[stderr]` block nor a `[stderr: path]` spill). grep-family "no match"
|
|
76
|
+
* semantics inside compound probes (loops, `;`-chains, substitutions) land
|
|
77
|
+
* exactly here: the run produced useful output, wrote nothing to stderr,
|
|
78
|
+
* and exited 1 only because the final stage matched nothing. The static
|
|
79
|
+
* single-pipeline gate (bash-tool _isBenignSearchExitOne) deliberately
|
|
80
|
+
* refuses these ambiguous shapes, so the result stays toolKind 'error' —
|
|
81
|
+
* consumers that must not overreact to an informational failure (the turn
|
|
82
|
+
* stop hook) test this signature instead of reclassifying the result.
|
|
83
|
+
* Signals, timeouts, and other exit codes carry different status markers
|
|
84
|
+
* and never match; a destructive-warning prefix also disqualifies.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} result
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isInformationalShellExitOne(result) {
|
|
90
|
+
if (typeof result !== 'string') return false;
|
|
91
|
+
const trimmed = result.trimStart();
|
|
92
|
+
const header = /^error:\s*\[shell-run-failed\]\s*\[exit code: 1\]\s*\n/i.exec(trimmed);
|
|
93
|
+
if (!header) return false;
|
|
94
|
+
const payload = trimmed.slice(header[0].length).trim();
|
|
95
|
+
if (!payload || payload === '(no output)') return false;
|
|
96
|
+
if (payload.startsWith('[stderr') || payload.includes('\n[stderr')) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
@@ -126,6 +126,36 @@ export async function sendWithRecovery(ctx) {
|
|
|
126
126
|
// Every branch below consumes it instead of re-inferring safety
|
|
127
127
|
// from provider-specific flags.
|
|
128
128
|
const outcome = readStreamOutcome(sendErr, relayWitness);
|
|
129
|
+
// Text-only exposure retraction (cross-provider): a stream that
|
|
130
|
+
// died after relaying ONLY text — no dispatched or complete tool
|
|
131
|
+
// calls, no terminal — is replayable IF the ask owner retracts the
|
|
132
|
+
// exposed characters (onTextReset ack === true: the TUI truncates
|
|
133
|
+
// its live tail, the bench driver truncates its accumulator).
|
|
134
|
+
// This is the loop-level analogue of anthropic's
|
|
135
|
+
// recoverNonStreaming for providers WITHOUT a non-streaming
|
|
136
|
+
// fallback (gemini, openai-compat, openai WS) and for stalls that
|
|
137
|
+
// outlived the provider's in-place recovery. Observed live:
|
|
138
|
+
// make-mips-interpreter died with 31 exposed chars + a pending
|
|
139
|
+
// never-dispatched tool input and burned the whole trial.
|
|
140
|
+
const retractExposedTextForReplay = async () => {
|
|
141
|
+
if (outcome.terminalObserved === true) return false;
|
|
142
|
+
if (outcome.sideEffectDispatched === true) return false;
|
|
143
|
+
if (outcome.dispatchAmbiguous === true) return false;
|
|
144
|
+
if (Number(outcome.toolCallsDispatched) > 0) return false;
|
|
145
|
+
if (Number(outcome.toolCallsComplete) > 0) return false;
|
|
146
|
+
if (relayWitness.toolCallsDispatched > 0) return false;
|
|
147
|
+
if (typeof opts?.onTextReset !== 'function') return false;
|
|
148
|
+
const chars = Math.max(0, Number(outcome.textObservedChars) || 0)
|
|
149
|
+
|| (typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0);
|
|
150
|
+
if (chars <= 0) return false;
|
|
151
|
+
let acked = false;
|
|
152
|
+
try {
|
|
153
|
+
acked = await opts.onTextReset({ chars, reason: 'loop-transport-retraction' }) === true;
|
|
154
|
+
} catch { acked = false; }
|
|
155
|
+
if (!acked) return false;
|
|
156
|
+
relayWitness.textEmitted = false;
|
|
157
|
+
return true;
|
|
158
|
+
};
|
|
129
159
|
// Gemini REST/SDK reports MAX_TOKENS by throwing a typed
|
|
130
160
|
// ProviderIncompleteError after preserving the streamed candidate.
|
|
131
161
|
// Normalize only that exact, safe no-tool output-limit shape into a
|
|
@@ -177,6 +207,36 @@ export async function sendWithRecovery(ctx) {
|
|
|
177
207
|
&& sendErr.partialContent.trim().length > 0
|
|
178
208
|
&& outcome.toolCallsComplete === 0
|
|
179
209
|
) {
|
|
210
|
+
// Retractable shape: text-only exposure with the owner's
|
|
211
|
+
// acknowledgement replays on a fresh request instead of
|
|
212
|
+
// failing the turn. Non-acked (or tool-bearing) shapes keep
|
|
213
|
+
// the explicit-failure contract below unchanged.
|
|
214
|
+
if (
|
|
215
|
+
transportRetriesUsed < TRANSPORT_RETRY_MAX
|
|
216
|
+
&& classifyError(sendErr) === 'transient'
|
|
217
|
+
&& await retractExposedTextForReplay()
|
|
218
|
+
) {
|
|
219
|
+
const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
|
|
220
|
+
try {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`[loop] exposed-text stall retracted (sess=${sessionId || 'unknown'} `
|
|
223
|
+
+ `iter=${nextIteration} len=${sendErr.partialContent.length}); `
|
|
224
|
+
+ `transport retry ${transportRetriesUsed + 1}/${TRANSPORT_RETRY_MAX} after ${waitMs}ms\n`,
|
|
225
|
+
);
|
|
226
|
+
} catch { /* best-effort */ }
|
|
227
|
+
try {
|
|
228
|
+
appendAgentTrace({
|
|
229
|
+
kind: 'exposed_text_retraction_retry',
|
|
230
|
+
sessionId: sessionId || null,
|
|
231
|
+
iteration: nextIteration,
|
|
232
|
+
attempt: transportRetriesUsed + 1,
|
|
233
|
+
waitMs,
|
|
234
|
+
partialContentLen: sendErr.partialContent.length,
|
|
235
|
+
});
|
|
236
|
+
} catch { /* best-effort */ }
|
|
237
|
+
await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
|
|
238
|
+
return { action: 'retry_transport' };
|
|
239
|
+
}
|
|
180
240
|
try {
|
|
181
241
|
process.stderr.write(
|
|
182
242
|
`[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
|
|
@@ -265,8 +325,8 @@ export async function sendWithRecovery(ctx) {
|
|
|
265
325
|
// send after a bounded wait instead of failing the turn.
|
|
266
326
|
if (
|
|
267
327
|
transportRetriesUsed < TRANSPORT_RETRY_MAX
|
|
268
|
-
&& outcome.replaySafe === true
|
|
269
328
|
&& classifyError(sendErr) === 'transient'
|
|
329
|
+
&& (outcome.replaySafe === true || await retractExposedTextForReplay())
|
|
270
330
|
) {
|
|
271
331
|
const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
|
|
272
332
|
try {
|
|
@@ -206,6 +206,11 @@ export async function executeTaskTool(args, options = {}) {
|
|
|
206
206
|
// consumed synchronously, so no re-arm. Drop the persisted ctx here
|
|
207
207
|
// or it leaks (cleanup only runs on a real watcher settle, which
|
|
208
208
|
// never happens for a never-re-armed entry).
|
|
209
|
+
// EXCEPT when this wait's tool call was aborted: its result was
|
|
210
|
+
// discarded, so nobody consumed the outcome — re-arm so the
|
|
211
|
+
// watcher delivers the completion notification instead of
|
|
212
|
+
// swallowing it.
|
|
213
|
+
else if (options?.signal?.aborted) watchBackgroundShellJob(taskId);
|
|
209
214
|
else clearShellJobNotifyCtx(taskId);
|
|
210
215
|
}
|
|
211
216
|
}
|
|
@@ -5,11 +5,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { clean } from './session-text.mjs';
|
|
6
6
|
import { readJsonSafe } from './fs-utils.mjs';
|
|
7
7
|
|
|
8
|
-
const OUTPUT_STYLE_ORDER = ['
|
|
8
|
+
const OUTPUT_STYLE_ORDER = ['detailed', 'simple', 'minimal', 'extreme-minimal'];
|
|
9
9
|
const OUTPUT_STYLE_ALIASES = new Map([
|
|
10
|
-
['compact', 'simple'],
|
|
11
|
-
['normal', 'simple'],
|
|
12
|
-
['detail', 'detailed'],
|
|
13
10
|
['extreme', 'extreme-minimal'],
|
|
14
11
|
['extremesimple', 'extreme-minimal'],
|
|
15
12
|
['extreme-simple', 'extreme-minimal'],
|
|
@@ -16,12 +16,18 @@ export const FIXED_AGENT_SLOTS = Object.freeze([
|
|
|
16
16
|
// (user: 창이 크지 않으니 설명은 짧게).
|
|
17
17
|
{ id: 'explore', label: 'Explore', description: 'Repository exploration', workflowSlot: 'explorer' },
|
|
18
18
|
{ id: 'maintainer', label: 'Maintainer', description: 'Memory and upkeep', workflowSlot: 'memory' },
|
|
19
|
-
{ id: 'worker', label: 'Worker', description: '
|
|
20
|
-
{ id: 'heavy-worker', label: 'Heavy Worker', description: '
|
|
19
|
+
{ id: 'worker', label: 'Worker', description: 'Simple tasks' },
|
|
20
|
+
{ id: 'heavy-worker', label: 'Heavy Worker', description: 'Complex tasks' },
|
|
21
21
|
{ id: 'reviewer', label: 'Reviewer', description: 'Diff and risk review' },
|
|
22
22
|
{ id: 'debugger', label: 'Debugger', description: 'Root-cause debugging' },
|
|
23
23
|
]);
|
|
24
24
|
const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
|
|
25
|
+
// Slot-backed built-ins (explore/maintainer) run through their own dedicated
|
|
26
|
+
// channels — the explore tool and the memory cycle — so they are never
|
|
27
|
+
// Lead-delegation targets and stay out of the Available Agents catalog.
|
|
28
|
+
const BUILTIN_SLOT_AGENT_IDS = new Set(
|
|
29
|
+
FIXED_AGENT_SLOTS.filter((agent) => agent.workflowSlot).map((agent) => agent.id),
|
|
30
|
+
);
|
|
25
31
|
export const DEFAULT_WORKFLOW_ID = 'default';
|
|
26
32
|
|
|
27
33
|
const SEARCH_CAPABLE_PROVIDERS = new Set([
|
|
@@ -282,16 +288,20 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
282
288
|
lines.push(pack.body);
|
|
283
289
|
// A hand-edited pack may name a hidden role in its `agents:` frontmatter;
|
|
284
290
|
// internal roles are never delegatable, so they never enter the catalog.
|
|
291
|
+
// Slot-backed built-ins are equally non-delegatable (they ride the explore
|
|
292
|
+
// tool / memory cycle), so they are filtered even when a pack names them.
|
|
285
293
|
const agentIds = (pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id))
|
|
286
|
-
.filter((id) => !isHiddenAgent(id));
|
|
294
|
+
.filter((id) => !isHiddenAgent(id) && !BUILTIN_SLOT_AGENT_IDS.has(id));
|
|
287
295
|
const agentBlocks = agentIds.map((id) => loadAgentDefinition(dir, id)).filter(Boolean);
|
|
288
296
|
if (agentBlocks.length) {
|
|
289
297
|
lines.push('# Available Agents');
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
// Name + description only: the AGENT.md body is the worker's own system
|
|
299
|
+
// prompt and rides in the worker session at spawn time — repeating it in
|
|
300
|
+
// the Lead prompt only bloats context. Lead picks agents by description
|
|
301
|
+
// (Claude Code whenToUse pattern); the workflow body carries the rules.
|
|
302
|
+
lines.push(agentBlocks
|
|
303
|
+
.map((agent) => `- ${agent.name} (${agent.id})${agent.description ? `: ${agent.description}` : ''}`)
|
|
304
|
+
.join('\n'));
|
|
295
305
|
}
|
|
296
306
|
return lines.join('\n\n');
|
|
297
307
|
}
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -26573,6 +26573,7 @@ function drainTuiSteeringPersist(leadSessionId) {
|
|
|
26573
26573
|
return _serialize(async () => {
|
|
26574
26574
|
let drained = [];
|
|
26575
26575
|
let droppedStale = 0;
|
|
26576
|
+
let prunedOrphans = 0;
|
|
26576
26577
|
try {
|
|
26577
26578
|
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
26578
26579
|
const next = normalizePendingStore(raw);
|
|
@@ -26586,7 +26587,21 @@ function drainTuiSteeringPersist(leadSessionId) {
|
|
|
26586
26587
|
return !stale;
|
|
26587
26588
|
});
|
|
26588
26589
|
drained = fresh.map(drainedRowToRestore).filter(Boolean);
|
|
26589
|
-
|
|
26590
|
+
for (const otherKey of Object.keys(next.sessions)) {
|
|
26591
|
+
if (otherKey === key) continue;
|
|
26592
|
+
const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
|
|
26593
|
+
const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
|
|
26594
|
+
const allStale = rows.every((row) => {
|
|
26595
|
+
const at = Number(row?.at) || otherTouched;
|
|
26596
|
+
return at > 0 && now - at > STALE_STEERING_RESTORE_TTL_MS;
|
|
26597
|
+
});
|
|
26598
|
+
if (rows.length === 0 || allStale) {
|
|
26599
|
+
delete next.sessions[otherKey];
|
|
26600
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
|
|
26601
|
+
prunedOrphans += rows.length;
|
|
26602
|
+
}
|
|
26603
|
+
}
|
|
26604
|
+
if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return void 0;
|
|
26590
26605
|
delete next.sessions[key];
|
|
26591
26606
|
if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
|
|
26592
26607
|
next.updatedAt = Date.now();
|
|
@@ -26602,6 +26617,13 @@ function drainTuiSteeringPersist(leadSessionId) {
|
|
|
26602
26617
|
if (droppedStale > 0) {
|
|
26603
26618
|
try {
|
|
26604
26619
|
process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}
|
|
26620
|
+
`);
|
|
26621
|
+
} catch {
|
|
26622
|
+
}
|
|
26623
|
+
}
|
|
26624
|
+
if (prunedOrphans > 0) {
|
|
26625
|
+
try {
|
|
26626
|
+
process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions
|
|
26605
26627
|
`);
|
|
26606
26628
|
} catch {
|
|
26607
26629
|
}
|
|
@@ -30212,6 +30234,15 @@ function createEngineApiA(bag) {
|
|
|
30212
30234
|
restoreState.requeueEntries = [];
|
|
30213
30235
|
restoreState.discardExecutionPendingResumeKeys = [];
|
|
30214
30236
|
}
|
|
30237
|
+
const pendingAfterAbortKick = setTimeout(() => {
|
|
30238
|
+
try {
|
|
30239
|
+
if (flags.disposed) return;
|
|
30240
|
+
if (getState().busy) return;
|
|
30241
|
+
if (pending.length > 0 && typeof drain === "function") void drain();
|
|
30242
|
+
} catch {
|
|
30243
|
+
}
|
|
30244
|
+
}, 150);
|
|
30245
|
+
pendingAfterAbortKick.unref?.();
|
|
30215
30246
|
const abortEpoch = flags.leadTurnEpoch;
|
|
30216
30247
|
const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0 ? Number(flags.manualAbortRecoveryMs) : MANUAL_ABORT_RECOVERY_MS;
|
|
30217
30248
|
const recoveryTimer = setTimeout(() => {
|
|
@@ -616,6 +616,23 @@ export function createEngineApiA(bag) {
|
|
|
616
616
|
// ── Bounded manual-abort recovery ───────────────────────────────────
|
|
617
617
|
// runtime.abort() above normally rejects the in-flight runtime.ask() so
|
|
618
618
|
// the turn's own finally clears busy within a tick. If that unwind is
|
|
619
|
+
// starved the recovery timer below hard-releases busy. Separately from
|
|
620
|
+
// recovery: queued work must never strand behind a cancelled turn
|
|
621
|
+
// (Codex parity — abort preserves pending input for the next turn; CC
|
|
622
|
+
// parity — the command queue survives cancel and fires when idle). The
|
|
623
|
+
// drain loop that owns a normal turn continues on its own; this bounded
|
|
624
|
+
// kick covers unwinds where no drain owner re-checks pending after busy
|
|
625
|
+
// clears. drain() self-guards (busy/draining/commandBusy), so the
|
|
626
|
+
// drain-owned path is unchanged and a duplicate kick is a no-op.
|
|
627
|
+
const pendingAfterAbortKick = setTimeout(() => {
|
|
628
|
+
try {
|
|
629
|
+
if (flags.disposed) return;
|
|
630
|
+
if (getState().busy) return;
|
|
631
|
+
if (pending.length > 0 && typeof drain === 'function') void drain();
|
|
632
|
+
} catch { /* best-effort */ }
|
|
633
|
+
}, 150);
|
|
634
|
+
pendingAfterAbortKick.unref?.();
|
|
635
|
+
// If that unwind is
|
|
619
636
|
// starved — e.g. a provider abort that never settles after a post-tool
|
|
620
637
|
// fetch stall — busy would stay true until the far-larger turn watchdog
|
|
621
638
|
// trips, wedging the TUI with dead input. Arm a short grace timer that
|
|
@@ -195,6 +195,7 @@ export function drainTuiSteeringPersist(leadSessionId) {
|
|
|
195
195
|
return _serialize(async () => {
|
|
196
196
|
let drained = [];
|
|
197
197
|
let droppedStale = 0;
|
|
198
|
+
let prunedOrphans = 0;
|
|
198
199
|
try {
|
|
199
200
|
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
200
201
|
const next = normalizePendingStore(raw);
|
|
@@ -210,7 +211,26 @@ export function drainTuiSteeringPersist(leadSessionId) {
|
|
|
210
211
|
return !stale;
|
|
211
212
|
});
|
|
212
213
|
drained = fresh.map(drainedRowToRestore).filter(Boolean);
|
|
213
|
-
|
|
214
|
+
// Orphan cleanup: buckets of OTHER sessions whose every row already
|
|
215
|
+
// aged past the restore TTL can never be restored (restore is keyed
|
|
216
|
+
// by the live session id), so they only grow the file forever
|
|
217
|
+
// (observed live: queued rows from sessions closed days ago). Prune
|
|
218
|
+
// them under the same lock/write.
|
|
219
|
+
for (const otherKey of Object.keys(next.sessions)) {
|
|
220
|
+
if (otherKey === key) continue;
|
|
221
|
+
const rows = Array.isArray(next.sessions[otherKey]) ? next.sessions[otherKey] : [];
|
|
222
|
+
const otherTouched = Number(next.sessionTouchedAt?.[otherKey]) || 0;
|
|
223
|
+
const allStale = rows.every((row) => {
|
|
224
|
+
const at = Number(row?.at) || otherTouched;
|
|
225
|
+
return at > 0 && (now - at) > STALE_STEERING_RESTORE_TTL_MS;
|
|
226
|
+
});
|
|
227
|
+
if (rows.length === 0 || allStale) {
|
|
228
|
+
delete next.sessions[otherKey];
|
|
229
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[otherKey];
|
|
230
|
+
prunedOrphans += rows.length;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (drained.length === 0 && droppedStale === 0 && prunedOrphans === 0) return undefined;
|
|
214
234
|
delete next.sessions[key];
|
|
215
235
|
if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
|
|
216
236
|
next.updatedAt = Date.now();
|
|
@@ -222,6 +242,9 @@ export function drainTuiSteeringPersist(leadSessionId) {
|
|
|
222
242
|
if (droppedStale > 0) {
|
|
223
243
|
try { process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}\n`); } catch {}
|
|
224
244
|
}
|
|
245
|
+
if (prunedOrphans > 0) {
|
|
246
|
+
try { process.stderr.write(`[tui] pruned ${prunedOrphans} orphaned steering row(s) from stale sessions\n`); } catch {}
|
|
247
|
+
}
|
|
225
248
|
return drained;
|
|
226
249
|
});
|
|
227
250
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
id: default
|
|
3
3
|
name: Cowork
|
|
4
4
|
description: "Parallel delegation."
|
|
5
|
-
agents: worker, heavy-worker, reviewer, debugger
|
|
5
|
+
agents: worker, heavy-worker, reviewer, debugger
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Cowork
|
|
@@ -13,23 +13,12 @@ investigation and planning — no edits, no state mutation, no delegation.
|
|
|
13
13
|
A new or changed request resets planning; a scope change requires fresh
|
|
14
14
|
approval.
|
|
15
15
|
|
|
16
|
-
On approval,
|
|
17
|
-
spawned in one turn; only a scope that depends on another's
|
|
18
|
-
Split the plan into as many scopes as possible: disjoint
|
|
19
|
-
are independent; merge only on a true output dependency.
|
|
20
|
-
scopes over sequential slices in one agent. Brief each agent
|
|
21
|
-
Brief contract.
|
|
22
|
-
|
|
23
|
-
Route by complexity: simple, well-understood implementation goes to Worker;
|
|
24
|
-
complex or investigative implementation goes to Heavy Worker; Lead itself
|
|
25
|
-
edits only a local, one-turn configuration/git change. Debugger only on a
|
|
26
|
-
defect needing deep root-cause analysis or a bug surviving 2+ review/fix
|
|
27
|
-
cycles.
|
|
28
|
-
|
|
29
|
-
Every implementation gets its own Reviewer, attached per scope — only the
|
|
30
|
-
local Lead-direct edits above are exempt. Keep the same reviewer through the
|
|
31
|
-
fix loop and repeat fix -> re-verify until clean; Lead cross-verifies in
|
|
32
|
-
parallel with the Reviewer.
|
|
16
|
+
On approval, delegate maximally: one agent per independent scope, fit to the
|
|
17
|
+
situation, all spawned in one turn; only a scope that depends on another's
|
|
18
|
+
output waits. Split the plan into as many scopes as possible: disjoint
|
|
19
|
+
file/module sets are independent; merge only on a true output dependency.
|
|
20
|
+
Prefer parallel scopes over sequential slices in one agent. Brief each agent
|
|
21
|
+
per the Lead Brief contract.
|
|
33
22
|
|
|
34
23
|
Report the verified result against the approved plan. Build, deploy, commit,
|
|
35
24
|
and push happen only on an explicit user request.
|