@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.11",
3
+ "version": "2.11.13",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
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
- streamingErrorMessage = data.message || data.error || raw.substring(0, 100);
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
- const outputError = detectAgentErrors(fullOutput);
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
- const error = failure?.error || 'Unknown error';
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
 
@@ -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 || JSON.stringify(data);
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
- if (eventType === 'error' && typeof data.message === 'string' && (data.message.includes('401 Unauthorized') || data.message.includes('401') || data.message.includes('Unauthorized'))) {
414
- nextState.authError = true;
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
- if (eventType === 'error' && typeof data.message === 'string') {
418
- nextState.streamErrors.push({ message: data.message });
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') {