@adhdev/daemon-core 0.9.82-rc.442 → 0.9.82-rc.444
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/commands/high-family/types.d.ts +4 -0
- package/dist/commands/router.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +551 -63
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +543 -63
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +39 -1
- package/dist/mesh/mesh-node-identity.d.ts +15 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +15 -1
- package/dist/providers/chat-message-normalization.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +28 -2
- package/package.json +2 -2
- package/src/commands/chat-commands-read.ts +41 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +4 -2
- package/src/commands/high-family/mesh-events.ts +11 -2
- package/src/commands/high-family/mesh-status.ts +102 -9
- package/src/commands/high-family/types.ts +5 -1
- package/src/commands/router.ts +19 -2
- package/src/index.ts +1 -1
- package/src/mesh/coordinator-prompt.ts +1 -0
- package/src/mesh/mesh-ledger.ts +185 -0
- package/src/mesh/mesh-node-identity.ts +106 -18
- package/src/mesh/mesh-reconcile-loop.ts +22 -1
- package/src/providers/chat-message-normalization.ts +1 -1
- package/src/providers/cli-provider-instance.ts +130 -17
- package/src/providers/native-history/antigravity-cli-transcript.ts +348 -25
- package/src/providers/native-history/dispatcher.ts +33 -14
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -49,6 +49,11 @@ export type MeshLedgerKind =
|
|
|
49
49
|
// the ledger so it survives coordinator restarts and is provider-neutral.
|
|
50
50
|
// payload: { text, category?, createdAt?, sourceCoordinator? }
|
|
51
51
|
| 'coordinator_operating_note'
|
|
52
|
+
// Retraction of a coordinator_operating_note. Append-only (history preserved);
|
|
53
|
+
// readers filter out the targeted note so it leaves the prompt/list. Targets by
|
|
54
|
+
// note id (exact) and/or by trimmed-text fingerprint (matches all notes with that
|
|
55
|
+
// text). payload: { targetNoteId?, targetFingerprint?, reason?, forgottenAt? }
|
|
56
|
+
| 'coordinator_operating_note_tombstone'
|
|
52
57
|
// Mission audit trail: mission record mutations (mesh_mission_upsert) so the
|
|
53
58
|
// ledger captures mission lifecycle, not just task events. Without these a
|
|
54
59
|
// mission create / goal rewrite / status transition left no ledger trace,
|
|
@@ -253,6 +258,32 @@ const ARCHIVABLE_KINDS: ReadonlySet<MeshLedgerKind> = new Set([
|
|
|
253
258
|
const DEFAULT_LEDGER_SLICE_LIMIT = 100;
|
|
254
259
|
export const MAX_LEDGER_SLICE_LIMIT = 500;
|
|
255
260
|
|
|
261
|
+
// ─── Operating-note growth control ──────────────
|
|
262
|
+
// coordinator_operating_note is append-only and, unlike task_* entries, is never
|
|
263
|
+
// archived by compactLedger (it is not in ARCHIVABLE_KINDS — it must survive
|
|
264
|
+
// restarts and there is no time-based cutoff for a "lesson"). Without dedicated
|
|
265
|
+
// controls the note set grows without bound and duplicate/stale notes crowd the
|
|
266
|
+
// bounded tail that rides into the coordinator prompt. These three constants back
|
|
267
|
+
// the three growth controls: dedupe-on-record, tombstone/forget, keep-latest-N.
|
|
268
|
+
|
|
269
|
+
// Kind marking an operating note as retracted. A tombstone is itself an
|
|
270
|
+
// append-only ledger entry (history is never destroyed); readers filter out the
|
|
271
|
+
// notes it targets. payload: { targetNoteId?, targetFingerprint?, reason? }
|
|
272
|
+
export const OPERATING_NOTE_KIND: MeshLedgerKind = 'coordinator_operating_note';
|
|
273
|
+
export const OPERATING_NOTE_TOMBSTONE_KIND: MeshLedgerKind = 'coordinator_operating_note_tombstone';
|
|
274
|
+
|
|
275
|
+
// Dedupe window: when recording a note, if the same trimmed text already appears
|
|
276
|
+
// among the most recent OPERATING_NOTE_DEDUPE_WINDOW notes, the record is a no-op
|
|
277
|
+
// (the existing entry is returned). Keeps the prompt tail from filling with the
|
|
278
|
+
// same lesson recorded 20 times.
|
|
279
|
+
export const OPERATING_NOTE_DEDUPE_WINDOW = 40;
|
|
280
|
+
|
|
281
|
+
// Keep-latest-N: pruneOperatingNotes retains at most this many live (non-tombstoned)
|
|
282
|
+
// operating notes, removing the oldest surplus and any tombstoned notes from the
|
|
283
|
+
// store. The prompt reads a much smaller tail (20), so this bound never trims what
|
|
284
|
+
// a coordinator actually sees while still capping unbounded store growth.
|
|
285
|
+
export const OPERATING_NOTE_KEEP_LATEST = 100;
|
|
286
|
+
|
|
256
287
|
// ─── Path Helpers ───────────────────────────────
|
|
257
288
|
|
|
258
289
|
export function getLedgerDir(): string {
|
|
@@ -605,6 +636,23 @@ export function appendLedgerEntry(
|
|
|
605
636
|
meshId: string,
|
|
606
637
|
partial: Omit<MeshLedgerEntry, 'id' | 'meshId' | 'timestamp'>,
|
|
607
638
|
): MeshLedgerEntry {
|
|
639
|
+
// Fix (1) dedupe-on-record: for a coordinator_operating_note, if the same
|
|
640
|
+
// trimmed text already appears among the most recent OPERATING_NOTE_DEDUPE_WINDOW
|
|
641
|
+
// notes, do NOT append a duplicate — return the existing entry so the bounded
|
|
642
|
+
// prompt tail can't be crowded by the same lesson recorded repeatedly. Other
|
|
643
|
+
// kinds (task_completed, …) are untouched.
|
|
644
|
+
if (partial.kind === OPERATING_NOTE_KIND) {
|
|
645
|
+
const text = operatingNoteText(partial.payload);
|
|
646
|
+
if (text) {
|
|
647
|
+
const recentNotes = readLedgerEntries(meshId, {
|
|
648
|
+
kind: [OPERATING_NOTE_KIND],
|
|
649
|
+
tail: OPERATING_NOTE_DEDUPE_WINDOW,
|
|
650
|
+
});
|
|
651
|
+
const existing = recentNotes.find(e => operatingNoteText(e.payload) === text);
|
|
652
|
+
if (existing) return existing;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
608
656
|
const entry: MeshLedgerEntry = {
|
|
609
657
|
id: randomUUID(),
|
|
610
658
|
meshId,
|
|
@@ -653,12 +701,149 @@ export function appendLedgerEntry(
|
|
|
653
701
|
appendFileSync(filePath, line, { encoding: 'utf-8', mode: 0o600 });
|
|
654
702
|
invalidateLedgerCache(meshId);
|
|
655
703
|
meshLedgerEvents.emit('append', meshId, entry);
|
|
704
|
+
// Fix (3) keep-latest-N: operating notes are never archived by compactLedger,
|
|
705
|
+
// so cap their store footprint here. Runs only when a note (or its tombstone)
|
|
706
|
+
// is recorded, and is a no-op until the live-note count exceeds the bound.
|
|
707
|
+
if (entry.kind === OPERATING_NOTE_KIND || entry.kind === OPERATING_NOTE_TOMBSTONE_KIND) {
|
|
708
|
+
try { pruneOperatingNotes(meshId); } catch { /* prune is best-effort */ }
|
|
709
|
+
}
|
|
656
710
|
return entry;
|
|
657
711
|
} catch (e: any) {
|
|
658
712
|
throw new Error(`Failed to append to ledger for mesh ${meshId}: ${e.message}`);
|
|
659
713
|
}
|
|
660
714
|
}
|
|
661
715
|
|
|
716
|
+
// ─── Operating-note growth controls ─────────────
|
|
717
|
+
|
|
718
|
+
/** Extract the trimmed note text from a coordinator_operating_note payload. */
|
|
719
|
+
function operatingNoteText(payload: Record<string, unknown> | undefined): string | undefined {
|
|
720
|
+
const text = payload && typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
721
|
+
return text || undefined;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Set of trimmed-text fingerprints and note ids retracted by tombstone entries in
|
|
726
|
+
* the given entry set. A tombstone targets by note id and/or by text fingerprint.
|
|
727
|
+
*/
|
|
728
|
+
function collectOperatingNoteTombstones(entries: MeshLedgerEntry[]): { ids: Set<string>; fingerprints: Set<string> } {
|
|
729
|
+
const ids = new Set<string>();
|
|
730
|
+
const fingerprints = new Set<string>();
|
|
731
|
+
for (const e of entries) {
|
|
732
|
+
if (e.kind !== OPERATING_NOTE_TOMBSTONE_KIND) continue;
|
|
733
|
+
const p = e.payload || {};
|
|
734
|
+
const targetId = typeof p.targetNoteId === 'string' ? p.targetNoteId.trim() : '';
|
|
735
|
+
const targetFp = typeof p.targetFingerprint === 'string' ? p.targetFingerprint.trim() : '';
|
|
736
|
+
if (targetId) ids.add(targetId);
|
|
737
|
+
if (targetFp) fingerprints.add(targetFp);
|
|
738
|
+
}
|
|
739
|
+
return { ids, fingerprints };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** True if the operating note is retracted by any tombstone in `tombstones`. */
|
|
743
|
+
export function isOperatingNoteTombstoned(
|
|
744
|
+
entry: Pick<MeshLedgerEntry, 'id' | 'payload'>,
|
|
745
|
+
tombstones: { ids: Set<string>; fingerprints: Set<string> },
|
|
746
|
+
): boolean {
|
|
747
|
+
if (tombstones.ids.has(entry.id)) return true;
|
|
748
|
+
const text = operatingNoteText(entry.payload);
|
|
749
|
+
return text ? tombstones.fingerprints.has(text) : false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Fix (2) supersede/remove: append a tombstone that retracts a coordinator
|
|
754
|
+
* operating note. Targets by note id and/or by exact trimmed text (a text target
|
|
755
|
+
* retracts every note with that text). History is preserved — the notes stay in
|
|
756
|
+
* the ledger but readers filter them out. Returns how many currently-live notes
|
|
757
|
+
* the tombstone will hide.
|
|
758
|
+
*/
|
|
759
|
+
export function tombstoneOperatingNote(
|
|
760
|
+
meshId: string,
|
|
761
|
+
target: { noteId?: string; text?: string; reason?: string },
|
|
762
|
+
): { tombstone: MeshLedgerEntry; matched: number } {
|
|
763
|
+
const noteId = typeof target.noteId === 'string' ? target.noteId.trim() : '';
|
|
764
|
+
const fingerprint = typeof target.text === 'string' ? target.text.trim() : '';
|
|
765
|
+
if (!noteId && !fingerprint) {
|
|
766
|
+
throw new Error('tombstoneOperatingNote requires a noteId or text target');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Count currently-live matches (not already tombstoned) for the caller's report.
|
|
770
|
+
const notes = readOperatingNotes(meshId);
|
|
771
|
+
const matched = notes.filter(n =>
|
|
772
|
+
(noteId && n.id === noteId) || (fingerprint && operatingNoteText(n.payload) === fingerprint),
|
|
773
|
+
).length;
|
|
774
|
+
|
|
775
|
+
const tombstone = appendLedgerEntry(meshId, {
|
|
776
|
+
kind: OPERATING_NOTE_TOMBSTONE_KIND,
|
|
777
|
+
payload: {
|
|
778
|
+
...(noteId ? { targetNoteId: noteId } : {}),
|
|
779
|
+
...(fingerprint ? { targetFingerprint: fingerprint } : {}),
|
|
780
|
+
...(target.reason && target.reason.trim() ? { reason: target.reason.trim() } : {}),
|
|
781
|
+
forgottenAt: new Date().toISOString(),
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
return { tombstone, matched };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Read live operating notes (tombstoned notes filtered out), oldest→newest.
|
|
789
|
+
* `tail` bounds the number of live notes returned (the freshest N).
|
|
790
|
+
*/
|
|
791
|
+
export function readOperatingNotes(meshId: string, opts?: { tail?: number }): MeshLedgerEntry[] {
|
|
792
|
+
const raw = getCachedRawEntries(meshId);
|
|
793
|
+
const tombstones = collectOperatingNoteTombstones(raw);
|
|
794
|
+
let notes = raw.filter(e => e.kind === OPERATING_NOTE_KIND && !isOperatingNoteTombstoned(e, tombstones));
|
|
795
|
+
if (opts?.tail && opts.tail > 0 && notes.length > opts.tail) {
|
|
796
|
+
notes = notes.slice(-opts.tail);
|
|
797
|
+
}
|
|
798
|
+
return notes;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Fix (3) keep-latest-N prune for coordinator_operating_note. Removes, from the
|
|
803
|
+
* store, (a) every note retracted by a tombstone, and (b) the oldest live notes
|
|
804
|
+
* beyond `keepLatest`. Tombstone entries themselves are retained as an audit trail
|
|
805
|
+
* of what was forgotten. Returns the number of note entries removed.
|
|
806
|
+
*/
|
|
807
|
+
export function pruneOperatingNotes(meshId: string, keepLatest: number = OPERATING_NOTE_KEEP_LATEST): number {
|
|
808
|
+
const raw = getCachedRawEntries(meshId);
|
|
809
|
+
const tombstones = collectOperatingNoteTombstones(raw);
|
|
810
|
+
|
|
811
|
+
const removeIds: string[] = [];
|
|
812
|
+
const liveNotes: MeshLedgerEntry[] = [];
|
|
813
|
+
for (const e of raw) {
|
|
814
|
+
if (e.kind !== OPERATING_NOTE_KIND) continue;
|
|
815
|
+
if (isOperatingNoteTombstoned(e, tombstones)) {
|
|
816
|
+
removeIds.push(e.id); // tombstoned notes are pruned first
|
|
817
|
+
} else {
|
|
818
|
+
liveNotes.push(e);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Drop the oldest live notes beyond keepLatest; the freshest (prompt tail) survive.
|
|
823
|
+
const bound = Math.max(0, Math.floor(keepLatest));
|
|
824
|
+
if (liveNotes.length > bound) {
|
|
825
|
+
for (const e of liveNotes.slice(0, liveNotes.length - bound)) removeIds.push(e.id);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (removeIds.length === 0) return 0;
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
MeshRuntimeStore.getInstance().deleteLedgerEntries(meshId, removeIds);
|
|
832
|
+
} catch { /* store unavailable — JSONL rewrite below still trims */ }
|
|
833
|
+
|
|
834
|
+
// Rewrite the JSONL mirror without the pruned note entries so the export
|
|
835
|
+
// artifact stays consistent with the store.
|
|
836
|
+
try {
|
|
837
|
+
const remaining = readLedgerFile(meshId).filter(e => !removeIds.includes(e.id));
|
|
838
|
+
const filePath = getLedgerPath(meshId);
|
|
839
|
+
const lines = remaining.length ? remaining.map(e => JSON.stringify(e)).join('\n') + '\n' : '';
|
|
840
|
+
writeFileSync(filePath, lines, { encoding: 'utf-8', mode: 0o600 });
|
|
841
|
+
} catch { /* JSONL rewrite best-effort; store is the primary read path */ }
|
|
842
|
+
|
|
843
|
+
invalidateLedgerCache(meshId);
|
|
844
|
+
return removeIds.length;
|
|
845
|
+
}
|
|
846
|
+
|
|
662
847
|
function clampLedgerSliceLimit(limit: unknown): number {
|
|
663
848
|
if (typeof limit !== 'number' || !Number.isFinite(limit)) return DEFAULT_LEDGER_SLICE_LIMIT;
|
|
664
849
|
return Math.max(1, Math.min(MAX_LEDGER_SLICE_LIMIT, Math.floor(limit)));
|
|
@@ -1308,6 +1308,28 @@ export class MeshGitProbeCache {
|
|
|
1308
1308
|
return `${daemonId}::${workspace}`;
|
|
1309
1309
|
}
|
|
1310
1310
|
|
|
1311
|
+
/**
|
|
1312
|
+
* Local (same-machine) git_status dedup. The bootstrap direct-truth hydrate
|
|
1313
|
+
* and the per-node render loop both call getGitRepoStatus(refreshUpstream:true)
|
|
1314
|
+
* for the same local workspace within one mesh_status call. Each such probe
|
|
1315
|
+
* fans out ~13-15 git subprocesses, and because the two passes are separated by
|
|
1316
|
+
* the render/hydrate work of every OTHER node they routinely straddle the
|
|
1317
|
+
* getGitRepoStatus 1.5s TTL, so the second pass re-shells the whole ~14-process
|
|
1318
|
+
* collection. Routing both through this cache (namespaced under a reserved
|
|
1319
|
+
* daemon id so it never collides with a remote-peer key) collapses them to one
|
|
1320
|
+
* collection per workspace per request, and reuses it across the reuse window
|
|
1321
|
+
* so the dashboard auto-retry loop can't restart a fresh local probe seconds
|
|
1322
|
+
* apart either.
|
|
1323
|
+
*/
|
|
1324
|
+
private static readonly LOCAL_PROBE_DAEMON_ID = '__local_git__';
|
|
1325
|
+
|
|
1326
|
+
async probeLocal(
|
|
1327
|
+
workspace: string,
|
|
1328
|
+
probe: () => Promise<Record<string, unknown> | null>,
|
|
1329
|
+
): Promise<Record<string, unknown> | null> {
|
|
1330
|
+
return this.probe(MeshGitProbeCache.LOCAL_PROBE_DAEMON_ID, workspace, probe);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1311
1333
|
/**
|
|
1312
1334
|
* Run `probe` for this peer, but reuse a fresh recent result or an in-flight
|
|
1313
1335
|
* probe for the same key when one is available. `probe` is only invoked when
|
|
@@ -1551,7 +1573,25 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1551
1573
|
const unavailableNodeIds: string[] = [];
|
|
1552
1574
|
const deadNodeIds: string[] = [];
|
|
1553
1575
|
|
|
1554
|
-
|
|
1576
|
+
// Each node's classification (local git probe, standing truth, or the remote
|
|
1577
|
+
// P2P fan-out) is independent, so probing them serially stacked one slow
|
|
1578
|
+
// (often TURN-relayed) peer's latency onto every other node — the 3×25s serial
|
|
1579
|
+
// stall. Classify all nodes concurrently via Promise.allSettled; each node has
|
|
1580
|
+
// its own bounded per-peer timeout + definitively-down fast-fail inside
|
|
1581
|
+
// probeRemoteMeshGitStatusWithRetry, so a hung peer degrades to `unavailable`
|
|
1582
|
+
// for THAT node only and never blocks the aggregate. The counters and
|
|
1583
|
+
// unavailable/dead node lists are folded from the settled results afterward so
|
|
1584
|
+
// no shared mutable state is touched concurrently.
|
|
1585
|
+
type NodeTruthResult =
|
|
1586
|
+
| { kind: 'dead'; nodeId: string }
|
|
1587
|
+
| { kind: 'unavailable'; nodeId: string; attempted?: boolean }
|
|
1588
|
+
| { kind: 'local' }
|
|
1589
|
+
| { kind: 'standing' }
|
|
1590
|
+
| { kind: 'peerConfirmed' }
|
|
1591
|
+
| { kind: 'peerUnavailable'; nodeId: string }
|
|
1592
|
+
| { kind: 'skip' };
|
|
1593
|
+
|
|
1594
|
+
const classifyNode = async (nodeIndex: number, node: any): Promise<NodeTruthResult> => {
|
|
1555
1595
|
const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
|
|
1556
1596
|
const workspace = readStringValue(node?.workspace);
|
|
1557
1597
|
const daemonId = readStringValue(node?.daemonId);
|
|
@@ -1573,23 +1613,27 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1573
1613
|
daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
|
|
1574
1614
|
);
|
|
1575
1615
|
if ((isSelfNode || isSelfDaemonNode) && isDeadLocalWorktreeNode(node)) {
|
|
1576
|
-
|
|
1577
|
-
continue;
|
|
1616
|
+
return { kind: 'dead', nodeId };
|
|
1578
1617
|
}
|
|
1579
1618
|
|
|
1580
1619
|
if (!workspace) {
|
|
1581
|
-
|
|
1582
|
-
continue;
|
|
1620
|
+
return (!isSelfNode && daemonId) ? { kind: 'unavailable', nodeId } : { kind: 'skip' };
|
|
1583
1621
|
}
|
|
1584
1622
|
|
|
1585
1623
|
if (fs.existsSync(workspace)) {
|
|
1586
1624
|
try {
|
|
1587
|
-
|
|
1625
|
+
// Route the local probe through the shared cache so the per-node
|
|
1626
|
+
// render loop's getGitRepoStatus for the same workspace reuses this
|
|
1627
|
+
// exact result instead of re-shelling ~14 git processes when the two
|
|
1628
|
+
// passes straddle the getGitRepoStatus 1.5s TTL.
|
|
1629
|
+
const runLocalProbe = () => getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true }) as unknown as Promise<Record<string, unknown> | null>;
|
|
1630
|
+
const localGit = args.probeCache
|
|
1631
|
+
? await args.probeCache.probeLocal(workspace, runLocalProbe)
|
|
1632
|
+
: await runLocalProbe();
|
|
1588
1633
|
if (localGit?.isGitRepo) {
|
|
1589
1634
|
const reporter = recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
1590
1635
|
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1591
|
-
|
|
1592
|
-
continue;
|
|
1636
|
+
return { kind: 'local' };
|
|
1593
1637
|
}
|
|
1594
1638
|
} catch {
|
|
1595
1639
|
// Fall through to remote classification.
|
|
@@ -1603,8 +1647,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1603
1647
|
// block the bootstrap.
|
|
1604
1648
|
const standingGit = buildInlineMeshTransitGitStatus(node);
|
|
1605
1649
|
if (standingGit) {
|
|
1606
|
-
|
|
1607
|
-
continue;
|
|
1650
|
+
return { kind: 'standing' };
|
|
1608
1651
|
}
|
|
1609
1652
|
|
|
1610
1653
|
if (!args.probeRemotePeers) {
|
|
@@ -1612,15 +1655,13 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1612
1655
|
// pending (the per-node loop marks it gitProbePending and the graph
|
|
1613
1656
|
// shows setup inventory for it). It is NOT unavailable — the graph
|
|
1614
1657
|
// must still render. An explicit refresh will fan out and freshen it.
|
|
1615
|
-
|
|
1658
|
+
return { kind: 'skip' };
|
|
1616
1659
|
}
|
|
1617
1660
|
|
|
1618
1661
|
if (!daemonId || !args.dispatchMeshCommand) {
|
|
1619
|
-
|
|
1620
|
-
continue;
|
|
1662
|
+
return !isSelfNode ? { kind: 'unavailable', nodeId } : { kind: 'skip' };
|
|
1621
1663
|
}
|
|
1622
1664
|
|
|
1623
|
-
peerAttemptedCount += 1;
|
|
1624
1665
|
// Bounded retry, gated on the peer staying `connected`: a slow
|
|
1625
1666
|
// (TURN-relayed) peer that just exceeds one probe window is recovered
|
|
1626
1667
|
// instead of being hard-failed. The connection is re-checked before each
|
|
@@ -1642,8 +1683,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1642
1683
|
if (remoteGit) {
|
|
1643
1684
|
const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
1644
1685
|
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1645
|
-
|
|
1646
|
-
continue;
|
|
1686
|
+
return { kind: 'peerConfirmed' };
|
|
1647
1687
|
}
|
|
1648
1688
|
|
|
1649
1689
|
// Invariant: a connected peer that still holds standing git truth is
|
|
@@ -1652,8 +1692,56 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1652
1692
|
// not currently connected, or it is connected but every bounded probe
|
|
1653
1693
|
// failed — that is the genuine "connected, no truth, retries exhausted"
|
|
1654
1694
|
// case that drives the explicit-refresh hard-fail.
|
|
1655
|
-
|
|
1656
|
-
}
|
|
1695
|
+
return { kind: 'peerUnavailable', nodeId };
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
const nodeEntries = [...nodes.entries()];
|
|
1699
|
+
const settledResults = await Promise.allSettled(
|
|
1700
|
+
nodeEntries.map(([nodeIndex, node]) => classifyNode(nodeIndex, node)),
|
|
1701
|
+
);
|
|
1702
|
+
settledResults.forEach((settled, i) => {
|
|
1703
|
+
const [nodeIndex, node] = nodeEntries[i];
|
|
1704
|
+
// A classifier should never reject (every probe is caught internally), but
|
|
1705
|
+
// if one does, degrade that node to `unavailable` when it is a remote peer —
|
|
1706
|
+
// never silently drop it, never fail the whole aggregate.
|
|
1707
|
+
const result: NodeTruthResult = settled.status === 'fulfilled'
|
|
1708
|
+
? settled.value
|
|
1709
|
+
: (() => {
|
|
1710
|
+
const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
|
|
1711
|
+
const daemonId = readStringValue(node?.daemonId);
|
|
1712
|
+
const isSelfNode = Boolean(
|
|
1713
|
+
nodeId && selectedCoordinatorNodeId && nodeId === selectedCoordinatorNodeId,
|
|
1714
|
+
) || Boolean(
|
|
1715
|
+
daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
|
|
1716
|
+
);
|
|
1717
|
+
return (!isSelfNode && daemonId) ? { kind: 'unavailable', nodeId } as NodeTruthResult : { kind: 'skip' } as NodeTruthResult;
|
|
1718
|
+
})();
|
|
1719
|
+
switch (result.kind) {
|
|
1720
|
+
case 'dead':
|
|
1721
|
+
deadNodeIds.push(result.nodeId);
|
|
1722
|
+
break;
|
|
1723
|
+
case 'unavailable':
|
|
1724
|
+
unavailableNodeIds.push(result.nodeId);
|
|
1725
|
+
break;
|
|
1726
|
+
case 'local':
|
|
1727
|
+
localConfirmedCount += 1;
|
|
1728
|
+
break;
|
|
1729
|
+
case 'standing':
|
|
1730
|
+
standingEvidenceCount += 1;
|
|
1731
|
+
break;
|
|
1732
|
+
case 'peerConfirmed':
|
|
1733
|
+
peerAttemptedCount += 1;
|
|
1734
|
+
peerConfirmedCount += 1;
|
|
1735
|
+
break;
|
|
1736
|
+
case 'peerUnavailable':
|
|
1737
|
+
peerAttemptedCount += 1;
|
|
1738
|
+
unavailableNodeIds.push(result.nodeId);
|
|
1739
|
+
break;
|
|
1740
|
+
case 'skip':
|
|
1741
|
+
default:
|
|
1742
|
+
break;
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1657
1745
|
|
|
1658
1746
|
return {
|
|
1659
1747
|
directEvidenceCount: localConfirmedCount + peerConfirmedCount + standingEvidenceCount,
|
|
@@ -523,11 +523,26 @@ export function resolveCoordinatorDrainDeliverability(
|
|
|
523
523
|
* remote pull is never blocked by our local coordinator's busy state. A pure
|
|
524
524
|
* stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
|
|
525
525
|
* result remains the surface and the drain proceeds. No regression to either.
|
|
526
|
+
*
|
|
527
|
+
* SELF-COORDINATOR INBOX LEVEL-DRAIN (Defect 2): the hold above assumes the ONLY
|
|
528
|
+
* surface for a busy local coordinator's events is a future PTY inject on its idle
|
|
529
|
+
* edge, so it defers to the reconcile loop. But when the drain caller IS the local
|
|
530
|
+
* coordinator reading its OWN inbox (the `get_pending_mesh_events` call whose events
|
|
531
|
+
* are returned in the caller's tool RESULT — a data queue the self-coordinating LLM
|
|
532
|
+
* consumes directly), the events ARE surfaced losslessly the moment the tool returns,
|
|
533
|
+
* with NO PTY write. A busy self-coordinating LLM that calls a mesh tool mid-turn would
|
|
534
|
+
* otherwise get an empty inbox (held) and only see the completion on its NEXT busy→idle
|
|
535
|
+
* edge — the measured ~59s strand. `callerIsSelfCoordinatorInboxRead` marks that safe
|
|
536
|
+
* caller: the hold is relaxed for it (return the events), while every OTHER drain (a
|
|
537
|
+
* backfill relay, a broadcast poll, a DIFFERENT coordinator that genuinely needs its PTY)
|
|
538
|
+
* still defers to the reconcile loop. This relaxes delivery INTO the coordinator's own
|
|
539
|
+
* inbox only — it never changes how events are injected into a live PTY prompt.
|
|
526
540
|
*/
|
|
527
541
|
export function shouldHoldPendingDrainForBusyLocalCoordinator(
|
|
528
542
|
components: Pick<DaemonComponents, 'instanceManager'> & { statusInstanceId?: string },
|
|
529
543
|
meshId: string,
|
|
530
544
|
requestedCoordinatorDaemonId?: string | null,
|
|
545
|
+
callerIsSelfCoordinatorInboxRead?: boolean,
|
|
531
546
|
): boolean {
|
|
532
547
|
if (!meshId) return false;
|
|
533
548
|
const deliverability = resolveCoordinatorDrainDeliverability(components, meshId);
|
|
@@ -539,7 +554,13 @@ export function shouldHoldPendingDrainForBusyLocalCoordinator(
|
|
|
539
554
|
readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId),
|
|
540
555
|
readNonEmptyString(loadConfig().machineId),
|
|
541
556
|
]);
|
|
542
|
-
|
|
557
|
+
const targetsLocalCoordinator = localIds.some(id => daemonIdsEquivalent(id, requested));
|
|
558
|
+
if (!targetsLocalCoordinator) return false;
|
|
559
|
+
// SELF-COORDINATOR INBOX LEVEL-DRAIN: the busy local coordinator is itself the caller,
|
|
560
|
+
// reading its own inbox — the drained events return in ITS tool result (lossless data-queue
|
|
561
|
+
// surface, no PTY inject). Do NOT hold; let the self-coordinator see its completions now.
|
|
562
|
+
if (callerIsSelfCoordinatorInboxRead) return false;
|
|
563
|
+
return true;
|
|
543
564
|
}
|
|
544
565
|
|
|
545
566
|
// Inject a drained pending event into a live coordinator session. Force-inject
|
|
@@ -27,7 +27,7 @@ export function extractFinalSummaryFromMessages(
|
|
|
27
27
|
return '';
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function readChatMessageTimestampMs(message: ChatMessage | null | undefined): number | undefined {
|
|
30
|
+
export function readChatMessageTimestampMs(message: ChatMessage | null | undefined): number | undefined {
|
|
31
31
|
if (!message) return undefined;
|
|
32
32
|
const record = message as ChatMessage & Record<string, unknown>;
|
|
33
33
|
for (const value of [record.timestamp, record.createdAt, record.created_at, record.updatedAt, record.time, record.receivedAt]) {
|