@link-assistant/hive-mind 2.12.5 → 2.13.1
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 +12 -0
- package/package.json +1 -1
- package/src/claude.budget-stats.lib.mjs +4 -2
- package/src/claude.lib.mjs +83 -17
- package/src/claude.stream-events.lib.mjs +56 -2
- package/src/disk-guard.lib.mjs +256 -0
- package/src/github.batch.lib.mjs +31 -11
- package/src/github.lib.mjs +3 -1
- package/src/hive.mjs +136 -11
- package/src/lib.mjs +23 -3
- package/src/limits-i18n.lib.mjs +8 -0
- package/src/list-solution-drafts.lib.mjs +17 -4
- package/src/locales/en.lino +10 -0
- package/src/locales/hi.lino +10 -0
- package/src/locales/ru.lino +10 -0
- package/src/locales/zh.lino +10 -0
- package/src/session-log-rename.lib.mjs +65 -0
- package/src/session-monitor.lib.mjs +45 -1
- package/src/solve.mjs +48 -4
- package/src/solve.repository.lib.mjs +5 -1
- package/src/solve.restart-shared.lib.mjs +18 -9
- package/src/solve.results.lib.mjs +6 -3
- package/src/solve.validation.lib.mjs +7 -9
- package/src/subscription-block-telegram.lib.mjs +115 -0
- package/src/subscription-error.lib.mjs +328 -0
- package/src/tool-retry.lib.mjs +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 736f6f9: Stop reporting a host that ran out of disk space as failed tasks: `/hive` now checks free space before each task, requeues the task as a deferral while peers are still running, reclaims only temp directories that no process is using, and exits with `EX_TEMPFAIL` (75) when work remains blocked. Also fix the false alarms around it — `getLogFile is not a function` in the restart paths, the bogus `.gitkeep` cleanup warning, benign in-session tool results and defaulted source cleanup being reported as problems, merged solution drafts being summarized as `(no PR found)`, and `--auto-cleanup` being a no-op at one call site.
|
|
8
|
+
|
|
9
|
+
## 2.13.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- aaf809a: Recognize subscription/account access blocks from every supported CLI (Claude, Codex, Qwen, Gemini, opencode) as their own error class: stop the run instead of retrying or switching model, auto-commit and push the in-flight work first, report what happened and what to do in the terminal, in the `/solve` exit message and in the Telegram completion message (en/ru/zh/hi), and stop the `/hive` queue so the fleet no longer rediscovers the block once per issue.
|
|
14
|
+
|
|
3
15
|
## 2.12.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -356,9 +356,11 @@ export const displaySessionTokenUsage = async ({ sessionId, tempDir, resultModel
|
|
|
356
356
|
try {
|
|
357
357
|
const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
|
|
358
358
|
if (!tokenUsage) return;
|
|
359
|
-
// Issue #1501: Log deduplication stats in verbose mode
|
|
359
|
+
// Issue #1501: Log deduplication stats in verbose mode.
|
|
360
|
+
// Issue #2160: informational, not a warning — the duplicates are a known upstream Claude Code
|
|
361
|
+
// accounting quirk and skipping them is exactly what keeps the token totals correct here.
|
|
360
362
|
if (tokenUsage.duplicateEntriesSkipped > 0) {
|
|
361
|
-
await log(`\n
|
|
363
|
+
await log(`\nℹ️ JSONL deduplication: skipped ${tokenUsage.duplicateEntriesSkipped} duplicate entries so token totals stay correct (known upstream behaviour: anthropics/claude-code#87303)`, { verbose: true });
|
|
362
364
|
}
|
|
363
365
|
if (tokenUsage.peakContextUsage > 0) {
|
|
364
366
|
await log(`📊 Peak restored-context input: ${formatNumber(tokenUsage.peakContextUsage)} tokens`, { verbose: true });
|
package/src/claude.lib.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { isENOSPC, buildToolErrorMessage } from './lib.mjs';
|
|
|
9
9
|
import { reportError } from './sentry.lib.mjs';
|
|
10
10
|
import { timeouts, retryLimits, claudeCode, getClaudeEnv, getMaxOutputTokensForModel } from './config.lib.mjs';
|
|
11
11
|
import { detectUsageLimit, formatUsageLimitMessage, isUsageLimitError } from './usage-limit.lib.mjs';
|
|
12
|
+
import { detectSubscriptionError, SUBSCRIPTION_BLOCKED_MARKER } from './subscription-error.lib.mjs'; // Issue #2161
|
|
12
13
|
import { createInteractiveHandler } from './interactive-mode.lib.mjs';
|
|
13
14
|
import { setupBidirectionalHandler, finalizeBidirectionalHandler, validateBidirectionalModeConfig, attachStreamingInput } from './bidirectional-interactive.lib.mjs';
|
|
14
15
|
import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
|
|
@@ -34,6 +35,7 @@ import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
|
34
35
|
import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.lib.mjs'; // Issue #1834 (PR #1835 feedback)
|
|
35
36
|
import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
|
|
36
37
|
import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
|
|
38
|
+
import { renameLogToSessionId } from './session-log-rename.lib.mjs'; // Issue #2160
|
|
37
39
|
import { showResumeCommand } from './claude.resume-output.lib.mjs';
|
|
38
40
|
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
39
41
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
@@ -376,6 +378,10 @@ export const executeClaudeCommand = async params => {
|
|
|
376
378
|
let isInternalServerError = false;
|
|
377
379
|
let isRequestTimeout = false;
|
|
378
380
|
let isRateLimitError = false; // Issue #1924: server-side 429 temporary rate limiting
|
|
381
|
+
// Issue #2161: account/subscription-level block (e.g. oauth_org_not_allowed).
|
|
382
|
+
// Terminal — never retried, never model-switched; carried out to the caller
|
|
383
|
+
// so /solve can stop with a specific diagnosis instead of a generic failure.
|
|
384
|
+
let subscriptionError = null;
|
|
379
385
|
let apiMarkedNotRetryable = false;
|
|
380
386
|
let resultNumTurns = 0;
|
|
381
387
|
let stderrErrors = [];
|
|
@@ -388,6 +394,11 @@ export const executeClaudeCommand = async params => {
|
|
|
388
394
|
let resultSummary = null;
|
|
389
395
|
let resultModelUsage = null;
|
|
390
396
|
let lastToolResultError = null;
|
|
397
|
+
// Issue #2160: an in-session tool failure the AI handles itself (a blocked command, its own
|
|
398
|
+
// Bash timeout, a bare non-zero exit status). Kept apart from lastToolResultError so it is not
|
|
399
|
+
// reported as the session error, but still available as the last-resort detail for a
|
|
400
|
+
// truncated stream that has nothing better to point at (issue #2023).
|
|
401
|
+
let lastBenignToolResultError = null;
|
|
391
402
|
// Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
|
|
392
403
|
const subAgentCalls = [];
|
|
393
404
|
// Issue #1590: Map tool_use_id -> subAgentCalls index for accumulating per-call usage from parent_tool_use_id events
|
|
@@ -628,16 +639,16 @@ export const executeClaudeCommand = async params => {
|
|
|
628
639
|
if (!sessionId && data.session_id) {
|
|
629
640
|
sessionId = data.session_id;
|
|
630
641
|
await log(`📌 Session ID: ${sessionId}`);
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
642
|
+
// Issue #2160: shared implementation, so restart/watch iterations rename their
|
|
643
|
+
// logs too and a caller that forgets the accessors gets a named reason.
|
|
644
|
+
const renameResult = await renameLogToSessionId({ sessionId, getLogFile, setLogFile, log });
|
|
645
|
+
if (!renameResult.ok && renameResult.error) {
|
|
646
|
+
reportError(renameResult.error, {
|
|
647
|
+
context: 'rename_session_log',
|
|
648
|
+
sessionId,
|
|
649
|
+
sessionLogFile: renameResult.sessionLogFile,
|
|
650
|
+
operation: 'rename_log_file',
|
|
651
|
+
});
|
|
641
652
|
}
|
|
642
653
|
}
|
|
643
654
|
const eventFacts = collectClaudeStreamEventFacts(data);
|
|
@@ -649,9 +660,16 @@ export const executeClaudeCommand = async params => {
|
|
|
649
660
|
await log('📝 Captured fallback summary from Claude compaction context', { verbose: true });
|
|
650
661
|
}
|
|
651
662
|
if (eventFacts.toolResultError) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
663
|
+
// Issue #2160: an in-session tool failure the AI handles itself is not a warning,
|
|
664
|
+
// and it must not replace the last assistant message — that message is what a
|
|
665
|
+
// truncated-stream failure is reported "after".
|
|
666
|
+
if (eventFacts.toolResultErrorIsBenign) {
|
|
667
|
+
lastBenignToolResultError = eventFacts.toolResultError;
|
|
668
|
+
await log(`ℹ️ In-session tool result (${eventFacts.toolResultErrorCategory}): ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
|
|
669
|
+
} else {
|
|
670
|
+
lastToolResultError = eventFacts.toolResultError;
|
|
671
|
+
await log(`⚠️ Tool result error detected: ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
|
|
672
|
+
}
|
|
655
673
|
}
|
|
656
674
|
// Issue #1708: signal busy/idle to the bidirectional handler so
|
|
657
675
|
// queue-comments-to-input mode can hold frames until the AI is
|
|
@@ -731,6 +749,24 @@ export const executeClaudeCommand = async params => {
|
|
|
731
749
|
isRateLimitError = true;
|
|
732
750
|
await log(`⚠️ Detected server-side rate limiting (429) from Claude CLI (will retry with --resume). request_id=${data.request_id || 'unknown'}`, { verbose: true });
|
|
733
751
|
}
|
|
752
|
+
// Issue #2161: account/subscription block. `data.error` carries the
|
|
753
|
+
// machine-readable code ("oauth_org_not_allowed" for the reported
|
|
754
|
+
// case) alongside api_error_status 403 — a far stronger signal than
|
|
755
|
+
// the rendered sentence, so it is passed to the detector first.
|
|
756
|
+
if (!subscriptionError) {
|
|
757
|
+
subscriptionError = detectSubscriptionError({
|
|
758
|
+
message: lastMessage,
|
|
759
|
+
tool: 'claude',
|
|
760
|
+
errorCode: typeof data.error === 'string' ? data.error : null,
|
|
761
|
+
apiErrorStatus: data.api_error_status,
|
|
762
|
+
terminalReason: data.terminal_reason,
|
|
763
|
+
});
|
|
764
|
+
if (subscriptionError) {
|
|
765
|
+
// Not verbose: this is the reason the whole run is about to end.
|
|
766
|
+
await log(`${SUBSCRIPTION_BLOCKED_MARKER} — ${subscriptionError.label}`);
|
|
767
|
+
await log(` code=${subscriptionError.code || 'n/a'} http=${data.api_error_status || 'n/a'} terminal_reason=${data.terminal_reason || 'n/a'} request_id=${data.request_id || 'unknown'}`, { verbose: true });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
734
770
|
// Issue #1834: Detect corrupted extended-thinking-block 400 (un-resumable session).
|
|
735
771
|
// Capture diagnostics (request id, content path) to aid debugging and upstream reports.
|
|
736
772
|
if ((lastMessage.includes('thinking') || lastMessage.includes('redacted_thinking')) && lastMessage.includes('cannot be modified')) {
|
|
@@ -765,6 +801,26 @@ export const executeClaudeCommand = async params => {
|
|
|
765
801
|
await log(`🤖 Sub-agent "${callEntry.description || 'unknown'}" completed: ${data.usage.total_tokens} total tokens`, { verbose: true });
|
|
766
802
|
}
|
|
767
803
|
}
|
|
804
|
+
// Issue #2161: Claude Code injects API failures as synthetic assistant
|
|
805
|
+
// messages flagged `is_api_error_message` and carrying the error code.
|
|
806
|
+
// In the reported run this arrived ~40s before the terminal result
|
|
807
|
+
// event, so detecting it here surfaces the diagnosis earlier.
|
|
808
|
+
if (data.type === 'assistant' && data.is_api_error_message === true && !subscriptionError) {
|
|
809
|
+
const apiErrorText = getClaudeMessageContent(data)
|
|
810
|
+
.filter(item => item.type === 'text' && item.text)
|
|
811
|
+
.map(item => item.text)
|
|
812
|
+
.join('\n');
|
|
813
|
+
subscriptionError = detectSubscriptionError({
|
|
814
|
+
message: apiErrorText,
|
|
815
|
+
tool: 'claude',
|
|
816
|
+
errorCode: typeof data.error === 'string' ? data.error : null,
|
|
817
|
+
});
|
|
818
|
+
if (subscriptionError) {
|
|
819
|
+
if (apiErrorText) lastMessage = apiErrorText;
|
|
820
|
+
await log(`${SUBSCRIPTION_BLOCKED_MARKER} — ${subscriptionError.label}`);
|
|
821
|
+
await log(` code=${subscriptionError.code || 'n/a'} request_id=${data.request_id || 'unknown'} uuid=${data.uuid || 'unknown'}`, { verbose: true });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
768
824
|
if (data.type === 'assistant' && data.message && data.message.content) {
|
|
769
825
|
const content = getClaudeMessageContent(data);
|
|
770
826
|
for (const item of content) {
|
|
@@ -860,9 +916,11 @@ export const executeClaudeCommand = async params => {
|
|
|
860
916
|
toolUseCount += eventFacts.toolUseCountDelta;
|
|
861
917
|
if (eventFacts.lastText) lastMessage = eventFacts.lastText;
|
|
862
918
|
if (!resultSummary && eventFacts.compactionSummary) resultSummary = eventFacts.compactionSummary;
|
|
863
|
-
|
|
919
|
+
// Issue #2160: same classification as the streaming path above.
|
|
920
|
+
if (eventFacts.toolResultError && eventFacts.toolResultErrorIsBenign) {
|
|
921
|
+
lastBenignToolResultError = eventFacts.toolResultError;
|
|
922
|
+
} else if (eventFacts.toolResultError) {
|
|
864
923
|
lastToolResultError = eventFacts.toolResultError;
|
|
865
|
-
lastMessage = eventFacts.toolResultError;
|
|
866
924
|
}
|
|
867
925
|
if (data?.type === 'result') {
|
|
868
926
|
resultEventReceived = true;
|
|
@@ -963,7 +1021,7 @@ export const executeClaudeCommand = async params => {
|
|
|
963
1021
|
}
|
|
964
1022
|
if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
|
|
965
1023
|
commandFailed = true;
|
|
966
|
-
lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
|
|
1024
|
+
lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage, lastBenignToolResultError });
|
|
967
1025
|
await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
|
|
968
1026
|
}
|
|
969
1027
|
const retryableLastError = classifyRetryableError(lastMessage);
|
|
@@ -977,7 +1035,12 @@ export const executeClaudeCommand = async params => {
|
|
|
977
1035
|
}
|
|
978
1036
|
// Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
|
|
979
1037
|
const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || isRateLimitError || retryableLastError.isRetryable || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
|
|
980
|
-
|
|
1038
|
+
// Issue #2161: an account/subscription block short-circuits every retry
|
|
1039
|
+
// path. Stale transient flags from earlier in the run (an overload at hour
|
|
1040
|
+
// one, say) must not schedule a retry that is guaranteed to fail the same
|
|
1041
|
+
// way — and each retry would burn another full startup against a provider
|
|
1042
|
+
// that has already refused the credentials.
|
|
1043
|
+
if ((commandFailed || isTransientError) && isTransientError && !subscriptionError) {
|
|
981
1044
|
// Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general → 2min–30min
|
|
982
1045
|
const isTimeoutRetry = isStartupTimeout || isActivityTimeout;
|
|
983
1046
|
const maxRetries = isTimeoutRetry ? retryLimits.maxTransientErrorRetries : isRequestTimeout ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
@@ -1005,6 +1068,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1005
1068
|
resultSummary,
|
|
1006
1069
|
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
1007
1070
|
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: 'API explicitly marked error as not retryable', toolLabel: 'Claude' }), exitCode },
|
|
1071
|
+
subscriptionError, // Issue #2161
|
|
1008
1072
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1009
1073
|
};
|
|
1010
1074
|
}
|
|
@@ -1055,6 +1119,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1055
1119
|
resultSummary, // Issue #1263: Include result summary
|
|
1056
1120
|
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
1057
1121
|
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted after ${maxRetries} retries`, toolLabel: 'Claude' }), exitCode },
|
|
1122
|
+
subscriptionError, // Issue #2161
|
|
1058
1123
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1059
1124
|
};
|
|
1060
1125
|
}
|
|
@@ -1123,6 +1188,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1123
1188
|
// Issue #1845: surface the core error (e.g. "API Error: Output blocked by content filtering policy").
|
|
1124
1189
|
// Issue #1941: a lone "}" fragment at interrupt time must not become "CLAUDE execution failed with }".
|
|
1125
1190
|
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Claude command failed with exit code ${exitCode}`, toolLabel: 'Claude' }), exitCode },
|
|
1191
|
+
subscriptionError, // Issue #2161: terminal account block — /solve stops and preserves the work
|
|
1126
1192
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1127
1193
|
};
|
|
1128
1194
|
}
|
|
@@ -19,12 +19,46 @@ const normalizeToolResultError = value => {
|
|
|
19
19
|
}
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Issue #2160: not every `tool_result` marked `is_error` says something about the session.
|
|
24
|
+
* Most of them are the AI's own command failing inside the session — the AI sees the result and
|
|
25
|
+
* carries on. Run 4c1dedd8 logged 26 "⚠️ Tool result error detected" lines, all of them of this
|
|
26
|
+
* kind (11 harness-blocked `sleep`s, 9 × `Exit code 143` Bash timeouts, 4 × `Exit code 1`,
|
|
27
|
+
* 2 × `Exit code 127`), and each one also overwrote the
|
|
28
|
+
* last assistant message, so a truncated stream could be reported as having failed "after:
|
|
29
|
+
* Blocked: sleep 240 …" instead of after what the AI actually said.
|
|
30
|
+
*
|
|
31
|
+
* Categories:
|
|
32
|
+
* - `harness_blocked` the AI tool's own harness refused the command (e.g. foreground sleep)
|
|
33
|
+
* - `command_timeout` the AI's command hit its Bash timeout (SIGTERM ⇒ exit code 143)
|
|
34
|
+
* - `command_exit_code` a bare non-zero exit status with no further detail
|
|
35
|
+
* Anything else is left unclassified and keeps being treated as a real error signal.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|null} toolResultError - normalized tool_result error text
|
|
38
|
+
* @returns {{benign: boolean, category: string|null}}
|
|
39
|
+
*/
|
|
40
|
+
export const classifyToolResultError = toolResultError => {
|
|
41
|
+
if (typeof toolResultError !== 'string' || !toolResultError.trim()) return { benign: false, category: null };
|
|
42
|
+
const text = toolResultError.trim();
|
|
43
|
+
|
|
44
|
+
if (/^Blocked:/i.test(text)) return { benign: true, category: 'harness_blocked' };
|
|
45
|
+
if (/Command timed out after/i.test(text)) return { benign: true, category: 'command_timeout' };
|
|
46
|
+
// A bare "Exit code 143" is the SIGTERM the AI tool sends when its own Bash timeout fires.
|
|
47
|
+
if (/^Exit code 143\.?$/i.test(text)) return { benign: true, category: 'command_timeout' };
|
|
48
|
+
if (/^Exit code \d+\.?$/i.test(text)) return { benign: true, category: 'command_exit_code' };
|
|
49
|
+
|
|
50
|
+
return { benign: false, category: null };
|
|
51
|
+
};
|
|
52
|
+
|
|
22
53
|
export const collectClaudeStreamEventFacts = data => {
|
|
23
54
|
const facts = {
|
|
24
55
|
messageCountDelta: 0,
|
|
25
56
|
toolUseCountDelta: 0,
|
|
26
57
|
lastText: null,
|
|
27
58
|
toolResultError: null,
|
|
59
|
+
// Issue #2160: set when toolResultError is an in-session, self-handled tool failure.
|
|
60
|
+
toolResultErrorIsBenign: false,
|
|
61
|
+
toolResultErrorCategory: null,
|
|
28
62
|
compactionSummary: null,
|
|
29
63
|
};
|
|
30
64
|
if (!data || typeof data !== 'object') return facts;
|
|
@@ -47,6 +81,12 @@ export const collectClaudeStreamEventFacts = data => {
|
|
|
47
81
|
facts.toolResultError = data.tool_use_result.trim();
|
|
48
82
|
}
|
|
49
83
|
|
|
84
|
+
if (facts.toolResultError) {
|
|
85
|
+
const classification = classifyToolResultError(facts.toolResultError);
|
|
86
|
+
facts.toolResultErrorIsBenign = classification.benign;
|
|
87
|
+
facts.toolResultErrorCategory = classification.category;
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
return facts;
|
|
51
91
|
};
|
|
52
92
|
|
|
@@ -54,8 +94,22 @@ export const shouldFailClaudeStreamWithoutResult = ({ commandFailed, streamingIn
|
|
|
54
94
|
return !commandFailed && !streamingInput && !resultEventReceived;
|
|
55
95
|
};
|
|
56
96
|
|
|
57
|
-
|
|
58
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Describe a stream that ended without a terminal result event (issue #2023).
|
|
99
|
+
*
|
|
100
|
+
* Detail preference, in order (issue #2160): a real tool error explains the truncation best; the
|
|
101
|
+
* last thing the AI said is next; a benign in-session tool result (a blocked command, a Bash
|
|
102
|
+
* timeout) is only used when there is nothing else, so it stays out of the message whenever the
|
|
103
|
+
* assistant actually said something.
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} params
|
|
106
|
+
* @param {string|null} [params.lastToolResultError] - last non-benign tool_result error
|
|
107
|
+
* @param {string|null} [params.lastMessage] - last assistant text
|
|
108
|
+
* @param {string|null} [params.lastBenignToolResultError] - last self-handled tool_result error
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export const buildMissingClaudeResultMessage = ({ lastToolResultError, lastMessage, lastBenignToolResultError = null }) => {
|
|
112
|
+
const detail = lastToolResultError || lastMessage || lastBenignToolResultError;
|
|
59
113
|
if (!detail) return 'Claude stream ended without a terminal result event';
|
|
60
114
|
return `Claude stream ended without a terminal result event after: ${String(detail).slice(0, 500)}`;
|
|
61
115
|
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-space guard for solver workspaces (issue #2160).
|
|
3
|
+
*
|
|
4
|
+
* Reported symptom: `hive … --all-issues` finished with `❌ 4 task(s) failed (completed: 6)` even
|
|
5
|
+
* though nothing was wrong with those 4 issues. The run log shows why:
|
|
6
|
+
*
|
|
7
|
+
* - the target repository was public, so solve's auto-cleanup default resolved to OFF and every
|
|
8
|
+
* `/tmp/gh-issue-solver-*` workspace (~10 GB each) was kept;
|
|
9
|
+
* - hive checked free disk space exactly once, at startup (73.2 GB free), and kept dequeuing;
|
|
10
|
+
* - after 6 completed tasks only 9.8 GB were left, so each remaining task tripped solve's
|
|
11
|
+
* pre-flight check (`❌ Insufficient disk space: 10047MB available, 10240MB required`), exited
|
|
12
|
+
* after ~12s, posted a "Solution Draft Failed" comment and was counted as a *task* failure.
|
|
13
|
+
*
|
|
14
|
+
* An exhausted disk is an environment condition: the task is still perfectly solvable once space is
|
|
15
|
+
* available. This module lets the orchestrator (a) reclaim workspaces nobody is using any more,
|
|
16
|
+
* (b) wait for in-flight work to release space, and (c) report the condition as a deferral instead
|
|
17
|
+
* of a task failure.
|
|
18
|
+
*
|
|
19
|
+
* Everything that touches the outside world (df, readdir, rm, clock, sleep) is injectable so the
|
|
20
|
+
* behaviour can be tested without a full disk.
|
|
21
|
+
*
|
|
22
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2160
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fsPromises from 'node:fs/promises';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Exit code a solver uses when it refuses to start because the host is out of disk space.
|
|
34
|
+
* 75 is EX_TEMPFAIL ("temporary failure, the user is invited to retry") from sysexits.h — the
|
|
35
|
+
* closest standard meaning to "nothing is wrong with the request, retry later".
|
|
36
|
+
*/
|
|
37
|
+
export const EXIT_CODE_INSUFFICIENT_DISK_SPACE = 75;
|
|
38
|
+
|
|
39
|
+
/** Prefix of the temporary directories solve clones repositories into. */
|
|
40
|
+
export const SOLVER_WORKSPACE_PREFIX = 'gh-issue-solver-';
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_TMP_ROOT = '/tmp';
|
|
43
|
+
|
|
44
|
+
/** A workspace whose contents changed this recently is never reclaimed. */
|
|
45
|
+
export const DEFAULT_MIN_IDLE_MS = 5 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
48
|
+
|
|
49
|
+
const defaultRemove = async targetPath => fsPromises.rm(targetPath, { recursive: true, force: true });
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Free space in MB on the filesystem holding `targetPath`, or null when it cannot be determined.
|
|
53
|
+
* `df -Pk` is POSIX-portable output (single line per filesystem, 1K blocks).
|
|
54
|
+
*/
|
|
55
|
+
export const getFreeDiskSpaceMB = async (targetPath = DEFAULT_TMP_ROOT, { exec = execFileAsync } = {}) => {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await exec('df', ['-Pk', targetPath]);
|
|
58
|
+
const lines = String(stdout).trim().split('\n');
|
|
59
|
+
if (lines.length < 2) return null;
|
|
60
|
+
const columns = lines[lines.length - 1].trim().split(/\s+/);
|
|
61
|
+
const availableKB = Number.parseInt(columns[3], 10);
|
|
62
|
+
if (!Number.isFinite(availableKB)) return null;
|
|
63
|
+
return Math.floor(availableKB / 1024);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Every `/tmp/gh-issue-solver-*` directory, oldest modification first. */
|
|
70
|
+
export const listSolverWorkspaces = async ({ tmpRoot = DEFAULT_TMP_ROOT, fileSystem = fsPromises } = {}) => {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await fileSystem.readdir(tmpRoot);
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const workspaces = [];
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
80
|
+
if (!name || !name.startsWith(SOLVER_WORKSPACE_PREFIX)) continue;
|
|
81
|
+
const workspacePath = path.join(tmpRoot, name);
|
|
82
|
+
let stats;
|
|
83
|
+
try {
|
|
84
|
+
stats = await fileSystem.stat(workspacePath);
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof stats.isDirectory === 'function' && !stats.isDirectory()) continue;
|
|
89
|
+
workspaces.push({ path: workspacePath, name, mtimeMs: Number(stats.mtimeMs) || 0 });
|
|
90
|
+
}
|
|
91
|
+
return workspaces.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Workspaces that a live process is currently sitting in. The AI tool runs with its workspace as
|
|
96
|
+
* cwd, so /proc/<pid>/cwd is an authoritative "do not touch" signal on Linux. When /proc cannot be
|
|
97
|
+
* read (macOS, restricted container) every workspace is reported as busy — refusing to guess is the
|
|
98
|
+
* only safe answer, since deleting a live workspace would destroy real work.
|
|
99
|
+
*/
|
|
100
|
+
export const findBusySolverWorkspaces = async ({ workspaces = [], procRoot = '/proc', fileSystem = fsPromises } = {}) => {
|
|
101
|
+
if (!workspaces.length) return new Set();
|
|
102
|
+
let pids;
|
|
103
|
+
try {
|
|
104
|
+
pids = (await fileSystem.readdir(procRoot)).map(entry => (typeof entry === 'string' ? entry : entry.name)).filter(name => /^\d+$/.test(name));
|
|
105
|
+
} catch {
|
|
106
|
+
return new Set(workspaces.map(workspace => workspace.path));
|
|
107
|
+
}
|
|
108
|
+
const cwds = [];
|
|
109
|
+
for (const pid of pids) {
|
|
110
|
+
try {
|
|
111
|
+
cwds.push(String(await fileSystem.readlink(path.join(procRoot, pid, 'cwd'))));
|
|
112
|
+
} catch {
|
|
113
|
+
// The process exited, or its cwd is not readable by this user — nothing to protect here.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const busy = new Set();
|
|
117
|
+
for (const workspace of workspaces) {
|
|
118
|
+
if (cwds.some(cwd => cwd === workspace.path || cwd.startsWith(`${workspace.path}/`))) busy.add(workspace.path);
|
|
119
|
+
}
|
|
120
|
+
return busy;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Entries of the given temp roots that `--auto-cleanup` may delete.
|
|
125
|
+
*
|
|
126
|
+
* The old implementation ran `sudo rm -rf /tmp/* /var/tmp/*`, which also destroys the workspaces,
|
|
127
|
+
* lock directories and log files of any *concurrent* hive/solve run on the same host — the run
|
|
128
|
+
* doing the cleanup is rarely the only tenant of /tmp. This builds an explicit list instead and
|
|
129
|
+
* leaves alone anything a live process is sitting in, anything the caller marked as protected, and
|
|
130
|
+
* the run's own log file.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} [options]
|
|
133
|
+
* @param {Array<string>} [options.roots=['/tmp','/var/tmp']] - Directories to clean
|
|
134
|
+
* @param {Iterable<string>} [options.protectedPaths] - Paths that must survive
|
|
135
|
+
* @returns {Promise<{remove: Array<string>, keep: Array<{path: string, reason: string}>}>}
|
|
136
|
+
*/
|
|
137
|
+
export const listCleanableTempEntries = async ({ roots = ['/tmp', '/var/tmp'], protectedPaths = new Set(), fileSystem = fsPromises, procRoot = '/proc' } = {}) => {
|
|
138
|
+
const protectedSet = new Set(Array.from(protectedPaths).filter(Boolean).map(String));
|
|
139
|
+
const remove = [];
|
|
140
|
+
const keep = [];
|
|
141
|
+
const candidates = [];
|
|
142
|
+
for (const root of roots) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await fileSystem.readdir(root);
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
151
|
+
if (!name || name === '.' || name === '..') continue;
|
|
152
|
+
candidates.push({ path: path.join(root, name), name, mtimeMs: 0 });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const busy = await findBusySolverWorkspaces({ workspaces: candidates, procRoot, fileSystem });
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const isProtected = protectedSet.has(candidate.path) || Array.from(protectedSet).some(protectedPath => protectedPath.startsWith(`${candidate.path}/`));
|
|
158
|
+
if (isProtected) {
|
|
159
|
+
keep.push({ path: candidate.path, reason: 'protected' });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (busy.has(candidate.path)) {
|
|
163
|
+
keep.push({ path: candidate.path, reason: 'process_cwd' });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
remove.push(candidate.path);
|
|
167
|
+
}
|
|
168
|
+
return { remove, keep };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Workspace paths mentioned in a line of solver output, used to protect in-flight workspaces. */
|
|
172
|
+
export const extractSolverWorkspacePaths = (text, { tmpRoot = DEFAULT_TMP_ROOT } = {}) => {
|
|
173
|
+
if (!text) return [];
|
|
174
|
+
const pattern = new RegExp(`${tmpRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${SOLVER_WORKSPACE_PREFIX}[A-Za-z0-9_-]+`, 'g');
|
|
175
|
+
return Array.from(new Set(String(text).match(pattern) || []));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove idle solver workspaces, oldest first, until `requiredMB` is free.
|
|
180
|
+
* A workspace is skipped when it is in flight, is some process's cwd, or was modified recently.
|
|
181
|
+
*/
|
|
182
|
+
export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, now = Date.now, log = async () => {}, fileSystem = fsPromises, procRoot = '/proc', getFreeMB = getFreeDiskSpaceMB, remove = defaultRemove } = {}) => {
|
|
183
|
+
const removed = [];
|
|
184
|
+
const skipped = [];
|
|
185
|
+
let freeMB = await getFreeMB(tmpRoot);
|
|
186
|
+
const workspaces = await listSolverWorkspaces({ tmpRoot, fileSystem });
|
|
187
|
+
if (!workspaces.length) return { removed, skipped, freeMB };
|
|
188
|
+
const busy = await findBusySolverWorkspaces({ workspaces, procRoot, fileSystem });
|
|
189
|
+
const currentTime = now();
|
|
190
|
+
for (const workspace of workspaces) {
|
|
191
|
+
if (freeMB !== null && freeMB >= requiredMB) break;
|
|
192
|
+
if (protectedPaths.has(workspace.path)) {
|
|
193
|
+
skipped.push({ path: workspace.path, reason: 'in_flight' });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (busy.has(workspace.path)) {
|
|
197
|
+
skipped.push({ path: workspace.path, reason: 'process_cwd' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (currentTime - workspace.mtimeMs < minIdleMs) {
|
|
201
|
+
skipped.push({ path: workspace.path, reason: 'recently_modified' });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await remove(workspace.path);
|
|
206
|
+
removed.push(workspace.path);
|
|
207
|
+
await log(` 🧹 Reclaimed idle solver workspace: ${workspace.path}`);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
skipped.push({ path: workspace.path, reason: 'remove_failed', error });
|
|
210
|
+
await log(` ⚠️ Could not remove ${workspace.path}: ${error.message}`, { level: 'warning' });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
freeMB = await getFreeMB(tmpRoot);
|
|
214
|
+
}
|
|
215
|
+
return { removed, skipped, freeMB };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Make sure `requiredMB` is free before a worker starts a task.
|
|
220
|
+
*
|
|
221
|
+
* Returns `{ ok: true }` when there is (or there now is) enough space, and
|
|
222
|
+
* `{ ok: false, reason: 'insufficient_disk_space' }` when the caller should defer the task instead
|
|
223
|
+
* of spawning a solver that would die in its pre-flight check.
|
|
224
|
+
*
|
|
225
|
+
* An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
|
|
226
|
+
* check, not a replacement for it.
|
|
227
|
+
*/
|
|
228
|
+
export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
|
|
229
|
+
const startedAt = now();
|
|
230
|
+
const reclaimed = [];
|
|
231
|
+
let freeMB = await getFreeMB(tmpRoot);
|
|
232
|
+
if (freeMB === null) {
|
|
233
|
+
await log(' 💾 Could not determine free disk space — continuing and letting the solver pre-flight check decide', { verbose: true });
|
|
234
|
+
return { ok: true, freeMB: null, reason: 'unknown_free_space', reclaimed, waitedMs: 0 };
|
|
235
|
+
}
|
|
236
|
+
if (freeMB >= requiredMB) {
|
|
237
|
+
await log(` 💾 Disk space before starting work: ${freeMB}MB free (${requiredMB}MB required)`, { verbose: true });
|
|
238
|
+
return { ok: true, freeMB, reason: 'sufficient', reclaimed, waitedMs: 0 };
|
|
239
|
+
}
|
|
240
|
+
await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
|
|
241
|
+
for (;;) {
|
|
242
|
+
const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
|
|
243
|
+
reclaimed.push(...result.removed);
|
|
244
|
+
if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
|
|
245
|
+
if (freeMB >= requiredMB) {
|
|
246
|
+
await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${result.removed.length} workspace(s)`);
|
|
247
|
+
return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
|
|
248
|
+
}
|
|
249
|
+
const elapsedMs = now() - startedAt;
|
|
250
|
+
if (elapsedMs + pollIntervalMs > maxWaitMs) {
|
|
251
|
+
return { ok: false, freeMB, reason: 'insufficient_disk_space', reclaimed, waitedMs: elapsedMs, skipped: result.skipped };
|
|
252
|
+
}
|
|
253
|
+
await log(` ⏳ Still ${freeMB}MB free of the ${requiredMB}MB required — waiting ${Math.round(pollIntervalMs / 1000)}s for in-flight work to release disk space`);
|
|
254
|
+
await sleep(pollIntervalMs);
|
|
255
|
+
}
|
|
256
|
+
};
|