github-issue-tower-defence-management 1.140.1 → 1.141.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/umino-project.yml +2 -1
- package/CHANGELOG.md +14 -0
- package/README.md +8 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js +32 -0
- package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionAssistantTurnsRepository.js +135 -0
- package/bin/adapter/repositories/FileSystemSessionAssistantTurnsRepository.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.js +120 -0
- package/bin/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.js.map +1 -0
- package/bin/domain/entities/OutputDegeneration.js +3 -0
- package/bin/domain/entities/OutputDegeneration.js.map +1 -0
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -5
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/OutputDegenerationDetector.js +150 -0
- package/bin/domain/usecases/OutputDegenerationDetector.js.map +1 -0
- package/bin/domain/usecases/SessionOutputDegenerationRecoveryUseCase.js +84 -0
- package/bin/domain/usecases/SessionOutputDegenerationRecoveryUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +44 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +33 -3
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +69 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +71 -0
- package/src/adapter/repositories/FileSystemSessionAssistantTurnsRepository.test.ts +138 -0
- package/src/adapter/repositories/FileSystemSessionAssistantTurnsRepository.ts +113 -0
- package/src/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.test.ts +85 -0
- package/src/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.ts +105 -0
- package/src/domain/entities/OutputDegeneration.ts +10 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +120 -8
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +25 -9
- package/src/domain/usecases/OutputDegenerationDetector.test.ts +167 -0
- package/src/domain/usecases/OutputDegenerationDetector.ts +169 -0
- package/src/domain/usecases/SessionOutputDegenerationRecoveryUseCase.test.ts +224 -0
- package/src/domain/usecases/SessionOutputDegenerationRecoveryUseCase.ts +142 -0
- package/src/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.ts +6 -0
- package/src/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.ts +4 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts +19 -0
- package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionAssistantTurnsRepository.d.ts +10 -0
- package/types/adapter/repositories/FileSystemSessionAssistantTurnsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.d.ts +15 -0
- package/types/adapter/repositories/FileSystemSessionDegenerationCooldownStateRepository.d.ts.map +1 -0
- package/types/domain/entities/OutputDegeneration.d.ts +10 -0
- package/types/domain/entities/OutputDegeneration.d.ts.map +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OutputDegenerationDetector.d.ts +32 -0
- package/types/domain/usecases/OutputDegenerationDetector.d.ts.map +1 -0
- package/types/domain/usecases/SessionOutputDegenerationRecoveryUseCase.d.ts +31 -0
- package/types/domain/usecases/SessionOutputDegenerationRecoveryUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository.d.ts.map +1 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
resetDegeneratedTmuxSessions,
|
|
6
|
+
DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS,
|
|
7
|
+
} from './resetDegeneratedTmuxSessions';
|
|
8
|
+
import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
9
|
+
|
|
10
|
+
describe('resetDegeneratedTmuxSessions', () => {
|
|
11
|
+
let temporaryDirectory: string;
|
|
12
|
+
let cooldownStateFilePath: string;
|
|
13
|
+
let loggedMessages: string[];
|
|
14
|
+
let consoleLogSpy: jest.SpiedFunction<typeof console.log>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
temporaryDirectory = fs.mkdtempSync(
|
|
18
|
+
path.join(os.tmpdir(), 'reset-degenerated-'),
|
|
19
|
+
);
|
|
20
|
+
cooldownStateFilePath = path.join(temporaryDirectory, 'cooldown.json');
|
|
21
|
+
loggedMessages = [];
|
|
22
|
+
consoleLogSpy = jest
|
|
23
|
+
.spyOn(console, 'log')
|
|
24
|
+
.mockImplementation((...args: unknown[]): void => {
|
|
25
|
+
loggedMessages.push(args.map((arg) => String(arg)).join(' '));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
consoleLogSpy.mockRestore();
|
|
31
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('wires the real adapters and runs end to end without acting when no sessions exist', async () => {
|
|
35
|
+
const runCommand: jest.MockedFunction<LocalCommandRunner['runCommand']> =
|
|
36
|
+
jest.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
37
|
+
const commandRunner: LocalCommandRunner = { runCommand };
|
|
38
|
+
|
|
39
|
+
await resetDegeneratedTmuxSessions({
|
|
40
|
+
enabled: false,
|
|
41
|
+
localCommandRunner: commandRunner,
|
|
42
|
+
warningMessage:
|
|
43
|
+
DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS.warningMessage,
|
|
44
|
+
graceSeconds: DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS.graceSeconds,
|
|
45
|
+
cooldownSeconds:
|
|
46
|
+
DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS.cooldownSeconds,
|
|
47
|
+
cooldownStateFilePath,
|
|
48
|
+
now: new Date('2026-07-26T00:00:00Z'),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const summaryLogged = loggedMessages.some((message) =>
|
|
52
|
+
message.includes(
|
|
53
|
+
'Output degeneration recovery: detected 0 degenerated session(s) of 0 interactive session(s)',
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
expect(summaryLogged).toBe(true);
|
|
57
|
+
const killInvoked = runCommand.mock.calls.some((callArguments) =>
|
|
58
|
+
callArguments[1].includes('kill-session'),
|
|
59
|
+
);
|
|
60
|
+
expect(killInvoked).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('exposes safe defaults with a five second grace and 300 second cooldown', () => {
|
|
64
|
+
expect(DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS.graceSeconds).toBe(5);
|
|
65
|
+
expect(DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS.cooldownSeconds).toBe(
|
|
66
|
+
300,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
3
|
+
import {
|
|
4
|
+
SessionOutputDegenerationRecoveryUseCase,
|
|
5
|
+
DEFAULT_OUTPUT_DEGENERATION_GRACE_SECONDS,
|
|
6
|
+
DEFAULT_OUTPUT_DEGENERATION_COOLDOWN_SECONDS,
|
|
7
|
+
DEFAULT_OUTPUT_DEGENERATION_WARNING_MESSAGE,
|
|
8
|
+
} from '../../../domain/usecases/SessionOutputDegenerationRecoveryUseCase';
|
|
9
|
+
import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositories/LocalProcessLiveSessionProcessSnapshotProvider';
|
|
10
|
+
import { ProcFsProcessEnvironReader } from '../../repositories/ProcFsProcessEnvironReader';
|
|
11
|
+
import { FileSystemInteractiveLiveSessionTranscriptResolver } from '../../repositories/FileSystemInteractiveLiveSessionTranscriptResolver';
|
|
12
|
+
import { FileSystemSessionAssistantTurnsRepository } from '../../repositories/FileSystemSessionAssistantTurnsRepository';
|
|
13
|
+
import { FileSystemSessionDegenerationCooldownStateRepository } from '../../repositories/FileSystemSessionDegenerationCooldownStateRepository';
|
|
14
|
+
import { TmuxSilentSessionNotificationRepository } from '../../repositories/TmuxSilentSessionNotificationRepository';
|
|
15
|
+
import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
|
|
16
|
+
import { RealSleeper } from '../../repositories/RealSleeper';
|
|
17
|
+
|
|
18
|
+
export type ResetDegeneratedTmuxSessionsParams = {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
localCommandRunner: LocalCommandRunner;
|
|
21
|
+
processEnvironReader?: ProcessEnvironReader;
|
|
22
|
+
warningMessage: string;
|
|
23
|
+
graceSeconds: number;
|
|
24
|
+
cooldownSeconds: number;
|
|
25
|
+
cooldownStateFilePath: string | null;
|
|
26
|
+
now: Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const resetDegeneratedTmuxSessions = async (
|
|
30
|
+
params: ResetDegeneratedTmuxSessionsParams,
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
const {
|
|
33
|
+
enabled,
|
|
34
|
+
localCommandRunner,
|
|
35
|
+
processEnvironReader,
|
|
36
|
+
warningMessage,
|
|
37
|
+
graceSeconds,
|
|
38
|
+
cooldownSeconds,
|
|
39
|
+
cooldownStateFilePath,
|
|
40
|
+
now,
|
|
41
|
+
} = params;
|
|
42
|
+
const useCase = new SessionOutputDegenerationRecoveryUseCase(
|
|
43
|
+
new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
44
|
+
localCommandRunner,
|
|
45
|
+
processEnvironReader ?? new ProcFsProcessEnvironReader(),
|
|
46
|
+
),
|
|
47
|
+
new FileSystemInteractiveLiveSessionTranscriptResolver(),
|
|
48
|
+
new FileSystemSessionAssistantTurnsRepository(),
|
|
49
|
+
new TmuxSilentSessionNotificationRepository(localCommandRunner),
|
|
50
|
+
new NodeTmuxSessionRepository(localCommandRunner),
|
|
51
|
+
cooldownStateFilePath !== null
|
|
52
|
+
? new FileSystemSessionDegenerationCooldownStateRepository(
|
|
53
|
+
cooldownStateFilePath,
|
|
54
|
+
)
|
|
55
|
+
: new FileSystemSessionDegenerationCooldownStateRepository(),
|
|
56
|
+
new RealSleeper(),
|
|
57
|
+
);
|
|
58
|
+
await useCase.run({
|
|
59
|
+
enabled,
|
|
60
|
+
warningMessage,
|
|
61
|
+
graceSeconds,
|
|
62
|
+
cooldownSeconds,
|
|
63
|
+
now,
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_RESET_DEGENERATED_TMUX_SESSIONS_PARAMS = {
|
|
68
|
+
warningMessage: DEFAULT_OUTPUT_DEGENERATION_WARNING_MESSAGE,
|
|
69
|
+
graceSeconds: DEFAULT_OUTPUT_DEGENERATION_GRACE_SECONDS,
|
|
70
|
+
cooldownSeconds: DEFAULT_OUTPUT_DEGENERATION_COOLDOWN_SECONDS,
|
|
71
|
+
} as const;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { FileSystemSessionAssistantTurnsRepository } from './FileSystemSessionAssistantTurnsRepository';
|
|
5
|
+
|
|
6
|
+
describe('FileSystemSessionAssistantTurnsRepository', () => {
|
|
7
|
+
let temporaryDirectory: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
temporaryDirectory = fs.mkdtempSync(
|
|
11
|
+
path.join(os.tmpdir(), 'assistant-turns-'),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const writeTranscript = (fileName: string, lines: string[]): string => {
|
|
20
|
+
const filePath = path.join(temporaryDirectory, fileName);
|
|
21
|
+
fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
|
|
22
|
+
return filePath;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('returns recent assistant turn texts newest first, ignoring non-assistant lines', async () => {
|
|
26
|
+
const filePath = writeTranscript('transcript.jsonl', [
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
type: 'assistant',
|
|
29
|
+
message: { content: [{ type: 'text', text: 'first turn' }] },
|
|
30
|
+
}),
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
type: 'user',
|
|
33
|
+
message: { content: [{ type: 'text', text: 'a user turn' }] },
|
|
34
|
+
}),
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
type: 'assistant',
|
|
37
|
+
message: {
|
|
38
|
+
content: [
|
|
39
|
+
{ type: 'text', text: 'second turn' },
|
|
40
|
+
{ type: 'tool_use', id: 'tool-1', name: 'Bash' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
type: 'assistant',
|
|
46
|
+
message: { content: [{ type: 'text', text: 'third turn' }] },
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const repository = new FileSystemSessionAssistantTurnsRepository();
|
|
51
|
+
const result = await repository.listRecentAssistantTurnsBySessionName(
|
|
52
|
+
new Map([['session', filePath]]),
|
|
53
|
+
10,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(result.get('session')).toEqual([
|
|
57
|
+
'third turn',
|
|
58
|
+
'second turn',
|
|
59
|
+
'first turn',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('limits the number of turns returned to maxTurnsPerSession', async () => {
|
|
64
|
+
const filePath = writeTranscript('transcript.jsonl', [
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
type: 'assistant',
|
|
67
|
+
message: { content: [{ type: 'text', text: 'oldest' }] },
|
|
68
|
+
}),
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
type: 'assistant',
|
|
71
|
+
message: { content: [{ type: 'text', text: 'middle' }] },
|
|
72
|
+
}),
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
type: 'assistant',
|
|
75
|
+
message: { content: [{ type: 'text', text: 'newest' }] },
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const repository = new FileSystemSessionAssistantTurnsRepository();
|
|
80
|
+
const result = await repository.listRecentAssistantTurnsBySessionName(
|
|
81
|
+
new Map([['session', filePath]]),
|
|
82
|
+
2,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(result.get('session')).toEqual(['newest', 'middle']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reads a string content assistant message', async () => {
|
|
89
|
+
const filePath = writeTranscript('transcript.jsonl', [
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
type: 'assistant',
|
|
92
|
+
message: { content: 'plain string turn' },
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const repository = new FileSystemSessionAssistantTurnsRepository();
|
|
97
|
+
const result = await repository.listRecentAssistantTurnsBySessionName(
|
|
98
|
+
new Map([['session', filePath]]),
|
|
99
|
+
10,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(result.get('session')).toEqual(['plain string turn']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('omits a session whose transcript is missing', async () => {
|
|
106
|
+
const repository = new FileSystemSessionAssistantTurnsRepository();
|
|
107
|
+
const result = await repository.listRecentAssistantTurnsBySessionName(
|
|
108
|
+
new Map([['session', path.join(temporaryDirectory, 'absent.jsonl')]]),
|
|
109
|
+
10,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result.has('session')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('drops the first partial line when reading only the transcript tail', async () => {
|
|
116
|
+
const firstLine = JSON.stringify({
|
|
117
|
+
type: 'assistant',
|
|
118
|
+
message: { content: [{ type: 'text', text: 'partial old turn' }] },
|
|
119
|
+
});
|
|
120
|
+
const secondLine = JSON.stringify({
|
|
121
|
+
type: 'assistant',
|
|
122
|
+
message: { content: [{ type: 'text', text: 'complete new turn' }] },
|
|
123
|
+
});
|
|
124
|
+
const filePath = writeTranscript('transcript.jsonl', [
|
|
125
|
+
firstLine,
|
|
126
|
+
secondLine,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const tailBytes = Buffer.byteLength(`${secondLine}\n`) + 5;
|
|
130
|
+
const repository = new FileSystemSessionAssistantTurnsRepository(tailBytes);
|
|
131
|
+
const result = await repository.listRecentAssistantTurnsBySessionName(
|
|
132
|
+
new Map([['session', filePath]]),
|
|
133
|
+
10,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(result.get('session')).toEqual(['complete new turn']);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { SessionAssistantTurnsRepository } from '../../domain/usecases/adapter-interfaces/SessionAssistantTurnsRepository';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TRANSCRIPT_TAIL_BYTES = 3_000_000;
|
|
5
|
+
|
|
6
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
7
|
+
typeof value === 'object' && value !== null;
|
|
8
|
+
|
|
9
|
+
const assistantText = (message: Record<string, unknown>): string => {
|
|
10
|
+
const content = message.content;
|
|
11
|
+
if (typeof content === 'string') {
|
|
12
|
+
return content;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(content)) {
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
for (const block of content) {
|
|
17
|
+
if (
|
|
18
|
+
isRecord(block) &&
|
|
19
|
+
block.type === 'text' &&
|
|
20
|
+
typeof block.text === 'string'
|
|
21
|
+
) {
|
|
22
|
+
parts.push(block.text);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parts.join(' ');
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class FileSystemSessionAssistantTurnsRepository implements SessionAssistantTurnsRepository {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly tailBytes: number = DEFAULT_TRANSCRIPT_TAIL_BYTES,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
listRecentAssistantTurnsBySessionName = async (
|
|
36
|
+
transcriptPathBySessionName: Map<string, string>,
|
|
37
|
+
maxTurnsPerSession: number,
|
|
38
|
+
): Promise<Map<string, string[]>> => {
|
|
39
|
+
const turnsBySessionName = new Map<string, string[]>();
|
|
40
|
+
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
41
|
+
const turns = this.readRecentAssistantTurns(
|
|
42
|
+
transcriptPath,
|
|
43
|
+
maxTurnsPerSession,
|
|
44
|
+
);
|
|
45
|
+
if (turns.length > 0) {
|
|
46
|
+
turnsBySessionName.set(sessionName, turns);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return turnsBySessionName;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
private readRecentAssistantTurns = (
|
|
53
|
+
transcriptPath: string,
|
|
54
|
+
maxTurns: number,
|
|
55
|
+
): string[] => {
|
|
56
|
+
const lines = this.readTailLines(transcriptPath);
|
|
57
|
+
const turns: string[] = [];
|
|
58
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
59
|
+
const trimmed = lines[index].trim();
|
|
60
|
+
if (trimmed.length === 0) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!isRecord(parsed) || parsed.type !== 'assistant') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const message = parsed.message;
|
|
73
|
+
if (!isRecord(message)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const text = assistantText(message);
|
|
77
|
+
if (text.trim().length === 0) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
turns.push(text);
|
|
81
|
+
if (turns.length >= maxTurns) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return turns;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private readTailLines = (transcriptPath: string): string[] => {
|
|
89
|
+
let handle: number;
|
|
90
|
+
try {
|
|
91
|
+
handle = fs.openSync(transcriptPath, 'r');
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const size = fs.fstatSync(handle).size;
|
|
97
|
+
const start = size > this.tailBytes ? size - this.tailBytes : 0;
|
|
98
|
+
const length = size - start;
|
|
99
|
+
const buffer = new Uint8Array(length);
|
|
100
|
+
fs.readSync(handle, buffer, 0, length, start);
|
|
101
|
+
let text = Buffer.from(buffer).toString('utf8');
|
|
102
|
+
if (start > 0) {
|
|
103
|
+
const newlineIndex = text.indexOf('\n');
|
|
104
|
+
text = newlineIndex === -1 ? '' : text.slice(newlineIndex + 1);
|
|
105
|
+
}
|
|
106
|
+
return text.split('\n');
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
} finally {
|
|
110
|
+
fs.closeSync(handle);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { FileSystemSessionDegenerationCooldownStateRepository } from './FileSystemSessionDegenerationCooldownStateRepository';
|
|
5
|
+
|
|
6
|
+
describe('FileSystemSessionDegenerationCooldownStateRepository', () => {
|
|
7
|
+
let temporaryDirectory: string;
|
|
8
|
+
let stateFilePath: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
temporaryDirectory = fs.mkdtempSync(
|
|
12
|
+
path.join(os.tmpdir(), 'degeneration-cooldown-'),
|
|
13
|
+
);
|
|
14
|
+
stateFilePath = path.join(temporaryDirectory, 'cooldown.json');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns an empty map when no state file exists', async () => {
|
|
22
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
23
|
+
stateFilePath,
|
|
24
|
+
);
|
|
25
|
+
expect(await repository.loadLastResetEpochSecondsBySessionName()).toEqual(
|
|
26
|
+
new Map(),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('persists and reloads a per-session reset time', async () => {
|
|
31
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
32
|
+
stateFilePath,
|
|
33
|
+
);
|
|
34
|
+
const now = new Date('2026-07-26T00:00:00Z');
|
|
35
|
+
await repository.recordReset({ sessionName: 'session-a', now });
|
|
36
|
+
|
|
37
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
38
|
+
expect(reloaded.get('session-a')).toBe(Math.floor(now.getTime() / 1000));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps distinct sessions separate and overwrites the same session', async () => {
|
|
42
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
43
|
+
stateFilePath,
|
|
44
|
+
);
|
|
45
|
+
await repository.recordReset({
|
|
46
|
+
sessionName: 'session-a',
|
|
47
|
+
now: new Date('2026-07-26T00:00:00Z'),
|
|
48
|
+
});
|
|
49
|
+
await repository.recordReset({
|
|
50
|
+
sessionName: 'session-b',
|
|
51
|
+
now: new Date('2026-07-26T00:01:00Z'),
|
|
52
|
+
});
|
|
53
|
+
await repository.recordReset({
|
|
54
|
+
sessionName: 'session-a',
|
|
55
|
+
now: new Date('2026-07-26T00:02:00Z'),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
59
|
+
expect(reloaded.get('session-a')).toBe(
|
|
60
|
+
Math.floor(new Date('2026-07-26T00:02:00Z').getTime() / 1000),
|
|
61
|
+
);
|
|
62
|
+
expect(reloaded.get('session-b')).toBe(
|
|
63
|
+
Math.floor(new Date('2026-07-26T00:01:00Z').getTime() / 1000),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('drops entries older than the retention window on the next write', async () => {
|
|
68
|
+
const repository = new FileSystemSessionDegenerationCooldownStateRepository(
|
|
69
|
+
stateFilePath,
|
|
70
|
+
60,
|
|
71
|
+
);
|
|
72
|
+
await repository.recordReset({
|
|
73
|
+
sessionName: 'stale-session',
|
|
74
|
+
now: new Date('2026-07-26T00:00:00Z'),
|
|
75
|
+
});
|
|
76
|
+
await repository.recordReset({
|
|
77
|
+
sessionName: 'fresh-session',
|
|
78
|
+
now: new Date('2026-07-26T00:05:00Z'),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const reloaded = await repository.loadLastResetEpochSecondsBySessionName();
|
|
82
|
+
expect(reloaded.has('stale-session')).toBe(false);
|
|
83
|
+
expect(reloaded.has('fresh-session')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { SessionDegenerationCooldownStateRepository } from '../../domain/usecases/adapter-interfaces/SessionDegenerationCooldownStateRepository';
|
|
5
|
+
|
|
6
|
+
type StoredResetEntry = {
|
|
7
|
+
sessionName: string;
|
|
8
|
+
resetEpochSeconds: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_RESET_RETENTION_WINDOW_SECONDS = 60 * 60;
|
|
15
|
+
|
|
16
|
+
const defaultStateFilePath = (): string => {
|
|
17
|
+
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
18
|
+
return path.join(base, 'tdpm', 'output-degeneration-cooldown.json');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class FileSystemSessionDegenerationCooldownStateRepository implements SessionDegenerationCooldownStateRepository {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly stateFilePath: string = defaultStateFilePath(),
|
|
24
|
+
private readonly retentionWindowSeconds: number = DEFAULT_RESET_RETENTION_WINDOW_SECONDS,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
loadLastResetEpochSecondsBySessionName = async (): Promise<
|
|
28
|
+
Map<string, number>
|
|
29
|
+
> => {
|
|
30
|
+
const lastResetBySessionName = new Map<string, number>();
|
|
31
|
+
for (const entry of this.readResetEntries()) {
|
|
32
|
+
lastResetBySessionName.set(entry.sessionName, entry.resetEpochSeconds);
|
|
33
|
+
}
|
|
34
|
+
return lastResetBySessionName;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
recordReset = async (params: {
|
|
38
|
+
sessionName: string;
|
|
39
|
+
now: Date;
|
|
40
|
+
}): Promise<void> => {
|
|
41
|
+
const resetEpochSeconds = Math.floor(params.now.getTime() / 1000);
|
|
42
|
+
const oldestRetainedEpochSeconds =
|
|
43
|
+
resetEpochSeconds - this.retentionWindowSeconds;
|
|
44
|
+
const mergedBySessionName = new Map<string, StoredResetEntry>();
|
|
45
|
+
for (const entry of this.readResetEntries()) {
|
|
46
|
+
if (
|
|
47
|
+
entry.resetEpochSeconds >= oldestRetainedEpochSeconds &&
|
|
48
|
+
entry.sessionName !== params.sessionName
|
|
49
|
+
) {
|
|
50
|
+
mergedBySessionName.set(entry.sessionName, entry);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
mergedBySessionName.set(params.sessionName, {
|
|
54
|
+
sessionName: params.sessionName,
|
|
55
|
+
resetEpochSeconds,
|
|
56
|
+
});
|
|
57
|
+
this.writeState(Array.from(mergedBySessionName.values()));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
private readResetEntries = (): StoredResetEntry[] => {
|
|
61
|
+
let raw: string;
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(this.stateFilePath, 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
if (!isRecord(parsed)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const storedEntries = parsed.resets;
|
|
77
|
+
if (!Array.isArray(storedEntries)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const entries: StoredResetEntry[] = [];
|
|
81
|
+
for (const storedEntry of storedEntries) {
|
|
82
|
+
if (!isRecord(storedEntry)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const sessionName = storedEntry.sessionName;
|
|
86
|
+
const resetEpochSeconds = storedEntry.resetEpochSeconds;
|
|
87
|
+
if (
|
|
88
|
+
typeof sessionName === 'string' &&
|
|
89
|
+
typeof resetEpochSeconds === 'number' &&
|
|
90
|
+
Number.isFinite(resetEpochSeconds)
|
|
91
|
+
) {
|
|
92
|
+
entries.push({ sessionName, resetEpochSeconds });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
private writeState = (resets: StoredResetEntry[]): void => {
|
|
99
|
+
const directory = path.dirname(this.stateFilePath);
|
|
100
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
101
|
+
const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
|
|
102
|
+
fs.writeFileSync(temporaryPath, JSON.stringify({ resets }));
|
|
103
|
+
fs.renameSync(temporaryPath, this.stateFilePath);
|
|
104
|
+
};
|
|
105
|
+
}
|