minovative-mind-cli 1.5.1 → 2.0.0
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.
- package/README.md +51 -45
- package/dist/commands/chat.js +7 -2
- package/dist/services/agent/slashCommands.js +156 -30
- package/dist/services/agent/toolLoop.d.ts +1 -1
- package/dist/services/agent/toolLoop.js +7 -2
- package/dist/services/agent/types.d.ts +2 -0
- package/dist/services/agent-tools.d.ts +9 -4
- package/dist/services/agent-tools.js +145 -21
- package/dist/services/agent.d.ts +8 -0
- package/dist/services/agent.js +285 -40
- package/dist/services/ai.d.ts +19 -5
- package/dist/services/ai.js +167 -35
- package/dist/services/changeLogger.d.ts +142 -0
- package/dist/services/changeLogger.js +132 -3
- package/dist/services/contextAgent.d.ts +6 -1
- package/dist/services/contextAgent.js +95 -14
- package/dist/services/embeddingIndex.d.ts +82 -0
- package/dist/services/embeddingIndex.js +613 -0
- package/dist/services/investigationComplexity.d.ts +45 -0
- package/dist/services/investigationComplexity.js +91 -0
- package/dist/services/metrics.d.ts +18 -0
- package/dist/services/metrics.js +7 -0
- package/dist/services/orchestration/fileLockRegistry.d.ts +125 -0
- package/dist/services/orchestration/fileLockRegistry.js +276 -0
- package/dist/services/orchestration/investigationAgent.d.ts +85 -0
- package/dist/services/orchestration/investigationAgent.js +359 -0
- package/dist/services/orchestration/investigationOrchestrator.d.ts +53 -0
- package/dist/services/orchestration/investigationOrchestrator.js +180 -0
- package/dist/services/orchestration/messageBus.d.ts +162 -0
- package/dist/services/orchestration/messageBus.js +225 -0
- package/dist/services/orchestration/orchestrator.d.ts +45 -0
- package/dist/services/orchestration/orchestrator.js +214 -0
- package/dist/services/orchestration/readCache.d.ts +79 -0
- package/dist/services/orchestration/readCache.js +108 -0
- package/dist/services/orchestration/scopedTools.d.ts +57 -0
- package/dist/services/orchestration/scopedTools.js +172 -0
- package/dist/services/orchestration/subAgent.d.ts +58 -0
- package/dist/services/orchestration/subAgent.js +187 -0
- package/dist/services/orchestration/taskGraph.d.ts +129 -0
- package/dist/services/orchestration/taskGraph.js +254 -0
- package/dist/services/proxyClient.d.ts +25 -0
- package/dist/services/proxyClient.js +60 -0
- package/dist/utils/asyncContext.d.ts +16 -0
- package/dist/utils/asyncContext.js +25 -0
- package/dist/utils/config.d.ts +3 -1
- package/dist/utils/config.js +3 -1
- package/dist/utils/contextPrompts.js +3 -2
- package/dist/utils/dependencyTracer/modules/api.d.ts +9 -0
- package/dist/utils/dependencyTracer/modules/api.js +62 -0
- package/dist/utils/dependencyTracer/modules/graph.d.ts +9 -0
- package/dist/utils/dependencyTracer/modules/graph.js +23 -0
- package/dist/utils/dependencyTracer/modules/profiles.d.ts +7 -0
- package/dist/utils/dependencyTracer/modules/profiles.js +120 -0
- package/dist/utils/dependencyTracer/modules/resolver.d.ts +7 -0
- package/dist/utils/dependencyTracer/modules/resolver.js +51 -0
- package/dist/utils/dependencyTracer/modules/types.d.ts +4 -0
- package/dist/utils/dependencyTracer/modules/types.js +1 -0
- package/dist/utils/dependencyTracer/modules/walker.d.ts +1 -0
- package/dist/utils/dependencyTracer/modules/walker.js +48 -0
- package/dist/utils/dependencyTracer.js +31 -17
- package/dist/utils/excludedExtensions.js +0 -1
- package/dist/utils/historyPrompt.d.ts +9 -0
- package/dist/utils/historyPrompt.js +87 -0
- package/dist/utils/logo.js +7 -7
- package/dist/utils/paste.d.ts +21 -0
- package/dist/utils/paste.js +22 -1
- package/dist/utils/profiles.d.ts +2 -0
- package/dist/utils/profiles.js +44 -0
- package/dist/utils/projectStorage.js +10 -7
- package/dist/utils/systemPrompts.d.ts +6 -3
- package/dist/utils/systemPrompts.js +106 -5
- package/dist/utils/types.d.ts +33 -0
- package/dist/utils/types.js +1 -0
- package/oclif.manifest.json +2 -2
- package/package.json +4 -3
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Investigation Complexity Evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight LLM-powered gatekeeper that determines whether a user prompt
|
|
5
|
+
* warrants parallel investigation or should use the existing single Context
|
|
6
|
+
* Agent path. This prevents wasteful multi-agent spawns on simple prompts
|
|
7
|
+
* like "fix the typo in README" while enabling parallelism for broad
|
|
8
|
+
* architectural prompts like "refactor auth to OAuth2."
|
|
9
|
+
*
|
|
10
|
+
* Only invoked when the `/sub-agents` toggle is ON and the Intent Router
|
|
11
|
+
* has already determined that context gathering is needed (SEARCH).
|
|
12
|
+
*/
|
|
13
|
+
import { createInvestigationComplexitySession } from './ai.js';
|
|
14
|
+
import { debugLog } from '../utils/logger.js';
|
|
15
|
+
// ─── Evaluator ───────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Evaluates whether the investigation phase should be parallelized.
|
|
18
|
+
*
|
|
19
|
+
* Uses `gemini-3.1-flash-lite` at temperature 0 to classify the prompt's
|
|
20
|
+
* investigation complexity. The PM dynamically identifies domains and groups
|
|
21
|
+
* them into agent assignments. Agent count = `agentAssignments.length`, which
|
|
22
|
+
* may be fewer than `domains.length` when related domains are batched together.
|
|
23
|
+
*
|
|
24
|
+
* @param userRequest - The user's prompt.
|
|
25
|
+
* @param projectType - Detected project type (e.g., "Node.js / TypeScript / React").
|
|
26
|
+
* @param approximateFileCount - Rough file count from the project tree.
|
|
27
|
+
* @param chatHistory - Recent conversation history for context.
|
|
28
|
+
* @returns The complexity classification with domain decomposition.
|
|
29
|
+
*/
|
|
30
|
+
export async function evaluateInvestigationComplexity(userRequest, projectType, approximateFileCount, chatHistory = '') {
|
|
31
|
+
try {
|
|
32
|
+
const session = createInvestigationComplexitySession();
|
|
33
|
+
let prompt = `User Request: "${userRequest}"
|
|
34
|
+
Project Type: ${projectType}
|
|
35
|
+
Approximate File Count: ${approximateFileCount}`;
|
|
36
|
+
if (chatHistory) {
|
|
37
|
+
prompt = `Previous Conversation Context:\n${chatHistory}\n\n${prompt}`;
|
|
38
|
+
}
|
|
39
|
+
const result = await session.sendMessage(prompt);
|
|
40
|
+
const text = result.response.text()?.trim() || '{}';
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
debugLog(`Investigation Complexity Parsed: ${JSON.stringify(parsed)}`);
|
|
43
|
+
// Validate the response shape
|
|
44
|
+
const strategy = parsed.strategy === 'PARALLEL' ? 'PARALLEL' : 'SINGLE';
|
|
45
|
+
if (strategy === 'SINGLE') {
|
|
46
|
+
return {
|
|
47
|
+
strategy: 'SINGLE',
|
|
48
|
+
domains: [],
|
|
49
|
+
agentAssignments: [],
|
|
50
|
+
reasoning: parsed.reasoning || 'Simple prompt — single agent sufficient.',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Validate agentAssignments array
|
|
54
|
+
const agentAssignments = [];
|
|
55
|
+
if (Array.isArray(parsed.agentAssignments)) {
|
|
56
|
+
for (const assignment of parsed.agentAssignments) {
|
|
57
|
+
if (assignment.agentLabel && Array.isArray(assignment.domains) && assignment.domains.length > 0) {
|
|
58
|
+
agentAssignments.push({
|
|
59
|
+
agentLabel: String(assignment.agentLabel),
|
|
60
|
+
domains: assignment.domains.map(String),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// If the PM said PARALLEL but produced no valid assignments, fall back to SINGLE
|
|
66
|
+
if (agentAssignments.length < 2) {
|
|
67
|
+
debugLog('Investigation Complexity: PM said PARALLEL but produced <2 valid assignments. Falling back to SINGLE.');
|
|
68
|
+
return {
|
|
69
|
+
strategy: 'SINGLE',
|
|
70
|
+
domains: [],
|
|
71
|
+
agentAssignments: [],
|
|
72
|
+
reasoning: 'PM produced insufficient agent assignments — falling back to single agent.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
strategy: 'PARALLEL',
|
|
77
|
+
domains: Array.isArray(parsed.domains) ? parsed.domains.map(String) : [],
|
|
78
|
+
agentAssignments,
|
|
79
|
+
reasoning: parsed.reasoning || '',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
debugLog(`Investigation Complexity Router failed, falling back to SINGLE. Error: ${String(e)}`);
|
|
84
|
+
return {
|
|
85
|
+
strategy: 'SINGLE',
|
|
86
|
+
domains: [],
|
|
87
|
+
agentAssignments: [],
|
|
88
|
+
reasoning: 'Evaluator failed — falling back to single agent.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface MetricCollector {
|
|
2
|
+
startTimer(name: string): void;
|
|
3
|
+
stopTimer(name: string): number;
|
|
4
|
+
recordTokenUsage(input: number, output: number, cached?: number): void;
|
|
5
|
+
recordCompressedContextSize(chars: number): void;
|
|
6
|
+
recordContextSelectedFiles(files: string[]): void;
|
|
7
|
+
recordFileModified(filePath: string): void;
|
|
8
|
+
recordToolTurn(): void;
|
|
9
|
+
recordSelfCorrection(): void;
|
|
10
|
+
recordVerificationResult(passed: boolean): void;
|
|
11
|
+
recordMatchTier(tier: 'exact' | 'normalized' | 'levenshtein' | 'none'): void;
|
|
12
|
+
recordInvestigationFailure(): void;
|
|
13
|
+
recordToolFailure(toolName: string): void;
|
|
14
|
+
recordModifyFailure(): void;
|
|
15
|
+
recordWriteFailure(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function setMetricCollector(collector: MetricCollector | null): void;
|
|
18
|
+
export declare function getMetricCollector(): MetricCollector | null;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview File Lock Registry for Sub-Agent Orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Implements a per-file locking mechanism that enables multiple sub-agents to safely
|
|
5
|
+
* edit the same file without race conditions. Key design properties:
|
|
6
|
+
*
|
|
7
|
+
* - **Sequential Handoff**: When Agent A releases a file, Agent B (queued) receives
|
|
8
|
+
* the diff of what A changed so it has full context before making its own edits.
|
|
9
|
+
*
|
|
10
|
+
* - **Timeout-Based Deadlock Recovery**: Every `acquire()` has a configurable timeout
|
|
11
|
+
* (default 120s). If the holding agent stalls, the lock is forcibly released and
|
|
12
|
+
* the waiting agent receives a warning about partial changes.
|
|
13
|
+
*
|
|
14
|
+
* - **Dead Agent Cleanup**: `forceReleaseAll(agentId)` releases all locks held by a
|
|
15
|
+
* crashed/stalled agent in one call, used by the orchestrator's health monitor.
|
|
16
|
+
*
|
|
17
|
+
* - **Pre-Allocation Ordering**: The orchestrator uses `detectFileConflicts()` from
|
|
18
|
+
* `taskGraph.ts` at dispatch time to serialize tasks that share target files,
|
|
19
|
+
* preventing circular wait deadlocks before they can form.
|
|
20
|
+
*/
|
|
21
|
+
/** Result returned when a lock is successfully acquired. */
|
|
22
|
+
export interface LockAcquisitionResult {
|
|
23
|
+
/** Whether the lock was granted */
|
|
24
|
+
granted: true;
|
|
25
|
+
/** If true, the lock was taken by force from a stalled agent */
|
|
26
|
+
forceReleased: boolean;
|
|
27
|
+
/** The diff/context from the previous holder (if any), for sequential handoff */
|
|
28
|
+
previousDiff: string | null;
|
|
29
|
+
/** ID of the agent that held the lock before (if any) */
|
|
30
|
+
previousAgentId: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** Error thrown when a lock acquisition times out. */
|
|
33
|
+
export declare class LockTimeoutError extends Error {
|
|
34
|
+
readonly filePath: string;
|
|
35
|
+
readonly holdingAgentId: string;
|
|
36
|
+
readonly waitingAgentId: string;
|
|
37
|
+
readonly timeoutMs: number;
|
|
38
|
+
constructor(filePath: string, holdingAgentId: string, waitingAgentId: string, timeoutMs: number);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Per-file locking system with sequential handoff, timeout recovery, and
|
|
42
|
+
* dead agent cleanup.
|
|
43
|
+
*
|
|
44
|
+
* Usage pattern (inside scoped tool wrapper):
|
|
45
|
+
* ```
|
|
46
|
+
* const result = await registry.acquire('src/config.ts', 'agent-a')
|
|
47
|
+
* // ... agent performs read/write operations ...
|
|
48
|
+
* registry.release('src/config.ts', 'agent-a', diffString)
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare class FileLockRegistry {
|
|
52
|
+
private locks;
|
|
53
|
+
/** Default timeout for lock acquisition (120 seconds) */
|
|
54
|
+
static readonly DEFAULT_TIMEOUT_MS = 120000;
|
|
55
|
+
/**
|
|
56
|
+
* Acquires a lock on a file path for a given agent.
|
|
57
|
+
*
|
|
58
|
+
* - If the file is unlocked: immediately grants the lock.
|
|
59
|
+
* - If the file is locked by the SAME agent: immediately returns (re-entrant).
|
|
60
|
+
* - If the file is locked by ANOTHER agent: queues this agent and waits
|
|
61
|
+
* until the holder releases, or the timeout fires.
|
|
62
|
+
*
|
|
63
|
+
* When the timeout fires, the system forcibly releases the stalled agent's lock
|
|
64
|
+
* and grants it to the waiting agent with a warning about partial file state.
|
|
65
|
+
*
|
|
66
|
+
* @param filePath - Relative file path being locked.
|
|
67
|
+
* @param agentId - The requesting agent's identifier.
|
|
68
|
+
* @param timeoutMs - Max wait time before force-releasing (default 120s).
|
|
69
|
+
*/
|
|
70
|
+
acquire(filePath: string, agentId: string, timeoutMs?: number): Promise<LockAcquisitionResult>;
|
|
71
|
+
/**
|
|
72
|
+
* Releases a lock held by the specified agent and propagates context to the
|
|
73
|
+
* next queued waiter (if any).
|
|
74
|
+
*
|
|
75
|
+
* @param filePath - The file to unlock.
|
|
76
|
+
* @param agentId - The releasing agent's identifier.
|
|
77
|
+
* @param diff - A human-readable summary of what the agent changed in the file.
|
|
78
|
+
*/
|
|
79
|
+
release(filePath: string, agentId: string, diff?: string | null): void;
|
|
80
|
+
/**
|
|
81
|
+
* Forcibly releases a lock from a stalled agent without waiting for the
|
|
82
|
+
* holder to call `release()`. Used by timeout handlers and the orchestrator's
|
|
83
|
+
* dead agent recovery.
|
|
84
|
+
*
|
|
85
|
+
* @param filePath - The file to force-unlock.
|
|
86
|
+
* @param stalledAgentId - The agent being evicted from the lock.
|
|
87
|
+
*/
|
|
88
|
+
forceRelease(filePath: string, stalledAgentId: string): void;
|
|
89
|
+
/**
|
|
90
|
+
* Releases ALL locks held by a specific agent. Used when the orchestrator
|
|
91
|
+
* detects that an agent has crashed or stalled and needs full cleanup.
|
|
92
|
+
*
|
|
93
|
+
* @param agentId - The dead/stalled agent whose locks should be released.
|
|
94
|
+
* @returns Array of file paths that were force-released.
|
|
95
|
+
*/
|
|
96
|
+
forceReleaseAll(agentId: string): string[];
|
|
97
|
+
/**
|
|
98
|
+
* Returns the list of file paths currently locked by a specific agent.
|
|
99
|
+
* Used for diagnostics, terminal display, and dead agent recovery context.
|
|
100
|
+
*/
|
|
101
|
+
getHeldLocks(agentId: string): string[];
|
|
102
|
+
/**
|
|
103
|
+
* Returns whether a specific file is currently locked.
|
|
104
|
+
*/
|
|
105
|
+
isLocked(filePath: string): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Returns the agent currently holding the lock on a file, or null if unlocked.
|
|
108
|
+
*/
|
|
109
|
+
getHolder(filePath: string): string | null;
|
|
110
|
+
/**
|
|
111
|
+
* Returns a diagnostic snapshot of all active locks.
|
|
112
|
+
* Used for terminal output and debugging.
|
|
113
|
+
*/
|
|
114
|
+
getSnapshot(): Array<{
|
|
115
|
+
filePath: string;
|
|
116
|
+
holder: string;
|
|
117
|
+
waiters: string[];
|
|
118
|
+
heldForMs: number;
|
|
119
|
+
}>;
|
|
120
|
+
/**
|
|
121
|
+
* Rejects all waiting agents and clears all locks.
|
|
122
|
+
* Called during orchestration teardown.
|
|
123
|
+
*/
|
|
124
|
+
shutdown(): void;
|
|
125
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview File Lock Registry for Sub-Agent Orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Implements a per-file locking mechanism that enables multiple sub-agents to safely
|
|
5
|
+
* edit the same file without race conditions. Key design properties:
|
|
6
|
+
*
|
|
7
|
+
* - **Sequential Handoff**: When Agent A releases a file, Agent B (queued) receives
|
|
8
|
+
* the diff of what A changed so it has full context before making its own edits.
|
|
9
|
+
*
|
|
10
|
+
* - **Timeout-Based Deadlock Recovery**: Every `acquire()` has a configurable timeout
|
|
11
|
+
* (default 120s). If the holding agent stalls, the lock is forcibly released and
|
|
12
|
+
* the waiting agent receives a warning about partial changes.
|
|
13
|
+
*
|
|
14
|
+
* - **Dead Agent Cleanup**: `forceReleaseAll(agentId)` releases all locks held by a
|
|
15
|
+
* crashed/stalled agent in one call, used by the orchestrator's health monitor.
|
|
16
|
+
*
|
|
17
|
+
* - **Pre-Allocation Ordering**: The orchestrator uses `detectFileConflicts()` from
|
|
18
|
+
* `taskGraph.ts` at dispatch time to serialize tasks that share target files,
|
|
19
|
+
* preventing circular wait deadlocks before they can form.
|
|
20
|
+
*/
|
|
21
|
+
import { debugLog } from '../../utils/logger.js';
|
|
22
|
+
/** Error thrown when a lock acquisition times out. */
|
|
23
|
+
export class LockTimeoutError extends Error {
|
|
24
|
+
filePath;
|
|
25
|
+
holdingAgentId;
|
|
26
|
+
waitingAgentId;
|
|
27
|
+
timeoutMs;
|
|
28
|
+
constructor(filePath, holdingAgentId, waitingAgentId, timeoutMs) {
|
|
29
|
+
super(`Lock timeout: Agent "${waitingAgentId}" waited ${timeoutMs}ms for ` +
|
|
30
|
+
`"${filePath}" held by "${holdingAgentId}".`);
|
|
31
|
+
this.filePath = filePath;
|
|
32
|
+
this.holdingAgentId = holdingAgentId;
|
|
33
|
+
this.waitingAgentId = waitingAgentId;
|
|
34
|
+
this.timeoutMs = timeoutMs;
|
|
35
|
+
this.name = 'LockTimeoutError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ─── File Lock Registry ──────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Per-file locking system with sequential handoff, timeout recovery, and
|
|
41
|
+
* dead agent cleanup.
|
|
42
|
+
*
|
|
43
|
+
* Usage pattern (inside scoped tool wrapper):
|
|
44
|
+
* ```
|
|
45
|
+
* const result = await registry.acquire('src/config.ts', 'agent-a')
|
|
46
|
+
* // ... agent performs read/write operations ...
|
|
47
|
+
* registry.release('src/config.ts', 'agent-a', diffString)
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export class FileLockRegistry {
|
|
51
|
+
locks = new Map();
|
|
52
|
+
/** Default timeout for lock acquisition (120 seconds) */
|
|
53
|
+
static DEFAULT_TIMEOUT_MS = 120_000;
|
|
54
|
+
/**
|
|
55
|
+
* Acquires a lock on a file path for a given agent.
|
|
56
|
+
*
|
|
57
|
+
* - If the file is unlocked: immediately grants the lock.
|
|
58
|
+
* - If the file is locked by the SAME agent: immediately returns (re-entrant).
|
|
59
|
+
* - If the file is locked by ANOTHER agent: queues this agent and waits
|
|
60
|
+
* until the holder releases, or the timeout fires.
|
|
61
|
+
*
|
|
62
|
+
* When the timeout fires, the system forcibly releases the stalled agent's lock
|
|
63
|
+
* and grants it to the waiting agent with a warning about partial file state.
|
|
64
|
+
*
|
|
65
|
+
* @param filePath - Relative file path being locked.
|
|
66
|
+
* @param agentId - The requesting agent's identifier.
|
|
67
|
+
* @param timeoutMs - Max wait time before force-releasing (default 120s).
|
|
68
|
+
*/
|
|
69
|
+
async acquire(filePath, agentId, timeoutMs = FileLockRegistry.DEFAULT_TIMEOUT_MS) {
|
|
70
|
+
const existing = this.locks.get(filePath);
|
|
71
|
+
// Case 1: File is unlocked — grant immediately
|
|
72
|
+
if (!existing) {
|
|
73
|
+
this.locks.set(filePath, {
|
|
74
|
+
holderId: agentId,
|
|
75
|
+
acquiredAt: Date.now(),
|
|
76
|
+
waiters: [],
|
|
77
|
+
accumulatedDiff: null,
|
|
78
|
+
});
|
|
79
|
+
debugLog(`FileLock: Agent "${agentId}" acquired "${filePath}" (no contention).`);
|
|
80
|
+
return {
|
|
81
|
+
granted: true,
|
|
82
|
+
forceReleased: false,
|
|
83
|
+
previousDiff: null,
|
|
84
|
+
previousAgentId: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Case 2: Re-entrant — same agent already holds this lock
|
|
88
|
+
if (existing.holderId === agentId) {
|
|
89
|
+
debugLog(`FileLock: Agent "${agentId}" re-entered lock on "${filePath}".`);
|
|
90
|
+
return {
|
|
91
|
+
granted: true,
|
|
92
|
+
forceReleased: false,
|
|
93
|
+
previousDiff: null,
|
|
94
|
+
previousAgentId: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Case 3: Another agent holds the lock — queue and wait
|
|
98
|
+
debugLog(`FileLock: Agent "${agentId}" queued for "${filePath}" (held by "${existing.holderId}").`);
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const timeoutHandle = setTimeout(() => {
|
|
101
|
+
// Timeout fired — force-release the stalled holder
|
|
102
|
+
debugLog(`FileLock: Timeout! Force-releasing "${filePath}" from stalled ` +
|
|
103
|
+
`agent "${existing.holderId}" for waiting agent "${agentId}".`);
|
|
104
|
+
this.forceRelease(filePath, existing.holderId);
|
|
105
|
+
// Grant to the waiting agent with a warning
|
|
106
|
+
this.locks.set(filePath, {
|
|
107
|
+
holderId: agentId,
|
|
108
|
+
acquiredAt: Date.now(),
|
|
109
|
+
waiters: [],
|
|
110
|
+
accumulatedDiff: null,
|
|
111
|
+
});
|
|
112
|
+
resolve({
|
|
113
|
+
granted: true,
|
|
114
|
+
forceReleased: true,
|
|
115
|
+
previousDiff: '[Lock forcibly released — previous agent stalled. File state may be partial.]',
|
|
116
|
+
previousAgentId: existing.holderId,
|
|
117
|
+
});
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
existing.waiters.push({ agentId, resolve, reject, timeoutHandle });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Releases a lock held by the specified agent and propagates context to the
|
|
124
|
+
* next queued waiter (if any).
|
|
125
|
+
*
|
|
126
|
+
* @param filePath - The file to unlock.
|
|
127
|
+
* @param agentId - The releasing agent's identifier.
|
|
128
|
+
* @param diff - A human-readable summary of what the agent changed in the file.
|
|
129
|
+
*/
|
|
130
|
+
release(filePath, agentId, diff = null) {
|
|
131
|
+
const entry = this.locks.get(filePath);
|
|
132
|
+
if (!entry) {
|
|
133
|
+
debugLog(`FileLock: Attempted to release unlocked file "${filePath}" by "${agentId}".`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (entry.holderId !== agentId) {
|
|
137
|
+
debugLog(`FileLock: Agent "${agentId}" attempted to release "${filePath}" ` +
|
|
138
|
+
`but it is held by "${entry.holderId}". Ignoring.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const nextWaiter = entry.waiters.shift();
|
|
142
|
+
if (nextWaiter) {
|
|
143
|
+
// Clear the waiter's timeout since we're granting naturally
|
|
144
|
+
clearTimeout(nextWaiter.timeoutHandle);
|
|
145
|
+
// Transfer lock to the next waiter with context propagation
|
|
146
|
+
entry.holderId = nextWaiter.agentId;
|
|
147
|
+
entry.acquiredAt = Date.now();
|
|
148
|
+
entry.accumulatedDiff = null;
|
|
149
|
+
debugLog(`FileLock: Agent "${agentId}" released "${filePath}". ` +
|
|
150
|
+
`Granted to queued agent "${nextWaiter.agentId}" with diff context.`);
|
|
151
|
+
nextWaiter.resolve({
|
|
152
|
+
granted: true,
|
|
153
|
+
forceReleased: false,
|
|
154
|
+
previousDiff: diff,
|
|
155
|
+
previousAgentId: agentId,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// No waiters — fully release the lock
|
|
160
|
+
this.locks.delete(filePath);
|
|
161
|
+
debugLog(`FileLock: Agent "${agentId}" released "${filePath}" (no waiters).`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Forcibly releases a lock from a stalled agent without waiting for the
|
|
166
|
+
* holder to call `release()`. Used by timeout handlers and the orchestrator's
|
|
167
|
+
* dead agent recovery.
|
|
168
|
+
*
|
|
169
|
+
* @param filePath - The file to force-unlock.
|
|
170
|
+
* @param stalledAgentId - The agent being evicted from the lock.
|
|
171
|
+
*/
|
|
172
|
+
forceRelease(filePath, stalledAgentId) {
|
|
173
|
+
const entry = this.locks.get(filePath);
|
|
174
|
+
if (!entry || entry.holderId !== stalledAgentId)
|
|
175
|
+
return;
|
|
176
|
+
debugLog(`FileLock: Force-releasing "${filePath}" from stalled agent "${stalledAgentId}".`);
|
|
177
|
+
// Clear all remaining waiter timeouts (they will be re-dispatched)
|
|
178
|
+
for (const waiter of entry.waiters) {
|
|
179
|
+
clearTimeout(waiter.timeoutHandle);
|
|
180
|
+
}
|
|
181
|
+
// If there's a next waiter, grant to them with a force-release warning
|
|
182
|
+
const nextWaiter = entry.waiters.shift();
|
|
183
|
+
if (nextWaiter) {
|
|
184
|
+
entry.holderId = nextWaiter.agentId;
|
|
185
|
+
entry.acquiredAt = Date.now();
|
|
186
|
+
entry.waiters = entry.waiters; // remaining waiters stay queued
|
|
187
|
+
entry.accumulatedDiff = null;
|
|
188
|
+
nextWaiter.resolve({
|
|
189
|
+
granted: true,
|
|
190
|
+
forceReleased: true,
|
|
191
|
+
previousDiff: '[Lock forcibly released — previous agent stalled. File state may be partial.]',
|
|
192
|
+
previousAgentId: stalledAgentId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.locks.delete(filePath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Releases ALL locks held by a specific agent. Used when the orchestrator
|
|
201
|
+
* detects that an agent has crashed or stalled and needs full cleanup.
|
|
202
|
+
*
|
|
203
|
+
* @param agentId - The dead/stalled agent whose locks should be released.
|
|
204
|
+
* @returns Array of file paths that were force-released.
|
|
205
|
+
*/
|
|
206
|
+
forceReleaseAll(agentId) {
|
|
207
|
+
const releasedFiles = [];
|
|
208
|
+
for (const [filePath, entry] of this.locks) {
|
|
209
|
+
if (entry.holderId === agentId) {
|
|
210
|
+
this.forceRelease(filePath, agentId);
|
|
211
|
+
releasedFiles.push(filePath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (releasedFiles.length > 0) {
|
|
215
|
+
debugLog(`FileLock: Force-released ${releasedFiles.length} locks from dead agent "${agentId}": ` +
|
|
216
|
+
`${releasedFiles.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
return releasedFiles;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns the list of file paths currently locked by a specific agent.
|
|
222
|
+
* Used for diagnostics, terminal display, and dead agent recovery context.
|
|
223
|
+
*/
|
|
224
|
+
getHeldLocks(agentId) {
|
|
225
|
+
const held = [];
|
|
226
|
+
for (const [filePath, entry] of this.locks) {
|
|
227
|
+
if (entry.holderId === agentId) {
|
|
228
|
+
held.push(filePath);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return held;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Returns whether a specific file is currently locked.
|
|
235
|
+
*/
|
|
236
|
+
isLocked(filePath) {
|
|
237
|
+
return this.locks.has(filePath);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Returns the agent currently holding the lock on a file, or null if unlocked.
|
|
241
|
+
*/
|
|
242
|
+
getHolder(filePath) {
|
|
243
|
+
return this.locks.get(filePath)?.holderId ?? null;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Returns a diagnostic snapshot of all active locks.
|
|
247
|
+
* Used for terminal output and debugging.
|
|
248
|
+
*/
|
|
249
|
+
getSnapshot() {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const snapshot = [];
|
|
252
|
+
for (const [filePath, entry] of this.locks) {
|
|
253
|
+
snapshot.push({
|
|
254
|
+
filePath,
|
|
255
|
+
holder: entry.holderId,
|
|
256
|
+
waiters: entry.waiters.map(w => w.agentId),
|
|
257
|
+
heldForMs: now - entry.acquiredAt,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return snapshot;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Rejects all waiting agents and clears all locks.
|
|
264
|
+
* Called during orchestration teardown.
|
|
265
|
+
*/
|
|
266
|
+
shutdown() {
|
|
267
|
+
for (const [, entry] of this.locks) {
|
|
268
|
+
for (const waiter of entry.waiters) {
|
|
269
|
+
clearTimeout(waiter.timeoutHandle);
|
|
270
|
+
waiter.reject(new Error('FileLockRegistry is shutting down.'));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
this.locks.clear();
|
|
274
|
+
debugLog('FileLock: Registry shut down — all locks cleared.');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Read-Only Investigation Sub-Agent Runner.
|
|
3
|
+
*
|
|
4
|
+
* Implements the lifecycle, health monitoring, and tool-loop execution for a single
|
|
5
|
+
* parallelized investigation agent. Unlike execution sub-agents in `subAgent.ts`,
|
|
6
|
+
* investigation agents are **strictly read-only** — they have no filesystem write
|
|
7
|
+
* tools, no file locks, and no message bus. They share a `ReadCache` to avoid
|
|
8
|
+
* redundant file reads across sibling agents.
|
|
9
|
+
*
|
|
10
|
+
* Each investigation agent is scoped to a set of domains (e.g., "Frontend auth
|
|
11
|
+
* components" + "Auth UI styling") and produces an `InvestigationResult` containing
|
|
12
|
+
* the files it found and a summary of its findings.
|
|
13
|
+
*/
|
|
14
|
+
import { ReadCache } from './readCache.js';
|
|
15
|
+
/**
|
|
16
|
+
* Result returned when an investigation agent completes its work.
|
|
17
|
+
* The `InvestigationOrchestrator` merges these across all agents.
|
|
18
|
+
*/
|
|
19
|
+
export interface InvestigationResult {
|
|
20
|
+
/** The domain(s) this agent investigated */
|
|
21
|
+
domains: string[];
|
|
22
|
+
/** Agent label (e.g., "Frontend", "Backend") */
|
|
23
|
+
agentLabel: string;
|
|
24
|
+
/** Files discovered as relevant, with their content */
|
|
25
|
+
relevantFiles: Map<string, {
|
|
26
|
+
text: string;
|
|
27
|
+
inlineData?: any;
|
|
28
|
+
}>;
|
|
29
|
+
/** Summary of findings for this agent's scope */
|
|
30
|
+
summary: string;
|
|
31
|
+
/** Web search findings (if any) */
|
|
32
|
+
webSearchSummary: string;
|
|
33
|
+
/** Total tokens consumed by this agent */
|
|
34
|
+
creditsUsed: number;
|
|
35
|
+
/** Whether the agent completed successfully */
|
|
36
|
+
success: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Executes a read-only investigation within a scoped set of domains.
|
|
40
|
+
*
|
|
41
|
+
* Key differences from `SubAgentRunner` (execution):
|
|
42
|
+
* - **No write tools**: Cannot modify, create, or delete files
|
|
43
|
+
* - **No file locks**: Read-only access means zero contention
|
|
44
|
+
* - **No message bus**: Agents don't need to coordinate — they work independently
|
|
45
|
+
* - **Shared ReadCache**: Checks cache before hitting disk to avoid redundant reads
|
|
46
|
+
* - **Shorter stall timeout**: 30s vs 60s — investigation should be faster
|
|
47
|
+
*/
|
|
48
|
+
export declare class InvestigationAgentRunner {
|
|
49
|
+
private readonly agentLabel;
|
|
50
|
+
private readonly domains;
|
|
51
|
+
private readonly workspaceRoot;
|
|
52
|
+
private readonly readCache;
|
|
53
|
+
private readonly projectTree;
|
|
54
|
+
private readonly projectType;
|
|
55
|
+
private chat;
|
|
56
|
+
private lastHeartbeat;
|
|
57
|
+
private creditsUsed;
|
|
58
|
+
/** Shorter stall timeout for investigation agents (120s to allow for heavy vision/web tasks) */
|
|
59
|
+
static readonly STALL_TIMEOUT_MS = 120000;
|
|
60
|
+
constructor(agentLabel: string, domains: string[], workspaceRoot: string, readCache: ReadCache, projectTree: string, projectType: string);
|
|
61
|
+
/**
|
|
62
|
+
* Builds a domain-scoped system instruction. Extends the base Context Agent
|
|
63
|
+
* instruction with a domain focus preamble.
|
|
64
|
+
*/
|
|
65
|
+
private buildSystemInstruction;
|
|
66
|
+
/**
|
|
67
|
+
* Updates the heartbeat timestamp. Prevents the stall detector from
|
|
68
|
+
* killing the agent during long-running tool calls.
|
|
69
|
+
*/
|
|
70
|
+
private pingHeartbeat;
|
|
71
|
+
/**
|
|
72
|
+
* Executes the investigation agent's tool loop.
|
|
73
|
+
*
|
|
74
|
+
* @param userRequest - The original user prompt.
|
|
75
|
+
* @param chatHistory - Recent conversation history for context.
|
|
76
|
+
* @param abortSignal - Signal to cancel execution.
|
|
77
|
+
* @param onProgress - Callback for spinner/terminal updates.
|
|
78
|
+
* @returns The investigation result with discovered files and summary.
|
|
79
|
+
*/
|
|
80
|
+
execute(userRequest: string, chatHistory: string, abortSignal: AbortSignal, onProgress?: (msg: string) => void): Promise<InvestigationResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Accumulates token usage from the chat session.
|
|
83
|
+
*/
|
|
84
|
+
private updateUsage;
|
|
85
|
+
}
|