@link-assistant/hive-mind 2.0.8 → 2.0.10

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 CHANGED
@@ -1,5 +1,69 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.10
4
+
5
+ ### Patch Changes
6
+
7
+ - e29c83e: Surface the docker-isolation session id + isolation backend immediately when the
8
+ Telegram bot launches a task, instead of only after the (potentially hour-long)
9
+ image pull / container startup finishes (#1946). `formatStartingWorkSessionMessage`
10
+ now renders the `Session:` and `šŸ”’ Isolation:` lines on the `šŸ”„ Starting...`
11
+ message, and `buildExecuteAndUpdateMessage` tracks the session up front (before
12
+ awaiting the launch) so the run is addressable by `/watch`, `/log` and `/status`
13
+ during the whole startup window. A new `untrackSession` helper removes the
14
+ optimistically-tracked session if the launch fails, so a phantom session is never
15
+ monitored or resumed. Fix applies to every caller of the shared execution path
16
+ (`/solve`, `/hive`, `/task`).
17
+
18
+ The image-preparation log gap and host-image re-download were reported upstream,
19
+ fixed there, and are now pinned in this repo's images: `Dockerfile` /
20
+ `Dockerfile.dind` bump `start-command` `0.29.1` → `0.29.2` (link-foundation/start#138
21
+ — the `docker pull`/dind-boot phase is now recorded in the `$` session log), and
22
+ `Dockerfile.dind` bumps its base from `konard/box-dind:2.3.2` → `2.3.5`
23
+ (link-foundation/box#106 — the dind entrypoint now verifies host-image passthrough
24
+ actually seeded the nested daemon instead of silently re-downloading ~30 GB). A
25
+ deep case study is in `docs/case-studies/issue-1946/`.
26
+
27
+ - 4fcdb9a: fix(auto-merge): treat timeout-cancelled CI as a failure and never finish a session with no log when `--attach-logs` is enabled (#1952)
28
+
29
+ A job that hits its `timeout-minutes` limit surfaces as a **check-run** with
30
+ conclusion `cancelled`, but the **parent workflow run** concludes `failure`.
31
+ `getDetailedCIStatus` only inspects check-runs, so the auto-merge loop saw the
32
+ lone `cancelled` check, posted a **"Cancelled CI/CD Requires Review"** comment and
33
+ stopped — even though the workflow run had failed and other jobs in it had failed
34
+ too. The cancelled branch of `getMergeBlockers` now cross-references the workflow
35
+ runs for the commit SHA via a new pure helper
36
+ `classifyCancelledCIByWorkflowRuns` (`src/cancelled-ci-rerun.lib.mjs`):
37
+ - a run still queued/in-progress → `ci_pending` (wait until **all** checks reach a
38
+ terminal state before auto-restarting);
39
+ - any completed `failure`/`timed_out`/`startup_failure` run → `ci_failure` (the AI
40
+ is restarted to fix it, instead of stopping for human review);
41
+ - otherwise → the original re-triggerable `ci_cancelled` flow (genuine manual
42
+ cancellation). The "requires review" stop path is skipped whenever a `ci_failure`
43
+ blocker coexists, and `startup_failure` is now counted as a failing run in the
44
+ branch-health check too.
45
+
46
+ Separately, the same session finished with **no log attached** despite
47
+ `--attach-logs` being enabled, because every attach path in `solve.mjs` is
48
+ conditional and the stop-for-review exits can return before any iteration uploads.
49
+ `attachLogToGitHub` now records a process-global `logAttachedToGitHub` flag on
50
+ every successful upload, and a final safety net (`attachFinalLogIfMissing` in the
51
+ new `src/attach-logs-guarantee.lib.mjs`) attaches the cumulative session log at the
52
+ end of `solve.mjs` whenever `--attach-logs` is on, a PR exists, and nothing has
53
+ attached a log yet. A session can no longer finish with no log when `--attach-logs`
54
+ is enabled.
55
+
56
+ Adds `tests/test-cancelled-timeout-fail-1952.mjs` (13 tests) and
57
+ `tests/test-attach-logs-safety-net-1952.mjs` (9 tests), plus a deep case study in
58
+ `docs/case-studies/issue-1952/` reconstructed from the real-world trigger
59
+ (xlabtg/teleton-agent PR #670).
60
+
61
+ ## 2.0.9
62
+
63
+ ### Patch Changes
64
+
65
+ - c8b241a: Fix Claude public cost estimates for 1-hour prompt-cache writes by pricing `cache_creation.ephemeral_1h_input_tokens` at the documented 2x input rate instead of the 5-minute cache-write rate.
66
+
3
67
  ## 2.0.8
4
68
 
5
69
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #1952: Guarantee that a working session never finishes with NO log attached when
5
+ * `--attach-logs` is enabled.
6
+ *
7
+ * Every log-attachment path in solve.mjs is conditional and can be skipped on some logic paths:
8
+ * - verifyResults() only attaches when the PR is detected as session-owned;
9
+ * - the temporary-watch block only runs when there were uncommitted changes;
10
+ * - the auto-merge/watch loops attach per AI iteration, but their stop-for-human-review exits
11
+ * (billing_limit, ci_cancelled_requires_review, external_review_limit, limit reached) can
12
+ * return before any iteration ran — attaching nothing.
13
+ * Without a final safety net such a session ends with no logs at all, exactly as reported.
14
+ *
15
+ * `attachLogToGitHub` records `global.logAttachedToGitHub` on every successful upload anywhere in
16
+ * the process, so this helper only attaches when nothing else did.
17
+ *
18
+ * @see https://github.com/link-assistant/hive-mind/issues/1952
19
+ */
20
+
21
+ /**
22
+ * Attach the final session log if `--attach-logs` is enabled and nothing has attached a log yet.
23
+ *
24
+ * @param {Object} params
25
+ * @param {boolean} params.shouldAttachLogs - Whether `--attach-logs` is enabled.
26
+ * @param {string|number|null} params.prNumber - Target PR number (no PR ⇒ nothing to attach to).
27
+ * @param {string} params.owner
28
+ * @param {string} params.repo
29
+ * @param {Function} params.$ - command-stream tagged executor.
30
+ * @param {Function} params.log
31
+ * @param {Function} params.sanitizeLogContent
32
+ * @param {Function} params.getLogFile - Returns the path to the cumulative session log.
33
+ * @param {Function} params.attachLogToGitHub
34
+ * @param {Object} params.argv
35
+ * @param {string|null} [params.sessionId]
36
+ * @param {string|null} [params.tempDir]
37
+ * @param {number|null} [params.anthropicTotalCostUSD]
38
+ * @param {Object|null} [params.resultModelUsage]
39
+ * @param {Object} [params.globalState] - Defaults to the process `global`; injectable for tests.
40
+ * @returns {Promise<boolean>} `true` if a log has been attached (by this helper or earlier).
41
+ */
42
+ export const attachFinalLogIfMissing = async ({ shouldAttachLogs, prNumber, owner, repo, $, log, sanitizeLogContent, getLogFile, attachLogToGitHub, argv, sessionId = null, tempDir = null, anthropicTotalCostUSD = null, resultModelUsage = null, globalState = global }) => {
43
+ // Only fire as a last resort: --attach-logs enabled, a PR to attach to, and nothing attached yet.
44
+ if (!shouldAttachLogs || !prNumber || globalState.logAttachedToGitHub) {
45
+ return globalState.logAttachedToGitHub === true;
46
+ }
47
+
48
+ await log('');
49
+ await log('šŸ“Ž No session log was attached yet — attaching final log (--attach-logs safety net)...');
50
+ try {
51
+ const logUploadSuccess = await attachLogToGitHub({
52
+ logFile: getLogFile(),
53
+ targetType: 'pr',
54
+ targetNumber: prNumber,
55
+ owner,
56
+ repo,
57
+ $,
58
+ log,
59
+ sanitizeLogContent,
60
+ verbose: argv?.verbose,
61
+ sessionId,
62
+ tempDir,
63
+ anthropicTotalCostUSD,
64
+ argv,
65
+ requestedModel: argv?.originalModel || argv?.model,
66
+ tool: argv?.tool || 'claude',
67
+ resultModelUsage,
68
+ });
69
+ if (logUploadSuccess) {
70
+ await log('āœ… Final working session log attached');
71
+ } else {
72
+ await log('āš ļø Final log attachment did not succeed (see messages above)', { level: 'warning' });
73
+ }
74
+ } catch (uploadError) {
75
+ await log(`āš ļø Error attaching final log: ${uploadError.message}`, { level: 'warning' });
76
+ }
77
+
78
+ return globalState.logAttachedToGitHub === true;
79
+ };
@@ -15,10 +15,81 @@ import { CANCELLED_CI_REVIEW_MARKER } from './tool-comments.lib.mjs';
15
15
 
16
16
  const CANCELLED_OR_STALE_CONCLUSIONS = new Set(['cancelled', 'stale']);
17
17
 
18
- export { CANCELLED_CI_REVIEW_MARKER };
18
+ /**
19
+ * Issue #1952: Workflow-run conclusions that represent a genuine CI failure rather than a
20
+ * re-triggerable cancellation.
21
+ *
22
+ * GitHub surfaces a job that hit its `timeout-minutes` limit as a check-run with
23
+ * `conclusion: 'cancelled'`, but the parent workflow_run concludes `'failure'` (a step that
24
+ * exceeds `timeout-minutes` likewise produces `'failure'`; an infrastructure/setup error
25
+ * produces `'startup_failure'`; the legacy max-execution timeout produces `'timed_out'`).
26
+ *
27
+ * `getDetailedCIStatus` only inspects check-runs, so it cannot tell a timeout failure
28
+ * (check-run cancelled + workflow_run failed) apart from a deliberate manual/concurrency
29
+ * cancellation (check-run cancelled + workflow_run cancelled). Cross-referencing the
30
+ * workflow-run conclusion lets the caller treat a timeout/failure as a CI failure (which the
31
+ * AI should fix / auto-restart) instead of a re-triggerable cancellation that stops for human
32
+ * review.
33
+ *
34
+ * @see https://github.com/link-assistant/hive-mind/issues/1952
35
+ */
36
+ const FAILURE_LIKE_WORKFLOW_RUN_CONCLUSIONS = new Set(['failure', 'timed_out', 'startup_failure']);
37
+
38
+ export { CANCELLED_CI_REVIEW_MARKER, FAILURE_LIKE_WORKFLOW_RUN_CONCLUSIONS };
19
39
 
20
40
  export const getRetriggerableWorkflowRuns = (runs = []) => runs.filter(run => CANCELLED_OR_STALE_CONCLUSIONS.has(run?.conclusion));
21
41
 
42
+ /**
43
+ * Issue #1952: Workflow runs that have not yet reached a terminal state.
44
+ *
45
+ * The issue requires waiting "until all checks are success, fail or cancelled, to auto
46
+ * restart". Even when every check-run already looks cancelled, a workflow run may still be
47
+ * `queued`/`in_progress` (e.g. a retrying matrix leg), so the auto-merge loop must keep
48
+ * waiting rather than prematurely classifying the result.
49
+ *
50
+ * @param {Array<{status?: string}>} runs - Workflow runs for the commit SHA.
51
+ * @returns {Array} Runs whose `status` is not `'completed'`.
52
+ */
53
+ export const getIncompleteWorkflowRuns = (runs = []) => runs.filter(run => run?.status && run.status !== 'completed');
54
+
55
+ /**
56
+ * Issue #1952: Completed workflow runs whose conclusion represents a genuine failure
57
+ * (failure / timed_out / startup_failure), including the timeout-cancellation case.
58
+ *
59
+ * @param {Array<{status?: string, conclusion?: string}>} runs - Workflow runs for the commit SHA.
60
+ * @returns {Array} Completed runs with a failure-like conclusion.
61
+ */
62
+ export const getFailedWorkflowRuns = (runs = []) => runs.filter(run => run?.status === 'completed' && FAILURE_LIKE_WORKFLOW_RUN_CONCLUSIONS.has(run?.conclusion));
63
+
64
+ /**
65
+ * Issue #1952: Decide how a "cancelled" CI status (per check-runs) should be reclassified
66
+ * after cross-referencing the workflow runs for the same commit SHA.
67
+ *
68
+ * - `pending` → at least one workflow run is still queued/in progress; keep waiting so we only
69
+ * act once every check has reached a terminal state.
70
+ * - `failure` → at least one completed workflow run failed/timed out/failed to start; the
71
+ * cancellation reflects a real failure (e.g. a job hit `timeout-minutes`) and must
72
+ * be treated as a CI failure, not a re-triggerable cancellation.
73
+ * - `cancelled`→ no failures and nothing pending; this is a genuine re-triggerable cancellation
74
+ * (manual cancel, concurrency cancel, stale) that the existing rerun flow handles.
75
+ *
76
+ * @param {{runs?: Array}} params
77
+ * @returns {{classification: 'pending'|'failure'|'cancelled', incompleteRuns: Array, failedRuns: Array}}
78
+ */
79
+ export const classifyCancelledCIByWorkflowRuns = ({ runs = [] } = {}) => {
80
+ const incompleteRuns = getIncompleteWorkflowRuns(runs);
81
+ if (incompleteRuns.length > 0) {
82
+ return { classification: 'pending', incompleteRuns, failedRuns: [] };
83
+ }
84
+
85
+ const failedRuns = getFailedWorkflowRuns(runs);
86
+ if (failedRuns.length > 0) {
87
+ return { classification: 'failure', incompleteRuns, failedRuns };
88
+ }
89
+
90
+ return { classification: 'cancelled', incompleteRuns, failedRuns };
91
+ };
92
+
22
93
  export const shouldStopForCancelledCIReview = ({ retriggerableRuns = [], rerunTriggered = false, rerunFailures = [] }) => {
23
94
  if (rerunTriggered) {
24
95
  return false;
@@ -111,17 +111,27 @@ export const displayModelUsage = async (usage, log) => {
111
111
  await log('');
112
112
  await log(' Cost Calculation (USD):');
113
113
  const breakdown = usage.costBreakdown;
114
- const types = [
115
- { key: 'input', label: 'Input' },
116
- { key: 'cacheWrite', label: 'Cache write' },
117
- { key: 'cacheRead', label: 'Cache read' },
118
- { key: 'output', label: 'Output' },
119
- ];
120
- for (const { key, label } of types) {
121
- if (breakdown[key].tokens > 0) {
122
- await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens Ɨ $${breakdown[key].costPerMillion}/M = $${new Decimal(breakdown[key].cost).toFixed(6)}`);
114
+ if (breakdown.input.tokens > 0) {
115
+ await log(` Input: ${formatNumber(breakdown.input.tokens)} tokens Ɨ $${breakdown.input.costPerMillion}/M = $${new Decimal(breakdown.input.cost).toFixed(6)}`);
116
+ }
117
+ if (breakdown.cacheWrite?.tokens > 0) {
118
+ if (breakdown.cacheWrite.hasExplicitTtlSplit) {
119
+ if (breakdown.cacheWrite5m.tokens > 0) {
120
+ await log(` Cache write (5m): ${formatNumber(breakdown.cacheWrite5m.tokens)} tokens Ɨ $${breakdown.cacheWrite5m.costPerMillion}/M = $${new Decimal(breakdown.cacheWrite5m.cost).toFixed(6)}`);
121
+ }
122
+ if (breakdown.cacheWrite1h.tokens > 0) {
123
+ await log(` Cache write (1h): ${formatNumber(breakdown.cacheWrite1h.tokens)} tokens Ɨ $${breakdown.cacheWrite1h.costPerMillion}/M = $${new Decimal(breakdown.cacheWrite1h.cost).toFixed(6)}`);
124
+ }
125
+ } else {
126
+ await log(` Cache write: ${formatNumber(breakdown.cacheWrite.tokens)} tokens Ɨ $${breakdown.cacheWrite.costPerMillion}/M = $${new Decimal(breakdown.cacheWrite.cost).toFixed(6)}`);
123
127
  }
124
128
  }
129
+ if (breakdown.cacheRead.tokens > 0) {
130
+ await log(` Cache read: ${formatNumber(breakdown.cacheRead.tokens)} tokens Ɨ $${breakdown.cacheRead.costPerMillion}/M = $${new Decimal(breakdown.cacheRead.cost).toFixed(6)}`);
131
+ }
132
+ if (breakdown.output.tokens > 0) {
133
+ await log(` Output: ${formatNumber(breakdown.output.tokens)} tokens Ɨ $${breakdown.output.costPerMillion}/M = $${new Decimal(breakdown.output.cost).toFixed(6)}`);
134
+ }
125
135
  // Issue #1710: itemise server-tool charges so the residual that puzzled
126
136
  // readers in PR #1707 ($0.04 web_search) is visible in the breakdown.
127
137
  if (breakdown.webSearch && breakdown.webSearch.requests > 0) {
@@ -8,12 +8,22 @@
8
8
  import Decimal from 'decimal.js-light';
9
9
  import { SERVER_TOOL_PRICING_USD } from './anthropic-server-tool-pricing.lib.mjs';
10
10
 
11
+ const getCacheWrite5mPrice = cost => cost.cache_write_5m ?? cost.cache_write ?? 0;
12
+
13
+ const getCacheWrite1hPrice = (cost, cacheWrite5mPrice) => {
14
+ if (cost.cache_write_1h !== undefined && cost.cache_write_1h !== null) return cost.cache_write_1h;
15
+ if (cost.input) return new Decimal(cost.input).mul(2).toNumber();
16
+ if (cacheWrite5mPrice) return new Decimal(cacheWrite5mPrice).mul(1.6).toNumber();
17
+ return 0;
18
+ };
19
+
11
20
  /**
12
21
  * Calculate USD cost for a model's usage with optional detailed breakdown.
13
22
  *
14
23
  * Cost components (Issue #1600 uses Decimal for precision):
15
24
  * - input Ɨ cost.input / 1M
16
- * - cacheWrite Ɨ cost.cache_write / 1M
25
+ * - cacheWrite5m Ɨ cost.cache_write / 1M
26
+ * - cacheWrite1h Ɨ (cost.cache_write_1h || cost.input Ɨ 2) / 1M
17
27
  * - cacheRead Ɨ cost.cache_read / 1M
18
28
  * - output Ɨ cost.output / 1M
19
29
  * - webSearch Ɨ $0.01 / request (Issue #1710 — see SERVER_TOOL_PRICING_USD)
@@ -32,6 +42,8 @@ export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) =
32
42
  const breakdown = {
33
43
  input: { tokens: 0, costPerMillion: 0, cost: 0 },
34
44
  cacheWrite: { tokens: 0, costPerMillion: 0, cost: 0 },
45
+ cacheWrite5m: { tokens: 0, costPerMillion: 0, cost: 0 },
46
+ cacheWrite1h: { tokens: 0, costPerMillion: 0, cost: 0 },
35
47
  cacheRead: { tokens: 0, costPerMillion: 0, cost: 0 },
36
48
  output: { tokens: 0, costPerMillion: 0, cost: 0 },
37
49
  // Issue #1710: server-side tool usage (web_search) is billed per-request,
@@ -47,11 +59,35 @@ export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) =
47
59
  cost: new Decimal(usage.inputTokens).div(million).mul(new Decimal(cost.input)).toNumber(),
48
60
  };
49
61
  }
50
- if (usage.cacheCreationTokens && cost.cache_write) {
62
+ const explicitCacheWrite5mTokens = usage.cacheCreation5mTokens || 0;
63
+ const explicitCacheWrite1hTokens = usage.cacheCreation1hTokens || 0;
64
+ const explicitCacheWriteTokens = explicitCacheWrite5mTokens + explicitCacheWrite1hTokens;
65
+ const cacheWriteTokens = Math.max(usage.cacheCreationTokens || 0, explicitCacheWriteTokens);
66
+ const hasCacheWriteTtlSplit = explicitCacheWriteTokens > 0;
67
+ const unsplitCacheWriteTokens = hasCacheWriteTtlSplit ? Math.max(0, cacheWriteTokens - explicitCacheWriteTokens) : cacheWriteTokens;
68
+ const cacheWrite5mTokens = hasCacheWriteTtlSplit ? explicitCacheWrite5mTokens + unsplitCacheWriteTokens : cacheWriteTokens;
69
+ const cacheWrite1hTokens = hasCacheWriteTtlSplit ? explicitCacheWrite1hTokens : 0;
70
+ const cacheWrite5mPrice = getCacheWrite5mPrice(cost);
71
+ const cacheWrite1hPrice = getCacheWrite1hPrice(cost, cacheWrite5mPrice);
72
+ if (cacheWriteTokens && (cacheWrite5mPrice || cacheWrite1hPrice)) {
73
+ const cacheWrite5mCost = new Decimal(cacheWrite5mTokens).div(million).mul(new Decimal(cacheWrite5mPrice)).toNumber();
74
+ const cacheWrite1hCost = new Decimal(cacheWrite1hTokens).div(million).mul(new Decimal(cacheWrite1hPrice)).toNumber();
75
+ const cacheWriteCost = new Decimal(cacheWrite5mCost).plus(new Decimal(cacheWrite1hCost)).toNumber();
51
76
  breakdown.cacheWrite = {
52
- tokens: usage.cacheCreationTokens,
53
- costPerMillion: cost.cache_write,
54
- cost: new Decimal(usage.cacheCreationTokens).div(million).mul(new Decimal(cost.cache_write)).toNumber(),
77
+ tokens: cacheWriteTokens,
78
+ costPerMillion: hasCacheWriteTtlSplit ? new Decimal(cacheWriteCost).div(new Decimal(cacheWriteTokens)).mul(million).toNumber() : cacheWrite5mPrice,
79
+ cost: cacheWriteCost,
80
+ hasExplicitTtlSplit: hasCacheWriteTtlSplit,
81
+ };
82
+ breakdown.cacheWrite5m = {
83
+ tokens: cacheWrite5mTokens,
84
+ costPerMillion: cacheWrite5mPrice,
85
+ cost: cacheWrite5mCost,
86
+ };
87
+ breakdown.cacheWrite1h = {
88
+ tokens: cacheWrite1hTokens,
89
+ costPerMillion: cacheWrite1hPrice,
90
+ cost: cacheWrite1hCost,
55
91
  };
56
92
  }
57
93
  if (usage.cacheReadTokens && cost.cache_read) {
@@ -222,7 +222,12 @@ export async function checkBranchCIHealth(owner, repo, branch = 'main', options,
222
222
  }
223
223
 
224
224
  // All runs for the latest commit are completed — check for failures
225
- const failedRuns = runs.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
225
+ // Issue #1952: Treat `startup_failure` as a failure too (a workflow that failed to start is a
226
+ // genuine failure, not a transient state). `cancelled` is intentionally NOT treated as a
227
+ // failure here: this branch-health check resolves the HEAD SHA up front (issue #1425), and a
228
+ // cancelled run on the resolved HEAD is normally a superseded/manual cancellation rather than a
229
+ // timeout — a timeout surfaces as `failure`/`timed_out` at the workflow-run level and is caught.
230
+ const failedRuns = runs.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out' || r.conclusion === 'startup_failure');
226
231
 
227
232
  if (failedRuns.length > 0) {
228
233
  if (verbose) {
@@ -790,6 +790,9 @@ ${sessionNote}
790
790
  await log(` ${status.emoji} ${status.label} uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} ${uploadTypeLabel}${chunkInfo}${posted.commentId ? ` (comment id=${posted.commentId})` : ''}`);
791
791
  await log(` šŸ”— Log URL: ${logUrl}`);
792
792
  await log(` šŸ“Š Log size: ${Math.round(logStats.size / 1024)}KB`);
793
+ // Issue #1952: Record that a session log was attached anywhere in this process so the
794
+ // top-level --attach-logs safety net can guarantee no session finishes with no logs.
795
+ global.logAttachedToGitHub = true;
793
796
  return true;
794
797
  } else {
795
798
  await log(` āŒ Failed to post comment with log link: ${posted.stderr || 'unknown error'}`);
@@ -813,7 +816,12 @@ ${sessionNote}
813
816
  }
814
817
  } else {
815
818
  // Comment fits within limit
816
- return await attachRegularComment(options, logComment);
819
+ const regularOk = await attachRegularComment(options, logComment);
820
+ // Issue #1952: see note above — mark a successful attach for the --attach-logs safety net.
821
+ if (regularOk) {
822
+ global.logAttachedToGitHub = true;
823
+ }
824
+ return regularOk;
817
825
  }
818
826
  } catch (uploadError) {
819
827
  // Issue #1212: ENOSPC-specific actionable guidance
@@ -194,6 +194,32 @@ export function getTrackedSessionInfo(sessionName) {
194
194
  return activeSessions.get(sessionName) || null;
195
195
  }
196
196
 
197
+ /**
198
+ * Stop tracking a session that was registered optimistically but never actually
199
+ * started (e.g. the start-command launch failed). Removes it from the in-memory
200
+ * map and the durable store without emitting a `session_completed` audit event —
201
+ * the session never ran, so it has no exit code to record (issue #1946).
202
+ *
203
+ * @param {string} sessionName - Name/UUID of the session to drop
204
+ * @param {boolean} verbose - Whether to log verbose output
205
+ */
206
+ export function untrackSession(sessionName, verbose = false) {
207
+ if (!sessionName) return;
208
+ const sessionInfo = activeSessions.get(sessionName) || null;
209
+ const existed = activeSessions.delete(sessionName);
210
+ if (verbose && existed) {
211
+ console.log(`[VERBOSE] Session ${sessionName} untracked (launch failed before it started)`);
212
+ }
213
+ if (sessionStore && isPersistableSession(sessionInfo)) {
214
+ try {
215
+ sessionStore.remove(sessionName, { status: 'launch-failed', exitCode: null });
216
+ } catch (error) {
217
+ console.error(`[session-monitor] Could not remove untracked session ${sessionName}: ${error.message}`);
218
+ }
219
+ }
220
+ logEvent('session_untracked', { sessionName });
221
+ }
222
+
197
223
  /**
198
224
  * Get the number of active sessions being tracked
199
225
  * @param {boolean} verbose - Whether to log verbose output
@@ -37,6 +37,12 @@ const { reportError } = sentryLib;
37
37
  const githubMergeLib = await import('./github-merge.lib.mjs');
38
38
  const { checkPRMergeable, checkForBillingLimitError, getDetailedCIStatus, getWorkflowRunsForSha, getWorkflowRunJobsCount, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI, getActivePRWorkflowRuns } = githubMergeLib;
39
39
 
40
+ // Issue #1952: Cross-reference cancelled check-runs against workflow-run conclusions so a
41
+ // job that hit `timeout-minutes` (check-run cancelled + workflow_run failed) is treated as a
42
+ // CI failure rather than a re-triggerable cancellation that stops for human review.
43
+ const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
44
+ const { classifyCancelledCIByWorkflowRuns } = cancelledCiRerunLib;
45
+
40
46
  /**
41
47
  * Issue #1712: Plain-English meaning of GitHub Actions / check-run statuses, so the
42
48
  * verbose log explains itself instead of forcing the user to look up GitHub docs.
@@ -816,19 +822,60 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
816
822
  billingMessage: billingCheck.message,
817
823
  });
818
824
  } else {
819
- // These need to be re-triggered, NOT treated as AI-fixable failures
825
+ // Issue #1952: A check-run with conclusion 'cancelled' does NOT always mean a
826
+ // re-triggerable cancellation. When a job hits its `timeout-minutes` limit GitHub
827
+ // cancels the job (check-run conclusion 'cancelled') but the parent workflow_run
828
+ // concludes 'failure'/'timed_out'/'startup_failure'. getDetailedCIStatus only sees the
829
+ // check-runs, so it reports status='cancelled' and we used to post a "Cancelled CI/CD
830
+ // Requires Review" comment and stop. Cross-reference the workflow runs for this SHA to
831
+ // tell the two cases apart:
832
+ // - any run still queued/in_progress → wait (ci_pending) until every check is terminal
833
+ // - any completed run failed/timed out → treat as a CI failure (auto-restart the AI)
834
+ // - otherwise → genuine re-triggerable cancellation (ci_cancelled)
820
835
  const cancelledOrStaleChecks = [...ciStatus.cancelledChecks, ...(ciStatus.staleChecks || [])];
821
836
  const cancelledDetails = cancelledOrStaleChecks.map(c => {
822
837
  const concPart = c.conclusion ? ` [${c.conclusion}]` : '';
823
838
  const urlPart = c.html_url ? ` — ${c.html_url}` : '';
824
839
  return `${c.name}${concPart}${urlPart}`;
825
840
  });
826
- blockers.push({
827
- type: 'ci_cancelled',
828
- message: 'CI/CD checks were cancelled or became stale',
829
- details: cancelledDetails,
830
- sha: ciStatus.sha,
831
- });
841
+
842
+ const cancelledWorkflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
843
+ const { classification, incompleteRuns, failedRuns } = classifyCancelledCIByWorkflowRuns({ runs: cancelledWorkflowRuns });
844
+
845
+ if (classification === 'pending') {
846
+ // Some checks already show cancelled, but a workflow run is still running — the issue
847
+ // requires waiting until ALL checks reach a terminal state before auto-restarting.
848
+ if (verbose) {
849
+ await log(`[VERBOSE] /merge: PR #${prNumber} has cancelled check-run(s) but ${incompleteRuns.length} workflow run(s) still in progress — waiting for all to reach a terminal state before classifying`);
850
+ }
851
+ blockers.push({
852
+ type: 'ci_pending',
853
+ message: `Some CI/CD checks are cancelled, but ${incompleteRuns.length} workflow run(s) have not reached a terminal state yet — waiting before deciding`,
854
+ details: incompleteRuns.map(formatRunLine),
855
+ });
856
+ } else if (classification === 'failure') {
857
+ // The cancellation reflects a real failure (e.g. a job hit `timeout-minutes`). Per
858
+ // issue #1952 this must be treated as a CI failure so the AI is restarted to fix it,
859
+ // instead of posting a "requires human review" comment for a re-trigger that would
860
+ // never make progress.
861
+ if (verbose) {
862
+ await log(`[VERBOSE] /merge: PR #${prNumber} cancelled check-run(s) belong to ${failedRuns.length} failed workflow run(s) (conclusions: ${[...new Set(failedRuns.map(r => r.conclusion))].join(', ')}) — treating cancellation as a CI failure`);
863
+ }
864
+ await log(formatAligned('āŒ', 'CI cancelled by failure/timeout:', `${failedRuns.map(r => `${r.name} (${r.conclusion})`).join(', ')}`, 2));
865
+ blockers.push({
866
+ type: 'ci_failure',
867
+ message: 'CI/CD checks were cancelled by a workflow failure or timeout (treated as a failure)',
868
+ details: [...cancelledDetails, ...failedRuns.map(r => `${r.path || r.name} (${r.conclusion}) — see ${r.html_url}`)],
869
+ });
870
+ } else {
871
+ // Genuine re-triggerable cancellation (manual cancel, concurrency cancel, stale).
872
+ blockers.push({
873
+ type: 'ci_cancelled',
874
+ message: 'CI/CD checks were cancelled or became stale',
875
+ details: cancelledDetails,
876
+ sha: ciStatus.sha,
877
+ });
878
+ }
832
879
  }
833
880
  } else if (ciStatus.status === 'failure') {
834
881
  // Some checks genuinely failed - check if it's billing limits first
@@ -489,7 +489,13 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
489
489
  // Cancelled checks (e.g., manually cancelled, cancelled by another workflow) should be
490
490
  // re-triggered automatically. We should NOT restart the AI for these.
491
491
  const cancelledBlocker = blockers.find(b => b.type === 'ci_cancelled');
492
- if (cancelledBlocker && !billingBlocker) {
492
+ // Issue #1952: When a genuine CI failure coexists with cancelled checks, the result is a
493
+ // failure ("if we still have other fails in the CI/CD checks - it is fail"). Defer to the
494
+ // ci_failure path (which restarts the AI) instead of attempting a re-trigger and then
495
+ // stopping for human review — the latter posted a misleading "Cancelled CI/CD Requires
496
+ // Review" comment even though real failures needed fixing.
497
+ const ciFailureBlocker = blockers.find(b => b.type === 'ci_failure');
498
+ if (cancelledBlocker && !billingBlocker && !ciFailureBlocker) {
493
499
  await log('');
494
500
  await log(formatAligned('šŸ”„', 'CANCELLED CI/CD CHECKS DETECTED', ''));
495
501
  await log(formatAligned('', 'Cancelled checks:', (cancelledBlocker.details || []).join(', '), 2));
@@ -570,7 +576,9 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
570
576
  // Reason 2: CI failures (only if NOT a billing limit issue and NOT just cancelled)
571
577
  // Only restart AI when we have genuine code failures (real feedback to act on)
572
578
  const externalReviewLimitBlocker = blockers.find(b => b.type === 'external_review_limit');
573
- const ciBlocker = blockers.find(b => b.type === 'ci_failure');
579
+ // Issue #1952: Reuse the ci_failure blocker resolved above so cancelled+failure mixes
580
+ // take the restart path rather than the cancelled-review path.
581
+ const ciBlocker = ciFailureBlocker;
574
582
  const hasMergeConflictBlocker = blockers.some(b => b.type === 'not_mergeable' && b.message?.includes('conflicts'));
575
583
  if (externalReviewLimitBlocker && !ciBlocker && !billingBlocker && !cancelledBlocker && !hasNewComments && !hasUncommittedChanges && !hasMergeConflictBlocker) {
576
584
  await log('');
package/src/solve.mjs CHANGED
@@ -58,6 +58,7 @@ const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('
58
58
  const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.disk-diagnostics.lib.mjs');
59
59
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
60
60
  const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
61
+ const { attachFinalLogIfMissing } = await import('./attach-logs-guarantee.lib.mjs'); // Issue #1952
61
62
  // Issue #1625: centralized markers + tracked comment posting for solve.mjs's
62
63
  // own usage-limit notifications (so they're excluded from the
63
64
  // "did the AI post anything?" check in --auto-attach-solution-summary).
@@ -1430,13 +1431,11 @@ try {
1430
1431
  }
1431
1432
  }
1432
1433
  }
1433
-
1434
- // If auto-merge succeeded, update logs attached status
1435
- if (autoMergeResult && autoMergeResult.success) {
1436
- logsAttached = true;
1437
- }
1438
1434
  }
1439
1435
 
1436
+ // Issue #1952: Final --attach-logs safety net + logsAttached reconciliation. See attach-logs-guarantee.lib.mjs.
1437
+ logsAttached = (await attachFinalLogIfMissing({ shouldAttachLogs, prNumber, owner, repo, $, log, sanitizeLogContent, getLogFile, attachLogToGitHub, argv, sessionId, tempDir, anthropicTotalCostUSD, resultModelUsage })) || logsAttached;
1438
+
1440
1439
  // Issue #1516: Cleanup after all signals (was before verifyResults, caused premature commits)
1441
1440
  await cleanupClaudeFile(tempDir, branchName, claudeCommitHash, argv);
1442
1441
 
@@ -328,7 +328,7 @@ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized
328
328
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
329
329
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
330
330
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
331
- const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
331
+ const { trackSession, untrackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
332
332
  const { createBotLogger } = await import('./bot-logger.lib.mjs');
333
333
  const { createSessionStore } = await import('./session-store.lib.mjs');
334
334
  const { createHeartbeat, resumeSessionsOnLaunch, createShutdownHandler } = await import('./bot-lifecycle.lib.mjs');
@@ -524,7 +524,7 @@ async function validateGitHubUrl(args, options = {}) {
524
524
  return { valid: true, parsed, normalizedUrl: url };
525
525
  }
526
526
 
527
- const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage });
527
+ const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage });
528
528
 
529
529
  bot.command('help', async ctx => {
530
530
  VERBOSE && console.log('[VERBOSE] /help command received');
@@ -98,7 +98,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
98
98
  * @returns {Function} executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation, tool, urlContext, sessionExtras)
99
99
  */
100
100
  export function buildExecuteAndUpdateMessage(deps) {
101
- const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage } = deps;
101
+ const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
102
102
  return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null } = {}) {
103
103
  const { chat, message_id: msgId } = startingMessage;
104
104
  const safeEdit = async text => {
@@ -115,12 +115,23 @@ export function buildExecuteAndUpdateMessage(deps) {
115
115
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
116
116
  let result, session, sessionInfo;
117
117
  if (iso) {
118
+ // Issue #1946: the isolation session UUID is generated locally, *before*
119
+ // start-command launches the (potentially multi-GB, slow) container. Show
120
+ // the UUID + isolation backend and track the session immediately so it is
121
+ // addressable by /watch, /log and /status during the whole startup window
122
+ // instead of only after the blocking launch returns. start-command runs the
123
+ // container detached, so the await below does not block other bot commands.
118
124
  session = iso.runner.generateSessionId();
119
125
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
126
+ sessionInfo = { ...baseSessionInfo, isolationBackend: iso.backend, sessionId: session };
127
+ trackSession(session, sessionInfo, VERBOSE);
128
+ await safeEdit(formatStartingWorkSessionMessage({ sessionName: session, isolationBackend: iso.backend, infoBlock, locale }));
120
129
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
121
- if (result.success) {
122
- sessionInfo = { ...baseSessionInfo, isolationBackend: iso.backend, sessionId: session };
123
- trackSession(session, sessionInfo, VERBOSE);
130
+ if (!result.success) {
131
+ // The launch never produced a live container — drop the optimistic
132
+ // tracking so a phantom session is not monitored or resumed.
133
+ if (typeof untrackSession === 'function') untrackSession(session, VERBOSE);
134
+ sessionInfo = undefined;
124
135
  }
125
136
  } else {
126
137
  result = await executeStartScreen(commandName, args);
@@ -65,9 +65,19 @@ export function formatSessionDurationSeconds(seconds) {
65
65
  return parts.join(' ');
66
66
  }
67
67
 
68
- export function formatStartingWorkSessionMessage({ infoBlock = '', locale = null } = {}) {
68
+ export function formatStartingWorkSessionMessage({ sessionName = null, isolationBackend = null, infoBlock = '', locale = null } = {}) {
69
+ const header = text(locale, 'telegram.work_session_starting', 'šŸ”„ Starting...');
69
70
  const details = infoBlock ? `\n\n${infoBlock}` : '';
70
- return `${text(locale, 'telegram.work_session_starting', 'šŸ”„ Starting...')}${details}`;
71
+ // Issue #1946: for isolation backends the session UUID is known *before* the
72
+ // (potentially long, multi-GB) container/image preparation finishes, so
73
+ // surface it together with the isolation backend right away. This makes the
74
+ // session addressable by /watch, /log and /status while it is still starting,
75
+ // instead of leaving an info-less "Starting..." up for the whole image pull.
76
+ if (!sessionName) return `${header}${details}`;
77
+ const sessionLabel = text(locale, 'telegram.session_label', 'Session');
78
+ const isolationLabel = text(locale, 'telegram.isolation_label', 'Isolation');
79
+ const isolationInfo = isolationBackend ? `\nšŸ”’ ${isolationLabel}: \`${isolationBackend}\`` : '';
80
+ return `${header}\n\nšŸ“Š ${sessionLabel}: \`${sessionName}\`${isolationInfo}${details}`;
71
81
  }
72
82
 
73
83
  export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', isolationBackend = null, infoBlock = '', locale = null } = {}) {