@adhdev/daemon-core 0.9.82-rc.23 → 0.9.82-rc.24
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/commands/router.d.ts +2 -0
- package/dist/index.js +293 -169
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +293 -169
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +5 -5
- package/package.json +1 -1
- package/src/commands/router.ts +48 -22
- package/src/mesh/mesh-events.ts +80 -29
- package/src/mesh/mesh-work-queue.ts +132 -119
|
@@ -9,12 +9,12 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
9
9
|
queuedAt: number;
|
|
10
10
|
}
|
|
11
11
|
export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean;
|
|
12
|
-
/** Drain and return all pending coordinator events,
|
|
13
|
-
export declare function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[];
|
|
12
|
+
/** Drain and return all pending coordinator events for meshId, removing them from disk. */
|
|
13
|
+
export declare function drainPendingMeshCoordinatorEvents(meshId?: string): PendingMeshCoordinatorEvent[];
|
|
14
14
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
15
|
-
export declare function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[];
|
|
16
|
-
/** Explicitly clear all pending coordinator events. */
|
|
17
|
-
export declare function clearPendingMeshCoordinatorEvents(): void;
|
|
15
|
+
export declare function getPendingMeshCoordinatorEvents(meshId?: string): readonly PendingMeshCoordinatorEvent[];
|
|
16
|
+
/** Explicitly clear all pending coordinator events for a mesh. */
|
|
17
|
+
export declare function clearPendingMeshCoordinatorEvents(meshId?: string): void;
|
|
18
18
|
export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
|
|
19
19
|
/**
|
|
20
20
|
* Triggers a queue check for all nodes in the mesh.
|
package/package.json
CHANGED
package/src/commands/router.ts
CHANGED
|
@@ -964,6 +964,8 @@ export interface CommandRouterDeps {
|
|
|
964
964
|
sessionHostControl?: SessionHostControlPlane | null;
|
|
965
965
|
/** Selected-coordinator mesh peer telemetry surface for target daemons, when supported by the runtime. */
|
|
966
966
|
getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
|
|
967
|
+
/** Dispatch a command to a remote mesh node via P2P/relay. Injected by cloud runtime; absent in standalone. */
|
|
968
|
+
dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
967
969
|
}
|
|
968
970
|
|
|
969
971
|
export interface CommandRouterResult {
|
|
@@ -1688,7 +1690,8 @@ export class DaemonCommandRouter {
|
|
|
1688
1690
|
}
|
|
1689
1691
|
|
|
1690
1692
|
case 'get_pending_mesh_events': {
|
|
1691
|
-
const
|
|
1693
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
1694
|
+
const events = drainPendingMeshCoordinatorEvents(meshId || undefined);
|
|
1692
1695
|
return { success: true, events };
|
|
1693
1696
|
}
|
|
1694
1697
|
|
|
@@ -3344,29 +3347,52 @@ export class DaemonCommandRouter {
|
|
|
3344
3347
|
}
|
|
3345
3348
|
if (workspace) {
|
|
3346
3349
|
if (!fs.existsSync(workspace)) {
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3350
|
+
// Workspace not local — attempt a P2P git probe for remote nodes.
|
|
3351
|
+
let remoteProbeApplied = false;
|
|
3352
|
+
if (!isSelfNode && daemonId && this.deps.dispatchMeshCommand) {
|
|
3353
|
+
try {
|
|
3354
|
+
const remoteResult = await Promise.race([
|
|
3355
|
+
this.deps.dispatchMeshCommand(daemonId, 'git_status', { workspace }),
|
|
3356
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
|
3357
|
+
]) as any;
|
|
3358
|
+
const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
|
|
3359
|
+
if (remoteGit && typeof remoteGit === 'object' && typeof remoteGit.isGitRepo === 'boolean') {
|
|
3360
|
+
status.git = remoteGit;
|
|
3361
|
+
status.health = remoteGit.isGitRepo
|
|
3362
|
+
? deriveMeshNodeHealthFromGit(remoteGit as unknown as Record<string, unknown>)
|
|
3363
|
+
: 'degraded';
|
|
3364
|
+
remoteProbeApplied = true;
|
|
3365
|
+
}
|
|
3366
|
+
} catch {
|
|
3367
|
+
// Probe timed out or P2P unavailable — fall back to cached status
|
|
3368
|
+
}
|
|
3356
3369
|
}
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3370
|
+
if (!remoteProbeApplied) {
|
|
3371
|
+
if (applyCachedInlineMeshNodeStatus(status, node)) {
|
|
3372
|
+
status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
|
|
3373
|
+
nodeStatuses.push(status);
|
|
3374
|
+
continue;
|
|
3375
|
+
}
|
|
3376
|
+
if (meshRecord?.source === 'inline_cache' && !isSelfNode) {
|
|
3377
|
+
status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
|
|
3378
|
+
nodeStatuses.push(status);
|
|
3379
|
+
continue;
|
|
3380
|
+
}
|
|
3366
3381
|
}
|
|
3367
|
-
}
|
|
3368
|
-
|
|
3369
|
-
|
|
3382
|
+
} else {
|
|
3383
|
+
try {
|
|
3384
|
+
const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
|
|
3385
|
+
status.git = gitStatus;
|
|
3386
|
+
if (gitStatus.isGitRepo) {
|
|
3387
|
+
status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
|
|
3388
|
+
} else {
|
|
3389
|
+
status.health = 'degraded';
|
|
3390
|
+
if (gitStatus.error && !status.error) status.error = gitStatus.error;
|
|
3391
|
+
}
|
|
3392
|
+
} catch {
|
|
3393
|
+
if (!applyCachedInlineMeshNodeStatus(status, node)) {
|
|
3394
|
+
status.health = 'degraded';
|
|
3395
|
+
}
|
|
3370
3396
|
}
|
|
3371
3397
|
}
|
|
3372
3398
|
} else {
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -1,31 +1,47 @@
|
|
|
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 {
|
|
@@ -38,30 +54,53 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
38
54
|
queuedAt: number;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
|
-
|
|
42
|
-
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
|
+
}
|
|
43
61
|
|
|
44
62
|
export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
|
|
45
|
-
|
|
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}`);
|
|
46
68
|
return false;
|
|
47
69
|
}
|
|
48
|
-
pendingMeshCoordinatorEvents.push(event);
|
|
49
|
-
return true;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
|
-
/** Drain and return all pending coordinator events,
|
|
53
|
-
export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
|
|
54
|
-
|
|
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 []; }
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
58
|
-
export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
|
|
59
|
-
|
|
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 []; }
|
|
60
97
|
}
|
|
61
98
|
|
|
62
|
-
/** Explicitly clear all pending coordinator events. */
|
|
63
|
-
export function clearPendingMeshCoordinatorEvents(): void {
|
|
64
|
-
|
|
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 */ }
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
function readNonEmptyString(value: unknown): string {
|
|
@@ -180,7 +219,16 @@ export function tryAssignQueueTask(
|
|
|
180
219
|
message: task.message,
|
|
181
220
|
}).catch((e: any) => {
|
|
182
221
|
LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
|
|
183
|
-
|
|
222
|
+
// Revert to pending so the task can be retried rather than permanently failing
|
|
223
|
+
updateTaskStatus(meshId, task.id, 'pending');
|
|
224
|
+
try {
|
|
225
|
+
appendLedgerEntry(meshId, {
|
|
226
|
+
kind: 'dispatch_failed' as any,
|
|
227
|
+
nodeId,
|
|
228
|
+
sessionId,
|
|
229
|
+
payload: { taskId: task.id, error: e?.message, retryable: true },
|
|
230
|
+
});
|
|
231
|
+
} catch { /* ledger write is best-effort */ }
|
|
184
232
|
});
|
|
185
233
|
return true;
|
|
186
234
|
}
|
|
@@ -614,10 +662,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
614
662
|
const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed');
|
|
615
663
|
completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
|
|
616
664
|
if (nodeId && providerType) {
|
|
617
|
-
//
|
|
618
|
-
|
|
665
|
+
// Queue state is already updated above; setImmediate avoids the
|
|
666
|
+
// 500 ms artificial delay while still deferring past this call frame.
|
|
667
|
+
setImmediate(() => {
|
|
619
668
|
tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
620
|
-
}
|
|
669
|
+
});
|
|
621
670
|
}
|
|
622
671
|
}
|
|
623
672
|
} else if (args.event === 'agent:ready') {
|
|
@@ -658,13 +707,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
658
707
|
}
|
|
659
708
|
|
|
660
709
|
if (sessionId && nodeId && providerType) {
|
|
661
|
-
|
|
662
|
-
|
|
710
|
+
sweepExpiredRemoteIdleSessions();
|
|
711
|
+
remoteIdleSessions.set(`${nodeId}:${sessionId}`, {
|
|
712
|
+
nodeId, sessionId, providerType,
|
|
713
|
+
expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
|
|
714
|
+
});
|
|
715
|
+
setImmediate(() => {
|
|
663
716
|
const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
664
|
-
if (assigned) {
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
}, 500);
|
|
717
|
+
if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
|
|
718
|
+
});
|
|
668
719
|
}
|
|
669
720
|
} else if (args.event === 'agent:generating_started') {
|
|
670
721
|
const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
@@ -50,6 +50,31 @@ function getQueuePath(meshId: string): string {
|
|
|
50
50
|
return join(getLedgerDir(), `${safe}.queue.json`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function getLockPath(meshId: string): string {
|
|
54
|
+
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
55
|
+
return join(getLedgerDir(), `${safe}.queue.lock`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Simple advisory file lock using O_EXCL (atomic create) for queue mutations.
|
|
60
|
+
* Retries up to 10 times at 30 ms intervals; proceeds without lock on timeout
|
|
61
|
+
* to prevent deadlock (best-effort — far better than no locking at all).
|
|
62
|
+
*/
|
|
63
|
+
function withQueueLock<T>(meshId: string, fn: () => T): T {
|
|
64
|
+
const lockPath = getLockPath(meshId);
|
|
65
|
+
let fd = -1;
|
|
66
|
+
for (let i = 0; i < 10; i++) {
|
|
67
|
+
try { fd = openSync(lockPath, 'wx'); break; } catch {
|
|
68
|
+
const deadline = Date.now() + 30;
|
|
69
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try { return fn(); } finally {
|
|
73
|
+
if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
|
|
74
|
+
try { unlinkSync(lockPath); } catch { /* already removed */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
function readQueue(meshId: string): MeshWorkQueueEntry[] {
|
|
54
79
|
const path = getQueuePath(meshId);
|
|
55
80
|
if (!existsSync(path)) return [];
|
|
@@ -74,20 +99,22 @@ export function enqueueTask(
|
|
|
74
99
|
message: string,
|
|
75
100
|
opts?: { targetNodeId?: string; targetSessionId?: string }
|
|
76
101
|
): MeshWorkQueueEntry {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
return withQueueLock(meshId, () => {
|
|
103
|
+
const queue = readQueue(meshId);
|
|
104
|
+
const entry: MeshWorkQueueEntry = {
|
|
105
|
+
id: randomUUID(),
|
|
106
|
+
meshId,
|
|
107
|
+
message,
|
|
108
|
+
status: 'pending',
|
|
109
|
+
targetNodeId: opts?.targetNodeId,
|
|
110
|
+
targetSessionId: opts?.targetSessionId,
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
updatedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
queue.push(entry);
|
|
115
|
+
writeQueue(meshId, queue);
|
|
116
|
+
return entry;
|
|
117
|
+
});
|
|
91
118
|
}
|
|
92
119
|
|
|
93
120
|
/**
|
|
@@ -106,39 +133,29 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
|
|
|
106
133
|
* Find the next pending task that this node is allowed to claim, and mark it as assigned.
|
|
107
134
|
*/
|
|
108
135
|
export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
q.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const entry = queue[targetIdx];
|
|
134
|
-
entry.status = 'assigned';
|
|
135
|
-
entry.assignedNodeId = nodeId;
|
|
136
|
-
entry.assignedSessionId = sessionId;
|
|
137
|
-
entry.dispatchTimestamp = new Date().toISOString();
|
|
138
|
-
entry.updatedAt = new Date().toISOString();
|
|
139
|
-
|
|
140
|
-
writeQueue(meshId, queue);
|
|
141
|
-
return entry;
|
|
136
|
+
return withQueueLock(meshId, () => {
|
|
137
|
+
const queue = readQueue(meshId);
|
|
138
|
+
const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
|
|
139
|
+
q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
|
|
140
|
+
));
|
|
141
|
+
if (hasActiveAssignment) return null;
|
|
142
|
+
let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
|
|
143
|
+
if (targetIdx === -1) {
|
|
144
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
|
|
145
|
+
}
|
|
146
|
+
if (targetIdx === -1) {
|
|
147
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
|
|
148
|
+
}
|
|
149
|
+
if (targetIdx === -1) return null;
|
|
150
|
+
const entry = queue[targetIdx];
|
|
151
|
+
entry.status = 'assigned';
|
|
152
|
+
entry.assignedNodeId = nodeId;
|
|
153
|
+
entry.assignedSessionId = sessionId;
|
|
154
|
+
entry.dispatchTimestamp = new Date().toISOString();
|
|
155
|
+
entry.updatedAt = new Date().toISOString();
|
|
156
|
+
writeQueue(meshId, queue);
|
|
157
|
+
return entry;
|
|
158
|
+
});
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
/**
|
|
@@ -150,14 +167,15 @@ export function updateTaskStatus(
|
|
|
150
167
|
taskId: string,
|
|
151
168
|
status: MeshTaskStatus,
|
|
152
169
|
): MeshWorkQueueEntry | null {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
170
|
+
return withQueueLock(meshId, () => {
|
|
171
|
+
const queue = readQueue(meshId);
|
|
172
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
173
|
+
if (idx === -1) return null;
|
|
174
|
+
queue[idx].status = status;
|
|
175
|
+
queue[idx].updatedAt = new Date().toISOString();
|
|
176
|
+
writeQueue(meshId, queue);
|
|
177
|
+
return queue[idx];
|
|
178
|
+
});
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
export function recordTaskAutoLaunch(
|
|
@@ -165,17 +183,16 @@ export function recordTaskAutoLaunch(
|
|
|
165
183
|
taskId: string,
|
|
166
184
|
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
167
185
|
): MeshWorkQueueEntry | null {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
...autoLaunch,
|
|
174
|
-
updatedAt
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return queue[idx];
|
|
186
|
+
return withQueueLock(meshId, () => {
|
|
187
|
+
const queue = readQueue(meshId);
|
|
188
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
189
|
+
if (idx === -1) return null;
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
queue[idx].autoLaunch = { ...autoLaunch, updatedAt: now };
|
|
192
|
+
queue[idx].updatedAt = now;
|
|
193
|
+
writeQueue(meshId, queue);
|
|
194
|
+
return queue[idx];
|
|
195
|
+
});
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
/**
|
|
@@ -186,17 +203,18 @@ export function cancelTask(
|
|
|
186
203
|
taskId: string,
|
|
187
204
|
opts?: { reason?: string },
|
|
188
205
|
): MeshWorkQueueEntry | null {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
return withQueueLock(meshId, () => {
|
|
207
|
+
const queue = readQueue(meshId);
|
|
208
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
209
|
+
if (idx === -1) return null;
|
|
210
|
+
const now = new Date().toISOString();
|
|
211
|
+
queue[idx].status = 'cancelled';
|
|
212
|
+
queue[idx].updatedAt = now;
|
|
213
|
+
queue[idx].cancelledAt = now;
|
|
214
|
+
if (opts?.reason) queue[idx].cancelReason = opts.reason;
|
|
215
|
+
writeQueue(meshId, queue);
|
|
216
|
+
return queue[idx];
|
|
217
|
+
});
|
|
200
218
|
}
|
|
201
219
|
|
|
202
220
|
/**
|
|
@@ -214,27 +232,28 @@ export function requeueTask(
|
|
|
214
232
|
clearTargetSession?: boolean;
|
|
215
233
|
},
|
|
216
234
|
): MeshWorkQueueEntry | null {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
return withQueueLock(meshId, () => {
|
|
236
|
+
const queue = readQueue(meshId);
|
|
237
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
238
|
+
if (idx === -1) return null;
|
|
239
|
+
const entry = queue[idx];
|
|
240
|
+
const now = new Date().toISOString();
|
|
241
|
+
entry.status = 'pending';
|
|
242
|
+
delete entry.assignedNodeId;
|
|
243
|
+
delete entry.assignedSessionId;
|
|
244
|
+
delete entry.cancelledAt;
|
|
245
|
+
delete entry.cancelReason;
|
|
246
|
+
if (opts?.clearTargetNode) delete entry.targetNodeId;
|
|
247
|
+
if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
|
|
248
|
+
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
249
|
+
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
250
|
+
entry.updatedAt = now;
|
|
251
|
+
entry.requeuedAt = now;
|
|
252
|
+
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
253
|
+
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
254
|
+
writeQueue(meshId, queue);
|
|
255
|
+
return entry;
|
|
256
|
+
});
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
/**
|
|
@@ -245,28 +264,22 @@ export function updateSessionTaskStatus(
|
|
|
245
264
|
sessionId: string,
|
|
246
265
|
status: MeshTaskStatus,
|
|
247
266
|
): MeshWorkQueueEntry | null {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
|
|
257
|
-
const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
|
|
258
|
-
if (time > bestTime) {
|
|
259
|
-
bestTime = time;
|
|
260
|
-
bestIdx = i;
|
|
267
|
+
return withQueueLock(meshId, () => {
|
|
268
|
+
const queue = readQueue(meshId);
|
|
269
|
+
let bestIdx = -1;
|
|
270
|
+
let bestTime = 0;
|
|
271
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
272
|
+
if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
|
|
273
|
+
const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
|
|
274
|
+
if (time > bestTime) { bestTime = time; bestIdx = i; }
|
|
261
275
|
}
|
|
262
276
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return queue[bestIdx];
|
|
277
|
+
if (bestIdx === -1) return null;
|
|
278
|
+
queue[bestIdx].status = status;
|
|
279
|
+
queue[bestIdx].updatedAt = new Date().toISOString();
|
|
280
|
+
writeQueue(meshId, queue);
|
|
281
|
+
return queue[bestIdx];
|
|
282
|
+
});
|
|
270
283
|
}
|
|
271
284
|
|
|
272
285
|
export interface MeshWorkQueueStats {
|