@adhdev/daemon-core 0.9.82-rc.366 → 0.9.82-rc.367
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/commands/chat-commands.d.ts +27 -0
- package/dist/commands/router.d.ts +34 -0
- package/dist/index.js +167 -18
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +167 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/commands/chat-commands.ts +96 -2
- package/src/commands/router.ts +135 -8
- package/src/mesh/mesh-events-coordinator.ts +43 -12
- package/src/mesh/mesh-reconcile-loop.ts +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.367",
|
|
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.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.367",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import type { ChatMessage } from '../types.js';
|
|
31
31
|
import type { SessionTransport } from '../shared-types.js';
|
|
32
32
|
import { filterUserFacingChatMessages, isActivityChatMessage, isUserFacingChatMessage, normalizeChatMessages } from '../providers/chat-message-normalization.js';
|
|
33
|
+
import { normalizeMeshWorkspaceForCompare } from '@adhdev/mesh-shared';
|
|
33
34
|
|
|
34
35
|
const RECENT_SEND_WINDOW_MS = 1200;
|
|
35
36
|
export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
|
|
@@ -989,6 +990,53 @@ function normalizeComparableWorkspace(value: unknown): string {
|
|
|
989
990
|
return path.resolve(text);
|
|
990
991
|
}
|
|
991
992
|
|
|
993
|
+
/**
|
|
994
|
+
* read_chat node scope verdict. One physical daemon hosts a base node plus several
|
|
995
|
+
* worktree nodes; mesh_read_chat always dispatches read_chat with the requested
|
|
996
|
+
* node's workspace (`args.workspace`). When the resolved target session actually
|
|
997
|
+
* lives in a DIFFERENT worktree, returning its transcript — or worse, letting the
|
|
998
|
+
* native-history-by-workspace fallback splice sibling worktree turns into the
|
|
999
|
+
* reply — makes the coordinator believe one session received every worktree's
|
|
1000
|
+
* work. This guard refuses a CONFIRMED cross-workspace read instead of mixing.
|
|
1001
|
+
*
|
|
1002
|
+
* Conservative by design (mirrors the WTCLAIM fix-B "unknown → allow" rule): only
|
|
1003
|
+
* a session id that resolves to a known workspace which is unequal to a known
|
|
1004
|
+
* intended workspace blocks. When either side is unknown — no targetSessionId, no
|
|
1005
|
+
* args.workspace, an unregistered session, the coordinator self-session, or a
|
|
1006
|
+
* plain dashboard read that never passes a node workspace — the read proceeds
|
|
1007
|
+
* untouched, so base-node and same-daemon coordinator reads never regress.
|
|
1008
|
+
*/
|
|
1009
|
+
export function evaluateReadChatNodeWorkspaceScope(args: {
|
|
1010
|
+
targetSessionId?: string;
|
|
1011
|
+
intendedWorkspace?: string;
|
|
1012
|
+
sessionWorkspace?: string;
|
|
1013
|
+
}): { scoped: false } | { scoped: true; intended: string; actual: string } {
|
|
1014
|
+
const targetSessionId = typeof args.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
|
|
1015
|
+
if (!targetSessionId) return { scoped: false };
|
|
1016
|
+
const intended = normalizeMeshWorkspaceForCompare(args.intendedWorkspace);
|
|
1017
|
+
const actual = normalizeMeshWorkspaceForCompare(args.sessionWorkspace);
|
|
1018
|
+
if (!intended || !actual) return { scoped: false };
|
|
1019
|
+
if (intended === actual) return { scoped: false };
|
|
1020
|
+
return { scoped: true, intended, actual };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Resolve the target session's ACTUAL workspace from the most authoritative source
|
|
1025
|
+
* available on this daemon: the session registry record (stamped at register
|
|
1026
|
+
* time), then the live CLI adapter's working directory, then the bound instance
|
|
1027
|
+
* state. Returns '' when nothing knows the session's workspace — the caller treats
|
|
1028
|
+
* that as "unknown" and does not block.
|
|
1029
|
+
*/
|
|
1030
|
+
function resolveTargetSessionActualWorkspace(h: CommandHelpers, targetSessionId: string): string {
|
|
1031
|
+
const registryWorkspace = (h.ctx?.sessionRegistry?.get?.(targetSessionId) as any)?.workspace;
|
|
1032
|
+
if (typeof registryWorkspace === 'string' && registryWorkspace.trim()) return registryWorkspace;
|
|
1033
|
+
const adapter = h.getCliAdapter?.(targetSessionId);
|
|
1034
|
+
if (adapter && typeof adapter.workingDir === 'string' && adapter.workingDir.trim()) return adapter.workingDir;
|
|
1035
|
+
const instanceWorkspace = (getTargetInstance(h, { targetSessionId })?.getState?.() as any)?.workspace;
|
|
1036
|
+
if (typeof instanceWorkspace === 'string' && instanceWorkspace.trim()) return instanceWorkspace;
|
|
1037
|
+
return '';
|
|
1038
|
+
}
|
|
1039
|
+
|
|
992
1040
|
function isCurrentRuntimePtySafelyAttributed(args: {
|
|
993
1041
|
adapter: CliAdapter;
|
|
994
1042
|
helpers: CommandHelpers;
|
|
@@ -2100,6 +2148,29 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
|
|
|
2100
2148
|
}
|
|
2101
2149
|
|
|
2102
2150
|
export async function handleReadChat(h: CommandHelpers, args: any): Promise<CommandResult> {
|
|
2151
|
+
// Node scope guard: a daemon hosting a base node + several worktree nodes must
|
|
2152
|
+
// not serve worktree A's transcript (or splice sibling worktree turns via the
|
|
2153
|
+
// native-history-by-workspace fallback) when a coordinator scoped the read to
|
|
2154
|
+
// worktree B. mesh_read_chat always passes the requested node's workspace as
|
|
2155
|
+
// args.workspace; refuse a CONFIRMED cross-workspace read rather than mix.
|
|
2156
|
+
{
|
|
2157
|
+
const guardSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
|
|
2158
|
+
if (guardSessionId && typeof args?.workspace === 'string' && args.workspace.trim()) {
|
|
2159
|
+
const verdict = evaluateReadChatNodeWorkspaceScope({
|
|
2160
|
+
targetSessionId: guardSessionId,
|
|
2161
|
+
intendedWorkspace: args.workspace,
|
|
2162
|
+
sessionWorkspace: resolveTargetSessionActualWorkspace(h, guardSessionId),
|
|
2163
|
+
});
|
|
2164
|
+
if (verdict.scoped) {
|
|
2165
|
+
LOG.info('Command', `[read_chat] node scope mismatch: session ${guardSessionId} workspace "${verdict.actual}" ≠ requested node workspace "${verdict.intended}" — refusing cross-worktree transcript`);
|
|
2166
|
+
return {
|
|
2167
|
+
success: false,
|
|
2168
|
+
code: 'read_chat_session_node_scope_mismatch',
|
|
2169
|
+
error: `Session ${guardSessionId} belongs to a different worktree (workspace "${verdict.actual}") than the requested node (workspace "${verdict.intended}"). Refusing to return a cross-worktree transcript — target the node that owns this session.`,
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2103
2174
|
// Resolve provider in order: explicit agentType/providerType > registered session.
|
|
2104
2175
|
// Without this fallback, callers that only have a sessionId (e.g. a chat tail
|
|
2105
2176
|
// controller that just got handed a session ID over WS) get an empty result
|
|
@@ -2649,10 +2720,33 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2649
2720
|
});
|
|
2650
2721
|
|
|
2651
2722
|
if (supportsNative && !decision.nativeSelected) {
|
|
2723
|
+
// Dead-end: we are in the history-only path (no live PTY/ACP
|
|
2724
|
+
// adapter was found for this target session) AND provider-native
|
|
2725
|
+
// history is not safely mappable to the requested session
|
|
2726
|
+
// (no historySessionId stamp / workspace mismatch). Previously
|
|
2727
|
+
// this returned `success:false`, which the command logger emits
|
|
2728
|
+
// at warn level on EVERY poll (handler.ts logCommandEnd) —
|
|
2729
|
+
// mesh coordinators poll read_chat continuously, so a worker whose
|
|
2730
|
+
// transcript can never be safely mapped produced a 100% warn-log
|
|
2731
|
+
// storm with no recovery. Switch to a SOFT response: success with
|
|
2732
|
+
// empty messages + pending:true so the coordinator treats it as
|
|
2733
|
+
// "no live messages readable yet" rather than a hard failure, and
|
|
2734
|
+
// carry the machine-readable reason for debuggability. The normal
|
|
2735
|
+
// live-adapter path (above) and the safe-native return (below) are
|
|
2736
|
+
// unaffected — this is strictly the both-absent dead end.
|
|
2737
|
+
LOG.debug('Command', `[read_chat] soft pending: no live adapter and native history not safely mappable target=${String(args?.targetSessionId || '')} provider=${agentStr} reason=native_history_not_safely_available`);
|
|
2652
2738
|
return {
|
|
2653
|
-
success:
|
|
2739
|
+
success: true,
|
|
2740
|
+
pending: true,
|
|
2741
|
+
// Both signals are true here: we reached the history-only path
|
|
2742
|
+
// because no live adapter was found (`live_adapter_not_found`),
|
|
2743
|
+
// and native history is not safely mappable
|
|
2744
|
+
// (`native_history_not_safely_available`).
|
|
2745
|
+
reason: 'native_history_not_safely_available',
|
|
2746
|
+
reasons: ['live_adapter_not_found', 'native_history_not_safely_available'],
|
|
2654
2747
|
code: 'native_history_not_safely_available',
|
|
2655
|
-
|
|
2748
|
+
messages: [],
|
|
2749
|
+
status: 'idle',
|
|
2656
2750
|
providerSessionId: historyProviderSessionId,
|
|
2657
2751
|
messageSource: decision.messageSource,
|
|
2658
2752
|
transcriptProvenance: decision.messageSource,
|
package/src/commands/router.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
normalizeMeshNodeId,
|
|
48
48
|
meshNodeIdMatches,
|
|
49
49
|
daemonIdsEquivalent,
|
|
50
|
+
meshWorkspacesEquivalent,
|
|
50
51
|
} from '@adhdev/mesh-shared';
|
|
51
52
|
import { SessionRegistry } from '../sessions/registry.js';
|
|
52
53
|
import { LOG } from '../logging/logger.js';
|
|
@@ -1130,6 +1131,21 @@ function readMeshTimeoutEnvMs(name: string, defaultMs: number): number {
|
|
|
1130
1131
|
// (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
|
|
1131
1132
|
export const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
|
|
1132
1133
|
export const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
|
|
1134
|
+
// Cold-open warmup budget for the FIRST direct-peer probe to a peer whose mesh
|
|
1135
|
+
// DataChannel is not open yet. A fresh cross-machine, TURN-relayed handshake
|
|
1136
|
+
// (ICE gather + TURN allocation + DTLS across two residential networks) routinely
|
|
1137
|
+
// needs many seconds. Charging that warmup against the response deadline
|
|
1138
|
+
// (MESH_DIRECT_PROBE_TIMEOUT_MS) made the very first git_status to a cold peer
|
|
1139
|
+
// false-timeout, after which the warm retry — reusing the now-open channel —
|
|
1140
|
+
// succeeded: the classic cold-open signature. This budget bounds ONLY the
|
|
1141
|
+
// "channel not open yet" phase; once the channel opens the response deadline
|
|
1142
|
+
// governs the round trip. A genuine connect failure still rejects immediately —
|
|
1143
|
+
// the mesh manager fails the peer the instant its PeerConnection state goes
|
|
1144
|
+
// terminal, and isMeshConnectionDefinitivelyDown pre-gates an already-dead peer —
|
|
1145
|
+
// so this never masks a real failure for the whole window; it only grants a
|
|
1146
|
+
// still-handshaking peer the time it legitimately needs. Matches the daemon-cloud
|
|
1147
|
+
// DaemonMeshManager CONNECT_TIMEOUT_MS (45s). Env-overridable for very slow links.
|
|
1148
|
+
export const MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS', 45_000);
|
|
1133
1149
|
// How long a successful per-peer git_status probe stays fresh enough to be
|
|
1134
1150
|
// reused instead of issuing another blocking `refreshUpstream:true` fan-out.
|
|
1135
1151
|
// A slow (TURN-relayed) peer's probe can take 9-23s, and the dashboard's
|
|
@@ -1198,17 +1214,120 @@ export class MeshGitProbeCache {
|
|
|
1198
1214
|
}
|
|
1199
1215
|
}
|
|
1200
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* Await `work` under a warmup-aware deadline so a cold-open DataChannel handshake
|
|
1219
|
+
* is NOT charged against the command response budget — the root cause of the
|
|
1220
|
+
* "first mesh probe to a cold peer false-times-out, the warm retry succeeds"
|
|
1221
|
+
* signature. Two budgets, switched by the live peer connection state:
|
|
1222
|
+
*
|
|
1223
|
+
* - While `isConnected()` returns false the peer's channel is still opening; the
|
|
1224
|
+
* cold-open `connectTimeoutMs` budget applies. This phase is deliberately
|
|
1225
|
+
* generous because a TURN-relayed cross-machine handshake legitimately needs
|
|
1226
|
+
* many seconds — but a genuine connect *failure* is surfaced by `work`
|
|
1227
|
+
* rejecting on its own (the mesh manager fails the peer the instant its
|
|
1228
|
+
* PeerConnection state goes terminal), so a real failure is never masked for
|
|
1229
|
+
* the whole window.
|
|
1230
|
+
* - The first time `isConnected()` returns true the channel is warm; from that
|
|
1231
|
+
* instant the tight `responseTimeoutMs` governs how long the handler may take.
|
|
1232
|
+
* Warm-channel callers therefore see behavior identical to the old single
|
|
1233
|
+
* `Promise.race(work, responseTimeoutMs)`.
|
|
1234
|
+
*
|
|
1235
|
+
* Rejects with `Error('timeout')` when either budget is exhausted, mirroring the
|
|
1236
|
+
* previous single-race contract. Pure except for timers + the injected
|
|
1237
|
+
* `isConnected` probe, so it is unit-testable under fake timers without any real
|
|
1238
|
+
* WebRTC. When no connection getter is wired `isConnected` should be `() => true`
|
|
1239
|
+
* (the caller's choice) so the response deadline governs from t0 — the legacy
|
|
1240
|
+
* single-budget behavior, never a combined connect+response window.
|
|
1241
|
+
*/
|
|
1242
|
+
export function awaitWithWarmupDeadline<T>(
|
|
1243
|
+
work: Promise<T>,
|
|
1244
|
+
opts: {
|
|
1245
|
+
isConnected: () => boolean;
|
|
1246
|
+
connectTimeoutMs: number;
|
|
1247
|
+
responseTimeoutMs: number;
|
|
1248
|
+
pollIntervalMs?: number;
|
|
1249
|
+
},
|
|
1250
|
+
): Promise<T> {
|
|
1251
|
+
const pollMs = Math.max(1, Math.min(opts.pollIntervalMs ?? 200, opts.connectTimeoutMs));
|
|
1252
|
+
return new Promise<T>((resolve, reject) => {
|
|
1253
|
+
let done = false;
|
|
1254
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
1255
|
+
let responseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1256
|
+
const startedAt = Date.now();
|
|
1257
|
+
const cleanup = () => {
|
|
1258
|
+
if (poll) { clearInterval(poll); poll = undefined; }
|
|
1259
|
+
if (responseTimer) { clearTimeout(responseTimer); responseTimer = undefined; }
|
|
1260
|
+
};
|
|
1261
|
+
const settle = (fn: () => void) => {
|
|
1262
|
+
if (done) return;
|
|
1263
|
+
done = true;
|
|
1264
|
+
cleanup();
|
|
1265
|
+
fn();
|
|
1266
|
+
};
|
|
1267
|
+
// Arm the response deadline exactly once, the moment the channel is warm.
|
|
1268
|
+
const armResponse = () => {
|
|
1269
|
+
if (responseTimer || done) return;
|
|
1270
|
+
responseTimer = setTimeout(
|
|
1271
|
+
() => settle(() => reject(new Error('timeout'))),
|
|
1272
|
+
opts.responseTimeoutMs,
|
|
1273
|
+
);
|
|
1274
|
+
if (typeof responseTimer.unref === 'function') responseTimer.unref();
|
|
1275
|
+
};
|
|
1276
|
+
const onPoll = () => {
|
|
1277
|
+
if (done) return;
|
|
1278
|
+
if (opts.isConnected()) {
|
|
1279
|
+
if (poll) { clearInterval(poll); poll = undefined; }
|
|
1280
|
+
armResponse();
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (Date.now() - startedAt >= opts.connectTimeoutMs) {
|
|
1284
|
+
settle(() => reject(new Error('timeout')));
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
if (opts.isConnected()) {
|
|
1288
|
+
// Already warm (e.g. a retry over an open channel) — skip the warmup
|
|
1289
|
+
// phase entirely and let the response deadline govern from t0.
|
|
1290
|
+
armResponse();
|
|
1291
|
+
} else {
|
|
1292
|
+
poll = setInterval(onPoll, pollMs);
|
|
1293
|
+
if (typeof poll.unref === 'function') poll.unref();
|
|
1294
|
+
}
|
|
1295
|
+
work.then(
|
|
1296
|
+
(val) => settle(() => resolve(val)),
|
|
1297
|
+
(err) => settle(() => reject(err)),
|
|
1298
|
+
);
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1201
1302
|
async function probeRemoteMeshGitStatus(args: {
|
|
1202
1303
|
dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
1203
1304
|
daemonId: string;
|
|
1204
1305
|
workspace: string;
|
|
1205
|
-
|
|
1306
|
+
// Response deadline — applies only once the peer's DataChannel is open (warm).
|
|
1307
|
+
responseTimeoutMs: number;
|
|
1308
|
+
// Cold-open warmup budget — applies only while the channel is still opening.
|
|
1309
|
+
connectTimeoutMs: number;
|
|
1310
|
+
// Live peer connection snapshot getter; lets the deadline tell "still warming
|
|
1311
|
+
// up" apart from "warm but slow". Absent → behave as if always warm (the
|
|
1312
|
+
// response deadline governs from t0, i.e. the legacy single-budget behavior).
|
|
1313
|
+
getConnection?: (daemonId: string) => Record<string, unknown> | null;
|
|
1206
1314
|
}): Promise<Record<string, unknown> | null> {
|
|
1207
1315
|
if (!args.dispatchMeshCommand) return null;
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1316
|
+
// Fire the dispatch first — this is what drives the mesh manager to ensure /
|
|
1317
|
+
// open the peer connection. The warmup-aware deadline then charges the
|
|
1318
|
+
// cold-open handshake to the connect budget and only the warm round trip to
|
|
1319
|
+
// the response budget, so the first probe to a cold peer is no longer
|
|
1320
|
+
// false-timed-out before its channel has even opened.
|
|
1321
|
+
const dispatch = args.dispatchMeshCommand(args.daemonId, 'git_status', { workspace: args.workspace, refreshUpstream: true });
|
|
1322
|
+
const getConnection = args.getConnection;
|
|
1323
|
+
const isConnected = getConnection
|
|
1324
|
+
? () => readMeshConnectionState(getConnection(args.daemonId)) === 'connected'
|
|
1325
|
+
: () => true;
|
|
1326
|
+
const remoteResult = await awaitWithWarmupDeadline(dispatch, {
|
|
1327
|
+
isConnected,
|
|
1328
|
+
connectTimeoutMs: args.connectTimeoutMs,
|
|
1329
|
+
responseTimeoutMs: args.responseTimeoutMs,
|
|
1330
|
+
}) as any;
|
|
1212
1331
|
const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
|
|
1213
1332
|
if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
|
|
1214
1333
|
// The member daemon stamps its own platform/arch onto the git_status result
|
|
@@ -1269,6 +1388,8 @@ export async function probeRemoteMeshGitStatusWithRetry(args: {
|
|
|
1269
1388
|
timeoutMs: number;
|
|
1270
1389
|
/** Per-attempt timeout for retries (attempts > 0); defaults to timeoutMs. */
|
|
1271
1390
|
retryTimeoutMs?: number;
|
|
1391
|
+
/** Cold-open warmup budget per attempt; defaults to MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS. */
|
|
1392
|
+
connectTimeoutMs?: number;
|
|
1272
1393
|
getConnection?: (daemonId: string) => Record<string, unknown> | null;
|
|
1273
1394
|
onConnection?: (connection: Record<string, unknown>) => void;
|
|
1274
1395
|
}): Promise<Record<string, unknown> | null> {
|
|
@@ -1300,7 +1421,9 @@ export async function probeRemoteMeshGitStatusWithRetry(args: {
|
|
|
1300
1421
|
dispatchMeshCommand: args.dispatchMeshCommand,
|
|
1301
1422
|
daemonId: args.daemonId,
|
|
1302
1423
|
workspace: args.workspace,
|
|
1303
|
-
|
|
1424
|
+
responseTimeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
|
|
1425
|
+
connectTimeoutMs: args.connectTimeoutMs ?? MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
|
|
1426
|
+
getConnection: args.getConnection,
|
|
1304
1427
|
});
|
|
1305
1428
|
if (remoteGit) return remoteGit;
|
|
1306
1429
|
} catch {
|
|
@@ -1447,6 +1570,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
|
|
|
1447
1570
|
workspace,
|
|
1448
1571
|
timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
|
|
1449
1572
|
retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
|
|
1573
|
+
connectTimeoutMs: MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
|
|
1450
1574
|
getConnection: args.getMeshPeerConnectionStatus,
|
|
1451
1575
|
});
|
|
1452
1576
|
const remoteGit = args.probeCache
|
|
@@ -1512,14 +1636,17 @@ function liveSessionRecordMatchesMeshNode(record: any, meshId: string, nodeId: s
|
|
|
1512
1636
|
if (!recordNodeId || recordNodeId !== nodeId) return false;
|
|
1513
1637
|
if (nodeIsMissingLocalWorktree) return false;
|
|
1514
1638
|
const recordWorkspace = readStringValue(record?.workspace);
|
|
1515
|
-
|
|
1639
|
+
// Normalized compare (shared WTCLAIM rule): a base node and a co-located worktree
|
|
1640
|
+
// clone differ ONLY by workspace root, so a separator/case-skewed exact compare
|
|
1641
|
+
// could wrongly keep a sibling worktree's session attached to this node.
|
|
1642
|
+
if (nodeWorkspace && recordWorkspace && !meshWorkspacesEquivalent(recordWorkspace, nodeWorkspace)) return false;
|
|
1516
1643
|
const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
|
|
1517
1644
|
return !recordMeshId || recordMeshId === meshId;
|
|
1518
1645
|
}
|
|
1519
1646
|
|
|
1520
1647
|
function liveSessionRecordMatchesMeshWorkspace(record: any, meshId: string, workspace: string): boolean {
|
|
1521
1648
|
const recordWorkspace = readStringValue(record?.workspace);
|
|
1522
|
-
if (!recordWorkspace || !workspace || recordWorkspace
|
|
1649
|
+
if (!recordWorkspace || !workspace || !meshWorkspacesEquivalent(recordWorkspace, workspace)) return false;
|
|
1523
1650
|
|
|
1524
1651
|
const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
|
|
1525
1652
|
if (recordMeshId) return recordMeshId === meshId;
|
|
@@ -19,7 +19,7 @@ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
|
19
19
|
import { getLastDisplayMessage } from '../status/snapshot.js';
|
|
20
20
|
import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
21
21
|
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
22
|
-
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, type MeshNodeIdentified } from '@adhdev/mesh-shared';
|
|
22
|
+
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, type MeshNodeIdentified } from '@adhdev/mesh-shared';
|
|
23
23
|
import {
|
|
24
24
|
findRecentTerminalLedgerEvidence,
|
|
25
25
|
hasDispatchAfterTerminal,
|
|
@@ -457,16 +457,10 @@ function deliverTaskToSession(dispatchThunk: () => Promise<unknown>, ctx: Delive
|
|
|
457
457
|
});
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
// WTCLAIM:
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// told apart. Kept local (the cli-manager copy is module-private) so the comparison rule
|
|
465
|
-
// stays identical to the one fix-B already uses on the worker side.
|
|
466
|
-
function normalizeMeshWorkspaceForCompare(dir?: string): string {
|
|
467
|
-
if (typeof dir !== 'string') return '';
|
|
468
|
-
return dir.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
469
|
-
}
|
|
460
|
+
// WTCLAIM: workspace normalization for base-vs-worktree comparison now lives in
|
|
461
|
+
// @adhdev/mesh-shared (normalizeMeshWorkspaceForCompare) so the enqueue→claim path,
|
|
462
|
+
// the mesh_status per-node session filter, and the read_chat node scope guard all
|
|
463
|
+
// share one comparison rule instead of drifting module-private copies.
|
|
470
464
|
|
|
471
465
|
export function tryAssignQueueTask(
|
|
472
466
|
components: DaemonComponents,
|
|
@@ -1844,7 +1838,26 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1844
1838
|
// re-attributed to the latest task (the normal task_completed path below).
|
|
1845
1839
|
const supersedesWeakTerminal = isWeakTerminalLedgerPayload(terminal.payload)
|
|
1846
1840
|
&& isGenuineCompletionEvidence(args.metadataEvent);
|
|
1847
|
-
|
|
1841
|
+
// CANON-B (direct-dispatch completion race): a FAST direct dispatch (mesh_send_task)
|
|
1842
|
+
// to an already-idle, previously-used session can have its genuine completion reach
|
|
1843
|
+
// this coordinator handler BEFORE the dispatching side records the new task's dispatch
|
|
1844
|
+
// row / task_dispatched ledger entry — insertDirectDispatch + appendLedgerEntry both run
|
|
1845
|
+
// AFTER the agent_command await resolves, while the worker may already be done. In that
|
|
1846
|
+
// window sessionHasActiveAssignment is false (no active dispatch row, no unterminal
|
|
1847
|
+
// ledger entry yet), so this prior-terminal dedup engages; and because providerSessionId
|
|
1848
|
+
// is STABLE across a reused session's turns, the providerSessionId/finalSummary match
|
|
1849
|
+
// below would suppress the NEW task's completion as a duplicate of the PRIOR task —
|
|
1850
|
+
// silently losing it (the observed intermittent miss; fresh enqueue/autoLaunch is immune
|
|
1851
|
+
// because a fresh session has no prior same-providerSessionId terminal and the queue row
|
|
1852
|
+
// is claimed atomically before dispatch). The echoed taskId is the authoritative
|
|
1853
|
+
// discriminator: when the completion names a DIFFERENT task than the recorded terminal,
|
|
1854
|
+
// it is a genuinely new task's completion, never a duplicate — let it through so it is
|
|
1855
|
+
// attributed to its own taskId. A same-task re-arrival (taskId equal) or a taskId-less
|
|
1856
|
+
// legacy event still falls through to the providerSessionId/finalSummary dedup.
|
|
1857
|
+
const terminalTaskId = readNonEmptyString(terminal.payload.taskId);
|
|
1858
|
+
const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
|
|
1859
|
+
const distinctTaskCompletion = !!eventTaskId && !!terminalTaskId && eventTaskId !== terminalTaskId;
|
|
1860
|
+
if (!newDispatchAfterTerminal && !supersedesWeakTerminal && !distinctTaskCompletion) {
|
|
1848
1861
|
const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
|
|
1849
1862
|
const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
|
|
1850
1863
|
const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
|
|
@@ -2467,6 +2480,24 @@ function forwardUnresolvedDelegateEvent(
|
|
|
2467
2480
|
workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
|
|
2468
2481
|
};
|
|
2469
2482
|
|
|
2483
|
+
// Self-addressed fallback: the resolved coordinator IS this daemon (a self-
|
|
2484
|
+
// coordinating / single-node mesh, or a delegate whose coordinator anchor resolved
|
|
2485
|
+
// to our own id). A cross-daemon mesh_forward_event to our own id is REFUSED by the
|
|
2486
|
+
// dispatch self-dial guard ("route via the local router instead"), so persisting it
|
|
2487
|
+
// to the outbox would only loop forever in PHASE 0's retry, never acked. Honour the
|
|
2488
|
+
// guard's advice: route the event straight through the local receiver — the exact
|
|
2489
|
+
// path the coordinator runs on receiving a remote push — and skip the outbox entirely.
|
|
2490
|
+
const selfDaemonIds = resolveCoordinatorDrainDaemonIds(components);
|
|
2491
|
+
if (selfDaemonIds.some(self => daemonIdsEquivalent(self, coordinatorDaemonId))) {
|
|
2492
|
+
try {
|
|
2493
|
+
handleMeshForwardEvent(components, payload);
|
|
2494
|
+
LOG.info('MeshEvents', `Self-addressed unresolved-delegate ${eventName} routed via local router (coordinator ${coordinatorDaemonId} is self) — outbox skipped`);
|
|
2495
|
+
} catch (e: any) {
|
|
2496
|
+
LOG.warn('MeshEvents', `Local route of self-addressed unresolved-delegate ${eventName} failed: ${e?.message || e}`);
|
|
2497
|
+
}
|
|
2498
|
+
return true;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2470
2501
|
// 1) Persist durably FIRST. Idempotent on fingerprint, so a re-fired completion
|
|
2471
2502
|
// does not duplicate the outbox row. If persistence fails we still attempt the
|
|
2472
2503
|
// push below (degrades to the old at-most-once behaviour rather than dropping
|
|
@@ -734,6 +734,12 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
|
|
|
734
734
|
const entries = peekUnresolvedDelegateForwards();
|
|
735
735
|
if (entries.length === 0) return;
|
|
736
736
|
|
|
737
|
+
// Every id-form THIS daemon answers to. A self-addressed outbox entry (coordinator
|
|
738
|
+
// == this daemon) must never be cross-dialled — see the self-route branch below.
|
|
739
|
+
const selfIds = resolveCoordinatorDaemonIds(components);
|
|
740
|
+
const isSelfCoordinatorId = (id: string): boolean =>
|
|
741
|
+
selfIds.some(self => daemonIdsEquivalent(self, id));
|
|
742
|
+
|
|
737
743
|
for (const entry of entries) {
|
|
738
744
|
// EVTTRACE correlation context for this outbox entry's retry.
|
|
739
745
|
const entryTraceCtx = {
|
|
@@ -742,6 +748,38 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
|
|
|
742
748
|
nodeId: readNonEmptyString(entry.payload.nodeId),
|
|
743
749
|
event: readNonEmptyString(entry.payload.event),
|
|
744
750
|
};
|
|
751
|
+
|
|
752
|
+
// Self-addressed forward: the coordinator daemon this entry targets IS this
|
|
753
|
+
// daemon (a self-coordinating / single-node mesh, or a delegate whose coordinator
|
|
754
|
+
// anchor resolved to our own id). A cross-daemon mesh_forward_event to our own id
|
|
755
|
+
// is REFUSED by the dispatch self-dial guard ("Refusing to send ... to this
|
|
756
|
+
// daemon's own id; route via the local router instead") on every retry, so the
|
|
757
|
+
// entry can never be acked and loops forever (~every tick), spamming the log and
|
|
758
|
+
// pinning the outbox row permanently undrained. Honour the guard's own advice:
|
|
759
|
+
// route the event straight through the local receiver (handleMeshForwardEvent —
|
|
760
|
+
// the same path the coordinator runs on receiving a remote push), then ack it.
|
|
761
|
+
// We drain regardless of the local result: a cross-daemon dispatch could not have
|
|
762
|
+
// resolved it either (the guard rejects before the receiver ever runs), so leaving
|
|
763
|
+
// it queued only re-spams. handleMeshForwardEvent has the BEST recovery chance —
|
|
764
|
+
// this daemon hosts the mesh, so its workspace/nodeId → meshId recovery applies.
|
|
765
|
+
if (isSelfCoordinatorId(entry.coordinatorDaemonId)) {
|
|
766
|
+
let localResult: any;
|
|
767
|
+
try {
|
|
768
|
+
traceMeshEventStage('forward_send', entryTraceCtx, `self → local router (${entry.coordinatorDaemonId})`);
|
|
769
|
+
localResult = handleMeshForwardEvent(components, entry.payload);
|
|
770
|
+
} catch (e: any) {
|
|
771
|
+
LOG.warn('MeshReconcile', `Local route of self-addressed forward to ${entry.coordinatorDaemonId} threw: ${e?.message || e} — draining anyway to break the retry loop`);
|
|
772
|
+
}
|
|
773
|
+
ackUnresolvedDelegateForward(entry.id);
|
|
774
|
+
if (localResult && localResult.success === false) {
|
|
775
|
+
LOG.warn('MeshReconcile', `Self-addressed unresolved-delegate ${readNonEmptyString(entry.payload.event)} rejected by local router (${readNonEmptyString(localResult.error) || 'no reason'}) — drained to break the self-forward retry loop`);
|
|
776
|
+
traceMeshEventDrop('self_forward_local_rejected', entryTraceCtx, readNonEmptyString(localResult.error) || 'no reason');
|
|
777
|
+
} else {
|
|
778
|
+
LOG.info('MeshReconcile', `Self-addressed unresolved-delegate ${readNonEmptyString(entry.payload.event)} routed via local router (coordinator ${entry.coordinatorDaemonId} is self) — drained`);
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
745
783
|
let result: any;
|
|
746
784
|
try {
|
|
747
785
|
traceMeshEventStage('forward_send', entryTraceCtx, `retry → ${entry.coordinatorDaemonId}`);
|