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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -2
  3. package/dist/commands/doctor.js +37 -1
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/init.js +114 -14
  6. package/dist/commands/init.js.map +1 -1
  7. package/dist/commands/run.d.ts +3 -0
  8. package/dist/commands/run.js +51 -20
  9. package/dist/commands/run.js.map +1 -1
  10. package/dist/commands/status.js +37 -6
  11. package/dist/commands/status.js.map +1 -1
  12. package/dist/config/files.js +23 -1
  13. package/dist/config/files.js.map +1 -1
  14. package/dist/config/schema.d.ts +0 -80
  15. package/dist/config/schema.js +162 -52
  16. package/dist/config/schema.js.map +1 -1
  17. package/dist/constants.d.ts +1 -0
  18. package/dist/constants.js +9 -0
  19. package/dist/constants.js.map +1 -1
  20. package/dist/filesystem/discovery.d.ts +2 -0
  21. package/dist/filesystem/discovery.js +2 -2
  22. package/dist/filesystem/discovery.js.map +1 -1
  23. package/dist/filesystem/gitignore.d.ts +3 -0
  24. package/dist/filesystem/gitignore.js +37 -7
  25. package/dist/filesystem/gitignore.js.map +1 -1
  26. package/dist/git/isolation.d.ts +1 -0
  27. package/dist/git/isolation.js +5 -4
  28. package/dist/git/isolation.js.map +1 -1
  29. package/dist/git/repository.d.ts +2 -1
  30. package/dist/git/repository.js +21 -11
  31. package/dist/git/repository.js.map +1 -1
  32. package/dist/index.js +17 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/providers/fake.js +16 -0
  35. package/dist/providers/fake.js.map +1 -1
  36. package/dist/providers/utils.js +24 -0
  37. package/dist/providers/utils.js.map +1 -1
  38. package/dist/queue/render.js +10 -3
  39. package/dist/queue/render.js.map +1 -1
  40. package/dist/queue/state.d.ts +53 -4
  41. package/dist/queue/state.js +301 -29
  42. package/dist/queue/state.js.map +1 -1
  43. package/dist/runtime/modes.d.ts +29 -0
  44. package/dist/runtime/modes.js +423 -0
  45. package/dist/runtime/modes.js.map +1 -0
  46. package/dist/runtime/prompts.d.ts +8 -3
  47. package/dist/runtime/prompts.js +58 -12
  48. package/dist/runtime/prompts.js.map +1 -1
  49. package/dist/runtime/routes.d.ts +28 -0
  50. package/dist/runtime/routes.js +87 -0
  51. package/dist/runtime/routes.js.map +1 -0
  52. package/dist/runtime/run-session.d.ts +3 -2
  53. package/dist/runtime/run-session.js +521 -206
  54. package/dist/runtime/run-session.js.map +1 -1
  55. package/dist/runtime/tooling.d.ts +2 -0
  56. package/dist/runtime/tooling.js +79 -0
  57. package/dist/runtime/tooling.js.map +1 -0
  58. package/dist/runtime/validation.d.ts +7 -0
  59. package/dist/runtime/validation.js +115 -0
  60. package/dist/runtime/validation.js.map +1 -0
  61. package/dist/types/domain.d.ts +139 -16
  62. package/package.json +1 -1
  63. 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 { commitAllChanges } from '../git/repository.js';
7
+ import { commitTaskChanges } from '../git/repository.js';
9
8
  import { prepareIsolationContext } from '../git/isolation.js';
10
- import { countTasksByStatus, loadRunState, loadTaskStore, mergeScanFindings, saveRunArtifact, saveRunState, saveTaskStore, claimNextTask, updateTaskStatus, } from '../queue/state.js';
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 { getRuntimeAdapter } from '../providers/index.js';
14
- import { noteFileProcessed, restoreSchedulerState, selectScanBatch } from './scheduler.js';
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
- async function saveAllState(projectRoot, config, store, state) {
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 processScans(options) {
45
- const adapter = getRuntimeAdapter(options.config.runtime.provider);
46
- const fingerprint = detectProjectFingerprint(options.workRoot);
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 files = discoverCandidateFiles(isolation.workRoot, {
198
- includeExtensions: options.config.scan.includeExtensions,
199
- excludeDirectories: options.config.scan.excludeDirectories,
200
- });
201
- let store = await loadTaskStore(options.projectRoot, options.config);
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: 'scan',
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
- scheduler: restoreSchedulerState(undefined, files),
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: 'scan',
202
+ phase: 'analyze',
238
203
  workRoot: isolation.workRoot,
239
- scheduler: restoreSchedulerState(state.scheduler, files),
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, store, state);
243
- await logger(`Run ${runId} prepared (isolation=${isolation.mode}, branch=${isolation.branchName ?? 'current-branch'}).`);
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: 'idle',
244
+ phase: 'summary',
252
245
  finishedAt: nowIso(),
253
246
  updatedAt: nowIso(),
254
247
  };
255
- await logger('Stop requested. Finishing with stopped status.');
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
- const latestFiles = discoverCandidateFiles(isolation.workRoot, {
259
- includeExtensions: options.config.scan.includeExtensions,
260
- excludeDirectories: options.config.scan.excludeDirectories,
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
- scheduler: restoreSchedulerState(state.scheduler, latestFiles),
268
+ workers: resetWorkers(state.workers),
265
269
  updatedAt: nowIso(),
266
270
  };
267
- const scanBatch = selectScanBatch(state.scheduler, latestFiles, options.config.scan.batchSize);
268
- if (scanBatch.length > 0) {
269
- await logger(`Scan batch selected (${scanBatch.length}): ${scanBatch.join(', ')}`);
270
- const processed = await processScans({
271
- files: scanBatch,
272
- projectRoot: options.projectRoot,
273
- workRoot: isolation.workRoot,
274
- config: options.config,
275
- agents: options.agents,
276
- state,
277
- store,
278
- logger,
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
- state = {
281
- ...processed.state,
282
- phase: 'scan',
283
- };
284
- store = processed.store;
285
- await saveAllState(options.projectRoot, options.config, store, state);
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 pendingCount = countTasksByStatus(store, 'pending');
288
- const noRemainingFiles = scanBatch.length === 0;
289
- const shouldFix = pendingCount >= options.config.execution.backlogTarget ||
290
- (noRemainingFiles && pendingCount > 0);
291
- if (shouldFix && state.completedFixCount < options.config.execution.maxFixesPerRun) {
292
- const fixed = await processSingleFix({
293
- projectRoot: options.projectRoot,
294
- workRoot: isolation.workRoot,
295
- config: options.config,
296
- state,
297
- store,
298
- logger,
299
- ...(options.skipCommitHooks ? { skipCommitHooks: true } : {}),
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
- state = fixed.state;
302
- store = fixed.store;
303
- if (fixed.fixedTaskId) {
304
- await logger(`Fix attempted for task ${fixed.fixedTaskId}.`);
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 exhaustedFiles = selectScanBatch(state.scheduler, latestFiles, 1).length === 0;
309
- const exhaustedTasks = countTasksByStatus(store, 'pending') === 0;
310
- if (exhaustedFiles && exhaustedTasks) {
311
- await logger('All scan and task queues are empty. Finishing.');
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: 'idle',
620
+ phase: 'summary',
320
621
  finishedAt: nowIso(),
321
622
  updatedAt: nowIso(),
623
+ summary: buildRunSummary(taskStore, fileStore),
322
624
  };
323
- await logger('Run completed.');
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: 'idle',
638
+ phase: 'summary',
331
639
  finishedAt: nowIso(),
332
640
  updatedAt: nowIso(),
641
+ summary: buildRunSummary(taskStore, fileStore),
333
642
  };
334
- await logger(`Run failed: ${error instanceof Error ? error.message : String(error)}`);
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, store, state);
652
+ await saveAllState(options.projectRoot, options.config, taskStore, fileStore, state);
338
653
  }
339
654
  return state;
340
655
  }