@link-assistant/hive-mind 2.9.0 → 2.9.2
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/package.json +1 -1
- package/src/agents-md-claude-support.lib.mjs +2 -2
- package/src/codex-capability-preflight.lib.mjs +225 -27
- package/src/codex-health.lib.mjs +123 -0
- package/src/codex.lib.mjs +49 -88
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/solve.config.lib.mjs +9 -0
- package/src/solve.mjs +11 -13
- package/src/tool-retry.lib.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.9.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a6d14db: Read the target repository's own agent instructions when deciding which Codex plugins and skills to provision. The capability preflight previously built its requirement corpus from the GitHub issue alone, so a repository that mandates a plugin in `AGENTS.md` — where the convention says to put it — provisioned nothing, and the model's own `request_plugin_install` attempt was rejected by Codex as an unrecognized `plugin_id`. Root and nested `AGENTS.md`, `CLAUDE.md` and `.codex/*.md` files under the checkout now feed the detector through a bounded walk that reports what it skipped, the zero-requirement path logs the sources it scanned instead of staying silent, a runtime plugin-install rejection is recognized and fails the run with a named diagnostic when the session produced no file changes, and `--require-codex-plugin` / `HIVE_MIND_CODEX_REQUIRED_PLUGINS` can state a requirement that no document spells out.
|
|
8
|
+
|
|
9
|
+
## 2.9.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3e48cb3: Resume Codex sessions after transient high-demand failures and preserve uncommitted work before uploading failure logs.
|
|
14
|
+
|
|
3
15
|
## 2.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
|
|
2
|
-
const CLAUDE_MD_FILENAME = 'CLAUDE.md';
|
|
1
|
+
export const AGENTS_MD_FILENAMES = Object.freeze(['AGENTS.md', 'agents.md']);
|
|
2
|
+
export const CLAUDE_MD_FILENAME = 'CLAUDE.md';
|
|
3
3
|
|
|
4
4
|
const noopLog = async () => {};
|
|
5
5
|
const fallbackFormatAligned = (_icon, label, value) => `${label} ${value}`;
|
|
@@ -14,16 +14,20 @@ import path from 'node:path';
|
|
|
14
14
|
import { promisify } from 'node:util';
|
|
15
15
|
|
|
16
16
|
import { CODEX_PLUGIN_CLI, buildPluginCachePath as buildAgentPluginCachePath, buildPluginPayloadRepairs, pluginIdParts, readMaterializedPluginSkills as readAgentMaterializedPluginSkills, repairPluginPayloads } from './agent-plugin-cache.lib.mjs';
|
|
17
|
+
import { AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME } from './agents-md-claude-support.lib.mjs';
|
|
17
18
|
|
|
18
19
|
const execFileAsync = promisify(execFile);
|
|
19
|
-
const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|
|
|
20
|
+
const REQUIREMENT_WORDS = /\b(?:depend(?:s|ency)?|install|invoke|mandatory|must|need(?:ed|s)?|preflight|required?|requires|us(?:e|es|ing))\b/i;
|
|
20
21
|
const NEGATED_REQUIREMENT = /\b(?:does\s+not\s+require|not\s+required|optional)\b/i;
|
|
21
22
|
// `(?!\.[a-z])` keeps email addresses and hostnames (`ops@example.com`) out of
|
|
22
23
|
// the plugin selector space.
|
|
23
24
|
const PLUGIN_SELECTOR = /\b([a-z0-9][a-z0-9-]*@[a-z0-9][a-z0-9-]*(?:-remote)?)\b(?!\.[a-z])/gi;
|
|
24
25
|
const NAMESPACED_SKILL = /\b([a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*)\b/gi;
|
|
25
26
|
const EXPLICIT_BARE_SKILL = /\$([a-z0-9][a-z0-9-]*)|`([a-z0-9][a-z0-9-]*)`\s+(?:agent\s+)?skill/gi;
|
|
26
|
-
|
|
27
|
+
// Issue #2102: `us(?:e|ing)` covers the imperative form (`Use \`ns:skill\``) and
|
|
28
|
+
// the participle a repository AGENTS.md uses for the same instruction
|
|
29
|
+
// (`Execute approved plans in an isolated worktree using \`ns:skill\``).
|
|
30
|
+
const CAPABILITY_PREFIX = /\b(?:depend(?:s|ed)?\s+on|install|invoke|must\s+(?:install|invoke|use)|need(?:ed|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|require(?:d|s)?(?:\s+to)?(?:\s+(?:install|invoke|use))?|us(?:e|ing))\s+(?:(?:the|an?)\s+)?(?:(?:agent\s+)?skill\s+)?(?:named\s+)?[`$]?$/i;
|
|
27
31
|
const CAPABILITY_SUFFIX = /^`?\s+(?:agent\s+)?(?:skill|capability)\b/i;
|
|
28
32
|
const STRUCTURED_DATA_VALUES = new Set(['array', 'boolean', 'false', 'integer', 'null', 'number', 'object', 'string', 'true']);
|
|
29
33
|
|
|
@@ -49,6 +53,11 @@ const PROSE_TOKENS = new Set(['agent', 'caution', 'codex', 'default', 'error', '
|
|
|
49
53
|
|
|
50
54
|
const isCapabilityToken = value => CAPABILITY_TOKEN.test(value) && !PROSE_TOKENS.has(value);
|
|
51
55
|
|
|
56
|
+
const skillParts = skill => {
|
|
57
|
+
const separator = skill.indexOf(':');
|
|
58
|
+
return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
|
|
59
|
+
};
|
|
60
|
+
|
|
52
61
|
const hasExplicitCapabilityContext = (line, match) => {
|
|
53
62
|
const before = line.slice(0, match.index);
|
|
54
63
|
const after = line.slice(match.index + match[0].length);
|
|
@@ -89,7 +98,7 @@ export function normalizePluginSelector(selector) {
|
|
|
89
98
|
.replace(/@openai-curated-remote$/u, '@openai-curated');
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
export function detectRequiredCodexCapabilities(text) {
|
|
101
|
+
export function detectRequiredCodexCapabilities(text, { source = null } = {}) {
|
|
93
102
|
const plugins = new Set();
|
|
94
103
|
const skills = new Set();
|
|
95
104
|
// Issue #2088: a fully qualified reference — `plugin@marketplace` in plugin
|
|
@@ -106,12 +115,12 @@ export function detectRequiredCodexCapabilities(text) {
|
|
|
106
115
|
|
|
107
116
|
const accept = (target, value, line, { qualified = false } = {}) => {
|
|
108
117
|
if (!isCapabilityName(value)) {
|
|
109
|
-
rejected.push({ capability: value, line });
|
|
118
|
+
rejected.push({ capability: value, line, source });
|
|
110
119
|
return;
|
|
111
120
|
}
|
|
112
121
|
target.add(value);
|
|
113
122
|
if (qualified) explicit.add(value);
|
|
114
|
-
evidence.push({ capability: value, line, explicit: qualified });
|
|
123
|
+
evidence.push({ capability: value, line, explicit: qualified, source });
|
|
115
124
|
};
|
|
116
125
|
|
|
117
126
|
for (const rawLine of String(text || '').split(/\r?\n/u)) {
|
|
@@ -121,12 +130,22 @@ export function detectRequiredCodexCapabilities(text) {
|
|
|
121
130
|
for (const match of line.matchAll(PLUGIN_SELECTOR)) {
|
|
122
131
|
const selector = normalizePluginSelector(match[1]);
|
|
123
132
|
if (hasExplicitPluginContext(line, match)) accept(plugins, selector, line, { qualified: true });
|
|
124
|
-
else rejected.push({ capability: selector, line });
|
|
133
|
+
else rejected.push({ capability: selector, line, source });
|
|
125
134
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
// Issue #2102: a conjunction shares one requirement verb. `invoke `a:x` and
|
|
136
|
+
// `a:y`` only puts `a:x` in capability context, so a later reference to a
|
|
137
|
+
// namespace already qualified on the same line inherits the qualification
|
|
138
|
+
// instead of being dropped. Restricting inheritance to the same namespace,
|
|
139
|
+
// and still rejecting structured-data values, keeps issues #2077 and #2080
|
|
140
|
+
// from resurfacing: prose cannot produce a second `ns:name` token for a
|
|
141
|
+
// namespace that was just named in an explicit requirement.
|
|
142
|
+
const lineSkills = [...line.matchAll(NAMESPACED_SKILL)].map(match => ({ skill: match[1].toLowerCase(), qualified: hasExplicitCapabilityContext(line, match) }));
|
|
143
|
+
const qualifiedNamespaces = new Set(lineSkills.filter(entry => entry.qualified).map(entry => skillParts(entry.skill).namespace));
|
|
144
|
+
for (const entry of lineSkills) {
|
|
145
|
+
const { namespace, name } = skillParts(entry.skill);
|
|
146
|
+
const inherited = qualifiedNamespaces.has(namespace) && !STRUCTURED_DATA_VALUES.has(name);
|
|
147
|
+
if (entry.qualified || inherited) accept(skills, entry.skill, line, { qualified: true });
|
|
148
|
+
else rejected.push({ capability: entry.skill, line, source });
|
|
130
149
|
}
|
|
131
150
|
for (const match of line.matchAll(EXPLICIT_BARE_SKILL)) accept(skills, (match[1] || match[2]).toLowerCase(), line);
|
|
132
151
|
}
|
|
@@ -231,11 +250,6 @@ const verifyModelVisibleSkills = async ({ command, env, runCommand, log, require
|
|
|
231
250
|
throw skillVisibilityError({ missing: outcome.missing, visible: outcome.visible, requirements });
|
|
232
251
|
};
|
|
233
252
|
|
|
234
|
-
const skillParts = skill => {
|
|
235
|
-
const separator = skill.indexOf(':');
|
|
236
|
-
return separator === -1 ? { namespace: null, name: skill } : { namespace: skill.slice(0, separator), name: skill.slice(separator + 1) };
|
|
237
|
-
};
|
|
238
|
-
|
|
239
253
|
const pluginProvidesSkill = async (plugin, skill) => {
|
|
240
254
|
const sourcePath = plugin?.source?.path;
|
|
241
255
|
if (!sourcePath) return false;
|
|
@@ -316,14 +330,182 @@ export async function resolveRequiredPlugins(options) {
|
|
|
316
330
|
return (await resolveRequiredCapabilities(options)).plugins;
|
|
317
331
|
}
|
|
318
332
|
|
|
319
|
-
const
|
|
333
|
+
const readIssueRequirementSegments = async ({ owner, repo, issueNumber, runCommand, env }) => {
|
|
320
334
|
const issueResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}`], env });
|
|
321
335
|
const issue = parseJsonCommand(issueResult, 'Codex capability issue discovery');
|
|
322
336
|
const commentsResult = await runCommand({ command: 'gh', args: ['api', `repos/${owner}/${repo}/issues/${issueNumber}/comments`, '--paginate'], env });
|
|
323
337
|
const comments = parseJsonCommand(commentsResult, 'Codex capability comment discovery');
|
|
324
|
-
|
|
338
|
+
const bodies = (Array.isArray(comments) ? comments : []).map(comment => comment?.body).filter(Boolean);
|
|
339
|
+
return {
|
|
340
|
+
segments: [{ source: `issue #${issueNumber}`, text: [issue?.title, issue?.body].filter(Boolean).join('\n') }, ...bodies.map((text, index) => ({ source: `comment ${index + 1}`, text }))],
|
|
341
|
+
sources: [`issue #${issueNumber}`, ...(bodies.length > 0 ? [`${bodies.length} comment${bodies.length === 1 ? '' : 's'}`] : [])],
|
|
342
|
+
};
|
|
325
343
|
};
|
|
326
344
|
|
|
345
|
+
// Issue #2102: the requirement corpus has to include the instructions the target
|
|
346
|
+
// repository gives the agent. CEHR2005/GCS-TS#5 delegated its entire mandatory
|
|
347
|
+
// workflow to `AGENTS.md` ("Follow the repository and nested engine `AGENTS.md`
|
|
348
|
+
// instructions"), so an issue-only corpus detected nothing, provisioning was
|
|
349
|
+
// skipped, and the model was left to call `request_plugin_install` — which
|
|
350
|
+
// `codex exec` can never satisfy.
|
|
351
|
+
//
|
|
352
|
+
// `CLAUDE.md` is scanned for parity with `--tool claude` (see
|
|
353
|
+
// `agents-md-claude-support.lib.mjs`, which presents `AGENTS.md` under that name)
|
|
354
|
+
// and `.codex/*.md` for repositories that keep Codex-specific rules there.
|
|
355
|
+
const AGENT_INSTRUCTION_FILENAMES = new Set([...AGENTS_MD_FILENAMES, CLAUDE_MD_FILENAME, 'claude.md']);
|
|
356
|
+
const CODEX_INSTRUCTION_DIRECTORY = '.codex';
|
|
357
|
+
// Vendored dependencies, build output and VCS internals carry instructions that
|
|
358
|
+
// belong to other projects; reading them would invent requirements the task
|
|
359
|
+
// never had.
|
|
360
|
+
const SKIPPED_INSTRUCTION_DIRECTORIES = new Set(['__pycache__', 'build', 'coverage', 'dist', 'node_modules', 'out', 'target', 'tmp', 'vendor', 'venv']);
|
|
361
|
+
const WALKED_HIDDEN_DIRECTORIES = new Set([CODEX_INSTRUCTION_DIRECTORY, '.github']);
|
|
362
|
+
const INSTRUCTION_WALK_LIMITS = Object.freeze({ maxDepth: 3, maxFiles: 24, maxBytes: 256 * 1024 });
|
|
363
|
+
|
|
364
|
+
const isInstructionFile = ({ name, directoryName }) => AGENT_INSTRUCTION_FILENAMES.has(name) || (directoryName === CODEX_INSTRUCTION_DIRECTORY && name.toLowerCase().endsWith('.md'));
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Bounded walk over the checked-out repository's agent instruction files.
|
|
368
|
+
*
|
|
369
|
+
* Bounded on purpose: depth, file count and file size are all capped. A
|
|
370
|
+
* candidate dropped by the count or size cap is reported in `skipped`, so a
|
|
371
|
+
* truncated scan is visible in the log instead of looking like "nothing to
|
|
372
|
+
* find"; directories excluded by name or depth are not reported, because
|
|
373
|
+
* listing every skipped `node_modules` would bury that signal. Symlinked
|
|
374
|
+
* directories are not followed, which also makes the walk cycle-free.
|
|
375
|
+
*/
|
|
376
|
+
export async function collectAgentInstructionFiles({ projectDir, maxDepth = INSTRUCTION_WALK_LIMITS.maxDepth, maxFiles = INSTRUCTION_WALK_LIMITS.maxFiles, maxBytes = INSTRUCTION_WALK_LIMITS.maxBytes } = {}) {
|
|
377
|
+
const files = [];
|
|
378
|
+
const skipped = [];
|
|
379
|
+
if (!projectDir) return { files, skipped };
|
|
380
|
+
|
|
381
|
+
const candidates = [];
|
|
382
|
+
const walk = async (directory, relative, depth) => {
|
|
383
|
+
let entries;
|
|
384
|
+
try {
|
|
385
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
386
|
+
} catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const directoryName = relative ? path.posix.basename(relative) : '';
|
|
390
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
391
|
+
const relativePath = relative ? `${relative}/${entry.name}` : entry.name;
|
|
392
|
+
if (entry.isDirectory()) {
|
|
393
|
+
if (depth >= maxDepth) continue;
|
|
394
|
+
if (SKIPPED_INSTRUCTION_DIRECTORIES.has(entry.name)) continue;
|
|
395
|
+
if (entry.name.startsWith('.') && !WALKED_HIDDEN_DIRECTORIES.has(entry.name)) continue;
|
|
396
|
+
await walk(path.join(directory, entry.name), relativePath, depth + 1);
|
|
397
|
+
} else if (entry.isFile() && isInstructionFile({ name: entry.name, directoryName })) {
|
|
398
|
+
candidates.push({ relativePath, absolutePath: path.join(directory, entry.name), depth });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
await walk(projectDir, '', 0);
|
|
403
|
+
|
|
404
|
+
candidates.sort((left, right) => left.depth - right.depth || left.relativePath.localeCompare(right.relativePath));
|
|
405
|
+
for (const candidate of candidates) {
|
|
406
|
+
let stats;
|
|
407
|
+
try {
|
|
408
|
+
stats = await fs.stat(candidate.absolutePath);
|
|
409
|
+
} catch {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (stats.size > maxBytes) {
|
|
413
|
+
skipped.push({ relativePath: candidate.relativePath, reason: 'too-large', bytes: stats.size });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (files.length >= maxFiles) {
|
|
417
|
+
skipped.push({ relativePath: candidate.relativePath, reason: 'max-files' });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
files.push({ relativePath: candidate.relativePath, text: await fs.readFile(candidate.absolutePath, 'utf8'), bytes: stats.size });
|
|
422
|
+
} catch {
|
|
423
|
+
// An unreadable instruction file is not a requirement source.
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { files, skipped };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Issue #2102 escape hatch: `--require-codex-plugin` /
|
|
431
|
+
* `HIVE_MIND_CODEX_REQUIRED_PLUGINS` let an operator state a requirement that no
|
|
432
|
+
* text declares. A selector without a marketplace cannot be installed by
|
|
433
|
+
* `codex plugin add`, so it is reported rather than silently ignored.
|
|
434
|
+
*/
|
|
435
|
+
export function parseRequiredCapabilityOverrides(value) {
|
|
436
|
+
const tokens = (Array.isArray(value) ? value : [value])
|
|
437
|
+
.flatMap(entry =>
|
|
438
|
+
String(entry ?? '')
|
|
439
|
+
.split(/[,;\s]+/u)
|
|
440
|
+
.map(token => token.trim())
|
|
441
|
+
)
|
|
442
|
+
.filter(Boolean);
|
|
443
|
+
const plugins = new Set();
|
|
444
|
+
const invalid = [];
|
|
445
|
+
for (const token of tokens) {
|
|
446
|
+
const selector = normalizePluginSelector(token);
|
|
447
|
+
if (selector.includes('@') && isCapabilityName(selector)) plugins.add(selector);
|
|
448
|
+
else invalid.push(token);
|
|
449
|
+
}
|
|
450
|
+
return { plugins: [...plugins].sort(), invalid };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Requirement corpus for the Codex capability preflight: the issue (title, body,
|
|
455
|
+
* comments), the checked-out repository's agent instruction files, and operator
|
|
456
|
+
* overrides. `sources` names everything that was scanned so a zero-requirement
|
|
457
|
+
* result is explainable from the log alone (issue #2102).
|
|
458
|
+
*/
|
|
459
|
+
export async function collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand = defaultRunCommand, env = process.env, requiredPlugins, instructionLimits } = {}) {
|
|
460
|
+
const segments = [];
|
|
461
|
+
const sources = [];
|
|
462
|
+
|
|
463
|
+
if (owner && repo && issueNumber) {
|
|
464
|
+
const issue = await readIssueRequirementSegments({ owner, repo, issueNumber, runCommand, env });
|
|
465
|
+
segments.push(...issue.segments);
|
|
466
|
+
sources.push(...issue.sources);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const instructions = await collectAgentInstructionFiles({ projectDir, ...instructionLimits });
|
|
470
|
+
for (const file of instructions.files) {
|
|
471
|
+
segments.push({ source: file.relativePath, text: file.text });
|
|
472
|
+
sources.push(file.relativePath);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const plugins = new Set();
|
|
476
|
+
const skills = new Set();
|
|
477
|
+
const explicit = new Set();
|
|
478
|
+
const evidence = [];
|
|
479
|
+
const rejected = [];
|
|
480
|
+
for (const segment of segments) {
|
|
481
|
+
const detected = detectRequiredCodexCapabilities(segment.text, { source: segment.source });
|
|
482
|
+
for (const plugin of detected.plugins) plugins.add(plugin);
|
|
483
|
+
for (const skill of detected.skills) skills.add(skill);
|
|
484
|
+
for (const capability of detected.explicit) explicit.add(capability);
|
|
485
|
+
evidence.push(...detected.evidence);
|
|
486
|
+
rejected.push(...detected.rejected);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const { label, value } of [
|
|
490
|
+
{ label: '--require-codex-plugin', value: requiredPlugins },
|
|
491
|
+
{ label: 'HIVE_MIND_CODEX_REQUIRED_PLUGINS', value: env?.HIVE_MIND_CODEX_REQUIRED_PLUGINS },
|
|
492
|
+
]) {
|
|
493
|
+
const overrides = parseRequiredCapabilityOverrides(value);
|
|
494
|
+
if (overrides.plugins.length === 0 && overrides.invalid.length === 0) continue;
|
|
495
|
+
sources.push(label);
|
|
496
|
+
for (const selector of overrides.plugins) {
|
|
497
|
+
plugins.add(selector);
|
|
498
|
+
// An operator override is as explicit as a requirement gets, so it fails
|
|
499
|
+
// closed like any other declared capability (issue #2088).
|
|
500
|
+
explicit.add(selector);
|
|
501
|
+
evidence.push({ capability: selector, line: `${label}=${selector}`, explicit: true, source: label });
|
|
502
|
+
}
|
|
503
|
+
for (const token of overrides.invalid) rejected.push({ capability: token, line: `${label}=${token} (expected plugin@marketplace)`, source: label });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { plugins: [...plugins].sort(), skills: [...skills].sort(), explicit: [...explicit].sort(), evidence, rejected, sources, skippedInstructionFiles: instructions.skipped };
|
|
507
|
+
}
|
|
508
|
+
|
|
327
509
|
const replaceWithRelativeSymlink = async ({ source, target }) => {
|
|
328
510
|
try {
|
|
329
511
|
const current = await fs.readlink(target);
|
|
@@ -586,22 +768,35 @@ export async function runCodexCapabilityPreflight(options = {}) {
|
|
|
586
768
|
}
|
|
587
769
|
}
|
|
588
770
|
|
|
589
|
-
async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
|
|
590
|
-
|
|
771
|
+
async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir, requiredPlugins, env = process.env, baseCodexHome = env.HIVE_MIND_PARENT_CODEX_HOME || env.CODEX_HOME || path.join(os.homedir(), '.codex'), codexPath = 'codex', runCommand = defaultRunCommand, log = async () => {} } = {}) {
|
|
772
|
+
// Issue #2102: requirements no longer come from the issue alone, so a missing
|
|
773
|
+
// issue number (a pull-request continuation, for example) must not skip the
|
|
774
|
+
// repository's own AGENTS.md/CLAUDE.md files or an operator override. Owner and
|
|
775
|
+
// repo are still required: the scoped CODEX_HOME is keyed on them.
|
|
776
|
+
if (!owner || !repo) return { required: false, plugins: [], codexHome: null };
|
|
591
777
|
|
|
592
778
|
// `executeToolWithBun` uses a shell expression for execution. Preflight uses
|
|
593
779
|
// execFile and therefore selects the installed Codex binary directly.
|
|
594
780
|
const command = /\s/u.test(codexPath) ? 'codex' : codexPath;
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
for (const { capability, line } of requirements.rejected || []) {
|
|
598
|
-
await log(` ⏭️ Ignored non-capability token '${capability}' from: ${line.slice(0, 160)}`, { verbose: true });
|
|
781
|
+
const requirements = await collectCodexCapabilityRequirements({ owner, repo, issueNumber, projectDir, runCommand, env, requiredPlugins });
|
|
782
|
+
const scanned = `sources: ${requirements.sources.join(', ') || 'none'}`;
|
|
783
|
+
for (const { capability, line, source } of requirements.rejected || []) {
|
|
784
|
+
await log(` ⏭️ Ignored non-capability token '${capability}' from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
|
|
785
|
+
}
|
|
786
|
+
for (const { relativePath, reason, bytes } of requirements.skippedInstructionFiles || []) {
|
|
787
|
+
await log(` ⏭️ Skipped instruction file ${relativePath} (${reason}${reason === 'too-large' ? `: ${bytes} bytes` : ''})`, { verbose: true });
|
|
788
|
+
}
|
|
789
|
+
// Issue #2102: a silent early return made a missed requirement indistinguishable
|
|
790
|
+
// from a task that has none. Name what was scanned so the next report is
|
|
791
|
+
// diagnosable from the log alone.
|
|
792
|
+
if (requirements.plugins.length === 0 && requirements.skills.length === 0) {
|
|
793
|
+
await log(`🔌 Codex capability preflight: no plugin or skill requirements detected (${scanned})`, { verbose: true });
|
|
794
|
+
return { required: false, plugins: [], codexHome: null, sources: requirements.sources };
|
|
599
795
|
}
|
|
600
|
-
if (requirements.plugins.length === 0 && requirements.skills.length === 0) return { required: false, plugins: [], codexHome: null };
|
|
601
796
|
|
|
602
|
-
await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s)`);
|
|
603
|
-
for (const { capability, line } of requirements.evidence || []) {
|
|
604
|
-
await log(` 🔎 '${capability}' detected from: ${line.slice(0, 160)}`, { verbose: true });
|
|
797
|
+
await log(`🔌 Codex capability preflight: detected ${requirements.plugins.length} plugin and ${requirements.skills.length} skill requirement(s) (${scanned})`);
|
|
798
|
+
for (const { capability, line, source } of requirements.evidence || []) {
|
|
799
|
+
await log(` 🔎 '${capability}' detected from ${source || 'requirement text'}: ${line.slice(0, 160)}`, { verbose: true });
|
|
605
800
|
}
|
|
606
801
|
// Every Codex-side preflight operation runs from the checkout that the
|
|
607
802
|
// solver will enter. Besides matching `codex exec`, this keeps any future
|
|
@@ -677,9 +872,12 @@ async function provisionCodexCapabilities({ owner, repo, issueNumber, projectDir
|
|
|
677
872
|
export default {
|
|
678
873
|
applyCodexCapabilityEnv,
|
|
679
874
|
buildPluginCachePath,
|
|
875
|
+
collectAgentInstructionFiles,
|
|
876
|
+
collectCodexCapabilityRequirements,
|
|
680
877
|
detectRequiredCodexCapabilities,
|
|
681
878
|
isCapabilityName,
|
|
682
879
|
isExplicitRequirement,
|
|
880
|
+
parseRequiredCapabilityOverrides,
|
|
683
881
|
readMaterializedPluginSkills,
|
|
684
882
|
repairScopedPluginPayloads,
|
|
685
883
|
resolveRequiredCapabilities,
|
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';
|
|
@@ -351,6 +351,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
351
351
|
itemErrors: state.itemErrors || [],
|
|
352
352
|
turnFailures: state.turnFailures || [],
|
|
353
353
|
streamErrors: state.streamErrors || [],
|
|
354
|
+
pluginInstallRejections: state.pluginInstallRejections || [],
|
|
354
355
|
observedUsageFieldSets: state.observedUsageFieldSets || [],
|
|
355
356
|
observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
|
|
356
357
|
};
|
|
@@ -371,6 +372,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
371
372
|
try {
|
|
372
373
|
data = sanitizeObjectStrings(JSON.parse(line));
|
|
373
374
|
} catch {
|
|
375
|
+
// Issue #2102: `request_plugin_install` is a codex builtin, so its rejection
|
|
376
|
+
// never arrives as an NDJSON item — it only exists in the interleaved OTEL
|
|
377
|
+
// text stream, which is exactly the set of lines that fail to parse here.
|
|
378
|
+
const rejection = matchCodexPluginInstallRejection(line);
|
|
379
|
+
if (rejection) nextState.pluginInstallRejections.push(rejection);
|
|
374
380
|
continue;
|
|
375
381
|
}
|
|
376
382
|
|
|
@@ -745,7 +751,10 @@ export const executeCodex = async params => {
|
|
|
745
751
|
// it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
|
|
746
752
|
await deployHandoffSkill({ tempDir, argv, log, $ });
|
|
747
753
|
const codexBaseEnv = getCodexExecEnv(argv.verbose);
|
|
748
|
-
|
|
754
|
+
// Issue #2102: the target repository's own agent instruction files are part of
|
|
755
|
+
// the requirement corpus, so the preflight needs the checkout; `--require-codex-plugin`
|
|
756
|
+
// is the explicit escape hatch for requirements no document states.
|
|
757
|
+
const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv, requiredPlugins: argv.requireCodexPlugin });
|
|
749
758
|
// Execute the Codex command
|
|
750
759
|
return await executeCodexCommand({
|
|
751
760
|
tempDir,
|
|
@@ -961,6 +970,7 @@ export const executeCodexCommand = async params => {
|
|
|
961
970
|
itemErrors: [],
|
|
962
971
|
turnFailures: [],
|
|
963
972
|
streamErrors: [],
|
|
973
|
+
pluginInstallRejections: [],
|
|
964
974
|
observedUsageFieldSets: [],
|
|
965
975
|
observedModelDiagnosticPaths: [],
|
|
966
976
|
};
|
|
@@ -1127,13 +1137,25 @@ export const executeCodexCommand = async params => {
|
|
|
1127
1137
|
await log(`⚠️ Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
|
|
1128
1138
|
}
|
|
1129
1139
|
const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
|
|
1140
|
+
// Every exit from this run reports the same accounting fields; only the
|
|
1141
|
+
// outcome-specific ones differ. `lastTextContent` is the result summary
|
|
1142
|
+
// captured from the JSON output stream (issue #1263).
|
|
1143
|
+
const buildRunResult = outcome => ({
|
|
1144
|
+
sessionId,
|
|
1145
|
+
limitReached,
|
|
1146
|
+
limitResetTime,
|
|
1147
|
+
pricingInfo,
|
|
1148
|
+
publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
|
|
1149
|
+
resultModelUsage,
|
|
1150
|
+
subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
|
|
1151
|
+
codexJsonDetails: codexJsonState,
|
|
1152
|
+
resultSummary: lastTextContent || null,
|
|
1153
|
+
...outcome,
|
|
1154
|
+
});
|
|
1130
1155
|
|
|
1131
1156
|
// Check for authentication errors first - these should never be retried
|
|
1132
1157
|
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 });
|
|
1158
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1137
1159
|
|
|
1138
1160
|
// Throw an error to stop retries and propagate the auth failure
|
|
1139
1161
|
const error = new Error('Codex authentication failed - 401 Unauthorized. Please run: codex login');
|
|
@@ -1199,25 +1221,9 @@ export const executeCodexCommand = async params => {
|
|
|
1199
1221
|
await log(` Error events: item=${codexErrorSummary.counts.item}, turn=${codexErrorSummary.counts.turn}, stream=${codexErrorSummary.counts.stream}`, { level: 'error' });
|
|
1200
1222
|
}
|
|
1201
1223
|
|
|
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 });
|
|
1224
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1206
1225
|
|
|
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
|
-
};
|
|
1226
|
+
return buildRunResult({ success: false, errorInfo: codexErrorSummary, result: codexErrorSummary.message });
|
|
1221
1227
|
}
|
|
1222
1228
|
|
|
1223
1229
|
if (exitCode !== 0) {
|
|
@@ -1269,24 +1275,22 @@ export const executeCodexCommand = async params => {
|
|
|
1269
1275
|
await log(`\n\n❌ Codex command failed with exit code ${exitCode}`, { level: 'error' });
|
|
1270
1276
|
}
|
|
1271
1277
|
|
|
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 });
|
|
1278
|
+
await logCodexResourceSnapshot({ getResourceSnapshot, log });
|
|
1276
1279
|
|
|
1277
|
-
return {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1280
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState) });
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Issue #2102: a rejected `request_plugin_install` means codex asked for a
|
|
1284
|
+
// capability the preflight did not provision, and under `codex exec` that
|
|
1285
|
+
// request can never succeed — so a run that produced nothing is blocked,
|
|
1286
|
+
// not finished. Reporting it as a named failure (instead of an empty
|
|
1287
|
+
// success) is what makes the missing requirement visible.
|
|
1288
|
+
const pluginProvisioning = getCodexPluginProvisioningHealth(codexJsonState, { capabilityPreflight });
|
|
1289
|
+
await reportCodexPluginProvisioning({ pluginProvisioning, log });
|
|
1290
|
+
if (!pluginProvisioning.healthy) {
|
|
1291
|
+
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1292
|
+
|
|
1293
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), pluginProvisioning, result: [pluginProvisioning.message, ...pluginProvisioning.guidance].join(' ') });
|
|
1290
1294
|
}
|
|
1291
1295
|
|
|
1292
1296
|
// Issue #1990: exit code 0 and the absence of a fatal codex error event are
|
|
@@ -1297,23 +1301,7 @@ export const executeCodexCommand = async params => {
|
|
|
1297
1301
|
// container filesystem needed to inspect and retry the failure (#1990).
|
|
1298
1302
|
const completionHealth = getCodexCompletionHealth(codexJsonState, { lastMessage });
|
|
1299
1303
|
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 });
|
|
1304
|
+
await reportCodexCompletionFailure({ completionHealth, log, getResourceSnapshot });
|
|
1317
1305
|
|
|
1318
1306
|
// Issue #1990: preserve the codex session so an outer full restart can
|
|
1319
1307
|
// resume with context (mirrors the transient-error retry above and the
|
|
@@ -1323,23 +1311,7 @@ export const executeCodexCommand = async params => {
|
|
|
1323
1311
|
// restart at the orchestration level.
|
|
1324
1312
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1325
1313
|
|
|
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
|
-
};
|
|
1314
|
+
return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), completionHealth, pluginProvisioning, incompleteSession: completionHealth.incompleteSession, diskPressureDetected: completionHealth.diskPressureDetected, result: completionHealth.reasons.join(' ') });
|
|
1343
1315
|
}
|
|
1344
1316
|
|
|
1345
1317
|
await log('\n\n✅ Codex command completed');
|
|
@@ -1351,18 +1323,7 @@ export const executeCodexCommand = async params => {
|
|
|
1351
1323
|
await log('⚠️ No result summary captured from Codex output or last-message file', { level: 'warning', verbose: true });
|
|
1352
1324
|
}
|
|
1353
1325
|
|
|
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
|
-
};
|
|
1326
|
+
return buildRunResult({ success: true, pluginProvisioning });
|
|
1366
1327
|
} catch (error) {
|
|
1367
1328
|
// Don't report auth errors to Sentry as they are user configuration issues
|
|
1368
1329
|
if (!error.isAuthError) {
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -715,6 +715,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
715
715
|
type: 'string',
|
|
716
716
|
description: 'Comma-separated list of MCP server names that gemini-cli is allowed to call (passes --allowed-mcp-server-names to gemini-cli). Only used when --tool gemini.',
|
|
717
717
|
},
|
|
718
|
+
// Issue #2102: the Codex capability preflight discovers requirements from the
|
|
719
|
+
// issue text and the target repository's agent instruction files (AGENTS.md,
|
|
720
|
+
// CLAUDE.md, .codex/). This is the escape hatch for a requirement no document
|
|
721
|
+
// states — codex's own `request_plugin_install` can never install anything
|
|
722
|
+
// under `codex exec`, so declaring it here is the only way in.
|
|
723
|
+
'require-codex-plugin': {
|
|
724
|
+
type: 'string',
|
|
725
|
+
description: 'Comma-separated list of Codex plugins (plugin@marketplace) that must be installed into the scoped CODEX_HOME before codex exec starts, in addition to the ones discovered from the issue text and the repository AGENTS.md/CLAUDE.md files. Fails the run when a listed plugin is unavailable. Equivalent to HIVE_MIND_CODEX_REQUIRED_PLUGINS. Only used when --tool codex.',
|
|
726
|
+
},
|
|
718
727
|
};
|
|
719
728
|
|
|
720
729
|
function hasRawOption(rawArgs, optionName) {
|
package/src/solve.mjs
CHANGED
|
@@ -1117,6 +1117,17 @@ try {
|
|
|
1117
1117
|
await log('');
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
|
+
// Preserve work before remote diagnostics; issue #2101 ended during log upload.
|
|
1121
|
+
try {
|
|
1122
|
+
const { criticalErrorRecovery } = await import('./config.lib.mjs');
|
|
1123
|
+
if (criticalErrorRecovery.autoCommitUncommittedChanges) {
|
|
1124
|
+
const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
|
|
1125
|
+
await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
|
|
1126
|
+
}
|
|
1127
|
+
} catch (preserveError) {
|
|
1128
|
+
await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1120
1131
|
// Attach failure logs before exiting (Issues #1212, #1462: fall back to issue if no PR)
|
|
1121
1132
|
const hasPR = global.createdPR && global.createdPR.number;
|
|
1122
1133
|
const hasIssue = global.issueNumber;
|
|
@@ -1167,19 +1178,6 @@ try {
|
|
|
1167
1178
|
}
|
|
1168
1179
|
}
|
|
1169
1180
|
|
|
1170
|
-
// Issue #1834 (PR #1835 feedback): "on all critical errors we auto commit uncommitted changes by
|
|
1171
|
-
// default." A failed session exits here before the normal auto-commit chokepoint below, so commit
|
|
1172
|
-
// + push any work first. On by default; disable via HIVE_MIND_AUTO_COMMIT_ON_CRITICAL_ERROR=false.
|
|
1173
|
-
try {
|
|
1174
|
-
const { criticalErrorRecovery } = await import('./config.lib.mjs');
|
|
1175
|
-
if (criticalErrorRecovery.autoCommitUncommittedChanges) {
|
|
1176
|
-
const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
|
|
1177
|
-
await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
|
|
1178
|
-
}
|
|
1179
|
-
} catch (preserveError) {
|
|
1180
|
-
await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
1181
|
await safeExit(1, toolFailureMessage);
|
|
1184
1182
|
}
|
|
1185
1183
|
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -47,7 +47,11 @@ export const classifyRetryableError = value => {
|
|
|
47
47
|
// overloaded API anyway. Claude Code already exposes its own per-request fallback
|
|
48
48
|
// via `--fallback-model` (wired in claude.lib.mjs), so we keep `--model` stable and
|
|
49
49
|
// simply retry. Therefore isCapacity is false → retry with the same model.
|
|
50
|
-
|
|
50
|
+
// Issue #2101: Codex may exhaust its internal WebSocket/HTTPS attempts and
|
|
51
|
+
// surface only "We're currently experiencing high demand, which may cause
|
|
52
|
+
// temporary errors." The terminal message omits both the preceding 503 and
|
|
53
|
+
// the backend's concurrency_limit code, so it must be recognized directly.
|
|
54
|
+
if (lower.includes('overloaded') || lower.includes('overloaded_error') || lower.includes('currently experiencing high demand') || lower.includes('too many concurrent requests') || lower.includes('concurrency_limit')) {
|
|
51
55
|
return { message, isRetryable: true, isCapacity: false, label: 'API overload' };
|
|
52
56
|
}
|
|
53
57
|
|