principles-disciple 1.7.1 → 1.7.2

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.
@@ -7,6 +7,7 @@ import { extractCommonSubstring } from '../utils/nlp.js';
7
7
  import { SystemLogger } from '../core/system-logger.js';
8
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
9
  import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
10
+ import { acquireLockAsync, releaseLock } from '../utils/file-lock.js';
10
11
  let intervalId = null;
11
12
  const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
12
13
  // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
@@ -61,84 +62,55 @@ export function summarizePainCandidateSample(text) {
61
62
  function isPendingPainCandidate(status) {
62
63
  return status === undefined || status === 'pending';
63
64
  }
64
- /**
65
- * Acquire an exclusive file lock for the given resource.
66
- * Returns a release function. Uses 'wx' flag for atomic exclusive create.
67
- * Detects stale locks by checking PID and mtime.
68
- */
69
- export function acquireQueueLock(lockPath, logger) {
70
- let retries = 0;
71
- while (retries < LOCK_MAX_RETRIES) {
72
- try {
73
- const fd = fs.openSync(lockPath, 'wx');
74
- fs.writeSync(fd, `${process.pid}\n${Date.now()}`);
75
- fs.closeSync(fd);
76
- return () => {
77
- try {
78
- fs.unlinkSync(lockPath);
79
- }
80
- catch { /* ignore */ }
81
- };
82
- }
83
- catch (err) {
84
- if (err.code === 'EEXIST') {
85
- // Check if lock is stale
86
- try {
87
- const stat = fs.statSync(lockPath);
88
- const content = fs.readFileSync(lockPath, 'utf8').trim();
89
- const pid = parseInt(content.split('\n')[0] || '0', 10);
90
- let isStale = false;
91
- if (pid > 0) {
92
- try {
93
- process.kill(pid, 0);
94
- }
95
- catch (e) {
96
- if (e.code === 'ESRCH')
97
- isStale = true;
98
- }
99
- if (!isStale && Date.now() - stat.mtimeMs > LOCK_STALE_MS)
100
- isStale = true;
101
- }
102
- else if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
103
- isStale = true;
104
- }
105
- if (isStale) {
106
- fs.unlinkSync(lockPath);
107
- retries++;
108
- const start = Date.now();
109
- while (Date.now() - start < LOCK_RETRY_DELAY_MS) { /* spin */ }
110
- continue;
111
- }
112
- }
113
- catch { /* stat/read failed, treat as busy */ }
114
- retries++;
115
- const start = Date.now();
116
- while (Date.now() - start < LOCK_RETRY_DELAY_MS) { /* spin */ }
117
- continue;
118
- }
119
- throw err;
120
- }
65
+ export async function acquireQueueLock(resourcePath, logger, lockSuffix = EVOLUTION_QUEUE_LOCK_SUFFIX) {
66
+ try {
67
+ const ctx = await acquireLockAsync(resourcePath, {
68
+ lockSuffix,
69
+ maxRetries: LOCK_MAX_RETRIES,
70
+ baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
71
+ lockStaleMs: LOCK_STALE_MS,
72
+ });
73
+ return () => releaseLock(ctx);
74
+ }
75
+ catch (error) {
76
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
77
+ throw error;
121
78
  }
122
- logger?.warn?.(`[PD:EvolutionWorker] Failed to acquire lock after ${LOCK_MAX_RETRIES} retries: ${lockPath}`);
123
- return null;
124
79
  }
125
- function normalizePainDedupKey(source, preview, reason) {
126
- // Include reason in dedup key to match createEvolutionTaskId() behavior
127
- // Different reasons for the same source/preview should create different tasks
128
- const normalizedReason = (reason || '').trim().toLowerCase();
129
- return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
80
+ async function requireQueueLock(resourcePath, logger, scope, lockSuffix = EVOLUTION_QUEUE_LOCK_SUFFIX) {
81
+ try {
82
+ return await acquireQueueLock(resourcePath, logger, lockSuffix);
83
+ }
84
+ catch {
85
+ throw new Error(`[PD:EvolutionWorker] ${scope}: queue lock unavailable for ${resourcePath}`);
86
+ }
130
87
  }
131
- export function hasRecentDuplicateTask(queue, source, preview, now, reason) {
88
+ export function extractEvolutionTaskId(task) {
89
+ if (!task)
90
+ return null;
91
+ const match = task.match(/\[ID:\s*([A-Za-z0-9_-]+)\]/);
92
+ return match?.[1] || null;
93
+ }
94
+ function findRecentDuplicateTask(queue, source, preview, now, reason) {
132
95
  const key = normalizePainDedupKey(source, preview, reason);
133
- return queue.some((task) => {
96
+ return queue.find((task) => {
134
97
  if (task.status === 'completed')
135
98
  return false;
136
- const taskTime = new Date(task.timestamp).getTime();
99
+ const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
137
100
  if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS)
138
101
  return false;
139
102
  return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
140
103
  });
141
104
  }
105
+ function normalizePainDedupKey(source, preview, reason) {
106
+ // Include reason in dedup key to match createEvolutionTaskId() behavior
107
+ // Different reasons for the same source/preview should create different tasks
108
+ const normalizedReason = (reason || '').trim().toLowerCase();
109
+ return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
110
+ }
111
+ export function hasRecentDuplicateTask(queue, source, preview, now, reason) {
112
+ return !!findRecentDuplicateTask(queue, source, preview, now, reason);
113
+ }
142
114
  export function hasEquivalentPromotedRule(dictionary, phrase) {
143
115
  const normalizedPhrase = phrase.trim().toLowerCase();
144
116
  return Object.values(dictionary.getAllRules()).some((rule) => {
@@ -153,7 +125,7 @@ export function hasEquivalentPromotedRule(dictionary, phrase) {
153
125
  return false;
154
126
  });
155
127
  }
156
- function checkPainFlag(wctx, logger) {
128
+ async function checkPainFlag(wctx, logger) {
157
129
  try {
158
130
  const painFlagPath = wctx.resolve('PAIN_FLAG');
159
131
  if (!fs.existsSync(painFlagPath))
@@ -182,10 +154,7 @@ function checkPainFlag(wctx, logger) {
182
154
  if (logger)
183
155
  logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
184
156
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
185
- const lockPath = queuePath + EVOLUTION_QUEUE_LOCK_SUFFIX;
186
- const releaseLock = acquireQueueLock(lockPath, logger);
187
- if (!releaseLock)
188
- return; // Could not acquire lock
157
+ const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
189
158
  try {
190
159
  let queue = [];
191
160
  if (fs.existsSync(queuePath)) {
@@ -198,9 +167,10 @@ function checkPainFlag(wctx, logger) {
198
167
  }
199
168
  }
200
169
  const now = Date.now();
201
- if (hasRecentDuplicateTask(queue, source, preview, now, reason)) {
170
+ const duplicateTask = findRecentDuplicateTask(queue, source, preview, now, reason);
171
+ if (duplicateTask) {
202
172
  logger?.info?.(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${source} preview=${preview || 'N/A'}`);
203
- fs.appendFileSync(painFlagPath, `\nstatus: queued\n`, 'utf8');
173
+ fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${duplicateTask.id}\n`, 'utf8');
204
174
  return;
205
175
  }
206
176
  const taskId = createEvolutionTaskId(source, score, preview, reason, now);
@@ -211,10 +181,11 @@ function checkPainFlag(wctx, logger) {
211
181
  reason,
212
182
  trigger_text_preview: preview,
213
183
  timestamp: new Date(now).toISOString(),
184
+ enqueued_at: new Date(now).toISOString(),
214
185
  status: 'pending'
215
186
  });
216
187
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
217
- fs.appendFileSync(painFlagPath, '\nstatus: queued\n', 'utf8');
188
+ fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
218
189
  }
219
190
  finally {
220
191
  releaseLock();
@@ -225,14 +196,12 @@ function checkPainFlag(wctx, logger) {
225
196
  logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
226
197
  }
227
198
  }
228
- function processEvolutionQueue(wctx, logger, eventLog) {
199
+ async function processEvolutionQueue(wctx, logger, eventLog) {
229
200
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
230
201
  if (!fs.existsSync(queuePath))
231
202
  return;
232
- const lockPath = queuePath + EVOLUTION_QUEUE_LOCK_SUFFIX;
233
- const releaseLock = acquireQueueLock(lockPath, logger);
234
- if (!releaseLock)
235
- return; // Could not acquire lock
203
+ const directivePath = wctx.resolve('EVOLUTION_DIRECTIVE');
204
+ const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
236
205
  try {
237
206
  let queue = [];
238
207
  try {
@@ -248,29 +217,29 @@ function processEvolutionQueue(wctx, logger, eventLog) {
248
217
  const timeout = config.get('intervals.task_timeout_ms') || (30 * 60 * 1000);
249
218
  for (const task of queue) {
250
219
  if (task.status === 'in_progress' && task.timestamp) {
251
- const age = Date.now() - new Date(task.timestamp).getTime();
220
+ const startedAt = task.started_at || task.timestamp;
221
+ const age = Date.now() - new Date(startedAt).getTime();
252
222
  if (age > timeout) {
253
223
  if (logger)
254
224
  logger.info(`[PD:EvolutionWorker] Resetting timed-out task: ${task.id}`);
255
225
  task.status = 'pending';
226
+ delete task.started_at;
227
+ delete task.assigned_session_key;
256
228
  queueChanged = true;
257
229
  }
258
230
  }
259
231
  }
260
232
  const pendingTasks = queue.filter(t => t.status === 'pending');
261
233
  if (pendingTasks.length > 0) {
262
- const directivePath = wctx.resolve('EVOLUTION_DIRECTIVE');
263
234
  const highestScoreTask = pendingTasks.sort((a, b) => b.score - a.score)[0];
235
+ const nowIso = new Date().toISOString();
264
236
  const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
265
237
  `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
266
- const directive = {
267
- active: true,
268
- task: taskDescription,
269
- timestamp: new Date().toISOString()
270
- };
271
- fs.writeFileSync(directivePath, JSON.stringify(directive, null, 2), 'utf8');
272
238
  highestScoreTask.task = taskDescription;
273
239
  highestScoreTask.status = 'in_progress';
240
+ highestScoreTask.started_at = nowIso;
241
+ delete highestScoreTask.completed_at;
242
+ delete highestScoreTask.assigned_session_key;
274
243
  queueChanged = true;
275
244
  if (eventLog) {
276
245
  eventLog.recordEvolutionTask({
@@ -279,6 +248,43 @@ function processEvolutionQueue(wctx, logger, eventLog) {
279
248
  reason: highestScoreTask.reason
280
249
  });
281
250
  }
251
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
252
+ queueChanged = false;
253
+ const directive = {
254
+ active: true,
255
+ taskId: highestScoreTask.id,
256
+ task: taskDescription,
257
+ timestamp: nowIso
258
+ };
259
+ try {
260
+ fs.writeFileSync(directivePath, JSON.stringify(directive, null, 2), 'utf8');
261
+ }
262
+ catch (directiveError) {
263
+ highestScoreTask.status = 'pending';
264
+ delete highestScoreTask.started_at;
265
+ delete highestScoreTask.task;
266
+ delete highestScoreTask.assigned_session_key;
267
+ try {
268
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
269
+ }
270
+ catch (rollbackError) {
271
+ throw new Error(`[PD:EvolutionWorker] Failed to persist directive and failed to roll back queue: ${String(directiveError)}; rollback=${String(rollbackError)}`);
272
+ }
273
+ throw directiveError;
274
+ }
275
+ }
276
+ else {
277
+ const hasInProgressTask = queue.some((task) => task.status === 'in_progress');
278
+ if (!hasInProgressTask && fs.existsSync(directivePath)) {
279
+ const clearedAt = new Date().toISOString();
280
+ fs.writeFileSync(directivePath, JSON.stringify({
281
+ active: false,
282
+ task: null,
283
+ taskId: null,
284
+ timestamp: clearedAt,
285
+ clearedAt,
286
+ }, null, 2), 'utf8');
287
+ }
282
288
  }
283
289
  if (queueChanged) {
284
290
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
@@ -343,7 +349,7 @@ async function processDetectionQueue(wctx, api, eventLog) {
343
349
  if (logger)
344
350
  logger.debug?.(`[PD:EvolutionWorker] L3 Semantic search failed: ${String(e)}`);
345
351
  }
346
- trackPainCandidate(text, wctx);
352
+ await trackPainCandidate(text, wctx);
347
353
  }
348
354
  }
349
355
  }
@@ -352,16 +358,11 @@ async function processDetectionQueue(wctx, api, eventLog) {
352
358
  logger.warn(`[PD:EvolutionWorker] Detection queue failed: ${String(err)}`);
353
359
  }
354
360
  }
355
- export function trackPainCandidate(text, wctx) {
361
+ export async function trackPainCandidate(text, wctx) {
356
362
  if (!shouldTrackPainCandidate(text))
357
363
  return;
358
364
  const candidatePath = wctx.resolve('PAIN_CANDIDATES');
359
- const lockPath = candidatePath + PAIN_CANDIDATES_LOCK_SUFFIX;
360
- const releaseLock = acquireQueueLock(lockPath, console);
361
- if (!releaseLock) {
362
- console.warn('[PD:EvolutionWorker] Failed to acquire pain candidates lock, skipping track');
363
- return;
364
- }
365
+ const releaseLock = await requireQueueLock(candidatePath, console, 'trackPainCandidate', PAIN_CANDIDATES_LOCK_SUFFIX);
365
366
  try {
366
367
  let data = { candidates: {} };
367
368
  if (fs.existsSync(candidatePath)) {
@@ -392,16 +393,11 @@ export function trackPainCandidate(text, wctx) {
392
393
  releaseLock();
393
394
  }
394
395
  }
395
- export function processPromotion(wctx, logger, eventLog) {
396
+ export async function processPromotion(wctx, logger, eventLog) {
396
397
  const candidatePath = wctx.resolve('PAIN_CANDIDATES');
397
398
  if (!fs.existsSync(candidatePath))
398
399
  return;
399
- const lockPath = candidatePath + PAIN_CANDIDATES_LOCK_SUFFIX;
400
- const releaseLock = acquireQueueLock(lockPath, logger);
401
- if (!releaseLock) {
402
- logger?.warn?.('[PD:EvolutionWorker] Failed to acquire pain candidates lock, skipping promotion');
403
- return;
404
- }
400
+ const releaseLock = await requireQueueLock(candidatePath, logger, 'processPromotion', PAIN_CANDIDATES_LOCK_SUFFIX);
405
401
  try {
406
402
  const config = wctx.config;
407
403
  const dictionary = wctx.dictionary;
@@ -453,6 +449,36 @@ export function processPromotion(wctx, logger, eventLog) {
453
449
  releaseLock();
454
450
  }
455
451
  }
452
+ export async function registerEvolutionTaskSession(workspaceResolve, taskId, sessionKey, logger) {
453
+ const queuePath = workspaceResolve('EVOLUTION_QUEUE');
454
+ if (!fs.existsSync(queuePath))
455
+ return false;
456
+ const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
457
+ try {
458
+ let queue;
459
+ try {
460
+ queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
461
+ }
462
+ catch (parseErr) {
463
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to parse EVOLUTION_QUEUE for session registration: ${queuePath} - ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
464
+ return false;
465
+ }
466
+ const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
467
+ if (!task) {
468
+ logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
469
+ return false;
470
+ }
471
+ task.assigned_session_key = sessionKey;
472
+ if (!task.started_at) {
473
+ task.started_at = new Date().toISOString();
474
+ }
475
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
476
+ return true;
477
+ }
478
+ finally {
479
+ releaseLock();
480
+ }
481
+ }
456
482
  export const EvolutionWorkerService = {
457
483
  id: 'principles-evolution-worker',
458
484
  api: null,
@@ -476,28 +502,32 @@ export const EvolutionWorkerService = {
476
502
  const initialDelay = 5000;
477
503
  const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
478
504
  intervalId = setInterval(() => {
479
- checkPainFlag(wctx, logger);
480
- processEvolutionQueue(wctx, logger, eventLog);
481
- if (api) {
482
- processDetectionQueue(wctx, api, eventLog).catch(err => {
483
- if (logger)
484
- logger.error(`[PD:EvolutionWorker] Error in detection queue: ${String(err)}`);
485
- });
486
- }
487
- processPromotion(wctx, logger, eventLog);
488
- wctx.dictionary.flush();
489
- flushAllSessions();
505
+ void (async () => {
506
+ await checkPainFlag(wctx, logger);
507
+ await processEvolutionQueue(wctx, logger, eventLog);
508
+ if (api) {
509
+ await processDetectionQueue(wctx, api, eventLog);
510
+ }
511
+ await processPromotion(wctx, logger, eventLog);
512
+ wctx.dictionary.flush();
513
+ flushAllSessions();
514
+ })().catch((err) => {
515
+ if (logger)
516
+ logger.error(`[PD:EvolutionWorker] Error in worker interval: ${String(err)}`);
517
+ });
490
518
  }, interval);
491
519
  setTimeout(() => {
492
- checkPainFlag(wctx, logger);
493
- processEvolutionQueue(wctx, logger, eventLog);
494
- if (api) {
495
- processDetectionQueue(wctx, api, eventLog).catch(err => {
496
- if (logger)
497
- logger.error(`[PD:EvolutionWorker] Startup detection queue failed: ${String(err)}`);
498
- });
499
- }
500
- processPromotion(wctx, logger, eventLog);
520
+ void (async () => {
521
+ await checkPainFlag(wctx, logger);
522
+ await processEvolutionQueue(wctx, logger, eventLog);
523
+ if (api) {
524
+ await processDetectionQueue(wctx, api, eventLog);
525
+ }
526
+ await processPromotion(wctx, logger, eventLog);
527
+ })().catch((err) => {
528
+ if (logger)
529
+ logger.error(`[PD:EvolutionWorker] Startup worker cycle failed: ${String(err)}`);
530
+ });
501
531
  }, initialDelay);
502
532
  },
503
533
  stop(ctx) {
@@ -73,6 +73,10 @@ export declare class RuntimeSummaryService {
73
73
  private static buildGfiSources;
74
74
  private static findLastPainSignal;
75
75
  private static buildGateStats;
76
+ private static resolveSessionSortTime;
77
+ private static mergeEvents;
78
+ private static getEventDedupKey;
79
+ private static resolveEvolutionDataQuality;
76
80
  private static readJsonFile;
77
81
  private static asFiniteNumber;
78
82
  }
@@ -27,7 +27,7 @@ export class RuntimeSummaryService {
27
27
  const bufferedEvents = hasBufferedEventAccess
28
28
  ? wctx.eventLog.getBufferedEvents()
29
29
  : [];
30
- const events = [...persistedEvents, ...bufferedEvents];
30
+ const events = this.mergeEvents(persistedEvents, bufferedEvents);
31
31
  const dailyStats = this.readJsonFile(path.join(wctx.stateDir, 'logs', 'daily-stats.json'), warnings, false);
32
32
  const today = generatedAt.slice(0, 10);
33
33
  const dailyGfiPeak = dailyStats?.[today]?.gfi?.peak;
@@ -69,7 +69,7 @@ export class RuntimeSummaryService {
69
69
  evolution: {
70
70
  queue: queueStats,
71
71
  directive: directiveSummary,
72
- dataQuality: queue ? 'authoritative' : 'partial',
72
+ dataQuality: this.resolveEvolutionDataQuality(queue, queueStats, directiveSummary),
73
73
  },
74
74
  pain: {
75
75
  activeFlag: Object.keys(painFlag).length > 0,
@@ -109,7 +109,7 @@ export class RuntimeSummaryService {
109
109
  pushWarning(warnings, `Failed to parse session snapshot: ${file}`);
110
110
  }
111
111
  }
112
- return sessions.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
112
+ return sessions.sort((a, b) => this.resolveSessionSortTime(b) - this.resolveSessionSortTime(a));
113
113
  }
114
114
  static selectSession(sessions, explicitSessionId) {
115
115
  if (explicitSessionId) {
@@ -133,9 +133,12 @@ export class RuntimeSummaryService {
133
133
  currentGfi: Number.isFinite(live.currentGfi) ? Number(live.currentGfi) : persisted?.currentGfi,
134
134
  dailyGfiPeak: Number.isFinite(live.dailyGfiPeak) ? Number(live.dailyGfiPeak) : persisted?.dailyGfiPeak,
135
135
  lastActivityAt: Number.isFinite(live.lastActivityAt) ? Number(live.lastActivityAt) : persisted?.lastActivityAt,
136
+ lastControlActivityAt: Number.isFinite(live.lastControlActivityAt)
137
+ ? Number(live.lastControlActivityAt)
138
+ : persisted?.lastControlActivityAt,
136
139
  });
137
140
  }
138
- return [...merged.values()].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
141
+ return [...merged.values()].sort((a, b) => this.resolveSessionSortTime(b) - this.resolveSessionSortTime(a));
139
142
  }
140
143
  static buildQueueStats(queue) {
141
144
  const stats = { pending: 0, inProgress: 0, completed: 0 };
@@ -298,6 +301,42 @@ export class RuntimeSummaryService {
298
301
  dataQuality: scoped.length > 0 ? 'authoritative' : 'partial',
299
302
  };
300
303
  }
304
+ static resolveSessionSortTime(session) {
305
+ return session.lastControlActivityAt ?? session.lastActivityAt ?? 0;
306
+ }
307
+ static mergeEvents(persistedEvents, bufferedEvents) {
308
+ const merged = new Map();
309
+ for (const entry of [...persistedEvents, ...bufferedEvents]) {
310
+ merged.set(this.getEventDedupKey(entry), entry);
311
+ }
312
+ return [...merged.values()].sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
313
+ }
314
+ static getEventDedupKey(entry) {
315
+ const eventId = typeof entry.data?.eventId === 'string' ? entry.data.eventId : null;
316
+ if (eventId) {
317
+ return `${entry.type}:${entry.sessionId ?? 'none'}:${eventId}`;
318
+ }
319
+ return [
320
+ entry.ts ?? 'no-ts',
321
+ entry.type ?? 'no-type',
322
+ entry.category ?? 'no-category',
323
+ entry.sessionId ?? 'no-session',
324
+ typeof entry.data?.source === 'string' ? entry.data.source : 'no-source',
325
+ typeof entry.data?.toolName === 'string' ? entry.data.toolName : 'no-tool',
326
+ typeof entry.data?.reason === 'string' ? entry.data.reason : 'no-reason',
327
+ ].join('::');
328
+ }
329
+ static resolveEvolutionDataQuality(queue, queueStats, directive) {
330
+ if (!queue)
331
+ return 'partial';
332
+ if (queueStats.inProgress > 0 && (!directive.exists || directive.active !== true)) {
333
+ return 'partial';
334
+ }
335
+ if (directive.active && queueStats.inProgress === 0 && queueStats.pending === 0) {
336
+ return 'partial';
337
+ }
338
+ return 'authoritative';
339
+ }
301
340
  static readJsonFile(filePath, warnings, warnOnMissing) {
302
341
  if (!fs.existsSync(filePath)) {
303
342
  if (warnOnMissing) {
@@ -5,8 +5,11 @@
5
5
  * Uses the low-level OpenClaw Subagent API.
6
6
  */
7
7
  import { Type } from '@sinclair/typebox';
8
+ import * as fs from 'fs';
8
9
  import { randomUUID } from 'node:crypto';
9
10
  import { loadAgentDefinition, listAvailableAgents } from '../core/agent-loader.js';
11
+ import { resolvePdPath } from '../core/paths.js';
12
+ import { extractEvolutionTaskId, registerEvolutionTaskSession } from '../service/evolution-worker.js';
10
13
  /**
11
14
  * Extract assistant text from session messages
12
15
  */
@@ -40,6 +43,23 @@ function extractAssistantText(messages) {
40
43
  }
41
44
  return '';
42
45
  }
46
+ async function registerDiagnosticianRun(api, task, sessionKey) {
47
+ const taskId = extractEvolutionTaskId(task);
48
+ if (!taskId)
49
+ return;
50
+ try {
51
+ const workspaceDir = api.resolvePath('.');
52
+ const queuePath = resolvePdPath(workspaceDir, 'EVOLUTION_QUEUE');
53
+ if (!fs.existsSync(queuePath)) {
54
+ api.logger?.warn?.(`[PD:AgentSpawn] Evolution task ${taskId} not registered because queue file is missing`);
55
+ return;
56
+ }
57
+ await registerEvolutionTaskSession((key) => resolvePdPath(workspaceDir, key), taskId, sessionKey, api.logger);
58
+ }
59
+ catch (error) {
60
+ api.logger?.warn?.(`[PD:AgentSpawn] Failed to register evolution task session: ${String(error)}`);
61
+ }
62
+ }
43
63
  /**
44
64
  * Build the full system prompt for a subagent
45
65
  * Combines the agent definition with any context-specific additions
@@ -285,6 +305,9 @@ pd_run_worker(
285
305
  deliver: false, // Critical: don't send directly to external channels
286
306
  idempotencyKey: randomUUID(),
287
307
  });
308
+ if (agentType === 'diagnostician') {
309
+ await registerDiagnosticianRun(api, task, sessionKey);
310
+ }
288
311
  if (runAsync) {
289
312
  const duration = Date.now() - startTime;
290
313
  return {
@@ -28,6 +28,11 @@ export interface LockContext {
28
28
  /** 获取锁的时间 */
29
29
  acquiredAt: number;
30
30
  }
31
+ export declare class LockAcquisitionError extends Error {
32
+ readonly filePath: string;
33
+ readonly lockPath: string;
34
+ constructor(message: string, filePath: string, lockPath: string);
35
+ }
31
36
  /**
32
37
  * 获取文件锁
33
38
  *
@@ -37,6 +42,7 @@ export interface LockContext {
37
42
  * @throws Error 如果无法获取锁
38
43
  */
39
44
  export declare function acquireLock(filePath: string, options?: LockOptions): LockContext;
45
+ export declare function acquireLockAsync(filePath: string, options?: LockOptions): Promise<LockContext>;
40
46
  /**
41
47
  * 释放文件锁
42
48
  *
@@ -52,6 +58,7 @@ export declare function releaseLock(ctx: LockContext): void;
52
58
  * @returns 操作的返回值
53
59
  */
54
60
  export declare function withLock<T>(filePath: string, fn: () => T, options?: LockOptions): T;
61
+ export declare function withLockAsync<T>(filePath: string, fn: () => Promise<T>, options?: LockOptions): Promise<T>;
55
62
  export declare function withAsyncLock<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
56
63
  /**
57
64
  * 检查锁状态(用于调试)