github-issue-tower-defence-management 1.117.9 → 1.118.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 (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-DID_sTas.js → index-DWiG7btO.js} +24 -24
  4. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +26 -12
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +44 -11
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +27 -7
  10. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  11. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +4 -1
  12. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +39 -26
  15. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +28 -2
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +2 -2
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +1 -7
  18. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +55 -26
  19. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +1 -8
  20. package/src/adapter/entry-points/console/ui-dist/assets/{index-DID_sTas.js → index-DWiG7btO.js} +24 -24
  21. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +18 -8
  23. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +8 -4
  24. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +81 -28
  25. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +91 -19
  26. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +91 -15
  27. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +54 -11
  28. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +34 -0
  29. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +6 -1
  30. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +9 -1
  31. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  32. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +8 -4
  33. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  34. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -2
  35. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  36. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -1
  38. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-DID_sTas.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DWiG7btO.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BMe1bEF4.css">
9
9
  </head>
10
10
  <body>
@@ -121,8 +121,10 @@ export class HandleScheduledEventUseCaseHandler {
121
121
  candidateDebounceStateFilePath?: string;
122
122
  activeHubTaskStatus?: string;
123
123
  silentMainStalledMessage?: string;
124
- silentSubAgentMessageHeader?: string;
125
- silentSubAgentMessageFooter?: string;
124
+ silentSubAgentIdleMessageHeader?: string;
125
+ silentSubAgentIdleMessageFooter?: string;
126
+ silentSubAgentLongRunningMessageHeader?: string;
127
+ silentSubAgentLongRunningMessageFooter?: string;
126
128
  credentials: {
127
129
  manager: {
128
130
  github: {
@@ -629,13 +631,21 @@ export class HandleScheduledEventUseCaseHandler {
629
631
  mergedInput.silentMainStalledMessage ??
630
632
  process.env.TDPM_SILENT_MAIN_STALLED_MESSAGE ??
631
633
  null,
632
- subAgentMessageHeader:
633
- mergedInput.silentSubAgentMessageHeader ??
634
- process.env.TDPM_SILENT_SUBAGENT_MESSAGE_HEADER ??
634
+ subAgentIdleMessageHeader:
635
+ mergedInput.silentSubAgentIdleMessageHeader ??
636
+ process.env.TDPM_SILENT_SUBAGENT_IDLE_MESSAGE_HEADER ??
635
637
  null,
636
- subAgentMessageFooter:
637
- mergedInput.silentSubAgentMessageFooter ??
638
- process.env.TDPM_SILENT_SUBAGENT_MESSAGE_FOOTER ??
638
+ subAgentIdleMessageFooter:
639
+ mergedInput.silentSubAgentIdleMessageFooter ??
640
+ process.env.TDPM_SILENT_SUBAGENT_IDLE_MESSAGE_FOOTER ??
641
+ null,
642
+ subAgentLongRunningMessageHeader:
643
+ mergedInput.silentSubAgentLongRunningMessageHeader ??
644
+ process.env.TDPM_SILENT_SUBAGENT_LONG_RUNNING_MESSAGE_HEADER ??
645
+ null,
646
+ subAgentLongRunningMessageFooter:
647
+ mergedInput.silentSubAgentLongRunningMessageFooter ??
648
+ process.env.TDPM_SILENT_SUBAGENT_LONG_RUNNING_MESSAGE_FOOTER ??
639
649
  null,
640
650
  },
641
651
  now: inTmuxNow,
@@ -20,8 +20,10 @@ const CLAUDE_PID = 201;
20
20
 
21
21
  const EMPTY_TEMPLATES: SilentSessionMessageTemplates = {
22
22
  mainStalledMessage: null,
23
- subAgentMessageHeader: null,
24
- subAgentMessageFooter: null,
23
+ subAgentIdleMessageHeader: null,
24
+ subAgentIdleMessageFooter: null,
25
+ subAgentLongRunningMessageHeader: null,
26
+ subAgentLongRunningMessageFooter: null,
25
27
  };
26
28
 
27
29
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
@@ -214,8 +216,10 @@ describe('notifySilentTmuxSessions', () => {
214
216
  ...baseParams(runner),
215
217
  messageTemplates: {
216
218
  mainStalledMessage: 'CUSTOM_MAIN_TEMPLATE',
217
- subAgentMessageHeader: null,
218
- subAgentMessageFooter: null,
219
+ subAgentIdleMessageHeader: null,
220
+ subAgentIdleMessageFooter: null,
221
+ subAgentLongRunningMessageHeader: null,
222
+ subAgentLongRunningMessageFooter: null,
219
223
  },
220
224
  });
221
225
 
@@ -4,20 +4,29 @@ import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSe
4
4
 
5
5
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
6
6
 
7
+ const THRESHOLDS = {
8
+ subAgentSilentThresholdSeconds: 300,
9
+ subAgentRunningThresholdSeconds: 900,
10
+ };
11
+
7
12
  const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
8
13
  composeMainStalledSection: jest.fn().mockReturnValue('FALLBACK_MAIN'),
9
14
  composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
10
15
  });
11
16
 
17
+ const noTemplates = {
18
+ mainStalledMessage: null,
19
+ subAgentIdleMessageHeader: null,
20
+ subAgentIdleMessageFooter: null,
21
+ subAgentLongRunningMessageHeader: null,
22
+ subAgentLongRunningMessageFooter: null,
23
+ };
24
+
12
25
  describe('ConfigurableSilentSessionMessageComposer', () => {
13
26
  it('uses the fallback main section when no main template is configured', () => {
14
27
  const fallback = createFallback();
15
28
  const composer = new ConfigurableSilentSessionMessageComposer(
16
- {
17
- mainStalledMessage: null,
18
- subAgentMessageHeader: null,
19
- subAgentMessageFooter: null,
20
- },
29
+ noTemplates,
21
30
  fallback,
22
31
  );
23
32
  expect(composer.composeMainStalledSection(600)).toBe('FALLBACK_MAIN');
@@ -28,9 +37,8 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
28
37
  const fallback = createFallback();
29
38
  const composer = new ConfigurableSilentSessionMessageComposer(
30
39
  {
40
+ ...noTemplates,
31
41
  mainStalledMessage: 'CUSTOM_MAIN',
32
- subAgentMessageHeader: null,
33
- subAgentMessageFooter: null,
34
42
  },
35
43
  fallback,
36
44
  );
@@ -40,52 +48,97 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
40
48
  expect(fallback.composeMainStalledSection).not.toHaveBeenCalled();
41
49
  });
42
50
 
43
- it('uses the fallback sub-agent section when neither header nor footer is configured', () => {
51
+ it('uses the fallback sub-agent section when no sub-agent template is configured', () => {
44
52
  const fallback = createFallback();
45
53
  const composer = new ConfigurableSilentSessionMessageComposer(
46
- {
47
- mainStalledMessage: null,
48
- subAgentMessageHeader: null,
49
- subAgentMessageFooter: null,
50
- },
54
+ noTemplates,
51
55
  fallback,
52
56
  );
53
57
  expect(
54
- composer.composeSubAgentSection([
55
- { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
56
- ]),
58
+ composer.composeSubAgentSection(
59
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
60
+ THRESHOLDS,
61
+ ),
57
62
  ).toBe('FALLBACK_SUB');
63
+ expect(fallback.composeSubAgentSection).toHaveBeenCalledWith(
64
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
65
+ THRESHOLDS,
66
+ );
58
67
  });
59
68
 
60
- it('renders the configured sub-agent header, list, and footer', () => {
69
+ it('renders the configured idle header, list, and footer for an idle sub-agent', () => {
61
70
  const fallback = createFallback();
62
71
  const composer = new ConfigurableSilentSessionMessageComposer(
63
72
  {
64
- mainStalledMessage: null,
65
- subAgentMessageHeader: 'HEADER',
66
- subAgentMessageFooter: 'FOOTER',
73
+ ...noTemplates,
74
+ subAgentIdleMessageHeader: 'IDLE_HEADER',
75
+ subAgentIdleMessageFooter: 'IDLE_FOOTER',
67
76
  },
68
77
  fallback,
69
78
  );
70
- const section = composer.composeSubAgentSection([
71
- { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
72
- ]);
73
- expect(section).toContain('HEADER');
79
+ const section = composer.composeSubAgentSection(
80
+ [{ label: 'task-a', silentSeconds: 360, runningSeconds: 60 }],
81
+ THRESHOLDS,
82
+ );
83
+ expect(section).toContain('IDLE_HEADER');
74
84
  expect(section).toContain('task-a');
75
- expect(section).toContain('silent for 6m');
85
+ expect(section).toContain('no output for 6m');
86
+ expect(section).toContain('IDLE_FOOTER');
87
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
88
+ expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('renders the configured long-running header, list, and footer for a long-running sub-agent', () => {
92
+ const fallback = createFallback();
93
+ const composer = new ConfigurableSilentSessionMessageComposer(
94
+ {
95
+ ...noTemplates,
96
+ subAgentLongRunningMessageHeader: 'LONG_HEADER',
97
+ subAgentLongRunningMessageFooter: 'LONG_FOOTER',
98
+ },
99
+ fallback,
100
+ );
101
+ const section = composer.composeSubAgentSection(
102
+ [{ label: 'task-b', silentSeconds: 30, runningSeconds: 1200 }],
103
+ THRESHOLDS,
104
+ );
105
+ expect(section).toContain('LONG_HEADER');
106
+ expect(section).toContain('task-b');
76
107
  expect(section).toContain('running for 20m');
77
- expect(section).toContain('FOOTER');
108
+ expect(section).toContain('LONG_FOOTER');
78
109
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
79
110
  expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
80
111
  });
81
112
 
113
+ it('emits both distinct configured sections for a sub-agent matching both conditions', () => {
114
+ const fallback = createFallback();
115
+ const composer = new ConfigurableSilentSessionMessageComposer(
116
+ {
117
+ ...noTemplates,
118
+ subAgentIdleMessageHeader: 'IDLE_HEADER',
119
+ subAgentLongRunningMessageHeader: 'LONG_HEADER',
120
+ },
121
+ fallback,
122
+ );
123
+ const section = composer.composeSubAgentSection(
124
+ [{ label: 'task-both', silentSeconds: 360, runningSeconds: 1200 }],
125
+ THRESHOLDS,
126
+ );
127
+ const [idleSection, longRunningSection] = section
128
+ .replace(`${SILENT_SESSION_REMINDER_SENTINEL} `, '')
129
+ .split('\n\n');
130
+ expect(idleSection).toContain('IDLE_HEADER');
131
+ expect(idleSection).toContain('no output for 6m');
132
+ expect(longRunningSection).toContain('LONG_HEADER');
133
+ expect(longRunningSection).toContain('running for 20m');
134
+ });
135
+
82
136
  it('does not double-prepend the sentinel when the template already carries it', () => {
83
137
  const fallback = createFallback();
84
138
  const composer = new ConfigurableSilentSessionMessageComposer(
85
139
  {
140
+ ...noTemplates,
86
141
  mainStalledMessage: `${SILENT_SESSION_REMINDER_SENTINEL} CUSTOM_MAIN`,
87
- subAgentMessageHeader: null,
88
- subAgentMessageFooter: null,
89
142
  },
90
143
  fallback,
91
144
  );
@@ -1,5 +1,8 @@
1
1
  import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnapshot';
2
- import { SilentSessionMessageComposer } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
2
+ import {
3
+ SilentSessionMessageComposer,
4
+ SubAgentStallThresholds,
5
+ } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
3
6
  import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
4
7
 
5
8
  const withReminderSentinel = (message: string): string =>
@@ -9,8 +12,10 @@ const withReminderSentinel = (message: string): string =>
9
12
 
10
13
  export type SilentSessionMessageTemplates = {
11
14
  mainStalledMessage: string | null;
12
- subAgentMessageHeader: string | null;
13
- subAgentMessageFooter: string | null;
15
+ subAgentIdleMessageHeader: string | null;
16
+ subAgentIdleMessageFooter: string | null;
17
+ subAgentLongRunningMessageHeader: string | null;
18
+ subAgentLongRunningMessageFooter: string | null;
14
19
  };
15
20
 
16
21
  const formatMinutes = (seconds: number): string => {
@@ -31,27 +36,94 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
31
36
  return withReminderSentinel(this.templates.mainStalledMessage);
32
37
  };
33
38
 
34
- composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
35
- if (
36
- this.templates.subAgentMessageHeader === null &&
37
- this.templates.subAgentMessageFooter === null
38
- ) {
39
- return this.fallback.composeSubAgentSection(subAgents);
39
+ composeSubAgentSection = (
40
+ subAgents: SubAgentActivity[],
41
+ thresholds: SubAgentStallThresholds,
42
+ ): string => {
43
+ const hasIdleTemplate =
44
+ this.templates.subAgentIdleMessageHeader !== null ||
45
+ this.templates.subAgentIdleMessageFooter !== null;
46
+ const hasLongRunningTemplate =
47
+ this.templates.subAgentLongRunningMessageHeader !== null ||
48
+ this.templates.subAgentLongRunningMessageFooter !== null;
49
+ if (!hasIdleTemplate && !hasLongRunningTemplate) {
50
+ return this.fallback.composeSubAgentSection(subAgents, thresholds);
40
51
  }
41
- const lines = subAgents.map(
52
+
53
+ const idleSubAgents = subAgents.filter(
54
+ (subAgent) =>
55
+ subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
56
+ );
57
+ const longRunningSubAgents = subAgents.filter(
42
58
  (subAgent) =>
43
- `- ${subAgent.label}: silent for ${formatMinutes(
44
- subAgent.silentSeconds,
45
- )}, running for ${formatMinutes(subAgent.runningSeconds)}`,
59
+ subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
46
60
  );
61
+
47
62
  const sections: string[] = [];
48
- if (this.templates.subAgentMessageHeader !== null) {
49
- sections.push(this.templates.subAgentMessageHeader);
63
+ if (idleSubAgents.length > 0 && hasIdleTemplate) {
64
+ sections.push(
65
+ this.composeIdleSection(
66
+ idleSubAgents,
67
+ this.templates.subAgentIdleMessageHeader,
68
+ this.templates.subAgentIdleMessageFooter,
69
+ ),
70
+ );
71
+ } else if (idleSubAgents.length > 0) {
72
+ sections.push(this.composeIdleSection(idleSubAgents, null, null));
73
+ }
74
+ if (longRunningSubAgents.length > 0 && hasLongRunningTemplate) {
75
+ sections.push(
76
+ this.composeLongRunningSection(
77
+ longRunningSubAgents,
78
+ this.templates.subAgentLongRunningMessageHeader,
79
+ this.templates.subAgentLongRunningMessageFooter,
80
+ ),
81
+ );
82
+ } else if (longRunningSubAgents.length > 0) {
83
+ sections.push(
84
+ this.composeLongRunningSection(longRunningSubAgents, null, null),
85
+ );
86
+ }
87
+ return withReminderSentinel(sections.join('\n\n'));
88
+ };
89
+
90
+ private composeIdleSection = (
91
+ idleSubAgents: SubAgentActivity[],
92
+ header: string | null,
93
+ footer: string | null,
94
+ ): string => {
95
+ const lines = idleSubAgents.map(
96
+ (subAgent) =>
97
+ `- ${subAgent.label}: no output for ${formatMinutes(subAgent.silentSeconds)}`,
98
+ );
99
+ const parts: string[] = [];
100
+ if (header !== null) {
101
+ parts.push(header);
102
+ }
103
+ parts.push(...lines);
104
+ if (footer !== null) {
105
+ parts.push(footer);
106
+ }
107
+ return parts.join('\n');
108
+ };
109
+
110
+ private composeLongRunningSection = (
111
+ longRunningSubAgents: SubAgentActivity[],
112
+ header: string | null,
113
+ footer: string | null,
114
+ ): string => {
115
+ const lines = longRunningSubAgents.map(
116
+ (subAgent) =>
117
+ `- ${subAgent.label}: running for ${formatMinutes(subAgent.runningSeconds)}`,
118
+ );
119
+ const parts: string[] = [];
120
+ if (header !== null) {
121
+ parts.push(header);
50
122
  }
51
- sections.push(...lines);
52
- if (this.templates.subAgentMessageFooter !== null) {
53
- sections.push(this.templates.subAgentMessageFooter);
123
+ parts.push(...lines);
124
+ if (footer !== null) {
125
+ parts.push(footer);
54
126
  }
55
- return withReminderSentinel(sections.join('\n'));
127
+ return parts.join('\n');
56
128
  };
57
129
  }
@@ -1,6 +1,11 @@
1
1
  import { DefaultSilentSessionMessageComposer } from './DefaultSilentSessionMessageComposer';
2
2
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
3
3
 
4
+ const THRESHOLDS = {
5
+ subAgentSilentThresholdSeconds: 300,
6
+ subAgentRunningThresholdSeconds: 900,
7
+ };
8
+
4
9
  describe('DefaultSilentSessionMessageComposer', () => {
5
10
  const composer = new DefaultSilentSessionMessageComposer();
6
11
 
@@ -10,9 +15,10 @@ describe('DefaultSilentSessionMessageComposer', () => {
10
15
  });
11
16
 
12
17
  it('embeds the reminder sentinel in the sub-agent section', () => {
13
- const section = composer.composeSubAgentSection([
14
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
15
- ]);
18
+ const section = composer.composeSubAgentSection(
19
+ [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
20
+ THRESHOLDS,
21
+ );
16
22
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
17
23
  });
18
24
 
@@ -99,23 +105,93 @@ describe('DefaultSilentSessionMessageComposer', () => {
99
105
  expect(section).not.toContain('""');
100
106
  });
101
107
 
102
- it('lists each sub-agent with its silent and running minutes', () => {
103
- const section = composer.composeSubAgentSection([
104
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
105
- { label: 'sub-process-2', silentSeconds: 0, runningSeconds: 960 },
106
- ]);
107
- expect(section).toContain('sub-process-1');
108
- expect(section).toContain('silent for 6m');
108
+ it('emits a distinct idle message for a sub-agent that is only output-idle', () => {
109
+ const section = composer.composeSubAgentSection(
110
+ [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
111
+ THRESHOLDS,
112
+ );
113
+ expect(section).toContain('sub-process-idle');
114
+ expect(section).toContain('no output for 6m');
115
+ expect(section).toContain('may be stalled');
116
+ expect(section).toContain('restart, hand off, or replace it');
117
+ expect(section).toContain('waiting on an external dependency');
118
+ expect(section).not.toContain('infinite loop');
119
+ });
120
+
121
+ it('emits a distinct long-running message for a sub-agent that has only run too long', () => {
122
+ const section = composer.composeSubAgentSection(
123
+ [
124
+ {
125
+ label: 'sub-process-long',
126
+ silentSeconds: 30,
127
+ runningSeconds: 1200,
128
+ },
129
+ ],
130
+ THRESHOLDS,
131
+ );
132
+ expect(section).toContain('sub-process-long');
109
133
  expect(section).toContain('running for 20m');
110
- expect(section).toContain('sub-process-2');
111
- expect(section).toContain('running for 16m');
134
+ expect(section).toContain('infinite loop');
135
+ expect(section).toContain('too large');
136
+ expect(section).toContain('not making forward progress');
137
+ expect(section).toContain(
138
+ 'do not dismiss it merely because it produced output recently',
139
+ );
140
+ expect(section).toContain(
141
+ 'break the task down, restart, hand off, or replace',
142
+ );
143
+ });
144
+
145
+ it('does not foreground the short idle time in the long-running message', () => {
146
+ const section = composer.composeSubAgentSection(
147
+ [
148
+ {
149
+ label: 'sub-process-long',
150
+ silentSeconds: 30,
151
+ runningSeconds: 1200,
152
+ },
153
+ ],
154
+ THRESHOLDS,
155
+ );
156
+ expect(section).not.toContain('no output for');
157
+ expect(section).not.toContain('silent for');
158
+ });
159
+
160
+ it('emits both distinct messages for a sub-agent matching both conditions, kept separate', () => {
161
+ const section = composer.composeSubAgentSection(
162
+ [{ label: 'sub-process-both', silentSeconds: 360, runningSeconds: 1200 }],
163
+ THRESHOLDS,
164
+ );
165
+ expect(section).toContain('no output for 6m');
166
+ expect(section).toContain('may be stalled');
167
+ expect(section).toContain('running for 20m');
168
+ expect(section).toContain('infinite loop');
169
+ const sentinelOccurrences =
170
+ section.split(SILENT_SESSION_REMINDER_SENTINEL).length - 1;
171
+ expect(sentinelOccurrences).toBe(2);
172
+ });
173
+
174
+ it('groups each sub-agent under the condition it matched', () => {
175
+ const section = composer.composeSubAgentSection(
176
+ [
177
+ { label: 'idle-only', silentSeconds: 360, runningSeconds: 60 },
178
+ { label: 'long-only', silentSeconds: 10, runningSeconds: 960 },
179
+ ],
180
+ THRESHOLDS,
181
+ );
182
+ const [idleSection, longRunningSection] = section.split('\n\n');
183
+ expect(idleSection).toContain('idle-only');
184
+ expect(idleSection).not.toContain('long-only');
185
+ expect(longRunningSection).toContain('long-only');
186
+ expect(longRunningSection).not.toContain('idle-only');
112
187
  });
113
188
 
114
189
  it('does not contain any host-specific or internal identifiers', () => {
115
190
  const mainSection = composer.composeMainStalledSection(600);
116
- const subSection = composer.composeSubAgentSection([
117
- { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
118
- ]);
191
+ const subSection = composer.composeSubAgentSection(
192
+ [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
193
+ THRESHOLDS,
194
+ );
119
195
  const combined = `${mainSection}\n${subSection}`.toLowerCase();
120
196
  expect(combined).not.toContain('claude');
121
197
  expect(combined).not.toContain('take ownership');
@@ -1,5 +1,8 @@
1
1
  import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
2
- import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
2
+ import {
3
+ SilentSessionMessageComposer,
4
+ SubAgentStallThresholds,
5
+ } from './adapter-interfaces/SilentSessionMessageComposer';
3
6
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
4
7
 
5
8
  const formatMinutes = (seconds: number): string => {
@@ -7,6 +10,38 @@ const formatMinutes = (seconds: number): string => {
7
10
  return `${minutes}m`;
8
11
  };
9
12
 
13
+ const composeIdleSubAgentSection = (
14
+ idleSubAgents: SubAgentActivity[],
15
+ ): string => {
16
+ const lines = idleSubAgents.map(
17
+ (subAgent) =>
18
+ `- ${subAgent.label}: no output for ${formatMinutes(
19
+ subAgent.silentSeconds,
20
+ )}`,
21
+ );
22
+ return [
23
+ `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-process(es) have produced no output for several minutes and may be stalled:`,
24
+ ...lines,
25
+ 'Check each one. If it is stuck, take action (restart, hand off, or replace it). If it is legitimately waiting on an external dependency (continuous integration, an external API, or another process), let it continue.',
26
+ ].join('\n');
27
+ };
28
+
29
+ const composeLongRunningSubAgentSection = (
30
+ longRunningSubAgents: SubAgentActivity[],
31
+ ): string => {
32
+ const lines = longRunningSubAgents.map(
33
+ (subAgent) =>
34
+ `- ${subAgent.label}: running for ${formatMinutes(
35
+ subAgent.runningSeconds,
36
+ )}`,
37
+ );
38
+ return [
39
+ `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-process(es) have been running longer than a task should normally take, which may mean an infinite loop, a task that is too large, or being stuck in an incorrect approach that is not making forward progress:`,
40
+ ...lines,
41
+ 'Verify each one is genuinely advancing toward completion; do not dismiss it merely because it produced output recently. If it is not progressing, intervene: break the task down, restart, hand off, or replace it.',
42
+ ].join('\n');
43
+ };
44
+
10
45
  const composeOwnerCallFormatGuidance = (
11
46
  ownerCallMarker: string | null,
12
47
  ): string => {
@@ -39,17 +74,25 @@ export class DefaultSilentSessionMessageComposer implements SilentSessionMessage
39
74
  return composeMainStalledMessage(mainSilentSeconds, this.ownerCallMarker);
40
75
  };
41
76
 
42
- composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
43
- const lines = subAgents.map(
77
+ composeSubAgentSection = (
78
+ subAgents: SubAgentActivity[],
79
+ thresholds: SubAgentStallThresholds,
80
+ ): string => {
81
+ const idleSubAgents = subAgents.filter(
82
+ (subAgent) =>
83
+ subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
84
+ );
85
+ const longRunningSubAgents = subAgents.filter(
44
86
  (subAgent) =>
45
- `- ${subAgent.label}: silent for ${formatMinutes(
46
- subAgent.silentSeconds,
47
- )}, running for ${formatMinutes(subAgent.runningSeconds)}`,
87
+ subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
48
88
  );
49
- return [
50
- `${SILENT_SESSION_REMINDER_SENTINEL} The following sub-processes have been silent or running for a long time:`,
51
- ...lines,
52
- 'If a sub-process is stalled, take action (restart, hand off, or replace it). If it is legitimately waiting on an external dependency (continuous integration, an external API, or another process), let it continue.',
53
- ].join('\n');
89
+ const sections: string[] = [];
90
+ if (idleSubAgents.length > 0) {
91
+ sections.push(composeIdleSubAgentSection(idleSubAgents));
92
+ }
93
+ if (longRunningSubAgents.length > 0) {
94
+ sections.push(composeLongRunningSubAgentSection(longRunningSubAgents));
95
+ }
96
+ return sections.join('\n\n');
54
97
  };
55
98
  }
@@ -446,12 +446,46 @@ describe('NotifySilentLiveSessionsUseCase', () => {
446
446
 
447
447
  expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
448
448
  subAgents,
449
+ {
450
+ subAgentSilentThresholdSeconds:
451
+ DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
452
+ subAgentRunningThresholdSeconds:
453
+ DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
454
+ },
449
455
  );
450
456
  expect(
451
457
  mockNotificationRepository.sendSelfCheckNotification,
452
458
  ).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
453
459
  });
454
460
 
461
+ it('passes a configured running threshold through to the sub-agent section composer', async () => {
462
+ setupLiveInteractiveSession(GITHUB_SESSION);
463
+ const subAgents: SubAgentActivity[] = [
464
+ {
465
+ label: 'sub-process-1',
466
+ silentSeconds: 10,
467
+ runningSeconds: 600,
468
+ },
469
+ ];
470
+ mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
471
+ new Map([[GITHUB_SESSION, subAgents]]),
472
+ );
473
+
474
+ await useCase.run({
475
+ ...runParams(),
476
+ subAgentRunningThresholdSeconds: 600,
477
+ });
478
+
479
+ expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith(
480
+ subAgents,
481
+ {
482
+ subAgentSilentThresholdSeconds:
483
+ DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
484
+ subAgentRunningThresholdSeconds: 600,
485
+ },
486
+ );
487
+ });
488
+
455
489
  it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
456
490
  mockSnapshotProvider.getSnapshot.mockResolvedValue({
457
491
  sessions: [{ sessionName: 'aw-host', panePids: [100] }],
@@ -277,7 +277,12 @@ export class NotifySilentLiveSessionsUseCase {
277
277
  );
278
278
  if (stalledSubAgents.length > 0) {
279
279
  sections.push(
280
- this.messageComposer.composeSubAgentSection(stalledSubAgents),
280
+ this.messageComposer.composeSubAgentSection(stalledSubAgents, {
281
+ subAgentSilentThresholdSeconds:
282
+ thresholds.subAgentSilentThresholdSeconds,
283
+ subAgentRunningThresholdSeconds:
284
+ thresholds.subAgentRunningThresholdSeconds,
285
+ }),
281
286
  );
282
287
  }
283
288
 
@@ -1,6 +1,14 @@
1
1
  import { SubAgentActivity } from '../../entities/LiveSessionActivitySnapshot';
2
2
 
3
+ export type SubAgentStallThresholds = {
4
+ subAgentSilentThresholdSeconds: number;
5
+ subAgentRunningThresholdSeconds: number;
6
+ };
7
+
3
8
  export interface SilentSessionMessageComposer {
4
9
  composeMainStalledSection: (mainSilentSeconds: number) => string;
5
- composeSubAgentSection: (subAgents: SubAgentActivity[]) => string;
10
+ composeSubAgentSection: (
11
+ subAgents: SubAgentActivity[],
12
+ thresholds: SubAgentStallThresholds,
13
+ ) => string;
6
14
  }