Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
91df660
Add the webhook-route-actor-ownership OpenSpec change
Aaronontheweb Aug 19, 2026
1762ae9
Add OpenSpec change for webhook route actor ownership
Aaronontheweb Aug 19, 2026
82457b0
Add WebhookRouteActor as the daemon-side route mutation authority
Aaronontheweb Aug 19, 2026
e4f586f
Route CLI webhook writes through the daemon with a disclosed fallback
Aaronontheweb Aug 19, 2026
e104a6c
Document the webhook management API and sync the authority spec
Aaronontheweb Aug 19, 2026
8669c5a
Mark memory-core-redesign tasks 5.3 and 6.6 as superseded
Aaronontheweb Aug 19, 2026
f80165d
Harden the webhook write path per review findings
Aaronontheweb Aug 19, 2026
d0ee92b
Resolve the daemon log by newest file, not by host date
Aaronontheweb Aug 19, 2026
e7496eb
Verify the eval container owns its port before the run starts
Aaronontheweb Aug 19, 2026
52f8800
Line-buffer the captured eval CLI stdout
Aaronontheweb Aug 19, 2026
887660a
Make the recall-filters assert POSIX-awk safe
Aaronontheweb Aug 19, 2026
e41cfda
Remove the CLI direct-file fallback: route mutations require the daemon
Aaronontheweb Aug 19, 2026
c001b0b
Trim decision-history prose from the change artifacts
Aaronontheweb Aug 19, 2026
ea21273
Align the parity spec purpose with the daemon-only CLI decision
Aaronontheweb Aug 19, 2026
fa99ba0
Prove the webhook route lifecycle end to end in smoke
Aaronontheweb Aug 19, 2026
71f184b
Correct the Webhooks.Enabled default in the operations skill
Aaronontheweb Aug 19, 2026
70782c5
Remove the cross-process mutex from the webhook route store
Aaronontheweb Aug 19, 2026
caf21eb
Log webhook route rejections with semantic log messages
Aaronontheweb Aug 19, 2026
582a487
Type the webhook route protocol with a name value object and an outco…
Aaronontheweb Aug 19, 2026
d7eb1ee
Prove the merged route keeps its required fields
Aaronontheweb Aug 19, 2026
b2179ea
Mark the D2 change-signal open question resolved
Aaronontheweb Aug 19, 2026
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
38 changes: 34 additions & 4 deletions evals/run-evals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,16 @@ start_eval_daemon() {
local deadline=$((SECONDS + 60))
while (( SECONDS < deadline )); do
if curl -fsS "http://127.0.0.1:$EVAL_PORT/api/health/ready" >/dev/null 2>&1; then
# The container runs with --network host, so any process on this
# port can answer the host-side readiness poll — including another
# eval run's daemon. Readiness must also prove THIS container's
# daemon is alive, or the whole run interrogates a stranger while
# every daemon-log assert reads its own dead container's empty log.
if ! docker exec "$EVAL_CONTAINER_NAME" pgrep -f netclawd >/dev/null 2>&1; then
echo "ERROR: port $EVAL_PORT answered but this container's daemon is not running — another daemon owns the port. Set NETCLAW_EVAL_PORT to a free port." >&2
docker logs "$EVAL_CONTAINER_NAME" >&2 2>&1 || true
exit 2
fi
echo "Eval daemon ready at http://127.0.0.1:$EVAL_PORT"
return 0
fi
Expand Down Expand Up @@ -862,6 +872,7 @@ run_prompt() {
# Record daemon log position before the prompt (the daemon writes to a
# daily-rotating file at /root/.netclaw/logs/daemon-YYYY-MM-DD.log, and
# the container bind-mounts that directory from $EVAL_HOME/logs).
resolve_daemon_log
if [[ -f "$DAEMON_LOG" ]]; then
DAEMON_LOG_LINES_BEFORE=$(wc -l < "$DAEMON_LOG")
else
Expand All @@ -882,7 +893,7 @@ run_prompt() {
# stdout, producing a false eval failure rather than a real one.
NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \
NETCLAW_HOME="$EVAL_HOME" \
timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" chat -p "${output_args[@]}" "$prompt" \
timeout "$PROMPT_TIMEOUT" stdbuf -oL -eL "$NETCLAW_BIN" chat -p "${output_args[@]}" "$prompt" \
> "$STDOUT_FILE" 2> "$STDERR_FILE" || true

# Brief pause for daemon log flush
Expand Down Expand Up @@ -922,6 +933,7 @@ run_prompt_resume() {
fi
STDERR_FILE="$MULTI_TURN_STDERR_FILE"

resolve_daemon_log
if [[ -f "$DAEMON_LOG" ]]; then
DAEMON_LOG_LINES_BEFORE=$(wc -l < "$DAEMON_LOG")
else
Expand All @@ -935,7 +947,7 @@ run_prompt_resume() {

NETCLAW_DAEMON_ENDPOINT="http://127.0.0.1:$EVAL_PORT" \
NETCLAW_HOME="$EVAL_HOME" \
timeout "$PROMPT_TIMEOUT" "$NETCLAW_BIN" chat -p --resume "$session_id" \
timeout "$PROMPT_TIMEOUT" stdbuf -oL -eL "$NETCLAW_BIN" chat -p --resume "$session_id" \
"${output_args[@]}" "$prompt" \
> "$turn_file" 2> "$turn_stderr_file" || true

Expand Down Expand Up @@ -1069,6 +1081,19 @@ stdout_response_not_contains() {
return 0
}

## The daemon runs inside the container on its own clock (UTC), so a host
## date computation can name a log file the daemon never writes — every
## daemon_log_contains then fails silently for the whole run (observed when a
## CDT-evening run crossed UTC midnight). Resolve the newest real log file
## instead of trusting a computed date. Callers re-resolve before they take a
## per-case line baseline; a midnight rollover inside a single case remains
## unhandled and acceptable.
resolve_daemon_log() {
local newest
newest=$(ls -1t "$EVAL_HOME"/logs/daemon-*.log 2>/dev/null | head -n 1)
[[ -n "$newest" ]] && DAEMON_LOG="$newest"
}

daemon_log_tail() {
if [[ -f "$DAEMON_LOG" ]]; then
tail -n +"$((DAEMON_LOG_LINES_BEFORE + 1))" "$DAEMON_LOG" 2>/dev/null
Expand Down Expand Up @@ -1365,9 +1390,14 @@ assert_memory_explicit_store() {

assert_memory_recall_filters() {
# After overfetch fix: at least one candidate selection should reduce the set.
# POSIX awk only: mawk lacks gawk's 3-argument match(), which made this
# assert die on a syntax error and fail unconditionally on hosts without
# gawk. Extract the two counts with sub() instead of capture groups.
daemon_log_tail | awk '
match($0, /rawCount=([0-9]+).*selectedCount=([0-9]+)/, m) {
if ((m[1] + 0) > (m[2] + 0)) {
/rawCount=[0-9]+.*selectedCount=[0-9]+/ {
raw = $0; sub(/.*rawCount=/, "", raw); sub(/[^0-9].*/, "", raw)
sel = $0; sub(/.*selectedCount=/, "", sel); sub(/[^0-9].*/, "", sel)
if ((raw + 0) > (sel + 0)) {
found = 1
}
}
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.60.0"
version: "2.61.0"
---

# Netclaw Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
## Webhook Management


Webhooks are gated on `Webhooks.Enabled` in `netclaw.json` (default `true`).
Webhooks are gated on `Webhooks.Enabled` in `netclaw.json` (default `false` — enable it explicitly before routes serve).
When disabled, the webhook HTTP endpoint returns 404 for all routes and
webhook tools are hidden from discovery.

Expand Down Expand Up @@ -61,6 +61,23 @@ existing values; provide an argument only when changing that setting.
Route files hot-reload without restarting the daemon. If a route file becomes
invalid, Netclaw removes that route immediately and emits an operational alert.

Route mutations serialize through one daemon-side authority. The daemon also
exposes an authenticated management resource, separate from the anonymous
delivery endpoint:

- `GET /api/webhooks` -> list routes (no secrets in responses)
- `GET /api/webhooks/{route}` -> route detail (no secrets)
- `PUT /api/webhooks/{route}` -> create or update; requires Operator authority
- `DELETE /api/webhooks/{route}` -> remove the route

The `netclaw webhooks` CLI manages routes through this resource. `set` and
`delete` require a running daemon: when the daemon does not answer, when an older
daemon lacks the resource, or when the daemon rejects the call, the command fails
and changes no file. The CLI never writes a route file. `list`, `show`, and
`validate` read the route files on disk, which stay canonical. To author a route
without a daemon, write the route file to the webhooks directory; the daemon
loads it at startup.

**Approval gate:** Webhooks run without a human — they cannot prompt for
approval. The same rules as reminders apply: shell commands must be pre-approved
in `tool-approvals.json`, and path arguments are scoped by the route's audience
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/memory-core-redesign/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ constitution gates (tests, evals where mapped, schema/skill sync, slopwatch).

- [ ] 5.1 **BREAKING**: restrict automatic recall to `recall_mode='auto'` in `SearchByPlanAsync` (searchable leaves the auto pool); update `MemoryIndexContextLayer` guidance
- [ ] 5.2 Formation: policy gate honors sidecar-proposed recall mode for durable facts, defaulting to `searchable`; observer distillation prompt rewritten for fewer, more comprehensive proposals with an explicit auto-mode whitelist (identity/preferences/environment)
- [ ] 5.3 Trace revival: reachable producer (sidecar may propose `trace` with 72 h TTL), fresh-trace auto-recall eligibility weighted below durable facts, removal of the unreachable turn-complete Trace dead code
- [ ] 5.3 [SUPERSEDED by PR #2007 — the turn-complete Trace lane was removed; only the unreachable MemoryClass.Trace resolver branches remain to adjudicate] Trace revival: reachable producer (sidecar may propose `trace` with 72 h TTL), fresh-trace auto-recall eligibility weighted below durable facts, removal of the unreachable turn-complete Trace dead code
- [ ] 5.4 `MemoryClass.ToolLesson` (`tool_lesson`) → Document/MergeDocument/Searchable with per-tool anchors; `store_memory` accepts the class and sets the `VerifiedToolFinding` checkpoint flag
- [ ] 5.5 Sidecar distillation prompt: correction-hunting instruction producing tool-lesson proposals
- [ ] 5.6 Per-tool context injection in the tool-execution pipeline: `[tool-lessons:<name>]` block on first use per session (bounded, once per tool, reset on compaction); remove the dead `verified-tool-finding` +25 recall bonus
Expand All @@ -69,7 +69,7 @@ constitution gates (tests, evals where mapped, schema/skill sync, slopwatch).
- [ ] 6.3 `netclaw memory consolidate --apply --plan <path>`: live-daemon refusal (override flag), `VACUUM INTO` backup, batched apply, re-embed + FTS rebuild, ledger row
- [ ] 6.4 Expiry sweep in the daemon maintenance loop (grace window, per-class deletion logging)
- [ ] 6.5 `netclaw memory status` (composition, coverage, pending checkpoints, expired-awaiting-sweep, recent ledger)
- [ ] 6.6 Checkpoint enqueue gating: turn-complete lane gated by the extractor's precondition at enqueue time
- [ ] 6.6 [SUPERSEDED by PR #2007 — the turn-complete lane was removed, not gated; no enqueue gating is needed] Checkpoint enqueue gating: turn-complete lane gated by the extractor's precondition at enqueue time
- [ ] 6.7 Subtraction: drop `memory_edges` DDL, remove facet/soft-scope inference from `DeterministicRetrievalPlanning` (keep stopword hygiene + lexical terms), delete dead Trace path remnants
- [ ] 6.8 Integration tests on a seeded corpus: backfill→dry-run→edited-plan apply→status round-trip; sweep deletes only past-grace rows
- [ ] 6.9 Runbook update (`docs/runbooks/memory-health-and-evals.md`): embedding, consolidation, sweep operations
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/webhook-route-actor-ownership/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-19
75 changes: 75 additions & 0 deletions openspec/changes/webhook-route-actor-ownership/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Design: webhook-route-actor-ownership

## Context

`WebhookRouteStore` serialized read-modify-write over per-route JSON files with a named OS mutex, because two processes wrote: the daemon (agent tools) and the CLI (command + TUI). The daemon-internal race is real — two sessions can invoke `set_webhook` concurrently on tool-executor threads. The mutex-based test proved flaky on Windows CI: its failing assertion measured thread-pool dispatch latency, not store correctness (root cause confirmed by reproduction under forced pool limits). The maintainer direction: an actor should own this state; CLI configuration should route through the daemon's HTTP API.

## Goals / Non-Goals

**Goals:**

- One mutation authority for webhook routes inside the daemon.
- CLI writes routes only through the daemon.
- Deterministic tests: message ordering, not thread choreography.
- An old CLI keeps working against a new daemon: its direct file write stays visible, because the actor holds no cache.
- A new CLI against an old daemon fails loudly and names the upgrade. Per D4 it does not fall back.

**Non-Goals:**

- No change to delivery, verification, hot-reload, or route file format.
- No generalization to other config surfaces yet.

## Decisions

### D1: Plain actor over the existing store; disk stays canonical

`WebhookRouteActor` is an ordinary `ReceiveActor` (not persistent). It handles `UpsertRoute`/`DeleteRoute`/`GetRoute`/`ListRoutes` messages, validates via `WebhookRouteValidator`, and persists through the existing `WebhookRouteStore` (which keeps its atomic temp-file-and-move write). Disk is the canonical store; the actor is the serialization point, not a second source of truth. Rationale: Akka.Persistence would create journal types and a second copy of secret-bearing config — both forbidden by the back-compat and security constraints. Alternative considered: journaled actor with disk projection — rejected for exactly those reasons.

### D2: The actor is cacheless — external file changes need no reconciliation

Implementation finding (supersedes the original signal-based wording): no route change signal exists. The `inbound-webhooks` spec permits request-time mtime-gated reload, and `WebhookRouteCatalog` re-reads route files lazily; there is no watcher on the webhooks directory. Rather than build one, the actor holds no cache: every read and every read-modify-write goes through the store to disk. An external write (old CLI, operator edit) is therefore visible to the very next actor operation with no reconciliation step. This is the direct consequence of D1 — the actor is the serialization point, not a second source of truth. The store takes no cross-process lock: each write is atomic on its own, and the accepted worst case during version skew is one lost same-route update. No new watcher machinery.

### D3: HTTP resource mirrors the reminders precedent

`/api/webhooks` endpoints are thin minimal-API handlers that `Ask` the actor with the standard request timeout and map results to HTTP statuses (validation failure → 400 with the validator's message; unknown route → 404; success → 200/204). Same auth middleware and exposure-mode rules as every `/api/*` surface. Agent tools inside the daemon `Ask` the actor directly — no loopback HTTP.

### D4: CLI route mutations are daemon-only

Maintainer decision: no fallback of any kind — no dual mode, no `--offline` flag, no local write path. One store, one writer.

`WebhooksCommand` probes availability once per invocation, immediately before the write. Three answers fail the command with exit code 1 and no file change:

- The daemon does not answer (transport failure, timeout, or no daemon client at all) → "The daemon is not reachable. Start the daemon to manage webhook routes."
- The daemon answers 404 for the resource (a daemon that predates it) → "This daemon does not serve the webhook route API. Upgrade the daemon."
- The daemon answers any other failure status → the daemon's own message, because the daemon is the enforcement point.

A transport failure between the probe and the write also fails the command. The daemon may have applied the change before the connection broke, so the CLI reports the uncertainty rather than repeating the write.

Reads (`list`, `show`, `validate`) stay on canonical disk. That is the read path, not a fallback: disk is the route store, the actor holds no cache, and `show --show-secret` needs a secret the API never returns. Argument grammar, `--dry-run`, and the merge preview all run before the probe, so they keep their own messages and exit codes with no daemon present.

The supported daemon-absent authoring path is a route file written on disk outside the CLI, which the daemon loads at startup.

### D5: Ask timeout and failure semantics

Tool and HTTP fronts use the daemon's standard ask timeout. A store I/O failure inside the actor faults the message with the error returned to the caller (tool result error / HTTP 500) — the actor does not swallow persistence failures. The actor itself restarts under default supervision on unexpected exceptions; its state is rebuilt from disk on restart, so a restart is always safe.

### D6: Test replacement, not test repair

The Windows-flaky choreography test is deleted and replaced by: (a) actor tests proving mailbox serialization of concurrent RMW (two `Ask`s, deterministic final state), (b) endpoint tests for `/api/webhooks` status mapping, (c) CLI command tests per daemon answer (daemon up → API call recorded and no file written; daemon down → exit 1 with the unreachable message and no file written; old daemon 404 → exit 1 with the upgrade message and no file written; 400/401/403 → exit 1 with the daemon's message and no file written), (d) no store-level cross-process-guard test, because the store holds no lock to guard.

## Risks / Trade-offs

- [Skew window: old CLI writes while the actor serves a request] → D2 keeps the actor cacheless; atomic writes keep every file complete; per-route files bound the blast radius to one lost same-route update.
- [CLI now depends on daemon availability for every route mutation] → accepted by maintainer decision (D4). A fallback would hide a misconfigured or stopped daemon and would let an operator bypass the enforcement point by inducing an error. An operator without a daemon authors the route file on disk and starts the daemon, which loads it.
- [Actor becomes a throughput bottleneck] → route mutations are rare, low-volume operator/agent actions; a single mailbox is far above the required throughput.
- [Two fronts drift (HTTP vs tools)] → both are thin `Ask` adapters over the same messages; validation lives only in the actor.
- [Skill/docs drift] → `netclaw-operations` skill row updated in the same PR per the skills sync rule.

## Migration Plan

Ship steps 1 and 2 together in one release. No data migration; no config change. Rollback is a PR revert; the disk format never changed.

## Open Questions

- RESOLVED: `InboundWebhooksConfigViewModel` needs no mode-selection seam — it has no route save. It writes only the `Webhooks.Enabled`/`ExecutionTimeoutSeconds` section of `netclaw.json` and delegates route authoring to the `netclaw webhooks` command. Its route read runs against canonical disk and is already correct under the cacheless design.
- RESOLVED BY D2 AMENDMENT: no change-signal plumbing exists or is needed — the actor is cacheless, so no watcher machinery was built.
Loading
Loading