Skip to content

Commit bc065b7

Browse files
committed
Add recurring input-required alerts with incident reset
1 parent 0ce3ac8 commit bc065b7

2 files changed

Lines changed: 122 additions & 15 deletions

File tree

cmd/ralphctl/telegram_command.go

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,8 @@ func newScopedStatusNotifyHandler(controlDir string, paths ralph.Paths, scope st
11321132
}
11331133
}
11341134

1135+
const telegramInputRequiredReminderInterval = 30 * time.Minute
1136+
11351137
func hasFleetProjects(controlDir string) (bool, error) {
11361138
cfg, err := ralph.LoadFleetConfig(controlDir)
11371139
if err != nil {
@@ -1143,6 +1145,7 @@ func hasFleetProjects(controlDir string) (bool, error) {
11431145
func newFleetStatusNotifyHandler(controlDir string, defaultPaths ralph.Paths, retryThreshold, permThreshold int) ralph.TelegramNotifyHandler {
11441146
initialized := false
11451147
prevByProject := map[string]ralph.Status{}
1148+
lastInputRequiredAlertAt := map[string]time.Time{}
11461149
return func(ctx context.Context) ([]string, error) {
11471150
_ = ctx
11481151

@@ -1189,14 +1192,24 @@ func newFleetStatusNotifyHandler(controlDir string, defaultPaths ralph.Paths, re
11891192
if !initialized {
11901193
continue
11911194
}
1192-
prev, ok := prevByProject[target.ID]
1193-
if !ok {
1194-
continue
1195-
}
1195+
prev := prevByProject[target.ID]
11961196
alerts = append(alerts, buildStatusAlerts(prev, current, retryThreshold, permThreshold)...)
1197+
now := time.Now().UTC()
1198+
lastAt := lastInputRequiredAlertAt[target.ID]
1199+
if shouldSendInputRequiredAlert(prev, current, lastAt, now) {
1200+
alerts = append(alerts, buildInputRequiredAlert(current.ProjectDir))
1201+
lastInputRequiredAlertAt[target.ID] = now
1202+
} else if !ralph.IsInputRequiredStatus(current) {
1203+
delete(lastInputRequiredAlertAt, target.ID)
1204+
}
11971205
}
11981206

11991207
prevByProject = currByProject
1208+
for projectID := range lastInputRequiredAlertAt {
1209+
if _, ok := currByProject[projectID]; !ok {
1210+
delete(lastInputRequiredAlertAt, projectID)
1211+
}
1212+
}
12001213
if !initialized {
12011214
initialized = true
12021215
return nil, nil
@@ -1208,6 +1221,7 @@ func newFleetStatusNotifyHandler(controlDir string, defaultPaths ralph.Paths, re
12081221
func newStatusNotifyHandler(paths ralph.Paths, retryThreshold, permThreshold int) ralph.TelegramNotifyHandler {
12091222
initialized := false
12101223
prev := ralph.Status{}
1224+
lastInputRequiredAlertAt := time.Time{}
12111225
return func(ctx context.Context) ([]string, error) {
12121226
_ = ctx
12131227
current, err := ralph.GetStatus(paths)
@@ -1220,6 +1234,13 @@ func newStatusNotifyHandler(paths ralph.Paths, retryThreshold, permThreshold int
12201234
return nil, nil
12211235
}
12221236
alerts := buildStatusAlerts(prev, current, retryThreshold, permThreshold)
1237+
now := time.Now().UTC()
1238+
if shouldSendInputRequiredAlert(prev, current, lastInputRequiredAlertAt, now) {
1239+
alerts = append(alerts, buildInputRequiredAlert(current.ProjectDir))
1240+
lastInputRequiredAlertAt = now
1241+
} else if !ralph.IsInputRequiredStatus(current) {
1242+
lastInputRequiredAlertAt = time.Time{}
1243+
}
12231244
prev = current
12241245
return alerts, nil
12251246
}
@@ -1280,16 +1301,34 @@ func buildStatusAlerts(prev, current ralph.Status, retryThreshold, permThreshold
12801301
valueOrDash(compactSingleLine(current.LastFailureCause, 160)),
12811302
))
12821303
}
1283-
if ralph.IsInputRequiredStatus(current) && !ralph.IsInputRequiredStatus(prev) {
1284-
out = append(out, fmt.Sprintf(
1285-
"[ralph alert][input_required]\n- project: %s\n- message: no queued work. add issue (`./ralph new ...`) or run PRD wizard (`/prd start -> /prd refine -> /prd apply`)",
1286-
project,
1287-
))
1288-
}
12891304

12901305
return out
12911306
}
12921307

1308+
func shouldSendInputRequiredAlert(prev, current ralph.Status, lastSentAt, now time.Time) bool {
1309+
if !ralph.IsInputRequiredStatus(current) {
1310+
return false
1311+
}
1312+
if !ralph.IsInputRequiredStatus(prev) {
1313+
return true
1314+
}
1315+
if lastSentAt.IsZero() {
1316+
return true
1317+
}
1318+
return now.Sub(lastSentAt) >= telegramInputRequiredReminderInterval
1319+
}
1320+
1321+
func buildInputRequiredAlert(project string) string {
1322+
p := strings.TrimSpace(project)
1323+
if p == "" {
1324+
p = "(unknown-project)"
1325+
}
1326+
return fmt.Sprintf(
1327+
"[ralph alert][input_required]\n- project: %s\n- message: no queued work. add issue (`./ralph new ...`) or run PRD wizard (`/prd start -> /prd refine -> /prd apply`)",
1328+
p,
1329+
)
1330+
}
1331+
12931332
func startTelegramDaemon(paths ralph.Paths, runArgs []string) (string, error) {
12941333
if err := ralph.EnsureLayout(paths); err != nil {
12951334
return "", err

cmd/ralphctl/telegram_command_test.go

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ func TestBuildStatusAlertsSkipsStuckWhenNoWork(t *testing.T) {
295295
}
296296
}
297297

298-
func TestBuildStatusAlertsInputRequiredTransition(t *testing.T) {
298+
func TestShouldSendInputRequiredAlertOnTransition(t *testing.T) {
299299
t.Parallel()
300300

301301
prev := ralph.Status{
@@ -311,10 +311,78 @@ func TestBuildStatusAlertsInputRequiredTransition(t *testing.T) {
311311
Blocked: 0,
312312
}
313313

314-
alerts := buildStatusAlerts(prev, curr, 2, 3)
315-
joined := strings.Join(alerts, "\n")
316-
if !strings.Contains(joined, "[input_required]") {
317-
t.Fatalf("input_required alert should be emitted on transition: %q", joined)
314+
if !shouldSendInputRequiredAlert(prev, curr, time.Time{}, time.Now().UTC()) {
315+
t.Fatalf("input_required alert should be emitted on transition")
316+
}
317+
}
318+
319+
func TestShouldSendInputRequiredAlertReminderInterval(t *testing.T) {
320+
t.Parallel()
321+
322+
prev := ralph.Status{
323+
ProjectDir: "/tmp/p",
324+
QueueReady: 0,
325+
InProgress: 0,
326+
Blocked: 0,
327+
}
328+
curr := prev
329+
now := time.Now().UTC()
330+
331+
if shouldSendInputRequiredAlert(prev, curr, now.Add(-5*time.Minute), now) {
332+
t.Fatalf("input_required reminder should be suppressed before interval")
333+
}
334+
if !shouldSendInputRequiredAlert(prev, curr, now.Add(-telegramInputRequiredReminderInterval-time.Minute), now) {
335+
t.Fatalf("input_required reminder should be emitted after interval")
336+
}
337+
}
338+
339+
func TestShouldSendInputRequiredAlertReentrySendsOncePerIncident(t *testing.T) {
340+
t.Parallel()
341+
342+
working := ralph.Status{
343+
ProjectDir: "/tmp/p",
344+
QueueReady: 1,
345+
InProgress: 0,
346+
Blocked: 0,
347+
}
348+
idle := ralph.Status{
349+
ProjectDir: "/tmp/p",
350+
QueueReady: 0,
351+
InProgress: 0,
352+
Blocked: 0,
353+
}
354+
now := time.Now().UTC()
355+
356+
// Incident #1: working -> idle should alert immediately.
357+
if !shouldSendInputRequiredAlert(working, idle, time.Time{}, now) {
358+
t.Fatalf("first incident should emit input_required alert")
359+
}
360+
361+
// While still idle, suppress until reminder interval.
362+
if shouldSendInputRequiredAlert(idle, idle, now, now.Add(5*time.Minute)) {
363+
t.Fatalf("same incident should not emit again before reminder interval")
364+
}
365+
366+
// Recovered: idle -> working should not emit.
367+
if shouldSendInputRequiredAlert(idle, working, now, now.Add(10*time.Minute)) {
368+
t.Fatalf("recovery should not emit input_required alert")
369+
}
370+
371+
// Incident #2: working -> idle should emit again immediately, even if last alert was recent.
372+
if !shouldSendInputRequiredAlert(working, idle, now, now.Add(11*time.Minute)) {
373+
t.Fatalf("reentry incident should emit input_required alert immediately")
374+
}
375+
}
376+
377+
func TestBuildInputRequiredAlert(t *testing.T) {
378+
t.Parallel()
379+
380+
msg := buildInputRequiredAlert("/tmp/project")
381+
if !strings.Contains(msg, "[input_required]") {
382+
t.Fatalf("alert tag missing: %q", msg)
383+
}
384+
if !strings.Contains(msg, "/tmp/project") {
385+
t.Fatalf("project path missing: %q", msg)
318386
}
319387
}
320388

0 commit comments

Comments
 (0)