@adhdev/daemon-core 0.9.82-rc.368 → 0.9.82-rc.369

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.
@@ -16,6 +16,7 @@ import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
16
16
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
17
17
  import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUnresolvedDelegateForward } from './mesh-unresolved-forward-outbox.js';
18
18
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
19
+ import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
19
20
  import { getLastDisplayMessage } from '../status/snapshot.js';
20
21
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
21
22
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
@@ -379,6 +380,29 @@ function resolveActiveDirectDispatchTaskId(meshId: string, sessionId: string): s
379
380
  // the durable cross-restart backstop for a timer lost to a daemon restart.
380
381
  const DISPATCH_CONFIRM_TIMEOUT_MS = 120_000;
381
382
 
383
+ // Cold-open connect budget for the warmup-aware REMOTE task dispatch deadline. A
384
+ // remote `agent_command` to a peer whose mesh DataChannel is not open yet first has
385
+ // to drive the cross-machine (often TURN-relayed) handshake; charging that warmup
386
+ // against the response budget is the same cold-open false-timeout the git_status
387
+ // probe path already guards against. This budget bounds ONLY the "channel not open
388
+ // yet" phase; once the channel is warm the DISPATCH_CONFIRM_TIMEOUT_MS response
389
+ // budget governs (identical to the legacy flat guard for an already-open peer, so
390
+ // no latency is added to a normal dispatch). Matches the daemon-cloud
391
+ // DaemonMeshManager CONNECT_TIMEOUT_MS (45s) so the caller-side deadline tracks the
392
+ // transport's own cold-open window rather than guessing.
393
+ const DISPATCH_CONNECT_TIMEOUT_MS = 45_000;
394
+
395
+ // Fail-loud (throttled) trace for a remote dispatch that ran with NO live mesh
396
+ // connection getter wired — the same degraded-warmup misconfiguration the git probe
397
+ // path warns about. Warn once per peer; resolveWarmupDeadlineOpts then falls back to
398
+ // the conservative combined budget instead of silently assuming "always warm".
399
+ const dispatchWarmupGetterMissingWarned = new Set<string>();
400
+ function warnDispatchWarmupGetterMissingOnce(daemonId: string): void {
401
+ if (dispatchWarmupGetterMissingWarned.has(daemonId)) return;
402
+ dispatchWarmupGetterMissingWarned.add(daemonId);
403
+ LOG.warn('MeshQueue', `Mesh peer connection getter unavailable for ${String(daemonId).slice(0, 12)}; remote task-dispatch warmup deadline degraded to the combined connect+response window. Avoids a cold-open false-timeout but loses warm/cold precision — wire getMeshPeerConnectionStatus on this daemon.`);
404
+ }
405
+
382
406
  interface DeliverTaskContext {
383
407
  meshId: string;
384
408
  nodeId: string;
@@ -397,7 +421,22 @@ interface DeliverTaskContext {
397
421
  // Bug B hang timeout are identical and live here once so a future change to the dispatch
398
422
  // lifecycle cannot drift between the two paths. The caller passes a `dispatchThunk` that
399
423
  // performs only the transport-specific send and returns its promise.
400
- function deliverTaskToSession(dispatchThunk: () => Promise<unknown>, ctx: DeliverTaskContext): void {
424
+ //
425
+ // Cold-open warmup (remote only): the REMOTE transport speaks over a P2P
426
+ // DataChannel that may still be opening when the first task is dispatched to a peer.
427
+ // When `warmup` is supplied the dispatch is awaited under the warmup-aware deadline
428
+ // (mesh-warmup-deadline) — the cold-open handshake is charged to the connect budget
429
+ // and only the warm round trip to the DISPATCH_CONFIRM_TIMEOUT_MS response budget —
430
+ // so the very first dispatch to a not-yet-open peer is no longer false-timed at the
431
+ // combined window. An already-open peer behaves identically to the legacy flat guard
432
+ // (response budget governs from t0), so a normal dispatch sees no added latency. The
433
+ // LOCAL transport (in-process cliManager) has no channel to warm up and keeps the
434
+ // flat Bug B hang guard.
435
+ function deliverTaskToSession(
436
+ dispatchThunk: () => Promise<unknown>,
437
+ ctx: DeliverTaskContext,
438
+ warmup?: { daemonId: string; getConnection?: (daemonId: string) => Record<string, unknown> | null },
439
+ ): void {
401
440
  const delivery = createSessionDelivery({
402
441
  meshId: ctx.meshId,
403
442
  nodeId: ctx.nodeId,
@@ -421,17 +460,32 @@ function deliverTaskToSession(dispatchThunk: () => Promise<unknown>, ctx: Delive
421
460
  }
422
461
 
423
462
  let timer: ReturnType<typeof setTimeout> | undefined;
424
- const guarded = Promise.race([
425
- dispatchPromise,
426
- new Promise<never>((_, reject) => {
427
- timer = setTimeout(
428
- () => reject(new Error(`dispatch_confirm_timeout after ${DISPATCH_CONFIRM_TIMEOUT_MS}ms`)),
429
- DISPATCH_CONFIRM_TIMEOUT_MS,
430
- );
431
- // Never keep the process alive solely for this confirm-timeout timer.
432
- if (typeof (timer as { unref?: () => void })?.unref === 'function') (timer as { unref: () => void }).unref();
433
- }),
434
- ]);
463
+ let guarded: Promise<unknown>;
464
+ if (warmup) {
465
+ // Remote P2P: cold-open-aware deadline. awaitWithWarmupDeadline owns its own
466
+ // timers (so `timer` stays undefined and the clearTimeout below is a no-op),
467
+ // and rejects with Error('timeout') when either budget lapses — the same
468
+ // retryable failure shape the catch below already handles (requeue + ledger).
469
+ guarded = awaitWithWarmupDeadline(dispatchPromise, resolveWarmupDeadlineOpts({
470
+ getConnection: warmup.getConnection,
471
+ daemonId: warmup.daemonId,
472
+ connectTimeoutMs: DISPATCH_CONNECT_TIMEOUT_MS,
473
+ responseTimeoutMs: DISPATCH_CONFIRM_TIMEOUT_MS,
474
+ onMissingGetter: warnDispatchWarmupGetterMissingOnce,
475
+ }));
476
+ } else {
477
+ guarded = Promise.race([
478
+ dispatchPromise,
479
+ new Promise<never>((_, reject) => {
480
+ timer = setTimeout(
481
+ () => reject(new Error(`dispatch_confirm_timeout after ${DISPATCH_CONFIRM_TIMEOUT_MS}ms`)),
482
+ DISPATCH_CONFIRM_TIMEOUT_MS,
483
+ );
484
+ // Never keep the process alive solely for this confirm-timeout timer.
485
+ if (typeof (timer as { unref?: () => void })?.unref === 'function') (timer as { unref: () => void }).unref();
486
+ }),
487
+ ]);
488
+ }
435
489
 
436
490
  guarded.then(() => {
437
491
  if (timer) clearTimeout(timer);
@@ -588,6 +642,10 @@ export function tryAssignQueueTask(
588
642
  ...(sourceCoordinatorSessionId ? { sourceCoordinatorSessionId } : {}),
589
643
  ...(localDaemonIdForDispatch ? { sourceCoordinatorDaemonId: localDaemonIdForDispatch } : {}),
590
644
  },
645
+ // Warmup-aware deadline: this dispatch can be the FIRST command to a
646
+ // peer whose mesh DataChannel is still opening — charge the cold-open
647
+ // handshake to the connect budget, not the response budget.
648
+ { daemonId: remoteDaemonId, getConnection: components.getMeshPeerConnectionStatus },
591
649
  );
592
650
  return true;
593
651
  }
@@ -0,0 +1,152 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-warmup-deadline — cold-open-aware deadline for mesh P2P dispatches
3
+ // ---------------------------------------------------------------------------
4
+ // A dependency-free leaf so the warmup deadline + its connection-probe resolution
5
+ // can be shared by BOTH the dashboard git_status probe path (commands/router.ts)
6
+ // and the general task-dispatch path (mesh/mesh-events-coordinator.ts) without an
7
+ // import cycle (router ⇄ mesh-events). Pure except for timers + the injected
8
+ // `isConnected` probe, so it is unit-testable under fake timers with no real WebRTC.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Await `work` under a warmup-aware deadline so a cold-open DataChannel handshake
13
+ * is NOT charged against the command response budget — the root cause of the
14
+ * "first mesh dispatch to a cold peer false-times-out, the warm retry succeeds"
15
+ * signature. Two budgets, switched by the live peer connection state:
16
+ *
17
+ * - While `isConnected()` returns false the peer's channel is still opening; the
18
+ * cold-open `connectTimeoutMs` budget applies. This phase is deliberately
19
+ * generous because a TURN-relayed cross-machine handshake legitimately needs
20
+ * many seconds — but a genuine connect *failure* is surfaced by `work`
21
+ * rejecting on its own (the mesh manager fails the peer the instant its
22
+ * PeerConnection state goes terminal), so a real failure is never masked for
23
+ * the whole window.
24
+ * - The first time `isConnected()` returns true the channel is warm; from that
25
+ * instant the tight `responseTimeoutMs` governs how long the handler may take.
26
+ * Warm-channel callers therefore see behavior identical to the old single
27
+ * `Promise.race(work, responseTimeoutMs)`.
28
+ *
29
+ * Rejects with `Error('timeout')` when either budget is exhausted, mirroring the
30
+ * previous single-race contract. When no connection getter is wired callers must
31
+ * NOT pass `() => true` ("always warm") — that re-introduces the cold-open
32
+ * false-timeout. Use {@link resolveWarmupDeadlineOpts} which degrades conservatively.
33
+ */
34
+ export function awaitWithWarmupDeadline<T>(
35
+ work: Promise<T>,
36
+ opts: {
37
+ isConnected: () => boolean;
38
+ connectTimeoutMs: number;
39
+ responseTimeoutMs: number;
40
+ pollIntervalMs?: number;
41
+ },
42
+ ): Promise<T> {
43
+ const pollMs = Math.max(1, Math.min(opts.pollIntervalMs ?? 200, opts.connectTimeoutMs));
44
+ return new Promise<T>((resolve, reject) => {
45
+ let done = false;
46
+ let poll: ReturnType<typeof setInterval> | undefined;
47
+ let responseTimer: ReturnType<typeof setTimeout> | undefined;
48
+ const startedAt = Date.now();
49
+ const cleanup = () => {
50
+ if (poll) { clearInterval(poll); poll = undefined; }
51
+ if (responseTimer) { clearTimeout(responseTimer); responseTimer = undefined; }
52
+ };
53
+ const settle = (fn: () => void) => {
54
+ if (done) return;
55
+ done = true;
56
+ cleanup();
57
+ fn();
58
+ };
59
+ // Arm the response deadline exactly once, the moment the channel is warm.
60
+ const armResponse = () => {
61
+ if (responseTimer || done) return;
62
+ responseTimer = setTimeout(
63
+ () => settle(() => reject(new Error('timeout'))),
64
+ opts.responseTimeoutMs,
65
+ );
66
+ if (typeof responseTimer.unref === 'function') responseTimer.unref();
67
+ };
68
+ const onPoll = () => {
69
+ if (done) return;
70
+ if (opts.isConnected()) {
71
+ if (poll) { clearInterval(poll); poll = undefined; }
72
+ armResponse();
73
+ return;
74
+ }
75
+ if (Date.now() - startedAt >= opts.connectTimeoutMs) {
76
+ settle(() => reject(new Error('timeout')));
77
+ }
78
+ };
79
+ if (opts.isConnected()) {
80
+ // Already warm (e.g. a retry over an open channel) — skip the warmup
81
+ // phase entirely and let the response deadline govern from t0.
82
+ armResponse();
83
+ } else {
84
+ poll = setInterval(onPoll, pollMs);
85
+ if (typeof poll.unref === 'function') poll.unref();
86
+ }
87
+ work.then(
88
+ (val) => settle(() => resolve(val)),
89
+ (err) => settle(() => reject(err)),
90
+ );
91
+ });
92
+ }
93
+
94
+ /** Minimal connection-state reader: a mesh peer snapshot stamps its live state on `.state`. */
95
+ export function readWarmupConnectionState(connection: Record<string, unknown> | null | undefined): string | undefined {
96
+ const state = (connection as { state?: unknown } | null | undefined)?.state;
97
+ return typeof state === 'string' && state.length > 0 ? state : undefined;
98
+ }
99
+
100
+ export interface ResolvedWarmupDeadlineOpts {
101
+ isConnected: () => boolean;
102
+ connectTimeoutMs: number;
103
+ responseTimeoutMs: number;
104
+ }
105
+
106
+ /**
107
+ * Build {@link awaitWithWarmupDeadline} opts from an OPTIONAL live peer-connection
108
+ * probe, handling the missing-getter case fail-loud instead of silently degrading.
109
+ *
110
+ * When `getConnection` is wired the normal cold-open/warm split applies: the probe
111
+ * is consulted live and the channel is "warm" only once it reports `connected`.
112
+ *
113
+ * When `getConnection` is ABSENT the old call sites fell back to `() => true`
114
+ * ("always warm"), which charges a still-opening cold channel against the response
115
+ * budget and silently re-introduces the exact cold-open false-timeout the warmup
116
+ * deadline exists to prevent. Instead we degrade CONSERVATIVELY and FAIL LOUD:
117
+ * - `onMissingGetter` is invoked so the caller can warn (the degrade is visible,
118
+ * never silent) — keyed/throttled by the caller as it sees fit.
119
+ * - the channel is treated as NOT observably warm (`isConnected: () => false`),
120
+ * so the response deadline never arms early on an unobservable channel.
121
+ * - the cold peer is granted the COMBINED connect+response window as one deadline,
122
+ * so a slow-but-live cold open is never false-timed at the shorter response
123
+ * budget. A genuinely hung dispatch still rejects when the combined window
124
+ * lapses, and `work` rejecting on its own (a real transport failure) still
125
+ * settles immediately.
126
+ *
127
+ * Note: a present getter that returns a non-`connected` snapshot (or `null` for an
128
+ * unknown peer) correctly yields `isConnected() === false` — i.e. the generous
129
+ * connect budget, never "always warm". Only a wholly absent getter degrades.
130
+ */
131
+ export function resolveWarmupDeadlineOpts(opts: {
132
+ getConnection?: ((daemonId: string) => Record<string, unknown> | null) | undefined;
133
+ daemonId: string;
134
+ connectTimeoutMs: number;
135
+ responseTimeoutMs: number;
136
+ onMissingGetter?: (daemonId: string) => void;
137
+ }): ResolvedWarmupDeadlineOpts {
138
+ const { getConnection, daemonId, connectTimeoutMs, responseTimeoutMs } = opts;
139
+ if (getConnection) {
140
+ return {
141
+ isConnected: () => readWarmupConnectionState(getConnection(daemonId)) === 'connected',
142
+ connectTimeoutMs,
143
+ responseTimeoutMs,
144
+ };
145
+ }
146
+ opts.onMissingGetter?.(daemonId);
147
+ return {
148
+ isConnected: () => false,
149
+ connectTimeoutMs: connectTimeoutMs + responseTimeoutMs,
150
+ responseTimeoutMs,
151
+ };
152
+ }