github-issue-tower-defence-management 1.122.9 → 1.122.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-D0RwhyOM.css → index-N9M1qhkw.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +3 -4
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
- package/bin/adapter/repositories/TranscriptRefusalTailStatusProvider.js +110 -0
- package/bin/adapter/repositories/TranscriptRefusalTailStatusProvider.js.map +1 -0
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -13
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +56 -12
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -2
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.js.map +1 -0
- package/package.json +5 -3
- package/src/adapter/entry-points/console/ui-dist/assets/{index-D0RwhyOM.css → index-N9M1qhkw.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -1
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +3 -2
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +12 -5
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -3
- package/src/adapter/repositories/TranscriptRefusalTailStatusProvider.test.ts +208 -0
- package/src/adapter/repositories/TranscriptRefusalTailStatusProvider.ts +80 -0
- package/src/domain/entities/Project.ts +1 -8
- package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +2 -2
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +54 -34
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +6 -23
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +154 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +60 -14
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +1 -3
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +147 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +26 -1
- package/src/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.ts +5 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +1 -2
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/adapter/repositories/TranscriptRefusalTailStatusProvider.d.ts +6 -0
- package/types/adapter/repositories/TranscriptRefusalTailStatusProvider.d.ts.map +1 -0
- package/types/domain/entities/Project.d.ts.map +1 -1
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +1 -3
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/RefusalTailStatusProvider.d.ts.map +1 -0
- /package/bin/adapter/entry-points/console/ui-dist/assets/{index-CC-RYye2.js → index-8H0HejYa.js} +0 -0
- /package/src/adapter/entry-points/console/ui-dist/assets/{index-CC-RYye2.js → index-8H0HejYa.js} +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { RefusalTailStatusProvider } from '../../domain/usecases/adapter-interfaces/RefusalTailStatusProvider';
|
|
3
|
+
|
|
4
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
5
|
+
typeof value === 'object' && value !== null;
|
|
6
|
+
|
|
7
|
+
const readString = (
|
|
8
|
+
value: Record<string, unknown>,
|
|
9
|
+
key: string,
|
|
10
|
+
): string | null => {
|
|
11
|
+
const candidate = value[key];
|
|
12
|
+
return typeof candidate === 'string' ? candidate : null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// A model refusal is durably recorded in the session transcript JSONL as an
|
|
16
|
+
// assistant entry whose stop reason is `refusal` (a system entry with
|
|
17
|
+
// `subtype: 'model_refusal_no_fallback'` precedes it). The stop reason lives
|
|
18
|
+
// on the embedded API message (`message.stop_reason`); the top-level field is
|
|
19
|
+
// also checked for robustness against transcript format variations.
|
|
20
|
+
const isRefusalAssistantEntry = (parsed: Record<string, unknown>): boolean => {
|
|
21
|
+
if (readString(parsed, 'type') !== 'assistant') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (readString(parsed, 'stop_reason') === 'refusal') {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
const message = parsed.message;
|
|
28
|
+
return isRecord(message) && readString(message, 'stop_reason') === 'refusal';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class TranscriptRefusalTailStatusProvider implements RefusalTailStatusProvider {
|
|
32
|
+
// A session is refusal-tailed when the most recent assistant entry in its
|
|
33
|
+
// transcript is a model refusal. Sending a reminder to such a session is
|
|
34
|
+
// guaranteed to burn a full-context API call and produce another refusal,
|
|
35
|
+
// so the monitor excludes it from reminder candidates. The gate is purely
|
|
36
|
+
// state-based: as soon as any non-refusal assistant entry appears after the
|
|
37
|
+
// refusal (manual nudge, restart, compaction), the session is no longer
|
|
38
|
+
// refusal-tailed and reminders resume.
|
|
39
|
+
listRefusalTailedSessionNames = async (
|
|
40
|
+
transcriptPathBySessionName: Map<string, string>,
|
|
41
|
+
): Promise<Set<string>> => {
|
|
42
|
+
const refusalTailedSessionNames = new Set<string>();
|
|
43
|
+
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
44
|
+
if (this.isTranscriptRefusalTailed(transcriptPath)) {
|
|
45
|
+
refusalTailedSessionNames.add(sessionName);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return refusalTailedSessionNames;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
private isTranscriptRefusalTailed = (transcriptPath: string): boolean => {
|
|
52
|
+
let content: string;
|
|
53
|
+
try {
|
|
54
|
+
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
let lastAssistantEntryIsRefusal = false;
|
|
59
|
+
for (const line of content.split('\n')) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (trimmed.length === 0) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(trimmed);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!isRecord(parsed)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (readString(parsed, 'type') !== 'assistant') {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
lastAssistantEntryIsRefusal = isRefusalAssistantEntry(parsed);
|
|
77
|
+
}
|
|
78
|
+
return lastAssistantEntryIsRefusal;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -3,14 +3,7 @@ export type FieldOption = {
|
|
|
3
3
|
id: string;
|
|
4
4
|
name: string;
|
|
5
5
|
color:
|
|
6
|
-
| '
|
|
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
|
-
|
|
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(
|
|
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
|
|
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('
|
|
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
|
-
'
|
|
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
|
|
82
|
+
'The owner is notified only when an owner-call is raised.',
|
|
90
83
|
);
|
|
91
|
-
expect(section).toContain('🔴');
|
|
92
84
|
});
|
|
93
85
|
|
|
94
|
-
it('
|
|
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
|
-
|
|
89
|
+
"a completion still awaits the owner's acknowledgment",
|
|
98
90
|
);
|
|
99
|
-
expect(section).
|
|
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('
|
|
169
|
+
section.split('in the format documented for this session').length - 1;
|
|
170
170
|
expect(formatOccurrences).toBe(1);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
it('
|
|
174
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -388,6 +388,160 @@ describe('IssueRejectionEvaluator', () => {
|
|
|
388
388
|
});
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
+
describe('transient getOpenPullRequest failure containment', () => {
|
|
392
|
+
let warnSpy: jest.SpyInstance;
|
|
393
|
+
|
|
394
|
+
beforeEach(() => {
|
|
395
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
warnSpy.mockRestore();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should not throw and should return no rejections when getOpenPullRequest rejects for a PR item (isPr=true)', async () => {
|
|
403
|
+
mockIssueRepository.getOpenPullRequest.mockRejectedValue(
|
|
404
|
+
new Error(
|
|
405
|
+
'GraphQL errors: [{"message":"Something went wrong while executing your query"}]',
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const result = await evaluator.evaluate({
|
|
410
|
+
url: 'https://github.com/user/repo/pull/10',
|
|
411
|
+
labels: [],
|
|
412
|
+
isPr: true,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(result.rejections).toHaveLength(0);
|
|
416
|
+
expect(result.approvedPrUrl).toBeNull();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should log one warning line containing the PR URL and error message when getOpenPullRequest rejects for a PR item', async () => {
|
|
420
|
+
mockIssueRepository.getOpenPullRequest.mockRejectedValue(
|
|
421
|
+
new Error('Something went wrong while executing your query'),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await evaluator.evaluate({
|
|
425
|
+
url: 'https://github.com/user/repo/pull/10',
|
|
426
|
+
labels: [],
|
|
427
|
+
isPr: true,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
431
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
432
|
+
expect.stringContaining('https://github.com/user/repo/pull/10'),
|
|
433
|
+
);
|
|
434
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
435
|
+
expect.stringContaining(
|
|
436
|
+
'Something went wrong while executing your query',
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should skip the failing URL, log a warning, and still evaluate the healthy PR in the same batch (prebuilt path)', async () => {
|
|
442
|
+
mockIssueRepository.getOpenPullRequest.mockImplementation(
|
|
443
|
+
(prUrl: string) =>
|
|
444
|
+
prUrl === 'https://github.com/user/repo/pull/8'
|
|
445
|
+
? Promise.reject(
|
|
446
|
+
new Error('Something went wrong while executing your query'),
|
|
447
|
+
)
|
|
448
|
+
: Promise.resolve(createReadyPr(prUrl)),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const result = await evaluator.evaluate(
|
|
452
|
+
{
|
|
453
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
454
|
+
labels: [],
|
|
455
|
+
isPr: false,
|
|
456
|
+
},
|
|
457
|
+
[],
|
|
458
|
+
{
|
|
459
|
+
relatedOpenPrUrls: [
|
|
460
|
+
'https://github.com/user/repo/pull/8',
|
|
461
|
+
'https://github.com/user/repo/pull/9',
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
467
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
468
|
+
expect.stringContaining('https://github.com/user/repo/pull/8'),
|
|
469
|
+
);
|
|
470
|
+
expect(result.rejections).toHaveLength(0);
|
|
471
|
+
expect(result.approvedPrUrl).toBe(
|
|
472
|
+
'https://github.com/user/repo/pull/9',
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should not reject with PULL_REQUEST_NOT_FOUND when the only related URL fails transiently (state unknown, skipped)', async () => {
|
|
477
|
+
mockIssueRepository.getOpenPullRequest.mockRejectedValue(
|
|
478
|
+
new Error('Something went wrong while executing your query'),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const result = await evaluator.evaluate(
|
|
482
|
+
{
|
|
483
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
484
|
+
labels: [],
|
|
485
|
+
isPr: false,
|
|
486
|
+
},
|
|
487
|
+
[],
|
|
488
|
+
{ relatedOpenPrUrls: ['https://github.com/user/repo/pull/8'] },
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
492
|
+
expect(result.rejections).toHaveLength(0);
|
|
493
|
+
expect(result.approvedPrUrl).toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should still evaluate the healthy remaining PR normally when it has a rejection reason (draft) after another URL fails', async () => {
|
|
497
|
+
mockIssueRepository.getOpenPullRequest.mockImplementation(
|
|
498
|
+
(prUrl: string) =>
|
|
499
|
+
prUrl === 'https://github.com/user/repo/pull/8'
|
|
500
|
+
? Promise.reject(
|
|
501
|
+
new Error('Something went wrong while executing your query'),
|
|
502
|
+
)
|
|
503
|
+
: Promise.resolve(createReadyPr(prUrl, { isDraft: true })),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const result = await evaluator.evaluate(
|
|
507
|
+
{
|
|
508
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
509
|
+
labels: [],
|
|
510
|
+
isPr: false,
|
|
511
|
+
},
|
|
512
|
+
[],
|
|
513
|
+
{
|
|
514
|
+
relatedOpenPrUrls: [
|
|
515
|
+
'https://github.com/user/repo/pull/8',
|
|
516
|
+
'https://github.com/user/repo/pull/9',
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
expect(result.rejections).toHaveLength(1);
|
|
522
|
+
expect(result.rejections[0].type).toBe('PULL_REQUEST_IS_DRAFT');
|
|
523
|
+
expect(result.approvedPrUrl).toBeNull();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should handle a non-Error rejection value without throwing', async () => {
|
|
527
|
+
mockIssueRepository.getOpenPullRequest.mockRejectedValue(
|
|
528
|
+
'string failure',
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const result = await evaluator.evaluate({
|
|
532
|
+
url: 'https://github.com/user/repo/pull/10',
|
|
533
|
+
labels: [],
|
|
534
|
+
isPr: true,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
538
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
539
|
+
expect.stringContaining('string failure'),
|
|
540
|
+
);
|
|
541
|
+
expect(result.rejections).toHaveLength(0);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
391
545
|
describe('change-target-must: label behavior', () => {
|
|
392
546
|
const prUrl = 'https://github.com/user/repo/pull/1';
|
|
393
547
|
|
|
@@ -67,17 +67,36 @@ export class IssueRejectionEvaluator {
|
|
|
67
67
|
!hasLabelAsLlmAgentName &&
|
|
68
68
|
(categoryLabels.length <= 0 || categoryLabels.includes('category:e2e'))
|
|
69
69
|
) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
let prsToCheck: RelatedPullRequest[];
|
|
71
|
+
let anyPrResolutionFailed = false;
|
|
72
|
+
if (issue.isPr) {
|
|
73
|
+
const resolved = await this.resolveOpenPrsForPrItem(issue.url);
|
|
74
|
+
if (resolved === null) {
|
|
75
|
+
// getOpenPullRequest failed transiently: the PR's state is unknown
|
|
76
|
+
// for this cycle, so skip it without emitting any rejection.
|
|
77
|
+
return { rejections, approvedPrUrl };
|
|
78
|
+
}
|
|
79
|
+
prsToCheck = resolved;
|
|
80
|
+
} else if (options.relatedOpenPrUrls != null) {
|
|
81
|
+
const resolved = await this.resolveOpenPrsFromUrls(
|
|
82
|
+
options.relatedOpenPrUrls,
|
|
83
|
+
);
|
|
84
|
+
prsToCheck = resolved.prs;
|
|
85
|
+
anyPrResolutionFailed = resolved.anyResolutionFailed;
|
|
86
|
+
} else {
|
|
87
|
+
prsToCheck = await this.issueRepository.findRelatedOpenPRs(issue.url);
|
|
88
|
+
}
|
|
75
89
|
|
|
76
90
|
if (prsToCheck.length <= 0) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
if (!anyPrResolutionFailed) {
|
|
92
|
+
rejections.push({
|
|
93
|
+
type: 'PULL_REQUEST_NOT_FOUND',
|
|
94
|
+
detail: 'PULL_REQUEST_NOT_FOUND',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// When a resolution failed and no PR resolved, the related PR state is
|
|
98
|
+
// unknown for this cycle; emit no rejection so a transient GraphQL
|
|
99
|
+
// error cannot cause a false PULL_REQUEST_NOT_FOUND revert.
|
|
81
100
|
} else if (prsToCheck.length > 1) {
|
|
82
101
|
rejections.push({
|
|
83
102
|
type: 'MULTIPLE_PULL_REQUESTS_FOUND',
|
|
@@ -164,10 +183,21 @@ export class IssueRejectionEvaluator {
|
|
|
164
183
|
return { rejections, approvedPrUrl };
|
|
165
184
|
};
|
|
166
185
|
|
|
186
|
+
// Returns null when getOpenPullRequest throws (e.g. a transient GitHub
|
|
187
|
+
// GraphQL server error): the PR's state is unknown for this cycle and the
|
|
188
|
+
// caller skips it instead of letting the error abort the schedule cycle.
|
|
167
189
|
private resolveOpenPrsForPrItem = async (
|
|
168
190
|
prUrl: string,
|
|
169
|
-
): Promise<RelatedPullRequest[]> => {
|
|
170
|
-
|
|
191
|
+
): Promise<RelatedPullRequest[] | null> => {
|
|
192
|
+
let pr: RelatedPullRequest | null;
|
|
193
|
+
try {
|
|
194
|
+
pr = await this.issueRepository.getOpenPullRequest(prUrl);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`IssueRejectionEvaluator: getOpenPullRequest failed, skipping PR for this cycle. prUrl: ${prUrl} error: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
171
201
|
if (pr === null) {
|
|
172
202
|
return [];
|
|
173
203
|
}
|
|
@@ -180,18 +210,34 @@ export class IssueRejectionEvaluator {
|
|
|
180
210
|
// so the resulting set is equivalent while avoiding the per-issue timeline
|
|
181
211
|
// query. Duplicate URLs are de-duplicated to mirror findRelatedOpenPRs, which
|
|
182
212
|
// collapses duplicates via its internal Map keyed by PR URL.
|
|
213
|
+
//
|
|
214
|
+
// When getOpenPullRequest throws for a URL (e.g. a transient GitHub GraphQL
|
|
215
|
+
// server error), that PR is logged and skipped for this cycle while the
|
|
216
|
+
// remaining URLs are still resolved; anyResolutionFailed reports whether at
|
|
217
|
+
// least one URL failed so the caller can avoid treating an unknown state as
|
|
218
|
+
// PULL_REQUEST_NOT_FOUND.
|
|
183
219
|
private resolveOpenPrsFromUrls = async (
|
|
184
220
|
prUrls: string[],
|
|
185
|
-
): Promise<RelatedPullRequest[]> => {
|
|
221
|
+
): Promise<{ prs: RelatedPullRequest[]; anyResolutionFailed: boolean }> => {
|
|
186
222
|
const uniquePrUrls = Array.from(new Set(prUrls));
|
|
187
223
|
const resolvedPrs: RelatedPullRequest[] = [];
|
|
224
|
+
let anyResolutionFailed = false;
|
|
188
225
|
for (const prUrl of uniquePrUrls) {
|
|
189
|
-
|
|
226
|
+
let pr: RelatedPullRequest | null;
|
|
227
|
+
try {
|
|
228
|
+
pr = await this.issueRepository.getOpenPullRequest(prUrl);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.warn(
|
|
231
|
+
`IssueRejectionEvaluator: getOpenPullRequest failed, skipping PR for this cycle. prUrl: ${prUrl} error: ${error instanceof Error ? error.message : String(error)}`,
|
|
232
|
+
);
|
|
233
|
+
anyResolutionFailed = true;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
190
236
|
if (pr !== null) {
|
|
191
237
|
resolvedPrs.push(pr);
|
|
192
238
|
}
|
|
193
239
|
}
|
|
194
|
-
return resolvedPrs;
|
|
240
|
+
return { prs: resolvedPrs, anyResolutionFailed };
|
|
195
241
|
};
|
|
196
242
|
|
|
197
243
|
private extractChangeTargetMustPaths = (labels: string[]): string[] => {
|
|
@@ -33,9 +33,7 @@ export class IllegalIssueStatusError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
type RejectedReasonType =
|
|
36
|
-
| '
|
|
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;
|