@link-assistant/hive-mind 2.11.11 → 2.11.13
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 +80 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +119 -20
- package/src/automation-stop-reporting.lib.mjs +272 -0
- package/src/cancelled-ci-rerun.lib.mjs +3 -1
- package/src/claude.lib.mjs +3 -2
- package/src/codex.lib.mjs +13 -12
- package/src/error-text.lib.mjs +130 -0
- package/src/gemini.lib.mjs +3 -1
- package/src/github-terminal-state.lib.mjs +50 -13
- package/src/interactive-codex-events.lib.mjs +4 -1
- package/src/lib.mjs +11 -0
- package/src/qwen.lib.mjs +1 -12
- package/src/solve.auto-merge-attempt.lib.mjs +245 -0
- package/src/solve.auto-merge.lib.mjs +54 -111
- package/src/solve.pre-pr-failure-notifier.lib.mjs +10 -2
- package/src/solve.watch.lib.mjs +35 -1
- package/src/tool-comments.lib.mjs +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 61f20ce: Closed issues no longer stop the mergeable loop, and every stop is now explained
|
|
8
|
+
in a GitHub comment (issue #2144).
|
|
9
|
+
|
|
10
|
+
A `solve …/pull/927 --auto-merge` run stopped after its first monitoring
|
|
11
|
+
iteration with `❌ GITHUB TARGET UNAVAILABLE: Issue #905 has been closed.` — in
|
|
12
|
+
the _same_ probe that reported the pull request as `"mergeable": true,
|
|
13
|
+
"mergeable_state": "clean"`. Nothing was posted to GitHub and the process exited
|
|
14
|
+
`0`, so the run looked successful while the pull request sat unmerged until a
|
|
15
|
+
human merged it manually two hours later.
|
|
16
|
+
|
|
17
|
+
- `src/github-terminal-state.lib.mjs` now distinguishes _terminal_ states from
|
|
18
|
+
_merge blockers_. A closed or deleted **issue** is a merge blocker: the loop
|
|
19
|
+
keeps working to make the pull request mergeable. Only the pull request being
|
|
20
|
+
merged, closed or unreachable — or the repository/branches it needs being gone
|
|
21
|
+
— stops the loop.
|
|
22
|
+
- A closed issue holds back `--auto-merge` only. When it does, the tool comments
|
|
23
|
+
that the pull request is ready and asks the user to reopen the issue or merge
|
|
24
|
+
manually, instead of stopping silently.
|
|
25
|
+
- New `src/automation-stop-reporting.lib.mjs`: a registry of 13 stop reasons,
|
|
26
|
+
each with a title, an explanation and concrete next steps, plus a deduped,
|
|
27
|
+
never-throwing reporter. It is wired into all 11 stop paths of
|
|
28
|
+
`--auto-merge`, `--auto-restart-until-mergeable` and `--watch`. Unknown reason
|
|
29
|
+
codes degrade to a generic comment, so a new stop can never regress to
|
|
30
|
+
silence.
|
|
31
|
+
- `attemptAutoMerge` was extracted into
|
|
32
|
+
`src/solve.auto-merge-attempt.lib.mjs` to stay within the repository's
|
|
33
|
+
file-length policy.
|
|
34
|
+
- Fixed a second defect found in the same log: the quiet GitHub probes from
|
|
35
|
+
issue #2130 were silently defeated because all three callers injected their own
|
|
36
|
+
mirroring `$`, so ~33 KB of pull request JSON plus the full issue payload were
|
|
37
|
+
written to the attached log on every iteration. They now pass
|
|
38
|
+
`quietProbe($)`, with a regression test.
|
|
39
|
+
|
|
40
|
+
Timeline, requirement inventory, root causes, edge cases and the codebase sweep:
|
|
41
|
+
`docs/case-studies/issue-2144/README.md`. Reproduction:
|
|
42
|
+
`experiments/issue-2144/repro-closed-issue-stops-loop.mjs`.
|
|
43
|
+
|
|
44
|
+
## 2.11.12
|
|
45
|
+
|
|
46
|
+
### Patch Changes
|
|
47
|
+
|
|
48
|
+
- be63949: Render structured tool error payloads as readable text instead of
|
|
49
|
+
`[object Object]` (issue #2141).
|
|
50
|
+
|
|
51
|
+
A `solve --tool agent --model formal-ai` run failed 22 seconds in and published
|
|
52
|
+
one artefact: a "Solution Draft Failed" comment reading `AGENT execution failed
|
|
53
|
+
with Agent reported error: [object Object]`. `--attach-logs` was off, so that
|
|
54
|
+
string was the entire post-mortem and the real cause is unrecoverable.
|
|
55
|
+
`@link-assistant/agent` emits `NamedError.toObject()` — `{"type":"error",
|
|
56
|
+
"error":{"name":"…","data":{"message":"…"}}}` — and the adapter interpolated that
|
|
57
|
+
object into a template literal. The `|| JSON.stringify(msg)` fallback that would
|
|
58
|
+
have saved the diagnosis was unreachable, because the object is truthy.
|
|
59
|
+
|
|
60
|
+
- New `src/error-text.lib.mjs` renders strings, `Error` instances, `NamedError`
|
|
61
|
+
payloads, nested `{error:{…}}` envelopes and arrays into one readable line —
|
|
62
|
+
circular-safe, depth-limited, truncated at 2000 characters, never a
|
|
63
|
+
placeholder.
|
|
64
|
+
- A codebase-wide audit found the same defect class in nine places across six
|
|
65
|
+
adapters; all now use the shared renderer. One of them was not cosmetic:
|
|
66
|
+
`codex.lib.mjs` guarded `error` / `turn.failed` events with `typeof
|
|
67
|
+
data.message === 'string'` and therefore **discarded** object-shaped failures,
|
|
68
|
+
reporting the run as a success.
|
|
69
|
+
- Defence in depth: `isMeaningfulErrorText` and `extractToolErrorCore` now reject
|
|
70
|
+
a core polluted by `[object Object]`, so any site this audit missed degrades to
|
|
71
|
+
the honest `AGENT execution failed` rather than the misleading long form.
|
|
72
|
+
- The agent adapter now fails fast when the CLI logs a fatal startup error
|
|
73
|
+
(`ProviderModelNotFoundError` and friends) and then exits 0 with no error event
|
|
74
|
+
and no output — a silent failure reproduced against agent CLI 0.25.5 and
|
|
75
|
+
reported upstream.
|
|
76
|
+
- `--verbose` dumps the raw JSON of every error and fatal log record, and the
|
|
77
|
+
pre-PR failure comment now tells the reader to rerun with
|
|
78
|
+
`--attach-logs --verbose`.
|
|
79
|
+
|
|
80
|
+
Case study, raw evidence and the upstream reports:
|
|
81
|
+
`docs/case-studies/issue-2141/README.md`.
|
|
82
|
+
|
|
3
83
|
## 2.11.11
|
|
4
84
|
|
|
5
85
|
### Patch Changes
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -27,12 +27,98 @@ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue
|
|
|
27
27
|
import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
|
|
28
28
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
|
|
29
29
|
import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
|
|
30
|
+
import { firstErrorText, stringifyErrorValue } from './error-text.lib.mjs';
|
|
30
31
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
31
32
|
import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
|
|
32
33
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
33
34
|
|
|
34
35
|
export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Render one streamed agent error record as human-readable text (issue #2141).
|
|
39
|
+
*
|
|
40
|
+
* `@link-assistant/agent` 0.25.x publishes `NamedError.toObject()` under the
|
|
41
|
+
* `error` key: `{"type":"error","error":{"name":"RetryTimeoutExceededError",
|
|
42
|
+
* "data":{"message":"…"}}}`. The previous chain `data.message || data.error ||
|
|
43
|
+
* raw.substring(0, 100)` returned that *object*, and interpolating it into
|
|
44
|
+
* `Agent reported error: ${…}` produced the reason published to GitHub in issue
|
|
45
|
+
* #2141: "AGENT execution failed with Agent reported error: [object Object]".
|
|
46
|
+
*
|
|
47
|
+
* @param {object} record - a sanitized JSON record from the agent stream.
|
|
48
|
+
* @param {string} [raw] - the raw record text, used as a last resort.
|
|
49
|
+
* @returns {string} readable error text, never `[object Object]`.
|
|
50
|
+
*/
|
|
51
|
+
export const extractAgentErrorText = (record, raw = '') => {
|
|
52
|
+
const fallback = String(raw || '').substring(0, 200) || 'Agent emitted an error event without any details';
|
|
53
|
+
return firstErrorText([record?.message, record?.error, record?.data, record], { fallback });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Model/provider initialization failures that leave the session unable to do any
|
|
58
|
+
* work at all (issue #2141).
|
|
59
|
+
*/
|
|
60
|
+
const FATAL_AGENT_LOG_PATTERNS = [/ProviderModelNotFoundError/i, /ProviderInitError/i, /NoSuchModelError/i, /ModelNotFoundError/i, /failed to initialize .*model/i];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect an agent `log` record that reports a fatal startup failure (issue #2141).
|
|
64
|
+
*
|
|
65
|
+
* Reproduced locally with agent CLI 0.25.5: `agent --model nonexistent/model`
|
|
66
|
+
* prints
|
|
67
|
+
*
|
|
68
|
+
* {"type":"log","level":"error","service":"session.prompt",
|
|
69
|
+
* "error":"ProviderModelNotFoundError",
|
|
70
|
+
* "hint":"Check that the model exists in the provider",
|
|
71
|
+
* "message":"Failed to initialize specified model - NOT falling back to default"}
|
|
72
|
+
*
|
|
73
|
+
* then `session.idle` and exits **0** without ever emitting `{"type":"error"}`.
|
|
74
|
+
* Hive Mind therefore reported the run as a success with no result summary, which
|
|
75
|
+
* is the same class of undiagnosable outcome as issue #2141's `[object Object]`:
|
|
76
|
+
* the run failed, but nothing said so.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} record - a sanitized JSON record from the agent stream.
|
|
79
|
+
* @returns {string|null} readable failure text, or null when the record is not fatal.
|
|
80
|
+
*/
|
|
81
|
+
export const detectFatalAgentLogRecord = record => {
|
|
82
|
+
if (!record || typeof record !== 'object') return null;
|
|
83
|
+
if (record.type !== 'log' || record.level !== 'error') return null;
|
|
84
|
+
|
|
85
|
+
const text = firstErrorText([record.error, record.message], { fallback: '' });
|
|
86
|
+
if (!text) return null;
|
|
87
|
+
const haystack = `${stringifyErrorValue(record.error)} ${stringifyErrorValue(record.message)}`;
|
|
88
|
+
if (!FATAL_AGENT_LOG_PATTERNS.some(pattern => pattern.test(haystack))) return null;
|
|
89
|
+
|
|
90
|
+
const parts = [stringifyErrorValue(record.error), stringifyErrorValue(record.message), record.hint ? `hint: ${stringifyErrorValue(record.hint)}` : ''];
|
|
91
|
+
return parts.filter(Boolean).join(' — ');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Scan agent stdout for explicit JSON error records (issues #1201, #2119, #2141).
|
|
96
|
+
*
|
|
97
|
+
* Exported so the detection precedence can be tested directly instead of being
|
|
98
|
+
* re-implemented in tests (the old duplicate in tests/test-agent-error-detection.mjs
|
|
99
|
+
* kept the `[object Object]` bug invisible).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} stdoutOutput - captured agent stdout.
|
|
102
|
+
* @returns {{detected: boolean, type?: string, match?: string, record?: object}}
|
|
103
|
+
*/
|
|
104
|
+
export const detectAgentErrorsInOutput = stdoutOutput => {
|
|
105
|
+
// Issue #2119: frame records by balanced JSON, not by newlines, so
|
|
106
|
+
// pretty-printed and concatenated records are still inspected.
|
|
107
|
+
for (const record of parseJsonRecords(stdoutOutput)) {
|
|
108
|
+
const msg = sanitizeObjectStrings(record);
|
|
109
|
+
|
|
110
|
+
// Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
|
|
111
|
+
if (msg === null || typeof msg !== 'object') continue;
|
|
112
|
+
|
|
113
|
+
// Check for explicit error message types from agent
|
|
114
|
+
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
115
|
+
return { detected: true, type: 'AgentError', match: extractAgentErrorText(msg, JSON.stringify(msg)), record: msg };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { detected: false };
|
|
120
|
+
};
|
|
121
|
+
|
|
36
122
|
// Import pricing functions from claude.lib.mjs
|
|
37
123
|
// We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
|
|
38
124
|
const claudeLib = await import('./claude.lib.mjs');
|
|
@@ -608,6 +694,9 @@ export const executeAgentCommand = async params => {
|
|
|
608
694
|
// Issue #1276: Track successful completion events to clear error flags
|
|
609
695
|
// When agent emits session.idle or disposal events, it means it recovered and completed successfully
|
|
610
696
|
let agentCompletedSuccessfully = false;
|
|
697
|
+
// Issue #2141: a fatal startup log record (e.g. ProviderModelNotFoundError)
|
|
698
|
+
// that the agent CLI reports without any `{"type":"error"}` event.
|
|
699
|
+
let fatalLogErrorMessage = null;
|
|
611
700
|
// Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
|
|
612
701
|
// This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
|
|
613
702
|
const streamingTokenUsage = createAgentTokenUsage();
|
|
@@ -668,8 +757,23 @@ export const executeAgentCommand = async params => {
|
|
|
668
757
|
// Issue #1201: Detect error events during streaming for reliable detection
|
|
669
758
|
if (data.type === 'error' || data.type === 'step_error') {
|
|
670
759
|
streamingErrorDetected = true;
|
|
671
|
-
|
|
760
|
+
// Issue #2141: render `{name, data:{message}}` payloads as text so the
|
|
761
|
+
// published failure reason is diagnosable instead of "[object Object]".
|
|
762
|
+
streamingErrorMessage = extractAgentErrorText(data, raw);
|
|
672
763
|
await log(`⚠️ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
|
|
764
|
+
// Issue #2141: keep the untouched record so the root cause survives even
|
|
765
|
+
// when the rendering above loses a field. Verbose-only to keep normal
|
|
766
|
+
// logs readable; --attach-logs then carries the full payload.
|
|
767
|
+
await log(` Raw error record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
|
|
768
|
+
}
|
|
769
|
+
// Issue #2141: fail fast when the CLI could not even start the model.
|
|
770
|
+
if (!fatalLogErrorMessage) {
|
|
771
|
+
const fatalLogText = detectFatalAgentLogRecord(data);
|
|
772
|
+
if (fatalLogText) {
|
|
773
|
+
fatalLogErrorMessage = fatalLogText;
|
|
774
|
+
await log(`⚠️ Fatal agent log record detected: ${fatalLogText}`, { level: 'warning' });
|
|
775
|
+
await log(` Raw log record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
|
|
776
|
+
}
|
|
673
777
|
}
|
|
674
778
|
// Issue #1263: Track text content for result summary
|
|
675
779
|
// Agent outputs text via 'text', 'assistant', or 'message' type events
|
|
@@ -759,26 +863,9 @@ export const executeAgentCommand = async params => {
|
|
|
759
863
|
// 1. Non-zero exit code (agent returns 1 on errors)
|
|
760
864
|
// 2. Explicit JSON error messages from agent (type: "error")
|
|
761
865
|
// 3. Usage limit detection (handled separately)
|
|
762
|
-
const detectAgentErrors = stdoutOutput => {
|
|
763
|
-
// Issue #2119: frame records by balanced JSON, not by newlines, so
|
|
764
|
-
// pretty-printed and concatenated records are still inspected.
|
|
765
|
-
for (const record of parseJsonRecords(stdoutOutput)) {
|
|
766
|
-
const msg = sanitizeObjectStrings(record);
|
|
767
|
-
|
|
768
|
-
// Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
|
|
769
|
-
if (msg === null || typeof msg !== 'object') continue;
|
|
770
|
-
|
|
771
|
-
// Check for explicit error message types from agent
|
|
772
|
-
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
773
|
-
return { detected: true, type: 'AgentError', match: msg.message || msg.error || JSON.stringify(msg).substring(0, 100) };
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
return { detected: false };
|
|
778
|
-
};
|
|
779
|
-
|
|
780
866
|
// Only check for JSON error messages, not pattern matching in output
|
|
781
|
-
|
|
867
|
+
// Issue #2141: the detection now renders structured payloads as text.
|
|
868
|
+
const outputError = detectAgentErrorsInOutput(fullOutput);
|
|
782
869
|
|
|
783
870
|
// Issue #1276: Clear streaming error detection if agent completed successfully
|
|
784
871
|
// When an error occurs during execution (e.g., timeout) but the agent recovers and completes,
|
|
@@ -857,6 +944,18 @@ export const executeAgentCommand = async params => {
|
|
|
857
944
|
}
|
|
858
945
|
}
|
|
859
946
|
|
|
947
|
+
// Issue #2141: agent CLI 0.25.5 exits 0 after `ProviderModelNotFoundError`
|
|
948
|
+
// without emitting an error event, so the run was published as a success
|
|
949
|
+
// with no result summary. Treat a fatal startup log record as the failure
|
|
950
|
+
// it is, but only when the session produced no work at all — a recovered
|
|
951
|
+
// error must keep exit code 0 authoritative (issue #1276).
|
|
952
|
+
if (exitCode === 0 && !outputError.detected && fatalLogErrorMessage && !lastTextContent) {
|
|
953
|
+
outputError.detected = true;
|
|
954
|
+
outputError.type = 'AgentFatalLog';
|
|
955
|
+
outputError.match = fatalLogErrorMessage;
|
|
956
|
+
await log(`\n⚠️ Agent exited 0 but never started a model: ${fatalLogErrorMessage}`, { level: 'warning' });
|
|
957
|
+
}
|
|
958
|
+
|
|
860
959
|
if (exitCode !== 0 || outputError.detected) {
|
|
861
960
|
const retryableError = classifyRetryableError(outputError.match || streamingErrorMessage || lastMessage || fullOutput);
|
|
862
961
|
if (retryableError.isRetryable) {
|
|
@@ -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
|
+
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { CANCELLED_CI_REVIEW_MARKER } from './tool-comments.lib.mjs';
|
|
15
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
15
16
|
|
|
16
17
|
const CANCELLED_OR_STALE_CONCLUSIONS = new Set(['cancelled', 'stale']);
|
|
17
18
|
|
|
@@ -122,7 +123,8 @@ const formatRunReference = run => {
|
|
|
122
123
|
|
|
123
124
|
const formatRerunFailure = failure => {
|
|
124
125
|
const runPart = formatRunReference(failure?.run);
|
|
125
|
-
|
|
126
|
+
// Issue #2141: the error may arrive as an object; never publish [object Object].
|
|
127
|
+
const error = stringifyErrorValue(failure?.error, { fallback: 'Unknown error' });
|
|
126
128
|
return `${runPart}: ${error}`;
|
|
127
129
|
};
|
|
128
130
|
|
package/src/claude.lib.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.li
|
|
|
36
36
|
import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
|
|
37
37
|
import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
|
|
38
38
|
import { showResumeCommand } from './claude.resume-output.lib.mjs';
|
|
39
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
39
40
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
40
41
|
export { availableModels, fetchModelInfo }; // Re-export for backward compatibility
|
|
41
42
|
export { formatNumber, mapModelToId, checkModelVisionCapability };
|
|
@@ -96,7 +97,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
96
97
|
const jsonMatch = text.match(/\{.*"error".*\}/);
|
|
97
98
|
if (jsonMatch) {
|
|
98
99
|
const errorObj = JSON.parse(jsonMatch[0]);
|
|
99
|
-
return errorObj.error;
|
|
100
|
+
return stringifyErrorValue(errorObj.error, { fallback: jsonMatch[0] }); // Issue #2141: the `error` field is usually an object
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
} catch (e) {
|
|
@@ -936,7 +937,7 @@ export const executeClaudeCommand = async params => {
|
|
|
936
937
|
}
|
|
937
938
|
if (data.type === 'text' && data.text) lastMessage = data.text;
|
|
938
939
|
else if (data.type === 'error') {
|
|
939
|
-
lastMessage = data.error
|
|
940
|
+
lastMessage = stringifyErrorValue(data.error, { fallback: JSON.stringify(data) }); // Issue #2141: `data.error` is often an object; render it as text so the reason is never "[object Object]" and the substring checks below get a real string
|
|
940
941
|
if (lastMessage.includes('Internal server error')) isInternalServerError = true;
|
|
941
942
|
}
|
|
942
943
|
// Issue #1491: Track token usage from stream events for independent calculation
|
package/src/codex.lib.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
|
|
|
25
25
|
import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
|
|
26
26
|
const __codexBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'codex', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
|
|
27
27
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
28
|
+
import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
|
|
28
29
|
import { createLineBuffer } from './json-stream.lib.mjs'; // Issue #2119
|
|
29
30
|
import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.mjs';
|
|
30
31
|
import { buildCodexRunDiagnostics, codexRunAlreadyFailed, describeCodexLastMessageOutcome } from './codex.run-diagnostics.lib.mjs'; // Issue #2130
|
|
@@ -410,20 +411,20 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
410
411
|
if (typeof getter(data) === 'string') observedModelPaths.add(pathName);
|
|
411
412
|
}
|
|
412
413
|
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
// Issue #2141: these payloads are not always strings. Requiring `typeof …
|
|
415
|
+
// === 'string'` dropped every object-shaped failure — a dropped
|
|
416
|
+
// `turn.failed` reads as a success — and interpolating one would have
|
|
417
|
+
// published "[object Object]". Render it as text instead.
|
|
418
|
+
const streamErrorText = eventType === 'error' ? firstErrorText([data.message, data.error]) : '';
|
|
419
|
+
if (streamErrorText) {
|
|
420
|
+
if (streamErrorText.includes('401') || streamErrorText.includes('Unauthorized')) nextState.authError = true;
|
|
421
|
+
nextState.streamErrors.push({ message: streamErrorText });
|
|
415
422
|
}
|
|
416
423
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (eventType === 'turn.failed' && typeof data.error?.message === 'string' && (data.error.message.includes('401 Unauthorized') || data.error.message.includes('401') || data.error.message.includes('Unauthorized'))) {
|
|
422
|
-
nextState.authError = true;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (eventType === 'turn.failed' && typeof data.error?.message === 'string') {
|
|
426
|
-
nextState.turnFailures.push({ message: data.error.message });
|
|
424
|
+
const turnFailureText = eventType === 'turn.failed' ? firstErrorText([data.error, data.message]) : '';
|
|
425
|
+
if (turnFailureText) {
|
|
426
|
+
if (turnFailureText.includes('401') || turnFailureText.includes('Unauthorized')) nextState.authError = true;
|
|
427
|
+
nextState.turnFailures.push({ message: turnFailureText });
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
if (eventType === 'turn.completed' && data.usage && typeof data.usage === 'object') {
|