@link-assistant/hive-mind 2.0.24 → 2.0.26

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,30 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.26
4
+
5
+ ### Patch Changes
6
+
7
+ - 13074f0: Add Codex model support for the GPT-5.6 preview family, Bedrock-prefixed OpenAI Codex model IDs, and the hidden `codex-auto-review` catalog entry while keeping `gpt-5.5` as the default.
8
+
9
+ ## 2.0.25
10
+
11
+ ### Patch Changes
12
+
13
+ - 148ca23: Fix exit-0-but-incomplete runs being reported as success under docker isolation (#1990). A `solve` run whose AI tool exited 0 while its session was cut off mid-run (e.g. the container ran out of disk) is now registered as a failure instead of a false success: codex requires its paired `turn.started`/`turn.completed` lifecycle, and gemini and qwen now require their terminal `result` event (claude already gated on it). A flagged failure preserves the AI session for a context-preserving retry and returns a non-zero exit so the docker container filesystem is kept for inspection. Disk-exhaustion strings are surfaced only as diagnostics, never as an independent failure gate, to avoid the #1955 echo false positive.
14
+
15
+ This also refreshes dependencies and picks up the upstream half of the #1990 fix. The
16
+ `start-command` pin in `Dockerfile`/`Dockerfile.dind` is bumped `0.30.1 → 0.30.2`,
17
+ which delivers [link-foundation/start#144](https://github.com/link-foundation/start/issues/144):
18
+ detached/isolated docker runs now surface the container's `OOMKilled` status and
19
+ preserve an abnormally-terminated container's filesystem for inspection instead of
20
+ auto-removing it. npm dependencies and devDependencies are updated to their latest
21
+ compatible versions (notably ESLint 9 → 10, which enables the `no-useless-assignment`
22
+ and `preserve-caught-error` recommended rules — all newly-flagged sites were fixed).
23
+ `jscpd` is intentionally held at `^4.0.5` because its 5.x line changes the
24
+ duplication baseline (it analyzes a wider file set, reporting 12.2% vs 10.7% on the
25
+ same tree) and would otherwise force weakening the duplication gate; this is a tooling
26
+ behavior change, not new duplication.
27
+
3
28
  ## 2.0.24
4
29
 
5
30
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.24",
3
+ "version": "2.0.26",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -57,30 +57,30 @@
57
57
  "*.md"
58
58
  ],
59
59
  "devDependencies": {
60
- "@changesets/cli": "^2.27.0",
61
- "@eslint/js": "^9.35.0",
62
- "eslint": "^9.35.0",
60
+ "@changesets/cli": "^2.31.0",
61
+ "@eslint/js": "^10.0.1",
62
+ "eslint": "^10.5.0",
63
63
  "eslint-config-prettier": "^10.1.8",
64
- "eslint-plugin-prettier": "^5.5.4",
64
+ "eslint-plugin-prettier": "^5.5.6",
65
65
  "husky": "^9.1.7",
66
66
  "jscpd": "^4.0.5",
67
- "lint-staged": "^16.2.7",
68
- "prettier": "^3.6.2",
67
+ "lint-staged": "^17.0.8",
68
+ "prettier": "^3.8.5",
69
69
  "test-anywhere": "^0.9.1"
70
70
  },
71
71
  "dependencies": {
72
- "@secretlint/core": "^11.2.5",
73
- "@secretlint/secretlint-rule-preset-recommend": "^11.2.5",
74
- "@sentry/node": "^10.15.0",
75
- "@sentry/profiling-node": "^10.15.0",
76
- "agent-commander": "^0.6.1",
77
- "dayjs": "^1.11.19",
72
+ "@secretlint/core": "^13.0.2",
73
+ "@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
74
+ "@sentry/node": "^10.62.0",
75
+ "@sentry/profiling-node": "^10.62.0",
76
+ "agent-commander": "^0.8.0",
77
+ "dayjs": "^1.11.21",
78
78
  "decimal.js-light": "^2.5.1",
79
79
  "lino-arguments": "^0.3.0",
80
80
  "lino-i18n": "^0.1.1",
81
- "lino-objects-codec": "^0.3.6",
82
- "secretlint": "^11.2.5",
83
- "semver": "^7.7.3",
81
+ "lino-objects-codec": "^0.4.0",
82
+ "secretlint": "^13.0.2",
83
+ "semver": "^7.8.5",
84
84
  "tinyld": "^1.3.4"
85
85
  },
86
86
  "lint-staged": {
@@ -91,7 +91,7 @@ const getAgentCommander = async () => {
91
91
  try {
92
92
  return await import('agent-commander');
93
93
  } catch (error) {
94
- throw new Error(`agent-commander is not installed or cannot be loaded. Install it with: npm install agent-commander\nOriginal error: ${error.message}`);
94
+ throw new Error(`agent-commander is not installed or cannot be loaded. Install it with: npm install agent-commander\nOriginal error: ${error.message}`, { cause: error });
95
95
  }
96
96
  };
97
97
 
@@ -19,7 +19,7 @@ function detectByScript(word) {
19
19
 
20
20
  export function detectWordLanguage(word, detector = detectLanguage) {
21
21
  if (!word || typeof word !== 'string') return null;
22
- let detected = null;
22
+ let detected;
23
23
  try {
24
24
  detected = detector(word);
25
25
  } catch {
@@ -77,7 +77,7 @@ export function resolveClaudeLanguageSetting(locale) {
77
77
 
78
78
  export const ensureClaudeQuietConfig = async ({ settingsPath, log, workLanguage = null } = {}) => {
79
79
  const resolvedPath = settingsPath || path.join(os.homedir(), '.claude', 'settings.json');
80
- let settings = {};
80
+ let settings;
81
81
  try {
82
82
  const content = await fs.readFile(resolvedPath, 'utf-8');
83
83
  const parsed = JSON.parse(content);
@@ -528,7 +528,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
528
528
  compactifications: compactifications.length > 0 ? compactifications : null,
529
529
  };
530
530
  } catch (readError) {
531
- throw new Error(`Failed to read session file: ${readError.message}`);
531
+ throw new Error(`Failed to read session file: ${readError.message}`, { cause: readError });
532
532
  }
533
533
  };
534
534
  // Extracted to claude.stderr.lib.mjs (Issue #477, #1337)
package/src/cleanup.mjs CHANGED
@@ -372,7 +372,7 @@ async function main() {
372
372
  if (!options.force) {
373
373
  console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}) and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
374
374
  console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
375
- let answer = '';
375
+ let answer;
376
376
  try {
377
377
  answer = await readConfirmationLine({ prompt: '> ' });
378
378
  } catch {
@@ -756,7 +756,7 @@ export async function listSessionTasks(options = {}) {
756
756
  return [];
757
757
  }
758
758
 
759
- let sessions = [];
759
+ let sessions;
760
760
  try {
761
761
  sessions = await listIsolationSessions(verbose);
762
762
  } catch {
@@ -0,0 +1,191 @@
1
+ // Codex run-health analysis helpers.
2
+ //
3
+ // Extracted from codex.lib.mjs (which exceeded the 1500-line max-lines budget).
4
+ // These functions inspect the parsed `codex exec --json` event state and decide
5
+ // whether a run genuinely succeeded:
6
+ // - getCodexErrorEventSummary: classifies stray error events, suppressing the
7
+ // #1955 echoed-fixture false positives once a turn has completed.
8
+ // - getCodexCompletionHealth: the #1990 turn-lifecycle gate that flags exit-0
9
+ // runs cut off mid-turn (e.g. by docker disk exhaustion) as unhealthy.
10
+ //
11
+ // Both are re-exported from codex.lib.mjs for backward compatibility, so existing
12
+ // importers (and tests) can keep importing them from either module.
13
+
14
+ import { isENOSPC } from './lib.mjs';
15
+
16
+ const unwrapCodexErrorMessage = value => {
17
+ if (!value) return '';
18
+ if (typeof value !== 'string') {
19
+ if (typeof value?.error?.message === 'string') return unwrapCodexErrorMessage(value.error.message);
20
+ if (typeof value?.message === 'string') return unwrapCodexErrorMessage(value.message);
21
+ return String(value);
22
+ }
23
+
24
+ let text = value.trim();
25
+ for (let i = 0; i < 3; i++) {
26
+ if (!text.startsWith('{') && !text.startsWith('[')) break;
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (typeof parsed?.error?.message === 'string') return unwrapCodexErrorMessage(parsed.error.message);
30
+ if (typeof parsed?.message === 'string') {
31
+ text = parsed.message.trim();
32
+ continue;
33
+ }
34
+ return JSON.stringify(parsed);
35
+ } catch {
36
+ break;
37
+ }
38
+ }
39
+ return text;
40
+ };
41
+
42
+ const isNonFatalCodexItemErrorMessage = message => /^in-process app-server event stream lagged; dropped \d+ events?$/i.test(message || '');
43
+
44
+ export const getCodexErrorEventSummary = codexJsonState => {
45
+ const events = [];
46
+ const ignoredEvents = [];
47
+
48
+ // Issue #1955: When the codex turn genuinely completed (a `turn.completed`
49
+ // event was observed) and codex never emitted a `turn.failed`, the session
50
+ // SUCCEEDED. Any stray top-level `error` (stream) or nested item `error` event
51
+ // in that case is non-fatal and must not fail the run. Two things produce such
52
+ // strays:
53
+ // 1. A transient error codex itself retried/recovered from before completing
54
+ // the turn (e.g. a momentary stream blip).
55
+ // 2. Echoed content that merely *looks* like a codex protocol event. The
56
+ // codex CLI prints OTEL telemetry (`codex_otel.log_only`,
57
+ // event.name="codex.tool_result") containing a raw `Output:` dump of each
58
+ // command's stdout. When a command prints a line shaped like a protocol
59
+ // event — e.g. a printed NDJSON fixture line
60
+ // `{"type":"error","message":"Network lookup skipped in fixture"}` — our
61
+ // line-by-line parser misreads it as a genuine codex stream error and
62
+ // fails an otherwise-successful run. This was the exact false positive in
63
+ // issue #1955 (codex finished, working tree clean, CI passed, yet the run
64
+ // was reported failed).
65
+ // `turn.failed` is the authoritative failure signal, so it is NEVER suppressed
66
+ // here; only non-`turn` error events are gated on turn completion.
67
+ const turnCompleted = (codexJsonState?.eventCounts?.['turn.completed'] || 0) > 0;
68
+ const turnFailed = (codexJsonState?.turnFailures?.length || 0) > 0;
69
+ const sessionSucceeded = turnCompleted && !turnFailed;
70
+
71
+ const addEvents = (type, items = []) => {
72
+ for (const item of items) {
73
+ const message = unwrapCodexErrorMessage(item?.message);
74
+ const event = { type, message: message || 'Codex emitted an error event' };
75
+ if (type === 'item' && isNonFatalCodexItemErrorMessage(message)) {
76
+ ignoredEvents.push({
77
+ ...event,
78
+ reason: 'Codex app-server backpressure warning; the turn can still complete successfully',
79
+ });
80
+ continue;
81
+ }
82
+ if (type !== 'turn' && sessionSucceeded) {
83
+ ignoredEvents.push({
84
+ ...event,
85
+ reason: 'Codex turn completed successfully with no turn.failed; stray non-turn error event is non-fatal (Issue #1955)',
86
+ });
87
+ continue;
88
+ }
89
+ events.push(event);
90
+ }
91
+ };
92
+
93
+ addEvents('item', codexJsonState?.itemErrors);
94
+ addEvents('turn', codexJsonState?.turnFailures);
95
+ addEvents('stream', codexJsonState?.streamErrors);
96
+
97
+ const countByType = items => ({
98
+ item: items.filter(item => item.type === 'item').length,
99
+ turn: items.filter(item => item.type === 'turn').length,
100
+ stream: items.filter(item => item.type === 'stream').length,
101
+ });
102
+
103
+ return {
104
+ hasError: events.length > 0,
105
+ message: events[0]?.message || null,
106
+ events,
107
+ ignoredEvents,
108
+ counts: countByType(events),
109
+ ignoredCounts: countByType(ignoredEvents),
110
+ observedCounts: {
111
+ item: codexJsonState?.itemErrors?.length || 0,
112
+ turn: codexJsonState?.turnFailures?.length || 0,
113
+ stream: codexJsonState?.streamErrors?.length || 0,
114
+ },
115
+ };
116
+ };
117
+
118
+ // Issue #1990: A Codex run can exit 0 with no fatal `turn.failed`/error event yet
119
+ // still be fundamentally broken. Under docker isolation two long-running
120
+ // `solve --tool codex` tasks reported SUCCESS (Exit Code: 0) while their
121
+ // containers had run out of disk: cargo builds died with "No space left on
122
+ // device" / exit 101, no commits were produced, and — critically — the codex
123
+ // turn was never completed (the process was cut off mid-turn). Because the exit
124
+ // code was 0 and codex emitted no `turn.failed`, executeCodexCommand declared
125
+ // success, which under docker isolation also discarded the container filesystem
126
+ // we needed to inspect and retry from.
127
+ //
128
+ // The authoritative, echo-proof signal is codex's own turn lifecycle: `codex
129
+ // exec` emits a paired `turn.started`/`turn.completed` for every turn (a failed
130
+ // turn emits `turn.failed`). When the process ends with started turns that
131
+ // neither completed nor failed, the session is INCOMPLETE regardless of the exit
132
+ // code. Both captured failures had turn.started=1, turn.completed=0,
133
+ // turn.failed=0 (see docs/case-studies/issue-1990).
134
+ //
135
+ // Disk-exhaustion strings ("No space left on device", ENOSPC) are deliberately
136
+ // NOT used as an independent failure gate: codex echoes the stdout of every
137
+ // command it runs back into its own stream (see issue #1955), so a target repo
138
+ // that merely prints or works on that phrase (e.g. a `sed`/`cat` of a saved log,
139
+ // both observed in the captured runs at exit_code 0) would be wrongly failed.
140
+ // Disk pressure is surfaced only as supporting *diagnostics* explaining why a
141
+ // session was likely cut off, never as the sole reason to fail a completed turn.
142
+ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } = {}) => {
143
+ const eventCounts = codexJsonState?.eventCounts || {};
144
+ const turnStarted = eventCounts['turn.started'] || 0;
145
+ const turnCompleted = eventCounts['turn.completed'] || 0;
146
+ const turnFailed = codexJsonState?.turnFailures?.length || 0;
147
+ const commandExecutions = codexJsonState?.commandExecutions || [];
148
+
149
+ // hadActivity = codex actually began doing work, so a genuinely empty stream
150
+ // (no turns, no commands) is never spuriously flagged — we only fail when work
151
+ // started but never finished.
152
+ const hadActivity = turnStarted > 0 || commandExecutions.length > 0 || (eventCounts['item.completed'] || 0) > 0;
153
+
154
+ // A started turn that never completed or failed = the process was cut off
155
+ // mid-turn (OOM / disk-full / container teardown) even though it exited 0.
156
+ const incompleteSession = hadActivity && turnCompleted + turnFailed < Math.max(turnStarted, 1);
157
+
158
+ // Diagnostic-only disk-pressure hints (never an independent failure gate).
159
+ const diskEvidence = [];
160
+ const addDiskEvidence = (source, text) => {
161
+ if (text && isENOSPC(text)) {
162
+ diskEvidence.push({ source, text: String(text).replace(/\s+/g, ' ').trim().slice(0, 300) });
163
+ }
164
+ };
165
+ for (const exec of commandExecutions) addDiskEvidence(`command:${exec.command || exec.id || 'unknown'}`, exec.aggregatedOutput);
166
+ for (const streamError of codexJsonState?.streamErrors || []) addDiskEvidence('stream-error', streamError.message);
167
+ for (const itemError of codexJsonState?.itemErrors || []) addDiskEvidence('item-error', itemError.message);
168
+ for (const turnFailure of codexJsonState?.turnFailures || []) addDiskEvidence('turn-failure', turnFailure.message);
169
+ addDiskEvidence('last-message', lastMessage);
170
+ addDiskEvidence('result-summary', codexJsonState?.resultSummary);
171
+ const diskPressureDetected = diskEvidence.length > 0;
172
+
173
+ const reasons = [];
174
+ if (incompleteSession) {
175
+ reasons.push(`Codex session ended without completing its turn (turn.started=${turnStarted}, turn.completed=${turnCompleted}, turn.failed=${turnFailed}); the process exited 0 but was cut off mid-turn.`);
176
+ if (diskPressureDetected) {
177
+ reasons.push(`Disk-exhaustion signals were present in ${diskEvidence.length} location(s) (e.g. "No space left on device") — the likely cause of the interrupted session.`);
178
+ }
179
+ }
180
+
181
+ return {
182
+ healthy: !incompleteSession,
183
+ incompleteSession,
184
+ diskPressureDetected,
185
+ diskEvidence,
186
+ turnStarted,
187
+ turnCompleted,
188
+ turnFailed,
189
+ reasons,
190
+ };
191
+ };
package/src/codex.lib.mjs CHANGED
@@ -15,6 +15,10 @@ const os = (await use('os')).default;
15
15
 
16
16
  // Import log from general lib
17
17
  import { log } from './lib.mjs';
18
+ // Issues #1955 / #1990: run-health analysis lives in its own module to keep this
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 };
18
22
  import { reportError } from './sentry.lib.mjs';
19
23
  import { timeouts, retryLimits } from './config.lib.mjs';
20
24
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -327,108 +331,6 @@ const upsertCodexItemError = (itemErrors, item) => {
327
331
  });
328
332
  };
329
333
 
330
- const unwrapCodexErrorMessage = value => {
331
- if (!value) return '';
332
- if (typeof value !== 'string') {
333
- if (typeof value?.error?.message === 'string') return unwrapCodexErrorMessage(value.error.message);
334
- if (typeof value?.message === 'string') return unwrapCodexErrorMessage(value.message);
335
- return String(value);
336
- }
337
-
338
- let text = value.trim();
339
- for (let i = 0; i < 3; i++) {
340
- if (!text.startsWith('{') && !text.startsWith('[')) break;
341
- try {
342
- const parsed = JSON.parse(text);
343
- if (typeof parsed?.error?.message === 'string') return unwrapCodexErrorMessage(parsed.error.message);
344
- if (typeof parsed?.message === 'string') {
345
- text = parsed.message.trim();
346
- continue;
347
- }
348
- return JSON.stringify(parsed);
349
- } catch {
350
- break;
351
- }
352
- }
353
- return text;
354
- };
355
-
356
- const isNonFatalCodexItemErrorMessage = message => /^in-process app-server event stream lagged; dropped \d+ events?$/i.test(message || '');
357
-
358
- export const getCodexErrorEventSummary = codexJsonState => {
359
- const events = [];
360
- const ignoredEvents = [];
361
-
362
- // Issue #1955: When the codex turn genuinely completed (a `turn.completed`
363
- // event was observed) and codex never emitted a `turn.failed`, the session
364
- // SUCCEEDED. Any stray top-level `error` (stream) or nested item `error` event
365
- // in that case is non-fatal and must not fail the run. Two things produce such
366
- // strays:
367
- // 1. A transient error codex itself retried/recovered from before completing
368
- // the turn (e.g. a momentary stream blip).
369
- // 2. Echoed content that merely *looks* like a codex protocol event. The
370
- // codex CLI prints OTEL telemetry (`codex_otel.log_only`,
371
- // event.name="codex.tool_result") containing a raw `Output:` dump of each
372
- // command's stdout. When a command prints a line shaped like a protocol
373
- // event — e.g. a printed NDJSON fixture line
374
- // `{"type":"error","message":"Network lookup skipped in fixture"}` — our
375
- // line-by-line parser misreads it as a genuine codex stream error and
376
- // fails an otherwise-successful run. This was the exact false positive in
377
- // issue #1955 (codex finished, working tree clean, CI passed, yet the run
378
- // was reported failed).
379
- // `turn.failed` is the authoritative failure signal, so it is NEVER suppressed
380
- // here; only non-`turn` error events are gated on turn completion.
381
- const turnCompleted = (codexJsonState?.eventCounts?.['turn.completed'] || 0) > 0;
382
- const turnFailed = (codexJsonState?.turnFailures?.length || 0) > 0;
383
- const sessionSucceeded = turnCompleted && !turnFailed;
384
-
385
- const addEvents = (type, items = []) => {
386
- for (const item of items) {
387
- const message = unwrapCodexErrorMessage(item?.message);
388
- const event = { type, message: message || 'Codex emitted an error event' };
389
- if (type === 'item' && isNonFatalCodexItemErrorMessage(message)) {
390
- ignoredEvents.push({
391
- ...event,
392
- reason: 'Codex app-server backpressure warning; the turn can still complete successfully',
393
- });
394
- continue;
395
- }
396
- if (type !== 'turn' && sessionSucceeded) {
397
- ignoredEvents.push({
398
- ...event,
399
- reason: 'Codex turn completed successfully with no turn.failed; stray non-turn error event is non-fatal (Issue #1955)',
400
- });
401
- continue;
402
- }
403
- events.push(event);
404
- }
405
- };
406
-
407
- addEvents('item', codexJsonState?.itemErrors);
408
- addEvents('turn', codexJsonState?.turnFailures);
409
- addEvents('stream', codexJsonState?.streamErrors);
410
-
411
- const countByType = items => ({
412
- item: items.filter(item => item.type === 'item').length,
413
- turn: items.filter(item => item.type === 'turn').length,
414
- stream: items.filter(item => item.type === 'stream').length,
415
- });
416
-
417
- return {
418
- hasError: events.length > 0,
419
- message: events[0]?.message || null,
420
- events,
421
- ignoredEvents,
422
- counts: countByType(events),
423
- ignoredCounts: countByType(ignoredEvents),
424
- observedCounts: {
425
- item: codexJsonState?.itemErrors?.length || 0,
426
- turn: codexJsonState?.turnFailures?.length || 0,
427
- stream: codexJsonState?.streamErrors?.length || 0,
428
- },
429
- };
430
- };
431
-
432
334
  export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId = null) => {
433
335
  const nextState = {
434
336
  sessionId: state.sessionId || null,
@@ -1339,6 +1241,59 @@ export const executeCodexCommand = async params => {
1339
1241
  };
1340
1242
  }
1341
1243
 
1244
+ // Issue #1990: exit code 0 and the absence of a fatal codex error event are
1245
+ // necessary but NOT sufficient for success. Verify the run actually
1246
+ // completed its turn before declaring success. A broken-but-exit-0 run (the
1247
+ // codex process cut off mid-turn by disk exhaustion / OOM) previously
1248
+ // reported SUCCESS, which under docker isolation also discarded the
1249
+ // container filesystem needed to inspect and retry the failure (#1990).
1250
+ const completionHealth = getCodexCompletionHealth(codexJsonState, { lastMessage });
1251
+ if (!completionHealth.healthy) {
1252
+ await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
1253
+ for (const reason of completionHealth.reasons) {
1254
+ await log(` • ${reason}`, { level: 'error' });
1255
+ }
1256
+ await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
1257
+ if (completionHealth.diskPressureDetected) {
1258
+ await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
1259
+ for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
1260
+ await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
1261
+ }
1262
+ await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
1263
+ }
1264
+
1265
+ const resourcesAfter = await getResourceSnapshot();
1266
+ await log('\n📈 System resources after execution:', { verbose: true });
1267
+ await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1268
+ await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1269
+
1270
+ // Issue #1990: preserve the codex session so an outer full restart can
1271
+ // resume with context (mirrors the transient-error retry above and the
1272
+ // `--tool claude` behavior). We do NOT inline-retry within the same broken
1273
+ // container — the run is registered as a failure so the session and (under
1274
+ // docker isolation) the container filesystem are preserved for a clean
1275
+ // restart at the orchestration level.
1276
+ if (sessionId && !argv.resume) argv.resume = sessionId;
1277
+
1278
+ return {
1279
+ success: false,
1280
+ sessionId,
1281
+ limitReached,
1282
+ limitResetTime,
1283
+ pricingInfo,
1284
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1285
+ resultModelUsage,
1286
+ subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1287
+ codexJsonDetails: codexJsonState,
1288
+ errorInfo: getCodexErrorEventSummary(codexJsonState),
1289
+ completionHealth,
1290
+ incompleteSession: completionHealth.incompleteSession,
1291
+ diskPressureDetected: completionHealth.diskPressureDetected,
1292
+ result: completionHealth.reasons.join(' '),
1293
+ resultSummary: lastTextContent || null,
1294
+ };
1295
+ }
1296
+
1342
1297
  await log('\n\n✅ Codex command completed');
1343
1298
 
1344
1299
  // Issue #1263: Log if result summary was captured
@@ -21,6 +21,7 @@ import { defaultModels, geminiModels } from './models/index.mjs';
21
21
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
22
22
  import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
23
23
  import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
24
+ import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
24
25
 
25
26
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
26
27
 
@@ -581,6 +582,52 @@ export const executeGeminiCommand = async params => {
581
582
  };
582
583
  }
583
584
 
585
+ // Issue #1990: exit 0 and a non-empty stream are necessary but NOT
586
+ // sufficient. gemini-cli's stream-json ends with a terminal `result` event;
587
+ // a run that did work but never emitted it was cut off mid-run (e.g. the
588
+ // docker container ran out of disk) and must be registered as a failure so
589
+ // the session is preserved for a context-preserving restart and — under
590
+ // docker isolation — the container filesystem is kept for inspection.
591
+ const completionHealth = getTerminalEventCompletionHealth({
592
+ eventCounts: geminiJsonState.eventCounts,
593
+ terminalEventTypes: ['result'],
594
+ hadActivity: (geminiJsonState.messageCount || 0) > 0 || (geminiJsonState.toolUseCount || 0) > 0,
595
+ diskEvidenceTexts: [
596
+ { source: 'output', text: allOutput },
597
+ { source: 'result-summary', text: geminiJsonState.resultSummary },
598
+ ],
599
+ });
600
+ if (!completionHealth.healthy) {
601
+ await log('\n\n❌ Gemini exited 0 but the run did not complete — treating as failure', { level: 'error' });
602
+ for (const reason of completionHealth.reasons) {
603
+ await log(` • ${reason}`, { level: 'error' });
604
+ }
605
+ if (completionHealth.diskPressureDetected) {
606
+ await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
607
+ for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
608
+ await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
609
+ }
610
+ await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
611
+ }
612
+ if (sessionId && !argv.resume) argv.resume = sessionId;
613
+ return {
614
+ success: false,
615
+ sessionId,
616
+ limitReached,
617
+ limitResetTime,
618
+ messageCount: geminiJsonState.messageCount || 0,
619
+ toolUseCount: geminiJsonState.toolUseCount || 0,
620
+ resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
621
+ pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
622
+ publicPricingEstimate: null,
623
+ resultSummary: geminiJsonState.resultSummary || null,
624
+ completionHealth,
625
+ incompleteSession: completionHealth.incompleteSession,
626
+ diskPressureDetected: completionHealth.diskPressureDetected,
627
+ errorInfo: { message: completionHealth.reasons.join(' ') },
628
+ };
629
+ }
630
+
584
631
  await log('\n\n✅ Gemini command completed');
585
632
  await log(`📊 Total messages: ${geminiJsonState.messageCount || 0}, Tool uses: ${geminiJsonState.toolUseCount || 0}`);
586
633
  if (geminiJsonState.resultSummary) {
package/src/git.lib.mjs CHANGED
@@ -233,7 +233,7 @@ export const validateGitIdentity = async ($, options = {}) => {
233
233
  const { log = console.log } = options;
234
234
 
235
235
  // Check user.name
236
- let userName = null;
236
+ let userName;
237
237
  try {
238
238
  const nameResult = await $`git config user.name 2>/dev/null || true`;
239
239
  userName = nameResult.stdout.toString().trim() || null;
@@ -242,7 +242,7 @@ export const validateGitIdentity = async ($, options = {}) => {
242
242
  }
243
243
 
244
244
  // Check user.email
245
- let userEmail = null;
245
+ let userEmail;
246
246
  try {
247
247
  const emailResult = await $`git config user.email 2>/dev/null || true`;
248
248
  userEmail = emailResult.stdout.toString().trim() || null;
@@ -19,7 +19,7 @@ function normalizeRepositoryFullName(owner, repositoryName) {
19
19
 
20
20
  function parseGitHubRepositoryUrlToken(token) {
21
21
  const cleaned = trimOutputToken(token);
22
- let pathName = null;
22
+ let pathName;
23
23
 
24
24
  if (cleaned.startsWith('git@github.com:')) {
25
25
  pathName = cleaned.slice('git@github.com:'.length);
@@ -967,7 +967,7 @@ export async function fetchProjectIssues(projectNumber, owner, statusFilter) {
967
967
  context: 'github.lib.mjs - GitHub CLI auth status check',
968
968
  level: 'error',
969
969
  });
970
- throw new Error('GitHub CLI authentication failed. Please run: gh auth login');
970
+ throw new Error('GitHub CLI authentication failed. Please run: gh auth login', { cause: error });
971
971
  }
972
972
  // Add delay to respect rate limits
973
973
  await log(' ⏰ Waiting 2 seconds before API call to respect rate limits...', { verbose: true });
@@ -116,7 +116,7 @@ const updateGitExclude = async ({ $, tempDir, log }) => {
116
116
  return false;
117
117
  }
118
118
 
119
- let existing = '';
119
+ let existing;
120
120
  try {
121
121
  existing = await fs.readFile(excludePath, 'utf8');
122
122
  } catch {
@@ -135,7 +135,7 @@ export const parseHiveScreensArgs = argv => {
135
135
  * the typical `NNNNN.name` session names.
136
136
  */
137
137
  export const listDetachedSessions = async ({ exec = execAsync } = {}) => {
138
- let stdout = '';
138
+ let stdout;
139
139
  try {
140
140
  ({ stdout } = await exec('screen -ls'));
141
141
  } catch (err) {
package/src/i18n.lib.mjs CHANGED
@@ -75,7 +75,7 @@ function refreshI18nRuntime() {
75
75
  export async function loadTranslations(locale) {
76
76
  if (localeCache.has(locale)) return localeCache.get(locale);
77
77
 
78
- let translations = {};
78
+ let translations;
79
79
  try {
80
80
  translations = await readLocaleFile(locale);
81
81
  } catch {
@@ -42,7 +42,7 @@ const shouldDisableSentry = () => {
42
42
  // Lazily import Sentry only if needed
43
43
  // This prevents the Sentry packages from keeping the event loop alive when not needed
44
44
  let Sentry = null;
45
- let nodeProfilingIntegration = null;
45
+ let nodeProfilingIntegration;
46
46
 
47
47
  // Initialize Sentry if not disabled
48
48
  if (!shouldDisableSentry()) {
@@ -151,14 +151,14 @@ export const createInteractiveHandler = options => {
151
151
  }
152
152
  }
153
153
 
154
- let hits = [];
154
+ let hits;
155
155
  try {
156
156
  hits = await containsKnownToken(body, knownTokens);
157
157
  } catch {
158
158
  hits = [];
159
159
  }
160
160
 
161
- let sanitized = body;
161
+ let sanitized;
162
162
  try {
163
163
  sanitized = await sanitizeCommentBody(body, {
164
164
  knownTokens,
@@ -495,7 +495,7 @@ ${createRawJsonSection(data)}`;
495
495
  state.toolUseRegistry.set(toolId, { toolName, toolIcon });
496
496
 
497
497
  // Format tool input based on tool type
498
- let inputDisplay = '';
498
+ let inputDisplay;
499
499
  const input = toolUse.input || {};
500
500
 
501
501
  if (toolName === 'Bash' && input.command) {
@@ -1266,7 +1266,7 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
1266
1266
  const gitLib = await import('./git.lib.mjs');
1267
1267
  return gitLib.repairGitIdentity();
1268
1268
  });
1269
- let repairOutcome = null;
1269
+ let repairOutcome;
1270
1270
  try {
1271
1271
  repairOutcome = await repairFn();
1272
1272
  } catch (error) {
@@ -125,6 +125,9 @@ export const codexModels = {
125
125
  'gpt-5.5': 'gpt-5.5',
126
126
  'gpt-5.5-mini': 'gpt-5.5-mini',
127
127
  'gpt-5.5-nano': 'gpt-5.5-nano',
128
+ 'gpt-5.6-sol': 'gpt-5.6-sol',
129
+ 'gpt-5.6-terra': 'gpt-5.6-terra',
130
+ 'gpt-5.6-luna': 'gpt-5.6-luna',
128
131
  'gpt-5.4': 'gpt-5.4',
129
132
  'gpt-5.4-mini': 'gpt-5.4-mini',
130
133
  'gpt-5.4-nano': 'gpt-5.4-nano',
@@ -133,6 +136,12 @@ export const codexModels = {
133
136
  'gpt-5.3-codex': 'gpt-5.3-codex',
134
137
  'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark',
135
138
  'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
139
+ 'openai.gpt-5.5': 'openai.gpt-5.5',
140
+ 'openai.gpt-5.4': 'openai.gpt-5.4',
141
+ 'openai.gpt-5.6-sol': 'openai.gpt-5.6-sol',
142
+ 'openai.gpt-5.6-terra': 'openai.gpt-5.6-terra',
143
+ 'openai.gpt-5.6-luna': 'openai.gpt-5.6-luna',
144
+ 'codex-auto-review': 'codex-auto-review',
136
145
  'o3-mini': 'o3-mini',
137
146
  gpt4: 'gpt-4',
138
147
  'gpt-4': 'gpt-4',
@@ -261,6 +270,9 @@ export const CODEX_MODELS = {
261
270
  'gpt-5.5': 'gpt-5.5',
262
271
  'gpt-5.5-mini': 'gpt-5.5-mini',
263
272
  'gpt-5.5-nano': 'gpt-5.5-nano',
273
+ 'gpt-5.6-sol': 'gpt-5.6-sol',
274
+ 'gpt-5.6-terra': 'gpt-5.6-terra',
275
+ 'gpt-5.6-luna': 'gpt-5.6-luna',
264
276
  'gpt-5.4': 'gpt-5.4',
265
277
  'gpt-5.4-mini': 'gpt-5.4-mini',
266
278
  'gpt-5.4-nano': 'gpt-5.4-nano',
@@ -269,6 +281,12 @@ export const CODEX_MODELS = {
269
281
  'gpt-5.3-codex': 'gpt-5.3-codex',
270
282
  'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark',
271
283
  'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
284
+ 'openai.gpt-5.5': 'openai.gpt-5.5',
285
+ 'openai.gpt-5.4': 'openai.gpt-5.4',
286
+ 'openai.gpt-5.6-sol': 'openai.gpt-5.6-sol',
287
+ 'openai.gpt-5.6-terra': 'openai.gpt-5.6-terra',
288
+ 'openai.gpt-5.6-luna': 'openai.gpt-5.6-luna',
289
+ 'codex-auto-review': 'codex-auto-review',
272
290
  'gpt-4': 'gpt-4',
273
291
  'gpt-4o': 'gpt-4o',
274
292
  };
@@ -344,7 +362,7 @@ export const getDefaultModelForTool = tool => {
344
362
  };
345
363
 
346
364
  let cachedInstalledCodexModelsPromise = null;
347
- const CODEX_DEFAULT_FALLBACK_CHAIN = ['gpt-5.4', 'gpt-5.5-mini', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.5-nano', 'gpt-5.4-nano'];
365
+ const CODEX_DEFAULT_FALLBACK_CHAIN = ['openai.gpt-5.5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'openai.gpt-5.6-sol', 'openai.gpt-5.6-terra', 'openai.gpt-5.6-luna', 'gpt-5.4', 'openai.gpt-5.4', 'gpt-5.5-mini', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.5-nano', 'gpt-5.4-nano'];
348
366
 
349
367
  export const getInstalledCodexModels = async () => {
350
368
  if (!cachedInstalledCodexModelsPromise) {
@@ -468,7 +486,7 @@ export const getValidModelsForTool = tool => {
468
486
  export const primaryModelNames = {
469
487
  claude: ['opus', 'sonnet', 'haiku', 'opusplan', 'fable'],
470
488
  opencode: ['grok', 'gpt4o'],
471
- codex: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.3-codex-spark'],
489
+ codex: ['gpt-5.5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'],
472
490
  agent: ['nemotron-3-super-free', 'minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free'],
473
491
  qwen: ['qwen3-coder-plus', 'qwen3-coder', 'qwen3-coder-flash'],
474
492
  gemini: ['flash', 'pro', 'flash-lite', 'auto'],
@@ -1132,6 +1150,13 @@ export const defaultFallbackModels = {
1132
1150
  'claude-opus-4-7': 'opus-4-6',
1133
1151
  },
1134
1152
  codex: {
1153
+ 'gpt-5.6-sol': 'gpt-5.5',
1154
+ 'gpt-5.6-terra': 'gpt-5.5',
1155
+ 'gpt-5.6-luna': 'gpt-5.5',
1156
+ 'openai.gpt-5.6-sol': 'openai.gpt-5.5',
1157
+ 'openai.gpt-5.6-terra': 'openai.gpt-5.5',
1158
+ 'openai.gpt-5.6-luna': 'openai.gpt-5.5',
1159
+ 'openai.gpt-5.5': 'openai.gpt-5.4',
1135
1160
  'gpt-5.5': 'gpt-5.4',
1136
1161
  },
1137
1162
  };
package/src/qwen.lib.mjs CHANGED
@@ -22,6 +22,7 @@ import { qwenModels, defaultModels } from './models/index.mjs';
22
22
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
23
23
  import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
24
24
  import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
25
+ import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
25
26
 
26
27
  export const mapModelToId = model => qwenModels[model] || model;
27
28
 
@@ -638,6 +639,48 @@ export const executeQwenCommand = async params => {
638
639
  };
639
640
  }
640
641
 
642
+ // Issue #1990: exit 0 with no error event is necessary but NOT sufficient.
643
+ // qwen-code's stream-json ends with a terminal `result` event; a run that
644
+ // did work but never emitted it was cut off mid-run (e.g. the docker
645
+ // container ran out of disk) and must be registered as a failure so the
646
+ // session is preserved for a context-preserving restart and — under docker
647
+ // isolation — the container filesystem is kept for inspection.
648
+ const completionHealth = getTerminalEventCompletionHealth({
649
+ eventCounts: qwenState.eventCounts,
650
+ terminalEventTypes: ['result'],
651
+ hadActivity: (qwenState.parsedEvents?.length || 0) > 0,
652
+ diskEvidenceTexts: [
653
+ { source: 'output', text: allOutput },
654
+ { source: 'result-summary', text: resultSummary },
655
+ ],
656
+ });
657
+ if (!completionHealth.healthy) {
658
+ await log('\n\n❌ Qwen Code exited 0 but the run did not complete — treating as failure', { level: 'error' });
659
+ for (const reason of completionHealth.reasons) {
660
+ await log(` • ${reason}`, { level: 'error' });
661
+ }
662
+ if (completionHealth.diskPressureDetected) {
663
+ await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
664
+ for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
665
+ await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
666
+ }
667
+ await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
668
+ }
669
+ if (sessionId && !argv.resume) argv.resume = sessionId;
670
+ return {
671
+ success: false,
672
+ sessionId,
673
+ limitReached: false,
674
+ limitResetTime: null,
675
+ ...usageResult,
676
+ resultSummary,
677
+ completionHealth,
678
+ incompleteSession: completionHealth.incompleteSession,
679
+ diskPressureDetected: completionHealth.diskPressureDetected,
680
+ errorInfo: { message: completionHealth.reasons.join(' ') },
681
+ };
682
+ }
683
+
641
684
  await log('\n\n✅ Qwen Code command completed');
642
685
  if (resultSummary) {
643
686
  await log('📝 Captured result summary from Qwen Code output', { verbose: true });
@@ -1025,7 +1025,7 @@ export async function resumeTrackedSessions(options = {}) {
1025
1025
  return { resumed, skipped };
1026
1026
  }
1027
1027
 
1028
- let persisted = [];
1028
+ let persisted;
1029
1029
  try {
1030
1030
  persisted = store.load();
1031
1031
  } catch (error) {
@@ -1297,7 +1297,7 @@ export async function getRunningSessionItems(verbose = false, options = {}) {
1297
1297
  const screenChecker = options.screenChecker || checkScreenSessionExists;
1298
1298
 
1299
1299
  for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1300
- let running = false;
1300
+ let running;
1301
1301
  let status = null;
1302
1302
 
1303
1303
  if (sessionInfo.isolationBackend) {
@@ -1275,9 +1275,9 @@ ${prBody}`,
1275
1275
  }
1276
1276
 
1277
1277
  if (errorMsg.includes('No commits between') || errorMsg.includes("Head sha can't be blank")) {
1278
- throw new Error(`PR creation failed - no commits between branches: ${cleanError}`);
1278
+ throw new Error(`PR creation failed - no commits between branches: ${cleanError}`, { cause: prCreateError });
1279
1279
  } else {
1280
- throw new Error(`PR creation failed: ${cleanError}`);
1280
+ throw new Error(`PR creation failed: ${cleanError}`, { cause: prCreateError });
1281
1281
  }
1282
1282
  }
1283
1283
  }
@@ -330,7 +330,7 @@ export const SOLVE_OPTION_DEFINITIONS = {
330
330
  },
331
331
  'fallback-model': {
332
332
  type: 'string',
333
- description: 'Fallback model to switch to on model capacity/overload errors (and, for Fable 5, on safety-classifier refusals). When supported, retries resume the same session with this model. Defaults: claude fable/claude-fable-5 -> opus (Opus 4.8); claude mythos-5/claude-mythos-5 -> fable; claude opus/opus-4-8 -> opus-4-7; claude opus-4-7 -> opus-4-6; codex gpt-5.5 -> gpt-5.4; all others unset.',
333
+ description: 'Fallback model to switch to on model capacity/overload errors (and, for Fable 5, on safety-classifier refusals). When supported, retries resume the same session with this model. Defaults: claude fable/claude-fable-5 -> opus (Opus 4.8); claude mythos-5/claude-mythos-5 -> fable; claude opus/opus-4-8 -> opus-4-7; claude opus-4-7 -> opus-4-6; codex gpt-5.6-sol/gpt-5.6-terra/gpt-5.6-luna -> gpt-5.5; codex gpt-5.5 -> gpt-5.4; all others unset.',
334
334
  default: undefined,
335
335
  },
336
336
  'sub-agent-model': {
@@ -125,9 +125,6 @@ export const parseEscalateRange = value => {
125
125
  throw new Error(`Invalid --escalate value: ${JSON.stringify(value)}. Expected a model range like "sonnet-fable".`);
126
126
  }
127
127
  const trimmed = raw.trim().toLowerCase();
128
- if (trimmed === '') {
129
- raw = DEFAULT_ESCALATE_RANGE;
130
- }
131
128
  const parts = (trimmed === '' ? DEFAULT_ESCALATE_RANGE : trimmed).split('-');
132
129
 
133
130
  const order = MODEL_ESCALATION_ORDER;
@@ -159,7 +159,7 @@ export const runKeepWorkingUntilDone = async ({ issueUrl, owner, repo, issueNumb
159
159
  let iteration = 0;
160
160
  while (true) {
161
161
  // Gather and scan sources fresh on every iteration.
162
- let sources = [];
162
+ let sources;
163
163
  try {
164
164
  sources = await collectDeferredWorkSources({ owner, repo, prNumber, resultSummary: lastResultSummary });
165
165
  } catch (error) {
@@ -550,8 +550,8 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
550
550
  }
551
551
  if (!safeToDelete) {
552
552
  if (argv.allowForceNonForkRepositoryDeletion) {
553
+ // Force flag set — proceed with deletion despite the failed safety check.
553
554
  await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
554
- safeToDelete = true;
555
555
  } else {
556
556
  await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
557
557
  await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
@@ -302,7 +302,7 @@ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnecti
302
302
 
303
303
  // Skip tool connection validation if in dry-run mode or explicitly requested
304
304
  if (!skipToolConnection) {
305
- let isToolConnected = false;
305
+ let isToolConnected;
306
306
  if (argv.useAgentCommander) {
307
307
  const agentCommanderLib = await import('./agent-commander.lib.mjs');
308
308
  isToolConnected = await agentCommanderLib.validateAgentCommanderConnection({
@@ -361,7 +361,6 @@ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnecti
361
361
  await log('❌ Cannot proceed without Claude CLI connection', { level: 'error' });
362
362
  return false;
363
363
  }
364
- isToolConnected = true;
365
364
  }
366
365
 
367
366
  // Check GitHub permissions (only when tool check is not skipped)
@@ -194,13 +194,9 @@ export async function launchBotWithRetry(bot, launchOptions, retryOptions = {})
194
194
  reject(new Error('Bot launch aborted during retry wait'));
195
195
  return;
196
196
  }
197
+ // `{ once: true }` removes the abort listener after it fires; on the
198
+ // natural-timeout path it is released when the AbortSignal is collected.
197
199
  signal.addEventListener('abort', onAbort, { once: true });
198
- // Clean up the listener when the timer fires naturally
199
- const originalResolve = resolve;
200
- resolve = () => {
201
- signal.removeEventListener('abort', onAbort);
202
- originalResolve();
203
- };
204
200
  }
205
201
  });
206
202
  }
@@ -116,7 +116,7 @@ export const registerTokensCommand = (bot, options = {}) => {
116
116
 
117
117
  // Step 2: authenticate by ownership of an allowlisted chat.
118
118
  const allowedChatIds = resolveAllowedChatIds(allowedChats);
119
- let isOperator = false;
119
+ let isOperator;
120
120
  try {
121
121
  isOperator = await isOperatorOfAnyAllowedChat({
122
122
  telegram: ctx.telegram,
@@ -145,7 +145,7 @@ export function registerTopCommand(bot, options) {
145
145
  const screenName = `top-chat-${chatId}`;
146
146
 
147
147
  // Check if screen session already exists
148
- let sessionExists = false;
148
+ let sessionExists;
149
149
  try {
150
150
  const { stdout } = await exec('screen -ls');
151
151
  sessionExists = stdout.includes(screenName);
@@ -0,0 +1,64 @@
1
+ // Tool-agnostic run-health analysis (Issue #1990).
2
+ //
3
+ // Background: under docker isolation two long-running `solve --tool codex` tasks
4
+ // reported SUCCESS (Exit Code: 0) while their containers had run out of disk —
5
+ // the AI session was cut off mid-run, no commits were produced, yet the process
6
+ // exited 0. Reporting that as success also discarded the container filesystem we
7
+ // needed to inspect and retry from.
8
+ //
9
+ // codex.lib.mjs gets a bespoke gate (paired turn.started/turn.completed lifecycle
10
+ // — see codex-health.lib.mjs) and claude.lib.mjs already requires its final
11
+ // `result` event (shouldFailClaudeStreamWithoutResult). This module provides the
12
+ // equivalent gate for the tools whose stream-json output (adopted from the Claude
13
+ // Agent SDK schema) ends with a single terminal `result` event: gemini-cli and
14
+ // qwen-code. An exit-0 run that clearly began work but never emitted that
15
+ // terminal event was interrupted and must NOT be reported as success.
16
+ //
17
+ // opencode is deliberately NOT gated here. Its `run --format json` output has no
18
+ // single terminal completion event we have verified is always emitted before a
19
+ // clean exit — opencode.lib.mjs treats several event types ('text', 'assistant',
20
+ // 'message', 'result', 'step_finish') as best-effort and decides success purely
21
+ // on the exit code. Gating opencode on a terminal event without first confirming
22
+ // upstream that it is reliably flushed would risk converting genuine successes
23
+ // into failures, so it is left as follow-up. See docs/case-studies/issue-1990.
24
+ //
25
+ // Disk-exhaustion strings ("No space left on device", ENOSPC) are surfaced only
26
+ // as supporting *diagnostics* — never an independent failure gate — to avoid the
27
+ // issue #1955 class of false positive where a tool echoes a command's stdout that
28
+ // merely mentions the phrase.
29
+
30
+ import { isENOSPC } from './lib.mjs';
31
+
32
+ export const getTerminalEventCompletionHealth = ({ eventCounts = {}, terminalEventTypes = ['result'], hadActivity = false, diskEvidenceTexts = [] } = {}) => {
33
+ const terminalCount = terminalEventTypes.reduce((sum, type) => sum + (eventCounts[type] || 0), 0);
34
+
35
+ // Only flag a run that did work but never reached its terminal event. A run
36
+ // with no activity at all is handled separately by each tool (e.g. gemini's
37
+ // emittedNoEvents check) and must not be double-counted here.
38
+ const incompleteSession = hadActivity && terminalCount === 0;
39
+
40
+ const diskEvidence = [];
41
+ for (const { source, text } of diskEvidenceTexts) {
42
+ if (text && isENOSPC(text)) {
43
+ diskEvidence.push({ source, text: String(text).replace(/\s+/g, ' ').trim().slice(0, 300) });
44
+ }
45
+ }
46
+ const diskPressureDetected = diskEvidence.length > 0;
47
+
48
+ const reasons = [];
49
+ if (incompleteSession) {
50
+ reasons.push(`The tool exited 0 but never emitted its terminal completion event (${terminalEventTypes.join('/')}); the session was cut off mid-run.`);
51
+ if (diskPressureDetected) {
52
+ reasons.push(`Disk-exhaustion signals were present in ${diskEvidence.length} location(s) (e.g. "No space left on device") — the likely cause of the interrupted session.`);
53
+ }
54
+ }
55
+
56
+ return {
57
+ healthy: !incompleteSession,
58
+ incompleteSession,
59
+ diskPressureDetected,
60
+ diskEvidence,
61
+ terminalCount,
62
+ reasons,
63
+ };
64
+ };
@@ -24,7 +24,7 @@ export const fetchUseMCodeFromCdn = async ({ fetcher = fetch } = {}) => {
24
24
  try {
25
25
  return await fetchUseMCodeFromUrl(USE_M_BOOTSTRAP_FALLBACK_URL, fetcher);
26
26
  } catch (fallbackError) {
27
- throw new Error(`Failed to load use-m bootstrap from primary and fallback URLs: ${primaryError.message}; ${fallbackError.message}`);
27
+ throw new Error(`Failed to load use-m bootstrap from primary and fallback URLs: ${primaryError.message}; ${fallbackError.message}`, { cause: fallbackError });
28
28
  }
29
29
  };
30
30
 
@@ -154,7 +154,7 @@ export const resolveClaudeSessionToolFlags = async ({ argv, log, fallbackBuildMc
154
154
  export const ensureDisallowedToolsInSettings = async ({ settingsPath, log } = {}) => {
155
155
  const resolvedPath = settingsPath || path.join(os.homedir(), '.claude', 'settings.json');
156
156
  const toBlock = buildDisallowedToolsList();
157
- let settings = {};
157
+ let settings;
158
158
  try {
159
159
  const content = await fs.readFile(resolvedPath, 'utf-8');
160
160
  settings = JSON.parse(content);
@@ -15,8 +15,8 @@ const { parseYouTrackIssueId, updateYouTrackIssueStage, addYouTrackComment, crea
15
15
  * @returns {Object} Validation result with YouTrack info
16
16
  */
17
17
  export async function validateYouTrackUrl(issueUrl) {
18
- let isYouTrackUrl = null;
19
- let youTrackIssueId = null;
18
+ let isYouTrackUrl;
19
+ let youTrackIssueId;
20
20
  let youTrackConfig = null;
21
21
 
22
22
  if (!issueUrl) {
@@ -128,7 +128,7 @@ async function makeYouTrackRequest(endpoint, config, options = {}) {
128
128
  return await response.json();
129
129
  } catch (error) {
130
130
  if (error.message.includes('fetch')) {
131
- throw new Error(`Failed to connect to YouTrack at ${config.url}: ${error.message}`);
131
+ throw new Error(`Failed to connect to YouTrack at ${config.url}: ${error.message}`, { cause: error });
132
132
  }
133
133
  throw error;
134
134
  }