@link-assistant/hive-mind 2.0.5 → 2.0.7
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 +53 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +8 -8
- package/src/gemini.lib.mjs +3 -3
- package/src/lib.mjs +54 -0
- package/src/opencode.lib.mjs +3 -2
- package/src/playwright-mcp.lib.mjs +83 -18
- package/src/qwen.lib.mjs +3 -3
- package/src/session-monitor.lib.mjs +37 -1
- package/src/solve.disk-diagnostics.lib.mjs +342 -0
- package/src/solve.mjs +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6d9a2bb: feat(solve): log working-tree size before/after the AI agent and warn on Telegram when disk usage exceeds 5 GB (#1945)
|
|
8
|
+
|
|
9
|
+
`/solve` now records the size of its temporary working tree at two checkpoints:
|
|
10
|
+
after the repository is cloned (before the AI agent starts) and after the AI
|
|
11
|
+
working session ends. Both checkpoints emit a structured `📊 [DISK]` marker into
|
|
12
|
+
the captured solve log, so the cloned-repo size, the AI-induced delta, and the
|
|
13
|
+
final total are visible in `tail -f`-style debugging.
|
|
14
|
+
|
|
15
|
+
The session monitor parses those markers from the captured log and appends a
|
|
16
|
+
`💾 Disk usage` block to the Telegram completion message. The block raises a
|
|
17
|
+
warning when the cloned repository exceeds 5 GB, when the working tree grew by
|
|
18
|
+
more than 5 GB during the run, or when the total disk usage for the task
|
|
19
|
+
exceeds 5 GB — exactly the three conditions called out in the issue.
|
|
20
|
+
|
|
21
|
+
Sizing uses `du -sb` (byte-accurate on Linux), falls back to `du -sk` on BSD/
|
|
22
|
+
macOS, and finally to `fs.statSync` for single-file targets — no new runtime
|
|
23
|
+
dependency. The threshold is 5 GiB and uses a strict `>` comparison, so a tree
|
|
24
|
+
that lands at exactly 5 GiB does not warn.
|
|
25
|
+
|
|
26
|
+
## 2.0.6
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 0c63706: Stop surfacing meaningless stream fragments as tool errors (#1941). When a tool
|
|
31
|
+
run is interrupted mid-stream (CTRL+C / SIGINT, exit code 130), the last captured
|
|
32
|
+
stdout line could be a stray structural character such as a lone `}`, which leaked
|
|
33
|
+
into the GitHub failure comment as "CLAUDE execution failed with }". A new shared
|
|
34
|
+
`isMeaningfulErrorText` helper (any error with at least one Unicode letter or digit
|
|
35
|
+
is real; pure punctuation is not) now guards the `extractToolErrorCore` chokepoint,
|
|
36
|
+
and a new `buildToolErrorMessage` helper labels interruptions explicitly
|
|
37
|
+
("Claude command interrupted (CTRL+C)") across the Claude and OpenCode runners.
|
|
38
|
+
- d4efc82: fix(playwright-mcp): do not abort the solve when the Playwright MCP preflight probe is inconclusive (#1943)
|
|
39
|
+
|
|
40
|
+
A `solve` run aborted before creating a pull request with
|
|
41
|
+
`❌ Playwright MCP preflight failed for Claude Code`. The local preflight ran
|
|
42
|
+
`timeout 5 claude mcp list`, but that command performs a live health check that
|
|
43
|
+
launches a browser and can take longer than five seconds; when the `timeout`
|
|
44
|
+
killed the probe, `ensureConnectedPlaywrightMcpServer` treated the non-zero exit
|
|
45
|
+
as a failure and stopped the whole run.
|
|
46
|
+
|
|
47
|
+
An inconclusive `mcp list` probe (timeout / crash / missing CLI) now falls back
|
|
48
|
+
to the local `@playwright/mcp` package check instead of aborting: if the package
|
|
49
|
+
is installed, the server connects on demand via Tool Search (issue #1901), so the
|
|
50
|
+
working session proceeds. The probe timeout now defaults to 30s and is overridable
|
|
51
|
+
via `PLAYWRIGHT_MCP_PREFLIGHT_TIMEOUT_SECONDS`, and the preflight emits verbose
|
|
52
|
+
diagnostics (probe exit code, matched rows, decision branch) so failures are
|
|
53
|
+
diagnosable from the log. The preflight still fails only when `@playwright/mcp`
|
|
54
|
+
is genuinely unavailable.
|
|
55
|
+
|
|
3
56
|
## 2.0.5
|
|
4
57
|
|
|
5
58
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -6,7 +6,7 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
6
6
|
const { $ } = await use('command-stream');
|
|
7
7
|
const fs = (await use('fs')).promises;
|
|
8
8
|
const path = (await use('path')).default;
|
|
9
|
-
import { log, isENOSPC } from './lib.mjs';
|
|
9
|
+
import { log, isENOSPC, buildToolErrorMessage } from './lib.mjs';
|
|
10
10
|
import { reportError } from './sentry.lib.mjs';
|
|
11
11
|
import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
|
|
12
12
|
import { detectUsageLimit, formatUsageLimitMessage, isUsageLimitError } from './usage-limit.lib.mjs';
|
|
@@ -1211,8 +1211,8 @@ export const executeClaudeCommand = async params => {
|
|
|
1211
1211
|
is503Error,
|
|
1212
1212
|
anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnStuckRetry, // Issue #1104/#1886
|
|
1213
1213
|
resultSummary,
|
|
1214
|
-
// Issue #1845: surface the actual error
|
|
1215
|
-
errorInfo: { message: lastMessage
|
|
1214
|
+
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
1215
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: 'API explicitly marked error as not retryable', toolLabel: 'Claude' }), exitCode },
|
|
1216
1216
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1217
1217
|
};
|
|
1218
1218
|
}
|
|
@@ -1260,8 +1260,8 @@ export const executeClaudeCommand = async params => {
|
|
|
1260
1260
|
is503Error, // preserve for callers that check this
|
|
1261
1261
|
anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnRetriesExhausted, // Issue #1104/#1886: Include cumulative cost even on failure
|
|
1262
1262
|
resultSummary, // Issue #1263: Include result summary
|
|
1263
|
-
// Issue #1845: surface the actual error
|
|
1264
|
-
errorInfo: { message: lastMessage
|
|
1263
|
+
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
1264
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted after ${maxRetries} retries`, toolLabel: 'Claude' }), exitCode },
|
|
1265
1265
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1266
1266
|
};
|
|
1267
1267
|
}
|
|
@@ -1327,9 +1327,9 @@ export const executeClaudeCommand = async params => {
|
|
|
1327
1327
|
errorDuringExecution,
|
|
1328
1328
|
anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnFailure, // Issue #1104/#1886: cumulative cost even on failure
|
|
1329
1329
|
resultSummary, // Issue #1263: Include result summary
|
|
1330
|
-
// Issue #1845: surface the core error (e.g. "API Error: Output blocked by content
|
|
1331
|
-
//
|
|
1332
|
-
errorInfo: { message: lastMessage
|
|
1330
|
+
// Issue #1845: surface the core error (e.g. "API Error: Output blocked by content filtering policy").
|
|
1331
|
+
// Issue #1941: a lone "}" fragment at interrupt time must not become "CLAUDE execution failed with }".
|
|
1332
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Claude command failed with exit code ${exitCode}`, toolLabel: 'Claude' }), exitCode },
|
|
1333
1333
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1334
1334
|
};
|
|
1335
1335
|
}
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -10,7 +10,7 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
10
10
|
|
|
11
11
|
const { $ } = await use('command-stream');
|
|
12
12
|
|
|
13
|
-
import { log } from './lib.mjs';
|
|
13
|
+
import { log, buildToolErrorMessage } from './lib.mjs';
|
|
14
14
|
import { reportError } from './sentry.lib.mjs';
|
|
15
15
|
import { timeouts, retryLimits } from './config.lib.mjs';
|
|
16
16
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
@@ -576,8 +576,8 @@ export const executeGeminiCommand = async params => {
|
|
|
576
576
|
pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
|
|
577
577
|
publicPricingEstimate: null,
|
|
578
578
|
resultSummary: geminiJsonState.resultSummary || null,
|
|
579
|
-
// Issue #1845: surface the actual error
|
|
580
|
-
errorInfo: { message: errorText
|
|
579
|
+
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
580
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage: errorText, exitCode, fallback: `Gemini command failed with exit code ${exitCode}`, toolLabel: 'Gemini' }), exitCode },
|
|
581
581
|
};
|
|
582
582
|
}
|
|
583
583
|
|
package/src/lib.mjs
CHANGED
|
@@ -665,6 +665,53 @@ export const cleanErrorMessage = error => {
|
|
|
665
665
|
return message;
|
|
666
666
|
};
|
|
667
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Decide whether a string looks like a meaningful, human-readable error message
|
|
670
|
+
* rather than a stray structural fragment (Issue #1941).
|
|
671
|
+
*
|
|
672
|
+
* When a tool process is interrupted mid-stream (CTRL+C / SIGINT) or killed, the
|
|
673
|
+
* last captured stdout line can be a lone JSON-structural character left over
|
|
674
|
+
* from a truncated stream — for example a bare `}` or `{`. Surfacing that as the
|
|
675
|
+
* "core error" produced nonsense failure messages such as
|
|
676
|
+
* "CLAUDE execution failed with }" / "failed by {". A real error message always
|
|
677
|
+
* contains at least one letter or digit (in any script), so we treat fragments
|
|
678
|
+
* that contain none as not meaningful.
|
|
679
|
+
*
|
|
680
|
+
* @param {*} value - Candidate error string
|
|
681
|
+
* @returns {boolean} True when the value contains usable error text
|
|
682
|
+
*/
|
|
683
|
+
export const isMeaningfulErrorText = value => {
|
|
684
|
+
if (!value || typeof value !== 'string') return false;
|
|
685
|
+
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
686
|
+
if (!collapsed) return false;
|
|
687
|
+
// Require at least one Unicode letter or number; pure punctuation/brackets
|
|
688
|
+
// (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
|
|
689
|
+
return /[\p{L}\p{N}]/u.test(collapsed);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Build a clean tool error message for `errorInfo.message`, rejecting
|
|
694
|
+
* meaningless stream fragments (Issue #1941).
|
|
695
|
+
*
|
|
696
|
+
* Picks the tool-reported `lastMessage` only when it is meaningful; otherwise
|
|
697
|
+
* falls back to an interrupt label (exit code 130 = SIGINT/CTRL+C) or the
|
|
698
|
+
* provided generic fallback. This keeps junk like a lone `}` out of the stored
|
|
699
|
+
* error so every downstream surface (GitHub comment, terminal, retry logic)
|
|
700
|
+
* shows something honest.
|
|
701
|
+
*
|
|
702
|
+
* @param {Object} options
|
|
703
|
+
* @param {string} [options.lastMessage] - The last message captured from the tool stream
|
|
704
|
+
* @param {number} [options.exitCode] - Process exit code
|
|
705
|
+
* @param {string} [options.fallback] - Generic fallback message
|
|
706
|
+
* @param {string} [options.toolLabel='Tool'] - Human tool label for the interrupt message
|
|
707
|
+
* @returns {string} A clean, meaningful error message
|
|
708
|
+
*/
|
|
709
|
+
export const buildToolErrorMessage = ({ lastMessage, exitCode, fallback, toolLabel = 'Tool' } = {}) => {
|
|
710
|
+
if (isMeaningfulErrorText(lastMessage)) return lastMessage.replace(/\s+/g, ' ').trim();
|
|
711
|
+
if (exitCode === 130) return `${toolLabel} command interrupted (CTRL+C)`;
|
|
712
|
+
return fallback;
|
|
713
|
+
};
|
|
714
|
+
|
|
668
715
|
/**
|
|
669
716
|
* Extract the core/root error string from a tool runner result (Issue #1845).
|
|
670
717
|
*
|
|
@@ -675,6 +722,10 @@ export const cleanErrorMessage = error => {
|
|
|
675
722
|
* (GitHub comments / exit message) and the terminal "Error details:" lines in
|
|
676
723
|
* watch / auto-merge so they never diverge.
|
|
677
724
|
*
|
|
725
|
+
* Issue #1941: a meaningless structural fragment (e.g. a lone `}` captured when
|
|
726
|
+
* a tool is interrupted mid-stream) is treated as "no usable error" so callers
|
|
727
|
+
* fall back to the generic phrase instead of "execution failed with }".
|
|
728
|
+
*
|
|
678
729
|
* @param {Object} options
|
|
679
730
|
* @param {Object} [options.toolResult] - Result object returned by the tool runner
|
|
680
731
|
* @returns {string|null} The core error string, or null when none is available
|
|
@@ -688,6 +739,9 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
|
|
|
688
739
|
|
|
689
740
|
if (!rawCore || typeof rawCore !== 'string') return null;
|
|
690
741
|
|
|
742
|
+
// Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
|
|
743
|
+
if (!isMeaningfulErrorText(rawCore)) return null;
|
|
744
|
+
|
|
691
745
|
// Collapse to a single clean line and strip noise.
|
|
692
746
|
const core = rawCore.replace(/\s+/g, ' ').trim();
|
|
693
747
|
return core || null;
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const path = (await use('path')).default;
|
|
|
14
14
|
const os = (await use('os')).default;
|
|
15
15
|
|
|
16
16
|
// Import log from general lib
|
|
17
|
-
import { log } from './lib.mjs';
|
|
17
|
+
import { log, isMeaningfulErrorText, buildToolErrorMessage } from './lib.mjs';
|
|
18
18
|
import { reportError } from './sentry.lib.mjs';
|
|
19
19
|
import { timeouts, retryLimits } from './config.lib.mjs';
|
|
20
20
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
@@ -532,7 +532,8 @@ export const executeOpenCodeCommand = async params => {
|
|
|
532
532
|
...pricingResult,
|
|
533
533
|
resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
|
|
534
534
|
// Issue #1845: surface the actual error so callers can show it to users
|
|
535
|
-
|
|
535
|
+
// Issue #1941: reject meaningless stream fragments (e.g. a lone "}").
|
|
536
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage: isMeaningfulErrorText(lastMessage) ? lastMessage : allOutput, exitCode, fallback: `OpenCode command failed with exit code ${exitCode}`, toolLabel: 'OpenCode' }), exitCode },
|
|
536
537
|
};
|
|
537
538
|
}
|
|
538
539
|
|
|
@@ -30,44 +30,109 @@ export const hasConnectedPlaywrightMcpServer = output => {
|
|
|
30
30
|
return rows.some(row => PLAYWRIGHT_MCP_CONNECTED_PATTERN.test(row) && !PLAYWRIGHT_MCP_UNAVAILABLE_PATTERN.test(row));
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// `claude mcp list` / `codex mcp list` perform live health checks against every
|
|
34
|
+
// registered MCP server (Playwright MCP launches a browser to report status),
|
|
35
|
+
// which can take noticeably longer than a couple of seconds on a cold cache or
|
|
36
|
+
// a busy CI host. A too-aggressive `timeout` kills the probe before it answers,
|
|
37
|
+
// which previously aborted the entire solve (issue #1943). The probe timeout is
|
|
38
|
+
// therefore generous by default and overridable for slow/fast environments.
|
|
39
|
+
export const PLAYWRIGHT_MCP_LIST_TIMEOUT_SECONDS_DEFAULT = 30;
|
|
40
|
+
|
|
41
|
+
export const getPlaywrightMcpListTimeoutSeconds = (env = process.env) => {
|
|
42
|
+
const parsed = Number.parseInt(env?.PLAYWRIGHT_MCP_PREFLIGHT_TIMEOUT_SECONDS ?? '', 10);
|
|
43
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : PLAYWRIGHT_MCP_LIST_TIMEOUT_SECONDS_DEFAULT;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const diagLog =
|
|
47
|
+
(log, opts = { verbose: true }) =>
|
|
48
|
+
async message => {
|
|
49
|
+
if (log) await log(message, opts);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const checkPlaywrightMcpPackageAvailability = async ({ log } = {}) => {
|
|
53
|
+
const diag = diagLog(log);
|
|
34
54
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const timeoutSeconds = getPlaywrightMcpListTimeoutSeconds();
|
|
56
|
+
const result = await $`timeout ${timeoutSeconds} npx --no-install @playwright/mcp --help 2>&1`.catch(() => null);
|
|
57
|
+
if (isCommandResultSuccess(result)) {
|
|
58
|
+
await diag('🎭 @playwright/mcp package is available via npx');
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const npmResult = await $`timeout ${timeoutSeconds} npm ls -g @playwright/mcp 2>&1`.catch(() => null);
|
|
62
|
+
const available = getCommandResultOutput(npmResult).includes('@playwright/mcp');
|
|
63
|
+
await diag(`🎭 @playwright/mcp global package ${available ? 'is installed' : 'was not found'}`);
|
|
64
|
+
return available;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await diag(`⚠️ @playwright/mcp package availability check threw: ${error.message}`);
|
|
40
67
|
return false;
|
|
41
68
|
}
|
|
42
69
|
};
|
|
43
70
|
|
|
44
|
-
export const ensureConnectedPlaywrightMcpServer = async ({ list, add, hasPackage = checkPlaywrightMcpPackageAvailability }) => {
|
|
71
|
+
export const ensureConnectedPlaywrightMcpServer = async ({ list, add, hasPackage = checkPlaywrightMcpPackageAvailability, log } = {}) => {
|
|
72
|
+
const diag = diagLog(log);
|
|
45
73
|
try {
|
|
46
74
|
const result = await list().catch(() => null);
|
|
47
|
-
|
|
75
|
+
const code = getCommandResultCode(result);
|
|
48
76
|
const output = getCommandResultOutput(result);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
const rows = getPlaywrightMcpListRows(output);
|
|
78
|
+
await diag(`🎭 Playwright MCP probe: 'mcp list' exit=${code === null ? 'timeout/none' : code}, playwright rows=${rows.length}${rows.length ? ` [${rows.join(' | ')}]` : ''}`);
|
|
79
|
+
|
|
80
|
+
// Inconclusive probe: the `mcp list` command itself failed (timed out,
|
|
81
|
+
// crashed, or its binary is missing). That tells us NOTHING about whether
|
|
82
|
+
// Playwright MCP actually works, so it must not abort the whole solve as it
|
|
83
|
+
// did in issue #1943. A still-connecting server is normal — Tool Search
|
|
84
|
+
// loads MCP tools on demand (issue #1901) — so fall back to the local
|
|
85
|
+
// @playwright/mcp package check: if the package is installed, the server can
|
|
86
|
+
// connect on demand and the working session should proceed.
|
|
87
|
+
if (!isCommandResultSuccess(result)) {
|
|
88
|
+
const packageAvailable = await hasPackage({ log });
|
|
89
|
+
await diag(`⚠️ Playwright MCP 'mcp list' probe was inconclusive (exit=${code === null ? 'timeout' : code}); @playwright/mcp package ${packageAvailable ? 'is installed, so Tool Search can connect it on demand — preflight passes' : 'is NOT installed — preflight fails'}`);
|
|
90
|
+
return packageAvailable;
|
|
91
|
+
}
|
|
52
92
|
|
|
93
|
+
if (hasConnectedPlaywrightMcpServer(output)) {
|
|
94
|
+
await diag('🎭 Playwright MCP reported as connected by mcp list');
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A registration row exists but is not reported connected (pending /
|
|
99
|
+
// disabled / failed). Leave it untouched (do not overwrite an intentional
|
|
100
|
+
// or in-progress registration) and let the caller decide.
|
|
101
|
+
if (rows.length > 0) {
|
|
102
|
+
await diag('🎭 Playwright MCP is registered but not reported connected by mcp list; leaving registration unchanged');
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No registration at all → register the default server when the package is
|
|
107
|
+
// available, then re-probe.
|
|
108
|
+
if (!(await hasPackage({ log }))) {
|
|
109
|
+
await diag('⚠️ No Playwright MCP registration found and @playwright/mcp package is unavailable');
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
await diag('🎭 No Playwright MCP registration found; registering the default server...');
|
|
53
113
|
await add().catch(() => null);
|
|
54
114
|
const retryResult = await list().catch(() => null);
|
|
55
|
-
|
|
56
|
-
|
|
115
|
+
const connected = isCommandResultSuccess(retryResult) && hasConnectedPlaywrightMcpServer(getCommandResultOutput(retryResult));
|
|
116
|
+
await diag(`🎭 Playwright MCP registration ${connected ? 'succeeded and is now connected' : 'did not report connected after add'}`);
|
|
117
|
+
return connected;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
await diag(`⚠️ Playwright MCP preflight probe threw: ${error.message}`);
|
|
57
120
|
return false;
|
|
58
121
|
}
|
|
59
122
|
};
|
|
60
123
|
|
|
61
|
-
export const ensureClaudePlaywrightMcpServer = async () =>
|
|
124
|
+
export const ensureClaudePlaywrightMcpServer = async ({ log } = {}) =>
|
|
62
125
|
ensureConnectedPlaywrightMcpServer({
|
|
63
|
-
list: () => $`timeout
|
|
126
|
+
list: () => $`timeout ${getPlaywrightMcpListTimeoutSeconds()} claude mcp list 2>&1`,
|
|
64
127
|
add: () => $`claude mcp add playwright -s user -- npx -y @playwright/mcp@latest --isolated --headless --no-sandbox --timeout-action=600000 --viewport-size 1920x1080`,
|
|
128
|
+
log,
|
|
65
129
|
});
|
|
66
130
|
|
|
67
|
-
export const ensureCodexPlaywrightMcpServer = async () =>
|
|
131
|
+
export const ensureCodexPlaywrightMcpServer = async ({ log } = {}) =>
|
|
68
132
|
ensureConnectedPlaywrightMcpServer({
|
|
69
|
-
list: () => $`timeout
|
|
133
|
+
list: () => $`timeout ${getPlaywrightMcpListTimeoutSeconds()} codex mcp list 2>&1`,
|
|
70
134
|
add: () => $`codex mcp add playwright -- npx -y @playwright/mcp@latest --isolated --headless --no-sandbox --timeout-action=600000 --viewport-size 1920x1080`,
|
|
135
|
+
log,
|
|
71
136
|
});
|
|
72
137
|
|
|
73
138
|
const SOLVE_PLAYWRIGHT_MCP_CHECKS = {
|
|
@@ -102,7 +167,7 @@ export const ensureSolvePlaywrightMcpReady = async ({ argv = {}, log = async ()
|
|
|
102
167
|
await log(`🎭 Checking Playwright MCP preflight for ${label}...`, { verbose: true });
|
|
103
168
|
|
|
104
169
|
try {
|
|
105
|
-
if (await checkFn()) {
|
|
170
|
+
if (await checkFn({ log })) {
|
|
106
171
|
await log(`🎭 Playwright MCP ready for ${label}`, { verbose: true });
|
|
107
172
|
return { ok: true, checkedTools: [tool], skipped: false };
|
|
108
173
|
}
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const fs = (await use('fs')).promises;
|
|
|
13
13
|
const path = (await use('path')).default;
|
|
14
14
|
const os = (await use('os')).default;
|
|
15
15
|
|
|
16
|
-
import { log } from './lib.mjs';
|
|
16
|
+
import { log, buildToolErrorMessage } from './lib.mjs';
|
|
17
17
|
import { reportError } from './sentry.lib.mjs';
|
|
18
18
|
import { timeouts, retryLimits } from './config.lib.mjs';
|
|
19
19
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
@@ -633,8 +633,8 @@ export const executeQwenCommand = async params => {
|
|
|
633
633
|
limitResetTime: null,
|
|
634
634
|
...usageResult,
|
|
635
635
|
resultSummary,
|
|
636
|
-
// Issue #1845: surface the actual error
|
|
637
|
-
errorInfo: { message: combinedErrorText || errorMessage
|
|
636
|
+
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
637
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage: combinedErrorText || errorMessage, exitCode, fallback: `Qwen Code command failed${exitCode !== 0 ? ` with exit code ${exitCode}` : ''}`, toolLabel: 'Qwen Code' }), exitCode },
|
|
638
638
|
};
|
|
639
639
|
}
|
|
640
640
|
|
|
@@ -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
|
+
};
|
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.
|