@link-assistant/hive-mind 2.11.8 → 2.11.10
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 +52 -0
- package/package.json +1 -1
- package/src/bidirectional-interactive.lib.mjs +6 -2
- package/src/child-exit.lib.mjs +107 -0
- package/src/codex-health.lib.mjs +21 -1
- package/src/codex.lib.mjs +43 -5
- package/src/codex.run-diagnostics.lib.mjs +11 -0
- package/src/contributing-guidelines.lib.mjs +19 -6
- package/src/development-log.lib.mjs +82 -5
- package/src/fix.ci-cd-issue.lib.mjs +5 -3
- package/src/fix.mjs +5 -2
- package/src/github-entity-validation.lib.mjs +4 -1
- package/src/github.lib.mjs +3 -3
- package/src/hive.mjs +15 -21
- package/src/isolation-runner.lib.mjs +5 -2
- package/src/lib.mjs +21 -0
- package/src/log-growth.lib.mjs +94 -0
- package/src/opencode.lib.mjs +17 -3
- package/src/pull-request-changes.lib.mjs +94 -24
- package/src/qwen.lib.mjs +42 -8
- package/src/review.mjs +12 -3
- package/src/session-kill-recovery.lib.mjs +6 -2
- package/src/solve.auto-continue.lib.mjs +6 -2
- package/src/solve.auto-merge.lib.mjs +1 -1
- package/src/solve.keep-working.lib.mjs +7 -2
- package/src/solve.minimal-restart-prompt.lib.mjs +11 -3
- package/src/solve.preparation.lib.mjs +5 -1
- package/src/solve.progress-monitoring.lib.mjs +5 -1
- package/src/solve.repository.lib.mjs +5 -2
- package/src/solve.results.lib.mjs +13 -8
- package/src/task.mjs +5 -3
- package/src/telegram-command-execution.lib.mjs +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8c8844a: Stop treating agent CLI stderr as a JSON protocol stream (issue #2136).
|
|
8
|
+
|
|
9
|
+
`codex exec --json` writes its NDJSON protocol to stdout only; its stderr carries
|
|
10
|
+
OTEL tracing whose `codex.tool_result` records dump the raw stdout of every
|
|
11
|
+
command Codex runs. When the task itself drove another agent CLI, that dump
|
|
12
|
+
replayed NDJSON byte-identical to Codex's own protocol, so an echoed
|
|
13
|
+
`turn.started` was counted as Codex's own and the completion gate failed a run
|
|
14
|
+
that had actually finished — posting a "Solution Draft Failed" comment on a pull
|
|
15
|
+
request that was complete and later merged.
|
|
16
|
+
|
|
17
|
+
Codex now parses only stdout as protocol; protocol-shaped JSON seen on stderr is
|
|
18
|
+
reported separately (`🪞 Echoed protocol-shaped lines on codex stderr`) and never
|
|
19
|
+
affects event counts, session id, token usage or error detection. The completion
|
|
20
|
+
gate additionally uses the ordered turn lifecycle (`🔁 Codex turn lifecycle`)
|
|
21
|
+
instead of comparing counts, so a stray `turn.started` can no longer fail a
|
|
22
|
+
completed run while a genuinely truncated turn still does. The same stream
|
|
23
|
+
separation is applied to qwen (whose stderr echo could raise a false error and
|
|
24
|
+
whose two streams shared one line buffer), and OpenCode now reports how many JSON
|
|
25
|
+
records it parsed from stderr.
|
|
26
|
+
|
|
27
|
+
## 2.11.9
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- 202bef0: Stop the session log from swallowing the pull request diff (issue #2135).
|
|
32
|
+
|
|
33
|
+
A session ended as `Work session failed (exit code: 1)` after its log reached
|
|
34
|
+
286 MB and the solve process died on the V8 heap limit.
|
|
35
|
+
`getPullRequestChangeStats` ran `gh pr diff` with command-stream's default
|
|
36
|
+
`mirror: true`, so the whole diff was echoed to stdout, copied into the session
|
|
37
|
+
log, committed to the branch by `--development-log`, and included again in the
|
|
38
|
+
next run's diff — each round larger than the last.
|
|
39
|
+
|
|
40
|
+
- `gh pr diff` is now read quietly and measured in a single streaming pass, with
|
|
41
|
+
a warning past 8 MB; the same quiet-probe treatment is applied to every other
|
|
42
|
+
unbounded-output probe, pinned by a source-scanning regression test.
|
|
43
|
+
- `src/contributing-guidelines.lib.mjs` no longer calls `.raw()` on a wrapped
|
|
44
|
+
`gh` promise, a `TypeError` that silently disabled guideline detection.
|
|
45
|
+
- New `src/child-exit.lib.mjs`: a child killed by a signal is reported as such
|
|
46
|
+
instead of `exited with code null`, and `hive` no longer records an
|
|
47
|
+
out-of-memory worker as a success.
|
|
48
|
+
- New `src/log-growth.lib.mjs`: the session log warns at 64 MB / 256 MB / 1 GB,
|
|
49
|
+
naming the usual cause, so a runaway log is visible before it is fatal.
|
|
50
|
+
- A failed development-log publication now discards the copies it wrote instead
|
|
51
|
+
of leaving them untracked, where they were read as the AI's uncommitted work
|
|
52
|
+
and triggered the restarts that multiplied the growth.
|
|
53
|
+
- `docs/case-studies/issue-2135` records the timeline, evidence and analysis.
|
|
54
|
+
|
|
3
55
|
## 2.11.8
|
|
4
56
|
|
|
5
57
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -243,7 +243,10 @@ export const createBidirectionalHandler = options => {
|
|
|
243
243
|
*/
|
|
244
244
|
const fetchCommentsFromEndpoint = async (apiPath, source) => {
|
|
245
245
|
try {
|
|
246
|
-
|
|
246
|
+
// Issue #2135: `mirror: false`. This is every comment body on the issue
|
|
247
|
+
// or pull request, re-read on a poll interval; mirroring it copied the
|
|
248
|
+
// whole conversation into the log once per poll.
|
|
249
|
+
const result = await quietProbe($)`gh api ${apiPath} --paginate --slurp`;
|
|
247
250
|
const parsed = JSON.parse(result.stdout?.toString() || '[]');
|
|
248
251
|
const comments = Array.isArray(parsed) && parsed.every(Array.isArray) ? parsed.flat() : parsed;
|
|
249
252
|
return comments.map(comment => ({
|
|
@@ -476,7 +479,8 @@ export const createBidirectionalHandler = options => {
|
|
|
476
479
|
if (!number || !owner || !repo) return null;
|
|
477
480
|
try {
|
|
478
481
|
const endpoint = kind === 'pr' ? `repos/${owner}/${repo}/pulls/${number}` : `repos/${owner}/${repo}/issues/${number}`;
|
|
479
|
-
|
|
482
|
+
// Issue #2135: `mirror: false` - the snapshot is compared, not shown.
|
|
483
|
+
const result = await quietProbe($)`gh api ${endpoint} --jq '{title, body}'`;
|
|
480
484
|
if (!result || result.code !== 0) return null;
|
|
481
485
|
const parsed = JSON.parse(result.stdout.toString() || '{}');
|
|
482
486
|
return { title: parsed.title || '', body: parsed.body || '' };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Say why a child process ended (issue #2135).
|
|
5
|
+
*
|
|
6
|
+
* The session captured for that issue ended with the solve child aborting on a
|
|
7
|
+
* V8 heap limit:
|
|
8
|
+
*
|
|
9
|
+
* FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
|
10
|
+
*
|
|
11
|
+
* `node` answers that by calling `abort()`, so the child is terminated by a
|
|
12
|
+
* signal and `child.on('close')` reports `code === null`. Every spawner in this
|
|
13
|
+
* repository interpolated that `code` straight into its message and produced
|
|
14
|
+
*
|
|
15
|
+
* ❌ Error: solve exited with code null
|
|
16
|
+
*
|
|
17
|
+
* which says nothing about memory, and the wrapper's own exit code 1 was all
|
|
18
|
+
* that reached the Telegram notification ("Work session failed (exit code:
|
|
19
|
+
* 1)"). The signal was known at that moment and simply thrown away.
|
|
20
|
+
*
|
|
21
|
+
* @module child-exit
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a signal says about how the child died.
|
|
26
|
+
*
|
|
27
|
+
* SIGABRT is the one that matters here: `node` aborts on a fatal V8 error, and
|
|
28
|
+
* "reached heap limit" is by far the most common of those in this codebase.
|
|
29
|
+
* SIGKILL is the other memory ending - the kernel's OOM killer, or a container
|
|
30
|
+
* runtime enforcing a memory limit, neither of which lets the process print
|
|
31
|
+
* anything at all.
|
|
32
|
+
*/
|
|
33
|
+
const SIGNAL_EXPLANATIONS = new Map([
|
|
34
|
+
['SIGABRT', 'the process aborted - for Node.js this is usually a fatal V8 error such as "Reached heap limit Allocation failed - JavaScript heap out of memory"; look for "FATAL ERROR" above'],
|
|
35
|
+
['SIGKILL', 'the process was killed outright - usually the kernel out-of-memory killer or a container memory limit; nothing it could print survives this'],
|
|
36
|
+
['SIGTERM', 'the process was asked to terminate - usually a timeout, a container stop, or another process shutting it down'],
|
|
37
|
+
['SIGINT', 'the process was interrupted (Ctrl+C)'],
|
|
38
|
+
['SIGSEGV', 'the process crashed with a segmentation fault'],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Describe how a spawned child ended, in a sentence a log reader can act on.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} params
|
|
45
|
+
* @param {string} params.command - what was spawned, as the reader knows it (e.g. `solve`).
|
|
46
|
+
* @param {number|null} [params.code] - the exit code from `close`/`exit`, null when signalled.
|
|
47
|
+
* @param {string|null} [params.signal] - the signal name from `close`/`exit`, when there is one.
|
|
48
|
+
* @returns {string} A description ending without punctuation, ready to be used
|
|
49
|
+
* as an `Error` message or logged as-is.
|
|
50
|
+
*/
|
|
51
|
+
export const describeChildExit = ({ command, code = null, signal = null }) => {
|
|
52
|
+
if (signal) {
|
|
53
|
+
const explanation = SIGNAL_EXPLANATIONS.get(signal);
|
|
54
|
+
return explanation ? `${command} was terminated by signal ${signal}: ${explanation}` : `${command} was terminated by signal ${signal}`;
|
|
55
|
+
}
|
|
56
|
+
if (code === null || code === undefined) {
|
|
57
|
+
// No code and no signal: rare, but "code null" on its own is exactly the
|
|
58
|
+
// uninformative message this module exists to replace.
|
|
59
|
+
return `${command} exited without a status code, so it did not finish normally (no signal was reported either)`;
|
|
60
|
+
}
|
|
61
|
+
return `${command} exited with code ${code}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when the ending looks like the child ran out of memory.
|
|
66
|
+
*
|
|
67
|
+
* Callers use this to add the one hint that would have saved the captured
|
|
68
|
+
* session: the child died of memory pressure, not of a failed check.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} params
|
|
71
|
+
* @param {number|null} [params.code]
|
|
72
|
+
* @param {string|null} [params.signal]
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export const isLikelyOutOfMemoryExit = ({ code = null, signal = null }) => signal === 'SIGABRT' || signal === 'SIGKILL' || (signal === null && code === 134);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wire `close`/`error` handlers that never lose a signal.
|
|
79
|
+
*
|
|
80
|
+
* The exit code handed to `onExit` is 1 for a signalled child: `code || 0`
|
|
81
|
+
* turned the `null` of a signalled exit into a success, so a worker killed by
|
|
82
|
+
* the OOM killer looked like a worker that finished its issue.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} params
|
|
85
|
+
* @param {import('node:child_process').ChildProcess} params.child
|
|
86
|
+
* @param {string} params.command - what was spawned, as the log reader knows it.
|
|
87
|
+
* @param {string} params.label - prefix for the exit line (e.g. ` [solve worker-1]`).
|
|
88
|
+
* @param {string} [params.errorLabel] - prefix for the spawn-failure line; defaults to `label`.
|
|
89
|
+
* @param {Function} params.log - async logger, `(message, options) => Promise`.
|
|
90
|
+
* @param {Function} [params.onLogError] - called as `(error, operation)` when logging itself fails.
|
|
91
|
+
* @param {Function} params.onExit - called once with `{ exitCode, code, signal, error }`.
|
|
92
|
+
*/
|
|
93
|
+
export const attachChildExitHandlers = ({ child, command, label, errorLabel = label, log, onLogError = () => {}, onExit }) => {
|
|
94
|
+
child.on('close', (code, signal) => {
|
|
95
|
+
if (signal) {
|
|
96
|
+
log(`${label} ${describeChildExit({ command, code, signal })}`, { level: 'error' }).catch(logError => onLogError(logError, 'log_child_signal_exit'));
|
|
97
|
+
}
|
|
98
|
+
onExit({ exitCode: signal ? 1 : code || 0, code, signal, error: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
child.on('error', error => {
|
|
102
|
+
log(`${errorLabel} Process error: ${error.message}`, { level: 'error' }).catch(logError => onLogError(logError, 'log_child_process_error'));
|
|
103
|
+
onExit({ exitCode: 1, code: null, signal: null, error });
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export default { describeChildExit, isLikelyOutOfMemoryExit, attachChildExitHandlers };
|
package/src/codex-health.lib.mjs
CHANGED
|
@@ -218,6 +218,26 @@ export const getCodexPluginProvisioningHealth = (codexJsonState, { capabilityPre
|
|
|
218
218
|
// both observed in the captured runs at exit_code 0) would be wrongly failed.
|
|
219
219
|
// Disk pressure is surfaced only as supporting *diagnostics* explaining why a
|
|
220
220
|
// session was likely cut off, never as the sole reason to fail a completed turn.
|
|
221
|
+
// Issue #2136: the count comparison above is only sound when every counted
|
|
222
|
+
// `turn.started` is codex's own. It is not: under `--verbose` codex's stderr
|
|
223
|
+
// carries OTEL records that dump the raw stdout of each command it ran, so a task
|
|
224
|
+
// that drives another agent CLI replays that agent's `turn.started` into our
|
|
225
|
+
// stream. One echoed line was enough to make turn.started=2 vs turn.completed=1
|
|
226
|
+
// and fail a run that had finished successfully (formal-ai PR #913 was open with
|
|
227
|
+
// green CI). codex.lib.mjs now keeps stderr out of the protocol counters, and the
|
|
228
|
+
// gate itself asks the order-aware question — "was the LAST lifecycle event a
|
|
229
|
+
// start?" — so a stray extra `turn.started` from any future echo path can no
|
|
230
|
+
// longer flip a completed session to failed, while a genuine cut-off mid-turn
|
|
231
|
+
// (the #1990 shape: the stream ends on `turn.started`) still fails.
|
|
232
|
+
const isIncompleteTurnLifecycle = (turnLifecycle, { turnStarted, turnCompleted, turnFailed }) => {
|
|
233
|
+
if (!Array.isArray(turnLifecycle) || turnLifecycle.length === 0) {
|
|
234
|
+
// Callers that hand-build a state without an ordered lifecycle keep the
|
|
235
|
+
// original count rule.
|
|
236
|
+
return turnCompleted + turnFailed < Math.max(turnStarted, 1);
|
|
237
|
+
}
|
|
238
|
+
return turnLifecycle.at(-1) === 'turn.started';
|
|
239
|
+
};
|
|
240
|
+
|
|
221
241
|
export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } = {}) => {
|
|
222
242
|
const eventCounts = codexJsonState?.eventCounts || {};
|
|
223
243
|
const turnStarted = eventCounts['turn.started'] || 0;
|
|
@@ -232,7 +252,7 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
232
252
|
|
|
233
253
|
// A started turn that never completed or failed = the process was cut off
|
|
234
254
|
// mid-turn (OOM / disk-full / container teardown) even though it exited 0.
|
|
235
|
-
const incompleteSession = hadActivity &&
|
|
255
|
+
const incompleteSession = hadActivity && isIncompleteTurnLifecycle(codexJsonState?.turnLifecycle, { turnStarted, turnCompleted, turnFailed });
|
|
236
256
|
|
|
237
257
|
// Diagnostic-only disk-pressure hints (never an independent failure gate).
|
|
238
258
|
const diskEvidence = [];
|
package/src/codex.lib.mjs
CHANGED
|
@@ -338,7 +338,21 @@ const upsertCodexItemError = (itemErrors, item) => {
|
|
|
338
338
|
});
|
|
339
339
|
};
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
// Issue #2136: `codex exec --json` writes its NDJSON protocol to **stdout** only.
|
|
342
|
+
// Its stderr carries OTEL tracing text (RUST_LOG=debug under --verbose), and each
|
|
343
|
+
// `codex.tool_result` record dumps the raw stdout of the command codex just ran —
|
|
344
|
+
// so a task driving another agent CLI replays that agent's NDJSON verbatim inside
|
|
345
|
+
// the trace. Feeding stderr through this parser counted the nested agent's
|
|
346
|
+
// `turn.started` as codex's own, and the #1990 completion gate then failed a run
|
|
347
|
+
// that had actually completed (see docs/case-studies/issue-2136).
|
|
348
|
+
//
|
|
349
|
+
// Fix: only stdout is trusted as protocol. Stderr is still scanned for the
|
|
350
|
+
// text-only diagnostics that legitimately live there (token/model diagnostics,
|
|
351
|
+
// the #2102 plugin-install rejection), but protocol-shaped JSON found there is
|
|
352
|
+
// counted into `telemetryEventCounts` and otherwise ignored — it never touches
|
|
353
|
+
// eventCounts, sessionId, usage or the error buckets.
|
|
354
|
+
export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId = null, { source = 'stdout' } = {}) => {
|
|
355
|
+
const isProtocolStream = source === 'stdout';
|
|
342
356
|
const nextState = {
|
|
343
357
|
sessionId: state.sessionId || null,
|
|
344
358
|
authError: state.authError || false,
|
|
@@ -359,6 +373,13 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
359
373
|
pluginInstallRejections: state.pluginInstallRejections || [],
|
|
360
374
|
observedUsageFieldSets: state.observedUsageFieldSets || [],
|
|
361
375
|
observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
|
|
376
|
+
// Issue #2136: protocol-shaped JSON seen on a non-protocol stream (telemetry
|
|
377
|
+
// echo), kept for diagnostics only.
|
|
378
|
+
telemetryEventCounts: state.telemetryEventCounts || {},
|
|
379
|
+
// Issue #2136: ordered turn lifecycle from the protocol stream, so the
|
|
380
|
+
// completion gate can ask "did the last turn finish?" instead of comparing
|
|
381
|
+
// counts that an echoed `turn.started` can skew.
|
|
382
|
+
turnLifecycle: state.turnLifecycle || [],
|
|
362
383
|
};
|
|
363
384
|
|
|
364
385
|
nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
|
|
@@ -396,7 +417,18 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
396
417
|
if (data === null || typeof data !== 'object') continue;
|
|
397
418
|
|
|
398
419
|
const eventType = typeof data.type === 'string' ? data.type : 'unknown';
|
|
420
|
+
|
|
421
|
+
// Issue #2136: a protocol-shaped object on a non-protocol stream is echoed
|
|
422
|
+
// telemetry, never a codex event. Count it for diagnostics and move on.
|
|
423
|
+
if (!isProtocolStream) {
|
|
424
|
+
nextState.telemetryEventCounts[eventType] = (nextState.telemetryEventCounts[eventType] || 0) + 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
399
428
|
nextState.eventCounts[eventType] = (nextState.eventCounts[eventType] || 0) + 1;
|
|
429
|
+
if (eventType === 'turn.started' || eventType === 'turn.completed' || eventType === 'turn.failed') {
|
|
430
|
+
nextState.turnLifecycle.push(eventType);
|
|
431
|
+
}
|
|
400
432
|
|
|
401
433
|
if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
|
|
402
434
|
nextState.sessionId = data.thread_id;
|
|
@@ -983,6 +1015,8 @@ export const executeCodexCommand = async params => {
|
|
|
983
1015
|
pluginInstallRejections: [],
|
|
984
1016
|
observedUsageFieldSets: [],
|
|
985
1017
|
observedModelDiagnosticPaths: [],
|
|
1018
|
+
telemetryEventCounts: {},
|
|
1019
|
+
turnLifecycle: [],
|
|
986
1020
|
};
|
|
987
1021
|
|
|
988
1022
|
// Issue #2119: a process chunk boundary can fall in the middle of an
|
|
@@ -1001,7 +1035,7 @@ export const executeCodexCommand = async params => {
|
|
|
1001
1035
|
lastMessage = raw;
|
|
1002
1036
|
const output = codexStdoutLines.write(raw);
|
|
1003
1037
|
|
|
1004
|
-
codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel);
|
|
1038
|
+
codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel, { source: 'stdout' });
|
|
1005
1039
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1006
1040
|
|
|
1007
1041
|
if (interactiveHandler || progressMonitor) {
|
|
@@ -1044,7 +1078,8 @@ export const executeCodexCommand = async params => {
|
|
|
1044
1078
|
await log(rawError, { stream: 'stderr' });
|
|
1045
1079
|
}
|
|
1046
1080
|
const errorOutput = codexStderrLines.write(rawError);
|
|
1047
|
-
|
|
1081
|
+
// Issue #2136: stderr is telemetry/tracing text, not the codex protocol.
|
|
1082
|
+
codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel, { source: 'stderr' });
|
|
1048
1083
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1049
1084
|
} else if (chunk.type === 'exit') {
|
|
1050
1085
|
exitCode = chunk.code;
|
|
@@ -1052,9 +1087,12 @@ export const executeCodexCommand = async params => {
|
|
|
1052
1087
|
}
|
|
1053
1088
|
|
|
1054
1089
|
// Release any line that was still being assembled when the stream ended.
|
|
1055
|
-
for (const remaining of [
|
|
1090
|
+
for (const [source, remaining] of [
|
|
1091
|
+
['stdout', codexStdoutLines.flush()],
|
|
1092
|
+
['stderr', codexStderrLines.flush()],
|
|
1093
|
+
]) {
|
|
1056
1094
|
if (!remaining.trim()) continue;
|
|
1057
|
-
codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel);
|
|
1095
|
+
codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel, { source });
|
|
1058
1096
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1059
1097
|
}
|
|
1060
1098
|
|
|
@@ -86,6 +86,17 @@ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedMo
|
|
|
86
86
|
if (Object.keys(state.eventCounts || {}).length > 0) push(`📊 Codex JSON events: ${counts(state.eventCounts)}`);
|
|
87
87
|
if (Object.keys(state.itemTypeCounts || {}).length > 0) push(`📦 Codex item types: ${counts(state.itemTypeCounts)}`);
|
|
88
88
|
|
|
89
|
+
// Issue #2136: protocol-shaped JSON that arrived on codex's stderr (OTEL
|
|
90
|
+
// `codex.tool_result` records replay the raw stdout of every command codex
|
|
91
|
+
// runs, so a task driving another agent CLI replays that agent's NDJSON). These
|
|
92
|
+
// lines are deliberately NOT counted as codex events; surfacing them keeps the
|
|
93
|
+
// discrepancy between "events codex emitted" and "protocol lines seen in the
|
|
94
|
+
// log" explainable when a run is investigated after the fact.
|
|
95
|
+
if (Object.keys(state.telemetryEventCounts || {}).length > 0) {
|
|
96
|
+
push(`🪞 Echoed protocol-shaped lines on codex stderr (ignored, not codex events): ${counts(state.telemetryEventCounts)}`);
|
|
97
|
+
}
|
|
98
|
+
if (state.turnLifecycle?.length > 0) push(`🔁 Codex turn lifecycle: ${state.turnLifecycle.join(' → ')}`);
|
|
99
|
+
|
|
89
100
|
const usage = state.tokenUsage || {};
|
|
90
101
|
if (usage.stepCount > 0) {
|
|
91
102
|
push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
|
|
@@ -13,6 +13,7 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
13
13
|
const { $: __rawDollar$ } = await use('command-stream');
|
|
14
14
|
const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
|
|
15
15
|
const $ = wrapDollarWithGhRetry(__rawDollar$);
|
|
16
|
+
const { QUIET_PROBE } = await import('./quiet-probe.lib.mjs'); // issue #2135: keep large read-only probe payloads out of the attached log
|
|
16
17
|
/**
|
|
17
18
|
* Common paths where contributing guidelines might be found
|
|
18
19
|
*/
|
|
@@ -41,15 +42,25 @@ export async function detectContributingGuidelines(owner, repo) {
|
|
|
41
42
|
// Try to find CONTRIBUTING file in the repo
|
|
42
43
|
for (const path of CONTRIBUTING_PATHS) {
|
|
43
44
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// Issue #2135: `mirror: false` - the answer is the file's whole content,
|
|
46
|
+
// base64-encoded, and it is decoded into `result.content` below rather
|
|
47
|
+
// than read from the log.
|
|
48
|
+
//
|
|
49
|
+
// `.raw()` used to be called on this result: `$` is wrapped by
|
|
50
|
+
// `wrapDollarWithGhRetry`, which returns a plain promise for `gh`
|
|
51
|
+
// commands, so `.raw()` threw a TypeError that the `catch` below
|
|
52
|
+
// swallowed - every repository looked as if it had no contributing
|
|
53
|
+
// guidelines. The command-stream result fields are read directly instead.
|
|
54
|
+
const checkResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/contents/${path} 2>/dev/null`;
|
|
55
|
+
const checkText = checkResult.stdout ? checkResult.stdout.toString().trim() : '';
|
|
56
|
+
if (checkResult.code === 0 && checkText) {
|
|
46
57
|
result.found = true;
|
|
47
58
|
result.path = path;
|
|
48
59
|
result.url = `https://github.com/${owner}/${repo}/blob/main/${path}`;
|
|
49
60
|
|
|
50
61
|
// Try to get the content from the response
|
|
51
62
|
try {
|
|
52
|
-
const data = JSON.parse(
|
|
63
|
+
const data = JSON.parse(checkText);
|
|
53
64
|
if (data.content) {
|
|
54
65
|
// Decode base64 content
|
|
55
66
|
result.content = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
@@ -68,9 +79,11 @@ export async function detectContributingGuidelines(owner, repo) {
|
|
|
68
79
|
// Try to find docs URL in README
|
|
69
80
|
if (!result.found) {
|
|
70
81
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
// Issue #2135: `mirror: false`, and the same `.raw()` fix as above.
|
|
83
|
+
const readme = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/readme 2>/dev/null`;
|
|
84
|
+
const readmeText = readme.stdout ? readme.stdout.toString().trim() : '';
|
|
85
|
+
if (readme.code === 0 && readmeText) {
|
|
86
|
+
const readmeData = JSON.parse(readmeText);
|
|
74
87
|
const readmeContent = Buffer.from(readmeData.content, 'base64').toString('utf-8');
|
|
75
88
|
|
|
76
89
|
// Look for contributing documentation URL
|
|
@@ -290,6 +290,62 @@ const verifyDevelopmentLogDirectory = async directoryPath => {
|
|
|
290
290
|
|
|
291
291
|
const getCommandOutput = result => (result?.stderr?.toString?.() || result?.stdout?.toString?.() || '').trim();
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Issue #2135: leave no untracked residue behind when publication fails.
|
|
295
|
+
*
|
|
296
|
+
* The artifacts are written into the user's workspace *before* they can be
|
|
297
|
+
* verified, staged and committed. When any of those steps fails the copies stay
|
|
298
|
+
* on disk, and every later `git status --porcelain` reports them:
|
|
299
|
+
*
|
|
300
|
+
* ⚠️ Development log collection failed: Development-log publication rescan
|
|
301
|
+
* found residual credential material.
|
|
302
|
+
* ?? dev/log/issues/191/pulls/192/sessions/
|
|
303
|
+
* 📝 Found uncommitted changes
|
|
304
|
+
* 🔄 AUTO-RESTART: Restarting Claude to handle uncommitted changes...
|
|
305
|
+
*
|
|
306
|
+
* The restarted session is then told it MUST commit those changes - so hive-mind
|
|
307
|
+
* asks the AI to commit hive-mind's own session log into the user's branch, and
|
|
308
|
+
* the next `gh pr diff` carries it (docs/case-studies/issue-2135, RC6).
|
|
309
|
+
*
|
|
310
|
+
* The copies are exactly that - copies; the originals stay in the session log
|
|
311
|
+
* and the tool's own state directory, so discarding them loses nothing. Only
|
|
312
|
+
* this run's session directory is removed, and only while it is still
|
|
313
|
+
* uncommitted.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} params
|
|
316
|
+
* @param {string} params.repositoryPath
|
|
317
|
+
* @param {string} params.sessionRelativeDirectory - this run's session directory
|
|
318
|
+
* @param {string} params.relativeDirectory - the development-log directory that may hold staged paths
|
|
319
|
+
* @param {Function} [params.$]
|
|
320
|
+
* @param {Function} [params.log]
|
|
321
|
+
* @returns {Promise<{discarded: boolean, reason?: string}>}
|
|
322
|
+
*/
|
|
323
|
+
export const discardUnpublishedDevelopmentLog = async ({ repositoryPath, sessionRelativeDirectory, relativeDirectory, $, log }) => {
|
|
324
|
+
if (!repositoryPath || !sessionRelativeDirectory) {
|
|
325
|
+
return { discarded: false, reason: 'nothing-to-discard' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if ($ && relativeDirectory) {
|
|
329
|
+
// Unstage first: `git add -f` may already have run, and a staged-but-uncommitted
|
|
330
|
+
// path is just as good at triggering the restart loop as an untracked one.
|
|
331
|
+
try {
|
|
332
|
+
await $({ cwd: repositoryPath })`git reset -q -- ${relativeDirectory}`;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await log?.(`⚠️ Could not unstage development log artifacts: ${error.message}`, { level: 'warning' });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await fs.rm(path.join(repositoryPath, sessionRelativeDirectory), { recursive: true, force: true });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await log?.(`⚠️ Could not remove unpublished development log artifacts in ${sessionRelativeDirectory}: ${error.message}`, { level: 'warning' });
|
|
342
|
+
return { discarded: false, reason: 'unremovable' };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await log?.(`🧹 Discarded unpublished development log artifacts in ${sessionRelativeDirectory} so they cannot be mistaken for the AI's uncommitted work (issue #2135)`);
|
|
346
|
+
return { discarded: true };
|
|
347
|
+
};
|
|
348
|
+
|
|
293
349
|
export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, $, log }) => {
|
|
294
350
|
if (!enabled) {
|
|
295
351
|
return { skipped: 'disabled' };
|
|
@@ -303,8 +359,24 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
303
359
|
// Issue #2048: verbose trace so the commit timing (relative to PR readiness signals) is diagnosable from logs.
|
|
304
360
|
await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}, log slice from byte ${logStartByte}`, { verbose: true });
|
|
305
361
|
|
|
362
|
+
// Kept outside the try so the failure paths below (and the catch) can clean up
|
|
363
|
+
// the copies this run wrote into the workspace - see issue #2135, RC6.
|
|
364
|
+
let artifacts = null;
|
|
365
|
+
// Unstaging is only meaningful once staging has been attempted, and issue
|
|
366
|
+
// #2111 requires that no git command run before the residual-credential
|
|
367
|
+
// rescan - so the cleanup gets `$` only after `git add` was reached.
|
|
368
|
+
let stagingAttempted = false;
|
|
369
|
+
const discardArtifacts = async () =>
|
|
370
|
+
discardUnpublishedDevelopmentLog({
|
|
371
|
+
repositoryPath,
|
|
372
|
+
sessionRelativeDirectory: artifacts?.sessionRelativeDirectory,
|
|
373
|
+
relativeDirectory: artifacts?.relativeDirectory,
|
|
374
|
+
$: stagingAttempted ? $ : null,
|
|
375
|
+
log,
|
|
376
|
+
});
|
|
377
|
+
|
|
306
378
|
try {
|
|
307
|
-
|
|
379
|
+
artifacts = await writeDevelopmentLogArtifacts({
|
|
308
380
|
repositoryPath,
|
|
309
381
|
logFile,
|
|
310
382
|
issueNumber,
|
|
@@ -328,10 +400,12 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
328
400
|
// call the publication helper.
|
|
329
401
|
await verifyDevelopmentLogDirectory(path.join(repositoryPath, artifacts.relativeDirectory));
|
|
330
402
|
|
|
403
|
+
stagingAttempted = true;
|
|
331
404
|
const addResult = await $({ cwd: repositoryPath })`git add -f -- ${artifacts.relativeDirectory}`;
|
|
332
405
|
if (addResult.code !== 0) {
|
|
333
406
|
await log?.(`⚠️ Could not stage development log: ${getCommandOutput(addResult)}`, { level: 'warning' });
|
|
334
|
-
|
|
407
|
+
await discardArtifacts();
|
|
408
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
335
409
|
}
|
|
336
410
|
|
|
337
411
|
const diffResult = await $({ cwd: repositoryPath })`git diff --cached --quiet -- ${artifacts.relativeDirectory}`;
|
|
@@ -341,14 +415,16 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
341
415
|
}
|
|
342
416
|
if (diffResult.code !== 1) {
|
|
343
417
|
await log?.(`⚠️ Could not inspect staged development log changes: ${getCommandOutput(diffResult)}`, { level: 'warning' });
|
|
344
|
-
|
|
418
|
+
await discardArtifacts();
|
|
419
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
345
420
|
}
|
|
346
421
|
|
|
347
422
|
const commitMessage = prNumber ? `Add development log for issue #${issueNumber} PR #${prNumber}` : `Add development log for issue #${issueNumber}`;
|
|
348
423
|
const commitResult = await $({ cwd: repositoryPath })`git commit -m ${commitMessage} -- ${artifacts.relativeDirectory}`;
|
|
349
424
|
if (commitResult.code !== 0) {
|
|
350
425
|
await log?.(`⚠️ Could not commit development log: ${getCommandOutput(commitResult)}`, { level: 'warning' });
|
|
351
|
-
|
|
426
|
+
await discardArtifacts();
|
|
427
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
352
428
|
}
|
|
353
429
|
|
|
354
430
|
await log?.('✅ Development log committed');
|
|
@@ -368,6 +444,7 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
368
444
|
return { ...artifacts, committed: true, pushed: true };
|
|
369
445
|
} catch (error) {
|
|
370
446
|
await log?.(`⚠️ Development log collection failed: ${error.message}`, { level: 'warning' });
|
|
371
|
-
|
|
447
|
+
const cleanup = await discardArtifacts();
|
|
448
|
+
return { skipped: 'error', error, discarded: cleanup.discarded };
|
|
372
449
|
}
|
|
373
450
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
7
|
import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, dedupeRunsByWorkflow } from './fix.ci-cd.lib.mjs';
|
|
8
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
8
9
|
import { createTaskIssue } from './task.issue-creation.lib.mjs';
|
|
9
10
|
|
|
10
11
|
function runCommand(command, args, options = {}) {
|
|
@@ -25,8 +26,8 @@ function runCommand(command, args, options = {}) {
|
|
|
25
26
|
child.on('error', error => {
|
|
26
27
|
resolve({ code: 1, stdout, stderr: stderr || error.message });
|
|
27
28
|
});
|
|
28
|
-
child.on('close', code => {
|
|
29
|
-
resolve({ code, stdout, stderr });
|
|
29
|
+
child.on('close', (code, signal) => {
|
|
30
|
+
resolve({ code, stdout, stderr, signal });
|
|
30
31
|
});
|
|
31
32
|
});
|
|
32
33
|
}
|
|
@@ -35,7 +36,8 @@ async function commandOutput(run, command, args) {
|
|
|
35
36
|
const result = await run(command, args);
|
|
36
37
|
if (result.code !== 0) {
|
|
37
38
|
const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
|
|
38
|
-
|
|
39
|
+
// Issue #2135: `describeChildExit` names a signal instead of "code null".
|
|
40
|
+
throw new Error(output || describeChildExit({ command, code: result.code, signal: result.signal }));
|
|
39
41
|
}
|
|
40
42
|
return result.stdout.trim();
|
|
41
43
|
}
|
package/src/fix.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import { spawn } from 'child_process';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
import { buildSolveArgs, partitionFixArgs, summarizeRunFailures } from './fix.ci-cd.lib.mjs';
|
|
20
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
20
21
|
import { createCiCdIssue, prepareCiCdIssue } from './fix.ci-cd-issue.lib.mjs';
|
|
21
22
|
import { setupStdioLogInterceptor } from './lib.mjs';
|
|
22
23
|
|
|
@@ -122,9 +123,11 @@ async function main() {
|
|
|
122
123
|
env: process.env,
|
|
123
124
|
});
|
|
124
125
|
child.on('error', reject);
|
|
125
|
-
|
|
126
|
+
// Issue #2135: `signal` used to be dropped here, so a solve child that
|
|
127
|
+
// aborted on a V8 heap limit was reported as "solve exited with code null".
|
|
128
|
+
child.on('close', (code, signal) => {
|
|
126
129
|
if (code === 0) resolve();
|
|
127
|
-
else reject(new Error(
|
|
130
|
+
else reject(new Error(describeChildExit({ command: 'solve', code, signal })));
|
|
128
131
|
});
|
|
129
132
|
});
|
|
130
133
|
}
|
|
@@ -8,6 +8,7 @@ if (typeof globalThis.use === 'undefined') await ensureUseM();
|
|
|
8
8
|
const { $ } = await use('command-stream');
|
|
9
9
|
import { ghCmdRetry } from './lib.mjs';
|
|
10
10
|
import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
11
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Compute the Levenshtein edit distance between two strings.
|
|
@@ -116,7 +117,9 @@ export async function checkBaseBranchExists({ owner, repo, baseBranch, verbose =
|
|
|
116
117
|
export async function buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose = false }) {
|
|
117
118
|
let suggestion = '';
|
|
118
119
|
try {
|
|
119
|
-
|
|
120
|
+
// Issue #2135: `mirror: false`. Only the closest name is reported (below),
|
|
121
|
+
// yet the raw answer is every branch in the repository.
|
|
122
|
+
const listResult = await ghCmdRetry(() => $(QUIET_PROBE)`gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`, { label: `list branches ${owner}/${repo}` });
|
|
120
123
|
if (listResult.code === 0) {
|
|
121
124
|
const branches = listResult.stdout
|
|
122
125
|
.toString()
|
package/src/github.lib.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { buildCostInfoString } from './github-cost-info.lib.mjs';
|
|
|
19
19
|
export { buildCostInfoString };
|
|
20
20
|
// #1756: route gh exec calls through transient + rate-limit retry wrapper
|
|
21
21
|
import { execGhWithRetry } from './github-rate-limit.lib.mjs';
|
|
22
|
-
import { QUIET_PROBE } from './quiet-probe.lib.mjs'; //
|
|
22
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs'; // issues #2130, #2135: keep read-only probe payloads out of the attached log
|
|
23
23
|
// Issue #1625: Named marker constants (single source of truth) + in-memory
|
|
24
24
|
// tracking for tool-posted comments. See tool-comments.lib.mjs for design.
|
|
25
25
|
import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
|
|
@@ -1324,7 +1324,7 @@ export function isGitHubUrlType(url, types) {
|
|
|
1324
1324
|
*/
|
|
1325
1325
|
export async function ghPrView({ prNumber, owner, repo, jsonFields = 'headRefName,body,number,mergeStateStatus,state,headRepositoryOwner' }) {
|
|
1326
1326
|
try {
|
|
1327
|
-
const prResult = await
|
|
1327
|
+
const prResult = await $(QUIET_PROBE)`gh pr view ${prNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
|
|
1328
1328
|
const stdout = prResult.stdout.toString();
|
|
1329
1329
|
const stderr = prResult.stderr ? prResult.stderr.toString() : '';
|
|
1330
1330
|
const code = prResult.code || 0;
|
|
@@ -1364,7 +1364,7 @@ export async function ghPrView({ prNumber, owner, repo, jsonFields = 'headRefNam
|
|
|
1364
1364
|
*/
|
|
1365
1365
|
export async function ghIssueView({ issueNumber, owner, repo, jsonFields = 'number,title' }) {
|
|
1366
1366
|
try {
|
|
1367
|
-
const issueResult = await
|
|
1367
|
+
const issueResult = await $(QUIET_PROBE)`gh issue view ${issueNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
|
|
1368
1368
|
const stdout = issueResult.stdout.toString();
|
|
1369
1369
|
const stderr = issueResult.stderr ? issueResult.stderr.toString() : '';
|
|
1370
1370
|
const code = issueResult.code || 0;
|