@adhdev/daemon-core 0.9.82-rc.22 → 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.
@@ -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 {