@adhdev/daemon-core 0.9.82-rc.305 → 0.9.82-rc.307

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.305",
3
+ "version": "0.9.82-rc.307",
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.305",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.307",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1003,10 +1003,66 @@ 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;
1012
1068
  // Standing-state model: the default (non-refresh) bootstrap load must NOT
@@ -1103,22 +1159,30 @@ async function hydrateInlineMeshDirectTruth(args: {
1103
1159
  }
1104
1160
 
1105
1161
  peerAttemptedCount += 1;
1106
- try {
1107
- const remoteGit = await probeRemoteMeshGitStatus({
1108
- dispatchMeshCommand: args.dispatchMeshCommand,
1109
- daemonId,
1110
- workspace,
1111
- timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1112
- });
1113
- if (remoteGit) {
1114
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
1115
- peerConfirmedCount += 1;
1116
- continue;
1117
- }
1118
- } catch {
1119
- // 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;
1120
1178
  }
1121
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.
1122
1186
  unavailableNodeIds.push(nodeId);
1123
1187
  }
1124
1188
 
@@ -6543,6 +6607,7 @@ export class DaemonCommandRouter {
6543
6607
  mesh: meshRecord.mesh,
6544
6608
  meshSource: meshRecord.source,
6545
6609
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
6610
+ getMeshPeerConnectionStatus: this.deps.getMeshPeerConnectionStatus,
6546
6611
  statusInstanceId: this.deps.statusInstanceId,
6547
6612
  localMachineId: loadConfig().machineId || '',
6548
6613
  probeRemotePeers,
@@ -8432,6 +8497,7 @@ export class DaemonCommandRouter {
8432
8497
  mesh,
8433
8498
  meshSource: meshRecord.source,
8434
8499
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
8500
+ getMeshPeerConnectionStatus: this.deps.getMeshPeerConnectionStatus,
8435
8501
  statusInstanceId: this.deps.statusInstanceId,
8436
8502
  localMachineId,
8437
8503
  // Standing-state model: only an explicit refresh fans
@@ -8640,57 +8706,32 @@ export class DaemonCommandRouter {
8640
8706
  // per-node git probe. On the default load a peer
8641
8707
  // with no held truth falls through to
8642
8708
  // gitProbePending below — the graph still renders.
8643
- try {
8644
- const remoteGit = await probeRemoteMeshGitStatus({
8645
- dispatchMeshCommand: this.deps.dispatchMeshCommand,
8646
- daemonId,
8647
- workspace,
8648
- timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
8649
- });
8650
- if (remoteGit) {
8651
- status.git = remoteGit;
8652
- status.health = remoteGit.isGitRepo
8653
- ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
8654
- : 'degraded';
8655
- const connection = readObjectRecord(status.connection);
8656
- const connectionState = readStringValue(connection.state);
8657
- const connectionReported = readBooleanValue(connection.reported) ?? false;
8658
- if (!connectionReported || connectionState === 'unknown') {
8659
- status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8660
- }
8661
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8662
- remoteProbeApplied = true;
8663
- }
8664
- } catch {
8665
- const refreshedConnection = this.deps.getMeshPeerConnectionStatus?.(daemonId);
8666
- const refreshedConnectionState = readStringValue(refreshedConnection?.state);
8667
- if (refreshedConnection && refreshedConnectionState === 'connected') {
8668
- status.connection = refreshedConnection;
8669
- try {
8670
- const remoteGit = await probeRemoteMeshGitStatus({
8671
- dispatchMeshCommand: this.deps.dispatchMeshCommand,
8672
- daemonId,
8673
- workspace,
8674
- timeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
8675
- });
8676
- if (remoteGit) {
8677
- status.git = remoteGit;
8678
- status.health = remoteGit.isGitRepo
8679
- ? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
8680
- : 'degraded';
8681
- const connection = readObjectRecord(status.connection);
8682
- const connectionState = readStringValue(connection.state);
8683
- const connectionReported = readBooleanValue(connection.reported) ?? false;
8684
- if (!connectionReported || connectionState === 'unknown') {
8685
- status.connection = buildLivePeerGitConnection(connection, refreshedAt);
8686
- }
8687
- recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8688
- remoteProbeApplied = true;
8689
- }
8690
- } catch {
8691
- // Probe timed out again or P2P unavailable — fall back to cached status
8692
- }
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);
8693
8732
  }
8733
+ recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
8734
+ remoteProbeApplied = true;
8694
8735
  }
8695
8736
  }
8696
8737
  if (!remoteProbeApplied) {
@@ -8781,7 +8822,12 @@ export class DaemonCommandRouter {
8781
8822
  liveSessionRecords: liveMeshSessions,
8782
8823
  });
8783
8824
  const { getMeshStatusMissionSummaries } = await import('../mesh/mesh-missions.js');
8784
- 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 });
8785
8831
  const statusResult = {
8786
8832
  success: true,
8787
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