@@ -468,16 +468,64 @@ describe("stuck session diagnostics threshold", () => {
|
468 | 468 | ); |
469 | 469 | logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); |
470 | 470 | vi.advanceTimersByTime(91_000); |
| 471 | +// One warning emitted (60s); the 90s tick is throttled but still recovers. |
471 | 472 | expect(events).toHaveLength(1); |
472 | | -expect(recoverStuckSession).toHaveBeenCalledTimes(1); |
| 473 | +expect(recoverStuckSession).toHaveBeenCalledTimes(2); |
473 | 474 | |
474 | 475 | vi.advanceTimersByTime(31_000); |
475 | 476 | } finally { |
476 | 477 | unsubscribe(); |
477 | 478 | } |
478 | 479 | |
479 | 480 | expect(events.map((event) => event.ageMs)).toEqual([60_000, 120_000]); |
| 481 | +// Recovery is requested on every heartbeat tick the session stays stuck, |
| 482 | +// including the throttled tick at 90s, so it must outpace the warn backoff. |
| 483 | +expect(recoverStuckSession).toHaveBeenCalledTimes(3); |
| 484 | +}); |
| 485 | + |
| 486 | +it("keeps scheduling recovery for a recovery-eligible stuck session while warnings are throttled", () => { |
| 487 | +const stuckEvents: Array<{ ageMs?: number }> = []; |
| 488 | +const recoveryRequests: Array<{ ageMs?: number }> = []; |
| 489 | +const recoverStuckSession = vi.fn(); |
| 490 | +const unsubscribe = onDiagnosticEvent((event) => { |
| 491 | +if (event.type === "session.stuck") { |
| 492 | +stuckEvents.push(event); |
| 493 | +} else if (event.type === "session.recovery.requested") { |
| 494 | +recoveryRequests.push(event); |
| 495 | +} |
| 496 | +}); |
| 497 | +try { |
| 498 | +startDiagnosticHeartbeat( |
| 499 | +{ |
| 500 | +diagnostics: { |
| 501 | +enabled: true, |
| 502 | +stuckSessionWarnMs: 30_000, |
| 503 | +}, |
| 504 | +}, |
| 505 | +{ recoverStuckSession }, |
| 506 | +); |
| 507 | +logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); |
| 508 | + |
| 509 | +// First warn tick (60s): emit the stuck warning and request recovery once. |
| 510 | +vi.advanceTimersByTime(61_000); |
| 511 | +expect(stuckEvents).toHaveLength(1); |
| 512 | +expect(recoverStuckSession).toHaveBeenCalledTimes(1); |
| 513 | + |
| 514 | +// Backoff tick (90s): the next warn age is 120s, so the warning is |
| 515 | +// throttled. Recovery must still be scheduled because the session is |
| 516 | +// recovery-eligible — the warning backoff must not gate recovery. |
| 517 | +vi.advanceTimersByTime(30_000); |
| 518 | +} finally { |
| 519 | +unsubscribe(); |
| 520 | +} |
| 521 | + |
| 522 | +// Warning stays throttled: still only the single 60s warning. |
| 523 | +expect(stuckEvents).toHaveLength(1); |
| 524 | +expect(stuckEvents.map((event) => event.ageMs)).toEqual([60_000]); |
| 525 | +// Recovery was not suppressed by the warning backoff on the 90s tick. |
480 | 526 | expect(recoverStuckSession).toHaveBeenCalledTimes(2); |
| 527 | +expect(recoveryRequests).toHaveLength(2); |
| 528 | +expect(recoveryRequests.map((event) => event.ageMs)).toEqual([60_000, 90_000]); |
481 | 529 | }); |
482 | 530 | |
483 | 531 | it("reports active sessions as stalled instead of stuck when active work stops progressing", () => { |
|