@gajae-code/coding-agent 0.12.19 → 0.12.20
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 +20 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -13,9 +13,24 @@
|
|
|
13
13
|
* - Dry-run by default: nothing is deleted unless `--prune`/`--force`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import type { BigIntStats, Dirent, Stats } from "node:fs";
|
|
17
|
+
import * as fsp from "node:fs/promises";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { exactRemoveDirectoryTree, exactUnlink, snapshotDirectoryTree } from "@gajae-code/natives";
|
|
21
|
+
import { getAgentDir, getBlobsDir, getSessionsDir, isEnoent, VERSION } from "@gajae-code/utils";
|
|
22
|
+
import { getDefault } from "../config/settings-schema";
|
|
23
|
+
import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
|
|
17
24
|
import { SessionIndex } from "../sdk/broker/session-index";
|
|
18
25
|
import { UnsupportedStateVersionError } from "../sdk/broker/state-version";
|
|
26
|
+
import {
|
|
27
|
+
BLOB_REFERENCE_MAX_LENGTH,
|
|
28
|
+
type CanonicalBlobEntry,
|
|
29
|
+
collectBlobReferences,
|
|
30
|
+
listCanonicalBlobs,
|
|
31
|
+
removeCanonicalBlob,
|
|
32
|
+
} from "../session/blob-store";
|
|
33
|
+
import { FileSessionStorage, probeSessionRetirement, retireSessionTranscript } from "../session/session-storage";
|
|
19
34
|
|
|
20
35
|
import { buildGcReportText } from "./gc-render";
|
|
21
36
|
import { collectSessionScopeUsage, type GcSessionScopeUsage, shouldReportSessionScope } from "./gc-session-scope";
|
|
@@ -155,6 +170,11 @@ export interface GcReport {
|
|
|
155
170
|
session_index?: GcSessionIndexHealth;
|
|
156
171
|
/** Managed-scope capacity, reported only when it is near or past the budget. */
|
|
157
172
|
session_scope?: GcSessionScopeUsage;
|
|
173
|
+
/**
|
|
174
|
+
* Disk-retention findings. Present only when `--disk` was passed; the
|
|
175
|
+
* PID-liveness axis above is unchanged by its absence or presence.
|
|
176
|
+
*/
|
|
177
|
+
disk?: GcDiskReport;
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
export interface GcRunResult {
|
|
@@ -261,6 +281,8 @@ interface ParsedGcArgs {
|
|
|
261
281
|
json: boolean;
|
|
262
282
|
prune: boolean;
|
|
263
283
|
repairSessionIndex: boolean;
|
|
284
|
+
/** Opt-in second axis: report (and with `--prune`, reclaim) on-disk retention. */
|
|
285
|
+
disk: boolean;
|
|
264
286
|
help: boolean;
|
|
265
287
|
}
|
|
266
288
|
|
|
@@ -270,6 +292,7 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
|
|
|
270
292
|
let json = false;
|
|
271
293
|
let prune = false;
|
|
272
294
|
let repairSessionIndex = false;
|
|
295
|
+
let disk = false;
|
|
273
296
|
|
|
274
297
|
let dryRun = false;
|
|
275
298
|
let help = false;
|
|
@@ -286,6 +309,9 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
|
|
|
286
309
|
case "--repair-session-index":
|
|
287
310
|
repairSessionIndex = true;
|
|
288
311
|
break;
|
|
312
|
+
case "--disk":
|
|
313
|
+
disk = true;
|
|
314
|
+
break;
|
|
289
315
|
|
|
290
316
|
case "--dry-run":
|
|
291
317
|
dryRun = true;
|
|
@@ -302,7 +328,7 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
|
|
|
302
328
|
if (repairSessionIndex && dryRun) throw new GcUsageError("repair_session_index_cannot_combine_with_dry_run");
|
|
303
329
|
// Explicit --dry-run always wins over --prune/--force.
|
|
304
330
|
if (dryRun) prune = false;
|
|
305
|
-
return { json, prune, repairSessionIndex, help };
|
|
331
|
+
return { json, prune, repairSessionIndex, disk, help };
|
|
306
332
|
}
|
|
307
333
|
|
|
308
334
|
/**
|
|
@@ -376,10 +402,19 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
|
|
|
376
402
|
* - prune mode with a failed intended removal => 1
|
|
377
403
|
* - warnings alone never fail the run
|
|
378
404
|
* - otherwise => 0
|
|
405
|
+
*
|
|
406
|
+
* The disk axis reuses the same policy against its own errors/failures, and is
|
|
407
|
+
* inert when `--disk` was not passed (`report.disk` is then undefined). A
|
|
408
|
+
* fail-closed KEEP is never a failure — only a hard scan error or a reclaim
|
|
409
|
+
* that was attempted and threw.
|
|
379
410
|
*/
|
|
380
411
|
export function computeExitCode(report: GcReport): number {
|
|
381
412
|
if (report.errors.length > 0) return 1;
|
|
382
413
|
if (!report.dry_run && report.counts.failed > 0) return 1;
|
|
414
|
+
if (report.disk) {
|
|
415
|
+
if (report.disk.errors.length > 0) return 1;
|
|
416
|
+
if (!report.disk.dry_run && report.disk.totals.failed > 0) return 1;
|
|
417
|
+
}
|
|
383
418
|
return 0;
|
|
384
419
|
}
|
|
385
420
|
|
|
@@ -440,6 +475,7 @@ export async function runGjcGcCommand(
|
|
|
440
475
|
cwd: string = process.cwd(),
|
|
441
476
|
env: NodeJS.ProcessEnv = process.env,
|
|
442
477
|
adapters?: GcStoreAdapter[],
|
|
478
|
+
diskPolicy?: Partial<GcDiskPolicy>,
|
|
443
479
|
): Promise<GcRunResult> {
|
|
444
480
|
let parsed: ParsedGcArgs;
|
|
445
481
|
try {
|
|
@@ -458,6 +494,14 @@ export async function runGjcGcCommand(
|
|
|
458
494
|
const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune);
|
|
459
495
|
report.operation = parsed.repairSessionIndex ? "repair_session_index" : parsed.prune ? "prune" : "dry_run";
|
|
460
496
|
report.session_index = await collectSessionIndexHealth(parsed.repairSessionIndex, resolveGcAgentDir(env));
|
|
497
|
+
if (parsed.disk) {
|
|
498
|
+
report.disk = await collectGcDiskReport({
|
|
499
|
+
agentDir: resolveGcAgentDir(env),
|
|
500
|
+
env,
|
|
501
|
+
policy: resolveGcDiskPolicy(diskPolicy),
|
|
502
|
+
prune: parsed.prune,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
461
505
|
const scopeUsage = await collectGcSessionScope(cwd, resolveGcAgentDir(env));
|
|
462
506
|
if (scopeUsage && shouldReportSessionScope(scopeUsage)) report.session_scope = scopeUsage;
|
|
463
507
|
const sessionIndexFailed =
|
|
@@ -465,7 +509,9 @@ export async function runGjcGcCommand(
|
|
|
465
509
|
report.session_index?.status === "unsupported" ||
|
|
466
510
|
report.session_index?.status === "repair_failed";
|
|
467
511
|
const status = sessionIndexFailed ? 1 : computeExitCode(report);
|
|
468
|
-
const stdout = parsed.json
|
|
512
|
+
const stdout = parsed.json
|
|
513
|
+
? `${JSON.stringify(report, null, 2)}\n`
|
|
514
|
+
: `${buildGcReportText(report)}${report.disk ? buildGcDiskReportText(report.disk) : ""}`;
|
|
469
515
|
return { stdout, stderr: "", status };
|
|
470
516
|
}
|
|
471
517
|
|
|
@@ -474,7 +520,7 @@ export function gcHelpText(): string {
|
|
|
474
520
|
"gjc gc - garbage-collect stale GJC session/PID records",
|
|
475
521
|
"",
|
|
476
522
|
"USAGE",
|
|
477
|
-
" $ gjc gc [--prune|--force] [--repair-session-index] [--json]",
|
|
523
|
+
" $ gjc gc [--prune|--force] [--disk] [--repair-session-index] [--json]",
|
|
478
524
|
|
|
479
525
|
"",
|
|
480
526
|
"FLAGS",
|
|
@@ -482,10 +528,16 @@ export function gcHelpText(): string {
|
|
|
482
528
|
" --dry-run Force report-only mode (overrides --prune/--force)",
|
|
483
529
|
" -j, --json Emit machine-readable JSON",
|
|
484
530
|
" --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
|
|
531
|
+
" --disk Also report on-disk retention (sessions, blobs, natives, backups)",
|
|
485
532
|
"",
|
|
486
533
|
"Liveness-only: a record is removed only when its owning process is dead",
|
|
487
534
|
"(ESRCH). Live / permission-denied / unknown processes are always kept.",
|
|
488
535
|
"",
|
|
536
|
+
"Disk retention (--disk) is a separate, opt-in axis. Without --prune it only",
|
|
537
|
+
"reports reclaimable bytes per surface. Live, referenced, permission-denied or",
|
|
538
|
+
"otherwise ambiguous state is always KEPT and the report says why. Managed",
|
|
539
|
+
"worktrees under ~/.gjc/wt are never touched.",
|
|
540
|
+
"",
|
|
489
541
|
].join("\n");
|
|
490
542
|
}
|
|
491
543
|
|
|
@@ -513,3 +565,1230 @@ export async function defaultGcAdapters(): Promise<GcStoreAdapter[]> {
|
|
|
513
565
|
localRootsGcAdapter,
|
|
514
566
|
];
|
|
515
567
|
}
|
|
568
|
+
|
|
569
|
+
// =============================================================================
|
|
570
|
+
// Disk-retention axis (`gjc gc --disk`)
|
|
571
|
+
// =============================================================================
|
|
572
|
+
//
|
|
573
|
+
// A second, explicitly opt-in axis. The PID-liveness axis above answers "is the
|
|
574
|
+
// owner of this record dead?"; this one answers "are these bytes still reachable
|
|
575
|
+
// from anything live?". Both share the same posture: dry-run by default, and
|
|
576
|
+
// anything live, referenced, permission-denied, unreadable or ambiguous is KEPT
|
|
577
|
+
// with the reason recorded in the report.
|
|
578
|
+
//
|
|
579
|
+
// Deliberately out of scope: `~/.gjc/wt` managed worktrees. Removing a worktree
|
|
580
|
+
// needs evidence-based merge detection, which this axis does not have.
|
|
581
|
+
|
|
582
|
+
/** On-disk surfaces the retention axis can reclaim. */
|
|
583
|
+
export type GcDiskSurface = "sessions" | "blobs" | "natives" | "backups";
|
|
584
|
+
|
|
585
|
+
export const GC_DISK_SURFACES: readonly GcDiskSurface[] = ["sessions", "blobs", "natives", "backups"] as const;
|
|
586
|
+
|
|
587
|
+
export type GcDiskAction = "keep" | "would_reclaim" | "reclaimed" | "reclaim_failed";
|
|
588
|
+
|
|
589
|
+
export interface GcDiskRecord {
|
|
590
|
+
surface: GcDiskSurface;
|
|
591
|
+
/** Session id, blob hash, natives version, or backup entry name. */
|
|
592
|
+
id: string;
|
|
593
|
+
path: string;
|
|
594
|
+
bytes: number;
|
|
595
|
+
age_days: number;
|
|
596
|
+
action: GcDiskAction;
|
|
597
|
+
reason: string;
|
|
598
|
+
error?: string;
|
|
599
|
+
/** Set when `bytes` is a floor because a walk was capped or partially unreadable. */
|
|
600
|
+
partial?: true;
|
|
601
|
+
/** Set when this entry was a reclaim candidate that its surface withheld on incomplete evidence. */
|
|
602
|
+
withheld?: true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Why a surface refused to reclaim anything, and how much it withheld to stay
|
|
607
|
+
* fail-closed. Present only when the surface declined.
|
|
608
|
+
*/
|
|
609
|
+
export interface GcDiskDeclined {
|
|
610
|
+
reason: string;
|
|
611
|
+
withheld: number;
|
|
612
|
+
withheld_bytes: number;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export interface GcDiskSurfaceReport {
|
|
616
|
+
surface: GcDiskSurface;
|
|
617
|
+
root: string;
|
|
618
|
+
scanned: number;
|
|
619
|
+
scanned_bytes: number;
|
|
620
|
+
reclaimable: number;
|
|
621
|
+
reclaimable_bytes: number;
|
|
622
|
+
reclaimed: number;
|
|
623
|
+
reclaimed_bytes: number;
|
|
624
|
+
kept: number;
|
|
625
|
+
kept_bytes: number;
|
|
626
|
+
failed: number;
|
|
627
|
+
/** Set when the surface declined to reclaim anything because its evidence was incomplete. */
|
|
628
|
+
declined?: GcDiskDeclined;
|
|
629
|
+
records: GcDiskRecord[];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export interface GcDiskError {
|
|
633
|
+
surface: GcDiskSurface;
|
|
634
|
+
scope: string;
|
|
635
|
+
message: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Retention policy, mirroring the `gc.*` settings one-for-one. */
|
|
639
|
+
export interface GcDiskPolicy {
|
|
640
|
+
sessions_max_age_days: number;
|
|
641
|
+
/** 0 disables the size axis; only the age axis retires transcripts then. */
|
|
642
|
+
sessions_max_total_bytes: number;
|
|
643
|
+
natives_keep_versions: number;
|
|
644
|
+
backups_max_age_days: number;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export interface GcDiskReport {
|
|
648
|
+
dry_run: boolean;
|
|
649
|
+
policy: GcDiskPolicy;
|
|
650
|
+
surfaces: Record<GcDiskSurface, GcDiskSurfaceReport>;
|
|
651
|
+
totals: {
|
|
652
|
+
scanned_bytes: number;
|
|
653
|
+
reclaimable_bytes: number;
|
|
654
|
+
reclaimed_bytes: number;
|
|
655
|
+
kept_bytes: number;
|
|
656
|
+
failed: number;
|
|
657
|
+
};
|
|
658
|
+
errors: GcDiskError[];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const GC_DISK_DAY_MS = 24 * 60 * 60 * 1000;
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* A blob younger than this is never swept: a live session may have written the
|
|
665
|
+
* bytes but not yet appended the entry that references them.
|
|
666
|
+
*/
|
|
667
|
+
const GC_DISK_BLOB_GRACE_MS = GC_DISK_DAY_MS;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* How many extra times the blob mark may re-absorb transcripts that appeared or
|
|
671
|
+
* grew while it was running. Ordinary concurrent session activity settles in one
|
|
672
|
+
* extra round; a store that still will not settle is not evidence anyone can
|
|
673
|
+
* sweep on, so the sweep withholds instead.
|
|
674
|
+
*/
|
|
675
|
+
const GC_DISK_MARK_REMARK_ROUNDS = 2;
|
|
676
|
+
|
|
677
|
+
/** Bound every recursive size walk so a pathological tree cannot stall `gjc gc`. */
|
|
678
|
+
const GC_DISK_MAX_WALK_ENTRIES = 200_000;
|
|
679
|
+
|
|
680
|
+
/** Cap per-surface record rendering; the JSON output always carries every record. */
|
|
681
|
+
const GC_DISK_MAX_RENDERED_RECORDS = 20;
|
|
682
|
+
|
|
683
|
+
/** Schema-backed defaults, so the CLI and the settings surface cannot drift. */
|
|
684
|
+
export const GC_DISK_POLICY_DEFAULTS: GcDiskPolicy = {
|
|
685
|
+
sessions_max_age_days: getDefault("gc.sessions.maxAgeDays"),
|
|
686
|
+
sessions_max_total_bytes: getDefault("gc.sessions.maxTotalBytes"),
|
|
687
|
+
natives_keep_versions: getDefault("gc.natives.keepVersions"),
|
|
688
|
+
backups_max_age_days: getDefault("gc.backups.maxAgeDays"),
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
export function resolveGcDiskPolicy(overrides: Partial<GcDiskPolicy> = {}): GcDiskPolicy {
|
|
692
|
+
return {
|
|
693
|
+
sessions_max_age_days: overrides.sessions_max_age_days ?? GC_DISK_POLICY_DEFAULTS.sessions_max_age_days,
|
|
694
|
+
sessions_max_total_bytes: overrides.sessions_max_total_bytes ?? GC_DISK_POLICY_DEFAULTS.sessions_max_total_bytes,
|
|
695
|
+
natives_keep_versions: overrides.natives_keep_versions ?? GC_DISK_POLICY_DEFAULTS.natives_keep_versions,
|
|
696
|
+
backups_max_age_days: overrides.backups_max_age_days ?? GC_DISK_POLICY_DEFAULTS.backups_max_age_days,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function gcDiskErrorText(error: unknown): string {
|
|
701
|
+
return error instanceof Error ? error.message : String(error);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function gcDiskAgeDays(now: number, mtimeMs: number): number {
|
|
705
|
+
return Math.round(((now - mtimeMs) / GC_DISK_DAY_MS) * 100) / 100;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function emptyGcDiskSurface(surface: GcDiskSurface, root: string): GcDiskSurfaceReport {
|
|
709
|
+
return {
|
|
710
|
+
surface,
|
|
711
|
+
root,
|
|
712
|
+
scanned: 0,
|
|
713
|
+
scanned_bytes: 0,
|
|
714
|
+
reclaimable: 0,
|
|
715
|
+
reclaimable_bytes: 0,
|
|
716
|
+
reclaimed: 0,
|
|
717
|
+
reclaimed_bytes: 0,
|
|
718
|
+
kept: 0,
|
|
719
|
+
kept_bytes: 0,
|
|
720
|
+
failed: 0,
|
|
721
|
+
records: [],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function summarizeGcDiskSurface(report: GcDiskSurfaceReport): void {
|
|
726
|
+
report.scanned = report.records.length;
|
|
727
|
+
report.scanned_bytes = 0;
|
|
728
|
+
report.reclaimable = 0;
|
|
729
|
+
report.reclaimable_bytes = 0;
|
|
730
|
+
report.reclaimed = 0;
|
|
731
|
+
report.reclaimed_bytes = 0;
|
|
732
|
+
report.kept = 0;
|
|
733
|
+
report.kept_bytes = 0;
|
|
734
|
+
report.failed = 0;
|
|
735
|
+
for (const record of report.records) {
|
|
736
|
+
report.scanned_bytes += record.bytes;
|
|
737
|
+
switch (record.action) {
|
|
738
|
+
case "would_reclaim":
|
|
739
|
+
report.reclaimable++;
|
|
740
|
+
report.reclaimable_bytes += record.bytes;
|
|
741
|
+
break;
|
|
742
|
+
case "reclaimed":
|
|
743
|
+
report.reclaimed++;
|
|
744
|
+
report.reclaimed_bytes += record.bytes;
|
|
745
|
+
break;
|
|
746
|
+
case "reclaim_failed":
|
|
747
|
+
report.failed++;
|
|
748
|
+
report.kept_bytes += record.bytes;
|
|
749
|
+
break;
|
|
750
|
+
default:
|
|
751
|
+
report.kept++;
|
|
752
|
+
report.kept_bytes += record.bytes;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Recursive, bounded byte count. Symlinks are never followed and never counted.
|
|
759
|
+
*
|
|
760
|
+
* `partial` means `bytes` is only a floor. `unreadable` is the narrower, harder
|
|
761
|
+
* fact: some entry in the tree could not be enumerated or stat'd, so the walk is
|
|
762
|
+
* blind to part of what it just measured. A caller about to remove the tree must
|
|
763
|
+
* treat that as incomplete evidence — a recursive remove cannot finish past an
|
|
764
|
+
* unreadable entry, so it would destroy the readable half and leave the rest.
|
|
765
|
+
*
|
|
766
|
+
* An entry that vanished mid-walk (`ENOENT`) is the opposite fact: it is gone,
|
|
767
|
+
* not withheld. A vanished entry cannot block a recursive remove, so it only
|
|
768
|
+
* makes `bytes` a floor and never withholds the tree.
|
|
769
|
+
*
|
|
770
|
+
* The walk is bounded in sizing work, not in reach: past
|
|
771
|
+
* {@link GC_DISK_MAX_WALK_ENTRIES} it stops stat'ing files but keeps opening
|
|
772
|
+
* directories, so a huge but perfectly readable tree stays reclaimable instead
|
|
773
|
+
* of being reported as unreadable.
|
|
774
|
+
*/
|
|
775
|
+
async function measureGcDiskTree(root: string): Promise<{ bytes: number; partial: boolean; unreadable: boolean }> {
|
|
776
|
+
let bytes = 0;
|
|
777
|
+
let visited = 0;
|
|
778
|
+
let partial = false;
|
|
779
|
+
let unreadable = false;
|
|
780
|
+
const stack: string[] = [root];
|
|
781
|
+
while (stack.length > 0) {
|
|
782
|
+
const dir = stack.pop()!;
|
|
783
|
+
let entries: Dirent[];
|
|
784
|
+
try {
|
|
785
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (!isEnoent(error)) {
|
|
788
|
+
partial = true;
|
|
789
|
+
unreadable = true;
|
|
790
|
+
}
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
visited++;
|
|
795
|
+
const child = path.join(dir, entry.name);
|
|
796
|
+
if (entry.isSymbolicLink()) {
|
|
797
|
+
partial = true;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (entry.isDirectory()) {
|
|
801
|
+
stack.push(child);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (!entry.isFile()) continue;
|
|
805
|
+
// The entry cap bounds the per-file `lstat` work, not the enumeration:
|
|
806
|
+
// past it `bytes` is a floor, but every directory is still opened so
|
|
807
|
+
// `unreadable` stays a complete answer for the whole tree.
|
|
808
|
+
if (visited > GC_DISK_MAX_WALK_ENTRIES) {
|
|
809
|
+
partial = true;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
bytes += (await fsp.lstat(child)).size;
|
|
814
|
+
} catch (error) {
|
|
815
|
+
partial = true;
|
|
816
|
+
if (!isEnoent(error)) unreadable = true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return { bytes, partial, unreadable };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Session ids reachable from a live surface. `complete: false` means at least
|
|
825
|
+
* one reference source could not be enumerated, in which case NO session may be
|
|
826
|
+
* retired — an unproven reference is treated as a live one.
|
|
827
|
+
*/
|
|
828
|
+
interface GcSessionReferences {
|
|
829
|
+
ids: Set<string>;
|
|
830
|
+
complete: boolean;
|
|
831
|
+
notes: string[];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function collectGcSessionReferences(agentDir: string, env: NodeJS.ProcessEnv): Promise<GcSessionReferences> {
|
|
835
|
+
const ids = new Set<string>();
|
|
836
|
+
const notes: string[] = [];
|
|
837
|
+
let complete = true;
|
|
838
|
+
const incomplete = (note: string): void => {
|
|
839
|
+
complete = false;
|
|
840
|
+
if (!notes.includes(note)) notes.push(note);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// 1. Harness root registries and every per-session lease directory beneath
|
|
844
|
+
// their roots. A registry entry or a session directory is a reference even
|
|
845
|
+
// when its lease owner is already dead: the liveness axis reaps those.
|
|
846
|
+
try {
|
|
847
|
+
for (const registry of await listHarnessRootRegistriesForGc(env)) {
|
|
848
|
+
if (registry.error) {
|
|
849
|
+
incomplete("harness_root_registry_unreadable");
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (registry.sessionId) ids.add(registry.sessionId);
|
|
853
|
+
for (const entry of registry.roots) {
|
|
854
|
+
const harnessSessions = path.join(path.resolve(entry.root), "sessions");
|
|
855
|
+
try {
|
|
856
|
+
for (const name of await fsp.readdir(harnessSessions)) ids.add(name);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (!isEnoent(error)) incomplete("harness_session_dir_unreadable");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
incomplete("harness_root_registry_scan_failed");
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 2. SDK hosts registered in the broker session index.
|
|
867
|
+
const indexDir = path.join(agentDir, "sdk", "sessions");
|
|
868
|
+
const indexFiles = [path.join(indexDir, "index.jsonl"), path.join(indexDir, "index.snapshot.json")];
|
|
869
|
+
let indexPresent = false;
|
|
870
|
+
for (const file of indexFiles) {
|
|
871
|
+
try {
|
|
872
|
+
await fsp.stat(file);
|
|
873
|
+
indexPresent = true;
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (!isEnoent(error)) incomplete("sdk_session_index_unreadable");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (indexPresent) {
|
|
879
|
+
try {
|
|
880
|
+
const index = new SessionIndex(agentDir);
|
|
881
|
+
await index.replay();
|
|
882
|
+
for (const session of index.listSessions().sessions) ids.add(session.sessionId);
|
|
883
|
+
} catch {
|
|
884
|
+
incomplete("sdk_session_index_replay_failed");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// 3. `local://` session roots: one directory per session id.
|
|
889
|
+
const localRoots = path.join(env.TMPDIR?.trim() || os.tmpdir(), "gjc-local");
|
|
890
|
+
try {
|
|
891
|
+
for (const name of await fsp.readdir(localRoots)) ids.add(name);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (!isEnoent(error)) incomplete("local_root_parent_unreadable");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return { ids, complete, notes };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Whether a reference scan saw every file its conclusions depend on.
|
|
901
|
+
* `complete: false` is a hard veto on reclamation for any surface whose evidence
|
|
902
|
+
* lives in a file the scan could not open.
|
|
903
|
+
*/
|
|
904
|
+
interface GcDiskEvidence {
|
|
905
|
+
complete: boolean;
|
|
906
|
+
notes: string[];
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function markGcDiskEvidenceIncomplete(evidence: GcDiskEvidence, note: string): void {
|
|
910
|
+
evidence.complete = false;
|
|
911
|
+
if (!evidence.notes.includes(note)) evidence.notes.push(note);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** One managed session transcript plus its sibling artifact directory. */
|
|
915
|
+
interface GcDiskTranscript {
|
|
916
|
+
sessionId: string;
|
|
917
|
+
path: string;
|
|
918
|
+
directory: string;
|
|
919
|
+
bytes: number;
|
|
920
|
+
mtimeMs: number;
|
|
921
|
+
partial: boolean;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Every managed transcript, plus whether that enumeration is complete evidence.
|
|
926
|
+
*
|
|
927
|
+
* A transcript that could not be enumerated or stat'd is absent from
|
|
928
|
+
* `transcripts`, which leaves the session surface fail-closed by construction
|
|
929
|
+
* (an unseen transcript is never retired). It does NOT leave the blob sweep
|
|
930
|
+
* safe: a blob referenced only by an unseen transcript would look unreferenced,
|
|
931
|
+
* so `evidence` is threaded to every surface that marks across all transcripts.
|
|
932
|
+
*/
|
|
933
|
+
interface GcDiskTranscriptScan {
|
|
934
|
+
transcripts: GcDiskTranscript[];
|
|
935
|
+
evidence: GcDiskEvidence;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Walk every managed session transcript under `sessionsRoot` through exactly one
|
|
940
|
+
* enumeration filter. Discovery and the blob sweep's staleness re-check both go
|
|
941
|
+
* through here, so their two views of the store are comparable by construction;
|
|
942
|
+
* anything the walk cannot read degrades `evidence` instead of quietly
|
|
943
|
+
* shrinking the result.
|
|
944
|
+
*/
|
|
945
|
+
async function walkGcDiskTranscripts(input: {
|
|
946
|
+
sessionsRoot: string;
|
|
947
|
+
surface: GcDiskSurface;
|
|
948
|
+
evidence: GcDiskEvidence;
|
|
949
|
+
errors: GcDiskError[];
|
|
950
|
+
visit: (transcriptPath: string, directory: string, stat: Stats) => Promise<void> | void;
|
|
951
|
+
}): Promise<void> {
|
|
952
|
+
const { sessionsRoot, surface, evidence, errors, visit } = input;
|
|
953
|
+
let projectDirs: Dirent[];
|
|
954
|
+
try {
|
|
955
|
+
projectDirs = await fsp.readdir(sessionsRoot, { withFileTypes: true });
|
|
956
|
+
} catch (error) {
|
|
957
|
+
// A missing sessions root is complete evidence: there are no transcripts.
|
|
958
|
+
if (isEnoent(error)) return;
|
|
959
|
+
errors.push({ surface, scope: sessionsRoot, message: gcDiskErrorText(error) });
|
|
960
|
+
markGcDiskEvidenceIncomplete(evidence, "sessions_root_unreadable");
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
for (const projectDir of projectDirs) {
|
|
965
|
+
if (!projectDir.isDirectory() || projectDir.isSymbolicLink()) continue;
|
|
966
|
+
const directory = path.join(sessionsRoot, projectDir.name);
|
|
967
|
+
let files: Dirent[];
|
|
968
|
+
try {
|
|
969
|
+
files = await fsp.readdir(directory, { withFileTypes: true });
|
|
970
|
+
} catch (error) {
|
|
971
|
+
errors.push({ surface, scope: directory, message: gcDiskErrorText(error) });
|
|
972
|
+
markGcDiskEvidenceIncomplete(evidence, "session_project_dir_unreadable");
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
for (const file of files) {
|
|
976
|
+
if (!file.name.endsWith(".jsonl") || !file.isFile() || file.isSymbolicLink()) continue;
|
|
977
|
+
const transcriptPath = path.join(directory, file.name);
|
|
978
|
+
let stat: Stats;
|
|
979
|
+
try {
|
|
980
|
+
stat = await fsp.lstat(transcriptPath);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
errors.push({ surface, scope: transcriptPath, message: gcDiskErrorText(error) });
|
|
983
|
+
markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable");
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
await visit(transcriptPath, directory, stat);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function discoverGcDiskTranscripts(sessionsRoot: string, errors: GcDiskError[]): Promise<GcDiskTranscriptScan> {
|
|
992
|
+
const evidence: GcDiskEvidence = { complete: true, notes: [] };
|
|
993
|
+
const transcripts: GcDiskTranscript[] = [];
|
|
994
|
+
await walkGcDiskTranscripts({
|
|
995
|
+
sessionsRoot,
|
|
996
|
+
surface: "sessions",
|
|
997
|
+
evidence,
|
|
998
|
+
errors,
|
|
999
|
+
visit: async (transcriptPath, directory, stat) => {
|
|
1000
|
+
const artifacts = await measureGcDiskTree(transcriptPath.slice(0, -".jsonl".length));
|
|
1001
|
+
transcripts.push({
|
|
1002
|
+
sessionId: path.basename(transcriptPath, ".jsonl"),
|
|
1003
|
+
path: transcriptPath,
|
|
1004
|
+
directory,
|
|
1005
|
+
bytes: stat.size + artifacts.bytes,
|
|
1006
|
+
mtimeMs: stat.mtimeMs,
|
|
1007
|
+
partial: artifacts.partial,
|
|
1008
|
+
});
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
return { transcripts, evidence };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Classify (and optionally retire) session transcripts. Returns the transcripts
|
|
1016
|
+
* that survived, which is exactly the mark set for the blob sweep.
|
|
1017
|
+
*/
|
|
1018
|
+
async function runGcDiskSessions(input: {
|
|
1019
|
+
surface: GcDiskSurfaceReport;
|
|
1020
|
+
transcripts: GcDiskTranscript[];
|
|
1021
|
+
references: GcSessionReferences;
|
|
1022
|
+
policy: GcDiskPolicy;
|
|
1023
|
+
now: number;
|
|
1024
|
+
prune: boolean;
|
|
1025
|
+
}): Promise<GcDiskTranscript[]> {
|
|
1026
|
+
const { surface, transcripts, references, policy, now, prune } = input;
|
|
1027
|
+
const maxAgeMs = policy.sessions_max_age_days * GC_DISK_DAY_MS;
|
|
1028
|
+
|
|
1029
|
+
// The newest transcript in each project directory is the `--continue` resume
|
|
1030
|
+
// target, so it is never a retention candidate regardless of age.
|
|
1031
|
+
const newestPerDirectory = new Map<string, GcDiskTranscript>();
|
|
1032
|
+
for (const transcript of transcripts) {
|
|
1033
|
+
const current = newestPerDirectory.get(transcript.directory);
|
|
1034
|
+
if (!current || transcript.mtimeMs > current.mtimeMs) newestPerDirectory.set(transcript.directory, transcript);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
interface Classified {
|
|
1038
|
+
transcript: GcDiskTranscript;
|
|
1039
|
+
record: GcDiskRecord;
|
|
1040
|
+
/** Only transcripts kept purely because they are recent may be retired for size. */
|
|
1041
|
+
sizeEligible: boolean;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const classified: Classified[] = [];
|
|
1045
|
+
for (const transcript of transcripts) {
|
|
1046
|
+
const record: GcDiskRecord = {
|
|
1047
|
+
surface: "sessions",
|
|
1048
|
+
id: transcript.sessionId,
|
|
1049
|
+
path: transcript.path,
|
|
1050
|
+
bytes: transcript.bytes,
|
|
1051
|
+
age_days: gcDiskAgeDays(now, transcript.mtimeMs),
|
|
1052
|
+
action: "keep",
|
|
1053
|
+
reason: "",
|
|
1054
|
+
...(transcript.partial ? { partial: true as const } : {}),
|
|
1055
|
+
};
|
|
1056
|
+
let sizeEligible = false;
|
|
1057
|
+
if (!references.complete) {
|
|
1058
|
+
record.reason = `reference_scan_incomplete: ${references.notes.join(", ")}`;
|
|
1059
|
+
} else if (references.ids.has(transcript.sessionId)) {
|
|
1060
|
+
record.reason = "referenced_by_live_surface";
|
|
1061
|
+
} else if (newestPerDirectory.get(transcript.directory) === transcript) {
|
|
1062
|
+
record.reason = "most_recent_resumable_session";
|
|
1063
|
+
} else if (now - transcript.mtimeMs < maxAgeMs) {
|
|
1064
|
+
record.reason = `newer_than_max_age(${policy.sessions_max_age_days}d)`;
|
|
1065
|
+
sizeEligible = true;
|
|
1066
|
+
} else {
|
|
1067
|
+
record.action = "would_reclaim";
|
|
1068
|
+
record.reason = `older_than_max_age(${policy.sessions_max_age_days}d)`;
|
|
1069
|
+
}
|
|
1070
|
+
classified.push({ transcript, record, sizeEligible });
|
|
1071
|
+
surface.records.push(record);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Size axis: when the store would still be over budget after the age pass,
|
|
1075
|
+
// retire the oldest recent-but-unreferenced transcripts until it fits. A
|
|
1076
|
+
// liveness keep is never overridden.
|
|
1077
|
+
if (policy.sessions_max_total_bytes > 0) {
|
|
1078
|
+
let projected = classified.reduce(
|
|
1079
|
+
(sum, item) => (item.record.action === "would_reclaim" ? sum : sum + item.transcript.bytes),
|
|
1080
|
+
0,
|
|
1081
|
+
);
|
|
1082
|
+
if (projected > policy.sessions_max_total_bytes) {
|
|
1083
|
+
const eligible = classified
|
|
1084
|
+
.filter(item => item.sizeEligible && item.record.action === "keep")
|
|
1085
|
+
.sort((a, b) => a.transcript.mtimeMs - b.transcript.mtimeMs);
|
|
1086
|
+
for (const item of eligible) {
|
|
1087
|
+
if (projected <= policy.sessions_max_total_bytes) break;
|
|
1088
|
+
item.record.action = "would_reclaim";
|
|
1089
|
+
item.record.reason = `over_max_total_bytes(${policy.sessions_max_total_bytes})`;
|
|
1090
|
+
projected -= item.transcript.bytes;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const storage = new FileSessionStorage();
|
|
1096
|
+
if (!prune) {
|
|
1097
|
+
// Dry run must project the prune verdict, not just the policy verdict: the
|
|
1098
|
+
// retention pass re-checks its own preconditions before it may call the
|
|
1099
|
+
// delete authority, and a candidate that fails them is never removable.
|
|
1100
|
+
for (const item of classified) {
|
|
1101
|
+
if (item.record.action !== "would_reclaim") continue;
|
|
1102
|
+
const probe = probeSessionRetirement(storage, surface.root, item.transcript.path);
|
|
1103
|
+
if (probe.kind === "retirable") continue;
|
|
1104
|
+
item.record.action = "keep";
|
|
1105
|
+
item.record.reason = `retention_declined: ${probe.reason}`;
|
|
1106
|
+
}
|
|
1107
|
+
return classified.filter(item => item.record.action !== "would_reclaim").map(item => item.transcript);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Retirement goes through the identity-bound verified delete authority, which
|
|
1111
|
+
// re-reads and re-verifies the transcript. A declined retirement is a KEEP,
|
|
1112
|
+
// not a failure — the same posture the pid probe takes on EPERM/unknown. A
|
|
1113
|
+
// half-completed one is a failure: bytes are already gone.
|
|
1114
|
+
const survivors: GcDiskTranscript[] = [];
|
|
1115
|
+
for (const item of classified) {
|
|
1116
|
+
if (item.record.action !== "would_reclaim") {
|
|
1117
|
+
survivors.push(item.transcript);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const outcome = await retireSessionTranscript(storage, surface.root, item.transcript.path);
|
|
1121
|
+
if (outcome.kind === "retired") {
|
|
1122
|
+
item.record.action = "reclaimed";
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
if (outcome.kind === "cleanup_pending") {
|
|
1126
|
+
// The transcript survives but its artifact tree was already detached or
|
|
1127
|
+
// partially removed. Reporting that as a plain keep would hide a
|
|
1128
|
+
// destructive partial failure behind a green exit code.
|
|
1129
|
+
item.record.action = "reclaim_failed";
|
|
1130
|
+
item.record.reason = `retention_incomplete: ${outcome.reason}`;
|
|
1131
|
+
item.record.error = outcome.reason;
|
|
1132
|
+
survivors.push(item.transcript);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
item.record.action = "keep";
|
|
1136
|
+
item.record.reason = `retention_declined: ${outcome.reason}`;
|
|
1137
|
+
survivors.push(item.transcript);
|
|
1138
|
+
}
|
|
1139
|
+
return survivors;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Stream a transcript and collect every blob reference it still holds. */
|
|
1143
|
+
async function markGcDiskBlobReferences(transcriptPath: string, into: Set<string>): Promise<boolean> {
|
|
1144
|
+
try {
|
|
1145
|
+
const decoder = new TextDecoder("utf-8");
|
|
1146
|
+
let carry = "";
|
|
1147
|
+
for await (const chunk of Bun.file(transcriptPath).stream()) {
|
|
1148
|
+
const text = carry + decoder.decode(chunk, { stream: true });
|
|
1149
|
+
collectBlobReferences(text, into);
|
|
1150
|
+
// Retain a reference-length tail so a hash split across chunks is still seen.
|
|
1151
|
+
carry = text.slice(-BLOB_REFERENCE_MAX_LENGTH);
|
|
1152
|
+
}
|
|
1153
|
+
collectBlobReferences(carry + decoder.decode(), into);
|
|
1154
|
+
return true;
|
|
1155
|
+
} catch {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** What a completed mark read: the exact transcript bytes its references came from. */
|
|
1161
|
+
interface GcDiskMarkedTranscript {
|
|
1162
|
+
size: number;
|
|
1163
|
+
mtimeMs: number;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Read one transcript's blob references and bind the result to the bytes that
|
|
1168
|
+
* produced it. An append landing after this lstat can introduce a reference the
|
|
1169
|
+
* pass never saw, which is precisely what the drift check below looks for.
|
|
1170
|
+
*/
|
|
1171
|
+
async function markGcDiskTranscript(
|
|
1172
|
+
transcriptPath: string,
|
|
1173
|
+
referenced: Set<string>,
|
|
1174
|
+
marked: Map<string, GcDiskMarkedTranscript>,
|
|
1175
|
+
evidence: GcDiskEvidence,
|
|
1176
|
+
errors: GcDiskError[],
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
if (!(await markGcDiskBlobReferences(transcriptPath, referenced))) {
|
|
1179
|
+
markGcDiskEvidenceIncomplete(evidence, "transcript_unreadable_during_mark");
|
|
1180
|
+
errors.push({ surface: "blobs", scope: transcriptPath, message: "transcript_unreadable_during_mark" });
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
const stat = await fsp.lstat(transcriptPath);
|
|
1185
|
+
marked.set(transcriptPath, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_after_mark");
|
|
1188
|
+
errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Transcripts the store holds that nobody has accounted for: created after the
|
|
1194
|
+
* session walk enumerated the store, or written to after their own bytes were
|
|
1195
|
+
* read.
|
|
1196
|
+
*
|
|
1197
|
+
* `accounted` is the session walk's enumeration. A transcript in it that is not
|
|
1198
|
+
* in the mark set is one the session surface decided to retire, and a dry run
|
|
1199
|
+
* leaves those on disk — re-marking them would make the dry run promise fewer
|
|
1200
|
+
* reclaimable blobs than `--prune` actually removes.
|
|
1201
|
+
*/
|
|
1202
|
+
async function findGcDiskMarkDrift(input: {
|
|
1203
|
+
sessionsRoot: string;
|
|
1204
|
+
accounted: ReadonlySet<string>;
|
|
1205
|
+
marked: Map<string, GcDiskMarkedTranscript>;
|
|
1206
|
+
evidence: GcDiskEvidence;
|
|
1207
|
+
errors: GcDiskError[];
|
|
1208
|
+
}): Promise<string[]> {
|
|
1209
|
+
const drifted: string[] = [];
|
|
1210
|
+
await walkGcDiskTranscripts({
|
|
1211
|
+
sessionsRoot: input.sessionsRoot,
|
|
1212
|
+
surface: "blobs",
|
|
1213
|
+
evidence: input.evidence,
|
|
1214
|
+
errors: input.errors,
|
|
1215
|
+
visit: (transcriptPath, _directory, stat) => {
|
|
1216
|
+
const mark = input.marked.get(transcriptPath);
|
|
1217
|
+
if (mark) {
|
|
1218
|
+
if (mark.size !== stat.size || mark.mtimeMs !== stat.mtimeMs) drifted.push(transcriptPath);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (!input.accounted.has(transcriptPath)) drifted.push(transcriptPath);
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
return drifted;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Mark and sweep the content-addressed blob store.
|
|
1229
|
+
*
|
|
1230
|
+
* A blob's only evidence of being referenced lives inside session transcripts,
|
|
1231
|
+
* so the sweep is sound only when every transcript was enumerated AND read.
|
|
1232
|
+
* `discovery` carries that verdict from the session walk; a transcript that
|
|
1233
|
+
* fails to stream here degrades it further. On incomplete evidence the sweep
|
|
1234
|
+
* still reports what it would have reclaimed and reclaims nothing, even under
|
|
1235
|
+
* `--prune`: an unproven reference is treated as a real one.
|
|
1236
|
+
*
|
|
1237
|
+
* The session walk is a snapshot, and a snapshot goes stale: a session started
|
|
1238
|
+
* or appended to while `gjc gc` was measuring the store can reference a blob the
|
|
1239
|
+
* mark never saw. So the mark re-walks the store, absorbs whatever appeared or
|
|
1240
|
+
* grew, and only sweeps once the store stops moving under it. Drift that
|
|
1241
|
+
* outlasts {@link GC_DISK_MARK_REMARK_ROUNDS} is incomplete evidence, not a
|
|
1242
|
+
* licence to delete.
|
|
1243
|
+
*/
|
|
1244
|
+
async function runGcDiskBlobs(input: {
|
|
1245
|
+
surface: GcDiskSurfaceReport;
|
|
1246
|
+
sessionsRoot: string;
|
|
1247
|
+
accounted: ReadonlySet<string>;
|
|
1248
|
+
survivors: GcDiskTranscript[];
|
|
1249
|
+
discovery: GcDiskEvidence;
|
|
1250
|
+
now: number;
|
|
1251
|
+
prune: boolean;
|
|
1252
|
+
errors: GcDiskError[];
|
|
1253
|
+
}): Promise<void> {
|
|
1254
|
+
const { surface, sessionsRoot, accounted, survivors, discovery, now, prune, errors } = input;
|
|
1255
|
+
const evidence: GcDiskEvidence = { complete: discovery.complete, notes: [...discovery.notes] };
|
|
1256
|
+
let blobs: CanonicalBlobEntry[];
|
|
1257
|
+
try {
|
|
1258
|
+
blobs = await listCanonicalBlobs(surface.root);
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
errors.push({ surface: "blobs", scope: surface.root, message: gcDiskErrorText(error) });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (blobs.length === 0) return;
|
|
1264
|
+
|
|
1265
|
+
const referenced = new Set<string>();
|
|
1266
|
+
const marked = new Map<string, GcDiskMarkedTranscript>();
|
|
1267
|
+
let pending = survivors.map(transcript => transcript.path);
|
|
1268
|
+
for (let round = 0; ; round++) {
|
|
1269
|
+
for (const transcriptPath of pending) {
|
|
1270
|
+
await markGcDiskTranscript(transcriptPath, referenced, marked, evidence, errors);
|
|
1271
|
+
}
|
|
1272
|
+
// Already-incomplete evidence withholds the sweep anyway; re-walking would
|
|
1273
|
+
// only re-report the same failure.
|
|
1274
|
+
if (!evidence.complete) break;
|
|
1275
|
+
pending = await findGcDiskMarkDrift({ sessionsRoot, accounted, marked, evidence, errors });
|
|
1276
|
+
if (pending.length === 0 || !evidence.complete) break;
|
|
1277
|
+
if (round === GC_DISK_MARK_REMARK_ROUNDS) {
|
|
1278
|
+
markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
let withheld = 0;
|
|
1284
|
+
let withheldBytes = 0;
|
|
1285
|
+
for (const blob of blobs) {
|
|
1286
|
+
const record: GcDiskRecord = {
|
|
1287
|
+
surface: "blobs",
|
|
1288
|
+
id: blob.hash,
|
|
1289
|
+
path: blob.path,
|
|
1290
|
+
bytes: blob.bytes,
|
|
1291
|
+
age_days: gcDiskAgeDays(now, blob.mtimeMs),
|
|
1292
|
+
action: "keep",
|
|
1293
|
+
reason: "",
|
|
1294
|
+
};
|
|
1295
|
+
if (referenced.has(blob.hash)) record.reason = "referenced_by_surviving_session";
|
|
1296
|
+
else if (now - blob.mtimeMs < GC_DISK_BLOB_GRACE_MS) record.reason = "within_write_grace_window";
|
|
1297
|
+
else if (!evidence.complete) {
|
|
1298
|
+
record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
|
|
1299
|
+
record.withheld = true;
|
|
1300
|
+
withheld++;
|
|
1301
|
+
withheldBytes += blob.bytes;
|
|
1302
|
+
} else {
|
|
1303
|
+
record.action = "would_reclaim";
|
|
1304
|
+
record.reason = "unreferenced_by_any_surviving_session";
|
|
1305
|
+
}
|
|
1306
|
+
surface.records.push(record);
|
|
1307
|
+
|
|
1308
|
+
if (!prune || record.action !== "would_reclaim") continue;
|
|
1309
|
+
const removal = await removeCanonicalBlob(blob);
|
|
1310
|
+
if (removal.removed) {
|
|
1311
|
+
record.action = "reclaimed";
|
|
1312
|
+
} else if (removal.failed) {
|
|
1313
|
+
record.action = "reclaim_failed";
|
|
1314
|
+
record.reason = removal.reason;
|
|
1315
|
+
record.error = removal.reason;
|
|
1316
|
+
} else {
|
|
1317
|
+
record.action = "keep";
|
|
1318
|
+
record.reason = removal.reason;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (!evidence.complete) {
|
|
1323
|
+
surface.declined = {
|
|
1324
|
+
reason: `evidence_incomplete: ${evidence.notes.join(", ")}`,
|
|
1325
|
+
withheld,
|
|
1326
|
+
withheld_bytes: withheldBytes,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Numeric `major.minor.patch` prefix, or undefined when the name is not a version. */
|
|
1332
|
+
function parseGcDiskVersion(value: string): [number, number, number] | undefined {
|
|
1333
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
1334
|
+
if (!match) return undefined;
|
|
1335
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function compareGcDiskVersions(a: [number, number, number], b: [number, number, number]): number {
|
|
1339
|
+
for (let index = 0; index < 3; index++) {
|
|
1340
|
+
if (a[index] !== b[index]) return a[index] - b[index];
|
|
1341
|
+
}
|
|
1342
|
+
return 0;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function runGcDiskNatives(input: {
|
|
1346
|
+
surface: GcDiskSurfaceReport;
|
|
1347
|
+
policy: GcDiskPolicy;
|
|
1348
|
+
now: number;
|
|
1349
|
+
prune: boolean;
|
|
1350
|
+
errors: GcDiskError[];
|
|
1351
|
+
runningVersion: string;
|
|
1352
|
+
}): Promise<void> {
|
|
1353
|
+
const { surface, policy, now, prune, errors, runningVersion } = input;
|
|
1354
|
+
let entries: Dirent[];
|
|
1355
|
+
try {
|
|
1356
|
+
entries = await fsp.readdir(surface.root, { withFileTypes: true });
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
if (!isEnoent(error)) errors.push({ surface: "natives", scope: surface.root, message: gcDiskErrorText(error) });
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const running = parseGcDiskVersion(runningVersion);
|
|
1363
|
+
const versioned = entries
|
|
1364
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
1365
|
+
.map(entry => ({ name: entry.name, version: parseGcDiskVersion(entry.name) }));
|
|
1366
|
+
const ordered = versioned
|
|
1367
|
+
.filter((entry): entry is { name: string; version: [number, number, number] } => entry.version !== undefined)
|
|
1368
|
+
.sort((a, b) => compareGcDiskVersions(b.version, a.version) || b.name.localeCompare(a.name));
|
|
1369
|
+
|
|
1370
|
+
// Keep the running version, anything newer than it, and the configured number
|
|
1371
|
+
// of immediate predecessors. Everything else is a cached leftover.
|
|
1372
|
+
const keep = new Set<string>();
|
|
1373
|
+
let predecessors = 0;
|
|
1374
|
+
for (const entry of ordered) {
|
|
1375
|
+
if (!running || compareGcDiskVersions(entry.version, running) >= 0) {
|
|
1376
|
+
keep.add(entry.name);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (predecessors < policy.natives_keep_versions) {
|
|
1380
|
+
keep.add(entry.name);
|
|
1381
|
+
predecessors++;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
for (const entry of versioned) {
|
|
1386
|
+
const directory = path.join(surface.root, entry.name);
|
|
1387
|
+
let stat: Stats;
|
|
1388
|
+
try {
|
|
1389
|
+
stat = await fsp.lstat(directory);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
errors.push({ surface: "natives", scope: directory, message: gcDiskErrorText(error) });
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const usage = await measureGcDiskTree(directory);
|
|
1395
|
+
const record: GcDiskRecord = {
|
|
1396
|
+
surface: "natives",
|
|
1397
|
+
id: entry.name,
|
|
1398
|
+
path: directory,
|
|
1399
|
+
bytes: usage.bytes,
|
|
1400
|
+
age_days: gcDiskAgeDays(now, stat.mtimeMs),
|
|
1401
|
+
action: "keep",
|
|
1402
|
+
reason: "",
|
|
1403
|
+
...(usage.partial ? { partial: true as const } : {}),
|
|
1404
|
+
};
|
|
1405
|
+
if (!entry.version) record.reason = "unrecognized_version_directory";
|
|
1406
|
+
else if (entry.name === runningVersion) record.reason = "running_version";
|
|
1407
|
+
else if (keep.has(entry.name)) record.reason = `retained_version(keepVersions=${policy.natives_keep_versions})`;
|
|
1408
|
+
else if (usage.unreadable) {
|
|
1409
|
+
// The walk could not read part of the tree this reclaim would remove, so
|
|
1410
|
+
// a recursive remove cannot finish: it would destroy the readable half
|
|
1411
|
+
// and leave the rest. Partial knowledge is never a licence to delete.
|
|
1412
|
+
record.reason = "withheld_evidence_incomplete: tree_unreadable";
|
|
1413
|
+
record.withheld = true;
|
|
1414
|
+
} else {
|
|
1415
|
+
record.action = "would_reclaim";
|
|
1416
|
+
record.reason = `beyond_keep_versions(${policy.natives_keep_versions})`;
|
|
1417
|
+
}
|
|
1418
|
+
surface.records.push(record);
|
|
1419
|
+
|
|
1420
|
+
if (!prune || record.action !== "would_reclaim") continue;
|
|
1421
|
+
const removal = await removeGcDiskEntry(directory, stat);
|
|
1422
|
+
if (removal.removed) record.action = "reclaimed";
|
|
1423
|
+
else if (removal.failed) {
|
|
1424
|
+
record.action = "reclaim_failed";
|
|
1425
|
+
record.reason = removal.reason;
|
|
1426
|
+
record.error = removal.reason;
|
|
1427
|
+
} else {
|
|
1428
|
+
record.action = "keep";
|
|
1429
|
+
record.reason = removal.reason;
|
|
1430
|
+
if (removal.withheld) record.withheld = true;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Remove one directory/file entry, fail-closed on identity drift. Anything that
|
|
1437
|
+
* changed inode or mtime between classification and removal is left alone.
|
|
1438
|
+
*
|
|
1439
|
+
* Directories use the native exact-tree remover. Its fresh descriptor-relative
|
|
1440
|
+
* snapshot is revalidated by the removal itself before an atomic detach, closing
|
|
1441
|
+
* the gap where a plain recursive `rm` could destroy readable siblings before
|
|
1442
|
+
* discovering that another subtree had become unreadable.
|
|
1443
|
+
*/
|
|
1444
|
+
async function removeGcDiskEntry(
|
|
1445
|
+
target: string,
|
|
1446
|
+
expected: Stats,
|
|
1447
|
+
): Promise<{ removed: true } | { removed: false; reason: string; failed?: true; withheld?: true }> {
|
|
1448
|
+
let current: Stats;
|
|
1449
|
+
try {
|
|
1450
|
+
current = await fsp.lstat(target);
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
|
|
1453
|
+
return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
|
|
1454
|
+
}
|
|
1455
|
+
if (current.isSymbolicLink()) return { removed: false, reason: "entry_is_symlink" };
|
|
1456
|
+
if (current.ino !== expected.ino || current.dev !== expected.dev) {
|
|
1457
|
+
return { removed: false, reason: "entry_identity_changed" };
|
|
1458
|
+
}
|
|
1459
|
+
if (current.mtimeMs !== expected.mtimeMs) return { removed: false, reason: "entry_changed" };
|
|
1460
|
+
|
|
1461
|
+
if (!current.isDirectory()) {
|
|
1462
|
+
try {
|
|
1463
|
+
await fsp.rm(target, { force: false });
|
|
1464
|
+
return { removed: true };
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
|
|
1467
|
+
return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const snapshot = snapshotDirectoryTree(target);
|
|
1472
|
+
if (!snapshot.ok || !snapshot.snapshot) {
|
|
1473
|
+
if (snapshot.code === "not_found") return { removed: false, reason: "entry_disappeared" };
|
|
1474
|
+
if (snapshot.code !== "reparse_point") {
|
|
1475
|
+
return { removed: false, reason: "withheld_evidence_incomplete: tree_unreadable", withheld: true };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
let identity: BigIntStats;
|
|
1479
|
+
let linkParent: BigIntStats;
|
|
1480
|
+
try {
|
|
1481
|
+
identity = await fsp.lstat(target, { bigint: true });
|
|
1482
|
+
linkParent = await fsp.lstat(path.dirname(target), { bigint: true });
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
|
|
1485
|
+
return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
|
|
1486
|
+
}
|
|
1487
|
+
const detached = exactUnlink(target, {
|
|
1488
|
+
dev: identity.dev,
|
|
1489
|
+
ino: identity.ino,
|
|
1490
|
+
nlink: identity.nlink,
|
|
1491
|
+
size: identity.size,
|
|
1492
|
+
mtimeNs: identity.mtimeNs,
|
|
1493
|
+
parentDev: linkParent.dev,
|
|
1494
|
+
parentIno: linkParent.ino,
|
|
1495
|
+
directory: true,
|
|
1496
|
+
detachOnly: true,
|
|
1497
|
+
quarantineName: `${path.basename(target)}.removing`,
|
|
1498
|
+
});
|
|
1499
|
+
if ((!detached.ok && detached.code !== "cleanup_pending") || !detached.detachedPath) {
|
|
1500
|
+
return { removed: false, reason: `entry_remove_failed: ${detached.code ?? "unknown"}`, failed: true };
|
|
1501
|
+
}
|
|
1502
|
+
try {
|
|
1503
|
+
await fsp.rm(detached.detachedPath, { recursive: true, force: false });
|
|
1504
|
+
if (detached.retainedPlaceholderPath) await fsp.rmdir(detached.retainedPlaceholderPath);
|
|
1505
|
+
return { removed: true };
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (isEnoent(error)) return { removed: true };
|
|
1508
|
+
return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
let parent: BigIntStats;
|
|
1513
|
+
try {
|
|
1514
|
+
parent = await fsp.lstat(path.dirname(target), { bigint: true });
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (isEnoent(error)) return { removed: false, reason: "entry_disappeared" };
|
|
1517
|
+
return { removed: false, reason: `entry_unverifiable: ${gcDiskErrorText(error)}`, failed: true };
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const removal = exactRemoveDirectoryTree(target, snapshot.snapshot, {
|
|
1521
|
+
dev: parent.dev,
|
|
1522
|
+
ino: parent.ino,
|
|
1523
|
+
});
|
|
1524
|
+
if (removal.ok) return { removed: true };
|
|
1525
|
+
if (removal.code === "cleanup_pending" && removal.payloadDurable === true && removal.detachedPath) {
|
|
1526
|
+
try {
|
|
1527
|
+
await fsp.rm(removal.detachedPath, { recursive: true, force: false });
|
|
1528
|
+
return { removed: true };
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
if (isEnoent(error)) return { removed: true };
|
|
1531
|
+
return { removed: false, reason: `entry_remove_failed: ${gcDiskErrorText(error)}`, failed: true };
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (removal.code === "not_found") return { removed: false, reason: "entry_disappeared" };
|
|
1535
|
+
if (removal.code === "identity_mismatch" || removal.code === "parent_mismatch") {
|
|
1536
|
+
return { removed: false, reason: "entry_changed" };
|
|
1537
|
+
}
|
|
1538
|
+
const retainedAtOriginal = !removal.detachedPath || path.resolve(removal.detachedPath) === path.resolve(target);
|
|
1539
|
+
if ((removal.code === "permission_denied" || removal.code === "io_error") && retainedAtOriginal) {
|
|
1540
|
+
return { removed: false, reason: "withheld_evidence_incomplete: tree_unreadable", withheld: true };
|
|
1541
|
+
}
|
|
1542
|
+
return {
|
|
1543
|
+
removed: false,
|
|
1544
|
+
reason: `entry_remove_failed: ${removal.code ?? "unknown"}`,
|
|
1545
|
+
failed: true,
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async function runGcDiskBackups(input: {
|
|
1550
|
+
surface: GcDiskSurfaceReport;
|
|
1551
|
+
gjcRoot: string;
|
|
1552
|
+
policy: GcDiskPolicy;
|
|
1553
|
+
now: number;
|
|
1554
|
+
prune: boolean;
|
|
1555
|
+
errors: GcDiskError[];
|
|
1556
|
+
}): Promise<void> {
|
|
1557
|
+
const { surface, gjcRoot, policy, now, prune, errors } = input;
|
|
1558
|
+
const maxAgeMs = policy.backups_max_age_days * GC_DISK_DAY_MS;
|
|
1559
|
+
const candidates: Array<{ id: string; path: string }> = [];
|
|
1560
|
+
|
|
1561
|
+
// `~/.gjc/backups/<entry>` — update/restore backup roots.
|
|
1562
|
+
try {
|
|
1563
|
+
for (const entry of await fsp.readdir(surface.root, { withFileTypes: true })) {
|
|
1564
|
+
if (entry.isSymbolicLink()) continue;
|
|
1565
|
+
candidates.push({ id: entry.name, path: path.join(surface.root, entry.name) });
|
|
1566
|
+
}
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
if (!isEnoent(error)) errors.push({ surface: "backups", scope: surface.root, message: gcDiskErrorText(error) });
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// `~/.gjc/*.bak` — sibling roots left by update/restore (agent.bak, natives-*.bak).
|
|
1572
|
+
try {
|
|
1573
|
+
for (const entry of await fsp.readdir(gjcRoot, { withFileTypes: true })) {
|
|
1574
|
+
if (!entry.name.endsWith(".bak") || entry.isSymbolicLink()) continue;
|
|
1575
|
+
candidates.push({ id: entry.name, path: path.join(gjcRoot, entry.name) });
|
|
1576
|
+
}
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
if (!isEnoent(error)) errors.push({ surface: "backups", scope: gjcRoot, message: gcDiskErrorText(error) });
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
for (const candidate of candidates) {
|
|
1582
|
+
let stat: Stats;
|
|
1583
|
+
try {
|
|
1584
|
+
stat = await fsp.lstat(candidate.path);
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
if (!isEnoent(error)) {
|
|
1587
|
+
errors.push({ surface: "backups", scope: candidate.path, message: gcDiskErrorText(error) });
|
|
1588
|
+
}
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if (stat.isSymbolicLink()) continue;
|
|
1592
|
+
const usage = stat.isDirectory()
|
|
1593
|
+
? await measureGcDiskTree(candidate.path)
|
|
1594
|
+
: { bytes: stat.size, partial: false, unreadable: false };
|
|
1595
|
+
const record: GcDiskRecord = {
|
|
1596
|
+
surface: "backups",
|
|
1597
|
+
id: candidate.id,
|
|
1598
|
+
path: candidate.path,
|
|
1599
|
+
bytes: usage.bytes,
|
|
1600
|
+
age_days: gcDiskAgeDays(now, stat.mtimeMs),
|
|
1601
|
+
action: "keep",
|
|
1602
|
+
reason: "",
|
|
1603
|
+
...(usage.partial ? { partial: true as const } : {}),
|
|
1604
|
+
};
|
|
1605
|
+
if (now - stat.mtimeMs < maxAgeMs) record.reason = `newer_than_max_age(${policy.backups_max_age_days}d)`;
|
|
1606
|
+
else if (usage.unreadable) {
|
|
1607
|
+
// The walk could not read part of the tree this reclaim would remove, so
|
|
1608
|
+
// a recursive remove cannot finish: it would destroy the readable half
|
|
1609
|
+
// and leave the rest. Partial knowledge is never a licence to delete.
|
|
1610
|
+
record.reason = "withheld_evidence_incomplete: tree_unreadable";
|
|
1611
|
+
record.withheld = true;
|
|
1612
|
+
} else {
|
|
1613
|
+
record.action = "would_reclaim";
|
|
1614
|
+
record.reason = `older_than_max_age(${policy.backups_max_age_days}d)`;
|
|
1615
|
+
}
|
|
1616
|
+
surface.records.push(record);
|
|
1617
|
+
|
|
1618
|
+
if (!prune || record.action !== "would_reclaim") continue;
|
|
1619
|
+
const removal = await removeGcDiskEntry(candidate.path, stat);
|
|
1620
|
+
if (removal.removed) record.action = "reclaimed";
|
|
1621
|
+
else if (removal.failed) {
|
|
1622
|
+
record.action = "reclaim_failed";
|
|
1623
|
+
record.reason = removal.reason;
|
|
1624
|
+
record.error = removal.reason;
|
|
1625
|
+
} else {
|
|
1626
|
+
record.action = "keep";
|
|
1627
|
+
record.reason = removal.reason;
|
|
1628
|
+
if (removal.withheld) record.withheld = true;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Run the disk-retention axis. Nothing is mutated unless `prune` is true; the
|
|
1635
|
+
* dry-run report projects exactly the same decisions a prune would make.
|
|
1636
|
+
*/
|
|
1637
|
+
export async function collectGcDiskReport(input: {
|
|
1638
|
+
agentDir: string;
|
|
1639
|
+
env: NodeJS.ProcessEnv;
|
|
1640
|
+
policy: GcDiskPolicy;
|
|
1641
|
+
prune: boolean;
|
|
1642
|
+
now?: number;
|
|
1643
|
+
runningVersion?: string;
|
|
1644
|
+
}): Promise<GcDiskReport> {
|
|
1645
|
+
const { agentDir, env, policy, prune } = input;
|
|
1646
|
+
const now = input.now ?? Date.now();
|
|
1647
|
+
const gjcRoot = path.dirname(path.resolve(agentDir));
|
|
1648
|
+
const errors: GcDiskError[] = [];
|
|
1649
|
+
const surfaces: Record<GcDiskSurface, GcDiskSurfaceReport> = {
|
|
1650
|
+
sessions: emptyGcDiskSurface("sessions", getSessionsDir(agentDir)),
|
|
1651
|
+
blobs: emptyGcDiskSurface("blobs", getBlobsDir(agentDir)),
|
|
1652
|
+
natives: emptyGcDiskSurface("natives", path.join(gjcRoot, "natives")),
|
|
1653
|
+
backups: emptyGcDiskSurface("backups", path.join(gjcRoot, "backups")),
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
const scan = await discoverGcDiskTranscripts(surfaces.sessions.root, errors);
|
|
1657
|
+
const references = await collectGcSessionReferences(agentDir, env);
|
|
1658
|
+
const survivors = await runGcDiskSessions({
|
|
1659
|
+
surface: surfaces.sessions,
|
|
1660
|
+
transcripts: scan.transcripts,
|
|
1661
|
+
references,
|
|
1662
|
+
policy,
|
|
1663
|
+
now,
|
|
1664
|
+
prune,
|
|
1665
|
+
});
|
|
1666
|
+
await runGcDiskBlobs({
|
|
1667
|
+
surface: surfaces.blobs,
|
|
1668
|
+
sessionsRoot: surfaces.sessions.root,
|
|
1669
|
+
accounted: new Set(scan.transcripts.map(transcript => transcript.path)),
|
|
1670
|
+
survivors,
|
|
1671
|
+
discovery: scan.evidence,
|
|
1672
|
+
now,
|
|
1673
|
+
prune,
|
|
1674
|
+
errors,
|
|
1675
|
+
});
|
|
1676
|
+
await runGcDiskNatives({
|
|
1677
|
+
surface: surfaces.natives,
|
|
1678
|
+
policy,
|
|
1679
|
+
now,
|
|
1680
|
+
prune,
|
|
1681
|
+
errors,
|
|
1682
|
+
runningVersion: input.runningVersion ?? VERSION,
|
|
1683
|
+
});
|
|
1684
|
+
await runGcDiskBackups({ surface: surfaces.backups, gjcRoot, policy, now, prune, errors });
|
|
1685
|
+
|
|
1686
|
+
const totals = { scanned_bytes: 0, reclaimable_bytes: 0, reclaimed_bytes: 0, kept_bytes: 0, failed: 0 };
|
|
1687
|
+
for (const name of GC_DISK_SURFACES) {
|
|
1688
|
+
const surface = surfaces[name];
|
|
1689
|
+
summarizeGcDiskSurface(surface);
|
|
1690
|
+
totals.scanned_bytes += surface.scanned_bytes;
|
|
1691
|
+
totals.reclaimable_bytes += surface.reclaimable_bytes;
|
|
1692
|
+
totals.reclaimed_bytes += surface.reclaimed_bytes;
|
|
1693
|
+
totals.kept_bytes += surface.kept_bytes;
|
|
1694
|
+
totals.failed += surface.failed;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
return { dry_run: !prune, policy, surfaces, totals, errors };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const GC_DISK_SURFACE_HEADINGS: Record<GcDiskSurface, string> = {
|
|
1701
|
+
sessions: "Session transcripts",
|
|
1702
|
+
blobs: "Content-addressed blobs",
|
|
1703
|
+
natives: "Cached native versions",
|
|
1704
|
+
backups: "Update/restore backups",
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
function formatGcDiskBytes(bytes: number): string {
|
|
1708
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1709
|
+
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
1710
|
+
let value = bytes / 1024;
|
|
1711
|
+
let unit = 0;
|
|
1712
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1713
|
+
value /= 1024;
|
|
1714
|
+
unit++;
|
|
1715
|
+
}
|
|
1716
|
+
return `${value.toFixed(1)} ${units[unit]}`;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function gcDiskActionLabel(record: GcDiskRecord): string {
|
|
1720
|
+
switch (record.action) {
|
|
1721
|
+
case "would_reclaim":
|
|
1722
|
+
return "would reclaim";
|
|
1723
|
+
case "reclaimed":
|
|
1724
|
+
return "reclaimed";
|
|
1725
|
+
case "reclaim_failed":
|
|
1726
|
+
return `reclaim failed${record.error ? `: ${record.error}` : ""}`;
|
|
1727
|
+
default:
|
|
1728
|
+
return "keep";
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
export function buildGcDiskReportText(disk: GcDiskReport): string {
|
|
1733
|
+
const lines: string[] = [];
|
|
1734
|
+
lines.push(
|
|
1735
|
+
disk.dry_run
|
|
1736
|
+
? "gjc gc --disk — report only (no bytes reclaimed; pass --prune to reclaim)"
|
|
1737
|
+
: "gjc gc --disk --prune — reclaim",
|
|
1738
|
+
);
|
|
1739
|
+
lines.push(
|
|
1740
|
+
` policy: sessions.maxAgeDays=${disk.policy.sessions_max_age_days} ` +
|
|
1741
|
+
`sessions.maxTotalBytes=${disk.policy.sessions_max_total_bytes} ` +
|
|
1742
|
+
`natives.keepVersions=${disk.policy.natives_keep_versions} ` +
|
|
1743
|
+
`backups.maxAgeDays=${disk.policy.backups_max_age_days}`,
|
|
1744
|
+
);
|
|
1745
|
+
lines.push("");
|
|
1746
|
+
|
|
1747
|
+
for (const name of GC_DISK_SURFACES) {
|
|
1748
|
+
const surface = disk.surfaces[name];
|
|
1749
|
+
lines.push(`${GC_DISK_SURFACE_HEADINGS[name]} (${surface.root})`);
|
|
1750
|
+
lines.push(
|
|
1751
|
+
` scanned=${surface.scanned} (${formatGcDiskBytes(surface.scanned_bytes)}) ` +
|
|
1752
|
+
(disk.dry_run
|
|
1753
|
+
? `reclaimable=${surface.reclaimable} (${formatGcDiskBytes(surface.reclaimable_bytes)}) `
|
|
1754
|
+
: `reclaimed=${surface.reclaimed} (${formatGcDiskBytes(surface.reclaimed_bytes)}) failed=${surface.failed} `) +
|
|
1755
|
+
`kept=${surface.kept} (${formatGcDiskBytes(surface.kept_bytes)})`,
|
|
1756
|
+
);
|
|
1757
|
+
if (surface.declined) {
|
|
1758
|
+
lines.push(
|
|
1759
|
+
` declined: reclaimed nothing — ${surface.declined.reason} ` +
|
|
1760
|
+
`(withheld=${surface.declined.withheld} (${formatGcDiskBytes(surface.declined.withheld_bytes)}))`,
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
// Reclaim decisions first: they are what an operator has to audit.
|
|
1764
|
+
const ranked = [...surface.records].sort(
|
|
1765
|
+
(a, b) => Number(a.action === "keep") - Number(b.action === "keep") || b.bytes - a.bytes,
|
|
1766
|
+
);
|
|
1767
|
+
for (const record of ranked.slice(0, GC_DISK_MAX_RENDERED_RECORDS)) {
|
|
1768
|
+
lines.push(
|
|
1769
|
+
` [${gcDiskActionLabel(record)}] ${record.path} ${formatGcDiskBytes(record.bytes)}` +
|
|
1770
|
+
`${record.partial ? "+" : ""} age=${record.age_days}d — ${record.reason}`,
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
if (ranked.length > GC_DISK_MAX_RENDERED_RECORDS) {
|
|
1774
|
+
lines.push(` … ${ranked.length - GC_DISK_MAX_RENDERED_RECORDS} more (use --json for the full list)`);
|
|
1775
|
+
}
|
|
1776
|
+
lines.push("");
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (disk.errors.length > 0) {
|
|
1780
|
+
lines.push(`Disk errors (${disk.errors.length})`);
|
|
1781
|
+
for (const error of disk.errors) lines.push(` [${error.surface}/${error.scope}] ${error.message}`);
|
|
1782
|
+
lines.push("");
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
lines.push(
|
|
1786
|
+
`Disk summary: scanned=${formatGcDiskBytes(disk.totals.scanned_bytes)} ` +
|
|
1787
|
+
(disk.dry_run
|
|
1788
|
+
? `reclaimable=${formatGcDiskBytes(disk.totals.reclaimable_bytes)}`
|
|
1789
|
+
: `reclaimed=${formatGcDiskBytes(disk.totals.reclaimed_bytes)} failed=${disk.totals.failed}`) +
|
|
1790
|
+
` kept=${formatGcDiskBytes(disk.totals.kept_bytes)}`,
|
|
1791
|
+
);
|
|
1792
|
+
lines.push("");
|
|
1793
|
+
return `${lines.join("\n")}`;
|
|
1794
|
+
}
|