@link-assistant/hive-mind 2.15.2 → 2.16.0

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.
@@ -10,6 +10,10 @@ import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRep
10
10
  import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
11
11
  export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile }; // Re-export for backward compatibility
12
12
  import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
13
+ // Issue #2189: bracket the log-upload phase with resource samples. The incident
14
+ // log's last sample was `after_agent`, ten minutes before the heap OOM, so the
15
+ // phase that actually died left no telemetry at all.
16
+ import { recordResourceSnapshot, RESOURCE_PHASE_LOG_UPLOAD_START, RESOURCE_PHASE_LOG_UPLOAD_END } from './solve.resource-diagnostics.lib.mjs';
13
17
  import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: https://github.com/link-assistant/hive-mind/issues/1236
14
18
  // Import model info helpers (Issue #1225)
15
19
  import { getToolDisplayName, getModelInfoForComment } from './models/index.mjs';
@@ -26,6 +30,13 @@ export { buildGitHubPullRequestUrl, buildGitHubPullRequestUrlOrNull, isGitHubUrl
26
30
  import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
27
31
  export const maskGitHubToken = maskToken; // Alias for backward compatibility
28
32
  export const escapeCodeBlocksInLog = logContent => logContent.replace(/```/g, '\\`\\`\\`'); // Escape ``` in logs
33
+ // Issue #2189: the old "too large" message always rendered megabytes, so a 200 KB
34
+ // log that still could not be inlined was reported as "0MB". Report the unit that
35
+ // actually carries information.
36
+ export const formatLogSizeForHumans = bytes => {
37
+ const size = Number(bytes) || 0;
38
+ return size >= 1024 * 1024 ? `${Math.round(size / 1024 / 1024)}MB` : `${Math.round(size / 1024)}KB`;
39
+ };
29
40
  const buildIssueFailureActionSection = targetType => {
30
41
  if (targetType !== 'issue') return '';
31
42
  return `
@@ -317,6 +328,150 @@ const getLogUploadTerminalStatus = ({ errorMessage, errorDuringExecution, isUsag
317
328
  if (isUsageLimit) return { emoji: '📎', label: 'Usage-limit execution log' };
318
329
  return { emoji: '✅', label: 'Solution draft log' };
319
330
  };
331
+ /**
332
+ * Build the inline `--attach-logs` comment body — the variant that embeds the
333
+ * whole transcript inside a `<details>` block.
334
+ *
335
+ * Issue #2189: extracted from attachLogToGitHub so that it is only ever called
336
+ * for a log small enough to inline. Previously the body was assembled
337
+ * unconditionally — including reading, sanitizing and escaping the log — and
338
+ * then discarded when the size check downstream routed the log to gh-upload-log
339
+ * anyway. On a 134 MB transcript that discarded pre-work alone exhausted V8's
340
+ * old space and killed the run.
341
+ *
342
+ * @param {object} params - Everything the four comment shapes interpolate
343
+ * @param {string} params.logContent - Already sanitized and markdown-escaped log
344
+ * @param {number} params.logSizeBytes - Size of the log on disk, for the summary line
345
+ * @returns {string} Markdown comment body
346
+ */
347
+ function buildInlineLogComment({ logContent, logSizeBytes, targetType, customTitle, sessionType, sessionId, errorMessage, errorDuringExecution, isUsageLimit, limitResetTime, toolName, resumeCommand, isAutoResumeEnabled, autoResumeMode, modelInfoString, budgetStats, totalCostUSD, anthropicTotalCostUSD, pricingInfo, failureAction }) {
348
+ let logComment;
349
+ // Usage limit comments should be shown whenever isUsageLimit is true, regardless of whether a generic errorMessage is provided.
350
+ if (isUsageLimit) {
351
+ // Usage limit error format - separate from general failures
352
+ logComment = `## ⏳ ${USAGE_LIMIT_REACHED_MARKER}
353
+
354
+ The automated solution draft was interrupted because the ${toolName} usage limit was reached.
355
+
356
+ ### 📊 Limit Information
357
+ - **Tool**: ${toolName}
358
+ - **Limit Type**: Usage limit exceeded`;
359
+ if (limitResetTime) {
360
+ // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
361
+ const formattedResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
362
+ logComment += `\n- **Reset Time**: ${formattedResetTime}`;
363
+ }
364
+ if (sessionId) {
365
+ logComment += `\n- **Session ID**: ${sessionId}`;
366
+ }
367
+ logComment += '\n\n### 🔄 How to Continue\n';
368
+ // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
369
+ if (isAutoResumeEnabled) {
370
+ const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
371
+ const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
372
+ logComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
373
+ } else {
374
+ // Manual resume mode - show CLI commands
375
+ if (limitResetTime) {
376
+ logComment += `Once the limit resets at **${limitResetTime}**, `;
377
+ } else {
378
+ logComment += 'Once the limit resets, ';
379
+ }
380
+ if (resumeCommand) {
381
+ logComment += `you can resume this session by running:
382
+ \`\`\`bash
383
+ ${resumeCommand}
384
+ \`\`\``;
385
+ } else if (sessionId) {
386
+ logComment += `you can resume this session using session ID: \`${sessionId}\``;
387
+ } else {
388
+ logComment += 'you can retry the operation.';
389
+ }
390
+ }
391
+ const footerNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
392
+ logComment += `${modelInfoString}
393
+
394
+ <details>
395
+ <summary>Click to expand execution log (${Math.round(logSizeBytes / 1024)}KB)</summary>
396
+
397
+ \`\`\`
398
+ ${logContent}
399
+ \`\`\`
400
+
401
+ </details>
402
+
403
+ ---
404
+ ${footerNote}`;
405
+ } else if (errorMessage) {
406
+ // Failure log format (non-usage-limit errors)
407
+ logComment = `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
408
+ The automated solution draft encountered an error:
409
+ \`\`\`
410
+ ${errorMessage}
411
+ \`\`\`${failureAction}${modelInfoString}
412
+
413
+ <details>
414
+ <summary>Click to expand failure log (${Math.round(logSizeBytes / 1024)}KB)</summary>
415
+
416
+ \`\`\`
417
+ ${logContent}
418
+ \`\`\`
419
+
420
+ </details>
421
+
422
+ ---
423
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
424
+ } else if (errorDuringExecution) {
425
+ // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
426
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
427
+ logComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
428
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
429
+
430
+ > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
431
+
432
+ <details>
433
+ <summary>Click to expand solution draft log (${Math.round(logSizeBytes / 1024)}KB)</summary>
434
+
435
+ \`\`\`
436
+ ${logContent}
437
+ \`\`\`
438
+
439
+ </details>
440
+
441
+ ---
442
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
443
+ } else {
444
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
445
+ // Determine title based on session type (Issue #1152) Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so the filter in checkForAiCreatedComments matches every variant with a single substring check against the centralized marker constant.
446
+ let title = customTitle;
447
+ let sessionNote = '';
448
+ if (sessionType === 'auto-resume') {
449
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto resume on limit reset)`;
450
+ sessionNote = '\n\n**Note**: This session was automatically resumed after a usage limit reset, with the previous context preserved.';
451
+ } else if (sessionType === 'auto-restart') {
452
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto restart on limit reset)`;
453
+ sessionNote = '\n\n**Note**: This session was automatically restarted after a usage limit reset (fresh start).';
454
+ } else if (sessionType === 'resume') {
455
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (Resumed)`;
456
+ sessionNote = '\n\n**Note**: This session was manually resumed using the --resume flag.';
457
+ }
458
+ logComment = `## ${title}
459
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}${sessionNote}
460
+
461
+ <details>
462
+ <summary>Click to expand solution draft log (${Math.round(logSizeBytes / 1024)}KB)</summary>
463
+
464
+ \`\`\`
465
+ ${logContent}
466
+ \`\`\`
467
+
468
+ </details>
469
+
470
+ ---
471
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
472
+ }
473
+ return logComment;
474
+ }
320
475
  /** Attaches a log file to a GitHub PR or issue as a comment. Returns true if upload succeeded. */
321
476
  export async function attachLogToGitHub(options) {
322
477
  const fs = (await use('fs')).promises;
@@ -351,9 +506,11 @@ export async function attachLogToGitHub(options) {
351
506
  resultModelUsage = null, // Issue #1454
352
507
  budgetStatsData = null, // Issue #1491: budget stats for comment
353
508
  failureActionSection = null,
509
+ recordResources = recordResourceSnapshot, // Issue #2189: injectable for tests
354
510
  } = options;
355
511
  const budgetStats = budgetStatsData ? buildBudgetStatsString(budgetStatsData.tokenUsage, budgetStatsData.subAgentCalls) : '';
356
512
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
513
+ let uploadPhaseEntered = false;
357
514
  try {
358
515
  // Issue #1212: Check disk space before attempting log upload (100MB minimum)
359
516
  try {
@@ -372,6 +529,12 @@ export async function attachLogToGitHub(options) {
372
529
  await log(' ⚠️ Log file is empty, skipping upload');
373
530
  return false;
374
531
  }
532
+ // Issue #2189: sample resources on the way in and on the way out of the
533
+ // upload, tagged with the log size, so a future post-mortem can see the V8
534
+ // heap climbing against its own limit instead of guessing from a machine
535
+ // that still had 10 GB of RAM free.
536
+ uploadPhaseEntered = true;
537
+ await recordResources({ phase: RESOURCE_PHASE_LOG_UPLOAD_START, log, label: `log upload start, ${formatLogSizeForHumans(logStats.size)} log` });
375
538
  // Issue #1173: gh-upload-log handles large files; skip inline comment for files > fileMaxSize
376
539
  const useLargeFileMode = logStats.size > githubLimits.fileMaxSize;
377
540
  if (useLargeFileMode && verbose) {
@@ -436,148 +599,37 @@ export async function attachLogToGitHub(options) {
436
599
  }
437
600
  }
438
601
  }
439
- // Read and sanitize log content
440
- const rawLogContent = await fs.readFile(logFile, 'utf8');
441
- if (verbose) {
442
- await log(' 🔍 Sanitizing log content to mask GitHub tokens...', { verbose: true });
443
- }
444
- let logContent = await sanitizeForPublication(rawLogContent);
445
- // Escape code blocks in the log content to prevent them from breaking markdown formatting
446
- if (verbose) {
447
- await log(' 🔧 Escaping code blocks in log content for safe embedding...', { verbose: true });
448
- }
449
- logContent = escapeCodeBlocksInLog(logContent);
450
602
  const failureAction = normalizeFailureActionSection(failureActionSection ?? buildIssueFailureActionSection(targetType));
451
- // Create formatted comment
452
- let logComment;
453
- // Usage limit comments should be shown whenever isUsageLimit is true, regardless of whether a generic errorMessage is provided.
454
- if (isUsageLimit) {
455
- // Usage limit error format - separate from general failures
456
- logComment = `## ${USAGE_LIMIT_REACHED_MARKER}
457
-
458
- The automated solution draft was interrupted because the ${toolName} usage limit was reached.
459
-
460
- ### 📊 Limit Information
461
- - **Tool**: ${toolName}
462
- - **Limit Type**: Usage limit exceeded`;
463
- if (limitResetTime) {
464
- // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
465
- const formattedResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
466
- logComment += `\n- **Reset Time**: ${formattedResetTime}`;
467
- }
468
- if (sessionId) {
469
- logComment += `\n- **Session ID**: ${sessionId}`;
603
+ // Issue #2189: choose the publication route from the file size BEFORE touching
604
+ // the bytes. The old order read the log, sanitized it and escaped it — three
605
+ // full-size strings and only then discovered the comment was far over
606
+ // GitHub's limit and had to be uploaded instead. On a 134 MB transcript that
607
+ // wasted pre-work alone exhausted V8's ~2 GB old space and killed the run
608
+ // ("FATAL ERROR: Reached heap limit"). A log that cannot possibly fit in a
609
+ // comment is now never read into memory at all: it streams straight to
610
+ // gh-upload-log, which sanitizes it block by block.
611
+ const canBuildInlineComment = !useLargeFileMode && logStats.size <= githubLimits.commentMaxSize;
612
+ let logComment = '';
613
+ if (canBuildInlineComment) {
614
+ // Read and sanitize log content
615
+ const rawLogContent = await fs.readFile(logFile, 'utf8');
616
+ if (verbose) {
617
+ await log(' 🔍 Sanitizing log content to mask GitHub tokens...', { verbose: true });
470
618
  }
471
- logComment += '\n\n### 🔄 How to Continue\n';
472
- // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
473
- if (isAutoResumeEnabled) {
474
- const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
475
- const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
476
- logComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
477
- } else {
478
- // Manual resume mode - show CLI commands
479
- if (limitResetTime) {
480
- logComment += `Once the limit resets at **${limitResetTime}**, `;
481
- } else {
482
- logComment += 'Once the limit resets, ';
483
- }
484
- if (resumeCommand) {
485
- logComment += `you can resume this session by running:
486
- \`\`\`bash
487
- ${resumeCommand}
488
- \`\`\``;
489
- } else if (sessionId) {
490
- logComment += `you can resume this session using session ID: \`${sessionId}\``;
491
- } else {
492
- logComment += 'you can retry the operation.';
493
- }
619
+ let logContent = await sanitizeForPublication(rawLogContent);
620
+ // Escape code blocks in the log content to prevent them from breaking markdown formatting
621
+ if (verbose) {
622
+ await log(' 🔧 Escaping code blocks in log content for safe embedding...', { verbose: true });
494
623
  }
495
- const footerNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
496
- logComment += `${modelInfoString}
497
-
498
- <details>
499
- <summary>Click to expand execution log (${Math.round(logStats.size / 1024)}KB)</summary>
500
-
501
- \`\`\`
502
- ${logContent}
503
- \`\`\`
504
-
505
- </details>
506
-
507
- ---
508
- ${footerNote}`;
509
- } else if (errorMessage) {
510
- // Failure log format (non-usage-limit errors)
511
- logComment = `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
512
- The automated solution draft encountered an error:
513
- \`\`\`
514
- ${errorMessage}
515
- \`\`\`${failureAction}${modelInfoString}
516
-
517
- <details>
518
- <summary>Click to expand failure log (${Math.round(logStats.size / 1024)}KB)</summary>
519
-
520
- \`\`\`
521
- ${logContent}
522
- \`\`\`
523
-
524
- </details>
525
-
526
- ---
527
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
528
- } else if (errorDuringExecution) {
529
- // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
530
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
531
- logComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
532
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
533
-
534
- > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
535
-
536
- <details>
537
- <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
538
-
539
- \`\`\`
540
- ${logContent}
541
- \`\`\`
542
-
543
- </details>
544
-
545
- ---
546
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
547
- } else {
548
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
549
- // Determine title based on session type (Issue #1152) Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so the filter in checkForAiCreatedComments matches every variant with a single substring check against the centralized marker constant.
550
- let title = customTitle;
551
- let sessionNote = '';
552
- if (sessionType === 'auto-resume') {
553
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto resume on limit reset)`;
554
- sessionNote = '\n\n**Note**: This session was automatically resumed after a usage limit reset, with the previous context preserved.';
555
- } else if (sessionType === 'auto-restart') {
556
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto restart on limit reset)`;
557
- sessionNote = '\n\n**Note**: This session was automatically restarted after a usage limit reset (fresh start).';
558
- } else if (sessionType === 'resume') {
559
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (Resumed)`;
560
- sessionNote = '\n\n**Note**: This session was manually resumed using the --resume flag.';
561
- }
562
- logComment = `## ${title}
563
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}${sessionNote}
564
-
565
- <details>
566
- <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
567
-
568
- \`\`\`
569
- ${logContent}
570
- \`\`\`
571
-
572
- </details>
573
-
574
- ---
575
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
624
+ logContent = escapeCodeBlocksInLog(logContent);
625
+ logComment = buildInlineLogComment({ logContent, logSizeBytes: logStats.size, targetType, customTitle, sessionType, sessionId, errorMessage, errorDuringExecution, isUsageLimit, limitResetTime, toolName, resumeCommand, isAutoResumeEnabled, autoResumeMode, modelInfoString, budgetStats, totalCostUSD, anthropicTotalCostUSD, pricingInfo, failureAction });
626
+ } else if (verbose) {
627
+ await log(` ⏭️ Log is ${formatLogSizeForHumans(logStats.size)} — skipping inline comment construction, the log goes straight to gh-upload-log`, { verbose: true });
576
628
  }
577
629
  // Check GitHub comment size limit or large file mode Issue #1173: Also use gh-upload-log for large files, not just long comments
578
- if (useLargeFileMode || logComment.length > githubLimits.commentMaxSize) {
579
- if (useLargeFileMode) {
580
- await log(` 📁 Log file too large for inline comment (${Math.round(logStats.size / 1024 / 1024)}MB), using gh-upload-log`);
630
+ if (!canBuildInlineComment || logComment.length > githubLimits.commentMaxSize) {
631
+ if (!canBuildInlineComment) {
632
+ await log(` 📁 Log file too large for inline comment (${formatLogSizeForHumans(logStats.size)}), using gh-upload-log`);
581
633
  } else {
582
634
  // Issue #2160: this is the expected route for a long log, not a problem — the upload
583
635
  // below handles it. Reporting it as a warning made every normal run look degraded.
@@ -607,23 +659,20 @@ ${logContent}
607
659
  isPublicRepo = false;
608
660
  await log(' ⚠️ Could not determine repository visibility, defaulting to private for safety');
609
661
  }
610
- // Create temp log file with sanitized content (no compression, just gh-upload-log)
611
- const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
612
- // Use the original sanitized content for upload since it's a plain text file
613
- await writeSanitizedPublicationFile(tempLogFile, rawLogContent);
662
+ // Issue #2189: hand the raw log path to the uploader and let it stream the
663
+ // sanitize into its own private temp file. This used to read the whole log
664
+ // a second time and write a sanitized copy here, after which
665
+ // uploadLogWithGhUploadLog read and sanitized that copy a *third* time —
666
+ // which is why the incident log shows "🔒 Sanitized 591 secrets" twice.
667
+ // There is now exactly one sanitize pass, and it never buffers the file.
614
668
  // Use gh-upload-log default auto mode and shared repository fallback.
615
669
  const uploadDescription = `Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}`;
616
- let uploadResult;
617
- try {
618
- uploadResult = await uploadLogWithGhUploadLog({
619
- logFile: tempLogFile,
620
- isPublic: isPublicRepo,
621
- description: uploadDescription,
622
- verbose,
623
- });
624
- } finally {
625
- await fs.unlink(tempLogFile).catch(() => {});
626
- }
670
+ const uploadResult = await uploadLogWithGhUploadLog({
671
+ logFile,
672
+ isPublic: isPublicRepo,
673
+ description: uploadDescription,
674
+ verbose,
675
+ });
627
676
  if (uploadResult.success) {
628
677
  // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks) Requirements: 1 chunk = direct raw link, >1 chunks = repo link Private repository raw URLs can contain short-lived tokens, so keep private uploads on the stable repository/tree page URL.
629
678
  const logUrl = selectLogUploadUrl({ uploadResult, isPublicRepo });
@@ -789,6 +838,17 @@ ${sessionNote}
789
838
  const msg = isENOSPC(uploadError) ? 'ENOSPC: No space left on device during log upload. Free disk space and retry.' : `Error uploading log file: ${uploadError.message}`;
790
839
  await log(` ❌ ${msg}`);
791
840
  return false;
841
+ } finally {
842
+ // Issue #2189: the closing sample must run on every exit path, including the
843
+ // failure ones — an upload that died half way through is exactly the case
844
+ // whose memory profile we need.
845
+ if (uploadPhaseEntered) {
846
+ try {
847
+ await recordResources({ phase: RESOURCE_PHASE_LOG_UPLOAD_END, log, label: 'log upload end' });
848
+ } catch {
849
+ /* telemetry must never change the outcome of the upload */
850
+ }
851
+ }
792
852
  }
793
853
  }
794
854
  /**