@link-assistant/hive-mind 2.0.19 → 2.0.21
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 +12 -0
- package/package.json +1 -1
- package/src/cleanup.lib.mjs +227 -0
- package/src/cleanup.mjs +87 -18
- package/src/cleanup.os.lib.mjs +83 -2
- package/src/isolation-runner.lib.mjs +42 -1
- package/src/session-monitor.lib.mjs +97 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- da88ee5: Add session-aware Docker-isolation container cleanup to hive-cleanup.
|
|
8
|
+
|
|
9
|
+
## 2.0.20
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 078f346: Reap successful Docker-isolated task containers at session completion, keep failed containers with cleanup instructions by default, and update Docker images to start-command 0.30.1.
|
|
14
|
+
|
|
3
15
|
## 2.0.19
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/cleanup.lib.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* @see https://github.com/link-assistant/hive-mind/issues/1848
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { classifyExitStatus, isExecutingSessionStatus, isFailureSessionStatus, isTerminalSessionStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
20
21
|
import { isValidIssueBranchName, parseIssueBranchName } from './solve.branch.lib.mjs';
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -161,6 +162,8 @@ export function buildActiveMatchers(activeTasks) {
|
|
|
161
162
|
sessionId: task.sessionId || null,
|
|
162
163
|
sessionName: task.sessionName || null,
|
|
163
164
|
status: task.status || null,
|
|
165
|
+
exitCode: task.exitCode ?? null,
|
|
166
|
+
isolation: task.isolation || null,
|
|
164
167
|
workspace: task.workspace || null,
|
|
165
168
|
});
|
|
166
169
|
}
|
|
@@ -431,3 +434,227 @@ export function formatEntryContext(item) {
|
|
|
431
434
|
|
|
432
435
|
return details.length > 0 ? ` (${details.join('; ')})` : '';
|
|
433
436
|
}
|
|
437
|
+
|
|
438
|
+
export const DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE = 'succeeded';
|
|
439
|
+
export const DOCKER_ISOLATION_CLEANUP_MODES = new Set(['succeeded', 'all', 'none']);
|
|
440
|
+
|
|
441
|
+
const DOCKER_ISOLATION_SESSION_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
442
|
+
|
|
443
|
+
function normalizeText(value) {
|
|
444
|
+
return String(value || '')
|
|
445
|
+
.trim()
|
|
446
|
+
.toLowerCase();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Normalize the docker-isolation cleanup policy.
|
|
451
|
+
*
|
|
452
|
+
* Modes:
|
|
453
|
+
* - succeeded: default; remove successful exited containers, keep failures
|
|
454
|
+
* - all: remove all terminal/exited task containers
|
|
455
|
+
* - none: report only
|
|
456
|
+
*
|
|
457
|
+
* @param {string|null|undefined} value
|
|
458
|
+
* @returns {'succeeded'|'all'|'none'}
|
|
459
|
+
*/
|
|
460
|
+
export function normalizeDockerIsolationCleanupMode(value) {
|
|
461
|
+
const mode = normalizeText(value);
|
|
462
|
+
if (!mode || ['default', 'true', '1', 'yes', 'on', 'succeeded', 'success', 'successful', 'failed-kept', 'keep-failed', 'on-failure'].includes(mode)) {
|
|
463
|
+
return DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE;
|
|
464
|
+
}
|
|
465
|
+
if (['false', '0', 'no', 'off', 'none', 'disabled'].includes(mode)) return 'none';
|
|
466
|
+
if (['all', 'everything', 'finished', 'terminal'].includes(mode)) return 'all';
|
|
467
|
+
throw new Error(`Invalid docker isolation cleanup mode: ${value}. Expected one of: succeeded, all, none`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* True when a Docker container name looks like a start-command session UUID.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} name
|
|
474
|
+
* @returns {boolean}
|
|
475
|
+
*/
|
|
476
|
+
export function isDockerIsolationSessionName(name) {
|
|
477
|
+
return DOCKER_ISOLATION_SESSION_NAME_RE.test(String(name || '').trim());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Parse Docker's status text, e.g. "Exited (137) 10 minutes ago".
|
|
482
|
+
*
|
|
483
|
+
* @param {string} status
|
|
484
|
+
* @returns {number|null}
|
|
485
|
+
*/
|
|
486
|
+
export function parseDockerContainerExitCode(status) {
|
|
487
|
+
const match = String(status || '').match(/\bExited\s+\((-?\d+)\)/i);
|
|
488
|
+
return match ? normalizeExitCode(match[1]) : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function createSessionLookup(sessionTasks) {
|
|
492
|
+
const lookup = new Map();
|
|
493
|
+
const merge = (key, task) => {
|
|
494
|
+
if (!key) return;
|
|
495
|
+
const existing = lookup.get(key) || {};
|
|
496
|
+
lookup.set(key, {
|
|
497
|
+
...existing,
|
|
498
|
+
...task,
|
|
499
|
+
status: task.status || existing.status || null,
|
|
500
|
+
exitCode: task.exitCode ?? existing.exitCode ?? null,
|
|
501
|
+
isolation: task.isolation || existing.isolation || null,
|
|
502
|
+
sessionId: task.sessionId || existing.sessionId || null,
|
|
503
|
+
sessionName: task.sessionName || existing.sessionName || null,
|
|
504
|
+
workspace: task.workspace || existing.workspace || null,
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
for (const task of sessionTasks || []) {
|
|
509
|
+
if (!task) continue;
|
|
510
|
+
merge(task.sessionId, task);
|
|
511
|
+
merge(task.sessionName, task);
|
|
512
|
+
}
|
|
513
|
+
return lookup;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function normalizeDockerContainer(container) {
|
|
517
|
+
const name = String(container?.name || container?.Names || container?.Name || '')
|
|
518
|
+
.replace(/^\//, '')
|
|
519
|
+
.trim();
|
|
520
|
+
const state = normalizeText(container?.state || container?.State);
|
|
521
|
+
const statusText = String(container?.status ?? container?.Status ?? '').trim();
|
|
522
|
+
const exitCode = normalizeExitCode(container?.exitCode ?? container?.ExitCode ?? parseDockerContainerExitCode(statusText));
|
|
523
|
+
const running = container?.running === true || state === 'running' || /^Up\b/i.test(statusText);
|
|
524
|
+
return {
|
|
525
|
+
...container,
|
|
526
|
+
id: container?.id || container?.ID || null,
|
|
527
|
+
image: container?.image || container?.Image || null,
|
|
528
|
+
name,
|
|
529
|
+
state,
|
|
530
|
+
status: statusText,
|
|
531
|
+
exitCode,
|
|
532
|
+
running,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function inferDockerContainerOutcome(container, session) {
|
|
537
|
+
const sessionStatus = normalizeText(session?.status);
|
|
538
|
+
const exitCode = normalizeExitCode(container.exitCode ?? session?.exitCode);
|
|
539
|
+
const running = container.running || isExecutingSessionStatus(sessionStatus);
|
|
540
|
+
const containerTerminal = ['exited', 'dead', 'removing'].includes(container.state) || /^Exited\b/i.test(container.status) || exitCode !== null;
|
|
541
|
+
const sessionTerminal = isTerminalSessionStatus(sessionStatus);
|
|
542
|
+
const terminal = !running && (sessionTerminal || containerTerminal);
|
|
543
|
+
const exitStatus = classifyExitStatus(exitCode);
|
|
544
|
+
const failed = terminal && (isFailureSessionStatus(sessionStatus) || isFailureSessionStatus(exitStatus) || (exitCode !== null && exitCode !== 0));
|
|
545
|
+
const successful = terminal && !failed && exitCode === 0;
|
|
546
|
+
const unknown = terminal && !successful && !failed;
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
running,
|
|
550
|
+
terminal,
|
|
551
|
+
exitCode,
|
|
552
|
+
successful,
|
|
553
|
+
failed,
|
|
554
|
+
unknown,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function dockerCleanupRecord(container, session, outcome, reason) {
|
|
559
|
+
return {
|
|
560
|
+
...container,
|
|
561
|
+
...outcome,
|
|
562
|
+
session: session || null,
|
|
563
|
+
reason,
|
|
564
|
+
command: `docker rm -f ${container.name}`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Plan selective cleanup for Docker-isolation task containers.
|
|
570
|
+
*
|
|
571
|
+
* The input containers are expected to be Docker ps records already filtered to
|
|
572
|
+
* session UUID names, but the planner filters defensively too. Running
|
|
573
|
+
* containers are never removed, regardless of mode.
|
|
574
|
+
*
|
|
575
|
+
* @param {Object} options
|
|
576
|
+
* @param {Array} options.containers
|
|
577
|
+
* @param {Array} [options.sessionTasks]
|
|
578
|
+
* @param {string} [options.mode]
|
|
579
|
+
* @returns {{keep: Array, remove: Array, mode: string}}
|
|
580
|
+
*/
|
|
581
|
+
export function planDockerIsolationCleanup(options = {}) {
|
|
582
|
+
const mode = normalizeDockerIsolationCleanupMode(options.mode);
|
|
583
|
+
const sessions = createSessionLookup(options.sessionTasks || []);
|
|
584
|
+
const keep = [];
|
|
585
|
+
const remove = [];
|
|
586
|
+
|
|
587
|
+
for (const rawContainer of options.containers || []) {
|
|
588
|
+
const container = normalizeDockerContainer(rawContainer);
|
|
589
|
+
if (!isDockerIsolationSessionName(container.name)) continue;
|
|
590
|
+
|
|
591
|
+
const session = sessions.get(container.name) || null;
|
|
592
|
+
const outcome = inferDockerContainerOutcome(container, session);
|
|
593
|
+
let reason;
|
|
594
|
+
let action = 'keep';
|
|
595
|
+
|
|
596
|
+
if (mode === 'none') {
|
|
597
|
+
reason = 'disabled';
|
|
598
|
+
} else if (outcome.running) {
|
|
599
|
+
reason = 'active-container';
|
|
600
|
+
} else if (!outcome.terminal) {
|
|
601
|
+
reason = 'unknown-container-state';
|
|
602
|
+
} else if (mode === 'all') {
|
|
603
|
+
reason = 'finished-container';
|
|
604
|
+
action = 'remove';
|
|
605
|
+
} else if (outcome.successful) {
|
|
606
|
+
reason = 'successful-container';
|
|
607
|
+
action = 'remove';
|
|
608
|
+
} else if (outcome.failed) {
|
|
609
|
+
reason = 'failed-container-kept';
|
|
610
|
+
} else {
|
|
611
|
+
reason = 'unknown-outcome-kept';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const record = dockerCleanupRecord(container, session, outcome, reason);
|
|
615
|
+
if (action === 'remove') remove.push(record);
|
|
616
|
+
else keep.push(record);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return { keep, remove, mode };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Human-readable docker-isolation cleanup reason.
|
|
624
|
+
*
|
|
625
|
+
* @param {string} reason
|
|
626
|
+
* @returns {string}
|
|
627
|
+
*/
|
|
628
|
+
export function describeDockerIsolationReason(reason) {
|
|
629
|
+
const map = {
|
|
630
|
+
disabled: 'docker-isolation cleanup disabled',
|
|
631
|
+
'active-container': 'running docker-isolation task',
|
|
632
|
+
'unknown-container-state': 'container state is not terminal',
|
|
633
|
+
'successful-container': 'successful docker-isolation task container',
|
|
634
|
+
'finished-container': 'finished docker-isolation task container',
|
|
635
|
+
'failed-container-kept': 'failed docker-isolation task kept for debugging',
|
|
636
|
+
'unknown-outcome-kept': 'docker-isolation task outcome unknown',
|
|
637
|
+
'all-mode': 'non-running docker-isolation container',
|
|
638
|
+
};
|
|
639
|
+
return map[reason] || reason;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Format a docker-isolation container record for CLI logs.
|
|
644
|
+
*
|
|
645
|
+
* @param {Object} item
|
|
646
|
+
* @returns {string}
|
|
647
|
+
*/
|
|
648
|
+
export function formatDockerIsolationContainerSummary(item) {
|
|
649
|
+
if (!item) return '';
|
|
650
|
+
const parts = [`session ${item.name}`];
|
|
651
|
+
if (item.image) parts.push(`image ${item.image}`);
|
|
652
|
+
if (item.state) parts.push(`state ${item.state}`);
|
|
653
|
+
if (item.status) parts.push(`status ${item.status}`);
|
|
654
|
+
if (item.exitCode !== null && item.exitCode !== undefined) parts.push(`exit ${item.exitCode}`);
|
|
655
|
+
if (item.session) parts.push(formatTaskSummary(item.session));
|
|
656
|
+
if (item.command && ['disabled', 'failed-container-kept', 'unknown-outcome-kept'].includes(item.reason)) {
|
|
657
|
+
parts.push(`remove when done: ${item.command}`);
|
|
658
|
+
}
|
|
659
|
+
return parts.join(', ');
|
|
660
|
+
}
|
package/src/cleanup.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* --no-keep-dirty allow deleting clones with unpushed changes
|
|
24
24
|
* --processes map claude/codex/etc. PIDs to task sessions
|
|
25
25
|
* --kill-orphaned-agents signal orphaned terminal-session agents
|
|
26
|
+
* --docker-isolation[=<mode>] cleanup task containers by session UUID
|
|
26
27
|
* --apt --journal --docker --npm Ubuntu/system cleanup (opt-in)
|
|
27
28
|
* --system shorthand for --apt --journal --npm
|
|
28
29
|
* --sudo prefix package-manager commands with sudo
|
|
@@ -35,8 +36,8 @@ import path from 'node:path';
|
|
|
35
36
|
import { promises as fsp } from 'node:fs';
|
|
36
37
|
|
|
37
38
|
import { isConfirmationYes, readConfirmationLine } from './confirmation.lib.mjs';
|
|
38
|
-
import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary } from './cleanup.lib.mjs';
|
|
39
|
-
import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees } from './cleanup.os.lib.mjs';
|
|
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
|
+
import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees, listDockerIsolationContainers, removeDockerContainer } from './cleanup.os.lib.mjs';
|
|
40
41
|
import { formatProcessDebugReport } from './process-debug.lib.mjs';
|
|
41
42
|
|
|
42
43
|
const args = process.argv.slice(2);
|
|
@@ -71,6 +72,17 @@ function parsePidList(values) {
|
|
|
71
72
|
.filter(value => Number.isInteger(value) && value > 0);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function parseDockerIsolationMode() {
|
|
76
|
+
if (hasFlag('--no-docker-isolation')) return 'none';
|
|
77
|
+
const configured = getFlagValue('--docker-isolation') ?? process.env.HIVE_MIND_CLEANUP_DOCKER_ISOLATION ?? process.env.HIVE_CLEANUP_DOCKER_ISOLATION ?? DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE;
|
|
78
|
+
try {
|
|
79
|
+
return normalizeDockerIsolationCleanupMode(configured);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`Error: ${error.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
// ---------------------------------------------------------------------------
|
|
75
87
|
// Early --version / --help handling (no heavy imports).
|
|
76
88
|
// ---------------------------------------------------------------------------
|
|
@@ -117,11 +129,17 @@ Process diagnostics:
|
|
|
117
129
|
System / Ubuntu cleanup (opt-in):
|
|
118
130
|
--apt apt-get clean / autoclean / autoremove
|
|
119
131
|
--journal journalctl --vacuum-time=2weeks
|
|
120
|
-
--docker docker system prune -f
|
|
132
|
+
--docker docker system prune -f (host-wide Docker cleanup)
|
|
121
133
|
--npm npm cache clean --force
|
|
122
134
|
--system Shorthand for --apt --journal --npm
|
|
123
135
|
--sudo Prefix package-manager commands with sudo
|
|
124
136
|
|
|
137
|
+
Docker isolation cleanup:
|
|
138
|
+
--docker-isolation[=<mode>] Clean task containers named by session UUID
|
|
139
|
+
[default: ${DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE}]
|
|
140
|
+
modes: succeeded, all, none
|
|
141
|
+
--no-docker-isolation Disable Docker-isolation task container cleanup
|
|
142
|
+
|
|
125
143
|
--verbose, -v Verbose logging
|
|
126
144
|
--version Show version number
|
|
127
145
|
--help, -h Show this help
|
|
@@ -150,6 +168,7 @@ const options = {
|
|
|
150
168
|
apt: hasFlag('--apt', '--system'),
|
|
151
169
|
journal: hasFlag('--journal', '--system'),
|
|
152
170
|
docker: hasFlag('--docker'),
|
|
171
|
+
dockerIsolationMode: parseDockerIsolationMode(),
|
|
153
172
|
npm: hasFlag('--npm', '--system'),
|
|
154
173
|
sudo: hasFlag('--sudo'),
|
|
155
174
|
};
|
|
@@ -311,16 +330,47 @@ async function main() {
|
|
|
311
330
|
await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeReason(item.reason)}${formatEntryContext(item)}`);
|
|
312
331
|
}
|
|
313
332
|
|
|
314
|
-
|
|
333
|
+
let dockerIsolationPlan = { keep: [], remove: [], mode: options.dockerIsolationMode };
|
|
334
|
+
if (options.dockerIsolationMode === 'none') {
|
|
335
|
+
await log('\n🐳 Docker isolation containers: disabled (--docker-isolation=none)');
|
|
336
|
+
} else {
|
|
337
|
+
const dockerIsolationContainers = listDockerIsolationContainers();
|
|
338
|
+
dockerIsolationPlan = planDockerIsolationCleanup({
|
|
339
|
+
containers: dockerIsolationContainers,
|
|
340
|
+
sessionTasks,
|
|
341
|
+
mode: options.dockerIsolationMode,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await log(`\n🐳 Docker isolation containers (${dockerIsolationPlan.mode}):`);
|
|
345
|
+
if (dockerIsolationPlan.keep.length === 0 && dockerIsolationPlan.remove.length === 0) {
|
|
346
|
+
await log(' (none detected)');
|
|
347
|
+
} else {
|
|
348
|
+
await log(' KEPT:');
|
|
349
|
+
if (dockerIsolationPlan.keep.length === 0) await log(' (none)');
|
|
350
|
+
for (const item of dockerIsolationPlan.keep) {
|
|
351
|
+
await log(` ${formatDockerIsolationContainerSummary(item)} — ${describeDockerIsolationReason(item.reason)}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await log(` ${options.dryRun ? 'WOULD REMOVE' : 'TO REMOVE'}:`);
|
|
355
|
+
if (dockerIsolationPlan.remove.length === 0) await log(' (none)');
|
|
356
|
+
for (const item of dockerIsolationPlan.remove) {
|
|
357
|
+
await log(` ${formatDockerIsolationContainerSummary(item)} — ${describeDockerIsolationReason(item.reason)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
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}`);
|
|
315
363
|
|
|
316
364
|
// 7. Execute deletion (unless dry-run).
|
|
365
|
+
const hasTempRemovals = classified.remove.length > 0;
|
|
366
|
+
const hasDockerRemovals = dockerIsolationPlan.remove.length > 0;
|
|
317
367
|
if (options.dryRun) {
|
|
318
368
|
await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
|
|
319
|
-
} else if (
|
|
369
|
+
} else if (!hasTempRemovals && !hasDockerRemovals) {
|
|
320
370
|
await log('\n✅ Nothing to delete.');
|
|
321
371
|
} else {
|
|
322
372
|
if (!options.force) {
|
|
323
|
-
console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}).`);
|
|
373
|
+
console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}) and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
|
|
324
374
|
console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
|
|
325
375
|
let answer = '';
|
|
326
376
|
try {
|
|
@@ -335,20 +385,39 @@ async function main() {
|
|
|
335
385
|
}
|
|
336
386
|
}
|
|
337
387
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
388
|
+
if (hasTempRemovals) {
|
|
389
|
+
await log('\n🗑️ Deleting...');
|
|
390
|
+
let deleted = 0;
|
|
391
|
+
let failed = 0;
|
|
392
|
+
for (const item of classified.remove) {
|
|
393
|
+
const ok = removePath(item.path);
|
|
394
|
+
if (ok) {
|
|
395
|
+
deleted++;
|
|
396
|
+
await vlog(` removed ${item.path}`);
|
|
397
|
+
} else {
|
|
398
|
+
failed++;
|
|
399
|
+
await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (hasDockerRemovals) {
|
|
406
|
+
await log('\n🐳 Removing Docker isolation containers...');
|
|
407
|
+
let removed = 0;
|
|
408
|
+
let failed = 0;
|
|
409
|
+
for (const item of dockerIsolationPlan.remove) {
|
|
410
|
+
const ok = removeDockerContainer(item.name);
|
|
411
|
+
if (ok) {
|
|
412
|
+
removed++;
|
|
413
|
+
await log(` ✓ ${item.command}`);
|
|
414
|
+
} else {
|
|
415
|
+
failed++;
|
|
416
|
+
await log(` ⚠️ failed: ${item.command}`, { level: 'warn' });
|
|
417
|
+
}
|
|
349
418
|
}
|
|
419
|
+
await log(`\n✅ Removed ${removed} Docker isolation containers${failed ? `, ${failed} failed` : ''}.`);
|
|
350
420
|
}
|
|
351
|
-
await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
|
|
352
421
|
}
|
|
353
422
|
|
|
354
423
|
// 8. System / Ubuntu cleanup (opt-in).
|
package/src/cleanup.os.lib.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
21
|
|
|
22
|
-
import { extractTaskRefsFromCommand, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
22
|
+
import { extractTaskRefsFromCommand, isDockerIsolationSessionName, parseDockerContainerExitCode, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
23
23
|
import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
|
|
24
24
|
|
|
25
25
|
/** Run a command, returning trimmed stdout or null on any failure. */
|
|
@@ -303,6 +303,73 @@ export function listScreenSessions() {
|
|
|
303
303
|
return sessions;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
function splitDockerNames(value) {
|
|
307
|
+
return String(value || '')
|
|
308
|
+
.split(',')
|
|
309
|
+
.map(name => name.trim().replace(/^\/+/, ''))
|
|
310
|
+
.filter(Boolean);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Parse `docker ps -a --format '{{json .}}'` output into docker-isolation task
|
|
315
|
+
* containers. start-command names native Docker isolation containers after the
|
|
316
|
+
* session UUID, so unrelated host containers are ignored.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} output
|
|
319
|
+
* @returns {Array<{id: string|null, name: string, image: string|null, state: string, status: string, exitCode: number|null, running: boolean}>}
|
|
320
|
+
*/
|
|
321
|
+
export function parseDockerPsJsonLines(output) {
|
|
322
|
+
const containers = [];
|
|
323
|
+
const seen = new Set();
|
|
324
|
+
|
|
325
|
+
for (const line of String(output || '').split('\n')) {
|
|
326
|
+
const trimmed = line.trim();
|
|
327
|
+
if (!trimmed) continue;
|
|
328
|
+
|
|
329
|
+
let data;
|
|
330
|
+
try {
|
|
331
|
+
data = JSON.parse(trimmed);
|
|
332
|
+
} catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const state = String(data.State || data.state || '')
|
|
337
|
+
.trim()
|
|
338
|
+
.toLowerCase();
|
|
339
|
+
const status = String(data.Status || data.status || '').trim();
|
|
340
|
+
const exitCode = parseDockerContainerExitCode(status);
|
|
341
|
+
const running = state === 'running' || /^Up\b/i.test(status);
|
|
342
|
+
|
|
343
|
+
for (const name of splitDockerNames(data.Names || data.Name || data.names || data.name)) {
|
|
344
|
+
if (!isDockerIsolationSessionName(name)) continue;
|
|
345
|
+
if (seen.has(name)) continue;
|
|
346
|
+
seen.add(name);
|
|
347
|
+
containers.push({
|
|
348
|
+
id: data.ID || data.Id || data.id || null,
|
|
349
|
+
name,
|
|
350
|
+
image: data.Image || data.image || null,
|
|
351
|
+
state,
|
|
352
|
+
status,
|
|
353
|
+
exitCode,
|
|
354
|
+
running,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return containers;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Enumerate Docker-isolation task containers from the local Docker daemon.
|
|
364
|
+
* Returns an empty list when docker is unavailable.
|
|
365
|
+
*
|
|
366
|
+
* @returns {Array}
|
|
367
|
+
*/
|
|
368
|
+
export function listDockerIsolationContainers() {
|
|
369
|
+
const out = tryExec('docker', ['ps', '-a', '--format', '{{json .}}']);
|
|
370
|
+
return out ? parseDockerPsJsonLines(out) : [];
|
|
371
|
+
}
|
|
372
|
+
|
|
306
373
|
function listStartCommandLogFiles(logRoot, maxFiles) {
|
|
307
374
|
const files = [];
|
|
308
375
|
const stack = [logRoot];
|
|
@@ -677,7 +744,7 @@ export function resolvePrHeadBranch(ref) {
|
|
|
677
744
|
* @param {Object} [options]
|
|
678
745
|
* @param {boolean} [options.verbose=false]
|
|
679
746
|
* @param {boolean} [options.resolveBranches=false] - resolve PR head branches via gh
|
|
680
|
-
* @returns {Promise<Array<{owner, repo, type, number, branch: string|null, sessionId: string|null, sessionName: string|null, status: string|null, workspace: string|null, terminal: boolean, startTime: string|null}>>}
|
|
747
|
+
* @returns {Promise<Array<{owner, repo, type, number, branch: string|null, sessionId: string|null, sessionName: string|null, status: string|null, exitCode: number|null, isolation: string|null, workspace: string|null, terminal: boolean, startTime: string|null}>>}
|
|
681
748
|
*/
|
|
682
749
|
export async function listSessionTasks(options = {}) {
|
|
683
750
|
const { verbose = false, resolveBranches = false } = options;
|
|
@@ -711,6 +778,8 @@ export async function listSessionTasks(options = {}) {
|
|
|
711
778
|
sessionId: session.uuid || null,
|
|
712
779
|
sessionName: session.sessionName || null,
|
|
713
780
|
status: session.status || null,
|
|
781
|
+
exitCode: session.exitCode ?? null,
|
|
782
|
+
isolation: session.isolation || null,
|
|
714
783
|
workspace: session.workingDirectory || null,
|
|
715
784
|
terminal,
|
|
716
785
|
startTime: session.startTime || null,
|
|
@@ -784,6 +853,18 @@ export function removePath(targetPath) {
|
|
|
784
853
|
}
|
|
785
854
|
}
|
|
786
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Remove a Docker-isolation task container by session UUID. Returns false for
|
|
858
|
+
* invalid names, missing docker, missing containers, or docker errors.
|
|
859
|
+
*
|
|
860
|
+
* @param {string} containerName
|
|
861
|
+
* @returns {boolean}
|
|
862
|
+
*/
|
|
863
|
+
export function removeDockerContainer(containerName) {
|
|
864
|
+
if (!isDockerIsolationSessionName(containerName)) return false;
|
|
865
|
+
return tryExec('docker', ['rm', '-f', containerName], { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] }) !== null;
|
|
866
|
+
}
|
|
867
|
+
|
|
787
868
|
/**
|
|
788
869
|
* System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
|
|
789
870
|
* are only described, never executed.
|
|
@@ -488,7 +488,7 @@ export function readSessionExitFromLog(logPath, options = {}) {
|
|
|
488
488
|
*/
|
|
489
489
|
async function findStartCommandBinary() {
|
|
490
490
|
try {
|
|
491
|
-
const result = await
|
|
491
|
+
const result = await $({ mirror: false })`which $`;
|
|
492
492
|
const path = result.stdout?.toString().trim() || '';
|
|
493
493
|
return path || null;
|
|
494
494
|
} catch {
|
|
@@ -831,6 +831,47 @@ export async function checkDockerContainerRunning(containerName, verbose = false
|
|
|
831
831
|
}
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Best-effort removal for a Docker container backing a native
|
|
836
|
+
* `$ --isolated docker` session.
|
|
837
|
+
*
|
|
838
|
+
* start-command names the container after the `--session` value. The monitor
|
|
839
|
+
* calls this only after the session is terminal and after the host-side log has
|
|
840
|
+
* been inspected, so removing the container reclaims its writable layer without
|
|
841
|
+
* losing the captured task log. Never throws: completion notification must not
|
|
842
|
+
* fail just because Docker already removed the container.
|
|
843
|
+
*
|
|
844
|
+
* @param {string} containerName - Container name (the session UUID)
|
|
845
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
846
|
+
* @returns {Promise<{success: boolean, output: string, error: string|null}>}
|
|
847
|
+
*/
|
|
848
|
+
export async function removeDockerContainer(containerName, verbose = false) {
|
|
849
|
+
if (!containerName) {
|
|
850
|
+
return { success: false, output: '', error: 'missing container name' };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
const result = await $({ mirror: false })`docker rm -f ${containerName}`;
|
|
855
|
+
const stdout = result.stdout?.toString() || '';
|
|
856
|
+
const stderr = result.stderr?.toString() || '';
|
|
857
|
+
if (verbose) {
|
|
858
|
+
console.log(`[VERBOSE] isolation-runner: docker rm -f '${containerName}' succeeded`);
|
|
859
|
+
}
|
|
860
|
+
return { success: true, output: stdout || stderr, error: null };
|
|
861
|
+
} catch (error) {
|
|
862
|
+
const stderr = error?.stderr?.toString?.() || '';
|
|
863
|
+
const stdout = error?.stdout?.toString?.() || '';
|
|
864
|
+
if (verbose) {
|
|
865
|
+
console.log(`[VERBOSE] isolation-runner: docker rm -f '${containerName}' failed: ${stderr.trim() || error?.message || error}`);
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
success: false,
|
|
869
|
+
output: stdout,
|
|
870
|
+
error: stderr.trim() || error?.message || String(error),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
834
875
|
/**
|
|
835
876
|
* Check whether a tmux session with the given name still exists.
|
|
836
877
|
* `tmux has-session -t <name>` exits 0 when it exists and non-zero otherwise,
|
|
@@ -110,6 +110,20 @@ export function getIsolationSessionStateForTests(sessionName, sessionInfo, optio
|
|
|
110
110
|
* mechanism will no longer be needed.
|
|
111
111
|
*/
|
|
112
112
|
export const NON_ISOLATION_SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
113
|
+
export const DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY = 'on-failure';
|
|
114
|
+
export const DOCKER_TASK_CONTAINER_KEEP_POLICIES = ['always', 'on-failure', 'never'];
|
|
115
|
+
|
|
116
|
+
export function resolveDockerTaskContainerKeepPolicy({ env = process.env, verbose = false } = {}) {
|
|
117
|
+
const raw = String(env?.HIVE_MIND_KEEP_TASK_CONTAINER || '')
|
|
118
|
+
.trim()
|
|
119
|
+
.toLowerCase();
|
|
120
|
+
if (!raw) return DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY;
|
|
121
|
+
if (DOCKER_TASK_CONTAINER_KEEP_POLICIES.includes(raw)) return raw;
|
|
122
|
+
if (verbose) {
|
|
123
|
+
console.log(`[VERBOSE] Invalid HIVE_MIND_KEEP_TASK_CONTAINER='${raw}', using '${DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY}'`);
|
|
124
|
+
}
|
|
125
|
+
return DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY;
|
|
126
|
+
}
|
|
113
127
|
|
|
114
128
|
/**
|
|
115
129
|
* Check if a screen session exists
|
|
@@ -345,6 +359,70 @@ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, read
|
|
|
345
359
|
}
|
|
346
360
|
}
|
|
347
361
|
|
|
362
|
+
function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
|
|
363
|
+
const outcome = classifySessionOutcome({ exitCode, status });
|
|
364
|
+
if (outcome.failed) return false;
|
|
365
|
+
if (exitCode === 0) return true;
|
|
366
|
+
|
|
367
|
+
const normalizedStatus = String(status || '')
|
|
368
|
+
.trim()
|
|
369
|
+
.toLowerCase();
|
|
370
|
+
return exitCode === null && (normalizedStatus === 'executed' || normalizedStatus === 'completed');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) {
|
|
374
|
+
return ['*Docker container kept*', `Container: \`${containerName}\``, `Policy: \`HIVE_MIND_KEEP_TASK_CONTAINER=${keepPolicy}\``, `Inspect: \`docker start -ai ${containerName}\``, `Shell: \`docker exec -it ${containerName} sh\``, `Remove when done: \`docker rm -f ${containerName}\``].join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function buildDockerTaskContainerCompletionAction({ sessionName, sessionInfo, exitCode = null, status = null, env = process.env, verbose = false } = {}) {
|
|
378
|
+
if (sessionInfo?.isolationBackend !== 'docker') {
|
|
379
|
+
return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const containerName = sessionInfo.sessionId || sessionName || null;
|
|
383
|
+
if (!containerName) {
|
|
384
|
+
return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const keepPolicy = resolveDockerTaskContainerKeepPolicy({ env, verbose });
|
|
388
|
+
const successful = isSuccessfulTaskCompletion({ exitCode, status });
|
|
389
|
+
const shouldKeep = keepPolicy === 'always' || (keepPolicy === 'on-failure' && !successful);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
applies: true,
|
|
393
|
+
containerName,
|
|
394
|
+
keepPolicy,
|
|
395
|
+
successful,
|
|
396
|
+
shouldRemove: !shouldKeep,
|
|
397
|
+
extraSection: shouldKeep ? formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) : '',
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function applyDockerTaskContainerCompletionAction(action, { verbose = false, removeDockerContainer = null } = {}) {
|
|
402
|
+
if (!action?.applies || !action.shouldRemove || !action.containerName) return;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const removeFn =
|
|
406
|
+
removeDockerContainer ||
|
|
407
|
+
(async (containerName, removeVerbose) => {
|
|
408
|
+
const runner = await getIsolationRunner();
|
|
409
|
+
return runner.removeDockerContainer(containerName, removeVerbose);
|
|
410
|
+
});
|
|
411
|
+
const result = await removeFn(action.containerName, verbose);
|
|
412
|
+
if (verbose) {
|
|
413
|
+
if (result?.success) {
|
|
414
|
+
console.log(`[VERBOSE] Removed docker task container '${action.containerName}' after terminal session completion`);
|
|
415
|
+
} else {
|
|
416
|
+
console.log(`[VERBOSE] Could not remove docker task container '${action.containerName}': ${result?.error || 'unknown error'}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (verbose) {
|
|
421
|
+
console.log(`[VERBOSE] Could not remove docker task container '${action.containerName}': ${error?.message || error}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
348
426
|
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
349
427
|
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
350
428
|
const elapsed = Date.now() - startTime.getTime();
|
|
@@ -602,8 +680,17 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
602
680
|
if (!stillRunning) {
|
|
603
681
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
604
682
|
|
|
683
|
+
let dockerTaskContainerAction = null;
|
|
605
684
|
try {
|
|
606
685
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
686
|
+
dockerTaskContainerAction = buildDockerTaskContainerCompletionAction({
|
|
687
|
+
sessionName,
|
|
688
|
+
sessionInfo,
|
|
689
|
+
exitCode: finalExitCode,
|
|
690
|
+
status: resolvedStatus,
|
|
691
|
+
env: options.env || process.env,
|
|
692
|
+
verbose,
|
|
693
|
+
});
|
|
607
694
|
|
|
608
695
|
// Issue #1688/#1905: When the original /solve URL was an issue, look up
|
|
609
696
|
// the created PR so the completion message can include both an
|
|
@@ -709,6 +796,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
709
796
|
console.log(`[VERBOSE] Could not build disk diagnostics section for ${sessionName}: ${diskError?.message || diskError}`);
|
|
710
797
|
}
|
|
711
798
|
}
|
|
799
|
+
const dockerTaskContainerExtraSections = dockerTaskContainerAction?.extraSection ? [dockerTaskContainerAction.extraSection] : [];
|
|
712
800
|
|
|
713
801
|
const message = formatSessionCompletionMessage({
|
|
714
802
|
sessionName,
|
|
@@ -718,7 +806,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
718
806
|
exitCode: finalExitCode,
|
|
719
807
|
infoBlock: sessionInfo?.infoBlock || '',
|
|
720
808
|
pullRequestUrl,
|
|
721
|
-
extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections],
|
|
809
|
+
extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections, ...dockerTaskContainerExtraSections],
|
|
722
810
|
});
|
|
723
811
|
|
|
724
812
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
@@ -758,10 +846,18 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
758
846
|
}
|
|
759
847
|
}
|
|
760
848
|
|
|
849
|
+
await applyDockerTaskContainerCompletionAction(dockerTaskContainerAction, {
|
|
850
|
+
verbose,
|
|
851
|
+
removeDockerContainer: options.removeDockerContainer,
|
|
852
|
+
});
|
|
761
853
|
completeSession(sessionName, finalExitCode || 0, verbose, resolvedStatus);
|
|
762
854
|
} catch (error) {
|
|
763
855
|
console.error(`Failed to send completion notification for ${sessionName}:`, error);
|
|
764
856
|
if (isMessageAlreadyUpdatedError(error)) {
|
|
857
|
+
await applyDockerTaskContainerCompletionAction(dockerTaskContainerAction, {
|
|
858
|
+
verbose,
|
|
859
|
+
removeDockerContainer: options.removeDockerContainer,
|
|
860
|
+
});
|
|
765
861
|
completeSession(sessionName, exitCode || 0, verbose, resolvedStatus);
|
|
766
862
|
} else {
|
|
767
863
|
sessionInfo.lastNotificationError = error.message;
|