github-issue-tower-defence-management 1.110.1 → 1.111.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
  8. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
  9. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
  10. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  11. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
  12. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
  13. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
  14. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  15. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
  16. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
  17. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
  18. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  19. package/bin/domain/entities/InteractiveLiveSession.js +3 -0
  20. package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
  21. package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
  22. package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
  26. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
  35. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
  36. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
  37. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
  38. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
  39. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
  40. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
  41. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
  42. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
  43. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
  44. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
  45. package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
  46. package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
  47. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
  48. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
  49. package/src/domain/entities/InteractiveLiveSession.ts +5 -0
  50. package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
  51. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
  53. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
  54. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
  55. package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
  56. package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
  57. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
  58. package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
  59. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
  60. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  61. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
  62. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  63. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
  64. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  65. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
  66. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  67. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
  68. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  69. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  70. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  71. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
  72. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
  73. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
  74. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  75. package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
  76. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
  77. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
  78. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
  79. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
  80. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  81. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
  82. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
  83. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
  84. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  85. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
  86. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  87. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  88. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  89. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
  90. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
  91. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
  92. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
@@ -1,102 +1,42 @@
1
1
  import {
2
2
  NotifySilentLiveSessionsUseCase,
3
- DEFAULT_MONITORED_STATUS,
4
3
  DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
5
4
  DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
6
5
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
7
6
  DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
8
7
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
9
8
  } from './NotifySilentLiveSessionsUseCase';
10
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
11
- import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
9
+ import { LiveSessionProcessSnapshotProvider } from './adapter-interfaces/LiveSessionProcessSnapshotProvider';
10
+ import { InteractiveLiveSessionTranscriptResolver } from './adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
12
11
  import { SessionOutputActivityRepository } from './adapter-interfaces/SessionOutputActivityRepository';
13
12
  import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionSubAgentActivityRepository';
14
13
  import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusProvider';
15
14
  import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
16
15
  import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
17
16
  import { Sleeper } from './adapter-interfaces/Sleeper';
18
- import { toTmuxSessionName } from './intmux/InTmuxByHumanSessionReconcileUseCase';
19
- import { Issue } from '../entities/Issue';
20
- import { Project } from '../entities/Project';
21
17
  import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
22
- import { IN_TMUX_STATUS_NAME } from '../entities/WorkflowStatus';
18
+ import { LiveSessionProcessSnapshot } from '../entities/LiveSessionProcessSnapshot';
19
+ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
23
20
 
24
21
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
25
22
 
26
23
  const MAIN_STALLED_SECTION = 'MAIN_STALLED_SECTION';
27
24
  const SUBAGENT_SECTION = 'SUBAGENT_SECTION';
28
25
 
29
- const createMockProject = (): Project => ({
30
- id: 'project-1',
31
- url: 'https://github.com/users/user/projects/1',
32
- databaseId: 1,
33
- name: 'Test Project',
34
- status: {
35
- name: 'Status',
36
- fieldId: 'field-1',
37
- statuses: [],
38
- },
39
- nextActionDate: null,
40
- nextActionHour: null,
41
- story: null,
42
- remainingEstimationMinutes: null,
43
- dependedIssueUrlSeparatedByComma: null,
44
- completionDate50PercentConfidence: null,
45
- });
46
-
47
- let issueCounter = 0;
48
- const createMockIssue = (overrides: Partial<Issue> = {}): Issue => {
49
- issueCounter += 1;
50
- return {
51
- nameWithOwner: 'user/repo',
52
- number: issueCounter,
53
- title: `Test Issue ${issueCounter}`,
54
- state: 'OPEN',
55
- status: IN_TMUX_STATUS_NAME,
56
- story: null,
57
- nextActionDate: null,
58
- nextActionHour: null,
59
- estimationMinutes: null,
60
- dependedIssueUrls: [],
61
- completionDate50PercentConfidence: null,
62
- url: `https://github.com/user/repo/issues/${issueCounter}`,
63
- assignees: [],
64
- labels: [],
65
- org: 'user',
66
- repo: 'repo',
67
- body: '',
68
- itemId: `item-${issueCounter}`,
69
- isPr: false,
70
- isInProgress: false,
71
- isClosed: false,
72
- createdAt: new Date(),
73
- author: 'testuser',
74
- closingIssueReferenceUrls: [],
75
- ...overrides,
76
- };
77
- };
78
-
79
26
  describe('NotifySilentLiveSessionsUseCase', () => {
80
27
  let useCase: NotifySilentLiveSessionsUseCase;
81
- let mockIssueRepository: Mocked<Pick<IssueRepository, 'getAllOpened'>>;
82
- let mockTmuxSessionRepository: Mocked<
83
- Pick<TmuxSessionRepository, 'listLiveSessionNames'>
84
- >;
28
+ let mockSnapshotProvider: Mocked<LiveSessionProcessSnapshotProvider>;
29
+ let mockTranscriptResolver: Mocked<InteractiveLiveSessionTranscriptResolver>;
85
30
  let mockSessionOutputActivityRepository: Mocked<SessionOutputActivityRepository>;
86
31
  let mockSubAgentActivityRepository: Mocked<SessionSubAgentActivityRepository>;
87
32
  let mockOwnerCallStatusProvider: Mocked<OwnerCallStatusProvider>;
88
33
  let mockNotificationRepository: Mocked<SilentSessionNotificationRepository>;
89
34
  let mockMessageComposer: Mocked<SilentSessionMessageComposer>;
90
35
  let mockSleeper: Mocked<Sleeper>;
91
- let mockProject: Project;
92
36
  const now = new Date('2026-06-26T00:00:00Z');
93
37
  const nowEpochSeconds = Math.floor(now.getTime() / 1000);
94
- const allowCacheMinutes = 10;
95
38
 
96
39
  const runParams = (): {
97
- project: Project;
98
- allowCacheMinutes: number;
99
- monitoredStatus: string;
100
40
  mainSilentThresholdSeconds: number;
101
41
  subAgentSilentThresholdSeconds: number;
102
42
  subAgentRunningThresholdSeconds: number;
@@ -104,9 +44,6 @@ describe('NotifySilentLiveSessionsUseCase', () => {
104
44
  staggerSeconds: number;
105
45
  now: Date;
106
46
  } => ({
107
- project: mockProject,
108
- allowCacheMinutes,
109
- monitoredStatus: DEFAULT_MONITORED_STATUS,
110
47
  mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
111
48
  subAgentSilentThresholdSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
112
49
  subAgentRunningThresholdSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
@@ -115,15 +52,55 @@ describe('NotifySilentLiveSessionsUseCase', () => {
115
52
  now,
116
53
  });
117
54
 
55
+ const emptySnapshot: LiveSessionProcessSnapshot = {
56
+ sessions: [],
57
+ processes: [],
58
+ };
59
+
60
+ const sessionFor = (sessionName: string): InteractiveLiveSession => ({
61
+ sessionName,
62
+ sessionId: `${sessionName}-uuid`,
63
+ configDir: `/config/${sessionName}`,
64
+ });
65
+
66
+ // The use case derives interactive sessions from the snapshot via the embedded
67
+ // pure resolver. To keep these tests focused on the orchestration, the
68
+ // snapshot is shaped so that the pure resolver yields exactly the named
69
+ // interactive sessions: one pane with a single claude child carrying a session
70
+ // id and config directory.
71
+ const snapshotWithSessions = (
72
+ sessionNames: string[],
73
+ ): LiveSessionProcessSnapshot => {
74
+ const sessions = sessionNames.map((sessionName, index) => ({
75
+ sessionName,
76
+ panePids: [100 + index * 10],
77
+ }));
78
+ const processes = sessionNames.map((sessionName, index) => ({
79
+ pid: 101 + index * 10,
80
+ ppid: 100 + index * 10,
81
+ commandLine: `claude --name ${sessionName}`,
82
+ sessionId: `${sessionName}-uuid`,
83
+ configDir: `/config/${sessionName}`,
84
+ }));
85
+ return { sessions, processes };
86
+ };
87
+
88
+ const transcriptMapFor = (sessionNames: string[]): Map<string, string> =>
89
+ new Map(
90
+ sessionNames.map((sessionName) => [
91
+ sessionName,
92
+ `/config/${sessionName}/projects/-home-user/${sessionName}-uuid.jsonl`,
93
+ ]),
94
+ );
95
+
118
96
  beforeEach(() => {
119
97
  jest.resetAllMocks();
120
98
  jest.spyOn(console, 'log').mockImplementation(() => undefined);
121
- mockProject = createMockProject();
122
- mockIssueRepository = {
123
- getAllOpened: jest.fn().mockResolvedValue([]),
99
+ mockSnapshotProvider = {
100
+ getSnapshot: jest.fn().mockResolvedValue(emptySnapshot),
124
101
  };
125
- mockTmuxSessionRepository = {
126
- listLiveSessionNames: jest.fn().mockResolvedValue([]),
102
+ mockTranscriptResolver = {
103
+ resolveTranscriptPaths: jest.fn().mockReturnValue(new Map()),
127
104
  };
128
105
  mockSessionOutputActivityRepository = {
129
106
  listSessionOutputActivities: jest.fn().mockResolvedValue([]),
@@ -153,8 +130,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
153
130
  sleep: jest.fn().mockResolvedValue(undefined),
154
131
  };
155
132
  useCase = new NotifySilentLiveSessionsUseCase(
156
- mockIssueRepository,
157
- mockTmuxSessionRepository,
133
+ mockSnapshotProvider,
134
+ mockTranscriptResolver,
158
135
  mockSessionOutputActivityRepository,
159
136
  mockSubAgentActivityRepository,
160
137
  mockOwnerCallStatusProvider,
@@ -164,24 +141,16 @@ describe('NotifySilentLiveSessionsUseCase', () => {
164
141
  );
165
142
  });
166
143
 
167
- const setupLiveMonitoredSession = (
168
- overrides: Partial<Issue> = {},
169
- ): string => {
170
- const issue = createMockIssue({
171
- status: IN_TMUX_STATUS_NAME,
172
- ...overrides,
173
- });
174
- const sessionName = toTmuxSessionName(issue.url);
175
- mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
176
- mockTmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
177
- sessionName,
178
- ]);
179
- return sessionName;
144
+ const setupLiveInteractiveSession = (sessionName: string): void => {
145
+ mockSnapshotProvider.getSnapshot.mockResolvedValue(
146
+ snapshotWithSessions([sessionName]),
147
+ );
148
+ mockTranscriptResolver.resolveTranscriptPaths.mockReturnValue(
149
+ transcriptMapFor([sessionName]),
150
+ );
180
151
  };
181
152
 
182
- it('exposes the monitored status and default thresholds as named constants', () => {
183
- expect(DEFAULT_MONITORED_STATUS).toBe('In Tmux by human');
184
- expect(DEFAULT_MONITORED_STATUS).toBe(IN_TMUX_STATUS_NAME);
153
+ it('exposes the default thresholds as named constants', () => {
185
154
  expect(DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS).toBe(600);
186
155
  expect(DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS).toBe(300);
187
156
  expect(DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS).toBe(900);
@@ -189,12 +158,12 @@ describe('NotifySilentLiveSessionsUseCase', () => {
189
158
  expect(DEFAULT_NOTIFICATION_STAGGER_SECONDS).toBe(25);
190
159
  });
191
160
 
192
- it('sends only the main stalled section when the main session is silent and no owner call is pending', async () => {
193
- const sessionName = setupLiveMonitoredSession();
161
+ it('notifies a plain-named live interactive session independently of any issue', async () => {
162
+ setupLiveInteractiveSession('workbench');
194
163
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
195
164
  [
196
165
  {
197
- sessionName,
166
+ sessionName: 'workbench',
198
167
  lastOutputEpochSeconds:
199
168
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
200
169
  },
@@ -206,25 +175,41 @@ describe('NotifySilentLiveSessionsUseCase', () => {
206
175
  expect(mockMessageComposer.composeMainStalledSection).toHaveBeenCalledWith(
207
176
  DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
208
177
  );
209
- expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
210
178
  expect(
211
179
  mockNotificationRepository.sendSelfCheckNotification,
212
- ).toHaveBeenCalledWith(sessionName, MAIN_STALLED_SECTION);
180
+ ).toHaveBeenCalledWith('workbench', MAIN_STALLED_SECTION);
181
+ });
182
+
183
+ it('passes the resolved transcript paths to the output and owner-call providers', async () => {
184
+ setupLiveInteractiveSession('workbench');
185
+
186
+ await useCase.run(runParams());
187
+
188
+ const expectedMap = transcriptMapFor(['workbench']);
189
+ expect(
190
+ mockSessionOutputActivityRepository.listSessionOutputActivities,
191
+ ).toHaveBeenCalledWith(expectedMap);
192
+ expect(
193
+ mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall,
194
+ ).toHaveBeenCalledWith(expectedMap);
195
+ expect(
196
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName,
197
+ ).toHaveBeenCalledWith(['workbench']);
213
198
  });
214
199
 
215
200
  it('does not send the main stalled section when an owner call is pending', async () => {
216
- const sessionName = setupLiveMonitoredSession();
201
+ setupLiveInteractiveSession('workbench');
217
202
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
218
203
  [
219
204
  {
220
- sessionName,
205
+ sessionName: 'workbench',
221
206
  lastOutputEpochSeconds:
222
207
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
223
208
  },
224
209
  ],
225
210
  );
226
211
  mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
227
- new Set([sessionName]),
212
+ new Set(['workbench']),
228
213
  );
229
214
 
230
215
  await useCase.run(runParams());
@@ -238,11 +223,11 @@ describe('NotifySilentLiveSessionsUseCase', () => {
238
223
  });
239
224
 
240
225
  it('does not send the main stalled section when output is within the threshold', async () => {
241
- const sessionName = setupLiveMonitoredSession();
226
+ setupLiveInteractiveSession('workbench');
242
227
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
243
228
  [
244
229
  {
245
- sessionName,
230
+ sessionName: 'workbench',
246
231
  lastOutputEpochSeconds:
247
232
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS + 1,
248
233
  },
@@ -256,16 +241,8 @@ describe('NotifySilentLiveSessionsUseCase', () => {
256
241
  ).not.toHaveBeenCalled();
257
242
  });
258
243
 
259
- it('sends the sub-agent section when a sub-agent exceeds the silent threshold regardless of main output', async () => {
260
- const sessionName = setupLiveMonitoredSession();
261
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
262
- [
263
- {
264
- sessionName,
265
- lastOutputEpochSeconds: nowEpochSeconds,
266
- },
267
- ],
268
- );
244
+ it('sends the sub-agent section when a sub-agent exceeds the silent threshold', async () => {
245
+ setupLiveInteractiveSession('workbench');
269
246
  const subAgents: SubAgentActivity[] = [
270
247
  {
271
248
  label: 'sub-process-1',
@@ -274,33 +251,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
274
251
  },
275
252
  ];
276
253
  mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
277
- new Map([[sessionName, subAgents]]),
278
- );
279
-
280
- await useCase.run(runParams());
281
-
282
- expect(
283
- mockMessageComposer.composeMainStalledSection,
284
- ).not.toHaveBeenCalled();
285
- expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
286
- subAgents,
287
- );
288
- expect(
289
- mockNotificationRepository.sendSelfCheckNotification,
290
- ).toHaveBeenCalledWith(sessionName, SUBAGENT_SECTION);
291
- });
292
-
293
- it('sends the sub-agent section when a sub-agent exceeds the running threshold', async () => {
294
- const sessionName = setupLiveMonitoredSession();
295
- const subAgents: SubAgentActivity[] = [
296
- {
297
- label: 'sub-process-1',
298
- silentSeconds: 10,
299
- runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
300
- },
301
- ];
302
- mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
303
- new Map([[sessionName, subAgents]]),
254
+ new Map([['workbench', subAgents]]),
304
255
  );
305
256
 
306
257
  await useCase.run(runParams());
@@ -308,183 +259,50 @@ describe('NotifySilentLiveSessionsUseCase', () => {
308
259
  expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
309
260
  subAgents,
310
261
  );
311
- });
312
-
313
- it('does not include sub-agents that are below both thresholds', async () => {
314
- const sessionName = setupLiveMonitoredSession();
315
- mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
316
- new Map([
317
- [
318
- sessionName,
319
- [{ label: 'sub-process-1', silentSeconds: 10, runningSeconds: 60 }],
320
- ],
321
- ]),
322
- );
323
-
324
- await useCase.run(runParams());
325
-
326
- expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
327
262
  expect(
328
263
  mockNotificationRepository.sendSelfCheckNotification,
329
- ).not.toHaveBeenCalled();
330
- });
331
-
332
- it('combines both sections into a single notification when both classifications apply', async () => {
333
- const sessionName = setupLiveMonitoredSession();
334
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
335
- [
336
- {
337
- sessionName,
338
- lastOutputEpochSeconds:
339
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
340
- },
341
- ],
342
- );
343
- mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
344
- new Map([
345
- [
346
- sessionName,
347
- [
348
- {
349
- label: 'sub-process-1',
350
- silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
351
- runningSeconds: 60,
352
- },
353
- ],
354
- ],
355
- ]),
356
- );
357
-
358
- await useCase.run(runParams());
359
-
360
- const sendCall =
361
- mockNotificationRepository.sendSelfCheckNotification.mock.calls[0];
362
- expect(sendCall[0]).toBe(sessionName);
363
- expect(sendCall[1]).toContain(MAIN_STALLED_SECTION);
364
- expect(sendCall[1]).toContain(SUBAGENT_SECTION);
264
+ ).toHaveBeenCalledWith('workbench', SUBAGENT_SECTION);
365
265
  });
366
266
 
367
- it('still sends the sub-agent section even when an owner call is pending', async () => {
368
- const sessionName = setupLiveMonitoredSession();
369
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
370
- [
267
+ it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
268
+ mockSnapshotProvider.getSnapshot.mockResolvedValue({
269
+ sessions: [{ sessionName: 'aw-host', panePids: [100] }],
270
+ processes: [
371
271
  {
372
- sessionName,
373
- lastOutputEpochSeconds:
374
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
272
+ pid: 101,
273
+ ppid: 100,
274
+ commandLine:
275
+ 'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
276
+ sessionId: 'aw-uuid',
277
+ configDir: '/config/aw',
375
278
  },
376
279
  ],
377
- );
378
- mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
379
- new Set([sessionName]),
380
- );
381
- mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
382
- new Map([
383
- [
384
- sessionName,
385
- [
386
- {
387
- label: 'sub-process-1',
388
- silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
389
- runningSeconds: 60,
390
- },
391
- ],
392
- ],
393
- ]),
394
- );
395
-
396
- await useCase.run(runParams());
397
-
398
- const sendCall =
399
- mockNotificationRepository.sendSelfCheckNotification.mock.calls[0];
400
- expect(sendCall[1]).not.toContain(MAIN_STALLED_SECTION);
401
- expect(sendCall[1]).toContain(SUBAGENT_SECTION);
402
- });
403
-
404
- it('passes the monitored session names to every data provider', async () => {
405
- const sessionName = setupLiveMonitoredSession();
280
+ });
406
281
 
407
282
  await useCase.run(runParams());
408
283
 
409
- expect(
410
- mockSessionOutputActivityRepository.listSessionOutputActivities,
411
- ).toHaveBeenCalledWith([sessionName]);
412
- expect(
413
- mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName,
414
- ).toHaveBeenCalledWith([sessionName]);
415
- expect(
416
- mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall,
417
- ).toHaveBeenCalledWith([sessionName]);
418
- expect(mockIssueRepository.getAllOpened).toHaveBeenCalledWith(
419
- mockProject,
420
- allowCacheMinutes,
284
+ expect(mockTranscriptResolver.resolveTranscriptPaths).toHaveBeenCalledWith(
285
+ [],
421
286
  );
422
- });
423
-
424
- it('does not notify a live session that maps to no monitored issue', async () => {
425
- mockIssueRepository.getAllOpened.mockResolvedValue([]);
426
- mockTmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
427
- 'orphan_session',
428
- ]);
429
-
430
- await useCase.run(runParams());
431
-
432
- expect(
433
- mockSessionOutputActivityRepository.listSessionOutputActivities,
434
- ).toHaveBeenCalledWith([]);
435
- expect(
436
- mockNotificationRepository.sendSelfCheckNotification,
437
- ).not.toHaveBeenCalled();
438
- });
439
-
440
- it('does not notify a monitored issue whose session is not live', async () => {
441
- const issue = createMockIssue({ status: IN_TMUX_STATUS_NAME });
442
- mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
443
- mockTmuxSessionRepository.listLiveSessionNames.mockResolvedValue([]);
444
-
445
- await useCase.run(runParams());
446
-
447
- expect(
448
- mockSessionOutputActivityRepository.listSessionOutputActivities,
449
- ).toHaveBeenCalledWith([]);
450
287
  expect(
451
288
  mockNotificationRepository.sendSelfCheckNotification,
452
289
  ).not.toHaveBeenCalled();
453
290
  });
454
291
 
455
- it('does not notify a live session whose issue status is not the monitored status', async () => {
456
- const issue = createMockIssue({ status: 'In Progress' });
457
- const sessionName = toTmuxSessionName(issue.url);
458
- mockIssueRepository.getAllOpened.mockResolvedValue([issue]);
459
- mockTmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
460
- sessionName,
461
- ]);
462
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
463
- [
464
- {
465
- sessionName,
466
- lastOutputEpochSeconds:
467
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
468
- },
469
- ],
470
- );
471
-
292
+ it('does nothing when there are no live interactive sessions', async () => {
472
293
  await useCase.run(runParams());
473
294
 
474
- expect(
475
- mockSessionOutputActivityRepository.listSessionOutputActivities,
476
- ).toHaveBeenCalledWith([]);
477
295
  expect(
478
296
  mockNotificationRepository.sendSelfCheckNotification,
479
297
  ).not.toHaveBeenCalled();
480
298
  });
481
299
 
482
300
  it('does not re-notify a session within the cooldown window', async () => {
483
- const sessionName = setupLiveMonitoredSession();
301
+ setupLiveInteractiveSession('workbench');
484
302
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
485
303
  [
486
304
  {
487
- sessionName,
305
+ sessionName: 'workbench',
488
306
  lastOutputEpochSeconds:
489
307
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
490
308
  },
@@ -499,68 +317,24 @@ describe('NotifySilentLiveSessionsUseCase', () => {
499
317
  expect(
500
318
  mockNotificationRepository.sendSelfCheckNotification,
501
319
  ).not.toHaveBeenCalled();
502
- expect(
503
- mockNotificationRepository.setLastNotifiedEpochSeconds,
504
- ).not.toHaveBeenCalled();
505
320
  });
506
321
 
507
- it('re-notifies a session once the cooldown window has elapsed', async () => {
508
- const sessionName = setupLiveMonitoredSession();
509
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
510
- [
511
- {
512
- sessionName,
513
- lastOutputEpochSeconds:
514
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
515
- },
516
- ],
322
+ it('sends to multiple sessions sequentially with a stagger delay between sends', async () => {
323
+ mockSnapshotProvider.getSnapshot.mockResolvedValue(
324
+ snapshotWithSessions(['alpha', 'bravo']),
517
325
  );
518
- mockNotificationRepository.getLastNotifiedEpochSeconds.mockResolvedValue(
519
- nowEpochSeconds - DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
326
+ mockTranscriptResolver.resolveTranscriptPaths.mockReturnValue(
327
+ transcriptMapFor(['alpha', 'bravo']),
520
328
  );
521
-
522
- await useCase.run(runParams());
523
-
524
- expect(
525
- mockNotificationRepository.sendSelfCheckNotification,
526
- ).toHaveBeenCalledWith(sessionName, MAIN_STALLED_SECTION);
527
- expect(
528
- mockNotificationRepository.setLastNotifiedEpochSeconds,
529
- ).toHaveBeenCalledWith(sessionName, nowEpochSeconds);
530
- });
531
-
532
- it('does not notify a session that has no recorded output activity and no sub-agents', async () => {
533
- setupLiveMonitoredSession();
534
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
535
- [],
536
- );
537
-
538
- await useCase.run(runParams());
539
-
540
- expect(
541
- mockNotificationRepository.sendSelfCheckNotification,
542
- ).not.toHaveBeenCalled();
543
- });
544
-
545
- it('sends to multiple sessions sequentially with a stagger delay between sends, not before the first or after the last', async () => {
546
- const issueA = createMockIssue({ status: IN_TMUX_STATUS_NAME });
547
- const issueB = createMockIssue({ status: IN_TMUX_STATUS_NAME });
548
- const sessionA = toTmuxSessionName(issueA.url);
549
- const sessionB = toTmuxSessionName(issueB.url);
550
- mockIssueRepository.getAllOpened.mockResolvedValue([issueA, issueB]);
551
- mockTmuxSessionRepository.listLiveSessionNames.mockResolvedValue([
552
- sessionA,
553
- sessionB,
554
- ]);
555
329
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
556
330
  [
557
331
  {
558
- sessionName: sessionA,
332
+ sessionName: 'alpha',
559
333
  lastOutputEpochSeconds:
560
334
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
561
335
  },
562
336
  {
563
- sessionName: sessionB,
337
+ sessionName: 'bravo',
564
338
  lastOutputEpochSeconds:
565
339
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
566
340
  },
@@ -579,13 +353,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
579
353
 
580
354
  await useCase.run(runParams());
581
355
 
582
- const sortedFirst = [sessionA, sessionB].sort()[0];
583
- const sortedSecond = [sessionA, sessionB].sort()[1];
584
- expect(callOrder).toEqual([
585
- `send:${sortedFirst}`,
586
- 'sleep',
587
- `send:${sortedSecond}`,
588
- ]);
356
+ expect(callOrder).toEqual(['send:alpha', 'sleep', 'send:bravo']);
589
357
  expect(mockSleeper.sleep).toHaveBeenCalledTimes(1);
590
358
  expect(mockSleeper.sleep).toHaveBeenCalledWith(
591
359
  DEFAULT_NOTIFICATION_STAGGER_SECONDS * 1000,
@@ -593,11 +361,11 @@ describe('NotifySilentLiveSessionsUseCase', () => {
593
361
  });
594
362
 
595
363
  it('does not suppress errors raised while sending a notification', async () => {
596
- const sessionName = setupLiveMonitoredSession();
364
+ setupLiveInteractiveSession('workbench');
597
365
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
598
366
  [
599
367
  {
600
- sessionName,
368
+ sessionName: 'workbench',
601
369
  lastOutputEpochSeconds:
602
370
  nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
603
371
  },
@@ -609,4 +377,12 @@ describe('NotifySilentLiveSessionsUseCase', () => {
609
377
 
610
378
  await expect(useCase.run(runParams())).rejects.toThrow('send-keys failed');
611
379
  });
380
+
381
+ it('uses the session entity helper for type completeness', () => {
382
+ expect(sessionFor('workbench')).toEqual({
383
+ sessionName: 'workbench',
384
+ sessionId: 'workbench-uuid',
385
+ configDir: '/config/workbench',
386
+ });
387
+ });
612
388
  });