corydora 0.4.0 → 1.0.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.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/dist/commands/doctor.js +37 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +114 -14
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +51 -20
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/status.js +37 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/config/files.js +23 -1
- package/dist/config/files.js.map +1 -1
- package/dist/config/schema.d.ts +0 -80
- package/dist/config/schema.js +162 -52
- package/dist/config/schema.js.map +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +9 -0
- package/dist/constants.js.map +1 -1
- package/dist/filesystem/discovery.d.ts +2 -0
- package/dist/filesystem/discovery.js +2 -2
- package/dist/filesystem/discovery.js.map +1 -1
- package/dist/filesystem/gitignore.d.ts +3 -0
- package/dist/filesystem/gitignore.js +37 -7
- package/dist/filesystem/gitignore.js.map +1 -1
- package/dist/git/isolation.d.ts +1 -0
- package/dist/git/isolation.js +5 -4
- package/dist/git/isolation.js.map +1 -1
- package/dist/git/repository.d.ts +2 -1
- package/dist/git/repository.js +21 -11
- package/dist/git/repository.js.map +1 -1
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/fake.js +16 -0
- package/dist/providers/fake.js.map +1 -1
- package/dist/providers/utils.js +24 -0
- package/dist/providers/utils.js.map +1 -1
- package/dist/queue/render.js +10 -3
- package/dist/queue/render.js.map +1 -1
- package/dist/queue/state.d.ts +53 -4
- package/dist/queue/state.js +301 -29
- package/dist/queue/state.js.map +1 -1
- package/dist/runtime/modes.d.ts +29 -0
- package/dist/runtime/modes.js +423 -0
- package/dist/runtime/modes.js.map +1 -0
- package/dist/runtime/prompts.d.ts +8 -3
- package/dist/runtime/prompts.js +58 -12
- package/dist/runtime/prompts.js.map +1 -1
- package/dist/runtime/routes.d.ts +28 -0
- package/dist/runtime/routes.js +87 -0
- package/dist/runtime/routes.js.map +1 -0
- package/dist/runtime/run-session.d.ts +3 -2
- package/dist/runtime/run-session.js +521 -206
- package/dist/runtime/run-session.js.map +1 -1
- package/dist/runtime/tooling.d.ts +2 -0
- package/dist/runtime/tooling.js +79 -0
- package/dist/runtime/tooling.js.map +1 -0
- package/dist/runtime/validation.d.ts +7 -0
- package/dist/runtime/validation.js +115 -0
- package/dist/runtime/validation.js.map +1 -0
- package/dist/types/domain.d.ts +139 -16
- package/package.json +1 -1
- package/schemas/corydora.schema.json +165 -4
|
@@ -2,16 +2,17 @@ import { appendFile, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
-
import { ensureCorydoraStructure } from '../config/files.js';
|
|
6
|
-
import { discoverCandidateFiles } from '../filesystem/discovery.js';
|
|
5
|
+
import { ensureCorydoraStructure, resolveStatePath } from '../config/files.js';
|
|
7
6
|
import { detectProjectFingerprint } from '../filesystem/project.js';
|
|
8
|
-
import {
|
|
7
|
+
import { commitTaskChanges } from '../git/repository.js';
|
|
9
8
|
import { prepareIsolationContext } from '../git/isolation.js';
|
|
10
|
-
import { countTasksByStatus, loadRunState, loadTaskStore, mergeScanFindings, saveRunArtifact, saveRunState, saveTaskStore,
|
|
9
|
+
import { appendRunEvent, countTasksByStatus, loadFileStore, loadRunState, loadTaskStore, mergeScanFindings, noteFileAnalyzed, noteFileRetry, noteTaskCompleted, noteTaskProgress, noteTaskRetry, reclaimExpiredFileLeases, reclaimExpiredTaskLeases, reconcileFileStore, saveFileStore, saveRunArtifact, saveRunState, saveTaskStore, leaseFilesForAnalysis, leaseTasksForFix, } from '../queue/state.js';
|
|
11
10
|
import { renderTaskQueues } from '../queue/render.js';
|
|
12
11
|
import { buildFixPrompt, buildScanPrompt } from './prompts.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { buildFileQueue, canRunSecondFixWorker, modePrompt, prepareAnalysisMaterial, } from './modes.js';
|
|
13
|
+
import { executeStageFix, executeStageScan, getStageAdapter, preflightIsolationMode, resolveStageRoute, } from './routes.js';
|
|
14
|
+
import { runValidation } from './validation.js';
|
|
15
|
+
import { collectLintFindings } from './tooling.js';
|
|
15
16
|
function nowIso() {
|
|
16
17
|
return new Date().toISOString();
|
|
17
18
|
}
|
|
@@ -35,147 +36,93 @@ async function readFileIfExists(path) {
|
|
|
35
36
|
}
|
|
36
37
|
return readFile(path, 'utf8');
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
function createWorkers(config) {
|
|
40
|
+
const analyzeWorkers = Array.from({ length: config.execution.maxAnalyzeWorkers }, (_value, index) => ({
|
|
41
|
+
id: `analyze-${index + 1}`,
|
|
42
|
+
kind: 'analyze',
|
|
43
|
+
status: 'idle',
|
|
44
|
+
}));
|
|
45
|
+
const fixWorkers = Array.from({ length: config.execution.maxFixWorkers }, (_value, index) => ({
|
|
46
|
+
id: `fix-${index + 1}`,
|
|
47
|
+
kind: 'fix',
|
|
48
|
+
status: 'idle',
|
|
49
|
+
}));
|
|
50
|
+
return [...analyzeWorkers, ...fixWorkers];
|
|
51
|
+
}
|
|
52
|
+
function setWorkerState(workers, workerId, updates) {
|
|
53
|
+
return workers.map((worker) => worker.id === workerId
|
|
54
|
+
? {
|
|
55
|
+
...worker,
|
|
56
|
+
...updates,
|
|
57
|
+
}
|
|
58
|
+
: worker);
|
|
59
|
+
}
|
|
60
|
+
function resetWorkers(workers) {
|
|
61
|
+
return workers.map((worker) => ({
|
|
62
|
+
...worker,
|
|
63
|
+
status: 'idle',
|
|
64
|
+
targetId: undefined,
|
|
65
|
+
startedAt: undefined,
|
|
66
|
+
details: undefined,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
function countRunnableFiles(store, now) {
|
|
70
|
+
return store.files.filter((file) => {
|
|
71
|
+
if (!['queued', 'deferred'].includes(file.status)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return !file.nextEligibleAt || new Date(file.nextEligibleAt) <= now;
|
|
75
|
+
}).length;
|
|
76
|
+
}
|
|
77
|
+
function countRunnableTasks(store, config, now) {
|
|
78
|
+
return store.tasks.filter((task) => {
|
|
79
|
+
if (!['queued', 'deferred'].includes(task.status)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!config.scan.allowBroadRisk && task.risk === 'broad') {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return !task.nextEligibleAt || new Date(task.nextEligibleAt) <= now;
|
|
86
|
+
}).length;
|
|
87
|
+
}
|
|
88
|
+
function listFixCandidates(store, config, now) {
|
|
89
|
+
return store.tasks.filter((task) => {
|
|
90
|
+
if (!['queued', 'deferred'].includes(task.status)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (!config.scan.allowBroadRisk && task.risk === 'broad') {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (task.nextEligibleAt && new Date(task.nextEligibleAt) > now) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return task.effort === 'small' || task.effort === 'medium';
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function outstandingAnalyzeTokens(store) {
|
|
103
|
+
return store.files
|
|
104
|
+
.filter((file) => file.status === 'leased')
|
|
105
|
+
.reduce((total, file) => total + file.estimatedTokens, 0);
|
|
106
|
+
}
|
|
107
|
+
function buildRunSummary(taskStore, fileStore) {
|
|
108
|
+
return [
|
|
109
|
+
`done=${countTasksByStatus(taskStore, 'done')}`,
|
|
110
|
+
`deferred=${countTasksByStatus(taskStore, 'deferred')}`,
|
|
111
|
+
`blocked=${countTasksByStatus(taskStore, 'blocked')}`,
|
|
112
|
+
`manual=${countTasksByStatus(taskStore, 'manual')}`,
|
|
113
|
+
`queued-files=${fileStore.files.filter((file) => file.status === 'queued').length}`,
|
|
114
|
+
].join(', ');
|
|
115
|
+
}
|
|
116
|
+
async function saveAllState(projectRoot, config, store, fileStore, state) {
|
|
39
117
|
await saveTaskStore(projectRoot, config, store);
|
|
118
|
+
await saveFileStore(projectRoot, config, fileStore);
|
|
40
119
|
await renderTaskQueues(projectRoot, config, store);
|
|
41
120
|
await saveRunState(projectRoot, config, state);
|
|
42
121
|
await saveRunArtifact(projectRoot, config, state);
|
|
43
122
|
}
|
|
44
|
-
async function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let nextState = options.state;
|
|
48
|
-
let nextStore = options.store;
|
|
49
|
-
await options.logger(`Scanning ${options.files.length} files.`);
|
|
50
|
-
const concurrency = Math.max(1, options.config.scan.maxConcurrentScans);
|
|
51
|
-
for (let index = 0; index < options.files.length; index += concurrency) {
|
|
52
|
-
const slice = options.files.slice(index, index + concurrency);
|
|
53
|
-
const results = await Promise.all(slice.map(async (file) => {
|
|
54
|
-
await options.logger(`Starting scan: ${file}`);
|
|
55
|
-
const fileContent = await readFile(resolve(options.workRoot, file), 'utf8');
|
|
56
|
-
const prompt = buildScanPrompt({
|
|
57
|
-
filePath: file,
|
|
58
|
-
fileContent,
|
|
59
|
-
fingerprint,
|
|
60
|
-
agents: options.agents.filter((agent) => agent.categories.some((category) => options.config.agents.enabledCategories.includes(category))),
|
|
61
|
-
});
|
|
62
|
-
try {
|
|
63
|
-
const result = await adapter.executeScan({
|
|
64
|
-
rootDir: options.projectRoot,
|
|
65
|
-
workingDirectory: options.workRoot,
|
|
66
|
-
model: options.config.runtime.model,
|
|
67
|
-
prompt,
|
|
68
|
-
dryRun: false,
|
|
69
|
-
settings: {
|
|
70
|
-
maxOutputTokens: options.config.runtime.maxOutputTokens,
|
|
71
|
-
requestTimeoutMs: options.config.runtime.requestTimeoutMs,
|
|
72
|
-
maxRetries: options.config.runtime.maxRetries,
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
return { file, result, success: true };
|
|
76
|
-
}
|
|
77
|
-
catch (error) {
|
|
78
|
-
return {
|
|
79
|
-
file,
|
|
80
|
-
result: error instanceof Error ? error.message : String(error),
|
|
81
|
-
success: false,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}));
|
|
85
|
-
for (const item of results) {
|
|
86
|
-
if (item.success) {
|
|
87
|
-
const merged = mergeScanFindings(nextStore, item.result.tasks);
|
|
88
|
-
nextStore = merged.store;
|
|
89
|
-
await options.logger(`Scan complete: ${item.file} (${item.result.tasks.length} raw, ${merged.added.length} new tasks).`);
|
|
90
|
-
nextState = {
|
|
91
|
-
...nextState,
|
|
92
|
-
scheduler: noteFileProcessed(nextState.scheduler, item.file, true),
|
|
93
|
-
updatedAt: nowIso(),
|
|
94
|
-
selectedFiles: [...new Set([...nextState.selectedFiles, item.file])],
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
await options.logger(`Scan failed: ${item.file} -> ${item.result}`);
|
|
99
|
-
nextState = {
|
|
100
|
-
...nextState,
|
|
101
|
-
scheduler: noteFileProcessed(nextState.scheduler, item.file, false),
|
|
102
|
-
updatedAt: nowIso(),
|
|
103
|
-
consecutiveFailures: nextState.consecutiveFailures + 1,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return {
|
|
109
|
-
state: nextState,
|
|
110
|
-
store: nextStore,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
async function processSingleFix(options) {
|
|
114
|
-
const adapter = getRuntimeAdapter(options.config.runtime.provider);
|
|
115
|
-
const claimedTask = claimNextTask(options.store, options.state.runId, options.config.scan.allowBroadRisk);
|
|
116
|
-
if (!claimedTask) {
|
|
117
|
-
return { state: options.state, store: options.store };
|
|
118
|
-
}
|
|
119
|
-
let nextStore = updateTaskStatus(options.store, claimedTask.id, 'claimed');
|
|
120
|
-
let nextState = {
|
|
121
|
-
...options.state,
|
|
122
|
-
claimedTaskIds: [...new Set([...options.state.claimedTaskIds, claimedTask.id])],
|
|
123
|
-
phase: 'fix',
|
|
124
|
-
updatedAt: nowIso(),
|
|
125
|
-
};
|
|
126
|
-
await options.logger(`Fix started: ${claimedTask.id} ${claimedTask.title}`);
|
|
127
|
-
await saveAllState(options.projectRoot, options.config, nextStore, nextState);
|
|
128
|
-
const fileContent = await readFileIfExists(resolve(options.workRoot, claimedTask.file));
|
|
129
|
-
const prompt = buildFixPrompt({
|
|
130
|
-
adapter,
|
|
131
|
-
task: claimedTask,
|
|
132
|
-
fileContent,
|
|
133
|
-
validateAfterFix: options.config.execution.validateAfterFix,
|
|
134
|
-
});
|
|
135
|
-
try {
|
|
136
|
-
const result = await adapter.executeFix({
|
|
137
|
-
rootDir: options.projectRoot,
|
|
138
|
-
workingDirectory: options.workRoot,
|
|
139
|
-
model: options.config.runtime.model,
|
|
140
|
-
prompt,
|
|
141
|
-
dryRun: false,
|
|
142
|
-
settings: {
|
|
143
|
-
maxOutputTokens: options.config.runtime.maxOutputTokens,
|
|
144
|
-
requestTimeoutMs: options.config.runtime.requestTimeoutMs,
|
|
145
|
-
maxRetries: options.config.runtime.maxRetries,
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
const committed = adapter.executionMode === 'fake'
|
|
149
|
-
? false
|
|
150
|
-
: commitAllChanges(options.workRoot, `corydora: ${claimedTask.category}: ${claimedTask.title.slice(0, 60)}`, options.skipCommitHooks ? { skipHooks: true } : {});
|
|
151
|
-
nextStore = updateTaskStatus(nextStore, claimedTask.id, committed || result.changedFiles.length > 0 ? 'done' : 'blocked', committed || result.changedFiles.length > 0 ? undefined : 'No changes were produced.');
|
|
152
|
-
nextState = {
|
|
153
|
-
...nextState,
|
|
154
|
-
completedFixCount: nextState.completedFixCount + 1,
|
|
155
|
-
completedTaskIds: [...new Set([...nextState.completedTaskIds, claimedTask.id])],
|
|
156
|
-
consecutiveFailures: 0,
|
|
157
|
-
updatedAt: nowIso(),
|
|
158
|
-
};
|
|
159
|
-
return {
|
|
160
|
-
state: nextState,
|
|
161
|
-
store: nextStore,
|
|
162
|
-
fixedTaskId: claimedTask.id,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
await options.logger(`Fix failed: ${claimedTask.id} ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
-
nextStore = updateTaskStatus(nextStore, claimedTask.id, 'failed', error instanceof Error ? error.message : String(error));
|
|
168
|
-
nextState = {
|
|
169
|
-
...nextState,
|
|
170
|
-
consecutiveFailures: nextState.consecutiveFailures + 1,
|
|
171
|
-
updatedAt: nowIso(),
|
|
172
|
-
};
|
|
173
|
-
return {
|
|
174
|
-
state: nextState,
|
|
175
|
-
store: nextStore,
|
|
176
|
-
fixedTaskId: claimedTask.id,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
123
|
+
async function emitEvent(projectRoot, config, logger, event) {
|
|
124
|
+
await appendRunEvent(projectRoot, config, event);
|
|
125
|
+
await logger(`${event.type}: ${event.message}`);
|
|
179
126
|
}
|
|
180
127
|
async function shouldStop(projectRoot, config) {
|
|
181
128
|
const state = await loadRunState(projectRoot, config);
|
|
@@ -188,35 +135,53 @@ export async function runCorydoraSession(options) {
|
|
|
188
135
|
: null;
|
|
189
136
|
const runId = existingRun?.runId ?? randomUUID().slice(0, 8);
|
|
190
137
|
const logger = createRunLogger(resolve(options.projectRoot, options.config.paths.logsDir, `${runId}.log`), options.logToConsole ?? true);
|
|
138
|
+
const analyzeRoute = resolveStageRoute(options.config, 'analyze');
|
|
139
|
+
const fixRoute = resolveStageRoute(options.config, 'fix');
|
|
140
|
+
const isolationPreflight = preflightIsolationMode({
|
|
141
|
+
projectRoot: options.projectRoot,
|
|
142
|
+
config: options.config,
|
|
143
|
+
fixRoute,
|
|
144
|
+
mode: options.mode,
|
|
145
|
+
});
|
|
191
146
|
const isolation = prepareIsolationContext({
|
|
192
147
|
projectRoot: options.projectRoot,
|
|
193
148
|
config: options.config,
|
|
194
149
|
runId,
|
|
195
150
|
dryRun: options.dryRun,
|
|
151
|
+
isolationMode: isolationPreflight.effectiveIsolationMode,
|
|
196
152
|
});
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
153
|
+
const fingerprint = detectProjectFingerprint(isolation.workRoot);
|
|
154
|
+
const filesStatePath = resolveStatePath(options.projectRoot, options.config, 'files.json');
|
|
155
|
+
let taskStore = reclaimExpiredTaskLeases(await loadTaskStore(options.projectRoot, options.config));
|
|
156
|
+
let fileStore = reclaimExpiredFileLeases(await loadFileStore(options.projectRoot, options.config));
|
|
157
|
+
fileStore = reconcileFileStore(fileStore, buildFileQueue({
|
|
158
|
+
projectRoot: options.projectRoot,
|
|
159
|
+
workRoot: isolation.workRoot,
|
|
160
|
+
config: options.config,
|
|
161
|
+
mode: options.mode,
|
|
162
|
+
taskStore,
|
|
163
|
+
}));
|
|
202
164
|
let state = existingRun ?? {
|
|
203
165
|
runId,
|
|
204
166
|
status: 'running',
|
|
205
|
-
phase: '
|
|
167
|
+
phase: 'analyze',
|
|
206
168
|
repositoryRoot: options.projectRoot,
|
|
207
169
|
workRoot: isolation.workRoot,
|
|
208
170
|
provider: options.config.runtime.provider,
|
|
209
171
|
model: options.config.runtime.model,
|
|
210
172
|
isolationMode: options.config.git.isolationMode,
|
|
173
|
+
effectiveIsolationMode: isolationPreflight.effectiveIsolationMode,
|
|
174
|
+
mode: options.mode,
|
|
175
|
+
selectedAgentIds: options.selectedAgentIds,
|
|
211
176
|
startedAt: nowIso(),
|
|
212
177
|
updatedAt: nowIso(),
|
|
213
178
|
stopRequested: false,
|
|
214
|
-
selectedFiles: [],
|
|
215
179
|
claimedTaskIds: [],
|
|
216
180
|
completedTaskIds: [],
|
|
217
181
|
consecutiveFailures: 0,
|
|
218
182
|
completedFixCount: 0,
|
|
219
|
-
|
|
183
|
+
filesPath: filesStatePath,
|
|
184
|
+
workers: createWorkers(options.config),
|
|
220
185
|
...(isolation.branchName ? { branchName: isolation.branchName } : {}),
|
|
221
186
|
...(isolation.baseBranch ? { baseBranch: isolation.baseBranch } : {}),
|
|
222
187
|
...(isolation.worktreePath ? { worktreePath: isolation.worktreePath } : {}),
|
|
@@ -234,107 +199,457 @@ export async function runCorydoraSession(options) {
|
|
|
234
199
|
state = {
|
|
235
200
|
...state,
|
|
236
201
|
status: 'running',
|
|
237
|
-
phase: '
|
|
202
|
+
phase: 'analyze',
|
|
238
203
|
workRoot: isolation.workRoot,
|
|
239
|
-
|
|
204
|
+
effectiveIsolationMode: isolationPreflight.effectiveIsolationMode,
|
|
205
|
+
mode: options.mode,
|
|
206
|
+
selectedAgentIds: options.selectedAgentIds,
|
|
207
|
+
filesPath: filesStatePath,
|
|
240
208
|
updatedAt: nowIso(),
|
|
209
|
+
workers: resetWorkers(state.workers.length > 0 ? state.workers : createWorkers(options.config)),
|
|
210
|
+
...(isolation.branchName ? { branchName: isolation.branchName } : {}),
|
|
211
|
+
...(isolation.baseBranch ? { baseBranch: isolation.baseBranch } : {}),
|
|
212
|
+
...(isolation.worktreePath ? { worktreePath: isolation.worktreePath } : {}),
|
|
241
213
|
};
|
|
242
|
-
await saveAllState(options.projectRoot, options.config,
|
|
243
|
-
await
|
|
214
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
215
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
216
|
+
runId,
|
|
217
|
+
at: nowIso(),
|
|
218
|
+
type: 'run.prepared',
|
|
219
|
+
stage: 'summary',
|
|
220
|
+
message: `Prepared run with ${fileStore.files.length} file candidates.`,
|
|
221
|
+
metadata: {
|
|
222
|
+
isolationMode: isolation.mode,
|
|
223
|
+
effectiveIsolationMode: isolationPreflight.effectiveIsolationMode,
|
|
224
|
+
mode: options.mode,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
if (isolationPreflight.reason) {
|
|
228
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
229
|
+
runId,
|
|
230
|
+
at: nowIso(),
|
|
231
|
+
type: 'run.preflight',
|
|
232
|
+
stage: 'summary',
|
|
233
|
+
message: isolationPreflight.reason,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
244
236
|
const deadline = Date.now() + options.config.execution.maxRuntimeMinutes * 60_000;
|
|
237
|
+
const leaseTtlMs = options.config.execution.leaseTtlMinutes * 60_000;
|
|
245
238
|
try {
|
|
246
239
|
while (Date.now() < deadline) {
|
|
247
240
|
if (await shouldStop(options.projectRoot, options.config)) {
|
|
248
241
|
state = {
|
|
249
242
|
...state,
|
|
250
243
|
status: 'stopped',
|
|
251
|
-
phase: '
|
|
244
|
+
phase: 'summary',
|
|
252
245
|
finishedAt: nowIso(),
|
|
253
246
|
updatedAt: nowIso(),
|
|
254
247
|
};
|
|
255
|
-
await
|
|
248
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
249
|
+
runId,
|
|
250
|
+
at: nowIso(),
|
|
251
|
+
type: 'run.stopped',
|
|
252
|
+
stage: 'summary',
|
|
253
|
+
message: 'Stop requested. Finishing with stopped status.',
|
|
254
|
+
});
|
|
256
255
|
break;
|
|
257
256
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
257
|
+
taskStore = reclaimExpiredTaskLeases(taskStore);
|
|
258
|
+
fileStore = reclaimExpiredFileLeases(fileStore);
|
|
259
|
+
fileStore = reconcileFileStore(fileStore, buildFileQueue({
|
|
260
|
+
projectRoot: options.projectRoot,
|
|
261
|
+
workRoot: isolation.workRoot,
|
|
262
|
+
config: options.config,
|
|
263
|
+
mode: options.mode,
|
|
264
|
+
taskStore,
|
|
265
|
+
}));
|
|
262
266
|
state = {
|
|
263
267
|
...state,
|
|
264
|
-
|
|
268
|
+
workers: resetWorkers(state.workers),
|
|
265
269
|
updatedAt: nowIso(),
|
|
266
270
|
};
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
271
|
+
const now = new Date();
|
|
272
|
+
const queuedTasks = countRunnableTasks(taskStore, options.config, now);
|
|
273
|
+
const queuedFiles = countRunnableFiles(fileStore, now);
|
|
274
|
+
const pendingBacklog = queuedTasks +
|
|
275
|
+
taskStore.tasks.filter((task) => ['leased', 'applying', 'validating'].includes(task.status))
|
|
276
|
+
.length;
|
|
277
|
+
let analyzeWorkerCount = Math.min(options.config.execution.maxAnalyzeWorkers, options.config.scan.maxConcurrentScans);
|
|
278
|
+
if (pendingBacklog >= options.config.execution.backlogTarget ||
|
|
279
|
+
outstandingAnalyzeTokens(fileStore) >
|
|
280
|
+
analyzeRoute.settings.maxOutputTokens * Math.max(1, analyzeWorkerCount)) {
|
|
281
|
+
analyzeWorkerCount = Math.min(analyzeWorkerCount, 1);
|
|
282
|
+
}
|
|
283
|
+
if (pendingBacklog >= options.config.execution.backlogTarget * 2) {
|
|
284
|
+
analyzeWorkerCount = 0;
|
|
285
|
+
}
|
|
286
|
+
if (state.consecutiveFailures >= 2) {
|
|
287
|
+
analyzeWorkerCount = Math.min(analyzeWorkerCount, 1);
|
|
288
|
+
}
|
|
289
|
+
if (analyzeWorkerCount > 0 && queuedFiles > 0) {
|
|
290
|
+
const leasedFiles = leaseFilesForAnalysis({
|
|
291
|
+
store: fileStore,
|
|
292
|
+
runId,
|
|
293
|
+
maxCount: analyzeWorkerCount,
|
|
294
|
+
leaseTtlMs,
|
|
295
|
+
now,
|
|
279
296
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
297
|
+
fileStore = leasedFiles.store;
|
|
298
|
+
leasedFiles.leased.forEach((file, index) => {
|
|
299
|
+
const worker = state.workers.filter((candidate) => candidate.kind === 'analyze')[index];
|
|
300
|
+
if (worker) {
|
|
301
|
+
state = {
|
|
302
|
+
...state,
|
|
303
|
+
workers: setWorkerState(state.workers, worker.id, {
|
|
304
|
+
status: 'running',
|
|
305
|
+
targetId: file.id,
|
|
306
|
+
startedAt: nowIso(),
|
|
307
|
+
details: file.path,
|
|
308
|
+
}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
313
|
+
const analyzeResults = await Promise.all(leasedFiles.leased.map(async (file) => {
|
|
314
|
+
try {
|
|
315
|
+
const analysisMaterial = file.analysisStrategy === 'tooling'
|
|
316
|
+
? null
|
|
317
|
+
: prepareAnalysisMaterial(isolation.workRoot, file);
|
|
318
|
+
const toolingFindings = options.mode === 'linting'
|
|
319
|
+
? collectLintFindings(isolation.workRoot, file.path)
|
|
320
|
+
: null;
|
|
321
|
+
if (toolingFindings && toolingFindings.length > 0) {
|
|
322
|
+
return {
|
|
323
|
+
kind: 'success',
|
|
324
|
+
file,
|
|
325
|
+
provider: 'tooling',
|
|
326
|
+
findings: toolingFindings,
|
|
327
|
+
analysisStrategy: 'tooling',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const aiMaterial = analysisMaterial ??
|
|
331
|
+
prepareAnalysisMaterial(isolation.workRoot, {
|
|
332
|
+
...file,
|
|
333
|
+
analysisStrategy: file.estimatedTokens > Math.floor(analyzeRoute.settings.maxOutputTokens * 0.75)
|
|
334
|
+
? 'windowed'
|
|
335
|
+
: 'full',
|
|
336
|
+
});
|
|
337
|
+
const scanPrompt = buildScanPrompt({
|
|
338
|
+
filePath: file.path,
|
|
339
|
+
material: aiMaterial,
|
|
340
|
+
fingerprint,
|
|
341
|
+
agents: options.agents,
|
|
342
|
+
modePrompt: modePrompt(options.mode),
|
|
343
|
+
});
|
|
344
|
+
const scanExecution = await executeStageScan(analyzeRoute, {
|
|
345
|
+
rootDir: options.projectRoot,
|
|
346
|
+
workingDirectory: isolation.workRoot,
|
|
347
|
+
prompt: scanPrompt,
|
|
348
|
+
dryRun: false,
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
kind: 'success',
|
|
352
|
+
file,
|
|
353
|
+
provider: scanExecution.provider,
|
|
354
|
+
findings: scanExecution.result.tasks,
|
|
355
|
+
analysisStrategy: aiMaterial.strategy,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
return {
|
|
360
|
+
kind: 'failure',
|
|
361
|
+
file,
|
|
362
|
+
error: error instanceof Error ? error.message : String(error),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}));
|
|
366
|
+
for (const result of analyzeResults) {
|
|
367
|
+
if (result.kind === 'success') {
|
|
368
|
+
const merged = mergeScanFindings(taskStore, result.findings, result.file.snapshotHash);
|
|
369
|
+
taskStore = merged.store;
|
|
370
|
+
fileStore = noteFileAnalyzed(fileStore, result.file.id, {
|
|
371
|
+
analysisStrategy: result.analysisStrategy,
|
|
372
|
+
});
|
|
373
|
+
state = {
|
|
374
|
+
...state,
|
|
375
|
+
phase: 'analyze',
|
|
376
|
+
consecutiveFailures: 0,
|
|
377
|
+
updatedAt: nowIso(),
|
|
378
|
+
};
|
|
379
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
380
|
+
runId,
|
|
381
|
+
at: nowIso(),
|
|
382
|
+
type: 'analysis.completed',
|
|
383
|
+
stage: 'analyze',
|
|
384
|
+
itemId: result.file.id,
|
|
385
|
+
itemPath: result.file.path,
|
|
386
|
+
message: `Analyzed ${result.file.path} with ${result.findings.length} finding(s).`,
|
|
387
|
+
metadata: {
|
|
388
|
+
provider: result.provider,
|
|
389
|
+
findings: result.findings.length,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
fileStore = noteFileRetry({
|
|
395
|
+
store: fileStore,
|
|
396
|
+
fileId: result.file.id,
|
|
397
|
+
error: result.error,
|
|
398
|
+
maxAttempts: options.config.execution.maxAttempts,
|
|
399
|
+
});
|
|
400
|
+
state = {
|
|
401
|
+
...state,
|
|
402
|
+
consecutiveFailures: state.consecutiveFailures + 1,
|
|
403
|
+
updatedAt: nowIso(),
|
|
404
|
+
};
|
|
405
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
406
|
+
runId,
|
|
407
|
+
at: nowIso(),
|
|
408
|
+
type: 'analysis.failed',
|
|
409
|
+
stage: 'analyze',
|
|
410
|
+
itemId: result.file.id,
|
|
411
|
+
itemPath: result.file.path,
|
|
412
|
+
message: result.error,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
286
417
|
}
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
418
|
+
const noRunnableFiles = countRunnableFiles(fileStore, new Date()) === 0;
|
|
419
|
+
const shouldFix = countRunnableTasks(taskStore, options.config, new Date()) > 0 &&
|
|
420
|
+
(countRunnableTasks(taskStore, options.config, new Date()) >=
|
|
421
|
+
options.config.execution.backlogTarget ||
|
|
422
|
+
noRunnableFiles) &&
|
|
423
|
+
state.completedFixCount < options.config.execution.maxFixesPerRun;
|
|
424
|
+
if (shouldFix) {
|
|
425
|
+
const fixCandidates = listFixCandidates(taskStore, options.config, new Date());
|
|
426
|
+
const requestedFixWorkers = options.config.execution.maxFixWorkers > 1 &&
|
|
427
|
+
canRunSecondFixWorker(fixCandidates.slice(0, 2).flatMap((task) => task.targetFiles))
|
|
428
|
+
? Math.min(options.config.execution.maxFixWorkers, 2)
|
|
429
|
+
: 1;
|
|
430
|
+
const leasedTasks = leaseTasksForFix({
|
|
431
|
+
store: taskStore,
|
|
432
|
+
runId,
|
|
433
|
+
maxCount: requestedFixWorkers,
|
|
434
|
+
leaseTtlMs,
|
|
435
|
+
allowBroadRisk: options.config.scan.allowBroadRisk,
|
|
436
|
+
now: new Date(),
|
|
300
437
|
});
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
438
|
+
taskStore = leasedTasks.store;
|
|
439
|
+
state = {
|
|
440
|
+
...state,
|
|
441
|
+
phase: 'fix',
|
|
442
|
+
claimedTaskIds: [
|
|
443
|
+
...new Set([...state.claimedTaskIds, ...leasedTasks.leased.map((task) => task.id)]),
|
|
444
|
+
],
|
|
445
|
+
workers: leasedTasks.leased.reduce((workers, task, index) => {
|
|
446
|
+
const fixWorker = workers.filter((candidate) => candidate.kind === 'fix')[index];
|
|
447
|
+
if (!fixWorker) {
|
|
448
|
+
return workers;
|
|
449
|
+
}
|
|
450
|
+
return setWorkerState(workers, fixWorker.id, {
|
|
451
|
+
status: 'running',
|
|
452
|
+
targetId: task.id,
|
|
453
|
+
startedAt: nowIso(),
|
|
454
|
+
details: task.title,
|
|
455
|
+
});
|
|
456
|
+
}, state.workers),
|
|
457
|
+
};
|
|
458
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
459
|
+
for (const task of leasedTasks.leased) {
|
|
460
|
+
taskStore = noteTaskProgress(taskStore, task.id, 'applying');
|
|
461
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
462
|
+
runId,
|
|
463
|
+
at: nowIso(),
|
|
464
|
+
type: 'fix.started',
|
|
465
|
+
stage: 'fix',
|
|
466
|
+
itemId: task.id,
|
|
467
|
+
itemPath: task.file,
|
|
468
|
+
message: `Fix started for ${task.title}.`,
|
|
469
|
+
});
|
|
470
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
471
|
+
try {
|
|
472
|
+
const fileContents = await Promise.all(task.handoff.targetFiles.map(async (filePath) => ({
|
|
473
|
+
path: filePath,
|
|
474
|
+
content: await readFileIfExists(resolve(isolation.workRoot, filePath)),
|
|
475
|
+
})));
|
|
476
|
+
const fixExecution = await executeStageFix(fixRoute, {
|
|
477
|
+
rootDir: options.projectRoot,
|
|
478
|
+
workingDirectory: isolation.workRoot,
|
|
479
|
+
prompt: buildFixPrompt({
|
|
480
|
+
adapter: getStageAdapter(fixRoute),
|
|
481
|
+
task,
|
|
482
|
+
fileContents,
|
|
483
|
+
modePrompt: modePrompt(options.mode),
|
|
484
|
+
}),
|
|
485
|
+
dryRun: false,
|
|
486
|
+
});
|
|
487
|
+
taskStore = noteTaskProgress(taskStore, task.id, 'validating');
|
|
488
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
489
|
+
const adapter = getStageAdapter({
|
|
490
|
+
...fixRoute,
|
|
491
|
+
provider: fixExecution.provider,
|
|
492
|
+
});
|
|
493
|
+
const validationResult = runValidation(isolation.workRoot, options.mode, options.config.execution.validateAfterFix);
|
|
494
|
+
if (validationResult.status === 'failed') {
|
|
495
|
+
taskStore = noteTaskRetry({
|
|
496
|
+
store: taskStore,
|
|
497
|
+
taskId: task.id,
|
|
498
|
+
error: validationResult.summary,
|
|
499
|
+
maxAttempts: options.config.execution.maxAttempts,
|
|
500
|
+
blocked: true,
|
|
501
|
+
validationResult,
|
|
502
|
+
});
|
|
503
|
+
state = {
|
|
504
|
+
...state,
|
|
505
|
+
consecutiveFailures: state.consecutiveFailures + 1,
|
|
506
|
+
updatedAt: nowIso(),
|
|
507
|
+
};
|
|
508
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
509
|
+
runId,
|
|
510
|
+
at: nowIso(),
|
|
511
|
+
type: 'fix.validation-failed',
|
|
512
|
+
stage: 'fix',
|
|
513
|
+
itemId: task.id,
|
|
514
|
+
itemPath: task.file,
|
|
515
|
+
message: validationResult.summary,
|
|
516
|
+
});
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const changedFiles = fixExecution.result.changedFiles.length > 0
|
|
520
|
+
? fixExecution.result.changedFiles
|
|
521
|
+
: task.handoff.targetFiles;
|
|
522
|
+
const committed = adapter.executionMode === 'fake'
|
|
523
|
+
? changedFiles.length > 0
|
|
524
|
+
: commitTaskChanges(isolation.workRoot, `corydora: ${task.category}: ${task.title.slice(0, 60)}`, changedFiles, options.skipCommitHooks ? { skipHooks: true } : {});
|
|
525
|
+
if (!committed) {
|
|
526
|
+
taskStore = noteTaskRetry({
|
|
527
|
+
store: taskStore,
|
|
528
|
+
taskId: task.id,
|
|
529
|
+
error: 'No changes were produced.',
|
|
530
|
+
maxAttempts: options.config.execution.maxAttempts,
|
|
531
|
+
blocked: true,
|
|
532
|
+
validationResult,
|
|
533
|
+
});
|
|
534
|
+
state = {
|
|
535
|
+
...state,
|
|
536
|
+
consecutiveFailures: state.consecutiveFailures + 1,
|
|
537
|
+
updatedAt: nowIso(),
|
|
538
|
+
};
|
|
539
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
540
|
+
runId,
|
|
541
|
+
at: nowIso(),
|
|
542
|
+
type: 'fix.blocked',
|
|
543
|
+
stage: 'fix',
|
|
544
|
+
itemId: task.id,
|
|
545
|
+
itemPath: task.file,
|
|
546
|
+
message: 'No changes were produced.',
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
taskStore = noteTaskCompleted(taskStore, task.id, validationResult);
|
|
551
|
+
state = {
|
|
552
|
+
...state,
|
|
553
|
+
completedFixCount: state.completedFixCount + 1,
|
|
554
|
+
completedTaskIds: [...new Set([...state.completedTaskIds, task.id])],
|
|
555
|
+
consecutiveFailures: 0,
|
|
556
|
+
updatedAt: nowIso(),
|
|
557
|
+
};
|
|
558
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
559
|
+
runId,
|
|
560
|
+
at: nowIso(),
|
|
561
|
+
type: 'fix.completed',
|
|
562
|
+
stage: 'fix',
|
|
563
|
+
itemId: task.id,
|
|
564
|
+
itemPath: task.file,
|
|
565
|
+
message: `Completed fix for ${task.title}.`,
|
|
566
|
+
metadata: {
|
|
567
|
+
changedFiles: changedFiles.length,
|
|
568
|
+
validation: validationResult.status,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
taskStore = noteTaskRetry({
|
|
574
|
+
store: taskStore,
|
|
575
|
+
taskId: task.id,
|
|
576
|
+
error: error instanceof Error ? error.message : String(error),
|
|
577
|
+
maxAttempts: options.config.execution.maxAttempts,
|
|
578
|
+
});
|
|
579
|
+
state = {
|
|
580
|
+
...state,
|
|
581
|
+
consecutiveFailures: state.consecutiveFailures + 1,
|
|
582
|
+
updatedAt: nowIso(),
|
|
583
|
+
};
|
|
584
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
585
|
+
runId,
|
|
586
|
+
at: nowIso(),
|
|
587
|
+
type: 'fix.failed',
|
|
588
|
+
stage: 'fix',
|
|
589
|
+
itemId: task.id,
|
|
590
|
+
itemPath: task.file,
|
|
591
|
+
message: error instanceof Error ? error.message : String(error),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
596
|
+
}
|
|
305
597
|
}
|
|
306
|
-
await saveAllState(options.projectRoot, options.config, store, state);
|
|
307
598
|
}
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
599
|
+
const remainingRunnableFiles = countRunnableFiles(fileStore, new Date());
|
|
600
|
+
const remainingRunnableTasks = countRunnableTasks(taskStore, options.config, new Date());
|
|
601
|
+
const activeTaskCount = taskStore.tasks.filter((task) => ['leased', 'applying', 'validating'].includes(task.status)).length;
|
|
602
|
+
if (remainingRunnableFiles === 0 && remainingRunnableTasks === 0 && activeTaskCount === 0) {
|
|
603
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
604
|
+
runId,
|
|
605
|
+
at: nowIso(),
|
|
606
|
+
type: 'run.drained',
|
|
607
|
+
stage: 'summary',
|
|
608
|
+
message: 'All file and task queues are drained.',
|
|
609
|
+
});
|
|
312
610
|
break;
|
|
313
611
|
}
|
|
612
|
+
if (remainingRunnableFiles === 0 && remainingRunnableTasks === 0) {
|
|
613
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_000));
|
|
614
|
+
}
|
|
314
615
|
}
|
|
315
616
|
if (state.status === 'running') {
|
|
316
617
|
state = {
|
|
317
618
|
...state,
|
|
318
619
|
status: 'completed',
|
|
319
|
-
phase: '
|
|
620
|
+
phase: 'summary',
|
|
320
621
|
finishedAt: nowIso(),
|
|
321
622
|
updatedAt: nowIso(),
|
|
623
|
+
summary: buildRunSummary(taskStore, fileStore),
|
|
322
624
|
};
|
|
323
|
-
await
|
|
625
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
626
|
+
runId,
|
|
627
|
+
at: nowIso(),
|
|
628
|
+
type: 'run.completed',
|
|
629
|
+
stage: 'summary',
|
|
630
|
+
message: state.summary ?? 'Run completed.',
|
|
631
|
+
});
|
|
324
632
|
}
|
|
325
633
|
}
|
|
326
634
|
catch (error) {
|
|
327
635
|
state = {
|
|
328
636
|
...state,
|
|
329
637
|
status: 'failed',
|
|
330
|
-
phase: '
|
|
638
|
+
phase: 'summary',
|
|
331
639
|
finishedAt: nowIso(),
|
|
332
640
|
updatedAt: nowIso(),
|
|
641
|
+
summary: buildRunSummary(taskStore, fileStore),
|
|
333
642
|
};
|
|
334
|
-
await
|
|
643
|
+
await emitEvent(options.projectRoot, options.config, logger, {
|
|
644
|
+
runId,
|
|
645
|
+
at: nowIso(),
|
|
646
|
+
type: 'run.failed',
|
|
647
|
+
stage: 'summary',
|
|
648
|
+
message: error instanceof Error ? error.message : String(error),
|
|
649
|
+
});
|
|
335
650
|
}
|
|
336
651
|
finally {
|
|
337
|
-
await saveAllState(options.projectRoot, options.config,
|
|
652
|
+
await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
|
|
338
653
|
}
|
|
339
654
|
return state;
|
|
340
655
|
}
|