github-issue-tower-defence-management 1.122.8 → 1.122.10

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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D0RwhyOM.css → index-N9M1qhkw.css} +1 -1
  3. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  4. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  6. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +3 -4
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  8. package/bin/adapter/repositories/TranscriptRefusalTailStatusProvider.js +110 -0
  9. package/bin/adapter/repositories/TranscriptRefusalTailStatusProvider.js.map +1 -0
  10. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -13
  11. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  12. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +35 -9
  14. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  15. package/bin/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.js +3 -0
  16. package/bin/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.js.map +1 -0
  17. package/package.json +5 -3
  18. package/src/adapter/entry-points/console/ui-dist/assets/{index-D0RwhyOM.css → index-N9M1qhkw.css} +1 -1
  19. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  20. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +4 -10
  21. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +3 -2
  22. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +12 -5
  23. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -3
  24. package/src/adapter/repositories/TranscriptRefusalTailStatusProvider.test.ts +208 -0
  25. package/src/adapter/repositories/TranscriptRefusalTailStatusProvider.ts +80 -0
  26. package/src/domain/entities/Project.ts +1 -8
  27. package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +2 -2
  28. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +54 -34
  29. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +6 -23
  30. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +1 -3
  31. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +177 -38
  32. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +42 -12
  33. package/src/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.ts +5 -0
  34. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  35. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +1 -2
  36. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  37. package/types/adapter/repositories/TranscriptRefusalTailStatusProvider.d.ts +6 -0
  38. package/types/adapter/repositories/TranscriptRefusalTailStatusProvider.d.ts.map +1 -0
  39. package/types/domain/entities/Project.d.ts.map +1 -1
  40. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +1 -3
  41. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  42. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  43. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
  44. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  45. package/types/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.d.ts +4 -0
  46. package/types/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.d.ts.map +1 -0
  47. /package/bin/adapter/entry-points/console/ui-dist/assets/{index-CC-RYye2.js → index-8H0HejYa.js} +0 -0
  48. /package/src/adapter/entry-points/console/ui-dist/assets/{index-CC-RYye2.js → index-8H0HejYa.js} +0 -0
@@ -3,14 +3,7 @@ export type FieldOption = {
3
3
  id: string;
4
4
  name: string;
5
5
  color:
6
- | 'GRAY'
7
- | 'BLUE'
8
- | 'GREEN'
9
- | 'YELLOW'
10
- | 'ORANGE'
11
- | 'RED'
12
- | 'PINK'
13
- | 'PURPLE';
6
+ 'GRAY' | 'BLUE' | 'GREEN' | 'YELLOW' | 'ORANGE' | 'RED' | 'PINK' | 'PURPLE';
14
7
  description: string;
15
8
  };
16
9
  export type Project = {
@@ -185,9 +185,9 @@ describe('AssignNoAssigneeIssueToManagerUseCase', () => {
185
185
  ...basicIssue,
186
186
  url: 'https://github.com/testOrg/testRepo/issues/44',
187
187
  };
188
+ const nonErrorValue: unknown = 'string-failure';
188
189
  mockIssueRepository.updateAssigneeList.mockImplementationOnce(() => {
189
- const nonError: unknown = 'string-failure';
190
- throw nonError;
190
+ throw nonErrorValue;
191
191
  });
192
192
 
193
193
  await expect(
@@ -48,7 +48,9 @@ describe('DefaultSilentSessionMessageComposer', () => {
48
48
  'Keep a monitor in place that notices when a sub-agent has produced no output for about 5 minutes.',
49
49
  ),
50
50
  ).toBe(1);
51
- expect(occurrences('share it through a new owner-call')).toBe(1);
51
+ expect(
52
+ occurrences('work the owner asked for has been completed or answered'),
53
+ ).toBe(1);
52
54
  });
53
55
 
54
56
  it('requests a remaining-minutes estimate in the next output', () => {
@@ -63,40 +65,38 @@ describe('DefaultSilentSessionMessageComposer', () => {
63
65
  expect(section).toContain('No output has been observed for 10 minutes.');
64
66
  });
65
67
 
66
- it('states the owner-call tag format exactly once: complete pair on one line, content starting with the red-circle emoji, self-contained', () => {
68
+ it('states the owner-call format guidance exactly once, deferring to the session-documented format', () => {
67
69
  const section = composer.composeMainStalledSection(600);
68
70
  const formatOccurrences =
69
- section.split('complete opening and closing pair on one line').length - 1;
71
+ section.split('in the format documented for this session').length - 1;
70
72
  expect(formatOccurrences).toBe(1);
71
- expect(section).toContain('🔴');
73
+ expect(section).toContain('written to be self-contained');
72
74
  expect(section).toContain(
73
- 'with the content starting immediately with the 🔴 emoji',
75
+ 'so the owner can understand the situation from that single message',
74
76
  );
75
- expect(section).toContain('written to be self-contained');
76
77
  });
77
78
 
78
79
  it('explains that the owner is notified only when an owner-call is raised', () => {
79
80
  const section = composer.composeMainStalledSection(600);
80
- expect(section).toContain('the owner is notified only when one is raised');
81
- });
82
-
83
- it('interpolates the configured owner-call marker into the format guidance when provided', () => {
84
- const markedComposer = new DefaultSilentSessionMessageComposer(
85
- '<<OWNER_CALL>>',
86
- );
87
- const section = markedComposer.composeMainStalledSection(600);
88
81
  expect(section).toContain(
89
- 'owner-call marker tag "<<OWNER_CALL>>" as a complete opening and closing pair on one line',
82
+ 'The owner is notified only when an owner-call is raised.',
90
83
  );
91
- expect(section).toContain('🔴');
92
84
  });
93
85
 
94
- it('falls back to generic owner-call format guidance when no marker is configured', () => {
86
+ it('frames a completed owner request as awaiting acknowledgment rather than a no-action case', () => {
95
87
  const section = composer.composeMainStalledSection(600);
96
88
  expect(section).toContain(
97
- 'owner-call marker tag as a complete opening and closing pair on one line',
89
+ "a completion still awaits the owner's acknowledgment",
98
90
  );
99
- expect(section).not.toContain('""');
91
+ expect(section).toContain('so it is not a no-action case');
92
+ });
93
+
94
+ it('contains no marker-tag example, tag name, or angle bracket in the format guidance', () => {
95
+ const section = composer.composeMainStalledSection(600);
96
+ expect(section).not.toContain('marker tag');
97
+ expect(section).not.toContain('opening and closing pair');
98
+ expect(section).not.toContain('<');
99
+ expect(section).not.toContain('>');
100
100
  });
101
101
 
102
102
  it('embeds the reminder sentinel in the stale-owner-call main-stalled section', () => {
@@ -166,22 +166,19 @@ describe('DefaultSilentSessionMessageComposer', () => {
166
166
  3600,
167
167
  );
168
168
  const formatOccurrences =
169
- section.split('complete opening and closing pair on one line').length - 1;
169
+ section.split('in the format documented for this session').length - 1;
170
170
  expect(formatOccurrences).toBe(1);
171
171
  });
172
172
 
173
- it('interpolates the configured owner-call marker into the stale-owner-call format guidance', () => {
174
- const markedComposer = new DefaultSilentSessionMessageComposer(
175
- '<<OWNER_CALL>>',
176
- );
177
- const section = markedComposer.composeMainStalledWithStaleOwnerCallSection(
173
+ it('contains no marker-tag example, tag name, or angle bracket in the stale-owner-call section', () => {
174
+ const section = composer.composeMainStalledWithStaleOwnerCallSection(
178
175
  600,
179
176
  3600,
180
177
  );
181
- expect(section).toContain(
182
- 'owner-call marker tag "<<OWNER_CALL>>" as a complete opening and closing pair on one line',
183
- );
184
- expect(section).toContain('🔴');
178
+ expect(section).not.toContain('marker tag');
179
+ expect(section).not.toContain('opening and closing pair');
180
+ expect(section).not.toContain('<');
181
+ expect(section).not.toContain('>');
185
182
  });
186
183
 
187
184
  it('composes the stale-owner-call section distinctly from the plain main-stalled section', () => {
@@ -350,9 +347,6 @@ describe('DefaultSilentSessionMessageComposer', () => {
350
347
  /do not wait passively/i,
351
348
  /is prohibited/i,
352
349
  ];
353
- const markedComposer = new DefaultSilentSessionMessageComposer(
354
- '<<OWNER_CALL>>',
355
- );
356
350
  const subAgent = {
357
351
  label: 'sub-process-1',
358
352
  silentSeconds: 360,
@@ -361,9 +355,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
361
355
  };
362
356
  const composedDefaultTexts = [
363
357
  composer.composeMainStalledSection(600),
364
- markedComposer.composeMainStalledSection(600),
365
358
  composer.composeMainStalledWithStaleOwnerCallSection(600, 3600),
366
- markedComposer.composeMainStalledWithStaleOwnerCallSection(600, 3600),
367
359
  composer.composeSubAgentSection({
368
360
  idleSubAgents: [subAgent],
369
361
  longRunningSubAgents: [subAgent],
@@ -376,6 +368,34 @@ describe('DefaultSilentSessionMessageComposer', () => {
376
368
  }
377
369
  });
378
370
 
371
+ it('composes default texts free of angle-bracket tag examples and emoji characters', () => {
372
+ const emojiPattern =
373
+ /[\u{1F000}-\u{1FAFF}\u{2190}-\u{21FF}\u{2300}-\u{23FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
374
+ const subAgent = {
375
+ label: 'sub-process-1',
376
+ silentSeconds: 360,
377
+ runningSeconds: 1200,
378
+ waitingOnExternalProcess: false,
379
+ };
380
+ const composedDefaultTexts = [
381
+ composer.composeMainStalledSection(600),
382
+ composer.composeMainStalledWithStaleOwnerCallSection(600, 3600),
383
+ composer.composeSubAgentSection({
384
+ idleSubAgents: [subAgent],
385
+ longRunningSubAgents: [subAgent],
386
+ }),
387
+ ];
388
+ for (const text of composedDefaultTexts) {
389
+ expect(text).not.toContain('<');
390
+ expect(text).not.toContain('>');
391
+ expect(text).not.toMatch(emojiPattern);
392
+ expect(text).not.toContain('\u{FE0F}');
393
+ expect(text).not.toContain('\u{200D}');
394
+ // The bracketed reminder sentinel remains allowed.
395
+ expect(text).toContain(SILENT_SESSION_REMINDER_SENTINEL);
396
+ }
397
+ });
398
+
379
399
  it('does not contain any host-specific or internal identifiers', () => {
380
400
  const mainSection = composer.composeMainStalledSection(600);
381
401
  const subAgent = {
@@ -42,29 +42,18 @@ const composeLongRunningSubAgentSection = (
42
42
  ].join('\n');
43
43
  };
44
44
 
45
- export const composeOwnerCallFormatGuidance = (
46
- ownerCallMarker: string | null,
47
- ): string => {
48
- const tagLabel =
49
- ownerCallMarker !== null && ownerCallMarker.length > 0
50
- ? ` "${ownerCallMarker}"`
51
- : '';
52
- return `Format reminder: write the owner-call marker tag${tagLabel} as a complete opening and closing pair on one line, with the content starting immediately with the 🔴 emoji and written to be self-contained, so the owner can understand the situation, the ask, and any decision needed from that single message.`;
45
+ export const composeOwnerCallFormatGuidance = (): string => {
46
+ return 'Please share it through a new owner-call in the format documented for this session, written to be self-contained so the owner can understand the situation from that single message.';
53
47
  };
54
48
 
55
- const composeMainStalledMessage = (
56
- mainSilentSeconds: number,
57
- ownerCallMarker: string | null,
58
- ): string => {
49
+ const composeMainStalledMessage = (mainSilentSeconds: number): string => {
59
50
  const minutes = Math.floor(mainSilentSeconds / 60);
60
51
  return [
61
52
  `${SILENT_SESSION_REMINDER_SENTINEL} This is an automated status check. No output has been observed for ${minutes} minutes. If you are waiting on an external process, no action is needed — please log one line explaining the wait. Otherwise please continue with the next step, with these points in mind:`,
62
53
  `1. Keep the session task list current, marking finished items as done.`,
63
54
  `2. Run independent pieces of work in parallel across sub-agents.`,
64
55
  `3. Keep a monitor in place that notices when a sub-agent has produced no output for about 5 minutes.`,
65
- `4. When an owner decision is needed, or when an owner request has been completed or answered, please share it through a new owner-call — the owner is notified only when one is raised. ${composeOwnerCallFormatGuidance(
66
- ownerCallMarker,
67
- )}`,
56
+ `4. When an owner decision is needed, or when work the owner asked for has been completed or answered, please share it through a new owner-call — a completion still awaits the owner's acknowledgment, so it is not a no-action case. The owner is notified only when an owner-call is raised. ${composeOwnerCallFormatGuidance()}`,
68
57
  `Please also include in your next output an estimate of the remaining minutes to finish all tasks.`,
69
58
  ].join('\n');
70
59
  };
@@ -72,24 +61,19 @@ const composeMainStalledMessage = (
72
61
  const composeMainStalledWithStaleOwnerCallMessage = (
73
62
  mainSilentSeconds: number,
74
63
  unansweredOwnerCallAgeSeconds: number,
75
- ownerCallMarker: string | null,
76
64
  ): string => {
77
65
  const silentMinutes = Math.floor(mainSilentSeconds / 60);
78
66
  const ownerCallAgeMinutes = Math.floor(unansweredOwnerCallAgeSeconds / 60);
79
67
  return [
80
68
  `${SILENT_SESSION_REMINDER_SENTINEL} This is an automated status check. No output has been observed for ${silentMinutes} minutes, and the owner call raised ${ownerCallAgeMinutes} minutes ago has not yet been acknowledged by the owner.`,
81
- `An owner call without an acknowledgment — whether it carried a completion report, a question, or a decision request — is still awaiting the owner's acknowledgment, and the earlier message may have been missed. Please re-raise its content as a fresh, self-contained owner call. ${composeOwnerCallFormatGuidance(
82
- ownerCallMarker,
83
- )}`,
69
+ `An owner call without an acknowledgment — whether it carried a completion report, a question, or a decision request — is still awaiting the owner's acknowledgment, and the earlier message may have been missed. Please re-raise its content as a fresh, self-contained owner call. ${composeOwnerCallFormatGuidance()}`,
84
70
  `Please also include in your next output an estimate of the remaining minutes to finish all tasks.`,
85
71
  ].join('\n');
86
72
  };
87
73
 
88
74
  export class DefaultSilentSessionMessageComposer implements SilentSessionMessageComposer {
89
- constructor(private readonly ownerCallMarker: string | null = null) {}
90
-
91
75
  composeMainStalledSection = (mainSilentSeconds: number): string => {
92
- return composeMainStalledMessage(mainSilentSeconds, this.ownerCallMarker);
76
+ return composeMainStalledMessage(mainSilentSeconds);
93
77
  };
94
78
 
95
79
  composeMainStalledWithStaleOwnerCallSection = (
@@ -99,7 +83,6 @@ export class DefaultSilentSessionMessageComposer implements SilentSessionMessage
99
83
  return composeMainStalledWithStaleOwnerCallMessage(
100
84
  mainSilentSeconds,
101
85
  unansweredOwnerCallAgeSeconds,
102
- this.ownerCallMarker,
103
86
  );
104
87
  };
105
88
 
@@ -33,9 +33,7 @@ export class IllegalIssueStatusError extends Error {
33
33
  }
34
34
  }
35
35
  type RejectedReasonType =
36
- | 'NO_REPORT_FROM_AGENT_BOT'
37
- | 'REPORT_HAS_NEXT_STEP'
38
- | PrRejectedReasonType;
36
+ 'NO_REPORT_FROM_AGENT_BOT' | 'REPORT_HAS_NEXT_STEP' | PrRejectedReasonType;
39
37
 
40
38
  export class NotifyFinishedIssuePreparationUseCase {
41
39
  private readonly issueRejectionEvaluator: IssueRejectionEvaluator;
@@ -17,6 +17,7 @@ import { InteractiveLiveSessionTranscriptResolver } from './adapter-interfaces/I
17
17
  import { SessionOutputActivityRepository } from './adapter-interfaces/SessionOutputActivityRepository';
18
18
  import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionSubAgentActivityRepository';
19
19
  import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusProvider';
20
+ import { RefusalTailStatusProvider } from './adapter-interfaces/RefusalTailStatusProvider';
20
21
  import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
21
22
  import { SilentSessionCandidateStateRepository } from './adapter-interfaces/SilentSessionCandidateStateRepository';
22
23
  import { SilentSessionHubTaskStatusCacheRepository } from './adapter-interfaces/SilentSessionHubTaskStatusCacheRepository';
@@ -52,6 +53,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
52
53
  let mockSleeper: Mocked<Sleeper>;
53
54
  let mockHubTaskStatusResolver: Mocked<HubTaskStatusResolver>;
54
55
  let mockHubTaskStatusCacheRepository: Mocked<SilentSessionHubTaskStatusCacheRepository>;
56
+ let mockRefusalTailStatusProvider: Mocked<RefusalTailStatusProvider>;
55
57
  const now = new Date('2026-06-26T00:00:00Z');
56
58
  const nowEpochSeconds = Math.floor(now.getTime() / 1000);
57
59
  const GITHUB_SESSION = 'https_//github_com/HiromiShikata/repo/issues/42';
@@ -191,6 +193,11 @@ describe('NotifySilentLiveSessionsUseCase', () => {
191
193
  loadHubTaskStatus: jest.fn().mockResolvedValue(null),
192
194
  saveHubTaskStatus: jest.fn().mockResolvedValue(undefined),
193
195
  };
196
+ mockRefusalTailStatusProvider = {
197
+ listRefusalTailedSessionNames: jest
198
+ .fn()
199
+ .mockResolvedValue(new Set<string>()),
200
+ };
194
201
  useCase = new NotifySilentLiveSessionsUseCase(
195
202
  mockSnapshotProvider,
196
203
  mockTranscriptResolver,
@@ -203,6 +210,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
203
210
  mockSleeper,
204
211
  mockHubTaskStatusResolver,
205
212
  mockHubTaskStatusCacheRepository,
213
+ mockRefusalTailStatusProvider,
206
214
  );
207
215
  });
208
216
 
@@ -388,7 +396,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
388
396
  ).toHaveBeenCalledWith([GITHUB_SESSION], expectedMap);
389
397
  });
390
398
 
391
- it('suppresses the stalled section and sends nothing while the unanswered owner call is younger than the grace period', async () => {
399
+ it('suppresses the stalled section and sends nothing while the latest owner call is unanswered', async () => {
392
400
  setupSilentMainSession(GITHUB_SESSION);
393
401
  mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
394
402
  new Map([
@@ -412,47 +420,33 @@ describe('NotifySilentLiveSessionsUseCase', () => {
412
420
  ).not.toHaveBeenCalled();
413
421
  });
414
422
 
415
- it('sends the stale-owner-call stalled section once the unanswered owner call reaches the grace period', async () => {
423
+ it('suppresses the reminder even when the unanswered owner call is far older than the former grace period (10 hours)', async () => {
416
424
  setupSilentMainSession(GITHUB_SESSION);
417
425
  mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
418
- new Map([
419
- [
420
- GITHUB_SESSION,
421
- nowEpochSeconds - DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
422
- ],
423
- ]),
426
+ new Map([[GITHUB_SESSION, nowEpochSeconds - 10 * 60 * 60]]),
424
427
  );
425
428
 
426
429
  await useCase.run(runParams());
427
430
 
428
431
  expect(
429
432
  mockMessageComposer.composeMainStalledWithStaleOwnerCallSection,
430
- ).toHaveBeenCalledWith(
431
- DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
432
- DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
433
- );
433
+ ).not.toHaveBeenCalled();
434
434
  expect(
435
435
  mockMessageComposer.composeMainStalledSection,
436
436
  ).not.toHaveBeenCalled();
437
437
  expect(
438
438
  mockNotificationRepository.sendSelfCheckNotification,
439
- ).toHaveBeenCalledWith(
440
- GITHUB_SESSION,
441
- MAIN_STALLED_STALE_OWNER_CALL_SECTION,
442
- );
439
+ ).not.toHaveBeenCalled();
440
+ expect(
441
+ mockCandidateStateRepository.saveCandidateSessionNames,
442
+ ).toHaveBeenCalledWith({
443
+ sessionNames: [],
444
+ now,
445
+ });
443
446
  });
444
447
 
445
- it('does not send the stale-owner-call section when the owner call is past the grace period but the main output is not silent past the threshold', async () => {
446
- setupLiveInteractiveSession(GITHUB_SESSION);
447
- mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
448
- [
449
- {
450
- sessionName: GITHUB_SESSION,
451
- lastOutputEpochSeconds:
452
- nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS + 1,
453
- },
454
- ],
455
- );
448
+ it('suppresses the reminder at exactly the former grace-period age instead of firing the stale-owner-call section', async () => {
449
+ setupSilentMainSession(GITHUB_SESSION);
456
450
  mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
457
451
  new Map([
458
452
  [
@@ -464,13 +458,28 @@ describe('NotifySilentLiveSessionsUseCase', () => {
464
458
 
465
459
  await useCase.run(runParams());
466
460
 
461
+ expect(
462
+ mockMessageComposer.composeMainStalledWithStaleOwnerCallSection,
463
+ ).not.toHaveBeenCalled();
464
+ expect(
465
+ mockMessageComposer.composeMainStalledSection,
466
+ ).not.toHaveBeenCalled();
467
467
  expect(
468
468
  mockNotificationRepository.sendSelfCheckNotification,
469
469
  ).not.toHaveBeenCalled();
470
470
  });
471
471
 
472
- it('defers a first-cycle stale-owner-call candidate until it persists into the next cycle', async () => {
473
- setupSilentMainSession(GITHUB_SESSION);
472
+ it('does not send anything when the owner call is old and the main output is not silent past the threshold', async () => {
473
+ setupLiveInteractiveSession(GITHUB_SESSION);
474
+ mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
475
+ [
476
+ {
477
+ sessionName: GITHUB_SESSION,
478
+ lastOutputEpochSeconds:
479
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS + 1,
480
+ },
481
+ ],
482
+ );
474
483
  mockOwnerCallStatusProvider.listUnansweredOwnerCallEpochSecondsBySessionName.mockResolvedValue(
475
484
  new Map([
476
485
  [
@@ -479,21 +488,12 @@ describe('NotifySilentLiveSessionsUseCase', () => {
479
488
  ],
480
489
  ]),
481
490
  );
482
- mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
483
- new Set<string>(),
484
- );
485
491
 
486
492
  await useCase.run(runParams());
487
493
 
488
494
  expect(
489
495
  mockNotificationRepository.sendSelfCheckNotification,
490
496
  ).not.toHaveBeenCalled();
491
- expect(
492
- mockCandidateStateRepository.saveCandidateSessionNames,
493
- ).toHaveBeenCalledWith({
494
- sessionNames: [GITHUB_SESSION],
495
- now,
496
- });
497
497
  });
498
498
 
499
499
  it('sends the main stalled section when the session is silent past the threshold and not waiting on the owner', async () => {
@@ -1485,6 +1485,145 @@ describe('NotifySilentLiveSessionsUseCase', () => {
1485
1485
  });
1486
1486
  });
1487
1487
 
1488
+ describe('refusal-tailed session gating', () => {
1489
+ it('excludes a main-stalled session whose last assistant turn is a refusal and logs one skip line', async () => {
1490
+ setupSilentMainSession(GITHUB_SESSION);
1491
+ mockRefusalTailStatusProvider.listRefusalTailedSessionNames.mockResolvedValue(
1492
+ new Set([GITHUB_SESSION]),
1493
+ );
1494
+
1495
+ await useCase.run(runParams());
1496
+
1497
+ expect(
1498
+ mockMessageComposer.composeMainStalledSection,
1499
+ ).not.toHaveBeenCalled();
1500
+ expect(
1501
+ mockNotificationRepository.sendSelfCheckNotification,
1502
+ ).not.toHaveBeenCalled();
1503
+ const skipLines = jest
1504
+ .mocked(console.log)
1505
+ .mock.calls.filter(
1506
+ (call) =>
1507
+ typeof call[0] === 'string' &&
1508
+ call[0].includes('last assistant turn was a model refusal'),
1509
+ );
1510
+ expect(skipLines).toEqual([
1511
+ [
1512
+ `Skipping ${GITHUB_SESSION}: last assistant turn was a model refusal; suppressing reminders until a non-refusal turn appears.`,
1513
+ ],
1514
+ ]);
1515
+ });
1516
+
1517
+ it('excludes a refusal-tailed session from the sub-agent reminder branch as well', async () => {
1518
+ setupLiveInteractiveSession(GITHUB_SESSION);
1519
+ const subAgents: SubAgentActivity[] = [
1520
+ {
1521
+ label: 'sub-process-1',
1522
+ silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
1523
+ runningSeconds: 60,
1524
+ waitingOnExternalProcess: false,
1525
+ },
1526
+ ];
1527
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
1528
+ new Map([[GITHUB_SESSION, subAgents]]),
1529
+ );
1530
+ mockRefusalTailStatusProvider.listRefusalTailedSessionNames.mockResolvedValue(
1531
+ new Set([GITHUB_SESSION]),
1532
+ );
1533
+
1534
+ await useCase.run(runParams());
1535
+
1536
+ expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
1537
+ expect(
1538
+ mockNotificationRepository.sendSelfCheckNotification,
1539
+ ).not.toHaveBeenCalled();
1540
+ expect(
1541
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName,
1542
+ ).toHaveBeenCalledWith([], transcriptMapFor([GITHUB_SESSION]));
1543
+ });
1544
+
1545
+ it('passes the resolved transcript paths to the refusal-tail provider', async () => {
1546
+ setupSilentMainSession(GITHUB_SESSION);
1547
+
1548
+ await useCase.run(runParams());
1549
+
1550
+ expect(
1551
+ mockRefusalTailStatusProvider.listRefusalTailedSessionNames,
1552
+ ).toHaveBeenCalledWith(transcriptMapFor([GITHUB_SESSION]));
1553
+ });
1554
+
1555
+ it('notifies a session again once the provider stops reporting it (a non-refusal turn followed the refusal)', async () => {
1556
+ setupSilentMainSession(GITHUB_SESSION);
1557
+ mockRefusalTailStatusProvider.listRefusalTailedSessionNames.mockResolvedValue(
1558
+ new Set<string>(),
1559
+ );
1560
+
1561
+ await useCase.run(runParams());
1562
+
1563
+ expect(
1564
+ mockNotificationRepository.sendSelfCheckNotification,
1565
+ ).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
1566
+ });
1567
+
1568
+ it('excludes only the refusal-tailed session when mixed with a healthy stalled session', async () => {
1569
+ mockSnapshotProvider.getSnapshot.mockResolvedValue(
1570
+ snapshotWithSessions([GITHUB_SESSION_ALPHA, GITHUB_SESSION_BRAVO]),
1571
+ );
1572
+ mockTranscriptResolver.resolveTranscriptPaths.mockReturnValue(
1573
+ transcriptMapFor([GITHUB_SESSION_ALPHA, GITHUB_SESSION_BRAVO]),
1574
+ );
1575
+ mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
1576
+ [
1577
+ {
1578
+ sessionName: GITHUB_SESSION_ALPHA,
1579
+ lastOutputEpochSeconds:
1580
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
1581
+ },
1582
+ {
1583
+ sessionName: GITHUB_SESSION_BRAVO,
1584
+ lastOutputEpochSeconds:
1585
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
1586
+ },
1587
+ ],
1588
+ );
1589
+ mockRefusalTailStatusProvider.listRefusalTailedSessionNames.mockResolvedValue(
1590
+ new Set([GITHUB_SESSION_ALPHA]),
1591
+ );
1592
+
1593
+ await useCase.run(runParams());
1594
+
1595
+ expect(
1596
+ mockNotificationRepository.sendSelfCheckNotification,
1597
+ ).toHaveBeenCalledTimes(1);
1598
+ expect(
1599
+ mockNotificationRepository.sendSelfCheckNotification,
1600
+ ).toHaveBeenCalledWith(GITHUB_SESSION_BRAVO, MAIN_STALLED_SECTION);
1601
+ });
1602
+
1603
+ it('keeps the existing behavior when no refusal-tail provider is configured', async () => {
1604
+ const useCaseWithoutProvider = new NotifySilentLiveSessionsUseCase(
1605
+ mockSnapshotProvider,
1606
+ mockTranscriptResolver,
1607
+ mockSessionOutputActivityRepository,
1608
+ mockSubAgentActivityRepository,
1609
+ mockOwnerCallStatusProvider,
1610
+ mockNotificationRepository,
1611
+ mockCandidateStateRepository,
1612
+ mockMessageComposer,
1613
+ mockSleeper,
1614
+ mockHubTaskStatusResolver,
1615
+ mockHubTaskStatusCacheRepository,
1616
+ );
1617
+ setupSilentMainSession(GITHUB_SESSION);
1618
+
1619
+ await useCaseWithoutProvider.run(runParams());
1620
+
1621
+ expect(
1622
+ mockNotificationRepository.sendSelfCheckNotification,
1623
+ ).toHaveBeenCalledWith(GITHUB_SESSION, MAIN_STALLED_SECTION);
1624
+ });
1625
+ });
1626
+
1488
1627
  describe('isGitHubIssueOrPullRequestSessionName', () => {
1489
1628
  it('accepts the encoded form of a github.com issue URL session name', () => {
1490
1629
  expect(
@@ -3,6 +3,7 @@ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
3
3
  import { LiveSessionProcessSnapshotProvider } from './adapter-interfaces/LiveSessionProcessSnapshotProvider';
4
4
  import { InteractiveLiveSessionTranscriptResolver } from './adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
5
5
  import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusProvider';
6
+ import { RefusalTailStatusProvider } from './adapter-interfaces/RefusalTailStatusProvider';
6
7
  import { SessionOutputActivityRepository } from './adapter-interfaces/SessionOutputActivityRepository';
7
8
  import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionSubAgentActivityRepository';
8
9
  import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
@@ -14,6 +15,10 @@ import { IssueRepository } from './adapter-interfaces/IssueRepository';
14
15
  import { ResolveInteractiveLiveSessionsUseCase } from './ResolveInteractiveLiveSessionsUseCase';
15
16
 
16
17
  export const DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS = 10 * 60;
18
+ // Retained only for backward compatibility of the configuration surface
19
+ // (TDPM_SILENT_UNANSWERED_OWNER_CALL_GRACE_SECONDS). The value is no longer
20
+ // consulted: an unanswered owner call suppresses the main-stall reminder
21
+ // unconditionally (treated as an infinite grace). See composeCandidate.
17
22
  export const DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS = 60 * 60;
18
23
  export const DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS = 5 * 60;
19
24
  export const DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS = 15 * 60;
@@ -75,6 +80,7 @@ export class NotifySilentLiveSessionsUseCase {
75
80
  private readonly sleeper: Sleeper,
76
81
  private readonly hubTaskStatusResolver: HubTaskStatusResolver | null = null,
77
82
  private readonly hubTaskStatusCacheRepository: SilentSessionHubTaskStatusCacheRepository | null = null,
83
+ private readonly refusalTailStatusProvider: RefusalTailStatusProvider | null = null,
78
84
  ) {}
79
85
 
80
86
  run = async (params: {
@@ -107,8 +113,31 @@ export class NotifySilentLiveSessionsUseCase {
107
113
  interactiveSessions,
108
114
  );
109
115
 
116
+ // A session whose most recent assistant turn is a model refusal is
117
+ // excluded from ALL reminder candidates (main-stall and sub-agent
118
+ // branches alike): each reminder delivery re-sends the full session
119
+ // context to the API and is guaranteed to produce another refusal, so
120
+ // reminding such a session only burns tokens. The gate is state-based
121
+ // (no time windows) and self-clears once a non-refusal assistant turn
122
+ // appears after the refusal.
123
+ const refusalTailedSessionNames =
124
+ this.refusalTailStatusProvider === null
125
+ ? new Set<string>()
126
+ : await this.refusalTailStatusProvider.listRefusalTailedSessionNames(
127
+ transcriptPathBySessionName,
128
+ );
129
+ const monitoredSessions = interactiveSessions.filter((session) => {
130
+ if (!refusalTailedSessionNames.has(session.sessionName)) {
131
+ return true;
132
+ }
133
+ console.log(
134
+ `Skipping ${session.sessionName}: last assistant turn was a model refusal; suppressing reminders until a non-refusal turn appears.`,
135
+ );
136
+ return false;
137
+ });
138
+
110
139
  const snapshots = await this.collectSnapshots(
111
- interactiveSessions,
140
+ monitoredSessions,
112
141
  transcriptPathBySessionName,
113
142
  params.now,
114
143
  );
@@ -367,22 +396,23 @@ export class NotifySilentLiveSessionsUseCase {
367
396
  const mainSilentSeconds = snapshot.mainSilentSeconds;
368
397
  const unansweredOwnerCallAgeSeconds =
369
398
  snapshot.unansweredOwnerCallAgeSeconds;
370
- const suppressedByRecentOwnerCall =
371
- unansweredOwnerCallAgeSeconds !== null &&
372
- unansweredOwnerCallAgeSeconds <
373
- thresholds.unansweredOwnerCallGraceSeconds;
399
+ // Owner-defined rule: whenever the latest owner call is newer than the
400
+ // latest owner reply (i.e. the call is unanswered), the session is
401
+ // waiting on the owner and MUST NOT receive a main-stall reminder —
402
+ // unconditionally, with no age or grace expiry. The persistent unread
403
+ // indicator in the owner's app covers the missed-call case, so a
404
+ // time-based re-fire is unnecessary. `unansweredOwnerCallGraceSeconds`
405
+ // is retained in the parameters only for backward compatibility of the
406
+ // call signature and is intentionally ignored (treated as infinite).
407
+ const suppressedByUnansweredOwnerCall =
408
+ unansweredOwnerCallAgeSeconds !== null;
374
409
  const mainTriggered =
375
410
  mainSilentSeconds !== null &&
376
411
  mainSilentSeconds >= thresholds.mainSilentThresholdSeconds &&
377
- !suppressedByRecentOwnerCall;
412
+ !suppressedByUnansweredOwnerCall;
378
413
  if (mainTriggered) {
379
414
  sections.push(
380
- unansweredOwnerCallAgeSeconds !== null
381
- ? this.messageComposer.composeMainStalledWithStaleOwnerCallSection(
382
- mainSilentSeconds,
383
- unansweredOwnerCallAgeSeconds,
384
- )
385
- : this.messageComposer.composeMainStalledSection(mainSilentSeconds),
415
+ this.messageComposer.composeMainStalledSection(mainSilentSeconds),
386
416
  );
387
417
  }
388
418
 
@@ -0,0 +1,5 @@
1
+ export interface RefusalTailStatusProvider {
2
+ listRefusalTailedSessionNames: (
3
+ transcriptPathBySessionName: Map<string, string>,
4
+ ) => Promise<Set<string>>;
5
+ }