@link-assistant/hive-mind 2.11.12 → 2.12.0
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 +51 -0
- package/package.json +1 -1
- package/src/agent-command.lib.mjs +74 -0
- package/src/agent.lib.mjs +59 -34
- package/src/agentic-cli-updater.lib.mjs +241 -0
- package/src/automation-stop-reporting.lib.mjs +272 -0
- package/src/formal-ai-isolation.lib.mjs +62 -0
- package/src/formal-ai-maintenance.lib.mjs +106 -0
- package/src/formal-ai-model.lib.mjs +25 -0
- package/src/formal-ai-runtime.lib.mjs +10 -0
- package/src/formal-ai-sidecar.lib.mjs +565 -0
- package/src/formal-ai-updater.lib.mjs +294 -0
- package/src/formal-ai-version.lib.mjs +100 -0
- package/src/formal-ai.lib.mjs +11 -16
- package/src/github-terminal-state.lib.mjs +50 -13
- package/src/isolation-runner.lib.mjs +32 -1
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -4
- package/src/solve.auto-merge-attempt.lib.mjs +245 -0
- package/src/solve.auto-merge.lib.mjs +54 -111
- package/src/solve.results.lib.mjs +2 -2
- package/src/solve.watch.lib.mjs +35 -1
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/tool-comments.lib.mjs +12 -1
- package/src/working-session-summary.lib.mjs +47 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Announce on GitHub *why* a long-running automation loop stopped.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2144: `--auto-restart-until-mergeable` and `--watch` used to exit
|
|
7
|
+
* silently on several paths (terminal GitHub entity states, tool execution
|
|
8
|
+
* failures, auto-resume limit). The reported incident stopped the loop on an
|
|
9
|
+
* open, mergeable pull request because its linked issue was closed, and left
|
|
10
|
+
* no GitHub comment at all — from the pull request's point of view the
|
|
11
|
+
* automation simply vanished.
|
|
12
|
+
*
|
|
13
|
+
* Two things live here:
|
|
14
|
+
* 1. A registry that turns an internal stop reason into human-readable text
|
|
15
|
+
* (what happened, what it means, what the user should do next).
|
|
16
|
+
* 2. `reportAutomationStop`, which posts that text as a deduplicated,
|
|
17
|
+
* tracked tool comment. Every stop path calls it, so "we stopped and
|
|
18
|
+
* exactly why" is always published.
|
|
19
|
+
*
|
|
20
|
+
* The module is intentionally free of top-level `command-stream` /`use-m`
|
|
21
|
+
* imports: the comment builders are pure functions and can be unit-tested
|
|
22
|
+
* without a GitHub environment. The `$` helper is passed in by callers.
|
|
23
|
+
*
|
|
24
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2144
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { AUTOMATION_STOPPED_MARKER, AUTO_MERGE_BLOCKED_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
|
|
28
|
+
|
|
29
|
+
export { AUTOMATION_STOPPED_MARKER, AUTO_MERGE_BLOCKED_MARKER };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Human-readable descriptions for every stop reason the solver can return.
|
|
33
|
+
*
|
|
34
|
+
* `canComment: false` marks reasons where the comment target itself is gone
|
|
35
|
+
* (deleted repository / pull request), so posting is skipped instead of
|
|
36
|
+
* producing a guaranteed API failure.
|
|
37
|
+
*/
|
|
38
|
+
export const STOP_REASONS = {
|
|
39
|
+
pull_request_closed: {
|
|
40
|
+
title: 'the pull request was closed without merging',
|
|
41
|
+
detail: 'A closed pull request can never become mergeable, so continuing to work on it would be pointless.',
|
|
42
|
+
nextSteps: ['Reopen the pull request and re-run the command to continue.'],
|
|
43
|
+
},
|
|
44
|
+
pull_request_unavailable: {
|
|
45
|
+
title: 'the pull request is no longer accessible',
|
|
46
|
+
detail: 'GitHub answered with 404/410 for this pull request (deleted, transferred, or access revoked).',
|
|
47
|
+
nextSteps: ['Verify the pull request still exists and that the token has access to it.'],
|
|
48
|
+
canComment: false,
|
|
49
|
+
},
|
|
50
|
+
repository_unavailable: {
|
|
51
|
+
title: 'the repository is no longer accessible',
|
|
52
|
+
detail: 'GitHub answered with 404/410 for the repository (deleted, renamed, made private, or access revoked).',
|
|
53
|
+
nextSteps: ['Verify the repository still exists and that the token has access to it.'],
|
|
54
|
+
canComment: false,
|
|
55
|
+
},
|
|
56
|
+
source_branch_unavailable: {
|
|
57
|
+
title: 'the source branch of the pull request is gone',
|
|
58
|
+
detail: 'The head branch (or its repository) is no longer accessible, so no further commits can be pushed to this pull request.',
|
|
59
|
+
nextSteps: ['Restore the source branch, or open a new pull request from a branch that still exists.'],
|
|
60
|
+
},
|
|
61
|
+
target_branch_unavailable: {
|
|
62
|
+
title: 'the target branch of the pull request is gone',
|
|
63
|
+
detail: 'The base branch (or its repository) is no longer accessible, so this pull request can never be merged as-is.',
|
|
64
|
+
nextSteps: ['Restore the base branch, or retarget this pull request to an existing branch.'],
|
|
65
|
+
},
|
|
66
|
+
terminal_github_entity_error: {
|
|
67
|
+
title: 'a GitHub entity required by this automation is no longer accessible',
|
|
68
|
+
detail: 'A repository, pull request, or branch answered with 404/410 while checking CI status.',
|
|
69
|
+
nextSteps: ['Verify the repository, pull request, and branches still exist and that the token has access to them.'],
|
|
70
|
+
},
|
|
71
|
+
auto_resume_limit_reached: {
|
|
72
|
+
title: 'the usage-limit auto-resume budget was exhausted',
|
|
73
|
+
detail: 'The AI session hit provider usage limits more times than `--auto-resume-max-iterations` allows.',
|
|
74
|
+
nextSteps: ['Re-run the command after the usage limit resets, or raise `--auto-resume-max-iterations`.'],
|
|
75
|
+
},
|
|
76
|
+
tool_failure: {
|
|
77
|
+
title: 'the AI session failed',
|
|
78
|
+
detail: 'The AI tool exited with an error that is not a usage limit, so restarting it automatically would most likely fail the same way.',
|
|
79
|
+
nextSteps: ['Review the attached working session log for the failure, fix the cause, and re-run the command.'],
|
|
80
|
+
},
|
|
81
|
+
tool_failure_after_resume: {
|
|
82
|
+
title: 'the AI session failed after resuming from a usage limit',
|
|
83
|
+
detail: 'The session was resumed once the usage limit reset, but the resumed run exited with an error.',
|
|
84
|
+
nextSteps: ['Review the attached working session log for the failure, fix the cause, and re-run the command.'],
|
|
85
|
+
},
|
|
86
|
+
merge_failed: {
|
|
87
|
+
title: 'GitHub refused the merge',
|
|
88
|
+
detail: 'Every merge requirement was satisfied, but the merge API call itself failed (branch protection, required reviews, or a race with another push).',
|
|
89
|
+
nextSteps: ['Check the branch protection rules and required reviews, then merge manually or re-run the command.'],
|
|
90
|
+
},
|
|
91
|
+
issue_closed: {
|
|
92
|
+
title: 'the linked issue is closed, so auto-merge was held back',
|
|
93
|
+
detail: 'The pull request is ready to merge. A closed issue never stops work on the pull request — it only blocks the automatic merge.',
|
|
94
|
+
nextSteps: ['Reopen the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.'],
|
|
95
|
+
},
|
|
96
|
+
issue_unavailable: {
|
|
97
|
+
title: 'the linked issue is no longer accessible, so auto-merge was held back',
|
|
98
|
+
detail: 'The pull request is ready to merge. A missing issue never stops work on the pull request — it only blocks the automatic merge.',
|
|
99
|
+
nextSteps: ['Restore or re-create the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.'],
|
|
100
|
+
},
|
|
101
|
+
watch_stopped: {
|
|
102
|
+
title: 'watch mode stopped',
|
|
103
|
+
detail: 'The watch loop reached a state where it can no longer make progress.',
|
|
104
|
+
nextSteps: ['Re-run the command once the reported condition is resolved.'],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const MODE_LABELS = {
|
|
109
|
+
'auto-restart-until-mergeable': '`--auto-restart-until-mergeable`',
|
|
110
|
+
'auto-merge': '`--auto-merge`',
|
|
111
|
+
watch: '`--watch`',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a stop reason to its description, with a safe fallback so an unknown
|
|
116
|
+
* or newly added reason is still reported (never silently swallowed).
|
|
117
|
+
*
|
|
118
|
+
* @param {string} reason
|
|
119
|
+
* @returns {{reason: string, title: string, detail: string, nextSteps: string[], canComment: boolean, known: boolean}}
|
|
120
|
+
*/
|
|
121
|
+
export const describeStopReason = reason => {
|
|
122
|
+
const key = String(reason || 'unknown');
|
|
123
|
+
const known = Object.prototype.hasOwnProperty.call(STOP_REASONS, key);
|
|
124
|
+
const entry = known ? STOP_REASONS[key] : null;
|
|
125
|
+
return {
|
|
126
|
+
reason: key,
|
|
127
|
+
title: entry?.title || `the automation stopped with reason \`${key}\``,
|
|
128
|
+
detail: entry?.detail || 'No further automatic progress is possible in this state.',
|
|
129
|
+
nextSteps: entry?.nextSteps || ['Review the working session log, resolve the reported condition, and re-run the command.'],
|
|
130
|
+
canComment: entry?.canComment !== false,
|
|
131
|
+
known,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const bulletList = lines =>
|
|
136
|
+
(lines || [])
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.map(line => `- ${line}`)
|
|
139
|
+
.join('\n');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the "automation stopped" comment body.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} options
|
|
145
|
+
* @param {string} options.reason internal stop reason
|
|
146
|
+
* @param {string} [options.mode] which loop stopped
|
|
147
|
+
* @param {string} [options.message] concrete message from the detector
|
|
148
|
+
* @param {string[]} [options.details] extra evidence lines
|
|
149
|
+
* @returns {string} markdown comment body
|
|
150
|
+
*/
|
|
151
|
+
export const buildAutomationStopComment = ({ reason, mode = null, message = null, details = [] }) => {
|
|
152
|
+
const description = describeStopReason(reason);
|
|
153
|
+
const modeLabel = MODE_LABELS[mode] || (mode ? `\`${mode}\`` : 'This automation');
|
|
154
|
+
const sections = [`## 🛑 ${AUTOMATION_STOPPED_MARKER}: ${description.title}`, '', `${modeLabel} stopped working on this pull request.`, '', `**Reason code:** \`${description.reason}\``];
|
|
155
|
+
|
|
156
|
+
if (message) {
|
|
157
|
+
sections.push('', `**What happened:** ${message}`);
|
|
158
|
+
}
|
|
159
|
+
sections.push('', description.detail);
|
|
160
|
+
|
|
161
|
+
const evidence = (details || []).filter(Boolean);
|
|
162
|
+
if (evidence.length > 0) {
|
|
163
|
+
sections.push('', '**Details:**', bulletList(evidence));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
sections.push('', '**What to do next:**', bulletList(description.nextSteps));
|
|
167
|
+
sections.push('', '---', `*Reported automatically by hive-mind (${mode || 'automation'}).*`);
|
|
168
|
+
|
|
169
|
+
return sections.join('\n');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the comment posted when the pull request is ready but `--auto-merge`
|
|
174
|
+
* is blocked by the state of the linked issue.
|
|
175
|
+
*
|
|
176
|
+
* Issue #2144: a closed issue must never stop the loop from making the pull
|
|
177
|
+
* request mergeable — it only blocks the *automatic* merge, and then the user
|
|
178
|
+
* is asked to reopen the issue or merge manually.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} options
|
|
181
|
+
* @param {Array<{reason: string, message: string, resolution?: string, details?: string[]}>} options.blockers
|
|
182
|
+
* @param {number|string|null} [options.issueNumber]
|
|
183
|
+
* @returns {string} markdown comment body
|
|
184
|
+
*/
|
|
185
|
+
export const buildAutoMergeBlockedComment = ({ blockers = [], issueNumber = null }) => {
|
|
186
|
+
const reasons = blockers.filter(Boolean);
|
|
187
|
+
const sections = [`## ⚠️ ${AUTO_MERGE_BLOCKED_MARKER}: this pull request is ready, but it was not merged automatically`, '', 'All merge requirements are satisfied — CI passed, there are no conflicts, and there are no pending changes.', '', 'Auto-merge (`--auto-merge`) was requested but is being held back:'];
|
|
188
|
+
|
|
189
|
+
for (const blocker of reasons) {
|
|
190
|
+
sections.push('', `- **${blocker.message}** (\`${blocker.reason}\`)`);
|
|
191
|
+
for (const detail of blocker.details || []) {
|
|
192
|
+
sections.push(` - ${detail}`);
|
|
193
|
+
}
|
|
194
|
+
if (blocker.resolution) {
|
|
195
|
+
sections.push(` - ➡️ ${blocker.resolution}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
sections.push('', '**What to do next:**');
|
|
200
|
+
sections.push(bulletList([issueNumber ? `Reopen issue #${issueNumber} and re-run the command so auto-merge can complete.` : 'Reopen the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.']));
|
|
201
|
+
sections.push('', '---', '*Reported automatically by hive-mind with the --auto-merge flag.*');
|
|
202
|
+
|
|
203
|
+
return sections.join('\n');
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Post a stop report to the pull request (or issue), deduplicated per reason.
|
|
208
|
+
*
|
|
209
|
+
* Never throws: a failed comment must not mask the stop itself.
|
|
210
|
+
*
|
|
211
|
+
* @param {Object} options
|
|
212
|
+
* @param {Function} options.$ command-stream tagged template
|
|
213
|
+
* @param {string} options.owner
|
|
214
|
+
* @param {string} options.repo
|
|
215
|
+
* @param {number|string} options.targetNumber pull request (or issue) number
|
|
216
|
+
* @param {string} options.reason
|
|
217
|
+
* @param {string} [options.mode]
|
|
218
|
+
* @param {string} [options.message]
|
|
219
|
+
* @param {string[]} [options.details]
|
|
220
|
+
* @param {boolean} [options.verbose]
|
|
221
|
+
* @param {Function} [options.log]
|
|
222
|
+
* @param {string} [options.body] pre-built body (skips buildAutomationStopComment)
|
|
223
|
+
* @param {string} [options.signature] pre-built dedup signature
|
|
224
|
+
* @returns {Promise<{posted: boolean, reason: string, skipped?: string, error?: string}>}
|
|
225
|
+
*/
|
|
226
|
+
export const reportAutomationStop = async ({ $, owner, repo, targetNumber, reason, mode = null, message = null, details = [], verbose = false, log = null, body = null, signature = null }) => {
|
|
227
|
+
const description = describeStopReason(reason);
|
|
228
|
+
const write = async text => {
|
|
229
|
+
if (typeof log === 'function') await log(text);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (!$ || !owner || !repo || !targetNumber) {
|
|
233
|
+
return { posted: false, reason: description.reason, skipped: 'missing_target' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!description.canComment) {
|
|
237
|
+
await write(` ℹ️ Not posting a stop comment: ${description.title}`);
|
|
238
|
+
return { posted: false, reason: description.reason, skipped: 'target_unavailable' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const commentBody = body || buildAutomationStopComment({ reason, mode, message, details });
|
|
242
|
+
const dedupSignature = signature || `${AUTOMATION_STOPPED_MARKER}: ${description.title}`;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const { checkForExistingComment } = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
246
|
+
const alreadyPosted = await checkForExistingComment(owner, repo, targetNumber, dedupSignature, verbose);
|
|
247
|
+
if (alreadyPosted) {
|
|
248
|
+
await write(` ℹ️ Stop reason already reported on #${targetNumber} (${description.reason})`);
|
|
249
|
+
return { posted: false, reason: description.reason, skipped: 'duplicate' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result = await postTrackedComment({ $, owner, repo, targetNumber, body: commentBody });
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
await write(` ⚠️ Could not post stop reason comment: ${result.stderr || 'unknown error'}`);
|
|
255
|
+
return { posted: false, reason: description.reason, error: result.stderr || 'post_failed' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await write(` 💬 Posted stop reason to #${targetNumber}: ${description.title}`);
|
|
259
|
+
return { posted: true, reason: description.reason };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
await write(` ⚠️ Could not post stop reason comment: ${error.message}`);
|
|
262
|
+
return { posted: false, reason: description.reason, error: error.message };
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export default {
|
|
267
|
+
STOP_REASONS,
|
|
268
|
+
describeStopReason,
|
|
269
|
+
buildAutomationStopComment,
|
|
270
|
+
buildAutoMergeBlockedComment,
|
|
271
|
+
reportAutomationStop,
|
|
272
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glue between the Docker isolation runner and the on-demand Formal AI sidecar
|
|
3
|
+
* (issue #2146, PR #2147 review).
|
|
4
|
+
*
|
|
5
|
+
* The lifecycle itself lives in `./formal-ai-sidecar.lib.mjs`; this module is
|
|
6
|
+
* the launch-time policy, kept separate so the runner stays readable and so the
|
|
7
|
+
* policy can be tested without launching a container:
|
|
8
|
+
*
|
|
9
|
+
* - a Formal AI task takes a lease *before* the container is created, because
|
|
10
|
+
* the endpoint has to be in the task's environment;
|
|
11
|
+
* - the container is attached to the internal network while the start gate
|
|
12
|
+
* still holds the task command back;
|
|
13
|
+
* - anything that goes wrong fails the launch closed, because issue #2146
|
|
14
|
+
* forbids a Formal AI task from silently continuing on another model.
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { acquireFormalAiSidecar, attachTaskToFormalAiNetwork, isFormalAiSidecarEnabled, isFormalAiTask, releaseFormalAiSidecar } from './formal-ai-sidecar.lib.mjs';
|
|
20
|
+
|
|
21
|
+
const logToConsole = message => console.log(message);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Take a sidecar lease when this task will be driven by Formal AI.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<{sidecar: object|null, error: string|null}>} `sidecar` is
|
|
27
|
+
* null when Formal AI is not involved (with `error` null) or when the sidecar
|
|
28
|
+
* refused to start (with `error` set — the caller must abort the launch).
|
|
29
|
+
*/
|
|
30
|
+
export const acquireFormalAiSidecarForTask = async ({ backend, args = [], model = null, tool = null, sessionId, env = process.env, verbose = false, log = logToConsole, acquire = acquireFormalAiSidecar } = {}) => {
|
|
31
|
+
if (backend !== 'docker' || !isFormalAiTask({ args, model }) || !isFormalAiSidecarEnabled(env)) return { sidecar: null, error: null };
|
|
32
|
+
try {
|
|
33
|
+
return { sidecar: await acquire({ sessionId, tool, model, env, verbose, log }), error: null };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { sidecar: null, error: `Formal AI sidecar could not be started, so the task was not launched (issue #2146): ${error?.message || error}` };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add the internal Formal AI network to the freshly-created task container.
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<string|null>} An error message when the task cannot reach
|
|
43
|
+
* Formal AI, or null when there is nothing to do or the attach succeeded.
|
|
44
|
+
*/
|
|
45
|
+
export const attachFormalAiTaskContainer = async ({ sidecar, sessionId, verbose = false, log = logToConsole, attach = attachTaskToFormalAiNetwork } = {}) => {
|
|
46
|
+
if (!sidecar) return null;
|
|
47
|
+
const result = await attach({ sessionId, verbose, log });
|
|
48
|
+
return result?.attached ? null : result?.error || 'unknown error';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Give the lease back; never throws, because a failed release must not mask the launch error. */
|
|
52
|
+
export const releaseFormalAiSidecarForTask = async ({ sidecar, sessionId, env = process.env, verbose = false, log = logToConsole, release = releaseFormalAiSidecar } = {}) => {
|
|
53
|
+
if (!sidecar) return null;
|
|
54
|
+
try {
|
|
55
|
+
return await release({ sessionId, env, verbose, log });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`[formal-ai-isolation] Could not release the Formal AI lease for '${sessionId}': ${error?.message || error}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseFormalAiSidecarForTask };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background maintenance for the Formal AI sidecar and the agentic CLIs
|
|
3
|
+
* (issue #2146, PR #2147 review).
|
|
4
|
+
*
|
|
5
|
+
* One periodic tick performs the three idle-only duties the review asked for,
|
|
6
|
+
* in the only order that is safe:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Reconcile and stop.** Drop leases whose task container is gone and, if
|
|
9
|
+
* nothing is left, stop the sidecar. Must run first — the update and the
|
|
10
|
+
* CLI refresh both require an idle host, and a crashed task would
|
|
11
|
+
* otherwise keep the sidecar alive forever.
|
|
12
|
+
* 2. **Update the Formal AI image**, including the non-destructive memory
|
|
13
|
+
* migration, while the sidecar is stopped.
|
|
14
|
+
* 3. **Refresh the agentic CLIs**, which is throttled independently because
|
|
15
|
+
* it queries the npm registry.
|
|
16
|
+
*
|
|
17
|
+
* Every step is best-effort: maintenance must never take the bot down, and a
|
|
18
|
+
* failure is reported and retried on the next tick rather than thrown.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { updateAgenticClisWhenIdle } from './agentic-cli-updater.lib.mjs';
|
|
24
|
+
import { reconcileFormalAiSidecar, stopFormalAiSidecar, withFormalAiSidecarLock } from './formal-ai-sidecar.lib.mjs';
|
|
25
|
+
import { updateFormalAiSidecarWhenIdle } from './formal-ai-updater.lib.mjs';
|
|
26
|
+
|
|
27
|
+
/** Default gap between maintenance ticks. */
|
|
28
|
+
export const DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stop the sidecar when no Formal AI task holds a lease any more.
|
|
32
|
+
*
|
|
33
|
+
* This is what turns "the task finished" into "the container is gone" without
|
|
34
|
+
* having to hook every completion path: a lease is only live while its task
|
|
35
|
+
* container is, so a finished, killed or crashed task all converge here.
|
|
36
|
+
*
|
|
37
|
+
* @returns {Promise<{leaseCount: number, stopped: boolean}>}
|
|
38
|
+
*/
|
|
39
|
+
export const stopIdleFormalAiSidecar = async ({ env = process.env, run, log = null, verbose = false, lockOptions = {} } = {}) =>
|
|
40
|
+
withFormalAiSidecarLock(
|
|
41
|
+
async () => {
|
|
42
|
+
const { leaseCount, container } = await reconcileFormalAiSidecar({ env, run, log, verbose });
|
|
43
|
+
if (leaseCount > 0 || !container.exists) return { leaseCount, stopped: false };
|
|
44
|
+
const { stopped } = await stopFormalAiSidecar({ env, run, log, verbose, reason: 'no Formal AI tasks running' });
|
|
45
|
+
return { leaseCount: 0, stopped };
|
|
46
|
+
},
|
|
47
|
+
{ env, log, ...lockOptions }
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run one maintenance tick.
|
|
52
|
+
*
|
|
53
|
+
* @returns {Promise<{idle: object, formalAi: object|null, agenticClis: object|null, errors: object[]}>}
|
|
54
|
+
*/
|
|
55
|
+
export const runFormalAiMaintenanceTick = async ({ env = process.env, run, log = null, verbose = false, updateFormalAi = updateFormalAiSidecarWhenIdle, updateClis = updateAgenticClisWhenIdle, stopIdle = stopIdleFormalAiSidecar } = {}) => {
|
|
56
|
+
const errors = [];
|
|
57
|
+
|
|
58
|
+
let idle = { leaseCount: null, stopped: false };
|
|
59
|
+
try {
|
|
60
|
+
idle = await stopIdle({ env, run, log, verbose });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
errors.push({ stage: 'stop-idle', error: error?.message || String(error) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let formalAi = null;
|
|
66
|
+
try {
|
|
67
|
+
formalAi = await updateFormalAi({ env, run, log, verbose });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
errors.push({ stage: 'formal-ai-update', error: error?.message || String(error) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let agenticClis = null;
|
|
73
|
+
try {
|
|
74
|
+
agenticClis = await updateClis({ env, run, log, verbose });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
errors.push({ stage: 'agentic-cli-update', error: error?.message || String(error) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (log && errors.length > 0) await log(`⚠️ Formal AI maintenance tick had ${errors.length} problem(s): ${errors.map(entry => `${entry.stage}: ${entry.error}`).join('; ')}`);
|
|
80
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-maintenance: leases=${idle.leaseCount ?? 'unknown'} stopped=${idle.stopped} update=${formalAi?.status ?? 'skipped'} clis=${agenticClis?.status ?? 'skipped'}`);
|
|
81
|
+
return { idle, formalAi, agenticClis, errors };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Start the periodic maintenance timer.
|
|
86
|
+
*
|
|
87
|
+
* The timer is unref'd so it never keeps the process alive on shutdown.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{stop: () => void}}
|
|
90
|
+
*/
|
|
91
|
+
export const startFormalAiMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runFormalAiMaintenanceTick } = {}) => {
|
|
92
|
+
const tick = () => {
|
|
93
|
+
runTick({ env, log, verbose }).catch(error => {
|
|
94
|
+
console.error(`[formal-ai-maintenance] tick failed: ${error?.message || error}`);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const timer = setIntervalImpl(tick, intervalMs);
|
|
99
|
+
timer?.unref?.();
|
|
100
|
+
tick();
|
|
101
|
+
return {
|
|
102
|
+
stop: () => clearIntervalImpl(timer),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export default { DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, runFormalAiMaintenanceTick, startFormalAiMaintenance, stopIdleFormalAiSidecar };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formal AI model identity — the smallest module that can answer "is this task
|
|
3
|
+
* driven by Formal AI?".
|
|
4
|
+
*
|
|
5
|
+
* These three symbols used to live in `src/models/index.mjs`, which bootstraps
|
|
6
|
+
* `use-m` at import time and therefore reaches the network. The Formal AI
|
|
7
|
+
* sidecar lifecycle (issue #2146) is imported by the isolation runner, whose
|
|
8
|
+
* own regression test asserts that importing it performs no network fetch, so
|
|
9
|
+
* the identity check has to be reachable without dragging the whole model
|
|
10
|
+
* catalogue in. `src/models/index.mjs` re-exports these so there is still a
|
|
11
|
+
* single definition.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The alias users type: `--model formal-ai`. */
|
|
17
|
+
export const FORMAL_AI_MODEL_ALIAS = 'formal-ai';
|
|
18
|
+
|
|
19
|
+
/** The provider-qualified id used when a tool routes through a provider. */
|
|
20
|
+
export const FORMAL_AI_PROVIDER_MODEL_ID = 'formalai/formal-ai';
|
|
21
|
+
|
|
22
|
+
/** True when a model string names Formal AI in either spelling. */
|
|
23
|
+
export const isFormalAiModel = model => model === FORMAL_AI_MODEL_ALIAS || model === FORMAL_AI_PROVIDER_MODEL_ID;
|
|
24
|
+
|
|
25
|
+
export default { FORMAL_AI_MODEL_ALIAS, FORMAL_AI_PROVIDER_MODEL_ID, isFormalAiModel };
|
|
@@ -53,6 +53,8 @@ import { homedir } from 'node:os';
|
|
|
53
53
|
import { dirname, join } from 'node:path';
|
|
54
54
|
import { promisify } from 'node:util';
|
|
55
55
|
|
|
56
|
+
import { assertSupportedFormalAiVersion, FORMAL_AI_MINIMUM_VERSION, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
|
|
57
|
+
|
|
56
58
|
const execFileAsync = promisify(execFile);
|
|
57
59
|
|
|
58
60
|
export const FORMAL_AI_DEFAULT_API_KEY = 'formal-ai';
|
|
@@ -359,6 +361,13 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
|
|
|
359
361
|
|
|
360
362
|
installExitHook();
|
|
361
363
|
|
|
364
|
+
// Issue #2146: `--no-tool-check` skipped the only version probe, allowing an
|
|
365
|
+
// old Formal AI build to return the same unexecuted plan through all five
|
|
366
|
+
// Claude/Codex restarts. Runtime safety cannot depend on preflight options.
|
|
367
|
+
const formalAiVersion = await (deps.readVersionImpl || readFormalAiBinaryVersion)({ formalAiPath: resolvedFormalAiPath, env });
|
|
368
|
+
assertSupportedFormalAiVersion(formalAiVersion);
|
|
369
|
+
await log(`🧠 Formal AI: version ${formalAiVersion} (minimum ${FORMAL_AI_MINIMUM_VERSION})`);
|
|
370
|
+
|
|
362
371
|
const apiKey = resolveFormalAiApiKey(env);
|
|
363
372
|
const externalBaseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL?.trim() || null;
|
|
364
373
|
const homeRoot = resolveFormalAiHomeRoot(env);
|
|
@@ -420,6 +429,7 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
|
|
|
420
429
|
client,
|
|
421
430
|
notes,
|
|
422
431
|
serverStarted: !!server,
|
|
432
|
+
formalAiVersion,
|
|
423
433
|
stop: async () => {
|
|
424
434
|
runtimeCache.delete(cacheKey);
|
|
425
435
|
await server?.stop?.();
|