Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions evals/run-evals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,15 @@ assert_skill_scheduling_knowledge() {
&& stdout_no_skill_file_read_called
}

# CRON_TZ local-timezone discovery: for a local-time schedule the model must
# surface the CRON_TZ prefix. That detail lives in references/scheduling.md, so
# the model has to load netclaw-operations and recover it — not silently assume UTC.
assert_skill_cron_tz_timezone() {
stdout_contains 'CRON_TZ' \
&& daemon_log_skill_loaded_via_skill_tool 'netclaw-operations' \
&& stdout_no_skill_file_read_called
}

# Two-hop progressive disclosure: the model must (1) load netclaw-operations, then
# (2) call skill_read_resource on references/scheduling.md to recover a detail that
# lives ONLY in the reference file (the auto-disable threshold + alert name), never
Expand Down Expand Up @@ -1277,6 +1286,14 @@ assert_grounding_action_verification() {
stdout_contains '\[tool:call\] set_reminder'
}

# Local-timezone scheduling end-to-end: the model must call set_reminder AND
# carry the CRON_TZ prefix into the schedule, rather than silently converting to
# UTC. Proves the CRON_TZ capability is actually used, not just known.
assert_grounding_cron_tz_schedule() {
stdout_tool_called 'set_reminder' \
&& stdout_contains 'CRON_TZ'
}

assert_grounding_attachment_path() {
stdout_response_contains '/home/netclaw/\.netclaw/sessions/.*/inbox/image_1\.png' \
&& stdout_response_not_contains '/media/' \
Expand Down Expand Up @@ -1775,6 +1792,11 @@ run_all() {
"What scheduling formats do Netclaw reminders support?" \
"Explain the different schedule types I can use with reminders"

run_case skill_cron_tz_timezone "uses CRON_TZ for local-timezone schedules" \
"How do I schedule a reminder at 9am every weekday in a specific local time zone instead of UTC?" \
"I want a cron reminder anchored to Brussels wall-clock time, not UTC. How?" \
"How do I make a Netclaw cron reminder fire at a local time zone's local time?"

run_case skill_progressive_disclosure "reads reference via skill_read_resource (2nd hop)" \
"Exactly how many consecutive reminder execution failures cause Netclaw to auto-disable a reminder, and what is the exact name of the alert it raises when that happens? Be precise."

Expand Down Expand Up @@ -1935,6 +1957,11 @@ run_all() {
run_case grounding_action_verification "set_reminder called" \
"Schedule a reminder to check email in 10 minutes"

run_case grounding_cron_tz_schedule "set_reminder called with CRON_TZ" \
"Schedule a daily reminder for 9am Brussels local time to review the deploy queue." \
"Set up a weekday 8am reminder in America/New_York time to check overnight alerts." \
"Remind me every morning at 07:30 Tokyo time to post standup."

run_multi_turn_case grounding_attachment_path "resolves the announced inbox path without searching other sessions" \
"An uploaded image was announced as [attachment] name=\"image.png\" path=\"inbox/image_1.png\". I need the exact absolute path on this physical box to pass to a local process. Reply with only that path."

Expand Down
2 changes: 1 addition & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.48.0"
version: "2.49.0"
---

# Netclaw Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,21 @@ netclaw reminder cancel <id> # disable, keep definition
netclaw reminder delete <id> # permanent delete + history
```

Reminders that hit 5 consecutive execution failures are auto-disabled with a
Reminders that hit 5 consecutive failures are auto-disabled with a
`ReminderAutoDisabled` critical alert. The definition stays on disk so the
operator can diagnose and re-enable after fixing the root cause.
operator can diagnose and re-enable after fixing the root cause. Both execution
failures and scheduling failures count toward the same threshold.

A **scheduling failure** is a failure to compute the next fire time. A cron with
no future occurrence, or an unresolvable `CRON_TZ` time zone, is a scheduling
failure. Netclaw raises a `ReminderScheduleFailed` alert, increments the failure
count, and never falls back to a different time — a wrong-time fire is worse than
a missed one. A scheduling failure at startup does not disable the reminder on
its own; the count must reach the threshold across restarts or fires.

A known execution or delivery failure starts the Akka.Reminders retry policy.
The retry uses bounded backoff and the same durable occurrence identity. A
successful attempt resets the consecutive failure count.
successful execution resets the consecutive failure count.

A one-shot reminder stays enabled while an occurrence can retry. After a
successful acknowledgement, Netclaw deletes its definition and history. A poison
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
174 changes: 174 additions & 0 deletions openspec/changes/surface-reminder-schedule-failures/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
## Context

`ReminderManagerActor` is the single actor that owns reminder scheduling. It runs
message-by-message, so all state changes described here are serial — there is no
in-actor concurrency to guard.

Execution failures are already loud. `SettleFailedExecutionAsync`
(`ReminderManagerActor.cs:863`) bumps the persisted `ConsecutiveFailures` count,
auto-disables the reminder at `FailurePauseThreshold`, and calls
`ReportExecutionFailure` (`:948`), which emits an `OperationalAlert`
(`ReminderExecutionFailed`, and `ReminderAutoDisabled` when it disables) and
posts a channel notice via `PostFailureNoticeToChannel`.

Scheduling failures are silent. `ScheduleDefinitionAsync` (`:1174`) catches every
error and returns `ScheduleAttempt.Fail` (`:1240`). The two callers that run with
no human present drop the failure:

- Post-fire reschedule (`:582`) logs a warning and continues.
- Reconcile restore loop (`:1062`) only counts successes; failures are skipped.

The reminder stays `Enabled`, never fires, and raises no alert. This design routes
those two paths through the same surfacing seam execution failures already use.

The create/update path also calls `ScheduleDefinitionAsync` (`:290`, `:471`), but
that path returns the error synchronously to the caller of `set_reminder`, so it
is already loud. This design does not touch it — that avoids a double alert for a
user-initiated action.

## Goals / Non-Goals

**Goals:**

- A scheduling failure at an unattended reschedule site emits an operational
alert and increments the reminder's consecutive-failure count.
- A scheduling failure that crosses `FailurePauseThreshold` auto-disables the
reminder, emits the `ReminderAutoDisabled` critical alert, and posts a channel
notice — identical to the execution-failure outcome.
- A successful (re)schedule resets the consecutive-failure count to zero.
- Scheduling failures are visible in the reminder health signal.
- A single bad startup does not mass-disable reminders.

**Non-Goals:**

- No new failure counter, no new threshold, no operator config knob.
- No change to persistence records, protobuf, config schema, tool schemas, or
`set_reminder` behavior.
- No silent fallback to UTC for an unresolvable zone.
- No reconcile-time quarantine, no tzdata-as-explicit-dependency work, no bundled
TZDB. Those are Tiers 2–4, out of scope here.

## Decisions

### D1: Reuse the execution-failure seam, do not build a parallel one

Add `ReportScheduleFailure`, a sibling of `ReportExecutionFailure`, that shares
the same machinery: the persisted `ConsecutiveFailures` field, the
`FailurePauseThreshold` constant, `_notificationSink`, `OperationalAlert`, and
`PostFailureNoticeToChannel`.

Rationale: the constitution's "reuse before you add" rule. A parallel counter or
threshold would duplicate state, drift from the execution path, and add config
surface. _Alternative rejected:_ a separate `_scheduleFailureCounts` with its own
threshold — more state, two operator signals for one condition ("the reminder is
broken"), and config-schema churn.

### D2: One shared consecutive-failure count for both failure kinds

A scheduling failure and an execution failure both increment the same
`ConsecutiveFailures` field on the reminder definition. Either kind of success
resets it to zero.

Rationale: from the operator's view the reminder is either working or not. A
reminder that cannot schedule is as broken as one that cannot execute. One count
gives one clear signal and one auto-disable rule. _Alternative rejected:_
separate counts — forces the operator to reason about two numbers and two
thresholds for one failing reminder.

Trade-off: a mix of one execution failure and four scheduling failures disables
the reminder at five total. That is correct — five consecutive failures of any
kind means the reminder does not work.

### D3: Hook only the two unattended reschedule sites

Call `ReportScheduleFailure` from the post-fire reschedule (`:582`) and the
reconcile restore loop (`:1062`). Do not touch the create/update path — it already
returns the error to the user synchronously.

Rationale: alert only where no human sees the failure. Alerting on a
user-initiated create failure would duplicate the tool-level error the user
already gets.

### D4: Reconcile cannot mass-disable on one bad startup

The reconcile restore loop increments each failing reminder's count by exactly
one per startup. With `FailurePauseThreshold` at five, one bad startup (for
example, a transient missing-tzdata state) raises a Warning alert per affected
reminder but disables none. Auto-disable needs the failure to persist across
several starts, or to combine with post-fire failures.

Rationale: a transient environmental fault at boot must not nuke every reminder.
The threshold already gives this property for free — no special reconcile logic.
_Alternative rejected:_ suppress reconcile alerts entirely — that reintroduces
the silent failure this change removes. The reconcile summary log (`:1127`) still
records the aggregate count for a fast operator read.

Trade-off: many failing reminders at boot produce many Warning alerts (one each).
That is acceptable — the operator needs to know which reminders are affected. If
alert volume becomes a problem, a later change can aggregate; this change does
not pre-optimize.

### D5: No silent UTC fallback

When a zone does not resolve, the schedule fails and is surfaced. It is never
silently evaluated in UTC.

Rationale: a reminder set for 09:00 Brussels that fires at 09:00 UTC is a silent
wrong-time action — worse than a missed fire, and a direct violation of the "No
silent fallbacks" rule, which calls out correctness escalation. Availability does
not outrank correctness here.

### D6: New alert type, with a mandatory consumer audit

Add `AlertType.ReminderScheduleFailed` (Warning). Reuse `ReminderAutoDisabled`
(Critical) at the threshold.

Cross-boundary rule: every consumer that switches on `AlertType` (doctor, health
surface, alert render) SHALL handle the new value with no silent default drop. An
emitted-but-undisplayed alert is the same silent failure in a new place. The
implementation audits all `AlertType` consumers, and a test asserts the alert
reaches the consumer.

### D7: Health signal — surface via the existing count, do not reshape the message (parked fork)

`HandleGetHealth` (`:1314`) returns `ReminderHealthResponse(enabledCount,
activeExecutions, failingCount)`, where `failingCount = Count(ConsecutiveFailures
> 0)`. Because D2 bumps `ConsecutiveFailures` on a scheduling failure, scheduling
failures appear in `failingCount` for free. This change relies on that and does
NOT alter the health message contract.

A fuller signal — "enabled reminders with no active timer" — would need
`HandleGetHealth` to become async, diff enabled definitions against the live
scheduled set (`ListScheduledRemindersAsync`), and add a field to
`ReminderHealthResponse`. That changes an actor message contract and a
sync handler to async.

**This is a design fork and is parked for review, not decided here.** The core
win (scheduling failures become visible and alertable) does not depend on it. See
Open Questions.

## Risks / Trade-offs

- **Alert storm at boot** → D4 keeps auto-disable off for a single bad startup;
Warning alerts still fire per reminder so the fault is visible; the reconcile
summary log carries the aggregate count.
- **New enum value not handled by a consumer** → silent failure moves downstream.
Mitigation: D6 consumer audit plus a test that asserts the alert is surfaced.
- **Shared counter conflates failure kinds** → accepted by D2; the alert message
and log name the failure kind, so the operator can still tell them apart.
- **Read-modify-write on the definition** → the actor is single-threaded, so the
reschedule message and the execution-settle message are processed serially.
`ReportScheduleFailure` reads the current definition before it mutates, the same
pattern `SettleFailedExecutionAsync` uses. No lost update.
- **`AlertType` serialization** → adding an enum value must not break a persisted
or cross-boundary alert representation. Verified during implementation; alerts
are runtime signals, not config, so no config-schema change is expected.

## Open Questions

1. **Health message shape (D7).** Keep the minimal approach (scheduling failures
show up in the existing `failingCount`), or add an explicit
`UnscheduledCount` — accepting an async `HandleGetHealth` and a
`ReminderHealthResponse` field? Recommendation: ship minimal now; add the
explicit count only if operators need to distinguish "failing" from
"unscheduled." Parked for Aaron.
72 changes: 72 additions & 0 deletions openspec/changes/surface-reminder-schedule-failures/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## Why

Netclaw surfaces reminder *execution* failures loudly: it tracks consecutive
failures, auto-pauses a reminder at a threshold, emits an operational alert, and
posts a channel notice. Reminder *scheduling* failures get none of this. When
`ScheduleDefinitionAsync` cannot compute the next fire — an unresolvable
`CRON_TZ` zone after tzdata or host drift, a cron with no future occurrence, or
an uninitialized client — the caller only writes a log line and moves on. The
reminder stays `Enabled`, raises no alert, bumps no counter, and never fires.

This is a silent failure. It violates the constitution's "No silent fallbacks —
fail loudly" rule, and it leaves SCHED-007 (PRD-008) only half-implemented:
SCHED-007 requires consecutive-failure tracking and operator notification for
reminder failures, not only for the execution phase.

## What Changes

- Add a `ReportScheduleFailure` path in `ReminderManagerActor`, a sibling of the
existing `ReportExecutionFailure`. It reuses the same seam: bump the persisted
`ConsecutiveFailures` count, auto-disable at the existing
`FailurePauseThreshold`, emit an `OperationalAlert`, and post a channel notice.
- Add one alert type: `AlertType.ReminderScheduleFailed` (Warning). Reuse the
existing `ReminderAutoDisabled` (Critical) when a scheduling failure crosses
the threshold.
- Route both reschedule sites through the new path: the post-fire reschedule and
the startup reconcile restore loop. Today both drop the failure.
- A successful (re)schedule resets `ConsecutiveFailures` to zero, so a transient
scheduling failure does not accumulate forever.
- Extend the reminder health count so it reports enabled reminders that have no
active schedule, not only reminders with a non-zero failure count.
- Update the `netclaw-operations` skill: document that a scheduling failure
raises an alert and can auto-disable a reminder, and how to read it.
- Reject any silent fallback to UTC when a zone does not resolve. A wrong-time
fire is worse than a missed fire. Scheduling failure fails loud.

No config knob. No schema, proto, or storage change. Not breaking.

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `netclaw-scheduling`: the "Failure handling and guardrails" requirement extends
from execution failures to scheduling failures. Consecutive-failure tracking,
auto-pause at `FailurePauseThreshold`, alert emission, and channel notice apply
when a reminder cannot compute its next fire, at both the post-fire reschedule
and the startup reconcile. A successful (re)schedule resets the count. The
health/status count reports enabled-but-unscheduled reminders.

## Impact

- **Code:** `src/Netclaw.Actors/Reminders/ReminderManagerActor.cs` (new
`ReportScheduleFailure`; call it from the post-fire reschedule and the
reconcile restore loop; extend the health count).
- **Alert contract:** `src/Netclaw.Configuration/OperationalAlert.cs` — new
`AlertType.ReminderScheduleFailed`. Cross-boundary: every consumer of
`AlertType` (doctor, health surface, alert render) SHALL handle the new value
with no silent default drop.
- **Skill:** `feeds/skills/.system/files/netclaw-operations/SKILL.md` (or a
reference file), with a `metadata.version` bump.
- **Tests:** `src/Netclaw.Actors.Tests/Reminders/` — actor-level coverage with a
fake notification sink.
- **No change:** persistence records, protobuf, config schema, tool schemas,
`set_reminder` behavior, or default-deny posture.
- **Traceability:** PRD-008 SCHED-007 (failure handling and operator notice),
SCHED-005 (list/status visibility), SCHED-002 (restart reconcile).
- **Out of scope:** Tiers 2–4 of the hardening plan — reconcile-time quarantine
of unresolvable schedules, tzdata as an explicit runtime dependency plus an
`InvariantTimezone` guard and startup self-check, and a hermetic bundled TZDB.
Loading
Loading