@link-assistant/hive-mind 2.1.8 → 2.1.11
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 +19 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +5 -5
- package/src/isolation-runner.lib.mjs +33 -4
- package/src/solve.pre-pr-failure-notifier.lib.mjs +2 -1
- package/src/solve.repository-recovery-message.lib.mjs +25 -5
- package/src/solve.repository-safety.lib.mjs +163 -0
- package/src/solve.repository.lib.mjs +49 -6
- package/src/tool-retry.lib.mjs +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.1.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 208e123: Fix false-positive npm releases (issue #2028). `setup-npm.mjs` now pins npm to the 11.x line and validates the result instead of installing `npm@latest` (which pulled in npm 12.0.0, whose sigstore regression crashes provenance publishes — npm/cli#9722). `publish-to-npm.mjs` no longer trusts the publish command's exit status alone: it observes the real exit code, scans output for failure patterns, and verifies the version is actually live on npm before reporting success, so a failed publish can no longer be reported as a successful release. Added `sanitize-npm-userconfig.mjs` to remove the deprecated `always-auth` npm warning from release logs.
|
|
8
|
+
|
|
9
|
+
## 2.1.10
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 9964e69: Retry Claude stream-json sessions with `--resume` when the stream ends without a terminal result event after tool output.
|
|
14
|
+
- afbc353: Avoid fetching `use-m` at isolation-runner import time when tests only use pure helper exports.
|
|
15
|
+
|
|
16
|
+
## 2.1.9
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 5559c5e: Check all replacement repository branch tips before deleting a non-fork fork replacement, and report concrete branch-only commits when deletion is unsafe.
|
|
21
|
+
|
|
3
22
|
## 2.1.8
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -1163,6 +1163,11 @@ export const executeClaudeCommand = async params => {
|
|
|
1163
1163
|
queuedFeedback,
|
|
1164
1164
|
};
|
|
1165
1165
|
}
|
|
1166
|
+
if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
|
|
1167
|
+
commandFailed = true;
|
|
1168
|
+
lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
|
|
1169
|
+
await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
|
|
1170
|
+
}
|
|
1166
1171
|
const retryableLastError = classifyRetryableError(lastMessage);
|
|
1167
1172
|
// Issue #1834: Corrupted extended-thinking blocks → try to resume the session first, then fall
|
|
1168
1173
|
// back to a fresh restart (PR #1835 feedback). When both caps are reached, tryThinkingBlockRecovery
|
|
@@ -1291,11 +1296,6 @@ export const executeClaudeCommand = async params => {
|
|
|
1291
1296
|
.join('\n');
|
|
1292
1297
|
await log(`\n\n❌ Command failed: No messages processed and errors detected in stderr\nStderr errors:\n${errorsPreview}`, { level: 'error' });
|
|
1293
1298
|
}
|
|
1294
|
-
if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
|
|
1295
|
-
commandFailed = true;
|
|
1296
|
-
lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
|
|
1297
|
-
await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
|
|
1298
|
-
}
|
|
1299
1299
|
if (commandFailed) {
|
|
1300
1300
|
// Take resource snapshot after failure
|
|
1301
1301
|
const resourcesAfter = await getResourceSnapshot();
|
|
@@ -20,11 +20,26 @@ import os from 'node:os';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
let commandStreamDollarPromise = null;
|
|
24
|
+
|
|
25
|
+
async function getCommandStreamDollar() {
|
|
26
|
+
if (!commandStreamDollarPromise) {
|
|
27
|
+
commandStreamDollarPromise = (async () => {
|
|
28
|
+
if (typeof globalThis.use === 'undefined') {
|
|
29
|
+
await ensureUseM();
|
|
30
|
+
}
|
|
31
|
+
const { $ } = await globalThis.use('command-stream');
|
|
32
|
+
return $;
|
|
33
|
+
})();
|
|
34
|
+
}
|
|
26
35
|
|
|
27
|
-
|
|
36
|
+
try {
|
|
37
|
+
return await commandStreamDollarPromise;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
commandStreamDollarPromise = null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
28
43
|
|
|
29
44
|
// Re-export the shared status predicates so existing callers that reach them via
|
|
30
45
|
// the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`)
|
|
@@ -516,6 +531,7 @@ export function readSessionExitFromLog(logPath, options = {}) {
|
|
|
516
531
|
*/
|
|
517
532
|
async function findStartCommandBinary() {
|
|
518
533
|
try {
|
|
534
|
+
const $ = await getCommandStreamDollar();
|
|
519
535
|
const result = await $({ mirror: false })`which $`;
|
|
520
536
|
const path = result.stdout?.toString().trim() || '';
|
|
521
537
|
return path || null;
|
|
@@ -677,6 +693,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
677
693
|
}
|
|
678
694
|
|
|
679
695
|
try {
|
|
696
|
+
const $ = await getCommandStreamDollar();
|
|
680
697
|
const result = await $({ mirror: false })`${binPath} --status ${sessionId} --output-format json`;
|
|
681
698
|
|
|
682
699
|
const stdout = result.stdout?.toString().trim() || '';
|
|
@@ -755,6 +772,7 @@ export async function listIsolationSessions(verbose = false) {
|
|
|
755
772
|
return [];
|
|
756
773
|
}
|
|
757
774
|
try {
|
|
775
|
+
const $ = await getCommandStreamDollar();
|
|
758
776
|
const result = await $({ mirror: false })`${binPath} --list --output-format json`;
|
|
759
777
|
const stdout = result.stdout?.toString().trim() || '';
|
|
760
778
|
const sessions = parseSessionListOutput(stdout);
|
|
@@ -791,6 +809,7 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
791
809
|
}
|
|
792
810
|
|
|
793
811
|
try {
|
|
812
|
+
const $ = await getCommandStreamDollar();
|
|
794
813
|
const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
|
|
795
814
|
const stdout = result.stdout?.toString() || '';
|
|
796
815
|
const stderr = result.stderr?.toString() || '';
|
|
@@ -827,6 +846,7 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
827
846
|
*/
|
|
828
847
|
export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
829
848
|
try {
|
|
849
|
+
const $ = await getCommandStreamDollar();
|
|
830
850
|
const result = await $({ mirror: false })`screen -ls`;
|
|
831
851
|
const output = result.stdout?.toString() || '';
|
|
832
852
|
const exists = output.includes(sessionName);
|
|
@@ -857,6 +877,7 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
|
857
877
|
*/
|
|
858
878
|
export async function checkDockerContainerRunning(containerName, verbose = false) {
|
|
859
879
|
try {
|
|
880
|
+
const $ = await getCommandStreamDollar();
|
|
860
881
|
const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Running}}'} ${containerName}`;
|
|
861
882
|
const running = (result.stdout?.toString() || '').trim() === 'true';
|
|
862
883
|
if (verbose) {
|
|
@@ -890,6 +911,7 @@ export function parseDockerContainerWritableLayerSizeOutput(output) {
|
|
|
890
911
|
export async function getDockerContainerWritableLayerSize(containerName, verbose = false) {
|
|
891
912
|
if (!containerName) return null;
|
|
892
913
|
try {
|
|
914
|
+
const $ = await getCommandStreamDollar();
|
|
893
915
|
const result = await $({ mirror: false })`docker inspect --size -f ${'{{.SizeRw}}'} ${containerName}`;
|
|
894
916
|
const bytes = parseDockerContainerWritableLayerSizeOutput(result.stdout?.toString() || '');
|
|
895
917
|
if (verbose) {
|
|
@@ -922,6 +944,7 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
|
|
|
922
944
|
|
|
923
945
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
924
946
|
try {
|
|
947
|
+
const $ = await getCommandStreamDollar();
|
|
925
948
|
await $({ mirror: false })`docker exec ${containerName} sh -c ${releaseCommand}`;
|
|
926
949
|
if (verbose) {
|
|
927
950
|
console.log(`[VERBOSE] isolation-runner: released docker start gate for '${containerName}'`);
|
|
@@ -960,6 +983,7 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
960
983
|
}
|
|
961
984
|
|
|
962
985
|
try {
|
|
986
|
+
const $ = await getCommandStreamDollar();
|
|
963
987
|
const result = await $({ mirror: false })`docker rm -f ${containerName}`;
|
|
964
988
|
const stdout = result.stdout?.toString() || '';
|
|
965
989
|
const stderr = result.stderr?.toString() || '';
|
|
@@ -992,6 +1016,7 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
992
1016
|
*/
|
|
993
1017
|
export async function checkTmuxSessionRunning(sessionName, verbose = false) {
|
|
994
1018
|
try {
|
|
1019
|
+
const $ = await getCommandStreamDollar();
|
|
995
1020
|
await $({ mirror: false })`tmux has-session -t ${sessionName}`;
|
|
996
1021
|
if (verbose) console.log(`[VERBOSE] isolation-runner: tmux has-session '${sessionName}': running`);
|
|
997
1022
|
return true;
|
|
@@ -1035,6 +1060,7 @@ export async function checkBackendSessionAlive(sessionId, backend, verbose = fal
|
|
|
1035
1060
|
*/
|
|
1036
1061
|
export async function checkDockerImagePresent(image, verbose = false) {
|
|
1037
1062
|
try {
|
|
1063
|
+
const $ = await getCommandStreamDollar();
|
|
1038
1064
|
await $({ mirror: false })`docker image inspect ${image}`;
|
|
1039
1065
|
if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': present`);
|
|
1040
1066
|
return true;
|
|
@@ -1062,6 +1088,7 @@ export async function checkDockerImagePresent(image, verbose = false) {
|
|
|
1062
1088
|
*/
|
|
1063
1089
|
export async function checkDockerStorageDriver(verbose = false) {
|
|
1064
1090
|
try {
|
|
1091
|
+
const $ = await getCommandStreamDollar();
|
|
1065
1092
|
const result = await $({ mirror: false })`docker info --format ${'{{.Driver}}'}`;
|
|
1066
1093
|
const driver = (result.stdout?.toString() || '').trim().toLowerCase() || null;
|
|
1067
1094
|
if (verbose) console.log(`[VERBOSE] isolation-runner: docker storage driver: ${driver || '(unknown)'}`);
|
|
@@ -1091,6 +1118,7 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1091
1118
|
try {
|
|
1092
1119
|
let dataRoot = '/var/lib/docker';
|
|
1093
1120
|
try {
|
|
1121
|
+
const $ = await getCommandStreamDollar();
|
|
1094
1122
|
const info = await $({ mirror: false })`docker info --format ${'{{.DockerRootDir}}'}`;
|
|
1095
1123
|
const root = (info.stdout?.toString() || '').trim();
|
|
1096
1124
|
if (root) dataRoot = root;
|
|
@@ -1099,6 +1127,7 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1099
1127
|
// fails on it (e.g. the path does not exist) we return null below.
|
|
1100
1128
|
}
|
|
1101
1129
|
|
|
1130
|
+
const $ = await getCommandStreamDollar();
|
|
1102
1131
|
const df = await $({ mirror: false })`df -Pk ${dataRoot}`;
|
|
1103
1132
|
// `df -P` guarantees one logical line per filesystem (no wrapping). The last
|
|
1104
1133
|
// line is the data row: Filesystem 1024-blocks Used Available Capacity Mount
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getTrackedToolCommentIds, postTrackedComment, SOLUTION_DRAFT_FAILED_MARKER } from './tool-comments.lib.mjs';
|
|
2
|
-
import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason } from './solve.repository-recovery-message.lib.mjs';
|
|
2
|
+
import { extractForkReplacementBlockedDetails, isForkReplacementBlockedReason, GITHUB_FORK_SUPPORT_URL } from './solve.repository-recovery-message.lib.mjs';
|
|
3
3
|
import { FORK_DIVERGENCE_RESOLUTION_OPTION, buildForkDivergenceFailureActionSection } from './solve.branch-divergence.lib.mjs';
|
|
4
4
|
|
|
5
5
|
export { FORK_DIVERGENCE_RESOLUTION_OPTION };
|
|
@@ -41,6 +41,7 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
|
|
|
41
41
|
- It could not prove whether the existing repository has unique commits, so it did not delete it automatically.
|
|
42
42
|
|
|
43
43
|
### What you can do
|
|
44
|
+
- Recover the fork without deleting: if \`${repository}\` was a fork of \`${upstream}\` that GitHub detached (for example after a private/public visibility change), ask GitHub Support at ${GITHUB_FORK_SUPPORT_URL} ("Attach, detach or reroute forks") to re-attach it. Detachment from a visibility change is documented as permanent, so this is not guaranteed, and while detached the repository cannot open a cross-repository pull request to \`${upstream}\`.
|
|
44
45
|
- Repository owner path: back up any needed work, then delete, rename, archive, or repair \`${repository}\` in GitHub and rerun the solver.
|
|
45
46
|
- External-owner path: ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
|
|
46
47
|
- Use \`--allow-force-non-fork-repository-deletion\` only after confirming that deleting \`${repository}\` is acceptable and any unique commits can be lost.`;
|
|
@@ -26,27 +26,47 @@ export function buildForkReplacementSafetyCheckDescription({ aheadBy = null, com
|
|
|
26
26
|
return 'Hive Mind could not prove the repository has no unique commits.';
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// GitHub Support's self-service fork request workflow. See
|
|
30
|
+
// https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/detaching-a-fork
|
|
31
|
+
export const GITHUB_FORK_SUPPORT_URL = 'https://support.github.com/request/fork';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Explain how to recover the GitHub fork relationship without deleting the
|
|
35
|
+
* existing repository. GitHub detaches a fork from its upstream network when the
|
|
36
|
+
* repository's visibility is toggled (for example private -> public), and that
|
|
37
|
+
* detachment is documented as permanent with no API/self-service reattachment.
|
|
38
|
+
* The only non-deletion path is a GitHub Support request. See issue #2019.
|
|
39
|
+
*/
|
|
40
|
+
export function buildDetachedForkRecoveryGuidance({ existingRepository, expectedUpstream } = {}) {
|
|
41
|
+
const repository = fallback(existingRepository, 'the existing repository');
|
|
42
|
+
const upstream = fallback(expectedUpstream, 'the expected upstream repository');
|
|
43
|
+
|
|
44
|
+
return `Recover the fork link WITHOUT deleting ${repository}: if ${repository} was once a GitHub fork of ${upstream} that got detached (for example after its visibility was switched to private and then back to public), the only path that keeps the repository is a GitHub Support request. Open ${GITHUB_FORK_SUPPORT_URL}, choose the "Attach, detach or reroute forks" flow, and ask to re-attach ${repository} to ${upstream}. GitHub documents visibility-change detachment as permanent, so reattachment is not guaranteed. While detached, ${repository} cannot open a cross-repository pull request to ${upstream}, so the solver needs either a reattached fork or a fresh fork to continue.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildForkReplacementBlockedReason({ existingRepository, expectedUpstream, relationshipDescription, safetyCheckDescription, likelyDetachedFork = false } = {}) {
|
|
30
48
|
const repository = fallback(existingRepository, 'the existing repository');
|
|
31
49
|
const upstream = fallback(expectedUpstream, 'the expected upstream repository');
|
|
32
50
|
const relationship = fallback(relationshipDescription, `${repository} is not a confirmed GitHub fork of ${upstream}.`);
|
|
33
51
|
const safetyCheck = fallback(safetyCheckDescription, buildForkReplacementSafetyCheckDescription());
|
|
52
|
+
const detachedForkNote = likelyDetachedFork ? `\n- Likely cause: ${repository} shares history with ${upstream} but GitHub reports it as a non-fork, which matches a fork that was detached from its network (commonly after a private/public visibility change).` : '';
|
|
34
53
|
|
|
35
54
|
return `${FORK_REPLACEMENT_BLOCKED_REASON_PREFIX}
|
|
36
55
|
|
|
37
56
|
What happened:
|
|
38
57
|
- Expected fork or replacement repository: ${repository}
|
|
39
58
|
- Expected upstream: ${upstream}
|
|
40
|
-
- Actual state: ${relationship}
|
|
59
|
+
- Actual state: ${relationship}${detachedForkNote}
|
|
41
60
|
- Safety check: ${safetyCheck}
|
|
42
61
|
|
|
43
62
|
Why it stopped:
|
|
44
63
|
Hive Mind did not delete ${repository} because the repository may contain commits that would be lost.
|
|
45
64
|
|
|
46
65
|
Options:
|
|
47
|
-
1.
|
|
48
|
-
2.
|
|
49
|
-
3.
|
|
66
|
+
1. ${buildDetachedForkRecoveryGuidance({ existingRepository: repository, expectedUpstream: upstream })}
|
|
67
|
+
2. Back up any needed work in ${repository}, then delete, rename, archive, or repair that repository in GitHub and rerun the solver.
|
|
68
|
+
3. If you do not control ${repository}, ask the repository owner or a Hive Mind administrator to clean it up and rerun the solver.
|
|
69
|
+
4. Rerun with --allow-force-non-fork-repository-deletion only after confirming that deleting ${repository} is acceptable.`;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
export function isForkReplacementBlockedReason(reason = '') {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { summarizeGitHubCompareFailure } from './solve.repository-recovery-message.lib.mjs';
|
|
6
|
+
|
|
7
|
+
const resultText = result => `${result?.stderr?.toString?.() || ''}${result?.stdout?.toString?.() || ''}`.trim();
|
|
8
|
+
const resultStdout = result => result?.stdout?.toString?.().trim() || '';
|
|
9
|
+
const shortSha = sha => String(sha || '').slice(0, 12);
|
|
10
|
+
|
|
11
|
+
const branchLabel = branch => {
|
|
12
|
+
const subject = branch.subject ? ` ${branch.subject}` : '';
|
|
13
|
+
return `${branch.ref} (${branch.uniqueCommitCount} commit(s), ${shortSha(branch.sha)}${subject})`;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function buildReplacementBranchSafetyDescription({ uniqueBranches = [], branchCount = 0, failureOutput = '' } = {}) {
|
|
17
|
+
if (failureOutput) {
|
|
18
|
+
return `Local Git branch reachability check failed (${summarizeGitHubCompareFailure(failureOutput)}), so Hive Mind could not prove all replacement repository branches are preserved upstream.`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (uniqueBranches.length > 0) {
|
|
22
|
+
const examples = uniqueBranches.slice(0, 3).map(branchLabel).join('; ');
|
|
23
|
+
const remaining = uniqueBranches.length > 3 ? `; and ${uniqueBranches.length - 3} more` : '';
|
|
24
|
+
return `Local Git branch reachability found ${uniqueBranches.length} replacement branch tip(s) with commits not reachable from upstream branches or PR refs: ${examples}${remaining}.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return `Local Git branch reachability found all ${branchCount} replacement branch tip(s) reachable from upstream branches or PR refs.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runStep(commandPromise, failurePrefix) {
|
|
31
|
+
const result = await commandPromise;
|
|
32
|
+
if (result.code !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
output: `${failurePrefix}: ${resultText(result) || `exit ${result.code}`}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, result };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parseReplacementRefs = output =>
|
|
42
|
+
String(output || '')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(line => line.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map(line => {
|
|
47
|
+
const [ref, sha] = line.split(/\s+/, 2);
|
|
48
|
+
return { ref, sha };
|
|
49
|
+
})
|
|
50
|
+
.filter(item => item.ref && item.sha);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Verify that deleting a non-fork replacement repository will not remove branch
|
|
54
|
+
* tips that are absent from the upstream repository. This is intentionally used
|
|
55
|
+
* only in the rare fork-replacement recovery path.
|
|
56
|
+
*/
|
|
57
|
+
export async function checkReplacementRepositoryBranchSafety({ $, owner, repo, existingRepository, tempRoot = os.tmpdir() }) {
|
|
58
|
+
const tempDir = await fs.mkdtemp(path.join(tempRoot, 'hive-mind-fork-replacement-'));
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const init = await runStep($({ cwd: tempDir })`git init -q 2>&1`, 'git init failed');
|
|
62
|
+
if (!init.ok) {
|
|
63
|
+
return {
|
|
64
|
+
safeToDelete: false,
|
|
65
|
+
branchCount: 0,
|
|
66
|
+
uniqueBranches: [],
|
|
67
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: init.output }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const upstreamUrl = `https://github.com/${owner}/${repo}.git`;
|
|
72
|
+
const replacementUrl = `https://github.com/${existingRepository}.git`;
|
|
73
|
+
|
|
74
|
+
const upstreamFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${upstreamUrl} '+refs/heads/*:refs/remotes/upstream/*' '+refs/pull/*/head:refs/remotes/upstream-pr/*' 2>&1`, 'upstream fetch failed');
|
|
75
|
+
if (!upstreamFetch.ok) {
|
|
76
|
+
return {
|
|
77
|
+
safeToDelete: false,
|
|
78
|
+
branchCount: 0,
|
|
79
|
+
uniqueBranches: [],
|
|
80
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: upstreamFetch.output }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const replacementFetch = await runStep($({ cwd: tempDir })`git fetch --filter=blob:none ${replacementUrl} '+refs/heads/*:refs/remotes/replacement/*' 2>&1`, 'replacement fetch failed');
|
|
85
|
+
if (!replacementFetch.ok) {
|
|
86
|
+
return {
|
|
87
|
+
safeToDelete: false,
|
|
88
|
+
branchCount: 0,
|
|
89
|
+
uniqueBranches: [],
|
|
90
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: replacementFetch.output }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const refsResult = await runStep($({ cwd: tempDir })`git for-each-ref --format='%(refname:short) %(objectname)' refs/remotes/replacement 2>&1`, 'replacement ref listing failed');
|
|
95
|
+
if (!refsResult.ok) {
|
|
96
|
+
return {
|
|
97
|
+
safeToDelete: false,
|
|
98
|
+
branchCount: 0,
|
|
99
|
+
uniqueBranches: [],
|
|
100
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: refsResult.output }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const replacementRefs = parseReplacementRefs(resultStdout(refsResult.result));
|
|
105
|
+
const uniqueBranches = [];
|
|
106
|
+
|
|
107
|
+
for (const { ref, sha } of replacementRefs) {
|
|
108
|
+
const countResult = await runStep($({ cwd: tempDir })`git rev-list --count ${ref} --not --remotes=upstream --remotes=upstream-pr 2>&1`, `unique commit count failed for ${ref}`);
|
|
109
|
+
if (!countResult.ok) {
|
|
110
|
+
return {
|
|
111
|
+
safeToDelete: false,
|
|
112
|
+
branchCount: replacementRefs.length,
|
|
113
|
+
uniqueBranches,
|
|
114
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: countResult.output }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const uniqueCommitCount = Number.parseInt(resultStdout(countResult.result), 10);
|
|
119
|
+
if (!Number.isFinite(uniqueCommitCount)) {
|
|
120
|
+
return {
|
|
121
|
+
safeToDelete: false,
|
|
122
|
+
branchCount: replacementRefs.length,
|
|
123
|
+
uniqueBranches,
|
|
124
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: `invalid unique commit count for ${ref}: ${resultStdout(countResult.result)}` }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (uniqueCommitCount > 0) {
|
|
129
|
+
const subjectResult = await $({ cwd: tempDir })`git log -1 --format=%s ${ref} 2>&1`;
|
|
130
|
+
uniqueBranches.push({
|
|
131
|
+
ref: ref.replace(/^replacement\//, ''),
|
|
132
|
+
sha,
|
|
133
|
+
uniqueCommitCount,
|
|
134
|
+
subject: subjectResult.code === 0 ? resultStdout(subjectResult) : '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reachableBranchCount = replacementRefs.length - uniqueBranches.length;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
safeToDelete: uniqueBranches.length === 0,
|
|
143
|
+
branchCount: replacementRefs.length,
|
|
144
|
+
reachableBranchCount,
|
|
145
|
+
// At least one branch tip is fully reachable from upstream refs, so the
|
|
146
|
+
// replacement shares history with upstream. Combined with GitHub reporting
|
|
147
|
+
// it as a non-fork, that matches a fork detached from its network (see
|
|
148
|
+
// issue #2019), typically after a private/public visibility change.
|
|
149
|
+
likelyDetachedFork: replacementRefs.length > 0 && reachableBranchCount > 0,
|
|
150
|
+
uniqueBranches,
|
|
151
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ uniqueBranches, branchCount: replacementRefs.length }),
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return {
|
|
155
|
+
safeToDelete: false,
|
|
156
|
+
branchCount: 0,
|
|
157
|
+
uniqueBranches: [],
|
|
158
|
+
safetyCheckDescription: buildReplacementBranchSafetyDescription({ failureOutput: error.message }),
|
|
159
|
+
};
|
|
160
|
+
} finally {
|
|
161
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -30,6 +30,7 @@ const { log, formatAligned } = lib;
|
|
|
30
30
|
// Import exit handler
|
|
31
31
|
import { safeExit } from './exit-handler.lib.mjs';
|
|
32
32
|
import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
|
|
33
|
+
import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
|
|
33
34
|
import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
|
|
34
35
|
|
|
35
36
|
// Import GitHub utilities for permission checks
|
|
@@ -525,34 +526,75 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
525
526
|
const relationshipDescription = !forkValidation.isFork ? `${existingForkName} is not a GitHub fork of ${owner}/${repo}.` : `${existingForkName} is a GitHub fork of ${forkValidation.parent || 'unknown parent'}, not ${owner}/${repo}.`;
|
|
526
527
|
await log(`${formatAligned('', '', detail)}`);
|
|
527
528
|
await log(`${formatAligned('', '', `Fork parent: ${forkValidation.parent || 'N/A (not a fork)'}, source: ${forkValidation.source || 'N/A'}, expected: ${owner}/${repo}`)}`);
|
|
528
|
-
// Safety check: compare commits before deleting to avoid data loss
|
|
529
|
-
|
|
529
|
+
// Safety check: compare commits before deleting to avoid data loss.
|
|
530
|
+
// GitHub compare only answers for fork-network refs, so a local Git
|
|
531
|
+
// fallback checks every replacement branch tip before deletion.
|
|
532
|
+
await log(`${formatAligned('🔍', 'Safety check:', 'Comparing default branch against upstream...')}`);
|
|
530
533
|
let safeToDelete = false;
|
|
531
534
|
let safetyCheckDescription = buildForkReplacementSafetyCheckDescription();
|
|
535
|
+
let shouldRunBranchReachabilityCheck = false;
|
|
536
|
+
let likelyDetachedFork = false;
|
|
532
537
|
try {
|
|
533
538
|
const cmp = await $`gh api repos/${owner}/${repo}/compare/${owner}:HEAD...${existingForkName.split('/')[0]}:HEAD --paginate --jq '.ahead_by' 2>&1`;
|
|
534
539
|
const aheadBy = parseInt(cmp.stdout.toString().trim(), 10);
|
|
535
540
|
if (cmp.code === 0 && aheadBy === 0) {
|
|
536
|
-
await log(`${formatAligned('✅', '
|
|
537
|
-
|
|
541
|
+
await log(`${formatAligned('✅', 'Default branch:', 'No additional commits in replacement repository')}`);
|
|
542
|
+
shouldRunBranchReachabilityCheck = true;
|
|
538
543
|
} else if (cmp.code === 0) {
|
|
539
544
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ aheadBy });
|
|
540
|
-
|
|
541
|
-
|
|
545
|
+
if (Number.isFinite(aheadBy)) {
|
|
546
|
+
await log(`${formatAligned('⚠️', 'UNSAFE:', `Default branch has ${aheadBy} commit(s) ahead of upstream that would be lost`)}`, { level: 'warning' });
|
|
547
|
+
} else {
|
|
548
|
+
await log(`${formatAligned('⚠️', 'Compare unclear:', 'GitHub compare did not return a numeric ahead_by value')}`, { level: 'warning' });
|
|
549
|
+
shouldRunBranchReachabilityCheck = true;
|
|
550
|
+
}
|
|
542
551
|
} else {
|
|
543
552
|
const compareOutput = (cmp.stderr?.toString() || '') + (cmp.stdout?.toString() || '');
|
|
544
553
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: compareOutput });
|
|
545
554
|
await log(`${formatAligned('⚠️', 'Compare failed:', compareOutput.split('\n')[0])}`, { level: 'warning' });
|
|
555
|
+
shouldRunBranchReachabilityCheck = true;
|
|
546
556
|
}
|
|
547
557
|
} catch (e) {
|
|
548
558
|
safetyCheckDescription = buildForkReplacementSafetyCheckDescription({ compareFailureOutput: e.message });
|
|
549
559
|
await log(`${formatAligned('⚠️', 'Compare error:', e.message)}`, { level: 'warning' });
|
|
560
|
+
shouldRunBranchReachabilityCheck = true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (shouldRunBranchReachabilityCheck) {
|
|
564
|
+
await log(`${formatAligned('🔍', 'Safety check:', 'Checking all replacement branches against upstream refs...')}`);
|
|
565
|
+
const branchSafety = await checkReplacementRepositoryBranchSafety({
|
|
566
|
+
$,
|
|
567
|
+
owner,
|
|
568
|
+
repo,
|
|
569
|
+
existingRepository: existingForkName,
|
|
570
|
+
});
|
|
571
|
+
safetyCheckDescription = branchSafety.safetyCheckDescription;
|
|
572
|
+
likelyDetachedFork = Boolean(branchSafety.likelyDetachedFork);
|
|
573
|
+
|
|
574
|
+
if (likelyDetachedFork) {
|
|
575
|
+
await log(`${formatAligned('🔗', 'Detached fork:', `${existingForkName} shares history with ${owner}/${repo} but is not a GitHub fork — this matches a fork detached by a private/public visibility change`)}`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (branchSafety.safeToDelete) {
|
|
579
|
+
await log(`${formatAligned('✅', 'Safe to delete:', `All ${branchSafety.branchCount} replacement branch tip(s) are reachable from upstream`)}`);
|
|
580
|
+
safeToDelete = true;
|
|
581
|
+
} else if (branchSafety.uniqueBranches.length > 0) {
|
|
582
|
+
await log(`${formatAligned('⚠️', 'UNSAFE:', `${branchSafety.uniqueBranches.length} replacement branch tip(s) have commits not reachable from upstream`)}`, { level: 'warning' });
|
|
583
|
+
for (const branch of branchSafety.uniqueBranches.slice(0, 3)) {
|
|
584
|
+
await log(`${formatAligned('', '', `${branch.ref}: ${branch.uniqueCommitCount} commit(s), ${branch.sha.slice(0, 12)} ${branch.subject || ''}`)}`, { level: 'warning' });
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
await log(`${formatAligned('⚠️', 'Branch check failed:', safetyCheckDescription)}`, { level: 'warning' });
|
|
588
|
+
}
|
|
550
589
|
}
|
|
551
590
|
if (!safeToDelete) {
|
|
552
591
|
if (argv.allowForceNonForkRepositoryDeletion) {
|
|
553
592
|
// Force flag set — proceed with deletion despite the failed safety check.
|
|
554
593
|
await log(`${formatAligned('⚠️', 'Force deletion ENABLED:', '--allow-force-non-fork-repository-deletion — proceeding despite potential data loss')}`, { level: 'warning' });
|
|
555
594
|
} else {
|
|
595
|
+
if (likelyDetachedFork) {
|
|
596
|
+
await log(` 🔗 Recover without deletion: ask GitHub Support to re-attach ${existingForkName} to ${owner}/${repo} at https://support.github.com/request/fork ("Attach, detach or reroute forks")`);
|
|
597
|
+
}
|
|
556
598
|
await log(` 💡 Manual fix required: back up work, then: gh repo delete ${existingForkName} --yes`);
|
|
557
599
|
await log(` Then run this command again to create a proper fork of ${owner}/${repo}`);
|
|
558
600
|
await log(` 🔧 Or force deletion (DANGEROUS): solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-force-non-fork-repository-deletion`);
|
|
@@ -563,6 +605,7 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
563
605
|
expectedUpstream: `${owner}/${repo}`,
|
|
564
606
|
relationshipDescription,
|
|
565
607
|
safetyCheckDescription,
|
|
608
|
+
likelyDetachedFork,
|
|
566
609
|
})
|
|
567
610
|
);
|
|
568
611
|
}
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -59,6 +59,13 @@ export const classifyRetryableError = value => {
|
|
|
59
59
|
return { message, isRetryable: true, isCapacity: false, label: 'Stream disconnected before completion' };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Issue #2023: Claude Code can close a stream-json process after tool output
|
|
63
|
+
// (for example "Exit code 144") without emitting the terminal result event.
|
|
64
|
+
// The session is still resumable; failing immediately loses in-progress work.
|
|
65
|
+
if (lower.includes('claude stream ended without a terminal result event')) {
|
|
66
|
+
return { message, isRetryable: true, isCapacity: false, label: 'Claude stream ended without terminal result' };
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
// Issue #1937: Stream idle timeout. When the Anthropic streaming response stalls
|
|
63
70
|
// (no bytes for the SDK's idle window) after the model has already emitted part of
|
|
64
71
|
// its answer, the Claude CLI aborts the turn and surfaces a synthetic assistant /
|