@link-assistant/hive-mind 2.16.0 → 2.18.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +55 -4
  11. package/src/cleanup.mjs +57 -3
  12. package/src/disk-guard.lib.mjs +21 -1
  13. package/src/formal-ai-version.lib.mjs +10 -6
  14. package/src/github-url-parser.lib.mjs +80 -23
  15. package/src/github-url-recovery.lib.mjs +514 -0
  16. package/src/hive.mjs +10 -0
  17. package/src/instrument.mjs +12 -14
  18. package/src/instrument.sanitize.lib.mjs +52 -0
  19. package/src/isolation-runner.lib.mjs +56 -33
  20. package/src/isolation-runner.parsers.lib.mjs +29 -3
  21. package/src/isolation-runner.resume.lib.mjs +263 -0
  22. package/src/locales/en.lino +7 -0
  23. package/src/locales/hi.lino +7 -0
  24. package/src/locales/ru.lino +7 -0
  25. package/src/locales/zh.lino +7 -0
  26. package/src/pull-request-changes.lib.mjs +1 -1
  27. package/src/session-kill-diagnostics.lib.mjs +47 -5
  28. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  29. package/src/session-kill-resume.lib.mjs +43 -16
  30. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  31. package/src/session-store.lib.mjs +1 -1
  32. package/src/solve.clone-errors.lib.mjs +86 -0
  33. package/src/solve.repository.lib.mjs +36 -63
  34. package/src/solve.resource-diagnostics.lib.mjs +34 -1
  35. package/src/solve.validation.lib.mjs +16 -0
  36. package/src/start-command-cli.lib.mjs +60 -0
  37. package/src/telegram-bot.mjs +51 -95
  38. package/src/telegram-overrides-validation.lib.mjs +73 -0
  39. package/src/working-session-summary.lib.mjs +1 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Re-enter the *same* container when recovering a killed session (issue #2189).
3
+ *
4
+ * The incident behind #2189 ended with a session that was killed 10 minutes
5
+ * after the AI tool had already finished its work. Recovery, when it finally
6
+ * happened, threw that container away: a fresh isolated run re-cloned the
7
+ * repository, re-installed everything and re-did work that was sitting on disk.
8
+ * The issue asks for the opposite — "ideally re-entering the same `$` session
9
+ * id / container".
10
+ *
11
+ * `start-command@0.33.0` (upstream link-foundation/start#162, filed from this
12
+ * very issue) makes that possible: `$ --resume <id> -- <command>` commits the
13
+ * stopped container's filesystem and runs the recovery command in a container
14
+ * derived from that snapshot, keeping the original execution UUID and log.
15
+ *
16
+ * Not every session may take that path, and the two exceptions are deliberate:
17
+ *
18
+ * - **Formal AI tasks** (issue #2146) reach their sidecar over an *internal*
19
+ * Docker network that Hive Mind attaches with `docker network connect` after
20
+ * the container is created. `$` knows nothing about that network, so a
21
+ * resumed container would come up without it and the task would silently talk
22
+ * to nothing. #2146 requires Formal AI to fail closed, so these fall back to
23
+ * the normal launch path, which re-acquires the sidecar lease properly.
24
+ * - **`--use-router` tasks** are attached to the router network the same way,
25
+ * with a freshly minted token, and have the same problem.
26
+ *
27
+ * Everything else — the overwhelming majority, and every session in the
28
+ * original incident — resumes in place.
29
+ *
30
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
31
+ * @see https://github.com/link-foundation/start/issues/162
32
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
33
+ */
34
+
35
+ import { isFormalAiTask } from './formal-ai-sidecar.lib.mjs';
36
+ import { hasUseRouterFlag } from './router-isolation.lib.mjs';
37
+ import { RESUME_MODES } from './isolation-runner.resume.lib.mjs';
38
+
39
+ /** Why a killed session cannot be re-entered in place. Reported, never thrown. */
40
+ export const IN_PLACE_SKIP_REASONS = Object.freeze({
41
+ NOT_DOCKER: 'not-docker',
42
+ NO_IDENTIFIER: 'no-identifier',
43
+ FORMAL_AI_TASK: 'formal-ai-task',
44
+ ROUTER_TASK: 'router-task',
45
+ NO_RESUME_SUPPORT: 'no-resume-support',
46
+ CONTAINER_GONE: 'container-gone',
47
+ UNSUPPORTED: 'resume-unsupported',
48
+ REFUSED: 'resume-refused',
49
+ ERROR: 'resume-error',
50
+ });
51
+
52
+ /**
53
+ * Decide — purely, from persisted facts — whether a killed session is a
54
+ * candidate for a same-container resume.
55
+ *
56
+ * Kept separate from the Docker probe below so the policy is testable without a
57
+ * daemon, and so a caller can report precisely *why* a session was relaunched
58
+ * from scratch instead of resumed.
59
+ *
60
+ * @param {Object} options
61
+ * @param {string} options.sessionName - The killed session's name (= container name)
62
+ * @param {Object} options.sessionInfo - Persisted session info
63
+ * @returns {{eligible: boolean, reason: string, identifier: string|null, containerName: string|null}}
64
+ */
65
+ export function planSameContainerResume({ sessionName = null, sessionInfo = {} } = {}) {
66
+ const containerName = sessionInfo?.sessionId || sessionName || null;
67
+ const identifier = sessionInfo?.executionUuid || containerName || null;
68
+ const base = { eligible: false, identifier, containerName };
69
+
70
+ if (sessionInfo?.isolationBackend !== 'docker') {
71
+ // screen/tmux sessions have no filesystem to preserve: their work happens
72
+ // on the host, which a fresh run already sees.
73
+ return { ...base, reason: IN_PLACE_SKIP_REASONS.NOT_DOCKER };
74
+ }
75
+ if (!identifier) return { ...base, reason: IN_PLACE_SKIP_REASONS.NO_IDENTIFIER };
76
+
77
+ const args = Array.isArray(sessionInfo?.args) ? sessionInfo.args : [];
78
+ if (isFormalAiTask({ args, model: sessionInfo?.model || null })) {
79
+ return { ...base, reason: IN_PLACE_SKIP_REASONS.FORMAL_AI_TASK };
80
+ }
81
+ if (hasUseRouterFlag(args)) return { ...base, reason: IN_PLACE_SKIP_REASONS.ROUTER_TASK };
82
+
83
+ return { ...base, eligible: true, reason: 'ready' };
84
+ }
85
+
86
+ /**
87
+ * Attempt the same-container resume. Never throws, and never leaves work
88
+ * running that it does not report: the caller may only fall back to a fresh
89
+ * launch when `resumed` is false.
90
+ *
91
+ * @param {Object} options
92
+ * @param {string} options.sessionName - The killed session's name
93
+ * @param {Object} options.sessionInfo - Persisted session info
94
+ * @param {Object} options.plan - Result of planKillRecovery() (needs `command.display`)
95
+ * @param {Object} options.runner - Isolation runner module
96
+ * @param {boolean} [options.verbose]
97
+ * @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, executionUuid: string|null, mode: string|null, snapshotImage: string|null}>}
98
+ */
99
+ export async function resumeKilledSessionInPlace({ sessionName, sessionInfo, plan, runner, verbose = false } = {}) {
100
+ const decision = planSameContainerResume({ sessionName, sessionInfo });
101
+ const miss = reason => ({ resumed: false, reason, sessionId: null, executionUuid: decision.identifier, mode: null, snapshotImage: null });
102
+ if (!decision.eligible) return miss(decision.reason);
103
+ if (typeof runner?.resumeIsolatedSession !== 'function' || typeof runner?.checkDockerContainerExists !== 'function') {
104
+ return miss(IN_PLACE_SKIP_REASONS.NO_RESUME_SUPPORT);
105
+ }
106
+
107
+ // A container that no longer exists has nothing left to re-enter; `$` would
108
+ // fall back to a full relaunch, which is what the caller does anyway — but
109
+ // through the path that also re-acquires leases.
110
+ const exists = await runner.checkDockerContainerExists(decision.containerName, verbose);
111
+ if (!exists) return miss(IN_PLACE_SKIP_REASONS.CONTAINER_GONE);
112
+
113
+ const result = await runner.resumeIsolatedSession(decision.identifier, { command: plan?.command?.display || null, verbose });
114
+ if (!result?.success) {
115
+ const reason = result?.unsupported ? IN_PLACE_SKIP_REASONS.UNSUPPORTED : IN_PLACE_SKIP_REASONS.REFUSED;
116
+ if (verbose) console.log(`[VERBOSE] In-place resume of ${sessionName} was not possible (${reason}): ${result?.error || 'no reason given'}`);
117
+ return miss(reason);
118
+ }
119
+
120
+ // `docker-snapshot` names the new container `<session>-resume-<attempt>`; the
121
+ // old name stays addressable through upstream's `sessionNameHistory`, but the
122
+ // *new* one is what `$ --status` reports on now, so that is what the monitor
123
+ // has to track. A resume that somehow reports the old name (a `docker-start`
124
+ // race, say) is tracked under the execution UUID instead, which upstream
125
+ // resolves just as well and cannot collide with the dying session's entry.
126
+ const returnedName = result.sessionName && result.sessionName !== sessionName ? result.sessionName : null;
127
+ const sessionId = returnedName || decision.identifier;
128
+ return {
129
+ resumed: true,
130
+ reason: result.mode === RESUME_MODES.DOCKER_SNAPSHOT ? 'resumed-in-place' : `resumed-${result.mode || 'unknown'}`,
131
+ sessionId,
132
+ executionUuid: result.uuid || decision.identifier,
133
+ mode: result.mode || null,
134
+ snapshotImage: result.snapshotImage || null,
135
+ };
136
+ }
@@ -24,6 +24,7 @@ import { readLastSessionIdFromLog, planKilledSessionResume } from './session-res
24
24
  import { resolveOnSessionKillPolicy, resolveSessionKillResumeAttempts, shouldResumeKilledSession, ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
25
25
  import { argvFromSessionArgs } from './session-monitor.kill-sections.lib.mjs';
26
26
  import { formatKillResumeSection } from './session-kill-diagnostics.lib.mjs';
27
+ import { resumeKilledSessionInPlace } from './session-kill-resume.in-place.lib.mjs';
27
28
 
28
29
  /** Field recording how many automatic recovery sessions this session produced. */
29
30
  export const KILL_RESUME_ATTEMPTS_FIELD = 'killRecoveryAttempts';
@@ -65,9 +66,18 @@ export function planKillRecovery({ sessionInfo = {}, logPath = null, killed = fa
65
66
  /**
66
67
  * Start the recovery working session decided by {@link planKillRecovery}.
67
68
  *
68
- * The new session is launched through the same isolation runner the original
69
- * used and is tracked like any other session, so it reports its own completion
70
- * (and, if it is killed too, its own diagnosis) through the normal path.
69
+ * Two ways in, in order of preference:
70
+ *
71
+ * 1. **Same container** (issue #2189) `$ --resume` re-enters the killed
72
+ * session's own filesystem, so the clone, the caches and the half-finished
73
+ * branch survive. See `./session-kill-resume.in-place.lib.mjs` for the
74
+ * cases that are deliberately excluded.
75
+ * 2. **A fresh isolated run** — the original behaviour, used whenever (1) is
76
+ * not available or refuses. Correct, just more expensive.
77
+ *
78
+ * Either way the new session is tracked like any other, so it reports its own
79
+ * completion (and, if it is killed too, its own diagnosis) through the normal
80
+ * path.
71
81
  *
72
82
  * @param {Object} options
73
83
  * @param {string} options.sessionName - The killed session's name
@@ -77,10 +87,10 @@ export function planKillRecovery({ sessionInfo = {}, logPath = null, killed = fa
77
87
  * @param {Function} options.trackSession - Tracker for the new session
78
88
  * @param {Function} [options.persistSnapshot] - Persist the attempt counter
79
89
  * @param {boolean} [options.verbose]
80
- * @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, display: string|null}>}
90
+ * @returns {Promise<{resumed: boolean, reason: string, sessionId: string|null, display: string|null, inPlace: boolean}>}
81
91
  */
82
92
  export async function startKillRecoverySession({ sessionName, sessionInfo, plan, runner, trackSession, persistSnapshot = null, verbose = false } = {}) {
83
- const fail = reason => ({ resumed: false, reason, sessionId: null, display: plan?.command?.display || null });
93
+ const fail = reason => ({ resumed: false, reason, sessionId: null, display: plan?.command?.display || null, inPlace: false });
84
94
  if (!plan?.shouldResume || !plan.command) return fail(plan?.reason || 'no-plan');
85
95
  if (!runner || typeof runner.executeWithIsolation !== 'function' || typeof runner.generateSessionId !== 'function') return fail('no-isolation-runner');
86
96
  if (typeof trackSession !== 'function') return fail('no-tracker');
@@ -88,10 +98,20 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
88
98
  if (!backend) return fail('no-isolation-backend');
89
99
 
90
100
  try {
91
- const newSessionId = runner.generateSessionId();
92
- const tool = sessionInfo?.tool || 'claude';
93
- const result = await runner.executeWithIsolation(sessionInfo?.command || 'solve', plan.command.args, { backend, sessionId: newSessionId, tool, verbose });
94
- if (!result?.success) return fail('start-failed');
101
+ // Preferred path: re-enter the container the work already happened in.
102
+ const inPlace = await resumeKilledSessionInPlace({ sessionName, sessionInfo, plan, runner, verbose });
103
+ let newSessionId = inPlace.sessionId;
104
+ let executionUuid = inPlace.executionUuid;
105
+ let containerFilesystemStartBytes = null;
106
+
107
+ if (!inPlace.resumed) {
108
+ newSessionId = runner.generateSessionId();
109
+ const tool = sessionInfo?.tool || 'claude';
110
+ const result = await runner.executeWithIsolation(sessionInfo?.command || 'solve', plan.command.args, { backend, sessionId: newSessionId, tool, verbose });
111
+ if (!result?.success) return fail('start-failed');
112
+ executionUuid = result.executionUuid || null;
113
+ containerFilesystemStartBytes = Number.isFinite(result.containerFilesystemStartBytes) ? result.containerFilesystemStartBytes : null;
114
+ }
95
115
 
96
116
  trackSession(
97
117
  newSessionId,
@@ -105,9 +125,15 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
105
125
  [KILL_RESUME_ATTEMPTS_FIELD]: plan.attempt,
106
126
  killRecoveryResumed: true,
107
127
  killRecoveryOfSession: sessionName,
128
+ // A resumed execution keeps its UUID; a fresh launch gets a new one, and
129
+ // inheriting the dead session's would make `$ --status` answer about the
130
+ // wrong execution until the monitor happened to correct it.
131
+ executionUuid,
132
+ killRecoveryInPlace: inPlace.resumed,
133
+ killRecoveryResumeMode: inPlace.mode || null,
108
134
  oomEventObservedAt: undefined,
109
135
  dockerBackendGoneFirstSeenAt: undefined,
110
- containerFilesystemStartBytes: Number.isFinite(result.containerFilesystemStartBytes) ? result.containerFilesystemStartBytes : null,
136
+ containerFilesystemStartBytes,
111
137
  },
112
138
  verbose
113
139
  );
@@ -122,9 +148,10 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
122
148
  }
123
149
 
124
150
  if (verbose) {
125
- console.log(`[VERBOSE] Session ${sessionName} was killed; started recovery session ${newSessionId} (attempt ${plan.attempt}/${plan.maxAttempts}): ${plan.command.display}`);
151
+ const how = inPlace.resumed ? `resumed in place (${inPlace.mode || 'unknown mode'})` : `started fresh (in-place resume skipped: ${inPlace.reason})`;
152
+ console.log(`[VERBOSE] Session ${sessionName} was killed; recovery session ${newSessionId} ${how} (attempt ${plan.attempt}/${plan.maxAttempts}): ${plan.command.display}`);
126
153
  }
127
- return { resumed: true, reason: 'started', sessionId: newSessionId, display: plan.command.display };
154
+ return { resumed: true, reason: inPlace.resumed ? inPlace.reason : 'started', sessionId: newSessionId, display: plan.command.display, inPlace: inPlace.resumed };
128
155
  } catch (error) {
129
156
  if (verbose) {
130
157
  console.log(`[VERBOSE] Could not start recovery session for ${sessionName}: ${error?.message || error}`);
@@ -138,7 +165,7 @@ export async function startKillRecoverySession({ sessionName, sessionInfo, plan,
138
165
  * Never throws — a failed recovery must still leave a correct kill report.
139
166
  *
140
167
  * @param {Object} options - See planKillRecovery() and startKillRecoverySession()
141
- * @returns {Promise<{resumed: boolean, reason: string, policy: string, sessionId: string|null, display: string|null, attempt: number, maxAttempts: number}>}
168
+ * @returns {Promise<{resumed: boolean, reason: string, policy: string, sessionId: string|null, display: string|null, attempt: number, maxAttempts: number, inPlace: boolean}>}
142
169
  */
143
170
  export async function recoverKilledSession({ sessionName, sessionInfo, logPath = null, killed = false, env = process.env, runner = null, trackSession = null, persistSnapshot = null, verbose = false, readLastSessionId = readLastSessionIdFromLog } = {}) {
144
171
  let plan;
@@ -146,15 +173,15 @@ export async function recoverKilledSession({ sessionName, sessionInfo, logPath =
146
173
  plan = planKillRecovery({ sessionInfo, logPath, killed, env, verbose, readLastSessionId });
147
174
  } catch (error) {
148
175
  if (verbose) console.log(`[VERBOSE] Could not plan kill recovery for ${sessionName}: ${error?.message || error}`);
149
- return { resumed: false, reason: 'plan-error', policy: ON_SESSION_KILL_RESUME, sessionId: null, display: null, attempt: 0, maxAttempts: 0 };
176
+ return { resumed: false, reason: 'plan-error', policy: ON_SESSION_KILL_RESUME, sessionId: null, display: null, attempt: 0, maxAttempts: 0, inPlace: false };
150
177
  }
151
178
 
152
179
  if (!plan.shouldResume) {
153
- return { resumed: false, reason: plan.reason, policy: plan.policy, sessionId: null, display: plan.command?.display || null, attempt: plan.attempt, maxAttempts: plan.maxAttempts };
180
+ return { resumed: false, reason: plan.reason, policy: plan.policy, sessionId: null, display: plan.command?.display || null, attempt: plan.attempt, maxAttempts: plan.maxAttempts, inPlace: false };
154
181
  }
155
182
 
156
183
  const started = await startKillRecoverySession({ sessionName, sessionInfo, plan, runner, trackSession, persistSnapshot, verbose });
157
- return { resumed: started.resumed, reason: started.reason, policy: plan.policy, sessionId: started.sessionId, display: started.display, attempt: plan.attempt, maxAttempts: plan.maxAttempts };
184
+ return { resumed: started.resumed, reason: started.reason, policy: plan.policy, sessionId: started.sessionId, display: started.display, attempt: plan.attempt, maxAttempts: plan.maxAttempts, inPlace: started.inPlace === true };
158
185
  }
159
186
 
160
187
  /**
@@ -95,6 +95,14 @@ export async function buildKillCompletionSections({ sessionName, sessionInfo, st
95
95
  exitCode,
96
96
  stopRequestedByUser: sessionInfo?.stopRequestedByUser === true,
97
97
  locale,
98
+ // start-command 0.33.0 scans the log tail for fatal markers when the
99
+ // command exits and reports what it found (link-foundation/start#164,
100
+ // #165 — both filed from this issue). Pass it through: `$` saw the exit
101
+ // live, so it can carry evidence our bounded re-read of a huge log may
102
+ // have missed. Absent on an older `$`, which is why the local scan stays.
103
+ reportedMemoryExhausted: statusResult?.memoryExhausted ?? null,
104
+ reportedMemoryExhaustedReason: statusResult?.memoryExhaustedReason ?? null,
105
+ reportedExitReason: statusResult?.exitReason ?? null,
98
106
  });
99
107
 
100
108
  const argv = argvFromSessionArgs(sessionInfo?.args);
@@ -52,7 +52,7 @@ import path from 'node:path';
52
52
  // - `stopRequestedByUser`/`stopRequestedBy` must survive a restart too: with
53
53
  // `--on-session-kill=resume` now the default, forgetting that an operator
54
54
  // asked for the stop would relaunch the very work they cancelled.
55
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args', 'completionNotifiedAt', 'completionExitCode', 'completionStatus', 'lastToolSessionId', 'killRecoveryAttempts', 'killRecoverySessionId', 'killRecoveryOfSession', 'killRecoveryResumed', 'stopRequestedByUser', 'stopRequestedBy', 'onSessionKill', 'resolvedPullRequestUrl'];
55
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args', 'completionNotifiedAt', 'completionExitCode', 'completionStatus', 'lastToolSessionId', 'killRecoveryAttempts', 'killRecoverySessionId', 'killRecoveryOfSession', 'killRecoveryResumed', 'killRecoveryInPlace', 'killRecoveryResumeMode', 'stopRequestedByUser', 'stopRequestedBy', 'onSessionKill', 'resolvedPullRequestUrl'];
56
56
 
57
57
  /**
58
58
  * Resolve the directory durable bot state is written to. Honors
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Clone failure classification and partial-clone cleanup for the solve command.
3
+ *
4
+ * Extracted from solve.repository.lib.mjs, which had grown past the 1350-line
5
+ * warning threshold that scripts/check-file-line-limits.sh enforces (issue
6
+ * #1593, surfaced again by issue #2198). These two helpers are pure decision
7
+ * logic with no dependency on the repository-setup flow around them, so they
8
+ * are the natural seam.
9
+ *
10
+ * Both names stay re-exported from solve.repository.lib.mjs so existing
11
+ * importers - and tests/anonymous-clone-auth-2192.test.mjs,
12
+ * tests/test-issue-1957-incomplete-clone.mjs - are unaffected.
13
+ */
14
+
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ import { isENOSPC } from './lib.mjs';
19
+ import { reportError } from './sentry.lib.mjs';
20
+ // Issue #2192: GitHub throttles *anonymous* git downloads; the wording overlaps
21
+ // the permission, not-found and rate-limit cases, so it is checked before them.
22
+ import { isAnonymousDownloadLimit } from './git-auth-transport.lib.mjs';
23
+
24
+ // Classify git clone errors to determine if they are retryable
25
+ export const classifyCloneError = errorOutput => {
26
+ const output = errorOutput.toLowerCase();
27
+ // Issue #1211: ENOSPC (disk full) errors - NOT retryable, requires user action
28
+ if (isENOSPC(errorOutput) || output.includes('no space left on device') || (output.includes('unable to write file') && output.includes('error')) || output.includes('errno -28')) {
29
+ return { type: 'ENOSPC', retryable: false, description: 'No space left on device' };
30
+ }
31
+
32
+ // Transient server errors (5xx) - typically retryable
33
+ if (output.includes('error: 500') || output.includes('internal server error') || output.includes('error: 502') || output.includes('error: 503') || output.includes('error: 504')) {
34
+ return { type: 'TRANSIENT', retryable: true, description: 'GitHub server error' };
35
+ }
36
+ // Network-related errors - typically retryable
37
+ // Issue #1957: git fetch-pack/sideband disconnects (e.g.
38
+ // "fetch-pack: unexpected disconnect while reading sideband packet",
39
+ // "early EOF", "the remote end hung up unexpectedly", "RPC failed",
40
+ // "index-pack failed") leave an incomplete or missing clone but are transient.
41
+ if (output.includes('connection refused') || output.includes('connection timed out') || output.includes('connection reset') || output.includes('unable to connect') || output.includes('network is unreachable') || output.includes('ssl error') || output.includes('unexpected disconnect') || output.includes('sideband') || output.includes('early eof') || output.includes('remote end hung up') || output.includes('rpc failed') || output.includes('fetch-pack') || output.includes('index-pack failed') || output.includes('transfer closed')) {
42
+ return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
43
+ }
44
+
45
+ // Issue #2192: GitHub refusing an *unauthenticated* download. Retryable, but
46
+ // waiting is not the remedy — the clone has to be authenticated. Checked
47
+ // before PERMISSION/NOT_FOUND/RATE_LIMIT because GitHub's wording ("limiting",
48
+ // "retry later or authenticate") overlaps all three.
49
+ if (isAnonymousDownloadLimit(errorOutput)) {
50
+ return { type: 'ANONYMOUS_RATE_LIMIT', retryable: true, description: 'GitHub is limiting unauthenticated downloads (this clone was not authenticated)' };
51
+ }
52
+
53
+ // Authentication/permission errors - not retryable
54
+ if (output.includes('error: 401') || output.includes('error: 403') || output.includes('authentication failed') || output.includes('permission denied')) {
55
+ return { type: 'PERMISSION', retryable: false, description: 'Authentication or permission error' };
56
+ }
57
+ // Repository not found - not retryable
58
+ if (output.includes('error: 404') || output.includes('not found') || output.includes('repository not found')) {
59
+ return { type: 'NOT_FOUND', retryable: false, description: 'Repository not found' };
60
+ }
61
+
62
+ // Rate limiting - retryable with backoff
63
+ if (output.includes('rate limit') || output.includes('too many requests') || output.includes('api rate limit exceeded')) {
64
+ return { type: 'RATE_LIMIT', retryable: true, description: 'Rate limit exceeded' };
65
+ }
66
+ // Default to retryable for unknown errors
67
+ return { type: 'UNKNOWN', retryable: true, description: 'Unknown error' };
68
+ };
69
+
70
+ // Issue #1957: remove leftovers from an interrupted clone so a retry can start clean.
71
+ // We empty the directory in place (rather than removing it) because the path was
72
+ // created up-front by setupTempDirectory and may be the configured working directory.
73
+ export const cleanPartialClone = async tempDir => {
74
+ try {
75
+ const entries = await fs.readdir(tempDir);
76
+ for (const entry of entries) {
77
+ await fs.rm(path.join(tempDir, entry), { recursive: true, force: true });
78
+ }
79
+ } catch (error) {
80
+ // Directory may not exist yet, or be unreadable — non-fatal; the retry/clone
81
+ // will surface any real problem with a clearer message.
82
+ if (error?.code !== 'ENOENT') {
83
+ reportError(error, { context: 'clean_partial_clone', tempDir, operation: 'empty_directory' });
84
+ }
85
+ }
86
+ };
@@ -28,12 +28,13 @@ const { log, formatAligned } = lib;
28
28
  // Import exit handler
29
29
  import { safeExit } from './exit-handler.lib.mjs';
30
30
  import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
31
+ import { reclaimAgentSnapshotStores } from './agent-snapshot-store.lib.mjs';
31
32
  import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
32
33
  import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
33
34
  import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
34
35
  // Issue #2192: GitHub throttles *anonymous* git downloads; a token must be sent
35
36
  // preemptively (a credential helper is never consulted for a public repository).
36
- import { GIT_AUTH_TRANSPORT_DISABLE, ensureAuthenticatedGitTransport, isAnonymousDownloadLimit } from './git-auth-transport.lib.mjs';
37
+ import { GIT_AUTH_TRANSPORT_DISABLE, ensureAuthenticatedGitTransport } from './git-auth-transport.lib.mjs';
37
38
 
38
39
  // Import GitHub utilities for permission checks
39
40
  const githubLib = await import('./github.lib.mjs');
@@ -931,69 +932,12 @@ Thank you!`;
931
932
 
932
933
  return { repoToClone, forkedRepo, upstreamRemote, prForkOwner: forkOwner };
933
934
  };
934
- // Classify git clone errors to determine if they are retryable
935
- export const classifyCloneError = errorOutput => {
936
- const output = errorOutput.toLowerCase();
937
- // Issue #1211: ENOSPC (disk full) errors - NOT retryable, requires user action
938
- if (lib.isENOSPC(errorOutput) || output.includes('no space left on device') || (output.includes('unable to write file') && output.includes('error')) || output.includes('errno -28')) {
939
- return { type: 'ENOSPC', retryable: false, description: 'No space left on device' };
940
- }
941
-
942
- // Transient server errors (5xx) - typically retryable
943
- if (output.includes('error: 500') || output.includes('internal server error') || output.includes('error: 502') || output.includes('error: 503') || output.includes('error: 504')) {
944
- return { type: 'TRANSIENT', retryable: true, description: 'GitHub server error' };
945
- }
946
- // Network-related errors - typically retryable
947
- // Issue #1957: git fetch-pack/sideband disconnects (e.g.
948
- // "fetch-pack: unexpected disconnect while reading sideband packet",
949
- // "early EOF", "the remote end hung up unexpectedly", "RPC failed",
950
- // "index-pack failed") leave an incomplete or missing clone but are transient.
951
- if (output.includes('connection refused') || output.includes('connection timed out') || output.includes('connection reset') || output.includes('unable to connect') || output.includes('network is unreachable') || output.includes('ssl error') || output.includes('unexpected disconnect') || output.includes('sideband') || output.includes('early eof') || output.includes('remote end hung up') || output.includes('rpc failed') || output.includes('fetch-pack') || output.includes('index-pack failed') || output.includes('transfer closed')) {
952
- return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
953
- }
954
-
955
- // Issue #2192: GitHub refusing an *unauthenticated* download. Retryable, but
956
- // waiting is not the remedy — the clone has to be authenticated. Checked
957
- // before PERMISSION/NOT_FOUND/RATE_LIMIT because GitHub's wording ("limiting",
958
- // "retry later or authenticate") overlaps all three.
959
- if (isAnonymousDownloadLimit(errorOutput)) {
960
- return { type: 'ANONYMOUS_RATE_LIMIT', retryable: true, description: 'GitHub is limiting unauthenticated downloads (this clone was not authenticated)' };
961
- }
962
-
963
- // Authentication/permission errors - not retryable
964
- if (output.includes('error: 401') || output.includes('error: 403') || output.includes('authentication failed') || output.includes('permission denied')) {
965
- return { type: 'PERMISSION', retryable: false, description: 'Authentication or permission error' };
966
- }
967
- // Repository not found - not retryable
968
- if (output.includes('error: 404') || output.includes('not found') || output.includes('repository not found')) {
969
- return { type: 'NOT_FOUND', retryable: false, description: 'Repository not found' };
970
- }
935
+ // Issue #2198: clone-failure classification and partial-clone cleanup live in
936
+ // their own module to keep this file under the line-limit warning threshold.
937
+ // Re-exported so importers keep working.
938
+ import { classifyCloneError, cleanPartialClone } from './solve.clone-errors.lib.mjs';
971
939
 
972
- // Rate limiting - retryable with backoff
973
- if (output.includes('rate limit') || output.includes('too many requests') || output.includes('api rate limit exceeded')) {
974
- return { type: 'RATE_LIMIT', retryable: true, description: 'Rate limit exceeded' };
975
- }
976
- // Default to retryable for unknown errors
977
- return { type: 'UNKNOWN', retryable: true, description: 'Unknown error' };
978
- };
979
-
980
- // Issue #1957: remove leftovers from an interrupted clone so a retry can start clean.
981
- // We empty the directory in place (rather than removing it) because the path was
982
- // created up-front by setupTempDirectory and may be the configured working directory.
983
- export const cleanPartialClone = async tempDir => {
984
- try {
985
- const entries = await fs.readdir(tempDir);
986
- for (const entry of entries) {
987
- await fs.rm(path.join(tempDir, entry), { recursive: true, force: true });
988
- }
989
- } catch (error) {
990
- // Directory may not exist yet, or be unreadable — non-fatal; the retry/clone
991
- // will surface any real problem with a clearer message.
992
- if (error?.code !== 'ENOENT') {
993
- reportError(error, { context: 'clean_partial_clone', tempDir, operation: 'empty_directory' });
994
- }
995
- }
996
- };
940
+ export { classifyCloneError, cleanPartialClone };
997
941
  // Clone repository and set up remotes with retry mechanism
998
942
  export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) => {
999
943
  const maxRetries = 3;
@@ -1340,6 +1284,30 @@ export const checkoutPrBranch = async (tempDir, branchName, prForkRemote, prFork
1340
1284
 
1341
1285
  return checkoutResult;
1342
1286
  };
1287
+ /**
1288
+ * Reclaim orphaned `@link-assistant/agent` snapshot stores (issue #2186).
1289
+ *
1290
+ * Deliberately *not* gated on `--auto-cleanup`: the stores this removes belong to
1291
+ * worktrees that no longer exist, so there is nothing left to restore them into
1292
+ * and keeping them has no debugging value. On a public repository auto-cleanup
1293
+ * defaults to off, and that must not also mean "leak ~5 GB/h of home-directory
1294
+ * state that no Hive Mind disk check can even see".
1295
+ *
1296
+ * Never fatal: this runs while solve is finalizing, after the work is done.
1297
+ */
1298
+ export const cleanupAgentSnapshotStores = async () => {
1299
+ try {
1300
+ const { removed } = await reclaimAgentSnapshotStores({ log: async (message, options) => log(message, options) });
1301
+ if (removed.length > 0) await log(`🧹 Reclaimed ${removed.length} orphaned agent snapshot store(s)`);
1302
+ } catch (cleanupError) {
1303
+ reportError(cleanupError, {
1304
+ context: 'cleanup_agent_snapshot_stores',
1305
+ operation: 'reclaim_agent_snapshots',
1306
+ });
1307
+ await log(`⚠️ Could not reclaim orphaned agent snapshot stores: ${cleanupError.message}`, { level: 'warning' });
1308
+ }
1309
+ };
1310
+
1343
1311
  // Cleanup temporary directory
1344
1312
  export const cleanupTempDirectory = async (tempDir, argv, limitReached) => {
1345
1313
  // Determine if we should skip cleanup
@@ -1370,4 +1338,9 @@ export const cleanupTempDirectory = async (tempDir, argv, limitReached) => {
1370
1338
  const reason = argv.autoCleanupSource === 'repository-visibility-default' ? 'auto-cleanup is off by default for public repositories' : '--no-auto-cleanup';
1371
1339
  await log(`\n📁 Keeping directory (${reason}): ${tempDir}`);
1372
1340
  }
1341
+
1342
+ // Issue #2186: whatever was decided about the workspace above, agent state
1343
+ // whose worktree is already gone is reclaimed. The store belonging to
1344
+ // `tempDir` is untouched while `tempDir` still exists.
1345
+ await cleanupAgentSnapshotStores();
1373
1346
  };
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import v8 from 'node:v8';
4
4
 
5
+ import { measureAgentSnapshotUsage } from './agent-snapshot-store.lib.mjs';
6
+
5
7
  export const RESOURCE_MARKER_PREFIX = '📈 [RESOURCES]';
6
8
 
7
9
  export const RESOURCE_PHASE_SOLVE_START = 'solve_start';
@@ -327,6 +329,7 @@ export function buildResourceMarker(snapshot) {
327
329
  const cpu = s.cpu || {};
328
330
  const memory = s.memory || {};
329
331
  const disk = s.disk || {};
332
+ const agentState = s.agentState || null;
330
333
  return [
331
334
  RESOURCE_MARKER_PREFIX,
332
335
  `phase=${encodeValue(s.phase || 'snapshot')}`,
@@ -353,6 +356,11 @@ export function buildResourceMarker(snapshot) {
353
356
  `mem=${encodeValue(`${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total`)}`,
354
357
  `heap=${encodeValue(formatHeapUsage(memory))}`,
355
358
  `disk=${encodeValue(`${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total`)}`,
359
+ // Issue #2186: only emitted when the agent state was actually measured, so
360
+ // markers written before this existed keep parsing byte-for-byte the same.
361
+ agentState ? `agentStatePath=${encodeValue(agentState.path)}` : null,
362
+ agentState ? numberField('agentStoreCount', finiteNumber(agentState.count)) : null,
363
+ agentState ? numberField('agentStoreBytes', finiteNumber(agentState.bytes)) : null,
356
364
  ]
357
365
  .filter(Boolean)
358
366
  .join(' ');
@@ -404,6 +412,15 @@ function parseMarkerLine(line) {
404
412
  usedPercent: parseNumber(fields.diskUsedPercent),
405
413
  error: fields.error ? decodeURIComponent(fields.error) : null,
406
414
  },
415
+ // Issue #2186: absent in markers produced before agent state was measured,
416
+ // and absent on hosts where the agent data home does not exist.
417
+ agentState: fields.agentStatePath
418
+ ? {
419
+ path: decodeURIComponent(fields.agentStatePath),
420
+ count: parseNumber(fields.agentStoreCount),
421
+ bytes: parseNumber(fields.agentStoreBytes),
422
+ }
423
+ : null,
407
424
  };
408
425
  }
409
426
 
@@ -440,13 +457,18 @@ export function formatResourceSnapshotForLog(snapshot, label = null) {
440
457
  const memory = s.memory || {};
441
458
  const disk = s.disk || {};
442
459
  const lines = [`📈 Resource usage (${phaseLabel}):`, ` CPU load: ${formatNumber(cpu.load1)} ${formatNumber(cpu.load5)} ${formatNumber(cpu.load15)}${Number.isFinite(cpu.cpuCount) ? ` (${cpu.cpuCount} CPUs)` : ''}`, ` Memory: ${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total (${formatBytes(memory.usedBytes)} used)`, ` Process RSS: ${formatBytes(memory.processRssBytes)}, V8 heap: ${formatHeapUsage(memory)}`, ` Disk (${disk.path || '/'}): ${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total${Number.isFinite(disk.usedPercent) ? ` (${disk.usedPercent.toFixed(1)}% used)` : ''}`];
460
+ // Issue #2186: `/` alone hid ~5 GB/h of agent snapshot growth under
461
+ // `~/.local/share`, so name the directory that is actually filling up.
462
+ if (s.agentState && Number(s.agentState.count) > 0) {
463
+ lines.push(` Agent snapshot stores (${s.agentState.path}): ${s.agentState.count} store(s), ${formatBytes(s.agentState.bytes)}${s.agentState.truncated ? '+ (measurement truncated)' : ''}`);
464
+ }
443
465
  if (isHeapUnderPressure(memory)) lines.push(` ⚠️ V8 heap is at ${memory.processHeapUsedPercent.toFixed(1)}% of its limit — a further allocation can abort the process with "JavaScript heap out of memory"`);
444
466
  if (disk.error) lines.push(` Disk probe error: ${disk.error}`);
445
467
  lines.push(buildResourceMarker(snapshot));
446
468
  return lines.join('\n');
447
469
  }
448
470
 
449
- export async function recordResourceSnapshot({ phase, log, diskPath = '/', label = null, capture = captureResourceSnapshot, logExecutionContext = false, detectContext = detectExecutionContext } = {}) {
471
+ export async function recordResourceSnapshot({ phase, log, diskPath = '/', label = null, capture = captureResourceSnapshot, logExecutionContext = false, detectContext = detectExecutionContext, measureAgentState = measureAgentSnapshotUsage } = {}) {
450
472
  if (typeof log !== 'function') return null;
451
473
  try {
452
474
  // Issue #2001: optionally report the execution context (host vs container)
@@ -459,6 +481,17 @@ export async function recordResourceSnapshot({ phase, log, diskPath = '/', label
459
481
  }
460
482
  }
461
483
  const snapshot = capture({ phase, diskPath });
484
+ // Issue #2186: agent state lives outside `diskPath` and needs the file
485
+ // system, so it is measured separately and stays best-effort — a missing or
486
+ // unreadable agent data home must never cost us the rest of the snapshot.
487
+ if (typeof measureAgentState === 'function') {
488
+ try {
489
+ const agentState = await measureAgentState();
490
+ if (agentState && Number(agentState.count) > 0) snapshot.agentState = agentState;
491
+ } catch {
492
+ /* agent state is a diagnostic extra, not a precondition */
493
+ }
494
+ }
462
495
  await log(formatResourceSnapshotForLog(snapshot, label));
463
496
  return snapshot;
464
497
  } catch (error) {
@@ -35,6 +35,9 @@ const {
35
35
  } = githubLib;
36
36
 
37
37
  // Import git-related functions for identity validation and repair
38
+ // Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
39
+ const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
40
+
38
41
  const gitLib = await import('./git.lib.mjs');
39
42
  const { checkGitIdentity, repairGitIdentity } = gitLib;
40
43
 
@@ -83,6 +86,16 @@ export const validateGitHubUrl = issueUrl => {
83
86
  return { isValid: false, isIssueUrl: null, isPrUrl: null };
84
87
  }
85
88
 
89
+ // Issue #2194: the URL needed repair before it could be understood. Say so up
90
+ // front, so a wrong guess is visible before a whole session runs against the
91
+ // wrong entity.
92
+ if (hasNotableRepair(parsedUrl.repairs)) {
93
+ console.error('ℹ️ Repaired the GitHub URL before solving:');
94
+ console.error(` You typed: ${revealHiddenCharacters(issueUrl)}`);
95
+ console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
96
+ console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
97
+ }
98
+
86
99
  // Check if it's an issue or pull request
87
100
  const isIssueUrl = parsedUrl.type === 'issue';
88
101
  const isPrUrl = parsedUrl.type === 'pull';
@@ -102,9 +115,12 @@ export const validateGitHubUrl = issueUrl => {
102
115
  isIssueUrl,
103
116
  isPrUrl,
104
117
  normalizedUrl: parsedUrl.normalized,
118
+ canonicalUrl: parsedUrl.canonical || parsedUrl.normalized,
105
119
  owner: parsedUrl.owner,
106
120
  repo: parsedUrl.repo,
107
121
  number: parsedUrl.number,
122
+ repairs: parsedUrl.repairs || [],
123
+ recovered: Boolean(parsedUrl.recovered),
108
124
  };
109
125
  };
110
126