@adhdev/daemon-core 0.9.77-rc.9 → 0.9.77
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 +3 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +2 -0
- package/dist/commands/mesh-coordinator.d.ts +10 -0
- package/dist/commands/router.d.ts +4 -1
- package/dist/config/mesh-config.d.ts +1 -0
- package/dist/git/git-worktree.d.ts +15 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +2116 -299
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2101 -299
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +14 -7
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +55 -0
- package/dist/mesh/mesh-ledger.d.ts +84 -4
- package/dist/mesh/mesh-sync.d.ts +4 -12
- package/dist/mesh/mesh-visualization.d.ts +70 -0
- package/dist/mesh/mesh-work-queue.d.ts +58 -1
- package/dist/mesh/p2p-relay-failure.d.ts +35 -0
- package/dist/providers/chat-message-normalization.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +6 -0
- package/dist/repo-mesh-types.d.ts +2 -0
- package/dist/shared-types.d.ts +38 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +5 -0
- package/src/cli-adapters/provider-cli-adapter.ts +30 -5
- package/src/commands/cli-manager.ts +0 -4
- package/src/commands/mesh-coordinator.ts +55 -7
- package/src/commands/router.ts +964 -26
- package/src/commands/stream-commands.ts +8 -1
- package/src/config/config.ts +2 -1
- package/src/config/mesh-config.ts +2 -0
- package/src/config/workspaces.ts +1 -1
- package/src/git/git-worktree.ts +56 -4
- package/src/index.d.ts +3 -0
- package/src/index.ts +29 -6
- package/src/mesh/coordinator-prompt.ts +21 -10
- package/src/mesh/mesh-events.ts +532 -22
- package/src/mesh/mesh-ledger-reconciliation.ts +115 -0
- package/src/mesh/mesh-ledger.ts +209 -8
- package/src/mesh/mesh-sync.ts +4 -34
- package/src/mesh/mesh-visualization.ts +341 -0
- package/src/mesh/mesh-work-queue.ts +183 -17
- package/src/mesh/p2p-relay-failure.ts +152 -0
- package/src/providers/acp-provider-instance.ts +2 -1
- package/src/providers/chat-message-normalization.ts +32 -0
- package/src/providers/cli-provider-instance.ts +155 -31
- package/src/providers/extension-provider-instance.ts +2 -1
- package/src/providers/ide-provider-instance.ts +2 -2
- package/src/repo-mesh-types.ts +2 -0
- package/src/shared-types.ts +38 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh Visualization — Transform RepoMeshStatus into a graph structure
|
|
3
|
+
* for SVG/Canvas rendering without external dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RepoMeshStatus, RepoMeshNodeStatus, RepoMeshNodeHealth } from '../repo-mesh-types.js';
|
|
7
|
+
import type { GitRepoStatus } from '../git/git-types.js';
|
|
8
|
+
|
|
9
|
+
// ─── Graph Types ─────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type MeshGraphNodeType = 'defaultBranchNode' | 'worktreeNode' | 'orphanNode';
|
|
12
|
+
export type MeshGraphEdgeType = 'parentBranch' | 'worktreeLink' | 'sessionLink';
|
|
13
|
+
|
|
14
|
+
export interface MeshGraphNode {
|
|
15
|
+
id: string;
|
|
16
|
+
type: MeshGraphNodeType;
|
|
17
|
+
label: string;
|
|
18
|
+
workspace: string;
|
|
19
|
+
branch: string | null;
|
|
20
|
+
health: RepoMeshNodeHealth;
|
|
21
|
+
ahead: number;
|
|
22
|
+
behind: number;
|
|
23
|
+
dirty: boolean;
|
|
24
|
+
dirtyFiles: number;
|
|
25
|
+
hasConflicts: boolean;
|
|
26
|
+
activeSessionCount: number;
|
|
27
|
+
activeSessions: string[];
|
|
28
|
+
providers: string[];
|
|
29
|
+
isOrphan: boolean;
|
|
30
|
+
orphanReasons: string[];
|
|
31
|
+
/** Next-step hint from convergence analysis */
|
|
32
|
+
nextStepHint?: string;
|
|
33
|
+
/** Original node status for drill-down */
|
|
34
|
+
source: RepoMeshNodeStatus;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MeshGraphEdge {
|
|
38
|
+
id: string;
|
|
39
|
+
source: string;
|
|
40
|
+
target: string;
|
|
41
|
+
type: MeshGraphEdgeType;
|
|
42
|
+
label?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MeshGraph {
|
|
46
|
+
meshId: string;
|
|
47
|
+
meshName: string;
|
|
48
|
+
repoIdentity: string;
|
|
49
|
+
refreshedAt: string;
|
|
50
|
+
nodes: MeshGraphNode[];
|
|
51
|
+
edges: MeshGraphEdge[];
|
|
52
|
+
/** Summary statistics */
|
|
53
|
+
stats: {
|
|
54
|
+
totalNodes: number;
|
|
55
|
+
onlineNodes: number;
|
|
56
|
+
dirtyNodes: number;
|
|
57
|
+
orphanNodes: number;
|
|
58
|
+
errorNodes: number;
|
|
59
|
+
offlineNodes: number;
|
|
60
|
+
totalActiveSessions: number;
|
|
61
|
+
};
|
|
62
|
+
/** Global orphan / stale warnings */
|
|
63
|
+
warnings: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Helpers ─────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function isDirty(git?: GitRepoStatus): boolean {
|
|
69
|
+
if (!git) return false;
|
|
70
|
+
return (git.staged + git.modified + git.untracked + git.deleted + git.renamed) > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dirtyFileCount(git?: GitRepoStatus): number {
|
|
74
|
+
if (!git) return 0;
|
|
75
|
+
return git.staged + git.modified + git.untracked + git.deleted + git.renamed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function detectOrphanReasons(node: RepoMeshNodeStatus, defaultBranch?: string | null): string[] {
|
|
79
|
+
const reasons: string[] = [];
|
|
80
|
+
const git = node.git;
|
|
81
|
+
|
|
82
|
+
if (!git) {
|
|
83
|
+
reasons.push('No git status available');
|
|
84
|
+
return reasons;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!git.isGitRepo) {
|
|
88
|
+
reasons.push('Not a git repository');
|
|
89
|
+
return reasons;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Detached HEAD
|
|
93
|
+
if (git.branch === null && git.headCommit) {
|
|
94
|
+
reasons.push('Detached HEAD');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// No upstream tracking
|
|
98
|
+
if (git.branch && !git.upstream) {
|
|
99
|
+
// Local-only branch — orphan if not default
|
|
100
|
+
if (defaultBranch && git.branch !== defaultBranch) {
|
|
101
|
+
reasons.push(`No upstream: ${git.branch}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Worktree branch with no upstream
|
|
106
|
+
if (git.branch && git.upstream === null && defaultBranch && git.branch !== defaultBranch) {
|
|
107
|
+
if (!reasons.includes(`No upstream: ${git.branch}`)) {
|
|
108
|
+
reasons.push(`No upstream: ${git.branch}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Stale assigned tasks would be detected at queue level, not per node git status
|
|
113
|
+
// We surface node-level errors as orphan indicators
|
|
114
|
+
if (node.error) {
|
|
115
|
+
reasons.push(`Error: ${node.error}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return reasons;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nodeHealthPriority(health: RepoMeshNodeHealth): number {
|
|
122
|
+
switch (health) {
|
|
123
|
+
case 'online': return 0;
|
|
124
|
+
case 'dirty': return 1;
|
|
125
|
+
case 'degraded': return 2;
|
|
126
|
+
case 'wrong_branch': return 3;
|
|
127
|
+
case 'offline': return 4;
|
|
128
|
+
case 'unknown': return 5;
|
|
129
|
+
default: return 5;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pickDominantHealth(healths: RepoMeshNodeHealth[]): RepoMeshNodeHealth {
|
|
134
|
+
if (healths.length === 0) return 'unknown';
|
|
135
|
+
return healths.reduce((best, h) =>
|
|
136
|
+
nodeHealthPriority(h) > nodeHealthPriority(best) ? h : best
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Graph Builder ─────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build a visualization graph from RepoMeshStatus.
|
|
144
|
+
*
|
|
145
|
+
* Nodes:
|
|
146
|
+
* - defaultBranchNode: the mesh's default branch (aggregated from nodes on that branch)
|
|
147
|
+
* - worktreeNode: a node on a feature / worktree branch
|
|
148
|
+
* - orphanNode: a node with orphanReasons (upstream missing, detached HEAD, etc.)
|
|
149
|
+
*
|
|
150
|
+
* Edges:
|
|
151
|
+
* - parentBranch: default branch → worktree branch (when branch name differs)
|
|
152
|
+
* - worktreeLink: links nodes that share the same branch (clustering hint)
|
|
153
|
+
* - sessionLink: node → session (lightweight, optional; not rendered as primary edge)
|
|
154
|
+
*/
|
|
155
|
+
export function buildMeshGraph(status: RepoMeshStatus): MeshGraph {
|
|
156
|
+
const nodes: MeshGraphNode[] = [];
|
|
157
|
+
const edges: MeshGraphEdge[] = [];
|
|
158
|
+
const warnings: string[] = [];
|
|
159
|
+
|
|
160
|
+
// Track branch → nodes for edge creation
|
|
161
|
+
const branchToNodeIds = new Map<string, string[]>();
|
|
162
|
+
|
|
163
|
+
for (const nodeStatus of status.nodes) {
|
|
164
|
+
const git = nodeStatus.git;
|
|
165
|
+
const branch = git?.branch || null;
|
|
166
|
+
const orphanReasons = detectOrphanReasons(nodeStatus, null);
|
|
167
|
+
const dirty = isDirty(git);
|
|
168
|
+
const dfc = dirtyFileCount(git);
|
|
169
|
+
|
|
170
|
+
// Determine node type
|
|
171
|
+
let type: MeshGraphNodeType = 'worktreeNode';
|
|
172
|
+
if (orphanReasons.length > 0) {
|
|
173
|
+
type = 'orphanNode';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const graphNode: MeshGraphNode = {
|
|
177
|
+
id: nodeStatus.nodeId,
|
|
178
|
+
type,
|
|
179
|
+
label: nodeStatus.machineLabel || nodeStatus.nodeId.slice(0, 8),
|
|
180
|
+
workspace: nodeStatus.workspace,
|
|
181
|
+
branch,
|
|
182
|
+
health: nodeStatus.health,
|
|
183
|
+
ahead: git?.ahead ?? 0,
|
|
184
|
+
behind: git?.behind ?? 0,
|
|
185
|
+
dirty,
|
|
186
|
+
dirtyFiles: dfc,
|
|
187
|
+
hasConflicts: git?.hasConflicts ?? false,
|
|
188
|
+
activeSessionCount: nodeStatus.activeSessions?.length ?? 0,
|
|
189
|
+
activeSessions: nodeStatus.activeSessions ?? [],
|
|
190
|
+
providers: nodeStatus.providers ?? [],
|
|
191
|
+
isOrphan: orphanReasons.length > 0,
|
|
192
|
+
orphanReasons,
|
|
193
|
+
source: nodeStatus,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
nodes.push(graphNode);
|
|
197
|
+
|
|
198
|
+
if (branch) {
|
|
199
|
+
const list = branchToNodeIds.get(branch) ?? [];
|
|
200
|
+
list.push(graphNode.id);
|
|
201
|
+
branchToNodeIds.set(branch, list);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Create synthetic default branch node if we can infer one
|
|
206
|
+
// Heuristic: most common branch among nodes with upstream
|
|
207
|
+
const branchUpstreamCounts = new Map<string, number>();
|
|
208
|
+
for (const n of status.nodes) {
|
|
209
|
+
const b = n.git?.branch;
|
|
210
|
+
const upstream = n.git?.upstream;
|
|
211
|
+
if (b && upstream) {
|
|
212
|
+
branchUpstreamCounts.set(b, (branchUpstreamCounts.get(b) ?? 0) + 1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
let inferredDefaultBranch: string | null = null;
|
|
216
|
+
let bestCount = 0;
|
|
217
|
+
for (const [b, count] of branchUpstreamCounts) {
|
|
218
|
+
if (count > bestCount) {
|
|
219
|
+
bestCount = count;
|
|
220
|
+
inferredDefaultBranch = b;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// If we have a clear default branch, create a synthetic node for it
|
|
225
|
+
const defaultBranchNodeId = inferredDefaultBranch
|
|
226
|
+
? `__branch_${inferredDefaultBranch}`
|
|
227
|
+
: null;
|
|
228
|
+
|
|
229
|
+
if (defaultBranchNodeId && inferredDefaultBranch) {
|
|
230
|
+
const branchNodes = branchToNodeIds.get(inferredDefaultBranch) ?? [];
|
|
231
|
+
const branchHealths = branchNodes.map(id =>
|
|
232
|
+
nodes.find(n => n.id === id)!.health
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const defaultNode: MeshGraphNode = {
|
|
236
|
+
id: defaultBranchNodeId,
|
|
237
|
+
type: 'defaultBranchNode',
|
|
238
|
+
label: inferredDefaultBranch,
|
|
239
|
+
workspace: '',
|
|
240
|
+
branch: inferredDefaultBranch,
|
|
241
|
+
health: pickDominantHealth(branchHealths),
|
|
242
|
+
ahead: 0,
|
|
243
|
+
behind: 0,
|
|
244
|
+
dirty: false,
|
|
245
|
+
dirtyFiles: 0,
|
|
246
|
+
hasConflicts: false,
|
|
247
|
+
activeSessionCount: 0,
|
|
248
|
+
activeSessions: [],
|
|
249
|
+
providers: [],
|
|
250
|
+
isOrphan: false,
|
|
251
|
+
orphanReasons: [],
|
|
252
|
+
nextStepHint: branchNodes.length > 0 ? `${branchNodes.length} node(s) on default branch` : undefined,
|
|
253
|
+
source: {
|
|
254
|
+
nodeId: defaultBranchNodeId,
|
|
255
|
+
machineLabel: inferredDefaultBranch,
|
|
256
|
+
workspace: '',
|
|
257
|
+
health: 'online',
|
|
258
|
+
providers: [],
|
|
259
|
+
activeSessions: [],
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
nodes.push(defaultNode);
|
|
263
|
+
|
|
264
|
+
// Edge: default branch → each worktree branch that is NOT the default
|
|
265
|
+
const seenBranches = new Set<string>();
|
|
266
|
+
for (const n of nodes) {
|
|
267
|
+
if (n.type === 'defaultBranchNode') continue;
|
|
268
|
+
if (!n.branch) continue;
|
|
269
|
+
if (n.branch === inferredDefaultBranch) {
|
|
270
|
+
// Link worktree nodes on default branch directly to default branch node
|
|
271
|
+
edges.push({
|
|
272
|
+
id: `${defaultBranchNodeId}--${n.id}`,
|
|
273
|
+
source: defaultBranchNodeId,
|
|
274
|
+
target: n.id,
|
|
275
|
+
type: 'parentBranch',
|
|
276
|
+
label: 'default',
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (seenBranches.has(n.branch)) continue;
|
|
281
|
+
seenBranches.add(n.branch);
|
|
282
|
+
edges.push({
|
|
283
|
+
id: `${defaultBranchNodeId}--branch_${n.branch}`,
|
|
284
|
+
source: defaultBranchNodeId,
|
|
285
|
+
target: n.branch,
|
|
286
|
+
type: 'parentBranch',
|
|
287
|
+
label: n.branch,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// worktreeLink edges: connect nodes sharing the same branch
|
|
293
|
+
for (const [branch, ids] of branchToNodeIds) {
|
|
294
|
+
if (ids.length < 2) continue;
|
|
295
|
+
for (let i = 1; i < ids.length; i++) {
|
|
296
|
+
edges.push({
|
|
297
|
+
id: `wt_${ids[0]}--${ids[i]}`,
|
|
298
|
+
source: ids[0],
|
|
299
|
+
target: ids[i],
|
|
300
|
+
type: 'worktreeLink',
|
|
301
|
+
label: branch,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Collect warnings
|
|
307
|
+
const orphanCount = nodes.filter(n => n.isOrphan).length;
|
|
308
|
+
if (orphanCount > 0) {
|
|
309
|
+
warnings.push(`${orphanCount} orphan node(s) detected`);
|
|
310
|
+
}
|
|
311
|
+
const conflictCount = nodes.filter(n => n.hasConflicts).length;
|
|
312
|
+
if (conflictCount > 0) {
|
|
313
|
+
warnings.push(`${conflictCount} node(s) with merge conflicts`);
|
|
314
|
+
}
|
|
315
|
+
const offlineCount = nodes.filter(n => n.health === 'offline').length;
|
|
316
|
+
if (offlineCount > 0) {
|
|
317
|
+
warnings.push(`${offlineCount} node(s) offline`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Stats
|
|
321
|
+
const stats = {
|
|
322
|
+
totalNodes: status.nodes.length,
|
|
323
|
+
onlineNodes: status.nodes.filter(n => n.health === 'online').length,
|
|
324
|
+
dirtyNodes: nodes.filter(n => n.dirty).length,
|
|
325
|
+
orphanNodes: orphanCount,
|
|
326
|
+
errorNodes: status.nodes.filter(n => !!n.error).length,
|
|
327
|
+
offlineNodes: offlineCount,
|
|
328
|
+
totalActiveSessions: status.nodes.reduce((sum, n) => sum + (n.activeSessions?.length ?? 0), 0),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
meshId: status.meshId,
|
|
333
|
+
meshName: status.meshName,
|
|
334
|
+
repoIdentity: status.repoIdentity,
|
|
335
|
+
refreshedAt: status.refreshedAt,
|
|
336
|
+
nodes,
|
|
337
|
+
edges,
|
|
338
|
+
stats,
|
|
339
|
+
warnings,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -3,7 +3,12 @@ import { join } from 'path';
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
5
5
|
|
|
6
|
-
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed';
|
|
6
|
+
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
7
|
+
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
8
|
+
export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
|
|
9
|
+
|
|
10
|
+
export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
|
|
11
|
+
export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
|
|
7
12
|
|
|
8
13
|
export interface MeshWorkQueueEntry {
|
|
9
14
|
id: string;
|
|
@@ -12,10 +17,30 @@ export interface MeshWorkQueueEntry {
|
|
|
12
17
|
status: MeshTaskStatus;
|
|
13
18
|
/** If specified, only this node can claim the task (used by legacy mesh_send_task) */
|
|
14
19
|
targetNodeId?: string;
|
|
20
|
+
/** If specified, only this runtime session can claim the task */
|
|
21
|
+
targetSessionId?: string;
|
|
15
22
|
/** The node that actually claimed and is executing the task */
|
|
16
23
|
assignedNodeId?: string;
|
|
17
24
|
/** The session currently executing the task */
|
|
18
25
|
assignedSessionId?: string;
|
|
26
|
+
/** Human/operator reason for terminal cancellation. */
|
|
27
|
+
cancelReason?: string;
|
|
28
|
+
cancelledAt?: string;
|
|
29
|
+
/** Human/operator reason for manually requeueing a task. */
|
|
30
|
+
requeueReason?: string;
|
|
31
|
+
requeuedAt?: string;
|
|
32
|
+
requeueCount?: number;
|
|
33
|
+
/** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
|
|
34
|
+
autoLaunch?: {
|
|
35
|
+
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
36
|
+
reason?: string;
|
|
37
|
+
nodeId?: string;
|
|
38
|
+
providerType?: string;
|
|
39
|
+
sessionId?: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
};
|
|
42
|
+
/** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
|
|
43
|
+
dispatchTimestamp?: string;
|
|
19
44
|
createdAt: string;
|
|
20
45
|
updatedAt: string;
|
|
21
46
|
}
|
|
@@ -47,7 +72,7 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
|
47
72
|
export function enqueueTask(
|
|
48
73
|
meshId: string,
|
|
49
74
|
message: string,
|
|
50
|
-
opts?: { targetNodeId?: string }
|
|
75
|
+
opts?: { targetNodeId?: string; targetSessionId?: string }
|
|
51
76
|
): MeshWorkQueueEntry {
|
|
52
77
|
const queue = readQueue(meshId);
|
|
53
78
|
const entry: MeshWorkQueueEntry = {
|
|
@@ -56,6 +81,7 @@ export function enqueueTask(
|
|
|
56
81
|
message,
|
|
57
82
|
status: 'pending',
|
|
58
83
|
targetNodeId: opts?.targetNodeId,
|
|
84
|
+
targetSessionId: opts?.targetSessionId,
|
|
59
85
|
createdAt: new Date().toISOString(),
|
|
60
86
|
updatedAt: new Date().toISOString(),
|
|
61
87
|
};
|
|
@@ -81,13 +107,25 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
|
|
|
81
107
|
*/
|
|
82
108
|
export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
|
|
83
109
|
const queue = readQueue(meshId);
|
|
110
|
+
|
|
111
|
+
// A worker must finish or fail its current queued assignment before it can
|
|
112
|
+
// claim another one. maxParallelTasks limits total mesh concurrency; it is
|
|
113
|
+
// not permission for one node/session to accumulate multiple assigned items.
|
|
114
|
+
const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
|
|
115
|
+
q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
|
|
116
|
+
));
|
|
117
|
+
if (hasActiveAssignment) return null;
|
|
84
118
|
|
|
85
119
|
// Find highest priority task:
|
|
86
|
-
// 1. Pending tasks explicitly targeted at this
|
|
87
|
-
// 2. Pending tasks
|
|
88
|
-
|
|
120
|
+
// 1. Pending tasks explicitly targeted at this runtime session
|
|
121
|
+
// 2. Pending tasks explicitly targeted at this node (but not another session)
|
|
122
|
+
// 3. Pending tasks with no target node/session
|
|
123
|
+
let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
|
|
89
124
|
if (targetIdx === -1) {
|
|
90
|
-
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.
|
|
125
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
|
|
126
|
+
}
|
|
127
|
+
if (targetIdx === -1) {
|
|
128
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
if (targetIdx === -1) return null;
|
|
@@ -96,6 +134,7 @@ export function claimNextTask(meshId: string, nodeId: string, sessionId: string)
|
|
|
96
134
|
entry.status = 'assigned';
|
|
97
135
|
entry.assignedNodeId = nodeId;
|
|
98
136
|
entry.assignedSessionId = sessionId;
|
|
137
|
+
entry.dispatchTimestamp = new Date().toISOString();
|
|
99
138
|
entry.updatedAt = new Date().toISOString();
|
|
100
139
|
|
|
101
140
|
writeQueue(meshId, queue);
|
|
@@ -121,6 +160,83 @@ export function updateTaskStatus(
|
|
|
121
160
|
return queue[idx];
|
|
122
161
|
}
|
|
123
162
|
|
|
163
|
+
export function recordTaskAutoLaunch(
|
|
164
|
+
meshId: string,
|
|
165
|
+
taskId: string,
|
|
166
|
+
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
167
|
+
): MeshWorkQueueEntry | null {
|
|
168
|
+
const queue = readQueue(meshId);
|
|
169
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
170
|
+
if (idx === -1) return null;
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
queue[idx].autoLaunch = {
|
|
173
|
+
...autoLaunch,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
};
|
|
176
|
+
queue[idx].updatedAt = now;
|
|
177
|
+
writeQueue(meshId, queue);
|
|
178
|
+
return queue[idx];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark a queue task as manually cancelled without deleting audit history.
|
|
183
|
+
*/
|
|
184
|
+
export function cancelTask(
|
|
185
|
+
meshId: string,
|
|
186
|
+
taskId: string,
|
|
187
|
+
opts?: { reason?: string },
|
|
188
|
+
): MeshWorkQueueEntry | null {
|
|
189
|
+
const queue = readQueue(meshId);
|
|
190
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
191
|
+
if (idx === -1) return null;
|
|
192
|
+
|
|
193
|
+
const now = new Date().toISOString();
|
|
194
|
+
queue[idx].status = 'cancelled';
|
|
195
|
+
queue[idx].updatedAt = now;
|
|
196
|
+
queue[idx].cancelledAt = now;
|
|
197
|
+
if (opts?.reason) queue[idx].cancelReason = opts.reason;
|
|
198
|
+
writeQueue(meshId, queue);
|
|
199
|
+
return queue[idx];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Return a queue task to pending for retry. By default, dead session targeting
|
|
204
|
+
* and assigned ownership are cleared so stale assignments do not strand again.
|
|
205
|
+
*/
|
|
206
|
+
export function requeueTask(
|
|
207
|
+
meshId: string,
|
|
208
|
+
taskId: string,
|
|
209
|
+
opts?: {
|
|
210
|
+
reason?: string;
|
|
211
|
+
targetNodeId?: string;
|
|
212
|
+
targetSessionId?: string;
|
|
213
|
+
clearTargetNode?: boolean;
|
|
214
|
+
clearTargetSession?: boolean;
|
|
215
|
+
},
|
|
216
|
+
): MeshWorkQueueEntry | null {
|
|
217
|
+
const queue = readQueue(meshId);
|
|
218
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
219
|
+
if (idx === -1) return null;
|
|
220
|
+
|
|
221
|
+
const entry = queue[idx];
|
|
222
|
+
const now = new Date().toISOString();
|
|
223
|
+
entry.status = 'pending';
|
|
224
|
+
delete entry.assignedNodeId;
|
|
225
|
+
delete entry.assignedSessionId;
|
|
226
|
+
delete entry.cancelledAt;
|
|
227
|
+
delete entry.cancelReason;
|
|
228
|
+
if (opts?.clearTargetNode) delete entry.targetNodeId;
|
|
229
|
+
if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
|
|
230
|
+
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
231
|
+
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
232
|
+
entry.updatedAt = now;
|
|
233
|
+
entry.requeuedAt = now;
|
|
234
|
+
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
235
|
+
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
236
|
+
writeQueue(meshId, queue);
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
|
|
124
240
|
/**
|
|
125
241
|
* Update the status of the task currently assigned to a specific session.
|
|
126
242
|
*/
|
|
@@ -130,24 +246,48 @@ export function updateSessionTaskStatus(
|
|
|
130
246
|
status: MeshTaskStatus,
|
|
131
247
|
): MeshWorkQueueEntry | null {
|
|
132
248
|
const queue = readQueue(meshId);
|
|
133
|
-
//
|
|
134
|
-
//
|
|
249
|
+
// Collect all assigned tasks for this session, then pick the one with the
|
|
250
|
+
// most recent dispatchTimestamp (or updatedAt fallback for legacy entries).
|
|
251
|
+
// This prevents completing the wrong task when multiple tasks were assigned
|
|
252
|
+
// to the same session in rapid succession.
|
|
253
|
+
let bestIdx = -1;
|
|
254
|
+
let bestTime = 0;
|
|
135
255
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
136
256
|
if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
|
|
137
|
-
queue[i].
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
257
|
+
const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
|
|
258
|
+
if (time > bestTime) {
|
|
259
|
+
bestTime = time;
|
|
260
|
+
bestIdx = i;
|
|
261
|
+
}
|
|
141
262
|
}
|
|
142
263
|
}
|
|
143
|
-
return null;
|
|
264
|
+
if (bestIdx === -1) return null;
|
|
265
|
+
|
|
266
|
+
queue[bestIdx].status = status;
|
|
267
|
+
queue[bestIdx].updatedAt = new Date().toISOString();
|
|
268
|
+
writeQueue(meshId, queue);
|
|
269
|
+
return queue[bestIdx];
|
|
144
270
|
}
|
|
145
271
|
|
|
146
272
|
export interface MeshWorkQueueStats {
|
|
273
|
+
total: number;
|
|
274
|
+
active: number;
|
|
275
|
+
historical: number;
|
|
147
276
|
pending: number;
|
|
148
277
|
assigned: number;
|
|
149
278
|
completed: number;
|
|
150
279
|
failed: number;
|
|
280
|
+
cancelled: number;
|
|
281
|
+
/** Source-of-truth active queue counters; only pending/assigned are live work. */
|
|
282
|
+
activeCounts: Record<MeshActiveTaskStatus, number>;
|
|
283
|
+
/** Terminal ledger records kept for audit/history; never count as active work. */
|
|
284
|
+
historicalCounts: Record<MeshHistoricalTaskStatus, number>;
|
|
285
|
+
activeAssignments: Array<{
|
|
286
|
+
id: string;
|
|
287
|
+
nodeId?: string;
|
|
288
|
+
sessionId?: string;
|
|
289
|
+
message: string;
|
|
290
|
+
}>;
|
|
151
291
|
}
|
|
152
292
|
|
|
153
293
|
/**
|
|
@@ -155,10 +295,36 @@ export interface MeshWorkQueueStats {
|
|
|
155
295
|
*/
|
|
156
296
|
export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
|
|
157
297
|
const queue = readQueue(meshId);
|
|
298
|
+
const pending = queue.filter(q => q.status === 'pending').length;
|
|
299
|
+
const assigned = queue.filter(q => q.status === 'assigned').length;
|
|
300
|
+
const completed = queue.filter(q => q.status === 'completed').length;
|
|
301
|
+
const failed = queue.filter(q => q.status === 'failed').length;
|
|
302
|
+
const cancelled = queue.filter(q => q.status === 'cancelled').length;
|
|
158
303
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
304
|
+
total: queue.length,
|
|
305
|
+
active: pending + assigned,
|
|
306
|
+
historical: completed + failed + cancelled,
|
|
307
|
+
pending,
|
|
308
|
+
assigned,
|
|
309
|
+
completed,
|
|
310
|
+
failed,
|
|
311
|
+
cancelled,
|
|
312
|
+
activeCounts: {
|
|
313
|
+
pending,
|
|
314
|
+
assigned,
|
|
315
|
+
},
|
|
316
|
+
historicalCounts: {
|
|
317
|
+
completed,
|
|
318
|
+
failed,
|
|
319
|
+
cancelled,
|
|
320
|
+
},
|
|
321
|
+
activeAssignments: queue
|
|
322
|
+
.filter(q => q.status === 'assigned')
|
|
323
|
+
.map(q => ({
|
|
324
|
+
id: q.id,
|
|
325
|
+
nodeId: q.assignedNodeId,
|
|
326
|
+
sessionId: q.assignedSessionId,
|
|
327
|
+
message: q.message,
|
|
328
|
+
})),
|
|
163
329
|
};
|
|
164
330
|
}
|