Skip to content
Draft
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
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.58.0"
version: "2.59.0"
---

# Netclaw Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ 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 execution resets the consecutive failure count.

A `current_session` reminder requires a session with no active turn. The session
defers the reminder during an active turn, a restart drain, or pipeline outage.
A supported gateway also defers the reminder when startup has not registered it.
Netclaw sends an Akka.Reminders negative acknowledgement for each deferral.
Each negative acknowledgement consumes one delivery attempt and applies durable
backoff. A scheduled retry does not increase the Netclaw failure count or create
failure history. Retry exhaustion creates one terminal failure and disables the
reminder. An unsupported origin channel is a permanent delivery failure.

A one-shot reminder stays enabled while an occurrence can retry. After a
successful acknowledgement, Netclaw deletes its definition and history. A poison
one-shot becomes disabled with a `Failed` outcome. Its definition and history
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-18
78 changes: 78 additions & 0 deletions openspec/changes/defer-busy-current-session-reminders/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
## Context

CurrentSession reminders reuse the origin session and its channel binding. Each binding writes trusted reminder input into the same session queue as user input.

The session accepts queued input during an active turn. Its tool-loop drain then copies only message content into the active turn and discards reminder identity.

The channel binding registers a delivery observer before queue admission. The observer waits for a `TurnCompleted.SourceReminderId` that the active human turn cannot produce.

Akka.Reminders 0.7.0 has no separate defer API. Its `NackAsync` contract schedules durable retry with bounded exponential backoff and consumes one delivery attempt.

## Goals / Non-Goals

**Goals:**

- Reject CurrentSession reminder admission while the target session has an active turn.
- Use Akka.Reminders for durable delay and retry ownership.
- Keep transient deferrals out of Netclaw failure history, alerts, and poison counts.
- Convert retry-budget exhaustion into one real reminder failure.
- Apply one contract to every supported CurrentSession channel binding.

**Non-Goals:**

- Change ordinary user-message buffer behavior.
- Change Channel or None reminder execution.
- Add a new Akka.Reminders API.
- Change reminder health-count retention or decay.
- Change reminder trust or approval policy.

## Decisions

### Channel bindings own the admission check

Slack, Discord, and Mattermost bindings already track `_turnInFlight`. They will reject a trusted reminder before observer registration or queue admission.

SignalR will track the same state because it has no equivalent field. Each binding will set the state after queue admission and clear it after turn completion or pipeline reset.

This boundary prevents metadata loss and observer leaks. A session-actor check would occur after the binding registers its delivery observer.

### A typed response distinguishes deferral from rejection

`CommandDeferred(SessionId, Reason)` will extend `ISessionResponse`. `CommandNack` will continue to identify permanent rejection.

The execution actor will map `CommandDeferred` to an internal `ReminderExecutionDeferred`. It will not create a failed history record.

### The manager maps deferral to durable negative acknowledgement

The manager will call `NackAsync` with the original envelope and the deferral reason. `RetryScheduled` will release the active execution without Netclaw failure state.

`Failed` or `Expired` means the scheduler cannot retry. The manager will then record one failed history entry and apply the existing terminal failure policy.

This choice consumes Akka delivery attempts. The existing policy provides ten attempts with backoff from one minute to a ten-minute cap.

### Supported gateway absence is transient

The execution actor will distinguish an unsupported origin from a supported gateway that has not registered. The supported case will use the deferral path.

The next Akka attempt will resolve the registry again. Netclaw will not add a local poll loop or retain a second retry queue.

### Accepted turns keep the delivery contract

After admission, `CommandAck`, `ReminderDeliveryResult`, and the one-hour observation timeout keep their current meaning. Transport delivery failures remain execution failures.

## Risks / Trade-offs

- **A session can stay busy through all ten attempts.** The final result becomes one terminal failure.
- **A queue-write timeout can race with successful admission.** Stable reminder identity lets the later Akka attempt use session deduplication.
- **A binding-local busy flag can become stale after a stream fault.** Every pipeline reset clears the flag and fails registered observers.
- **The terminal deferral result arrives after Akka settles the occurrence.** The manager will report any later local persistence failure as a settlement fault.

## Migration Plan

1. Deploy the additive transient response and manager behavior together.
2. Keep the existing reminder JSON and Akka.Reminders database formats.
3. Roll back by restoring the prior binary. No data migration is necessary.

## Open Questions

None.
36 changes: 36 additions & 0 deletions openspec/changes/defer-busy-current-session-reminders/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

PRD-008 requires reliable reminder retries and useful failure signals. A busy session now accepts a CurrentSession reminder but cannot preserve its turn identity.

The reminder then waits one hour for an observation that cannot occur. A late gateway registration also creates a false permanent failure.

## What Changes

- Add a typed transient deferral response for CurrentSession reminder admission.
- Make each supported session binding defer a reminder before queue admission when its session has an active turn.
- Make the reminder execution actor report transient admission deferrals to the reminder manager.
- Make the manager use the Akka.Reminders negative acknowledgement path for durable backoff.
- Do not write failed history, increment `ConsecutiveFailures`, or emit failure alerts while Akka schedules another attempt.
- Count retry-budget exhaustion as one real reminder failure.
- Treat late registration for a supported origin gateway as a deferral.
- Keep permanent validation errors and accepted-turn delivery failures on the existing failure path.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `netclaw-scheduling`: Define transient CurrentSession admission deferral and its retry and failure-count behavior.

## Impact

- **Source PRDs:** PRD-008.
- **In scope:** CurrentSession delivery through Slack, Discord, Mattermost, SignalR, and TUI session bindings.
- **Out of scope:** Channel delivery, no-delivery reminders, reminder health-count decay, and a new Akka.Reminders deferral API.
- **APIs:** Add one transient session response. No wire or persistence format changes apply.
- **Dependencies:** Continue to use `Aaron.Akka.Reminders` 0.7.0 and its existing `NackAsync` retry contract.
- **Security:** Preserve the current trusted reminder source, audience, boundary, and approval policy.
- **Operations:** Busy sessions cause bounded scheduler backoff. Scheduled deferrals do not appear as Netclaw execution failures.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
## ADDED Requirements

### Requirement: Transient CurrentSession admission deferral

The CurrentSession delivery path SHALL defer a trusted reminder before queue admission when the target session cannot start a distinct turn. Deferral SHALL use the original Akka.Reminders occurrence and SHALL consume one Akka delivery attempt.

#### Scenario: Busy session defers before queue admission

- **GIVEN** a supported CurrentSession binding has an active turn
- **WHEN** the binding receives `DeliverTrustedSessionTurn`
- **THEN** it replies with `CommandDeferred`
- **AND** it does not register a delivery observer
- **AND** it does not write the reminder to the session queue

#### Scenario: Successful admission marks the binding busy

- **GIVEN** a supported CurrentSession binding has no active turn
- **WHEN** it admits a trusted reminder to the session queue
- **THEN** it marks the turn as active before it handles another admission
- **AND** it clears the active state after `TurnCompleted` or pipeline reset

#### Scenario: Supported gateway has not registered

- **GIVEN** a CurrentSession reminder has a supported origin channel type
- **AND** that channel gateway has not registered after daemon startup
- **WHEN** the execution actor resolves the gateway
- **THEN** it reports a transient deferral
- **AND** it does not report an unsupported channel error

#### Scenario: Unsupported origin remains a failure

- **GIVEN** a CurrentSession reminder has an unsupported origin channel type
- **WHEN** the execution actor validates the origin
- **THEN** it reports a permanent execution failure

### Requirement: Deferred occurrence settlement

The reminder manager SHALL settle a transient admission deferral through `IReminderClient.NackAsync`. It SHALL separate an available scheduler retry from a terminal retry result.

#### Scenario: Scheduler accepts the deferral

- **GIVEN** a CurrentSession execution reports a transient deferral
- **WHEN** `NackAsync` returns `RetryScheduled`
- **THEN** the manager releases the active execution
- **AND** it does not append a failed history record
- **AND** it does not increment `ConsecutiveFailures`
- **AND** it does not emit a reminder failure alert
- **AND** reminder status exposes the scheduler's next attempt

#### Scenario: Deferral exhausts the retry budget

- **GIVEN** a CurrentSession execution reports a transient deferral
- **WHEN** `NackAsync` returns `Failed` or `Expired`
- **THEN** the manager records one failed history entry
- **AND** it increments `ConsecutiveFailures` once
- **AND** it applies the existing terminal occurrence policy

#### Scenario: Retry enters an idle session

- **GIVEN** Akka.Reminders retries a deferred CurrentSession occurrence
- **AND** the target session is idle
- **WHEN** the binding admits the reminder
- **THEN** the reminder runs as a distinct turn
- **AND** `TurnCompleted.SourceReminderId` contains the stable occurrence key

#### Scenario: Accepted-turn delivery fails

- **GIVEN** a CurrentSession reminder was admitted as a distinct turn
- **WHEN** its channel reports `ReminderDeliveryResult.Delivered` as false
- **THEN** the manager records the result through the existing failure path

### Requirement: CurrentSession deferral channel parity

Slack, Discord, Mattermost, SignalR, and TUI session delivery SHALL apply the same transient deferral contract.

#### Scenario: Each supported binding rejects concurrent reminder admission

- **GIVEN** any supported CurrentSession channel binding has an active turn
- **WHEN** another CurrentSession reminder targets that binding
- **THEN** the binding replies with `CommandDeferred`
- **AND** the scheduler owns the next attempt
21 changes: 21 additions & 0 deletions openspec/changes/defer-busy-current-session-reminders/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## 1. Admission contract

- [x] 1.1 Add the transient session response and reminder execution outcome.
- [x] 1.2 Defer busy or unavailable CurrentSession admission across all supported bindings.
- [x] 1.3 Distinguish a late supported gateway from an unsupported origin.

## 2. Durable settlement

- [x] 2.1 Map transient deferral to Akka.Reminders `NackAsync` without Netclaw failure state.
- [x] 2.2 Convert retry-budget exhaustion into one terminal reminder failure.

## 3. Automated proof

- [x] 3.1 Add binding contract tests for busy deferral and observer cleanup.
- [x] 3.2 Add reminder manager tests for scheduled deferral and terminal exhaustion.
- [x] 3.3 Add execution actor tests for late gateway registration and unsupported origins.

## 4. Guidance and validation

- [x] 4.1 Update the scheduling operations guidance and increase its skill version.
- [x] 4.2 Run focused tests, affected suites, Slopwatch, header verification, and `git diff --check`.
Original file line number Diff line number Diff line change
Expand Up @@ -323,43 +323,68 @@ public async Task Reminder_delivery_reports_failure_when_post_throws()
ClearReplyClientThrows();
}

// Regression for the observer-clobber bug: two distinct reminders can target
// the same session concurrently. A single observer field is overwritten by
// the second dispatch before the first turn completes, so the first
// reminder's result is misrouted. Each observer must receive ITS OWN keyed
// result.
// A busy session must defer a second reminder before it registers an observer.
// The same occurrence can enter the session after the active turn ends.
[Fact]
public async Task Concurrent_reminders_to_same_session_each_get_their_own_result()
public async Task Busy_session_defers_a_second_reminder()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-reminder-concurrent");
const string keyA = "reminder-A:111";
const string keyB = "reminder-B:222";
var outputRelease = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var pipeline = new RecordingSessionPipeline(_ =>
[
new TextOutput("reply A") { SessionId = sid },
new TurnCompleted { SessionId = sid, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1), SourceReminderId = new ReminderId(keyA) },
new TextOutput("reply B") { SessionId = sid },
new TurnCompleted { SessionId = sid, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(2), SourceReminderId = new ReminderId(keyB) }
], reactive: true);
new TurnCompleted
{
SessionId = sid,
TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1),
SourceReminderId = new ReminderId(keyA)
},
new TurnCompleted
{
SessionId = sid,
TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(2),
SourceReminderId = new ReminderId(keyB)
},
new TextOutput("retry barrier") { SessionId = sid }
], outputRelease.Task);

var observerA = CreateTestProbe();
var observerB = CreateTestProbe();
var secondAttempt = CreateTestProbe();
var binding = CreateBindingActor(sid, pipeline, detector);
await pipeline.Created;

// Both reminders dispatched before either turn completes.
binding.Tell(new DeliverTrustedSessionTurn(sid, "reminder A", CreateReminderSource(keyA, observerA.Ref)));
binding.Tell(new DeliverTrustedSessionTurn(sid, "reminder B", CreateReminderSource(keyB, observerB.Ref)));
binding.Tell(new DeliverTrustedSessionTurn(
sid,
"reminder A",
CreateReminderSource(keyA, observerA.Ref)));
await AwaitAssertAsync(() => Assert.Single(pipeline.CapturedInputs), cancellationToken: ct);

var resultA = await observerA.ExpectMsgAsync<ReminderDeliveryResult>(
secondAttempt.Send(
binding,
new DeliverTrustedSessionTurn(sid, "reminder B", CreateReminderSource(keyB, observerB.Ref)));
var deferred = await secondAttempt.ExpectMsgAsync<CommandDeferred>(
TimeSpan.FromSeconds(5), cancellationToken: ct);
Assert.Equal(new ReminderId(keyA), resultA.ReminderDeliveryKey);
Assert.Equal(sid, deferred.SessionId);
Assert.Contains("active turn", deferred.Reason, StringComparison.OrdinalIgnoreCase);

var resultB = await observerB.ExpectMsgAsync<ReminderDeliveryResult>(
outputRelease.TrySetResult();
var firstResult = await observerA.ExpectMsgAsync<ReminderDeliveryResult>(
TimeSpan.FromSeconds(5), cancellationToken: ct);
Assert.Equal(new ReminderId(keyB), resultB.ReminderDeliveryKey);
Assert.Equal(new ReminderId(keyA), firstResult.ReminderDeliveryKey);
await observerB.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), ct);
await AwaitAssertAsync(() =>
{
Assert.Contains(GetPostedTexts(), text =>
text.Contains("retry barrier", StringComparison.Ordinal));
}, cancellationToken: ct);

var input = Assert.Single(pipeline.CapturedInputs);
Assert.Equal(new ReminderId(keyA), input.ReminderId);
}

// Regression for the misleading-fallback bug: when the real content post
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public sealed class RecordingSessionPipeline : ISessionPipeline
private readonly List<IWithSessionId> _recordedFeedback = [];
private readonly Func<SessionId, IReadOnlyList<SessionOutput>> _outputFactory;
private readonly bool _reactive;
private readonly Task? _outputRelease;
private readonly TaskCompletionSource<SessionPipelineOptions> _created = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private SessionPipelineOptions? _capturedOptions;
Expand Down Expand Up @@ -49,6 +50,15 @@ public RecordingSessionPipeline(
_reactive = reactive;
}

public RecordingSessionPipeline(
Func<SessionId, IReadOnlyList<SessionOutput>> outputFactory,
Task outputRelease)
{
_outputFactory = outputFactory;
_reactive = true;
_outputRelease = outputRelease;
}

public SessionPipelineOptions? CapturedOptions => Volatile.Read(ref _capturedOptions);
public Task<SessionPipelineOptions> Created => _created.Task;
public IReadOnlyList<IWithSessionId> RecordedFeedback
Expand Down Expand Up @@ -105,6 +115,8 @@ public Task<MaterializedSession> CreateAsync(
{
// Wait for the first input to arrive before emitting anything.
await gate.Reader.ReadAsync(cancellationToken);
if (_outputRelease is not null)
await _outputRelease.WaitAsync(cancellationToken);
}

if (state < outputs.Count)
Expand Down
Loading
Loading