github-issue-tower-defence-management 1.122.10 → 1.122.12
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/repositories/FileSystemSilentSessionCandidateStateRepository.js +12 -50
- package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +32 -3
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.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/NotifySilentLiveSessionsUseCase.js +14 -34
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +42 -213
- package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +11 -83
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +68 -5
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +43 -3
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +154 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +60 -14
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +74 -127
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +15 -51
- package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +0 -8
- package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +0 -10
- package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
- package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +0 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +0 -8
- package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
|
@@ -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[] => {
|
|
@@ -167,12 +167,6 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
167
167
|
.fn()
|
|
168
168
|
.mockResolvedValue(everyNameRecentSet()),
|
|
169
169
|
saveCandidateSessionNames: jest.fn().mockResolvedValue(undefined),
|
|
170
|
-
loadAnnouncedRunningSubAgentLabels: jest
|
|
171
|
-
.fn()
|
|
172
|
-
.mockResolvedValue(new Set<string>()),
|
|
173
|
-
saveAnnouncedRunningSubAgentLabels: jest
|
|
174
|
-
.fn()
|
|
175
|
-
.mockResolvedValue(undefined),
|
|
176
170
|
};
|
|
177
171
|
mockMessageComposer = {
|
|
178
172
|
composeMainStalledSection: jest
|
|
@@ -573,7 +567,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
573
567
|
const subAgents: SubAgentActivity[] = [
|
|
574
568
|
{
|
|
575
569
|
label: 'sub-process-1',
|
|
576
|
-
silentSeconds:
|
|
570
|
+
silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
577
571
|
runningSeconds: 600,
|
|
578
572
|
waitingOnExternalProcess: false,
|
|
579
573
|
},
|
|
@@ -588,11 +582,33 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
588
582
|
});
|
|
589
583
|
|
|
590
584
|
expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
|
|
591
|
-
idleSubAgents:
|
|
585
|
+
idleSubAgents: subAgents,
|
|
592
586
|
longRunningSubAgents: subAgents,
|
|
593
587
|
});
|
|
594
588
|
});
|
|
595
589
|
|
|
590
|
+
it('suppresses the long-running section for a sub-agent that produced output recently even past the running threshold', async () => {
|
|
591
|
+
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
592
|
+
const subAgents: SubAgentActivity[] = [
|
|
593
|
+
{
|
|
594
|
+
label: 'sub-process-1',
|
|
595
|
+
silentSeconds: 10,
|
|
596
|
+
runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
597
|
+
waitingOnExternalProcess: false,
|
|
598
|
+
},
|
|
599
|
+
];
|
|
600
|
+
mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
|
|
601
|
+
new Map([[GITHUB_SESSION, subAgents]]),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
await useCase.run(runParams());
|
|
605
|
+
|
|
606
|
+
expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
|
|
607
|
+
expect(
|
|
608
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
609
|
+
).not.toHaveBeenCalled();
|
|
610
|
+
});
|
|
611
|
+
|
|
596
612
|
it('excludes an owner-handover spawn from selection so no notification is sent', async () => {
|
|
597
613
|
mockSnapshotProvider.getSnapshot.mockResolvedValue({
|
|
598
614
|
sessions: [{ sessionName: 'aw-host', panePids: [100] }],
|
|
@@ -764,15 +780,21 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
764
780
|
runningSeconds: 60,
|
|
765
781
|
waitingOnExternalProcess: true,
|
|
766
782
|
});
|
|
767
|
-
const
|
|
783
|
+
const quietLongRunningSubAgent = (
|
|
768
784
|
label: string,
|
|
769
785
|
waitingOnExternalProcess: boolean,
|
|
770
786
|
): SubAgentActivity => ({
|
|
771
787
|
label,
|
|
772
|
-
silentSeconds:
|
|
788
|
+
silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
773
789
|
runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
774
790
|
waitingOnExternalProcess,
|
|
775
791
|
});
|
|
792
|
+
const producingLongRunningSubAgent = (label: string): SubAgentActivity => ({
|
|
793
|
+
label,
|
|
794
|
+
silentSeconds: 30,
|
|
795
|
+
runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
796
|
+
waitingOnExternalProcess: false,
|
|
797
|
+
});
|
|
776
798
|
|
|
777
799
|
const setupSubAgents = (subAgents: SubAgentActivity[]): void => {
|
|
778
800
|
setupLiveInteractiveSession(GITHUB_SESSION);
|
|
@@ -817,69 +839,19 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
817
839
|
).toHaveBeenCalledTimes(2);
|
|
818
840
|
});
|
|
819
841
|
|
|
820
|
-
it('
|
|
821
|
-
setupSubAgents([
|
|
822
|
-
{
|
|
823
|
-
label: 'sub-process-1',
|
|
824
|
-
silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
825
|
-
runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
826
|
-
waitingOnExternalProcess: true,
|
|
827
|
-
},
|
|
828
|
-
]);
|
|
829
|
-
|
|
830
|
-
await useCase.run(runParams());
|
|
831
|
-
|
|
832
|
-
expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
|
|
833
|
-
idleSubAgents: [],
|
|
834
|
-
longRunningSubAgents: [
|
|
835
|
-
{
|
|
836
|
-
label: 'sub-process-1',
|
|
837
|
-
silentSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
838
|
-
runningSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
839
|
-
waitingOnExternalProcess: true,
|
|
840
|
-
},
|
|
841
|
-
],
|
|
842
|
-
});
|
|
843
|
-
expect(
|
|
844
|
-
mockNotificationRepository.sendSelfCheckNotification,
|
|
845
|
-
).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
it('records the announced running label only after the notification is sent', async () => {
|
|
849
|
-
setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
|
|
850
|
-
|
|
851
|
-
await useCase.run(runParams());
|
|
852
|
-
|
|
853
|
-
expect(
|
|
854
|
-
mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
|
|
855
|
-
).toHaveBeenCalledWith({
|
|
856
|
-
sessionName: GITHUB_SESSION,
|
|
857
|
-
labels: ['sub-process-1'],
|
|
858
|
-
now,
|
|
859
|
-
});
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
it('does not record an announced running label when the first-cycle debounce defers the send', async () => {
|
|
863
|
-
setupSubAgents([longRunningSubAgent('sub-process-1', false)]);
|
|
864
|
-
mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
|
|
865
|
-
new Set<string>(),
|
|
866
|
-
);
|
|
842
|
+
it('never selects a waiting sub-agent for the long-running section even when quiet past both thresholds', async () => {
|
|
843
|
+
setupSubAgents([quietLongRunningSubAgent('sub-process-1', true)]);
|
|
867
844
|
|
|
868
845
|
await useCase.run(runParams());
|
|
869
846
|
|
|
847
|
+
expect(mockMessageComposer.composeSubAgentSection).not.toHaveBeenCalled();
|
|
870
848
|
expect(
|
|
871
849
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
872
850
|
).not.toHaveBeenCalled();
|
|
873
|
-
expect(
|
|
874
|
-
mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
|
|
875
|
-
).not.toHaveBeenCalled();
|
|
876
851
|
});
|
|
877
852
|
|
|
878
|
-
it('
|
|
879
|
-
setupSubAgents([
|
|
880
|
-
mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
|
|
881
|
-
new Set(['sub-process-1']),
|
|
882
|
-
);
|
|
853
|
+
it('never selects a long-running sub-agent that produced output within the silent threshold', async () => {
|
|
854
|
+
setupSubAgents([producingLongRunningSubAgent('sub-process-1')]);
|
|
883
855
|
|
|
884
856
|
await useCase.run(runParams());
|
|
885
857
|
|
|
@@ -889,98 +861,73 @@ describe('NotifySilentLiveSessionsUseCase', () => {
|
|
|
889
861
|
).not.toHaveBeenCalled();
|
|
890
862
|
});
|
|
891
863
|
|
|
892
|
-
it('
|
|
893
|
-
setupSubAgents([
|
|
894
|
-
longRunningSubAgent('sub-process-1', false),
|
|
895
|
-
longRunningSubAgent('sub-process-2', false),
|
|
896
|
-
]);
|
|
897
|
-
mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
|
|
898
|
-
new Set(['sub-process-1']),
|
|
899
|
-
);
|
|
864
|
+
it('includes a quiet long-running sub-agent in both the idle and long-running sections', async () => {
|
|
865
|
+
setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
|
|
900
866
|
|
|
901
867
|
await useCase.run(runParams());
|
|
902
868
|
|
|
903
869
|
expect(mockMessageComposer.composeSubAgentSection).toHaveBeenCalledWith({
|
|
904
|
-
idleSubAgents: [],
|
|
905
|
-
longRunningSubAgents: [
|
|
870
|
+
idleSubAgents: [quietLongRunningSubAgent('sub-process-1', false)],
|
|
871
|
+
longRunningSubAgents: [
|
|
872
|
+
quietLongRunningSubAgent('sub-process-1', false),
|
|
873
|
+
],
|
|
906
874
|
});
|
|
907
875
|
expect(
|
|
908
|
-
|
|
909
|
-
).toHaveBeenCalledWith(
|
|
910
|
-
sessionName: GITHUB_SESSION,
|
|
911
|
-
labels: ['sub-process-1', 'sub-process-2'],
|
|
912
|
-
now,
|
|
913
|
-
});
|
|
876
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
877
|
+
).toHaveBeenCalledWith(GITHUB_SESSION, SUBAGENT_SECTION);
|
|
914
878
|
});
|
|
915
879
|
|
|
916
|
-
it('
|
|
917
|
-
setupSubAgents([]);
|
|
918
|
-
mockCandidateStateRepository.loadAnnouncedRunningSubAgentLabels.mockResolvedValue(
|
|
919
|
-
new Set(['sub-process-1']),
|
|
920
|
-
);
|
|
880
|
+
it('keeps notifying a quiet long-running sub-agent on every cycle while the condition holds', async () => {
|
|
881
|
+
setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
|
|
921
882
|
|
|
883
|
+
await useCase.run(runParams());
|
|
922
884
|
await useCase.run(runParams());
|
|
923
885
|
|
|
924
886
|
expect(
|
|
925
|
-
|
|
926
|
-
).
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
887
|
+
mockNotificationRepository.sendSelfCheckNotification,
|
|
888
|
+
).toHaveBeenCalledTimes(2);
|
|
889
|
+
expect(
|
|
890
|
+
mockMessageComposer.composeSubAgentSection,
|
|
891
|
+
).toHaveBeenNthCalledWith(2, {
|
|
892
|
+
idleSubAgents: [quietLongRunningSubAgent('sub-process-1', false)],
|
|
893
|
+
longRunningSubAgents: [
|
|
894
|
+
quietLongRunningSubAgent('sub-process-1', false),
|
|
895
|
+
],
|
|
930
896
|
});
|
|
931
897
|
});
|
|
932
898
|
|
|
933
|
-
it('
|
|
934
|
-
setupSubAgents([
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
async ({ sessionName }) =>
|
|
938
|
-
new Set(announcedLabelsBySessionName.get(sessionName) ?? []),
|
|
939
|
-
);
|
|
940
|
-
mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels.mockImplementation(
|
|
941
|
-
async ({ sessionName, labels }) => {
|
|
942
|
-
announcedLabelsBySessionName.set(sessionName, labels);
|
|
943
|
-
},
|
|
899
|
+
it('defers a first-cycle long-running candidate until it persists into the next cycle', async () => {
|
|
900
|
+
setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
|
|
901
|
+
mockCandidateStateRepository.loadRecentCandidateSessionNames.mockResolvedValue(
|
|
902
|
+
new Set<string>(),
|
|
944
903
|
);
|
|
945
904
|
|
|
946
905
|
await useCase.run(runParams());
|
|
906
|
+
|
|
947
907
|
expect(
|
|
948
908
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
949
|
-
).
|
|
909
|
+
).not.toHaveBeenCalled();
|
|
910
|
+
expect(
|
|
911
|
+
mockCandidateStateRepository.saveCandidateSessionNames,
|
|
912
|
+
).toHaveBeenCalledWith({ sessionNames: [GITHUB_SESSION], now });
|
|
913
|
+
});
|
|
950
914
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
mockTranscriptResolver,
|
|
954
|
-
mockSessionOutputActivityRepository,
|
|
955
|
-
mockSubAgentActivityRepository,
|
|
956
|
-
mockOwnerCallStatusProvider,
|
|
957
|
-
mockNotificationRepository,
|
|
958
|
-
mockCandidateStateRepository,
|
|
959
|
-
mockMessageComposer,
|
|
960
|
-
mockSleeper,
|
|
961
|
-
mockHubTaskStatusResolver,
|
|
962
|
-
mockHubTaskStatusCacheRepository,
|
|
963
|
-
);
|
|
964
|
-
await secondUseCase.run(
|
|
965
|
-
runParams({ now: new Date(now.getTime() + 60 * 1000) }),
|
|
966
|
-
);
|
|
915
|
+
it('stops notifying once the long-running sub-agent drops out of the activity snapshot', async () => {
|
|
916
|
+
setupSubAgents([quietLongRunningSubAgent('sub-process-1', false)]);
|
|
967
917
|
|
|
918
|
+
await useCase.run(runParams());
|
|
968
919
|
expect(
|
|
969
920
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
970
921
|
).toHaveBeenCalledTimes(1);
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
it('does not touch the announced-label record for a main-only reminder', async () => {
|
|
974
|
-
setupSilentMainSession(GITHUB_SESSION);
|
|
975
922
|
|
|
923
|
+
mockSubAgentActivityRepository.listSubAgentActivitiesBySessionName.mockResolvedValue(
|
|
924
|
+
new Map<string, SubAgentActivity[]>(),
|
|
925
|
+
);
|
|
976
926
|
await useCase.run(runParams());
|
|
977
927
|
|
|
978
928
|
expect(
|
|
979
929
|
mockNotificationRepository.sendSelfCheckNotification,
|
|
980
|
-
).
|
|
981
|
-
expect(
|
|
982
|
-
mockCandidateStateRepository.saveAnnouncedRunningSubAgentLabels,
|
|
983
|
-
).not.toHaveBeenCalled();
|
|
930
|
+
).toHaveBeenCalledTimes(1);
|
|
984
931
|
});
|
|
985
932
|
});
|
|
986
933
|
|
|
@@ -60,8 +60,6 @@ export type HubTaskStatusResolver = Pick<IssueRepository, 'getIssueByUrl'>;
|
|
|
60
60
|
type NotifyCandidate = {
|
|
61
61
|
sessionName: string;
|
|
62
62
|
message: string;
|
|
63
|
-
newlyAnnouncedRunningLabels: string[];
|
|
64
|
-
retainedAnnouncedRunningLabels: string[];
|
|
65
63
|
};
|
|
66
64
|
|
|
67
65
|
export class NotifySilentLiveSessionsUseCase {
|
|
@@ -144,7 +142,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
144
142
|
|
|
145
143
|
const candidates: NotifyCandidate[] = [];
|
|
146
144
|
for (const sessionSnapshot of snapshots) {
|
|
147
|
-
const candidate =
|
|
145
|
+
const candidate = this.composeCandidate(sessionSnapshot, params);
|
|
148
146
|
if (candidate !== null) {
|
|
149
147
|
candidates.push(candidate);
|
|
150
148
|
}
|
|
@@ -198,16 +196,6 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
198
196
|
);
|
|
199
197
|
sentCount += 1;
|
|
200
198
|
console.log(`Notified ${candidate.sessionName}.`);
|
|
201
|
-
if (candidate.newlyAnnouncedRunningLabels.length > 0) {
|
|
202
|
-
await this.candidateStateRepository.saveAnnouncedRunningSubAgentLabels({
|
|
203
|
-
sessionName: candidate.sessionName,
|
|
204
|
-
labels: [
|
|
205
|
-
...candidate.retainedAnnouncedRunningLabels,
|
|
206
|
-
...candidate.newlyAnnouncedRunningLabels,
|
|
207
|
-
],
|
|
208
|
-
now: params.now,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
199
|
}
|
|
212
200
|
};
|
|
213
201
|
|
|
@@ -381,7 +369,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
381
369
|
});
|
|
382
370
|
};
|
|
383
371
|
|
|
384
|
-
private composeCandidate =
|
|
372
|
+
private composeCandidate = (
|
|
385
373
|
snapshot: LiveSessionActivitySnapshot,
|
|
386
374
|
thresholds: {
|
|
387
375
|
mainSilentThresholdSeconds: number;
|
|
@@ -390,7 +378,7 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
390
378
|
subAgentRunningThresholdSeconds: number;
|
|
391
379
|
now: Date;
|
|
392
380
|
},
|
|
393
|
-
):
|
|
381
|
+
): NotifyCandidate | null => {
|
|
394
382
|
const sections: string[] = [];
|
|
395
383
|
|
|
396
384
|
const mainSilentSeconds = snapshot.mainSilentSeconds;
|
|
@@ -421,20 +409,24 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
421
409
|
!subAgent.waitingOnExternalProcess &&
|
|
422
410
|
subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
|
|
423
411
|
);
|
|
412
|
+
// The long-running advisory is gated on output recency, mirroring the
|
|
413
|
+
// idle branch: a sub-agent that produced output recently is working, no
|
|
414
|
+
// matter how long it has been running, so it is never selected. Only a
|
|
415
|
+
// sub-agent that is BOTH long-running and quiet (and not waiting on a
|
|
416
|
+
// live external process) qualifies, and it is re-selected on EVERY cycle
|
|
417
|
+
// while the condition holds — there is intentionally no fire-once state
|
|
418
|
+
// and no time-window suppression, matching the idle-branch semantics.
|
|
424
419
|
const longRunningSubAgents = snapshot.subAgents.filter(
|
|
425
420
|
(subAgent) =>
|
|
426
|
-
subAgent.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
await this.reconcileAnnouncedRunningLabels(snapshot, thresholds.now);
|
|
430
|
-
const newlyLongRunningSubAgents = longRunningSubAgents.filter(
|
|
431
|
-
(subAgent) => !retainedAnnouncedRunningLabels.includes(subAgent.label),
|
|
421
|
+
!subAgent.waitingOnExternalProcess &&
|
|
422
|
+
subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds &&
|
|
423
|
+
subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
|
|
432
424
|
);
|
|
433
|
-
if (idleSubAgents.length > 0 ||
|
|
425
|
+
if (idleSubAgents.length > 0 || longRunningSubAgents.length > 0) {
|
|
434
426
|
sections.push(
|
|
435
427
|
this.messageComposer.composeSubAgentSection({
|
|
436
428
|
idleSubAgents,
|
|
437
|
-
longRunningSubAgents
|
|
429
|
+
longRunningSubAgents,
|
|
438
430
|
}),
|
|
439
431
|
);
|
|
440
432
|
}
|
|
@@ -445,34 +437,6 @@ export class NotifySilentLiveSessionsUseCase {
|
|
|
445
437
|
return {
|
|
446
438
|
sessionName: snapshot.sessionName,
|
|
447
439
|
message: sections.join('\n\n'),
|
|
448
|
-
newlyAnnouncedRunningLabels: newlyLongRunningSubAgents.map(
|
|
449
|
-
(subAgent) => subAgent.label,
|
|
450
|
-
),
|
|
451
|
-
retainedAnnouncedRunningLabels,
|
|
452
440
|
};
|
|
453
441
|
};
|
|
454
|
-
|
|
455
|
-
private reconcileAnnouncedRunningLabels = async (
|
|
456
|
-
snapshot: LiveSessionActivitySnapshot,
|
|
457
|
-
now: Date,
|
|
458
|
-
): Promise<string[]> => {
|
|
459
|
-
const announcedLabels =
|
|
460
|
-
await this.candidateStateRepository.loadAnnouncedRunningSubAgentLabels({
|
|
461
|
-
sessionName: snapshot.sessionName,
|
|
462
|
-
});
|
|
463
|
-
const currentLabels = new Set(
|
|
464
|
-
snapshot.subAgents.map((subAgent) => subAgent.label),
|
|
465
|
-
);
|
|
466
|
-
const retainedLabels = Array.from(announcedLabels).filter((label) =>
|
|
467
|
-
currentLabels.has(label),
|
|
468
|
-
);
|
|
469
|
-
if (retainedLabels.length !== announcedLabels.size) {
|
|
470
|
-
await this.candidateStateRepository.saveAnnouncedRunningSubAgentLabels({
|
|
471
|
-
sessionName: snapshot.sessionName,
|
|
472
|
-
labels: retainedLabels,
|
|
473
|
-
now,
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
return retainedLabels;
|
|
477
|
-
};
|
|
478
442
|
}
|