@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 +94 -0
- package/package.json +1 -1
- package/src/attach-logs-guarantee.lib.mjs +79 -0
- package/src/cancelled-ci-rerun.lib.mjs +72 -1
- package/src/codex.lib.mjs +38 -1
- package/src/github-merge-ci.lib.mjs +6 -1
- package/src/github.lib.mjs +9 -1
- package/src/session-monitor.lib.mjs +26 -0
- package/src/solve.auto-merge-helpers.lib.mjs +54 -7
- package/src/solve.auto-merge.lib.mjs +10 -2
- package/src/solve.mjs +4 -5
- package/src/telegram-bot.mjs +2 -2
- package/src/telegram-command-execution.lib.mjs +15 -4
- package/src/tool-retry.lib.mjs +42 -1
- package/src/work-session-formatting.lib.mjs +12 -2
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/github.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
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);
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 } = {}) {
|