@link-assistant/hive-mind 2.0.23 → 2.0.25
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 +25 -0
- package/package.json +16 -16
- package/src/agent-commander.lib.mjs +1 -1
- package/src/auto-language.lib.mjs +1 -1
- package/src/claude-quiet-config.lib.mjs +1 -1
- package/src/claude.lib.mjs +1 -1
- package/src/cleanup.mjs +1 -1
- package/src/cleanup.os.lib.mjs +1 -1
- package/src/codex-health.lib.mjs +191 -0
- package/src/codex.lib.mjs +57 -102
- package/src/gemini.lib.mjs +47 -0
- package/src/git.lib.mjs +2 -2
- package/src/github-repository-names.lib.mjs +1 -1
- package/src/github.lib.mjs +1 -1
- package/src/handoff-skill.lib.mjs +1 -1
- package/src/hive-screens.lib.mjs +1 -1
- package/src/i18n.lib.mjs +1 -1
- package/src/instrument.mjs +1 -1
- package/src/interactive-mode.lib.mjs +3 -3
- package/src/isolation-runner.lib.mjs +100 -3
- package/src/qwen.lib.mjs +43 -0
- package/src/session-monitor.lib.mjs +57 -15
- package/src/session-store.lib.mjs +1 -1
- package/src/solve.auto-pr.lib.mjs +2 -2
- package/src/solve.disk-diagnostics.lib.mjs +50 -29
- package/src/solve.escalate.lib.mjs +0 -3
- package/src/solve.keep-working.lib.mjs +1 -1
- package/src/solve.repository.lib.mjs +1 -1
- package/src/solve.validation.lib.mjs +1 -2
- package/src/telegram-bot-launcher.lib.mjs +2 -6
- package/src/telegram-command-execution.lib.mjs +4 -0
- package/src/telegram-isolation.lib.mjs +1 -0
- package/src/telegram-tokens-command.lib.mjs +1 -1
- package/src/telegram-top-command.lib.mjs +1 -1
- package/src/tool-run-health.lib.mjs +64 -0
- package/src/use-m-bootstrap.lib.mjs +1 -1
- package/src/useless-tools.lib.mjs +1 -1
- package/src/youtrack/solve.youtrack.lib.mjs +2 -2
- package/src/youtrack/youtrack.lib.mjs +1 -1
|
@@ -151,14 +151,14 @@ export const createInteractiveHandler = options => {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
let hits
|
|
154
|
+
let hits;
|
|
155
155
|
try {
|
|
156
156
|
hits = await containsKnownToken(body, knownTokens);
|
|
157
157
|
} catch {
|
|
158
158
|
hits = [];
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
let sanitized
|
|
161
|
+
let sanitized;
|
|
162
162
|
try {
|
|
163
163
|
sanitized = await sanitizeCommentBody(body, {
|
|
164
164
|
knownTokens,
|
|
@@ -495,7 +495,7 @@ ${createRawJsonSection(data)}`;
|
|
|
495
495
|
state.toolUseRegistry.set(toolId, { toolName, toolIcon });
|
|
496
496
|
|
|
497
497
|
// Format tool input based on tool type
|
|
498
|
-
let inputDisplay
|
|
498
|
+
let inputDisplay;
|
|
499
499
|
const input = toolUse.input || {};
|
|
500
500
|
|
|
501
501
|
if (toolName === 'Bash' && input.command) {
|
|
@@ -58,6 +58,11 @@ const DOCKER_ISOLATION_SHELL = 'sh';
|
|
|
58
58
|
// less headroom than this cannot safely pull one. Diagnostic only — never
|
|
59
59
|
// blocks startup. See issue #1914.
|
|
60
60
|
const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
|
|
61
|
+
// Docker-only start gate used to capture the container writable-layer baseline
|
|
62
|
+
// before the task command begins cloning or generating files. The parent
|
|
63
|
+
// releases the gate immediately after `docker inspect --size`; the fallback
|
|
64
|
+
// keeps the task from hanging forever if the parent exits at the wrong time.
|
|
65
|
+
const DOCKER_START_GATE_WAIT_TENTHS = 300;
|
|
61
66
|
// Sentinel start-command's detached docker logger records when it cannot capture
|
|
62
67
|
// the container's real exit code. A terminal `$ --status` carrying this value is
|
|
63
68
|
// ambiguous — the container may still be running — so we cross-check it against
|
|
@@ -94,6 +99,16 @@ function buildShellCommand(command, args = []) {
|
|
|
94
99
|
return [command, ...args].map(shellQuote).join(' ');
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
function buildDockerStartGatePath(sessionId) {
|
|
103
|
+
return sessionId ? `/tmp/hive-mind-disk-baseline-${sessionId}` : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildDockerStartGatedCommand(taskCommand, sessionId) {
|
|
107
|
+
const gatePath = buildDockerStartGatePath(sessionId);
|
|
108
|
+
if (!gatePath) return taskCommand;
|
|
109
|
+
return `gate=${shellQuote(gatePath)}; i=0; while [ ! -e "$gate" ] && [ "$i" -lt ${DOCKER_START_GATE_WAIT_TENTHS} ]; do i=$((i+1)); sleep 0.1; done; rm -f "$gate"; exec ${taskCommand}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
|
|
98
113
|
return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
|
|
99
114
|
}
|
|
@@ -234,7 +249,8 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
|
|
|
234
249
|
startArgs.push('--volume', `${mount.source}:${mount.target}`);
|
|
235
250
|
}
|
|
236
251
|
|
|
237
|
-
|
|
252
|
+
const taskCommand = buildShellCommand(command, args);
|
|
253
|
+
startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
|
|
238
254
|
return startArgs;
|
|
239
255
|
}
|
|
240
256
|
|
|
@@ -541,7 +557,7 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
541
557
|
* @param {string} [options.sessionId] - UUID for session tracking (auto-generated if not provided)
|
|
542
558
|
* @param {string} [options.tool] - AI tool selected for the task; used to scope Docker auth mounts
|
|
543
559
|
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
544
|
-
* @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string}>}
|
|
560
|
+
* @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string, containerFilesystemStartBytes?: number|null}>}
|
|
545
561
|
*/
|
|
546
562
|
export async function executeWithIsolation(command, args, options = {}) {
|
|
547
563
|
const { backend, verbose = false } = options;
|
|
@@ -598,6 +614,15 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
598
614
|
if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
|
|
599
615
|
}
|
|
600
616
|
|
|
617
|
+
let containerFilesystemStartBytes = null;
|
|
618
|
+
if (result.success && backend === 'docker') {
|
|
619
|
+
try {
|
|
620
|
+
containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
|
|
621
|
+
} finally {
|
|
622
|
+
await releaseDockerContainerStartGate(sessionId, verbose);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
601
626
|
// Issue #1939: capture the freshly-launched docker session's reported status
|
|
602
627
|
// and the live container state together, so the next iteration has the data to
|
|
603
628
|
// diagnose a premature "executed/-1" status (problem #1) or a surprise image
|
|
@@ -611,6 +636,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
611
636
|
success: true,
|
|
612
637
|
sessionId,
|
|
613
638
|
output: result.output,
|
|
639
|
+
containerFilesystemStartBytes,
|
|
614
640
|
};
|
|
615
641
|
}
|
|
616
642
|
|
|
@@ -831,6 +857,77 @@ export async function checkDockerContainerRunning(containerName, verbose = false
|
|
|
831
857
|
}
|
|
832
858
|
}
|
|
833
859
|
|
|
860
|
+
export function parseDockerContainerWritableLayerSizeOutput(output) {
|
|
861
|
+
const text = String(output || '').trim();
|
|
862
|
+
if (!text) return null;
|
|
863
|
+
const bytes = Number.parseInt(text.split(/\s+/)[0], 10);
|
|
864
|
+
return Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Best-effort size of a Docker task container's writable layer.
|
|
869
|
+
*
|
|
870
|
+
* `docker inspect --size` exposes `.SizeRw`, which excludes the image's base
|
|
871
|
+
* layers and counts only filesystem data created or changed by this container.
|
|
872
|
+
* That is the closest Docker-native representation of per-task disk usage.
|
|
873
|
+
*
|
|
874
|
+
* @param {string} containerName - Container name (the session UUID)
|
|
875
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
876
|
+
* @returns {Promise<number|null>} Writable layer bytes, or null when unavailable.
|
|
877
|
+
*/
|
|
878
|
+
export async function getDockerContainerWritableLayerSize(containerName, verbose = false) {
|
|
879
|
+
if (!containerName) return null;
|
|
880
|
+
try {
|
|
881
|
+
const result = await $({ mirror: false })`docker inspect --size -f ${'{{.SizeRw}}'} ${containerName}`;
|
|
882
|
+
const bytes = parseDockerContainerWritableLayerSizeOutput(result.stdout?.toString() || '');
|
|
883
|
+
if (verbose) {
|
|
884
|
+
const label = bytes === null ? 'unknown' : `${bytes} bytes`;
|
|
885
|
+
console.log(`[VERBOSE] isolation-runner: docker writable layer size for '${containerName}': ${label}`);
|
|
886
|
+
}
|
|
887
|
+
return bytes;
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (verbose) {
|
|
890
|
+
const stderr = error?.stderr?.toString?.().trim();
|
|
891
|
+
console.log(`[VERBOSE] isolation-runner: could not inspect writable layer size for '${containerName}': ${stderr || error?.message || error}`);
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Release the Docker-only start gate after the writable-layer baseline has been
|
|
899
|
+
* captured. Best-effort: the gated task also has a timeout fallback.
|
|
900
|
+
*
|
|
901
|
+
* @param {string} containerName - Container name (the session UUID)
|
|
902
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
903
|
+
* @returns {Promise<boolean>} true when the gate file was touched.
|
|
904
|
+
*/
|
|
905
|
+
export async function releaseDockerContainerStartGate(containerName, verbose = false) {
|
|
906
|
+
const gatePath = buildDockerStartGatePath(containerName);
|
|
907
|
+
if (!containerName || !gatePath) return false;
|
|
908
|
+
const releaseCommand = `touch ${shellQuote(gatePath)}`;
|
|
909
|
+
let lastError = null;
|
|
910
|
+
|
|
911
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
912
|
+
try {
|
|
913
|
+
await $({ mirror: false })`docker exec ${containerName} sh -c ${releaseCommand}`;
|
|
914
|
+
if (verbose) {
|
|
915
|
+
console.log(`[VERBOSE] isolation-runner: released docker start gate for '${containerName}'`);
|
|
916
|
+
}
|
|
917
|
+
return true;
|
|
918
|
+
} catch (error) {
|
|
919
|
+
lastError = error;
|
|
920
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (verbose) {
|
|
925
|
+
const stderr = lastError?.stderr?.toString?.().trim();
|
|
926
|
+
console.log(`[VERBOSE] isolation-runner: could not release docker start gate for '${containerName}': ${stderr || lastError?.message || lastError}`);
|
|
927
|
+
}
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
|
|
834
931
|
/**
|
|
835
932
|
* Best-effort removal for a Docker container backing a native
|
|
836
933
|
* `$ --isolated docker` session.
|
|
@@ -1169,7 +1266,7 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
|
|
|
1169
1266
|
const gitLib = await import('./git.lib.mjs');
|
|
1170
1267
|
return gitLib.repairGitIdentity();
|
|
1171
1268
|
});
|
|
1172
|
-
let repairOutcome
|
|
1269
|
+
let repairOutcome;
|
|
1173
1270
|
try {
|
|
1174
1271
|
repairOutcome = await repairFn();
|
|
1175
1272
|
} catch (error) {
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { qwenModels, defaultModels } from './models/index.mjs';
|
|
|
22
22
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
23
23
|
import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
24
24
|
import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
25
|
+
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
25
26
|
|
|
26
27
|
export const mapModelToId = model => qwenModels[model] || model;
|
|
27
28
|
|
|
@@ -638,6 +639,48 @@ export const executeQwenCommand = async params => {
|
|
|
638
639
|
};
|
|
639
640
|
}
|
|
640
641
|
|
|
642
|
+
// Issue #1990: exit 0 with no error event is necessary but NOT sufficient.
|
|
643
|
+
// qwen-code's stream-json ends with a terminal `result` event; a run that
|
|
644
|
+
// did work but never emitted it was cut off mid-run (e.g. the docker
|
|
645
|
+
// container ran out of disk) and must be registered as a failure so the
|
|
646
|
+
// session is preserved for a context-preserving restart and — under docker
|
|
647
|
+
// isolation — the container filesystem is kept for inspection.
|
|
648
|
+
const completionHealth = getTerminalEventCompletionHealth({
|
|
649
|
+
eventCounts: qwenState.eventCounts,
|
|
650
|
+
terminalEventTypes: ['result'],
|
|
651
|
+
hadActivity: (qwenState.parsedEvents?.length || 0) > 0,
|
|
652
|
+
diskEvidenceTexts: [
|
|
653
|
+
{ source: 'output', text: allOutput },
|
|
654
|
+
{ source: 'result-summary', text: resultSummary },
|
|
655
|
+
],
|
|
656
|
+
});
|
|
657
|
+
if (!completionHealth.healthy) {
|
|
658
|
+
await log('\n\n❌ Qwen Code exited 0 but the run did not complete — treating as failure', { level: 'error' });
|
|
659
|
+
for (const reason of completionHealth.reasons) {
|
|
660
|
+
await log(` • ${reason}`, { level: 'error' });
|
|
661
|
+
}
|
|
662
|
+
if (completionHealth.diskPressureDetected) {
|
|
663
|
+
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
664
|
+
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
|
665
|
+
await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
|
|
666
|
+
}
|
|
667
|
+
await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
|
|
668
|
+
}
|
|
669
|
+
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
670
|
+
return {
|
|
671
|
+
success: false,
|
|
672
|
+
sessionId,
|
|
673
|
+
limitReached: false,
|
|
674
|
+
limitResetTime: null,
|
|
675
|
+
...usageResult,
|
|
676
|
+
resultSummary,
|
|
677
|
+
completionHealth,
|
|
678
|
+
incompleteSession: completionHealth.incompleteSession,
|
|
679
|
+
diskPressureDetected: completionHealth.diskPressureDetected,
|
|
680
|
+
errorInfo: { message: completionHealth.reasons.join(' ') },
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
641
684
|
await log('\n\n✅ Qwen Code command completed');
|
|
642
685
|
if (resultSummary) {
|
|
643
686
|
await log('📝 Captured result summary from Qwen Code output', { verbose: true });
|
|
@@ -338,19 +338,32 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
/**
|
|
341
|
-
* Issue #1945: Parse `📊 [DISK]` checkpoint markers out
|
|
342
|
-
*
|
|
343
|
-
* Telegram extraSection
|
|
344
|
-
*
|
|
341
|
+
* Issue #1945/#1988: Parse `📊 [DISK]` repository-size checkpoint markers out
|
|
342
|
+
* of the captured solve log, optionally add docker writable-layer sizes, and
|
|
343
|
+
* build the Telegram extraSection. Returns an empty string if there is no
|
|
344
|
+
* repository or docker filesystem data to show.
|
|
345
345
|
*/
|
|
346
|
-
async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile } = {}) {
|
|
347
|
-
if (!logPath) return '';
|
|
346
|
+
async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile, isolationBackend = null, containerFilesystemStartBytes = null, containerFilesystemAfterBytes = null } = {}) {
|
|
347
|
+
if (!logPath && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
|
|
348
348
|
try {
|
|
349
349
|
const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
|
|
350
|
-
|
|
350
|
+
let logText = '';
|
|
351
|
+
if (logPath) {
|
|
352
|
+
try {
|
|
353
|
+
logText = await readFile(logPath, 'utf8');
|
|
354
|
+
} catch (readError) {
|
|
355
|
+
if (verbose) {
|
|
356
|
+
console.log(`[VERBOSE] Could not read session log ${logPath} for disk diagnostics: ${readError?.message || readError}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
351
360
|
const parsed = diskLib.parseDiskMarkers(logText);
|
|
352
|
-
if (!parsed.afterClone && !parsed.afterAgent) return '';
|
|
353
|
-
return diskLib.formatDiskDiagnosticsBlock(parsed
|
|
361
|
+
if (!parsed.afterClone && !parsed.afterAgent && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
|
|
362
|
+
return diskLib.formatDiskDiagnosticsBlock(parsed, {
|
|
363
|
+
isolationBackend,
|
|
364
|
+
containerFilesystemStartBytes,
|
|
365
|
+
containerFilesystemAfterBytes,
|
|
366
|
+
});
|
|
354
367
|
} catch (error) {
|
|
355
368
|
if (verbose) {
|
|
356
369
|
console.log(`[VERBOSE] Could not inspect session log ${logPath} for disk diagnostics: ${error?.message || error}`);
|
|
@@ -359,6 +372,26 @@ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, read
|
|
|
359
372
|
}
|
|
360
373
|
}
|
|
361
374
|
|
|
375
|
+
async function getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
376
|
+
if (sessionInfo?.isolationBackend !== 'docker') return null;
|
|
377
|
+
const containerName = sessionInfo.sessionId || sessionName;
|
|
378
|
+
if (!containerName) return null;
|
|
379
|
+
try {
|
|
380
|
+
if (typeof sizeProvider === 'function') {
|
|
381
|
+
const bytes = await sizeProvider(containerName, { sessionName, sessionInfo, verbose });
|
|
382
|
+
return Number.isFinite(bytes) ? bytes : null;
|
|
383
|
+
}
|
|
384
|
+
const runner = await getIsolationRunner();
|
|
385
|
+
if (typeof runner.getDockerContainerWritableLayerSize !== 'function') return null;
|
|
386
|
+
return await runner.getDockerContainerWritableLayerSize(containerName, verbose);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (verbose) {
|
|
389
|
+
console.log(`[VERBOSE] Could not inspect docker filesystem size for ${containerName}: ${error?.message || error}`);
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
362
395
|
function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
|
|
363
396
|
const outcome = classifySessionOutcome({ exitCode, status });
|
|
364
397
|
if (outcome.failed) return false;
|
|
@@ -783,13 +816,22 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
783
816
|
}
|
|
784
817
|
}
|
|
785
818
|
|
|
786
|
-
// Issue #1945: append a "💾 Disk usage" block
|
|
787
|
-
//
|
|
788
|
-
// parsed from the captured solve log markers.
|
|
819
|
+
// Issue #1945/#1988: append a "💾 Disk usage" block from repository
|
|
820
|
+
// size markers and, for docker isolation, the container writable layer.
|
|
789
821
|
const diskExtraSections = [];
|
|
790
822
|
try {
|
|
791
823
|
const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
792
|
-
const
|
|
824
|
+
const containerFilesystemAfterBytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
|
|
825
|
+
verbose,
|
|
826
|
+
sizeProvider: options.dockerContainerSizeProvider,
|
|
827
|
+
});
|
|
828
|
+
const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, {
|
|
829
|
+
verbose,
|
|
830
|
+
readFile: options.readFile,
|
|
831
|
+
isolationBackend: sessionInfo?.isolationBackend || statusResult?.isolation || null,
|
|
832
|
+
containerFilesystemStartBytes: Number.isFinite(sessionInfo?.containerFilesystemStartBytes) ? sessionInfo.containerFilesystemStartBytes : null,
|
|
833
|
+
containerFilesystemAfterBytes,
|
|
834
|
+
});
|
|
793
835
|
if (diskBlock) diskExtraSections.push(diskBlock);
|
|
794
836
|
} catch (diskError) {
|
|
795
837
|
if (verbose) {
|
|
@@ -983,7 +1025,7 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
983
1025
|
return { resumed, skipped };
|
|
984
1026
|
}
|
|
985
1027
|
|
|
986
|
-
let persisted
|
|
1028
|
+
let persisted;
|
|
987
1029
|
try {
|
|
988
1030
|
persisted = store.load();
|
|
989
1031
|
} catch (error) {
|
|
@@ -1255,7 +1297,7 @@ export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
|
1255
1297
|
const screenChecker = options.screenChecker || checkScreenSessionExists;
|
|
1256
1298
|
|
|
1257
1299
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1258
|
-
let running
|
|
1300
|
+
let running;
|
|
1259
1301
|
let status = null;
|
|
1260
1302
|
|
|
1261
1303
|
if (sessionInfo.isolationBackend) {
|
|
@@ -33,7 +33,7 @@ import path from 'node:path';
|
|
|
33
33
|
// excluded so the snapshot stays small and safe to reload.
|
|
34
34
|
// `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
|
|
35
35
|
// with its exact original invocation plus `--resume <lastSessionId>`.
|
|
36
|
-
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
36
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Resolve the directory durable bot state is written to. Honors
|
|
@@ -1275,9 +1275,9 @@ ${prBody}`,
|
|
|
1275
1275
|
}
|
|
1276
1276
|
|
|
1277
1277
|
if (errorMsg.includes('No commits between') || errorMsg.includes("Head sha can't be blank")) {
|
|
1278
|
-
throw new Error(`PR creation failed - no commits between branches: ${cleanError}
|
|
1278
|
+
throw new Error(`PR creation failed - no commits between branches: ${cleanError}`, { cause: prCreateError });
|
|
1279
1279
|
} else {
|
|
1280
|
-
throw new Error(`PR creation failed: ${cleanError}
|
|
1280
|
+
throw new Error(`PR creation failed: ${cleanError}`, { cause: prCreateError });
|
|
1281
1281
|
}
|
|
1282
1282
|
}
|
|
1283
1283
|
}
|
|
@@ -10,12 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Both checkpoints are written to the captured solve log as a single-line
|
|
12
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - cloned repository > WARNING_THRESHOLD_BYTES
|
|
17
|
-
* - delta during run > WARNING_THRESHOLD_BYTES
|
|
18
|
-
* - total space used > WARNING_THRESHOLD_BYTES
|
|
13
|
+
* markers and, on the completion message, surfaces a Telegram block plus a
|
|
14
|
+
* warning when total task disk usage crosses the configured threshold.
|
|
19
15
|
*
|
|
20
16
|
* Implementation notes:
|
|
21
17
|
*
|
|
@@ -220,54 +216,79 @@ export function computeDiskWarnings(parsed, threshold = WARNING_THRESHOLD_BYTES)
|
|
|
220
216
|
|
|
221
217
|
/**
|
|
222
218
|
* Telegram block (Markdown code fence) describing the captured sizes plus,
|
|
223
|
-
* when
|
|
224
|
-
* string when there are no markers
|
|
219
|
+
* when the task total crosses the threshold, a warning tail. Returns an empty
|
|
220
|
+
* string when there are no markers or docker container filesystem sizes to show
|
|
221
|
+
* (no logs ⇒ no surprise output).
|
|
225
222
|
*
|
|
226
223
|
* Returned shape:
|
|
227
224
|
*
|
|
228
225
|
* 💾 Disk usage (gh-issue-solver-…)
|
|
229
226
|
* ```
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
227
|
+
* Repository size:
|
|
228
|
+
* Cloned: 12.0 GB
|
|
229
|
+
* On completion: 12.4 GB (+500 MB)
|
|
233
230
|
*
|
|
234
|
-
* ⚠️
|
|
235
|
-
* ⚠️ Total disk usage exceeds 5.0 GB
|
|
231
|
+
* ⚠️ Total disk usage per task exceeds 5.0 GB
|
|
236
232
|
* ```
|
|
237
233
|
*
|
|
238
234
|
* @param {{afterClone: object|null, afterAgent: object|null}} parsed
|
|
239
235
|
* @param {Object} [options]
|
|
240
236
|
* @param {number} [options.threshold=WARNING_THRESHOLD_BYTES]
|
|
241
237
|
* @param {string} [options.title='💾 Disk usage']
|
|
238
|
+
* @param {string} [options.isolationBackend] - Adds container filesystem details for docker isolation.
|
|
239
|
+
* @param {number|null} [options.containerFilesystemStartBytes]
|
|
240
|
+
* @param {number|null} [options.containerFilesystemAfterBytes]
|
|
242
241
|
* @returns {string}
|
|
243
242
|
*/
|
|
244
243
|
export function formatDiskDiagnosticsBlock(parsed, options = {}) {
|
|
245
|
-
if (!parsed || (!parsed.afterClone && !parsed.afterAgent)) return '';
|
|
246
244
|
const threshold = Number.isFinite(options.threshold) ? options.threshold : WARNING_THRESHOLD_BYTES;
|
|
247
245
|
const title = options.title || '💾 Disk usage';
|
|
248
|
-
const
|
|
246
|
+
const isolationBackend = String(options.isolationBackend || '').toLowerCase();
|
|
247
|
+
const isDockerIsolation = isolationBackend === 'docker';
|
|
248
|
+
const containerFilesystemStartBytes = Number.isFinite(options.containerFilesystemStartBytes) ? options.containerFilesystemStartBytes : null;
|
|
249
|
+
const containerFilesystemAfterBytes = Number.isFinite(options.containerFilesystemAfterBytes) ? options.containerFilesystemAfterBytes : null;
|
|
250
|
+
const hasRepositoryMarkers = Boolean(parsed?.afterClone || parsed?.afterAgent);
|
|
251
|
+
const hasContainerFilesystemMarkers = isDockerIsolation && (containerFilesystemStartBytes !== null || containerFilesystemAfterBytes !== null);
|
|
252
|
+
if (!hasRepositoryMarkers && !hasContainerFilesystemMarkers) return '';
|
|
253
|
+
|
|
249
254
|
const lines = [];
|
|
250
|
-
const cloneBytes = parsed
|
|
251
|
-
const totalBytes = parsed
|
|
252
|
-
const deltaBytes = parsed
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
const cloneBytes = parsed?.afterClone?.bytes ?? null;
|
|
256
|
+
const totalBytes = parsed?.afterAgent?.bytes ?? null;
|
|
257
|
+
const deltaBytes = parsed?.afterAgent?.deltaBytes ?? null;
|
|
258
|
+
|
|
259
|
+
const pushSizeLine = (label, bytes, suffix = '') => {
|
|
260
|
+
if (bytes === null) return;
|
|
261
|
+
lines.push(` ${label.padEnd(16)} ${formatBytes(bytes)}${suffix}`);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
if (hasRepositoryMarkers) {
|
|
265
|
+
lines.push('Repository size:');
|
|
266
|
+
pushSizeLine('Cloned:', cloneBytes);
|
|
267
|
+
if (totalBytes !== null) {
|
|
268
|
+
const deltaStr = deltaBytes !== null ? ` (${formatBytesDelta(deltaBytes)})` : '';
|
|
269
|
+
pushSizeLine('On completion:', totalBytes, deltaStr);
|
|
270
|
+
} else if (deltaBytes !== null) {
|
|
271
|
+
lines.push(` ${'Delta during run:'.padEnd(16)} ${formatBytesDelta(deltaBytes)}`);
|
|
272
|
+
}
|
|
255
273
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
lines.push(
|
|
259
|
-
|
|
260
|
-
|
|
274
|
+
|
|
275
|
+
if (hasContainerFilesystemMarkers) {
|
|
276
|
+
lines.push('Container filesystem size:');
|
|
277
|
+
pushSizeLine('On start:', containerFilesystemStartBytes);
|
|
278
|
+
pushSizeLine('On completion:', containerFilesystemAfterBytes);
|
|
261
279
|
}
|
|
262
|
-
|
|
280
|
+
|
|
281
|
+
const taskTotalBytes = isDockerIsolation && containerFilesystemAfterBytes !== null ? containerFilesystemAfterBytes : (totalBytes ?? cloneBytes);
|
|
263
282
|
const warningLines = [];
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
283
|
+
if (Number.isFinite(taskTotalBytes) && taskTotalBytes > threshold) {
|
|
284
|
+
warningLines.push(`⚠️ Total disk usage per task exceeds ${formatBytes(threshold)}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
267
287
|
if (warningLines.length) {
|
|
268
288
|
lines.push('');
|
|
269
289
|
lines.push(...warningLines);
|
|
270
290
|
}
|
|
291
|
+
|
|
271
292
|
return `${title}\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
|
|
272
293
|
}
|
|
273
294
|
|
|
@@ -125,9 +125,6 @@ export const parseEscalateRange = value => {
|
|
|
125
125
|
throw new Error(`Invalid --escalate value: ${JSON.stringify(value)}. Expected a model range like "sonnet-fable".`);
|
|
126
126
|
}
|
|
127
127
|
const trimmed = raw.trim().toLowerCase();
|
|
128
|
-
if (trimmed === '') {
|
|
129
|
-
raw = DEFAULT_ESCALATE_RANGE;
|
|
130
|
-
}
|
|
131
128
|
const parts = (trimmed === '' ? DEFAULT_ESCALATE_RANGE : trimmed).split('-');
|
|
132
129
|
|
|
133
130
|
const order = MODEL_ESCALATION_ORDER;
|
|
@@ -159,7 +159,7 @@ export const runKeepWorkingUntilDone = async ({ issueUrl, owner, repo, issueNumb
|
|
|
159
159
|
let iteration = 0;
|
|
160
160
|
while (true) {
|
|
161
161
|
// Gather and scan sources fresh on every iteration.
|
|
162
|
-
let sources
|
|
162
|
+
let sources;
|
|
163
163
|
try {
|
|
164
164
|
sources = await collectDeferredWorkSources({ owner, repo, prNumber, resultSummary: lastResultSummary });
|
|
165
165
|
} catch (error) {
|
|
@@ -550,8 +550,8 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
550
550
|
}
|
|
551
551
|
if (!safeToDelete) {
|
|
552
552
|
if (argv.allowForceNonForkRepositoryDeletion) {
|
|
553
|
+
// Force flag set — proceed with deletion despite the failed safety check.
|
|
553
554
|
await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
|
|
554
|
-
safeToDelete = true;
|
|
555
555
|
} else {
|
|
556
556
|
await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
|
|
557
557
|
await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
|
|
@@ -302,7 +302,7 @@ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnecti
|
|
|
302
302
|
|
|
303
303
|
// Skip tool connection validation if in dry-run mode or explicitly requested
|
|
304
304
|
if (!skipToolConnection) {
|
|
305
|
-
let isToolConnected
|
|
305
|
+
let isToolConnected;
|
|
306
306
|
if (argv.useAgentCommander) {
|
|
307
307
|
const agentCommanderLib = await import('./agent-commander.lib.mjs');
|
|
308
308
|
isToolConnected = await agentCommanderLib.validateAgentCommanderConnection({
|
|
@@ -361,7 +361,6 @@ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnecti
|
|
|
361
361
|
await log('❌ Cannot proceed without Claude CLI connection', { level: 'error' });
|
|
362
362
|
return false;
|
|
363
363
|
}
|
|
364
|
-
isToolConnected = true;
|
|
365
364
|
}
|
|
366
365
|
|
|
367
366
|
// Check GitHub permissions (only when tool check is not skipped)
|
|
@@ -194,13 +194,9 @@ export async function launchBotWithRetry(bot, launchOptions, retryOptions = {})
|
|
|
194
194
|
reject(new Error('Bot launch aborted during retry wait'));
|
|
195
195
|
return;
|
|
196
196
|
}
|
|
197
|
+
// `{ once: true }` removes the abort listener after it fires; on the
|
|
198
|
+
// natural-timeout path it is released when the AbortSignal is collected.
|
|
197
199
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
198
|
-
// Clean up the listener when the timer fires naturally
|
|
199
|
-
const originalResolve = resolve;
|
|
200
|
-
resolve = () => {
|
|
201
|
-
signal.removeEventListener('abort', onAbort);
|
|
202
|
-
originalResolve();
|
|
203
|
-
};
|
|
204
200
|
}
|
|
205
201
|
});
|
|
206
202
|
}
|
|
@@ -127,6 +127,10 @@ export function buildExecuteAndUpdateMessage(deps) {
|
|
|
127
127
|
trackSession(session, sessionInfo, VERBOSE);
|
|
128
128
|
await safeEdit(formatStartingWorkSessionMessage({ sessionName: session, isolationBackend: iso.backend, infoBlock, locale }));
|
|
129
129
|
result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
|
|
130
|
+
if (result.success && sessionInfo && Number.isFinite(result.containerFilesystemStartBytes)) {
|
|
131
|
+
sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
|
|
132
|
+
trackSession(session, sessionInfo, VERBOSE);
|
|
133
|
+
}
|
|
130
134
|
if (!result.success) {
|
|
131
135
|
// The launch never produced a live container — drop the optimistic
|
|
132
136
|
// tracking so a phantom session is not monitored or resumed.
|
|
@@ -92,6 +92,7 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
|
|
|
92
92
|
command: item.command || 'solve',
|
|
93
93
|
isolationBackend: iso.backend,
|
|
94
94
|
sessionId: sid,
|
|
95
|
+
containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
|
|
95
96
|
tool,
|
|
96
97
|
infoBlock: item.infoBlock,
|
|
97
98
|
// Issue #1688: propagate URL context + requester through the queue so the
|
|
@@ -116,7 +116,7 @@ export const registerTokensCommand = (bot, options = {}) => {
|
|
|
116
116
|
|
|
117
117
|
// Step 2: authenticate by ownership of an allowlisted chat.
|
|
118
118
|
const allowedChatIds = resolveAllowedChatIds(allowedChats);
|
|
119
|
-
let isOperator
|
|
119
|
+
let isOperator;
|
|
120
120
|
try {
|
|
121
121
|
isOperator = await isOperatorOfAnyAllowedChat({
|
|
122
122
|
telegram: ctx.telegram,
|
|
@@ -145,7 +145,7 @@ export function registerTopCommand(bot, options) {
|
|
|
145
145
|
const screenName = `top-chat-${chatId}`;
|
|
146
146
|
|
|
147
147
|
// Check if screen session already exists
|
|
148
|
-
let sessionExists
|
|
148
|
+
let sessionExists;
|
|
149
149
|
try {
|
|
150
150
|
const { stdout } = await exec('screen -ls');
|
|
151
151
|
sessionExists = stdout.includes(screenName);
|