principles-disciple 1.7.1 → 1.7.3

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.
@@ -4,13 +4,20 @@ import { SystemLogger } from './system-logger.js';
4
4
  const sessions = new Map();
5
5
  /** Directory for persisting session state */
6
6
  let persistDir = null;
7
- /** Debounce timer for persistence */
8
- let persistTimer = null;
7
+ /** Debounce timers for persistence, one per session */
8
+ const persistTimers = new Map();
9
9
  function logSessionTrackerWarning(message, error) {
10
10
  const detail = error instanceof Error ? error.message : error ? String(error) : '';
11
11
  const suffix = detail ? `: ${detail}` : '';
12
12
  console.warn(`[PD:SessionTracker] ${message}${suffix}`);
13
13
  }
14
+ function touchActivity(state, kind = 'general') {
15
+ const now = Date.now();
16
+ state.lastActivityAt = now;
17
+ if (kind === 'control') {
18
+ state.lastControlActivityAt = now;
19
+ }
20
+ }
14
21
  /**
15
22
  * Initialize persistence for session state.
16
23
  * Call this once during plugin startup.
@@ -82,18 +89,24 @@ function persistSession(state) {
82
89
  * Schedule persistence with debounce.
83
90
  */
84
91
  function schedulePersistence(state) {
85
- if (persistTimer) {
86
- clearTimeout(persistTimer);
92
+ const existing = persistTimers.get(state.sessionId);
93
+ if (existing) {
94
+ clearTimeout(existing);
87
95
  }
88
- persistTimer = setTimeout(() => {
96
+ const timer = setTimeout(() => {
89
97
  persistSession(state);
90
- persistTimer = null;
98
+ persistTimers.delete(state.sessionId);
91
99
  }, 1000); // 1 second debounce
100
+ persistTimers.set(state.sessionId, timer);
92
101
  }
93
102
  /**
94
103
  * Force persist all sessions immediately.
95
104
  */
96
105
  export function flushAllSessions() {
106
+ for (const timer of persistTimers.values()) {
107
+ clearTimeout(timer);
108
+ }
109
+ persistTimers.clear();
97
110
  for (const state of sessions.values()) {
98
111
  persistSession(state);
99
112
  }
@@ -108,6 +121,7 @@ function getOrCreateSession(sessionId, workspaceDir) {
108
121
  llmTurns: 0,
109
122
  blockedAttempts: 0,
110
123
  lastActivityAt: Date.now(),
124
+ lastControlActivityAt: Date.now(),
111
125
  totalInputTokens: 0,
112
126
  totalOutputTokens: 0,
113
127
  cacheHits: 0,
@@ -141,13 +155,13 @@ export function trackToolRead(sessionId, filePath, workspaceDir) {
141
155
  const state = getOrCreateSession(sessionId, workspaceDir);
142
156
  const normalizedPath = path.posix.normalize(filePath.replace(/\\/g, '/'));
143
157
  state.toolReadsByFile[normalizedPath] = (state.toolReadsByFile[normalizedPath] || 0) + 1;
144
- state.lastActivityAt = Date.now();
158
+ touchActivity(state);
145
159
  return state;
146
160
  }
147
161
  export function trackLlmOutput(sessionId, usage, config, workspaceDir) {
148
162
  const state = getOrCreateSession(sessionId, workspaceDir);
149
163
  state.llmTurns += 1;
150
- state.lastActivityAt = Date.now();
164
+ touchActivity(state);
151
165
  if (usage) {
152
166
  state.totalInputTokens += usage.input || 0;
153
167
  state.totalOutputTokens += usage.output || 0;
@@ -195,7 +209,7 @@ export function trackFriction(sessionId, deltaF, hash, workspaceDir, options) {
195
209
  state.currentGfi = (state.currentGfi || 0) + addedFriction;
196
210
  const sourceKey = options?.source || (hash ? `unattributed:${hash}` : 'unattributed:unknown');
197
211
  ledger[sourceKey] = (ledger[sourceKey] || 0) + addedFriction;
198
- state.lastActivityAt = Date.now();
212
+ touchActivity(state, 'control');
199
213
  SystemLogger.log(state.workspaceDir, 'GFI_INC', `Friction added: +${addedFriction.toFixed(1)} (Base: ${deltaF}, Mult: ${multiplier.toFixed(2)}). Total GFI: ${state.currentGfi.toFixed(1)}`);
200
214
  // Update daily stats
201
215
  state.dailyToolFailures++;
@@ -228,6 +242,7 @@ export function resetFriction(sessionId, workspaceDir, options) {
228
242
  state.lastErrorSource = '';
229
243
  }
230
244
  }
245
+ touchActivity(state, 'control');
231
246
  schedulePersistence(state);
232
247
  return state;
233
248
  }
@@ -239,6 +254,7 @@ export function resetFriction(sessionId, workspaceDir, options) {
239
254
  state.lastErrorSource = '';
240
255
  state.consecutiveErrors = 0;
241
256
  state.lastErrorHash = '';
257
+ touchActivity(state, 'control');
242
258
  // Schedule persistence
243
259
  schedulePersistence(state);
244
260
  return state;
@@ -250,6 +266,7 @@ export function resetFriction(sessionId, workspaceDir, options) {
250
266
  export function recordThinkingCheckpoint(sessionId, workspaceDir) {
251
267
  const state = getOrCreateSession(sessionId, workspaceDir);
252
268
  state.lastThinkingTimestamp = Date.now();
269
+ touchActivity(state, 'control');
253
270
  SystemLogger.log(state.workspaceDir, 'THINKING_CHECKPOINT', `Deep thinking recorded at ${new Date(state.lastThinkingTimestamp).toISOString()}`);
254
271
  schedulePersistence(state);
255
272
  return state;
@@ -269,13 +286,14 @@ export function hasRecentThinking(sessionId, windowMs = 5 * 60 * 1000) {
269
286
  export function trackBlock(sessionId) {
270
287
  const state = getOrCreateSession(sessionId);
271
288
  state.blockedAttempts += 1;
272
- state.lastActivityAt = Date.now();
289
+ touchActivity(state, 'control');
290
+ schedulePersistence(state);
273
291
  return state;
274
292
  }
275
293
  export function setInjectedProbationIds(sessionId, ids, workspaceDir) {
276
294
  const state = getOrCreateSession(sessionId, workspaceDir);
277
295
  state.injectedProbationIds = [...ids];
278
- state.lastActivityAt = Date.now();
296
+ touchActivity(state, 'control');
279
297
  schedulePersistence(state);
280
298
  return state;
281
299
  }
@@ -300,6 +318,11 @@ export function listSessions(workspaceDir) {
300
318
  }));
301
319
  }
302
320
  export function clearSession(sessionId) {
321
+ const timer = persistTimers.get(sessionId);
322
+ if (timer) {
323
+ clearTimeout(timer);
324
+ persistTimers.delete(sessionId);
325
+ }
303
326
  sessions.delete(sessionId);
304
327
  }
305
328
  // Memory cleanup for abandoned sessions (older than 2 hours)
@@ -307,6 +330,11 @@ export function garbageCollectSessions() {
307
330
  const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
308
331
  for (const [id, state] of sessions.entries()) {
309
332
  if (state.lastActivityAt < twoHoursAgo) {
333
+ const timer = persistTimers.get(id);
334
+ if (timer) {
335
+ clearTimeout(timer);
336
+ persistTimers.delete(id);
337
+ }
310
338
  sessions.delete(id);
311
339
  // Also delete persisted file
312
340
  if (persistDir) {
@@ -21,8 +21,7 @@ export interface TrustScorecard {
21
21
  reward_policy?: 'frozen_all_positive' | 'frozen_atomic_positive_keep_plan_ready';
22
22
  }
23
23
  export type TrustStage = 1 | 2 | 3 | 4;
24
- export declare const EXPLORATORY_TOOLS: string[];
25
- export declare const CONSTRUCTIVE_TOOLS: string[];
24
+ export declare const EXPLORATORY_TOOLS: Set<string>;
26
25
  export declare class TrustEngine {
27
26
  private scorecard;
28
27
  private workspaceDir;
@@ -8,27 +8,8 @@ import { EventLogService } from './event-log.js';
8
8
  import { resolvePdPath } from './paths.js';
9
9
  import { ConfigService } from './config-service.js';
10
10
  import { TrajectoryRegistry } from './trajectory.js';
11
- export const EXPLORATORY_TOOLS = [
12
- // 文件读取
13
- 'read', 'read_file', 'read_many_files', 'image_read',
14
- // 搜索和列表
15
- 'search_file_content', 'grep', 'grep_search', 'list_directory', 'ls', 'glob',
16
- // Web
17
- 'web_fetch', 'web_search',
18
- // 用户交互
19
- 'ask_user', 'ask_user_question',
20
- // LSP
21
- 'lsp_hover', 'lsp_goto_definition', 'lsp_find_references',
22
- // 内存和状态
23
- 'memory_recall', 'save_memory', 'todo_read', 'todo_write',
24
- // 状态查询
25
- 'pd-status', 'trust', 'report',
26
- ];
27
- export const CONSTRUCTIVE_TOOLS = [
28
- 'write', 'write_file', 'edit', 'edit_file', 'replace', 'apply_patch',
29
- 'insert', 'patch', 'delete_file', 'move_file', 'run_shell_command',
30
- 'pd_run_worker', 'sessions_spawn', 'evolve-task', 'init-strategy'
31
- ];
11
+ import { EXPLORATORY_TOOLS as SHARED_EXPLORATORY_TOOLS } from '../constants/tools.js';
12
+ export const EXPLORATORY_TOOLS = new Set(SHARED_EXPLORATORY_TOOLS);
32
13
  const LEGACY_TRUST_REWARD_POLICY = 'frozen_all_positive';
33
14
  export class TrustEngine {
34
15
  scorecard;
@@ -130,7 +111,7 @@ export class TrustEngine {
130
111
  recordSuccess(reason, context, isSubagent = false) {
131
112
  const toolName = context?.toolName;
132
113
  // 1. Check if this is an exploratory tool success
133
- const isExploratory = toolName && EXPLORATORY_TOOLS.includes(toolName);
114
+ const isExploratory = toolName ? EXPLORATORY_TOOLS.has(toolName) : false;
134
115
  if (reason === 'tool_success' && isExploratory) {
135
116
  this.scorecard.exploratory_failure_streak = 0;
136
117
  this.touchScorecard();
@@ -147,7 +128,7 @@ export class TrustEngine {
147
128
  const penalties = settings.penalties;
148
129
  const toolName = context?.toolName;
149
130
  // 1. Classification: Is this an exploratory failure?
150
- const isExploratory = toolName && EXPLORATORY_TOOLS.includes(toolName);
131
+ const isExploratory = toolName ? EXPLORATORY_TOOLS.has(toolName) : false;
151
132
  // 2. Cold start grace (only for non-risky actions)
152
133
  if (type !== 'risky' && this.isColdStart() && (this.scorecard.grace_failures_remaining || 0) > 0) {
153
134
  this.scorecard.grace_failures_remaining = (this.scorecard.grace_failures_remaining || 0) - 1;
@@ -8,32 +8,14 @@ import { assessRiskLevel, estimateLineChanges } from '../core/risk-calculator.js
8
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
9
  import { checkEvolutionGate } from '../core/evolution-engine.js';
10
10
  import { EventLogService } from '../core/event-log.js';
11
+ import { AGENT_TOOLS, BASH_TOOLS_SET, HIGH_RISK_TOOLS, LOW_RISK_WRITE_TOOLS, WRITE_TOOLS, } from '../constants/tools.js';
11
12
  // ═══ GFI Gate Tool Tiers ═══
12
13
  // TIER 0: 只读工具 - 永不拦截
13
- const READ_ONLY_TOOLS = new Set([
14
- 'read', 'read_file', 'read_many_files', 'image_read',
15
- 'search_file_content', 'grep', 'grep_search', 'list_directory', 'ls', 'glob',
16
- 'lsp_hover', 'lsp_goto_definition', 'lsp_find_references',
17
- 'web_fetch', 'web_search', 'ref_search_documentation', 'ref_read_url',
18
- 'resolve-library-id', 'get-library-docs',
19
- 'todo_read', 'save_memory',
20
- 'deep_reflect',
21
- ]);
22
14
  // TIER 1: 低风险修改 - GFI >= low_risk_block 时拦截
23
15
  // 注意:pd_run_worker、sessions_spawn、task 是 Agent 派生工具,不应被 GFI Gate 拦截
24
16
  // 它们属于 AGENT_TOOLS,在早期过滤后直接放行
25
- const LOW_RISK_WRITE_TOOLS = new Set([
26
- 'write', 'write_file',
27
- 'edit', 'edit_file', 'replace', 'apply_patch', 'insert', 'patch',
28
- ]);
29
17
  // TIER 2: 高风险操作 - GFI >= high_risk_block 时拦截
30
- const HIGH_RISK_TOOLS = new Set([
31
- 'delete_file', 'move_file',
32
- ]);
33
18
  // TIER 3: Bash 命令 - 根据内容判断
34
- const BASH_TOOLS_SET = new Set([
35
- 'bash', 'run_shell_command', 'exec', 'execute', 'shell', 'cmd',
36
- ]);
37
19
  /**
38
20
  * 分析 Bash 命令风险等级
39
21
  */
@@ -118,12 +100,9 @@ function calculateDynamicThreshold(baseThreshold, trustStage, lineChanges, confi
118
100
  export function handleBeforeToolCall(event, ctx) {
119
101
  const logger = ctx.logger || console;
120
102
  // 1. Identify tool type
121
- const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'replace', 'insert', 'patch', 'edit_file', 'delete_file', 'move_file'];
122
- const BASH_TOOLS = ['bash', 'run_shell_command', 'exec', 'execute', 'shell', 'cmd'];
123
- const AGENT_TOOLS = ['pd_run_worker', 'sessions_spawn'];
124
- const isBash = BASH_TOOLS.includes(event.toolName);
125
- const isWriteTool = WRITE_TOOLS.includes(event.toolName);
126
- const isAgentTool = AGENT_TOOLS.includes(event.toolName);
103
+ const isBash = BASH_TOOLS_SET.has(event.toolName);
104
+ const isWriteTool = WRITE_TOOLS.has(event.toolName);
105
+ const isAgentTool = AGENT_TOOLS.has(event.toolName);
127
106
  // Profile loaded first for config-driven behavior (see below)
128
107
  if (!ctx.workspaceDir || (!isWriteTool && !isBash && !isAgentTool)) {
129
108
  return;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { getSession, resetFriction } from '../core/session-tracker.js';
3
+ import { clearInjectedProbationIds, getSession, resetFriction, setInjectedProbationIds } from '../core/session-tracker.js';
4
4
  import { WorkspaceContext } from '../core/workspace-context.js';
5
5
  import { defaultContextConfig } from '../types.js';
6
6
  import { extractSummary, getHistoryVersions } from '../core/focus-history.js';
@@ -575,6 +575,14 @@ ACTION: Run self-audit. If stable, reply ONLY with "HEARTBEAT_OK".
575
575
  const reducer = wctx.evolutionReducer;
576
576
  const active = reducer.getActivePrinciples().slice(-3);
577
577
  const probation = reducer.getProbationPrinciples().slice(0, 5);
578
+ if (ctx.sessionId) {
579
+ if (probation.length > 0) {
580
+ setInjectedProbationIds(ctx.sessionId, probation.map((p) => p.id), workspaceDir);
581
+ }
582
+ else {
583
+ clearInjectedProbationIds(ctx.sessionId, workspaceDir);
584
+ }
585
+ }
578
586
  if (active.length > 0 || probation.length > 0) {
579
587
  const lines = [];
580
588
  if (active.length > 0) {
@@ -593,6 +601,9 @@ ACTION: Run self-audit. If stable, reply ONLY with "HEARTBEAT_OK".
593
601
  }
594
602
  }
595
603
  catch (e) {
604
+ if (ctx.sessionId) {
605
+ clearInjectedProbationIds(ctx.sessionId, workspaceDir);
606
+ }
596
607
  logger?.warn?.(`[PD:Prompt] Failed to load evolution principles: ${String(e)}`);
597
608
  }
598
609
  // Build appendSystemContext with recency effect
@@ -1,8 +1,22 @@
1
+ import * as fs from 'fs';
1
2
  import { writePainFlag } from '../core/pain.js';
2
3
  import { WorkspaceContext } from '../core/workspace-context.js';
3
4
  import { empathyObserverManager } from '../service/empathy-observer-manager.js';
4
- import { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from '../service/evolution-worker.js';
5
- import * as fs from 'fs';
5
+ import { acquireQueueLock } from '../service/evolution-worker.js';
6
+ const COMPLETION_RETRY_DELAY_MS = 250;
7
+ const COMPLETION_MAX_RETRIES = 3;
8
+ const COMPLETION_RETRY_TTL_MS = 60 * 60 * 1000; // 1 hour TTL for retry entries
9
+ const DIAGNOSTICIAN_SESSION_PREFIX = 'agent:diagnostician:';
10
+ const completionRetryCounts = new Map();
11
+ // Cleanup expired retry entries periodically
12
+ function cleanupExpiredRetryEntries() {
13
+ const now = Date.now();
14
+ for (const [key, value] of completionRetryCounts.entries()) {
15
+ if (now > value.expires) {
16
+ completionRetryCounts.delete(key);
17
+ }
18
+ }
19
+ }
6
20
  function emitSubagentPainEvent(wctx, payload) {
7
21
  try {
8
22
  wctx.evolutionReducer.emitSync({
@@ -22,19 +36,73 @@ function emitSubagentPainEvent(wctx, payload) {
22
36
  console.warn(`[PD:Subagent] failed to emit evolution event: ${String(e)}`);
23
37
  }
24
38
  }
39
+ function isDiagnosticianSession(targetSessionKey) {
40
+ return typeof targetSessionKey === 'string' && targetSessionKey.startsWith(DIAGNOSTICIAN_SESSION_PREFIX);
41
+ }
42
+ function cleanupPainFlagForTask(wctx, completedTaskId, queue) {
43
+ const painFlagPath = wctx.resolve('PAIN_FLAG');
44
+ try {
45
+ const painData = fs.readFileSync(painFlagPath, 'utf8');
46
+ const taskIdMatch = painData.match(/^task_id:\s*(.+)$/m);
47
+ const painTaskId = taskIdMatch?.[1]?.trim();
48
+ const hasQueuedStatus = painData.includes('status: queued');
49
+ const hasRemainingActiveTasks = queue.some((task) => task?.status === 'pending' || task?.status === 'in_progress');
50
+ if (!hasQueuedStatus)
51
+ return;
52
+ if (painTaskId) {
53
+ if (painTaskId === completedTaskId) {
54
+ fs.unlinkSync(painFlagPath);
55
+ }
56
+ return;
57
+ }
58
+ // Legacy fallback: only clear an untagged queued pain flag when there are
59
+ // no active queue entries left. This avoids unrelated diagnostician runs
60
+ // from deleting a queued flag that belongs to another task.
61
+ if (!hasRemainingActiveTasks) {
62
+ fs.unlinkSync(painFlagPath);
63
+ }
64
+ }
65
+ catch (e) {
66
+ if (e.code === 'ENOENT')
67
+ return; // File doesn't exist, nothing to clean up
68
+ console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
69
+ }
70
+ }
71
+ function getCompletionRetryKey(workspaceDir, targetSessionKey) {
72
+ return `${workspaceDir}::${targetSessionKey}`;
73
+ }
74
+ function scheduleCompletionRetry(event, ctx, attempt) {
75
+ const workspaceDir = ctx.workspaceDir;
76
+ const targetSessionKey = event.targetSessionKey;
77
+ if (!workspaceDir || !targetSessionKey || attempt >= COMPLETION_MAX_RETRIES) {
78
+ return;
79
+ }
80
+ cleanupExpiredRetryEntries();
81
+ const retryKey = getCompletionRetryKey(workspaceDir, targetSessionKey);
82
+ completionRetryCounts.set(retryKey, {
83
+ count: attempt + 1,
84
+ expires: Date.now() + COMPLETION_RETRY_TTL_MS
85
+ });
86
+ setTimeout(() => {
87
+ void handleSubagentEnded(event, ctx).finally(() => {
88
+ const entry = completionRetryCounts.get(retryKey);
89
+ if (!entry || entry.count <= attempt + 1) {
90
+ completionRetryCounts.delete(retryKey);
91
+ }
92
+ });
93
+ }, COMPLETION_RETRY_DELAY_MS);
94
+ }
25
95
  export async function handleSubagentEnded(event, ctx) {
26
96
  const { outcome, targetSessionKey } = event;
27
97
  const workspaceDir = ctx.workspaceDir;
28
98
  if (!workspaceDir)
29
99
  return;
30
100
  const wctx = WorkspaceContext.fromHookContext(ctx);
31
- // Empathy observer subagent session is handled by sidecar manager
32
101
  if (targetSessionKey?.startsWith('empathy_obs:')) {
33
102
  await empathyObserverManager.reap(ctx.api, targetSessionKey, workspaceDir);
34
103
  return;
35
104
  }
36
105
  const config = wctx.config;
37
- // 1. Autonomous Pain Capture: If subagent failed, record pain
38
106
  if (outcome === 'error' || outcome === 'timeout') {
39
107
  const scoreSettings = config.get('scores');
40
108
  const score = outcome === 'error' ? scoreSettings.subagent_error_penalty : scoreSettings.subagent_timeout_penalty;
@@ -53,70 +121,48 @@ export async function handleSubagentEnded(event, ctx) {
53
121
  sessionId: ctx.sessionId,
54
122
  });
55
123
  }
56
- // 2. Loop Closure: Clean up evolution queue if any subagent finished successfully
57
124
  if (outcome === 'ok' || outcome === 'deleted') {
58
- // ── Trust Engine: Record success using V2 API ──
59
125
  wctx.trust.recordSuccess('subagent_success', {
60
126
  sessionId: ctx.sessionId,
61
127
  api: ctx.api
62
128
  }, true);
63
- const queuePath = wctx.resolve('EVOLUTION_QUEUE');
64
- if (fs.existsSync(queuePath)) {
65
- const lockPath = queuePath + EVOLUTION_QUEUE_LOCK_SUFFIX;
66
- const releaseLock = acquireQueueLock(lockPath, console);
67
- if (!releaseLock) {
68
- console.warn('[PD:Subagent] Failed to acquire queue lock, skipping queue update');
69
- return;
70
- }
71
- try {
72
- const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
73
- let changed = false;
74
- const resolveTaskTime = (task) => {
75
- const raw = task?.enqueued_at || task?.timestamp;
76
- const ts = new Date(raw).getTime();
77
- return Number.isFinite(ts) ? ts : Number.MAX_SAFE_INTEGER;
78
- };
79
- // Resolve the queue entry by its position, not by id. Historical pain
80
- // records may legitimately share the same id in legacy data.
81
- let oldestTaskIndex = -1;
82
- let oldestTaskTime = Number.MAX_SAFE_INTEGER;
83
- queue.forEach((task, index) => {
84
- if (task?.status !== 'in_progress')
85
- return;
86
- const taskTime = resolveTaskTime(task);
87
- if (taskTime < oldestTaskTime) {
88
- oldestTaskTime = taskTime;
89
- oldestTaskIndex = index;
90
- }
91
- });
92
- if (oldestTaskIndex !== -1) {
93
- queue[oldestTaskIndex].status = 'completed';
94
- queue[oldestTaskIndex].completed_at = new Date().toISOString();
95
- changed = true;
96
- // Clean up the .pain_flag if it was queued, to reset the environment
97
- const painFlagPath = wctx.resolve('PAIN_FLAG');
98
- if (fs.existsSync(painFlagPath)) {
99
- try {
100
- const painData = fs.readFileSync(painFlagPath, 'utf8');
101
- if (painData.includes('status: queued')) {
102
- fs.unlinkSync(painFlagPath);
103
- }
104
- }
105
- catch (e) {
106
- console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
107
- }
108
- }
109
- }
110
- if (changed) {
111
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
112
- }
113
- }
114
- catch (e) {
115
- console.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
116
- }
117
- finally {
118
- releaseLock();
119
- }
129
+ }
130
+ if ((outcome !== 'ok' && outcome !== 'deleted') || !isDiagnosticianSession(targetSessionKey)) {
131
+ return;
132
+ }
133
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
134
+ if (!fs.existsSync(queuePath))
135
+ return;
136
+ const retryKey = getCompletionRetryKey(workspaceDir, targetSessionKey);
137
+ const retryEntry = completionRetryCounts.get(retryKey);
138
+ const attempt = retryEntry?.count || 0;
139
+ let releaseLock = null;
140
+ try {
141
+ releaseLock = await acquireQueueLock(queuePath, console);
142
+ const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
143
+ let completedTaskId = null;
144
+ const matchedTask = queue.find((task) => task?.status === 'in_progress'
145
+ && typeof task?.assigned_session_key === 'string'
146
+ && task.assigned_session_key === targetSessionKey);
147
+ if (matchedTask) {
148
+ matchedTask.status = 'completed';
149
+ matchedTask.completed_at = new Date().toISOString();
150
+ delete matchedTask.assigned_session_key;
151
+ completedTaskId = matchedTask.id;
152
+ }
153
+ else {
154
+ console.warn(`[PD:Subagent] No in-progress evolution task matched subagent session ${targetSessionKey}`);
120
155
  }
156
+ if (completedTaskId) {
157
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
158
+ cleanupPainFlagForTask(wctx, completedTaskId, queue);
159
+ }
160
+ }
161
+ catch (e) {
162
+ console.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
163
+ scheduleCompletionRetry(event, ctx, attempt);
164
+ }
165
+ finally {
166
+ releaseLock?.();
121
167
  }
122
168
  }
@@ -2,6 +2,8 @@ export interface OverviewResponse {
2
2
  workspaceDir: string;
3
3
  generatedAt: string;
4
4
  dataFreshness: string | null;
5
+ dataSource: 'trajectory_db_analytics';
6
+ runtimeControlPlaneSource: 'pd_evolution_status';
5
7
  summary: {
6
8
  repeatErrorRate: number;
7
9
  userCorrectionRate: number;
@@ -98,6 +98,8 @@ export class ControlUiQueryService {
98
98
  workspaceDir: this.workspaceDir,
99
99
  generatedAt: new Date().toISOString(),
100
100
  dataFreshness: stats.lastIngestAt,
101
+ dataSource: 'trajectory_db_analytics',
102
+ runtimeControlPlaneSource: 'pd_evolution_status',
101
103
  summary: {
102
104
  repeatErrorRate: roundRate(Number(failureStats.repeated_failures), Number(failureStats.total_failures)),
103
105
  userCorrectionRate: roundRate(correctionTotal, stats.userTurns),
@@ -7,6 +7,10 @@ export interface EvolutionQueueItem {
7
7
  source: string;
8
8
  reason: string;
9
9
  timestamp: string;
10
+ enqueued_at?: string;
11
+ started_at?: string;
12
+ completed_at?: string;
13
+ assigned_session_key?: string;
10
14
  trigger_text_preview?: string;
11
15
  status: 'pending' | 'in_progress' | 'completed';
12
16
  }
@@ -19,12 +23,8 @@ export declare function createEvolutionTaskId(source: string, score: number, pre
19
23
  export declare function shouldTrackPainCandidate(text: string): boolean;
20
24
  export declare function createPainCandidateFingerprint(text: string): string;
21
25
  export declare function summarizePainCandidateSample(text: string): string;
22
- /**
23
- * Acquire an exclusive file lock for the given resource.
24
- * Returns a release function. Uses 'wx' flag for atomic exclusive create.
25
- * Detects stale locks by checking PID and mtime.
26
- */
27
- export declare function acquireQueueLock(lockPath: string, logger: any): (() => void) | null;
26
+ export declare function acquireQueueLock(resourcePath: string, logger: any, lockSuffix?: string): Promise<() => void>;
27
+ export declare function extractEvolutionTaskId(task: string): string | null;
28
28
  export declare function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean;
29
29
  export declare function hasEquivalentPromotedRule(dictionary: {
30
30
  getAllRules(): Record<string, {
@@ -34,8 +34,12 @@ export declare function hasEquivalentPromotedRule(dictionary: {
34
34
  status: string;
35
35
  }>;
36
36
  }, phrase: string): boolean;
37
- export declare function trackPainCandidate(text: string, wctx: WorkspaceContext): void;
38
- export declare function processPromotion(wctx: WorkspaceContext, logger: any, eventLog: any): void;
37
+ export declare function trackPainCandidate(text: string, wctx: WorkspaceContext): Promise<void>;
38
+ export declare function processPromotion(wctx: WorkspaceContext, logger: any, eventLog: any): Promise<void>;
39
+ export declare function registerEvolutionTaskSession(workspaceResolve: (key: string) => string, taskId: string, sessionKey: string, logger?: {
40
+ warn?: (message: string) => void;
41
+ info?: (message: string) => void;
42
+ }): Promise<boolean>;
39
43
  export interface ExtendedEvolutionWorkerService {
40
44
  id: string;
41
45
  api: OpenClawPluginApi | null;