@link-assistant/hive-mind 2.9.1 → 2.10.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 +12 -0
- package/README.hi.md +5 -0
- package/README.md +5 -0
- package/README.ru.md +5 -0
- package/README.zh.md +5 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +15 -8
- package/src/agents-md-claude-support.lib.mjs +2 -2
- package/src/claude.lib.mjs +10 -10
- package/src/codex-capability-preflight.lib.mjs +225 -27
- package/src/codex-health.lib.mjs +123 -0
- package/src/codex.lib.mjs +58 -92
- package/src/formal-ai.lib.mjs +132 -0
- package/src/gemini.lib.mjs +11 -5
- package/src/isolation-runner.lib.mjs +57 -3
- package/src/models/index.mjs +31 -6
- package/src/opencode.lib.mjs +43 -36
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/qwen.lib.mjs +10 -5
- package/src/solve.config.lib.mjs +9 -0
- package/src/solve.mjs +3 -1
- package/src/solve.validation.lib.mjs +16 -1
package/src/codex-health.lib.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// Both are re-exported from codex.lib.mjs for backward compatibility, so existing
|
|
12
12
|
// importers (and tests) can keep importing them from either module.
|
|
13
13
|
|
|
14
|
+
import { normalizePluginSelector } from './codex-capability-preflight.lib.mjs';
|
|
14
15
|
import { isENOSPC } from './lib.mjs';
|
|
15
16
|
|
|
16
17
|
const unwrapCodexErrorMessage = value => {
|
|
@@ -115,6 +116,84 @@ export const getCodexErrorEventSummary = codexJsonState => {
|
|
|
115
116
|
};
|
|
116
117
|
};
|
|
117
118
|
|
|
119
|
+
// Issue #2102: `request_plugin_install` can never succeed under `codex exec`.
|
|
120
|
+
// The tool validates its `plugin_id` against the server-driven
|
|
121
|
+
// `<recommended_plugins>` list with an exact string comparison, and `codex exec`
|
|
122
|
+
// auto-cancels the elicitation it would raise, so a model that reaches for it is
|
|
123
|
+
// stuck in a loop it cannot exit. In the captured GCS-TS#5 runs that produced no
|
|
124
|
+
// work at all, the only trace was in codex's OTEL text stream (the tool is a
|
|
125
|
+
// builtin, so there is no NDJSON `mcp_tool_call` item to inspect):
|
|
126
|
+
//
|
|
127
|
+
// INFO codex_otel.log_only: event.name="codex.tool_result"
|
|
128
|
+
// tool_name=request_plugin_install call_id=… arguments={"plugin_id":"…"}
|
|
129
|
+
// … success=false output=plugin_id must match one of the entries in the
|
|
130
|
+
// <recommended_plugins> list
|
|
131
|
+
// ERROR codex_core::tools::router: error=plugin_id must match one of the
|
|
132
|
+
// entries in the <recommended_plugins> list
|
|
133
|
+
//
|
|
134
|
+
// Both patterns are anchored at the beginning of the line rather than matched
|
|
135
|
+
// anywhere in it: codex echoes the stdout of every command it runs back into its
|
|
136
|
+
// own stream (issue #1955), and this repository's own case-study logs contain
|
|
137
|
+
// these very lines. An echoed copy is always preceded by the emitting tool's own
|
|
138
|
+
// prefix (`tool_name=shell … output=…`), so requiring `request_plugin_install` to
|
|
139
|
+
// be the tool of the line's *own* event, and the router error to open the line,
|
|
140
|
+
// keeps replayed text from being read as a live rejection.
|
|
141
|
+
const PLUGIN_INSTALL_MESSAGE_TEXT = 'plugin_id must match one of the entries in the <recommended_plugins> list';
|
|
142
|
+
const PLUGIN_INSTALL_MESSAGE_PATTERN = /plugin_id must match one of the entries in the <recommended_plugins> list/;
|
|
143
|
+
const PLUGIN_INSTALL_TOOL_RESULT = /^(?:\S+\s+)?(?:TRACE|DEBUG|INFO|WARN|ERROR)\s+codex_otel\.log_only:\s+event\.name="codex\.tool_result"\s+tool_name=request_plugin_install\b/;
|
|
144
|
+
const PLUGIN_INSTALL_ROUTER_ERROR = /^(?:\S+\s+)?ERROR\s+codex_core::tools::router:\s+error=plugin_id must match one of the entries in the <recommended_plugins> list/;
|
|
145
|
+
const PLUGIN_INSTALL_CALL_ID = /\bcall_id=(\S+)/;
|
|
146
|
+
const PLUGIN_INSTALL_PLUGIN_ID = /"plugin_id"\s*:\s*"([^"]+)"/;
|
|
147
|
+
const PLUGIN_INSTALL_SUCCESS = /\bsuccess=(true|false)\b/;
|
|
148
|
+
|
|
149
|
+
export const matchCodexPluginInstallRejection = line => {
|
|
150
|
+
const text = String(line || '');
|
|
151
|
+
if (PLUGIN_INSTALL_ROUTER_ERROR.test(text)) return { source: 'router', callId: null, pluginId: null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
|
|
152
|
+
if (!PLUGIN_INSTALL_TOOL_RESULT.test(text)) return null;
|
|
153
|
+
if (!PLUGIN_INSTALL_MESSAGE_PATTERN.test(text)) return null;
|
|
154
|
+
if (PLUGIN_INSTALL_SUCCESS.exec(text)?.[1] === 'true') return null;
|
|
155
|
+
return { source: 'tool_result', callId: PLUGIN_INSTALL_CALL_ID.exec(text)?.[1] || null, pluginId: PLUGIN_INSTALL_PLUGIN_ID.exec(text)?.[1] || null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Issue #2102: a rejected runtime install means the capability preflight did not
|
|
159
|
+
// provision what the task needs, so the run cannot do the work it was asked to
|
|
160
|
+
// do. It is reported as a failure only when nothing was produced: a model may
|
|
161
|
+
// probe `request_plugin_install` and then complete the task without the plugin,
|
|
162
|
+
// and failing that run retroactively would discard real output.
|
|
163
|
+
export const getCodexPluginProvisioningHealth = (codexJsonState, { capabilityPreflight = null } = {}) => {
|
|
164
|
+
const rejections = codexJsonState?.pluginInstallRejections || [];
|
|
165
|
+
const requestedPlugins = [...new Set(rejections.map(entry => entry.pluginId).filter(Boolean))].sort();
|
|
166
|
+
const fileChanges = codexJsonState?.fileChanges || [];
|
|
167
|
+
const producedWork = fileChanges.length > 0;
|
|
168
|
+
const detected = rejections.length > 0;
|
|
169
|
+
|
|
170
|
+
const reasons = [];
|
|
171
|
+
const guidance = [];
|
|
172
|
+
if (detected) {
|
|
173
|
+
reasons.push(`Codex called request_plugin_install${requestedPlugins.length > 0 ? ` for ${requestedPlugins.join(', ')}` : ''} and codex rejected it: ${PLUGIN_INSTALL_MESSAGE_TEXT}. Under codex exec this tool can never install a plugin, so the model cannot recover on its own.`);
|
|
174
|
+
reasons.push(capabilityPreflight?.required ? `The Hive Mind Codex capability preflight ran for ${(capabilityPreflight.plugins || []).join(', ') || 'no plugins'}, so the plugin the model asked for was not among the requirements it discovered.` : 'The Hive Mind Codex capability preflight detected no requirements for this task, so nothing was provisioned before codex exec.');
|
|
175
|
+
for (const plugin of requestedPlugins.length > 0 ? requestedPlugins : ['<plugin>@<marketplace>']) {
|
|
176
|
+
// Issue #2102: the model asks for `@openai-curated-remote`, which is a
|
|
177
|
+
// synthesized namespace that `codex plugin add` cannot install; the
|
|
178
|
+
// preflight's normalization maps it onto the installable `@openai-curated`
|
|
179
|
+
// marketplace, so the guidance must quote the selector that actually works.
|
|
180
|
+
guidance.push(`Declare the requirement so the preflight provisions it: --require-codex-plugin ${normalizePluginSelector(plugin)} (or HIVE_MIND_CODEX_REQUIRED_PLUGINS).`);
|
|
181
|
+
}
|
|
182
|
+
guidance.push('Requirements declared in the target repository AGENTS.md / CLAUDE.md are discovered automatically; run with --verbose to see the sources the preflight scanned.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
healthy: !detected || producedWork,
|
|
187
|
+
detected,
|
|
188
|
+
producedWork,
|
|
189
|
+
requestedPlugins,
|
|
190
|
+
rejections,
|
|
191
|
+
message: detected ? reasons[0] : null,
|
|
192
|
+
reasons,
|
|
193
|
+
guidance,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
118
197
|
// Issue #1990: A Codex run can exit 0 with no fatal `turn.failed`/error event yet
|
|
119
198
|
// still be fundamentally broken. Under docker isolation two long-running
|
|
120
199
|
// `solve --tool codex` tasks reported SUCCESS (Exit Code: 0) while their
|
|
@@ -189,3 +268,47 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
189
268
|
reasons,
|
|
190
269
|
};
|
|
191
270
|
};
|
|
271
|
+
|
|
272
|
+
// Reporting helpers for the run gates in codex.lib.mjs. They live here with the
|
|
273
|
+
// analysis they narrate so codex.lib.mjs stays inside the 1500-line budget
|
|
274
|
+
// (issues #1730 / #1990).
|
|
275
|
+
export const logCodexResourceSnapshot = async ({ getResourceSnapshot, log }) => {
|
|
276
|
+
const resourcesAfter = await getResourceSnapshot();
|
|
277
|
+
await log('\n📈 System resources after execution:', { verbose: true });
|
|
278
|
+
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
279
|
+
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export const reportCodexCompletionFailure = async ({ completionHealth, log, getResourceSnapshot }) => {
|
|
283
|
+
await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
|
|
284
|
+
for (const reason of completionHealth.reasons) {
|
|
285
|
+
await log(` • ${reason}`, { level: 'error' });
|
|
286
|
+
}
|
|
287
|
+
await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
|
|
288
|
+
if (completionHealth.diskPressureDetected) {
|
|
289
|
+
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
290
|
+
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
|
291
|
+
await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
|
|
292
|
+
}
|
|
293
|
+
await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
|
|
294
|
+
}
|
|
295
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export const reportCodexPluginProvisioning = async ({ pluginProvisioning, log }) => {
|
|
299
|
+
if (!pluginProvisioning.detected) return;
|
|
300
|
+
if (!pluginProvisioning.healthy) {
|
|
301
|
+
await log('\n\n❌ Codex could not obtain a required plugin at runtime — treating as failure', { level: 'error' });
|
|
302
|
+
for (const reason of pluginProvisioning.reasons) {
|
|
303
|
+
await log(` • ${reason}`, { level: 'error' });
|
|
304
|
+
}
|
|
305
|
+
for (const hint of pluginProvisioning.guidance) {
|
|
306
|
+
await log(` 💡 ${hint}`, { level: 'error' });
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await log(`\n⚠️ Codex asked to install ${pluginProvisioning.requestedPlugins.join(', ') || 'a plugin'} at runtime and was rejected, but the run still produced changes`, { level: 'warning' });
|
|
311
|
+
for (const hint of pluginProvisioning.guidance) {
|
|
312
|
+
await log(` 💡 ${hint}`, { level: 'warning', verbose: true });
|
|
313
|
+
}
|
|
314
|
+
};
|
package/src/codex.lib.mjs
CHANGED
|
@@ -17,8 +17,8 @@ const os = (await use('os')).default;
|
|
|
17
17
|
import { log } from './lib.mjs';
|
|
18
18
|
// Issues #1955 / #1990: run-health analysis lives in its own module to keep this
|
|
19
19
|
// file under the max-lines budget. Re-exported below for backward compatibility.
|
|
20
|
-
import { getCodexErrorEventSummary, getCodexCompletionHealth } from './codex-health.lib.mjs';
|
|
21
|
-
export { getCodexErrorEventSummary, getCodexCompletionHealth };
|
|
20
|
+
import { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, logCodexResourceSnapshot, matchCodexPluginInstallRejection, reportCodexCompletionFailure, reportCodexPluginProvisioning } from './codex-health.lib.mjs';
|
|
21
|
+
export { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, matchCodexPluginInstallRejection };
|
|
22
22
|
import { reportError } from './sentry.lib.mjs';
|
|
23
23
|
import { timeouts, retryLimits } from './config.lib.mjs';
|
|
24
24
|
import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
|
|
@@ -31,6 +31,7 @@ import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
|
|
|
31
31
|
import { ensureCodexPlaywrightMcpServer, getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
|
|
32
32
|
import { fetchModelInfo } from './model-info.lib.mjs';
|
|
33
33
|
import { defaultModels } from './models/index.mjs';
|
|
34
|
+
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
34
35
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
35
36
|
import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
36
37
|
import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
|
|
@@ -351,6 +352,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
351
352
|
itemErrors: state.itemErrors || [],
|
|
352
353
|
turnFailures: state.turnFailures || [],
|
|
353
354
|
streamErrors: state.streamErrors || [],
|
|
355
|
+
pluginInstallRejections: state.pluginInstallRejections || [],
|
|
354
356
|
observedUsageFieldSets: state.observedUsageFieldSets || [],
|
|
355
357
|
observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
|
|
356
358
|
};
|
|
@@ -371,6 +373,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
371
373
|
try {
|
|
372
374
|
data = sanitizeObjectStrings(JSON.parse(line));
|
|
373
375
|
} catch {
|
|
376
|
+
// Issue #2102: `request_plugin_install` is a codex builtin, so its rejection
|
|
377
|
+
// never arrives as an NDJSON item — it only exists in the interleaved OTEL
|
|
378
|
+
// text stream, which is exactly the set of lines that fail to parse here.
|
|
379
|
+
const rejection = matchCodexPluginInstallRejection(line);
|
|
380
|
+
if (rejection) nextState.pluginInstallRejections.push(rejection);
|
|
374
381
|
continue;
|
|
375
382
|
}
|
|
376
383
|
|
|
@@ -745,7 +752,10 @@ export const executeCodex = async params => {
|
|
|
745
752
|
// it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
|
|
746
753
|
await deployHandoffSkill({ tempDir, argv, log, $ });
|
|
747
754
|
const codexBaseEnv = getCodexExecEnv(argv.verbose);
|
|
748
|
-
|
|
755
|
+
// Issue #2102: the target repository's own agent instruction files are part of
|
|
756
|
+
// the requirement corpus, so the preflight needs the checkout; `--require-codex-plugin`
|
|
757
|
+
// is the explicit escape hatch for requirements no document states.
|
|
758
|
+
const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv, requiredPlugins: argv.requireCodexPlugin });
|
|
749
759
|
// Execute the Codex command
|
|
750
760
|
return await executeCodexCommand({
|
|
751
761
|
tempDir,
|
|
@@ -807,6 +817,11 @@ export const executeCodexCommand = async params => {
|
|
|
807
817
|
|
|
808
818
|
let execCommand;
|
|
809
819
|
const mappedModel = mapModelToId(argv.model);
|
|
820
|
+
const toolInvocation = resolveFormalAiToolInvocation({
|
|
821
|
+
tool: 'codex',
|
|
822
|
+
model: argv.model,
|
|
823
|
+
toolPath: codexPath,
|
|
824
|
+
});
|
|
810
825
|
const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
|
|
811
826
|
const isResumeMode = !!argv.resume;
|
|
812
827
|
const codexEnv = applyCodexCapabilityEnv(capabilityPreflight?.codexBaseEnv || getCodexExecEnv(argv.verbose), {
|
|
@@ -880,11 +895,10 @@ export const executeCodexCommand = async params => {
|
|
|
880
895
|
if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
|
|
881
896
|
}
|
|
882
897
|
|
|
883
|
-
const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${
|
|
898
|
+
const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${toolInvocation.displayCommand} ${codexArgs})`;
|
|
884
899
|
|
|
885
|
-
await
|
|
886
|
-
|
|
887
|
-
await log('');
|
|
900
|
+
const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
|
|
901
|
+
if (preparedResult) return preparedResult;
|
|
888
902
|
|
|
889
903
|
try {
|
|
890
904
|
let interactiveHandler = null;
|
|
@@ -961,6 +975,7 @@ export const executeCodexCommand = async params => {
|
|
|
961
975
|
itemErrors: [],
|
|
962
976
|
turnFailures: [],
|
|
963
977
|
streamErrors: [],
|
|
978
|
+
pluginInstallRejections: [],
|
|
964
979
|
observedUsageFieldSets: [],
|
|
965
980
|
observedModelDiagnosticPaths: [],
|
|
966
981
|
};
|
|
@@ -1127,13 +1142,25 @@ export const executeCodexCommand = async params => {
|
|
|
1127
1142
|
await log(`⚠️ Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
|
|
1128
1143
|
}
|
|
1129
1144
|
const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
|
|
1145
|
+
// Every exit from this run reports the same accounting fields; only the
|
|
1146
|
+
// outcome-specific ones differ. `lastTextContent` is the result summary
|
|
1147
|
+
// captured from the JSON output stream (issue #1263).
|
|
1148
|
+
const buildRunResult = outcome => ({
|
|
1149
|
+
sessionId,
|
|
1150
|
+
limitReached,
|
|
1151
|
+
limitResetTime,
|
|
1152
|
+
pricingInfo,
|
|
1153
|
+
publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
|
|
1154
|
+
resultModelUsage,
|
|
1155
|
+
subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
|
|
1156
|
+
codexJsonDetails: codexJsonState,
|
|
1157
|
+
resultSummary: lastTextContent || null,
|
|
1158
|
+
...outcome,
|
|
1159
|
+
});
|
|
1130
1160
|
|
|
1131
1161
|
// Check for authentication errors first - these should never be retried
|
|
1132
1162
|
if (authError) {
|
|
1133
|
-
|
|
1134
|
-
await log('\n📈 System resources after execution:', { verbose: true });
|
|
1135
|
-
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
1136
|
-
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
1163
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1137
1164
|
|
|
1138
1165
|
// Throw an error to stop retries and propagate the auth failure
|
|
1139
1166
|
const error = new Error('Codex authentication failed - 401 Unauthorized. Please run: codex login');
|
|
@@ -1199,25 +1226,9 @@ export const executeCodexCommand = async params => {
|
|
|
1199
1226
|
await log(` Error events: item=${codexErrorSummary.counts.item}, turn=${codexErrorSummary.counts.turn}, stream=${codexErrorSummary.counts.stream}`, { level: 'error' });
|
|
1200
1227
|
}
|
|
1201
1228
|
|
|
1202
|
-
|
|
1203
|
-
await log('\n📈 System resources after execution:', { verbose: true });
|
|
1204
|
-
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
1205
|
-
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
1229
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1206
1230
|
|
|
1207
|
-
return {
|
|
1208
|
-
success: false,
|
|
1209
|
-
sessionId,
|
|
1210
|
-
limitReached,
|
|
1211
|
-
limitResetTime,
|
|
1212
|
-
pricingInfo,
|
|
1213
|
-
publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
|
|
1214
|
-
resultModelUsage,
|
|
1215
|
-
subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
|
|
1216
|
-
codexJsonDetails: codexJsonState,
|
|
1217
|
-
errorInfo: codexErrorSummary,
|
|
1218
|
-
result: codexErrorSummary.message,
|
|
1219
|
-
resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
|
|
1220
|
-
};
|
|
1231
|
+
return buildRunResult({ success: false, errorInfo: codexErrorSummary, result: codexErrorSummary.message });
|
|
1221
1232
|
}
|
|
1222
1233
|
|
|
1223
1234
|
if (exitCode !== 0) {
|
|
@@ -1269,24 +1280,22 @@ export const executeCodexCommand = async params => {
|
|
|
1269
1280
|
await log(`\n\n❌ Codex command failed with exit code ${exitCode}`, { level: 'error' });
|
|
1270
1281
|
}
|
|
1271
1282
|
|
|
1272
|
-
|
|
1273
|
-
await log('\n📈 System resources after execution:', { verbose: true });
|
|
1274
|
-
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
1275
|
-
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
1283
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1276
1284
|
|
|
1277
|
-
return {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1285
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState) });
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Issue #2102: a rejected `request_plugin_install` means codex asked for a
|
|
1289
|
+
// capability the preflight did not provision, and under `codex exec` that
|
|
1290
|
+
// request can never succeed — so a run that produced nothing is blocked,
|
|
1291
|
+
// not finished. Reporting it as a named failure (instead of an empty
|
|
1292
|
+
// success) is what makes the missing requirement visible.
|
|
1293
|
+
const pluginProvisioning = getCodexPluginProvisioningHealth(codexJsonState, { capabilityPreflight });
|
|
1294
|
+
await reportCodexPluginProvisioning({ pluginProvisioning, log });
|
|
1295
|
+
if (!pluginProvisioning.healthy) {
|
|
1296
|
+
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1297
|
+
|
|
1298
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), pluginProvisioning, result: [pluginProvisioning.message, ...pluginProvisioning.guidance].join(' ') });
|
|
1290
1299
|
}
|
|
1291
1300
|
|
|
1292
1301
|
// Issue #1990: exit code 0 and the absence of a fatal codex error event are
|
|
@@ -1297,23 +1306,7 @@ export const executeCodexCommand = async params => {
|
|
|
1297
1306
|
// container filesystem needed to inspect and retry the failure (#1990).
|
|
1298
1307
|
const completionHealth = getCodexCompletionHealth(codexJsonState, { lastMessage });
|
|
1299
1308
|
if (!completionHealth.healthy) {
|
|
1300
|
-
await
|
|
1301
|
-
for (const reason of completionHealth.reasons) {
|
|
1302
|
-
await log(` • ${reason}`, { level: 'error' });
|
|
1303
|
-
}
|
|
1304
|
-
await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
|
|
1305
|
-
if (completionHealth.diskPressureDetected) {
|
|
1306
|
-
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
1307
|
-
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
|
1308
|
-
await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
|
|
1309
|
-
}
|
|
1310
|
-
await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
const resourcesAfter = await getResourceSnapshot();
|
|
1314
|
-
await log('\n📈 System resources after execution:', { verbose: true });
|
|
1315
|
-
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
1316
|
-
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
1309
|
+
await reportCodexCompletionFailure({ completionHealth, log, getResourceSnapshot });
|
|
1317
1310
|
|
|
1318
1311
|
// Issue #1990: preserve the codex session so an outer full restart can
|
|
1319
1312
|
// resume with context (mirrors the transient-error retry above and the
|
|
@@ -1323,23 +1316,7 @@ export const executeCodexCommand = async params => {
|
|
|
1323
1316
|
// restart at the orchestration level.
|
|
1324
1317
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1325
1318
|
|
|
1326
|
-
return {
|
|
1327
|
-
success: false,
|
|
1328
|
-
sessionId,
|
|
1329
|
-
limitReached,
|
|
1330
|
-
limitResetTime,
|
|
1331
|
-
pricingInfo,
|
|
1332
|
-
publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
|
|
1333
|
-
resultModelUsage,
|
|
1334
|
-
subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
|
|
1335
|
-
codexJsonDetails: codexJsonState,
|
|
1336
|
-
errorInfo: getCodexErrorEventSummary(codexJsonState),
|
|
1337
|
-
completionHealth,
|
|
1338
|
-
incompleteSession: completionHealth.incompleteSession,
|
|
1339
|
-
diskPressureDetected: completionHealth.diskPressureDetected,
|
|
1340
|
-
result: completionHealth.reasons.join(' '),
|
|
1341
|
-
resultSummary: lastTextContent || null,
|
|
1342
|
-
};
|
|
1319
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), completionHealth, pluginProvisioning, incompleteSession: completionHealth.incompleteSession, diskPressureDetected: completionHealth.diskPressureDetected, result: completionHealth.reasons.join(' ') });
|
|
1343
1320
|
}
|
|
1344
1321
|
|
|
1345
1322
|
await log('\n\n✅ Codex command completed');
|
|
@@ -1351,18 +1328,7 @@ export const executeCodexCommand = async params => {
|
|
|
1351
1328
|
await log('⚠️ No result summary captured from Codex output or last-message file', { level: 'warning', verbose: true });
|
|
1352
1329
|
}
|
|
1353
1330
|
|
|
1354
|
-
return {
|
|
1355
|
-
success: true,
|
|
1356
|
-
sessionId,
|
|
1357
|
-
limitReached,
|
|
1358
|
-
limitResetTime,
|
|
1359
|
-
pricingInfo,
|
|
1360
|
-
publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
|
|
1361
|
-
resultModelUsage,
|
|
1362
|
-
subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
|
|
1363
|
-
codexJsonDetails: codexJsonState,
|
|
1364
|
-
resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
|
|
1365
|
-
};
|
|
1331
|
+
return buildRunResult({ success: true, pluginProvisioning });
|
|
1366
1332
|
} catch (error) {
|
|
1367
1333
|
// Don't report auth errors to Sentry as they are user configuration issues
|
|
1368
1334
|
if (!error.isAuthError) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { FORMAL_AI_MODEL_ALIAS, isFormalAiModel } from './models/index.mjs';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export const FORMAL_AI_SUPPORTED_TOOLS = Object.freeze(['claude', 'agent', 'opencode', 'codex', 'qwen', 'gemini']);
|
|
11
|
+
export const DEFAULT_FORMAL_AI_PATH = 'formal-ai';
|
|
12
|
+
|
|
13
|
+
const SAFE_SHELL_WORD = /^[a-zA-Z0-9_\-./=,+@:]+$/;
|
|
14
|
+
|
|
15
|
+
const shellQuote = value => {
|
|
16
|
+
const stringValue = String(value);
|
|
17
|
+
if (SAFE_SHELL_WORD.test(stringValue)) return stringValue;
|
|
18
|
+
return `'${stringValue.replaceAll("'", "'\\''")}'`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const normalizeExternalBaseUrl = value => {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(value);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`HIVE_MIND_FORMAL_AI_BASE_URL must be a valid HTTP(S) URL, received "${value}"`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
32
|
+
throw new Error('HIVE_MIND_FORMAL_AI_BASE_URL must be an HTTP(S) origin without credentials, a path, a query, or a fragment');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parsed.origin;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the executable and leading arguments for one Hive tool invocation.
|
|
40
|
+
* Formal AI owns the temporary client configuration and forwards all remaining
|
|
41
|
+
* arguments to the selected agentic CLI unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export const resolveFormalAiToolInvocation = ({ tool, model, toolPath, env = process.env }) => {
|
|
44
|
+
if (!isFormalAiModel(model)) {
|
|
45
|
+
return {
|
|
46
|
+
command: toolPath,
|
|
47
|
+
args: [],
|
|
48
|
+
displayCommand: shellQuote(toolPath),
|
|
49
|
+
formalAi: false,
|
|
50
|
+
baseUrl: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!FORMAL_AI_SUPPORTED_TOOLS.includes(tool)) {
|
|
55
|
+
throw new Error(`Formal AI dispatch does not support Hive tool "${tool}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const command = env.HIVE_MIND_FORMAL_AI_PATH?.trim() || DEFAULT_FORMAL_AI_PATH;
|
|
59
|
+
const baseUrl = normalizeExternalBaseUrl(env.HIVE_MIND_FORMAL_AI_BASE_URL);
|
|
60
|
+
const args = ['with'];
|
|
61
|
+
|
|
62
|
+
if (baseUrl) {
|
|
63
|
+
args.push('--no-start-server', '--base-url', baseUrl);
|
|
64
|
+
}
|
|
65
|
+
args.push(tool);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
command,
|
|
69
|
+
args,
|
|
70
|
+
displayCommand: [command, ...args].map(shellQuote).join(' '),
|
|
71
|
+
formalAi: true,
|
|
72
|
+
baseUrl,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check both the wrapper and the selected native CLI without starting a model
|
|
78
|
+
* server or spending a model request.
|
|
79
|
+
*/
|
|
80
|
+
export const validateFormalAiToolConnection = async (tool, { env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
81
|
+
const invocation = resolveFormalAiToolInvocation({
|
|
82
|
+
tool,
|
|
83
|
+
model: FORMAL_AI_MODEL_ALIAS,
|
|
84
|
+
toolPath: tool,
|
|
85
|
+
env,
|
|
86
|
+
});
|
|
87
|
+
const args = ['with', '--no-start-server', tool, '--version'];
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = await run(invocation.command, args, {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
env: { ...process.env, ...env },
|
|
93
|
+
timeout: timeoutMs,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
valid: true,
|
|
97
|
+
command: invocation.command,
|
|
98
|
+
args,
|
|
99
|
+
version: result?.stdout?.trim() || null,
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
valid: false,
|
|
104
|
+
command: invocation.command,
|
|
105
|
+
args,
|
|
106
|
+
error: error?.stderr?.trim() || error?.message || String(error),
|
|
107
|
+
code: error?.code,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const isPrepareOnly = argv => !!(argv?.dryRun || argv?.onlyPrepareCommand);
|
|
113
|
+
|
|
114
|
+
export const createPreparedToolResult = preparedCommand => ({
|
|
115
|
+
success: true,
|
|
116
|
+
preparedOnly: true,
|
|
117
|
+
preparedCommand,
|
|
118
|
+
sessionId: null,
|
|
119
|
+
limitReached: false,
|
|
120
|
+
errorDuringExecution: false,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const logPreparedToolCommand = async ({ argv, fullCommand, log, formatAligned }) => {
|
|
124
|
+
await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
|
|
125
|
+
await log(fullCommand);
|
|
126
|
+
await log('');
|
|
127
|
+
|
|
128
|
+
if (!isPrepareOnly(argv)) return null;
|
|
129
|
+
|
|
130
|
+
await log('🧪 Command prepared; AI execution skipped.');
|
|
131
|
+
return createPreparedToolResult(fullCommand);
|
|
132
|
+
};
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Iss
|
|
|
18
18
|
const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
|
|
19
19
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
20
20
|
import { defaultModels, geminiModels } from './models/index.mjs';
|
|
21
|
+
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
21
22
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
22
23
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
23
24
|
import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
@@ -423,6 +424,11 @@ export const executeGeminiCommand = async params => {
|
|
|
423
424
|
await log(` Load: ${resourcesBefore.load}`, { verbose: true });
|
|
424
425
|
|
|
425
426
|
const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
|
|
427
|
+
const toolInvocation = resolveFormalAiToolInvocation({
|
|
428
|
+
tool: 'gemini',
|
|
429
|
+
model: argv.model || defaultModels.gemini,
|
|
430
|
+
toolPath: geminiPath,
|
|
431
|
+
});
|
|
426
432
|
const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
|
|
427
433
|
|
|
428
434
|
if (argv.resume) {
|
|
@@ -432,11 +438,11 @@ export const executeGeminiCommand = async params => {
|
|
|
432
438
|
// Issue #1809: build args via shared helper so verbose/sandbox/include-dirs
|
|
433
439
|
// toggles stay consistent between the logged command and the real invocation.
|
|
434
440
|
const geminiArgList = buildGeminiArgs(argv, mappedModel, { tempDir, workspaceTmpDir });
|
|
435
|
-
const
|
|
441
|
+
const fullGeminiArgList = [...toolInvocation.args, ...geminiArgList];
|
|
442
|
+
const fullCommand = `(cd ${shellQuote(tempDir)} && ${toolInvocation.displayCommand} ${geminiArgList.map(shellQuote).join(' ')} <<< <prompt>)`;
|
|
436
443
|
|
|
437
|
-
await
|
|
438
|
-
|
|
439
|
-
await log('');
|
|
444
|
+
const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
|
|
445
|
+
if (preparedResult) return preparedResult;
|
|
440
446
|
|
|
441
447
|
let geminiJsonState = {};
|
|
442
448
|
let allOutput = '';
|
|
@@ -455,7 +461,7 @@ export const executeGeminiCommand = async params => {
|
|
|
455
461
|
cwd: tempDir,
|
|
456
462
|
stdin: combinedPrompt,
|
|
457
463
|
mirror: false,
|
|
458
|
-
})`${
|
|
464
|
+
})`${toolInvocation.command} ${fullGeminiArgList}`;
|
|
459
465
|
|
|
460
466
|
await log(`${formatAligned('📋', 'Command details:', '')}`);
|
|
461
467
|
await log(formatAligned('📂', 'Working directory:', tempDir, 2));
|