@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 +64 -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/claude.budget-stats.lib.mjs +19 -9
- package/src/claude.cost.lib.mjs +41 -5
- 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/work-session-formatting.lib.mjs +12 -2
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
|
@@ -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;
|
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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) {
|
package/src/claude.cost.lib.mjs
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
-
|
|
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:
|
|
53
|
-
costPerMillion:
|
|
54
|
-
cost:
|
|
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
|
-
|
|
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);
|
|
@@ -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 } = {}) {
|