@adhdev/daemon-core 0.9.82-rc.4 → 0.9.82-rc.41

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
  /**
@@ -244,29 +263,26 @@ export function updateSessionTaskStatus(
244
263
  meshId: string,
245
264
  sessionId: string,
246
265
  status: MeshTaskStatus,
266
+ opts?: { occurredAt?: string },
247
267
  ): 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') {
268
+ return withQueueLock(meshId, () => {
269
+ const queue = readQueue(meshId);
270
+ const occurredAtTime = opts?.occurredAt ? new Date(opts.occurredAt).getTime() : Number.NaN;
271
+ const hasOccurredAt = Number.isFinite(occurredAtTime);
272
+ let bestIdx = -1;
273
+ let bestTime = 0;
274
+ for (let i = queue.length - 1; i >= 0; i--) {
275
+ if (queue[i].assignedSessionId !== sessionId || queue[i].status !== 'assigned') continue;
257
276
  const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
258
- if (time > bestTime) {
259
- bestTime = time;
260
- bestIdx = i;
261
- }
277
+ if (hasOccurredAt && Number.isFinite(time) && time > occurredAtTime) continue;
278
+ if (time > bestTime) { bestTime = time; bestIdx = i; }
262
279
  }
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];
280
+ if (bestIdx === -1) return null;
281
+ queue[bestIdx].status = status;
282
+ queue[bestIdx].updatedAt = new Date().toISOString();
283
+ writeQueue(meshId, queue);
284
+ return queue[bestIdx];
285
+ });
270
286
  }
271
287
 
272
288
  export interface MeshWorkQueueStats {
@@ -1,9 +1,11 @@
1
1
  import type { ChatMessage } from '../types.js';
2
2
  import { flattenContent } from './contracts.js';
3
3
 
4
+ export const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4_000;
5
+
4
6
  export function extractFinalSummaryFromMessages(
5
7
  messages: ChatMessage[] | null | undefined,
6
- maxChars: number = 500,
8
+ maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
7
9
  ): string {
8
10
  if (!Array.isArray(messages) || messages.length === 0) return '';
9
11
 
@@ -259,17 +259,155 @@ export interface RepoMeshStatus {
259
259
  meshId: string;
260
260
  meshName: string;
261
261
  repoIdentity: string;
262
+ defaultBranch?: string;
262
263
  refreshedAt: string;
263
264
  nodes: RepoMeshNodeStatus[];
265
+ queue?: RepoMeshQueueStatus;
266
+ ledger?: RepoMeshLedgerStatus;
267
+ }
268
+
269
+ export interface RepoMeshSessionStatus {
270
+ sessionId: string;
271
+ providerType?: string;
272
+ state?: string;
273
+ lifecycle?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'interrupted';
274
+ surfaceKind?: 'live_runtime' | 'recovery_snapshot' | 'inactive_record';
275
+ recoveryState?: string | null;
276
+ workspace?: string | null;
277
+ title?: string | null;
278
+ lastActivityAt?: string | null;
279
+ isCached?: boolean;
280
+ }
281
+
282
+ export type RepoMeshPeerConnectionState = 'self' | 'connected' | 'connecting' | 'disconnected' | 'failed' | 'closed' | 'unknown';
283
+ export type RepoMeshPeerConnectionTransport = 'local' | 'direct' | 'relay' | 'unknown';
284
+
285
+ export interface RepoMeshPeerConnectionStatus {
286
+ perspective: 'selected_coordinator';
287
+ source: 'mesh_peer_status' | 'not_reported';
288
+ state: RepoMeshPeerConnectionState;
289
+ transport: RepoMeshPeerConnectionTransport;
290
+ reported: boolean;
291
+ reason?: string;
292
+ lastStateChangeAt?: string;
293
+ lastConnectedAt?: string;
294
+ lastCommandAt?: string;
264
295
  }
265
296
 
266
297
  export interface RepoMeshNodeStatus {
267
298
  nodeId: string;
268
299
  machineLabel: string;
269
300
  workspace: string;
301
+ repoRoot?: string;
302
+ daemonId?: string;
303
+ machineId?: string;
304
+ machineStatus?: string;
305
+ isLocalWorktree?: boolean;
306
+ worktreeBranch?: string;
270
307
  health: RepoMeshNodeHealth;
271
308
  git?: GitRepoStatus;
309
+ /**
310
+ * True when the selected coordinator has evidence that a peer git probe is still
311
+ * in flight or just timed out during initial mesh handshake, so callers should
312
+ * treat missing git data as pending instead of authoritative absence.
313
+ */
314
+ gitProbePending?: boolean;
272
315
  providers: string[];
273
316
  activeSessions: string[];
317
+ activeSessionDetails?: RepoMeshSessionStatus[];
318
+ providerPriority?: string[];
319
+ launchReady?: boolean;
320
+ lastSeenAt?: string;
321
+ updatedAt?: string;
322
+ connection?: RepoMeshPeerConnectionStatus;
274
323
  error?: string;
275
324
  }
325
+
326
+ export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
327
+
328
+ export interface RepoMeshQueueTask {
329
+ id: string;
330
+ meshId: string;
331
+ message: string;
332
+ status: RepoMeshQueueTaskStatus;
333
+ targetNodeId?: string;
334
+ targetSessionId?: string;
335
+ assignedNodeId?: string;
336
+ assignedSessionId?: string;
337
+ cancelReason?: string;
338
+ cancelledAt?: string;
339
+ requeueReason?: string;
340
+ requeuedAt?: string;
341
+ requeueCount?: number;
342
+ autoLaunch?: {
343
+ status: 'skipped' | 'started' | 'failed' | 'completed';
344
+ reason?: string;
345
+ nodeId?: string;
346
+ providerType?: string;
347
+ sessionId?: string;
348
+ updatedAt: string;
349
+ };
350
+ dispatchTimestamp?: string;
351
+ createdAt: string;
352
+ updatedAt: string;
353
+ }
354
+
355
+ export interface RepoMeshQueueSummary {
356
+ total: number;
357
+ active: number;
358
+ historical: number;
359
+ pending: number;
360
+ assigned: number;
361
+ completed: number;
362
+ failed: number;
363
+ cancelled: number;
364
+ activeCounts: {
365
+ pending: number;
366
+ assigned: number;
367
+ };
368
+ historicalCounts: {
369
+ completed: number;
370
+ failed: number;
371
+ cancelled: number;
372
+ };
373
+ activeAssignments: Array<{
374
+ id: string;
375
+ nodeId?: string;
376
+ sessionId?: string;
377
+ message: string;
378
+ }>;
379
+ }
380
+
381
+ export interface RepoMeshQueueStatus {
382
+ tasks: RepoMeshQueueTask[];
383
+ summary: RepoMeshQueueSummary;
384
+ }
385
+
386
+ export interface RepoMeshLedgerEntryStatus {
387
+ id: string;
388
+ meshId: string;
389
+ timestamp: string;
390
+ kind: string;
391
+ nodeId?: string;
392
+ sessionId?: string;
393
+ providerType?: string;
394
+ payload: Record<string, unknown>;
395
+ }
396
+
397
+ export interface RepoMeshLedgerSummaryStatus {
398
+ meshId: string;
399
+ totalEntries: number;
400
+ taskDispatched: number;
401
+ taskCompleted: number;
402
+ taskFailed: number;
403
+ taskStalled: number;
404
+ sessionLaunched: number;
405
+ checkpointCreated: number;
406
+ lastActivityAt: string | null;
407
+ recentFailures: number;
408
+ }
409
+
410
+ export interface RepoMeshLedgerStatus {
411
+ entries: RepoMeshLedgerEntryStatus[];
412
+ summary: RepoMeshLedgerSummaryStatus;
413
+ }