github-issue-tower-defence-management 1.114.1 → 1.115.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 +14 -0
- package/README.md +5 -5
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +10 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +14 -14
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +36 -2
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +34 -6
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +6 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +120 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +7 -0
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +46 -0
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +57 -5
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +14 -14
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +188 -1
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +59 -0
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +76 -0
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +37 -6
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +8 -5
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +12 -0
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -1
|
@@ -6,6 +6,7 @@ import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interface
|
|
|
6
6
|
import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
|
|
7
7
|
import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
|
|
8
8
|
import { SilentSessionMessageTemplates } from '../../repositories/ConfigurableSilentSessionMessageComposer';
|
|
9
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
9
10
|
import { SILENT_SESSION_REMINDER_SENTINEL } from '../../../domain/usecases/silentSessionReminderSentinel';
|
|
10
11
|
import {
|
|
11
12
|
notifySilentTmuxSessions,
|
|
@@ -130,6 +131,8 @@ describe('notifySilentTmuxSessions', () => {
|
|
|
130
131
|
subAgentOutputRootDirectory: null,
|
|
131
132
|
subAgentProcessMatchPattern: null,
|
|
132
133
|
subAgentTranscriptRootDirectory: null,
|
|
134
|
+
activeHubTaskStatus: null,
|
|
135
|
+
hubTaskStatusResolver: null,
|
|
133
136
|
messageTemplates: EMPTY_TEMPLATES,
|
|
134
137
|
now: NOW,
|
|
135
138
|
...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
|
|
@@ -266,6 +269,123 @@ describe('notifySilentTmuxSessions', () => {
|
|
|
266
269
|
expect(secondSendCall).toBeUndefined();
|
|
267
270
|
});
|
|
268
271
|
|
|
272
|
+
const HUB_TASK_SESSION_NAME =
|
|
273
|
+
'https://github.com/HiromiShikata/repo/issues/42';
|
|
274
|
+
|
|
275
|
+
const writeUrlSessionTranscript = (): void => {
|
|
276
|
+
const projectDirectory = path.join(configDir, 'projects', '-home-user');
|
|
277
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
278
|
+
const silentTimestamp = new Date(
|
|
279
|
+
(NOW_EPOCH_SECONDS - 11 * 60) * 1000,
|
|
280
|
+
).toISOString();
|
|
281
|
+
fs.writeFileSync(
|
|
282
|
+
path.join(projectDirectory, `${SESSION_ID}.jsonl`),
|
|
283
|
+
JSON.stringify({
|
|
284
|
+
type: 'assistant',
|
|
285
|
+
timestamp: silentTimestamp,
|
|
286
|
+
message: {
|
|
287
|
+
role: 'assistant',
|
|
288
|
+
content: [{ type: 'text', text: 'progress update' }],
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
'utf8',
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const urlSessionRunner = (): Mocked<LocalCommandRunner> => {
|
|
296
|
+
const runner = createMockRunner();
|
|
297
|
+
runner.runCommand.mockImplementation(async (program, args) => {
|
|
298
|
+
if (program === 'tmux' && args[0] === 'list-sessions') {
|
|
299
|
+
return {
|
|
300
|
+
stdout: `${HUB_TASK_SESSION_NAME}\n`,
|
|
301
|
+
stderr: '',
|
|
302
|
+
exitCode: 0,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (program === 'tmux' && args[0] === 'list-panes') {
|
|
306
|
+
return { stdout: `${PANE_PID}\n`, stderr: '', exitCode: 0 };
|
|
307
|
+
}
|
|
308
|
+
if (program === 'ps') {
|
|
309
|
+
return {
|
|
310
|
+
stdout: ` ${PANE_PID} 1 shell\n ${CLAUDE_PID} ${PANE_PID} claude --name ${HUB_TASK_SESSION_NAME}\n`,
|
|
311
|
+
stderr: '',
|
|
312
|
+
exitCode: 0,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
316
|
+
});
|
|
317
|
+
return runner;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const makeIssue = (overrides: {
|
|
321
|
+
state: Issue['state'];
|
|
322
|
+
status: string | null;
|
|
323
|
+
}): Issue => ({
|
|
324
|
+
nameWithOwner: 'HiromiShikata/repo',
|
|
325
|
+
number: 42,
|
|
326
|
+
title: 'Hub task',
|
|
327
|
+
state: overrides.state,
|
|
328
|
+
status: overrides.status,
|
|
329
|
+
story: null,
|
|
330
|
+
nextActionDate: null,
|
|
331
|
+
nextActionHour: null,
|
|
332
|
+
estimationMinutes: null,
|
|
333
|
+
dependedIssueUrls: [],
|
|
334
|
+
completionDate50PercentConfidence: null,
|
|
335
|
+
url: HUB_TASK_SESSION_NAME,
|
|
336
|
+
assignees: [],
|
|
337
|
+
labels: [],
|
|
338
|
+
org: 'HiromiShikata',
|
|
339
|
+
repo: 'repo',
|
|
340
|
+
body: '',
|
|
341
|
+
itemId: 'item-id',
|
|
342
|
+
isPr: false,
|
|
343
|
+
isInProgress: false,
|
|
344
|
+
isClosed: overrides.state !== 'OPEN',
|
|
345
|
+
createdAt: NOW,
|
|
346
|
+
author: 'HiromiShikata',
|
|
347
|
+
closingIssueReferenceUrls: [],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('skips a URL-named session whose hub task is no longer in the active status', async () => {
|
|
351
|
+
writeUrlSessionTranscript();
|
|
352
|
+
const runner = urlSessionRunner();
|
|
353
|
+
const getIssueByUrl = jest
|
|
354
|
+
.fn()
|
|
355
|
+
.mockResolvedValue(makeIssue({ state: 'OPEN', status: 'Todo' }));
|
|
356
|
+
|
|
357
|
+
await notifySilentTmuxSessions({
|
|
358
|
+
...baseParams(runner),
|
|
359
|
+
activeHubTaskStatus: 'In tmux',
|
|
360
|
+
hubTaskStatusResolver: { getIssueByUrl },
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(getIssueByUrl).toHaveBeenCalledWith(HUB_TASK_SESSION_NAME);
|
|
364
|
+
const sendCall = runner.runCommand.mock.calls.find(
|
|
365
|
+
(call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
|
|
366
|
+
);
|
|
367
|
+
expect(sendCall).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('sends to a URL-named session whose hub task is in the active status', async () => {
|
|
371
|
+
writeUrlSessionTranscript();
|
|
372
|
+
const runner = urlSessionRunner();
|
|
373
|
+
const getIssueByUrl = jest
|
|
374
|
+
.fn()
|
|
375
|
+
.mockResolvedValue(makeIssue({ state: 'OPEN', status: 'In tmux' }));
|
|
376
|
+
|
|
377
|
+
await notifySilentTmuxSessions({
|
|
378
|
+
...baseParams(runner),
|
|
379
|
+
activeHubTaskStatus: 'In tmux',
|
|
380
|
+
hubTaskStatusResolver: { getIssueByUrl },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const sendCall = runner.runCommand.mock.calls.find(
|
|
384
|
+
(call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
|
|
385
|
+
);
|
|
386
|
+
expect(sendCall?.[1][2]).toBe(HUB_TASK_SESSION_NAME);
|
|
387
|
+
});
|
|
388
|
+
|
|
269
389
|
it('re-sends within the cooldown window when each project pass uses its own cache scope', async () => {
|
|
270
390
|
silentAssistantTranscript();
|
|
271
391
|
// This reproduces the per-project cooldown defect: when each project pass
|
|
@@ -4,6 +4,7 @@ import { OwnerCallStatusProvider } from '../../../domain/usecases/adapter-interf
|
|
|
4
4
|
import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
5
5
|
import {
|
|
6
6
|
NotifySilentLiveSessionsUseCase,
|
|
7
|
+
HubTaskStatusResolver,
|
|
7
8
|
DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
8
9
|
DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
9
10
|
DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
@@ -44,6 +45,8 @@ export type NotifySilentTmuxSessionsParams = {
|
|
|
44
45
|
subAgentRunningThresholdSeconds: number;
|
|
45
46
|
cooldownSeconds: number;
|
|
46
47
|
staggerSeconds: number;
|
|
48
|
+
activeHubTaskStatus: string | null;
|
|
49
|
+
hubTaskStatusResolver: HubTaskStatusResolver | null;
|
|
47
50
|
messageTemplates: SilentSessionMessageTemplates;
|
|
48
51
|
now: Date;
|
|
49
52
|
};
|
|
@@ -99,6 +102,8 @@ export const notifySilentTmuxSessions = async (
|
|
|
99
102
|
subAgentRunningThresholdSeconds,
|
|
100
103
|
cooldownSeconds,
|
|
101
104
|
staggerSeconds,
|
|
105
|
+
activeHubTaskStatus,
|
|
106
|
+
hubTaskStatusResolver,
|
|
102
107
|
messageTemplates,
|
|
103
108
|
now,
|
|
104
109
|
} = params;
|
|
@@ -133,6 +138,7 @@ export const notifySilentTmuxSessions = async (
|
|
|
133
138
|
),
|
|
134
139
|
messageComposer,
|
|
135
140
|
new RealSleeper(),
|
|
141
|
+
hubTaskStatusResolver,
|
|
136
142
|
);
|
|
137
143
|
await useCase.run({
|
|
138
144
|
mainSilentThresholdSeconds,
|
|
@@ -140,6 +146,7 @@ export const notifySilentTmuxSessions = async (
|
|
|
140
146
|
subAgentRunningThresholdSeconds,
|
|
141
147
|
cooldownSeconds,
|
|
142
148
|
staggerSeconds,
|
|
149
|
+
activeHubTaskStatus,
|
|
143
150
|
now,
|
|
144
151
|
});
|
|
145
152
|
};
|
|
@@ -87,6 +87,52 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
|
|
|
87
87
|
expect(result.get('workbench')).toBe(rotatedPath);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it('resolves a non-resume session to the descendant id file when the own launch id file is absent', () => {
|
|
91
|
+
const configDir = path.join(configRoot, 'non-resume');
|
|
92
|
+
const harnessPath = writeTranscript({
|
|
93
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
94
|
+
cwdSlug: '-home-user',
|
|
95
|
+
sessionId: 'harness-id',
|
|
96
|
+
});
|
|
97
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
98
|
+
sharedProjectsDirectory,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const result = resolver.resolveTranscriptPaths([
|
|
102
|
+
{
|
|
103
|
+
sessionName: 'non-resume',
|
|
104
|
+
sessionId: 'launch-id',
|
|
105
|
+
candidateSessionIds: ['launch-id', 'harness-id'],
|
|
106
|
+
configDir,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(result.get('non-resume')).toBe(harnessPath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('resolves a resume session to the own id file when descendants share the own id', () => {
|
|
114
|
+
const configDir = path.join(configRoot, 'resume');
|
|
115
|
+
const ownPath = writeTranscript({
|
|
116
|
+
projectsDirectory: path.join(configDir, 'projects'),
|
|
117
|
+
cwdSlug: '-home-user',
|
|
118
|
+
sessionId: 'own-id',
|
|
119
|
+
});
|
|
120
|
+
const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
|
|
121
|
+
sharedProjectsDirectory,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = resolver.resolveTranscriptPaths([
|
|
125
|
+
{
|
|
126
|
+
sessionName: 'resume',
|
|
127
|
+
sessionId: 'own-id',
|
|
128
|
+
candidateSessionIds: ['own-id', 'own-id'],
|
|
129
|
+
configDir,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
expect(result.get('resume')).toBe(ownPath);
|
|
134
|
+
});
|
|
135
|
+
|
|
90
136
|
it('resolves a transcript that lives under the shared projects directory', () => {
|
|
91
137
|
const configDir = path.join(configRoot, 'workbench');
|
|
92
138
|
const sharedPath = writeTranscript({
|
|
@@ -31,6 +31,17 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
31
31
|
message: { role: 'user', content: 'go ahead' },
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
const toolResultEntry = (timestamp: string): object => ({
|
|
35
|
+
type: 'tool_result',
|
|
36
|
+
timestamp,
|
|
37
|
+
toolUseResult: { stdout: 'done', stderr: '' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const untimestampedEntry = (): object => ({
|
|
41
|
+
type: 'summary',
|
|
42
|
+
summary: 'no timestamp on this entry',
|
|
43
|
+
});
|
|
44
|
+
|
|
34
45
|
const writeTranscript = (fileName: string, lines: object[]): string => {
|
|
35
46
|
const filePath = path.join(rootDirectory, fileName);
|
|
36
47
|
fs.writeFileSync(
|
|
@@ -41,7 +52,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
41
52
|
return filePath;
|
|
42
53
|
};
|
|
43
54
|
|
|
44
|
-
it('returns the latest
|
|
55
|
+
it('returns the latest entry timestamp of any type as the last activity epoch', async () => {
|
|
45
56
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
46
57
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
47
58
|
userEntry('2026-06-27T10:30:00.000Z'),
|
|
@@ -57,13 +68,13 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
57
68
|
{
|
|
58
69
|
sessionName: 'workbench',
|
|
59
70
|
lastOutputEpochSeconds: Math.floor(
|
|
60
|
-
Date.parse('2026-06-27T10:
|
|
71
|
+
Date.parse('2026-06-27T10:30:00.000Z') / 1000,
|
|
61
72
|
),
|
|
62
73
|
},
|
|
63
74
|
]);
|
|
64
75
|
});
|
|
65
76
|
|
|
66
|
-
it('
|
|
77
|
+
it('advances the last activity time when a later user entry follows an assistant entry', async () => {
|
|
67
78
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
68
79
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
69
80
|
userEntry('2026-06-27T11:00:00.000Z'),
|
|
@@ -78,7 +89,28 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
78
89
|
{
|
|
79
90
|
sessionName: 'workbench',
|
|
80
91
|
lastOutputEpochSeconds: Math.floor(
|
|
81
|
-
Date.parse('2026-06-
|
|
92
|
+
Date.parse('2026-06-27T11:00:00.000Z') / 1000,
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('advances the last activity time when a tool_result entry is the latest entry', async () => {
|
|
99
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
100
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
101
|
+
toolResultEntry('2026-06-27T10:45:00.000Z'),
|
|
102
|
+
]);
|
|
103
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
104
|
+
|
|
105
|
+
const result = await repository.listSessionOutputActivities(
|
|
106
|
+
new Map([['workbench', transcriptPath]]),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual([
|
|
110
|
+
{
|
|
111
|
+
sessionName: 'workbench',
|
|
112
|
+
lastOutputEpochSeconds: Math.floor(
|
|
113
|
+
Date.parse('2026-06-27T10:45:00.000Z') / 1000,
|
|
82
114
|
),
|
|
83
115
|
},
|
|
84
116
|
]);
|
|
@@ -107,7 +139,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
107
139
|
]);
|
|
108
140
|
});
|
|
109
141
|
|
|
110
|
-
it('
|
|
142
|
+
it('resolves a transcript whose only entry is a non-assistant entry', async () => {
|
|
111
143
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
112
144
|
userEntry('2026-06-27T10:00:00.000Z'),
|
|
113
145
|
]);
|
|
@@ -117,6 +149,26 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
117
149
|
new Map([['workbench', transcriptPath]]),
|
|
118
150
|
);
|
|
119
151
|
|
|
152
|
+
expect(result).toEqual([
|
|
153
|
+
{
|
|
154
|
+
sessionName: 'workbench',
|
|
155
|
+
lastOutputEpochSeconds: Math.floor(
|
|
156
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('omits sessions whose transcript has no parseable timestamp', async () => {
|
|
163
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
164
|
+
untimestampedEntry(),
|
|
165
|
+
]);
|
|
166
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
167
|
+
|
|
168
|
+
const result = await repository.listSessionOutputActivities(
|
|
169
|
+
new Map([['workbench', transcriptPath]]),
|
|
170
|
+
);
|
|
171
|
+
|
|
120
172
|
expect(result).toEqual([]);
|
|
121
173
|
});
|
|
122
174
|
|
|
@@ -22,11 +22,14 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Reads the last main-session
|
|
25
|
+
* Reads the last main-session activity time for each live session from its
|
|
26
26
|
* already-resolved transcript path. Idle time is computed from the timestamp of
|
|
27
|
-
* the latest
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* the latest entry of any kind (assistant text, owner replies, tool results, or
|
|
28
|
+
* any other entry type) rather than from the transcript file modification time.
|
|
29
|
+
* Because a session that is actively running tool calls keeps appending entries
|
|
30
|
+
* such as `user` and `tool_result` even while it emits no assistant text, every
|
|
31
|
+
* entry with a parseable timestamp counts as activity, so a working session is
|
|
32
|
+
* not mistaken for a silent one.
|
|
30
33
|
*/
|
|
31
34
|
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
32
35
|
listSessionOutputActivities = async (
|
|
@@ -35,7 +38,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
35
38
|
const activities: LiveSessionOutputActivity[] = [];
|
|
36
39
|
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
37
40
|
const lastOutputEpochSeconds =
|
|
38
|
-
this.
|
|
41
|
+
this.readLastActivityEpochSeconds(transcriptPath);
|
|
39
42
|
if (lastOutputEpochSeconds !== null) {
|
|
40
43
|
activities.push({ sessionName, lastOutputEpochSeconds });
|
|
41
44
|
}
|
|
@@ -43,7 +46,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
43
46
|
return activities;
|
|
44
47
|
};
|
|
45
48
|
|
|
46
|
-
private
|
|
49
|
+
private readLastActivityEpochSeconds = (
|
|
47
50
|
transcriptPath: string,
|
|
48
51
|
): number | null => {
|
|
49
52
|
let content: string;
|
|
@@ -52,7 +55,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
52
55
|
} catch {
|
|
53
56
|
return null;
|
|
54
57
|
}
|
|
55
|
-
let
|
|
58
|
+
let lastActivityEpochMs: number | null = null;
|
|
56
59
|
for (const line of content.split('\n')) {
|
|
57
60
|
const trimmed = line.trim();
|
|
58
61
|
if (trimmed.length === 0) {
|
|
@@ -67,20 +70,17 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
67
70
|
if (!isRecord(parsed)) {
|
|
68
71
|
continue;
|
|
69
72
|
}
|
|
70
|
-
if (readString(parsed, 'type') !== 'assistant') {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
73
|
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
74
74
|
if (epochMs === null) {
|
|
75
75
|
continue;
|
|
76
76
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
77
|
+
if (lastActivityEpochMs === null || epochMs > lastActivityEpochMs) {
|
|
78
|
+
lastActivityEpochMs = epochMs;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
if (
|
|
81
|
+
if (lastActivityEpochMs === null) {
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
|
-
return Math.floor(
|
|
84
|
+
return Math.floor(lastActivityEpochMs / 1000);
|
|
85
85
|
};
|
|
86
86
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NotifySilentLiveSessionsUseCase,
|
|
3
|
+
HubTaskStatusResolver,
|
|
4
|
+
parseHubTaskIssueUrlFromSessionName,
|
|
3
5
|
DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
4
6
|
DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
5
7
|
DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
6
8
|
DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
|
|
7
9
|
DEFAULT_NOTIFICATION_STAGGER_SECONDS,
|
|
8
10
|
} from './NotifySilentLiveSessionsUseCase';
|
|
11
|
+
import { Issue } from '../entities/Issue';
|
|
9
12
|
import { LiveSessionProcessSnapshotProvider } from './adapter-interfaces/LiveSessionProcessSnapshotProvider';
|
|
10
13
|
import { InteractiveLiveSessionTranscriptResolver } from './adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
|
|
11
14
|
import { SessionOutputActivityRepository } from './adapter-interfaces/SessionOutputActivityRepository';
|
|
@@ -33,15 +36,19 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
33
36
|
let mockNotificationRepository: Mocked<SilentSessionNotificationRepository>;
|
|
34
37
|
let mockMessageComposer: Mocked<SilentSessionMessageComposer>;
|
|
35
38
|
let mockSleeper: Mocked<Sleeper>;
|
|
39
|
+
let mockHubTaskStatusResolver: Mocked<HubTaskStatusResolver>;
|
|
36
40
|
const now = new Date('2026-06-26T00:00:00Z');
|
|
37
41
|
const nowEpochSeconds = Math.floor(now.getTime() / 1000);
|
|
38
42
|
|
|
39
|
-
const runParams = (
|
|
43
|
+
const runParams = (
|
|
44
|
+
overrides?: Partial<{ activeHubTaskStatus: string | null }>,
|
|
45
|
+
): {
|
|
40
46
|
mainSilentThresholdSeconds: number;
|
|
41
47
|
subAgentSilentThresholdSeconds: number;
|
|
42
48
|
subAgentRunningThresholdSeconds: number;
|
|
43
49
|
cooldownSeconds: number;
|
|
44
50
|
staggerSeconds: number;
|
|
51
|
+
activeHubTaskStatus: string | null;
|
|
45
52
|
now: Date;
|
|
46
53
|
} => ({
|
|
47
54
|
mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
@@ -49,7 +56,9 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
49
56
|
subAgentRunningThresholdSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
50
57
|
cooldownSeconds: DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
|
|
51
58
|
staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
|
|
59
|
+
activeHubTaskStatus: null,
|
|
52
60
|
now,
|
|
61
|
+
...overrides,
|
|
53
62
|
});
|
|
54
63
|
|
|
55
64
|
const emptySnapshot: LiveSessionProcessSnapshot = {
|
|
@@ -131,6 +140,9 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
131
140
|
mockSleeper = {
|
|
132
141
|
sleep: jest.fn().mockResolvedValue(undefined),
|
|
133
142
|
};
|
|
143
|
+
mockHubTaskStatusResolver = {
|
|
144
|
+
getIssueByUrl: jest.fn().mockResolvedValue(null),
|
|
145
|
+
};
|
|
134
146
|
useCase = new NotifySilentLiveSessionsUseCase(
|
|
135
147
|
mockSnapshotProvider,
|
|
136
148
|
mockTranscriptResolver,
|
|
@@ -140,9 +152,51 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
140
152
|
mockNotificationRepository,
|
|
141
153
|
mockMessageComposer,
|
|
142
154
|
mockSleeper,
|
|
155
|
+
mockHubTaskStatusResolver,
|
|
143
156
|
);
|
|
144
157
|
});
|
|
145
158
|
|
|
159
|
+
const issueFor = (overrides: Partial<Issue>): Issue => ({
|
|
160
|
+
nameWithOwner: 'HiromiShikata/repo',
|
|
161
|
+
number: 42,
|
|
162
|
+
title: 'Hub task',
|
|
163
|
+
state: 'OPEN',
|
|
164
|
+
status: 'In tmux',
|
|
165
|
+
story: null,
|
|
166
|
+
nextActionDate: null,
|
|
167
|
+
nextActionHour: null,
|
|
168
|
+
estimationMinutes: null,
|
|
169
|
+
dependedIssueUrls: [],
|
|
170
|
+
completionDate50PercentConfidence: null,
|
|
171
|
+
url: 'https://github.com/HiromiShikata/repo/issues/42',
|
|
172
|
+
assignees: [],
|
|
173
|
+
labels: [],
|
|
174
|
+
org: 'HiromiShikata',
|
|
175
|
+
repo: 'repo',
|
|
176
|
+
body: '',
|
|
177
|
+
itemId: 'item-id',
|
|
178
|
+
isPr: false,
|
|
179
|
+
isInProgress: false,
|
|
180
|
+
isClosed: false,
|
|
181
|
+
createdAt: now,
|
|
182
|
+
author: 'HiromiShikata',
|
|
183
|
+
closingIssueReferenceUrls: [],
|
|
184
|
+
...overrides,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const setupSilentMainSession = (sessionName: string): void => {
|
|
188
|
+
setupLiveInteractiveSession(sessionName);
|
|
189
|
+
mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
|
|
190
|
+
[
|
|
191
|
+
{
|
|
192
|
+
sessionName,
|
|
193
|
+
lastOutputEpochSeconds:
|
|
194
|
+
nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
146
200
|
const setupLiveInteractiveSession = (sessionName: string): void => {
|
|
147
201
|
mockSnapshotProvider.getSnapshot.mockResolvedValue(
|
|
148
202
|
snapshotWithSessions([sessionName]),
|
|
@@ -406,6 +460,139 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
406
460
|
await expect(useCase.run(runParams())).rejects.toThrow('send-keys failed');
|
|
407
461
|
});
|
|
408
462
|
|
|
463
|
+
describe('hub-task active-status pre-send gate', () => {
|
|
464
|
+
const HUB_TASK_SESSION = 'https://github.com/HiromiShikata/repo/issues/42';
|
|
465
|
+
const ACTIVE_STATUS = 'In tmux';
|
|
466
|
+
|
|
467
|
+
it('sends when the hub task is open and in the active status', async () => {
|
|
468
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
469
|
+
mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
|
|
470
|
+
issueFor({
|
|
471
|
+
url: HUB_TASK_SESSION,
|
|
472
|
+
state: 'OPEN',
|
|
473
|
+
status: ACTIVE_STATUS,
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
478
|
+
|
|
479
|
+
expect(mockHubTaskStatusResolver.getIssueByUrl).toHaveBeenCalledWith(
|
|
480
|
+
HUB_TASK_SESSION,
|
|
481
|
+
);
|
|
482
|
+
expect(
|
|
483
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
484
|
+
).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('skips when the hub task status differs from the active status', async () => {
|
|
488
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
489
|
+
mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
|
|
490
|
+
issueFor({ url: HUB_TASK_SESSION, state: 'OPEN', status: 'Todo' }),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
494
|
+
|
|
495
|
+
expect(
|
|
496
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
497
|
+
).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('skips when the hub task issue is closed', async () => {
|
|
501
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
502
|
+
mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(
|
|
503
|
+
issueFor({
|
|
504
|
+
url: HUB_TASK_SESSION,
|
|
505
|
+
state: 'CLOSED',
|
|
506
|
+
status: ACTIVE_STATUS,
|
|
507
|
+
isClosed: true,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
512
|
+
|
|
513
|
+
expect(
|
|
514
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
515
|
+
).not.toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('leaves a non-URL session name unchecked and sends as before', async () => {
|
|
519
|
+
setupSilentMainSession('workbench');
|
|
520
|
+
|
|
521
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
522
|
+
|
|
523
|
+
expect(mockHubTaskStatusResolver.getIssueByUrl).not.toHaveBeenCalled();
|
|
524
|
+
expect(
|
|
525
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
526
|
+
).toHaveBeenCalledWith('workbench', MAIN_STALLED_SECTION);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('does not call the resolver at all when the active status is unconfigured', async () => {
|
|
530
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
531
|
+
|
|
532
|
+
await useCase.run(runParams({ activeHubTaskStatus: null }));
|
|
533
|
+
|
|
534
|
+
expect(mockHubTaskStatusResolver.getIssueByUrl).not.toHaveBeenCalled();
|
|
535
|
+
expect(
|
|
536
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
537
|
+
).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('fails open and logs a warning when status resolution throws', async () => {
|
|
541
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
542
|
+
const warnSpy = jest
|
|
543
|
+
.spyOn(console, 'warn')
|
|
544
|
+
.mockImplementation(() => undefined);
|
|
545
|
+
mockHubTaskStatusResolver.getIssueByUrl.mockRejectedValue(
|
|
546
|
+
new Error('GitHub API timeout'),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
550
|
+
|
|
551
|
+
expect(
|
|
552
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
553
|
+
).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
|
|
554
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
555
|
+
expect.stringContaining('fail-open'),
|
|
556
|
+
);
|
|
557
|
+
warnSpy.mockRestore();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('fails open and logs a warning when the hub task cannot be resolved', async () => {
|
|
561
|
+
setupSilentMainSession(HUB_TASK_SESSION);
|
|
562
|
+
const warnSpy = jest
|
|
563
|
+
.spyOn(console, 'warn')
|
|
564
|
+
.mockImplementation(() => undefined);
|
|
565
|
+
mockHubTaskStatusResolver.getIssueByUrl.mockResolvedValue(null);
|
|
566
|
+
|
|
567
|
+
await useCase.run(runParams({ activeHubTaskStatus: ACTIVE_STATUS }));
|
|
568
|
+
|
|
569
|
+
expect(
|
|
570
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
571
|
+
).toHaveBeenCalledWith(HUB_TASK_SESSION, MAIN_STALLED_SECTION);
|
|
572
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
573
|
+
expect.stringContaining('fail-open'),
|
|
574
|
+
);
|
|
575
|
+
warnSpy.mockRestore();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('parses a github.com issue URL session name and rejects other names', () => {
|
|
579
|
+
expect(parseHubTaskIssueUrlFromSessionName(HUB_TASK_SESSION)).toBe(
|
|
580
|
+
HUB_TASK_SESSION,
|
|
581
|
+
);
|
|
582
|
+
expect(parseHubTaskIssueUrlFromSessionName('workbench')).toBeNull();
|
|
583
|
+
expect(
|
|
584
|
+
parseHubTaskIssueUrlFromSessionName(
|
|
585
|
+
'https://github.com/HiromiShikata/repo/pull/42',
|
|
586
|
+
),
|
|
587
|
+
).toBeNull();
|
|
588
|
+
expect(
|
|
589
|
+
parseHubTaskIssueUrlFromSessionName(
|
|
590
|
+
'https://example.com/HiromiShikata/repo/issues/42',
|
|
591
|
+
),
|
|
592
|
+
).toBeNull();
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
409
596
|
it('uses the session entity helper for type completeness', () => {
|
|
410
597
|
expect(sessionFor('workbench')).toEqual({
|
|
411
598
|
sessionName: 'workbench',
|