@link-assistant/hive-mind 2.16.0 → 2.17.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 +106 -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/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/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/start-command-cli.lib.mjs +60 -0
- package/src/telegram-bot.mjs +31 -91
- package/src/telegram-overrides-validation.lib.mjs +73 -0
- package/src/working-session-summary.lib.mjs +1 -1
|
@@ -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) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared access to the `$` CLI (start-command, link-foundation/start).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/isolation-runner.lib.mjs (issue #2189) so the resume/attach
|
|
5
|
+
* wrappers added for `start-command@0.33.0` can reach the same lazily-loaded
|
|
6
|
+
* `command-stream` `$` and the same PATH lookup without importing the runner —
|
|
7
|
+
* which would create a cycle — and without duplicating either.
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/link-foundation/start
|
|
10
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
14
|
+
|
|
15
|
+
/** The message every wrapper reports when `$` is not installed. */
|
|
16
|
+
export const START_COMMAND_MISSING_ERROR = '`$` (start-command) binary not found on PATH. Install link-foundation/start.';
|
|
17
|
+
|
|
18
|
+
let commandStreamDollarPromise = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazily load `command-stream`'s `$` template tag.
|
|
22
|
+
*
|
|
23
|
+
* Cached across calls; a failed load clears the cache so a transient failure
|
|
24
|
+
* (a cold `use-m` fetch, say) does not poison every later call.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<Function>} The `$` template tag
|
|
27
|
+
*/
|
|
28
|
+
export async function getCommandStreamDollar() {
|
|
29
|
+
if (!commandStreamDollarPromise) {
|
|
30
|
+
commandStreamDollarPromise = (async () => {
|
|
31
|
+
if (typeof globalThis.use === 'undefined') {
|
|
32
|
+
await ensureUseM();
|
|
33
|
+
}
|
|
34
|
+
const { $ } = await globalThis.use('command-stream');
|
|
35
|
+
return $;
|
|
36
|
+
})();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return await commandStreamDollarPromise;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
commandStreamDollarPromise = null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find the `$` CLI binary path.
|
|
48
|
+
*
|
|
49
|
+
* @returns {Promise<string|null>} Path to the `$` binary, or null when absent
|
|
50
|
+
*/
|
|
51
|
+
export async function findStartCommandBinary() {
|
|
52
|
+
try {
|
|
53
|
+
const $ = await getCommandStreamDollar();
|
|
54
|
+
const result = await $({ mirror: false })`which $`;
|
|
55
|
+
const resolved = result.stdout?.toString().trim() || '';
|
|
56
|
+
return resolved || null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -27,10 +27,10 @@ const yargs = getLinoYargsFactory();
|
|
|
27
27
|
const { createYargsConfig: createTelegramYargsConfig } = await import('./telegram.config.lib.mjs');
|
|
28
28
|
const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
|
|
29
29
|
const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
|
|
30
|
-
const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
|
|
31
30
|
const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
|
|
32
31
|
const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
|
|
33
32
|
const { mergeArgsWithOverrides } = await import('./args-overrides.lib.mjs'); // issue #2085
|
|
33
|
+
const { validateCommandOverrides } = await import('./telegram-overrides-validation.lib.mjs'); // issue #2198
|
|
34
34
|
const config = createTelegramYargsConfig(yargs(hideBin(process.argv))).parse();
|
|
35
35
|
|
|
36
36
|
// Configuration priority: CLI option > --configuration LINO > .lenv > .env
|
|
@@ -109,96 +109,26 @@ if (ISOLATION_BACKEND) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Validate solve overrides early using
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
stderrBuffer.push(chunk);
|
|
128
|
-
return true;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
// Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
|
|
133
|
-
const testYargs = createSolveYargsConfig(yargs());
|
|
134
|
-
// Suppress yargs error output - we'll handle errors ourselves
|
|
135
|
-
testYargs
|
|
136
|
-
.exitProcess(false)
|
|
137
|
-
.showHelpOnFail(false)
|
|
138
|
-
.fail((msg, err) => {
|
|
139
|
-
if (err) throw err;
|
|
140
|
-
throw new Error(msg);
|
|
141
|
-
});
|
|
142
|
-
await testYargs.parse(testArgs);
|
|
143
|
-
// Issue #1482: Validate --base-branch in overrides early
|
|
144
|
-
const overrideBranchError = validateBranchInArgs(solveOverridesForValidation);
|
|
145
|
-
if (overrideBranchError) throw new Error(overrideBranchError);
|
|
146
|
-
console.log('✅ Solve overrides validated successfully');
|
|
147
|
-
} finally {
|
|
148
|
-
// Restore stderr
|
|
149
|
-
process.stderr.write = originalStderrWrite;
|
|
150
|
-
}
|
|
151
|
-
} catch (error) {
|
|
152
|
-
const enhancedError = enhanceUnknownArgumentError(error, createSolveYargsConfig(yargs()));
|
|
153
|
-
console.error(`❌ Invalid solve-overrides: ${enhancedError.message || String(enhancedError)}`);
|
|
154
|
-
console.error(` Overrides: ${solveOverrides.join(' ')}`);
|
|
155
|
-
process.exit(1);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
// Validate hive overrides early using hive's yargs config
|
|
159
|
-
// Only validate if hive command is enabled
|
|
160
|
-
if (hiveEnabled && hiveOverrides.length > 0) {
|
|
161
|
-
console.log('Validating hive overrides...');
|
|
162
|
-
try {
|
|
163
|
-
const { backend: hiveOverrideIsolation, filteredArgs: hiveOverridesForValidation } = extractIsolationFromArgs(hiveOverrides);
|
|
164
|
-
if (hiveOverrideIsolation && !isValidPerCommandIsolation(hiveOverrideIsolation)) {
|
|
165
|
-
throw new Error(`Invalid --isolation value '${hiveOverrideIsolation}'. Must be: screen, tmux, or docker`);
|
|
166
|
-
}
|
|
167
|
-
// Add a dummy URL as the first argument (required positional for hive)
|
|
168
|
-
const testArgs = ['https://github.com/test/test', ...hiveOverridesForValidation];
|
|
169
|
-
|
|
170
|
-
// Temporarily suppress stderr to avoid yargs error output during validation
|
|
171
|
-
const originalStderrWrite = process.stderr.write;
|
|
172
|
-
const stderrBuffer = [];
|
|
173
|
-
process.stderr.write = chunk => {
|
|
174
|
-
stderrBuffer.push(chunk);
|
|
175
|
-
return true;
|
|
176
|
-
};
|
|
177
|
-
try {
|
|
178
|
-
// Use .parse() instead of yargs(args).parseSync() to ensure .strict() mode works
|
|
179
|
-
const testYargs = createHiveYargsConfig(yargs());
|
|
180
|
-
// Suppress yargs error output - we'll handle errors ourselves
|
|
181
|
-
testYargs
|
|
182
|
-
.exitProcess(false)
|
|
183
|
-
.showHelpOnFail(false)
|
|
184
|
-
.fail((msg, err) => {
|
|
185
|
-
if (err) throw err;
|
|
186
|
-
throw new Error(msg);
|
|
187
|
-
});
|
|
188
|
-
await testYargs.parse(testArgs);
|
|
189
|
-
const overrideBranchError = validateBranchInArgs(hiveOverridesForValidation); // Issue #1482
|
|
190
|
-
if (overrideBranchError) throw new Error(overrideBranchError);
|
|
191
|
-
console.log('✅ Hive overrides validated successfully');
|
|
192
|
-
} finally {
|
|
193
|
-
// Restore stderr
|
|
194
|
-
process.stderr.write = originalStderrWrite;
|
|
195
|
-
}
|
|
196
|
-
} catch (error) {
|
|
197
|
-
const enhancedError = enhanceUnknownArgumentError(error, createHiveYargsConfig(yargs()));
|
|
198
|
-
console.error(`❌ Invalid hive-overrides: ${enhancedError.message || String(enhancedError)}`);
|
|
199
|
-
console.error(` Overrides: ${hiveOverrides.join(' ')}`);
|
|
200
|
-
process.exit(1);
|
|
112
|
+
// Validate solve/hive overrides early, using each command's own yargs config, so
|
|
113
|
+
// a bad flag is rejected at startup instead of when the first session spawns
|
|
114
|
+
// (issue #1209). The shared implementation lives in
|
|
115
|
+
// telegram-overrides-validation.lib.mjs (issue #2198).
|
|
116
|
+
for (const { enabled, label, flag, overrides, createYargsConfig, dummyUrl } of [
|
|
117
|
+
{ enabled: solveEnabled, label: 'Solve', flag: 'solve-overrides', overrides: solveOverrides, createYargsConfig: createSolveYargsConfig, dummyUrl: 'https://github.com/test/test/issues/1' },
|
|
118
|
+
{ enabled: hiveEnabled, label: 'Hive', flag: 'hive-overrides', overrides: hiveOverrides, createYargsConfig: createHiveYargsConfig, dummyUrl: 'https://github.com/test/test' },
|
|
119
|
+
]) {
|
|
120
|
+
if (!enabled || overrides.length === 0) continue;
|
|
121
|
+
|
|
122
|
+
console.log(`Validating ${label.toLowerCase()} overrides...`);
|
|
123
|
+
const result = await validateCommandOverrides({ overrides, createYargsConfig, yargs, dummyUrl });
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
console.log(`✅ ${label} overrides validated successfully`);
|
|
126
|
+
continue;
|
|
201
127
|
}
|
|
128
|
+
|
|
129
|
+
console.error(`❌ Invalid ${flag}: ${result.message}`);
|
|
130
|
+
console.error(` Overrides: ${overrides.join(' ')}`);
|
|
131
|
+
process.exit(1);
|
|
202
132
|
}
|
|
203
133
|
|
|
204
134
|
// Handle dry-run mode - exit after validation WITHOUT loading heavy dependencies
|
|
@@ -1252,7 +1182,17 @@ async function onBotLaunched() {
|
|
|
1252
1182
|
// the monitor resumes watching — and finally reports any that were killed while
|
|
1253
1183
|
// the bot was down. Done before starting the monitor so the first tick already
|
|
1254
1184
|
// sees the resumed sessions.
|
|
1255
|
-
|
|
1185
|
+
// Issue #2189: before replaying the durable store, let `$ --resume-all`
|
|
1186
|
+
// settle the isolation backend's own record — a restart orphans the detached
|
|
1187
|
+
// completion watchers, so an execution that ended while the bot was down has
|
|
1188
|
+
// nobody left to write its exit.
|
|
1189
|
+
await resumeSessionsOnLaunch({
|
|
1190
|
+
resumeTrackedSessions,
|
|
1191
|
+
reconcileIsolationSessions: typeof isolationRunner?.resumeAllIsolationSessions === 'function' ? ({ verbose }) => isolationRunner.resumeAllIsolationSessions({ verbose }) : null,
|
|
1192
|
+
botStartTime: BOT_START_TIME,
|
|
1193
|
+
verbose: VERBOSE,
|
|
1194
|
+
logger: botLogger,
|
|
1195
|
+
});
|
|
1256
1196
|
|
|
1257
1197
|
startSessionMonitoringOnce();
|
|
1258
1198
|
startFormalAiMaintenanceOnce();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Early validation of --solve-overrides / --hive-overrides for the Telegram bot.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/telegram-bot.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). The solve and hive blocks were
|
|
7
|
+
* near-identical copies of each other, so folding them into one parameterised
|
|
8
|
+
* function removes the duplication as well as the lines.
|
|
9
|
+
*
|
|
10
|
+
* Behaviour is unchanged and pinned by tests/test-telegram-bot-dry-run.mjs and
|
|
11
|
+
* tests/test-telegram-bot-configuration-isolation-links-notation.mjs, which
|
|
12
|
+
* assert on the exact console output the caller prints.
|
|
13
|
+
*
|
|
14
|
+
* The function reports rather than exits: the caller owns the messages and the
|
|
15
|
+
* exit code, which keeps this module testable in-process.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
|
|
19
|
+
import { validateBranchInArgs } from './solve.branch.lib.mjs';
|
|
20
|
+
import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses `overrides` through the real yargs config of the target command, so an
|
|
24
|
+
* unknown or malformed flag is rejected at bot startup rather than when the
|
|
25
|
+
* first command spawns a session (issue #1209).
|
|
26
|
+
*
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {string[]} options.overrides Override arguments to validate.
|
|
29
|
+
* @param {Function} options.createYargsConfig The command's yargs config factory.
|
|
30
|
+
* @param {Function} options.yargs A yargs factory (bound to Links Notation).
|
|
31
|
+
* @param {string} options.dummyUrl Stand-in for the command's required positional.
|
|
32
|
+
* @returns {Promise<{ok: true} | {ok: false, message: string}>}
|
|
33
|
+
*/
|
|
34
|
+
export const validateCommandOverrides = async ({ overrides, createYargsConfig, yargs, dummyUrl }) => {
|
|
35
|
+
try {
|
|
36
|
+
const { backend: overrideIsolation, filteredArgs } = extractIsolationFromArgs(overrides);
|
|
37
|
+
if (overrideIsolation && !isValidPerCommandIsolation(overrideIsolation)) {
|
|
38
|
+
throw new Error(`Invalid --isolation value '${overrideIsolation}'. Must be: screen, tmux, or docker`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const testArgs = [dummyUrl, ...filteredArgs];
|
|
42
|
+
|
|
43
|
+
// yargs writes its own diagnostics to stderr before the .fail() handler
|
|
44
|
+
// runs; suppress them so the caller's single message is what the operator
|
|
45
|
+
// sees.
|
|
46
|
+
const originalStderrWrite = process.stderr.write;
|
|
47
|
+
process.stderr.write = () => true;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// .parse() rather than parseSync() so .strict() mode is honoured.
|
|
51
|
+
const testYargs = createYargsConfig(yargs());
|
|
52
|
+
testYargs
|
|
53
|
+
.exitProcess(false)
|
|
54
|
+
.showHelpOnFail(false)
|
|
55
|
+
.fail((msg, err) => {
|
|
56
|
+
if (err) throw err;
|
|
57
|
+
throw new Error(msg);
|
|
58
|
+
});
|
|
59
|
+
await testYargs.parse(testArgs);
|
|
60
|
+
|
|
61
|
+
// Issue #1482: --base-branch inside the overrides is validated too.
|
|
62
|
+
const overrideBranchError = validateBranchInArgs(filteredArgs);
|
|
63
|
+
if (overrideBranchError) throw new Error(overrideBranchError);
|
|
64
|
+
} finally {
|
|
65
|
+
process.stderr.write = originalStderrWrite;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { ok: true };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const enhancedError = enhanceUnknownArgumentError(error, createYargsConfig(yargs()));
|
|
71
|
+
return { ok: false, message: enhancedError.message || String(enhancedError) };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -64,7 +64,7 @@ export const formatWorkingSessionSummaryMarkdown = text => {
|
|
|
64
64
|
let inFence = false;
|
|
65
65
|
let previousNonEmpty = '';
|
|
66
66
|
|
|
67
|
-
for (let index = 0; index < lines.length;
|
|
67
|
+
for (let index = 0; index < lines.length;) {
|
|
68
68
|
const line = lines[index];
|
|
69
69
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
70
70
|
inFence = !inFence;
|