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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -5
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +10 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +14 -14
  8. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  9. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +36 -2
  10. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  11. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +34 -6
  12. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +6 -0
  15. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +120 -0
  16. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +7 -0
  17. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +46 -0
  18. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +57 -5
  19. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +14 -14
  20. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +188 -1
  21. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +59 -0
  22. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +76 -0
  23. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +37 -6
  24. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  25. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  26. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  27. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +8 -5
  28. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  29. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
  30. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  31. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +12 -0
  32. 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 assistant entry timestamp as the last output epoch', async () => {
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:05:00.000Z') / 1000,
71
+ Date.parse('2026-06-27T10:30:00.000Z') / 1000,
61
72
  ),
62
73
  },
63
74
  ]);
64
75
  });
65
76
 
66
- it('ignores user entries when computing the last output epoch', async () => {
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-27T10:00:00.000Z') / 1000,
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('omits sessions whose transcript has no assistant entry', async () => {
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 output time for each live session from its
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 `assistant` entry rather than from the transcript file modification
28
- * time, so a transcript touched only by tool results or owner replies still
29
- * counts as silent.
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.readLastAssistantOutputEpochSeconds(transcriptPath);
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 readLastAssistantOutputEpochSeconds = (
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 lastAssistantEpochMs: number | null = null;
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 (lastAssistantEpochMs === null || epochMs > lastAssistantEpochMs) {
78
- lastAssistantEpochMs = epochMs;
77
+ if (lastActivityEpochMs === null || epochMs > lastActivityEpochMs) {
78
+ lastActivityEpochMs = epochMs;
79
79
  }
80
80
  }
81
- if (lastAssistantEpochMs === null) {
81
+ if (lastActivityEpochMs === null) {
82
82
  return null;
83
83
  }
84
- return Math.floor(lastAssistantEpochMs / 1000);
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',