@adhdev/daemon-core 0.9.82-rc.462 → 0.9.82-rc.464
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/router-aggregate-status.d.ts +24 -0
- package/dist/commands/router-mesh-session-owner.d.ts +59 -0
- package/dist/commands/router.d.ts +7 -47
- package/dist/index.js +285 -219
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +285 -219
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +9 -8
- package/dist/providers/cli-provider-instance.d.ts +9 -0
- package/dist/providers/spec/types.d.ts +22 -0
- package/package.json +3 -3
- package/src/commands/router-aggregate-status.ts +209 -0
- package/src/commands/router-mesh-session-owner.ts +114 -0
- package/src/commands/router.ts +27 -255
- package/src/mesh/mesh-events-pending.ts +11 -10
- package/src/mesh/mesh-reconcile-loop.ts +4 -3
- package/src/providers/cli-provider-instance.ts +17 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +54 -2
- package/src/providers/spec/native-history-executor.ts +73 -6
- package/src/providers/spec/types.ts +22 -0
|
@@ -56,15 +56,16 @@ export interface PendingEventEmitHint {
|
|
|
56
56
|
* T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
|
|
57
57
|
* (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
|
|
58
58
|
* it instead (excluded from the delivered batch + WARN + counter), and unicast
|
|
59
|
-
* routing is the only delivery path (there is no v1 broadcast fallback).
|
|
60
|
-
* default
|
|
59
|
+
* routing is the only delivery path (there is no v1 broadcast fallback). On by
|
|
60
|
+
* default; set MESH_PROTOCOL_V2_ENFORCE=0/false/off/no to disable and restore the
|
|
61
|
+
* accept-and-warn rollout behaviour exactly.
|
|
61
62
|
*
|
|
62
|
-
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* no data migration
|
|
67
|
-
* operator can toggle it without a restart.
|
|
63
|
+
* Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env).
|
|
64
|
+
* Now that every node emits v2 (§배포 게이트 1 / risk §4), the code default is ON —
|
|
65
|
+
* a manual env injection is no longer required to get enforce behaviour. Rollback to
|
|
66
|
+
* accept mode is a pure-env step: set `MESH_PROTOCOL_V2_ENFORCE=0` (or `false`/`off`/
|
|
67
|
+
* `no`) — no code change, no data migration (the schema is additive). Read at call
|
|
68
|
+
* time so a test / operator can toggle it without a restart.
|
|
68
69
|
*
|
|
69
70
|
* Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
|
|
70
71
|
* already consumed the event from its store by the time routing runs, so "held
|
|
@@ -283,6 +283,15 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
283
283
|
private completionHasFinalAssistantMessage;
|
|
284
284
|
private buildExternalTranscriptProbe;
|
|
285
285
|
private recordPendingTranscriptProbe;
|
|
286
|
+
/**
|
|
287
|
+
* The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
|
|
288
|
+
* at a per-coordinator HERMES_HOME so its state.db lives in a tmpdir instead
|
|
289
|
+
* of ~/.hermes). The native-history executor expands `${HERMES_HOME:-~/.hermes}`
|
|
290
|
+
* from this map, so the completion gate MUST pass it through — otherwise the
|
|
291
|
+
* gate reads ~/.hermes, finds no coordinator-session transcript, and
|
|
292
|
+
* false-fires missing_final_assistant on every coordinator turn.
|
|
293
|
+
*/
|
|
294
|
+
private spawnedEnvOverrides;
|
|
286
295
|
private readExternalCompletionMessages;
|
|
287
296
|
private completionFinalAssistantEvidence;
|
|
288
297
|
private completionFinalSummary;
|
|
@@ -83,6 +83,28 @@ export interface NativeHistorySqliteSource {
|
|
|
83
83
|
path: string;
|
|
84
84
|
session_query: string;
|
|
85
85
|
message_query: string;
|
|
86
|
+
/**
|
|
87
|
+
* Optional sub-session cluster expansion. Some agents (hermes ≥0.14) split
|
|
88
|
+
* a SINGLE logical turn across several `sessions` rows linked by a parent
|
|
89
|
+
* pointer, and the turn's final assistant message lands in a DIFFERENT row
|
|
90
|
+
* than the one `session_query` / the daemon's pin resolves. Reading only
|
|
91
|
+
* the anchor session then surfaces zero (or stale) assistant bubbles even
|
|
92
|
+
* though the answer is physically present in a sibling/descendant row —
|
|
93
|
+
* `read_chat` returns no final assistant and the completion gate false-fires
|
|
94
|
+
* `missing_final_assistant`.
|
|
95
|
+
*
|
|
96
|
+
* When present, the executor treats the resolved session id as an ANCHOR
|
|
97
|
+
* and runs this query (bound `?` = anchor id) to expand it to every session
|
|
98
|
+
* id in the same logical cluster (typically a `WITH RECURSIVE` walk over the
|
|
99
|
+
* parent pointer, up to the root and back down through all descendants).
|
|
100
|
+
* `message_query` is then run once per cluster id and the rows merged and
|
|
101
|
+
* re-sorted by their mapped timestamp, so the turn's final assistant — in
|
|
102
|
+
* whichever sub-session it was written — is always included. Each returned
|
|
103
|
+
* row's first column is a cluster session id.
|
|
104
|
+
*
|
|
105
|
+
* Absent → single-session behaviour is unchanged (anchor session only).
|
|
106
|
+
*/
|
|
107
|
+
session_cluster_query?: string;
|
|
86
108
|
message_map: NativeHistoryMessageMap;
|
|
87
109
|
}
|
|
88
110
|
export interface NativeHistoryMessageMap {
|
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.464",
|
|
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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.464",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.464",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate mesh-status cache — extracted from router.ts (behavior-preserving code move).
|
|
3
|
+
*
|
|
4
|
+
* These functions were `DaemonCommandRouter` methods; they now take the router
|
|
5
|
+
* instance as `self`. The class keeps thin delegating wrappers
|
|
6
|
+
* (getCachedAggregateMeshStatus / rememberAggregateMeshStatus are bound into
|
|
7
|
+
* HighFamilyContext — intra-cluster calls therefore go through `self.` so instance
|
|
8
|
+
* dispatch is preserved). No log string, error message, refusal condition, or
|
|
9
|
+
* result shape changed — only physical location + `this.` → `self.`.
|
|
10
|
+
*
|
|
11
|
+
* The group is self-contained: it reads only `self.aggregateMeshStatusCache` and
|
|
12
|
+
* imported mesh-node-identity / mesh-work-queue helpers. It does NOT touch the
|
|
13
|
+
* inline-mesh cache cluster, so the move is a pure lift.
|
|
14
|
+
*/
|
|
15
|
+
import type { DaemonCommandRouter } from './router.js';
|
|
16
|
+
import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
|
|
17
|
+
import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
|
|
18
|
+
import {
|
|
19
|
+
applyInlineMeshBranchConvergence,
|
|
20
|
+
buildInlineMeshTransitGitStatus,
|
|
21
|
+
buildLivePeerGitConnection,
|
|
22
|
+
deriveMeshNodeHealthFromGit,
|
|
23
|
+
isDeadLocalWorktreeNode,
|
|
24
|
+
readBooleanValue,
|
|
25
|
+
readInlineMeshNodeId,
|
|
26
|
+
readObjectRecord,
|
|
27
|
+
readStringValue,
|
|
28
|
+
shouldRefreshStalePendingAggregate,
|
|
29
|
+
summarizeInlineMeshBranchConvergence,
|
|
30
|
+
} from '../mesh/mesh-node-identity.js';
|
|
31
|
+
|
|
32
|
+
export function cloneJsonValue<T>(value: T): T {
|
|
33
|
+
if (typeof structuredClone === 'function') return structuredClone(value);
|
|
34
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hydrateCachedAggregateMeshStatusFromInline(
|
|
38
|
+
self: DaemonCommandRouter,
|
|
39
|
+
snapshot: any,
|
|
40
|
+
mesh: any,
|
|
41
|
+
options?: { requireDirectPeerTruth?: boolean },
|
|
42
|
+
): any {
|
|
43
|
+
if (!mesh || typeof mesh !== 'object' || !Array.isArray(mesh.nodes) || !Array.isArray(snapshot?.nodes)) return snapshot;
|
|
44
|
+
const inlineNodesById = new Map<string, any>();
|
|
45
|
+
for (const node of mesh.nodes) {
|
|
46
|
+
const nodeId = readInlineMeshNodeId(node);
|
|
47
|
+
if (nodeId) inlineNodesById.set(nodeId, node);
|
|
48
|
+
}
|
|
49
|
+
if (!inlineNodesById.size) return snapshot;
|
|
50
|
+
|
|
51
|
+
let changed = false;
|
|
52
|
+
const unavailableNodeIds = new Set<string>();
|
|
53
|
+
const sourceOfTruth = readObjectRecord(snapshot.sourceOfTruth);
|
|
54
|
+
const directPeerTruth = readObjectRecord(sourceOfTruth.directPeerTruth);
|
|
55
|
+
// Dead local worktree nodes (isLocalWorktree, workspace deleted from disk)
|
|
56
|
+
// carry no live truth and must never gate the aggregate as unavailable.
|
|
57
|
+
// A cached snapshot built before the worktree was removed can still list
|
|
58
|
+
// such a node in unavailableNodeIds, which would wedge the graph in a
|
|
59
|
+
// permanent direct_peer_truth_unavailable; drop them here so the held
|
|
60
|
+
// standing-state truth for the surviving nodes satisfies the aggregate.
|
|
61
|
+
const deadNodeIds = new Set<string>();
|
|
62
|
+
for (const node of mesh.nodes) {
|
|
63
|
+
if (!isDeadLocalWorktreeNode(node)) continue;
|
|
64
|
+
const deadId = readInlineMeshNodeId(node);
|
|
65
|
+
if (deadId) deadNodeIds.add(deadId);
|
|
66
|
+
}
|
|
67
|
+
let droppedDeadUnavailable = false;
|
|
68
|
+
for (const entry of Array.isArray(directPeerTruth.unavailableNodeIds) ? directPeerTruth.unavailableNodeIds : []) {
|
|
69
|
+
const nodeId = readStringValue(entry);
|
|
70
|
+
if (!nodeId) continue;
|
|
71
|
+
if (deadNodeIds.has(nodeId)) {
|
|
72
|
+
droppedDeadUnavailable = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
unavailableNodeIds.add(nodeId);
|
|
76
|
+
}
|
|
77
|
+
// Force a rewrite when a dead worktree was filtered out of a previously
|
|
78
|
+
// built unavailable set, even if no live git was re-hydrated this pass —
|
|
79
|
+
// otherwise the early-return below would hand back the stale snapshot that
|
|
80
|
+
// still says direct_peer_truth_unavailable.
|
|
81
|
+
if (droppedDeadUnavailable) changed = true;
|
|
82
|
+
|
|
83
|
+
const nodes = snapshot.nodes.map((statusNode: any) => {
|
|
84
|
+
const nodeId = normalizeMeshNodeId(statusNode);
|
|
85
|
+
const inlineNode = nodeId ? inlineNodesById.get(nodeId) : undefined;
|
|
86
|
+
if (!inlineNode) return statusNode;
|
|
87
|
+
const liveGit = buildInlineMeshTransitGitStatus(inlineNode);
|
|
88
|
+
if (!liveGit) return statusNode;
|
|
89
|
+
const nextStatus = { ...statusNode };
|
|
90
|
+
nextStatus.git = liveGit;
|
|
91
|
+
nextStatus.health = deriveMeshNodeHealthFromGit(liveGit);
|
|
92
|
+
applyInlineMeshBranchConvergence(mesh, inlineNode, nextStatus);
|
|
93
|
+
nextStatus.launchReady = readBooleanValue(nextStatus.launchReady) ?? true;
|
|
94
|
+
const connection = readObjectRecord(nextStatus.connection);
|
|
95
|
+
const connectionState = readStringValue(connection.state);
|
|
96
|
+
const connectionReported = readBooleanValue(connection.reported) ?? false;
|
|
97
|
+
if (!connectionReported || connectionState === 'unknown') {
|
|
98
|
+
nextStatus.connection = buildLivePeerGitConnection(connection);
|
|
99
|
+
}
|
|
100
|
+
delete nextStatus.gitProbePending;
|
|
101
|
+
const error = readStringValue(nextStatus.error);
|
|
102
|
+
if (error && /pending_git|git probe|live peer git snapshot|no peer git snapshot/i.test(error)) delete nextStatus.error;
|
|
103
|
+
if (!readStringValue(nextStatus.machineStatus)) nextStatus.machineStatus = 'online';
|
|
104
|
+
if (nodeId) unavailableNodeIds.delete(nodeId);
|
|
105
|
+
changed = true;
|
|
106
|
+
return nextStatus;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const aggregateDirectTruthSatisfied = sourceOfTruth.coordinatorOwnsLiveTruth === true
|
|
110
|
+
|| directPeerTruth.satisfied === true;
|
|
111
|
+
if (!changed && !(options?.requireDirectPeerTruth && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied)) return snapshot;
|
|
112
|
+
const nextSourceOfTruth = {
|
|
113
|
+
...sourceOfTruth,
|
|
114
|
+
...(Object.keys(directPeerTruth).length ? {
|
|
115
|
+
directPeerTruth: {
|
|
116
|
+
...directPeerTruth,
|
|
117
|
+
satisfied: options?.requireDirectPeerTruth === true
|
|
118
|
+
? aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0
|
|
119
|
+
: directPeerTruth.satisfied,
|
|
120
|
+
unavailableNodeIds: [...unavailableNodeIds],
|
|
121
|
+
},
|
|
122
|
+
...(options?.requireDirectPeerTruth === true ? {
|
|
123
|
+
coordinatorOwnsLiveTruth: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0,
|
|
124
|
+
currentStatus: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0 ? 'live_git_and_session_probes' : 'direct_peer_truth_unavailable',
|
|
125
|
+
} : {}),
|
|
126
|
+
} : {}),
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
...snapshot,
|
|
130
|
+
...(options?.requireDirectPeerTruth === true && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied ? {
|
|
131
|
+
success: false,
|
|
132
|
+
code: 'mesh_direct_peer_truth_unavailable',
|
|
133
|
+
error: 'Selected coordinator could not confirm direct mesh truth for every remote node yet.',
|
|
134
|
+
} : {}),
|
|
135
|
+
sourceOfTruth: nextSourceOfTruth,
|
|
136
|
+
branchConvergenceSummary: summarizeInlineMeshBranchConvergence(nodes),
|
|
137
|
+
nodes,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getCachedAggregateMeshStatus(
|
|
142
|
+
self: DaemonCommandRouter,
|
|
143
|
+
meshId: string,
|
|
144
|
+
mesh?: any,
|
|
145
|
+
options?: { requireDirectPeerTruth?: boolean; allowStalePending?: boolean },
|
|
146
|
+
): any | null {
|
|
147
|
+
const cached = self.aggregateMeshStatusCache.get(meshId);
|
|
148
|
+
if (!cached?.snapshot || cached.snapshot.success !== true || !Array.isArray(cached.snapshot.nodes)) return null;
|
|
149
|
+
// Genuine invalidation still forces truth: a queue mutation bumps the
|
|
150
|
+
// revision, so a stale-revision snapshot is never served (even under the
|
|
151
|
+
// SWR allowStalePending path below).
|
|
152
|
+
if (cached.queueRevision !== getMeshQueueRevision(meshId)) return null;
|
|
153
|
+
let snapshot = cloneJsonValue(cached.snapshot);
|
|
154
|
+
snapshot = hydrateCachedAggregateMeshStatusFromInline(self, snapshot, mesh, options);
|
|
155
|
+
// SWR: allowStalePending lets the interactive detail-open serve a snapshot
|
|
156
|
+
// that still has pending peer-git nodes (would otherwise miss here) so the
|
|
157
|
+
// graph paints instantly; the caller fires a background freshen. The
|
|
158
|
+
// queueRevision guard above is NOT relaxed — only the pending-git freshness
|
|
159
|
+
// gate is, so a genuine queue/identity mutation still forces a live rebuild.
|
|
160
|
+
if (!options?.allowStalePending && shouldRefreshStalePendingAggregate(snapshot, options)) return null;
|
|
161
|
+
const ageMs = Math.max(0, Date.now() - cached.builtAt);
|
|
162
|
+
const sourceOfTruth = snapshot.sourceOfTruth && typeof snapshot.sourceOfTruth === 'object'
|
|
163
|
+
? snapshot.sourceOfTruth
|
|
164
|
+
: {};
|
|
165
|
+
snapshot.sourceOfTruth = {
|
|
166
|
+
...sourceOfTruth,
|
|
167
|
+
aggregateSnapshot: {
|
|
168
|
+
...(sourceOfTruth.aggregateSnapshot && typeof sourceOfTruth.aggregateSnapshot === 'object'
|
|
169
|
+
? sourceOfTruth.aggregateSnapshot
|
|
170
|
+
: {}),
|
|
171
|
+
owner: 'coordinator_daemon_memory',
|
|
172
|
+
cached: true,
|
|
173
|
+
source: 'memory',
|
|
174
|
+
refreshReason: 'memory_cache_hit',
|
|
175
|
+
ageMs,
|
|
176
|
+
cachedAt: new Date(cached.builtAt).toISOString(),
|
|
177
|
+
returnedAt: new Date().toISOString(),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
return snapshot;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function rememberAggregateMeshStatus(
|
|
184
|
+
self: DaemonCommandRouter,
|
|
185
|
+
meshId: string,
|
|
186
|
+
snapshot: any,
|
|
187
|
+
refreshReason: string,
|
|
188
|
+
): any {
|
|
189
|
+
if (!snapshot || typeof snapshot !== 'object' || snapshot.success !== true || !Array.isArray(snapshot.nodes)) return snapshot;
|
|
190
|
+
const builtAt = Date.now();
|
|
191
|
+
const next = cloneJsonValue(snapshot);
|
|
192
|
+
const sourceOfTruth = next.sourceOfTruth && typeof next.sourceOfTruth === 'object'
|
|
193
|
+
? next.sourceOfTruth
|
|
194
|
+
: {};
|
|
195
|
+
next.sourceOfTruth = {
|
|
196
|
+
...sourceOfTruth,
|
|
197
|
+
aggregateSnapshot: {
|
|
198
|
+
owner: 'coordinator_daemon_memory',
|
|
199
|
+
cached: false,
|
|
200
|
+
source: 'live_refresh',
|
|
201
|
+
refreshReason,
|
|
202
|
+
ageMs: 0,
|
|
203
|
+
cachedAt: new Date(builtAt).toISOString(),
|
|
204
|
+
returnedAt: new Date(builtAt).toISOString(),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
self.aggregateMeshStatusCache.set(meshId, { builtAt, snapshot: cloneJsonValue(next), queueRevision: getMeshQueueRevision(meshId) });
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote mesh-session owner resolution — extracted from router.ts (behavior-preserving code move).
|
|
3
|
+
*
|
|
4
|
+
* These functions were `DaemonCommandRouter` methods; they now take the router
|
|
5
|
+
* instance as `self`. resolveRemoteMeshSessionOwnerDaemonId stays reachable as a
|
|
6
|
+
* public method (the [Z] session-scoped forward in executeDaemonCommand and the
|
|
7
|
+
* mesh-session-scoped-remote-forward test call it), so the class keeps a thin
|
|
8
|
+
* delegating wrapper. No log string, error message, refusal condition, or result
|
|
9
|
+
* shape changed — only physical location + `this.` → `self.`.
|
|
10
|
+
*
|
|
11
|
+
* The group is read-only: it reads self.deps.statusInstanceId, the cached inline-mesh
|
|
12
|
+
* nodes, and the aggregate-status snapshot nodes. It never mutates router state.
|
|
13
|
+
*/
|
|
14
|
+
import type { DaemonCommandRouter } from './router.js';
|
|
15
|
+
import { meshNodeIdMatches, daemonIdsEquivalent } from '@adhdev/mesh-shared';
|
|
16
|
+
import {
|
|
17
|
+
collectMeshNodeHostedSessionIds,
|
|
18
|
+
readMeshNodeDaemonId,
|
|
19
|
+
readObjectRecord,
|
|
20
|
+
} from '../mesh/mesh-node-identity.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the REMOTE worker daemonId that owns a given session, when the session
|
|
24
|
+
* belongs to a mesh node hosted on a DIFFERENT daemon than this coordinator.
|
|
25
|
+
*
|
|
26
|
+
* The coordinator does not host remote-worker session instances in its own
|
|
27
|
+
* instanceManager/sessionRegistry — only their cached mesh-node metadata. A
|
|
28
|
+
* dashboard-issued session-scoped command (invoke_provider_script / resolve_action /
|
|
29
|
+
* set_mode / …) lands on the coordinator with a targetSessionId the coordinator can't
|
|
30
|
+
* find locally, and without forwarding it dies as "Live session not found". send_chat
|
|
31
|
+
* happens to survive (its target resolves to the worker by another route), but the
|
|
32
|
+
* controlbar commands do not — so the controlbar buttons appear to do nothing.
|
|
33
|
+
*
|
|
34
|
+
* Mirror the existing node-level remote-forward pattern (fast_forward_mesh_node etc.):
|
|
35
|
+
* scan the candidate mesh nodes for the one hosting the targetSessionId, and return its
|
|
36
|
+
* daemonId when that daemonId is a remote daemon (i.e. not this coordinator's own
|
|
37
|
+
* statusInstanceId). Returns undefined for a locally-hosted session (no forward — execute
|
|
38
|
+
* locally as before) or when ownership can't be resolved.
|
|
39
|
+
*
|
|
40
|
+
* The candidate set spans BOTH the cached inline-mesh nodes and the live aggregate
|
|
41
|
+
* mesh-status snapshots. The inline cache reliably carries only each node's single primary
|
|
42
|
+
* session (cachedStatus.activeSession), so a worker hosting more than one session exposes its
|
|
43
|
+
* non-primary sessions only on the aggregate snapshot nodes (status.activeSessions /
|
|
44
|
+
* activeSessionDetails, built from live session records). collectMeshNodeHostedSessionIds does
|
|
45
|
+
* the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
|
|
46
|
+
* session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
|
|
47
|
+
* other consumers depend on stay untouched.
|
|
48
|
+
*
|
|
49
|
+
* CANCEL-STOP-RELAY: the session-id cache scan above only matches when the coordinator's
|
|
50
|
+
* cached status snapshot already lists the worker's session id in a recognized active-sessions
|
|
51
|
+
* shape. A worktree-clone worker session whose id form/timing differs from the cached snapshot
|
|
52
|
+
* (or is simply not yet reflected) misses the scan, so a stop that carries the authoritative
|
|
53
|
+
* owning nodeId (mesh_queue_cancel knows assignedNodeId) used to silently fail to forward.
|
|
54
|
+
* `ownerNodeIdHint` adds a deterministic fallback: when the session-id scan misses, resolve the
|
|
55
|
+
* owner daemonId by matching the node by id (meshNodeIdMatches — same form-tolerant compare the
|
|
56
|
+
* rest of the router uses, no new raw compare). The same self-loopback guard applies to both
|
|
57
|
+
* paths, so a coordinator-hosted node is still never force-forwarded to a remote form of itself.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveRemoteMeshSessionOwnerDaemonId(
|
|
60
|
+
self: DaemonCommandRouter,
|
|
61
|
+
sessionId: string,
|
|
62
|
+
ownerNodeIdHint?: string,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
65
|
+
const nodeHint = typeof ownerNodeIdHint === 'string' ? ownerNodeIdHint.trim() : '';
|
|
66
|
+
if (!trimmed && !nodeHint) return undefined;
|
|
67
|
+
const selfDaemonId = self.deps.statusInstanceId;
|
|
68
|
+
const candidates = collectMeshSessionOwnerCandidateNodes(self);
|
|
69
|
+
if (trimmed) {
|
|
70
|
+
for (const node of candidates) {
|
|
71
|
+
if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
|
|
72
|
+
const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
|
|
73
|
+
// A matching node with no readable daemonId can't be attributed — keep scanning
|
|
74
|
+
// the remaining candidates (e.g. the same session on an aggregate node that does
|
|
75
|
+
// carry the daemonId) rather than bailing on the whole resolution.
|
|
76
|
+
if (!nodeDaemonId) continue;
|
|
77
|
+
// Only forward to a genuinely remote daemon. When the owning node is this
|
|
78
|
+
// coordinator itself (locally hosted worker), fall through to local handling.
|
|
79
|
+
// id-form robust: the node daemonId and selfDaemonId may be stored in different
|
|
80
|
+
// forms of the same machine — a strict `===` would miss the self-match and forward
|
|
81
|
+
// a local session to a remote form of THIS daemon (loopback).
|
|
82
|
+
if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
|
|
83
|
+
return nodeDaemonId;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Deterministic fallback: the session-id scan missed (cache lag / id-form mismatch on a
|
|
87
|
+
// worktree-clone worker), but the caller knows the authoritative owning nodeId. Resolve the
|
|
88
|
+
// owner daemonId straight off that node — never the fuzzy session cache.
|
|
89
|
+
if (nodeHint) {
|
|
90
|
+
for (const node of candidates) {
|
|
91
|
+
if (!meshNodeIdMatches(node, nodeHint)) continue;
|
|
92
|
+
const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
|
|
93
|
+
if (!nodeDaemonId) continue;
|
|
94
|
+
if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
|
|
95
|
+
return nodeDaemonId;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Candidate nodes for remote-session owner resolution: the cached inline-mesh nodes (which
|
|
103
|
+
* carry each node's primary session) plus the nodes from every cached aggregate mesh-status
|
|
104
|
+
* snapshot (which carry each node's full live session list). getCachedInlineMeshNodes()
|
|
105
|
+
* returns a fresh array, so appending the aggregate nodes never mutates cached state.
|
|
106
|
+
*/
|
|
107
|
+
export function collectMeshSessionOwnerCandidateNodes(self: DaemonCommandRouter): any[] {
|
|
108
|
+
const nodes: any[] = self.getCachedInlineMeshNodes();
|
|
109
|
+
for (const cached of self.aggregateMeshStatusCache.values()) {
|
|
110
|
+
const snapshotNodes = cached?.snapshot?.nodes;
|
|
111
|
+
if (Array.isArray(snapshotNodes)) nodes.push(...snapshotNodes);
|
|
112
|
+
}
|
|
113
|
+
return nodes;
|
|
114
|
+
}
|