@adhdev/daemon-core 0.9.82-rc.374 → 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 +309 -57
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +309 -57
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +53 -0
- package/dist/mesh/mesh-ledger.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/mesh/coordinator-prompt.ts +145 -0
- package/src/mesh/mesh-events-coordinator.ts +45 -1
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-reconcile-loop.ts +114 -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:
|
|
@@ -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;
|
|
@@ -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) => {
|
|
@@ -33,6 +33,49 @@ import type {
|
|
|
33
33
|
} from '../repo-mesh-types.js';
|
|
34
34
|
import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Cheap, locally-derived "what just happened" snapshot for the coordinator
|
|
38
|
+
* prompt. Built at launch from the local ledger + work-queue stats — no remote
|
|
39
|
+
* peer probe. Surfaces the gap a fresh coordinator otherwise misses: it can't
|
|
40
|
+
* see recent failures / queue depth until it manually calls mesh_task_history.
|
|
41
|
+
*
|
|
42
|
+
* All fields are optional so callers that have nothing to report (or fail to
|
|
43
|
+
* read the ledger) simply omit the section — the prompt output stays identical
|
|
44
|
+
* to the pre-activity form in that case.
|
|
45
|
+
*/
|
|
46
|
+
export interface CoordinatorRecentActivity {
|
|
47
|
+
/** task_failed entries from the recent window, newest last. */
|
|
48
|
+
recentFailures?: Array<{
|
|
49
|
+
timestamp?: string;
|
|
50
|
+
nodeId?: string;
|
|
51
|
+
/** Short task title/message, already truncated by the caller. */
|
|
52
|
+
summary?: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** Count of task_failed entries inside the recent (30-min) window. */
|
|
55
|
+
recentFailureCount?: number;
|
|
56
|
+
/** Pending (unclaimed) tasks in the work queue. */
|
|
57
|
+
pendingTasks?: number;
|
|
58
|
+
/** Assigned-but-not-yet-terminal tasks in the work queue. */
|
|
59
|
+
assignedTasks?: number;
|
|
60
|
+
/** Stalled tasks recorded in the ledger. */
|
|
61
|
+
stalledTasks?: number;
|
|
62
|
+
/** ISO timestamp of the most recent ledger activity, if any. */
|
|
63
|
+
lastActivityAt?: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One coordinator operating note — a runtime-accumulated lesson (provider
|
|
68
|
+
* quirk, pattern to avoid, recovery lesson) persisted in the ledger so it
|
|
69
|
+
* survives coordinator restarts and is provider-neutral (visible to codex /
|
|
70
|
+
* hermes / antigravity coordinators, not just Claude's memory).
|
|
71
|
+
*/
|
|
72
|
+
export interface CoordinatorOperatingNote {
|
|
73
|
+
text: string;
|
|
74
|
+
category?: 'provider_quirk' | 'pattern_to_avoid' | 'recovery_lesson';
|
|
75
|
+
createdAt?: string;
|
|
76
|
+
sourceCoordinator?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
// ─── Prompt Builder ─────────────────────────────
|
|
37
80
|
|
|
38
81
|
export interface CoordinatorPromptContext {
|
|
@@ -46,6 +89,18 @@ export interface CoordinatorPromptContext {
|
|
|
46
89
|
* stays identical to the pre-M3 form in that case.
|
|
47
90
|
*/
|
|
48
91
|
missionSection?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Gap1: recent ledger/queue activity surfaced so a freshly-launched
|
|
94
|
+
* coordinator sees recent failures + queue depth without first calling
|
|
95
|
+
* mesh_task_history. Omitted → no "## Recent Activity" section.
|
|
96
|
+
*/
|
|
97
|
+
recentActivity?: CoordinatorRecentActivity;
|
|
98
|
+
/**
|
|
99
|
+
* Gap2-A: runtime-accumulated operating notes (provider-neutral lessons)
|
|
100
|
+
* read from the ledger at launch. Omitted/empty → no "## Operating Notes"
|
|
101
|
+
* section.
|
|
102
|
+
*/
|
|
103
|
+
operatingNotes?: CoordinatorOperatingNote[];
|
|
49
104
|
}
|
|
50
105
|
|
|
51
106
|
/**
|
|
@@ -132,6 +187,14 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
132
187
|
sections.push(ctx.missionSection.trim());
|
|
133
188
|
}
|
|
134
189
|
|
|
190
|
+
// ── Recent Activity (Gap1) — only present when there's something to show ──
|
|
191
|
+
const recentActivity = buildRecentActivitySection(ctx.recentActivity);
|
|
192
|
+
if (recentActivity) sections.push(recentActivity);
|
|
193
|
+
|
|
194
|
+
// ── Operating Notes (Gap2-A) — only present when notes exist ──
|
|
195
|
+
const operatingNotes = buildOperatingNotesSection(ctx.operatingNotes);
|
|
196
|
+
if (operatingNotes) sections.push(operatingNotes);
|
|
197
|
+
|
|
135
198
|
// ── Policy ──
|
|
136
199
|
sections.push(buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }));
|
|
137
200
|
|
|
@@ -187,6 +250,8 @@ function readUserPromptFile(cliType: string | undefined, suffix: string): string
|
|
|
187
250
|
* {{cliType}} — coordinator CLI type or empty
|
|
188
251
|
* {{nodes}} — full node section (status if known, otherwise config)
|
|
189
252
|
* {{mission}} — active mission summary section (empty when none)
|
|
253
|
+
* {{recentActivity}} — recent failures + queue depth section (empty when none)
|
|
254
|
+
* {{operatingNotes}} — accumulated operating notes section (empty when none)
|
|
190
255
|
* {{policy}} — full policy section
|
|
191
256
|
* {{tools}} — the canonical tools table
|
|
192
257
|
* {{workflow}} — the canonical orchestration workflow
|
|
@@ -211,6 +276,8 @@ function expandPromptPlaceholders(template: string, ctx: CoordinatorPromptContex
|
|
|
211
276
|
cliType: coordinatorCliType || '',
|
|
212
277
|
nodes: nodesSection,
|
|
213
278
|
mission: ctx.missionSection?.trim() || '',
|
|
279
|
+
recentActivity: buildRecentActivitySection(ctx.recentActivity) || '',
|
|
280
|
+
operatingNotes: buildOperatingNotesSection(ctx.operatingNotes) || '',
|
|
214
281
|
policy: buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }),
|
|
215
282
|
tools: TOOLS_SECTION,
|
|
216
283
|
workflow: WORKFLOW_SECTION,
|
|
@@ -303,6 +370,83 @@ function indentFollowing(text: string, pad: string): string {
|
|
|
303
370
|
return [lines[0], ...lines.slice(1).map(l => pad + l)].join('\n');
|
|
304
371
|
}
|
|
305
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Gap1 — render the "## Recent Activity" section from the locally-derived
|
|
375
|
+
* activity snapshot. Returns '' (no section) when there's nothing worth
|
|
376
|
+
* surfacing: no recent failures, no queued work, no stalls. This keeps a quiet
|
|
377
|
+
* mesh's prompt identical to the pre-activity form.
|
|
378
|
+
*/
|
|
379
|
+
function buildRecentActivitySection(activity?: CoordinatorRecentActivity): string {
|
|
380
|
+
if (!activity) return '';
|
|
381
|
+
const failures = Array.isArray(activity.recentFailures) ? activity.recentFailures : [];
|
|
382
|
+
const pending = Number.isFinite(activity.pendingTasks) ? Number(activity.pendingTasks) : 0;
|
|
383
|
+
const assigned = Number.isFinite(activity.assignedTasks) ? Number(activity.assignedTasks) : 0;
|
|
384
|
+
const stalled = Number.isFinite(activity.stalledTasks) ? Number(activity.stalledTasks) : 0;
|
|
385
|
+
const recentFailureCount = Number.isFinite(activity.recentFailureCount)
|
|
386
|
+
? Number(activity.recentFailureCount)
|
|
387
|
+
: failures.length;
|
|
388
|
+
|
|
389
|
+
// Nothing actionable to show → omit the section entirely.
|
|
390
|
+
if (failures.length === 0 && pending === 0 && assigned === 0 && stalled === 0 && recentFailureCount === 0) {
|
|
391
|
+
return '';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const lines: string[] = ['## Recent Activity', ''];
|
|
395
|
+
lines.push('A snapshot of this mesh\'s recent ledger/queue state at launch. Use it to decide what needs attention first; call `mesh_task_history` / `mesh_view_queue` for full detail.');
|
|
396
|
+
lines.push('');
|
|
397
|
+
|
|
398
|
+
const counts: string[] = [];
|
|
399
|
+
if (pending > 0) counts.push(`**${pending}** pending`);
|
|
400
|
+
if (assigned > 0) counts.push(`**${assigned}** assigned`);
|
|
401
|
+
if (stalled > 0) counts.push(`**${stalled}** stalled`);
|
|
402
|
+
if (recentFailureCount > 0) counts.push(`**${recentFailureCount}** failed in the last 30 min`);
|
|
403
|
+
if (counts.length) lines.push(`- Queue/ledger: ${counts.join(', ')}.`);
|
|
404
|
+
if (activity.lastActivityAt) lines.push(`- Last ledger activity: ${activity.lastActivityAt}.`);
|
|
405
|
+
|
|
406
|
+
if (failures.length > 0) {
|
|
407
|
+
// Newest first, capped to the 5 most recent so the prompt stays lean.
|
|
408
|
+
const recent = failures.slice(-5).reverse();
|
|
409
|
+
lines.push('', 'Recent failures (newest first):');
|
|
410
|
+
for (const f of recent) {
|
|
411
|
+
const when = f.timestamp ? `${f.timestamp} ` : '';
|
|
412
|
+
const node = f.nodeId ? `node \`${f.nodeId}\`` : 'unknown node';
|
|
413
|
+
const summary = (f.summary || '').trim();
|
|
414
|
+
lines.push(`- ${when}${node}${summary ? ` — ${summary}` : ''}`);
|
|
415
|
+
}
|
|
416
|
+
lines.push('', '_Check `mesh_task_history` before retrying; repeated failures on the same node mean reassign or escalate, not retry._');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return lines.join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Gap2-A — render the "## Operating Notes" section from accumulated coordinator
|
|
424
|
+
* notes. Returns '' when there are none, so a mesh that has never recorded a
|
|
425
|
+
* note gets the unchanged prompt. Notes are runtime-accumulated lessons that
|
|
426
|
+
* persist across coordinator restarts and are provider-neutral.
|
|
427
|
+
*/
|
|
428
|
+
function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[]): string {
|
|
429
|
+
const valid = Array.isArray(notes)
|
|
430
|
+
? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
|
|
431
|
+
: [];
|
|
432
|
+
if (valid.length === 0) return '';
|
|
433
|
+
|
|
434
|
+
const categoryLabel: Record<string, string> = {
|
|
435
|
+
provider_quirk: 'provider quirk',
|
|
436
|
+
pattern_to_avoid: 'pattern to avoid',
|
|
437
|
+
recovery_lesson: 'recovery lesson',
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const lines: string[] = ['## Operating Notes', ''];
|
|
441
|
+
lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
|
|
442
|
+
lines.push('');
|
|
443
|
+
for (const n of valid) {
|
|
444
|
+
const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
|
|
445
|
+
lines.push(`- ${cat}${n.text.trim()}`);
|
|
446
|
+
}
|
|
447
|
+
return lines.join('\n');
|
|
448
|
+
}
|
|
449
|
+
|
|
306
450
|
function buildPolicySection(policy: RepoMeshPolicy): string {
|
|
307
451
|
const rules: string[] = [];
|
|
308
452
|
if (policy.requirePreTaskCheckpoint) rules.push('- Create a git checkpoint **before** starting each task');
|
|
@@ -340,6 +484,7 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
340
484
|
| \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
|
|
341
485
|
| \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
|
|
342
486
|
| \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
|
|
487
|
+
| \`mesh_record_note\` | Record a durable, provider-neutral operating note (provider quirk / pattern to avoid / recovery lesson). Future coordinators see it under "## Operating Notes" at launch |
|
|
343
488
|
| \`mesh_git_status\` | Check git status on a specific node |
|
|
344
489
|
| \`mesh_read_node_logs\` | Fetch a remote node's daemon log tail directly over P2P (grep/since/byte-bounded, secrets redacted) — no session/PowerShell needed to debug a node's daemon |
|
|
345
490
|
| \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
|