@adhdev/daemon-core 0.9.82-rc.481 → 0.9.82-rc.483
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/config/mesh-config.d.ts +10 -28
- package/dist/index.d.ts +2 -2
- package/dist/index.js +337 -249
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +337 -244
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +35 -9
- package/dist/mesh/mesh-reconcile-loop.d.ts +4 -0
- package/dist/mesh/mesh-runtime-store.d.ts +47 -2
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +4 -0
- package/dist/repo-mesh-types.d.ts +8 -14
- package/dist/types.d.ts +15 -6
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +81 -60
- package/src/commands/low-family/coordinator-prompt.ts +53 -0
- package/src/commands/med-family/mesh-crud.ts +19 -62
- package/src/config/mesh-config.ts +21 -140
- package/src/index.ts +1 -2
- package/src/mesh/contracts.ts +59 -18
- package/src/mesh/coordinator-prompt.ts +22 -6
- package/src/mesh/mesh-event-forwarding.ts +38 -102
- package/src/mesh/mesh-events-pending.ts +20 -0
- package/src/mesh/mesh-reconcile-loop.ts +157 -2
- package/src/mesh/mesh-runtime-store.ts +118 -3
- package/src/mesh/mesh-unresolved-forward-outbox.ts +30 -0
- package/src/repo-mesh-types.ts +8 -14
- package/src/types.ts +21 -6
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
coordinatorIdentityFromEmitFields,
|
|
15
15
|
coordinatorIdentityKey,
|
|
16
16
|
isMeshEventScope,
|
|
17
|
+
isTerminalTaskEvent,
|
|
17
18
|
MESH_PROTOCOL_VERSION_V2,
|
|
18
19
|
shouldDeliverPendingEventToCoordinator,
|
|
19
20
|
type CoordinatorIdentity,
|
|
@@ -408,6 +409,25 @@ function routeV2EventsForDrainer(
|
|
|
408
409
|
// Broadcast → any coordinator; system → daemon handler only (never a
|
|
409
410
|
// coordinator). Delegates to the contract helper for those two scopes.
|
|
410
411
|
if (validated.scope !== 'unicast') {
|
|
412
|
+
// Defense-in-depth (MAGI-REPLICA-COMPLETION-EVENT-LEAK): a TERMINAL task
|
|
413
|
+
// event that reached the queue as broadcast is an ownership leak — a
|
|
414
|
+
// completion/stop belongs to the coordinator that dispatched the task, so
|
|
415
|
+
// a sibling coordinator that never dispatched it must NOT act on it. The
|
|
416
|
+
// emit-side stamp now narrows unaddressed terminal events to unicast, but a
|
|
417
|
+
// legacy/version-skewed/other-path broadcast can still arrive here; filter
|
|
418
|
+
// it by dispatchedBy vs the drainer using the SAME daemon-form/session
|
|
419
|
+
// matching semantics as unicast (identityDeliversTo), so the true owner —
|
|
420
|
+
// possibly addressed under a different daemon-id form — still receives it.
|
|
421
|
+
if (validated.scope === 'broadcast' && isTerminalTaskEvent(validated.event)) {
|
|
422
|
+
if (identityDeliversTo(validated.dispatchedBy, drainer)) {
|
|
423
|
+
ctx.batchSeen.add(eventId);
|
|
424
|
+
bump('v2Delivered');
|
|
425
|
+
kept.push(event);
|
|
426
|
+
} else {
|
|
427
|
+
bump('v2RoutedAway');
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
411
431
|
if (shouldDeliverPendingEventToCoordinator(validated, drainer)) {
|
|
412
432
|
ctx.batchSeen.add(eventId);
|
|
413
433
|
bump('v2Delivered');
|
|
@@ -54,10 +54,11 @@ import {
|
|
|
54
54
|
peekUnresolvedDelegateForwards,
|
|
55
55
|
ackUnresolvedDelegateForward,
|
|
56
56
|
expireStaleUnresolvedDelegateForwards,
|
|
57
|
+
registerUnresolvedForwardRetryNudge,
|
|
57
58
|
} from './mesh-unresolved-forward-outbox.js';
|
|
58
59
|
import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
|
|
59
60
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
60
|
-
import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
|
|
61
|
+
import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
|
|
61
62
|
import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
|
|
62
63
|
import { resolveSessionBusyVerdict } from './mesh-queue-assignment.js';
|
|
63
64
|
import { readLedgerEntries } from './mesh-ledger.js';
|
|
@@ -841,6 +842,107 @@ function recoverStrandedAssignedDispatches(components: DaemonComponents, meshId:
|
|
|
841
842
|
}
|
|
842
843
|
}
|
|
843
844
|
|
|
845
|
+
// ── PHASE 2.6: assigned-zombie sweep (runtime-store GC, SoT 1-11 (a)) ─────────
|
|
846
|
+
// recoverStrandedAssignedDispatches (PHASE 2.5) can only age a row by its
|
|
847
|
+
// dispatchTimestamp — a row that never got one (a legacy claim, a crashed claim
|
|
848
|
+
// path, a row whose payload drifted) is invisible to it FOREVER: it contributes 0
|
|
849
|
+
// pending (PHASE 3 skips), holds the node-busy gate (hasActiveNodeAssignment), and
|
|
850
|
+
// nothing ever transitions it. This sweep is that missing terminal net, scoped
|
|
851
|
+
// PRECISELY to the rows PHASE 2.5 can never touch (no parseable dispatchTimestamp)
|
|
852
|
+
// so the two nets never race each other over the same row.
|
|
853
|
+
//
|
|
854
|
+
// Conservative by construction:
|
|
855
|
+
// - age-gated on updatedAt/createdAt (>= ZOMBIE_ASSIGNED_MIN_AGE_MS) so a freshly
|
|
856
|
+
// claimed row mid-launch is never touched;
|
|
857
|
+
// - terminal ledger evidence wins first (row flips to the evidenced terminal,
|
|
858
|
+
// mirroring PHASE 2.5's terminal branch);
|
|
859
|
+
// - only fails a row whose assigned session is POSITIVELY absent on the daemon
|
|
860
|
+
// that owns the assigned node — a locally-present session (idle or generating)
|
|
861
|
+
// is skipped, and a REMOTE node's session (not locally observable) is skipped
|
|
862
|
+
// entirely rather than guessed dead;
|
|
863
|
+
// - the failure reason is explicit in both the queue mutation trace and a
|
|
864
|
+
// task_failed ledger entry, so the transition is auditable, never silent.
|
|
865
|
+
const ZOMBIE_ASSIGNED_MIN_AGE_MS = 30 * 60 * 1000; // 30 min — generous vs. session launch/restart races
|
|
866
|
+
|
|
867
|
+
export function reconcileZombieAssignedTasks(
|
|
868
|
+
components: DaemonComponents,
|
|
869
|
+
mesh: { id: string; nodes?: unknown[] },
|
|
870
|
+
selfIds: string[],
|
|
871
|
+
): void {
|
|
872
|
+
const meshId = mesh.id;
|
|
873
|
+
const assigned = getQueue(meshId, { status: ['assigned'] });
|
|
874
|
+
if (!assigned.length) return;
|
|
875
|
+
const nowMs = Date.now();
|
|
876
|
+
|
|
877
|
+
// True when THIS daemon is authoritative for the row's assigned node — the only
|
|
878
|
+
// case where "no local instance" positively means "session no longer exists".
|
|
879
|
+
// Accepts a daemon-id form match against selfIds, or a mesh-node whose daemonId
|
|
880
|
+
// resolves to this daemon. Absent assignedNodeId → local (nothing remote to defer to).
|
|
881
|
+
const assignedNodeIsLocal = (assignedNodeId?: string): boolean => {
|
|
882
|
+
if (!assignedNodeId) return true;
|
|
883
|
+
if (selfIds.some(id => daemonIdsEquivalent(id, assignedNodeId))) return true;
|
|
884
|
+
const nodes = Array.isArray(mesh.nodes) ? mesh.nodes : [];
|
|
885
|
+
const node = nodes.find(n => meshNodeIdMatches(n as never, assignedNodeId)) as { daemonId?: unknown } | undefined;
|
|
886
|
+
const nodeDaemonId = readNonEmptyString(node?.daemonId);
|
|
887
|
+
return !!nodeDaemonId && selfIds.some(id => daemonIdsEquivalent(id, nodeDaemonId));
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
for (const row of assigned) {
|
|
891
|
+
// Rows WITH a parseable dispatchTimestamp belong to PHASE 2.5 — never double-handle.
|
|
892
|
+
if (Number.isFinite(Date.parse(row.dispatchTimestamp ?? ''))) continue;
|
|
893
|
+
const updatedMs = Date.parse(row.updatedAt ?? '');
|
|
894
|
+
const createdMs = Date.parse(row.createdAt ?? '');
|
|
895
|
+
const anchorMs = Number.isFinite(updatedMs) ? updatedMs : createdMs;
|
|
896
|
+
if (!Number.isFinite(anchorMs)) continue; // cannot age it → leave untouched
|
|
897
|
+
if (nowMs - anchorMs < ZOMBIE_ASSIGNED_MIN_AGE_MS) continue;
|
|
898
|
+
|
|
899
|
+
// A terminal already evidenced in the ledger → flip the row to that terminal
|
|
900
|
+
// (the completion arrived but the queue flip was lost), same as PHASE 2.5.
|
|
901
|
+
const terminal = findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id });
|
|
902
|
+
if (terminal) {
|
|
903
|
+
const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
|
|
904
|
+
updateTaskStatus(meshId, row.id, status);
|
|
905
|
+
LOG.warn('MeshReconcile', `Zombie assigned task ${row.id} on mesh ${meshId} had ${terminal.kind} ledger evidence — flipped to ${status}`);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (!assignedNodeIsLocal(row.assignedNodeId)) continue; // remote session not locally observable — never guess
|
|
910
|
+
if (row.assignedSessionId) {
|
|
911
|
+
const verdict = resolveSessionBusyVerdict(components, row.assignedSessionId);
|
|
912
|
+
if (verdict !== 'UNKNOWN') continue; // session exists locally (idle or busy) → not a zombie
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const reason = row.assignedSessionId
|
|
916
|
+
? 'assigned_zombie_session_missing'
|
|
917
|
+
: 'assigned_zombie_no_session_bound';
|
|
918
|
+
const failed = updateTaskStatus(meshId, row.id, 'failed');
|
|
919
|
+
if (!failed) continue;
|
|
920
|
+
try {
|
|
921
|
+
appendLedgerEntry(meshId, {
|
|
922
|
+
kind: 'task_failed',
|
|
923
|
+
nodeId: row.assignedNodeId,
|
|
924
|
+
sessionId: row.assignedSessionId,
|
|
925
|
+
payload: {
|
|
926
|
+
taskId: row.id,
|
|
927
|
+
reason,
|
|
928
|
+
source: 'reconcile_zombie_assigned_sweep',
|
|
929
|
+
ageMs: nowMs - anchorMs,
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
} catch { /* ledger write is best-effort */ }
|
|
933
|
+
LOG.warn('MeshReconcile', `Failed zombie assigned task ${row.id} on mesh ${meshId} `
|
|
934
|
+
+ `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, no dispatchTimestamp, `
|
|
935
|
+
+ `stale ${Math.round((nowMs - anchorMs) / 60000)}m, ${reason})`);
|
|
936
|
+
traceMeshEventDrop('assigned_zombie_failed', {
|
|
937
|
+
taskId: row.id,
|
|
938
|
+
sessionId: row.assignedSessionId,
|
|
939
|
+
nodeId: row.assignedNodeId,
|
|
940
|
+
meshId,
|
|
941
|
+
event: 'agent:generating_completed',
|
|
942
|
+
}, `${reason} stale=${Math.round((nowMs - anchorMs) / 60000)}m`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
844
946
|
export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
|
|
845
947
|
const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
846
948
|
// The id-set used to scope the local queue drain (status id + machineId). See
|
|
@@ -857,7 +959,11 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
857
959
|
// coordinator's mesh cannot be reached by the coordinator's PHASE 1 pull (it is
|
|
858
960
|
// in no mesh.node), so its completion must be PUSHED to the coordinator. This
|
|
859
961
|
// drains the durable outbox enqueued by forwardUnresolvedDelegateEvent and retries
|
|
860
|
-
// any push that has not yet been acked.
|
|
962
|
+
// any push that has not yet been acked. Since the spontaneous immediate push was
|
|
963
|
+
// removed (polling single-model §2.1), this PHASE 0 retry is the ONLY delivery
|
|
964
|
+
// path for unresolved-delegate events; the enqueue site nudges an early run of it
|
|
965
|
+
// (scheduleUnresolvedForwardNudge) so happy-path latency stays sub-interval.
|
|
966
|
+
// See mesh-unresolved-forward-outbox.ts.
|
|
861
967
|
if (dispatchMeshCommand) {
|
|
862
968
|
try {
|
|
863
969
|
await retryUnresolvedDelegateForwards(components);
|
|
@@ -897,6 +1003,13 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
897
1003
|
} catch (e: any) {
|
|
898
1004
|
LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
899
1005
|
}
|
|
1006
|
+
// PHASE 2.6 — assigned-zombie sweep: terminal-fails the rows PHASE 2.5
|
|
1007
|
+
// can never age (no dispatchTimestamp) whose session is positively gone.
|
|
1008
|
+
try {
|
|
1009
|
+
reconcileZombieAssignedTasks(components, mesh, selfIds);
|
|
1010
|
+
} catch (e: any) {
|
|
1011
|
+
LOG.warn('MeshReconcile', `Assigned-zombie sweep failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
1012
|
+
}
|
|
900
1013
|
}
|
|
901
1014
|
}
|
|
902
1015
|
|
|
@@ -1328,6 +1441,42 @@ export function __resetUnresolvedForwardRejectionCountsForTests(): void {
|
|
|
1328
1441
|
unresolvedForwardRejectionCounts.clear();
|
|
1329
1442
|
}
|
|
1330
1443
|
|
|
1444
|
+
// ── Unresolved-forward reconcile nudge (polling single-model §2.1 (B)) ────────
|
|
1445
|
+
// forwardUnresolvedDelegateEvent no longer pushes the event itself — it only
|
|
1446
|
+
// persists to the durable outbox and fires a data-free nudge asking THIS loop to
|
|
1447
|
+
// run the PHASE 0 retry soon. The nudge is:
|
|
1448
|
+
// - coalesced: one pending timer at a time, so a completion burst schedules a
|
|
1449
|
+
// single early retry pass instead of one per event;
|
|
1450
|
+
// - non-overlapping: skipped while a nudged pass is still in flight (the
|
|
1451
|
+
// periodic tick remains the backstop);
|
|
1452
|
+
// - loss-tolerant: an unregistered/cleared/failed nudge merely means delivery
|
|
1453
|
+
// waits for the next periodic tick (≤ one reconcile interval) — never a loss.
|
|
1454
|
+
const UNRESOLVED_FORWARD_NUDGE_DELAY_MS = 250;
|
|
1455
|
+
let unresolvedForwardNudgeTimer: NodeJS.Timeout | undefined;
|
|
1456
|
+
let unresolvedForwardNudgeRunning = false;
|
|
1457
|
+
|
|
1458
|
+
function scheduleUnresolvedForwardNudge(components: DaemonComponents): void {
|
|
1459
|
+
if (!components.dispatchMeshCommand) return; // no transport → periodic tick handles/no-ops
|
|
1460
|
+
if (unresolvedForwardNudgeTimer) return; // coalesce a burst into one early pass
|
|
1461
|
+
unresolvedForwardNudgeTimer = setTimeout(() => {
|
|
1462
|
+
unresolvedForwardNudgeTimer = undefined;
|
|
1463
|
+
if (unresolvedForwardNudgeRunning) return; // an earlier pass is in flight — tick covers
|
|
1464
|
+
unresolvedForwardNudgeRunning = true;
|
|
1465
|
+
void retryUnresolvedDelegateForwards(components)
|
|
1466
|
+
.catch((e: any) => LOG.warn('MeshReconcile', `Nudged unresolved-forward retry failed: ${e?.message || e}`))
|
|
1467
|
+
.finally(() => { unresolvedForwardNudgeRunning = false; });
|
|
1468
|
+
}, UNRESOLVED_FORWARD_NUDGE_DELAY_MS);
|
|
1469
|
+
// Never keep the process alive solely for a pending nudge.
|
|
1470
|
+
if (typeof unresolvedForwardNudgeTimer.unref === 'function') unresolvedForwardNudgeTimer.unref();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function clearUnresolvedForwardNudge(): void {
|
|
1474
|
+
if (unresolvedForwardNudgeTimer) {
|
|
1475
|
+
clearTimeout(unresolvedForwardNudgeTimer);
|
|
1476
|
+
unresolvedForwardNudgeTimer = undefined;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1331
1480
|
async function retryUnresolvedDelegateForwards(components: DaemonComponents): Promise<void> {
|
|
1332
1481
|
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
1333
1482
|
if (!dispatchMeshCommand) return;
|
|
@@ -1463,10 +1612,16 @@ export function setupMeshReconcileLoop(components: DaemonComponents): ReconcileL
|
|
|
1463
1612
|
}, intervalMs);
|
|
1464
1613
|
// Don't keep the process alive solely for this timer.
|
|
1465
1614
|
if (typeof timer.unref === 'function') timer.unref();
|
|
1615
|
+
// Register the unresolved-forward nudge handler: the enqueue site
|
|
1616
|
+
// (forwardUnresolvedDelegateEvent) fires it after persisting an outbox row so
|
|
1617
|
+
// the PHASE 0 retry runs early instead of waiting for the next periodic tick.
|
|
1618
|
+
registerUnresolvedForwardRetryNudge(() => scheduleUnresolvedForwardNudge(components));
|
|
1466
1619
|
LOG.info('MeshReconcile', `Mesh reconcile loop started (interval ${intervalMs}ms)`);
|
|
1467
1620
|
return {
|
|
1468
1621
|
stop() {
|
|
1469
1622
|
clearInterval(timer);
|
|
1623
|
+
registerUnresolvedForwardRetryNudge(undefined);
|
|
1624
|
+
clearUnresolvedForwardNudge();
|
|
1470
1625
|
LOG.info('MeshReconcile', 'Mesh reconcile loop stopped');
|
|
1471
1626
|
},
|
|
1472
1627
|
};
|
|
@@ -1557,10 +1557,82 @@ export class MeshRuntimeStore {
|
|
|
1557
1557
|
|
|
1558
1558
|
/**
|
|
1559
1559
|
* Prune tool call log entries older than the given age in ms.
|
|
1560
|
-
*
|
|
1560
|
+
* Returns the number of rows deleted. Also used by the periodic retention
|
|
1561
|
+
* sweep (pruneMeshRuntimeRetention) — the in-write sweep in recordMeshToolCall
|
|
1562
|
+
* only fires every 200 calls and only covers the rate-limit window, so a
|
|
1563
|
+
* quiet mesh otherwise accumulates rows indefinitely.
|
|
1561
1564
|
*/
|
|
1562
|
-
pruneToolCallLog(olderThanMs: number):
|
|
1563
|
-
this.db.prepare('DELETE FROM mesh_tool_call_log WHERE called_at < ?').run(Date.now() - olderThanMs);
|
|
1565
|
+
pruneToolCallLog(olderThanMs: number): number {
|
|
1566
|
+
return this.db.prepare('DELETE FROM mesh_tool_call_log WHERE called_at < ?').run(Date.now() - olderThanMs).changes;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Retention prune for mesh_event_ledger (SoT 1-11 (b)). The ledger is append-only
|
|
1571
|
+
* with NO lifecycle GC of its own, so lifecycle events accumulate without bound
|
|
1572
|
+
* (the dominant mesh-runtime.db growth). Every production reader is bounded to a
|
|
1573
|
+
* recent window (readLedgerEntries tail/limit ≤ a few hundred; task-stats /
|
|
1574
|
+
* terminal-evidence scans look at recent tasks), so rows past a generous age only
|
|
1575
|
+
* cost space. Excluded from deletion — retained forever:
|
|
1576
|
+
* - coordinator_operating_note / _tombstone: runtime-accumulated lessons whose
|
|
1577
|
+
* whole point is surviving restarts; a tombstone must also outlive the notes
|
|
1578
|
+
* it retracts.
|
|
1579
|
+
* Timestamps are ISO-8601 TEXT, so the lexicographic `<` cutoff is a correct time
|
|
1580
|
+
* comparison; a malformed timestamp compares greater than any ISO date and is
|
|
1581
|
+
* conservatively retained. Returns rows deleted.
|
|
1582
|
+
*/
|
|
1583
|
+
pruneEventLedger(olderThanMs: number): number {
|
|
1584
|
+
const cutoffIso = new Date(Date.now() - Math.max(0, olderThanMs)).toISOString();
|
|
1585
|
+
return this.db.prepare(
|
|
1586
|
+
`DELETE FROM mesh_event_ledger
|
|
1587
|
+
WHERE timestamp < ?
|
|
1588
|
+
AND kind NOT IN ('coordinator_operating_note', 'coordinator_operating_note_tombstone')`
|
|
1589
|
+
).run(cutoffIso).changes;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Retention prune for TERMINAL (completed/cancelled/failed) mesh_queue rows
|
|
1594
|
+
* (SoT 1-11 (b)). Terminal rows are kept as recent history (mesh_task_history,
|
|
1595
|
+
* completion-dedup taskId lookups) but nothing ever deletes them, so the queue
|
|
1596
|
+
* table grows monotonically. Rows past the retention window serve no reader —
|
|
1597
|
+
* every dedup/attribution path operates on recent tasks — EXCEPT as a dependency
|
|
1598
|
+
* anchor: taskDependenciesSatisfied resolves dependsOn by id and treats a MISSING
|
|
1599
|
+
* row as not-completed, so deleting a completed row that a still-live
|
|
1600
|
+
* (pending/assigned) row depends on would permanently strand the dependent.
|
|
1601
|
+
* Those ids are collected first and excluded. Returns rows deleted.
|
|
1602
|
+
*/
|
|
1603
|
+
pruneTerminalQueueEntries(olderThanMs: number): number {
|
|
1604
|
+
const cutoffIso = new Date(Date.now() - Math.max(0, olderThanMs)).toISOString();
|
|
1605
|
+
return this.transaction(() => {
|
|
1606
|
+
// Dependency guard: protect every id a live row still depends on.
|
|
1607
|
+
const liveRows = this.db.prepare(
|
|
1608
|
+
`SELECT payload FROM mesh_queue WHERE status IN ('pending', 'assigned')`
|
|
1609
|
+
).all() as Array<{ payload: string }>;
|
|
1610
|
+
const protectedIds = new Set<string>();
|
|
1611
|
+
for (const row of liveRows) {
|
|
1612
|
+
try {
|
|
1613
|
+
const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
|
|
1614
|
+
if (Array.isArray(entry.dependsOn)) {
|
|
1615
|
+
for (const dep of entry.dependsOn) {
|
|
1616
|
+
if (typeof dep === 'string' && dep) protectedIds.add(dep);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
} catch { /* unparsable payload → nothing to protect */ }
|
|
1620
|
+
}
|
|
1621
|
+
const candidates = this.db.prepare(
|
|
1622
|
+
`SELECT id FROM mesh_queue
|
|
1623
|
+
WHERE status IN ('completed', 'cancelled', 'failed') AND updated_at < ?`
|
|
1624
|
+
).all(cutoffIso) as Array<{ id: string }>;
|
|
1625
|
+
const deletable = candidates.map(r => r.id).filter(id => !protectedIds.has(id));
|
|
1626
|
+
let removed = 0;
|
|
1627
|
+
// Chunk the DELETE to stay well under SQLite's bind-parameter limit.
|
|
1628
|
+
for (let i = 0; i < deletable.length; i += 500) {
|
|
1629
|
+
const chunk = deletable.slice(i, i + 500);
|
|
1630
|
+
removed += this.db.prepare(
|
|
1631
|
+
`DELETE FROM mesh_queue WHERE id IN (${chunk.map(() => '?').join(',')})`
|
|
1632
|
+
).run(...chunk).changes;
|
|
1633
|
+
}
|
|
1634
|
+
return removed;
|
|
1635
|
+
});
|
|
1564
1636
|
}
|
|
1565
1637
|
|
|
1566
1638
|
// ── G2: Event Ledger ────────────────────────────────────────────────────
|
|
@@ -2148,3 +2220,46 @@ export class MeshRuntimeStore {
|
|
|
2148
2220
|
return removed;
|
|
2149
2221
|
}
|
|
2150
2222
|
}
|
|
2223
|
+
|
|
2224
|
+
// ─── Mesh runtime retention windows (SoT 1-11 (b) / gap I-10) ────────────────
|
|
2225
|
+
// mesh-runtime.db had lifecycle GC only for mesh_pending_events (prunePendingEvents,
|
|
2226
|
+
// hourly via the mesh-event maintenance sweep) and fingerprints/tool-call windows;
|
|
2227
|
+
// mesh_event_ledger and terminal mesh_queue rows grew without bound. These windows
|
|
2228
|
+
// are deliberately CONSERVATIVE — every production reader operates on a recent
|
|
2229
|
+
// window far narrower than these, so the deletes trade only dead space:
|
|
2230
|
+
// - Event ledger 30 days: readers are tail/limit-bounded (≤ a few hundred rows) or
|
|
2231
|
+
// recent-task scoped; 30d comfortably exceeds any reconcile/stat/audit horizon.
|
|
2232
|
+
// Operating notes are exempted inside pruneEventLedger (retained forever).
|
|
2233
|
+
// - Tool-call log 14 days: it backs a seconds-scale rate-limit window; 14d keeps a
|
|
2234
|
+
// generous debugging horizon at trivial cost.
|
|
2235
|
+
// - Terminal queue rows 30 days: mesh_task_history / completion-dedup lookups are
|
|
2236
|
+
// recent-task scoped; live dependsOn anchors are exempted inside
|
|
2237
|
+
// pruneTerminalQueueEntries.
|
|
2238
|
+
// No VACUUM here by design: reclaiming file pages is not worth stalling the daemon's
|
|
2239
|
+
// single writer; freed pages are reused by future inserts.
|
|
2240
|
+
export const MESH_EVENT_LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
2241
|
+
export const MESH_TOOL_CALL_LOG_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|
2242
|
+
export const MESH_TERMINAL_QUEUE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Periodic retention sweep for the mesh-runtime.db tables that previously had no
|
|
2246
|
+
* lifecycle GC (event ledger, tool-call log, terminal queue rows). Runs on the SAME
|
|
2247
|
+
* cadence as the pending-events retention prune (the hourly mesh-event maintenance
|
|
2248
|
+
* sweep in mesh-event-forwarding.ts). Best-effort and idempotent: a store failure
|
|
2249
|
+
* degrades to a no-op with one warn; an empty table costs three cheap DELETEs.
|
|
2250
|
+
*/
|
|
2251
|
+
export function pruneMeshRuntimeRetention(): { ledger: number; toolCalls: number; terminalQueue: number } {
|
|
2252
|
+
try {
|
|
2253
|
+
const store = MeshRuntimeStore.getInstance();
|
|
2254
|
+
const ledger = store.pruneEventLedger(MESH_EVENT_LEDGER_RETENTION_MS);
|
|
2255
|
+
const toolCalls = store.pruneToolCallLog(MESH_TOOL_CALL_LOG_RETENTION_MS);
|
|
2256
|
+
const terminalQueue = store.pruneTerminalQueueEntries(MESH_TERMINAL_QUEUE_RETENTION_MS);
|
|
2257
|
+
if (ledger + toolCalls + terminalQueue > 0) {
|
|
2258
|
+
LOG.info('MeshRuntimeStore', `Retention prune removed ${ledger} ledger / ${toolCalls} tool-call / ${terminalQueue} terminal-queue row(s)`);
|
|
2259
|
+
}
|
|
2260
|
+
return { ledger, toolCalls, terminalQueue };
|
|
2261
|
+
} catch (e: any) {
|
|
2262
|
+
LOG.warn('MeshRuntimeStore', `Runtime retention prune failed: ${e?.message || e}`);
|
|
2263
|
+
return { ledger: 0, toolCalls: 0, terminalQueue: 0 };
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
@@ -65,6 +65,36 @@ function getStore(): MeshRuntimeStore | undefined {
|
|
|
65
65
|
try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Reconcile-nudge registry (polling-single-model §2.1 (B)).
|
|
70
|
+
//
|
|
71
|
+
// The spontaneous best-effort immediate push that used to run inline in
|
|
72
|
+
// forwardUnresolvedDelegateEvent was removed: the durable outbox row + the
|
|
73
|
+
// reconcile loop's PHASE 0 retry (acked, retry-capped) is now the ONLY delivery
|
|
74
|
+
// path for an unresolved-delegate event. To keep the happy-path latency low
|
|
75
|
+
// without re-introducing a spontaneous data push, the enqueue site emits a
|
|
76
|
+
// data-free NUDGE: "outbox has work — run the PHASE 0 retry soon". The reconcile
|
|
77
|
+
// loop registers the handler at setup; a lost/unregistered nudge is harmless —
|
|
78
|
+
// the next periodic tick (default 4s) covers it, so nudge loss = bounded delay,
|
|
79
|
+
// never event loss.
|
|
80
|
+
//
|
|
81
|
+
// A registry (rather than a direct import of the reconcile loop) keeps the
|
|
82
|
+
// module graph acyclic: mesh-reconcile-loop already imports from
|
|
83
|
+
// mesh-event-forwarding (via the mesh-events-coordinator barrel), so the
|
|
84
|
+
// forwarding side must not import the loop back.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
let retryNudgeHandler: (() => void) | undefined;
|
|
87
|
+
|
|
88
|
+
/** Register (or clear, with undefined) the PHASE 0 retry nudge handler. */
|
|
89
|
+
export function registerUnresolvedForwardRetryNudge(handler?: () => void): void {
|
|
90
|
+
retryNudgeHandler = handler;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Fire-and-forget nudge: ask the reconcile loop to run the outbox retry soon. */
|
|
94
|
+
export function nudgeUnresolvedForwardRetry(): void {
|
|
95
|
+
try { retryNudgeHandler?.(); } catch { /* nudge is best-effort; the periodic tick covers */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
/**
|
|
69
99
|
* Durably enqueue an unresolved-delegate forward for a coordinator daemon. The
|
|
70
100
|
* `forwardPayload` is the flat shape handleMeshForwardEvent reads on the coordinator.
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
|
|
15
15
|
import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
|
|
16
16
|
import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type { MagiKindPanelMap } from '@adhdev/mesh-shared';
|
|
18
18
|
|
|
19
19
|
// ─── Core Mesh Types ────────────────────────────
|
|
20
20
|
|
|
@@ -527,7 +527,7 @@ const DIRTY_WORKSPACE_BEHAVIORS = new Set<RepoMeshPolicy['dirtyWorkspaceBehavior
|
|
|
527
527
|
|
|
528
528
|
/** Min/max bounds for the global write-task parallel cap. */
|
|
529
529
|
export const MESH_MAX_PARALLEL_TASKS_MIN = 1;
|
|
530
|
-
export const MESH_MAX_PARALLEL_TASKS_MAX =
|
|
530
|
+
export const MESH_MAX_PARALLEL_TASKS_MAX = 64;
|
|
531
531
|
|
|
532
532
|
/**
|
|
533
533
|
* Default multiplier applied to the write cap to derive the read-only diagnosis
|
|
@@ -814,18 +814,12 @@ export interface RepoMeshCoordinatorConfig {
|
|
|
814
814
|
export interface LocalMeshConfig {
|
|
815
815
|
meshes: LocalMeshEntry[];
|
|
816
816
|
/**
|
|
817
|
-
* MAGI
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* MAGI-KIND-PANEL: per-task_kind panel bindings (machine-local). Keyed by
|
|
825
|
-
* task_kind (rca / design / claim_audit / freeform); each maps to ≥1
|
|
826
|
-
* `(node × provider × model?)` slot. A `mesh_magi_review` invoked with a bare
|
|
827
|
-
* `task_kind` resolves its panel from here — an unconfigured kind is a hard
|
|
828
|
-
* error, never a synthesized fallback. Optional; absent on pre-feature configs.
|
|
817
|
+
* MAGI-KIND-PANEL: per-task_kind panel bindings (machine-local), the SOLE MAGI
|
|
818
|
+
* panel-resolution surface (the former named-panel `magiPanels` map was removed).
|
|
819
|
+
* Keyed by task_kind (rca / design / claim_audit / freeform); each maps to ≥1
|
|
820
|
+
* `(node × provider × model?)` slot. A `mesh_magi_review` resolves its panel from
|
|
821
|
+
* here — an unconfigured kind is a hard error, never a synthesized fallback.
|
|
822
|
+
* Optional; absent on pre-feature configs.
|
|
829
823
|
*/
|
|
830
824
|
magiKindPanels?: MagiKindPanelMap;
|
|
831
825
|
}
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* When modifying this file, also update interface contracts in AGENT_PROTOCOL.md.
|
|
6
6
|
*/
|
|
7
7
|
import type { StatusReportPayload, AvailableProviderInfo } from './shared-types.js';
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
ChatMessageKind,
|
|
10
|
+
ChatMessageVisibility,
|
|
11
|
+
ChatMessageTranscriptVisibility,
|
|
12
|
+
ChatMessageAudience,
|
|
13
|
+
ChatMessageSource,
|
|
14
|
+
} from './providers/chat-message-normalization.js';
|
|
9
15
|
|
|
10
16
|
// ── Daemon Status ──
|
|
11
17
|
|
|
@@ -54,11 +60,20 @@ export interface ChatMessage {
|
|
|
54
60
|
/** Optional: fiber metadata */
|
|
55
61
|
_type?: string;
|
|
56
62
|
_sub?: string;
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Transcript visibility/audience contract for separating chat-visible content
|
|
65
|
+
* from internal/debug runtime rows. These reference the canonical named unions
|
|
66
|
+
* declared alongside the classifier (chat-message-normalization.ts) so the known
|
|
67
|
+
* values have one source of truth instead of a hand-inlined copy that drifts.
|
|
68
|
+
* Each alias keeps the `| (string & {})` escape hatch: the read-chat contract
|
|
69
|
+
* (read-chat-contract.ts) preserves ANY producer-supplied string verbatim, so
|
|
70
|
+
* the type must stay open — it documents the known values without forbidding
|
|
71
|
+
* provider-specific extensions.
|
|
72
|
+
*/
|
|
73
|
+
visibility?: ChatMessageVisibility;
|
|
74
|
+
transcriptVisibility?: ChatMessageTranscriptVisibility;
|
|
75
|
+
audience?: ChatMessageAudience;
|
|
76
|
+
source?: ChatMessageSource;
|
|
62
77
|
userFacing?: boolean;
|
|
63
78
|
internal?: boolean;
|
|
64
79
|
isInternal?: boolean;
|