@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.
- package/dist/boot/daemon-lifecycle.d.ts +1 -0
- package/dist/commands/router.d.ts +37 -32
- package/dist/index.js +245 -89
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +245 -89
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
- package/package.json +2 -2
- package/src/boot/daemon-lifecycle.ts +8 -0
- package/src/commands/cli-manager.ts +57 -10
- package/src/commands/high-family/mesh-status.ts +6 -3
- package/src/commands/router.ts +188 -94
- package/src/mesh/mesh-events-coordinator.ts +70 -12
- package/src/mesh/mesh-warmup-deadline.ts +152 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
}
|