@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.
@@ -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. See mesh-unresolved-forward-outbox.ts.
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
- * Exposed for testing.
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): void {
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.
@@ -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 { MagiPanelMap, MagiKindPanelMap } from '@adhdev/mesh-shared';
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 = 8;
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 cross-verification panels (machine-local). Keyed by panel name; each
818
- * binds concrete `(node × provider)` members machine-dependent facts — so
819
- * panels live here in meshes.json, never in the repo-shared .adhdev/mesh.json.
820
- * Optional: absent on configs written before MAGI existed.
821
- */
822
- magiPanels?: MagiPanelMap;
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 { ChatMessageKind } from './providers/chat-message-normalization.js';
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
- /** Transcript visibility/audience contract for separating chat-visible content from internal/debug runtime rows. */
58
- visibility?: 'visible' | 'user' | 'chat' | 'hidden' | 'debug' | 'internal' | (string & {});
59
- transcriptVisibility?: 'visible' | 'user' | 'chat' | 'hidden' | 'debug' | 'internal' | (string & {});
60
- audience?: 'chat' | 'debug' | 'trace' | 'internal' | (string & {});
61
- source?: 'assistant_text' | 'tool_call' | 'terminal_command' | 'runtime_activity' | 'runtime_status' | 'provider_chrome' | 'control' | (string & {});
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;