@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.
- package/dist/index.js +372 -66
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +372 -66
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +53 -0
- package/dist/mesh/mesh-events-stale.d.ts +12 -0
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/providers/chat-message-normalization.d.ts +1 -1
- package/dist/providers/cli-provider-instance.d.ts +3 -0
- package/package.json +2 -2
- package/src/commands/high-family/mesh-coordinator-launch.ts +67 -2
- package/src/commands/med-family/cli-agent.ts +25 -13
- package/src/commands/med-family/fast-forward.ts +80 -48
- package/src/commands/med-family/mesh-crud.ts +12 -2
- package/src/mesh/coordinator-prompt.ts +145 -0
- package/src/mesh/mesh-events-coordinator.ts +64 -1
- package/src/mesh/mesh-events-stale.ts +23 -0
- package/src/mesh/mesh-events-utils.ts +1 -1
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-reconcile-loop.ts +145 -10
- package/src/providers/chat-message-normalization.ts +1 -1
- package/src/providers/cli-provider-instance.ts +69 -2
|
@@ -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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
100
|
-
//
|
|
101
|
-
// the dispatch
|
|
102
|
-
// the
|
|
103
|
-
//
|
|
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
|
-
|
|
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:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
294
|
-
|| (!!selfMachineId && (nodeDaemonId
|
|
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,
|