@adhdev/daemon-core 0.9.82-rc.4 → 0.9.82-rc.40
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/boot/daemon-lifecycle.d.ts +2 -0
- package/dist/commands/router.d.ts +11 -0
- package/dist/git/git-commands.d.ts +1 -0
- package/dist/git/git-status.d.ts +5 -0
- package/dist/git/git-types.d.ts +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1363 -311
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1362 -311
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +17 -5
- package/dist/mesh/mesh-work-queue.d.ts +3 -1
- package/dist/providers/chat-message-normalization.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +128 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +3 -0
- package/src/commands/router.ts +1200 -169
- package/src/git/git-commands.ts +3 -3
- package/src/git/git-status.ts +97 -6
- package/src/git/git-summary.ts +3 -0
- package/src/git/git-types.ts +11 -0
- package/src/index.ts +9 -1
- package/src/mesh/mesh-events.ts +168 -30
- package/src/mesh/mesh-work-queue.ts +135 -119
- package/src/providers/chat-message-normalization.ts +3 -1
- package/src/repo-mesh-types.ts +138 -0
package/src/git/git-commands.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface GitPushResult extends GitRepoIdentity {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export interface GitCommandServices {
|
|
65
|
-
getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
65
|
+
getStatus?: (params: { workspace: string; refreshUpstream?: boolean }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
66
66
|
getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
|
|
67
67
|
getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
|
|
68
68
|
createSnapshot?: (params: {
|
|
@@ -171,7 +171,7 @@ const defaultSnapshotStore = createGitSnapshotStore({
|
|
|
171
171
|
|
|
172
172
|
export function createDefaultGitCommandServices(): GitCommandServices {
|
|
173
173
|
return {
|
|
174
|
-
getStatus: ({ workspace }) => getGitRepoStatus(workspace),
|
|
174
|
+
getStatus: ({ workspace, refreshUpstream }) => getGitRepoStatus(workspace, { refreshUpstream }),
|
|
175
175
|
getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
|
|
176
176
|
getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
|
|
177
177
|
createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
|
|
@@ -290,7 +290,7 @@ export async function handleGitCommand(
|
|
|
290
290
|
switch (command) {
|
|
291
291
|
case 'git_status': {
|
|
292
292
|
if (!services.getStatus) return serviceNotImplemented(command);
|
|
293
|
-
const status = await runService(() => services.getStatus!({ workspace }));
|
|
293
|
+
const status = await runService(() => services.getStatus!({ workspace, refreshUpstream: optionalBoolean(args?.refreshUpstream) }));
|
|
294
294
|
return 'success' in status ? status : { success: true, status };
|
|
295
295
|
}
|
|
296
296
|
|
package/src/git/git-status.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import type { GitRepoStatus, GitSubmoduleStatus } from './git-types.js';
|
|
1
|
+
import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
|
|
2
2
|
import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
|
|
3
3
|
|
|
4
|
+
type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
|
|
5
|
+
|
|
4
6
|
export interface GitStatusOptions {
|
|
5
7
|
timeoutMs?: number;
|
|
6
8
|
/** When true, include submodule status in the result. Defaults to true. */
|
|
7
9
|
includeSubmodules?: boolean;
|
|
8
10
|
/** Optional filter to exclude specific submodule paths from status */
|
|
9
11
|
submoduleIgnorePaths?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* When true, refresh the tracked remote before trusting ahead/behind.
|
|
14
|
+
* Callers should opt into this only for convergence-critical surfaces.
|
|
15
|
+
*/
|
|
16
|
+
refreshUpstream?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GitUpstreamProbe {
|
|
20
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
21
|
+
upstreamFetchedAt?: number;
|
|
22
|
+
upstreamFetchError?: string;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
25
|
export async function getGitRepoStatus(
|
|
@@ -18,8 +31,16 @@ export async function getGitRepoStatus(
|
|
|
18
31
|
|
|
19
32
|
try {
|
|
20
33
|
const repo = await resolveGitRepository(workspace, options);
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
let parsed = await readPorcelainStatus(repo, options);
|
|
35
|
+
let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
|
|
36
|
+
|
|
37
|
+
if (options.refreshUpstream) {
|
|
38
|
+
upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
|
|
39
|
+
if (upstreamProbe.upstreamStatus === 'fresh') {
|
|
40
|
+
parsed = await readPorcelainStatus(repo, options);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
const head = await readHead(repo, options);
|
|
24
45
|
const stashCount = await readStashCount(repo, options);
|
|
25
46
|
|
|
@@ -36,6 +57,9 @@ export async function getGitRepoStatus(
|
|
|
36
57
|
headCommit: head.commit,
|
|
37
58
|
headMessage: head.message,
|
|
38
59
|
upstream: parsed.upstream,
|
|
60
|
+
upstreamStatus: parsed.upstream ? upstreamProbe.upstreamStatus : 'no_upstream',
|
|
61
|
+
upstreamFetchedAt: upstreamProbe.upstreamFetchedAt,
|
|
62
|
+
upstreamFetchError: upstreamProbe.upstreamFetchError,
|
|
39
63
|
ahead: parsed.ahead,
|
|
40
64
|
behind: parsed.behind,
|
|
41
65
|
staged: parsed.staged,
|
|
@@ -74,6 +98,72 @@ interface ParsedPorcelainStatus {
|
|
|
74
98
|
conflictFiles: string[];
|
|
75
99
|
}
|
|
76
100
|
|
|
101
|
+
async function readPorcelainStatus(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<ParsedPorcelainStatus> {
|
|
102
|
+
const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
|
|
103
|
+
return parsePorcelainV2Status(statusOutput.stdout);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getInitialUpstreamProbe(parsed: ParsedPorcelainStatus): GitUpstreamProbe {
|
|
107
|
+
return {
|
|
108
|
+
upstreamStatus: parsed.upstream ? 'unchecked' : 'no_upstream',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function refreshTrackedUpstream(
|
|
113
|
+
repo: ResolvedGitRepo,
|
|
114
|
+
parsed: ParsedPorcelainStatus,
|
|
115
|
+
options: GitStatusOptions,
|
|
116
|
+
): Promise<GitUpstreamProbe> {
|
|
117
|
+
if (!parsed.upstream || !parsed.branch) {
|
|
118
|
+
return { upstreamStatus: 'no_upstream' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
|
|
122
|
+
if (!remoteName) {
|
|
123
|
+
return {
|
|
124
|
+
upstreamStatus: 'stale',
|
|
125
|
+
upstreamFetchError: `Unable to resolve remote for upstream '${parsed.upstream}'`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
|
|
131
|
+
return {
|
|
132
|
+
upstreamStatus: 'fresh',
|
|
133
|
+
upstreamFetchedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
upstreamStatus: 'stale',
|
|
138
|
+
upstreamFetchError: formatGitError(error),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readBranchRemote(repo: ResolvedGitRepo, branch: string, options: GitStatusOptions): Promise<string | null> {
|
|
144
|
+
try {
|
|
145
|
+
const result = await runGit(repo, ['config', '--get', `branch.${branch}.remote`], options);
|
|
146
|
+
return result.stdout.trim() || null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function inferRemoteName(upstream: string): string | null {
|
|
153
|
+
const [remoteName] = upstream.split('/');
|
|
154
|
+
return remoteName?.trim() || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatGitError(error: unknown): string {
|
|
158
|
+
if (error instanceof GitCommandError) {
|
|
159
|
+
return error.stderr || error.message;
|
|
160
|
+
}
|
|
161
|
+
if (error instanceof Error) {
|
|
162
|
+
return error.message;
|
|
163
|
+
}
|
|
164
|
+
return String(error);
|
|
165
|
+
}
|
|
166
|
+
|
|
77
167
|
export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
78
168
|
const parsed: ParsedPorcelainStatus = {
|
|
79
169
|
branch: null,
|
|
@@ -145,7 +235,7 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
async function readHead(
|
|
148
|
-
repo:
|
|
238
|
+
repo: ResolvedGitRepo,
|
|
149
239
|
options: GitStatusOptions,
|
|
150
240
|
): Promise<{ commit: string | null; message: string | null }> {
|
|
151
241
|
try {
|
|
@@ -163,7 +253,7 @@ async function readHead(
|
|
|
163
253
|
}
|
|
164
254
|
|
|
165
255
|
async function readStashCount(
|
|
166
|
-
repo:
|
|
256
|
+
repo: ResolvedGitRepo,
|
|
167
257
|
options: GitStatusOptions,
|
|
168
258
|
): Promise<number> {
|
|
169
259
|
try {
|
|
@@ -187,6 +277,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
|
|
|
187
277
|
headCommit: null,
|
|
188
278
|
headMessage: null,
|
|
189
279
|
upstream: null,
|
|
280
|
+
upstreamStatus: 'unavailable',
|
|
190
281
|
ahead: 0,
|
|
191
282
|
behind: 0,
|
|
192
283
|
staged: 0,
|
|
@@ -206,7 +297,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
|
|
|
206
297
|
// ─── Submodule Status ───────────────────────────
|
|
207
298
|
|
|
208
299
|
async function getSubmoduleStatuses(
|
|
209
|
-
repo:
|
|
300
|
+
repo: ResolvedGitRepo,
|
|
210
301
|
options: GitStatusOptions,
|
|
211
302
|
): Promise<GitSubmoduleStatus[]> {
|
|
212
303
|
if (!repo.repoRoot) return [];
|
package/src/git/git-summary.ts
CHANGED
|
@@ -22,6 +22,9 @@ export function createGitCompactSummary(status: GitRepoStatus, diffSummary?: Git
|
|
|
22
22
|
isGitRepo: status.isGitRepo,
|
|
23
23
|
repoRoot: status.repoRoot,
|
|
24
24
|
branch: status.branch,
|
|
25
|
+
upstreamStatus: status.upstreamStatus,
|
|
26
|
+
upstreamFetchedAt: status.upstreamFetchedAt,
|
|
27
|
+
upstreamFetchError: status.upstreamFetchError,
|
|
25
28
|
dirty:
|
|
26
29
|
status.staged > 0 ||
|
|
27
30
|
status.modified > 0 ||
|
package/src/git/git-types.ts
CHANGED
|
@@ -40,11 +40,19 @@ export interface GitSubmoduleStatus {
|
|
|
40
40
|
error?: string;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type GitUpstreamFreshness = 'fresh' | 'unchecked' | 'stale' | 'no_upstream' | 'unavailable';
|
|
44
|
+
|
|
43
45
|
export interface GitRepoStatus extends GitRepoIdentity {
|
|
44
46
|
branch: string | null;
|
|
45
47
|
headCommit: string | null;
|
|
46
48
|
headMessage: string | null;
|
|
47
49
|
upstream: string | null;
|
|
50
|
+
/** Whether ahead/behind was verified against a freshly fetched upstream ref. */
|
|
51
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
52
|
+
/** Timestamp for the fetch that refreshed upstream refs when upstreamStatus === 'fresh'. */
|
|
53
|
+
upstreamFetchedAt?: number;
|
|
54
|
+
/** Error from the last refresh attempt when upstreamStatus === 'stale'. */
|
|
55
|
+
upstreamFetchError?: string;
|
|
48
56
|
ahead: number;
|
|
49
57
|
behind: number;
|
|
50
58
|
staged: number;
|
|
@@ -134,6 +142,9 @@ export interface GitCompactSummary {
|
|
|
134
142
|
isGitRepo: boolean;
|
|
135
143
|
repoRoot: string | null;
|
|
136
144
|
branch: string | null;
|
|
145
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
146
|
+
upstreamFetchedAt?: number;
|
|
147
|
+
upstreamFetchError?: string;
|
|
137
148
|
dirty: boolean;
|
|
138
149
|
changedFiles: number;
|
|
139
150
|
ahead: number;
|
package/src/index.ts
CHANGED
|
@@ -103,6 +103,14 @@ export type {
|
|
|
103
103
|
LocalMeshNodeEntry,
|
|
104
104
|
RepoMeshStatus,
|
|
105
105
|
RepoMeshNodeStatus,
|
|
106
|
+
RepoMeshSessionStatus,
|
|
107
|
+
RepoMeshQueueTask,
|
|
108
|
+
RepoMeshQueueTaskStatus,
|
|
109
|
+
RepoMeshQueueSummary,
|
|
110
|
+
RepoMeshQueueStatus,
|
|
111
|
+
RepoMeshLedgerEntryStatus,
|
|
112
|
+
RepoMeshLedgerSummaryStatus,
|
|
113
|
+
RepoMeshLedgerStatus,
|
|
106
114
|
} from './repo-mesh-types.js';
|
|
107
115
|
export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
|
|
108
116
|
|
|
@@ -168,7 +176,7 @@ export type { MeshWorkQueueEntry, MeshTaskStatus, MeshWorkQueueStats } from './m
|
|
|
168
176
|
// export type { MeshGraph, MeshGraphNode, MeshGraphEdge, MeshGraphNodeType, MeshGraphEdgeType } from './mesh/mesh-visualization.js';
|
|
169
177
|
|
|
170
178
|
// ── Mesh Events ──
|
|
171
|
-
export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents } from './mesh/mesh-events.js';
|
|
179
|
+
export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
|
|
172
180
|
export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
|
|
173
181
|
|
|
174
182
|
// ── Mesh P2P Relay Failure Classification ──
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -1,57 +1,106 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
4
|
import { loadConfig } from '../config/config.js';
|
|
3
5
|
import { getMesh, getMeshByRepo } from '../config/mesh-config.js';
|
|
4
6
|
import { detectCLI } from '../detection/cli-detector.js';
|
|
5
7
|
import { LOG } from '../logging/logger.js';
|
|
6
|
-
import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
|
|
8
|
+
import { appendLedgerEntry, buildTaskCompletionEvidence, getLedgerDir, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
|
|
7
9
|
import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
|
|
8
10
|
import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch } from './mesh-work-queue.js';
|
|
9
11
|
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Remote Node Idle Session Tracking
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
|
-
// Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
|
|
14
|
-
// can assign tasks to them.
|
|
15
|
+
// Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
|
|
16
|
+
// can assign tasks to them. Each entry carries an expiresAt timestamp;
|
|
17
|
+
// entries are swept on insertion to prevent unbounded growth.
|
|
15
18
|
// ---------------------------------------------------------------------------
|
|
16
19
|
interface RemoteIdleSession {
|
|
17
20
|
nodeId: string;
|
|
18
21
|
sessionId: string;
|
|
19
22
|
providerType: string;
|
|
23
|
+
expiresAt: number;
|
|
20
24
|
}
|
|
25
|
+
const REMOTE_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
21
26
|
const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${nodeId}:${sessionId}`
|
|
22
27
|
|
|
28
|
+
function sweepExpiredRemoteIdleSessions(): void {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [key, session] of remoteIdleSessions) {
|
|
31
|
+
if (session.expiresAt <= now) remoteIdleSessions.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
// ---------------------------------------------------------------------------
|
|
24
|
-
// MCP coordinator pending-event queue
|
|
36
|
+
// MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
|
|
25
37
|
// ---------------------------------------------------------------------------
|
|
26
38
|
// When a mesh event fires but no CLI coordinator session is registered (e.g.
|
|
27
|
-
// the coordinator is Claude Code running via MCP), we
|
|
28
|
-
//
|
|
39
|
+
// the coordinator is Claude Code running via MCP), we persist the event to a
|
|
40
|
+
// per-mesh JSONL file so it survives daemon restarts. The 50-entry hard cap
|
|
41
|
+
// is removed; the file is drained atomically on each get_pending_mesh_events
|
|
42
|
+
// call and limited to 100 KB to prevent runaway growth.
|
|
43
|
+
//
|
|
44
|
+
// File: <ledgerDir>/<meshId>.pending-events.jsonl
|
|
29
45
|
// ---------------------------------------------------------------------------
|
|
30
46
|
|
|
31
47
|
export interface PendingMeshCoordinatorEvent {
|
|
32
48
|
event: string;
|
|
33
49
|
meshId: string;
|
|
34
50
|
nodeLabel: string;
|
|
51
|
+
nodeId?: string;
|
|
52
|
+
workspace?: string;
|
|
35
53
|
metadataEvent: Record<string, unknown>;
|
|
36
54
|
queuedAt: number;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
const
|
|
57
|
+
function getPendingEventsPath(meshId: string): string {
|
|
58
|
+
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
59
|
+
return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
|
|
63
|
+
try {
|
|
64
|
+
appendFileSync(getPendingEventsPath(event.meshId), JSON.stringify(event) + '\n', 'utf-8');
|
|
65
|
+
return true;
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
LOG.warn('MeshEvents', `Failed to persist pending coordinator event: ${e?.message || e}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
41
71
|
|
|
42
|
-
/** Drain and return all pending coordinator events,
|
|
43
|
-
export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
|
|
44
|
-
|
|
72
|
+
/** Drain and return all pending coordinator events for meshId, removing them from disk. */
|
|
73
|
+
export function drainPendingMeshCoordinatorEvents(meshId?: string): PendingMeshCoordinatorEvent[] {
|
|
74
|
+
if (!meshId) return [];
|
|
75
|
+
const path = getPendingEventsPath(meshId);
|
|
76
|
+
if (!existsSync(path)) return [];
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(path, 'utf-8');
|
|
79
|
+
try { unlinkSync(path); } catch { /* concurrent drain already removed it */ }
|
|
80
|
+
return raw.split('\n').filter(Boolean).flatMap(line => {
|
|
81
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
82
|
+
});
|
|
83
|
+
} catch { return []; }
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
48
|
-
export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
|
|
49
|
-
|
|
87
|
+
export function getPendingMeshCoordinatorEvents(meshId?: string): readonly PendingMeshCoordinatorEvent[] {
|
|
88
|
+
if (!meshId) return [];
|
|
89
|
+
const path = getPendingEventsPath(meshId);
|
|
90
|
+
if (!existsSync(path)) return [];
|
|
91
|
+
try {
|
|
92
|
+
const raw = readFileSync(path, 'utf-8');
|
|
93
|
+
return raw.split('\n').filter(Boolean).flatMap(line => {
|
|
94
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
95
|
+
});
|
|
96
|
+
} catch { return []; }
|
|
50
97
|
}
|
|
51
98
|
|
|
52
|
-
/** Explicitly clear all pending coordinator events. */
|
|
53
|
-
export function clearPendingMeshCoordinatorEvents(): void {
|
|
54
|
-
|
|
99
|
+
/** Explicitly clear all pending coordinator events for a mesh. */
|
|
100
|
+
export function clearPendingMeshCoordinatorEvents(meshId?: string): void {
|
|
101
|
+
if (!meshId) return;
|
|
102
|
+
const path = getPendingEventsPath(meshId);
|
|
103
|
+
if (existsSync(path)) try { unlinkSync(path); } catch { /* already removed */ }
|
|
55
104
|
}
|
|
56
105
|
|
|
57
106
|
function readNonEmptyString(value: unknown): string {
|
|
@@ -140,6 +189,62 @@ function shouldSuppressIntentionalCleanupStop(args: {
|
|
|
140
189
|
return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
|
|
141
190
|
}
|
|
142
191
|
|
|
192
|
+
const RECENT_COMPLETION_FINGERPRINT_TTL_MS = 10 * 60 * 1000;
|
|
193
|
+
const recentCompletionFingerprints = new Map<string, number>();
|
|
194
|
+
|
|
195
|
+
function readEventTimestamp(value: unknown): number | null {
|
|
196
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
197
|
+
if (typeof value === 'string' && value.trim()) {
|
|
198
|
+
const numeric = Number(value);
|
|
199
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
200
|
+
const parsed = Date.parse(value);
|
|
201
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildMeshCompletionFingerprint(args: {
|
|
207
|
+
meshId: string;
|
|
208
|
+
event: string;
|
|
209
|
+
sessionId: string;
|
|
210
|
+
providerType?: string;
|
|
211
|
+
providerSessionId?: string;
|
|
212
|
+
timestamp?: number | null;
|
|
213
|
+
finalSummary?: string;
|
|
214
|
+
}): string {
|
|
215
|
+
const timestampPart = Number.isFinite(args.timestamp)
|
|
216
|
+
? String(args.timestamp)
|
|
217
|
+
: readNonEmptyString(args.finalSummary).slice(0, 200);
|
|
218
|
+
return [
|
|
219
|
+
args.meshId,
|
|
220
|
+
args.event,
|
|
221
|
+
args.sessionId,
|
|
222
|
+
args.providerType || '',
|
|
223
|
+
args.providerSessionId || '',
|
|
224
|
+
timestampPart,
|
|
225
|
+
].join('::');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isDuplicateMeshCompletionEvent(args: {
|
|
229
|
+
meshId: string;
|
|
230
|
+
event: string;
|
|
231
|
+
sessionId: string;
|
|
232
|
+
providerType?: string;
|
|
233
|
+
providerSessionId?: string;
|
|
234
|
+
timestamp?: number | null;
|
|
235
|
+
finalSummary?: string;
|
|
236
|
+
}): boolean {
|
|
237
|
+
const fingerprint = buildMeshCompletionFingerprint(args);
|
|
238
|
+
if (!fingerprint) return false;
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [key, seenAt] of recentCompletionFingerprints.entries()) {
|
|
241
|
+
if (now - seenAt > RECENT_COMPLETION_FINGERPRINT_TTL_MS) recentCompletionFingerprints.delete(key);
|
|
242
|
+
}
|
|
243
|
+
if (recentCompletionFingerprints.has(fingerprint)) return true;
|
|
244
|
+
recentCompletionFingerprints.set(fingerprint, now);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
143
248
|
|
|
144
249
|
export function tryAssignQueueTask(
|
|
145
250
|
components: DaemonComponents,
|
|
@@ -170,7 +275,16 @@ export function tryAssignQueueTask(
|
|
|
170
275
|
message: task.message,
|
|
171
276
|
}).catch((e: any) => {
|
|
172
277
|
LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
|
|
173
|
-
|
|
278
|
+
// Revert to pending so the task can be retried rather than permanently failing
|
|
279
|
+
updateTaskStatus(meshId, task.id, 'pending');
|
|
280
|
+
try {
|
|
281
|
+
appendLedgerEntry(meshId, {
|
|
282
|
+
kind: 'dispatch_failed' as any,
|
|
283
|
+
nodeId,
|
|
284
|
+
sessionId,
|
|
285
|
+
payload: { taskId: task.id, error: e?.message, retryable: true },
|
|
286
|
+
});
|
|
287
|
+
} catch { /* ledger write is best-effort */ }
|
|
174
288
|
});
|
|
175
289
|
return true;
|
|
176
290
|
}
|
|
@@ -593,6 +707,23 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
593
707
|
return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
|
|
594
708
|
}
|
|
595
709
|
|
|
710
|
+
const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
|
|
711
|
+
if (args.event === 'agent:generating_completed' && eventSessionId) {
|
|
712
|
+
const duplicateCompletion = isDuplicateMeshCompletionEvent({
|
|
713
|
+
meshId: args.meshId,
|
|
714
|
+
event: args.event,
|
|
715
|
+
sessionId: eventSessionId,
|
|
716
|
+
providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
|
|
717
|
+
providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
|
|
718
|
+
timestamp: eventTimestamp,
|
|
719
|
+
finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
|
|
720
|
+
});
|
|
721
|
+
if (duplicateCompletion) {
|
|
722
|
+
LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
|
|
723
|
+
return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
596
727
|
// ── Task Queue & Ledger ──
|
|
597
728
|
let completedTaskForLedger: { id?: string } | null = null;
|
|
598
729
|
if (args.event === 'agent:generating_completed') {
|
|
@@ -601,13 +732,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
601
732
|
const providerType = readNonEmptyString(args.metadataEvent.providerType);
|
|
602
733
|
|
|
603
734
|
if (sessionId) {
|
|
604
|
-
const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed'
|
|
735
|
+
const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed', {
|
|
736
|
+
occurredAt: eventTimestamp !== null ? new Date(eventTimestamp).toISOString() : undefined,
|
|
737
|
+
});
|
|
605
738
|
completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
|
|
606
739
|
if (nodeId && providerType) {
|
|
607
|
-
//
|
|
608
|
-
|
|
740
|
+
// Queue state is already updated above; setImmediate avoids the
|
|
741
|
+
// 500 ms artificial delay while still deferring past this call frame.
|
|
742
|
+
setImmediate(() => {
|
|
609
743
|
tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
610
|
-
}
|
|
744
|
+
});
|
|
611
745
|
}
|
|
612
746
|
}
|
|
613
747
|
} else if (args.event === 'agent:ready') {
|
|
@@ -648,13 +782,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
648
782
|
}
|
|
649
783
|
|
|
650
784
|
if (sessionId && nodeId && providerType) {
|
|
651
|
-
|
|
652
|
-
|
|
785
|
+
sweepExpiredRemoteIdleSessions();
|
|
786
|
+
remoteIdleSessions.set(`${nodeId}:${sessionId}`, {
|
|
787
|
+
nodeId, sessionId, providerType,
|
|
788
|
+
expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
|
|
789
|
+
});
|
|
790
|
+
setImmediate(() => {
|
|
653
791
|
const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
654
|
-
if (assigned) {
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
}, 500);
|
|
792
|
+
if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
|
|
793
|
+
});
|
|
658
794
|
}
|
|
659
795
|
} else if (args.event === 'agent:generating_started') {
|
|
660
796
|
const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
|
|
@@ -781,17 +917,18 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
781
917
|
|
|
782
918
|
if (coordinatorInstances.length === 0) {
|
|
783
919
|
// No CLI coordinator session found — buffer for MCP-based coordinators.
|
|
784
|
-
if (
|
|
785
|
-
pendingMeshCoordinatorEvents.push({
|
|
920
|
+
if (queuePendingMeshCoordinatorEvent({
|
|
786
921
|
event: args.event,
|
|
787
922
|
meshId: args.meshId,
|
|
788
923
|
nodeLabel: args.nodeLabel,
|
|
924
|
+
nodeId: args.nodeId || undefined,
|
|
925
|
+
workspace: readNonEmptyString(args.metadataEvent.workspace),
|
|
789
926
|
metadataEvent: {
|
|
790
927
|
...args.metadataEvent,
|
|
791
928
|
...(recoveryContext ? { recoveryContext } : {}),
|
|
792
929
|
},
|
|
793
930
|
queuedAt: Date.now(),
|
|
794
|
-
})
|
|
931
|
+
})) {
|
|
795
932
|
LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
|
|
796
933
|
}
|
|
797
934
|
return { success: true, forwarded: 0 };
|
|
@@ -834,6 +971,7 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
834
971
|
providerType: readNonEmptyString(payload.providerType),
|
|
835
972
|
providerSessionId: readNonEmptyString(payload.providerSessionId),
|
|
836
973
|
finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
|
|
974
|
+
...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
|
|
837
975
|
intentional: payload.intentional === true,
|
|
838
976
|
intentionalStop: payload.intentionalStop === true,
|
|
839
977
|
operatorCleanup: payload.operatorCleanup === true,
|