@link-assistant/hive-mind 2.0.6 → 2.0.8
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/CHANGELOG.md +56 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +16 -16
- package/src/config.lib.mjs +36 -0
- package/src/github.lib.mjs +7 -2
- package/src/models/index.mjs +19 -3
- package/src/session-monitor.lib.mjs +37 -1
- package/src/solve.disk-diagnostics.lib.mjs +342 -0
- package/src/solve.execution.lib.mjs +1 -0
- package/src/solve.mjs +17 -8
- package/src/solve.pre-pr-failure-notifier.lib.mjs +1 -0
- package/src/solve.results.lib.mjs +3 -0
- package/src/tool-retry.lib.mjs +60 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 072e941: fix(retry): keep the requested `--model` on transient overloads instead of switching to the fallback (#1949)
|
|
8
|
+
|
|
9
|
+
A transient **HTTP 529 "Overloaded"** result used to be classified as a
|
|
10
|
+
model-_capacity_ error (`isCapacity: true`), which made the shared retry helper
|
|
11
|
+
switch the user's requested `--model` to the configured fallback
|
|
12
|
+
(`opus -> opus-4-7`) on every overload. A 529 is a server-wide, transient
|
|
13
|
+
overload — not a signal that the selected model is full — so the run should retry
|
|
14
|
+
the **same** model. The overload branch in `src/tool-retry.lib.mjs` now returns
|
|
15
|
+
`isCapacity: false`; only a genuine "the selected model is at capacity" message
|
|
16
|
+
still triggers a `--model` switch. The fix lives in the shared helper, so every
|
|
17
|
+
tool (claude, codex, gemini, qwen, opencode, agent) inherits it.
|
|
18
|
+
|
|
19
|
+
Per-request fallback is now delegated to Claude Code itself: the claude tool
|
|
20
|
+
forwards `--fallback-model <id>` so overloads fall back _inside_ the CLI while our
|
|
21
|
+
`--model` stays stable.
|
|
22
|
+
|
|
23
|
+
Two display fixes remove the ambiguity that made this hard to diagnose:
|
|
24
|
+
- Warnings now render the resolved model ID alongside the alias, e.g.
|
|
25
|
+
`opus (claude-opus-4-8) -> opus-4-7 (claude-opus-4-7)`, via a new
|
|
26
|
+
`formatModelWithResolvedId` helper.
|
|
27
|
+
- The verbose per-retry "execution context" block now uses a shared
|
|
28
|
+
`logExecutionContext` helper that prints the resolved model actually passed to
|
|
29
|
+
the CLI, replacing a broken `argv.model === 'opus' ? 'opus' : 'sonnet'`
|
|
30
|
+
heuristic that mislabelled every non-`opus` alias as `sonnet`.
|
|
31
|
+
|
|
32
|
+
The PR/issue comment now shows the requested model with its resolved ID and the
|
|
33
|
+
requested thinking level (e.g. `high (~23999 tokens)`) via a new
|
|
34
|
+
`describeRequestedThinking` helper.
|
|
35
|
+
|
|
36
|
+
## 2.0.7
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- 6d9a2bb: feat(solve): log working-tree size before/after the AI agent and warn on Telegram when disk usage exceeds 5 GB (#1945)
|
|
41
|
+
|
|
42
|
+
`/solve` now records the size of its temporary working tree at two checkpoints:
|
|
43
|
+
after the repository is cloned (before the AI agent starts) and after the AI
|
|
44
|
+
working session ends. Both checkpoints emit a structured `📊 [DISK]` marker into
|
|
45
|
+
the captured solve log, so the cloned-repo size, the AI-induced delta, and the
|
|
46
|
+
final total are visible in `tail -f`-style debugging.
|
|
47
|
+
|
|
48
|
+
The session monitor parses those markers from the captured log and appends a
|
|
49
|
+
`💾 Disk usage` block to the Telegram completion message. The block raises a
|
|
50
|
+
warning when the cloned repository exceeds 5 GB, when the working tree grew by
|
|
51
|
+
more than 5 GB during the run, or when the total disk usage for the task
|
|
52
|
+
exceeds 5 GB — exactly the three conditions called out in the issue.
|
|
53
|
+
|
|
54
|
+
Sizing uses `du -sb` (byte-accurate on Linux), falls back to `du -sk` on BSD/
|
|
55
|
+
macOS, and finally to `fs.statSync` for single-file targets — no new runtime
|
|
56
|
+
dependency. The threshold is 5 GiB and uses a strict `>` comparison, so a tree
|
|
57
|
+
that lands at exactly 5 GiB does not warn.
|
|
58
|
+
|
|
3
59
|
## 2.0.6
|
|
4
60
|
|
|
5
61
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from
|
|
|
26
26
|
import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
|
|
27
27
|
import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
|
|
28
28
|
import { fetchModelInfo } from './model-info.lib.mjs';
|
|
29
|
-
import { classifyRetryableError, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
29
|
+
import { classifyRetryableError, logExecutionContext, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
30
30
|
import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
31
31
|
import { withAgentsMdAsClaudeMd } from './agents-md-claude-support.lib.mjs';
|
|
32
32
|
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
@@ -619,18 +619,10 @@ export const executeClaudeCommand = async params => {
|
|
|
619
619
|
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
|
|
620
620
|
}
|
|
621
621
|
if (argv.verbose) {
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
await
|
|
626
|
-
await log(` Branch: ${branchName}`, { verbose: true });
|
|
627
|
-
await log(` Prompt length: ${prompt.length} chars`, { verbose: true });
|
|
628
|
-
await log(` System prompt length: ${systemPrompt.length} chars`, { verbose: true });
|
|
629
|
-
if (feedbackLines && feedbackLines.length > 0) {
|
|
630
|
-
await log(` Feedback info included: Yes (${feedbackLines.length} lines)`, { verbose: true });
|
|
631
|
-
} else {
|
|
632
|
-
await log(' Feedback info included: No', { verbose: true });
|
|
633
|
-
}
|
|
622
|
+
// Issue #1949: logExecutionContext shows the requested alias with its resolved
|
|
623
|
+
// full ID (e.g. "opus (claude-opus-4-8)"). The old `argv.model === 'opus' ?
|
|
624
|
+
// 'opus' : 'sonnet'` heuristic mislabelled every non-"opus" alias as "sonnet".
|
|
625
|
+
await logExecutionContext({ log, model: argv.model, tool: 'claude', tempDir, branchName, promptLength: prompt.length, systemPromptLength: systemPrompt.length, feedbackLines });
|
|
634
626
|
}
|
|
635
627
|
const resourcesBefore = await getResourceSnapshot();
|
|
636
628
|
await log('📈 System resources before execution:', { verbose: true });
|
|
@@ -704,7 +696,13 @@ export const executeClaudeCommand = async params => {
|
|
|
704
696
|
const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
|
|
705
697
|
const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
|
|
706
698
|
const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
|
|
699
|
+
// Issue #1949: Let Claude Code handle transient overload (529) fallback via its own
|
|
700
|
+
// `--fallback-model` flag (it re-tries the primary each turn) instead of us swapping
|
|
701
|
+
// `--model`. Only for plain `--model` runs (not opusplan) with a distinct fallback.
|
|
702
|
+
const mappedFallbackModel = argv.fallbackModel ? mapModelToId(argv.fallbackModel) : undefined;
|
|
703
|
+
const useClaudeFallbackModel = !resolvedPlanModel && mappedFallbackModel && mappedFallbackModel !== effectiveModel;
|
|
707
704
|
let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
|
|
705
|
+
if (useClaudeFallbackModel) claudeArgs += ` --fallback-model ${mappedFallbackModel}`;
|
|
708
706
|
// Declare queuedFeedback for use in catch/finally blocks and return value
|
|
709
707
|
let queuedFeedback = [];
|
|
710
708
|
// Issue #817: When --accept-incomming-comments-as-input is set and we are
|
|
@@ -761,17 +759,19 @@ export const executeClaudeCommand = async params => {
|
|
|
761
759
|
const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
|
|
762
760
|
const mcpDisableArgs = mcpConfigPath ? ['--strict-mcp-config', '--mcp-config', mcpConfigPath] : [];
|
|
763
761
|
const disallowedToolsArgs = disallowedToolsList.length ? ['--disallowedTools', ...disallowedToolsList] : [];
|
|
762
|
+
const fallbackModelArgs = useClaudeFallbackModel ? ['--fallback-model', mappedFallbackModel] : []; // Issue #1949: Claude Code's per-request overload fallback
|
|
763
|
+
if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
|
|
764
764
|
if (argv.resume) {
|
|
765
765
|
const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
|
|
766
|
-
execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
|
|
766
|
+
execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
|
|
767
767
|
} else if (streamingInput) {
|
|
768
768
|
// Issue #817: Drive Claude via --input-format stream-json on a pipe
|
|
769
769
|
// stdin. Initial prompt + later PR comments are written as NDJSON
|
|
770
770
|
// frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
|
|
771
771
|
const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
|
|
772
|
-
execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
|
|
772
|
+
execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
|
|
773
773
|
} else {
|
|
774
|
-
execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
|
|
774
|
+
execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
|
|
775
775
|
}
|
|
776
776
|
if (streamingInput) {
|
|
777
777
|
await attachStreamingInput(bidirectionalHandler, execCommand, prompt, log, !!argv.verbose);
|
package/src/config.lib.mjs
CHANGED
|
@@ -412,6 +412,42 @@ export const getTokensToThinkingLevel = (maxBudget = DEFAULT_MAX_THINKING_BUDGET
|
|
|
412
412
|
// Default tokens to thinking level function (using default max budget)
|
|
413
413
|
export const tokensToThinkingLevel = getTokensToThinkingLevel(DEFAULT_MAX_THINKING_BUDGET);
|
|
414
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Issue #1949: Produce a human-readable description of the thinking level that was
|
|
417
|
+
* requested for a run, for inclusion in the PR/issue "Models used" comment. The
|
|
418
|
+
* user asked us to "display requested and (actual thinking level if possible)".
|
|
419
|
+
*
|
|
420
|
+
* The level is derived from `argv.think` (an explicit level keyword) and/or
|
|
421
|
+
* `argv.thinkingBudget` (an explicit token budget), mirroring the same translation
|
|
422
|
+
* logic used by resolveThinkingSettings():
|
|
423
|
+
* - When only --think is given, show the keyword (with the token budget it maps to).
|
|
424
|
+
* - When only --thinking-budget is given, derive the keyword from the budget.
|
|
425
|
+
* - When neither is given, the level is the tool's default → returns null so the
|
|
426
|
+
* caller can omit the line rather than guess.
|
|
427
|
+
*
|
|
428
|
+
* @param {Object} argv - Parsed CLI args (reads think, thinkingBudget, maxThinkingBudget)
|
|
429
|
+
* @returns {string|null} e.g. "high (~24000 tokens)", "off (disabled)", or null
|
|
430
|
+
*/
|
|
431
|
+
export const describeRequestedThinking = (argv = {}) => {
|
|
432
|
+
if (!argv || typeof argv !== 'object') return null;
|
|
433
|
+
const maxBudget = argv.maxThinkingBudget ?? DEFAULT_MAX_THINKING_BUDGET;
|
|
434
|
+
const levelToTokens = getThinkingLevelToTokens(maxBudget);
|
|
435
|
+
const tokensToLevel = getTokensToThinkingLevel(maxBudget);
|
|
436
|
+
|
|
437
|
+
let level = argv.think;
|
|
438
|
+
let budget = argv.thinkingBudget;
|
|
439
|
+
|
|
440
|
+
// Neither specified → tool default; the caller omits the line.
|
|
441
|
+
if (level === undefined && budget === undefined) return null;
|
|
442
|
+
|
|
443
|
+
if (level === undefined && budget !== undefined) level = tokensToLevel(budget);
|
|
444
|
+
if (budget === undefined && level !== undefined) budget = levelToTokens[level];
|
|
445
|
+
|
|
446
|
+
if (level === 'off' || budget === 0) return 'off (disabled)';
|
|
447
|
+
if (budget !== undefined && budget !== null) return `${level} (~${budget} tokens)`;
|
|
448
|
+
return String(level);
|
|
449
|
+
};
|
|
450
|
+
|
|
415
451
|
/**
|
|
416
452
|
* Valid effort levels for Opus 4.6 and Sonnet 4.6 (Issue #1238, Issue #1620)
|
|
417
453
|
* These models use CLAUDE_CODE_EFFORT_LEVEL for thinking depth control
|
package/src/github.lib.mjs
CHANGED
|
@@ -5,7 +5,7 @@ if (typeof globalThis.use === 'undefined') await ensureUseM();
|
|
|
5
5
|
const { $ } = await use('command-stream'); // Use command-stream for consistent $ behavior
|
|
6
6
|
import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.mjs';
|
|
7
7
|
import { reportError } from './sentry.lib.mjs';
|
|
8
|
-
import { githubLimits, timeouts } from './config.lib.mjs';
|
|
8
|
+
import { describeRequestedThinking, githubLimits, timeouts } from './config.lib.mjs';
|
|
9
9
|
import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
|
|
10
10
|
import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent } from './token-sanitization.lib.mjs';
|
|
11
11
|
export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent }; // Re-export for backward compatibility
|
|
@@ -356,6 +356,8 @@ export async function attachLogToGitHub(options) {
|
|
|
356
356
|
pricingInfo = null,
|
|
357
357
|
errorDuringExecution = false, // Issue #1088
|
|
358
358
|
requestedModel = null, // Issue #1225: The --model flag value
|
|
359
|
+
argv = null, // Issue #1949: parsed CLI args, used to derive the requested thinking level for the comment
|
|
360
|
+
thinkingInfo = null, // Issue #1949: explicit thinking level description (overrides the value derived from argv)
|
|
359
361
|
tool = null, // The tool used (claude, agent, opencode, codex)
|
|
360
362
|
resultModelUsage = null, // Issue #1454
|
|
361
363
|
budgetStatsData = null, // Issue #1491: budget stats for comment
|
|
@@ -428,7 +430,10 @@ export async function attachLogToGitHub(options) {
|
|
|
428
430
|
let modelInfoString = '';
|
|
429
431
|
if (requestedModel || tool || actualModelIds) {
|
|
430
432
|
try {
|
|
431
|
-
|
|
433
|
+
// Issue #1949: prefer an explicit thinkingInfo, otherwise derive it from argv
|
|
434
|
+
// (e.g. "high (~24000 tokens)"). null when the run used the tool's default.
|
|
435
|
+
const resolvedThinkingInfo = thinkingInfo ?? describeRequestedThinking(argv);
|
|
436
|
+
modelInfoString = await getModelInfoForComment({ requestedModel, tool, pricingInfo, actualModelIds, thinkingInfo: resolvedThinkingInfo });
|
|
432
437
|
if (verbose && modelInfoString) {
|
|
433
438
|
await log(' 🤖 Model info fetched for comment', { verbose: true });
|
|
434
439
|
}
|
package/src/models/index.mjs
CHANGED
|
@@ -926,7 +926,7 @@ const doesRequestedMatchActual = (requestedModel, actualModelId, tool) => {
|
|
|
926
926
|
* @param {Array<{modelId: string, modelInfo: Object|null}>|null} options.modelsUsed - Actual models used from CLI JSON output
|
|
927
927
|
* @returns {string} Formatted markdown string for model info section
|
|
928
928
|
*/
|
|
929
|
-
export const buildModelInfoString = ({ requestedModel = null, tool = null, pricingInfo = null, modelInfo = null, modelsUsed = null } = {}) => {
|
|
929
|
+
export const buildModelInfoString = ({ requestedModel = null, tool = null, pricingInfo = null, modelInfo = null, modelsUsed = null, thinkingInfo = null } = {}) => {
|
|
930
930
|
const hasRequested = requestedModel !== null && requestedModel !== undefined;
|
|
931
931
|
const hasModelsUsed = Array.isArray(modelsUsed) && modelsUsed.length > 0;
|
|
932
932
|
const hasModelInfo = modelInfo !== null;
|
|
@@ -941,7 +941,22 @@ export const buildModelInfoString = ({ requestedModel = null, tool = null, prici
|
|
|
941
941
|
}
|
|
942
942
|
|
|
943
943
|
if (hasRequested) {
|
|
944
|
-
|
|
944
|
+
// Issue #1949: the bare alias (e.g. "opus") is ambiguous \u2014 show the full model
|
|
945
|
+
// ID it resolves to so reviewers know exactly which model ran, e.g.
|
|
946
|
+
// "Requested: `opus` (`claude-opus-4-8`)". When the alias already equals its
|
|
947
|
+
// resolved ID (or cannot be resolved) we just print the alias once.
|
|
948
|
+
const resolvedRequested = resolveModelId(requestedModel, tool);
|
|
949
|
+
if (resolvedRequested && String(resolvedRequested).toLowerCase() !== String(requestedModel).toLowerCase()) {
|
|
950
|
+
info += `\n- Requested: \`${requestedModel}\` (\`${resolvedRequested}\`)`;
|
|
951
|
+
} else {
|
|
952
|
+
info += `\n- Requested: \`${requestedModel}\``;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Issue #1949: surface the requested thinking level alongside the model so the
|
|
957
|
+
// comment records how deeply the model was asked to think (null = tool default).
|
|
958
|
+
if (thinkingInfo) {
|
|
959
|
+
info += `\n- Thinking level: ${thinkingInfo}`;
|
|
945
960
|
}
|
|
946
961
|
|
|
947
962
|
if (hasModelsUsed) {
|
|
@@ -1044,7 +1059,7 @@ export const resolveDefaultFallbackModel = (tool, model) => {
|
|
|
1044
1059
|
* @param {Array<string>|null} options.actualModelIds - Actual model IDs from CLI JSON output
|
|
1045
1060
|
* @returns {Promise<string>} Formatted markdown model info section
|
|
1046
1061
|
*/
|
|
1047
|
-
export const getModelInfoForComment = async ({ requestedModel = null, tool = null, pricingInfo = null, actualModelIds = null } = {}) => {
|
|
1062
|
+
export const getModelInfoForComment = async ({ requestedModel = null, tool = null, pricingInfo = null, actualModelIds = null, thinkingInfo = null } = {}) => {
|
|
1048
1063
|
let modelIds = [];
|
|
1049
1064
|
|
|
1050
1065
|
if (Array.isArray(actualModelIds) && actualModelIds.length > 0) {
|
|
@@ -1075,5 +1090,6 @@ export const getModelInfoForComment = async ({ requestedModel = null, tool = nul
|
|
|
1075
1090
|
pricingInfo,
|
|
1076
1091
|
modelInfo: modelsUsed.length === 0 ? firstModelInfo : null,
|
|
1077
1092
|
modelsUsed: modelsUsed.length > 0 ? modelsUsed : null,
|
|
1093
|
+
thinkingInfo,
|
|
1078
1094
|
});
|
|
1079
1095
|
};
|
|
@@ -297,6 +297,28 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Issue #1945: Parse `📊 [DISK]` checkpoint markers out of the captured solve
|
|
302
|
+
* log and, when the captured sizes cross the 5 GB threshold(s), build a
|
|
303
|
+
* Telegram extraSection that warns the operator. Returns an empty string if
|
|
304
|
+
* the log is unreadable or contains no markers.
|
|
305
|
+
*/
|
|
306
|
+
async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile } = {}) {
|
|
307
|
+
if (!logPath) return '';
|
|
308
|
+
try {
|
|
309
|
+
const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
|
|
310
|
+
const logText = await readFile(logPath, 'utf8');
|
|
311
|
+
const parsed = diskLib.parseDiskMarkers(logText);
|
|
312
|
+
if (!parsed.afterClone && !parsed.afterAgent) return '';
|
|
313
|
+
return diskLib.formatDiskDiagnosticsBlock(parsed);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (verbose) {
|
|
316
|
+
console.log(`[VERBOSE] Could not inspect session log ${logPath} for disk diagnostics: ${error?.message || error}`);
|
|
317
|
+
}
|
|
318
|
+
return '';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
300
322
|
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
301
323
|
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
302
324
|
const elapsed = Date.now() - startTime.getTime();
|
|
@@ -648,6 +670,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
648
670
|
}
|
|
649
671
|
}
|
|
650
672
|
|
|
673
|
+
// Issue #1945: append a "💾 Disk usage" block (with warnings when the
|
|
674
|
+
// cloned repo, the delta during the run, or the total exceed 5 GB)
|
|
675
|
+
// parsed from the captured solve log markers.
|
|
676
|
+
const diskExtraSections = [];
|
|
677
|
+
try {
|
|
678
|
+
const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
679
|
+
const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, { verbose });
|
|
680
|
+
if (diskBlock) diskExtraSections.push(diskBlock);
|
|
681
|
+
} catch (diskError) {
|
|
682
|
+
if (verbose) {
|
|
683
|
+
console.log(`[VERBOSE] Could not build disk diagnostics section for ${sessionName}: ${diskError?.message || diskError}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
651
687
|
const message = formatSessionCompletionMessage({
|
|
652
688
|
sessionName,
|
|
653
689
|
sessionInfo,
|
|
@@ -656,7 +692,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
656
692
|
exitCode: finalExitCode,
|
|
657
693
|
infoBlock: sessionInfo?.infoBlock || '',
|
|
658
694
|
pullRequestUrl,
|
|
659
|
-
extraSections: [...limitsExtraSections, ...resumeExtraSections],
|
|
695
|
+
extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections],
|
|
660
696
|
});
|
|
661
697
|
|
|
662
698
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-space diagnostics for the `/solve` command (issue #1945).
|
|
3
|
+
*
|
|
4
|
+
* Captures two checkpoints around the AI working session:
|
|
5
|
+
*
|
|
6
|
+
* 1. AFTER_CLONE — size of the freshly-cloned `tempDir` BEFORE the AI agent
|
|
7
|
+
* starts. Tells us how large the repository itself is.
|
|
8
|
+
* 2. AFTER_AGENT — size of the same `tempDir` AFTER the AI agent has
|
|
9
|
+
* finished, so we can see how many bytes the working session added.
|
|
10
|
+
*
|
|
11
|
+
* Both checkpoints are written to the captured solve log as a single-line
|
|
12
|
+
* structured marker. The Telegram bot's `session-monitor.lib.mjs` parses those
|
|
13
|
+
* markers and, on the completion message, surfaces a Telegram block plus
|
|
14
|
+
* warnings when any of the three thresholds from the issue are crossed:
|
|
15
|
+
*
|
|
16
|
+
* - cloned repository > WARNING_THRESHOLD_BYTES
|
|
17
|
+
* - delta during run > WARNING_THRESHOLD_BYTES
|
|
18
|
+
* - total space used > WARNING_THRESHOLD_BYTES
|
|
19
|
+
*
|
|
20
|
+
* Implementation notes:
|
|
21
|
+
*
|
|
22
|
+
* - Uses `du -sb <path>` on Linux for byte-accurate sizing, falls back to
|
|
23
|
+
* `du -sk <path>` (kilobytes ×1024) on systems without GNU coreutils
|
|
24
|
+
* (macOS BSD `du` doesn't support `-b`). A final fs.statSync fallback
|
|
25
|
+
* keeps the helper non-throwing for plain files / inaccessible dirs.
|
|
26
|
+
* - The marker format is deliberately ASCII and key=value so it survives
|
|
27
|
+
* log truncation and stays parseable with a one-line regex. We DO NOT
|
|
28
|
+
* emit JSON because the existing log is human-tailing-friendly and a
|
|
29
|
+
* stray closing brace from another logger could confuse JSON.parse.
|
|
30
|
+
*
|
|
31
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1945
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
|
|
37
|
+
/** 5 GB threshold (binary). Matches the issue body verbatim. */
|
|
38
|
+
export const WARNING_THRESHOLD_BYTES = 5 * 1024 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
export const DISK_MARKER_PREFIX = '📊 [DISK]';
|
|
41
|
+
export const DISK_PHASE_AFTER_CLONE = 'after_clone';
|
|
42
|
+
export const DISK_PHASE_AFTER_AGENT = 'after_agent';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Measure the size of a path in bytes. Robust to missing tools / paths.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} targetPath
|
|
48
|
+
* @returns {number|null} Bytes, or null if the path is missing/unreadable.
|
|
49
|
+
*/
|
|
50
|
+
export function measureDirectorySize(targetPath) {
|
|
51
|
+
if (!targetPath) return null;
|
|
52
|
+
// Prefer `du -sb` (GNU coreutils) for byte-accurate sizing.
|
|
53
|
+
try {
|
|
54
|
+
const out = execFileSync('du', ['-sb', targetPath], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
timeout: 60_000,
|
|
58
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
59
|
+
}).trim();
|
|
60
|
+
const bytes = parseInt(out.split(/\s+/)[0], 10);
|
|
61
|
+
if (Number.isFinite(bytes) && bytes >= 0) return bytes;
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to -sk fallback for BSD du / macOS.
|
|
64
|
+
}
|
|
65
|
+
// BSD `du` (macOS) doesn't support -b but does support -sk (kilobytes).
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync('du', ['-sk', targetPath], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
70
|
+
timeout: 60_000,
|
|
71
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
72
|
+
}).trim();
|
|
73
|
+
const kb = parseInt(out.split(/\s+/)[0], 10);
|
|
74
|
+
if (Number.isFinite(kb) && kb >= 0) return kb * 1024;
|
|
75
|
+
} catch {
|
|
76
|
+
// Fall through to fs.statSync — last resort for single-file paths.
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const stat = fs.statSync(targetPath);
|
|
80
|
+
return stat.size;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Human-readable byte format. Two flavours:
|
|
88
|
+
* - `formatBytes(bytes)` → `"12.0 GB"` (matches limits.lib.mjs style)
|
|
89
|
+
* - `formatBytesCompact(b)` → `"12G"` (matches the issue body verbatim
|
|
90
|
+
* and cleanup.lib.mjs)
|
|
91
|
+
*
|
|
92
|
+
* @param {number|null|undefined} bytes
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function formatBytes(bytes) {
|
|
96
|
+
if (bytes == null || Number.isNaN(bytes)) return '? B';
|
|
97
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
98
|
+
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
|
99
|
+
let value = bytes / 1024;
|
|
100
|
+
let unit = 0;
|
|
101
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
102
|
+
value /= 1024;
|
|
103
|
+
unit++;
|
|
104
|
+
}
|
|
105
|
+
// 1 decimal for GB and above (matches limits.lib formatBytes), none below.
|
|
106
|
+
const decimals = units[unit] === 'GB' || units[unit] === 'TB' || units[unit] === 'PB' ? 1 : 0;
|
|
107
|
+
return `${value.toFixed(decimals)} ${units[unit]}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Signed byte delta — adds a leading "+" for positive non-zero values so a
|
|
112
|
+
* growth like 500 MB renders as "+500 MB" in both logs and Telegram.
|
|
113
|
+
*/
|
|
114
|
+
export function formatBytesDelta(bytes) {
|
|
115
|
+
if (bytes == null || Number.isNaN(bytes)) return '? B';
|
|
116
|
+
if (bytes === 0) return '±0 B';
|
|
117
|
+
const sign = bytes > 0 ? '+' : '-';
|
|
118
|
+
return `${sign}${formatBytes(Math.abs(bytes))}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeForMarker(value) {
|
|
122
|
+
// Strip newlines and the marker prefix so a path containing the literal
|
|
123
|
+
// "📊 [DISK]" cannot inject a fake marker. Paths almost never contain spaces
|
|
124
|
+
// in /tmp but we still quote with backticks for the human-readable suffix
|
|
125
|
+
// and use key=value pairs for the parseable head.
|
|
126
|
+
return String(value)
|
|
127
|
+
.replace(/[\r\n]+/g, ' ')
|
|
128
|
+
.slice(0, 2048);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build a single-line structured log marker the parent (Telegram bot) can
|
|
133
|
+
* parse out of the captured log to surface size warnings.
|
|
134
|
+
*
|
|
135
|
+
* Example (after_clone):
|
|
136
|
+
* 📊 [DISK] phase=after_clone bytes=12884901888 path=/tmp/foo size=12.0 GB
|
|
137
|
+
*
|
|
138
|
+
* Example (after_agent):
|
|
139
|
+
* 📊 [DISK] phase=after_agent bytes=13312000000 deltaBytes=524288000 path=/tmp/foo size=12.4 GB delta=+500.0 MB
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} params
|
|
142
|
+
* @param {string} params.phase - 'after_clone' | 'after_agent'
|
|
143
|
+
* @param {number|null} params.bytes - Current size of tempDir in bytes
|
|
144
|
+
* @param {number|null} [params.deltaBytes] - Bytes added since after_clone (after_agent only)
|
|
145
|
+
* @param {string} params.path - The measured path
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function buildDiskMarker({ phase, bytes, deltaBytes = null, path: targetPath }) {
|
|
149
|
+
const head = [`phase=${phase}`];
|
|
150
|
+
if (Number.isFinite(bytes)) head.push(`bytes=${bytes}`);
|
|
151
|
+
if (Number.isFinite(deltaBytes)) head.push(`deltaBytes=${deltaBytes}`);
|
|
152
|
+
head.push(`path=${escapeForMarker(targetPath || '')}`);
|
|
153
|
+
const suffixParts = [];
|
|
154
|
+
if (Number.isFinite(bytes)) suffixParts.push(`size=${formatBytes(bytes)}`);
|
|
155
|
+
if (Number.isFinite(deltaBytes)) suffixParts.push(`delta=${formatBytesDelta(deltaBytes)}`);
|
|
156
|
+
const suffix = suffixParts.length ? ` ${suffixParts.join(' ')}` : '';
|
|
157
|
+
return `${DISK_MARKER_PREFIX} ${head.join(' ')}${suffix}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse all `📊 [DISK]` markers out of a captured solve log. The LAST marker
|
|
162
|
+
* for each phase wins (sessions that restart can emit more than one).
|
|
163
|
+
*
|
|
164
|
+
* @param {string} logText
|
|
165
|
+
* @returns {{
|
|
166
|
+
* afterClone: {bytes:number|null, path:string|null} | null,
|
|
167
|
+
* afterAgent: {bytes:number|null, deltaBytes:number|null, path:string|null} | null
|
|
168
|
+
* }}
|
|
169
|
+
*/
|
|
170
|
+
export function parseDiskMarkers(logText) {
|
|
171
|
+
const result = { afterClone: null, afterAgent: null };
|
|
172
|
+
if (!logText || typeof logText !== 'string') return result;
|
|
173
|
+
// Anchor to the marker prefix so a quoted user comment containing this
|
|
174
|
+
// string mid-line is not mistakenly parsed.
|
|
175
|
+
const re = /📊 \[DISK\] ([^\n\r]+)/g;
|
|
176
|
+
let m;
|
|
177
|
+
while ((m = re.exec(logText)) !== null) {
|
|
178
|
+
const pairs = {};
|
|
179
|
+
// key=value tokens, where value runs until next " key=" or EOL.
|
|
180
|
+
const tokenRe = /(\w+)=([^\s][^\n\r]*?)(?=\s+\w+=|$)/g;
|
|
181
|
+
let t;
|
|
182
|
+
while ((t = tokenRe.exec(m[1])) !== null) {
|
|
183
|
+
pairs[t[1]] = t[2];
|
|
184
|
+
}
|
|
185
|
+
const phase = pairs.phase;
|
|
186
|
+
if (phase !== DISK_PHASE_AFTER_CLONE && phase !== DISK_PHASE_AFTER_AGENT) continue;
|
|
187
|
+
const bytes = parseInt(pairs.bytes, 10);
|
|
188
|
+
const deltaBytes = parseInt(pairs.deltaBytes, 10);
|
|
189
|
+
const entry = {
|
|
190
|
+
bytes: Number.isFinite(bytes) ? bytes : null,
|
|
191
|
+
path: pairs.path || null,
|
|
192
|
+
};
|
|
193
|
+
if (phase === DISK_PHASE_AFTER_AGENT) {
|
|
194
|
+
entry.deltaBytes = Number.isFinite(deltaBytes) ? deltaBytes : null;
|
|
195
|
+
result.afterAgent = entry;
|
|
196
|
+
} else {
|
|
197
|
+
result.afterClone = entry;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Decide which of the three issue thresholds were crossed.
|
|
205
|
+
*
|
|
206
|
+
* @param {{afterClone: object|null, afterAgent: object|null}} parsed
|
|
207
|
+
* @param {number} [threshold=WARNING_THRESHOLD_BYTES]
|
|
208
|
+
* @returns {{cloneTooLarge:boolean, deltaTooLarge:boolean, totalTooLarge:boolean}}
|
|
209
|
+
*/
|
|
210
|
+
export function computeDiskWarnings(parsed, threshold = WARNING_THRESHOLD_BYTES) {
|
|
211
|
+
const cloneBytes = parsed?.afterClone?.bytes ?? null;
|
|
212
|
+
const totalBytes = parsed?.afterAgent?.bytes ?? cloneBytes;
|
|
213
|
+
const deltaBytes = parsed?.afterAgent?.deltaBytes ?? null;
|
|
214
|
+
return {
|
|
215
|
+
cloneTooLarge: Number.isFinite(cloneBytes) && cloneBytes > threshold,
|
|
216
|
+
deltaTooLarge: Number.isFinite(deltaBytes) && deltaBytes > threshold,
|
|
217
|
+
totalTooLarge: Number.isFinite(totalBytes) && totalBytes > threshold,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Telegram block (Markdown code fence) describing the captured sizes plus,
|
|
223
|
+
* when any threshold is crossed, a `⚠️ Warnings:` tail. Returns an empty
|
|
224
|
+
* string when there are no markers in the log (no logs ⇒ no surprise output).
|
|
225
|
+
*
|
|
226
|
+
* Returned shape:
|
|
227
|
+
*
|
|
228
|
+
* 💾 Disk usage (gh-issue-solver-…)
|
|
229
|
+
* ```
|
|
230
|
+
* Cloned repository: 12.0 GB
|
|
231
|
+
* After agent: 12.4 GB (+500.0 MB)
|
|
232
|
+
* Threshold: 5.0 GB
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ Cloned repository exceeds 5.0 GB
|
|
235
|
+
* ⚠️ Total disk usage exceeds 5.0 GB
|
|
236
|
+
* ```
|
|
237
|
+
*
|
|
238
|
+
* @param {{afterClone: object|null, afterAgent: object|null}} parsed
|
|
239
|
+
* @param {Object} [options]
|
|
240
|
+
* @param {number} [options.threshold=WARNING_THRESHOLD_BYTES]
|
|
241
|
+
* @param {string} [options.title='💾 Disk usage']
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function formatDiskDiagnosticsBlock(parsed, options = {}) {
|
|
245
|
+
if (!parsed || (!parsed.afterClone && !parsed.afterAgent)) return '';
|
|
246
|
+
const threshold = Number.isFinite(options.threshold) ? options.threshold : WARNING_THRESHOLD_BYTES;
|
|
247
|
+
const title = options.title || '💾 Disk usage';
|
|
248
|
+
const warnings = computeDiskWarnings(parsed, threshold);
|
|
249
|
+
const lines = [];
|
|
250
|
+
const cloneBytes = parsed.afterClone?.bytes ?? null;
|
|
251
|
+
const totalBytes = parsed.afterAgent?.bytes ?? null;
|
|
252
|
+
const deltaBytes = parsed.afterAgent?.deltaBytes ?? null;
|
|
253
|
+
if (cloneBytes !== null) {
|
|
254
|
+
lines.push(`Cloned repository: ${formatBytes(cloneBytes)}`);
|
|
255
|
+
}
|
|
256
|
+
if (totalBytes !== null) {
|
|
257
|
+
const deltaStr = deltaBytes !== null ? ` (${formatBytesDelta(deltaBytes)})` : '';
|
|
258
|
+
lines.push(`After agent: ${formatBytes(totalBytes)}${deltaStr}`);
|
|
259
|
+
} else if (deltaBytes !== null) {
|
|
260
|
+
lines.push(`Delta during run: ${formatBytesDelta(deltaBytes)}`);
|
|
261
|
+
}
|
|
262
|
+
lines.push(`Threshold: ${formatBytes(threshold)}`);
|
|
263
|
+
const warningLines = [];
|
|
264
|
+
if (warnings.cloneTooLarge) warningLines.push(`⚠️ Cloned repository exceeds ${formatBytes(threshold)}`);
|
|
265
|
+
if (warnings.deltaTooLarge) warningLines.push(`⚠️ Folder grew by more than ${formatBytes(threshold)} during the run`);
|
|
266
|
+
if (warnings.totalTooLarge) warningLines.push(`⚠️ Total disk usage exceeds ${formatBytes(threshold)}`);
|
|
267
|
+
if (warningLines.length) {
|
|
268
|
+
lines.push('');
|
|
269
|
+
lines.push(...warningLines);
|
|
270
|
+
}
|
|
271
|
+
return `${title}\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Capture the AFTER_CLONE checkpoint and log it. Safe to call when `log`
|
|
276
|
+
* is missing; degrades to console.log so a CLI-only run still shows the size.
|
|
277
|
+
*
|
|
278
|
+
* Returns the captured size in bytes so the caller can stash it for the
|
|
279
|
+
* AFTER_AGENT delta calculation, or null if measurement failed.
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} params
|
|
282
|
+
* @param {string} params.tempDir
|
|
283
|
+
* @param {Function} [params.log] - The bound `log` from solve.mjs
|
|
284
|
+
* @returns {Promise<number|null>}
|
|
285
|
+
*/
|
|
286
|
+
export async function recordAfterCloneSize({ tempDir, log }) {
|
|
287
|
+
const bytes = measureDirectorySize(tempDir);
|
|
288
|
+
const marker = buildDiskMarker({
|
|
289
|
+
phase: DISK_PHASE_AFTER_CLONE,
|
|
290
|
+
bytes,
|
|
291
|
+
path: tempDir,
|
|
292
|
+
});
|
|
293
|
+
if (log) {
|
|
294
|
+
await log(`\n${marker}`);
|
|
295
|
+
} else {
|
|
296
|
+
console.log(marker);
|
|
297
|
+
}
|
|
298
|
+
return bytes;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Capture the AFTER_AGENT checkpoint and log it (with delta versus the
|
|
303
|
+
* AFTER_CLONE checkpoint when available). Returns the captured size in bytes.
|
|
304
|
+
*
|
|
305
|
+
* @param {Object} params
|
|
306
|
+
* @param {string} params.tempDir
|
|
307
|
+
* @param {number|null} params.beforeBytes - The AFTER_CLONE size captured earlier
|
|
308
|
+
* @param {Function} [params.log]
|
|
309
|
+
* @returns {Promise<number|null>}
|
|
310
|
+
*/
|
|
311
|
+
export async function recordAfterAgentSize({ tempDir, beforeBytes, log }) {
|
|
312
|
+
const bytes = measureDirectorySize(tempDir);
|
|
313
|
+
const deltaBytes = Number.isFinite(bytes) && Number.isFinite(beforeBytes) ? bytes - beforeBytes : null;
|
|
314
|
+
const marker = buildDiskMarker({
|
|
315
|
+
phase: DISK_PHASE_AFTER_AGENT,
|
|
316
|
+
bytes,
|
|
317
|
+
deltaBytes,
|
|
318
|
+
path: tempDir,
|
|
319
|
+
});
|
|
320
|
+
if (log) {
|
|
321
|
+
await log(`\n${marker}`);
|
|
322
|
+
} else {
|
|
323
|
+
console.log(marker);
|
|
324
|
+
}
|
|
325
|
+
return bytes;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export default {
|
|
329
|
+
WARNING_THRESHOLD_BYTES,
|
|
330
|
+
DISK_MARKER_PREFIX,
|
|
331
|
+
DISK_PHASE_AFTER_CLONE,
|
|
332
|
+
DISK_PHASE_AFTER_AGENT,
|
|
333
|
+
measureDirectorySize,
|
|
334
|
+
formatBytes,
|
|
335
|
+
formatBytesDelta,
|
|
336
|
+
buildDiskMarker,
|
|
337
|
+
parseDiskMarkers,
|
|
338
|
+
computeDiskWarnings,
|
|
339
|
+
formatDiskDiagnosticsBlock,
|
|
340
|
+
recordAfterCloneSize,
|
|
341
|
+
recordAfterAgentSize,
|
|
342
|
+
};
|
|
@@ -197,6 +197,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
|
|
|
197
197
|
verbose: argv.verbose || false,
|
|
198
198
|
errorMessage: cleanErrorMessage(error),
|
|
199
199
|
// Issue #1225: Pass model and tool info for PR comments
|
|
200
|
+
argv,
|
|
200
201
|
requestedModel: argv.originalModel || argv.model,
|
|
201
202
|
tool: argv.tool || 'claude',
|
|
202
203
|
});
|
package/src/solve.mjs
CHANGED
|
@@ -55,6 +55,7 @@ const { configureWorkingSession, beginWorkingSession, endWorkingSession } = awai
|
|
|
55
55
|
const getResourceSnapshot = memoryCheck.getResourceSnapshot;
|
|
56
56
|
const { handleAutoPrCreation } = await import('./solve.auto-pr.lib.mjs');
|
|
57
57
|
const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('./solve.repo-setup.lib.mjs');
|
|
58
|
+
const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.disk-diagnostics.lib.mjs');
|
|
58
59
|
const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
|
|
59
60
|
const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
|
|
60
61
|
// Issue #1625: centralized markers + tracked comment posting for solve.mjs's
|
|
@@ -501,6 +502,8 @@ try {
|
|
|
501
502
|
needsClone,
|
|
502
503
|
});
|
|
503
504
|
|
|
505
|
+
cleanupContext.diskDiagnostics = { beforeBytes: await recordAfterCloneSize({ tempDir, log }) };
|
|
506
|
+
|
|
504
507
|
// Verify default branch and status using the new module
|
|
505
508
|
// Pass argv, owner, repo, issueUrl for empty repository auto-initialization (--auto-init-repository)
|
|
506
509
|
const defaultBranch = await verifyDefaultBranchAndStatus({
|
|
@@ -814,6 +817,12 @@ try {
|
|
|
814
817
|
toolResult = claudeResult;
|
|
815
818
|
}
|
|
816
819
|
|
|
820
|
+
try {
|
|
821
|
+
await recordAfterAgentSize({ tempDir, beforeBytes: cleanupContext.diskDiagnostics?.beforeBytes ?? null, log });
|
|
822
|
+
} catch (diskError) {
|
|
823
|
+
await log(`⚠️ Disk-size measurement failed: ${cleanErrorMessage(diskError)}`, { level: 'warning', verbose: true });
|
|
824
|
+
}
|
|
825
|
+
|
|
817
826
|
// Issue #1823: Mark the end of the AI working session. If a graceful-shutdown interrupt arrived
|
|
818
827
|
// during the session (deferred by the working-session guard), honor it now: auto-commit any
|
|
819
828
|
// uncommitted changes and exit gracefully — only AFTER the AI tool has fully finished its turn.
|
|
@@ -933,10 +942,10 @@ try {
|
|
|
933
942
|
toolName: getToolDisplayName(argv.tool),
|
|
934
943
|
resumeCommand,
|
|
935
944
|
sessionId,
|
|
945
|
+
argv,
|
|
936
946
|
requestedModel: argv.originalModel || argv.model,
|
|
937
947
|
tool: argv.tool || 'claude',
|
|
938
|
-
// Issue #1454:
|
|
939
|
-
resultModelUsage,
|
|
948
|
+
resultModelUsage, // Issue #1454: accurate multi-model display
|
|
940
949
|
});
|
|
941
950
|
|
|
942
951
|
if (logUploadSuccess) {
|
|
@@ -1005,10 +1014,10 @@ try {
|
|
|
1005
1014
|
// See: https://github.com/link-assistant/hive-mind/issues/1152
|
|
1006
1015
|
isAutoResumeEnabled: true,
|
|
1007
1016
|
autoResumeMode: limitContinueMode,
|
|
1017
|
+
argv,
|
|
1008
1018
|
requestedModel: argv.originalModel || argv.model,
|
|
1009
1019
|
tool: argv.tool || 'claude',
|
|
1010
|
-
// Issue #1454:
|
|
1011
|
-
resultModelUsage,
|
|
1020
|
+
resultModelUsage, // Issue #1454: accurate multi-model display
|
|
1012
1021
|
});
|
|
1013
1022
|
|
|
1014
1023
|
if (logUploadSuccess) {
|
|
@@ -1116,10 +1125,10 @@ try {
|
|
|
1116
1125
|
sessionId,
|
|
1117
1126
|
// If not a usage limit case, fall back to generic failure format
|
|
1118
1127
|
errorMessage: limitReached ? undefined : toolFailureMessage,
|
|
1128
|
+
argv,
|
|
1119
1129
|
requestedModel: argv.originalModel || argv.model,
|
|
1120
1130
|
tool: argv.tool || 'claude',
|
|
1121
|
-
// Issue #1454:
|
|
1122
|
-
resultModelUsage,
|
|
1131
|
+
resultModelUsage, // Issue #1454: accurate multi-model display
|
|
1123
1132
|
});
|
|
1124
1133
|
|
|
1125
1134
|
if (logUploadSuccess) {
|
|
@@ -1371,10 +1380,10 @@ try {
|
|
|
1371
1380
|
sessionId,
|
|
1372
1381
|
tempDir,
|
|
1373
1382
|
anthropicTotalCostUSD,
|
|
1383
|
+
argv,
|
|
1374
1384
|
requestedModel: argv.originalModel || argv.model,
|
|
1375
1385
|
tool: argv.tool || 'claude',
|
|
1376
|
-
// Issue #1454:
|
|
1377
|
-
resultModelUsage,
|
|
1386
|
+
resultModelUsage, // Issue #1454: accurate multi-model display
|
|
1378
1387
|
});
|
|
1379
1388
|
|
|
1380
1389
|
if (logUploadSuccess) {
|
|
@@ -206,6 +206,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
|
|
|
206
206
|
verbose: argv.verbose,
|
|
207
207
|
errorMessage: `${errorPrefix}\n\nReason: ${reason || 'Unknown error'}`,
|
|
208
208
|
failureActionSection: buildPrePullRequestFailureActionSection(reason),
|
|
209
|
+
argv,
|
|
209
210
|
requestedModel: argv.originalModel || argv.model,
|
|
210
211
|
tool: argv.tool || 'claude',
|
|
211
212
|
});
|
|
@@ -875,6 +875,7 @@ Fixes ${issueRef}
|
|
|
875
875
|
// Issue #1152: Pass sessionType for differentiated log comments
|
|
876
876
|
sessionType,
|
|
877
877
|
// Issue #1225: Pass model and tool info for PR comments
|
|
878
|
+
argv,
|
|
878
879
|
requestedModel: argv.originalModel || argv.model,
|
|
879
880
|
tool: argv.tool || 'claude',
|
|
880
881
|
// Issue #1454: Pass resultModelUsage for accurate multi-model display
|
|
@@ -961,6 +962,7 @@ Fixes ${issueRef}
|
|
|
961
962
|
// Issue #1152: Pass sessionType for differentiated log comments
|
|
962
963
|
sessionType,
|
|
963
964
|
// Issue #1225: Pass model and tool info for issue comments
|
|
965
|
+
argv,
|
|
964
966
|
requestedModel: argv.originalModel || argv.model,
|
|
965
967
|
tool: argv.tool || 'claude',
|
|
966
968
|
// Issue #1454: Pass resultModelUsage for accurate multi-model display
|
|
@@ -1052,6 +1054,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
|
|
|
1052
1054
|
verbose: argv.verbose || false,
|
|
1053
1055
|
errorMessage: cleanErrorMessage(error),
|
|
1054
1056
|
// Issue #1225: Pass model and tool info for PR comments
|
|
1057
|
+
argv,
|
|
1055
1058
|
requestedModel: argv.originalModel || argv.model,
|
|
1056
1059
|
tool: argv.tool || 'claude',
|
|
1057
1060
|
});
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -27,12 +27,28 @@ export const classifyRetryableError = value => {
|
|
|
27
27
|
const message = normalizeMessage(value);
|
|
28
28
|
const lower = message.toLowerCase();
|
|
29
29
|
|
|
30
|
+
// Genuine model-specific capacity: the API explicitly tells us this *particular*
|
|
31
|
+
// model is full and recommends trying a *different* model (e.g. Codex's
|
|
32
|
+
// "Selected model is at capacity. Please try a different model."). Here a model
|
|
33
|
+
// switch is the correct, API-recommended recovery, so isCapacity stays true.
|
|
30
34
|
if (lower.includes('selected model is at capacity') || (lower.includes('at capacity') && lower.includes('try a different model'))) {
|
|
31
35
|
return { message, isRetryable: true, isCapacity: true, label: 'Model capacity error' };
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
// Issue #1949: Transient server-wide overload (HTTP 529 / "overloaded_error"). The
|
|
39
|
+
// Claude API surfaces this as a synthetic result message:
|
|
40
|
+
// "API Error: 529 Overloaded. This is a server-side issue, usually temporary —
|
|
41
|
+
// try again in a moment. If it persists, check https://status.claude.com."
|
|
42
|
+
// This is NOT a model-specific capacity problem — Anthropic's own guidance is to
|
|
43
|
+
// retry the *same* request after a short backoff (the message literally says "try
|
|
44
|
+
// again in a moment"). Switching the requested `--model` to a fallback (e.g.
|
|
45
|
+
// opus -> opus-4-7) is wrong here: it silently downgrades the user's chosen model
|
|
46
|
+
// for a purely transient blip, and the fallback model lives behind the same
|
|
47
|
+
// overloaded API anyway. Claude Code already exposes its own per-request fallback
|
|
48
|
+
// via `--fallback-model` (wired in claude.lib.mjs), so we keep `--model` stable and
|
|
49
|
+
// simply retry. Therefore isCapacity is false → retry with the same model.
|
|
34
50
|
if (lower.includes('overloaded') || lower.includes('overloaded_error')) {
|
|
35
|
-
return { message, isRetryable: true, isCapacity:
|
|
51
|
+
return { message, isRetryable: true, isCapacity: false, label: 'API overload' };
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
if (lower.includes('request timed out')) {
|
|
@@ -140,6 +156,34 @@ export const resolveConfiguredFallbackModel = ({ tool, currentModel, configuredF
|
|
|
140
156
|
return resolveDefaultFallbackModel(tool, currentModel);
|
|
141
157
|
};
|
|
142
158
|
|
|
159
|
+
// Issue #1949: Render a model alias together with the full ID it resolves to, e.g.
|
|
160
|
+
// "opus (claude-opus-4-8)". Earlier the warning printed only the bare alias, so a
|
|
161
|
+
// message like "Switching to fallback model: opus -> opus-4-7" was ambiguous (what
|
|
162
|
+
// does "opus" actually map to?). Showing both removes that ambiguity. When the alias
|
|
163
|
+
// already equals its resolved ID (or cannot be resolved) we just print the alias.
|
|
164
|
+
export const formatModelWithResolvedId = (model, tool) => {
|
|
165
|
+
if (!model) return String(model);
|
|
166
|
+
const resolved = resolveModelId(model, tool);
|
|
167
|
+
if (!resolved || normalizeModelKey(resolved) === normalizeModelKey(model)) return String(model);
|
|
168
|
+
return `${model} (${resolved})`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Issue #1949: Shared verbose "execution context" logger. Extracted from
|
|
172
|
+
// claude.lib.mjs so the per-tool retry loops can emit a consistent pre-run summary
|
|
173
|
+
// (resolved model, working dir, branch, prompt sizes, feedback) without each file
|
|
174
|
+
// duplicating the block. The model line uses formatModelWithResolvedId so the alias
|
|
175
|
+
// and its full ID are always shown together (e.g. "opus (claude-opus-4-8)").
|
|
176
|
+
export const logExecutionContext = async ({ log, model, tool, tempDir, branchName, promptLength, systemPromptLength, feedbackLines } = {}) => {
|
|
177
|
+
if (typeof log !== 'function') return;
|
|
178
|
+
await log(` Model: ${formatModelWithResolvedId(model, tool)}`, { verbose: true });
|
|
179
|
+
await log(` Working directory: ${tempDir}`, { verbose: true });
|
|
180
|
+
await log(` Branch: ${branchName}`, { verbose: true });
|
|
181
|
+
await log(` Prompt length: ${promptLength} chars`, { verbose: true });
|
|
182
|
+
await log(` System prompt length: ${systemPromptLength} chars`, { verbose: true });
|
|
183
|
+
const feedbackCount = feedbackLines && feedbackLines.length > 0 ? feedbackLines.length : 0;
|
|
184
|
+
await log(feedbackCount > 0 ? ` Feedback info included: Yes (${feedbackCount} lines)` : ' Feedback info included: No', { verbose: true });
|
|
185
|
+
};
|
|
186
|
+
|
|
143
187
|
export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage } = {}) => {
|
|
144
188
|
const fallbackModel = resolveConfiguredFallbackModel({
|
|
145
189
|
tool,
|
|
@@ -148,7 +192,17 @@ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage
|
|
|
148
192
|
});
|
|
149
193
|
|
|
150
194
|
const classification = classifyRetryableError(errorMessage);
|
|
195
|
+
|
|
196
|
+
// Issue #1949: Only switch the requested `--model` for genuine model-specific
|
|
197
|
+
// capacity errors where the API itself recommends a different model. Transient,
|
|
198
|
+
// retryable conditions (overload/529, timeouts, rate limits, socket drops, …) are
|
|
199
|
+
// classified with isCapacity=false and must retry the *same* model — Claude Code's
|
|
200
|
+
// own `--fallback-model` handles per-request fallback for those without us mutating
|
|
201
|
+
// the user's chosen model.
|
|
151
202
|
if (!fallbackModel || !classification.isCapacity || !argv?.model) {
|
|
203
|
+
if (typeof log === 'function' && classification.isRetryable && !classification.isCapacity) {
|
|
204
|
+
await log(` Keeping requested model ${formatModelWithResolvedId(argv?.model, tool)} (transient ${classification.label || 'error'} — no fallback switch, Issue #1949)`, { verbose: true });
|
|
205
|
+
}
|
|
152
206
|
return { switched: false, fallbackModel, reason: classification.label };
|
|
153
207
|
}
|
|
154
208
|
|
|
@@ -163,7 +217,9 @@ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage
|
|
|
163
217
|
if (!argv.fallbackModel) argv.fallbackModel = fallbackModel;
|
|
164
218
|
|
|
165
219
|
if (typeof log === 'function') {
|
|
166
|
-
|
|
220
|
+
// Issue #1949: show the resolved full model IDs so the switch is unambiguous,
|
|
221
|
+
// e.g. "opus (claude-opus-4-8) -> opus-4-7 (claude-opus-4-7)".
|
|
222
|
+
await log(`🔀 Switching to fallback model: ${formatModelWithResolvedId(previousModel, tool)} -> ${formatModelWithResolvedId(fallbackModel, tool)}`, { level: 'warning' });
|
|
167
223
|
}
|
|
168
224
|
|
|
169
225
|
return {
|
|
@@ -180,4 +236,6 @@ export default {
|
|
|
180
236
|
waitWithCountdown,
|
|
181
237
|
resolveConfiguredFallbackModel,
|
|
182
238
|
maybeSwitchToFallbackModel,
|
|
239
|
+
formatModelWithResolvedId,
|
|
240
|
+
logExecutionContext,
|
|
183
241
|
};
|