Skip to content

Commit e39e9aa

Browse files
committed
Merge main, resolve Cargo.toml conflict
2 parents 7021f2f + f9d3128 commit e39e9aa

15 files changed

Lines changed: 1400 additions & 10 deletions

File tree

Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,6 @@ emojis = "0.8"
9797

9898
# TLS (shared crypto backend for slack-morphism, reqwest, teloxide)
9999
rustls = { version = "0.23", default-features = false, features = ["ring"] }
100-
# slack-morphism enables tokio-tungstenite/rustls-native-certs, but tokio-tungstenite 0.28
101-
# restructured features — rustls-tls-native-roots is now needed to activate actual TLS.
102100
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] }
103101

104102
# Telegram

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ Every memory has a type, an importance score, and graph edges connecting it to r
112112
- **Memory import** — dump files into the `ingest/` folder and Spacebot extracts structured memories automatically. Supports text, markdown, and PDF files. Migrating from OpenClaw? Drop your markdown memory files in and walk away.
113113
- **Cross-channel recall** — branches can read transcripts from other conversations
114114
- **Memory bulletin** — the cortex generates a periodic briefing of the agent's knowledge, injected into every conversation
115+
- **Warmup readiness contract** — branch/worker/cron dispatch checks `ready_for_work` (warm state + embedding ready + fresh bulletin), records cold-dispatch metrics, and triggers background forced warmup without blocking channels
115116

116117
### Scheduling
117118

docs/content/docs/(configuration)/config.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ worker_timeout_secs = 300
9191
branch_timeout_secs = 60
9292
circuit_breaker_threshold = 3 # consecutive failures before auto-disable
9393

94+
# Warmup controls for cold-start behavior and manual rewarm.
95+
[defaults.warmup]
96+
enabled = true
97+
eager_embedding_load = true
98+
refresh_secs = 900
99+
startup_delay_secs = 5
100+
94101
# Browser automation for workers.
95102
[defaults.browser]
96103
enabled = true
@@ -214,6 +221,7 @@ Most config values are hot-reloaded when their files change. Spacebot watches `c
214221
| `context_window` | Yes | Next compaction/worker check uses new size |
215222
| `max_concurrent_branches` | Yes | Next branch spawn checks new limit |
216223
| Browser config | Yes | Next worker spawn uses new config |
224+
| Warmup config | Yes | Next warmup pass uses new values |
217225
| Identity files (SOUL.md, etc.) | Yes | Next channel message renders new identity |
218226
| Skills (SKILL.md files) | Yes | Next message / worker spawn sees new skills |
219227
| Bindings | Yes | Next message routes using new bindings |
@@ -467,6 +475,23 @@ Thresholds are fractions of `context_window`.
467475
| `branch_timeout_secs` | integer | 60 | Branch timeout before cancellation |
468476
| `circuit_breaker_threshold` | integer | 3 | Consecutive failures before auto-disable |
469477

478+
### `[defaults.warmup]`
479+
480+
| Key | Type | Default | Description |
481+
|-----|------|---------|-------------|
482+
| `enabled` | bool | true | Enable background warmup loop |
483+
| `eager_embedding_load` | bool | true | Warm embedding model before first recall/write workload |
484+
| `refresh_secs` | integer | 900 | Seconds between background warmup passes |
485+
| `startup_delay_secs` | integer | 5 | Delay before first warmup pass after boot |
486+
487+
Dispatch readiness is derived from warmup runtime state:
488+
489+
- warmup state must be `warm`
490+
- embedding must be ready
491+
- bulletin age must be fresh (<= `max(60s, refresh_secs * 2)`)
492+
493+
When branch/worker/cron dispatch happens before readiness is satisfied, Spacebot still dispatches, increments cold-dispatch metrics, and queues a forced warmup pass in the background.
494+
470495
### `[defaults.browser]`
471496

472497
| Key | Type | Default | Description |

docs/content/docs/(core)/cortex.mdx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,81 @@ branch_timeout_secs = 60
161161
circuit_breaker_threshold = 3
162162
```
163163

164+
## Warmup API
165+
166+
Warmup is exposed via the agent API so you can inspect cold/warm state and trigger a re-warm pass on demand.
167+
168+
### `GET /api/agents/warmup?agent_id=...`
169+
170+
Returns warmup status for one agent. If `agent_id` is omitted, returns statuses for all agents.
171+
172+
```json
173+
{
174+
"statuses": [
175+
{
176+
"agent_id": "main",
177+
"status": {
178+
"state": "warm",
179+
"embedding_ready": true,
180+
"last_refresh_unix_ms": 1763935800123,
181+
"last_error": null,
182+
"bulletin_age_secs": 42
183+
}
184+
}
185+
]
186+
}
187+
```
188+
189+
### `POST /api/agents/warmup`
190+
191+
Triggers a one-shot warmup pass for one agent or all agents.
192+
193+
```json
194+
{
195+
"agent_id": "main",
196+
"force": false
197+
}
198+
```
199+
200+
- `agent_id` optional: omit to trigger all agents.
201+
- `force` optional: when `true`, runs the warmup pass even if warmup is disabled in config.
202+
203+
Response:
204+
205+
```json
206+
{
207+
"status": "warming",
208+
"forced": false,
209+
"accepted_agents": ["main"]
210+
}
211+
```
212+
213+
### Warmup States
214+
215+
- `cold` — warmup is disabled or has not run yet.
216+
- `warming` — a warmup pass is currently in progress.
217+
- `warm` — latest warmup pass succeeded.
218+
- `degraded` — last warmup pass failed; `last_error` contains the reason.
219+
220+
### Readiness Contract
221+
222+
Branch, worker, and cron dispatch paths consult a derived `ready_for_work` signal:
223+
224+
- warmup state is `warm`
225+
- embedding model is ready
226+
- bulletin age is fresh (<= `max(60s, refresh_secs * 2)`)
227+
228+
If dispatch arrives while not ready, Spacebot does **not** block the channel or scheduler:
229+
230+
- dispatch continues in degraded-safe mode
231+
- a forced warmup pass is queued in the background
232+
- readiness telemetry is emitted
233+
234+
Metrics:
235+
236+
- `spacebot_dispatch_while_cold_count{agent_id,dispatch_type,reason}`
237+
- `spacebot_warmup_recovery_latency_ms{agent_id,dispatch_type}`
238+
164239
## Failure Modes
165240

166241
**What if the cortex bulletin fails?**

src/agent/channel.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,7 @@ pub async fn spawn_branch_from_state(
13251325
&description,
13261326
&system_prompt,
13271327
&description,
1328+
"branch",
13281329
)
13291330
.await
13301331
}
@@ -1352,10 +1353,49 @@ async fn spawn_memory_persistence_branch(
13521353
&prompt,
13531354
&system_prompt,
13541355
"persisting memories...",
1356+
"memory_persistence_branch",
13551357
)
13561358
.await
13571359
}
13581360

1361+
fn ensure_dispatch_readiness(state: &ChannelState, dispatch_type: &'static str) {
1362+
let readiness = state.deps.runtime_config.work_readiness();
1363+
if readiness.ready {
1364+
return;
1365+
}
1366+
1367+
let reason = readiness
1368+
.reason
1369+
.map(|value| value.as_str())
1370+
.unwrap_or("unknown");
1371+
tracing::warn!(
1372+
agent_id = %state.deps.agent_id,
1373+
channel_id = %state.channel_id,
1374+
dispatch_type,
1375+
reason,
1376+
warmup_state = ?readiness.warmup_state,
1377+
embedding_ready = readiness.embedding_ready,
1378+
bulletin_age_secs = ?readiness.bulletin_age_secs,
1379+
stale_after_secs = readiness.stale_after_secs,
1380+
"dispatch requested before readiness contract was satisfied"
1381+
);
1382+
1383+
#[cfg(feature = "metrics")]
1384+
crate::telemetry::Metrics::global()
1385+
.dispatch_while_cold_count
1386+
.with_label_values(&[&*state.deps.agent_id, dispatch_type, reason])
1387+
.inc();
1388+
1389+
let warmup_config = **state.deps.runtime_config.warmup.load();
1390+
let should_trigger = readiness.warmup_state != crate::config::WarmupState::Warming
1391+
&& (readiness.reason != Some(crate::config::WorkReadinessReason::EmbeddingNotReady)
1392+
|| warmup_config.eager_embedding_load);
1393+
1394+
if should_trigger {
1395+
crate::agent::cortex::trigger_forced_warmup(state.deps.clone(), dispatch_type);
1396+
}
1397+
}
1398+
13591399
/// Shared branch spawning logic.
13601400
///
13611401
/// Checks the branch limit, clones history, creates a Branch, spawns it as
@@ -1366,6 +1406,7 @@ async fn spawn_branch(
13661406
prompt: &str,
13671407
system_prompt: &str,
13681408
status_label: &str,
1409+
dispatch_type: &'static str,
13691410
) -> std::result::Result<BranchId, AgentError> {
13701411
let max_branches = **state.deps.runtime_config.max_concurrent_branches.load();
13711412
{
@@ -1377,6 +1418,7 @@ async fn spawn_branch(
13771418
});
13781419
}
13791420
}
1421+
ensure_dispatch_readiness(state, dispatch_type);
13801422

13811423
let history = {
13821424
let h = state.history.read().await;
@@ -1472,6 +1514,7 @@ pub async fn spawn_worker_from_state(
14721514
suggested_skills: &[&str],
14731515
) -> std::result::Result<WorkerId, AgentError> {
14741516
check_worker_limit(state).await?;
1517+
ensure_dispatch_readiness(state, "worker");
14751518
let task = task.into();
14761519

14771520
let rc = &state.deps.runtime_config;
@@ -1582,6 +1625,7 @@ pub async fn spawn_opencode_worker_from_state(
15821625
interactive: bool,
15831626
) -> std::result::Result<crate::WorkerId, AgentError> {
15841627
check_worker_limit(state).await?;
1628+
ensure_dispatch_readiness(state, "opencode_worker");
15851629
let task = task.into();
15861630
let directory = std::path::PathBuf::from(directory);
15871631

0 commit comments

Comments
 (0)