@adhdev/daemon-core 1.0.18-rc.6 → 1.0.18-rc.8
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/dist/index.js +3857 -3391
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3853 -3381
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/mesh-disk-retention.d.ts +105 -0
- package/dist/mesh/mesh-ledger.d.ts +28 -1
- package/dist/mesh/mesh-runtime-store.d.ts +11 -0
- package/dist/providers/cli-provider-instance-types.d.ts +2 -0
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +10 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
- package/src/commands/windows-atomic-upgrade.ts +19 -1
- package/src/config/mesh-json-config.ts +8 -0
- package/src/mesh/coordinator-prompt.ts +256 -10
- package/src/mesh/mesh-disk-retention.ts +370 -0
- package/src/mesh/mesh-ledger.ts +72 -0
- package/src/mesh/mesh-reconcile-loop.ts +49 -0
- package/src/mesh/mesh-runtime-store.ts +21 -0
- package/src/providers/cli-provider-instance-types.ts +25 -0
- package/src/providers/cli-provider-instance.ts +116 -0
|
@@ -34,6 +34,7 @@ import type {
|
|
|
34
34
|
import { mergeAndNormalizePolicy, resolveProviderMaxParallel } from '../repo-mesh-types.js';
|
|
35
35
|
import { getDifficultyBrains } from '../config/mesh-config.js';
|
|
36
36
|
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
37
|
+
import { isNoteExpired, OPERATING_NOTE_CATEGORY_TTL_DAYS } from './mesh-ledger.js';
|
|
37
38
|
import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
|
|
38
39
|
import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
|
|
39
40
|
|
|
@@ -84,6 +85,41 @@ export interface CoordinatorOperatingNote {
|
|
|
84
85
|
category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
|
|
85
86
|
createdAt?: string;
|
|
86
87
|
sourceCoordinator?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Operating-notes lifecycle (minimal first cut). When true the note ALWAYS
|
|
90
|
+
* rides into the coordinator prompt — it is never dropped by TTL expiry and
|
|
91
|
+
* survives the injection cap ahead of unpinned notes. Legacy notes without
|
|
92
|
+
* this field default to false.
|
|
93
|
+
*/
|
|
94
|
+
pinned?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Optional explicit expiry ISO timestamp. When set and in the past, an
|
|
97
|
+
* UNPINNED note is dropped from the injected prompt (read-side only — the
|
|
98
|
+
* ledger entry is never pruned by age). When absent, the category→TTL map
|
|
99
|
+
* (see isNoteExpired) governs expiry; pinned notes never expire regardless.
|
|
100
|
+
*/
|
|
101
|
+
expiresAt?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Phase 2 — the ledger id of this note, when known. Threaded from the ledger
|
|
104
|
+
* entry id at launch so version-supersede can target a specific note and
|
|
105
|
+
* same-class folding can list the subsumed ids. Repo-declared notes may lack
|
|
106
|
+
* one; absent is fine (folding/supersede fall back to the subject key).
|
|
107
|
+
*/
|
|
108
|
+
noteId?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Phase 2 (b) version-supersede — an optional note-id or stable subject-key
|
|
111
|
+
* this note replaces. At injection, any earlier LIVE note whose `noteId` or
|
|
112
|
+
* `subjectKey` matches this value is hidden from the prompt (the ledger entry
|
|
113
|
+
* is retained for audit). Optional and lossless: absent = supersedes nothing.
|
|
114
|
+
*/
|
|
115
|
+
supersedes?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
|
|
118
|
+
* same subject. Drives version-supersede targeting and same-class folding.
|
|
119
|
+
* When absent, folding derives a key from a leading `[tag]` bracket instead,
|
|
120
|
+
* so legacy notes still collapse by their conventional tag prefix.
|
|
121
|
+
*/
|
|
122
|
+
subjectKey?: string;
|
|
87
123
|
}
|
|
88
124
|
|
|
89
125
|
// ─── Prompt Builder ─────────────────────────────
|
|
@@ -582,33 +618,243 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
|
|
|
582
618
|
const OPERATING_NOTES_PROMPT_CAP = 20;
|
|
583
619
|
const OPERATING_NOTE_MAX_CHARS = 300;
|
|
584
620
|
|
|
585
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Phase 2 (d) — byte budget for the injected Operating Notes list. The count cap
|
|
623
|
+
* above (20) is still a ceiling, but selection now fills up to this UTF-8 byte
|
|
624
|
+
* budget on the RENDERED note lines, ranked pinned > durable > recency, so a few
|
|
625
|
+
* long notes don't crowd out many short ones and vice-versa. Pinned notes are
|
|
626
|
+
* ALWAYS kept even if they alone exceed the budget (pinned is author-opt-in and
|
|
627
|
+
* bounded by the author); the budget only bounds the unpinned tail.
|
|
628
|
+
*
|
|
629
|
+
* ~6KB ≈ the 20-note cap at typical note length, so on ordinary meshes this
|
|
630
|
+
* changes nothing — it only kicks in when notes run long. It sits well under the
|
|
631
|
+
* whole-prompt PROMPT_SOFT_CAP_BYTES (60KB) so the two caps don't fight.
|
|
632
|
+
*/
|
|
633
|
+
const OPERATING_NOTE_INJECTION_BYTE_BUDGET = 6 * 1024;
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* A category is "durable" (survives the cap ahead of recency) when it has no
|
|
637
|
+
* TTL entry — provider_quirk, uncategorized, or any unknown category. This
|
|
638
|
+
* mirrors isNoteExpired's durability rule so ranking and expiry agree.
|
|
639
|
+
*/
|
|
640
|
+
function isDurableCategory(category?: string): boolean {
|
|
641
|
+
if (!category) return true;
|
|
642
|
+
return !(category in OPERATING_NOTE_CATEGORY_TTL_DAYS);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Phase 2 (b)/(c) — derive a note's subject key for supersede targeting and
|
|
647
|
+
* same-class folding. Precedence:
|
|
648
|
+
* 1. an explicit `subjectKey` (trimmed, lowercased) when present, else
|
|
649
|
+
* 2. a leading `[tag]` bracket, so legacy notes that follow the conventional
|
|
650
|
+
* `[some-tag] …` prefix still group/supersede by that tag, else
|
|
651
|
+
* 3. undefined — the note has no derivable subject and never folds/supersedes
|
|
652
|
+
* by key (it can still be targeted by exact noteId).
|
|
653
|
+
* Pure. Lowercased so `[Foo]` and `[foo]` collapse together.
|
|
654
|
+
*/
|
|
655
|
+
function deriveSubjectKey(note: CoordinatorOperatingNote): string | undefined {
|
|
656
|
+
const explicit = typeof note.subjectKey === 'string' ? note.subjectKey.trim().toLowerCase() : '';
|
|
657
|
+
if (explicit) return explicit;
|
|
658
|
+
const text = typeof note.text === 'string' ? note.text.trimStart() : '';
|
|
659
|
+
const m = /^\[([^\]]{1,80})\]/.exec(text);
|
|
660
|
+
const tag = m ? m[1].trim().toLowerCase() : '';
|
|
661
|
+
return tag || undefined;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Phase 2 (c) — deterministic same-class fold. Given ranked notes (already
|
|
666
|
+
* highest-priority first), collapse runs of UNPINNED notes that share the same
|
|
667
|
+
* category AND subject key into a single injected entry: keep the highest-ranked
|
|
668
|
+
* (first-seen) note and record the note-ids/count it subsumes so the rendered
|
|
669
|
+
* line can say "(+N earlier)". Pinned notes never fold — each pinned note is
|
|
670
|
+
* author-opted-in and shown verbatim. The store is untouched; this is a pure,
|
|
671
|
+
* model-free text fold at injection time.
|
|
672
|
+
*
|
|
673
|
+
* Returns the folded note list (order preserved) with per-entry subsumed info.
|
|
674
|
+
*/
|
|
675
|
+
interface FoldedNote {
|
|
676
|
+
note: CoordinatorOperatingNote;
|
|
677
|
+
/** note-ids of same-class notes this entry subsumes (may be empty). */
|
|
678
|
+
subsumedIds: string[];
|
|
679
|
+
/** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
|
|
680
|
+
subsumedCount: number;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function foldSameClassNotes(ranked: CoordinatorOperatingNote[]): FoldedNote[] {
|
|
684
|
+
const out: FoldedNote[] = [];
|
|
685
|
+
// group key → index into `out` of the surviving (highest-ranked) entry.
|
|
686
|
+
const survivorByKey = new Map<string, number>();
|
|
687
|
+
for (const note of ranked) {
|
|
688
|
+
const subject = deriveSubjectKey(note);
|
|
689
|
+
// Only unpinned notes with a category AND a subject key are foldable —
|
|
690
|
+
// without both there is no reliable "same class/subject" signal, so we
|
|
691
|
+
// keep the note standalone (lossless for legacy/uncategorized notes).
|
|
692
|
+
const foldable = !note.pinned && !!note.category && !!subject;
|
|
693
|
+
if (foldable) {
|
|
694
|
+
const key = `${note.category}${subject}`;
|
|
695
|
+
const existing = survivorByKey.get(key);
|
|
696
|
+
if (existing !== undefined) {
|
|
697
|
+
const survivor = out[existing];
|
|
698
|
+
survivor.subsumedCount += 1;
|
|
699
|
+
if (typeof note.noteId === 'string' && note.noteId) survivor.subsumedIds.push(note.noteId);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
survivorByKey.set(key, out.length);
|
|
703
|
+
}
|
|
704
|
+
out.push({ note, subsumedIds: [], subsumedCount: 0 });
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Estimate the UTF-8 byte cost of one rendered operating-note line (Phase 2 d). */
|
|
710
|
+
function renderedNoteBytes(folded: FoldedNote): number {
|
|
711
|
+
return byteLength(renderOperatingNoteLine(folded));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Operating-notes lifecycle selection (read-side). Given the effective notes
|
|
716
|
+
* (oldest-first, ledger order) and the current time, produce the ordered list
|
|
717
|
+
* that rides into the prompt:
|
|
718
|
+
* (i) ALWAYS include pinned notes.
|
|
719
|
+
* (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
|
|
720
|
+
* (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
|
|
721
|
+
* or subjectKey); pinned notes are never dropped by supersede.
|
|
722
|
+
* (iv) rank pinned-first, then durable-category, then recency (newest first).
|
|
723
|
+
* (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
|
|
724
|
+
* (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
|
|
725
|
+
* kept even if they alone exceed the byte budget.
|
|
726
|
+
* Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
|
|
727
|
+
* carries per-entry fold info for the renderer.
|
|
728
|
+
*
|
|
729
|
+
* Ranking is stable on the original ledger index so, within a tier, newest notes
|
|
730
|
+
* lead. The caps keep the leading (highest-priority) entries.
|
|
731
|
+
*/
|
|
732
|
+
export function selectOperatingNotesForPrompt(
|
|
733
|
+
notes: CoordinatorOperatingNote[],
|
|
734
|
+
now: number,
|
|
735
|
+
cap: number = OPERATING_NOTES_PROMPT_CAP,
|
|
736
|
+
byteBudget: number = OPERATING_NOTE_INJECTION_BYTE_BUDGET,
|
|
737
|
+
): { shown: FoldedNote[]; omittedCount: number } {
|
|
586
738
|
const valid = Array.isArray(notes)
|
|
587
739
|
? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
|
|
588
740
|
: [];
|
|
589
|
-
if (valid.length === 0) return '';
|
|
590
741
|
|
|
742
|
+
// (i)+(ii): keep pinned always; drop expired unpinned. Retain original index
|
|
743
|
+
// for a stable recency tiebreak (later index == newer, ledger is oldest-first).
|
|
744
|
+
let kept = valid
|
|
745
|
+
.map((note, index) => ({ note, index }))
|
|
746
|
+
.filter(({ note }) => note.pinned || !isNoteExpired(note as any, now));
|
|
747
|
+
|
|
748
|
+
// (iii) Phase 2 (b) version-supersede: a LATER live note may name (via
|
|
749
|
+
// `supersedes`) an earlier note's noteId or subjectKey; the earlier one is
|
|
750
|
+
// then hidden. "Later" == higher ledger index. Collect every supersede target
|
|
751
|
+
// paired with the max index that supersedes it, then drop any UNPINNED note
|
|
752
|
+
// whose id/subjectKey is superseded by a strictly-later note. Pinned notes are
|
|
753
|
+
// never dropped by supersede (pinned always wins, mirroring TTL).
|
|
754
|
+
const supersededAtIndex = new Map<string, number>();
|
|
755
|
+
for (const { note, index } of kept) {
|
|
756
|
+
const target = typeof note.supersedes === 'string' ? note.supersedes.trim().toLowerCase() : '';
|
|
757
|
+
if (!target) continue;
|
|
758
|
+
const prev = supersededAtIndex.get(target);
|
|
759
|
+
if (prev === undefined || index > prev) supersededAtIndex.set(target, index);
|
|
760
|
+
}
|
|
761
|
+
if (supersededAtIndex.size > 0) {
|
|
762
|
+
kept = kept.filter(({ note, index }) => {
|
|
763
|
+
if (note.pinned) return true;
|
|
764
|
+
const byId = typeof note.noteId === 'string' ? note.noteId.trim().toLowerCase() : '';
|
|
765
|
+
const bySubject = deriveSubjectKey(note);
|
|
766
|
+
const supIdx = Math.max(
|
|
767
|
+
byId ? (supersededAtIndex.get(byId) ?? -1) : -1,
|
|
768
|
+
bySubject ? (supersededAtIndex.get(bySubject) ?? -1) : -1,
|
|
769
|
+
);
|
|
770
|
+
return !(supIdx > index); // superseded by a strictly-later note → drop
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// (iv): rank pinned > durable > recency (newest first within a tier).
|
|
775
|
+
const rank = (n: CoordinatorOperatingNote): number =>
|
|
776
|
+
n.pinned ? 0 : isDurableCategory(n.category) ? 1 : 2;
|
|
777
|
+
kept.sort((a, b) => {
|
|
778
|
+
const r = rank(a.note) - rank(b.note);
|
|
779
|
+
if (r !== 0) return r;
|
|
780
|
+
return b.index - a.index; // newer (higher index) first
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// (v) Phase 2 (c): fold same-class/same-subject unpinned runs into one entry.
|
|
784
|
+
const folded = foldSameClassNotes(kept.map(k => k.note));
|
|
785
|
+
|
|
786
|
+
// (vi) Phase 2 (d): fill up to the byte budget AND the count cap. Pinned
|
|
787
|
+
// entries are always kept (never dropped to fit the budget); the budget/cap
|
|
788
|
+
// bound the unpinned tail. Entries are already highest-priority first, so we
|
|
789
|
+
// walk in order and stop once an UNPINNED entry would break a bound.
|
|
790
|
+
const shown: FoldedNote[] = [];
|
|
791
|
+
let usedBytes = 0;
|
|
792
|
+
let usedCount = 0;
|
|
793
|
+
for (const entry of folded) {
|
|
794
|
+
if (entry.note.pinned) {
|
|
795
|
+
shown.push(entry);
|
|
796
|
+
usedBytes += renderedNoteBytes(entry);
|
|
797
|
+
usedCount += 1;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (usedCount >= cap) break;
|
|
801
|
+
const bytes = renderedNoteBytes(entry);
|
|
802
|
+
if (usedCount > 0 && usedBytes + bytes > byteBudget) break;
|
|
803
|
+
shown.push(entry);
|
|
804
|
+
usedBytes += bytes;
|
|
805
|
+
usedCount += 1;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// omittedCount counts eligible (non-expired, non-superseded, post-fold) entries
|
|
809
|
+
// that did not make the cut. Folded-away duplicates are not "omitted" — they are
|
|
810
|
+
// represented by their survivor's "(+N earlier)" marker, so exclude them here.
|
|
811
|
+
const omittedCount = Math.max(0, folded.length - shown.length);
|
|
812
|
+
return { shown, omittedCount };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Render one operating-note bullet line (Phase 2 c fold-aware). Shared by the
|
|
817
|
+
* byte-budget estimator and the section renderer so the budget is measured on
|
|
818
|
+
* the exact bytes that ship.
|
|
819
|
+
*/
|
|
820
|
+
function renderOperatingNoteLine(folded: FoldedNote): string {
|
|
821
|
+
const n = folded.note;
|
|
591
822
|
const categoryLabel: Record<string, string> = {
|
|
592
823
|
provider_quirk: 'provider quirk',
|
|
593
824
|
pattern_to_avoid: 'pattern to avoid',
|
|
594
825
|
recovery_lesson: 'recovery lesson',
|
|
595
826
|
};
|
|
827
|
+
const pin = n.pinned ? '📌 ' : '';
|
|
828
|
+
const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
|
|
829
|
+
const fold = folded.subsumedCount > 0
|
|
830
|
+
? ` _(+${folded.subsumedCount} earlier same-subject note${folded.subsumedCount === 1 ? '' : 's'} folded${folded.subsumedIds.length ? `: ${folded.subsumedIds.join(', ')}` : ''})_`
|
|
831
|
+
: '';
|
|
832
|
+
return `- ${pin}${cat}${truncateNote(n.text.trim())}${fold}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[], now: number = Date.now()): string {
|
|
836
|
+
const hasAny = Array.isArray(notes)
|
|
837
|
+
? notes.some(n => n && typeof n.text === 'string' && n.text.trim())
|
|
838
|
+
: false;
|
|
839
|
+
if (!hasAny) return '';
|
|
596
840
|
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
841
|
+
// Lifecycle selection: pinned-always + expired-unpinned-dropped +
|
|
842
|
+
// superseded-dropped + rank (pinned > durable > recency) + same-class fold +
|
|
843
|
+
// byte-budget/count cap. `shown` is already highest-priority-first and carries
|
|
844
|
+
// per-entry fold info; renderOperatingNoteLine emits the exact bytes the
|
|
845
|
+
// budget was measured against.
|
|
846
|
+
const { shown, omittedCount } = selectOperatingNotesForPrompt(notes ?? [], now);
|
|
847
|
+
if (shown.length === 0) return '';
|
|
601
848
|
|
|
602
849
|
const lines: string[] = ['## Operating Notes', ''];
|
|
603
850
|
lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
|
|
604
851
|
lines.push('');
|
|
605
|
-
for (const
|
|
606
|
-
|
|
607
|
-
lines.push(`- ${cat}${truncateNote(n.text.trim())}`);
|
|
852
|
+
for (const entry of shown) {
|
|
853
|
+
lines.push(renderOperatingNoteLine(entry));
|
|
608
854
|
}
|
|
609
855
|
if (omittedCount > 0) {
|
|
610
856
|
lines.push('');
|
|
611
|
-
lines.push(`_${omittedCount}
|
|
857
|
+
lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted to fit the injection cap/byte-budget (kept in ledger; expired, superseded, and same-subject-folded notes are also hidden from this list but retained for audit; prune with \`mesh_forget_note\`)._`);
|
|
612
858
|
}
|
|
613
859
|
return lines.join('\n');
|
|
614
860
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// mesh-disk-retention — periodic disk / worktree retention for ~/.adhdev
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime/GC and
|
|
5
|
+
// grew the data volume until a refine bootstrap failed at 98% disk (mission
|
|
6
|
+
// 86def38d). Immediate reclaim was manual; this module implements the code-level
|
|
7
|
+
// RETENTION so it does not recur.
|
|
8
|
+
//
|
|
9
|
+
// What it prunes (all thresholds defensive — never touches a live/in-use file):
|
|
10
|
+
// 1. JSONL ledger files ~/.adhdev/mesh-ledger/*.jsonl older than 30 days
|
|
11
|
+
// (legacy after the SQLite ledger; zero lifetime before this).
|
|
12
|
+
// 2. session-host runtimes ~/.adhdev/session-host/*/runtimes/*.json for
|
|
13
|
+
// TERMINATED (dead) runtimes older than 14 days (live runtimes never touched).
|
|
14
|
+
// 3. DB backups ~/.adhdev/mesh-ledger/mesh-runtime.db.bak-* older
|
|
15
|
+
// than 7 days.
|
|
16
|
+
// Plus DETECTION-ONLY orphan-worktree signalling (see mesh-reconcile-loop.ts):
|
|
17
|
+
// 4. a worktree present on disk with no matching live mesh node is reported as a
|
|
18
|
+
// cleanup_candidate ledger entry — NEVER auto-deleted (that stays manual /
|
|
19
|
+
// coordinator-driven).
|
|
20
|
+
//
|
|
21
|
+
// The functions are split into PURE core selectors (age/orphan decisions, taking
|
|
22
|
+
// explicit paths + a `now` timestamp so they are deterministic and require no fs
|
|
23
|
+
// mocking in tests) and thin runtime wrappers that resolve the real ~/.adhdev
|
|
24
|
+
// paths and perform the unlink. The pure selectors are the unit-tested surface.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
statSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
} from 'fs';
|
|
34
|
+
import { join } from 'path';
|
|
35
|
+
import { getConfigDir } from '../config/config.js';
|
|
36
|
+
import { getLedgerDir, appendLedgerEntry, readLedgerEntries } from './mesh-ledger.js';
|
|
37
|
+
import { isSessionHostLiveRuntime } from '../session-host/runtime-surface.js';
|
|
38
|
+
import type { SessionHostSurfaceRecordLike } from '../session-host/runtime-surface.js';
|
|
39
|
+
import { listWorktrees } from '../git/git-worktree.js';
|
|
40
|
+
import type { LocalMeshEntry } from '../repo-mesh-types.js';
|
|
41
|
+
import { LOG } from '../logging/logger.js';
|
|
42
|
+
|
|
43
|
+
// ─── Thresholds (all defensive) ────────────────────────────────────────────
|
|
44
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
/** JSONL ledger files are legacy after the SQLite ledger — 30-day lifetime. */
|
|
46
|
+
export const LEDGER_JSONL_MAX_AGE_MS = 30 * DAY_MS;
|
|
47
|
+
/** Terminated session-host runtimes: conservative 14-day retention. */
|
|
48
|
+
export const SESSION_HOST_RUNTIME_MAX_AGE_MS = 14 * DAY_MS;
|
|
49
|
+
/** mesh-runtime.db.bak-* backups: 7-day retention. */
|
|
50
|
+
export const DB_BAK_MAX_AGE_MS = 7 * DAY_MS;
|
|
51
|
+
|
|
52
|
+
// ─── (1) JSONL ledger retention ─────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** A file candidate for age-based pruning: absolute path + last-modified epoch ms. */
|
|
55
|
+
export interface AgedFile {
|
|
56
|
+
path: string;
|
|
57
|
+
mtimeMs: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* PURE. Select the JSONL ledger files whose mtime is older than `maxAgeMs`
|
|
62
|
+
* relative to `now`. A file exactly at the threshold is KEPT (strict `>`), so a
|
|
63
|
+
* 30-day-old file survives its 30th day and is pruned on the 31st. Deterministic:
|
|
64
|
+
* no fs access, no clock read.
|
|
65
|
+
*/
|
|
66
|
+
export function selectExpiredLedgerJsonl(
|
|
67
|
+
files: AgedFile[],
|
|
68
|
+
now: number,
|
|
69
|
+
maxAgeMs: number = LEDGER_JSONL_MAX_AGE_MS,
|
|
70
|
+
): AgedFile[] {
|
|
71
|
+
return files.filter(f => now - f.mtimeMs > maxAgeMs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── (2) session-host runtime retention ─────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** A parsed session-host runtime file: its path, mtime, and the wrapped record. */
|
|
77
|
+
export interface SessionHostRuntimeFile {
|
|
78
|
+
path: string;
|
|
79
|
+
mtimeMs: number;
|
|
80
|
+
/** The `record` object from the on-disk `{ record, snapshot, updatedAt }` file. */
|
|
81
|
+
record: SessionHostSurfaceRecordLike | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* PURE. Select the session-host runtime files safe to delete: ONLY those whose
|
|
86
|
+
* runtime is terminated/dead (NOT a live runtime — decided by the session-host-core
|
|
87
|
+
* SSOT `isSessionHostLiveRuntime`) AND older than `maxAgeMs`. A live runtime, or a
|
|
88
|
+
* dead-but-recent one, is always kept. A file whose record failed to parse is treated
|
|
89
|
+
* as NON-live but is still age-gated, so a corrupt-but-fresh file is never removed.
|
|
90
|
+
*/
|
|
91
|
+
export function selectExpiredSessionHostRuntimes(
|
|
92
|
+
files: SessionHostRuntimeFile[],
|
|
93
|
+
now: number,
|
|
94
|
+
maxAgeMs: number = SESSION_HOST_RUNTIME_MAX_AGE_MS,
|
|
95
|
+
): SessionHostRuntimeFile[] {
|
|
96
|
+
return files.filter(f => {
|
|
97
|
+
// Never delete a live runtime, regardless of age.
|
|
98
|
+
if (isSessionHostLiveRuntime(f.record ?? undefined)) return false;
|
|
99
|
+
// Terminated/dead: age-gate it.
|
|
100
|
+
return now - f.mtimeMs > maxAgeMs;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── (3) DB backup retention ────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** True for a `mesh-runtime.db.bak-*` backup filename (basename only). */
|
|
107
|
+
export function isDbBackupFileName(name: string): boolean {
|
|
108
|
+
return /^mesh-runtime\.db\.bak-/.test(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** PURE. Select `.bak-*` backups older than `maxAgeMs`. Strict `>` (see ledger). */
|
|
112
|
+
export function selectExpiredDbBackups(
|
|
113
|
+
files: AgedFile[],
|
|
114
|
+
now: number,
|
|
115
|
+
maxAgeMs: number = DB_BAK_MAX_AGE_MS,
|
|
116
|
+
): AgedFile[] {
|
|
117
|
+
return files.filter(f => now - f.mtimeMs > maxAgeMs);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── (4) orphan worktree detection (detection-only) ─────────────────────────
|
|
121
|
+
|
|
122
|
+
/** Minimal shape needed to decide whether a worktree path is orphaned. */
|
|
123
|
+
export interface WorktreePathLike {
|
|
124
|
+
path: string;
|
|
125
|
+
bare?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A live mesh node's known workspace paths (self workspace + repoRoot, normalized). */
|
|
129
|
+
export interface LiveNodeWorkspaceLike {
|
|
130
|
+
workspace?: string;
|
|
131
|
+
repoRoot?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizePath(p: string): string {
|
|
135
|
+
// Trim a single trailing separator so "/a/b/" and "/a/b" compare equal.
|
|
136
|
+
// Case-preserving (git worktree list + meshes.json are both raw paths from the
|
|
137
|
+
// same daemon, so a case-fold would be over-eager on case-sensitive FS).
|
|
138
|
+
return p.replace(/[/\\]+$/, '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* PURE. Given the worktrees git reports on disk and the set of live-node workspace
|
|
143
|
+
* paths, return the worktrees that have NO matching live node — the orphan
|
|
144
|
+
* cleanup_candidates.
|
|
145
|
+
*
|
|
146
|
+
* SAFETY:
|
|
147
|
+
* - `mainWorktreePath` (the primary repo checkout, i.e. worktree[0]) is NEVER an
|
|
148
|
+
* orphan — it is the base repo, not a mesh clone.
|
|
149
|
+
* - `bare` worktrees are skipped (git's internal bookkeeping, not a node checkout).
|
|
150
|
+
* - Matching is path-equality after trailing-separator normalization against the
|
|
151
|
+
* union of every live node's `workspace` and `repoRoot`.
|
|
152
|
+
* This is DETECTION ONLY — the caller signals a cleanup_candidate; it must not delete.
|
|
153
|
+
*/
|
|
154
|
+
export function detectOrphanWorktrees(
|
|
155
|
+
worktrees: WorktreePathLike[],
|
|
156
|
+
liveNodes: LiveNodeWorkspaceLike[],
|
|
157
|
+
mainWorktreePath: string,
|
|
158
|
+
): WorktreePathLike[] {
|
|
159
|
+
const liveePaths = new Set<string>();
|
|
160
|
+
for (const n of liveNodes) {
|
|
161
|
+
if (n.workspace) liveePaths.add(normalizePath(n.workspace));
|
|
162
|
+
if (n.repoRoot) liveePaths.add(normalizePath(n.repoRoot));
|
|
163
|
+
}
|
|
164
|
+
const mainNorm = normalizePath(mainWorktreePath);
|
|
165
|
+
return worktrees.filter(wt => {
|
|
166
|
+
if (wt.bare) return false;
|
|
167
|
+
const norm = normalizePath(wt.path);
|
|
168
|
+
if (norm === mainNorm) return false; // base repo checkout — never an orphan
|
|
169
|
+
return !liveePaths.has(norm);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Runtime wrappers (resolve real paths + perform the unlink) ──────────────
|
|
174
|
+
// These are the side-effecting callers used by the reconcile loop. They are thin:
|
|
175
|
+
// gather → delegate to a PURE selector → unlink. Kept out of the unit tests (which
|
|
176
|
+
// target the deterministic selectors); their I/O is exercised by the daemon at runtime.
|
|
177
|
+
|
|
178
|
+
function safeUnlink(path: string): boolean {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(path);
|
|
181
|
+
return true;
|
|
182
|
+
} catch (e: any) {
|
|
183
|
+
LOG.warn('DiskRetention', `Failed to delete ${path}: ${e?.message || e}`);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function listDirFiles(dir: string): AgedFile[] {
|
|
189
|
+
if (!existsSync(dir)) return [];
|
|
190
|
+
const out: AgedFile[] = [];
|
|
191
|
+
let names: string[];
|
|
192
|
+
try {
|
|
193
|
+
names = readdirSync(dir);
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
for (const name of names) {
|
|
198
|
+
const path = join(dir, name);
|
|
199
|
+
try {
|
|
200
|
+
const st = statSync(path);
|
|
201
|
+
if (st.isFile()) out.push({ path, mtimeMs: st.mtimeMs });
|
|
202
|
+
} catch {
|
|
203
|
+
// vanished between readdir and stat — skip
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Prune expired legacy JSONL ledger files under ~/.adhdev/mesh-ledger/.
|
|
211
|
+
* Matches only `*.jsonl` (never the SQLite .db / -wal / -shm files). Returns the
|
|
212
|
+
* count deleted. Best-effort: individual unlink failures are logged, not thrown.
|
|
213
|
+
*/
|
|
214
|
+
export function pruneExpiredLedgerJsonl(now: number = Date.now()): number {
|
|
215
|
+
const dir = getLedgerDir();
|
|
216
|
+
const jsonl = listDirFiles(dir).filter(f => f.path.endsWith('.jsonl'));
|
|
217
|
+
const expired = selectExpiredLedgerJsonl(jsonl, now);
|
|
218
|
+
let deleted = 0;
|
|
219
|
+
for (const f of expired) if (safeUnlink(f.path)) deleted++;
|
|
220
|
+
if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} JSONL ledger file(s) older than 30d`);
|
|
221
|
+
return deleted;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Prune expired `mesh-runtime.db.bak-*` backups under ~/.adhdev/mesh-ledger/.
|
|
226
|
+
* Never touches the live DB (only names matching the .bak- prefix). Returns count.
|
|
227
|
+
*/
|
|
228
|
+
export function pruneExpiredDbBackups(now: number = Date.now()): number {
|
|
229
|
+
const dir = getLedgerDir();
|
|
230
|
+
const baks = listDirFiles(dir).filter(f => isDbBackupFileName(f.path.split(/[/\\]/).pop() || ''));
|
|
231
|
+
const expired = selectExpiredDbBackups(baks, now);
|
|
232
|
+
let deleted = 0;
|
|
233
|
+
for (const f of expired) if (safeUnlink(f.path)) deleted++;
|
|
234
|
+
if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} mesh-runtime.db.bak-* backup(s) older than 7d`);
|
|
235
|
+
return deleted;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Prune terminated session-host runtime files older than 14 days across every
|
|
240
|
+
* ~/.adhdev/session-host/<app>/runtimes/ directory. A LIVE runtime is never deleted
|
|
241
|
+
* regardless of age (isSessionHostLiveRuntime SSOT). Returns count deleted.
|
|
242
|
+
*/
|
|
243
|
+
export function pruneExpiredSessionHostRuntimes(now: number = Date.now()): number {
|
|
244
|
+
const root = join(getConfigDir(), 'session-host');
|
|
245
|
+
if (!existsSync(root)) return 0;
|
|
246
|
+
let apps: string[];
|
|
247
|
+
try {
|
|
248
|
+
apps = readdirSync(root);
|
|
249
|
+
} catch {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
const candidates: SessionHostRuntimeFile[] = [];
|
|
253
|
+
for (const app of apps) {
|
|
254
|
+
const runtimesDir = join(root, app, 'runtimes');
|
|
255
|
+
if (!existsSync(runtimesDir)) continue;
|
|
256
|
+
for (const f of listDirFiles(runtimesDir)) {
|
|
257
|
+
if (!f.path.endsWith('.json')) continue;
|
|
258
|
+
let record: SessionHostRuntimeFile['record'] = null;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(readFileSync(f.path, 'utf-8'));
|
|
261
|
+
const rec = parsed && typeof parsed === 'object' ? parsed.record : null;
|
|
262
|
+
record = rec && typeof rec === 'object' ? (rec as SessionHostSurfaceRecordLike) : null;
|
|
263
|
+
} catch {
|
|
264
|
+
// Unparseable → treat as non-live; still age-gated by the selector,
|
|
265
|
+
// so a corrupt-but-fresh file is preserved.
|
|
266
|
+
record = null;
|
|
267
|
+
}
|
|
268
|
+
candidates.push({ path: f.path, mtimeMs: f.mtimeMs, record });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const expired = selectExpiredSessionHostRuntimes(candidates, now);
|
|
272
|
+
let deleted = 0;
|
|
273
|
+
for (const f of expired) if (safeUnlink(f.path)) deleted++;
|
|
274
|
+
if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} terminated session-host runtime file(s) older than 14d`);
|
|
275
|
+
return deleted;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Run the file-deleting retention passes (JSONL ledger, DB backups, session-host
|
|
280
|
+
* runtimes) once. Each pass is isolated so one failing pass never blocks the others.
|
|
281
|
+
* Orphan-worktree DETECTION is driven separately in the reconcile loop (it needs the
|
|
282
|
+
* live mesh config + git worktree list and emits a ledger signal rather than deleting).
|
|
283
|
+
*/
|
|
284
|
+
export function runDiskRetentionSweep(now: number = Date.now()): { ledgerJsonl: number; dbBackups: number; sessionHostRuntimes: number } {
|
|
285
|
+
let ledgerJsonl = 0;
|
|
286
|
+
let dbBackups = 0;
|
|
287
|
+
let sessionHostRuntimes = 0;
|
|
288
|
+
try { ledgerJsonl = pruneExpiredLedgerJsonl(now); } catch (e: any) { LOG.warn('DiskRetention', `Ledger JSONL prune failed: ${e?.message || e}`); }
|
|
289
|
+
try { dbBackups = pruneExpiredDbBackups(now); } catch (e: any) { LOG.warn('DiskRetention', `DB backup prune failed: ${e?.message || e}`); }
|
|
290
|
+
try { sessionHostRuntimes = pruneExpiredSessionHostRuntimes(now); } catch (e: any) { LOG.warn('DiskRetention', `Session-host runtime prune failed: ${e?.message || e}`); }
|
|
291
|
+
return { ledgerJsonl, dbBackups, sessionHostRuntimes };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Orphan worktree detection (detection-only, emits cleanup_candidate) ──────
|
|
295
|
+
|
|
296
|
+
/** How many recent worktree_cleanup_candidate entries to scan for the re-emit guard. */
|
|
297
|
+
const ORPHAN_DEDUPE_WINDOW = 200;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Detect orphaned worktrees for ONE mesh and emit a `worktree_cleanup_candidate`
|
|
301
|
+
* ledger signal for each — DETECTION ONLY, never deletes. An orphan is a git worktree
|
|
302
|
+
* on disk with no matching live mesh node (compared by path). It:
|
|
303
|
+
* 1. picks a base (non-worktree) node owned by this mesh to anchor `git worktree list`;
|
|
304
|
+
* 2. diffs the reported worktrees against the union of every node's workspace/repoRoot;
|
|
305
|
+
* 3. skips the main worktree + bare entries (detectOrphanWorktrees safety);
|
|
306
|
+
* 4. suppresses a repeat for a worktreePath already signalled within the recent window
|
|
307
|
+
* (idempotent re-emit guard), so the hourly sweep doesn't spam the ledger.
|
|
308
|
+
* Returns the list of newly-signalled orphan paths. Best-effort: git/ledger failures are
|
|
309
|
+
* logged and yield an empty result, never thrown.
|
|
310
|
+
*/
|
|
311
|
+
export async function detectAndSignalOrphanWorktrees(
|
|
312
|
+
mesh: LocalMeshEntry,
|
|
313
|
+
now: number = Date.now(),
|
|
314
|
+
): Promise<string[]> {
|
|
315
|
+
const nodes = Array.isArray(mesh.nodes) ? mesh.nodes : [];
|
|
316
|
+
// Anchor: a base (non-worktree) node's repoRoot (preferred) or workspace. The base
|
|
317
|
+
// node is the primary checkout; its git dir enumerates every worktree of the repo.
|
|
318
|
+
const baseNode = nodes.find(n => !n.isLocalWorktree && (n.repoRoot || n.workspace));
|
|
319
|
+
const repoRoot = baseNode?.repoRoot || baseNode?.workspace;
|
|
320
|
+
if (!repoRoot) return []; // no local base checkout for this mesh on this daemon
|
|
321
|
+
|
|
322
|
+
let worktrees: WorktreePathLike[];
|
|
323
|
+
try {
|
|
324
|
+
worktrees = await listWorktrees(repoRoot);
|
|
325
|
+
} catch (e: any) {
|
|
326
|
+
LOG.warn('DiskRetention', `git worktree list failed for mesh ${mesh.id} (${repoRoot}): ${e?.message || e}`);
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
// git worktree list emits the main worktree first; treat it as the base repo.
|
|
330
|
+
const mainWorktreePath = worktrees[0]?.path || repoRoot;
|
|
331
|
+
const liveNodes: LiveNodeWorkspaceLike[] = nodes.map(n => ({ workspace: n.workspace, repoRoot: n.repoRoot }));
|
|
332
|
+
const orphans = detectOrphanWorktrees(worktrees, liveNodes, mainWorktreePath);
|
|
333
|
+
if (orphans.length === 0) return [];
|
|
334
|
+
|
|
335
|
+
// Re-emit guard: skip a worktreePath already signalled in the recent window so the
|
|
336
|
+
// hourly sweep is idempotent and does not flood the ledger with duplicates.
|
|
337
|
+
let recentPaths = new Set<string>();
|
|
338
|
+
try {
|
|
339
|
+
const recent = readLedgerEntries(mesh.id, { kind: ['worktree_cleanup_candidate'], tail: ORPHAN_DEDUPE_WINDOW });
|
|
340
|
+
for (const e of recent) {
|
|
341
|
+
const p = typeof e.payload?.worktreePath === 'string' ? e.payload.worktreePath : '';
|
|
342
|
+
if (p) recentPaths.add(p);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
recentPaths = new Set();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const signalled: string[] = [];
|
|
349
|
+
for (const wt of orphans) {
|
|
350
|
+
if (recentPaths.has(wt.path)) continue;
|
|
351
|
+
try {
|
|
352
|
+
appendLedgerEntry(mesh.id, {
|
|
353
|
+
kind: 'worktree_cleanup_candidate',
|
|
354
|
+
payload: {
|
|
355
|
+
worktreePath: wt.path,
|
|
356
|
+
reason: 'no_matching_live_node',
|
|
357
|
+
state: 'cleanup_candidate',
|
|
358
|
+
detectedAt: new Date(now).toISOString(),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
signalled.push(wt.path);
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
LOG.warn('DiskRetention', `Failed to record orphan worktree signal for ${wt.path}: ${e?.message || e}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (signalled.length > 0) {
|
|
367
|
+
LOG.info('DiskRetention', `Detected ${signalled.length} orphan worktree(s) for mesh ${mesh.id} (cleanup_candidate — NOT deleted): ${signalled.join(', ')}`);
|
|
368
|
+
}
|
|
369
|
+
return signalled;
|
|
370
|
+
}
|