@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.
- package/CHANGELOG.md +125 -0
- package/README.hi.md +12 -0
- package/README.md +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +24 -12
- package/package.json +24 -17
- package/src/agent-snapshot-store.lib.mjs +252 -0
- package/src/agent.lib.mjs +25 -25
- package/src/agent.version-gates.lib.mjs +73 -0
- package/src/bot-lifecycle.lib.mjs +55 -4
- package/src/cleanup.mjs +57 -3
- package/src/disk-guard.lib.mjs +21 -1
- package/src/formal-ai-version.lib.mjs +10 -6
- package/src/github-url-parser.lib.mjs +80 -23
- package/src/github-url-recovery.lib.mjs +514 -0
- package/src/hive.mjs +10 -0
- package/src/instrument.mjs +12 -14
- package/src/instrument.sanitize.lib.mjs +52 -0
- package/src/isolation-runner.lib.mjs +56 -33
- package/src/isolation-runner.parsers.lib.mjs +29 -3
- package/src/isolation-runner.resume.lib.mjs +263 -0
- package/src/locales/en.lino +7 -0
- package/src/locales/hi.lino +7 -0
- package/src/locales/ru.lino +7 -0
- package/src/locales/zh.lino +7 -0
- package/src/pull-request-changes.lib.mjs +1 -1
- package/src/session-kill-diagnostics.lib.mjs +47 -5
- package/src/session-kill-resume.in-place.lib.mjs +136 -0
- package/src/session-kill-resume.lib.mjs +43 -16
- package/src/session-monitor.kill-sections.lib.mjs +8 -0
- package/src/session-store.lib.mjs +1 -1
- package/src/solve.clone-errors.lib.mjs +86 -0
- package/src/solve.repository.lib.mjs +36 -63
- package/src/solve.resource-diagnostics.lib.mjs +34 -1
- package/src/solve.validation.lib.mjs +16 -0
- package/src/start-command-cli.lib.mjs +60 -0
- package/src/telegram-bot.mjs +51 -95
- package/src/telegram-overrides-validation.lib.mjs +73 -0
- 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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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
|
|