Skip to content

Commit 9ae30b1

Browse files
committed
Персистентность журнала транзакций и реактивация сессий
- Журнал undo/redo теперь сохраняется в JSON формате (journal.json) с использованием Jackson. - Сохранение журнала выполняется синхронно после каждой операции (commit, undo, redo), исправляя проблему потери данных при завершении JVM. - Пути к файлам в журнале хранятся относительно project root для портируемости. - Снапшоты хранятся только по имени файла (uuid.bak), путь восстанавливается из sessionId. - SessionContext.getOrCreate() автоматически проверяет существование сессии на диске и реактивирует её с загрузкой журнала. - InitTool поддерживает режим реактивации при передаче существующего sessionId. - TodoTool расширен новыми действиями: add, close, list, reopen. - Обновление README.md с документацией реактивации сессий.
1 parent c35e2bf commit 9ae30b1

13 files changed

Lines changed: 908 additions & 55 deletions

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ Each tool in NTS is designed as part of an **interconnected discipline system**.
251251

252252
**Discipline role:** Everything the agent does is tracked. There's no "anonymous" editing. If something breaks, the session journal knows exactly what happened and when.
253253

254+
**Session Reactivation:** If the server restarts or connection drops, the session can be reactivated:
255+
```json
256+
{ "sessionId": "your-previous-uuid" }
257+
```
258+
This restores the session directory with todos and file history. In-memory state (tokens, undo stack) starts fresh, but disk-persisted data is preserved.
259+
254260
**Connection:** All other tools require `sessionId`. This isn't bureaucracy — it's **traceability**.
255261

256262
---
@@ -820,6 +826,12 @@ NTS меняет микро-эффективность на макро-надё
820826

821827
**Роль в дисциплине:** Всё, что делает агент, отслеживается. Нет «анонимного» редактирования. Если что-то сломается — журнал сессии знает, что именно произошло и когда.
822828

829+
**Реактивация сессии:** Если сервер перезапустился или соединение прервалось, сессию можно реактивировать:
830+
```json
831+
{ "sessionId": "ваш-предыдущий-uuid" }
832+
```
833+
Это восстанавливает директорию сессии с todos и историей файлов. Состояние в памяти (токены, стек undo) начинается с чистого листа, но данные на диске сохраняются.
834+
823835
**Связь:** Все остальные инструменты требуют `sessionId`. Это не бюрократия — это **прослеживаемость**.
824836

825837
---

app/src/main/java/ru/nts/tools/mcp/McpServer.java

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,25 @@ private static void processMessageInternal(String message) {
579579
"in the 'sessionId' parameter for all subsequent requests.");
580580
}
581581
if (!isValidSession(sessionId)) {
582-
throw new IllegalStateException(
583-
"INVALID_SESSION: Session ID '" + sessionId + "' is not recognized. " +
584-
"The session may have expired or never existed. " +
585-
"Call nts_init to create a new session.");
582+
// Проверяем, существует ли сессия на диске (может быть реактивирована)
583+
if (SessionContext.existsOnDisk(sessionId)) {
584+
SessionContext.SessionMetadata meta = SessionContext.getSessionMetadata(sessionId);
585+
String createdInfo = meta != null && meta.createdAt() != null
586+
? " (created: " + meta.createdAt() + ")"
587+
: "";
588+
throw new IllegalStateException(
589+
"SESSION_INACTIVE: Session '" + sessionId + "' exists but is not active" + createdInfo + ".\n" +
590+
"The server may have restarted since your last interaction.\n\n" +
591+
"[ACTION REQUIRED: Reactivate the session by calling:]\n" +
592+
"nts_init(sessionId=\"" + sessionId + "\")\n\n" +
593+
"This will restore your session with preserved todos and file history.\n" +
594+
"Note: In-memory state (access tokens, undo stack) will start fresh.");
595+
} else {
596+
throw new IllegalStateException(
597+
"INVALID_SESSION: Session ID '" + sessionId + "' is not recognized. " +
598+
"The session may have been deleted or the ID is incorrect. " +
599+
"Call nts_init() to create a new session.");
600+
}
586601
}
587602
}
588603

@@ -592,7 +607,13 @@ private static void processMessageInternal(String message) {
592607
ctx.setCurrentToolName(toolName);
593608
}
594609
try {
595-
response.set("result", router.callTool(toolName, params));
610+
JsonNode toolResult = router.callTool(toolName, params);
611+
response.set("result", toolResult);
612+
613+
// Обновляем активность сессии после успешного вызова
614+
if (ctx != null && !"default".equals(ctx.getSessionId())) {
615+
ctx.touchActivity();
616+
}
596617
} finally {
597618
if (ctx != null) {
598619
ctx.setCurrentToolName(null);

app/src/main/java/ru/nts/tools/mcp/core/McpTool.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
public interface McpTool {
2929

3030
/**
31-
* Таймаут по умолчанию для выполнения инструментов (30 секунд).
31+
* Таймаут по умолчанию для выполнения инструментов (5 минут).
3232
*/
33-
int DEFAULT_TIMEOUT_SECONDS = 30;
33+
int DEFAULT_TIMEOUT_SECONDS = 300;
3434

3535
/**
3636
* Инструменты, исключённые из глобального таймаута.

app/src/main/java/ru/nts/tools/mcp/core/NtsTokenException.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ public static NtsTokenException externalChange(Path path) {
8787
}
8888

8989
private static String truncateToken(String token) {
90+
// Показываем полный токен - LLM должна видеть какой именно токен не сработал
9091
if (token == null) return "null";
91-
if (token.length() <= 20) return token;
92-
return token.substring(0, 10) + "..." + token.substring(token.length() - 10);
92+
return token;
9393
}
9494
}

app/src/main/java/ru/nts/tools/mcp/core/SessionContext.java

Lines changed: 200 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
*/
1616
package ru.nts.tools.mcp.core;
1717

18+
import java.io.IOException;
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.time.Instant;
1822
import java.util.Map;
1923
import java.util.concurrent.ConcurrentHashMap;
2024

@@ -54,30 +58,73 @@ public class SessionContext {
5458
// Текущий вызываемый инструмент (для диагностики)
5559
private volatile String currentToolName;
5660

61+
// Время создания сессии
62+
private final Instant createdAt;
63+
64+
// Время последней активности
65+
private volatile Instant lastActivityAt;
66+
67+
// Имя файла метаданных сессии
68+
private static final String SESSION_METADATA_FILE = "session.meta";
69+
5770
/**
5871
* Создает новый контекст сессии.
5972
*/
6073
private SessionContext(String sessionId) {
6174
this.sessionId = sessionId;
62-
this.transactionManager = new SessionTransactionManager();
75+
this.transactionManager = new SessionTransactionManager(sessionId);
76+
this.lineAccessTracker = new SessionLineAccessTracker();
77+
this.searchTracker = new SessionSearchTracker();
78+
this.fileLineageTracker = new FileLineageTracker();
79+
this.externalChangeTracker = new ExternalChangeTracker();
80+
this.createdAt = Instant.now();
81+
this.lastActivityAt = this.createdAt;
82+
}
83+
84+
/**
85+
* Создает контекст сессии с восстановленными временными метками.
86+
* Используется при реактивации существующей сессии.
87+
*/
88+
private SessionContext(String sessionId, Instant createdAt) {
89+
this.sessionId = sessionId;
90+
this.transactionManager = new SessionTransactionManager(sessionId);
6391
this.lineAccessTracker = new SessionLineAccessTracker();
6492
this.searchTracker = new SessionSearchTracker();
6593
this.fileLineageTracker = new FileLineageTracker();
6694
this.externalChangeTracker = new ExternalChangeTracker();
95+
this.createdAt = createdAt;
96+
this.lastActivityAt = Instant.now();
6797
}
6898

6999
// ==================== Static API ====================
70100

71101
/**
72102
* Получает или создает контекст для указанной сессии.
73103
* Если sessionId == null или пустой, используется "default" сессия.
74-
* Это критично для совместимости с клиентами, не передающими sessionId.
104+
* Если сессия существует на диске, но не в памяти — реактивирует её.
75105
*/
76106
public static SessionContext getOrCreate(String sessionId) {
77107
if (sessionId == null || sessionId.isBlank()) {
78108
sessionId = "default";
79109
}
80-
return sessions.computeIfAbsent(sessionId, SessionContext::new);
110+
String id = sessionId;
111+
return sessions.computeIfAbsent(id, key -> {
112+
// Проверяем, существует ли сессия на диске
113+
if (existsOnDisk(key)) {
114+
// Реактивируем без добавления в map (computeIfAbsent сам добавит)
115+
SessionMetadata meta = getSessionMetadata(key);
116+
if (meta != null) {
117+
SessionContext ctx = new SessionContext(key, meta.createdAt());
118+
ctx.transactionManager.loadJournal();
119+
if (meta.activeTodoFile() != null) {
120+
ctx.setActiveTodoFile(meta.activeTodoFile());
121+
}
122+
return ctx;
123+
}
124+
}
125+
// Создаём новый контекст
126+
return new SessionContext(key);
127+
});
81128
}
82129

83130
/**
@@ -146,6 +193,104 @@ public static int getActiveSessionCount() {
146193
return sessions.size();
147194
}
148195

196+
/**
197+
* Проверяет, существует ли сессия на диске (была создана ранее).
198+
* Это позволяет определить, можно ли реактивировать сессию после перезапуска сервера.
199+
*
200+
* @param sessionId ID сессии для проверки
201+
* @return true если директория сессии существует на диске
202+
*/
203+
public static boolean existsOnDisk(String sessionId) {
204+
if (sessionId == null || sessionId.isBlank() || "default".equals(sessionId)) {
205+
return false;
206+
}
207+
Path sessionDir = PathSanitizer.getRoot().resolve(".nts/sessions/" + sessionId);
208+
Path metaFile = sessionDir.resolve(SESSION_METADATA_FILE);
209+
return Files.exists(metaFile);
210+
}
211+
212+
/**
213+
* Возвращает информацию о сессии на диске.
214+
*
215+
* @param sessionId ID сессии
216+
* @return информация о сессии или null если не найдена
217+
*/
218+
public static SessionMetadata getSessionMetadata(String sessionId) {
219+
if (sessionId == null || sessionId.isBlank()) {
220+
return null;
221+
}
222+
Path sessionDir = PathSanitizer.getRoot().resolve(".nts/sessions/" + sessionId);
223+
Path metaFile = sessionDir.resolve(SESSION_METADATA_FILE);
224+
225+
if (!Files.exists(metaFile)) {
226+
return null;
227+
}
228+
229+
try {
230+
String content = Files.readString(metaFile);
231+
String[] lines = content.split("\n");
232+
Instant created = null;
233+
Instant lastActivity = null;
234+
String activeTodo = null;
235+
236+
for (String line : lines) {
237+
if (line.startsWith("created=")) {
238+
created = Instant.parse(line.substring(8));
239+
} else if (line.startsWith("lastActivity=")) {
240+
lastActivity = Instant.parse(line.substring(13));
241+
} else if (line.startsWith("activeTodo=")) {
242+
activeTodo = line.substring(11);
243+
}
244+
}
245+
246+
return new SessionMetadata(sessionId, created, lastActivity, activeTodo);
247+
} catch (Exception e) {
248+
return null;
249+
}
250+
}
251+
252+
/**
253+
* Реактивирует существующую сессию из директории на диске.
254+
* Восстанавливает контекст сессии и журнал транзакций (undo/redo стеки).
255+
* Токены доступа к файлам начинаются с чистого состояния.
256+
*
257+
* @param sessionId ID сессии для реактивации
258+
* @return восстановленный контекст сессии
259+
* @throws IllegalArgumentException если сессия не найдена на диске
260+
*/
261+
public static SessionContext reactivateSession(String sessionId) {
262+
SessionMetadata meta = getSessionMetadata(sessionId);
263+
if (meta == null) {
264+
throw new IllegalArgumentException("Session not found on disk: " + sessionId);
265+
}
266+
267+
// Создаём контекст с восстановленным временем создания
268+
SessionContext ctx = new SessionContext(sessionId, meta.createdAt());
269+
sessions.put(sessionId, ctx);
270+
271+
// Восстанавливаем журнал транзакций (undo/redo стеки)
272+
ctx.transactionManager.loadJournal();
273+
274+
// Восстанавливаем активный TODO
275+
if (meta.activeTodoFile() != null) {
276+
ctx.setActiveTodoFile(meta.activeTodoFile());
277+
}
278+
279+
return ctx;
280+
}
281+
282+
/**
283+
* Проверяет, активна ли сессия в памяти.
284+
*/
285+
public static boolean isActiveInMemory(String sessionId) {
286+
return sessions.containsKey(sessionId);
287+
}
288+
289+
/**
290+
* Метаданные сессии для хранения на диске.
291+
*/
292+
public record SessionMetadata(String sessionId, Instant createdAt, Instant lastActivityAt, String activeTodoFile) {}
293+
149294
// ==================== Instance API ====================
150295

151296
public String getSessionId() {
@@ -178,6 +323,8 @@ public String getActiveTodoFile() {
178323

179324
public void setActiveTodoFile(String fileName) {
180325
this.activeTodoFile = fileName;
326+
// Сохраняем метаданные для персистентности TODO между сессиями
327+
saveMetadata();
181328
}
182329

183330
/**
@@ -216,6 +363,56 @@ public java.nio.file.Path getSnapshotsDir() {
216363
return getSessionDir().resolve("snapshots");
217364
}
218365

366+
/**
367+
* Возвращает время создания сессии.
368+
*/
369+
public Instant getCreatedAt() {
370+
return createdAt;
371+
}
372+
373+
/**
374+
* Возвращает время последней активности.
375+
*/
376+
public Instant getLastActivityAt() {
377+
return lastActivityAt;
378+
}
379+
380+
/**
381+
* Обновляет время последней активности.
382+
* Вызывается автоматически при каждом вызове инструмента.
383+
*/
384+
public void touchActivity() {
385+
this.lastActivityAt = Instant.now();
386+
// Синхронно сохраняем метаданные для надёжности при перезапусках
387+
saveMetadata();
388+
}
389+
390+
/**
391+
* Сохраняет метаданные сессии на диск.
392+
* Вызывается при создании сессии и при обновлении активности.
393+
*/
394+
public void saveMetadata() {
395+
if ("default".equals(sessionId)) {
396+
return; // default сессия не сохраняется
397+
}
398+
399+
Path metaFile = getSessionDir().resolve(SESSION_METADATA_FILE);
400+
try {
401+
Files.createDirectories(metaFile.getParent());
402+
StringBuilder content = new StringBuilder();
403+
content.append("sessionId=").append(sessionId).append("\n");
404+
content.append("created=").append(createdAt).append("\n");
405+
content.append("lastActivity=").append(lastActivityAt).append("\n");
406+
if (activeTodoFile != null) {
407+
content.append("activeTodo=").append(activeTodoFile).append("\n");
408+
}
409+
Files.writeString(metaFile, content.toString());
410+
} catch (IOException e) {
411+
// Логируем, но не прерываем операцию
412+
System.err.println("Warning: Failed to save session metadata: " + e.getMessage());
413+
}
414+
}
415+
219416
/**
220417
* Очистка ресурсов сессии.
221418
*/

app/src/main/java/ru/nts/tools/mcp/core/SessionLineAccessTracker.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -554,11 +554,9 @@ public String getTokenStatusForLLM(Path path, int requestedStart, int requestedE
554554
for (LineAccessToken t : fileTokens) {
555555
sb.append(" • lines ").append(t.startLine()).append("-").append(t.endLine());
556556

557-
// Показываем короткий ID токена (первые 8 символов после LAT:)
557+
// Показываем ПОЛНЫЙ токен - LLM нужен полный токен для использования!
558558
String encoded = t.encode();
559-
if (encoded.startsWith("LAT:") && encoded.length() > 12) {
560-
sb.append(": ").append(encoded.substring(0, 12)).append("...");
561-
}
559+
sb.append(": ").append(encoded);
562560

563561
// Пометка если этот токен покрывает запрошенный диапазон
564562
if (requestedStart > 0 && requestedEnd > 0 && t.covers(requestedStart, requestedEnd)) {

0 commit comments

Comments
 (0)