feat(openclaw): upgrade fleet to DeepSeek V4.1 Flash 1017 and harden operations

- Upgrade OpenClaw primary model from deepseek-v4.1-flash to deepseek-v4.1-flash-1017 across all interactive agents, heartbeats, compaction, and cron jobs.
- Configure hardened OpenRouter provider routing for 1017 (deepinfra, inference-net, parasail, cloudflare) with strict fp8/bf16 quantization and tool_choice verification.
- Retain deepseek-v4.1-flash (0910), z-ai/glm-5.3-flash, and qwen3.8-flash in the automated fallback chain.
- Update ADR 0002 with 1017 economics (30% price reduction on prompt/cache, +60% intelligence benchmark increase) and a 7-point provider qualification playbook.
- Add operational scripts and tooling for session compaction, subagent vector deduplication, gateway preflight, and triage diagnostics in k8s/openclaw.
- Update DuckDB backend engine, git-sync logic, and test suites.
- Enhance Mila tracking UI components and add container build configuration.
- Sync Terraform infrastructure state for Authentik.
This commit is contained in:
Moritz Graf
2026-10-18 10:54:59 +02:00
parent 7ce1da18ab
commit dd60a095c6
26 changed files with 1786 additions and 1301 deletions
@@ -1,5 +1,5 @@
---
status: "accepted (trial: GLM-5.3-Flash)"
status: "accepted (fleet-wide primary: DeepSeek-V4.1-Flash-1017; updated 2026-10-17)"
date: 2026-09-13
decision-makers: ["Moritz Graf", "Antigravity"]
consulted: []
@@ -28,7 +28,7 @@ How do we architect the full-spectrum model selection across **all modalities**
## Decision Drivers
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. Gemini 3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
* **State-of-the-Art Recency**: Exclusively employ the latest generation models (e.g. DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, Sonar Pro). Completely decommission legacy generations (e.g. Gemini 2.5).
* **Conversational & Reasoning Quality**: High emotional intelligence, nuanced German fluency, personal context recall, and strict adherence to tool-calling protocols (ADR 0001).
* **Strict Modality Decoupling**: Each capability must route to its dedicated, best-in-class engine:
- Text & Agentic Reasoning
@@ -38,9 +38,9 @@ How do we architect the full-spectrum model selection across **all modalities**
- Text-to-Speech / Audio Output (synthesizing briefings and podcasts)
- Web Search & Retrieval (grounded search)
- Semantic Embeddings (vector memory)
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), zero background bleed, and predictable billing.
* **Inference Economics**: Low per-token costs for large conversational contexts (15k–80k tokens), high prompt-cache discount efficiency (`cacheRead`), zero background bleed, and predictable billing.
* **Latency & Turn Responsiveness**: Conversational turns on Telegram must return within **4–10 seconds**, avoiding provider queuing or massive prefill bottlenecks.
* **Resilience & Fault Tolerance**: Automated fallback chains across independent providers on OpenRouter.
* **Resilience & Fault Tolerance**: Automated fallback chains and strict quantization/provider pins (`fp8`/`bf16`, `tool_choice` support) across independent providers on OpenRouter.
---
@@ -60,22 +60,23 @@ How do we architect the full-spectrum model selection across **all modalities**
│ Tier 1 │ │ Tier 2 │ │ Tier 3 │ │ Tier 4 │ │ Tier 5 │
│ • Low Latency │ │ • Deep Analysis │ │ • target: none │ │ • Nano Banana 2 │ │ • Whisper V3 │
│ • Tool Routing │ │ • Autonomous │ │ • Zero Bleed │ │ • Dedicated │ │ • Gemini 3.1 │
│ • Native Vision │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
│ • Decoupled OCR │ │ • Isolated Run │ │ • 30m Polling │ │ Raster Head │ │ Flash TTS │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Primary (Trial):│ │ Primary (Trial):│ │ Primary: │ │ Primary: │ │ STT: │
│ z-ai/ │ │ z-ai/ │ │ z-ai/ │ │ google/ │ │ openai/ │
│ glm-5.3-flash │ │ glm-5.3-flash │ │ glm-5.3-flash │ │ gemini-3.1- │ │ whisper-large- │
│ │ │ │ │ │ │ flash-image │ │ v3-turbo │
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ │ │
│ 1. deepseek- │ │ 1. deepseek- │ │ 1. deepseek- │ │ Fallbacks: │ │ TTS: │
│ v4.1-flash │ │ v4.1-flash │ │ v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
│ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ 2. qwen3.8-flash│ │ flash-preview│ │ gemini-3.1- │
│ │ │ │ │ │ │ 2. gemini-3-pro-│ │ flash-tts │
│ Vision Fallback:│ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
│ google/ │ │ │ │ │ │ │ │ Speech HD) │
│ Primary: │ │ Primary: │ │ Primary: │ │ Primary: │ │ STT: │
│ deepseek/ │ │ deepseek/ │ │ deepseek/ │ │ google/ │ │ openai/ │
│ deepseek-v4.1- │ │ deepseek-v4.1- │ │ deepseek-v4.1- │ │ gemini-3.1- │ │ whisper-large- │
│ flash-1017 │ │ flash-1017 │ │ flash-1017 │ │ flash-image │ │ v3-turbo │
│ │ │ │ │ │ │ │ │
│ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ Fallbacks: │ │ TTS: │
│ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. v4.1-flash │ │ 1. gemini-3.1- │ │ google/ │
│ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ 2. glm-5.3-flash│ │ flash-preview│ │ gemini-3.1- │
│ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 3. qwen3.8-flash│ │ 2. gemini-3-pro-│ │ flash-tts │
│ │ │ │ │ │ │ image │ │ (FB: MiniMax 2.8│
│ Vision (OCR): │ │ │ │ │ │ │ │ Speech HD) │
│ google/ │ │ │ │ │ │ │ │ │
│ gemini-3.8- │ │ │ │ │ │ │ │ │
│ flash │ │ │ │ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
@@ -86,27 +87,28 @@ How do we architect the full-spectrum model selection across **all modalities**
## Modality Specifications & Cost-Benefit Analysis
### 1. Interactive Conductor & Agentic Reasoning (Tier 1)
* **Primary (Active Trial)**: `openrouter/z-ai/glm-5.3-flash`
- **Pricing**: \$0.075 / 1M prompt tokens; \$0.300 / 1M completion tokens. This is 50–75% more cost-effective than DeepSeek V4.1-Flash and offers a 1.3M token context window. Under daytime context headroom (~25k–60k tokens), daily cost is only **~\$0.01–\$0.02 / day**.
- **Performance & Reasoning**: SOTA Chinese & English reasoning, competitive coding benchmarks, and native vision perception.
- **Forensic Diagnosis & Tool-Calling Protocol Fix**:
- *Incident*: On September 14, 2026, GLM-5.3-Flash emitted raw `<tool_call>supermarket__supermarket_search...</tool_call>` XML into Telegram instead of executing tools.
- *Root Cause Identified*: In Zhipu AI's pre-training tokenizer, function calls are emitted as special XML tokens. When models are queried through OpenRouter's OpenAI-compatible API, OpenRouter only intercepts and translates `<tool_call>` tokens into JSON if the tool `name` exists in the request's registered `tools: [...]` array. OpenClaw uses dynamic Tool Search (`tools.toolSearch.mode: "directory"`) where only 3 control tools (`tool_search`, `tool_describe`, `tool_call`) are registered in `tools`. When GLM received deferred schemas (e.g., `supermarket__supermarket_search`), its pre-training prior prompted it to call the tool directly. Because `supermarket__supermarket_search` was not in OpenRouter's `tools` array, OpenRouter passed the raw XML through as message text. Once that text entered the session history (events SEQ 297 & 306), GLM was in-context primed to repeat the XML pattern.
- *Remediation & Verification*: We introduced an explicit deferred tool execution protocol into `AGENTS.md` instructing the model that deferred tools must NEVER be called directly and MUST be routed via `tool_call({ id: 'tool_name', args: { ... } })`. Direct OpenRouter API probes confirmed that with this rule, GLM-5.3-Flash calls `tool_call` with 100% compliant JSON and zero XML leakage. Session events were sanitized.
* **Primary (Fleet-Wide Standard as of 2026-10-17)**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
- **1017 Snapshot Upgrade (Pareto Jump in Quality & Price)**: Released on October 17, 2026, `deepseek/deepseek-v4.1-flash-1017` boosts the Artificial Analysis Intelligence Index from **24.7 to 39.5** (+60%) via Reinforcement Learning with Self-Feedback (RLSF) while cutting DeepInfra `fp8` pricing by an additional **30%** (`$0.098/M` prompt, `$0.294/M` completion, and **`$0.00294/M` `cacheRead`** vs. `$0.140/$0.420/$0.0042` on the 0910 baseline).
- **Provider Pinning & Tool-Compatibility Verification**:
- Pinned `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]`.
- Live OpenRouter routing forensics verified that `morph/fp8` is filtered out by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` (official) is filtered by account privacy guardrails (`Paid model training violation`), and `baseten` returns `HTTP 429`. Meanwhile, `deepinfra`, `inference-net`, `parasail`, and `cloudflare` deliver 100% tool-calling reliability and 2–5s latency.
- **Post-Trial Evaluation of `z-ai/glm-5.3-flash` (2026-09-13 to 2026-10-17)**:
- While `z-ai/glm-5.3-flash` was trialed as primary conductor, 17-day production telemetry (Oct 1–17, 2026) revealed **12 mid-stream tool-call aborts** (`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`) across 5 agents (`main`, `grabengasse_media`, `koch`, `hebamme`, `doktor`) when generating nested `tool_call({ id, args })` payloads after `tool_describe`, as well as occasional thinking-only whitespace stalls (Issue 23) and long-context attention drift (Issues 22 & 25).
- Promoting `deepseek/deepseek-v4.1-flash-1017` to primary across all agents eliminates malformed tool-call stream aborts AND lowers total monthly cost by >50% compared to GLM 5.3 Flash.
- **Decoupled Vision Override**: `deepseek/deepseek-v4.1-flash-1017`, `deepseek/deepseek-v4.1-flash`, and `z-ai/glm-5.3-flash` are registered with `input: ["text"]` under `models.providers.openrouter.models` so that OpenClaw always routes user photos through `tools.media.image` (`google/gemini-3.8-flash`) for high-precision German OCR before handing enriched text to the chat model (Issue 29).
* **Fallbacks**:
1. `openrouter/deepseek/deepseek-v4.1-flash`: Rock-solid JSON tool-calling and sub-3s response times via dedicated OpenRouter ASIC cluster, available immediately if GLM experiences any upstream provider queuing or issues.
2. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
1. `openrouter/deepseek/deepseek-v4.1-flash` (`0910` baseline across 25+ providers, pinned to `deepinfra`, `streamlake`, `inference-net`, `digitalocean`, `venice`, `nebius`).
2. `openrouter/z-ai/glm-5.3-flash`: Secondary fallback pinned to `fp8`/`bf16` providers (`morph`, `near-ai`, `digitalocean`, `venice`, `streamlake`).
3. `openrouter/qwen/qwen3.8-flash`: Provider diversity.
### 2. Autonomous Subagents (Tier 2)
* **General Subagents**: `openrouter/z-ai/glm-5.3-flash`
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Inherits the `tool_call` protocol guardrails from `AGENTS.md`.
* **Deep Analysis & IT Specialists (`itguy`, `recherche`)**: `openrouter/deepseek/deepseek-v4.1-flash`
- High-precision reasoning, robust nested tool loops without syntax drift, and immune to XML leakage on extensive research queries or CLI diagnostics.
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/qwen/qwen3.8-flash`.
* **All Subagents & Specialists**: `openrouter/deepseek/deepseek-v4.1-flash-1017`
- **Execution**: Spawned via `sessions_spawn` with a 300-second timeout. Provides high-precision reasoning, rock-solid nested JSON tool loops (`tool_search` -> `tool_describe` -> `tool_call`), and zero syntax drift.
* **Fallback**: `openrouter/deepseek/deepseek-v4.1-flash` / `openrouter/z-ai/glm-5.3-flash` / `openrouter/qwen/qwen3.8-flash`.
### 3. Background Daemon & Heartbeats (Tier 3)
* **Configuration**: `heartbeat.model: "openrouter/z-ai/glm-5.3-flash"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
* **Rationale**: Decoupled completely from user conversation transcripts. Polling occurs against an isolated, minimal prompt (~400 tokens), dropping weekly heartbeat token burn from 21,000,000 to <150,000 tokens (<\$0.02 / week).
* **Configuration**: `heartbeat.model: "openrouter/deepseek/deepseek-v4.1-flash-1017"`, `heartbeat.target: none`, `heartbeat.lightContext: true`.
* **Rationale**: Because background heartbeats have a 75%+ `cacheRead` share (`22.86M` `cacheRead` vs. `7.73M` uncached input over 17 days), DeepSeek V4.1 Flash 1017's \$0.00294/M cache read rate on DeepInfra reduces 17-day heartbeat cost to <\$0.90 while keeping prompt cache warm on the primary provider.
### 4. Vision Input vs. Text-to-Image Generation (Tier 4)
* **Crucial Distinction**:
@@ -171,14 +173,83 @@ To align on remaining edge cases before final sign-off:
---
## OpenRouter Provider Qualification Criteria & Re-Evaluation Playbook
Whenever a new model snapshot (such as `deepseek/deepseek-v4.1-flash-1017`) is evaluated or re-checked as new hosting providers join OpenRouter, every candidate provider MUST pass the following **7-point qualification gate** before being added to `extra_body.provider.order` in [openclaw-instance.secret.yaml](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml):
### 1. Hard Technical Gates (Must Pass All)
1. **Quantization Precision (`fp8` / `bf16` Only)**:
- **Rule**: Must run `fp8` or `bf16` (or verified full-precision `unknown` from trusted cloud hosts like Cloudflare).
- **Reject**: Any `fp4`, `nvfp4`, `mxfp4`, or `int4` quantization (`decart`, `sail-research`, `wafer`, `open-inference`, `phala` fp4). 4-bit quantizations severely degrade nested JSON tool-call syntax and German grammar.
2. **OpenRouter Routing Funnel & Guardrail Compatibility (`tools` + `tool_choice: "auto"` Pass)**:
- **Rule**: Must not be stripped by OpenRouter's internal routing filters (`Filter by Tool Compatibility` or `Filter by Guardrails`) when a request includes `tools: [...]` and `tool_choice: "auto"`.
- **Why**: Metadata in `/api/v1/models/.../endpoints` can claim `supports_tool_choice: true` even when OpenRouter's live router blocks the provider on actual tool calls (e.g. `morph/fp8` is stripped by `Filter by Tool Compatibility`, and `deepseek` official is stripped by `Paid model training violation` account privacy settings).
3. **Nested MCP `tool_call` Serialization Integrity (2/2 Live Pass)**:
- **Rule**: Must cleanly emit `finish_reason: "tool_calls"` with valid, parseable nested JSON arguments (`{"id": "mcp:...", "args": {...}}`) across back-to-back test runs without mid-stream SSE aborts (`Provider returned an incomplete or malformed tool call`) or HTTP `429` upstream rate limits.
4. **Context Window & Completion Headroom**:
- **Rule**: `context_length >= 262,144` (1,048,576 preferred) and `max_completion_tokens >= 131,072`.
- **Why**: Active daytime sessions with system prompts and tool schemas reach 20k–80k tokens.
5. **Uptime & Reliability**:
- **Rule**: `uptime_last_1d >= 95%` and `uptime_last_30m == 100%`.
### 2. Economic & Latency Ranking (How to Order Passing Providers)
Among providers passing all 5 technical gates, rank them in `extra_body.provider.order` by **effective production cost** and **turn latency**:
1. **Implicit Prompt Cache Read Rate (`input_cache_read`) — Highest Weight**:
- Because **73%–94%** of OpenClaw's token volume (both interactive multi-turn chats and 30m background heartbeats) consists of cached prompt reads (`cacheRead`), a provider with `supports_implicit_caching: true` and a **95%–98% cache discount** (`<= $0.005 / 1M` tokens, e.g. `deepinfra` at `$0.00294/M` or `streamlake` at `$0.0030/M`) is vastly cheaper in net monthly spend than a provider with slightly cheaper uncached input but weak or missing `cacheRead` discounts (e.g. `nebius` with `cache_read: null` or `modal`/`primeintellect` at `$0.030/M`).
2. **Base Prompt & Completion Pricing**:
- Target `prompt <= $0.18 / 1M` and `completion <= $0.65 / 1M` for top-priority slots; allow higher-priced providers (`<= $0.30 / $1.20`) only in lower fallback slots (#3/#4) for outage resilience.
3. **End-to-End Tool-Call Latency**:
- Target **1.0s – 5.5s** for a ~300-token reasoning + tool-call turn.
### 3. One-Shot Live Verification Command (For Future Checks)
Any agent (including a fast/lightweight model) can audit all currently available OpenRouter endpoints for `deepseek/deepseek-v4.1-flash-1017` in one step by running:
```bash
# 1. Inspect current endpoint metadata, pricing, cache_read, and 24h uptime:
curl -s https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints | \
jq -r '.data.endpoints[] | "\(.provider_name) | tag=\(.tag) | quant=\(.quantization) | ctx=\(.context_length) | prompt=\(.pricing.prompt) | comp=\(.pricing.completion) | cache=\(.pricing.input_cache_read) | uptime1d=\(.uptime_last_1d)"'
# 2. Live-test candidate providers inside openclaw-0 with real nested tool_call payloads:
kubectl exec openclaw-0 -n openclaw -c openclaw -- python3 -c '
import os, json, time, urllib.request
api_key = os.environ["OPENROUTER_API_KEY"]
tools = [{"type": "function", "function": {"name": "tool_call", "parameters": {"type": "object", "properties": {"id": {"type": "string"}, "args": {"type": "object"}}, "required": ["id", "args"]}}}]
candidates = ["deepinfra", "streamlake", "inference-net", "venice", "digitalocean", "novita", "parasail", "cloudflare", "nebius"]
for prov in candidates:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps({
"model": "deepseek/deepseek-v4.1-flash-1017",
"messages": [{"role": "user", "content": "Call tool_call with id=\"mcp:homeassistant:homeassistant__ha_get_state\" and args={\"entity_id\": \"media_player.bad\"}"}],
"tools": tools, "tool_choice": "auto", "max_tokens": 250,
"provider": {"order": [prov], "allow_fallbacks": False}
}).encode(),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=20) as r:
b = json.loads(r.read().decode())
tc = b["choices"][0]["message"].get("tool_calls")
args = tc[0]["function"]["arguments"] if tc else "NONE"
print(f"PASS | {prov:15s} | {time.time()-t0:4.2f}s | actual={b.get(\"provider\")} | args={args}")
except urllib.error.HTTPError as e:
print(f"FAIL | {prov:15s} | HTTP {e.code} | {e.read().decode()[:140]}")
except Exception as e:
print(f"FAIL | {prov:15s} | ERR {e}")
'
```
---
## Consequences
### Positive
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on Gemini 3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
* **Unified Latest-Generation Stack**: Completely eliminates legacy Gemini 2.5 models; standardizes exclusively on DeepSeek V4.1 Flash 1017, Gemini 3.8/3.1, GLM 5.3, Whisper V3 Turbo, and Sonar Pro.
* **100% Reliable Image Generation**: Correctly routes image generation requests to Nano Banana 2 (`google/gemini-3.1-flash-image`), completely eliminating HTTP 404 generation errors.
* **Cohesive Voice & Audio Pipeline**: Clear architectural documentation of the Whisper V3 Turbo and Gemini 3.1 Flash TTS integration for dossiers and podcasts.
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$1.20–\$1.80** (85–90% savings) through decoupled heartbeats and nightly compaction.
* **Guaranteed Cost Ceiling**: Drops weekly expenditure from **\$12.55 to ~\$0.80–\$1.40** (~90% savings) through DeepSeek V4.1 Flash 1017's `$0.00294/M` cache reads, decoupled heartbeats, and nightly compaction.
### Negative & Mitigations
* **OpenRouter Provider Variability**: GLM-5.3-Flash prefill throughput varies across OpenRouter hosting providers.
* *Mitigation*: Daily compaction bounds transcript size; failover to DeepSeek V4.1-Flash is available at zero architectural switching cost.
* **OpenRouter Provider Rollout Lag on Day-0 Model Releases**: Newly released snapshots initially launch with 5–10 providers before the full fleet (25+ providers) joins over subsequent days.
* *Mitigation*: Codified the 7-point provider qualification gate and one-shot test script above, and configured automatic model fallback to the `0910` snapshot (`openrouter/deepseek/deepseek-v4.1-flash`).
+45
View File
@@ -966,4 +966,49 @@ This document captures fixed issues, operational quirks, and incident root-cause
- **How to Avoid Recurrence**:
- Always decouple perception tasks (OCR, image description, speech-to-text) into `tools.media` or `imageModel`, keeping primary conversational models focused on reasoning and dialogue without changing the core chat persona.
---
### Issue 30: GLM-5.3-Flash Malformed Tool Call Stream Aborts (`streamlake/fp8`), Cold HDD Prompt Assembly Stall, and Fleet-Wide Migration to DeepSeek V4.1 Flash
- **Date**: 2026-10-17
- **Severity**: High (Topic 164 `#Grabengasse & Media` failed with `Provider returned an incomplete or malformed tool call`; 12 total occurrences across 5 agents in 17 days; 504.5s cold-disk event-loop stall on first morning request)
- **Symptom**:
1. At 07:11:02 CEST, Moritz sent a voice note in Topic 164 (`#Grabengasse & Media`, agent `grabengasse_media`): *"Kannst du mir auf dem Sonos in der Küche die Bibi Blocksberg Playlist Der Affe ist los abspielen?"*
2. The turn stalled for **8 minutes and 24 seconds** (`504,493 ms`) during `system-prompt` assembly (`cpuCoreRatio=0.005`, pure disk I/O wait) before sending the first LLM request at 07:19:26 CEST.
3. In Turn 1 (`SEQ 175`), `z-ai/glm-5.3-flash` called `tool_search({"query": "sonos spotify play"})`. In Turn 2 (`SEQ 177`), it called `tool_describe({"name": "mcp:spotify:spotify__search"})`.
4. In Turn 3 (`SEQ 179`, `responseId: gen-1792214444-T46fmmBLqgmCCe0YJp4X`), after generating 326 reasoning tokens (`"Search for 'Bibi Blocksberg Der Affe ist los' using mcp:spotify:spotify__search"`), the upstream OpenRouter provider (`streamlake/fp8`) aborted the stream mid-JSON while serializing nested `tool_call({"id": "mcp:spotify:spotify__search", "args": {...}})` arguments:
`stopReason: "error"`, `errorMessage: "Provider returned an incomplete or malformed tool call"`.
5. OpenClaw only retries or triggers model fallbacks on HTTP status errors (`429`, `502`, `503`, `504`) prior to stream start—not when an HTTP `200 OK` SSE stream terminates mid-generation with a malformed tool-call chunk.
- **Root Cause & Fleet Forensics**:
1. **`z-ai/glm-5.3-flash` Nested Tool-Call Fragility on `streamlake/fp8` & `siliconflow/fp8`**:
- Across October 1–17, 2026, `Provider returned an incomplete or malformed tool call` occurred **12 times** across `main` (6×), `grabengasse_media` (3×), `koch` (1×), `hebamme` (1×), and `doktor` (1×)—exclusively on `z-ai/glm-5.3-flash`, always right after `tool_describe` when emitting nested `tool_call` JSON payloads.
2. **Suboptimal OpenRouter Provider Routing**:
- `extra_body.provider.order` for `z-ai/glm-5.3-flash` listed `"cloudflare"` first (which does not host `glm-5.3-flash` on OpenRouter), falling through to `streamlake/fp8` (91.7% uptime) and `siliconflow/fp8` (74.1% uptime), and did not block `fp4` (`nvfp4`) quantizations (`phala`, `atlas-cloud`, `parasail`).
3. **Fleet-Wide Cost & Cache-Hit Economics (`DeepSeek V4.1 Flash` vs. `GLM 5.3 Flash`)**:
- Over the 17-day period (Oct 1–17, 2026), 73% to 94% of all tokens across interactive agents (`21.93M` `cacheRead` vs. `8.94M` uncached input) and background heartbeats (`22.86M` `cacheRead` vs. `7.73M` uncached input) were prompt cache reads.
- While `deepseek/deepseek-v4.1-flash` has a slightly higher base uncached prompt rate (`$0.14–$0.15/M` vs. `$0.06–$0.075/M`), its top `fp8` providers (`deepinfra` and `streamlake`, both 100% uptime and full `tool_choice` support) charge **3.5× to 10× less for `cacheRead` tokens** (`$0.0042/M` on DeepInfra, `$0.0030/M` on StreamLake vs. `$0.015–$0.030/M` on GLM 5.3 Flash).
- Consequently, running the entire fleet (interactive agents + background heartbeats) on `deepseek/deepseek-v4.1-flash` via `deepinfra`/`streamlake` costs **~$2.69 over 17 days (~$4.75/month)** vs. **$4.50 actual on `GLM 5.3 Flash` (~$7.95/month)**—a **~40% net cost reduction** alongside 0 malformed tool calls.
4. **Cold Rotational HDD Read Stall After Nightly Backups**:
- Nightly backups (`03:30`) and compaction (`04:45`) evicted the Knowledge Wiki and agent workspace `*.md` files from the Linux kernel page cache, causing the first morning turn's synchronous `system-prompt` loader to block on rotational SATA HDD seeks for 504.5s.
- **Resolution**:
1. **Fleet-Wide Migration to `openrouter/deepseek/deepseek-v4.1-flash-1017` (with `deepseek-v4.1-flash` 0910 as first fallback)**:
- Switched `agents.defaults.model.primary`, `utilityModel`, `heartbeat.model`, `subagents.model`, `memory-core.config.dreaming.model`, and all interactive agent entries (`main`, `fitness`, `koch`, `hebamme`, `travel`, `shopping`, `regensburg`, `recherche`, `doktor`, `itguy`, `grabengasse_media`, `mila_tracker`) to the newly released October 17, 2026 snapshot `openrouter/deepseek/deepseek-v4.1-flash-1017` (Artificial Analysis Intelligence Index 39.5 vs. 24.7 on 0910, and 30% cheaper on DeepInfra at `$0.098/M` prompt, `$0.294/M` completion, `$0.00294/M` `cacheRead`), with `openrouter/deepseek/deepseek-v4.1-flash`, `openrouter/z-ai/glm-5.3-flash`, and `openrouter/qwen/qwen3.8-flash` as fallbacks (`nutrition_tracker` remains on `openrouter/google/gemini-3.8-flash`).
2. **Preserved Decoupled Vision Perception (Issue 29 Compatibility)**:
- Registered both `deepseek/deepseek-v4.1-flash-1017` and `deepseek/deepseek-v4.1-flash` with `input: ["text"]` under `models.providers.openrouter.models` in `openclaw-instance.secret.yaml` so OpenClaw continues routing all image attachments through `tools.media.image` (`google/gemini-3.8-flash`) for OCR before passing enriched text to DeepSeek V4.1 Flash.
3. **Hardened OpenRouter Provider & Quantization Routing (`extra_body.provider`)**:
- Configured `openrouter/deepseek/deepseek-v4.1-flash-1017` with `order: ["deepinfra", "inference-net", "parasail", "cloudflare"]`, `quantizations: ["fp8", "bf16", "unknown"]`, and `ignore: ["morph", "baseten", "deepseek", "open-inference", "decart", "sail-research", "wafer"]` (live routing tests showed `morph/fp8` is rejected by OpenRouter's `Filter by Tool Compatibility` when `tools` are passed, `deepseek` is blocked by account training-opt-out guardrails, and `baseten` returns HTTP 429).
- Configured fallback `openrouter/deepseek/deepseek-v4.1-flash` (`0910`) with `order: ["deepinfra", "streamlake", "inference-net", "digitalocean", "venice", "nebius"]` and `quantizations: ["fp8", "bf16", "unknown"]`.
- Cleaned up `openrouter/z-ai/glm-5.3-flash` fallback routing (`order: ["morph", "near-ai", "digitalocean", "venice", "streamlake"]`, `quantizations: ["fp8", "bf16", "unknown"]`, blocking `fp4`).
4. **Direct MCP Tool Fastpaths & 1-Turn Sonos Playbook for `grabengasse_media` (Topic 164)**:
- Added exact MCP tool IDs (`mcp:spotify:spotify__search`, `mcp:spotify:spotify__play`, `mcp:spotify:spotify__transfer_playback`, `mcp:jelly:jelly__jelly_search`, `mcp:jelly:jelly__jelly_request`, `mcp:homeassistant:homeassistant__ha_get_state`, `mcp:homeassistant:homeassistant__ha_call_service`) as well as all verified Sonos entity IDs (`media_player.bad`, `media_player.wohnzimmer`, `media_player.kuche_sonos`, `media_player.kuche_ikea`, `media_player.dining_room`) and Sonos `source_list` favorites (`"Fusion 2026 Sets"`, `"ChilloutTechno"`, `"deephouse"`, `"OberDITSCHI"`, etc.) directly to `openclaw-instance.secret.yaml`, `knowledge-wiki/agents/grabengasse_media.md`, and `/home/openclaw/.openclaw/workspace-grabengasse_media/AGENTS.md`.
- Eliminates both the 2 MCP discovery turns (`tool_search` + `tool_describe`) and the 2 file-read turns (`read("USER.md")`, `read("MEMORY.md")`, `read("SKILL.md")`).
5. **Extended Linux Page-Cache Pre-Warming to Knowledge Wiki & Workspace Markdown Files**:
- Updated `k8s/openclaw/scripts/gateway-preflight.mjs` and `k8s/openclaw/scripts/compact_sessions.py` (which now also runs `prewarm_page_cache()` at the end of the 04:45 nightly run) to pre-read all Knowledge Wiki (`*.md`, `*.jsonl`) and `workspace*/` `*.md` files into RAM, preventing morning `system-prompt` disk freezes.
6. **SQLite `session_nodes` Trigger (`entry_valid`) & Subagent Vector Re-Indexing Fix (`extraPaths: []`)**:
- Discovered that SQLite `session_nodes` has an `AFTER UPDATE OF entry_json` trigger (`session_nodes_entry_valid_after_entry_update`) that resets `entry_valid = 0` whenever `entry_json` is modified, throwing `SessionCanonicalKeyMigrationRequiredError` until `UPDATE session_nodes SET entry_valid = 1` is executed.
- Hardened `k8s/openclaw/scripts/recover_sessions.py` to always enforce `PRAGMA foreign_keys = ON` before deleting trailing error turns, set `entry_valid = 1` across all rows, and reset `attempts = 0` on pending `channel_ingress_events` in `state/openclaw.sqlite`.
- Explicitly set `memory.search.extraPaths: []` at the top level in `openclaw-instance.secret.yaml` and `dedup_subagent_vectors.py` so `mergeMode: merge` does not leave `extraPaths` active globally in `openclaw.json`, preventing subagents from re-embedding 4,059 wiki chunks on their first turn after startup.
- **How to Avoid Recurrence**:
- Always enforce `quantizations: ["fp8", "bf16"]` in OpenRouter `extra_body.provider` for tool-calling agents so OpenRouter never routes to degraded `fp4` (`nvfp4`) endpoints.
- Whenever updating `entry_json` in `session_nodes` via SQLite, always follow up with `UPDATE session_nodes SET entry_valid = 1` in a separate statement (after the `AFTER UPDATE OF entry_json` trigger fires), and always set `PRAGMA foreign_keys = ON` before deleting from `transcript_events`.
- Keep Knowledge Wiki and workspace Markdown files in the nightly `prewarm_page_cache()` pass so morning turns hit RAM (`<50ms`) instead of cold rotational disks.
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -34,7 +34,7 @@ jobs:
expr: "30 3 * * *"
tz: "Europe/Berlin"
agent: "fitness"
model: "openrouter/deepseek/deepseek-v4.1-flash"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
@@ -58,7 +58,7 @@ jobs:
expr: "0 3 * * 0"
tz: "Europe/Berlin"
agent: "itguy"
model: "openrouter/deepseek/deepseek-v4.1-flash"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
@@ -83,7 +83,7 @@ jobs:
expr: "30 6 * * 5"
tz: "Europe/Berlin"
agent: "recherche"
model: "openrouter/deepseek/deepseek-v4.1-flash"
model: "openrouter/deepseek/deepseek-v4.1-flash-1017"
session: "isolated"
channel: "telegram"
to: "-1004326921510"
Binary file not shown.
+4 -2
View File
@@ -41,11 +41,12 @@ TIER_A_TARGETS = [
]
def prewarm_page_cache():
print("=== Pre-warming SQLite databases into OS page cache ===")
print("=== Pre-warming SQLite databases and Knowledge Wiki into OS page cache ===")
t0 = time.time()
try:
subprocess.run(
"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true",
"cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; "
"find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true",
shell=True,
timeout=90.0
)
@@ -329,6 +330,7 @@ def main():
compact_active_sessions()
checkpoint_wal_journals()
vacuum_and_optimize()
prewarm_page_cache()
if __name__ == "__main__":
main()
+47 -33
View File
@@ -1,34 +1,48 @@
#!/usr/bin/env python3
import glob, os, sqlite3, sys
#!/usr/bin/env python3
import glob, json, os, sqlite3, sys
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
cfg_path = "/home/node/.openclaw/openclaw.json"
if os.path.exists(cfg_path):
try:
with open(cfg_path, "r") as f:
cfg = json.load(f)
mem_search = cfg.get("memory", {}).get("search", {})
if mem_search.get("extraPaths"):
mem_search["extraPaths"] = []
with open(cfg_path, "w") as f:
json.dump(cfg, f, indent=2)
print("[dedup_subagents] Cleared top-level memory.search.extraPaths from openclaw.json (scoped to main only)")
except Exception as e:
print(f"[dedup_subagents] Warning checking openclaw.json: {e}", file=sys.stderr)
vec_path = "/app/node_modules/sqlite-vec-linux-x64/vec0.so"
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
agent = os.path.basename(os.path.dirname(os.path.dirname(db)))
if agent == "main":
continue
try:
conn = sqlite3.connect(db, timeout=10.0)
if os.path.exists(vec_path):
conn.enable_load_extension(True)
conn.load_extension(vec_path)
cur = conn.cursor()
chk = cur.execute("SELECT count(*) FROM memory_index_chunks").fetchone()[0]
if chk > 0:
print(f"[dedup_subagents] Purging {chk} duplicated vector chunks from agent {agent}...")
for t in [
"memory_index_sources",
"memory_index_chunks",
"memory_index_chunk_recall_metadata",
"memory_index_chunk_provenance",
"memory_entry_origins",
"memory_embedding_cache",
"memory_index_chunks_vec"
]:
try: cur.execute(f"DELETE FROM {t}")
except Exception: pass
conn.commit()
cur.execute("VACUUM")
print(f"[dedup_subagents] Reclaimed disk space for agent {agent}")
conn.close()
except Exception as e:
print(f"[dedup_subagents] Notice for {agent}: {e}", file=sys.stderr)
+72 -72
View File
@@ -1,78 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { execSync, spawn } from "node:child_process";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
if (!process.env.__OPENCLAW_PREFLIGHT_DONE) {
process.env.__OPENCLAW_PREFLIGHT_DONE = "1";
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
const isGateway = process.argv.some(arg => arg.endsWith("openclaw.mjs") || arg.endsWith("openclaw")) && process.argv.includes("gateway");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
if (isGateway) {
const t0 = Date.now();
console.log("[gateway-preflight] Initializing container self-healing preflight...");
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
// 1. Clean up stale gateway lock files from ungraceful kills
for (const dir of ["/tmp/openclaw-1000", "/tmp"]) {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith("gateway") && file.endsWith(".lock")) {
const fullPath = path.join(dir, file);
fs.unlinkSync(fullPath);
console.log(`[gateway-preflight] Removed stale lock: ${fullPath}`);
}
}
} catch (e) {
console.warn(`[gateway-preflight] Lock cleanup warning in ${dir}:`, e.message);
}
}
}
// 2. Clear false database quarantines
const qPath = "/home/node/.openclaw/state/openclaw-quarantine.sqlite";
if (fs.existsSync(qPath)) {
try {
execSync(`/home/node/.openclaw/.local/bin/python3 -c "import sqlite3; c=sqlite3.connect(\x27${qPath}\x27); c.execute(\x27DELETE FROM quarantined_databases\x27); c.commit(); c.close()"`, { stdio: "ignore" });
console.log("[gateway-preflight] Cleared database quarantine.");
} catch (_) {}
}
// 3. Deduplicate subagent vector databases (keeps non-main agent DBs < 15MB)
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/dedup_subagent_vectors.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Vector deduplication warning:", e.message);
}
// 4. Pre-warm page cache sequentially (transforms random 70 IOPS disk reads into sequential streaming)
try {
const pw0 = Date.now();
execSync("cat /home/node/.openclaw/state/openclaw.sqlite /home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite > /dev/null 2>&1 || true; find /home/node/.openclaw/wiki/main /home/node/.openclaw/workspace* -maxdepth 3 -name '*.md' -exec cat {} + > /dev/null 2>&1 || true");
console.log(`[gateway-preflight] Pre-warmed SQLite databases and Wiki markdown in ${Date.now() - pw0}ms`);
} catch (_) {}
// 5. Recover any wedged running sessions left over from ungraceful container restart
try {
execSync("/home/node/.openclaw/.local/bin/python3 /home/node/.openclaw/scripts/recover_sessions.py", { stdio: "inherit" });
} catch (e) {
console.warn("[gateway-preflight] Session recovery warning:", e.message);
}
// 6. Spawn background turn replay daemon if interrupted turns were detected
try {
if (fs.existsSync("/tmp/openclaw-interrupted-turns.json")) {
console.log("[gateway-preflight] Spawning background turn replay daemon...");
const child = spawn("/home/node/.openclaw/.local/bin/python3", ["/home/node/.openclaw/scripts/replay_interrupted_turns.py"], {
detached: true,
stdio: "ignore"
});
child.unref();
}
} catch (e) {
console.warn("[gateway-preflight] Could not spawn turn replay daemon:", e.message);
}
console.log(`[gateway-preflight] Self-healing completed in ${Date.now() - t0}ms.`);
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ def triage_agent_session(agent, topic_id=None, limit=10, db_root="/home/node/.op
events.reverse()
print("\n📜 RECENT TRANSCRIPT EVENTS (Chronological):")
last_role = None
for seq, ca_ms, ej_str in events:
try:
+91 -75
View File
@@ -1,80 +1,96 @@
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
#!/usr/bin/env python3
import glob, sqlite3, json, os, sys, time
t0 = time.time()
recovered = 0
interrupted_turns = []
t0 = time.time()
recovered = 0
interrupted_turns = []
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
def extract_prompt(msg):
if not msg: return ""
content = msg.get("content")
if isinstance(content, str): return content
if isinstance(content, list):
parts = []
for c in content:
if isinstance(c, dict): parts.append(c.get("text") or c.get("content") or "")
elif isinstance(c, str): parts.append(c)
return "\n".join(filter(None, parts))
return str(content or "")
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id FROM session_nodes WHERE status = \"running\"")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status = \"running\"")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
for db in sorted(glob.glob("/home/node/.openclaw/agents/*/agent/openclaw-agent.sqlite")):
try:
conn = sqlite3.connect(db, timeout=10.0)
cursor = conn.cursor()
while True:
cursor.execute("PRAGMA foreign_key_check;")
violations = cursor.fetchall()
if not violations: break
for table, rowid, parent, fkid in violations:
cursor.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
cursor.execute("SELECT session_key, entry_json, current_session_id, status FROM session_nodes WHERE status IN (\"running\", \"failed\", \"error\")")
rows = cursor.fetchall()
agent_id = os.path.basename(os.path.dirname(os.path.dirname(db)))
for sk, ej, csid, st in rows:
try:
p = json.loads(ej) if ej else {}
sid = csid or p.get("sessionId")
if sid:
ev = cursor.execute("SELECT seq, event_json FROM transcript_events WHERE session_id = ? ORDER BY seq DESC LIMIT 1", (sid,)).fetchone()
if ev:
ev_seq, ev_raw = ev
try:
ev_data = json.loads(ev_raw)
msg = ev_data.get("message") or ev_data
if st == "running" and msg.get("role") == "user":
prompt_text = extract_prompt(msg)
ctx = p.get("restartRecoveryDeliveryContext") or p.get("delivery", {}).get("context") or p.get("delivery", {}).get("route", {}).get("context") or {}
interrupted_turns.append({
"agent": agent_id,
"session_key": sk,
"session_id": sid,
"context": ctx,
"prompt": prompt_text
})
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
elif msg.get("role") == "assistant" and msg.get("stopReason") == "error":
cursor.execute("PRAGMA foreign_keys = ON")
cursor.execute("DELETE FROM transcript_events WHERE session_id = ? AND seq = ?", (sid, ev_seq))
except Exception as ex:
print(f"[recover_sessions] Error parsing event: {ex}", file=sys.stderr)
p["status"] = "done"
p["activeWriterRunId"] = None
p["activeClaimRunId"] = None
p["restartRecoveryDeliveryRunId"] = None
cursor.execute("UPDATE session_nodes SET status = \"done\", entry_json = ? WHERE session_key = ?", (json.dumps(p), sk))
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE session_key = ?", (sk,))
recovered += 1
except Exception: pass
cursor.execute("UPDATE session_nodes SET entry_valid = 1 WHERE entry_valid != 1")
cursor.execute("UPDATE session_windows SET status = \"done\" WHERE status IN (\"running\", \"failed\", \"error\")")
conn.commit()
conn.close()
except Exception as e:
print(f"[recover_sessions] Warning for {db}: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
state_db = "/home/node/.openclaw/state/openclaw.sqlite"
if os.path.exists(state_db):
try:
sconn = sqlite3.connect(state_db, timeout=10.0)
scur = sconn.cursor()
scur.execute("UPDATE channel_ingress_events SET attempts = 0, last_attempt_at = NULL, last_error = NULL WHERE status = 'pending' AND attempts > 0")
sconn.commit()
sconn.close()
except Exception as e:
print(f"[recover_sessions] Warning resetting ingress backoff: {e}", file=sys.stderr)
if interrupted_turns:
try:
with open("/tmp/openclaw-interrupted-turns.json", "w") as f:
json.dump(interrupted_turns, f, indent=2)
print(f"[recover_sessions] Detected {len(interrupted_turns)} interrupted user turn(s) saved to /tmp/openclaw-interrupted-turns.json")
except Exception as e:
print(f"[recover_sessions] Error writing interrupted turns: {e}", file=sys.stderr)
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
print(f"[recover_sessions] Completed in {time.time() - t0:.2f}s ({recovered} wedged sessions recovered)")
@@ -1,97 +1,97 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
#!/usr/bin/env python3
import json, os, subprocess, sys, time, urllib.request
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
INTERRUPTED_FILE = "/tmp/openclaw-interrupted-turns.json"
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
if not os.path.exists(INTERRUPTED_FILE):
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
try:
with open(INTERRUPTED_FILE, "r") as f:
turns = json.load(f)
except Exception as e:
print(f"[replay_interrupted_turns] Failed to read {INTERRUPTED_FILE}: {e}", file=sys.stderr)
sys.exit(0)
if not turns:
sys.exit(0)
if not turns:
sys.exit(0)
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
print(f"[replay_interrupted_turns] Found {len(turns)} interrupted turn(s). Waiting for gateway /healthz...")
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
gateway_ready = False
for _ in range(60):
try:
with urllib.request.urlopen("http://127.0.0.1:18790/healthz", timeout=3) as resp:
if resp.status == 200:
gateway_ready = True
break
except Exception:
pass
time.sleep(2)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
if not gateway_ready:
print("[replay_interrupted_turns] Gateway did not become healthy in 120s. Aborting.", file=sys.stderr)
sys.exit(1)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
# Allow 5s for Telegram ingress to bind
time.sleep(5)
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
for turn in turns:
sk = turn.get("session_key")
sid = turn.get("session_id")
agent_id = turn.get("agent")
ctx = turn.get("context") or {}
prompt = turn.get("prompt") or ""
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
channel = ctx.get("channel") or "telegram"
to_target = ctx.get("to") or ""
thread_id = ctx.get("threadId")
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
target = to_target
if target.startswith("telegram:"):
parts = target.split(":")
target = parts[1] if len(parts) > 1 else target
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
print(f"[replay_interrupted_turns] Processing agent '{agent_id}' on session '{sk}' target='{target}' thread='{thread_id}'")
# 1. Send status notification to Telegram if target exists
if target:
notice = "⚠️ OpenClaw wurde während der Bearbeitung neu gestartet – deine Anfrage wird jetzt nachgeholt..."
notify_cmd = [
"openclaw", "message", "send",
"--channel", channel,
"--target", str(target),
"-m", notice
]
if thread_id is not None:
notify_cmd.extend(["--thread-id", str(thread_id)])
try:
subprocess.run(notify_cmd, check=False, timeout=30)
print(f"[replay_interrupted_turns] Sent recovery notice to {target}")
except Exception as e:
print(f"[replay_interrupted_turns] Notice failed: {e}", file=sys.stderr)
# 2. Replay agent turn
if sk and prompt:
p_short = (prompt[:60] + "...") if len(prompt) > 60 else prompt
print(f"[replay_interrupted_turns] Replaying agent turn: {p_short}")
agent_cmd = [
"openclaw", "agent",
"--session-key", sk,
"-m", prompt,
"--deliver"
]
try:
res = subprocess.run(agent_cmd, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
print(f"[replay_interrupted_turns] Turn replay succeeded for {sk}")
else:
print(f"[replay_interrupted_turns] Turn replay exited with code {res.returncode}: {res.stderr[:200]}", file=sys.stderr)
except Exception as e:
print(f"[replay_interrupted_turns] Replay execution error: {e}", file=sys.stderr)
try:
os.remove(INTERRUPTED_FILE)
print(f"[replay_interrupted_turns] Cleaned up {INTERRUPTED_FILE}")
except Exception:
pass
+1 -1
View File
@@ -54,7 +54,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
--cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \
--agent "fitness" \
--model "openrouter/deepseek/deepseek-v4.1-flash" \
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
--session "isolated" \
--channel "telegram" \
--to "$TELEGRAM_GROUP" \
+1 -1
View File
@@ -48,7 +48,7 @@ kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER_NAME" -- openclaw cron a
--cron "$CRON_SCHEDULE" \
--tz "$TIMEZONE" \
--agent "itguy" \
--model "openrouter/deepseek/deepseek-v4.1-flash" \
--model "openrouter/deepseek/deepseek-v4.1-flash-1017" \
--session "isolated" \
--channel "telegram" \
--to "$TELEGRAM_GROUP" \
+142 -31
View File
@@ -734,44 +734,93 @@ class DuckDBEngine:
today_wet_count = int(diaper_agg[1]) if diaper_agg and diaper_agg[1] is not None else 0
today_dirty_count = int(diaper_agg[2]) if diaper_agg and diaper_agg[2] is not None else 0
# 5. 7d and 30d sleep averages
# 5. 7d and 30d sleep averages (use completed days < today_str when available)
avg_7 = self.con.execute(
"""
f"""
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2),
ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
FROM (
SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
FROM v_sleep_daily
WHERE subject = 'mila' AND log_date < '{today_str}'
ORDER BY log_date DESC LIMIT 7
)
"""
).fetchone()
if not avg_7 or avg_7[0] is None:
avg_7 = self.con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2),
ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count
FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—"
avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—"
avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—"
avg_30 = self.con.execute(
"""
f"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
FROM (
SELECT total_sleep_hours FROM v_sleep_daily
WHERE subject = 'mila' AND log_date < '{today_str}'
ORDER BY log_date DESC LIMIT 30
)
"""
).fetchone()
if not avg_30 or avg_30[0] is None:
avg_30 = self.con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
"""
).fetchone()
avg_30_tot = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—"
feed_avg_7 = self.con.execute(
"""
f"""
SELECT ROUND(AVG(total_consumed_ml), 0), ROUND(AVG(total_solid_g), 0),
ROUND(AVG(total_kcal), 0), ROUND(AVG(bottle_count), 1)
FROM (SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
FROM v_feeding_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
FROM (
SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
FROM v_feeding_daily
WHERE subject = 'mila' AND log_date < '{today_str}'
ORDER BY log_date DESC LIMIT 7
)
"""
).fetchone()
if not feed_avg_7 or feed_avg_7[0] is None:
feed_avg_7 = self.con.execute(
"""
SELECT ROUND(AVG(total_consumed_ml), 0), ROUND(AVG(total_solid_g), 0),
ROUND(AVG(total_kcal), 0), ROUND(AVG(bottle_count), 1)
FROM (SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count
FROM v_feeding_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
diaper_avg_7 = self.con.execute(
"""
f"""
SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1)
FROM (SELECT wet_count, total_changes
FROM v_diaper_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
FROM (
SELECT wet_count, total_changes
FROM v_diaper_daily
WHERE subject = 'mila' AND log_date < '{today_str}'
ORDER BY log_date DESC LIMIT 7
)
"""
).fetchone()
if not diaper_avg_7 or diaper_avg_7[0] is None:
diaper_avg_7 = self.con.execute(
"""
SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1)
FROM (SELECT wet_count, total_changes
FROM v_diaper_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
# 6. Past 30 Days History Table & sessions
history_rows_data = self.con.execute(
@@ -837,10 +886,18 @@ class DuckDBEngine:
for r in history_rows_data:
d_str = str(r[0])
d_obj = datetime.date.fromisoformat(d_str)
nh, nm = divmod(r[2] or 0, 60)
d_sessions = sessions_by_date.get(d_str, [])
d_sessions = list(sessions_by_date.get(d_str, []))
row_night_min = int(r[2] or 0)
row_nap_min = int(r[4] or 0)
row_tot_hours = round(r[1], 2) if r[1] is not None else 0.0
if d_str == today_str and row_night_min == 0 and today_night_min > 0:
row_night_min = today_night_min
row_tot_hours = round((row_night_min + row_nap_min) / 60.0, 2)
nh, nm = divmod(row_night_min, 60)
night_durs = [s["duration_minutes"] for s in d_sessions if s["type"] == "night" and s["duration_minutes"]]
longest_night = max(night_durs) if night_durs else int(r[2] or 0)
longest_night = max(night_durs) if night_durs else row_night_min
wake_windows = []
day_sessions = [s for s in d_sessions if s["type"] == "nap" and s.get("local_start") and s.get("local_end")]
@@ -860,12 +917,12 @@ class DuckDBEngine:
"weekday": WEEKDAYS_DE[d_obj.weekday()],
"formatted_date": d_obj.strftime("%d.%m."),
"is_today": (d_str == today_str),
"total_hours": round(r[1], 2) if r[1] is not None else 0.0,
"night_minutes": int(r[2] or 0),
"total_hours": row_tot_hours,
"night_minutes": row_night_min,
"night_duration_str": f"{nh} h {nm:02d} min" if nh > 0 else f"{nm} min",
"nap_minutes": int(r[4] or 0),
"nap_minutes": row_nap_min,
"nap_count": int(r[3] or 0),
"nap_str": f"{r[3]} ({r[4]} min)",
"nap_str": f"{r[3]} ({row_nap_min} min)",
"longest_night_stretch_min": longest_night,
"avg_wake_window_min": avg_wake,
"feed_bottles_ml": int(r[5]),
@@ -1457,6 +1514,37 @@ class DuckDBEngine:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def _parse_event_dt(
self,
raw: str,
now_dt: datetime.datetime,
actor: str = "",
base_date: Optional[str] = None,
) -> datetime.datetime:
s = raw.strip()
m_time = re.fullmatch(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$", s)
if m_time:
d_part = base_date or now_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d")
hh = int(m_time.group(1))
mm = int(m_time.group(2))
ss = int(m_time.group(3) or 0)
return datetime.datetime.fromisoformat(f"{d_part}T{hh:02d}:{mm:02d}:{ss:02d}").replace(tzinfo=BERLIN_TZ)
dt = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
if dt.tzinfo is None:
return dt.replace(tzinfo=BERLIN_TZ)
if s.endswith("+00:00") or s.endswith("Z"):
dt_wall_berlin = dt.replace(tzinfo=BERLIN_TZ)
# Guard against LLM agents formatting local Europe/Berlin wall-clock times with +00:00 / Z
if (
(dt > now_dt + datetime.timedelta(minutes=5) and dt_wall_berlin <= now_dt + datetime.timedelta(minutes=5))
or ((actor or "").startswith("agent:") and dt.second == 0 and dt.microsecond == 0)
):
return dt_wall_berlin
return dt.astimezone(BERLIN_TZ)
def add_event(self, req: EventCreateRequest) -> Dict[str, Any]:
with self._lock:
event_id = str(ULID())
@@ -1476,9 +1564,7 @@ class DuckDBEngine:
start_dt = (now_dt - delta).astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
start_dt = datetime.datetime.fromisoformat(req.start)
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
start_dt = self._parse_event_dt(req.start, now_dt, actor=req.actor, base_date=req.date)
start_iso = start_dt.isoformat()
elif req.since:
delta = parse_duration(req.since)
@@ -1488,21 +1574,48 @@ class DuckDBEngine:
start_dt = now_dt.astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
start_dt = datetime.datetime.fromisoformat(req.start)
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
start_dt = self._parse_event_dt(req.start, now_dt, actor=req.actor)
start_iso = start_dt.isoformat()
end_iso = None
end_dt = None
if req.end:
end_dt = datetime.datetime.fromisoformat(req.end)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
end_dt = self._parse_event_dt(
req.end,
now_dt,
actor=req.actor,
base_date=start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d"),
)
if end_dt < start_dt:
raise ValueError(f"End time {req.end} cannot be earlier than start time {req.start}")
end_iso = end_dt.isoformat()
# Populate Mila standard recipe defaults when omitted by tracking agents
if req.subject == "mila":
if req.kind == "feeding.solid" or (req.kind == "nutrition.intake" and "brei" in str(req.payload.get("item") or req.payload.get("type") or "").lower()):
if req.kind == "nutrition.intake":
req.kind = "feeding.solid"
has_grams = any(req.payload.get(k) not in (None, 0) for k in ("amount_g", "consumed_g", "qty"))
if not has_grams:
item_l = str(req.payload.get("item") or req.meal or "").lower()
if any(w in item_l for w in ("abend", "nachmittag", "dinner")):
req.payload["amount_g"] = 200
req.payload.setdefault("kcal", 110)
else:
req.payload["amount_g"] = 190
req.payload.setdefault("kcal", 135)
elif req.kind == "feeding.bottle":
c_ml = req.payload.get("consumed_ml")
a_ml = req.payload.get("amount_ml")
l_ml = req.payload.get("left_ml")
if (c_ml is None or c_ml == 0) and (a_ml is None or a_ml == 0):
if l_ml is not None:
req.payload["amount_ml"] = 270
req.payload["consumed_ml"] = max(0, 270 - int(l_ml))
else:
req.payload["amount_ml"] = 270
req.payload["consumed_ml"] = 270
# 15-Minute Duplicate Guard
if not req.allow_duplicate:
if req.kind in ("nutrition.intake", "feeding.bottle", "feeding.solid"):
@@ -1722,9 +1835,7 @@ class DuckDBEngine:
delta = parse_duration(req.since)
end_dt = (now_dt - delta).astimezone(BERLIN_TZ)
elif req.end_time:
end_dt = datetime.datetime.fromisoformat(req.end_time)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
end_dt = self._parse_event_dt(req.end_time, now_dt, actor=req.actor)
else:
end_dt = now_dt.astimezone(BERLIN_TZ)
+4 -4
View File
@@ -43,7 +43,7 @@ class GitSyncManager:
try:
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
if (self.wiki_dir / ".git").is_dir():
if (self.wiki_dir / ".git").exists():
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
except Exception as e:
@@ -64,7 +64,7 @@ class GitSyncManager:
"""Ensures repository exists and is initialized."""
with self._lock:
self._configure_git_client()
if (self.wiki_dir / ".git").is_dir():
if (self.wiki_dir / ".git").exists():
try:
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
@@ -144,7 +144,7 @@ class GitSyncManager:
def pull_rebase(self) -> bool:
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
if not (self.wiki_dir / ".git").is_dir():
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
@@ -208,7 +208,7 @@ class GitSyncManager:
def commit_and_push(self, commit_msg: str) -> bool:
"""Stages data/, commits with descriptive message, rebases and pushes."""
if not (self.wiki_dir / ".git").is_dir():
if not (self.wiki_dir / ".git").exists():
return False
with self._lock:
+18 -15
View File
@@ -21,23 +21,26 @@ logger = logging.getLogger("duckdb_backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Initializing DuckDB Backend...")
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
if getattr(app.state, "git_sync", None) is None or getattr(app.state, "engine", None) is None:
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
yield
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
try:
yield
finally:
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
else:
yield
app = FastAPI(
+6 -5
View File
@@ -13,13 +13,14 @@ from app.main import app
@pytest.fixture(scope="session")
def test_data_dir():
# Use real wiki data dir if exists, else temporary copy
temp_root = Path(tempfile.mkdtemp())
temp_data = temp_root / "data"
if DATA_DIR.is_dir():
yield DATA_DIR
shutil.copytree(DATA_DIR, temp_data)
else:
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
temp_data.mkdir(parents=True, exist_ok=True)
yield temp_data
shutil.rmtree(temp_root, ignore_errors=True)
@pytest.fixture
@@ -127,3 +127,50 @@ def test_duplicate_open_sleep_api_endpoint(client: TestClient):
"subject": "test_baby",
})
assert stop_res.status_code == 200
def test_agent_utc_wallclock_guard_and_mila_recipe_defaults(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Agent passes +00:00 on local wall-clock times (e.g. 08:30 to 10:20)
nap_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T08:30:00+00:00",
end="2026-10-17T10:20:00+00:00",
payload={"type": "nap", "location": "bett"},
)
res_nap = engine.add_event(nap_req)
assert res_nap["status"] == "success"
assert res_nap["record"]["startIso"] == "2026-10-17T08:30:00+02:00"
assert res_nap["record"]["endIso"] == "2026-10-17T10:20:00+02:00"
assert res_nap["record"]["sleepTimeSeconds"] == 6600
# 2. Solid food without amount_g gets standard recipe defaults
brei_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T12:20:00+00:00",
meal="lunch",
payload={"item": "mittagsbrei"},
)
res_brei = engine.add_event(brei_req)
assert res_brei["record"]["start"] == "2026-10-17T12:20:00+02:00"
assert res_brei["record"]["payload"]["amount_g"] == 190
assert res_brei["record"]["payload"]["kcal"] == 135
abend_req = EventCreateRequest(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-17T16:40:00+00:00",
meal="dinner",
payload={"item": "abendbrei"},
)
res_abend = engine.add_event(abend_req)
assert res_abend["record"]["start"] == "2026-10-17T16:40:00+02:00"
assert res_abend["record"]["payload"]["amount_g"] == 200
assert res_abend["record"]["payload"]["kcal"] == 110
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.DS_Store
+69 -3
View File
@@ -178,7 +178,16 @@ const liveCounterStr = computed(() => {
// Wake Window status evaluation (sweet spot 135 to 180 min, i.e. 2h15m to 3h00m)
const wakeWindowStatus = computed(() => {
if (!data.value || data.value.live_status.is_sleeping) return null
const mins = data.value.live_status.wake_window_minutes ?? 0
const ls = data.value.live_status
let mins = ls.wake_window_minutes ?? 0
if (ls.last_wake_iso) {
try {
const wakeTime = new Date(ls.last_wake_iso).getTime()
if (!isNaN(wakeTime)) {
mins = Math.max(0, Math.floor((nowEpoch.value - wakeTime) / 60000))
}
} catch {}
}
if (mins < 135) {
return {
text: 'Entspannt wach',
@@ -255,8 +264,21 @@ const lastFeedDetail = computed(() => {
const diaperStatus = computed(() => {
if (!data.value) return null
const ls = data.value.live_status
const elapsed = ls.last_diaper_since_minutes ?? (ls as any).last_diaper_elapsed_minutes
const elapsedStool = (ls as any).last_stool_elapsed_minutes
let elapsed = ls.last_diaper_since_minutes ?? (ls as any).last_diaper_elapsed_minutes
let elapsedStool = (ls as any).last_stool_elapsed_minutes
if ((ls as any).last_diaper_iso) {
try {
const t = new Date((ls as any).last_diaper_iso).getTime()
if (!isNaN(t)) elapsed = Math.max(0, Math.floor((nowEpoch.value - t) / 60000))
} catch {}
}
if ((ls as any).last_stool_iso) {
try {
const t = new Date((ls as any).last_stool_iso).getTime()
if (!isNaN(t)) elapsedStool = Math.max(0, Math.floor((nowEpoch.value - t) / 60000))
} catch {}
}
const formatElapsed = (mins: number | null | undefined) => {
if (mins === null || mins === undefined) return null
@@ -549,6 +571,50 @@ const currentAgeMonths = computed(() => {
</button>
<div v-show="isTodayTimelineOpen" class="space-y-4 pt-2 border-t border-zinc-800/60">
<!-- Night Sleep Breakdown (if started today) -->
<div v-if="data.today.nights && data.today.nights.length > 0" class="space-y-2">
<div class="text-xs font-bold text-indigo-400 uppercase tracking-wider flex items-center gap-1.5">
<span>🌙</span>
<span>Nachtschlaf ({{ data.today.nights.length }} erfasst)</span>
</div>
<div class="space-y-2">
<div
v-for="(ns, idx) in data.today.nights"
:key="ns.id || idx"
class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60 hover:border-indigo-500/30 transition text-xs sm:text-sm"
>
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400 font-mono text-xs font-bold">
🌙
</div>
<div>
<div class="font-semibold text-zinc-200 flex items-center gap-2">
<span>{{ ns.start }} – {{ ns.end || 'schläft gerade' }} Uhr</span>
<span v-if="ns.is_open" class="w-2 h-2 rounded-full bg-indigo-400 animate-pulse"></span>
</div>
<div class="text-[11px] text-zinc-400">
<span v-if="ns.location">({{ ns.location }})</span>
<span v-if="ns.note"> · {{ ns.note }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-bold text-indigo-400 font-mono">
{{ ns.duration_minutes ? `${ns.duration_minutes} min` : 'in Ausführung' }}
</span>
<button
v-if="ns.id"
@click="handleDeleteItem(ns.id)"
title="Nachtschlaf löschen"
class="p-1 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- Naps Breakdown -->
<div class="space-y-2">
<div class="text-xs font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1.5">
+75 -21
View File
@@ -57,7 +57,10 @@ const bars = computed(() => {
let nightHours = 0
let napHours = 0
if (day.sessions && day.sessions.length > 0) {
if (typeof day.night_minutes === 'number' || typeof day.nap_minutes === 'number') {
nightHours = Math.max(0, (day.night_minutes || 0) / 60)
napHours = Math.max(0, (day.nap_minutes || 0) / 60)
} else if (day.sessions && day.sessions.length > 0) {
for (const s of day.sessions) {
const mins = s.duration_minutes || 0
if (s.type === 'night') {
@@ -71,7 +74,7 @@ const bars = computed(() => {
napHours = Math.max(0, day.total_hours - nightHours)
}
const totalHours = Math.max(0, day.total_hours)
const totalHours = Math.max(0, day.total_hours, nightHours + napHours)
const yTotal = getY(totalHours)
const yNight = getY(nightHours)
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
@@ -172,6 +175,59 @@ function getMinuteFromTime(timeStr: string): number {
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
}
interface RasterBlock {
startMin: number
durationMin: number
type: 'night' | 'nap'
label: string
title: string
}
function getDay24hBlocks(day: HistoryRow, dayIdx: number): RasterBlock[] {
const blocks: RasterBlock[] = []
// 1. Check previous calendar day (props.history[dayIdx + 1]) for overnight sleep spilling into 00:00..wake_time
const prevDay = props.history[dayIdx + 1]
if (prevDay?.sessions) {
for (const s of prevDay.sessions) {
if (!s.start) continue
const sMin = getMinuteFromTime(s.start)
const dur = s.duration_minutes || 60
if (sMin + dur > 1440) {
const morningMin = Math.min(1440, (sMin + dur) - 1440)
if (morningMin > 0) {
blocks.push({
startMin: 0,
durationMin: morningMin,
type: s.type === 'night' ? 'night' : 'nap',
label: `${morningMin}m`,
title: `Nachtschlaf (Vorabend ${s.start} – ${s.end || 'morgens'}): ${morningMin} min nach 00:00 Uhr (${s.duration_minutes} min gesamt)`,
})
}
}
}
}
// 2. Add sessions starting on this calendar day (clamped to 24:00)
if (day.sessions) {
for (const s of day.sessions) {
if (!s.start) continue
const sMin = getMinuteFromTime(s.start)
const dur = s.duration_minutes || 60
const clampedDur = Math.max(1, Math.min(dur, 1440 - sMin))
blocks.push({
startMin: sMin,
durationMin: clampedDur,
type: s.type === 'night' ? 'night' : 'nap',
label: `${clampedDur}m`,
title: `${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`,
})
}
}
return blocks
}
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
@@ -474,7 +530,7 @@ function handlePointer(event: MouseEvent | TouchEvent) {
<!-- Raster Bars -->
<div class="space-y-1.5">
<div
v-for="day in rasterDays"
v-for="(day, dayIdx) in rasterDays"
:key="day.date"
class="flex items-center gap-2 group"
>
@@ -494,24 +550,22 @@ function handlePointer(event: MouseEvent | TouchEvent) {
</div>
<!-- Sessions Blocks -->
<template v-if="day.sessions && day.sessions.length > 0">
<template v-for="(s, sIdx) in day.sessions" :key="sIdx">
<!-- If valid start time -->
<div
v-if="s.start"
:style="{
left: `${(getMinuteFromTime(s.start) / 1440) * 100}%`,
width: `${Math.max(1.2, ((s.duration_minutes || 60) / 1440) * 100)}%`,
}"
:class="s.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
:title="`${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`"
>
<span v-if="(s.duration_minutes || 0) >= 45" class="truncate px-0.5">
{{ s.duration_minutes }}m
</span>
</div>
</template>
<template v-if="getDay24hBlocks(day, dayIdx).length > 0">
<div
v-for="(b, bIdx) in getDay24hBlocks(day, dayIdx)"
:key="bIdx"
:style="{
left: `${(b.startMin / 1440) * 100}%`,
width: `${Math.max(1.2, (b.durationMin / 1440) * 100)}%`,
}"
:class="b.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
:title="b.title"
>
<span v-if="b.durationMin >= 45" class="truncate px-0.5">
{{ b.label }}
</span>
</div>
</template>
<div v-else class="text-[10px] text-zinc-600 italic px-2 py-0.5">Keine Einzelschläfchen</div>
</div>
+2
View File
@@ -36,6 +36,8 @@ export interface HistoryRow {
formatted_date: string
is_today: boolean
total_hours: number
night_minutes?: number
nap_minutes?: number
night_duration_str: string
nap_str: string
feed_bottles_ml: number
+30
View File
@@ -376,6 +376,23 @@ resource "authentik_application" "hosting_family" {
protocol_provider = authentik_provider_proxy.hosting_family.id
}
# 11. Family Portal (family.haumdaucher.de)
resource "authentik_provider_proxy" "family_portal" {
name = "Family Portal"
external_host = "https://family.haumdaucher.de"
mode = "forward_single"
authorization_flow = data.authentik_flow.default_authorization_flow.id
invalidation_flow = data.authentik_flow.default_invalidation_flow.id
intercept_header_auth = true
access_token_validity = "days=30"
}
resource "authentik_application" "family_portal" {
name = "Family Portal"
slug = "family-portal"
protocol_provider = authentik_provider_proxy.family_portal.id
}
# --- Outpost: Embedded Proxy Outpost ---
resource "authentik_outpost" "embedded" {
@@ -392,6 +409,7 @@ resource "authentik_outpost" "embedded" {
authentik_provider_proxy.n8n.id,
authentik_provider_proxy.silverbullet.id,
authentik_provider_proxy.hosting_family.id,
authentik_provider_proxy.family_portal.id,
]
}
@@ -789,6 +807,18 @@ resource "authentik_policy_binding" "hosting_family_family" {
order = 1
}
resource "authentik_policy_binding" "family_portal_admin" {
target = authentik_application.family_portal.uuid
group = authentik_group.infra_admins.id
order = 0
}
resource "authentik_policy_binding" "family_portal_family" {
target = authentik_application.family_portal.uuid
group = authentik_group.family.id
order = 1
}
# 2. Shared & Member Applications (Accessible to all authenticated users)
resource "authentik_policy_binding" "grafana_users" {
+151 -5
View File
@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
"serial": 423,
"serial": 429,
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
"outputs": {
"coder_oauth_client_secret": {
@@ -217,7 +217,7 @@
"id": "7",
"is_active": true,
"is_superuser": false,
"last_login": "2026-09-05T14:23:02.674777Z",
"last_login": "2026-10-12T06:28:53.399647Z",
"name": "Amelie Graf",
"path": "users",
"pk": 7,
@@ -306,6 +306,41 @@
}
]
},
{
"mode": "managed",
"type": "authentik_application",
"name": "family_portal",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"backchannel_providers": null,
"group": "",
"id": "family-portal",
"meta_description": "",
"meta_hide": false,
"meta_icon": "",
"meta_launch_url": "",
"meta_publisher": "",
"name": "Family Portal",
"open_in_new_tab": false,
"policy_engine_mode": "any",
"protocol_provider": 17,
"slug": "family-portal",
"uuid": "48cb7294-f186-4fa9-80d8-007b9ebb8f62"
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_provider_proxy.family_portal",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_application",
@@ -1155,7 +1190,8 @@
3,
10,
15,
16
16,
17
],
"service_connection": "",
"type": "proxy"
@@ -1165,6 +1201,7 @@
"private": "bnVsbA==",
"dependencies": [
"authentik_provider_proxy.alertmanager",
"authentik_provider_proxy.family_portal",
"authentik_provider_proxy.gitea_phpmyadmin",
"authentik_provider_proxy.hass_coder",
"authentik_provider_proxy.hosting_family",
@@ -1251,6 +1288,73 @@
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
"name": "family_portal_admin",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"enabled": true,
"failure_result": false,
"group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
"id": "f9dffd65-0fe7-4384-a2e1-684ff2a85475",
"negate": false,
"order": 0,
"policy": "",
"target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
"timeout": 30,
"user": 0
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_application.family_portal",
"authentik_group.infra_admins",
"authentik_provider_proxy.family_portal",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
"name": "family_portal_family",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"enabled": true,
"failure_result": false,
"group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
"id": "033b6b44-6a29-4d01-b7f1-750b33070541",
"negate": false,
"order": 1,
"policy": "",
"target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
"timeout": 30,
"user": 0
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_application.family_portal",
"authentik_group.family",
"authentik_provider_proxy.family_portal",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow",
"data.authentik_user.amy"
]
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
@@ -2302,6 +2406,48 @@
}
]
},
{
"mode": "managed",
"type": "authentik_provider_proxy",
"name": "family_portal",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"access_token_validity": "days=30",
"authentication_flow": "",
"authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
"basic_auth_enabled": false,
"basic_auth_password_attribute": "",
"basic_auth_username_attribute": "",
"client_id": "4HO1RbjsgDq3JI5K4VeXF3SFCmeCTePXs3466tfz",
"cookie_domain": "",
"external_host": "https://family.haumdaucher.de",
"id": "17",
"intercept_header_auth": true,
"internal_host": "",
"internal_host_ssl_validation": true,
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
"jwks_sources": null,
"jwt_federation_providers": null,
"jwt_federation_sources": null,
"mode": "forward_single",
"name": "Family Portal",
"property_mappings": null,
"refresh_token_validity": "days=30",
"skip_path_regex": ""
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_provider_proxy",
@@ -2410,8 +2556,8 @@
"internal_host_ssl_validation": true,
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
"jwks_sources": null,
"jwt_federation_providers": null,
"jwt_federation_sources": null,
"jwt_federation_providers": [],
"jwt_federation_sources": [],
"mode": "forward_single",
"name": "Hosting Family",
"property_mappings": null,
@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
"serial": 417,
"serial": 423,
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
"outputs": {
"coder_oauth_client_secret": {
@@ -211,7 +211,8 @@
"date_joined": "2026-09-05T14:23:01.528947Z",
"email": "",
"groups": [
"ad33b003-77a4-4571-bec8-8301f6d6552b"
"ad33b003-77a4-4571-bec8-8301f6d6552b",
"5e91f468-a0f2-4433-aa23-a7b6ad69df38"
],
"id": "7",
"is_active": true,
@@ -495,6 +496,41 @@
}
]
},
{
"mode": "managed",
"type": "authentik_application",
"name": "hosting_family",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"backchannel_providers": null,
"group": "",
"id": "hosting-family",
"meta_description": "",
"meta_hide": false,
"meta_icon": "",
"meta_launch_url": "",
"meta_publisher": "",
"name": "Hosting Family",
"open_in_new_tab": false,
"policy_engine_mode": "any",
"protocol_provider": 16,
"slug": "hosting-family",
"uuid": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3"
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_provider_proxy.hosting_family",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_application",
@@ -1007,8 +1043,8 @@
"id": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
"is_superuser": false,
"name": "family",
"parents": null,
"roles": null,
"parents": [],
"roles": [],
"users": [
7
]
@@ -1118,7 +1154,8 @@
7,
3,
10,
15
15,
16
],
"service_connection": "",
"type": "proxy"
@@ -1130,6 +1167,7 @@
"authentik_provider_proxy.alertmanager",
"authentik_provider_proxy.gitea_phpmyadmin",
"authentik_provider_proxy.hass_coder",
"authentik_provider_proxy.hosting_family",
"authentik_provider_proxy.n8n",
"authentik_provider_proxy.nextcloud_phpmyadmin",
"authentik_provider_proxy.openclaw",
@@ -1393,6 +1431,73 @@
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
"name": "hosting_family_admin",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"enabled": true,
"failure_result": false,
"group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
"id": "55b2f74f-f650-40a2-9781-a00a5978e1e8",
"negate": false,
"order": 0,
"policy": "",
"target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
"timeout": 30,
"user": 0
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_application.hosting_family",
"authentik_group.infra_admins",
"authentik_provider_proxy.hosting_family",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
"name": "hosting_family_family",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"enabled": true,
"failure_result": false,
"group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
"id": "b598a889-f04b-4955-8a01-6558cfaad60e",
"negate": false,
"order": 1,
"policy": "",
"target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
"timeout": 30,
"user": 0
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"authentik_application.hosting_family",
"authentik_group.family",
"authentik_provider_proxy.hosting_family",
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow",
"data.authentik_user.amy"
]
}
]
},
{
"mode": "managed",
"type": "authentik_policy_binding",
@@ -2281,6 +2386,48 @@
}
]
},
{
"mode": "managed",
"type": "authentik_provider_proxy",
"name": "hosting_family",
"provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"access_token_validity": "days=30",
"authentication_flow": "",
"authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
"basic_auth_enabled": false,
"basic_auth_password_attribute": "",
"basic_auth_username_attribute": "",
"client_id": "27XovQEtW8nMAgUN6kANZnQZMMbWVEbi8Mh6VsGP",
"cookie_domain": "",
"external_host": "https://hosting.haumdaucher.de",
"id": "16",
"intercept_header_auth": true,
"internal_host": "",
"internal_host_ssl_validation": true,
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
"jwks_sources": null,
"jwt_federation_providers": null,
"jwt_federation_sources": null,
"mode": "forward_single",
"name": "Hosting Family",
"property_mappings": null,
"refresh_token_validity": "days=30",
"skip_path_regex": ""
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA==",
"dependencies": [
"data.authentik_flow.default_authorization_flow",
"data.authentik_flow.default_invalidation_flow"
]
}
]
},
{
"mode": "managed",
"type": "authentik_provider_proxy",