@link-assistant/hive-mind 1.78.8 → 1.78.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 +92 -0
- package/README.hi.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/isolation-runner.lib.mjs +204 -68
- package/src/solve.auto-merge-helpers.lib.mjs +53 -1
- package/src/solve.auto-merge.lib.mjs +10 -5
- package/src/telegram-bot.mjs +21 -7
- package/src/telegram-log-command.lib.mjs +6 -1
- package/src/telegram-message-filters.lib.mjs +59 -15
- package/src/telegram-start-stop-command.lib.mjs +8 -1
- package/src/telegram-task-command.lib.mjs +8 -1
- package/src/telegram-terminal-watch-command.lib.mjs +6 -1
- package/src/telegram-tokens-command.lib.mjs +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.78.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 02faadb: fix(auto-merge): stop `/merge` from hanging forever on fork PRs with external-only `success` checks (#1918)
|
|
8
|
+
|
|
9
|
+
The `/merge` auto-merge watch loop could spin on the same commit indefinitely
|
|
10
|
+
(observed 73 minutes, 72 identical iterations, before a human killed it). It
|
|
11
|
+
happened on a **fork pull request** whose only repo workflows trigger on `push`
|
|
12
|
+
(which never fires for fork commits in the base repo) while an external app
|
|
13
|
+
(CodeRabbit) reported CI status `success` with **0 workflow runs** for the head
|
|
14
|
+
SHA.
|
|
15
|
+
|
|
16
|
+
Root cause: the watch loop reset its consecutive "no workflow runs" safety-valve
|
|
17
|
+
counter (`consecutiveNoRunsChecks`) on every iteration whenever
|
|
18
|
+
`ciStatus.status !== 'no_checks'`. Because external-only checks make the status
|
|
19
|
+
`'success'`, the counter was pinned at `1` and never reached
|
|
20
|
+
`MAX_NO_RUNS_CHECKS`, so the valve that should have ended the wait never fired —
|
|
21
|
+
the loop logged `check 1/5` forever.
|
|
22
|
+
|
|
23
|
+
Fix: `getMergeBlockers()` now returns a `noWorkflowRunsForCommit` flag that is
|
|
24
|
+
true while it is still waiting for PR-triggered workflow runs to register, and a
|
|
25
|
+
new pure helper `shouldResetNoRunsCounter(ciStatus, noWorkflowRunsForCommit)`
|
|
26
|
+
only resets the counter when CI is **not** in that waiting state. The counter
|
|
27
|
+
now climbs `1 → 2 → … → 5`, trips the safety valve in a few minutes, and `/merge`
|
|
28
|
+
proceeds. The #1503 behaviors (reset on new push / on genuine CI runs) are
|
|
29
|
+
preserved and regression-guarded.
|
|
30
|
+
|
|
31
|
+
Added `tests/test-merge-stuck-no-workflow-runs-1918.mjs` and a full case study
|
|
32
|
+
with timeline, root-cause analysis, and the captured logs under
|
|
33
|
+
`docs/case-studies/issue-1918`.
|
|
34
|
+
|
|
35
|
+
- 9e00f14: fix(telegram): never re-execute a forwarded command (`/task`, `/stop`, `/tokens`, `/log`, `/terminal_watch`) (#1922)
|
|
36
|
+
|
|
37
|
+
Forwarding a message that starts with a bot command (for example the bot's own
|
|
38
|
+
`/task <url>` reply, or any `/task https://github.com/owner/repo`) caused the
|
|
39
|
+
Telegram bot to execute the command again — creating a brand-new GitHub issue or
|
|
40
|
+
spawning a session the user never intended. `/task` and `/split` only checked
|
|
41
|
+
`isOldMessage` and never rejected forwarded messages, unlike `/help`, `/solve`,
|
|
42
|
+
`/hive`, `/merge`, etc.
|
|
43
|
+
|
|
44
|
+
Root cause: the existing `isForwardedOrReply` filter rejects _both_ forwards and
|
|
45
|
+
replies, so commands that use the reply feature (`/task` issue creation, `/solve`
|
|
46
|
+
URL extraction, targeted `/stop`) could not use it without breaking replies — and
|
|
47
|
+
were therefore left without any forwarded check at all.
|
|
48
|
+
|
|
49
|
+
Fix: a new dedicated `isForwarded(ctx)` filter detects _only_ forwarded messages
|
|
50
|
+
(new `forward_origin` API + legacy `forward_*` fields) and intentionally ignores
|
|
51
|
+
replies. It is now applied to every command that previously lacked a forwarded
|
|
52
|
+
guard — `/task`, `/split`, `/stop` (including targeted `/stop <uuid>`), `/tokens`,
|
|
53
|
+
`/log`, `/terminal_watch`/`/watch` — and `/solve` was refactored to reuse it
|
|
54
|
+
instead of its ad-hoc inline check. Genuine user replies keep working.
|
|
55
|
+
|
|
56
|
+
Added unit tests for `isForwarded` and for forwarded `/task`/`/split` rejection,
|
|
57
|
+
plus a full case study with timeline and per-command audit under
|
|
58
|
+
`docs/case-studies/issue-1922`.
|
|
59
|
+
|
|
60
|
+
## 1.78.9
|
|
61
|
+
|
|
62
|
+
### Patch Changes
|
|
63
|
+
|
|
64
|
+
- a3d4d41: fix(isolation): use native Docker isolation and seed the nested daemon for `--isolation docker` (#1914)
|
|
65
|
+
|
|
66
|
+
Two problems made `--isolation docker` behave wrong on the Docker-in-Docker bot
|
|
67
|
+
host:
|
|
68
|
+
1. **It wasn't real Docker isolation.** Hive Mind launched isolated tasks as
|
|
69
|
+
`$ --isolated screen -- docker run …`, so `$ --status` reported
|
|
70
|
+
`options / isolated screen` — a screen wrapper around a raw `docker run`, not
|
|
71
|
+
the native Docker backend. Hive Mind now builds
|
|
72
|
+
`$ --isolated docker --image <img> [--privileged] --shell sh … --detached --session <uuid> -- '<cmd>'`,
|
|
73
|
+
so start-command owns the container lifecycle and `--status` reports real
|
|
74
|
+
Docker isolation.
|
|
75
|
+
2. **The 30+ GB image was re-downloaded for every task.** The bot runs inside a
|
|
76
|
+
DinD container whose nested `dockerd` starts with an empty image store. box
|
|
77
|
+
can seed that daemon from the host (host-image passthrough), but only when the
|
|
78
|
+
host Docker socket is bind-mounted — and when it isn't, passthrough is a
|
|
79
|
+
_silent_ no-op, so the first isolated task pulled the whole image from the
|
|
80
|
+
registry. Hive Mind now runs a startup preflight (`preflightDockerIsolation`)
|
|
81
|
+
that probes the nested daemon and, when the image is absent, prints the exact
|
|
82
|
+
remediation (mount `/var/run/docker.sock` + set `DIND_HOST_PASSTHROUGH_IMAGES`,
|
|
83
|
+
or run `scripts/preload-dind-isolation-image.mjs`). The production deploy
|
|
84
|
+
script was the real root cause — its `docker run` never mounted the host
|
|
85
|
+
socket — and has been fixed to pass `-v /var/run/docker.sock:…:ro` plus the
|
|
86
|
+
allowlist.
|
|
87
|
+
|
|
88
|
+
Also filed the silent-passthrough footgun upstream as link-foundation/box#102
|
|
89
|
+
(warn when an allowlist is set but no socket is mounted) — **now fixed and shipped
|
|
90
|
+
in box v2.3.2** — and bumped this repo's base images from `konard/box:2.3.1` /
|
|
91
|
+
`konard/box-dind:2.3.1` to `2.3.2` so the upstream warning ships at the source.
|
|
92
|
+
Added a deep case study with the full reproduction, timeline, and root-cause
|
|
93
|
+
analysis under `docs/case-studies/issue-1914`.
|
|
94
|
+
|
|
3
95
|
## 1.78.8
|
|
4
96
|
|
|
5
97
|
### Patch Changes
|
package/README.hi.md
CHANGED
|
@@ -45,7 +45,7 @@ Hive Mind एक **सामान्यवादी AI** (मिनी-AGI) ह
|
|
|
45
45
|
|
|
46
46
|
Hive Mind में औसत प्रोग्रामर से अलग न पहचानी जा सकने वाली उच्च रचनात्मकता है। यदि आवश्यकताएँ अस्पष्ट हों तो यह प्रश्न पूछता है, और आप PR टिप्पणियों के माध्यम से चलते-चलते स्पष्ट कर सकते हैं।
|
|
47
47
|
|
|
48
|
-
विस्तृत विशेषताओं और तुलनाओं के लिए, [docs/FEATURES.hi.md](./docs/FEATURES.hi.md) और [docs/COMPARISON.hi.md](./docs/COMPARISON.hi.md) देखें।
|
|
48
|
+
प्रोजेक्ट के दृष्टिकोण और उदाहरण उपयोगकर्ता यात्राओं के लिए, [docs/VISION.hi.md](./docs/VISION.hi.md) देखें। विस्तृत विशेषताओं और तुलनाओं के लिए, [docs/FEATURES.hi.md](./docs/FEATURES.hi.md) और [docs/COMPARISON.hi.md](./docs/COMPARISON.hi.md) देखें।
|
|
49
49
|
|
|
50
50
|
## ⚠️ चेतावनी
|
|
51
51
|
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ Both tools can be combined in the same hive. Workers can run different tools in
|
|
|
45
45
|
|
|
46
46
|
Hive Mind has high creativity indistinguishable from average programmers. It asks questions if requirements are unclear, and you can clarify on the go via PR comments.
|
|
47
47
|
|
|
48
|
-
For detailed features and comparisons, see [docs/FEATURES.md](./docs/FEATURES.md) and [docs/COMPARISON.md](./docs/COMPARISON.md).
|
|
48
|
+
For the project's vision and example user journeys, see [docs/VISION.md](./docs/VISION.md). For detailed features and comparisons, see [docs/FEATURES.md](./docs/FEATURES.md) and [docs/COMPARISON.md](./docs/COMPARISON.md).
|
|
49
49
|
|
|
50
50
|
## ⚠️ WARNING
|
|
51
51
|
|
package/README.ru.md
CHANGED
|
@@ -45,7 +45,7 @@ Hive Mind — это **универсальный ИИ** (мини-AGI), спо
|
|
|
45
45
|
|
|
46
46
|
Hive Mind обладает высоким уровнем творчества, неотличимым от среднего программиста. Он задаёт вопросы, если требования неясны, и вы можете уточнять их на ходу через комментарии к PR.
|
|
47
47
|
|
|
48
|
-
Подробные возможности и сравнения см. в [docs/FEATURES.ru.md](./docs/FEATURES.ru.md) и [docs/COMPARISON.ru.md](./docs/COMPARISON.ru.md).
|
|
48
|
+
Видение проекта и примеры пользовательских сценариев см. в [docs/VISION.ru.md](./docs/VISION.ru.md). Подробные возможности и сравнения см. в [docs/FEATURES.ru.md](./docs/FEATURES.ru.md) и [docs/COMPARISON.ru.md](./docs/COMPARISON.ru.md).
|
|
49
49
|
|
|
50
50
|
## ⚠️ ПРЕДУПРЕЖДЕНИЕ
|
|
51
51
|
|
package/README.zh.md
CHANGED
|
@@ -45,7 +45,7 @@ Hive Mind 是一款**通用 AI**(迷你 AGI),能够处理广泛的任务
|
|
|
45
45
|
|
|
46
46
|
Hive Mind 具备与普通程序员无异的高度创造力。当需求不明确时,它会主动提问,您也可以随时通过 PR 评论进行说明补充。
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
关于项目的愿景和示例用户旅程,请参见 [docs/VISION.zh.md](./docs/VISION.zh.md)。详细功能和对比信息,请参见 [docs/FEATURES.zh.md](./docs/FEATURES.zh.md) 和 [docs/COMPARISON.zh.md](./docs/COMPARISON.zh.md)。
|
|
49
49
|
|
|
50
50
|
## ⚠️ 警告
|
|
51
51
|
|
package/package.json
CHANGED
|
@@ -32,13 +32,21 @@ const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'c
|
|
|
32
32
|
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
33
33
|
const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
34
34
|
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
35
|
-
// Docker's `--pull` accepts these policies. We only emit the flag when an
|
|
36
|
-
// operator explicitly opts in; otherwise Docker's own default ("missing")
|
|
37
|
-
// applies and `docker run` reuses any locally present image. See issue #1879.
|
|
38
|
-
const VALID_DOCKER_PULL_POLICIES = new Set(['always', 'missing', 'never']);
|
|
39
|
-
const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
|
|
40
35
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
41
|
-
|
|
36
|
+
// Default path where the host Docker socket is bind-mounted inside a DinD
|
|
37
|
+
// container so box's host-image passthrough can copy host images into the
|
|
38
|
+
// nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy
|
|
39
|
+
// must mount it (`-v /var/run/docker.sock:/var/run/host-docker.sock:ro`) or the
|
|
40
|
+
// nested daemon starts empty and the first isolated task pulls the full,
|
|
41
|
+
// multi-gigabyte image. See issue #1914.
|
|
42
|
+
const DEFAULT_HOST_DOCKER_SOCK = '/var/run/host-docker.sock';
|
|
43
|
+
// Force a POSIX shell for the inner command of Docker-isolated tasks. solve/
|
|
44
|
+
// hive/task live on the image's baked-in PATH, so `sh -c` resolves them without
|
|
45
|
+
// needing a login shell. Forcing the shell (instead of start's 'auto') also
|
|
46
|
+
// skips start's shell-detection probe, which would otherwise `docker run` a
|
|
47
|
+
// throwaway container — booting the dind image's dockerd entrypoint — purely to
|
|
48
|
+
// check whether bash exists. See issue #1914.
|
|
49
|
+
const DOCKER_ISOLATION_SHELL = 'sh';
|
|
42
50
|
|
|
43
51
|
function normalizeProcessIds(value) {
|
|
44
52
|
if (!value || typeof value !== 'object') return {};
|
|
@@ -62,19 +70,10 @@ function shellQuote(value) {
|
|
|
62
70
|
return `'${stringValue.replaceAll("'", "'\\''")}'`;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
function shellDoubleQuote(value) {
|
|
66
|
-
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
73
|
function buildShellCommand(command, args = []) {
|
|
70
74
|
return [command, ...args].map(shellQuote).join(' ');
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function makeDockerContainerName(sessionId) {
|
|
74
|
-
const normalizedSession = String(sessionId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
75
|
-
return `${DOCKER_CONTAINER_PREFIX}-${normalizedSession}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
77
|
function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
|
|
79
78
|
return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
|
|
80
79
|
}
|
|
@@ -117,20 +116,15 @@ export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
/**
|
|
120
|
-
* Resolve the Docker
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* present in the (possibly nested) daemon and fail fast instead of silently
|
|
126
|
-
* re-downloading it. Invalid values are ignored. See issue #1879.
|
|
119
|
+
* Resolve the path where the host Docker socket is expected to be mounted inside
|
|
120
|
+
* a DinD container. box's entrypoint reads this socket to copy host images into
|
|
121
|
+
* the nested daemon (host-image passthrough). Defaults to
|
|
122
|
+
* `/var/run/host-docker.sock` and can be overridden with `DIND_HOST_DOCKER_SOCK`
|
|
123
|
+
* (the same variable box honors). See issue #1914.
|
|
127
124
|
*/
|
|
128
|
-
export function
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
.toLowerCase();
|
|
132
|
-
if (!raw) return null;
|
|
133
|
-
return VALID_DOCKER_PULL_POLICIES.has(raw) ? raw : null;
|
|
125
|
+
export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
126
|
+
const explicit = String(env.DIND_HOST_DOCKER_SOCK || '').trim();
|
|
127
|
+
return explicit || DEFAULT_HOST_DOCKER_SOCK;
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
/**
|
|
@@ -157,46 +151,63 @@ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.en
|
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* auth mounts directly.
|
|
154
|
+
* Resolve the image-variant marker recorded inside the isolated container.
|
|
155
|
+
* A `hive-mind-dind` image is always the dind variant; otherwise fall back to
|
|
156
|
+
* the parent's `HIVE_MIND_IMAGE_VARIANT` (or `regular`).
|
|
164
157
|
*/
|
|
165
|
-
|
|
158
|
+
function resolveImageVariant(image, env = process.env) {
|
|
159
|
+
return image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the `$` (start-command) arguments that launch a Docker-isolated task
|
|
164
|
+
* using start-command's NATIVE Docker backend (`$ --isolated docker`).
|
|
165
|
+
*
|
|
166
|
+
* Issue #1914: earlier versions wrapped a hand-rolled `docker run` inside a
|
|
167
|
+
* `screen` session (`$ --isolated screen -- docker run …`). That was *screen*
|
|
168
|
+
* isolation merely shelling out to Docker — not Docker isolation. We now hand
|
|
169
|
+
* the container lifecycle to start-command itself and only contribute the
|
|
170
|
+
* pieces Hive Mind must control: which image to run, privileged mode for the
|
|
171
|
+
* dind variant, the environment markers, and the credential mounts scoped to
|
|
172
|
+
* the selected tool.
|
|
173
|
+
*
|
|
174
|
+
* start-command's Docker backend reuses a locally present image and only pulls
|
|
175
|
+
* when it is missing (`docker run` with Docker's default "missing" pull
|
|
176
|
+
* policy), so a host image seeded into the nested daemon via box passthrough is
|
|
177
|
+
* reused instead of re-downloaded — no `--pull` plumbing required (issue #1879).
|
|
178
|
+
*/
|
|
179
|
+
export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
|
|
166
180
|
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
167
181
|
const image = getDockerIsolationImage({ env });
|
|
168
|
-
const innerCommand = buildShellCommand(command, args);
|
|
169
|
-
const dockerArgs = ['docker', 'run', '--rm'];
|
|
170
|
-
|
|
171
|
-
// Reuse a locally present image instead of re-downloading it when the
|
|
172
|
-
// operator opts in. Omitted by default so Docker's "missing" policy applies.
|
|
173
|
-
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
174
|
-
if (pullPolicy) {
|
|
175
|
-
dockerArgs.push('--pull', pullPolicy);
|
|
176
|
-
}
|
|
177
182
|
|
|
178
|
-
|
|
183
|
+
const startArgs = ['--isolated', 'docker', '--image', image];
|
|
179
184
|
|
|
180
185
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
181
|
-
|
|
186
|
+
startArgs.push('--privileged');
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
// Force the inner shell so start-command does not probe the image to detect
|
|
190
|
+
// one (see DOCKER_ISOLATION_SHELL).
|
|
191
|
+
startArgs.push('--shell', DOCKER_ISOLATION_SHELL);
|
|
192
|
+
|
|
193
|
+
// The image already sets HOME=/home/box and WORKDIR /home/box; pass HOME
|
|
194
|
+
// explicitly anyway so the credential mounts under /home/box resolve even if
|
|
195
|
+
// a future image forgets to. start-command has no --workdir flag, so the
|
|
196
|
+
// working directory comes from the image's WORKDIR.
|
|
197
|
+
startArgs.push('-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`, '-e', `HIVE_MIND_IMAGE_VARIANT=${resolveImageVariant(image, env)}`);
|
|
186
198
|
|
|
187
199
|
for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
|
|
188
|
-
|
|
200
|
+
startArgs.push('--volume', `${mount.source}:${mount.target}`);
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return [...dockerArgs.map(shellQuote), shellDoubleQuote(innerCommand)].join(' ');
|
|
203
|
+
startArgs.push('--detached', '--session', sessionId, '--', buildShellCommand(command, args));
|
|
204
|
+
return startArgs;
|
|
194
205
|
}
|
|
195
206
|
|
|
196
207
|
export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
197
208
|
const { backend, sessionId } = options;
|
|
198
209
|
if (backend === 'docker') {
|
|
199
|
-
return
|
|
210
|
+
return buildDockerIsolationStartArgs(command, args, { ...options, sessionId });
|
|
200
211
|
}
|
|
201
212
|
return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
|
|
202
213
|
}
|
|
@@ -413,10 +424,11 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
413
424
|
if (backend === 'docker') {
|
|
414
425
|
const env = options.env || process.env;
|
|
415
426
|
const image = getDockerIsolationImage({ env });
|
|
416
|
-
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
417
427
|
const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
|
|
428
|
+
console.log('[VERBOSE] isolation-runner: Docker isolation backend: native ($ --isolated docker)');
|
|
418
429
|
console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
|
|
419
|
-
console.log(`[VERBOSE] isolation-runner: Docker isolation
|
|
430
|
+
console.log(`[VERBOSE] isolation-runner: Docker isolation privileged: ${shouldRunPrivilegedDockerIsolation(image, env)}`);
|
|
431
|
+
console.log('[VERBOSE] isolation-runner: Docker isolation pull: reuse local image if present, pull only if missing (start-command default)');
|
|
420
432
|
console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
|
|
421
433
|
}
|
|
422
434
|
}
|
|
@@ -553,12 +565,126 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
|
553
565
|
}
|
|
554
566
|
}
|
|
555
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Check whether the Docker container backing a native `$ --isolated docker`
|
|
570
|
+
* session is still running.
|
|
571
|
+
*
|
|
572
|
+
* start-command names the container after the `--session` value, so the
|
|
573
|
+
* (possibly nested) Docker daemon can be queried directly. This is the
|
|
574
|
+
* native-Docker analogue of the `screen -ls` fallback: it is consulted only
|
|
575
|
+
* when `$ --status` has no usable record. The bot runs inside a Docker-in-
|
|
576
|
+
* Docker container, so `docker` here talks to the same nested daemon that
|
|
577
|
+
* start-command launched the task container on. See issue #1914.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} containerName - Container name (the session UUID)
|
|
580
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
581
|
+
* @returns {Promise<boolean>} True if the container exists and is running
|
|
582
|
+
*/
|
|
583
|
+
export async function checkDockerContainerRunning(containerName, verbose = false) {
|
|
584
|
+
try {
|
|
585
|
+
const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Running}}'} ${containerName}`;
|
|
586
|
+
const running = (result.stdout?.toString() || '').trim() === 'true';
|
|
587
|
+
if (verbose) {
|
|
588
|
+
console.log(`[VERBOSE] isolation-runner: docker inspect for '${containerName}': ${running ? 'running' : 'not running'}`);
|
|
589
|
+
}
|
|
590
|
+
return running;
|
|
591
|
+
} catch {
|
|
592
|
+
// `docker inspect` exits non-zero when no such container exists.
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Check whether an image is present in the local Docker daemon.
|
|
599
|
+
*
|
|
600
|
+
* Inside a Docker-in-Docker container "local" is the NESTED daemon. `docker
|
|
601
|
+
* image inspect` exits 0 only when the image exists, so a non-zero exit (or a
|
|
602
|
+
* missing docker binary) is treated as absent. Used by the startup preflight to
|
|
603
|
+
* predict whether the first isolated task will trigger a full image pull.
|
|
604
|
+
* See issue #1914.
|
|
605
|
+
*
|
|
606
|
+
* @param {string} image - Image reference (repo:tag)
|
|
607
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
608
|
+
* @returns {Promise<boolean>} True if the image is present locally
|
|
609
|
+
*/
|
|
610
|
+
export async function checkDockerImagePresent(image, verbose = false) {
|
|
611
|
+
try {
|
|
612
|
+
await $({ mirror: false })`docker image inspect ${image}`;
|
|
613
|
+
if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': present`);
|
|
614
|
+
return true;
|
|
615
|
+
} catch {
|
|
616
|
+
if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': absent`);
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Startup preflight for `--isolation docker`.
|
|
623
|
+
*
|
|
624
|
+
* The bot usually runs inside a Docker-in-Docker container whose NESTED daemon
|
|
625
|
+
* starts with an empty image store. If the isolation image is not already in
|
|
626
|
+
* that nested daemon, the first isolated task makes `docker run` pull a fresh
|
|
627
|
+
* copy — which for the Hive Mind images is multiple gigabytes (issues #1914,
|
|
628
|
+
* #1879). box can seed the nested daemon automatically (host-image passthrough)
|
|
629
|
+
* but only when the host Docker socket is bind-mounted into the container; if it
|
|
630
|
+
* is not mounted, passthrough is a SILENT no-op and the re-download is the first
|
|
631
|
+
* symptom an operator sees.
|
|
632
|
+
*
|
|
633
|
+
* This preflight makes that condition observable at startup instead: it reports
|
|
634
|
+
* whether the image is already present (reuse, no pull) and, when it is absent,
|
|
635
|
+
* warns loudly with the exact remediation (mount the host socket / set the
|
|
636
|
+
* passthrough allowlist, or run the preload script). It never throws and never
|
|
637
|
+
* blocks startup — a misconfigured passthrough should degrade to a slow first
|
|
638
|
+
* task, not a dead bot.
|
|
639
|
+
*
|
|
640
|
+
* @param {Object} [options]
|
|
641
|
+
* @param {Object} [options.env] - Environment (defaults to process.env)
|
|
642
|
+
* @param {Function} [options.existsSync] - fs.existsSync (injectable for tests)
|
|
643
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
644
|
+
* @param {Object} [options.logger] - Logger with .log/.warn (defaults to console)
|
|
645
|
+
* @param {Function} [options.checkImagePresent] - Image-presence probe (injectable for tests)
|
|
646
|
+
* @returns {Promise<{image: string, sock: string, socketMounted: boolean, imagePresent: boolean, isDind: boolean, ok: boolean, warnings: string[]}>}
|
|
647
|
+
*/
|
|
648
|
+
export async function preflightDockerIsolation(options = {}) {
|
|
649
|
+
const { env = process.env, existsSync = fs.existsSync, verbose = false, logger = console, checkImagePresent = checkDockerImagePresent } = options;
|
|
650
|
+
|
|
651
|
+
const image = getDockerIsolationImage({ env });
|
|
652
|
+
const sock = resolveHostDockerSock({ env });
|
|
653
|
+
const isDind = shouldRunPrivilegedDockerIsolation(image, env);
|
|
654
|
+
const socketMounted = Boolean(existsSync(sock));
|
|
655
|
+
const imagePresent = Boolean(await checkImagePresent(image, verbose));
|
|
656
|
+
|
|
657
|
+
const result = { image, sock, socketMounted, imagePresent, isDind, ok: imagePresent, warnings: [] };
|
|
658
|
+
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
659
|
+
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
660
|
+
|
|
661
|
+
if (imagePresent) {
|
|
662
|
+
info(`✅ Docker isolation image '${image}' is already present locally — isolated tasks reuse it (no multi-GB pull). See issue #1914.`);
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Image absent: the first isolated task will pull the full image. Explain the
|
|
667
|
+
// most likely cause and the exact fix instead of letting the operator first
|
|
668
|
+
// discover it as a surprise multi-gigabyte download mid-task.
|
|
669
|
+
const preload = `node scripts/preload-dind-isolation-image.mjs --image ${image}`;
|
|
670
|
+
if (isDind && !socketMounted) {
|
|
671
|
+
result.warnings.push(`Docker isolation image '${image}' is NOT in the nested Docker daemon and the host Docker socket is not mounted at ${sock}. ` + `box host-image passthrough cannot seed the nested daemon, so the FIRST isolated task will pull the full image (the Hive Mind images are multiple GB). ` + `Fix the deployment: add '-v /var/run/docker.sock:${sock}:ro' and '-e DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind konard/hive-mind-dind"' to the bot container's 'docker run', or seed it now with: ${preload}`);
|
|
672
|
+
} else if (isDind && socketMounted) {
|
|
673
|
+
result.warnings.push(`Docker isolation image '${image}' is NOT in the nested Docker daemon even though the host Docker socket is mounted at ${sock}. ` + `box host-image passthrough may have skipped it (check DIND_HOST_PASSTHROUGH mode, the DIND_HOST_PASSTHROUGH_IMAGES allowlist, and that the host actually has '${image}' with a registry digest). ` + `The first isolated task will pull the full image. Seed it now with: ${preload}`);
|
|
674
|
+
} else {
|
|
675
|
+
result.warnings.push(`Docker isolation image '${image}' is not present locally; the first isolated task will pull it. ` + `If this host already has it under a different tag, pin HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG, or seed it with: ${preload}`);
|
|
676
|
+
}
|
|
677
|
+
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
556
681
|
/**
|
|
557
682
|
* Check if an isolated session is still running.
|
|
558
|
-
* Uses `$ --status` first, with a
|
|
559
|
-
*
|
|
683
|
+
* Uses `$ --status` first, with a backend-specific fallback (screen -ls for
|
|
684
|
+
* screen, docker inspect for docker) to work around start-command UUID
|
|
685
|
+
* mismatch issues.
|
|
560
686
|
*
|
|
561
|
-
* @param {string} sessionId - UUID of the session (
|
|
687
|
+
* @param {string} sessionId - UUID of the session (also the screen session name / docker container name)
|
|
562
688
|
* @param {Object} [options] - Options
|
|
563
689
|
* @param {string} [options.backend] - Isolation backend ('screen', 'tmux', 'docker')
|
|
564
690
|
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
@@ -579,19 +705,29 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
579
705
|
}
|
|
580
706
|
}
|
|
581
707
|
|
|
582
|
-
// Fallback
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
|
|
708
|
+
// Fallback used only when `$ --status` has no usable record. This works
|
|
709
|
+
// around older start-command bugs where `$ --status` can't resolve a session
|
|
710
|
+
// by its --session name (only by an internal UUID). See issue #1545.
|
|
711
|
+
// - screen sessions: confirm via `screen -ls`.
|
|
712
|
+
// - docker sessions: confirm via `docker inspect` on the container that
|
|
713
|
+
// start-command named after the session UUID. Native Docker isolation
|
|
714
|
+
// (issue #1914) is a real container, not a screen wrapper, so the screen
|
|
715
|
+
// check no longer applies to it.
|
|
716
|
+
if (shouldFallbackToScreenStatus(result)) {
|
|
717
|
+
if (backend === 'screen') {
|
|
718
|
+
const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
|
|
719
|
+
if (screenRunning && verbose) {
|
|
720
|
+
console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
|
|
721
|
+
}
|
|
722
|
+
return screenRunning;
|
|
723
|
+
}
|
|
724
|
+
if (backend === 'docker') {
|
|
725
|
+
const containerRunning = await checkDockerContainerRunning(sessionId, verbose);
|
|
726
|
+
if (containerRunning && verbose) {
|
|
727
|
+
console.log(`[VERBOSE] isolation-runner: $ --status says not running, but docker inspect confirms container '${sessionId}' is still active`);
|
|
728
|
+
}
|
|
729
|
+
return containerRunning;
|
|
593
730
|
}
|
|
594
|
-
return screenRunning;
|
|
595
731
|
}
|
|
596
732
|
|
|
597
733
|
return false;
|
|
@@ -421,9 +421,53 @@ export const trackAuthenticatedUserCommentsSince = async (owner, repo, prNumber,
|
|
|
421
421
|
* - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
|
|
422
422
|
* - no_checks: No CI checks yet (race condition) → wait
|
|
423
423
|
*/
|
|
424
|
+
/**
|
|
425
|
+
* Issue #1918: Decide whether the consecutive "no workflow runs" counter should be
|
|
426
|
+
* reset after a getMergeBlockers() result.
|
|
427
|
+
*
|
|
428
|
+
* The counter (`consecutiveNoRunsChecks` in the watch loop) is a safety valve: after
|
|
429
|
+
* MAX_NO_RUNS_CHECKS consecutive checks where a repo's PR-triggered workflows produced
|
|
430
|
+
* 0 workflow runs, /merge stops waiting and trusts the available signal (external
|
|
431
|
+
* checks / mergeability). For that valve to ever fire, the counter must keep
|
|
432
|
+
* incrementing across watch-loop iterations for the same commit.
|
|
433
|
+
*
|
|
434
|
+
* The previous logic reset the counter whenever `ciStatus.status !== 'no_checks'`.
|
|
435
|
+
* That is wrong when `status === 'success'` comes from EXTERNAL checks only (e.g.
|
|
436
|
+
* CodeRabbit) while the repo's own PR-triggered workflows have 0 runs — for example a
|
|
437
|
+
* fork PR whose only workflow triggers on `push`, which never fires for fork commits in
|
|
438
|
+
* the base repo. In that case getMergeBlockers keeps emitting the "no workflow runs"
|
|
439
|
+
* wait, but the caller reset the counter to 0 every iteration, pinning it at "check
|
|
440
|
+
* 1/5" forever — an infinite watch loop that hung for over an hour (Issue #1918).
|
|
441
|
+
*
|
|
442
|
+
* Fix: keep the counter whenever getMergeBlockers signals it is still inside the
|
|
443
|
+
* no-workflow-runs wait (`noWorkflowRunsForCommit === true`), regardless of ciStatus.
|
|
444
|
+
*
|
|
445
|
+
* @param {{status?: string}|null|undefined} ciStatus - Detailed CI status object.
|
|
446
|
+
* @param {boolean} [noWorkflowRunsForCommit=false] - True when getMergeBlockers is still
|
|
447
|
+
* waiting for PR-triggered workflow runs to register for the current commit.
|
|
448
|
+
* @returns {boolean} true if `consecutiveNoRunsChecks` should be reset to 0.
|
|
449
|
+
*/
|
|
450
|
+
export const shouldResetNoRunsCounter = (ciStatus, noWorkflowRunsForCommit = false) => {
|
|
451
|
+
// Still inside the no-workflow-runs safety-valve wait — the counter MUST keep
|
|
452
|
+
// climbing toward MAX_NO_RUNS_CHECKS, so do not reset it.
|
|
453
|
+
if (noWorkflowRunsForCommit) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
// Genuine CI checks exist (pending/success/failure backed by workflow runs) — the
|
|
457
|
+
// "no runs" counter is irrelevant and should be reset.
|
|
458
|
+
return Boolean(ciStatus && ciStatus.status !== 'no_checks');
|
|
459
|
+
};
|
|
460
|
+
|
|
424
461
|
export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
|
|
425
462
|
const blockers = [];
|
|
426
463
|
|
|
464
|
+
// Issue #1918: Tracks whether we are still waiting for PR-triggered workflow runs to
|
|
465
|
+
// register for the current commit (0 runs observed). When true, the caller must NOT
|
|
466
|
+
// reset its consecutive-no-runs counter, otherwise the MAX_NO_RUNS_CHECKS safety valve
|
|
467
|
+
// never fires and /merge loops forever (e.g. fork PR + push-only workflow + a passing
|
|
468
|
+
// external check reporting status 'success').
|
|
469
|
+
let noWorkflowRunsForCommit = false;
|
|
470
|
+
|
|
427
471
|
// Use detailed CI status to distinguish between all possible states
|
|
428
472
|
const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
|
|
429
473
|
|
|
@@ -630,6 +674,8 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
630
674
|
if (verbose) {
|
|
631
675
|
await log(formatAligned('⏳', 'Waiting for CI:', `No workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}) — check ${checkCount}/${MAX_NO_RUNS_CHECKS}, commit age: ${commitInfo.ageSeconds ?? 'unknown'}s`, 2));
|
|
632
676
|
}
|
|
677
|
+
// Issue #1918: Still waiting for workflow runs to register — keep the counter.
|
|
678
|
+
noWorkflowRunsForCommit = true;
|
|
633
679
|
blockers.push({
|
|
634
680
|
type: 'ci_pending',
|
|
635
681
|
message: `CI/CD workflow runs have not appeared yet — workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}), waiting for GitHub to register workflow runs (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`,
|
|
@@ -640,6 +686,8 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
640
686
|
if (verbose) {
|
|
641
687
|
await log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
|
|
642
688
|
}
|
|
689
|
+
// Issue #1918: Still inside the grace-period wait for workflow runs — keep the counter.
|
|
690
|
+
noWorkflowRunsForCommit = true;
|
|
643
691
|
blockers.push({
|
|
644
692
|
type: 'ci_pending',
|
|
645
693
|
message: `CI/CD workflow runs have not appeared yet — commit is ${commitInfo.ageSeconds}s old, waiting for GitHub to register workflow runs (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s)`,
|
|
@@ -717,6 +765,9 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
717
765
|
if (verbose) {
|
|
718
766
|
await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} external checks), but repo has PR-triggered workflows with 0 workflow runs — likely race condition (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`);
|
|
719
767
|
}
|
|
768
|
+
// Issue #1918: Keep the caller's no-runs counter climbing so the safety valve
|
|
769
|
+
// can fire — otherwise a 'success' from external-only checks resets it forever.
|
|
770
|
+
noWorkflowRunsForCommit = true;
|
|
720
771
|
// Wait for GitHub Actions to register workflow runs
|
|
721
772
|
blockers.push({
|
|
722
773
|
type: 'ci_pending',
|
|
@@ -839,11 +890,12 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
839
890
|
});
|
|
840
891
|
}
|
|
841
892
|
|
|
842
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
|
|
893
|
+
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false, noWorkflowRunsForCommit };
|
|
843
894
|
};
|
|
844
895
|
|
|
845
896
|
export default {
|
|
846
897
|
checkForExistingComment,
|
|
847
898
|
checkForNonBotComments,
|
|
848
899
|
getMergeBlockers,
|
|
900
|
+
shouldResetNoRunsCounter,
|
|
849
901
|
};
|
|
@@ -54,7 +54,7 @@ import { limitReset } from './config.lib.mjs';
|
|
|
54
54
|
|
|
55
55
|
// Import helper functions extracted for file size management (Issue #1593)
|
|
56
56
|
const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
57
|
-
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
|
|
57
|
+
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, shouldResetNoRunsCounter, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
|
|
58
58
|
|
|
59
59
|
// Issue #1769: cancelled/stale CI re-run failures need a human action stop, not polling forever.
|
|
60
60
|
const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
|
|
@@ -203,10 +203,15 @@ export const watchUntilMergeable = async params => {
|
|
|
203
203
|
consecutiveNoRunsChecks++;
|
|
204
204
|
|
|
205
205
|
// Get merge blockers
|
|
206
|
-
const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
|
|
207
|
-
|
|
208
|
-
// Issue #1503: Reset counter when CI checks exist (safety valve only for
|
|
209
|
-
|
|
206
|
+
const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus, noWorkflowRunsForCommit } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
|
|
207
|
+
|
|
208
|
+
// Issue #1503/#1918: Reset counter when CI checks exist (safety valve only for
|
|
209
|
+
// consecutive "no runs"). Issue #1918: do NOT reset while getMergeBlockers is still
|
|
210
|
+
// waiting for PR-triggered workflow runs to register (noWorkflowRunsForCommit). A
|
|
211
|
+
// 'success' status from external-only checks (e.g. CodeRabbit) on a fork PR whose
|
|
212
|
+
// workflow only triggers on `push` previously reset the counter every iteration,
|
|
213
|
+
// pinning it at "check 1/5" forever and hanging /merge for over an hour.
|
|
214
|
+
if (shouldResetNoRunsCounter(ciStatus, noWorkflowRunsForCommit)) {
|
|
210
215
|
// CI checks exist (pending, success, failure, etc.) — the "no runs" counter is irrelevant
|
|
211
216
|
consecutiveNoRunsChecks = 0;
|
|
212
217
|
} else if (noCiConfigured || noCiTriggered) {
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-s
|
|
|
40
40
|
const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
|
|
41
41
|
const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
|
|
42
42
|
const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
|
|
43
|
-
const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
|
|
43
|
+
const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
|
|
44
44
|
const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
|
|
45
45
|
const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
|
|
46
46
|
const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
|
|
@@ -180,6 +180,16 @@ if (ISOLATION_BACKEND) {
|
|
|
180
180
|
}
|
|
181
181
|
console.log(`🔒 Isolation mode enabled: ${ISOLATION_BACKEND} (experimental)`);
|
|
182
182
|
isolationRunner = await import('./isolation-runner.lib.mjs');
|
|
183
|
+
// For docker isolation, run a startup preflight so a missing/un-passed-through
|
|
184
|
+
// image surfaces as a loud, actionable warning instead of a surprise multi-GB
|
|
185
|
+
// pull on the first isolated task (issues #1914, #1879). Never throws.
|
|
186
|
+
if (ISOLATION_BACKEND === 'docker' && typeof isolationRunner.preflightDockerIsolation === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
await isolationRunner.preflightDockerIsolation({ verbose: VERBOSE });
|
|
189
|
+
} catch (preflightError) {
|
|
190
|
+
console.error(`⚠️ Docker isolation preflight failed (continuing): ${preflightError?.message || preflightError}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
// Validate solve overrides early using solve's yargs config
|
|
@@ -359,6 +369,13 @@ function isForwardedOrReply(ctx) {
|
|
|
359
369
|
return _isForwardedOrReply(ctx, { verbose: VERBOSE });
|
|
360
370
|
}
|
|
361
371
|
|
|
372
|
+
// Forwarded-only check (issue #1922). Commands that support the reply feature
|
|
373
|
+
// (e.g. /task, /solve) must still reject forwarded commands without rejecting
|
|
374
|
+
// genuine user replies, so they use this instead of isForwardedOrReply.
|
|
375
|
+
function isForwarded(ctx) {
|
|
376
|
+
return _isForwarded(ctx, { verbose: VERBOSE });
|
|
377
|
+
}
|
|
378
|
+
|
|
362
379
|
/**
|
|
363
380
|
* Validates the model name in the args array and returns an error message if invalid
|
|
364
381
|
* @param {string[]} args - Array of command arguments
|
|
@@ -603,7 +620,7 @@ const { registerLanguageCommand } = await import('./telegram-language-command.li
|
|
|
603
620
|
registerLanguageCommand(bot, { VERBOSE, isOldMessage, isForwardedOrReply });
|
|
604
621
|
|
|
605
622
|
const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
|
|
606
|
-
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
|
|
623
|
+
const sharedCommandOpts = { VERBOSE, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
|
|
607
624
|
registerAcceptInvitesCommand(bot, sharedCommandOpts);
|
|
608
625
|
const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
|
|
609
626
|
registerMergeCommand(bot, sharedCommandOpts);
|
|
@@ -653,12 +670,9 @@ async function handleSolveCommand(ctx) {
|
|
|
653
670
|
}
|
|
654
671
|
|
|
655
672
|
// Check if this is a forwarded message (not allowed)
|
|
656
|
-
// But allow reply messages for URL extraction feature
|
|
673
|
+
// But allow reply messages for URL extraction feature (issue #1922)
|
|
657
674
|
const message = ctx.message;
|
|
658
|
-
|
|
659
|
-
const isOldApiForwarded = message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date;
|
|
660
|
-
|
|
661
|
-
if (isForwarded || isOldApiForwarded) {
|
|
675
|
+
if (isForwarded(ctx)) {
|
|
662
676
|
if (VERBOSE) {
|
|
663
677
|
console.log(`[VERBOSE] ${solveCommandDisplay} ignored: forwarded message`);
|
|
664
678
|
}
|
|
@@ -154,7 +154,7 @@ async function fileSize(filePath) {
|
|
|
154
154
|
* @param {Function} [options.parseGitHubUrl] - Override for tests
|
|
155
155
|
*/
|
|
156
156
|
export async function registerLogCommand(bot, options) {
|
|
157
|
-
const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
157
|
+
const { VERBOSE = false, isOldMessage, isForwarded, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
158
158
|
const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
|
|
159
159
|
const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
|
|
160
160
|
const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
|
|
@@ -167,6 +167,11 @@ export async function registerLogCommand(bot, options) {
|
|
|
167
167
|
VERBOSE && console.log('[VERBOSE] /log ignored: old message');
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
|
+
// Issue #1922: never re-execute a forwarded command.
|
|
171
|
+
if (isForwarded && isForwarded(ctx)) {
|
|
172
|
+
VERBOSE && console.log('[VERBOSE] /log ignored: forwarded message');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
170
175
|
|
|
171
176
|
const chat = ctx.chat;
|
|
172
177
|
const message = ctx.message;
|
|
@@ -57,31 +57,35 @@ export function isChatAuthorized(chatId, allowedChats) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Check if a message is
|
|
60
|
+
* Check if a message is a forwarded message.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* Unlike {@link isForwardedOrReply}, this function ONLY detects forwarded
|
|
63
|
+
* messages and intentionally ignores replies. It exists so command handlers
|
|
64
|
+
* that rely on the reply feature (e.g. `/task` issue creation, `/solve` URL
|
|
65
|
+
* extraction) can still reject forwarded commands — which must never be
|
|
66
|
+
* executed again — while continuing to accept genuine user replies.
|
|
67
|
+
*
|
|
68
|
+
* A forwarded command is dangerous because forwarding the bot's own response
|
|
69
|
+
* (or any message that starts with `/task`, `/solve`, ...) would otherwise
|
|
70
|
+
* trigger a brand-new execution that the user never intended.
|
|
68
71
|
*
|
|
69
72
|
* @param {Object} ctx - Telegraf context object
|
|
70
73
|
* @param {Object} [options] - Options
|
|
71
74
|
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
72
|
-
* @returns {boolean} true if message is forwarded
|
|
75
|
+
* @returns {boolean} true if message is forwarded (and should be filtered)
|
|
76
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1922
|
|
73
77
|
*/
|
|
74
|
-
export function
|
|
78
|
+
export function isForwarded(ctx, options = {}) {
|
|
75
79
|
const message = ctx.message;
|
|
76
80
|
if (!message) {
|
|
77
81
|
if (options.verbose) {
|
|
78
|
-
console.log('[VERBOSE]
|
|
82
|
+
console.log('[VERBOSE] isForwarded: No message object');
|
|
79
83
|
}
|
|
80
84
|
return false;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
if (options.verbose) {
|
|
84
|
-
console.log('[VERBOSE]
|
|
88
|
+
console.log('[VERBOSE] isForwarded: Checking forwarding fields...');
|
|
85
89
|
console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
|
|
86
90
|
console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
|
|
87
91
|
console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
|
|
@@ -90,8 +94,6 @@ export function isForwardedOrReply(ctx, options = {}) {
|
|
|
90
94
|
console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
|
|
91
95
|
console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
|
|
92
96
|
console.log('[VERBOSE] message.forward_date:', message.forward_date);
|
|
93
|
-
console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
|
|
94
|
-
console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// Check if message is forwarded (has forward_origin field with actual content)
|
|
@@ -99,15 +101,57 @@ export function isForwardedOrReply(ctx, options = {}) {
|
|
|
99
101
|
// which are truthy in JavaScript but don't indicate a forwarded message
|
|
100
102
|
if (message.forward_origin && message.forward_origin.type) {
|
|
101
103
|
if (options.verbose) {
|
|
102
|
-
console.log('[VERBOSE]
|
|
104
|
+
console.log('[VERBOSE] isForwarded: TRUE - forward_origin.type exists:', message.forward_origin.type);
|
|
103
105
|
}
|
|
104
106
|
return true;
|
|
105
107
|
}
|
|
106
108
|
// Also check old forwarding API fields for backward compatibility
|
|
107
109
|
if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
|
|
108
110
|
if (options.verbose) {
|
|
109
|
-
console.log('[VERBOSE]
|
|
111
|
+
console.log('[VERBOSE] isForwarded: TRUE - old forwarding API field detected');
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.verbose) {
|
|
117
|
+
console.log('[VERBOSE] isForwarded: FALSE - no forwarding detected');
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if a message is forwarded or a reply to another user's message.
|
|
124
|
+
*
|
|
125
|
+
* This function distinguishes between:
|
|
126
|
+
* 1. Forwarded messages (should be ignored)
|
|
127
|
+
* 2. User replies to other messages (should be ignored, except for /solve reply feature)
|
|
128
|
+
* 3. Forum topic messages (should NOT be ignored - they have reply_to_message pointing
|
|
129
|
+
* to the topic's first message with forum_topic_created)
|
|
130
|
+
* 4. Normal messages (should NOT be ignored)
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} ctx - Telegraf context object
|
|
133
|
+
* @param {Object} [options] - Options
|
|
134
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
135
|
+
* @returns {boolean} true if message is forwarded or a reply (and should be filtered)
|
|
136
|
+
*/
|
|
137
|
+
export function isForwardedOrReply(ctx, options = {}) {
|
|
138
|
+
const message = ctx.message;
|
|
139
|
+
if (!message) {
|
|
140
|
+
if (options.verbose) {
|
|
141
|
+
console.log('[VERBOSE] isForwardedOrReply: No message object');
|
|
110
142
|
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.verbose) {
|
|
147
|
+
console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
|
|
148
|
+
console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
|
|
149
|
+
console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Forwarded messages are handled by the shared isForwarded() filter so the
|
|
153
|
+
// forwarding detection logic lives in exactly one place (issue #1922).
|
|
154
|
+
if (isForwarded(ctx, options)) {
|
|
111
155
|
return true;
|
|
112
156
|
}
|
|
113
157
|
// Check if message is a reply (has reply_to_message field with actual content)
|
|
@@ -270,7 +270,7 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
|
|
|
270
270
|
* See https://github.com/link-assistant/hive-mind/issues/1871.
|
|
271
271
|
*/
|
|
272
272
|
export function registerStartStopCommands(bot, options) {
|
|
273
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
273
|
+
const { VERBOSE = false, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
274
274
|
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
275
275
|
// Issue #1783: look the UUID up in the session monitor so /stop can let the
|
|
276
276
|
// user who started the task stop it (mirrors /terminal_watch from PR #1779).
|
|
@@ -529,6 +529,13 @@ export function registerStartStopCommands(bot, options) {
|
|
|
529
529
|
VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
|
|
530
530
|
return;
|
|
531
531
|
}
|
|
532
|
+
// Issue #1922: a forwarded /stop (even a targeted `/stop <uuid>`) must never
|
|
533
|
+
// be re-executed. Replies are still allowed because the targeted modes use
|
|
534
|
+
// them on purpose (#524, #1780), so we use the forwarded-only filter here.
|
|
535
|
+
if (isForwarded && isForwarded(ctx)) {
|
|
536
|
+
VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded message');
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
532
539
|
|
|
533
540
|
// Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
|
|
534
541
|
// the chat-level stop, because both targeted modes are intentionally
|
|
@@ -95,7 +95,7 @@ function injectLanguageIfMissing(args, locale) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export function registerTaskCommands(bot, options) {
|
|
98
|
-
const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, createTaskIssue: createTaskIssueFn = createTaskIssue, resolveLocale = null } = options;
|
|
98
|
+
const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isForwarded, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, createTaskIssue: createTaskIssueFn = createTaskIssue, resolveLocale = null } = options;
|
|
99
99
|
|
|
100
100
|
async function handleTaskCommand(ctx) {
|
|
101
101
|
const commandName = getTaskCommandNameFromText(ctx.message?.text) || 'task';
|
|
@@ -114,6 +114,13 @@ export function registerTaskCommands(bot, options) {
|
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
116
|
if (isOldMessage(ctx)) return;
|
|
117
|
+
// Issue #1922: a forwarded /task command must never be re-executed. Replies
|
|
118
|
+
// are still allowed because /task uses them for issue creation, so we use the
|
|
119
|
+
// forwarded-only filter instead of isForwardedOrReply.
|
|
120
|
+
if (isForwarded && isForwarded(ctx)) {
|
|
121
|
+
VERBOSE && console.log(`[VERBOSE] ${commandDisplay} ignored: forwarded message`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
117
124
|
if (!isGroupChat(ctx)) {
|
|
118
125
|
await ctx.reply(`❌ The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
|
|
119
126
|
return;
|
|
@@ -307,7 +307,7 @@ export async function startAutoTerminalWatchForSession({ bot, ctx, sessionId, se
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
export async function registerTerminalWatchCommand(bot, options) {
|
|
310
|
-
const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
310
|
+
const { VERBOSE = false, isOldMessage, isForwarded, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
311
311
|
const runner = !options.querySessionStatus || !options.isTerminalSessionStatus ? await import('./isolation-runner.lib.mjs') : null;
|
|
312
312
|
const querySessionStatus = options.querySessionStatus || runner.querySessionStatus;
|
|
313
313
|
const isTerminalSessionStatus = options.isTerminalSessionStatus || runner.isTerminalSessionStatus;
|
|
@@ -318,6 +318,11 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
318
318
|
const handleTerminalWatchCommand = async ctx => {
|
|
319
319
|
VERBOSE && console.log('[VERBOSE] /terminal_watch command received');
|
|
320
320
|
if (isOldMessage && isOldMessage(ctx)) return;
|
|
321
|
+
// Issue #1922: never re-execute a forwarded command.
|
|
322
|
+
if (isForwarded && isForwarded(ctx)) {
|
|
323
|
+
VERBOSE && console.log('[VERBOSE] /terminal_watch ignored: forwarded message');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
321
326
|
|
|
322
327
|
const chat = ctx.chat;
|
|
323
328
|
const message = ctx.message;
|
|
@@ -90,7 +90,7 @@ export const formatTokenList = tokens => {
|
|
|
90
90
|
* @param {Function} [options.fetchTokens] — test override for getAllKnownLocalTokens
|
|
91
91
|
*/
|
|
92
92
|
export const registerTokensCommand = (bot, options = {}) => {
|
|
93
|
-
const { VERBOSE = false, isOldMessage, allowedChats } = options;
|
|
93
|
+
const { VERBOSE = false, isOldMessage, isForwarded, allowedChats } = options;
|
|
94
94
|
const fetchTokens = options.fetchTokens || getAllKnownLocalTokens;
|
|
95
95
|
|
|
96
96
|
bot.command('tokens', async ctx => {
|
|
@@ -98,6 +98,11 @@ export const registerTokensCommand = (bot, options = {}) => {
|
|
|
98
98
|
VERBOSE && console.log('[VERBOSE] /tokens ignored: old message');
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
|
+
// Issue #1922: never re-execute a forwarded command.
|
|
102
|
+
if (isForwarded && isForwarded(ctx)) {
|
|
103
|
+
VERBOSE && console.log('[VERBOSE] /tokens ignored: forwarded message');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
101
106
|
|
|
102
107
|
const chat = ctx.chat;
|
|
103
108
|
if (!chat || !ctx.from) return;
|