@adhdev/daemon-core 0.9.82-rc.304 → 0.9.82-rc.306

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.
@@ -11,6 +11,7 @@
11
11
  * never stored — it is derived from queue task statuses (mission_id) at
12
12
  * query time.
13
13
  */
14
+ import { type MeshMissionStats } from './mesh-task-stats.js';
14
15
  export type MeshMissionStatus = 'active' | 'paused' | 'completed' | 'abandoned';
15
16
  export declare const MESH_MISSION_STATUSES: MeshMissionStatus[];
16
17
  export interface MeshMissionRecord {
@@ -36,6 +37,13 @@ export interface MeshMissionTaskAggregate {
36
37
  }
37
38
  export interface MeshMissionSummary extends MeshMissionRecord {
38
39
  tasks: MeshMissionTaskAggregate;
40
+ /**
41
+ * Operational rollup (durations / attempts) derived from the ledger via
42
+ * computeMeshMissionStats. Optional: only populated by surfaces that opt in
43
+ * (e.g. mesh_status), since the rollup scans a bounded ledger tail per
44
+ * mission. Absent on the lightweight task-aggregate-only summaries.
45
+ */
46
+ stats?: MeshMissionStats;
39
47
  }
40
48
  /**
41
49
  * Slim mission summary for the mesh_status compact (default) surface. Drops the
@@ -83,6 +91,7 @@ export declare function getActiveMeshMissionSummaries(meshId: string): MeshMissi
83
91
  export declare function getMeshStatusMissionSummaries(meshId: string, options?: {
84
92
  historyLimit?: number;
85
93
  verbose?: boolean;
94
+ withStats?: boolean;
86
95
  }): MeshMissionSummary[] | MeshMissionSlimSummary[];
87
96
  /**
88
97
  * Read-only mission listing for the mesh_mission_list tool. Returns summaries
@@ -11,7 +11,7 @@
11
11
  * IMPORTANT: This file must remain runtime-free (types only).
12
12
  */
13
13
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
- import type { MeshMissionSummary } from './mesh/mesh-missions.js';
14
+ import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
15
15
  export interface RepoMesh {
16
16
  id: string;
17
17
  name: string;
@@ -393,8 +393,13 @@ export interface RepoMeshStatus {
393
393
  * capped, newest-first slice of completed/abandoned history. Omitted by older
394
394
  * daemons — the dashboard must treat this as optional and render an empty
395
395
  * state when absent. Split on each entry's `status` for live vs. history.
396
+ *
397
+ * Compact (the default) status calls send the slim shape — `goalPreview` +
398
+ * `goalTruncated` instead of the full `goal` — while verbose sends the full
399
+ * `goal`. Consumers must read `goal ?? goalPreview`. Each entry may also carry
400
+ * an optional `stats` operational rollup (durations / retries).
396
401
  */
397
- missions?: MeshMissionSummary[];
402
+ missions?: (MeshMissionSummary | MeshMissionSlimSummary)[];
398
403
  }
399
404
  export type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
400
405
  import type { RepoMeshSessionStatus } from '@adhdev/mesh-shared';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.304",
3
+ "version": "0.9.82-rc.306",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.304",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.306",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1003,17 +1003,83 @@ async function probeRemoteMeshGitStatus(args: {
1003
1003
  : null;
1004
1004
  }
1005
1005
 
1006
+ /** Number of bounded retries after the initial direct-peer git probe attempt. */
1007
+ const MESH_DIRECT_PROBE_MAX_RETRIES = 2;
1008
+
1009
+ function readMeshConnectionState(connection: Record<string, unknown> | null | undefined): string | undefined {
1010
+ return readStringValue((connection as any)?.state);
1011
+ }
1012
+
1013
+ /**
1014
+ * Probe a remote peer's git_status with a bounded retry budget, but only while
1015
+ * the peer is reported `connected`. A single slow (often TURN-relayed) peer can
1016
+ * exceed one probe window; retrying — with the connection re-checked before each
1017
+ * attempt so we abandon a peer that dropped — recovers it without blocking the
1018
+ * mesh forever. Shared by the bootstrap hydrate path and the per-node render
1019
+ * path so both treat a connected-but-slow peer identically.
1020
+ *
1021
+ * Returns the git status on success, or null if every attempt failed/timed out
1022
+ * (caller decides how to classify). `getConnection` is consulted before each
1023
+ * attempt; a non-`connected` state short-circuits the retry loop (the very first
1024
+ * attempt always runs so a missing connection getter still gets one try).
1025
+ */
1026
+ async function probeRemoteMeshGitStatusWithRetry(args: {
1027
+ dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1028
+ daemonId: string;
1029
+ workspace: string;
1030
+ timeoutMs: number;
1031
+ /** Per-attempt timeout for retries (attempts > 0); defaults to timeoutMs. */
1032
+ retryTimeoutMs?: number;
1033
+ getConnection?: (daemonId: string) => Record<string, unknown> | null;
1034
+ onConnection?: (connection: Record<string, unknown>) => void;
1035
+ }): Promise<Record<string, unknown> | null> {
1036
+ for (let attempt = 0; attempt <= MESH_DIRECT_PROBE_MAX_RETRIES; attempt += 1) {
1037
+ if (attempt > 0) {
1038
+ // Re-check liveness before spending another probe window; a peer that
1039
+ // dropped between attempts is not worth retrying.
1040
+ const connection = args.getConnection?.(args.daemonId);
1041
+ if (args.getConnection && readMeshConnectionState(connection) !== 'connected') break;
1042
+ if (connection) args.onConnection?.(connection);
1043
+ // Exponential backoff: 250ms, 500ms before attempts 1 and 2.
1044
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** (attempt - 1)));
1045
+ }
1046
+ try {
1047
+ const remoteGit = await probeRemoteMeshGitStatus({
1048
+ dispatchMeshCommand: args.dispatchMeshCommand,
1049
+ daemonId: args.daemonId,
1050
+ workspace: args.workspace,
1051
+ timeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
1052
+ });
1053
+ if (remoteGit) return remoteGit;
1054
+ } catch {
1055
+ // Timed out or P2P error — fall through to the next bounded attempt.
1056
+ }
1057
+ }
1058
+ return null;
1059
+ }
1060
+
1006
1061
  async function hydrateInlineMeshDirectTruth(args: {
1007
1062
  mesh: any;
1008
1063
  meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config';
1009
1064
  dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1065
+ getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
1010
1066
  statusInstanceId?: string;
1011
1067
  localMachineId?: string;
1068
+ // Standing-state model: the default (non-refresh) bootstrap load must NOT
1069
+ // fan out a blocking git_status probe to every peer — a single slow
1070
+ // (TURN-relayed) peer would time out and mark the whole mesh unavailable,
1071
+ // blocking the graph. When false, a non-local node is satisfied from its
1072
+ // held standing git truth (lastGit / cachedStatus reflected via mesh
1073
+ // events) and is never pushed to unavailableNodeIds merely because no live
1074
+ // probe was attempted. Only an explicit refresh (probeRemotePeers=true)
1075
+ // performs the fan-out and classifies an unreachable peer as unavailable.
1076
+ probeRemotePeers: boolean;
1012
1077
  }): Promise<{
1013
1078
  directEvidenceCount: number;
1014
1079
  localConfirmedCount: number;
1015
1080
  peerAttemptedCount: number;
1016
1081
  peerConfirmedCount: number;
1082
+ standingEvidenceCount: number;
1017
1083
  unavailableNodeIds: string[];
1018
1084
  }> {
1019
1085
  const nodes = Array.isArray(args.mesh?.nodes) ? args.mesh.nodes : [];
@@ -1023,6 +1089,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1023
1089
  localConfirmedCount: 0,
1024
1090
  peerAttemptedCount: 0,
1025
1091
  peerConfirmedCount: 0,
1092
+ standingEvidenceCount: 0,
1026
1093
  unavailableNodeIds: [],
1027
1094
  };
1028
1095
  }
@@ -1036,6 +1103,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1036
1103
  let localConfirmedCount = 0;
1037
1104
  let peerAttemptedCount = 0;
1038
1105
  let peerConfirmedCount = 0;
1106
+ let standingEvidenceCount = 0;
1039
1107
  const unavailableNodeIds: string[] = [];
1040
1108
 
1041
1109
  for (const [nodeIndex, node] of nodes.entries()) {
@@ -1066,36 +1134,64 @@ async function hydrateInlineMeshDirectTruth(args: {
1066
1134
  }
1067
1135
  }
1068
1136
 
1137
+ // Standing-state first: a non-local peer's held git truth (reflected
1138
+ // from its self-emitted mesh events into node.lastGit / cachedStatus)
1139
+ // counts as direct evidence without any probe. On the default load this
1140
+ // is the ONLY thing we consult — no fan-out, so one slow peer can't
1141
+ // block the bootstrap.
1142
+ const standingGit = buildInlineMeshTransitGitStatus(node);
1143
+ if (standingGit) {
1144
+ standingEvidenceCount += 1;
1145
+ continue;
1146
+ }
1147
+
1148
+ if (!args.probeRemotePeers) {
1149
+ // Default (non-refresh) load: a peer with no held truth yet is left
1150
+ // pending (the per-node loop marks it gitProbePending and the graph
1151
+ // shows setup inventory for it). It is NOT unavailable — the graph
1152
+ // must still render. An explicit refresh will fan out and freshen it.
1153
+ continue;
1154
+ }
1155
+
1069
1156
  if (!daemonId || !args.dispatchMeshCommand) {
1070
1157
  if (!isSelfNode) unavailableNodeIds.push(nodeId);
1071
1158
  continue;
1072
1159
  }
1073
1160
 
1074
1161
  peerAttemptedCount += 1;
1075
- try {
1076
- const remoteGit = await probeRemoteMeshGitStatus({
1077
- dispatchMeshCommand: args.dispatchMeshCommand,
1078
- daemonId,
1079
- workspace,
1080
- timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1081
- });
1082
- if (remoteGit) {
1083
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1084
- peerConfirmedCount += 1;
1085
- continue;
1086
- }
1087
- } catch {
1088
- // Strict direct-only path: do not fall back to persisted cloud truth here.
1162
+ // Bounded retry, gated on the peer staying `connected`: a slow
1163
+ // (TURN-relayed) peer that just exceeds one probe window is recovered
1164
+ // instead of being hard-failed. The connection is re-checked before each
1165
+ // retry so a peer that actually dropped is abandoned promptly.
1166
+ const remoteGit = await probeRemoteMeshGitStatusWithRetry({
1167
+ dispatchMeshCommand: args.dispatchMeshCommand,
1168
+ daemonId,
1169
+ workspace,
1170
+ timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1171
+ retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
1172
+ getConnection: args.getMeshPeerConnectionStatus,
1173
+ });
1174
+ if (remoteGit) {
1175
+ recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1176
+ peerConfirmedCount += 1;
1177
+ continue;
1089
1178
  }
1090
1179
 
1180
+ // Invariant: a connected peer that still holds standing git truth is
1181
+ // never classified unavailable (standingGit short-circuited above, so by
1182
+ // here there is no held truth). Only push to unavailable when the peer is
1183
+ // not currently connected, or it is connected but every bounded probe
1184
+ // failed — that is the genuine "connected, no truth, retries exhausted"
1185
+ // case that drives the explicit-refresh hard-fail.
1091
1186
  unavailableNodeIds.push(nodeId);
1092
1187
  }
1093
1188
 
1094
1189
  return {
1095
- directEvidenceCount: localConfirmedCount + peerConfirmedCount,
1190
+ directEvidenceCount: localConfirmedCount + peerConfirmedCount + standingEvidenceCount,
1096
1191
  localConfirmedCount,
1097
1192
  peerAttemptedCount,
1098
1193
  peerConfirmedCount,
1194
+ standingEvidenceCount,
1099
1195
  unavailableNodeIds,
1100
1196
  };
1101
1197
  }
@@ -6504,12 +6600,17 @@ export class DaemonCommandRouter {
6504
6600
  if (!meshRecord?.mesh) return { success: false, error: 'Mesh not found' };
6505
6601
 
6506
6602
  const requireDirectPeerTruth = args?.requireDirectPeerTruth === true;
6603
+ // Only an explicit refresh fans out a blocking peer probe.
6604
+ // Default loads are satisfied from held standing-state git truth.
6605
+ const probeRemotePeers = args?.refresh === true || args?.forceRefresh === true;
6507
6606
  const directTruth = await hydrateInlineMeshDirectTruth({
6508
6607
  mesh: meshRecord.mesh,
6509
6608
  meshSource: meshRecord.source,
6510
6609
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
6610
+ getMeshPeerConnectionStatus: this.deps.getMeshPeerConnectionStatus,
6511
6611
  statusInstanceId: this.deps.statusInstanceId,
6512
6612
  localMachineId: loadConfig().machineId || '',
6613
+ probeRemotePeers,
6513
6614
  });
6514
6615
  const directTruthSatisfied = meshRecord.source !== 'inline_bootstrap' || directTruth.directEvidenceCount > 0;
6515
6616
  const sourceOfTruth = {
@@ -8396,14 +8497,20 @@ export class DaemonCommandRouter {
8396
8497
  mesh,
8397
8498
  meshSource: meshRecord.source,
8398
8499
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
8500
+ getMeshPeerConnectionStatus: this.deps.getMeshPeerConnectionStatus,
8399
8501
  statusInstanceId: this.deps.statusInstanceId,
8400
8502
  localMachineId,
8503
+ // Standing-state model: only an explicit refresh fans
8504
+ // out a blocking peer git probe. Default loads return
8505
+ // held truth so one slow peer can't block the graph.
8506
+ probeRemotePeers: refreshRequested,
8401
8507
  })
8402
8508
  : {
8403
8509
  directEvidenceCount: 0,
8404
8510
  localConfirmedCount: 0,
8405
8511
  peerAttemptedCount: 0,
8406
8512
  peerConfirmedCount: 0,
8513
+ standingEvidenceCount: 0,
8407
8514
  unavailableNodeIds: [] as string[],
8408
8515
  };
8409
8516
  // Default/cached loads may not attempt a remote peer probe yet; do not surface that as
@@ -8421,9 +8528,15 @@ export class DaemonCommandRouter {
8421
8528
  && mesh.nodes
8422
8529
  .filter((node: any) => unavailableDirectTruthNodeIds.has(normalizeMeshNodeId(node) ?? ''))
8423
8530
  .every((node: any) => node?.isLocalWorktree === true);
8531
+ // Default (non-refresh) loads never hard-fail: held
8532
+ // standing-state truth is returned and the graph renders
8533
+ // immediately. The hard mesh_direct_peer_truth_unavailable
8534
+ // failure is reserved for an explicit refresh that actually
8535
+ // attempted a peer probe and could not confirm any evidence.
8424
8536
  const directTruthSatisfied = !requireDirectPeerTruth
8537
+ || !refreshRequested
8425
8538
  || (effectiveDirectTruth.directEvidenceCount > 0 && (effectiveDirectTruth.unavailableNodeIds.length === 0 || unavailableNodesAreOnlyRemovedWorktrees));
8426
- if (requireDirectPeerTruth && !directTruthSatisfied) {
8539
+ if (requireDirectPeerTruth && refreshRequested && !directTruthSatisfied) {
8427
8540
  const failureResult = {
8428
8541
  success: false,
8429
8542
  code: 'mesh_direct_peer_truth_unavailable',
@@ -8588,58 +8701,37 @@ export class DaemonCommandRouter {
8588
8701
  status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8589
8702
  }
8590
8703
  remoteProbeApplied = true;
8591
- } else if (!isSelfNode && daemonId && this.deps.dispatchMeshCommand && !directTruthUnavailableNodeIds.has(nodeId)) {
8592
- try {
8593
- const remoteGit = await probeRemoteMeshGitStatus({
8594
- dispatchMeshCommand: this.deps.dispatchMeshCommand,
8595
- daemonId,
8596
- workspace,
8597
- timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
8598
- });
8599
- if (remoteGit) {
8600
- status.git = remoteGit;
8601
- status.health = remoteGit.isGitRepo
8602
- ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
8603
- : 'degraded';
8604
- const connection = readObjectRecord(status.connection);
8605
- const connectionState = readStringValue(connection.state);
8606
- const connectionReported = readBooleanValue(connection.reported) ?? false;
8607
- if (!connectionReported || connectionState === 'unknown') {
8608
- status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8609
- }
8610
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8611
- remoteProbeApplied = true;
8612
- }
8613
- } catch {
8614
- const refreshedConnection = this.deps.getMeshPeerConnectionStatus?.(daemonId);
8615
- const refreshedConnectionState = readStringValue(refreshedConnection?.state);
8616
- if (refreshedConnection && refreshedConnectionState === 'connected') {
8617
- status.connection = refreshedConnection;
8618
- try {
8619
- const remoteGit = await probeRemoteMeshGitStatus({
8620
- dispatchMeshCommand: this.deps.dispatchMeshCommand,
8621
- daemonId,
8622
- workspace,
8623
- timeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
8624
- });
8625
- if (remoteGit) {
8626
- status.git = remoteGit;
8627
- status.health = remoteGit.isGitRepo
8628
- ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
8629
- : 'degraded';
8630
- const connection = readObjectRecord(status.connection);
8631
- const connectionState = readStringValue(connection.state);
8632
- const connectionReported = readBooleanValue(connection.reported) ?? false;
8633
- if (!connectionReported || connectionState === 'unknown') {
8634
- status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8635
- }
8636
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8637
- remoteProbeApplied = true;
8638
- }
8639
- } catch {
8640
- // Probe timed out again or P2P unavailable — fall back to cached status
8641
- }
8704
+ } else if (refreshRequested && !isSelfNode && daemonId && this.deps.dispatchMeshCommand && !directTruthUnavailableNodeIds.has(nodeId)) {
8705
+ // Only an explicit refresh fans out a blocking
8706
+ // per-node git probe. On the default load a peer
8707
+ // with no held truth falls through to
8708
+ // gitProbePending below — the graph still renders.
8709
+ // Bounded retry (shared with the bootstrap hydrate
8710
+ // path), gated on the peer staying connected, so a
8711
+ // slow TURN-relayed peer is recovered rather than
8712
+ // dropped after a single timeout.
8713
+ const remoteGit = await probeRemoteMeshGitStatusWithRetry({
8714
+ dispatchMeshCommand: this.deps.dispatchMeshCommand,
8715
+ daemonId,
8716
+ workspace,
8717
+ timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
8718
+ retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
8719
+ getConnection: this.deps.getMeshPeerConnectionStatus,
8720
+ onConnection: connection => { status.connection = connection; },
8721
+ });
8722
+ if (remoteGit) {
8723
+ status.git = remoteGit;
8724
+ status.health = remoteGit.isGitRepo
8725
+ ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
8726
+ : 'degraded';
8727
+ const connection = readObjectRecord(status.connection);
8728
+ const connectionState = readStringValue(connection.state);
8729
+ const connectionReported = readBooleanValue(connection.reported) ?? false;
8730
+ if (!connectionReported || connectionState === 'unknown') {
8731
+ status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8642
8732
  }
8733
+ recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8734
+ remoteProbeApplied = true;
8643
8735
  }
8644
8736
  }
8645
8737
  if (!remoteProbeApplied) {
@@ -8730,7 +8822,12 @@ export class DaemonCommandRouter {
8730
8822
  liveSessionRecords: liveMeshSessions,
8731
8823
  });
8732
8824
  const { getMeshStatusMissionSummaries } = await import('../mesh/mesh-missions.js');
8733
- const missions = getMeshStatusMissionSummaries(meshId, { verbose: verboseMissions });
8825
+ // withStats opts in to per-mission operational rollups (durations /
8826
+ // retries) for the dashboard mission detail. The rollup scans a
8827
+ // bounded ledger tail per mission, but only over the bounded set
8828
+ // returned here (live + capped history), so the cost stays linear
8829
+ // in visible missions rather than the whole mesh history.
8830
+ const missions = getMeshStatusMissionSummaries(meshId, { verbose: verboseMissions, withStats: true });
8734
8831
  const statusResult = {
8735
8832
  success: true,
8736
8833
  meshId: mesh.id,
@@ -15,6 +15,7 @@
15
15
  import { randomUUID } from 'crypto';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
17
  import { getQueue } from './mesh-work-queue.js';
18
+ import { computeMeshMissionStats, type MeshMissionStats } from './mesh-task-stats.js';
18
19
 
19
20
  export type MeshMissionStatus = 'active' | 'paused' | 'completed' | 'abandoned';
20
21
 
@@ -45,6 +46,13 @@ export interface MeshMissionTaskAggregate {
45
46
 
46
47
  export interface MeshMissionSummary extends MeshMissionRecord {
47
48
  tasks: MeshMissionTaskAggregate;
49
+ /**
50
+ * Operational rollup (durations / attempts) derived from the ledger via
51
+ * computeMeshMissionStats. Optional: only populated by surfaces that opt in
52
+ * (e.g. mesh_status), since the rollup scans a bounded ledger tail per
53
+ * mission. Absent on the lightweight task-aggregate-only summaries.
54
+ */
55
+ stats?: MeshMissionStats;
48
56
  }
49
57
 
50
58
  /**
@@ -171,7 +179,7 @@ function slimMissionSummary(summary: MeshMissionSummary): MeshMissionSlimSummary
171
179
  */
172
180
  export function getMeshStatusMissionSummaries(
173
181
  meshId: string,
174
- options?: { historyLimit?: number; verbose?: boolean },
182
+ options?: { historyLimit?: number; verbose?: boolean; withStats?: boolean },
175
183
  ): MeshMissionSummary[] | MeshMissionSlimSummary[] {
176
184
  const historyLimit = Math.max(0, options?.historyLimit ?? 10);
177
185
  const all = getMeshMissions(meshId);
@@ -180,7 +188,15 @@ export function getMeshStatusMissionSummaries(
180
188
  .filter(m => m.status === 'completed' || m.status === 'abandoned')
181
189
  .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
182
190
  .slice(0, historyLimit);
183
- const full = [...live, ...history].map(mission => summarizeMeshMission(meshId, mission));
191
+ let full = [...live, ...history].map(mission => summarizeMeshMission(meshId, mission));
192
+ // Operational stats (durations / attempts) are an opt-in projection: each
193
+ // mission's rollup scans a bounded ledger tail, so we only compute it for
194
+ // the bounded set we are about to return (live + capped history), not for
195
+ // every mission in the mesh. The dashboard graph opts in so mission detail
196
+ // can show wall-clock / retries without a second round trip.
197
+ if (options?.withStats) {
198
+ full = full.map(summary => ({ ...summary, stats: computeMeshMissionStats(meshId, summary.id) }));
199
+ }
184
200
  return options?.verbose ? full : full.map(slimMissionSummary);
185
201
  }
186
202
 
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
15
- import type { MeshMissionSummary } from './mesh/mesh-missions.js';
15
+ import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
16
16
 
17
17
  // ─── Core Mesh Types ────────────────────────────
18
18
 
@@ -486,8 +486,13 @@ export interface RepoMeshStatus {
486
486
  * capped, newest-first slice of completed/abandoned history. Omitted by older
487
487
  * daemons — the dashboard must treat this as optional and render an empty
488
488
  * state when absent. Split on each entry's `status` for live vs. history.
489
+ *
490
+ * Compact (the default) status calls send the slim shape — `goalPreview` +
491
+ * `goalTruncated` instead of the full `goal` — while verbose sends the full
492
+ * `goal`. Consumers must read `goal ?? goalPreview`. Each entry may also carry
493
+ * an optional `stats` operational rollup (durations / retries).
489
494
  */
490
- missions?: MeshMissionSummary[];
495
+ missions?: (MeshMissionSummary | MeshMissionSlimSummary)[];
491
496
  }
492
497
 
493
498
  // RepoMeshSessionStatus shape now lives in @adhdev/mesh-shared (shared with