github-issue-tower-defence-management 1.140.2 → 1.142.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/CHANGELOG.md +20 -0
- package/README.md +13 -6
- package/bin/adapter/entry-points/console/dashboardComposeService.js +23 -4
- package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -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/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- 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/adapter/repositories/FileSystemSubAgentLivenessResolver.js +3 -3
- package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js.map +1 -1
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +23 -9
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/entities/OutputDegeneration.js +3 -0
- package/bin/domain/entities/OutputDegeneration.js.map +1 -0
- 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/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +41 -5
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +16 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/dashboardComposeService.test.ts +82 -0
- package/src/adapter/entry-points/console/dashboardComposeService.ts +38 -4
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +44 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +69 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +71 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +6 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +4 -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/adapter/repositories/FileSystemSubAgentLivenessResolver.test.ts +94 -0
- package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.ts +3 -3
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +368 -1
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +28 -10
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +31 -0
- package/src/domain/entities/OutputDegeneration.ts +10 -0
- 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/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +164 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +63 -5
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +73 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +23 -0
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -1
- 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/entry-points/handlers/tokenStatusWriter.d.ts +2 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- 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/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -1
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/entities/OutputDegeneration.d.ts +10 -0
- package/types/domain/entities/OutputDegeneration.d.ts.map +1 -0
- 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
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +3 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +6 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
|
@@ -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;
|
|
@@ -128,6 +128,11 @@ describe('writeTokenStatus', () => {
|
|
|
128
128
|
const written = readJson(path.join(dir, 'token-status.json'));
|
|
129
129
|
const expected: TokenStatusFile = {
|
|
130
130
|
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
131
|
+
sevenDayWindowAggregate: {
|
|
132
|
+
usedPercent: 50,
|
|
133
|
+
includedTokenCount: 1,
|
|
134
|
+
totalTokenCount: 2,
|
|
135
|
+
},
|
|
131
136
|
tokens: [
|
|
132
137
|
{
|
|
133
138
|
name: 'alice',
|
|
@@ -216,6 +221,7 @@ describe('writeTokenStatus', () => {
|
|
|
216
221
|
};
|
|
217
222
|
expect(written).toEqual({
|
|
218
223
|
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
224
|
+
sevenDayWindowAggregate: null,
|
|
219
225
|
tokens: [
|
|
220
226
|
expectedAlice,
|
|
221
227
|
{
|
|
@@ -4,9 +4,11 @@ import type { Issue } from '../../../domain/entities/Issue';
|
|
|
4
4
|
import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
|
|
5
5
|
import {
|
|
6
6
|
GenerateTokenStatusUseCase,
|
|
7
|
+
SevenDayWindowAggregate,
|
|
7
8
|
TokenRateLimitSnapshot,
|
|
8
9
|
TokenStatus,
|
|
9
10
|
TokenStatusInput,
|
|
11
|
+
computeSevenDayWindowAggregate,
|
|
10
12
|
} from '../../../domain/usecases/dashboard/GenerateTokenStatusUseCase';
|
|
11
13
|
import { InTmuxByHumanSessionTokenCountUseCase } from '../../../domain/usecases/InTmuxByHumanSessionTokenCountUseCase';
|
|
12
14
|
import { OauthTokenCandidate } from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
@@ -35,6 +37,7 @@ export type TokenStatusWriterParams = {
|
|
|
35
37
|
|
|
36
38
|
export type TokenStatusFile = {
|
|
37
39
|
tokens: TokenStatus[];
|
|
40
|
+
sevenDayWindowAggregate: SevenDayWindowAggregate | null;
|
|
38
41
|
capturedAt: string;
|
|
39
42
|
};
|
|
40
43
|
|
|
@@ -247,6 +250,7 @@ export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
|
|
|
247
250
|
|
|
248
251
|
const file: TokenStatusFile = {
|
|
249
252
|
tokens,
|
|
253
|
+
sevenDayWindowAggregate: computeSevenDayWindowAggregate(tokens),
|
|
250
254
|
capturedAt: now.toISOString(),
|
|
251
255
|
};
|
|
252
256
|
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { SubAgentProcessLister } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
|
|
4
5
|
import { FileSystemSubAgentLivenessResolver } from './FileSystemSubAgentLivenessResolver';
|
|
6
|
+
import { FileSystemSubAgentTranscriptDirectoryResolver } from './FileSystemSubAgentTranscriptDirectoryResolver';
|
|
7
|
+
import { TranscriptSessionSubAgentActivityRepository } from './TranscriptSessionSubAgentActivityRepository';
|
|
5
8
|
|
|
6
9
|
describe('FileSystemSubAgentLivenessResolver', () => {
|
|
7
10
|
let runtimeRoot: string;
|
|
@@ -40,6 +43,20 @@ describe('FileSystemSubAgentLivenessResolver', () => {
|
|
|
40
43
|
expect(result).toEqual(new Set(['a70a08e0587d6f484', 'ad5d15d239d7e72e8']));
|
|
41
44
|
});
|
|
42
45
|
|
|
46
|
+
it('returns only the leading agent id token of each record line', async () => {
|
|
47
|
+
writeRunningFile(
|
|
48
|
+
'ad3d5702d1339492e impl:redcircle-only-tag-format (no branch)\naf19c02b4c3a5d6e7 chore:board-sync i1300-record-parsing\n',
|
|
49
|
+
);
|
|
50
|
+
const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
|
|
51
|
+
|
|
52
|
+
const result = await resolver.resolveLiveSubAgentIds({
|
|
53
|
+
sessionName,
|
|
54
|
+
mainTranscriptPath,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual(new Set(['ad3d5702d1339492e', 'af19c02b4c3a5d6e7']));
|
|
58
|
+
});
|
|
59
|
+
|
|
43
60
|
it('returns an empty set when the running file exists but lists no ids', async () => {
|
|
44
61
|
writeRunningFile('\n \n');
|
|
45
62
|
const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
|
|
@@ -85,3 +102,80 @@ describe('FileSystemSubAgentLivenessResolver', () => {
|
|
|
85
102
|
expect(result).toBeNull();
|
|
86
103
|
});
|
|
87
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('running-subagents record agent id contract with TranscriptSessionSubAgentActivityRepository', () => {
|
|
107
|
+
const cwdSlug = '-home-user-worktrees-issue-1300';
|
|
108
|
+
const sessionUuid = 'c0f1a2b3-4d5e-6f70-8192-a3b4c5d6e7f8';
|
|
109
|
+
const sessionName = 'https_//github_com/owner/repo/issues/1300';
|
|
110
|
+
const liveAgentId = 'ad3d5702d1339492e';
|
|
111
|
+
const now = new Date('2026-07-28T12:00:00.000Z');
|
|
112
|
+
const emptyProcessLister: SubAgentProcessLister = {
|
|
113
|
+
listProcesses: async () => [],
|
|
114
|
+
};
|
|
115
|
+
let runtimeRoot: string;
|
|
116
|
+
let transcriptRoot: string;
|
|
117
|
+
let mainTranscriptPath: string;
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'subagent-live-rt-'));
|
|
121
|
+
transcriptRoot = fs.mkdtempSync(
|
|
122
|
+
path.join(os.tmpdir(), 'subagent-live-tx-'),
|
|
123
|
+
);
|
|
124
|
+
mainTranscriptPath = path.join(
|
|
125
|
+
transcriptRoot,
|
|
126
|
+
cwdSlug,
|
|
127
|
+
`${sessionUuid}.jsonl`,
|
|
128
|
+
);
|
|
129
|
+
fs.mkdirSync(path.dirname(mainTranscriptPath), { recursive: true });
|
|
130
|
+
fs.writeFileSync(mainTranscriptPath, '', 'utf8');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
fs.rmSync(runtimeRoot, { force: true, recursive: true });
|
|
135
|
+
fs.rmSync(transcriptRoot, { force: true, recursive: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps a sub-agent whose id is recorded together with its label on one line', async () => {
|
|
139
|
+
const runningDirectory = path.join(runtimeRoot, cwdSlug, sessionUuid);
|
|
140
|
+
fs.mkdirSync(runningDirectory, { recursive: true });
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
path.join(runningDirectory, 'running-subagents.txt'),
|
|
143
|
+
`${liveAgentId} impl:subagent-liveness-record-parsing (no branch)\n`,
|
|
144
|
+
'utf8',
|
|
145
|
+
);
|
|
146
|
+
const subAgentsDirectory = path.join(
|
|
147
|
+
transcriptRoot,
|
|
148
|
+
cwdSlug,
|
|
149
|
+
sessionUuid,
|
|
150
|
+
'subagents',
|
|
151
|
+
);
|
|
152
|
+
fs.mkdirSync(subAgentsDirectory, { recursive: true });
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
path.join(subAgentsDirectory, `agent-${liveAgentId}.jsonl`),
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
type: 'assistant',
|
|
157
|
+
timestamp: '2026-07-28T11:00:00.000Z',
|
|
158
|
+
message: {
|
|
159
|
+
stop_reason: 'tool_use',
|
|
160
|
+
content: [{ type: 'tool_use', name: 'Read', input: {} }],
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
'utf8',
|
|
164
|
+
);
|
|
165
|
+
const repository = new TranscriptSessionSubAgentActivityRepository(
|
|
166
|
+
new FileSystemSubAgentTranscriptDirectoryResolver(transcriptRoot),
|
|
167
|
+
emptyProcessLister,
|
|
168
|
+
now,
|
|
169
|
+
new FileSystemSubAgentLivenessResolver(runtimeRoot),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const result = await repository.listSubAgentActivitiesBySessionName(
|
|
173
|
+
[sessionName],
|
|
174
|
+
new Map([[sessionName, mainTranscriptPath]]),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(result.get(sessionName)?.map((activity) => activity.label)).toEqual([
|
|
178
|
+
`agent-${liveAgentId}`,
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -25,9 +25,9 @@ export class FileSystemSubAgentLivenessResolver implements SubAgentLivenessResol
|
|
|
25
25
|
}
|
|
26
26
|
const liveIds = new Set<string>();
|
|
27
27
|
for (const line of content.split('\n')) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
liveIds.add(
|
|
28
|
+
const agentId = line.trim().split(/\s+/)[0];
|
|
29
|
+
if (agentId.length > 0) {
|
|
30
|
+
liveIds.add(agentId);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
return liveIds;
|