viberadar 0.3.59 → 0.3.61

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.
@@ -145,6 +145,9 @@ function randomInt(min, max) {
145
145
  return min;
146
146
  return Math.floor(Math.random() * (max - min + 1)) + min;
147
147
  }
148
+ function newRunId() {
149
+ return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
150
+ }
148
151
  function detectQueueBlockSignal(line) {
149
152
  const s = line.toLowerCase();
150
153
  const is403 = ((s.includes('403') && (s.includes('forbidden') || s.includes('unexpected status') || s.includes('status'))) ||
@@ -848,6 +851,10 @@ function startServer({ data: initialData, port, projectRoot }) {
848
851
  let testsRunning = false;
849
852
  const agentQueue = [];
850
853
  let queueCooldownTimer = null;
854
+ let activeAgentProcess = null;
855
+ const runs = [];
856
+ const MAX_RUN_HISTORY = 120;
857
+ let activeRunId = null;
851
858
  // Keyed by absolute file path → per-file failure details from last test run
852
859
  const lastTestResults = new Map();
853
860
  // ── SSE clients ────────────────────────────────────────────────────────────
@@ -863,6 +870,194 @@ function startServer({ data: initialData, port, projectRoot }) {
863
870
  }
864
871
  }
865
872
  }
873
+ function compactRunHistory() {
874
+ if (runs.length <= MAX_RUN_HISTORY)
875
+ return;
876
+ runs.splice(0, runs.length - MAX_RUN_HISTORY);
877
+ }
878
+ function refreshQueuePositions() {
879
+ const pos = new Map();
880
+ agentQueue.forEach((item, index) => pos.set(item.runId, index + 1));
881
+ runs.forEach((run) => {
882
+ run.queuePosition = pos.get(run.runId) ?? null;
883
+ });
884
+ }
885
+ function queueSnapshotItem(item, index) {
886
+ return {
887
+ runId: item.runId,
888
+ title: item.title,
889
+ task: item.task,
890
+ featureKey: item.featureKey || null,
891
+ filePath: item.filePath || null,
892
+ selectedFilePaths: item.selectedFilePaths || null,
893
+ selectedFileCount: item.selectedFilePaths?.length || 0,
894
+ position: index + 1,
895
+ };
896
+ }
897
+ function emitQueueUpdated() {
898
+ refreshQueuePositions();
899
+ const queue = agentQueue.map((item, index) => queueSnapshotItem(item, index));
900
+ broadcast('agent-queue-updated', { queue });
901
+ }
902
+ function findRun(runId) {
903
+ return runs.find((r) => r.runId === runId);
904
+ }
905
+ function createRun(item, phase) {
906
+ const now = new Date().toISOString();
907
+ const run = {
908
+ runId: item.runId,
909
+ task: item.task,
910
+ title: item.title,
911
+ agent: item.agent,
912
+ featureKey: item.featureKey,
913
+ filePath: item.filePath,
914
+ selectedFilePaths: item.selectedFilePaths,
915
+ phase,
916
+ queuePosition: null,
917
+ createdAt: now,
918
+ };
919
+ runs.push(run);
920
+ compactRunHistory();
921
+ refreshQueuePositions();
922
+ broadcast('agent-run-created', { run });
923
+ return run;
924
+ }
925
+ function updateRun(runId, patch) {
926
+ const run = findRun(runId);
927
+ if (!run)
928
+ return;
929
+ Object.assign(run, patch);
930
+ refreshQueuePositions();
931
+ broadcast('agent-run-updated', { run });
932
+ }
933
+ function setRunPhase(runId, phase, patch = {}) {
934
+ const nowIso = new Date().toISOString();
935
+ const autoPatch = {};
936
+ if (phase === 'starting' && !findRun(runId)?.startedAt)
937
+ autoPatch.startedAt = nowIso;
938
+ if (phase === 'completed' || phase === 'failed' || phase === 'canceled') {
939
+ autoPatch.finishedAt = nowIso;
940
+ }
941
+ updateRun(runId, { phase, ...autoPatch, ...patch });
942
+ if (phase === 'running' || phase === 'starting' || phase === 'validating') {
943
+ activeRunId = runId;
944
+ }
945
+ if (phase === 'completed' || phase === 'failed' || phase === 'canceled') {
946
+ if (activeRunId === runId)
947
+ activeRunId = null;
948
+ const run = findRun(runId);
949
+ if (run)
950
+ broadcast('agent-run-finished', { run });
951
+ }
952
+ }
953
+ function buildValidationStats(fileOutcomes) {
954
+ let covered = 0;
955
+ let notCovered = 0;
956
+ let blocked = 0;
957
+ let infra = 0;
958
+ for (const outcome of fileOutcomes) {
959
+ if (outcome.status === 'covered')
960
+ covered += 1;
961
+ else if (outcome.status === 'not-covered')
962
+ notCovered += 1;
963
+ else if (outcome.status === 'blocked')
964
+ blocked += 1;
965
+ else if (outcome.status === 'infra')
966
+ infra += 1;
967
+ }
968
+ return {
969
+ total: fileOutcomes.length,
970
+ covered,
971
+ notCovered,
972
+ blocked,
973
+ infra,
974
+ };
975
+ }
976
+ function buildFileOutcomes(targetSourcePaths) {
977
+ const normalizeRelPath = (p) => p.replace(/\\/g, '/');
978
+ const uniqueTargets = Array.from(new Set(targetSourcePaths.map(normalizeRelPath)));
979
+ const srcByPath = new Map(currentData.modules
980
+ .filter((m) => m.type !== 'test')
981
+ .map((m) => [normalizeRelPath(m.relativePath), m]));
982
+ const fileOutcomes = uniqueTargets.map((relPath) => {
983
+ const mod = srcByPath.get(relPath);
984
+ if (!mod) {
985
+ return {
986
+ sourcePath: relPath,
987
+ status: 'blocked',
988
+ reason: 'source-file-not-found-after-rescan',
989
+ };
990
+ }
991
+ if (mod.isInfra) {
992
+ return {
993
+ sourcePath: relPath,
994
+ status: 'infra',
995
+ reason: 'matched-infra-ignore-pattern',
996
+ };
997
+ }
998
+ if (mod.hasTests) {
999
+ return {
1000
+ sourcePath: relPath,
1001
+ status: 'covered',
1002
+ testFile: mod.testFile ? normalizeRelPath(mod.testFile) : undefined,
1003
+ };
1004
+ }
1005
+ return {
1006
+ sourcePath: relPath,
1007
+ status: 'not-covered',
1008
+ reason: 'scanner-did-not-link-test-file',
1009
+ };
1010
+ });
1011
+ return { fileOutcomes, validationStats: buildValidationStats(fileOutcomes) };
1012
+ }
1013
+ function moveQueueItem(runId, direction) {
1014
+ const index = agentQueue.findIndex((item) => item.runId === runId);
1015
+ if (index === -1)
1016
+ return { ok: false, message: `Run ${runId} не найден в очереди` };
1017
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
1018
+ if (targetIndex < 0 || targetIndex >= agentQueue.length) {
1019
+ return { ok: false, message: `Run ${runId} нельзя сдвинуть ${direction === 'up' ? 'вверх' : 'вниз'}` };
1020
+ }
1021
+ const [item] = agentQueue.splice(index, 1);
1022
+ agentQueue.splice(targetIndex, 0, item);
1023
+ emitQueueUpdated();
1024
+ return { ok: true };
1025
+ }
1026
+ function cancelQueuedRun(runId, reason = 'canceled-by-user') {
1027
+ const index = agentQueue.findIndex((item) => item.runId === runId);
1028
+ if (index === -1)
1029
+ return { ok: false, message: `Run ${runId} не найден в очереди` };
1030
+ const [item] = agentQueue.splice(index, 1);
1031
+ setRunPhase(item.runId, 'canceled', { error: reason });
1032
+ emitQueueUpdated();
1033
+ return { ok: true };
1034
+ }
1035
+ function enqueueItem(item) {
1036
+ agentQueue.push(item);
1037
+ refreshQueuePositions();
1038
+ emitQueueUpdated();
1039
+ }
1040
+ function buildAgentStateSnapshot() {
1041
+ refreshQueuePositions();
1042
+ const queue = agentQueue.map((item, index) => queueSnapshotItem(item, index));
1043
+ const activeRun = activeRunId ? (findRun(activeRunId) || null) : null;
1044
+ return {
1045
+ activeRun,
1046
+ queue,
1047
+ runs: runs.slice(-50),
1048
+ runtimeFlags: {
1049
+ agentRunning,
1050
+ testsRunning,
1051
+ queueCooldownActive: !!queueCooldownTimer,
1052
+ queueMax: runtimeEnv.agentQueueMax,
1053
+ cooldownMinMs: runtimeEnv.agentCooldownMinMs,
1054
+ cooldownMaxMs: runtimeEnv.agentCooldownMaxMs,
1055
+ codexSandboxMode: runtimeEnv.codexSandboxMode,
1056
+ autoFixFailedTests: runtimeEnv.autoFixFailedTests,
1057
+ autoFixMaxRetries: runtimeEnv.autoFixMaxRetries,
1058
+ },
1059
+ };
1060
+ }
866
1061
  function clearQueueCooldownTimer() {
867
1062
  if (!queueCooldownTimer)
868
1063
  return;
@@ -875,9 +1070,19 @@ function startServer({ data: initialData, port, projectRoot }) {
875
1070
  agentQueue.length = 0;
876
1071
  process.stdout.write(` ⛔ Queue stopped: ${reason} (dropped: ${dropped})\n`);
877
1072
  broadcast('agent-output', { line: `⛔ Очередь остановлена: ${reason}`, isError: true });
1073
+ const nowIso = new Date().toISOString();
1074
+ runs.forEach((run) => {
1075
+ if (run.phase === 'queued') {
1076
+ run.phase = 'canceled';
1077
+ run.finishedAt = nowIso;
1078
+ run.error = reason;
1079
+ broadcast('agent-run-finished', { run });
1080
+ }
1081
+ });
878
1082
  if (dropped > 0) {
879
1083
  broadcast('agent-output', { line: `🗑 Отменено задач из очереди: ${dropped}` });
880
1084
  }
1085
+ emitQueueUpdated();
881
1086
  broadcast('agent-queue-stopped', { reason, dropped });
882
1087
  }
883
1088
  // ── File watcher + re-scan ─────────────────────────────────────────────────
@@ -919,9 +1124,10 @@ function startServer({ data: initialData, port, projectRoot }) {
919
1124
  return;
920
1125
  }
921
1126
  const next = agentQueue.shift();
1127
+ emitQueueUpdated();
922
1128
  process.stdout.write(` 📋 Starting next from queue: "${next.title}" (remaining: ${agentQueue.length})\n`);
923
- broadcast('agent-output', { line: `📋 Следующая задача из очереди: ${next.title}` });
924
- broadcast('agent-output', { line: ` В очереди осталось: ${agentQueue.length}` });
1129
+ broadcast('agent-output', { runId: next.runId, line: `📋 Следующая задача из очереди: ${next.title}` });
1130
+ broadcast('agent-output', { runId: next.runId, line: ` В очереди осталось: ${agentQueue.length}` });
925
1131
  executeAgentItem(next);
926
1132
  }
927
1133
  else {
@@ -931,8 +1137,11 @@ function startServer({ data: initialData, port, projectRoot }) {
931
1137
  }
932
1138
  /** Actually spawn the agent process for a queue item */
933
1139
  function executeAgentItem(item) {
934
- const { task, featureKey, filePath, selectedFilePaths, title, agent, savedErrors, savedFailedFiles, savedTestType, autoFixAttempt = 0, autoFixSourceTask, } = item;
1140
+ const { runId, task, featureKey, filePath, selectedFilePaths, title, agent, savedErrors, savedFailedFiles, savedTestType, autoFixAttempt = 0, autoFixSourceTask, } = item;
935
1141
  const normalizeRelPath = (p) => p.replace(/\\/g, '/');
1142
+ const emitOutput = (line, isError = false, isDim = false) => {
1143
+ broadcast('agent-output', { runId, line, isError, isDim });
1144
+ };
936
1145
  const targetSourcePaths = (() => {
937
1146
  if (task === 'write-tests-file' && filePath) {
938
1147
  return [normalizeRelPath(filePath)];
@@ -947,14 +1156,24 @@ function startServer({ data: initialData, port, projectRoot }) {
947
1156
  }
948
1157
  return [];
949
1158
  })();
1159
+ setRunPhase(runId, 'starting', {
1160
+ targetSourcePaths,
1161
+ error: undefined,
1162
+ fileOutcomes: undefined,
1163
+ validationStats: undefined,
1164
+ });
1165
+ function failBeforeStart(message) {
1166
+ setRunPhase(runId, 'failed', { error: message, targetSourcePaths });
1167
+ broadcast('agent-error', { runId, message });
1168
+ agentRunning = false;
1169
+ processNextInQueue();
1170
+ }
950
1171
  // Build prompt lazily at execution time
951
1172
  let prompt;
952
1173
  if (task === 'write-tests') {
953
1174
  const feat = currentData.features?.find(f => f.key === featureKey);
954
1175
  if (!feat) {
955
- broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
956
- agentRunning = false;
957
- processNextInQueue();
1176
+ failBeforeStart(`Фича не найдена: ${featureKey}`);
958
1177
  return;
959
1178
  }
960
1179
  prompt = buildWriteTestsPrompt(feat, currentData.modules, currentData.testRunner || 'vitest');
@@ -962,9 +1181,7 @@ function startServer({ data: initialData, port, projectRoot }) {
962
1181
  else if (task === 'write-tests-file') {
963
1182
  const feat = currentData.features?.find(f => f.key === featureKey);
964
1183
  if (!feat || !filePath) {
965
- broadcast('agent-error', { message: 'Не указана фича или файл' });
966
- agentRunning = false;
967
- processNextInQueue();
1184
+ failBeforeStart('Не указана фича или файл');
968
1185
  return;
969
1186
  }
970
1187
  prompt = buildWriteTestsForFilePrompt(filePath, feat, currentData.modules, currentData.testRunner || 'vitest');
@@ -972,9 +1189,7 @@ function startServer({ data: initialData, port, projectRoot }) {
972
1189
  else if (task === 'write-tests-selected') {
973
1190
  const feat = currentData.features?.find(f => f.key === featureKey);
974
1191
  if (!feat || !selectedFilePaths || selectedFilePaths.length === 0) {
975
- broadcast('agent-error', { message: 'Не указана фича или выбранные файлы' });
976
- agentRunning = false;
977
- processNextInQueue();
1192
+ failBeforeStart('Не указана фича или выбранные файлы');
978
1193
  return;
979
1194
  }
980
1195
  prompt = buildWriteTestsForSelectedPrompt(selectedFilePaths, feat, currentData.modules, currentData.testRunner || 'vitest');
@@ -982,27 +1197,21 @@ function startServer({ data: initialData, port, projectRoot }) {
982
1197
  else if (task === 'refresh-tests-selected') {
983
1198
  const feat = currentData.features?.find(f => f.key === featureKey);
984
1199
  if (!feat || !selectedFilePaths || selectedFilePaths.length === 0) {
985
- broadcast('agent-error', { message: 'Не указана фича или выбранные файлы' });
986
- agentRunning = false;
987
- processNextInQueue();
1200
+ failBeforeStart('Не указана фича или выбранные файлы');
988
1201
  return;
989
1202
  }
990
1203
  prompt = buildRefreshTestsForSelectedPrompt(selectedFilePaths, feat, currentData.modules, currentData.testRunner || 'vitest');
991
1204
  }
992
1205
  else if (task === 'fix-tests') {
993
1206
  if (!filePath || !savedErrors || savedErrors.length === 0) {
994
- broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}` });
995
- agentRunning = false;
996
- processNextInQueue();
1207
+ failBeforeStart(`Нет сохранённых ошибок для ${filePath}`);
997
1208
  return;
998
1209
  }
999
1210
  prompt = buildFixTestsPrompt(filePath, savedErrors);
1000
1211
  }
1001
1212
  else if (task === 'fix-tests-all') {
1002
1213
  if (!savedFailedFiles || savedFailedFiles.length === 0) {
1003
- broadcast('agent-error', { message: 'Нет упавших тестов для исправления' });
1004
- agentRunning = false;
1005
- processNextInQueue();
1214
+ failBeforeStart('Нет упавших тестов для исправления');
1006
1215
  return;
1007
1216
  }
1008
1217
  prompt = buildFixAllTestsPrompt(savedFailedFiles, savedTestType || 'unit');
@@ -1010,9 +1219,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1010
1219
  else if (task === 'generate-e2e-plan') {
1011
1220
  const feat = currentData.features?.find(f => f.key === featureKey);
1012
1221
  if (!feat) {
1013
- broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
1014
- agentRunning = false;
1015
- processNextInQueue();
1222
+ failBeforeStart(`Фича не найдена: ${featureKey}`);
1016
1223
  return;
1017
1224
  }
1018
1225
  prompt = buildE2ePlanPrompt(feat, currentData.modules);
@@ -1021,9 +1228,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1021
1228
  const feat = currentData.features?.find(f => f.key === featureKey);
1022
1229
  const plan = featureKey ? loadE2ePlan(projectRoot, featureKey) : null;
1023
1230
  if (!feat || !plan) {
1024
- broadcast('agent-error', { message: `Фича или план не найдены: ${featureKey}` });
1025
- agentRunning = false;
1026
- processNextInQueue();
1231
+ failBeforeStart(`Фича или план не найдены: ${featureKey}`);
1027
1232
  return;
1028
1233
  }
1029
1234
  prompt = buildWriteE2eTestPrompt(feat, plan, currentData.modules);
@@ -1032,7 +1237,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1032
1237
  prompt = buildMapUnmappedPrompt(currentData.modules, currentData.features || []);
1033
1238
  }
1034
1239
  agentRunning = true;
1240
+ setRunPhase(runId, 'running', { targetSourcePaths });
1035
1241
  broadcast('agent-started', {
1242
+ runId,
1036
1243
  title,
1037
1244
  task,
1038
1245
  featureKey,
@@ -1057,27 +1264,30 @@ function startServer({ data: initialData, port, projectRoot }) {
1057
1264
  shell: true,
1058
1265
  stdio: ['ignore', 'pipe', 'pipe'],
1059
1266
  });
1060
- broadcast('agent-output', { line: `🚀 Запускаю: ${agent === 'claude' ? 'Claude Code' : 'Codex'}` });
1267
+ activeAgentProcess = proc;
1268
+ emitOutput(`🚀 Запускаю: ${agent === 'claude' ? 'Claude Code' : 'Codex'}`);
1061
1269
  if (agent === 'codex') {
1062
- broadcast('agent-output', { line: `🔐 Codex sandbox: ${runtimeEnv.codexSandboxMode}` });
1270
+ emitOutput(`🔐 Codex sandbox: ${runtimeEnv.codexSandboxMode}`);
1063
1271
  }
1064
- broadcast('agent-output', { line: `📄 Задача записана в .viberadar/task.md` });
1272
+ emitOutput('📄 Задача записана в .viberadar/task.md');
1065
1273
  // Track test files written/edited by agent (for auto-run after)
1066
1274
  const createdTestFiles = [];
1067
1275
  // Accumulate full result text for E2E plan parsing
1068
1276
  let agentResultText = '';
1069
1277
  let agentRawOutputText = '';
1070
1278
  let queueBlockSignal = null;
1279
+ let finalFileOutcomes = [];
1280
+ let finalValidationStats = buildValidationStats([]);
1281
+ let validationError;
1282
+ let lastTestResult = null;
1283
+ let lastTestSummary = null;
1071
1284
  function inspectQueueBlockSignal(line) {
1072
1285
  if (queueBlockSignal !== null)
1073
1286
  return;
1074
1287
  const signal = detectQueueBlockSignal(line);
1075
1288
  if (signal !== null) {
1076
1289
  queueBlockSignal = signal;
1077
- broadcast('agent-output', {
1078
- line: `⚠️ Обнаружен блокирующий сигнал ${signal}. После завершения текущей задачи очередь будет остановлена.`,
1079
- isError: true,
1080
- });
1290
+ emitOutput(`⚠️ Обнаружен блокирующий сигнал ${signal}. После завершения текущей задачи очередь будет остановлена.`, true);
1081
1291
  }
1082
1292
  }
1083
1293
  function trackWrittenFiles(raw) {
@@ -1109,21 +1319,21 @@ function startServer({ data: initialData, port, projectRoot }) {
1109
1319
  trackWrittenFiles(raw);
1110
1320
  const parsed = agent === 'claude' ? parseClaudeEvent(raw) : raw;
1111
1321
  if (!parsed) {
1112
- broadcast('agent-output', { line: raw.slice(0, 120), isDim: true });
1322
+ emitOutput(raw.slice(0, 120), false, true);
1113
1323
  return;
1114
1324
  }
1115
1325
  if (parsed.startsWith('§RESULT§')) {
1116
1326
  agentResultText = parsed.slice('§RESULT§'.length).trim();
1117
- broadcast('agent-output', { line: '─────────────────────────────' });
1327
+ emitOutput('─────────────────────────────');
1118
1328
  for (const l of agentResultText.split('\n')) {
1119
1329
  if (l.trim())
1120
- broadcast('agent-output', { line: ' ' + l });
1330
+ emitOutput(' ' + l);
1121
1331
  }
1122
1332
  }
1123
1333
  else {
1124
1334
  for (const l of parsed.split('\n')) {
1125
1335
  if (l.trim())
1126
- broadcast('agent-output', { line: l });
1336
+ emitOutput(l);
1127
1337
  }
1128
1338
  }
1129
1339
  });
@@ -1135,11 +1345,21 @@ function startServer({ data: initialData, port, projectRoot }) {
1135
1345
  agentRawOutputText += line + '\n';
1136
1346
  }
1137
1347
  inspectQueueBlockSignal(line);
1138
- broadcast('agent-output', { line, isError: true });
1348
+ emitOutput(line, true);
1139
1349
  }
1140
1350
  });
1141
1351
  proc.on('close', async (code) => {
1142
1352
  agentRunning = false;
1353
+ activeAgentProcess = null;
1354
+ const alreadyCanceled = findRun(runId)?.phase === 'canceled';
1355
+ if (alreadyCanceled) {
1356
+ process.stdout.write(` ⏹ Agent process closed for canceled run ${runId}\n`);
1357
+ processNextInQueue(true);
1358
+ return;
1359
+ }
1360
+ let finalPhase = 'completed';
1361
+ let finalError;
1362
+ let autoFixQueued = false;
1143
1363
  if (code === 0) {
1144
1364
  // Auto-run created/fixed test files and show results
1145
1365
  let testFilesToRun;
@@ -1153,9 +1373,11 @@ function startServer({ data: initialData, port, projectRoot }) {
1153
1373
  testFilesToRun = createdTestFiles;
1154
1374
  }
1155
1375
  if ((task === 'write-tests' || task === 'write-tests-file' || task === 'write-tests-selected' || task === 'refresh-tests-selected' || task === 'fix-tests' || task === 'fix-tests-all') && testFilesToRun.length > 0) {
1156
- broadcast('agent-output', { line: '─────────────────────────────' });
1157
- broadcast('agent-output', { line: `🧪 Запускаю тесты (${testFilesToRun.length} файлов)...` });
1376
+ setRunPhase(runId, 'validating', { targetSourcePaths });
1377
+ emitOutput('─────────────────────────────');
1378
+ emitOutput(`🧪 Запускаю тесты (${testFilesToRun.length} файлов)...`);
1158
1379
  const result = await runTestFiles(testFilesToRun, projectRoot);
1380
+ lastTestResult = result;
1159
1381
  lastTestResults.clear();
1160
1382
  for (const [fp, detail] of Object.entries(result.fileDetails)) {
1161
1383
  if (detail.failed > 0)
@@ -1171,26 +1393,27 @@ function startServer({ data: initialData, port, projectRoot }) {
1171
1393
  const testedFileCount = testFilesToRun.length;
1172
1394
  const failedFileCount = failedFiles.length;
1173
1395
  const passedFileCount = Math.max(0, testedFileCount - failedFileCount);
1174
- broadcast('agent-output', { line: '┌──────────────── Тест-отчёт ────────────────' });
1396
+ emitOutput('┌──────────────── Тест-отчёт ────────────────');
1175
1397
  if (result.runError) {
1176
- broadcast('agent-output', { line: `│ ❌ Ошибка запуска тестов: ${result.runError}` });
1398
+ emitOutput(`│ ❌ Ошибка запуска тестов: ${result.runError}`);
1399
+ finalPhase = 'failed';
1400
+ finalError = `Ошибка запуска тестов: ${result.runError}`;
1177
1401
  }
1178
1402
  else {
1179
1403
  const status = result.failed === 0 ? '✅ OK' : '❌ FAILED';
1180
- broadcast('agent-output', { line: `│ Статус: ${status}` });
1181
- broadcast('agent-output', { line: `│ Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}` });
1182
- broadcast('agent-output', { line: `│ Тест-кейсы: passed ${result.passed} • failed ${result.failed}` });
1404
+ emitOutput(`│ Статус: ${status}`);
1405
+ emitOutput(`│ Файлы: ${testedFileCount} • passed: ${passedFileCount} • failed: ${failedFileCount}`);
1406
+ emitOutput(`│ Тест-кейсы: passed ${result.passed} • failed ${result.failed}`);
1183
1407
  }
1184
- broadcast('agent-output', { line: '└─────────────────────────────────────────────' });
1408
+ emitOutput('└─────────────────────────────────────────────');
1185
1409
  if (result.failed > 0) {
1186
1410
  for (const f of failedFiles) {
1187
- broadcast('agent-output', { line: ` ❌ ${f.rel} — ${f.detail.failed} упало` });
1411
+ emitOutput(` ❌ ${f.rel} — ${f.detail.failed} упало`);
1188
1412
  for (const e of f.detail.errors.slice(0, 3)) {
1189
- broadcast('agent-output', { line: ` • ${e.testName}`, isDim: true });
1413
+ emitOutput(` • ${e.testName}`, false, true);
1190
1414
  }
1191
1415
  }
1192
1416
  }
1193
- let autoFixQueued = false;
1194
1417
  if (!result.runError && result.failed > 0) {
1195
1418
  const nextAttempt = autoFixAttempt + 1;
1196
1419
  const sourceTask = autoFixSourceTask || task;
@@ -1202,6 +1425,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1202
1425
  const only = failedFiles[0];
1203
1426
  const fileName = only.rel.split('/').pop() || only.rel;
1204
1427
  fixItem = {
1428
+ runId: newRunId(),
1205
1429
  task: 'fix-tests',
1206
1430
  featureKey,
1207
1431
  filePath: only.rel,
@@ -1214,6 +1438,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1214
1438
  }
1215
1439
  else if (failedFiles.length > 1) {
1216
1440
  fixItem = {
1441
+ runId: newRunId(),
1217
1442
  task: 'fix-tests-all',
1218
1443
  featureKey,
1219
1444
  title: `${agent === 'claude' ? 'Claude Code' : 'Codex'} — автоисправление ${failedFiles.length} тестов ${attemptSuffix}`,
@@ -1226,22 +1451,23 @@ function startServer({ data: initialData, port, projectRoot }) {
1226
1451
  }
1227
1452
  if (fixItem) {
1228
1453
  if (agentQueue.length < runtimeEnv.agentQueueMax) {
1454
+ createRun(fixItem, 'queued');
1229
1455
  agentQueue.unshift(fixItem);
1456
+ emitQueueUpdated();
1230
1457
  autoFixQueued = true;
1231
- broadcast('agent-output', { line: `🛠️ Обнаружены падения. Запускаю автоисправление ${attemptSuffix}...` });
1458
+ emitOutput(`🛠️ Обнаружены падения. Запускаю автоисправление ${attemptSuffix}...`);
1232
1459
  broadcast('agent-queued', {
1460
+ runId: fixItem.runId,
1233
1461
  queueLength: agentQueue.length,
1234
1462
  title: fixItem.title,
1235
1463
  task: fixItem.task,
1236
1464
  featureKey: fixItem.featureKey || null,
1237
1465
  filePath: fixItem.filePath || null,
1466
+ selectedFilePaths: fixItem.selectedFilePaths || null,
1238
1467
  });
1239
1468
  }
1240
1469
  else {
1241
- broadcast('agent-output', {
1242
- line: `⚠️ Автоисправление не поставлено: очередь заполнена (${runtimeEnv.agentQueueMax})`,
1243
- isError: true,
1244
- });
1470
+ emitOutput(`⚠️ Автоисправление не поставлено: очередь заполнена (${runtimeEnv.agentQueueMax})`, true);
1245
1471
  }
1246
1472
  }
1247
1473
  }
@@ -1249,13 +1475,19 @@ function startServer({ data: initialData, port, projectRoot }) {
1249
1475
  const reason = !runtimeEnv.autoFixFailedTests
1250
1476
  ? 'автоисправление выключено'
1251
1477
  : `достигнут лимит попыток (${runtimeEnv.autoFixMaxRetries})`;
1252
- broadcast('agent-output', { line: `⚠️ Автоисправление не запущено: ${reason}` });
1478
+ emitOutput(`⚠️ Автоисправление не запущено: ${reason}`);
1253
1479
  }
1254
1480
  }
1255
1481
  if (result.failed > 0 && !autoFixQueued) {
1256
- broadcast('agent-output', { line: ' → Нажми 🔧 исправить в дашборде чтобы агент починил' });
1482
+ emitOutput(' → Нажми 🔧 исправить в дашборде чтобы агент починил');
1483
+ finalPhase = 'failed';
1484
+ if (!finalError)
1485
+ finalError = 'Есть упавшие тесты';
1257
1486
  }
1258
- broadcast('agent-summary', { ...result, testedFileCount, passedFileCount, failedFileCount, autoFixQueued });
1487
+ if (result.failed > 0 && autoFixQueued && finalPhase !== 'failed') {
1488
+ finalPhase = 'completed';
1489
+ }
1490
+ lastTestSummary = { testedFileCount, passedFileCount, failedFileCount, autoFixQueued };
1259
1491
  }
1260
1492
  // E2E plan post-processing
1261
1493
  if (task === 'generate-e2e-plan' && featureKey) {
@@ -1279,9 +1511,12 @@ function startServer({ data: initialData, port, projectRoot }) {
1279
1511
  ? ` Возможная причина: блокировка/лимит агента (${queueBlockSignal}).`
1280
1512
  : '';
1281
1513
  broadcast('e2e-plan-error', {
1514
+ runId,
1282
1515
  featureKey,
1283
1516
  message: `Не удалось распарсить план: ${err.message}.${blockHint}`.trim(),
1284
1517
  });
1518
+ finalPhase = 'failed';
1519
+ finalError = `Не удалось распарсить E2E план: ${err.message}`;
1285
1520
  }
1286
1521
  }
1287
1522
  if (task === 'write-e2e-tests' && featureKey) {
@@ -1297,45 +1532,69 @@ function startServer({ data: initialData, port, projectRoot }) {
1297
1532
  }
1298
1533
  process.stdout.write(' ✅ Agent done, rescanning...\n');
1299
1534
  try {
1535
+ if (targetSourcePaths.length > 0) {
1536
+ setRunPhase(runId, 'validating', { targetSourcePaths });
1537
+ }
1300
1538
  currentData = await (0, scanner_1.scanProject)(projectRoot);
1301
1539
  if (targetSourcePaths.length > 0) {
1302
- const srcByPath = new Map(currentData.modules
1303
- .filter((m) => m.type !== 'test')
1304
- .map((m) => [normalizeRelPath(m.relativePath), m]));
1305
- const unresolved = targetSourcePaths.filter((rel) => {
1306
- const mod = srcByPath.get(rel);
1307
- return !!mod && !mod.hasTests && !mod.isInfra;
1308
- });
1309
- if (unresolved.length > 0) {
1310
- broadcast('agent-output', {
1311
- line: `⚠️ После задачи осталось без тестов: ${unresolved.length}/${targetSourcePaths.length} файлов`,
1312
- isError: true,
1540
+ const matrix = buildFileOutcomes(targetSourcePaths);
1541
+ finalFileOutcomes = matrix.fileOutcomes;
1542
+ finalValidationStats = matrix.validationStats;
1543
+ const unresolved = finalFileOutcomes.filter((o) => o.status === 'not-covered');
1544
+ const blocked = finalFileOutcomes.filter((o) => o.status === 'blocked');
1545
+ if (unresolved.length > 0 || blocked.length > 0) {
1546
+ emitOutput(`⚠️ После задачи осталось без тестов: ${unresolved.length}/${targetSourcePaths.length} файлов`, true);
1547
+ unresolved.slice(0, 20).forEach((entry) => {
1548
+ emitOutput(` • ${entry.sourcePath}${entry.reason ? ` — ${entry.reason}` : ''}`, true);
1313
1549
  });
1314
- unresolved.slice(0, 20).forEach((rel) => {
1315
- broadcast('agent-output', { line: ` • ${rel}`, isError: true });
1316
- });
1317
- if (unresolved.length > 20) {
1318
- broadcast('agent-output', {
1319
- line: ` ... и еще ${unresolved.length - 20} файлов`,
1320
- isError: true,
1550
+ if (blocked.length > 0) {
1551
+ emitOutput(`⚠️ Заблокированные/несопоставленные файлы: ${blocked.length}`, true);
1552
+ blocked.slice(0, 10).forEach((entry) => {
1553
+ emitOutput(` • ${entry.sourcePath}${entry.reason ? ` — ${entry.reason}` : ''}`, true);
1321
1554
  });
1322
1555
  }
1556
+ finalPhase = 'failed';
1557
+ if (!finalError) {
1558
+ finalError = `Валидация покрытия: covered=${finalValidationStats.covered}, not-covered=${finalValidationStats.notCovered}, blocked=${finalValidationStats.blocked}, infra=${finalValidationStats.infra}`;
1559
+ }
1323
1560
  }
1324
1561
  else {
1325
- broadcast('agent-output', {
1326
- line: `✅ Проверка: все ${targetSourcePaths.length} целевых файлов теперь отмечены как с тестами`,
1327
- });
1562
+ emitOutput(`✅ Проверка: все ${targetSourcePaths.length} целевых файлов теперь отмечены как с тестами`);
1328
1563
  }
1329
1564
  }
1330
1565
  broadcast('data-updated');
1331
1566
  }
1332
- catch { }
1567
+ catch (err) {
1568
+ validationError = err?.message || String(err);
1569
+ finalPhase = 'failed';
1570
+ finalError = `Ошибка валидации после rescan: ${validationError}`;
1571
+ emitOutput(`❌ Не удалось выполнить валидацию после задачи: ${validationError}`, true);
1572
+ }
1573
+ if (targetSourcePaths.length > 0) {
1574
+ broadcast('agent-summary', {
1575
+ runId,
1576
+ targetSourcePaths,
1577
+ fileOutcomes: finalFileOutcomes,
1578
+ validationStats: finalValidationStats,
1579
+ ...(lastTestResult || {}),
1580
+ ...(lastTestSummary || {}),
1581
+ });
1582
+ }
1583
+ setRunPhase(runId, finalPhase, {
1584
+ targetSourcePaths,
1585
+ fileOutcomes: finalFileOutcomes,
1586
+ validationStats: finalValidationStats,
1587
+ error: finalError,
1588
+ });
1333
1589
  processNextInQueue(true);
1334
1590
  }
1335
1591
  else if (code === 255) {
1336
1592
  process.stdout.write(` ❌ Agent auth error (exit code 255)\n`);
1593
+ const message = `${agent === 'claude' ? 'Claude Code' : 'Codex'} не авторизован. Нажми 🔑 Перелогиниться в меню агента.`;
1594
+ setRunPhase(runId, 'failed', { targetSourcePaths, error: message });
1337
1595
  broadcast('agent-error', {
1338
- message: `${agent === 'claude' ? 'Claude Code' : 'Codex'} не авторизован. Нажми 🔑 Перелогиниться в меню агента.`,
1596
+ runId,
1597
+ message,
1339
1598
  authRequired: true,
1340
1599
  agent,
1341
1600
  });
@@ -1343,7 +1602,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1343
1602
  }
1344
1603
  else {
1345
1604
  process.stdout.write(` ❌ Agent failed (exit code ${code})\n`);
1346
- broadcast('agent-error', { message: `Агент завершился с кодом ${code}` });
1605
+ const message = `Агент завершился с кодом ${code}`;
1606
+ setRunPhase(runId, 'failed', { targetSourcePaths, error: message });
1607
+ broadcast('agent-error', { runId, message });
1347
1608
  if (queueBlockSignal === 403 || queueBlockSignal === 429) {
1348
1609
  stopQueuedTasks(`пойман ${queueBlockSignal} от ${agent === 'claude' ? 'Claude Code' : 'Codex'}`);
1349
1610
  }
@@ -1352,13 +1613,19 @@ function startServer({ data: initialData, port, projectRoot }) {
1352
1613
  });
1353
1614
  proc.on('error', (err) => {
1354
1615
  agentRunning = false;
1616
+ activeAgentProcess = null;
1617
+ if (findRun(runId)?.phase === 'canceled') {
1618
+ processNextInQueue(true);
1619
+ return;
1620
+ }
1355
1621
  const isNotFound = err.code === 'ENOENT' || err.message.includes('ENOENT');
1356
1622
  const agentName = agent === 'claude' ? 'Claude Code' : 'Codex';
1357
1623
  const msg = isNotFound
1358
1624
  ? `${agentName} не установлен. Скачай с ${agent === 'claude' ? 'claude.ai/download' : 'github.com/openai/codex'}`
1359
1625
  : `Не удалось запустить ${agent}: ${err.message}`;
1360
1626
  process.stdout.write(' ❌ Agent spawn error: ' + err.message + '\n');
1361
- broadcast('agent-error', { message: msg, notInstalled: isNotFound, agent });
1627
+ setRunPhase(runId, 'failed', { targetSourcePaths, error: msg });
1628
+ broadcast('agent-error', { runId, message: msg, notInstalled: isNotFound, agent });
1362
1629
  processNextInQueue(true);
1363
1630
  });
1364
1631
  }
@@ -1367,7 +1634,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1367
1634
  const agent = currentData.agent;
1368
1635
  if (!agent) {
1369
1636
  broadcast('agent-error', { message: 'Агент не выбран. Укажи agent в viberadar.config.json' });
1370
- return;
1637
+ return null;
1371
1638
  }
1372
1639
  const agentLabel = agent === 'claude' ? 'Claude Code' : 'Codex';
1373
1640
  let title;
@@ -1378,7 +1645,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1378
1645
  const feat = currentData.features?.find(f => f.key === featureKey);
1379
1646
  if (!feat) {
1380
1647
  broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
1381
- return;
1648
+ return null;
1382
1649
  }
1383
1650
  title = `${agentLabel} — тесты для "${feat.label}"`;
1384
1651
  }
@@ -1386,7 +1653,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1386
1653
  const feat = currentData.features?.find(f => f.key === featureKey);
1387
1654
  if (!feat || !filePath) {
1388
1655
  broadcast('agent-error', { message: 'Не указана фича или файл' });
1389
- return;
1656
+ return null;
1390
1657
  }
1391
1658
  const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
1392
1659
  title = `${agentLabel} — тест для "${fileName}"`;
@@ -1396,7 +1663,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1396
1663
  const count = selectedFilePaths?.length ?? 0;
1397
1664
  if (!feat || count === 0) {
1398
1665
  broadcast('agent-error', { message: 'Не указана фича или выбранные файлы' });
1399
- return;
1666
+ return null;
1400
1667
  }
1401
1668
  title = `${agentLabel} — тесты для выбранных файлов (${count})`;
1402
1669
  }
@@ -1405,14 +1672,14 @@ function startServer({ data: initialData, port, projectRoot }) {
1405
1672
  const count = selectedFilePaths?.length ?? 0;
1406
1673
  if (!feat || count === 0) {
1407
1674
  broadcast('agent-error', { message: 'Не указана фича или выбранные файлы' });
1408
- return;
1675
+ return null;
1409
1676
  }
1410
1677
  title = `${agentLabel} — актуализировать тесты (${count})`;
1411
1678
  }
1412
1679
  else if (task === 'fix-tests') {
1413
1680
  if (!filePath) {
1414
1681
  broadcast('agent-error', { message: 'Не указан файл для исправления' });
1415
- return;
1682
+ return null;
1416
1683
  }
1417
1684
  for (const [fp, detail] of lastTestResults) {
1418
1685
  const rel = path.relative(projectRoot, fp).replace(/\\/g, '/');
@@ -1423,7 +1690,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1423
1690
  }
1424
1691
  if (!savedErrors || savedErrors.length === 0) {
1425
1692
  broadcast('agent-error', { message: `Нет сохранённых ошибок для ${filePath}. Сначала запусти тесты.` });
1426
- return;
1693
+ return null;
1427
1694
  }
1428
1695
  const fileName = filePath.replace(/\\/g, '/').split('/').pop() || filePath;
1429
1696
  title = `${agentLabel} — исправить тесты в "${fileName}"`;
@@ -1440,7 +1707,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1440
1707
  }
1441
1708
  if (savedFailedFiles.length === 0) {
1442
1709
  broadcast('agent-error', { message: `Нет упавших ${savedTestType} тестов. Сначала запусти тесты.` });
1443
- return;
1710
+ return null;
1444
1711
  }
1445
1712
  title = `${agentLabel} — починить все ${savedTestType} тесты (${savedFailedFiles.length} файлов)`;
1446
1713
  }
@@ -1448,7 +1715,7 @@ function startServer({ data: initialData, port, projectRoot }) {
1448
1715
  const feat = currentData.features?.find(f => f.key === featureKey);
1449
1716
  if (!feat) {
1450
1717
  broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
1451
- return;
1718
+ return null;
1452
1719
  }
1453
1720
  title = `${agentLabel} — E2E план для "${feat.label}"`;
1454
1721
  }
@@ -1456,25 +1723,39 @@ function startServer({ data: initialData, port, projectRoot }) {
1456
1723
  const feat = currentData.features?.find(f => f.key === featureKey);
1457
1724
  if (!feat) {
1458
1725
  broadcast('agent-error', { message: `Фича не найдена: ${featureKey}` });
1459
- return;
1726
+ return null;
1460
1727
  }
1461
1728
  title = `${agentLabel} — пишу E2E тесты для "${feat.label}"`;
1462
1729
  }
1463
1730
  else {
1464
1731
  title = `${agentLabel} — разобрать unmapped`;
1465
1732
  }
1466
- const item = { task, featureKey, filePath, selectedFilePaths, title, agent, savedErrors, savedFailedFiles, savedTestType };
1733
+ const item = {
1734
+ runId: newRunId(),
1735
+ task,
1736
+ featureKey,
1737
+ filePath,
1738
+ selectedFilePaths,
1739
+ title,
1740
+ agent,
1741
+ savedErrors,
1742
+ savedFailedFiles,
1743
+ savedTestType,
1744
+ };
1745
+ createRun(item, 'queued');
1467
1746
  if (agentRunning || queueCooldownTimer) {
1468
1747
  if (agentQueue.length >= runtimeEnv.agentQueueMax) {
1748
+ setRunPhase(item.runId, 'failed', { error: `Очередь переполнена (${runtimeEnv.agentQueueMax})` });
1469
1749
  const msg = `Очередь агента ограничена (${runtimeEnv.agentQueueMax}). Дождись завершения текущих задач.`;
1470
- broadcast('agent-error', { message: msg });
1750
+ broadcast('agent-error', { runId: item.runId, message: msg });
1471
1751
  process.stdout.write(` ⚠️ Queue limit reached (${runtimeEnv.agentQueueMax}), rejected: "${title}"\n`);
1472
- return;
1752
+ return item.runId;
1473
1753
  }
1474
- agentQueue.push(item);
1754
+ enqueueItem(item);
1475
1755
  const ql = agentQueue.length;
1476
1756
  process.stdout.write(` 📋 Agent busy, queued: "${title}" (queue size: ${ql})\n`);
1477
1757
  broadcast('agent-queued', {
1758
+ runId: item.runId,
1478
1759
  queueLength: ql,
1479
1760
  title,
1480
1761
  task,
@@ -1482,9 +1763,10 @@ function startServer({ data: initialData, port, projectRoot }) {
1482
1763
  filePath: filePath || null,
1483
1764
  selectedFilePaths: selectedFilePaths || null,
1484
1765
  });
1485
- return;
1766
+ return item.runId;
1486
1767
  }
1487
1768
  executeAgentItem(item);
1769
+ return item.runId;
1488
1770
  }
1489
1771
  // ── Chokidar watcher ───────────────────────────────────────────────────────
1490
1772
  chokidar_1.default.watch([
@@ -1504,7 +1786,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1504
1786
  .on('unlink', f => scheduleRescan(path.join(projectRoot, f)));
1505
1787
  // ── HTTP server ────────────────────────────────────────────────────────────
1506
1788
  const server = http.createServer((req, res) => {
1507
- const url = req.url ?? '/';
1789
+ const rawUrl = req.url ?? '/';
1790
+ const parsedUrl = new URL(rawUrl, 'http://127.0.0.1');
1791
+ const url = parsedUrl.pathname;
1508
1792
  if (url === '/' || url === '/radar/qa' || url === '/radar/observability') {
1509
1793
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1510
1794
  res.end(DASHBOARD_HTML);
@@ -1544,7 +1828,98 @@ function startServer({ data: initialData, port, projectRoot }) {
1544
1828
  }
1545
1829
  if (url === '/api/status') {
1546
1830
  res.writeHead(200, { 'Content-Type': 'application/json' });
1547
- res.end(JSON.stringify({ agentRunning, queueLength: agentQueue.length }));
1831
+ res.end(JSON.stringify({
1832
+ agentRunning,
1833
+ queueLength: agentQueue.length,
1834
+ activeRunId,
1835
+ queueCooldownActive: !!queueCooldownTimer,
1836
+ }));
1837
+ return;
1838
+ }
1839
+ if (url === '/api/agent/state' && req.method === 'GET') {
1840
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1841
+ res.end(JSON.stringify(buildAgentStateSnapshot()));
1842
+ return;
1843
+ }
1844
+ const queueCancelMatch = url.match(/^\/api\/queue\/([^/]+)\/cancel$/);
1845
+ if (queueCancelMatch && req.method === 'POST') {
1846
+ const runId = decodeURIComponent(queueCancelMatch[1]);
1847
+ const result = cancelQueuedRun(runId, 'canceled-by-user');
1848
+ if (!result.ok) {
1849
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1850
+ res.end(JSON.stringify({ ok: false, error: result.message }));
1851
+ return;
1852
+ }
1853
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1854
+ res.end(JSON.stringify({ ok: true, state: buildAgentStateSnapshot() }));
1855
+ return;
1856
+ }
1857
+ const queueRetryMatch = url.match(/^\/api\/queue\/([^/]+)\/retry$/);
1858
+ if (queueRetryMatch && req.method === 'POST') {
1859
+ const runId = decodeURIComponent(queueRetryMatch[1]);
1860
+ const existing = findRun(runId);
1861
+ if (!existing) {
1862
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1863
+ res.end(JSON.stringify({ ok: false, error: `Run ${runId} не найден` }));
1864
+ return;
1865
+ }
1866
+ if (existing.task === 'fix-tests' || existing.task === 'fix-tests-all') {
1867
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1868
+ res.end(JSON.stringify({
1869
+ ok: false,
1870
+ error: `Retry для ${existing.task} недоступен: нет сохраненного контекста ошибок`,
1871
+ }));
1872
+ return;
1873
+ }
1874
+ const retriedItem = {
1875
+ runId: newRunId(),
1876
+ task: existing.task,
1877
+ featureKey: existing.featureKey,
1878
+ filePath: existing.filePath,
1879
+ selectedFilePaths: existing.selectedFilePaths,
1880
+ title: `${existing.title} (retry)`,
1881
+ agent: existing.agent,
1882
+ };
1883
+ createRun(retriedItem, 'queued');
1884
+ enqueueItem(retriedItem);
1885
+ broadcast('agent-queued', {
1886
+ runId: retriedItem.runId,
1887
+ queueLength: agentQueue.length,
1888
+ title: retriedItem.title,
1889
+ task: retriedItem.task,
1890
+ featureKey: retriedItem.featureKey || null,
1891
+ filePath: retriedItem.filePath || null,
1892
+ selectedFilePaths: retriedItem.selectedFilePaths || null,
1893
+ });
1894
+ if (!agentRunning && !queueCooldownTimer) {
1895
+ processNextInQueue(false);
1896
+ }
1897
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1898
+ res.end(JSON.stringify({ ok: true, runId: retriedItem.runId, state: buildAgentStateSnapshot() }));
1899
+ return;
1900
+ }
1901
+ const queueReorderMatch = url.match(/^\/api\/queue\/([^/]+)\/reorder$/);
1902
+ if (queueReorderMatch && req.method === 'POST') {
1903
+ const runId = decodeURIComponent(queueReorderMatch[1]);
1904
+ let body = '';
1905
+ req.on('data', d => body += d);
1906
+ req.on('end', () => {
1907
+ let direction = 'up';
1908
+ try {
1909
+ const parsed = JSON.parse(body || '{}');
1910
+ if (parsed?.direction === 'down')
1911
+ direction = 'down';
1912
+ }
1913
+ catch { }
1914
+ const result = moveQueueItem(runId, direction);
1915
+ if (!result.ok) {
1916
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1917
+ res.end(JSON.stringify({ ok: false, error: result.message }));
1918
+ return;
1919
+ }
1920
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1921
+ res.end(JSON.stringify({ ok: true, state: buildAgentStateSnapshot() }));
1922
+ });
1548
1923
  return;
1549
1924
  }
1550
1925
  if (url === '/api/run-all-tests' && req.method === 'POST') {
@@ -1679,9 +2054,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1679
2054
  try {
1680
2055
  const { task, featureKey, filePath, selectedFilePaths } = JSON.parse(body);
1681
2056
  process.stdout.write(` 📥 run-agent: task=${task} featureKey=${featureKey} filePath=${filePath} selected=${Array.isArray(selectedFilePaths) ? selectedFilePaths.length : 0}\n`);
1682
- runAgent(task, featureKey, filePath, selectedFilePaths);
2057
+ const runId = runAgent(task, featureKey, filePath, selectedFilePaths);
1683
2058
  res.writeHead(200, { 'Content-Type': 'application/json' });
1684
- res.end(JSON.stringify({ ok: true }));
2059
+ res.end(JSON.stringify({ ok: true, runId }));
1685
2060
  }
1686
2061
  catch (err) {
1687
2062
  process.stdout.write(` ❌ run-agent parse error: ${err.message}\n`);
@@ -1692,21 +2067,40 @@ function startServer({ data: initialData, port, projectRoot }) {
1692
2067
  return;
1693
2068
  }
1694
2069
  if (url === '/api/cancel-agent' && req.method === 'POST') {
2070
+ const nowReason = 'canceled-by-user';
2071
+ if (activeRunId) {
2072
+ setRunPhase(activeRunId, 'canceled', { error: nowReason });
2073
+ }
2074
+ for (const q of agentQueue) {
2075
+ setRunPhase(q.runId, 'canceled', { error: nowReason });
2076
+ }
1695
2077
  agentRunning = false;
2078
+ if (activeAgentProcess) {
2079
+ try {
2080
+ activeAgentProcess.kill('SIGTERM');
2081
+ }
2082
+ catch { }
2083
+ activeAgentProcess = null;
2084
+ }
1696
2085
  agentQueue.length = 0; // clear queue too
1697
2086
  clearQueueCooldownTimer();
2087
+ emitQueueUpdated();
1698
2088
  process.stdout.write(' ⏹ Agent state reset by user (queue cleared)\n');
1699
2089
  res.writeHead(200, { 'Content-Type': 'application/json' });
1700
- res.end(JSON.stringify({ ok: true }));
2090
+ res.end(JSON.stringify({ ok: true, state: buildAgentStateSnapshot() }));
1701
2091
  return;
1702
2092
  }
1703
2093
  if (url === '/api/clear-queue' && req.method === 'POST') {
1704
2094
  const cleared = agentQueue.length;
2095
+ for (const q of agentQueue) {
2096
+ setRunPhase(q.runId, 'canceled', { error: 'queue-cleared-by-user' });
2097
+ }
1705
2098
  agentQueue.length = 0;
1706
2099
  clearQueueCooldownTimer();
2100
+ emitQueueUpdated();
1707
2101
  process.stdout.write(` 🗑 Queue cleared (${cleared} items)\n`);
1708
2102
  res.writeHead(200, { 'Content-Type': 'application/json' });
1709
- res.end(JSON.stringify({ ok: true, cleared }));
2103
+ res.end(JSON.stringify({ ok: true, cleared, state: buildAgentStateSnapshot() }));
1710
2104
  return;
1711
2105
  }
1712
2106
  if (url === '/api/set-agent' && req.method === 'POST') {
@@ -1750,9 +2144,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1750
2144
  try {
1751
2145
  const { featureKey } = JSON.parse(body);
1752
2146
  broadcast('e2e-plan-generating', { featureKey });
1753
- runAgent('generate-e2e-plan', featureKey);
2147
+ const runId = runAgent('generate-e2e-plan', featureKey);
1754
2148
  res.writeHead(200, { 'Content-Type': 'application/json' });
1755
- res.end(JSON.stringify({ ok: true }));
2149
+ res.end(JSON.stringify({ ok: true, runId }));
1756
2150
  }
1757
2151
  catch (err) {
1758
2152
  res.writeHead(400);
@@ -1832,9 +2226,9 @@ function startServer({ data: initialData, port, projectRoot }) {
1832
2226
  try {
1833
2227
  const { featureKey } = JSON.parse(body);
1834
2228
  broadcast('e2e-tests-writing', { featureKey });
1835
- runAgent('write-e2e-tests', featureKey);
2229
+ const runId = runAgent('write-e2e-tests', featureKey);
1836
2230
  res.writeHead(200, { 'Content-Type': 'application/json' });
1837
- res.end(JSON.stringify({ ok: true }));
2231
+ res.end(JSON.stringify({ ok: true, runId }));
1838
2232
  }
1839
2233
  catch (err) {
1840
2234
  res.writeHead(400);