@adhdev/daemon-core 0.9.82-rc.373 → 0.9.82-rc.375

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.
@@ -21,6 +21,47 @@
21
21
  * a static prompt, which is also fine.
22
22
  */
23
23
  import type { LocalMeshEntry, RepoMeshStatus } from '../repo-mesh-types.js';
24
+ /**
25
+ * Cheap, locally-derived "what just happened" snapshot for the coordinator
26
+ * prompt. Built at launch from the local ledger + work-queue stats — no remote
27
+ * peer probe. Surfaces the gap a fresh coordinator otherwise misses: it can't
28
+ * see recent failures / queue depth until it manually calls mesh_task_history.
29
+ *
30
+ * All fields are optional so callers that have nothing to report (or fail to
31
+ * read the ledger) simply omit the section — the prompt output stays identical
32
+ * to the pre-activity form in that case.
33
+ */
34
+ export interface CoordinatorRecentActivity {
35
+ /** task_failed entries from the recent window, newest last. */
36
+ recentFailures?: Array<{
37
+ timestamp?: string;
38
+ nodeId?: string;
39
+ /** Short task title/message, already truncated by the caller. */
40
+ summary?: string;
41
+ }>;
42
+ /** Count of task_failed entries inside the recent (30-min) window. */
43
+ recentFailureCount?: number;
44
+ /** Pending (unclaimed) tasks in the work queue. */
45
+ pendingTasks?: number;
46
+ /** Assigned-but-not-yet-terminal tasks in the work queue. */
47
+ assignedTasks?: number;
48
+ /** Stalled tasks recorded in the ledger. */
49
+ stalledTasks?: number;
50
+ /** ISO timestamp of the most recent ledger activity, if any. */
51
+ lastActivityAt?: string | null;
52
+ }
53
+ /**
54
+ * One coordinator operating note — a runtime-accumulated lesson (provider
55
+ * quirk, pattern to avoid, recovery lesson) persisted in the ledger so it
56
+ * survives coordinator restarts and is provider-neutral (visible to codex /
57
+ * hermes / antigravity coordinators, not just Claude's memory).
58
+ */
59
+ export interface CoordinatorOperatingNote {
60
+ text: string;
61
+ category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
62
+ createdAt?: string;
63
+ sourceCoordinator?: string;
64
+ }
24
65
  export interface CoordinatorPromptContext {
25
66
  mesh: LocalMeshEntry;
26
67
  status?: RepoMeshStatus;
@@ -32,6 +73,18 @@ export interface CoordinatorPromptContext {
32
73
  * stays identical to the pre-M3 form in that case.
33
74
  */
34
75
  missionSection?: string;
76
+ /**
77
+ * Gap1: recent ledger/queue activity surfaced so a freshly-launched
78
+ * coordinator sees recent failures + queue depth without first calling
79
+ * mesh_task_history. Omitted → no "## Recent Activity" section.
80
+ */
81
+ recentActivity?: CoordinatorRecentActivity;
82
+ /**
83
+ * Gap2-A: runtime-accumulated operating notes (provider-neutral lessons)
84
+ * read from the ledger at launch. Omitted/empty → no "## Operating Notes"
85
+ * section.
86
+ */
87
+ operatingNotes?: CoordinatorOperatingNote[];
35
88
  }
36
89
  /**
37
90
  * Compose the final coordinator prompt from four layers, in this precedence:
@@ -11,6 +11,18 @@ export declare function findRecentTerminalLedgerEvidence(args: {
11
11
  } | null;
12
12
  export declare function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId: string): boolean;
13
13
  export declare function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean;
14
+ export declare function findTerminalLedgerEvidenceForTask(args: {
15
+ meshId: string;
16
+ taskId?: string;
17
+ sessionId?: string;
18
+ nodeId?: string;
19
+ tail?: number;
20
+ }): {
21
+ id: string;
22
+ kind: Extract<MeshLedgerKind, 'task_completed' | 'task_failed' | 'task_stalled'>;
23
+ payload: Record<string, unknown>;
24
+ timestamp: string;
25
+ } | null;
14
26
  export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
15
27
  meshId: string;
16
28
  nodeId?: string;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -1,5 +1,5 @@
1
1
  import type { ChatMessage } from '../types.js';
2
- export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4000;
2
+ export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
3
3
  export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
4
4
  /**
5
5
  * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
@@ -87,6 +87,7 @@ export declare class CliProviderInstance implements ProviderInstance {
87
87
  private runtimeMessages;
88
88
  private lastPersistedHistoryMessages;
89
89
  private lastAcknowledgedUserInputAt;
90
+ private recentUserInputAcks;
90
91
  private lastNativeSourceCanonicalCheckAt;
91
92
  private lastNativeSourceCanonicalCacheKey;
92
93
  private cachedSqliteDb;
@@ -168,6 +169,8 @@ export declare class CliProviderInstance implements ProviderInstance {
168
169
  isModalParked(): boolean;
169
170
  onEvent(event: string, data?: any): void;
170
171
  recordAcknowledgedUserInput(input: InputEnvelope | string): void;
172
+ /** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
173
+ private pruneRecentUserInputAcks;
171
174
  dispose(): void;
172
175
  private completedDebounceTimer;
173
176
  private completedDebouncePending;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.373",
3
+ "version": "0.9.82-rc.375",
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.373",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.375",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -59,6 +59,71 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
59
59
  try { return buildMissionPromptSection(id); } catch { return ''; }
60
60
  };
61
61
 
62
+ // Gap1: surface recent ledger/queue activity (recent failures +
63
+ // queue depth) so a freshly-launched coordinator sees them
64
+ // without first calling mesh_task_history. All cheap local reads
65
+ // — no remote peer probe. Best-effort: a read failure just omits
66
+ // the section, never blocks launch.
67
+ const buildRecentActivityBestEffort = async (id: string) => {
68
+ try {
69
+ const { getLedgerSummary, readLedgerEntries } = await import('../../mesh/mesh-ledger.js');
70
+ const { getMeshQueueStats } = await import('../../mesh/mesh-work-queue.js');
71
+ const summary = getLedgerSummary(id);
72
+ const queue = getMeshQueueStats(id);
73
+ const failureEntries = readLedgerEntries(id, { kind: ['task_failed'], tail: 5 });
74
+ const recentFailures = failureEntries.map((e) => {
75
+ const p = (e.payload || {}) as Record<string, unknown>;
76
+ const raw = typeof p.taskSummary === 'string' ? p.taskSummary
77
+ : typeof p.message === 'string' ? p.message
78
+ : typeof p.error === 'string' ? p.error
79
+ : '';
80
+ const summaryText = raw.length > 160 ? `${raw.slice(0, 160)}…` : raw;
81
+ return {
82
+ timestamp: e.timestamp,
83
+ nodeId: e.nodeId,
84
+ summary: summaryText,
85
+ };
86
+ });
87
+ return {
88
+ recentFailures,
89
+ recentFailureCount: summary.recentFailures,
90
+ pendingTasks: queue.pending,
91
+ assignedTasks: queue.assigned,
92
+ stalledTasks: summary.taskStalled,
93
+ lastActivityAt: summary.lastActivityAt,
94
+ };
95
+ } catch { return undefined; }
96
+ };
97
+
98
+ // Gap2-A: load accumulated operating notes (provider-neutral
99
+ // lessons) from the ledger so they ride into the prompt. Newest
100
+ // last; cap to the most recent 20 so the section stays lean.
101
+ // Best-effort: a read failure just omits the section.
102
+ const buildOperatingNotesBestEffort = async (id: string) => {
103
+ try {
104
+ const { readLedgerEntries } = await import('../../mesh/mesh-ledger.js');
105
+ const noteEntries = readLedgerEntries(id, { kind: ['coordinator_operating_note'], tail: 20 });
106
+ const notes = noteEntries
107
+ .map((e) => {
108
+ const p = (e.payload || {}) as Record<string, unknown>;
109
+ const text = typeof p.text === 'string' ? p.text.trim() : '';
110
+ if (!text) return null;
111
+ const category: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson' | undefined =
112
+ p.category === 'provider_quirk' || p.category === 'pattern_to_avoid' || p.category === 'recovery_lesson'
113
+ ? p.category
114
+ : undefined;
115
+ return {
116
+ text,
117
+ category,
118
+ createdAt: typeof p.createdAt === 'string' ? p.createdAt : e.timestamp,
119
+ sourceCoordinator: typeof p.sourceCoordinator === 'string' ? p.sourceCoordinator : undefined,
120
+ };
121
+ })
122
+ .filter((n): n is NonNullable<typeof n> => n !== null);
123
+ return notes.length ? notes : undefined;
124
+ } catch { return undefined; }
125
+ };
126
+
62
127
  // Support inline mesh data from cloud (bypasses local meshes.json lookup)
63
128
  let mesh: any;
64
129
  if (args?.inlineMesh && typeof args.inlineMesh === 'object') {
@@ -164,7 +229,7 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
164
229
  // Build coordinator prompt first — fail closed on errors.
165
230
  let cliCmdSystemPrompt = '';
166
231
  try {
167
- cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id) });
232
+ cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id), recentActivity: await buildRecentActivityBestEffort(mesh.id), operatingNotes: await buildOperatingNotesBestEffort(mesh.id) });
168
233
  } catch (error: any) {
169
234
  const message = error?.message || String(error);
170
235
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -382,7 +447,7 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
382
447
  // broken mesh state is visible instead of silently launching with weaker rules.
383
448
  let systemPrompt = '';
384
449
  try {
385
- systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id) });
450
+ systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined, missionSection: buildMissionSectionBestEffort(mesh.id), recentActivity: await buildRecentActivityBestEffort(mesh.id), operatingNotes: await buildOperatingNotesBestEffort(mesh.id) });
386
451
  } catch (error: any) {
387
452
  const message = error?.message || String(error);
388
453
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -96,15 +96,22 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
96
96
  } catch { /* best-effort — dispatch still proceeds without the stamp */ }
97
97
  }
98
98
  }
99
- const agentResult = await ctx.deps.cliManager.handleCliCommand('agent_command', args);
100
- // Bug C fix (part 2): when dispatching a task to a mesh node session, override
101
- // the dispatch acknowledgement risk reason to 'bootstrap_still_running' when
102
- // the target node's worktree bootstrap is still running. Informational only
103
- // dispatch is NOT blocked.
99
+ // Bug C fix / bootstrapPending dispatch gap: a task dispatched to a mesh node
100
+ // whose worktree bootstrap is STILL running must NOT be injected yet. Before this
101
+ // gate the dispatch proceeded and the prompt landed in the session's input buffer
102
+ // while the provider (e.g. claude CLI) was not yet ready to consume it the inject
103
+ // was silently swallowed (the chat bubble showed the text but the session never
104
+ // transitioned to generating and never claimed the task). The prior code only
105
+ // ANNOTATED the already-completed dispatch with 'bootstrap_still_running' after the
106
+ // fact, which did not close the gap. Defer instead: refuse the inject with a
107
+ // recoverable signal so the coordinator re-sends once the node is ready (the
108
+ // confirmed "re-send to a ready session works" path), mirroring the queued-delivery
109
+ // contract for busy sessions. send_chat only — non-task actions still pass through.
104
110
  const meshCtx = args?.meshContext as Record<string, unknown> | undefined;
105
111
  const dispatchNodeId = readStringValue(meshCtx?.nodeId);
106
112
  const dispatchMeshId = readStringValue(meshCtx?.meshId);
107
- if (dispatchNodeId && dispatchMeshId && agentResult?.success !== false) {
113
+ const isSendChat = args?.action === 'send_chat';
114
+ if (isSendChat && dispatchNodeId && dispatchMeshId) {
108
115
  try {
109
116
  const { getMesh } = await import('../../config/mesh-config.js');
110
117
  const meshObj = getMesh(dispatchMeshId) ?? ctx.getCachedInlineMesh(dispatchMeshId);
@@ -114,16 +121,21 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
114
121
  const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
115
122
  if (bootstrapStatus === 'running') {
116
123
  return {
117
- success: true,
118
- ...agentResult,
119
- dispatchAcknowledgementRisk: true,
120
- dispatchAcknowledgementRiskReason: 'bootstrap_still_running',
121
- nextAction: 'Wait for worktree_bootstrap_complete event before dispatching work to this node.',
124
+ success: false,
125
+ recoverable: true,
126
+ dispatched: false,
127
+ code: 'mesh_node_bootstrap_pending',
128
+ reason: 'bootstrap_still_running',
129
+ nodeId: dispatchNodeId,
130
+ meshId: dispatchMeshId,
131
+ ...(readStringValue(meshCtx?.taskId) ? { taskId: readStringValue(meshCtx?.taskId) } : {}),
132
+ error: `Node '${dispatchNodeId}' worktree bootstrap is still running; a task injected now would land in the session input buffer before the provider is ready to consume it and be silently lost. Dispatch deferred.`,
133
+ nextAction: 'Wait for the worktree_bootstrap_complete event (or poll mesh_status until the node session is ready), then re-send the task with mesh_send_task. Alternatively use mesh_enqueue_task so the queue auto-assigns it once a ready session is available.',
122
134
  };
123
135
  }
124
- } catch { /* best-effort */ }
136
+ } catch { /* best-effort — if the bootstrap probe fails, fall through and dispatch */ }
125
137
  }
126
- return agentResult;
138
+ return ctx.deps.cliManager.handleCliCommand('agent_command', args);
127
139
  },
128
140
 
129
141
  // ─── Logs ───
@@ -51,58 +51,90 @@ export const fastForwardHandlers: Record<string, MedFamilyHandler> = {
51
51
  },
52
52
 
53
53
  fast_forward_mesh_node: async (ctx: MedFamilyContext, args: any) => {
54
- const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
55
- const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
56
- let workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
57
- let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
58
- ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
59
- : undefined;
60
- let nodeDaemonId: string | undefined;
61
- let allowAutoPublishSubmoduleMainCommits = false;
62
- if (meshId && nodeId) {
63
- // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
64
- const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
65
- const mesh = meshRecord?.mesh;
66
- const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
67
- if (!workspace) {
68
- workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
54
+ // The whole handler is wrapped: the safety-gate evaluation (mesh resolution,
55
+ // git status/stash/submodule fan-out) can throw on some platforms notably a
56
+ // win32 daemon whose git invocation parsing or slow submodule probe raises
57
+ // before fastForwardMeshNode's own guarded body runs. router.execute re-throws,
58
+ // so an escaping throw surfaces to the coordinator as an opaque
59
+ // "Daemon IPC command failed" instead of the structured blockingReasons result
60
+ // a mac node returns cleanly. Catch it and return the same blocked shape so the
61
+ // coordinator can read the reason and route, never an IPC crash. Mirrors the
62
+ // mesh_init handler's try-catch contract.
63
+ const workspaceForError = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
64
+ const meshIdForError = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
65
+ const nodeIdForError = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
66
+ try {
67
+ const meshId = meshIdForError;
68
+ const nodeId = nodeIdForError;
69
+ let workspace = workspaceForError;
70
+ let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
71
+ ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
72
+ : undefined;
73
+ let nodeDaemonId: string | undefined;
74
+ let allowAutoPublishSubmoduleMainCommits = false;
75
+ if (meshId && nodeId) {
76
+ // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
77
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
78
+ const mesh = meshRecord?.mesh;
79
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
80
+ if (!workspace) {
81
+ workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
82
+ }
83
+ if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
84
+ submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
85
+ }
86
+ allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
87
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
69
88
  }
70
- if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
71
- submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
89
+ // If the target node belongs to a remote daemon, forward the command there.
90
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
91
+ // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
92
+ const selfDaemonId = ctx.deps.statusInstanceId;
93
+ // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
94
+ // machine's core must be treated as local (not remote) so it is not forwarded /
95
+ // P2P self-dialed. Equivalent → local.
96
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
97
+ if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
98
+ const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
99
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
100
+ workspace,
101
+ _meshDirectDispatch: true,
102
+ });
103
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
72
104
  }
73
- allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
74
- nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
75
- }
76
- // If the target node belongs to a remote daemon, forward the command there.
77
- // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
78
- // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
79
- const selfDaemonId = ctx.deps.statusInstanceId;
80
- // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
81
- // machine's core must be treated as local (not remote) so it is not forwarded /
82
- // P2P self-dialed. Equivalent → local.
83
- const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
84
- if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
85
- const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
86
- ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
105
+ const result = await (fastForwardMeshNode({
106
+ meshId: meshId || undefined,
107
+ nodeId: nodeId || undefined,
87
108
  workspace,
88
- _meshDirectDispatch: true,
89
- });
90
- return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
109
+ branch: typeof args?.branch === 'string' ? args.branch : undefined,
110
+ execute: args?.execute === true,
111
+ dryRun: args?.dryRun === true,
112
+ updateSubmodules: args?.updateSubmodules === true,
113
+ submoduleIgnorePaths,
114
+ mode: args?.mode === 'push' ? 'push' : 'merge',
115
+ pushSubmodules: args?.pushSubmodules === true,
116
+ allowAutoPublishSubmoduleMainCommits,
117
+ }) as Promise<unknown>);
118
+ return result as CommandRouterResult;
119
+ } catch (e: any) {
120
+ const errorMessage = e?.message || String(e);
121
+ return {
122
+ success: false,
123
+ code: 'fast_forward_safety_gate_error',
124
+ ...(meshIdForError ? { meshId: meshIdForError } : {}),
125
+ ...(nodeIdForError ? { nodeId: nodeIdForError } : {}),
126
+ workspace: workspaceForError,
127
+ mode: args?.mode === 'push' ? 'push' : 'merge',
128
+ allowed: false,
129
+ willRun: false,
130
+ executed: false,
131
+ // Surface the throw as a blocking reason instead of an opaque IPC crash
132
+ // so the coordinator gets the same structured shape a clean node returns.
133
+ blockingReasons: ['fast_forward_safety_gate_error'],
134
+ operationError: errorMessage,
135
+ error: errorMessage,
136
+ } as CommandRouterResult;
91
137
  }
92
- const result = await (fastForwardMeshNode({
93
- meshId: meshId || undefined,
94
- nodeId: nodeId || undefined,
95
- workspace,
96
- branch: typeof args?.branch === 'string' ? args.branch : undefined,
97
- execute: args?.execute === true,
98
- dryRun: args?.dryRun === true,
99
- updateSubmodules: args?.updateSubmodules === true,
100
- submoduleIgnorePaths,
101
- mode: args?.mode === 'push' ? 'push' : 'merge',
102
- pushSubmodules: args?.pushSubmodules === true,
103
- allowAutoPublishSubmoduleMainCommits,
104
- }) as Promise<unknown>);
105
- return result as CommandRouterResult;
106
138
  },
107
139
 
108
140
  refine_mesh_node: async (ctx: MedFamilyContext, args: any) => {
@@ -289,9 +289,19 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
289
289
  const nodeMachineId = readMeshNodeMachineId(node as Record<string, unknown>) || '';
290
290
  const selfDaemonId = ctx.deps.statusInstanceId || '';
291
291
  const selfMachineId = (() => { try { return loadConfig().machineId || ''; } catch { return ''; } })();
292
+ // Identity match is form-safe: a daemon answers to the same machine
293
+ // under interchangeable id forms (bare `mach_X`, cloud `daemon_mach_X`,
294
+ // standalone `standalone_mach_X`). statusInstanceId/loadConfig().machineId
295
+ // and the node's stored daemonId/machineId frequently hold DIFFERENT forms
296
+ // of the same machine, so a raw `===` would miss the self-match and let the
297
+ // coordinator delete its own live base node (the very accident this guard
298
+ // exists to prevent). daemonIdsEquivalent collapses every form to its
299
+ // machine core before comparing, so a same-machine match is caught
300
+ // regardless of which form each side carries. This only widens matches
301
+ // (every raw-`===` hit still matches) — fail-open → fail-closed.
292
302
  const isCoordinatorBaseNode =
293
- (!!selfDaemonId && (nodeDaemonId === selfDaemonId || nodeMachineId === selfDaemonId))
294
- || (!!selfMachineId && (nodeDaemonId === selfMachineId || nodeMachineId === selfMachineId));
303
+ (!!selfDaemonId && (daemonIdsEquivalent(nodeDaemonId, selfDaemonId) || daemonIdsEquivalent(nodeMachineId, selfDaemonId)))
304
+ || (!!selfMachineId && (daemonIdsEquivalent(nodeDaemonId, selfMachineId) || daemonIdsEquivalent(nodeMachineId, selfMachineId)));
295
305
  if (isCoordinatorBaseNode) {
296
306
  return {
297
307
  success: false,