@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.
@@ -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, clearing the queue. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.23",
3
+ "version": "0.9.82-rc.24",
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",
@@ -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 events = drainPendingMeshCoordinatorEvents();
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
- if (applyCachedInlineMeshNodeStatus(status, node)) {
3348
- status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3349
- nodeStatuses.push(status);
3350
- continue;
3351
- }
3352
- if (meshRecord?.source === 'inline_cache' && !isSelfNode) {
3353
- status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
3354
- nodeStatuses.push(status);
3355
- continue;
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
- try {
3359
- const gitStatus = await getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true });
3360
- status.git = gitStatus;
3361
- if (gitStatus.isGitRepo) {
3362
- status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
3363
- } else {
3364
- status.health = 'degraded';
3365
- if (gitStatus.error && !status.error) status.error = gitStatus.error;
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
- } catch {
3368
- if (!applyCachedInlineMeshNodeStatus(status, node)) {
3369
- status.health = 'degraded';
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 {
@@ -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 buffer the event here.
28
- // The MCP server drains this queue on every mesh_status / mesh_send_task poll.
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
- const MAX_PENDING_EVENTS = 50;
42
- const pendingMeshCoordinatorEvents: PendingMeshCoordinatorEvent[] = [];
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
- if (pendingMeshCoordinatorEvents.length >= MAX_PENDING_EVENTS) {
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, clearing the queue. */
53
- export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
54
- return pendingMeshCoordinatorEvents.splice(0);
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
- return pendingMeshCoordinatorEvents.slice();
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
- pendingMeshCoordinatorEvents.splice(0);
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
- updateTaskStatus(meshId, task.id, 'failed');
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
- // Short delay to allow completion event to propagate before pulling next
618
- setTimeout(() => {
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
- }, 500);
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
- remoteIdleSessions.set(`${nodeId}:${sessionId}`, { nodeId, sessionId, providerType });
662
- setTimeout(() => {
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
- remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
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
- const queue = readQueue(meshId);
78
- const entry: MeshWorkQueueEntry = {
79
- id: randomUUID(),
80
- meshId,
81
- message,
82
- status: 'pending',
83
- targetNodeId: opts?.targetNodeId,
84
- targetSessionId: opts?.targetSessionId,
85
- createdAt: new Date().toISOString(),
86
- updatedAt: new Date().toISOString(),
87
- };
88
- queue.push(entry);
89
- writeQueue(meshId, queue);
90
- return entry;
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
- 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;
118
-
119
- // Find highest priority task:
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);
124
- if (targetIdx === -1) {
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);
129
- }
130
-
131
- if (targetIdx === -1) return null;
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
- const queue = readQueue(meshId);
154
- const idx = queue.findIndex(q => q.id === taskId);
155
- if (idx === -1) return null;
156
-
157
- queue[idx].status = status;
158
- queue[idx].updatedAt = new Date().toISOString();
159
- writeQueue(meshId, queue);
160
- return queue[idx];
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
- 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];
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
- 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];
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
- 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;
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
- const queue = readQueue(meshId);
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;
255
- for (let i = queue.length - 1; i >= 0; i--) {
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
- 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];
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 {