@link-assistant/hive-mind 2.0.9 → 2.0.11

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,99 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.11
4
+
5
+ ### Patch Changes
6
+
7
+ - a29902a: fix(codex): don't fail a completed turn on echoed fixture content; expand transient network auto-retry (#1955)
8
+
9
+ A `--tool codex` run failed with `❌ Codex emitted error event: Network lookup
10
+ skipped in fixture` even though the codex session **succeeded** (`turn.completed=1`,
11
+ `turn.failed=0`, working tree clean, full pricing produced). The phrase was not a
12
+ real error: while building an unrelated NDJSON adapter, the codex agent printed a
13
+ **test fixture** to its terminal. In verbose mode (`RUST_LOG=debug`) the codex CLI
14
+ renders OTEL telemetry (`codex_otel.log_only`, `event.name="codex.tool_result"`)
15
+ to stderr, including a raw `Output:` dump of each command's stdout. Our line-by-line
16
+ parser — which consumes stderr as well as stdout — `JSON.parse`d the fixture line
17
+ `{"type":"error","message":"Network lookup skipped in fixture"}` and bucketed it as
18
+ a genuine codex stream error.
19
+ - `getCodexErrorEventSummary()` (`src/codex.lib.mjs`) now treats any stray
20
+ **non-`turn`** error event as non-fatal whenever the turn completed successfully
21
+ (a `turn.completed` with no `turn.failed`). `turn.failed` remains the authoritative
22
+ failure signal and is never suppressed; suppressed strays are still recorded in
23
+ `ignoredEvents` (and logged per-event in verbose mode) for observability. This is
24
+ transport-agnostic — it fixes the false positive regardless of how the echo
25
+ arrived.
26
+ - `classifyRetryableError()` (`src/tool-retry.lib.mjs`, shared by
27
+ claude/codex/gemini/qwen/opencode) now classifies the full set of genuinely
28
+ transient network faults as retryable (`isCapacity:false`): DNS failures
29
+ (`ENOTFOUND`, `EAI_AGAIN`, "temporary failure in name resolution"), connection
30
+ faults (`ETIMEDOUT`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `EPIPE`,
31
+ "no route to host", "network is unreachable"), and gateway errors (502/504 and
32
+ Cloudflare `52x`); the 503 branch was broadened to "service unavailable". Aligns
33
+ with AWS retry guidance, RFC 9110 §15.6, and the getaddrinfo(3) man-page. The
34
+ fixture phrase itself is explicitly guarded to stay non-retryable.
35
+
36
+ Adds `tests/test-issue-1955-codex-fixture-false-positive.mjs` (23 tests) and a deep
37
+ case study in `docs/case-studies/issue-1955/`.
38
+
39
+ ## 2.0.10
40
+
41
+ ### Patch Changes
42
+
43
+ - e29c83e: Surface the docker-isolation session id + isolation backend immediately when the
44
+ Telegram bot launches a task, instead of only after the (potentially hour-long)
45
+ image pull / container startup finishes (#1946). `formatStartingWorkSessionMessage`
46
+ now renders the `Session:` and `🔒 Isolation:` lines on the `🔄 Starting...`
47
+ message, and `buildExecuteAndUpdateMessage` tracks the session up front (before
48
+ awaiting the launch) so the run is addressable by `/watch`, `/log` and `/status`
49
+ during the whole startup window. A new `untrackSession` helper removes the
50
+ optimistically-tracked session if the launch fails, so a phantom session is never
51
+ monitored or resumed. Fix applies to every caller of the shared execution path
52
+ (`/solve`, `/hive`, `/task`).
53
+
54
+ The image-preparation log gap and host-image re-download were reported upstream,
55
+ fixed there, and are now pinned in this repo's images: `Dockerfile` /
56
+ `Dockerfile.dind` bump `start-command` `0.29.1` → `0.29.2` (link-foundation/start#138
57
+ — the `docker pull`/dind-boot phase is now recorded in the `$` session log), and
58
+ `Dockerfile.dind` bumps its base from `konard/box-dind:2.3.2` → `2.3.5`
59
+ (link-foundation/box#106 — the dind entrypoint now verifies host-image passthrough
60
+ actually seeded the nested daemon instead of silently re-downloading ~30 GB). A
61
+ deep case study is in `docs/case-studies/issue-1946/`.
62
+
63
+ - 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)
64
+
65
+ A job that hits its `timeout-minutes` limit surfaces as a **check-run** with
66
+ conclusion `cancelled`, but the **parent workflow run** concludes `failure`.
67
+ `getDetailedCIStatus` only inspects check-runs, so the auto-merge loop saw the
68
+ lone `cancelled` check, posted a **"Cancelled CI/CD Requires Review"** comment and
69
+ stopped — even though the workflow run had failed and other jobs in it had failed
70
+ too. The cancelled branch of `getMergeBlockers` now cross-references the workflow
71
+ runs for the commit SHA via a new pure helper
72
+ `classifyCancelledCIByWorkflowRuns` (`src/cancelled-ci-rerun.lib.mjs`):
73
+ - a run still queued/in-progress → `ci_pending` (wait until **all** checks reach a
74
+ terminal state before auto-restarting);
75
+ - any completed `failure`/`timed_out`/`startup_failure` run → `ci_failure` (the AI
76
+ is restarted to fix it, instead of stopping for human review);
77
+ - otherwise → the original re-triggerable `ci_cancelled` flow (genuine manual
78
+ cancellation). The "requires review" stop path is skipped whenever a `ci_failure`
79
+ blocker coexists, and `startup_failure` is now counted as a failing run in the
80
+ branch-health check too.
81
+
82
+ Separately, the same session finished with **no log attached** despite
83
+ `--attach-logs` being enabled, because every attach path in `solve.mjs` is
84
+ conditional and the stop-for-review exits can return before any iteration uploads.
85
+ `attachLogToGitHub` now records a process-global `logAttachedToGitHub` flag on
86
+ every successful upload, and a final safety net (`attachFinalLogIfMissing` in the
87
+ new `src/attach-logs-guarantee.lib.mjs`) attaches the cumulative session log at the
88
+ end of `solve.mjs` whenever `--attach-logs` is on, a PR exists, and nothing has
89
+ attached a log yet. A session can no longer finish with no log when `--attach-logs`
90
+ is enabled.
91
+
92
+ Adds `tests/test-cancelled-timeout-fail-1952.mjs` (13 tests) and
93
+ `tests/test-attach-logs-safety-net-1952.mjs` (9 tests), plus a deep case study in
94
+ `docs/case-studies/issue-1952/` reconstructed from the real-world trigger
95
+ (xlabtg/teleton-agent PR #670).
96
+
3
97
  ## 2.0.9
4
98
 
5
99
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
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;
package/src/codex.lib.mjs CHANGED
@@ -358,6 +358,30 @@ const isNonFatalCodexItemErrorMessage = message => /^in-process app-server event
358
358
  export const getCodexErrorEventSummary = codexJsonState => {
359
359
  const events = [];
360
360
  const ignoredEvents = [];
361
+
362
+ // Issue #1955: When the codex turn genuinely completed (a `turn.completed`
363
+ // event was observed) and codex never emitted a `turn.failed`, the session
364
+ // SUCCEEDED. Any stray top-level `error` (stream) or nested item `error` event
365
+ // in that case is non-fatal and must not fail the run. Two things produce such
366
+ // strays:
367
+ // 1. A transient error codex itself retried/recovered from before completing
368
+ // the turn (e.g. a momentary stream blip).
369
+ // 2. Echoed content that merely *looks* like a codex protocol event. The
370
+ // codex CLI prints OTEL telemetry (`codex_otel.log_only`,
371
+ // event.name="codex.tool_result") containing a raw `Output:` dump of each
372
+ // command's stdout. When a command prints a line shaped like a protocol
373
+ // event — e.g. a printed NDJSON fixture line
374
+ // `{"type":"error","message":"Network lookup skipped in fixture"}` — our
375
+ // line-by-line parser misreads it as a genuine codex stream error and
376
+ // fails an otherwise-successful run. This was the exact false positive in
377
+ // issue #1955 (codex finished, working tree clean, CI passed, yet the run
378
+ // was reported failed).
379
+ // `turn.failed` is the authoritative failure signal, so it is NEVER suppressed
380
+ // here; only non-`turn` error events are gated on turn completion.
381
+ const turnCompleted = (codexJsonState?.eventCounts?.['turn.completed'] || 0) > 0;
382
+ const turnFailed = (codexJsonState?.turnFailures?.length || 0) > 0;
383
+ const sessionSucceeded = turnCompleted && !turnFailed;
384
+
361
385
  const addEvents = (type, items = []) => {
362
386
  for (const item of items) {
363
387
  const message = unwrapCodexErrorMessage(item?.message);
@@ -369,6 +393,13 @@ export const getCodexErrorEventSummary = codexJsonState => {
369
393
  });
370
394
  continue;
371
395
  }
396
+ if (type !== 'turn' && sessionSucceeded) {
397
+ ignoredEvents.push({
398
+ ...event,
399
+ reason: 'Codex turn completed successfully with no turn.failed; stray non-turn error event is non-fatal (Issue #1955)',
400
+ });
401
+ continue;
402
+ }
372
403
  events.push(event);
373
404
  }
374
405
  };
@@ -1146,7 +1177,13 @@ export const executeCodexCommand = async params => {
1146
1177
  const codexErrorSummary = getCodexErrorEventSummary(codexJsonState);
1147
1178
  if (codexErrorSummary.ignoredEvents.length > 0) {
1148
1179
  const ignoredMessages = [...new Set(codexErrorSummary.ignoredEvents.map(event => event.message))].join('; ');
1149
- await log(`⚠️ Ignoring non-fatal Codex item error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
1180
+ await log(`⚠️ Ignoring non-fatal Codex error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
1181
+ // Issue #1955: trace why each stray error event was treated as non-fatal so a
1182
+ // future regression (e.g. a real error wrongly suppressed) is diagnosable from
1183
+ // the verbose log without re-deriving the turn.completed/turn.failed state.
1184
+ for (const ignored of codexErrorSummary.ignoredEvents) {
1185
+ await log(` ↳ [${ignored.type}] "${ignored.message}" — ${ignored.reason}`, { verbose: true });
1186
+ }
1150
1187
  }
1151
1188
  if (codexErrorSummary.hasError) {
1152
1189
  const limitSource = codexErrorSummary.message || lastMessage;
@@ -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);
@@ -89,6 +89,44 @@ export const classifyRetryableError = value => {
89
89
  return { message, isRetryable: true, isCapacity: false, label: 'Socket/connection closed unexpectedly' };
90
90
  }
91
91
 
92
+ // Issue #1955: Transient DNS resolution failures. When the local resolver, the
93
+ // upstream DNS, or the network briefly drops, Node's undici/fetch (and the Codex
94
+ // CLI's reqwest stack) surface the failure with one of these signatures:
95
+ // getaddrinfo ENOTFOUND api.openai.com / getaddrinfo EAI_AGAIN api.github.com /
96
+ // "Temporary failure in name resolution" / "dns error" / "failed to lookup
97
+ // address information". These are 100% temporary — the host is not gone, name
98
+ // resolution simply failed for a moment — so the same request is safe to retry
99
+ // after a backoff. Switching models does not help (it is a network-layer fault),
100
+ // so isCapacity is false.
101
+ // NOTE: deliberately scoped to real resolver error tokens so it never matches
102
+ // unrelated text that merely contains the word "lookup" (e.g. the echoed fixture
103
+ // line "Network lookup skipped in fixture" from issue #1955, which is not an error
104
+ // at all).
105
+ if (lower.includes('enotfound') || lower.includes('eai_again') || lower.includes('temporary failure in name resolution') || lower.includes('getaddrinfo') || lower.includes('dns error') || lower.includes('failed to lookup address information') || lower.includes('name or service not known')) {
106
+ return { message, isRetryable: true, isCapacity: false, label: 'DNS resolution failure' };
107
+ }
108
+
109
+ // Issue #1955: Transient connection-level network failures from the OS/socket
110
+ // layer — the peer is unreachable or refused the connection for a moment, or a
111
+ // connect/read timed out. These are temporary (load balancer rotating, a node
112
+ // briefly down, a VPN/proxy hiccup, a flaky link) and the identical request
113
+ // typically succeeds on retry. Covers Node libuv error codes and their textual
114
+ // equivalents. ETIMEDOUT/"timed out" here is the connection/socket timeout
115
+ // (distinct from the API-level "request timed out" handled above).
116
+ if (lower.includes('etimedout') || lower.includes('connection timed out') || lower.includes('econnrefused') || lower.includes('connection refused') || lower.includes('ehostunreach') || lower.includes('no route to host') || lower.includes('enetunreach') || lower.includes('network is unreachable') || lower.includes('epipe') || lower.includes('eai_fail')) {
117
+ return { message, isRetryable: true, isCapacity: false, label: 'Transient network connection failure' };
118
+ }
119
+
120
+ // Issue #1955: Transient HTTP gateway / proxy errors (502 Bad Gateway, 504 Gateway
121
+ // Timeout) and Cloudflare's edge family (520 Unknown Error, 521 Web Server Is Down,
122
+ // 522 Connection Timed Out, 523 Origin Is Unreachable, 524 A Timeout Occurred).
123
+ // These come from an intermediary (CDN/proxy/load balancer), not from a request the
124
+ // client got wrong, and clear on their own — OpenAI/Anthropic/GitHub all front their
125
+ // APIs with such proxies. Safe to retry the same request after a backoff.
126
+ if (lower.includes('502 bad gateway') || lower.includes('bad gateway') || lower.includes('504 gateway timeout') || lower.includes('gateway time-out') || lower.includes('gateway timeout') || lower.includes('api error: 502') || lower.includes('api error: 504') || /\b52[0-4]\b/.test(lower)) {
127
+ return { message, isRetryable: true, isCapacity: false, label: 'Gateway error (502/504/52x)' };
128
+ }
129
+
92
130
  // Issue #1834: Corrupted extended-thinking blocks. When extended thinking is combined with tool
93
131
  // use, Claude Code can persist a thinking block to the session transcript with the `thinking`
94
132
  // text emptied to "" while retaining the original `signature`. On resume/continue the block is
@@ -120,7 +158,10 @@ export const classifyRetryableError = value => {
120
158
  return { message, isRetryable: true, isCapacity: false, label: 'Server rate limited (429)' };
121
159
  }
122
160
 
123
- if (lower.includes('api error: 503') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
161
+ // Issue #1955: broadened to also catch the bare "503 Service Unavailable" that
162
+ // GitHub/OpenAI/Anthropic return when a backend is briefly saturated — a
163
+ // transient, self-clearing condition, safe to retry with the same request.
164
+ if (lower.includes('api error: 503') || lower.includes('503 service unavailable') || lower.includes('service unavailable') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
124
165
  return { message, isRetryable: true, isCapacity: false, label: '503 network error' };
125
166
  }
126
167
 
@@ -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 } = {}) {