@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
package/src/agent.lib.mjs CHANGED
@@ -349,32 +349,14 @@ export const mapModelToId = model => {
349
349
  return agentModels[model] || model;
350
350
  };
351
351
 
352
- export const MIN_AGENT_LIVE_INPUT_VERSION = '0.24.1';
352
+ // The Agent CLI version floors live in their own module (issue #2198: this
353
+ // file crossed the 1350-line warning threshold). `validateAgentConnection`
354
+ // below reads them directly, and they are re-exported so importers of
355
+ // `agent.lib.mjs` -- tests/test-codex-support.mjs and
356
+ // tests/test-issue-2186-agent-snapshot-leak.mjs among them -- do not move.
357
+ import { MIN_AGENT_LIVE_INPUT_VERSION, MIN_AGENT_FORMAL_AI_VERSION, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION, getAgentCliVersion } from './agent.version-gates.lib.mjs';
353
358
 
354
- export const getAgentCliVersion = versionOutput => {
355
- return semver.clean(versionOutput) || semver.coerce(versionOutput)?.version || null;
356
- };
357
-
358
- export const agentCliSupportsLiveInput = versionOutput => {
359
- const version = getAgentCliVersion(versionOutput);
360
- return !!version && semver.gte(version, MIN_AGENT_LIVE_INPUT_VERSION);
361
- };
362
-
363
- /**
364
- * Agent only fails closed on a `--model` argv it cannot parse from js-0.25.8
365
- * onwards (link-assistant/agent#293, fixed by PR #294): earlier releases logged
366
- * a CRITICAL record and then answered with their *default* model. Issue #2146
367
- * requires Formal AI to be the only model a task can reach, and a guard that
368
- * reads the CRITICAL record can only stop the run after Agent has already
369
- * decided, so a Formal AI task refuses to start below this release.
370
- */
371
- export const MIN_AGENT_FORMAL_AI_VERSION = '0.25.8';
372
-
373
- /** True when this Agent CLI aborts instead of silently picking another model. */
374
- export const agentCliFailsClosedOnModelMismatch = versionOutput => {
375
- const version = getAgentCliVersion(versionOutput);
376
- return !!version && semver.gte(version, MIN_AGENT_FORMAL_AI_VERSION);
377
- };
359
+ export { MIN_AGENT_LIVE_INPUT_VERSION, MIN_AGENT_FORMAL_AI_VERSION, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION, getAgentCliVersion, agentCliSupportsLiveInput, agentCliFailsClosedOnModelMismatch, agentCliPrunesOrphanSnapshots } from './agent.version-gates.lib.mjs';
378
360
 
379
361
  // Function to validate Agent connection
380
362
  export const validateAgentConnection = async (model = defaultModels.agent, options = {}) => {
@@ -411,6 +393,24 @@ export const validateAgentConnection = async (model = defaultModels.agent, optio
411
393
  }
412
394
  }
413
395
 
396
+ if (!agentVersion || !semver.gte(agentVersion, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION)) {
397
+ await log(`❌ Hive Mind requires @link-assistant/agent >= ${MIN_AGENT_SNAPSHOT_HYGIENE_VERSION}`, { level: 'error' });
398
+ await log(' Older releases write a full, standalone copy of the repository into', { level: 'error' });
399
+ await log(' ~/.local/share/link-assistant-agent/snapshot/ per project and never reclaim it', { level: 'error' });
400
+ await log(' (link-assistant/agent#298): issue #2186 lost 31 GB to 115 orphaned stores in one task.', { level: 'error' });
401
+ if (agentVersion) {
402
+ await log(` Installed Agent CLI version: ${agentVersion}`, { level: 'error' });
403
+ } else {
404
+ await log(' Could not determine the installed Agent CLI version.', { level: 'error' });
405
+ }
406
+ await log(' Update with: bun install -g @link-assistant/agent@latest', { level: 'error' });
407
+ return false;
408
+ }
409
+
410
+ // The two capability gates below are subsumed by the floor above while it
411
+ // stays the highest of the three. They are kept because each states an
412
+ // independent contract with its own diagnosis, and any one of the floors
413
+ // can move on its own.
414
414
  if (requireLiveInput && (!agentVersion || !semver.gte(agentVersion, MIN_AGENT_LIVE_INPUT_VERSION))) {
415
415
  await log(`❌ Agent live stream-json input requires @link-assistant/agent >= ${MIN_AGENT_LIVE_INPUT_VERSION}`, { level: 'error' });
416
416
  if (agentVersion) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Version floors for the Agent CLI, and the predicates that read them.
3
+ *
4
+ * Each floor is the first Agent release in which a behaviour Hive Mind depends
5
+ * on actually holds; below it the CLI does something subtly wrong rather than
6
+ * failing, so the caller refuses to start instead of trusting the result. The
7
+ * comments on each constant record which upstream issue moved the floor.
8
+ *
9
+ * Extracted from `src/agent.lib.mjs` when issue #2186 pushed that file past the
10
+ * 1350-line warning threshold enforced by `scripts/check-file-line-limits.sh`.
11
+ * `agent.lib.mjs` re-exports every name here, so importers are unaffected.
12
+ *
13
+ * @see https://github.com/link-assistant/hive-mind/issues/2198
14
+ */
15
+
16
+ import semver from 'semver';
17
+
18
+ export const MIN_AGENT_LIVE_INPUT_VERSION = '0.24.1';
19
+
20
+ export const getAgentCliVersion = versionOutput => {
21
+ // `agent --version` can come back as `undefined` when the probe times out or
22
+ // the binary writes nothing to stdout, and `semver.clean(undefined)` throws.
23
+ // The floors below must answer "unknown", not blow up, so the caller reports
24
+ // the missing version instead of a `TypeError`.
25
+ const text = versionOutput == null ? '' : String(versionOutput);
26
+ return semver.clean(text) || semver.coerce(text)?.version || null;
27
+ };
28
+
29
+ export const agentCliSupportsLiveInput = versionOutput => {
30
+ const version = getAgentCliVersion(versionOutput);
31
+ return !!version && semver.gte(version, MIN_AGENT_LIVE_INPUT_VERSION);
32
+ };
33
+
34
+ /**
35
+ * Agent only fails closed on a `--model` argv it cannot parse from js-0.25.8
36
+ * onwards (link-assistant/agent#293, fixed by PR #294): earlier releases logged
37
+ * a CRITICAL record and then answered with their *default* model. Issue #2146
38
+ * requires Formal AI to be the only model a task can reach, and a guard that
39
+ * reads the CRITICAL record can only stop the run after Agent has already
40
+ * decided, so a Formal AI task refuses to start below this release.
41
+ */
42
+ export const MIN_AGENT_FORMAL_AI_VERSION = '0.25.8';
43
+
44
+ /** True when this Agent CLI aborts instead of silently picking another model. */
45
+ export const agentCliFailsClosedOnModelMismatch = versionOutput => {
46
+ const version = getAgentCliVersion(versionOutput);
47
+ return !!version && semver.gte(version, MIN_AGENT_FORMAL_AI_VERSION);
48
+ };
49
+
50
+ /**
51
+ * Agent keeps a rollback snapshot per project under
52
+ * `$XDG_DATA_HOME/link-assistant-agent/snapshot/<project id>`, and that project
53
+ * id is the worktree's *root commit*. Before js-0.26.1 the store was a
54
+ * standalone object database — no `objects/info/alternates` — and nothing ever
55
+ * removed it, so any harness that runs the agent inside a throwaway `git init`
56
+ * checkout minted a brand-new full copy of the repository per invocation and
57
+ * never reclaimed one. Issue #2186 measured 115 orphaned stores / 31 GB in a
58
+ * single 9.5 h task (~5 GB/h, every recorded worktree already deleted) while
59
+ * every Hive Mind disk check — the 10 GB pre-flight gate, `disk-guard`,
60
+ * `hive-cleanup` — reported a healthy workspace, because all of them only look
61
+ * at `/tmp`. link-assistant/agent#298 (PR #300, shipped in 0.26.1) shares the
62
+ * repository's objects through `objects/info/alternates` and prunes projects
63
+ * whose recorded worktree no longer exists, which is what makes an unattended
64
+ * multi-hour run bounded. Older releases are refused rather than left to fill
65
+ * the disk.
66
+ */
67
+ export const MIN_AGENT_SNAPSHOT_HYGIENE_VERSION = '0.26.1';
68
+
69
+ /** True when this Agent CLI shares snapshot objects and prunes dead projects. */
70
+ export const agentCliPrunesOrphanSnapshots = versionOutput => {
71
+ const version = getAgentCliVersion(versionOutput);
72
+ return !!version && semver.gte(version, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION);
73
+ };
@@ -83,6 +83,52 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
83
83
  };
84
84
  }
85
85
 
86
+ /**
87
+ * Reconcile the isolation backend's own view of still-running executions.
88
+ *
89
+ * Issue #2189: the detached-docker completion watchers are children of the
90
+ * process that launched them, so a bot restart leaves every running container
91
+ * unsupervised — its exit is never written to the log footer, which is one of
92
+ * the ways the reported session stayed in limbo. `$ --resume-all`
93
+ * (start-command >= 0.33.0) re-attaches a watcher to what is still alive and
94
+ * finalizes what died meanwhile; it starts no work on its own.
95
+ *
96
+ * Run *before* the durable store is replayed so the first monitor tick reads
97
+ * settled state rather than racing upstream's reconciliation. An older `$`
98
+ * without the verb, or no isolation at all, is a no-op — never an error.
99
+ *
100
+ * @returns {Promise<{attempted: boolean, reconciled: number, reattached: number, running: number, unsupported: boolean, error: string|null}>}
101
+ */
102
+ async function reconcileIsolationExecutions({ reconcileIsolationSessions, verbose, logger, consoleImpl }) {
103
+ const idle = { attempted: false, reconciled: 0, reattached: 0, running: 0, unsupported: false, error: null };
104
+ if (typeof reconcileIsolationSessions !== 'function') return idle;
105
+ try {
106
+ const result = await reconcileIsolationSessions({ verbose });
107
+ const executions = Array.isArray(result?.executions) ? result.executions : [];
108
+ const count = action => executions.filter(entry => entry?.action === action).length;
109
+ const summary = {
110
+ attempted: true,
111
+ reconciled: count('reconciled'),
112
+ reattached: count('reattached'),
113
+ running: count('running'),
114
+ unsupported: result?.unsupported === true,
115
+ error: result?.success === false && result?.unsupported !== true ? result?.error || 'unknown error' : null,
116
+ };
117
+ if (summary.reconciled > 0 || summary.reattached > 0) {
118
+ consoleImpl.log(`♻️ Reconciled ${executions.length} isolated execution(s) with the isolation backend (${summary.reattached} re-attached, ${summary.reconciled} finalized after running unsupervised)`);
119
+ } else if (verbose) {
120
+ consoleImpl.log(`[VERBOSE] resume-all: ${summary.unsupported ? 'this `$` build has no --resume-all; skipped' : `${executions.length} execution(s), nothing to re-attach`}`);
121
+ }
122
+ logger?.event?.('isolation_executions_reconciled', summary);
123
+ return summary;
124
+ } catch (error) {
125
+ // Startup reconciliation is best effort by construction: it must never be
126
+ // able to stop the bot from coming up.
127
+ consoleImpl.error(`[telegram-bot] Could not reconcile isolated executions: ${error?.message || error}`);
128
+ return { ...idle, attempted: true, error: error?.message || String(error) };
129
+ }
130
+ }
131
+
86
132
  /**
87
133
  * Resume sessions left tracked by a previous run (requirements #2/#4).
88
134
  *
@@ -92,9 +138,14 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
92
138
  * `sessions_resumed` event either way and never throws: a resume failure must
93
139
  * not stop the bot from coming up.
94
140
  *
95
- * @returns {Promise<{ resumed: any[], skipped: any[], error?: Error }>}
141
+ * Issue #2189 added the step before it: `reconcileIsolationSessions`
142
+ * (`$ --resume-all`) settles the isolation backend's own record of what is
143
+ * still running, so the replayed sessions are matched against the truth.
144
+ *
145
+ * @returns {Promise<{ resumed: any[], skipped: any[], reconciliation: object, error?: Error }>}
96
146
  */
97
- export async function resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTime, verbose = false, logger, consoleImpl = console } = {}) {
147
+ export async function resumeSessionsOnLaunch({ resumeTrackedSessions, reconcileIsolationSessions = null, botStartTime, verbose = false, logger, consoleImpl = console } = {}) {
148
+ const reconciliation = await reconcileIsolationExecutions({ reconcileIsolationSessions, verbose, logger, consoleImpl });
98
149
  try {
99
150
  const { resumed, skipped } = await resumeTrackedSessions({ botStartTime, verbose });
100
151
  if (resumed.length > 0) {
@@ -105,11 +156,11 @@ export async function resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTi
105
156
  skipped: skipped.length,
106
157
  sessions: resumed.map(r => r.sessionName),
107
158
  });
108
- return { resumed, skipped };
159
+ return { resumed, skipped, reconciliation };
109
160
  } catch (error) {
110
161
  consoleImpl.error(`[telegram-bot] Failed to resume tracked sessions: ${error.message}`);
111
162
  logger.error('Failed to resume tracked sessions', { error: error.message });
112
- return { resumed: [], skipped: [], error };
163
+ return { resumed: [], skipped: [], reconciliation, error };
113
164
  }
114
165
  }
115
166
 
package/src/cleanup.mjs CHANGED
@@ -39,6 +39,7 @@ import { isConfirmationYes, readConfirmationLine } from './confirmation.lib.mjs'
39
39
  import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary, DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE, describeDockerIsolationReason, formatDockerIsolationContainerSummary, normalizeDockerIsolationCleanupMode, planDockerIsolationCleanup } from './cleanup.lib.mjs';
40
40
  import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees, listDockerIsolationContainers, removeDockerContainer } from './cleanup.os.lib.mjs';
41
41
  import { formatProcessDebugReport } from './process-debug.lib.mjs';
42
+ import { classifyAgentSnapshotStores, describeAgentSnapshotReason, getAgentDataHome } from './agent-snapshot-store.lib.mjs';
42
43
  import { setupStdioLogInterceptor } from './lib.mjs';
43
44
  import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
44
45
 
@@ -138,6 +139,11 @@ System / Ubuntu cleanup (opt-in):
138
139
  --system Shorthand for --apt --journal --npm
139
140
  --sudo Prefix package-manager commands with sudo
140
141
 
142
+ Agent state cleanup (issue #2186):
143
+ --no-agent-snapshots Do not reclaim orphaned @link-assistant/agent
144
+ snapshot stores under
145
+ $XDG_DATA_HOME/link-assistant-agent/snapshot/
146
+
141
147
  Docker isolation cleanup:
142
148
  --docker-isolation[=<mode>] Clean task containers named by session UUID
143
149
  [default: ${DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE}]
@@ -173,6 +179,7 @@ const options = {
173
179
  journal: hasFlag('--journal', '--system'),
174
180
  docker: hasFlag('--docker'),
175
181
  dockerIsolationMode: parseDockerIsolationMode(),
182
+ agentSnapshots: !hasFlag('--no-agent-snapshots'),
176
183
  npm: hasFlag('--npm', '--system'),
177
184
  sudo: hasFlag('--sudo'),
178
185
  };
@@ -371,18 +378,48 @@ async function main() {
371
378
  }
372
379
  }
373
380
 
374
- await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)}), docker keep ${dockerIsolationPlan.keep.length}, docker remove ${dockerIsolationPlan.remove.length}`);
381
+ // Issue #2186: agent's snapshot stores live in the home directory, outside the
382
+ // tmp root everything above scans, and each one is a full copy of a
383
+ // repository. A store is only listed for removal when the worktree its project
384
+ // record points at is gone, so this can never disturb a live checkout.
385
+ let agentSnapshotPlan = { orphaned: [], keep: [] };
386
+ if (!options.agentSnapshots) {
387
+ await log('\n🗄️ Agent snapshot stores: disabled (--no-agent-snapshots)');
388
+ } else {
389
+ const agentDataHome = getAgentDataHome();
390
+ agentSnapshotPlan = await classifyAgentSnapshotStores({ dataHome: agentDataHome });
391
+ for (const item of [...agentSnapshotPlan.keep, ...agentSnapshotPlan.orphaned]) item.size = getPathSize(item.path);
392
+ const orphanBytes = agentSnapshotPlan.orphaned.reduce((total, item) => total + (item.size || 0), 0);
393
+ await log(`\n🗄️ Agent snapshot stores (${path.join(agentDataHome, 'snapshot')}):`);
394
+ if (agentSnapshotPlan.keep.length === 0 && agentSnapshotPlan.orphaned.length === 0) {
395
+ await log(' (none)');
396
+ } else {
397
+ await log(' KEPT:');
398
+ if (agentSnapshotPlan.keep.length === 0) await log(' (none)');
399
+ for (const item of agentSnapshotPlan.keep.sort((a, b) => (b.size || 0) - (a.size || 0))) {
400
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeAgentSnapshotReason(item.reason)}${item.worktree ? ` (${item.worktree})` : ''}`);
401
+ }
402
+ await log(` ${options.dryRun ? 'WOULD REMOVE' : 'TO REMOVE'} (${formatBytes(orphanBytes)}):`);
403
+ if (agentSnapshotPlan.orphaned.length === 0) await log(' (none)');
404
+ for (const item of agentSnapshotPlan.orphaned.sort((a, b) => (b.size || 0) - (a.size || 0))) {
405
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeAgentSnapshotReason(item.reason)}${item.worktree ? ` (${item.worktree})` : ''}`);
406
+ }
407
+ }
408
+ }
409
+
410
+ await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)}), docker keep ${dockerIsolationPlan.keep.length}, docker remove ${dockerIsolationPlan.remove.length}, agent snapshots remove ${agentSnapshotPlan.orphaned.length}`);
375
411
 
376
412
  // 7. Execute deletion (unless dry-run).
377
413
  const hasTempRemovals = classified.remove.length > 0;
378
414
  const hasDockerRemovals = dockerIsolationPlan.remove.length > 0;
415
+ const hasAgentSnapshotRemovals = agentSnapshotPlan.orphaned.length > 0;
379
416
  if (options.dryRun) {
380
417
  await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
381
- } else if (!hasTempRemovals && !hasDockerRemovals) {
418
+ } else if (!hasTempRemovals && !hasDockerRemovals && !hasAgentSnapshotRemovals) {
382
419
  await log('\n✅ Nothing to delete.');
383
420
  } else {
384
421
  if (!options.force) {
385
- console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}) and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
422
+ console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}), ${agentSnapshotPlan.orphaned.length} orphaned agent snapshot stores and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
386
423
  console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
387
424
  let answer;
388
425
  try {
@@ -414,6 +451,23 @@ async function main() {
414
451
  await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
415
452
  }
416
453
 
454
+ if (hasAgentSnapshotRemovals) {
455
+ await log('\n🗄️ Removing orphaned agent snapshot stores...');
456
+ let deleted = 0;
457
+ let failed = 0;
458
+ for (const item of agentSnapshotPlan.orphaned) {
459
+ const ok = removePath(item.path);
460
+ if (ok) {
461
+ deleted++;
462
+ await vlog(` removed ${item.path}`);
463
+ } else {
464
+ failed++;
465
+ await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
466
+ }
467
+ }
468
+ await log(`\n✅ Deleted ${deleted} orphaned agent snapshot stores${failed ? `, ${failed} failed` : ''}.`);
469
+ }
470
+
417
471
  if (hasDockerRemovals) {
418
472
  await log('\n🐳 Removing Docker isolation containers...');
419
473
  let removed = 0;
@@ -27,6 +27,8 @@ import path from 'node:path';
27
27
  import { execFile } from 'node:child_process';
28
28
  import { promisify } from 'node:util';
29
29
 
30
+ import { DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS, getAgentDataHome, reclaimAgentSnapshotStores } from './agent-snapshot-store.lib.mjs';
31
+
30
32
  const execFileAsync = promisify(execFile);
31
33
 
32
34
  /**
@@ -224,8 +226,12 @@ export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAUL
224
226
  *
225
227
  * An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
226
228
  * check, not a replacement for it.
229
+ *
230
+ * Issue #2186 added a second source of reclaimable space: orphaned
231
+ * `@link-assistant/agent` snapshot stores in the home directory, which no `/tmp`-scoped check
232
+ * could see. Pass `agentDataHome: null` to opt out.
227
233
  */
228
- export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
234
+ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove, agentDataHome = getAgentDataHome(), agentSnapshotMinIdleMs = DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS } = {}) => {
229
235
  const startedAt = now();
230
236
  const reclaimed = [];
231
237
  let freeMB = await getFreeMB(tmpRoot);
@@ -239,6 +245,20 @@ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = D
239
245
  }
240
246
  await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
241
247
  for (;;) {
248
+ // Issue #2186: orphaned agent snapshot stores are pure garbage — their
249
+ // worktree is gone, so nothing can be restored from them — while a solver
250
+ // workspace may still be wanted for debugging. Reclaim them first, and note
251
+ // that they live in the home directory, which every check here used to be
252
+ // blind to.
253
+ if (agentDataHome) {
254
+ const agentResult = await reclaimAgentSnapshotStores({ dataHome: agentDataHome, minIdleMs: agentSnapshotMinIdleMs, stopWhenFreeMB: requiredMB, getFreeMB: () => getFreeMB(tmpRoot), now, log, fileSystem, remove });
255
+ reclaimed.push(...agentResult.removed);
256
+ if (agentResult.freeMB !== null && agentResult.freeMB !== undefined) freeMB = agentResult.freeMB;
257
+ if (freeMB >= requiredMB) {
258
+ await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${agentResult.removed.length} orphaned agent snapshot store(s)`);
259
+ return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
260
+ }
261
+ }
242
262
  const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
243
263
  reclaimed.push(...result.removed);
244
264
  if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
@@ -29,13 +29,17 @@ export const FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION = '0.336.0';
29
29
  * PR #2147 this is the *initial* pin only: once the container is running,
30
30
  * `src/formal-ai-updater.lib.mjs` replaces it with the newest published image
31
31
  * while no Formal AI task holds a lease. 0.339.0 restored `cargo install
32
- * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routes
33
- * command execution through the published command-stream component; the
34
- * memory-contract sources (`src/cli_memory.rs`, `src/server.rs`,
35
- * `src/shared_memory.rs`) are byte-identical to 0.337.0 and
36
- * `src/memory/upgrade.rs` only adds an explicit advisory-lock release.
32
+ * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routed
33
+ * command execution through the published command-stream component. 0.345.0 is
34
+ * the current release and is a safe bootstrap for the same reason 0.339.1 was:
35
+ * its Cargo.lock still carries no `openssl-sys`, so the builder stage keeps
36
+ * building on a stock `rust:slim` image, and the four memory-contract sources
37
+ * (`src/cli_memory.rs`, `src/server.rs`, `src/shared_memory.rs`,
38
+ * `src/memory/upgrade.rs`) are byte-identical to 0.339.1 — 0.340.0-0.345.0 only
39
+ * change reasoning data, benchmarks and unrelated handlers. Verified by
40
+ * diffing the published crates; see docs/case-studies/issue-2186.
37
41
  */
38
- export const FORMAL_AI_BOOTSTRAP_VERSION = '0.339.1';
42
+ export const FORMAL_AI_BOOTSTRAP_VERSION = '0.345.0';
39
43
 
40
44
  export const parseFormalAiVersion = stdout => {
41
45
  const line = String(stdout || '')
@@ -1,8 +1,37 @@
1
1
  import { reportError } from './sentry.lib.mjs';
2
+ import { describeHiddenCharacters, repairGitHubPathParts, repairGitHubUrlText, revealHiddenCharacters, traceUrlRecovery } from './github-url-recovery.lib.mjs';
3
+
4
+ /**
5
+ * Attach the issue #2194 recovery diagnostics to a parse result.
6
+ *
7
+ * `original` is always present so a caller can show the user what they actually
8
+ * sent; `hidden`/`revealed` only appear when there was something invisible to
9
+ * reveal, keeping the common result small.
10
+ *
11
+ * @param {Object} result - The result object to annotate (mutated and returned).
12
+ * @param {string} original - The URL exactly as it was passed in.
13
+ * @param {Array<{code: string, message: string, notable: boolean}>} repairs
14
+ * @param {Array<{escape: string, name: string}>} hidden
15
+ * @returns {Object} The same result object.
16
+ */
17
+ function withRecoveryDiagnostics(result, original, repairs, hidden) {
18
+ result.original = original;
19
+ result.repairs = repairs;
20
+ result.recovered = repairs.length > 0;
21
+ if (hidden.length > 0) {
22
+ result.hidden = hidden;
23
+ result.revealed = revealHiddenCharacters(original);
24
+ }
25
+ return result;
26
+ }
2
27
 
3
28
  /**
4
29
  * Universal GitHub URL parser that handles various formats
5
30
  * @param {string} url - The GitHub URL to parse
31
+ * @param {Object} [options] - Parsing options
32
+ * @param {boolean} [options.recover=true] - Repair recoverable damage (invisible
33
+ * Unicode, wrappers, look-alike punctuation, `/pulls/30` for `/pull/30`, …)
34
+ * before parsing. Pass `false` to see the URL exactly as it was typed.
6
35
  * @returns {Object} Parsed URL information including:
7
36
  * - valid: boolean indicating if the URL is valid
8
37
  * - normalized: the normalized URL (https://github.com/...), query/fragment kept
@@ -13,22 +42,41 @@ import { reportError } from './sentry.lib.mjs';
13
42
  * - number: issue/PR number (if applicable)
14
43
  * - path: additional path components
15
44
  * - error: error message if invalid
45
+ * - original: the URL exactly as it was passed in
46
+ * - repairs: the list of repairs recovery had to apply (issue #2194)
47
+ * - recovered: true when at least one repair was applied
48
+ * - hidden/revealed: codepoint diagnostics, present only when the input carried
49
+ * invisible or look-alike characters
16
50
  */
17
- export function parseGitHubUrl(url) {
51
+ export function parseGitHubUrl(url, options = {}) {
18
52
  if (!url || typeof url !== 'string') {
19
53
  return {
20
54
  valid: false,
21
55
  error: 'Invalid input: URL must be a non-empty string',
22
56
  };
23
57
  }
58
+ const { recover = true } = options;
59
+ // Issue #2194: a URL that renders correctly on screen can still be broken —
60
+ // `…/pulls/30` previews as a healthy page, a zero-width space is unprintable by
61
+ // definition. Repair what can be repaired first, and keep a record of it, so the
62
+ // user is told what was interpreted instead of being told "invalid URL".
63
+ const hidden = describeHiddenCharacters(url);
64
+ let repairs = [];
24
65
  // Trim whitespace and remove trailing slashes
25
66
  let normalizedUrl = url.trim().replace(/\/+$/, '');
67
+ if (recover) {
68
+ const repaired = repairGitHubUrlText(normalizedUrl);
69
+ repairs = repaired.repairs;
70
+ if (repaired.rejection) {
71
+ traceUrlRecovery('rejected', { original: url, error: repaired.rejection, repairs, hidden });
72
+ return withRecoveryDiagnostics({ valid: false, error: repaired.rejection }, url, repairs, hidden);
73
+ }
74
+ normalizedUrl = repaired.text.replace(/\/+$/, '');
75
+ if (repairs.length > 0) traceUrlRecovery('repaired-text', { original: url, repaired: normalizedUrl, repairs, hidden });
76
+ }
26
77
  // Check if this looks like a valid GitHub-related input Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
27
78
  if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
28
- return {
29
- valid: false,
30
- error: 'Invalid GitHub URL format',
31
- };
79
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
32
80
  }
33
81
  // Handle protocol normalization
34
82
  if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
@@ -40,10 +88,7 @@ export function parseGitHubUrl(url) {
40
88
  normalizedUrl = 'https://github.com/' + normalizedUrl;
41
89
  } else {
42
90
  // Has github.com somewhere but not at the start - likely malformed
43
- return {
44
- valid: false,
45
- error: 'Invalid GitHub URL format',
46
- };
91
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
47
92
  }
48
93
  }
49
94
  // Convert http to https
@@ -56,11 +101,16 @@ export function parseGitHubUrl(url) {
56
101
  // Generate suggested URL by replacing backslashes with forward slashes
57
102
  const suggestedUrl = urlBeforeQueryAndHash.replace(/\\/g, '/');
58
103
  const urlAfterPath = normalizedUrl.substring(urlBeforeQueryAndHash.length);
59
- return {
60
- valid: false,
61
- error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
62
- suggestion: suggestedUrl + urlAfterPath,
63
- };
104
+ return withRecoveryDiagnostics(
105
+ {
106
+ valid: false,
107
+ error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
108
+ suggestion: suggestedUrl + urlAfterPath,
109
+ },
110
+ url,
111
+ repairs,
112
+ hidden
113
+ );
64
114
  }
65
115
  // Parse the URL
66
116
  let urlObj;
@@ -74,17 +124,11 @@ export function parseGitHubUrl(url) {
74
124
  url: normalizedUrl,
75
125
  });
76
126
  }
77
- return {
78
- valid: false,
79
- error: 'Invalid URL format',
80
- };
127
+ return withRecoveryDiagnostics({ valid: false, error: 'Invalid URL format' }, url, repairs, hidden);
81
128
  }
82
129
  // Ensure it's a GitHub URL
83
130
  if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
84
- return {
85
- valid: false,
86
- error: 'Not a GitHub URL',
87
- };
131
+ return withRecoveryDiagnostics({ valid: false, error: 'Not a GitHub URL' }, url, repairs, hidden);
88
132
  }
89
133
  // Normalize hostname
90
134
  if (urlObj.hostname === 'www.github.com') {
@@ -92,7 +136,19 @@ export function parseGitHubUrl(url) {
92
136
  urlObj = new globalThis.URL(normalizedUrl);
93
137
  }
94
138
  // Parse the pathname
95
- const pathParts = urlObj.pathname.split('/').filter(p => p);
139
+ let pathParts = urlObj.pathname.split('/').filter(p => p);
140
+ // Issue #2194: `/owner/repo/pulls/30` carries every byte needed to address pull
141
+ // request 30 — restore it rather than reporting the pull request list page.
142
+ if (recover) {
143
+ const pathRepair = repairGitHubPathParts(pathParts);
144
+ if (pathRepair.repairs.length > 0) {
145
+ repairs = repairs.concat(pathRepair.repairs);
146
+ pathParts = pathRepair.parts;
147
+ urlObj.pathname = `/${pathParts.join('/')}`;
148
+ normalizedUrl = urlObj.toString().replace(/\/+$/, '');
149
+ traceUrlRecovery('repaired-path', { original: url, repaired: normalizedUrl, repairs, hidden });
150
+ }
151
+ }
96
152
  // Handle different GitHub URL patterns
97
153
  const result = {
98
154
  valid: true,
@@ -108,6 +164,7 @@ export function parseGitHubUrl(url) {
108
164
  protocol: 'https',
109
165
  path: urlObj.pathname,
110
166
  };
167
+ withRecoveryDiagnostics(result, url, repairs, hidden);
111
168
  // No path - just github.com
112
169
  if (pathParts.length === 0) {
113
170
  result.type = 'home';