@adhdev/daemon-core 0.9.82-rc.365 → 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.
@@ -59,6 +59,14 @@ export interface MeshQueueTriggerResult {
59
59
  status?: string;
60
60
  }>;
61
61
  autoLaunchStarted: boolean;
62
+ /**
63
+ * True when a worker session is already on its way to claim a still-pending task —
64
+ * either launched this tick (autoLaunchStarted) or launched on a prior tick and still
65
+ * booting/awaiting-claim. Callers MUST treat this as "wait, do not launch another
66
+ * session": a second launch double-edits the worktree. Mutually informative with
67
+ * `noIdleMeshSessionAvailable`, which is suppressed whenever this is true.
68
+ */
69
+ autoLaunchPending?: boolean;
62
70
  noIdleMeshSessionAvailable?: boolean;
63
71
  }
64
72
  export declare function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.365",
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.365",
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: false,
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
- error: 'Provider-native history was not safely available for the requested CLI session.',
2748
+ messages: [],
2749
+ status: 'idle',
2656
2750
  providerSessionId: historyProviderSessionId,
2657
2751
  messageSource: decision.messageSource,
2658
2752
  transcriptProvenance: decision.messageSource,
@@ -19,7 +19,7 @@ import { loadState, saveState } from '../config/state-store.js';
19
19
  import { getWorkspaceState, resolveLaunchDirectory } from '../config/workspaces.js';
20
20
  import { appendRecentActivity } from '../config/recent-activity.js';
21
21
  import { shortHash } from '../system/hash.js';
22
- import { unregisterMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
22
+ import { unregisterMeshCoordinator, getCoordinatorForSession, listCoordinatorsForWorkspace } from '../mesh/coordinator-registry.js';
23
23
  import { upsertSavedProviderSession } from '../config/saved-sessions.js';
24
24
  import { buildLegacyModelModeSummaryMetadata, normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
25
25
  import { CliProviderInstance } from '../providers/cli-provider-instance.js';
@@ -1069,7 +1069,33 @@ export class DaemonCliManager {
1069
1069
  // Both restores are provider-agnostic — getSettings is keyed by provider type and the
1070
1070
  // registry mark is type-independent.
1071
1071
  const restoredSettings: Record<string, any> = { ...this.providerLoader.getSettings(normalizedType) };
1072
- const coordinatorEntry = getCoordinatorForSession(record.runtimeId);
1072
+ // Primary rebind: exact persisted-registry match by runtimeId (stable across
1073
+ // restart, see session-host runtimeId = runtimeRecord.sessionId).
1074
+ let coordinatorEntry = getCoordinatorForSession(record.runtimeId);
1075
+ // CORDBADGE fallback: the by-id match misses when a coordinator's runtime
1076
+ // re-attaches under a different runtimeId than the one it was registered with
1077
+ // (the registry survived, but its key no longer lines up). Without a rebind the
1078
+ // restored session silently loses meshCoordinatorFor → the coordinator badge and
1079
+ // selfIdentification block vanish and pending mesh events stop draining into its
1080
+ // PTY, and the only recovery is a manual coordinator restart. Recover the mark
1081
+ // from the persisted registry scoped to this exact workspace, but ONLY when it is
1082
+ // UNAMBIGUOUS: exactly one registered coordinator for this workspace AND its
1083
+ // cliType matches the restored session's type. The registry never holds worker
1084
+ // sessions (only launch_mesh_coordinator registrations), so this cannot mis-mark a
1085
+ // delegated worker; the uniqueness + cliType gate keeps it from guessing when two
1086
+ // coordinators ever shared a workspace. Anything ambiguous stays unbound (we would
1087
+ // rather miss a badge than mis-attribute one).
1088
+ if (!coordinatorEntry?.meshId && record.workspace) {
1089
+ const workspaceCoordinators = listCoordinatorsForWorkspace(record.workspace)
1090
+ .filter(e => e.meshId && (!e.cliType || e.cliType === record.cliType));
1091
+ if (workspaceCoordinators.length === 1) {
1092
+ coordinatorEntry = workspaceCoordinators[0];
1093
+ LOG.info(
1094
+ 'CLI',
1095
+ `↻ Rebound coordinator mark by workspace for ${record.runtimeKey || record.runtimeId} (mesh ${coordinatorEntry.meshId} @ ${record.workspace}); registry key did not match runtimeId`
1096
+ );
1097
+ }
1098
+ }
1073
1099
  if (coordinatorEntry?.meshId) {
1074
1100
  restoredSettings.meshCoordinatorFor = coordinatorEntry.meshId;
1075
1101
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * RF-ROUTER HIGH family — command registry.
3
+ *
4
+ * Aggregates the high-coupling command handlers extracted from
5
+ * DaemonCommandRouter.executeDaemonCommand into a single cmd → handler map. The
6
+ * router consults this registry after the LOW- and MED-family registries and
7
+ * before its remaining switch (registry hit → return the handler result; miss →
8
+ * fall through to the switch / CommandHandler delegation), so the facade and
9
+ * dispatch semantics are unchanged.
10
+ *
11
+ * HIGH handlers are the most router-coupled: beyond the MED collaborators they
12
+ * reach the router's aggregate-status memory cache and running-refine-job table.
13
+ * The router binds those onto HighFamilyContext at dispatch — see types.ts.
14
+ */
15
+ import { meshEventsHandlers } from './mesh-events.js';
16
+ import { meshCoordinatorLaunchHandlers } from './mesh-coordinator-launch.js';
17
+ import { meshStatusHandlers } from './mesh-status.js';
18
+ import type { HighFamilyRegistry } from './types.js';
19
+
20
+ export type { HighFamilyContext, HighFamilyHandler, HighFamilyRegistry } from './types.js';
21
+
22
+ export const highFamilyRegistry: HighFamilyRegistry = new Map(
23
+ Object.entries({
24
+ ...meshEventsHandlers,
25
+ ...meshCoordinatorLaunchHandlers,
26
+ ...meshStatusHandlers,
27
+ }),
28
+ );