From 5e10d4d5a9858c3f1c638d9db1ae95f582ac9017 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Sun, 5 Jul 2026 21:28:28 +0200
Subject: [PATCH 001/240] fix(acpproc): widen set_model retry schedule for
large-context model warm-up (mitto-8qp)
Resolves RPC context-deadline storms (~157/day, sonnet-5) caused by an
aggressive shrinking retry budget that timed out during large-context
model warm-up. The prior attempt-1 timeout (12s) was smaller than the
observed warm-up latency of large-context models (e.g.
claude-sonnet-5-0-500k, >12s), so every attempt of the shrinking
{12s,8s,5s} schedule timed out even though most of the outer 90s
setModelAsyncCallerBudget sat unused.
Widen the per-attempt schedule to {20s,15s,8s} (~44s total per caller),
which still stays within the contention bound covered by
setModelAsyncCallerBudget at up to 4 concurrent callers. Update all
mirrored comments/docs describing the old 25s math, and add a
cross-package mirrored-constants-drift convention note so future
schedule changes remember to update internal/conversation's local
mirror.
Add TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget to
internal/acpproc/acp_process_manager_test.go.
---
.augment/rules/01-go-conventions.md | 6 ++
internal/acpproc/acp_process_manager.go | 4 +-
internal/acpproc/acp_process_manager_test.go | 98 ++++++++++++++++++--
internal/acpproc/shared_acp_process.go | 37 ++++----
internal/conversation/constraints_test.go | 5 +-
5 files changed, 122 insertions(+), 28 deletions(-)
diff --git a/.augment/rules/01-go-conventions.md b/.augment/rules/01-go-conventions.md
index 9fb096822..647be22f9 100644
--- a/.augment/rules/01-go-conventions.md
+++ b/.augment/rules/01-go-conventions.md
@@ -81,6 +81,12 @@ for attempt := 1; attempt <= maxAttempts; attempt++ {
**Key principle**: Only *tighten* deadlines, never extend. `shouldFailFastCreateAttempt` bails when remaining budget < per-attempt timeout.
+### Cross-Package Mirrored Constants Drift
+
+When a package can't import another (e.g. `internal/conversation` can't import `internal/acpproc`), it may hardcode a **local mirror** of a schedule/budget constant, flagged with a comment like `// Mirror of shared_acp_process.go set_model constants` (see `internal/conversation/constraints_test.go`'s `scheduleSum`). Changing the source constant does **not** auto-update the mirror.
+
+**Rule**: When changing a retry schedule/timeout constant, `grep` for comments referencing it (e.g. the old sum/values) across the whole module — not just the owning package — and update every mirror + stale doc comment describing the old math in the same change.
+
## Explicit Lock Management in Retry Loops
`defer mu.Unlock()` does **not** compose safely with manual unlock + retry. If the locked variable is reassigned during retry, defer fires on the wrong object → double-unlock panic.
diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go
index bf2e7917b..21195eded 100644
--- a/internal/acpproc/acp_process_manager.go
+++ b/internal/acpproc/acp_process_manager.go
@@ -856,8 +856,8 @@ func (m *ACPProcessManager) getOrCreateAuxiliarySession(ctx context.Context, wor
// Budget: setModelAsyncCallerBudget (90s) derived from m.ctx (NOT the caller
// ctx, which is short-lived and may expire before the goroutine runs).
// Worst-case: setModelSem queued behind ~3 other holders each taking up to
- // 3×8s + jitter backoff (≤25s each) → ~75s wait before the semaphore is
- // acquired. Since this is off the critical path, a generous budget has no
+ // the schedule {20s,15s,8s} + jitter backoff (~44s each) before the semaphore
+ // is acquired. Since this is off the critical path, a generous budget has no
// UX cost. m.ctx cancels on manager shutdown as a safety backstop.
capturedWorkspaceUUID := workspaceUUID
capturedPurpose := purpose
diff --git a/internal/acpproc/acp_process_manager_test.go b/internal/acpproc/acp_process_manager_test.go
index c61b1b5a5..a8ad4b982 100644
--- a/internal/acpproc/acp_process_manager_test.go
+++ b/internal/acpproc/acp_process_manager_test.go
@@ -808,8 +808,9 @@ func TestSetModelAsyncBudgetMath(t *testing.T) {
}
// TestSetModelAttemptTimeoutSchedule asserts structural invariants of the per-attempt
-// deadline schedule (mitto-f7q): length tied to max-attempts, attempt-1 sized for cold
-// warm-up, total ≤ 25s (unchanged from prior 3×8s), and non-increasing order.
+// deadline schedule (mitto-f7q; attempt-1 lower bound raised for mitto-8qp): length tied
+// to max-attempts, attempt-1 sized above large-context warm-up latency, total bounded so
+// setModelAsyncCallerBudget contention math stays valid, and non-increasing order.
func TestSetModelAttemptTimeoutSchedule(t *testing.T) {
schedule := setSessionModelAttemptTimeouts
@@ -818,18 +819,31 @@ func TestSetModelAttemptTimeoutSchedule(t *testing.T) {
got, setSessionModelMaxAttempts)
}
- // Attempt-1 must be sized above the observed 8s cold-model clamp (p95 evidence).
- if schedule[0] < 12*time.Second {
- t.Errorf("attempt-1 timeout = %v, want >= 12s (sized for cold warm-up p95)", schedule[0])
+ // Attempt-1 must be sized above the genuine warm-up latency of large-context models
+ // (e.g. claude-sonnet-5-0-500k, observed >12s). The prior 12s bound was smaller than
+ // that latency, so attempt-1 always timed out and the shrinking retries were guaranteed
+ // to fail (mitto-8qp). 16s leaves headroom above the observed 12s+ warm-up.
+ if schedule[0] < 16*time.Second {
+ t.Errorf("attempt-1 timeout = %v, want >= 16s (sized above large-context warm-up, mitto-8qp)", schedule[0])
}
- // Total must not exceed 25s so setModelAsyncCallerBudget contention math is valid.
+ // The schedule sum must stay within the contention bound the async budget can cover
+ // (derived exactly as in TestSetModelAsyncBudgetMath): with N=4 concurrent callers the
+ // expected contention coverage is (N-2)×(scheduleSum + maxJitteredBackoff), and that
+ // must not exceed setModelAsyncCallerBudget. Rearranged: scheduleSum must not exceed
+ // budget/(N-2) − maxJitteredBackoff. This replaces the old fixed 25s cap, which forced
+ // attempt-1 too small to cover real warm-up latency (mitto-8qp).
+ const maxConcurrentCallers = 4
+ maxJitteredBackoff := time.Duration(float64(setSessionModelRetryBaseDelay)*float64(setSessionModelMaxAttempts-1)*(1+setSessionModelRetryJitterRatio)) + setSessionModelRetryBaseDelay
+ maxScheduleSum := setModelAsyncCallerBudget/time.Duration(maxConcurrentCallers-2) - maxJitteredBackoff
+
var total time.Duration
for _, d := range schedule {
total += d
}
- if total > 25*time.Second {
- t.Errorf("sum(setSessionModelAttemptTimeouts) = %v, want <= 25s (total must not grow)", total)
+ if total > maxScheduleSum {
+ t.Errorf("sum(setSessionModelAttemptTimeouts) = %v, want <= %v (contention bound; setModelAsyncCallerBudget math)",
+ total, maxScheduleSum)
}
// Timeouts must be non-increasing (front-loaded for cold start).
@@ -841,6 +855,74 @@ func TestSetModelAttemptTimeoutSchedule(t *testing.T) {
}
}
+// simulateSetModelRetryLoop mirrors the per-attempt deadline decision of
+// SharedACPProcess.SetSessionModel: each attempt is granted setSessionModelAttemptTimeouts[i]
+// of budget, an attempt "succeeds" only when the model's real warm-up latency fits within
+// that attempt's budget, and the whole call must complete within outerBudget. It returns
+// the 1-based attempt number that succeeded, or 0 if all attempts timed out / the outer
+// budget was exhausted. Pure (no real sleeps) so the schedule's behavioural contract can
+// be unit-tested deterministically.
+func simulateSetModelRetryLoop(rpcLatency, outerBudget time.Duration) (succeededAttempt int) {
+ var elapsed time.Duration
+ for attempt := 1; attempt <= setSessionModelMaxAttempts; attempt++ {
+ perAttempt := setSessionModelAttemptTimeouts[attempt-1]
+ // The attempt is bounded by both its own per-attempt deadline and the remaining
+ // outer budget (context.WithTimeout(ctx, perAttempt) with ctx carrying outerBudget).
+ remaining := outerBudget - elapsed
+ if remaining <= 0 {
+ return 0
+ }
+ effective := perAttempt
+ if remaining < effective {
+ effective = remaining
+ }
+ if rpcLatency <= effective {
+ return attempt // RPC completed within this attempt's budget.
+ }
+ // Attempt timed out after consuming its effective budget.
+ elapsed += effective
+ }
+ return 0
+}
+
+// TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget reproduces mitto-8qp:
+// the "Aux set_model RPC context-deadline storm". A large-context model (e.g.
+// claude-sonnet-5-0-500k) has a genuine set_model warm-up latency that exceeds attempt-1's
+// budget. Because the schedule SHRINKS (12s -> 8s -> 5s), every subsequent retry has an
+// even smaller budget than the first, so all three attempts are GUARANTEED to fail with
+// "context deadline exceeded" — even though the outer async caller budget
+// (setModelAsyncCallerBudget, ~90s) has 60-90s of unused headroom (per the bead's log
+// evidence: ctx_remaining_ms stayed 68-90s throughout).
+//
+// Expected (correct) behaviour: a model whose warm-up latency is well within the outer
+// budget must eventually succeed via retry. This test asserts that contract and therefore
+// FAILS on the current shrinking schedule (no attempt is >= the model's latency) and will
+// PASS once the fix widens/flattens the per-attempt schedule to cover realistic warm-up
+// latency within the ample outer budget.
+func TestSetModelSchedule_LargeContextModelSucceedsWithinOuterBudget(t *testing.T) {
+ // Observed warm-up latency for a 500k-context model per the bead's logs: attempt-1
+ // consistently burned its full 12s budget (rpc_ms=12000) without completing, i.e. the
+ // true latency is > 12s. 13s is a conservative representative value that is still far
+ // below the ~90s outer async budget.
+ const largeModelWarmupLatency = 13 * time.Second
+
+ if largeModelWarmupLatency >= setModelAsyncCallerBudget {
+ t.Fatalf("test premise invalid: model latency %v must be < outer budget %v",
+ largeModelWarmupLatency, setModelAsyncCallerBudget)
+ }
+
+ attempt := simulateSetModelRetryLoop(largeModelWarmupLatency, setModelAsyncCallerBudget)
+ if attempt == 0 {
+ t.Fatalf("mitto-8qp reproduced: set_model for a %v-warm-up model never succeeded across "+
+ "%d attempts (schedule %v), despite %v of outer budget — the shrinking per-attempt "+
+ "schedule guarantees failure for models whose warm-up exceeds attempt-1's budget",
+ largeModelWarmupLatency, setSessionModelMaxAttempts,
+ setSessionModelAttemptTimeouts, setModelAsyncCallerBudget)
+ }
+ t.Logf("set_model for a %v-warm-up model succeeded on attempt %d (schedule %v, outer budget %v)",
+ largeModelWarmupLatency, attempt, setSessionModelAttemptTimeouts, setModelAsyncCallerBudget)
+}
+
// TestSetModelRetryJitter verifies that the jittered backoff delay applied in
// SetSessionModel's retry loop stays within the expected bounds (mitto-f7q, Option 3).
//
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index 61cf31946..f11633433 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -32,8 +32,9 @@ const (
processStartRetryJitterRatio = 0.3
// setSessionModelMaxAttempts is the maximum number of set_model RPC attempts per call.
- // Schedule {12s,8s,5s} totals 25s per caller + jitter (≤900ms) ≈ 25s, unchanged from
- // the prior 3×8s budget — so setModelSem contention at wakeup is unaffected.
+ // Schedule {20s,15s,8s} totals 43s per caller + jitter (≤1.2s) ≈ 44s (attempt-1 widened
+ // for mitto-8qp so large-context warm-up fits). The total stays within the contention
+ // bound covered by setModelAsyncCallerBudget (see TestSetModelAsyncBudgetMath).
setSessionModelMaxAttempts = 3
// setSessionModelRetryBaseDelay is the base backoff between set_model retry attempts.
setSessionModelRetryBaseDelay = 300 * time.Millisecond
@@ -41,7 +42,7 @@ const (
// added to each retry backoff. Jitter in [0, base×ratio) de-correlates concurrent callers
// that would otherwise retry in lock-step (mitto-f7q, Option 3).
// With ratio=0.5: attempt-2 delay ∈ [300ms, 450ms), attempt-3 ∈ [600ms, 750ms).
- // Total per-caller worst-case: sum(schedule) + 750ms ≈ 25s.
+ // Total per-caller worst-case: sum(schedule) + ~1.2s jitter ≈ 44s.
setSessionModelRetryJitterRatio = 0.5
// sessionCreateMaxAttempts is the maximum number of session/new RPC attempts per call.
@@ -68,9 +69,9 @@ const (
// setModelAsyncCallerBudget is the context timeout given to the background goroutine
// that performs the aux-session model switch asynchronously (mitto-f7q, Option 4).
// Budget reasoning: the capacity-1 setModelSem may be held by up to ~3 concurrent callers,
- // each taking at most ~25s (3×8s + jitter). Semaphore wait ≤ 3×25s = 75s; adding slack
- // for our own retries gives ~100s worst-case. 90s covers the expected contention at server
- // wakeup (≤4 concurrent aux sessions) while avoiding an indefinite hang if the process
+ // each taking at most ~44s (schedule {20s,15s,8s} + jitter). 90s covers the EXPECTED
+ // contention at server wakeup — (N-2)×perCallerMax with N=4, i.e. 2×~44s ≈ 88s ≤ 90s
+ // (see TestSetModelAsyncBudgetMath) — while avoiding an indefinite hang if the process
// is unhealthy. m.ctx cancels on manager shutdown as a hard backstop.
setModelAsyncCallerBudget = 90 * time.Second
@@ -91,7 +92,8 @@ const (
// This mirrors the child-session de-stagger pattern (constraintModelSwitchChildStartupJitter
// in internal/conversation/bgsession_config.go, introduced for mitto-x4e). The jitter
// waits on m.ctx — not the budget context — so it does NOT consume the 90 s budget.
- // Do NOT increase the sum of setSessionModelAttemptTimeouts — the total must stay ≈25s.
+ // Do NOT increase the sum of setSessionModelAttemptTimeouts beyond the contention bound
+ // enforced by TestSetModelAsyncBudgetMath (≈43.8s at 4 concurrent callers).
auxModelSwitchStartupJitter = 10 * time.Second
// processInitializeAttemptTimeout is the per-attempt deadline for the ACP Initialize
@@ -133,16 +135,19 @@ const (
)
// setSessionModelAttemptTimeouts is the per-attempt deadline schedule for set_model RPCs
-// (mitto-f7q). Attempt-1 is sized above the observed cold-model warm-up p95 (~8s) so a
-// cold claude-haiku-4-5 can complete on the first attempt; later attempts shrink to keep
-// the total (12+8+5 = 25s) ≈ constant vs the prior 3×8s, leaving setModelSem contention
-// unchanged. The array length is tied to setSessionModelMaxAttempts at compile time.
-// Do NOT increase the sum — the total must remain ≈25s so setModelAsyncCallerBudget (90s)
-// contention math stays valid.
+// (mitto-f7q; attempt-1 widened for mitto-8qp). Attempt-1 is sized above the genuine
+// warm-up latency of large-context models (e.g. claude-sonnet-5-0-500k, observed >12s):
+// the prior 12s attempt-1 was smaller than that latency, so every attempt of the
+// then-shrinking 12/8/5 schedule timed out with "context deadline exceeded" even though
+// ~60-90s of the outer setModelAsyncCallerBudget (90s) sat unused (mitto-8qp). Later
+// attempts shrink to keep the total bounded so setModelSem contention stays covered by the
+// async budget. The array length is tied to setSessionModelMaxAttempts at compile time.
+// Do NOT let the sum exceed the contention bound enforced by TestSetModelAsyncBudgetMath
+// (≈43.8s at 4 concurrent callers) or setModelAsyncCallerBudget (90s) math stops holding.
var setSessionModelAttemptTimeouts = [setSessionModelMaxAttempts]time.Duration{
- 12 * time.Second, // attempt 1: sized for cold-model warm-up p95
- 8 * time.Second, // attempt 2: standard
- 5 * time.Second, // attempt 3: final, minimal budget
+ 20 * time.Second, // attempt 1: sized for large-context model warm-up (mitto-8qp)
+ 15 * time.Second, // attempt 2: standard retry
+ 8 * time.Second, // attempt 3: final, minimal budget
}
// auxStartupJitter returns a random duration in [0, max) to de-stagger concurrent
diff --git a/internal/conversation/constraints_test.go b/internal/conversation/constraints_test.go
index f49a07861..573354ea0 100644
--- a/internal/conversation/constraints_test.go
+++ b/internal/conversation/constraints_test.go
@@ -213,9 +213,10 @@ func TestConstraintModelSwitchBudgetMath(t *testing.T) {
const (
maxConcurrentCallers = 4 // from bead: ~4 concurrent sessions at wakeup
// Mirror of internal/acpproc/shared_acp_process.go set_model constants.
- // Attempt schedule {12s,8s,5s} sums to 25s — same total as the prior 3×8s (mitto-f7q).
+ // Attempt schedule {20s,15s,8s} sums to 43s (attempt-1 widened for mitto-8qp so
+ // large-context warm-up fits, within the contention bound covered by the budget).
maxRetries = 3 // setSessionModelMaxAttempts
- scheduleSum = 25 * time.Second // sum(setSessionModelAttemptTimeouts)
+ scheduleSum = 43 * time.Second // sum(setSessionModelAttemptTimeouts)
retryBaseDelay = 300 * time.Millisecond // setSessionModelRetryBaseDelay
retryJitterRatio = 0.5 // setSessionModelRetryJitterRatio
)
From af118245913027ca8d50c9a3430acab0ebdafe95 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Sun, 5 Jul 2026 21:28:41 +0200
Subject: [PATCH 002/240] fix(agents): correct mcp-list.sh config paths for
claude-code, codex, cline (mitto-llr)
Audit of vendor-specific MCP server discovery scripts found suspicious
config paths causing missing MCP servers in agent discovery. Corrects
claude-code (now reads ~/.claude.json) and fixes codex and cline
discovery scripts.
Add TestMCPList_RealConfigPaths_mitto_llr table-driven regression test
in internal/agents/manager_test.go.
---
.../builtin/claude-code/cmds/mcp-list.sh | 101 ++++++---
config/agents/builtin/cline/cmds/mcp-list.sh | 103 ++++++---
config/agents/builtin/codex/cmds/mcp-list.sh | 199 +++++++++++++++---
internal/agents/manager_test.go | 100 +++++++++
4 files changed, 423 insertions(+), 80 deletions(-)
diff --git a/config/agents/builtin/claude-code/cmds/mcp-list.sh b/config/agents/builtin/claude-code/cmds/mcp-list.sh
index ac409b52e..e218f1fec 100755
--- a/config/agents/builtin/claude-code/cmds/mcp-list.sh
+++ b/config/agents/builtin/claude-code/cmds/mcp-list.sh
@@ -3,35 +3,84 @@
# Input: {"path": "/optional/workspace/path"} (optional, via stdin)
# Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]}
+# Claude Code stores MCP servers under "mcpServers" in:
+# user: ~/.claude.json (top-level mcpServers)
+# local: ~/.claude.json (per-project entry: projects..mcpServers)
+# project: /.mcp.json (top-level mcpServers, shared/checked-in)
+# NOTE: ~/.claude/settings.json is NOT read for mcpServers (silently ignored by Claude Code).
+# Later scopes override earlier ones by server name.
+
INPUT=$(cat 2>/dev/null || echo '{}')
-CONFIG_FILE="${HOME}/.claude/settings.json"
-if [ ! -f "$CONFIG_FILE" ]; then
- echo '{"servers": []}'
- exit 0
+# Extract optional workspace path from input
+WORKSPACE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
+
+USER_CONFIG="$HOME/.claude.json"
+PROJECT_CONFIG=""
+if [ -n "$WORKSPACE_PATH" ]; then
+ PROJECT_CONFIG="$WORKSPACE_PATH/.mcp.json"
fi
+# Merge mcpServers from all scopes (paths passed via env to avoid quoting issues).
+MITTO_USER_CONFIG="$USER_CONFIG" \
+MITTO_PROJECT_CONFIG="$PROJECT_CONFIG" \
+MITTO_WORKSPACE_PATH="$WORKSPACE_PATH" \
python3 -c "
-import json, sys
-try:
- with open('$CONFIG_FILE') as f:
- config = json.load(f)
- servers = config.get('mcpServers', {})
- result = []
- for name, cfg in servers.items():
- entry = {'name': name}
- if 'command' in cfg:
- entry['command'] = cfg['command']
- if 'args' in cfg:
- entry['args'] = cfg['args']
- if 'url' in cfg:
- entry['url'] = cfg['url']
- if 'env' in cfg:
- entry['env'] = cfg['env']
- if 'headers' in cfg:
- entry['headers'] = cfg['headers']
- result.append(entry)
- print(json.dumps({'servers': result}))
-except Exception:
- print(json.dumps({'servers': []}))
+import json, os
+
+def load_json(path):
+ if not path or not os.path.isfile(path):
+ return None
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except Exception:
+ return None
+
+def servers_of(data):
+ if not isinstance(data, dict):
+ return {}
+ servers = data.get('mcpServers', {})
+ return servers if isinstance(servers, dict) else {}
+
+merged = {}
+
+# 1) user scope: top-level mcpServers in ~/.claude.json
+user_data = load_json(os.environ.get('MITTO_USER_CONFIG', ''))
+for name, cfg in servers_of(user_data).items():
+ if isinstance(cfg, dict):
+ merged[name] = cfg
+
+# 2) local scope: per-project mcpServers keyed by workspace path in ~/.claude.json
+ws = os.environ.get('MITTO_WORKSPACE_PATH', '')
+if ws and isinstance(user_data, dict):
+ projects = user_data.get('projects', {})
+ if isinstance(projects, dict):
+ proj = projects.get(ws, {})
+ for name, cfg in servers_of(proj).items():
+ if isinstance(cfg, dict):
+ merged[name] = cfg
+
+# 3) project scope: /.mcp.json
+proj_data = load_json(os.environ.get('MITTO_PROJECT_CONFIG', ''))
+for name, cfg in servers_of(proj_data).items():
+ if isinstance(cfg, dict):
+ merged[name] = cfg
+
+result = []
+for name, cfg in merged.items():
+ entry = {'name': name}
+ if cfg.get('command'):
+ entry['command'] = cfg['command']
+ if cfg.get('args'):
+ entry['args'] = cfg['args']
+ if cfg.get('url'):
+ entry['url'] = cfg['url']
+ if cfg.get('env'):
+ entry['env'] = cfg['env']
+ if cfg.get('headers'):
+ entry['headers'] = cfg['headers']
+ result.append(entry)
+
+print(json.dumps({'servers': result}))
"
diff --git a/config/agents/builtin/cline/cmds/mcp-list.sh b/config/agents/builtin/cline/cmds/mcp-list.sh
index b8cfa2c74..d5d20df55 100755
--- a/config/agents/builtin/cline/cmds/mcp-list.sh
+++ b/config/agents/builtin/cline/cmds/mcp-list.sh
@@ -3,35 +3,82 @@
# Input: {"path": "/optional/workspace/path"} (optional, via stdin)
# Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]}
-INPUT=$(cat 2>/dev/null || echo '{}')
-CONFIG_FILE="${HOME}/.cline/mcp_settings.json"
+# Cline (VSCode extension "saoudrizwan.claude-dev") stores MCP servers under
+# "mcpServers" in its globalStorage settings file. Location is OS-specific:
+# macOS: ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json
+# Linux: ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json
+# Windows: %APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json
+# CLI/SDK variant: ~/.cline/data/settings/cline_mcp_settings.json
+# Overrides honored: CLINE_MCP_SETTINGS_PATH (full file path), CLINE_DIR (base dir).
+# The first existing candidate wins.
-if [ ! -f "$CONFIG_FILE" ]; then
- echo '{"servers": []}'
- exit 0
-fi
+INPUT=$(cat 2>/dev/null || echo '{}')
python3 -c "
-import json, sys
-try:
- with open('$CONFIG_FILE') as f:
- config = json.load(f)
- servers = config.get('mcpServers', {})
- result = []
- for name, cfg in servers.items():
- entry = {'name': name}
- if 'command' in cfg:
- entry['command'] = cfg['command']
- if 'args' in cfg:
- entry['args'] = cfg['args']
- if 'url' in cfg:
- entry['url'] = cfg['url']
- if 'env' in cfg:
- entry['env'] = cfg['env']
- if 'headers' in cfg:
- entry['headers'] = cfg['headers']
- result.append(entry)
- print(json.dumps({'servers': result}))
-except Exception:
- print(json.dumps({'servers': []}))
+import json, os, sys
+
+home = os.path.expanduser('~')
+rel = os.path.join('saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json')
+
+candidates = []
+
+# Explicit overrides first.
+override = os.environ.get('CLINE_MCP_SETTINGS_PATH', '')
+if override:
+ candidates.append(override)
+cline_dir = os.environ.get('CLINE_DIR', '')
+if cline_dir:
+ candidates.append(os.path.join(cline_dir, 'data', 'settings', 'cline_mcp_settings.json'))
+
+# OS-specific VSCode globalStorage location.
+if sys.platform == 'darwin':
+ candidates.append(os.path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', rel))
+elif sys.platform.startswith('win'):
+ appdata = os.environ.get('APPDATA', os.path.join(home, 'AppData', 'Roaming'))
+ candidates.append(os.path.join(appdata, 'Code', 'User', 'globalStorage', rel))
+else:
+ candidates.append(os.path.join(home, '.config', 'Code', 'User', 'globalStorage', rel))
+
+# CLI/SDK variant.
+candidates.append(os.path.join(home, '.cline', 'data', 'settings', 'cline_mcp_settings.json'))
+
+def load(path):
+ if not path or not os.path.isfile(path):
+ return None
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except Exception:
+ return None
+
+config = None
+for path in candidates:
+ config = load(path)
+ if config is not None:
+ break
+
+servers = {}
+if isinstance(config, dict):
+ s = config.get('mcpServers', {})
+ if isinstance(s, dict):
+ servers = s
+
+result = []
+for name, cfg in servers.items():
+ if not isinstance(cfg, dict):
+ continue
+ entry = {'name': name}
+ if cfg.get('command'):
+ entry['command'] = cfg['command']
+ if cfg.get('args'):
+ entry['args'] = cfg['args']
+ if cfg.get('url'):
+ entry['url'] = cfg['url']
+ if cfg.get('env'):
+ entry['env'] = cfg['env']
+ if cfg.get('headers'):
+ entry['headers'] = cfg['headers']
+ result.append(entry)
+
+print(json.dumps({'servers': result}))
"
diff --git a/config/agents/builtin/codex/cmds/mcp-list.sh b/config/agents/builtin/codex/cmds/mcp-list.sh
index 045265098..ceee3858d 100755
--- a/config/agents/builtin/codex/cmds/mcp-list.sh
+++ b/config/agents/builtin/codex/cmds/mcp-list.sh
@@ -3,35 +3,182 @@
# Input: {"path": "/optional/workspace/path"} (optional, via stdin)
# Output: {"servers": [{"name": "...", "command": "...", "args": [...], "url": "...", "env": {...}}]}
+# Codex stores MCP servers in TOML under [mcp_servers.] tables in:
+# user: ~/.codex/config.toml
+# project: /.codex/config.toml (trusted projects)
+# Each table has: command, args = [...], url, and an [mcp_servers..env] subtable.
+# Later scopes override earlier ones by server name.
+# TOML is parsed via tomllib/tomli when available, else a minimal embedded parser
+# (the machine's python3 may lack tomllib, so we must not depend on py3.11+).
+
INPUT=$(cat 2>/dev/null || echo '{}')
-CONFIG_FILE="${HOME}/.codex/config.json"
-if [ ! -f "$CONFIG_FILE" ]; then
- echo '{"servers": []}'
- exit 0
+# Extract optional workspace path from input
+WORKSPACE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
+
+USER_CONFIG="$HOME/.codex/config.toml"
+PROJECT_CONFIG=""
+if [ -n "$WORKSPACE_PATH" ]; then
+ PROJECT_CONFIG="$WORKSPACE_PATH/.codex/config.toml"
fi
+MITTO_USER_CONFIG="$USER_CONFIG" \
+MITTO_PROJECT_CONFIG="$PROJECT_CONFIG" \
python3 -c "
-import json, sys
-try:
- with open('$CONFIG_FILE') as f:
- config = json.load(f)
- servers = config.get('mcpServers', {})
- result = []
- for name, cfg in servers.items():
- entry = {'name': name}
- if 'command' in cfg:
- entry['command'] = cfg['command']
- if 'args' in cfg:
- entry['args'] = cfg['args']
- if 'url' in cfg:
- entry['url'] = cfg['url']
- if 'env' in cfg:
- entry['env'] = cfg['env']
- if 'headers' in cfg:
- entry['headers'] = cfg['headers']
- result.append(entry)
- print(json.dumps({'servers': result}))
-except Exception:
- print(json.dumps({'servers': []}))
+import json, os, re
+
+def parse_toml(text):
+ # Prefer a real TOML parser when present.
+ try:
+ import tomllib # py3.11+
+ return tomllib.loads(text)
+ except Exception:
+ pass
+ try:
+ import tomli # backport
+ return tomli.loads(text)
+ except Exception:
+ pass
+ return _mini_toml(text)
+
+def _strip_comment(s):
+ # Remove an unquoted trailing '#' comment.
+ out = []
+ in_str = False
+ quote = ''
+ i = 0
+ while i < len(s):
+ c = s[i]
+ if in_str:
+ out.append(c)
+ if c == quote:
+ in_str = False
+ else:
+ if c in ('\"', \"'\"):
+ in_str = True
+ quote = c
+ out.append(c)
+ elif c == '#':
+ break
+ else:
+ out.append(c)
+ i += 1
+ return ''.join(out)
+
+def _parse_value(v):
+ v = v.strip()
+ if not v:
+ return ''
+ if v[0] == '[' and v[-1] == ']':
+ inner = v[1:-1].strip()
+ if not inner:
+ return []
+ # Split top-level commas (values here are simple strings/numbers).
+ items, buf, in_str, quote = [], [], False, ''
+ for c in inner:
+ if in_str:
+ buf.append(c)
+ if c == quote:
+ in_str = False
+ elif c in ('\"', \"'\"):
+ in_str = True
+ quote = c
+ buf.append(c)
+ elif c == ',':
+ items.append(''.join(buf).strip())
+ buf = []
+ else:
+ buf.append(c)
+ if buf:
+ items.append(''.join(buf).strip())
+ return [_parse_scalar(x) for x in items if x != '']
+ return _parse_scalar(v)
+
+def _parse_scalar(v):
+ v = v.strip()
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in ('\"', \"'\"):
+ return v[1:-1]
+ if v == 'true':
+ return True
+ if v == 'false':
+ return False
+ try:
+ if re.fullmatch(r'-?[0-9]+', v):
+ return int(v)
+ return float(v)
+ except Exception:
+ return v
+
+def _mini_toml(text):
+ root = {}
+ cur = root
+ for raw in text.splitlines():
+ line = _strip_comment(raw).strip()
+ if not line:
+ continue
+ if line.startswith('[') and line.endswith(']'):
+ path = line[1:-1].strip()
+ # Split on unquoted dots.
+ parts, buf, in_str, quote = [], [], False, ''
+ for c in path:
+ if in_str:
+ buf.append(c)
+ if c == quote:
+ in_str = False
+ elif c in ('\"', \"'\"):
+ in_str = True
+ quote = c
+ elif c == '.':
+ parts.append(''.join(buf).strip())
+ buf = []
+ else:
+ buf.append(c)
+ if buf:
+ parts.append(''.join(buf).strip())
+ cur = root
+ for p in parts:
+ cur = cur.setdefault(p, {})
+ continue
+ if '=' in line:
+ k, _, v = line.partition('=')
+ cur[k.strip()] = _parse_value(v)
+ return root
+
+def servers_of(data):
+ if not isinstance(data, dict):
+ return {}
+ s = data.get('mcp_servers', {})
+ return s if isinstance(s, dict) else {}
+
+def load(path):
+ if not path or not os.path.isfile(path):
+ return {}
+ try:
+ with open(path) as f:
+ return parse_toml(f.read())
+ except Exception:
+ return {}
+
+merged = {}
+for var in ('MITTO_USER_CONFIG', 'MITTO_PROJECT_CONFIG'):
+ for name, cfg in servers_of(load(os.environ.get(var, ''))).items():
+ if isinstance(cfg, dict):
+ merged[name] = cfg
+
+result = []
+for name, cfg in merged.items():
+ entry = {'name': name}
+ if cfg.get('command'):
+ entry['command'] = cfg['command']
+ if cfg.get('args'):
+ entry['args'] = cfg['args']
+ if cfg.get('url'):
+ entry['url'] = cfg['url']
+ if cfg.get('env'):
+ entry['env'] = cfg['env']
+ if cfg.get('headers'):
+ entry['headers'] = cfg['headers']
+ result.append(entry)
+
+print(json.dumps({'servers': result}))
"
diff --git a/internal/agents/manager_test.go b/internal/agents/manager_test.go
index 042524f87..534146eb4 100644
--- a/internal/agents/manager_test.go
+++ b/internal/agents/manager_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "runtime"
"testing"
"time"
)
@@ -585,6 +586,105 @@ func TestMCPServer_HeadersUnmarshal(t *testing.T) {
}
}
+// TestMCPList_RealConfigPaths_mitto_llr reproduces mitto-llr: the mcp-list.sh
+// scripts for claude-code, codex, and cline read the WRONG on-disk config
+// location (and, for codex, the wrong format), so a genuinely-configured MCP
+// server is silently never surfaced — ListMCPServers fails-soft to an empty
+// list. Each sub-test writes a server config at the tool's REAL location
+// (verified against current vendor docs) under a fake HOME, then runs the real
+// builtin mcp-list.sh via the Manager and asserts the server is surfaced.
+//
+// These sub-tests FAIL until the paths/format are fixed:
+// - claude-code: reads ~/.claude/settings.json; real is ~/.claude.json
+// - codex: reads ~/.codex/config.json (JSON); real is ~/.codex/config.toml (TOML [mcp_servers.*])
+// - cline: reads ~/.cline/mcp_settings.json; real is the VSCode extension globalStorage file
+func TestMCPList_RealConfigPaths_mitto_llr(t *testing.T) {
+ agentsDir, err := filepath.Abs(filepath.Join("..", "..", "config", "agents"))
+ if err != nil {
+ t.Fatalf("failed to resolve agents dir: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(agentsDir, "builtin")); err != nil {
+ t.Fatalf("builtin agents dir not found at %s: %v", agentsDir, err)
+ }
+
+ // clineRealConfigPath returns the canonical Cline VSCode extension MCP
+ // settings location (globalStorage) for the current OS.
+ clineRealConfigPath := func(home string) string {
+ switch runtime.GOOS {
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
+ case "windows":
+ return filepath.Join(home, "AppData", "Roaming", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
+ default:
+ return filepath.Join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
+ }
+ }
+
+ tests := []struct {
+ agent string
+ serverName string
+ // configFor returns (absolute path, file contents) for the config to
+ // write under the given fake HOME.
+ configFor func(home string) (string, string)
+ }{
+ {
+ agent: "claude-code",
+ serverName: "repro-claude",
+ configFor: func(home string) (string, string) {
+ return filepath.Join(home, ".claude.json"),
+ `{"mcpServers":{"repro-claude":{"command":"node","args":["srv.js"],"env":{"API_KEY":"secret"}}}}`
+ },
+ },
+ {
+ agent: "codex",
+ serverName: "repro-codex",
+ configFor: func(home string) (string, string) {
+ return filepath.Join(home, ".codex", "config.toml"),
+ "[mcp_servers.repro-codex]\ncommand = \"node\"\nargs = [\"srv.js\"]\n"
+ },
+ },
+ {
+ agent: "cline",
+ serverName: "repro-cline",
+ configFor: func(home string) (string, string) {
+ return clineRealConfigPath(home),
+ `{"mcpServers":{"repro-cline":{"command":"node","args":["srv.js"]}}}`
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.agent, func(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ cfgPath, cfgBody := tc.configFor(home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0755); err != nil {
+ t.Fatalf("mkdir for config: %v", err)
+ }
+ if err := os.WriteFile(cfgPath, []byte(cfgBody), 0644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ m := NewManager(agentsDir, nil)
+ out, err := m.ListMCPServers(context.Background(), tc.agent, &MCPListInput{Path: home})
+ if err != nil {
+ t.Fatalf("ListMCPServers(%s) error: %v", tc.agent, err)
+ }
+
+ var names []string
+ for _, s := range out.Servers {
+ names = append(names, s.Name)
+ if s.Name == tc.serverName {
+ return // surfaced — behavior is correct
+ }
+ }
+ t.Fatalf("mitto-llr: %s mcp-list.sh did not surface server %q configured at real path %s; got servers=%v (script reads the wrong location)",
+ tc.agent, tc.serverName, cfgPath, names)
+ })
+ }
+}
+
// TestMCPServer_EnvOmitEmpty verifies that an MCPServer with no env vars marshals
// without an "env" key (json:",omitempty"), keeping the listing output clean.
func TestMCPServer_EnvOmitEmpty(t *testing.T) {
From 3b48ef13dcb6d11dc13da91e62eaa5539025abde Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Sun, 5 Jul 2026 21:29:00 +0200
Subject: [PATCH 003/240] fix(acpproc): recycle degraded shared ACP process via
GC health-tier saturation poll (mitto-tfb)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The saturation-detection infrastructure from mitto-13ck.2 tracked
consecutive session/new + LoadSession + set_model context-deadlines but
only ever failed fast — it never recycled the degraded shared process,
so a saturated process could hang around indefinitely.
Add a public, non-mutating IsSaturated() accessor on SharedACPProcess
(distinct from the private isSaturated(), which self-clears to probe
mode) so the GC's health tier can poll saturation state without
perturbing the state machine, and wire it into the GC to proactively
recycle degraded idle shared processes.
Add TestGCHealthTier_RecyclesSaturatedIdleProcess.
---
internal/acpproc/acp_process_gc.go | 111 ++++++++++++++++++++++++
internal/acpproc/acp_process_gc_test.go | 84 ++++++++++++++++++
internal/acpproc/shared_acp_process.go | 14 +++
3 files changed, 209 insertions(+)
diff --git a/internal/acpproc/acp_process_gc.go b/internal/acpproc/acp_process_gc.go
index 08ad28b7b..f593c6854 100644
--- a/internal/acpproc/acp_process_gc.go
+++ b/internal/acpproc/acp_process_gc.go
@@ -234,6 +234,13 @@ func (m *ACPProcessManager) gcLoop() {
// loop prompt due soon), its sessions are GC-suspended and closed and the
// process is stopped to reclaim memory. Disabled when MemoryRecycleThreshold is 0.
//
+// Tier 5 proactively recycles degraded (saturated) idle processes (mitto-tfb): when
+// a shared process has been flagged saturated by the mitto-13ck.2 infra (repeated
+// NewSession/LoadSession deadlines) and is fully idle (same gates as Tier 4), its
+// sessions are GC-suspended and closed and the process is stopped so the next
+// NewSession lazily builds a fresh, healthy process — instead of the degraded one
+// continuing to starve resumes/loop-prompts.
+//
// Tier 3 cleans up auxiliary sessions that have been idle longer than AuxIdleTimeout.
// Cleaned-up sessions are lazily re-created on next use via getOrCreateAuxiliarySession.
func (m *ACPProcessManager) RunGCOnce() {
@@ -625,6 +632,110 @@ gcTier1:
}
}
+ // ----------------------------------------------------------------
+ // Tier 5: proactively recycle degraded (saturated) idle processes (mitto-tfb)
+ // The saturation infra (mitto-13ck.2) only fails fast on new requests; it never
+ // recycles the degraded process, so a shared process that has starved one resume
+ // keeps starving the next resume/loop-prompt. Convert repeated NewSession/
+ // LoadSession deadlines into a fresh process: when a process is flagged saturated
+ // AND fully idle (same hard safety gates as Tier 4), close its sessions and stop
+ // it so the next NewSession lazily builds a healthy replacement. Re-query sessions
+ // so any closed by earlier tiers are excluded.
+ // ----------------------------------------------------------------
+ {
+ sessionsByWorkspace = m.sessionQuery()
+
+ m.mu.RLock()
+ healthUUIDs := make([]string, 0, len(m.processes))
+ for uuid := range m.processes {
+ healthUUIDs = append(healthUUIDs, uuid)
+ }
+ m.mu.RUnlock()
+
+ for _, workspaceUUID := range healthUUIDs {
+ p := m.GetProcess(workspaceUUID)
+ if p == nil {
+ continue
+ }
+
+ // Only act on degraded processes.
+ if !p.IsSaturated() {
+ continue
+ }
+
+ // Hard safety gates: only recycle a fully-idle process (same as Tier 4).
+ if rpcs := p.ActiveRPCs(); rpcs > 0 {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping health recycle (busy)",
+ "workspace_uuid", workspaceUUID,
+ "reason", "in-flight RPCs",
+ "active_rpcs", rpcs)
+ }
+ continue
+ }
+ sessions := sessionsByWorkspace[workspaceUUID]
+ busy := false
+ for _, s := range sessions {
+ if s.IsPrompting {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping health recycle (busy)",
+ "workspace_uuid", workspaceUUID,
+ "reason", "session prompting",
+ "session_id", s.SessionID)
+ }
+ busy = true
+ break
+ }
+ if s.QueueLength > 0 {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping health recycle (busy)",
+ "workspace_uuid", workspaceUUID,
+ "reason", "non-empty queue",
+ "session_id", s.SessionID,
+ "queue_length", s.QueueLength)
+ }
+ busy = true
+ break
+ }
+ if s.NextLoopAt != nil && s.NextLoopAt.Before(now.Add(2*m.gcConfig.Interval)) {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping health recycle (busy)",
+ "workspace_uuid", workspaceUUID,
+ "reason", "loop prompt due soon",
+ "session_id", s.SessionID,
+ "next_loop_at", s.NextLoopAt)
+ }
+ busy = true
+ break
+ }
+ }
+ if busy {
+ continue
+ }
+
+ // Saturated and idle — recycle to reclaim a healthy process.
+ if m.logger != nil {
+ m.logger.Info("GC: recycling saturated idle shared ACP process",
+ "workspace_uuid", workspaceUUID,
+ "session_count", len(sessions))
+ }
+ // Mark each session GC-suspended BEFORE closing so the WebSocket
+ // auto-resume handler skips resume and avoids a thrash loop — same
+ // ordering as Tier 1's loop-suspend and Tier 4's memory-recycle paths.
+ for _, s := range sessions {
+ m.MarkGCSuspended(s.SessionID)
+ m.sessionClose(s.SessionID)
+ }
+ // Stop the now-sessionless process; the next NewSession lazily builds a
+ // fresh one with zeroed saturation state.
+ m.StopProcess(workspaceUUID)
+ // Keep sessionless bookkeeping consistent.
+ m.gcMu.Lock()
+ delete(m.lastSessionSeen, workspaceUUID)
+ m.gcMu.Unlock()
+ }
+ }
+
// ----------------------------------------------------------------
// Tier 3: clean up idle auxiliary sessions
// ----------------------------------------------------------------
diff --git a/internal/acpproc/acp_process_gc_test.go b/internal/acpproc/acp_process_gc_test.go
index 3a50b6319..7d14177a9 100644
--- a/internal/acpproc/acp_process_gc_test.go
+++ b/internal/acpproc/acp_process_gc_test.go
@@ -1474,3 +1474,87 @@ func TestGCTier4_DisabledWhenThresholdZero(t *testing.T) {
t.Error("process should NOT be recycled when memory recycling is disabled")
}
}
+
+// TestGCHealthTier_RecyclesSaturatedIdleProcess reproduces mitto-tfb: a shared
+// ACP process that has become saturated/degraded (repeated session/new + LoadSession
+// context-deadlines tracked by mitto-13ck.2) is NOT recycled by GC, even though it
+// is fully idle and safe to recycle. The saturation infra only fails fast on new
+// requests; nothing converts "repeated deadlines" into a fresh process, so the
+// degraded process keeps starving the next resume / loop-prompt.
+//
+// This test drives the process to saturation via recordRPCTimeout() (mirroring the
+// signal from real session/new timeouts) while keeping RSS well BELOW the Tier 4
+// memory threshold — isolating the HEALTH signal from the memory signal. With the
+// process fully idle (no in-flight RPCs, no prompting/queued/loop-due sessions), a
+// proactive-health GC tier SHOULD recycle it so the next resume lands on a fresh,
+// healthy process.
+//
+// Before the fix this FAILS: no health-based recycle tier exists, so the saturated
+// process survives GC. After the fix it passes: the idle saturated process is
+// GC-suspended, its sessions closed, and the process stopped.
+func TestGCHealthTier_RecyclesSaturatedIdleProcess(t *testing.T) {
+ workspaceUUID := "ws-saturated"
+ proc := newTestSharedProcess()
+
+ // Drive the process to saturation: consecutive RPC timeouts up to the
+ // threshold trip the saturated state (same path real session/new deadlines
+ // take via recordRPCTimeout).
+ for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
+ proc.recordRPCTimeout()
+ }
+ if !proc.isSaturated() {
+ t.Fatalf("test setup: process should be saturated after %d timeouts", sessionSaturationTimeoutThreshold)
+ }
+ // isSaturated() self-clears to a probe when the cooldown elapses; re-trip so
+ // the process is unambiguously saturated for the GC pass below.
+ for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
+ proc.recordRPCTimeout()
+ }
+
+ sessions := map[string][]conversation.SessionInfo{
+ workspaceUUID: {
+ {SessionID: "s1", WorkspaceUUID: workspaceUUID, HasObservers: true},
+ {SessionID: "s2", WorkspaceUUID: workspaceUUID, HasObservers: true},
+ },
+ }
+
+ var mu sync.Mutex
+ closed := make(map[string]bool)
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ closed[id] = true
+ },
+ )
+ m.mu.Lock()
+ m.processes[workspaceUUID] = proc
+ m.mu.Unlock()
+
+ // Keep RSS BELOW the memory threshold so Tier 4 does NOT fire — only the
+ // health/saturation signal should drive the recycle.
+ m.gcConfig.MemoryRecycleThreshold = gcTier4Threshold
+ m.rssSampler = func(p *SharedACPProcess) (uint64, error) { return gcTier4Threshold / 2, nil }
+
+ m.RunGCOnce()
+
+ m.mu.RLock()
+ _, exists := m.processes[workspaceUUID]
+ m.mu.RUnlock()
+ if exists {
+ t.Error("saturated idle process should have been recycled (stopped) by the health tier")
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ for _, id := range []string{"s1", "s2"} {
+ if !closed[id] {
+ t.Errorf("expected session %s to be closed during health recycle", id)
+ }
+ if !m.IsGCSuspended(id) {
+ t.Errorf("expected session %s to be marked GC-suspended before close", id)
+ }
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index f11633433..f3009fee3 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -859,6 +859,20 @@ func (p *SharedACPProcess) isSaturated() bool {
return true
}
+// IsSaturated reports whether the shared process is currently flagged saturated
+// (mitto-tfb Phase 2). Unlike the private isSaturated(), this is a NON-mutating read:
+// it never self-clears to probe mode, so the GC's proactive health-recycle tier can
+// poll it without perturbing the saturation state machine. It returns true while the
+// cooldown window (saturatedUntil) is set and has not yet elapsed.
+func (p *SharedACPProcess) IsSaturated() bool {
+ p.saturationMu.Lock()
+ defer p.saturationMu.Unlock()
+ if p.saturatedUntil.IsZero() {
+ return false
+ }
+ return time.Now().Before(p.saturatedUntil)
+}
+
// rpcErrorCode extracts the JSON-RPC error code from err when it (or any error it
// wraps) is an *acp.RequestError. The second return reports whether a code was
// found. Used to surface a structured, queryable rpc_code on NewSession failures
From 3328eddb214aa0e09637a7c3b091509f140efb82 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Sun, 5 Jul 2026 21:30:30 +0200
Subject: [PATCH 004/240] feat(config): make model-capability tags
always-available and runtime-consumed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add config.DefaultModelProfiles() as the canonical, hardcoded set of
7 model-capability profiles (Claude/Opus/Sonnet/Haiku/GPT-5/GPT-4/
Gemini), kept in sync with config/config.default.yaml's `models:`
block by `make check-model-tags`. Unlike config.default.yaml, which
only seeds settings.json on first run, these profiles are always
available via the new (*Config).EffectiveModelProfiles(), which unions
settings.json's Models with the canonical defaults (user profiles win
on name collision).
Route all tag/name resolution (ModelProfileByName, ModelProfilesByTag,
ResolveModelTags) through EffectiveModelProfiles so a prompt's
preferredModels (modelTag/modelName) resolves even when settings.json
predates or omits `models:` — previously this silently no-opped to the
baseline model.
Add config.CanonicalModelTags() (sorted, de-duped tag set) and wire it
into GET /api/config as model_tags so SettingsDialog.js can suggest
canonical tags via a when editing a profile's tags.
Add `make check-model-tags` target validating builtin prompt modelTag
references and Go/YAML drift.
---
.augment/rules/08-config.md | 6 +-
CLAUDE.md | 4 +-
Makefile | 9 +-
config/config.default.yaml | 8 +-
internal/config/config.go | 100 ++++++++++++-
internal/config/config_test.go | 172 ++++++++++++++++++++--
internal/conversation/bgsession_prompt.go | 11 +-
internal/web/handlers/config_get.go | 3 +
web/static/components/SettingsDialog.js | 15 ++
9 files changed, 296 insertions(+), 32 deletions(-)
diff --git a/.augment/rules/08-config.md b/.augment/rules/08-config.md
index 4dd2610b9..d2e651b7e 100644
--- a/.augment/rules/08-config.md
+++ b/.augment/rules/08-config.md
@@ -101,7 +101,7 @@ Note: `/mitto/api/settings` manages global `settings.json`. For per-session feat
models:
- name: Claude Opus # UI label (read-only)
criteria: { matchMode: contains, pattern: Opus } # Case-insensitive pattern matching
- tags: [Smartest, Reasoning, Expensive] # Interface-only semantic tags
+ tags: [Smartest, Reasoning, Expensive] # Capability tags, consumed at runtime
```
**Tag union matching** (additive): If a model name matches multiple profiles (e.g., `Claude Opus 4.5`):
@@ -109,7 +109,9 @@ models:
- Then: Matches `Opus` profile → Adds `[Smartest, Reasoning, Expensive]`
- Result: `[Anthropic, Smartest, Reasoning, Expensive]` (union)
-Use `matchMode: contains` for robust cross-version matching. Tags are interface-only; runtime consumption is tracked separately (see `mitto-2cc`). Shipped defaults include: Claude, Opus, Sonnet, Haiku, GPT-5, GPT-4, Gemini. Test: `TestParse_EmbeddedDefaultModelProfiles()` in `internal/config/config_test.go`.
+Use `matchMode: contains` for robust cross-version matching. Shipped defaults include: Claude, Opus, Sonnet, Haiku, GPT-5, GPT-4, Gemini. Test: `TestParse_EmbeddedDefaultModelProfiles()` in `internal/config/config_test.go`.
+
+**Canonical Go defaults (always available, not just first-run seed)**: `config.DefaultModelProfiles()` hardcodes the same 7 profiles in Go — the single source of truth, kept in sync with `config.default.yaml`'s `models:` block by `make check-model-tags`. `(*Config).EffectiveModelProfiles()` returns `settings.json`'s `Models` unioned with these defaults (user profile wins on name collision, defaults fill gaps; nil-safe). All tag/name resolution — `ModelProfileByName`, `ModelProfilesByTag`, `ResolveModelTags` — routes through `EffectiveModelProfiles()`, so a prompt's `preferredModels: {modelTag: Coding}` resolves even when `settings.json` predates or omits `models:` (previously it silently no-opped to the baseline model — this was the root cause of `preferredModels` "not working" for users with pre-existing settings). `config.CanonicalModelTags()` returns the sorted, de-duped tag set; `make check-model-tags` rejects any builtin prompt's `modelTag` that isn't in this set.
## ACP Server Constraints
diff --git a/CLAUDE.md b/CLAUDE.md
index 2bb1b8b17..54bd04ca8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,7 +64,7 @@ go test -v -tags integration ./tests/integration/inprocess/
- **Log authoritative source**: Check `events.jsonl` (session dir) when debugging; server logs rotate and have gaps.
- **daisyUI drawer GPU bug**: `.drawer-side` + fixed-position overlay compete for pointer events → blank artifacts. Fix: See `web/static/styles.css` for verified pattern. Do NOT use `translateZ(0)`.
- **Zombie WebSocket recovery**: When phone sleeps or app backgrounded, WS may enter "zombie" state (appearing open but dead). On visibility change or app activate, force-close and reconnect. This is expected behavior — not a bug. See `.augment/rules/23-web-frontend-mobile.md` for resilience patterns.
-- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk (session gaps, restarts, or reverted stashes can silently drop them). Before continuing/relying on earlier work, re-check with `git status`/`git diff` or re-view the file rather than assuming.
+- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk — session gaps, restarts, or a **concurrent loop conversation** (e.g. a PR-babysitting/cleanup loop sharing the same repo) stashing/resetting the working directory mid-task can silently drop them. Re-check with `git status`/`git diff`/re-view before relying on earlier work; if files vanish unexpectedly, check `git stash list` first — work is often auto-stashed, not lost.
## New Agent Capability Checklist
@@ -140,6 +140,8 @@ Per-agent `mcp-list.sh` config paths/keys are **not** interchangeable across age
- `app.js` line ~1928: `headerLoopState()` returns `{ state, label, badgeClass }` pill object
- Issue `mitto-36nm` tracks UI clarity improvement (prompt visibility + pill disambiguation)
+**Persistence symmetry (LoopStore, `internal/session/loop.go`)**: un-loop calls `Detach()` (saves settings to a slot, clears active config); re-loop/restore reads it back via `GetSaved()`. A **fresh** loop create must call `ClearSaved()` right after `Set()` so a stale saved slot doesn't leak into a later un-loop — done identically in REST (`session_loop_write.go` `handleSetLoop`) and MCP (`mcpserver/server.go` create-loop path) to keep both interfaces symmetric.
+
## Tokensave Rule (Mandatory)
**NEVER use Explore agents for code research when tokensave is available.** Use `tokensave_context`, `tokensave_search`, `tokensave_callees`, `tokensave_callers`, `tokensave_impact`, `tokensave_node`, `tokensave_files`, or `tokensave_affected` first. See CLAUDE.md in project root for full details.
diff --git a/Makefile b/Makefile
index a74e0c96b..d5eb68410 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build install test test-go test-js test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean
+.PHONY: build install test test-go test-js check-model-tags test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean
# Binary name
BINARY_NAME=mitto
@@ -44,6 +44,13 @@ test-js: deps-js
@echo "Running JavaScript tests..."
$(NPM) test
+# Validate builtin prompt model tags against the canonical Go tag set.
+# Fails if any builtin prompt references a modelTag not in config.CanonicalModelTags(),
+# or if config/config.default.yaml `models:` drifts from config.DefaultModelProfiles().
+check-model-tags:
+ @echo "Validating builtin prompt model tags..."
+ $(GOTEST) -run 'TestBuiltinPrompts_ModelTagsAreCanonical|TestDefaultModelProfiles_MatchesEmbeddedYAML|TestCanonicalModelTags|TestEffectiveModelProfiles_MergeAndPrecedence' ./internal/config/
+
# =============================================================================
# Integration & UI Tests
# =============================================================================
diff --git a/config/config.default.yaml b/config/config.default.yaml
index b80b8e457..4dd35c556 100644
--- a/config/config.default.yaml
+++ b/config/config.default.yaml
@@ -33,8 +33,12 @@ acp: []
# first run); existing settings.json files are left untouched. Tags overlap by design:
# a model name is matched against every profile and the union of matching tags applies
# (e.g. "Claude Opus 4.x" resolves to Anthropic + Smartest + Reasoning + Expensive).
-# Tags are interface-only today (parsed and exposed via the Go API, not yet consumed
-# at runtime). Edit or extend these to match the models you use.
+#
+# Tags ARE consumed at runtime: a prompt's `preferredModels` (modelTag/modelName)
+# resolves against these profiles to pick the model a prompt runs on. This YAML is only
+# the first-run seed — the canonical, always-available set lives in Go
+# (config.DefaultModelProfiles); the two are kept in sync by `make check-model-tags`.
+# Edit or extend these to match the models you use.
models:
- name: Claude
criteria:
diff --git a/internal/config/config.go b/internal/config/config.go
index 6edcb8ec5..2ac836382 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "sort"
"strings"
"gopkg.in/yaml.v3"
@@ -36,6 +37,77 @@ type ModelProfile struct {
Tags []string `json:"tags,omitempty"`
}
+// DefaultModelProfiles returns the canonical, hardcoded set of model profiles.
+// This is the single Go source of truth for well-known model-capability tags and
+// mirrors the `models:` block in config/config.default.yaml (kept in sync by the
+// `make check-model-tags` target).
+//
+// Unlike config.default.yaml — which only seeds settings.json on first run — these
+// profiles are always available at runtime via EffectiveModelProfiles, so tag-based
+// prompt preferredModels (e.g. `modelTag: Coding`) resolve even when the user's
+// settings.json has an empty or partial `Models` list. A fresh copy is returned on
+// each call so callers may mutate the result freely.
+func DefaultModelProfiles() []ModelProfile {
+ contains := func(pattern string) *ACPServerConstraint {
+ return &ACPServerConstraint{MatchMode: "contains", Pattern: pattern}
+ }
+ return []ModelProfile{
+ {Name: "Claude", Criteria: contains("Claude"), Tags: []string{"Anthropic"}},
+ {Name: "Claude Opus", Criteria: contains("Opus"), Tags: []string{"Smartest", "Reasoning", "Expensive"}},
+ {Name: "Claude Sonnet", Criteria: contains("Sonnet"), Tags: []string{"Smart", "Coding"}},
+ {Name: "Claude Haiku", Criteria: contains("Haiku"), Tags: []string{"Fast", "Cheap"}},
+ {Name: "GPT-5", Criteria: contains("GPT-5"), Tags: []string{"Smart", "Reasoning", "Coding"}},
+ {Name: "GPT-4", Criteria: contains("GPT-4"), Tags: []string{"Smart", "Coding"}},
+ {Name: "Gemini", Criteria: contains("Gemini"), Tags: []string{"Smart", "LongContext"}},
+ }
+}
+
+// CanonicalModelTags returns the sorted, de-duplicated set of capability tags carried
+// by DefaultModelProfiles. It is the authoritative list of tags that a prompt's
+// preferredModels `modelTag:` may reference and is used by the `make check-model-tags`
+// validator to reject unknown tags in builtin prompts.
+func CanonicalModelTags() []string {
+ seen := make(map[string]struct{})
+ var tags []string
+ for _, p := range DefaultModelProfiles() {
+ for _, t := range p.Tags {
+ if _, dup := seen[t]; dup {
+ continue
+ }
+ seen[t] = struct{}{}
+ tags = append(tags, t)
+ }
+ }
+ sort.Strings(tags)
+ return tags
+}
+
+// EffectiveModelProfiles returns the model profiles that should be used for tag/name
+// resolution: the user-configured profiles (Config.Models) unioned with the canonical
+// DefaultModelProfiles as a fallback. User profiles take precedence — a default profile
+// is only appended when no user profile shares its name (case-insensitive). This
+// guarantees well-known tags always resolve even when settings.json omits `models:`,
+// without overriding any customisation the user has made. Safe to call on a nil Config.
+func (c *Config) EffectiveModelProfiles() []ModelProfile {
+ var user []ModelProfile
+ if c != nil {
+ user = c.Models
+ }
+ out := make([]ModelProfile, len(user))
+ copy(out, user)
+ haveName := make(map[string]struct{}, len(user))
+ for _, p := range user {
+ haveName[strings.ToLower(p.Name)] = struct{}{}
+ }
+ for _, d := range DefaultModelProfiles() {
+ if _, ok := haveName[strings.ToLower(d.Name)]; ok {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out
+}
+
// ConstraintMatchesName reports whether name matches the constraint's Pattern under
// its MatchMode. It is the single-string core of the constraint match engine, shared by
// MatchConstraintOption (which applies it across a list of option names) and by model-tag
@@ -1883,31 +1955,43 @@ func ProfilesByTag(profiles []ModelProfile, tag string) []ModelProfile {
// ModelProfileByName returns the model profile with the given name (case-insensitive).
// The bool is false when no profile matches. Intended for consumers that need to look up
-// a profile's tags or criteria by its display name.
+// a profile's tags or criteria by its display name. Resolution uses EffectiveModelProfiles
+// so well-known profiles resolve even when settings.json omits `models:`.
func (c *Config) ModelProfileByName(name string) (*ModelProfile, bool) {
- p := ProfileByName(c.Models, name)
+ profiles := c.EffectiveModelProfiles()
+ p := ProfileByName(profiles, name)
return p, p != nil
}
// ModelProfilesByTag returns all model profiles carrying the given tag (case-insensitive),
// mirroring how ACP server tags are compared elsewhere. Returns an empty slice when none match.
+// Resolution uses EffectiveModelProfiles so well-known tags resolve even when settings.json
+// omits `models:`.
func (c *Config) ModelProfilesByTag(tag string) []ModelProfile {
- return ProfilesByTag(c.Models, tag)
+ return ProfilesByTag(c.EffectiveModelProfiles(), tag)
}
// ResolveModelTags returns the UNION of capability tags from every model profile whose
// Criteria matches modelName (using the shared ConstraintMatchesName engine). Tags are
-// de-duplicated case-insensitively, preserving first-seen order. It is a pure function of
-// (profiles, name) so config never needs to import conversation. Returns nil when modelName
-// is empty, no profile has criteria, or nothing matches (a nil slice is safe to range/index).
+// de-duplicated case-insensitively, preserving first-seen order. Resolution uses
+// EffectiveModelProfiles so well-known tags resolve even when settings.json omits `models:`.
+// Returns nil when modelName is empty or nothing matches (a nil slice is safe to range/index).
func (c *Config) ResolveModelTags(modelName string) []string {
if modelName == "" {
return nil
}
+ return resolveModelTags(c.EffectiveModelProfiles(), modelName)
+}
+
+// resolveModelTags is the pure, slice-based core shared by (*Config).ResolveModelTags.
+// It returns the union (case-insensitive de-dup, first-seen order) of tags from every
+// profile whose Criteria matches modelName. Kept separate so callers with a plain
+// []ModelProfile (and tests) can resolve without the canonical-default merge.
+func resolveModelTags(profiles []ModelProfile, modelName string) []string {
var tags []string
seen := make(map[string]struct{})
- for i := range c.Models {
- p := &c.Models[i]
+ for i := range profiles {
+ p := &profiles[i]
if !ConstraintMatchesName(p.Criteria, modelName) {
continue
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c9ce7736d..98635f3c3 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,9 +1,13 @@
package config
import (
+ "io/fs"
"os"
"path/filepath"
+ "sort"
+ "strings"
"testing"
+ "time"
defaultConfig "github.com/inercia/mitto/config"
)
@@ -2500,39 +2504,43 @@ func TestModelProfileByName(t *testing.T) {
}
// TestModelProfilesByTag covers case-insensitive tag filtering, including a tag shared
-// by multiple profiles.
+// by multiple profiles. It exercises the pure slice-based engine (ProfilesByTag) so it
+// stays free of the canonical-default merge that (*Config).ModelProfilesByTag applies;
+// the merge itself is covered by TestEffectiveModelProfiles_MergeAndPrecedence.
func TestModelProfilesByTag(t *testing.T) {
- cfg := &Config{Models: []ModelProfile{
+ profiles := []ModelProfile{
{Name: "Opus", Tags: []string{"Smartest", "Expensive"}},
{Name: "Sonnet", Tags: []string{"Smart", "Cheap"}},
{Name: "Haiku", Tags: []string{"Fast", "Cheap"}},
- }}
+ }
- cheap := cfg.ModelProfilesByTag("cheap")
+ cheap := ProfilesByTag(profiles, "cheap")
if len(cheap) != 2 {
- t.Fatalf("ModelProfilesByTag(cheap) count = %d, want 2", len(cheap))
+ t.Fatalf("ProfilesByTag(cheap) count = %d, want 2", len(cheap))
}
if cheap[0].Name != "Sonnet" || cheap[1].Name != "Haiku" {
- t.Errorf("ModelProfilesByTag(cheap) = [%s %s], want [Sonnet Haiku]", cheap[0].Name, cheap[1].Name)
+ t.Errorf("ProfilesByTag(cheap) = [%s %s], want [Sonnet Haiku]", cheap[0].Name, cheap[1].Name)
}
- if got := cfg.ModelProfilesByTag("missing"); len(got) != 0 {
- t.Errorf("ModelProfilesByTag(missing) count = %d, want 0", len(got))
+ if got := ProfilesByTag(profiles, "missing"); len(got) != 0 {
+ t.Errorf("ProfilesByTag(missing) count = %d, want 0", len(got))
}
}
// TestResolveModelTags covers tag resolution across every match mode, the union (with
// case-insensitive de-dup) across multiple matching profiles, the no-match / empty cases,
-// and that criteria-less profiles never contribute tags.
+// and that criteria-less profiles never contribute tags. It exercises the pure slice-based
+// core (resolveModelTags) so it stays free of the canonical-default merge that
+// (*Config).ResolveModelTags applies; the merge is covered elsewhere.
func TestResolveModelTags(t *testing.T) {
- cfg := &Config{Models: []ModelProfile{
+ profiles := []ModelProfile{
{Name: "Opus", Criteria: &ACPServerConstraint{MatchMode: "contains", Pattern: "Opus"}, Tags: []string{"Smart", "Expensive"}},
{Name: "Claude", Criteria: &ACPServerConstraint{MatchMode: "regex", Pattern: "opus|sonnet"}, Tags: []string{"Anthropic", "smart"}},
{Name: "Sonnet", Criteria: &ACPServerConstraint{MatchMode: "exact", Pattern: "Sonnet 4.6"}, Tags: []string{"Cheap"}},
{Name: "Pro", Criteria: &ACPServerConstraint{MatchMode: "startsWith", Pattern: "opus"}, Tags: []string{"Pro"}},
{Name: "Look", Criteria: &ACPServerConstraint{MatchMode: "lookAlike", Pattern: "Opus 4.8"}, Tags: []string{"Latest"}},
{Name: "TagsOnly", Tags: []string{"NeverApplied"}}, // nil criteria → never matches
- }}
+ }
tests := []struct {
name string
@@ -2549,7 +2557,13 @@ func TestResolveModelTags(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := cfg.ResolveModelTags(tt.modelName)
+ if tt.modelName == "" {
+ if got := (&Config{Models: profiles}).ResolveModelTags(""); got != nil {
+ t.Fatalf("ResolveModelTags(\"\") = %v, want nil", got)
+ }
+ return
+ }
+ got := resolveModelTags(profiles, tt.modelName)
if len(got) != len(tt.want) {
t.Fatalf("ResolveModelTags(%q) = %v, want %v", tt.modelName, got, tt.want)
}
@@ -2659,3 +2673,137 @@ func TestParse_EmbeddedDefaultShortcuts(t *testing.T) {
}
}
}
+
+// TestDefaultModelProfiles_MatchesEmbeddedYAML asserts the hardcoded Go source of
+// truth (DefaultModelProfiles) stays in sync with the shipped config.default.yaml
+// `models:` block — same profile names, criteria, and tags in the same order. This is
+// the drift guard invoked by `make check-model-tags`.
+func TestDefaultModelProfiles_MatchesEmbeddedYAML(t *testing.T) {
+ cfg, err := Parse(defaultConfig.DefaultConfigYAML)
+ if err != nil {
+ t.Fatalf("Parse(embedded default) failed: %v", err)
+ }
+ got := DefaultModelProfiles()
+ if len(got) != len(cfg.Models) {
+ t.Fatalf("DefaultModelProfiles() count = %d, config.default.yaml models = %d", len(got), len(cfg.Models))
+ }
+ for i := range got {
+ g, y := got[i], cfg.Models[i]
+ if g.Name != y.Name {
+ t.Errorf("profile[%d] name = %q (Go) vs %q (YAML)", i, g.Name, y.Name)
+ }
+ if g.Criteria == nil || y.Criteria == nil {
+ t.Errorf("profile[%d] %q missing criteria (Go=%v YAML=%v)", i, g.Name, g.Criteria, y.Criteria)
+ continue
+ }
+ if g.Criteria.MatchMode != y.Criteria.MatchMode || g.Criteria.Pattern != y.Criteria.Pattern {
+ t.Errorf("profile[%d] %q criteria = %+v (Go) vs %+v (YAML)", i, g.Name, g.Criteria, y.Criteria)
+ }
+ if strings.Join(g.Tags, ",") != strings.Join(y.Tags, ",") {
+ t.Errorf("profile[%d] %q tags = %v (Go) vs %v (YAML)", i, g.Name, g.Tags, y.Tags)
+ }
+ }
+}
+
+// TestCanonicalModelTags pins the canonical capability-tag set (sorted, de-duplicated)
+// derived from DefaultModelProfiles.
+func TestCanonicalModelTags(t *testing.T) {
+ want := []string{"Anthropic", "Cheap", "Coding", "Expensive", "Fast", "LongContext", "Reasoning", "Smart", "Smartest"}
+ got := CanonicalModelTags()
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("CanonicalModelTags() = %v, want %v", got, want)
+ }
+}
+
+// TestEffectiveModelProfiles_MergeAndPrecedence verifies that user-configured profiles
+// win on name collision and canonical defaults fill the gaps, including the empty and
+// nil-Config cases (the exact scenario behind tag routing silently no-oping when
+// settings.json omits `models:`).
+func TestEffectiveModelProfiles_MergeAndPrecedence(t *testing.T) {
+ // Nil Config → all canonical defaults.
+ var nilCfg *Config
+ if got := nilCfg.EffectiveModelProfiles(); len(got) != len(DefaultModelProfiles()) {
+ t.Fatalf("nil Config EffectiveModelProfiles() = %d, want %d", len(got), len(DefaultModelProfiles()))
+ }
+
+ // Empty Models → all canonical defaults, and a tag resolves.
+ empty := &Config{}
+ if got := empty.ModelProfilesByTag("Coding"); len(got) == 0 {
+ t.Errorf("empty Config: modelTag Coding resolved to no profile (regression: routing no-ops)")
+ }
+
+ // User override on a colliding name wins; a non-colliding user profile is preserved;
+ // defaults fill the rest.
+ user := &Config{Models: []ModelProfile{
+ {Name: "Claude Sonnet", Criteria: &ACPServerConstraint{MatchMode: "exact", Pattern: "My Sonnet"}, Tags: []string{"Custom"}},
+ {Name: "MyLocal", Criteria: &ACPServerConstraint{MatchMode: "contains", Pattern: "local"}, Tags: []string{"Cheap"}},
+ }}
+ eff := user.EffectiveModelProfiles()
+ // User profiles come first, in order.
+ if eff[0].Name != "Claude Sonnet" || eff[0].Criteria.Pattern != "My Sonnet" || eff[0].Tags[0] != "Custom" {
+ t.Errorf("user override not preserved/first: %+v", eff[0])
+ }
+ if eff[1].Name != "MyLocal" {
+ t.Errorf("non-colliding user profile not preserved at index 1: %+v", eff[1])
+ }
+ // The colliding default (Claude Sonnet) must NOT be appended again.
+ sonnetCount := 0
+ for _, p := range eff {
+ if p.Name == "Claude Sonnet" {
+ sonnetCount++
+ }
+ }
+ if sonnetCount != 1 {
+ t.Errorf("Claude Sonnet appears %d times, want 1 (default should be dropped on collision)", sonnetCount)
+ }
+ // A default with a unique name (e.g. Claude Opus) is still present.
+ if p, ok := user.ModelProfileByName("Claude Opus"); !ok || p == nil {
+ t.Errorf("canonical default 'Claude Opus' missing after merge")
+ }
+}
+
+// TestBuiltinPrompts_ModelTagsAreCanonical is the validator behind `make check-model-tags`:
+// every `modelTag:` used by any embedded builtin prompt must be a known canonical tag.
+// This fails CI if a prompt references a tag that no model profile can carry.
+func TestBuiltinPrompts_ModelTagsAreCanonical(t *testing.T) {
+ canonical := make(map[string]struct{})
+ for _, tag := range CanonicalModelTags() {
+ canonical[strings.ToLower(tag)] = struct{}{}
+ }
+
+ entries, err := fs.ReadDir(defaultConfig.BuiltinPromptsFS, defaultConfig.BuiltinPromptsDir)
+ if err != nil {
+ t.Fatalf("read embedded builtin prompts: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("no embedded builtin prompts found")
+ }
+
+ var unknown []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ data, err := fs.ReadFile(defaultConfig.BuiltinPromptsFS, defaultConfig.BuiltinPromptsDir+"/"+e.Name())
+ if err != nil {
+ t.Fatalf("read %s: %v", e.Name(), err)
+ }
+ pf, err := ParsePromptFile(e.Name(), data, time.Time{})
+ if err != nil {
+ t.Fatalf("parse %s: %v", e.Name(), err)
+ }
+ for _, pm := range pf.PreferredModels {
+ if pm.ModelTag == "" {
+ continue
+ }
+ if _, ok := canonical[strings.ToLower(pm.ModelTag)]; !ok {
+ unknown = append(unknown, e.Name()+": "+pm.ModelTag)
+ }
+ }
+ }
+ if len(unknown) > 0 {
+ sort.Strings(unknown)
+ t.Fatalf("builtin prompts reference unknown modelTag(s) not in CanonicalModelTags():\n %s",
+ strings.Join(unknown, "\n "))
+ }
+}
diff --git a/internal/conversation/bgsession_prompt.go b/internal/conversation/bgsession_prompt.go
index fde71c06b..0025be242 100644
--- a/internal/conversation/bgsession_prompt.go
+++ b/internal/conversation/bgsession_prompt.go
@@ -862,13 +862,12 @@ func (bs *BackgroundSession) pdResolvePreferredModels(promptName string) []confi
return bs.preferredModelsResolver(promptName, bs.workingDir)
}
-// pdModelProfiles exposes the global model profiles (Settings → Models) so
-// SelectPreferredModel can resolve PromptPreferredModel entries by name/tag.
+// pdModelProfiles exposes the model profiles used to resolve PromptPreferredModel
+// entries by name/tag. It returns the user-configured profiles (Settings → Models)
+// unioned with the canonical DefaultModelProfiles as a fallback, so well-known tags
+// (e.g. "Coding", "Cheap") always resolve even when settings.json omits `models:`.
func (bs *BackgroundSession) pdModelProfiles() []config.ModelProfile {
- if bs.mittoConfig == nil {
- return nil
- }
- return bs.mittoConfig.Models
+ return bs.mittoConfig.EffectiveModelProfiles()
}
func (bs *BackgroundSession) pdResolvePromptParameters(promptName string) []config.PromptParameter {
diff --git a/internal/web/handlers/config_get.go b/internal/web/handlers/config_get.go
index c8019c223..e556648a8 100644
--- a/internal/web/handlers/config_get.go
+++ b/internal/web/handlers/config_get.go
@@ -71,6 +71,9 @@ func (h *Handlers) HandleGetConfig(w http.ResponseWriter, r *http.Request) {
"config_readonly": h.deps.ConfigReadOnly,
"api_prefix": h.deps.APIPrefix, // Include API prefix for frontend to use
"models": []configPkg.ModelProfile{},
+ // Canonical capability tags (single Go source of truth) so the frontend can
+ // suggest them when editing model-profile tags, without duplicating the list.
+ "model_tags": configPkg.CanonicalModelTags(),
}
// Include RC file path if config is from an RC file
diff --git a/web/static/components/SettingsDialog.js b/web/static/components/SettingsDialog.js
index f08e0a6a3..938a44444 100644
--- a/web/static/components/SettingsDialog.js
+++ b/web/static/components/SettingsDialog.js
@@ -1260,6 +1260,11 @@ export function SettingsDialog({
const [acpServers, setAcpServers] = useState([]);
// Model profiles (named profiles pairing criteria with capability tags)
const [modelProfiles, setModelProfiles] = useState([]);
+ // Canonical capability tags suggested when editing a profile's tags. Sourced
+ // from the backend (config.model_tags → config.CanonicalModelTags), so the
+ // suggestion list stays in sync with Go and is always present. Free-text entry
+ // of other tags remains allowed (these are only hints).
+ const [modelTags, setModelTags] = useState([]);
// Accordion: index of the single expanded model profile (-1 = all collapsed)
const [expandedProfileIndex, setExpandedProfileIndex] = useState(-1);
// Raw text drafts for the tags input, keyed by profile index — lets the
@@ -1644,6 +1649,7 @@ export function SettingsDialog({
servers.forEach(assignStableKey);
setAcpServers(servers);
setModelProfiles(Array.isArray(config.models) ? config.models : []);
+ setModelTags(Array.isArray(config.model_tags) ? config.model_tags : []);
setExpandedProfileIndex(-1);
setTagDrafts({});
@@ -4868,6 +4874,14 @@ export function SettingsDialog({
Mitto can branch on tags instead of raw model names.
+
+
+ ${modelTags.map(
+ (t) => html` `,
+ )}
+
+
${modelProfiles.map((p, i) => {
const isExpanded = expandedProfileIndex === i;
const trimmedName = (p.name || "").trim();
@@ -4972,6 +4986,7 @@ export function SettingsDialog({
type="text"
class="input input-sm w-full"
placeholder="e.g., Smart, Cheap"
+ list="model-tag-suggestions"
value=${tagDrafts[i] !== undefined
? tagDrafts[i]
: tags.join(", ")}
From 81507fcb1894336031efbf4760720b297a9f1b61 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Sun, 5 Jul 2026 21:30:45 +0200
Subject: [PATCH 005/240] fix(web): hide overflow shortcut buttons on narrow
screens
Per-folder/global shortcut toolbars (conversation, beads-issue,
tasks-list) can carry several shortcut buttons that overflow the
toolbar on phone-width viewports. Keep only the first shortcut visible
below 640px via a new .mitto-shortcut-extra utility applied to buttons
at index > 0.
Also document the un-loop/re-loop LoopStore persistence symmetry
requirement and note that a concurrent loop conversation sharing the
same repo can stash/reset the working directory mid-task, silently
dropping a prior turn's uncommitted edits.
---
.augment/rules/02-session.md | 2 ++
CLAUDE.md | 10 +++-------
web/static/app.js | 3 +++
web/static/components/BeadsView.js | 6 ++++++
web/static/styles-v2.css | 10 ++++++++++
5 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/.augment/rules/02-session.md b/.augment/rules/02-session.md
index cf3c8d072..f480254b5 100644
--- a/.augment/rules/02-session.md
+++ b/.augment/rules/02-session.md
@@ -130,6 +130,8 @@ Stored in `loop.json`. Only top-level sessions may have loop prompts (child →
**Critical**: Changing `LoopStore.Update()` signature requires updating BOTH `session_loop_api.go` (PATCH handler) AND `mcpserver/server.go` (MCP tool) — both call `Update()`.
+**Un-loop/re-loop persistence symmetry**: `Detach()` saves settings to a slot and clears the active config (un-loop); `GetSaved()`/restore reads it back. A fresh loop `Set()` (not a restore) must be followed by `ClearSaved()` so a stale saved slot doesn't leak in later — required in both `session_loop_write.go` (`handleSetLoop`) and `mcpserver/server.go` (MCP create-loop path).
+
## Auxiliary Package
The `internal/auxiliary` package provides a hidden ACP session for utility tasks. Lazy init, auto-approve permissions, file writes denied, thread-safe.
diff --git a/CLAUDE.md b/CLAUDE.md
index 54bd04ca8..ef0ecefb3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -101,15 +101,11 @@ Used in `BeadsView.js` (list actions + issue-detail header).
## Model Selection & Preferred Models
-Prompts can declare `preferredModels:` to route to specific ACP models. `selectPreferredModel()` in `constraints.go` picks the best match using configurable match modes (`"contains"`, `"exact"`, `"startsWith"`, `"regex"`, `"lookAlike"`). **Key insight**: If the active model already satisfies the preference, it's kept; otherwise the preference is applied. This avoids unnecessary model switches in multi-model sessions.
+Prompts can declare `preferredModels:` to route to specific ACP models. `selectPreferredModel()` in `constraints.go` picks the best match using configurable match modes (`"contains"`, `"exact"`, `"startsWith"`, `"regex"`, `"lookAlike"`). If the active model already satisfies the preference, it's kept; otherwise applied — avoids unnecessary switches in multi-model sessions.
-**Per-prompt transient overrides**: When a prompt declares `preferredModels`, `setActiveModelOnly()` temporarily switches models for that prompt's execution **without** recording a `session_change` event. This is **intentional**:
-- Baseline model (conversation-level setting) remains unchanged
-- No "Model changed to X" message in timeline (silent override)
-- After prompt completes, `restoreBaselineIfOverride()` flips model back to baseline
-- Result: Heavy-lift work runs on cheaper models (e.g., Sonnet) while conversation stays on your chosen baseline (e.g., Opus)
+**Per-prompt transient overrides**: `setActiveModelOnly()` switches models for a prompt's execution **without** recording a `session_change` event (silent; conversation-level baseline is untouched). `restoreBaselineIfOverride()` flips back after the prompt completes. **Contrast**: manual UI selection → `applyConfigOption()` → `cmRecordSessionChange()` → persistent event, updates baseline.
-**Contrast**: Manual model selection (via UI dropdown) → `applyConfigOption()` → `cmRecordSessionChange()` → records persistent `session_change` event and updates baseline.
+**Config-level tag resolution**: `(*Config).EffectiveModelProfiles()` unions `settings.json`'s `Models` with hardcoded `config.DefaultModelProfiles()` (7 canonical profiles), user wins by name — so `modelTag:` always resolves even when `settings.json` predates/omits `models:`. `make check-model-tags` keeps `config.default.yaml` and the Go defaults in sync and rejects unknown tags in builtin prompts. See `.augment/rules/08-config.md`.
## CEL Tool Evaluation (Fail-Open Behavior)
diff --git a/web/static/app.js b/web/static/app.js
index 5e37b11e5..fec08c7df 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -2594,6 +2594,9 @@ function App() {
return {
kind: "button",
testId: `conversation-shortcut-btn-${i}`,
+ // On phone-width screens only the first shortcut is shown; the rest are
+ // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow.
+ className: i > 0 ? "mitto-shortcut-extra" : undefined,
icon: html`<${Icon} className="w-4 h-4" />`,
tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`,
ariaLabel: found
diff --git a/web/static/components/BeadsView.js b/web/static/components/BeadsView.js
index 2019b9c41..b498d69d9 100644
--- a/web/static/components/BeadsView.js
+++ b/web/static/components/BeadsView.js
@@ -967,6 +967,9 @@ export function BeadsDetailPanel({
return {
kind: "button",
testId: `beads-issue-shortcut-btn-${i}`,
+ // On phone-width screens only the first shortcut is shown; the rest are
+ // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow.
+ className: i > 0 ? "mitto-shortcut-extra" : undefined,
icon: html`<${Icon} className="w-4 h-4" />`,
tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`,
ariaLabel: found
@@ -4710,6 +4713,9 @@ export function BeadsView({
return {
kind: "button",
testId: `beads-shortcut-btn-${i}`,
+ // On phone-width screens only the first shortcut is shown; the rest are
+ // hidden (see .mitto-shortcut-extra in styles-v2.css) to avoid overflow.
+ className: i > 0 ? "mitto-shortcut-extra" : undefined,
icon: html`<${Icon} className="w-4 h-4" />`,
tip: found ? sc.prompt : `Prompt "${sc.prompt}" not found`,
ariaLabel: found
diff --git a/web/static/styles-v2.css b/web/static/styles-v2.css
index 23c3390db..22a09e16b 100644
--- a/web/static/styles-v2.css
+++ b/web/static/styles-v2.css
@@ -740,6 +740,16 @@ a:hover {
background: #334155;
}
+/* Shortcut buttons overflow on narrow screens. A toolbar may carry several
+ per-folder/global shortcut buttons; on phone-width viewports only the first
+ shortcut is kept and the rest are hidden so they don't overflow the toolbar.
+ Applied via the item className on shortcut buttons at index > 0. */
+@media (max-width: 640px) {
+ .mitto-toolbar > button.mitto-shortcut-extra {
+ display: none;
+ }
+}
+
/* daisyUI menu items are / elements. They must NOT inherit the
generic per-button border above, which would draw a box around each menu
entry (context menus, dropdowns, sidebar menus, etc.). */
From e07e5f69b993b1ba728b7916cc246b766f2b0785 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 10:48:27 +0200
Subject: [PATCH 006/240] fix(acp): recognize agent heap-OOM and stop surfacing
misleading set-model ERROR (mitto-5q8)
Part A: add JS-heap-OOM stderr patterns to stderrCrashPatterns so
StartStderrMonitor fires onCrashDetected immediately on agent Node/V8
heap exhaustion, speeding proactive recycle instead of waiting for the
60s control-request timeout.
Part B: in applyConfigOptionWithOpts, downgrade the set-model failure
log to WARN ("Skipping model change; agent process is restarting")
when IsACPConnectionError(err) is true (dead/restarting shared ACP
process), since this is a transient restart-gap condition rather than
a genuine model-selection failure. Genuine failures on a live process
still log ERROR. Control flow and the wrapped error return are
unchanged.
---
.../conversation/bgsession_acp_process.go | 5 ++
.../bgsession_acp_process_test.go | 68 +++++++++++++++++
internal/conversation/config_manager.go | 8 +-
internal/conversation/config_manager_test.go | 73 +++++++++++++++++++
4 files changed, 153 insertions(+), 1 deletion(-)
create mode 100644 internal/conversation/bgsession_acp_process_test.go
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index f05e8a09e..473e0675a 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -402,6 +402,11 @@ var stderrCrashPatterns = []string{
"received message with neither id nor method",
// From acp-go-sdk's notification queue overflow handler (triggers when process is overwhelmed)
"failed to queue notification; closing connection",
+ // Node/V8 fatal error when the agent subprocess exhausts its JS heap (mitto-5q8).
+ // Detecting this immediately speeds proactive recycle instead of waiting for the
+ // dead process to be discovered on the next RPC attempt.
+ "JavaScript heap out of memory",
+ "Reached heap limit",
}
// StartStderrMonitor starts a goroutine that reads from stderr and writes to the collector.
diff --git a/internal/conversation/bgsession_acp_process_test.go b/internal/conversation/bgsession_acp_process_test.go
new file mode 100644
index 000000000..8cf3eb602
--- /dev/null
+++ b/internal/conversation/bgsession_acp_process_test.go
@@ -0,0 +1,68 @@
+package conversation
+
+import (
+ "io"
+ "testing"
+ "time"
+)
+
+// TestStartStderrMonitor_HeapOOM_TriggersCrashDetection is a regression test for
+// mitto-5q8: when the agent subprocess (Node/V8) dies with a JS heap-OOM fatal
+// error, StartStderrMonitor must recognize the pattern and invoke onCrashDetected
+// immediately, rather than waiting for the SDK's control-request timeout.
+func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() {
+ select {
+ case crashDetected <- struct{}{}:
+ default:
+ }
+ }
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil)
+
+ chunk := "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
+ go func() {
+ _, _ = pw.Write([]byte(chunk))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ // expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected onCrashDetected to be invoked for heap-OOM stderr output")
+ }
+}
+
+// TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection is a sanity
+// check that ordinary stderr output does not falsely trigger crash detection.
+func TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() {
+ select {
+ case crashDetected <- struct{}{}:
+ default:
+ }
+ }
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil)
+
+ go func() {
+ _, _ = pw.Write([]byte("some normal debug output\n"))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ t.Fatal("did not expect onCrashDetected for normal stderr output")
+ case <-time.After(300 * time.Millisecond):
+ // expected: no crash signal
+ }
+}
diff --git a/internal/conversation/config_manager.go b/internal/conversation/config_manager.go
index a37cb0e6f..bb7d7eac2 100644
--- a/internal/conversation/config_manager.go
+++ b/internal/conversation/config_manager.go
@@ -255,7 +255,13 @@ func (c configManager) applyConfigOptionWithOpts(d configDeps, ctx context.Conte
previousModel := d.cmGetCurrentModelID()
if err := d.cmSetSessionModel(ctx, value); err != nil {
if l := d.cmLogger(); l != nil {
- l.Error("Failed to set session model", "config_id", configID, "value", value, "error", err)
+ if IsACPConnectionError(err) {
+ // Dead/restarting process (e.g. agent heap-OOM crash, mitto-5q8): this is a
+ // transient restart-gap condition, not a genuine model-selection failure.
+ l.Warn("Skipping model change; agent process is restarting", "config_id", configID, "value", value, "error", err)
+ } else {
+ l.Error("Failed to set session model", "config_id", configID, "value", value, "error", err)
+ }
}
return fmt.Errorf("failed to set %s: %w", configID, err)
}
diff --git a/internal/conversation/config_manager_test.go b/internal/conversation/config_manager_test.go
index 57204fbc0..b64893aad 100644
--- a/internal/conversation/config_manager_test.go
+++ b/internal/conversation/config_manager_test.go
@@ -367,6 +367,79 @@ func TestConfigManager_ApplyConfigOption_ModeRPCError(t *testing.T) {
}
}
+// recordingHandler is a minimal slog.Handler that captures emitted records for
+// assertions in tests (mitto-5q8).
+type recordingHandler struct {
+ mu sync.Mutex
+ records []slog.Record
+}
+
+func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
+func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.records = append(h.records, r)
+ return nil
+}
+func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
+func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
+
+func (h *recordingHandler) hasRecord(level slog.Level, message string) bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ for _, r := range h.records {
+ if r.Level == level && r.Message == message {
+ return true
+ }
+ }
+ return false
+}
+
+// TestConfigManager_ApplyConfigOption_ModelRPCError_ConnectionError verifies that when
+// cmSetSessionModel fails with a dead/restarting-process error (e.g. after an agent
+// heap-OOM crash, mitto-5q8), applyConfigOption logs a WARN "Skipping model change..."
+// instead of the misleading ERROR "Failed to set session model", while still returning
+// the wrapped error.
+func TestConfigManager_ApplyConfigOption_ModelRPCError_ConnectionError(t *testing.T) {
+ c := configManager{}
+ d := newFakeConfigDeps()
+ d.setModelErr = errors.New("shared ACP process has exited")
+ handler := &recordingHandler{}
+ d.logger = slog.New(handler)
+
+ err := c.applyConfigOption(d, context.Background(), ConfigOptionCategoryModel, "m-2")
+ if err == nil {
+ t.Fatal("expected error when model RPC fails")
+ }
+ if handler.hasRecord(slog.LevelError, "Failed to set session model") {
+ t.Fatal("did not expect misleading ERROR log for a connection/restart error")
+ }
+ if !handler.hasRecord(slog.LevelWarn, "Skipping model change; agent process is restarting") {
+ t.Fatal("expected WARN log about skipping model change during restart")
+ }
+}
+
+// TestConfigManager_ApplyConfigOption_ModelRPCError_GenuineFailure verifies that a
+// non-connection error (a genuine failure on a live process) still logs at ERROR.
+func TestConfigManager_ApplyConfigOption_ModelRPCError_GenuineFailure(t *testing.T) {
+ c := configManager{}
+ d := newFakeConfigDeps()
+ d.setModelErr = errors.New("boom")
+ handler := &recordingHandler{}
+ d.logger = slog.New(handler)
+
+ err := c.applyConfigOption(d, context.Background(), ConfigOptionCategoryModel, "m-2")
+ if err == nil {
+ t.Fatal("expected error when model RPC fails")
+ }
+ if !handler.hasRecord(slog.LevelError, "Failed to set session model") {
+ t.Fatal("expected ERROR log for a genuine (non-connection) failure")
+ }
+ if handler.hasRecord(slog.LevelWarn, "Skipping model change; agent process is restarting") {
+ t.Fatal("did not expect the restart WARN log for a genuine failure")
+ }
+}
+
func TestConfigManager_FlushPendingConfig_Empty(t *testing.T) {
c := configManager{}
d := newFakeConfigDeps()
From e62f64fc5a6e7bd6ad3983bea319726bafd53004 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 14:47:25 +0200
Subject: [PATCH 007/240] fix(beads): fall back to stdout when bd exits
non-zero with empty stderr (mitto-9o6)
execRunner.Run discarded stdout on failure, so when bd exits non-zero
during dolt backend warm-up but writes its diagnostic to stdout (or
nothing) instead of stderr, the logged error carried stderr="" and the
real cause was invisible.
Add diagnosticOutput(): use stderr when bd wrote to it, otherwise fall
back to trimmed stdout, rune-safe length-capped at 2000 chars to avoid
flooding logs. IsNotFound is unaffected since bd's not-found message
always goes to stderr.
Graceful degradation (the other half of mitto-9o6) was already covered
by the existing runBeadsRead bounded-retry logic.
---
internal/beads/beads_test.go | 48 ++++++++++++++++++++++++++++++++++++
internal/beads/cli.go | 22 ++++++++++++++++-
2 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go
index 0637cccfc..8fe61f9da 100644
--- a/internal/beads/beads_test.go
+++ b/internal/beads/beads_test.go
@@ -776,6 +776,54 @@ func TestEnvWithActor_OverridesAndDedupes(t *testing.T) {
}
}
+func TestDiagnosticOutput(t *testing.T) {
+ longInput := strings.Repeat("x", maxDiagnosticLen+500)
+ wantTruncatedLen := maxDiagnosticLen + len([]rune("… (truncated)"))
+
+ tests := []struct {
+ name string
+ stderr string
+ stdout string
+ want string
+ }{
+ {
+ name: "stderr non-empty is returned as-is, trimmed",
+ stderr: " boom\n",
+ stdout: "ignored",
+ want: "boom",
+ },
+ {
+ name: "stderr empty falls back to stdout",
+ stderr: "",
+ stdout: " warming up\n",
+ want: "warming up",
+ },
+ {
+ name: "both empty",
+ stderr: "",
+ stdout: "",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := diagnosticOutput(tt.stderr, tt.stdout); got != tt.want {
+ t.Errorf("diagnosticOutput(%q, %q) = %q, want %q", tt.stderr, tt.stdout, got, tt.want)
+ }
+ })
+ }
+
+ t.Run("over-length input is truncated", func(t *testing.T) {
+ got := diagnosticOutput(longInput, "")
+ if !strings.HasSuffix(got, "… (truncated)") {
+ t.Fatalf("diagnosticOutput() = %q, want suffix %q", got, "… (truncated)")
+ }
+ if gotLen := len([]rune(got)); gotLen != wantTruncatedLen {
+ t.Errorf("len([]rune(diagnosticOutput())) = %d, want %d", gotLen, wantTruncatedLen)
+ }
+ })
+}
+
func TestNewClient_DefaultsWebUIActor(t *testing.T) {
c, ok := NewClient().(*cliClient)
if !ok {
diff --git a/internal/beads/cli.go b/internal/beads/cli.go
index 72b85aa4f..27d754395 100644
--- a/internal/beads/cli.go
+++ b/internal/beads/cli.go
@@ -51,12 +51,32 @@ func (r execRunner) Run(ctx context.Context, dir string, args ...string) ([]byte
} else if errors.As(err, &exitErr) {
msg = "bd exited with non-zero status"
}
- return nil, stderr.String(), errors.New(msg)
+ return nil, diagnosticOutput(stderr.String(), stdout.String()), errors.New(msg)
}
return stdout.Bytes(), "", nil
}
+// maxDiagnosticLen bounds captured bd output logged on failure so a large
+// stdout cannot flood the logs.
+const maxDiagnosticLen = 2000
+
+// diagnosticOutput returns the best available diagnostic text for a failed bd
+// invocation: stderr when bd wrote to it, otherwise stdout (bd sometimes emits
+// its error there, or exits non-zero with no stderr during dolt warm-up). The
+// result is trimmed and rune-safe length-bounded.
+func diagnosticOutput(stderr, stdout string) string {
+ diag := strings.TrimSpace(stderr)
+ if diag == "" {
+ diag = strings.TrimSpace(stdout)
+ }
+ runes := []rune(diag)
+ if len(runes) > maxDiagnosticLen {
+ diag = string(runes[:maxDiagnosticLen]) + "… (truncated)"
+ }
+ return diag
+}
+
// envWithActor returns a copy of the current process environment with any
// existing BEADS_ACTOR entry removed and a single BEADS_ACTOR=actor appended, so
// the bd subprocess is stamped with the given actor regardless of what the
From 4c81048c9824ac28806f4125976b39e83cee2554 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 14:48:36 +0200
Subject: [PATCH 008/240] feat(web): auto-unarchive loop conversations after
ACP failure recovers
Loop conversations auto-archived due to broken ACP (ArchiveReasonACPFailures)
previously stayed archived forever unless a human noticed and manually
unarchived them. Add LoopRunner.checkAutoUnarchiveRecovery(), polled once
per minute from RunOnce():
- Eligibility: archived with ArchiveReasonACPFailures and a loop config
still exists (store.Loop(id).Get() succeeds). Manual/inactivity archives
and non-loop ACP failures are excluded.
- Retry cadence: ~1h per conversation, anchored on
Metadata.AutoUnarchiveLastAttemptAt (or ArchivedAt) so it survives restarts.
- Anti-storm stagger: 10m global minimum gap between attempts; at most the
single most-overdue session is retried per poll.
- A retry performs the same steps as a manual unarchive: clear archive
fields, resume the ACP process, broadcast state changes, and reuse
handlers.Handlers.RestoreLoopOnUnarchive (now exported) to re-enable
the loop.
- Persist the attempt timestamp before invoking the callback so it
survives a crash mid-resume; clear it only on success.
Adds the AutoUnarchiveLastAttemptAt metadata field, wiring in server.go,
a full test suite in loop_runner_test.go, and doc updates.
---
.../rules/15-web-backend-session-lifecycle.md | 14 +-
.augment/rules/42-mcpserver-development.md | 11 +-
docs/devel/session-management.md | 9 +
internal/mcpserver/server.go | 1 +
internal/session/types.go | 5 +
internal/web/handlers/session_update.go | 11 +-
internal/web/loop_runner.go | 199 ++++++++++-
internal/web/loop_runner_test.go | 309 ++++++++++++++++++
internal/web/server.go | 31 ++
9 files changed, 560 insertions(+), 30 deletions(-)
diff --git a/.augment/rules/15-web-backend-session-lifecycle.md b/.augment/rules/15-web-backend-session-lifecycle.md
index c18d759bd..642983393 100644
--- a/.augment/rules/15-web-backend-session-lifecycle.md
+++ b/.augment/rules/15-web-backend-session-lifecycle.md
@@ -71,12 +71,24 @@ The GC suspends idle loop sessions whose next prompt is far away, saving ACP res
| `inactivity` | `ArchiveReasonInactivity` | Auto-archive after configured inactive period|
| `acp_start_failures`| `ArchiveReasonACPFailures` | `ACPStartFailureCount` ≥ threshold (3) |
-Broadcast in `session_archived` WebSocket message as `archive_reason` field.
+Broadcast in `session_archived` WebSocket message as `archive_reason` field. `acp_start_failures` on a loop conversation is the only reason eligible for Auto-Unarchive Recovery (below).
## Auto-Archive
Config: `session.auto_archive_inactive_after: "1w"` (in `checkAutoArchive()`). Excluded: already-archived, child sessions, sessions with loop prompts (enabled or paused).
+## Auto-Unarchive Recovery
+
+Loop conversations auto-archived due to broken ACP (`ArchiveReasonACPFailures`) are retried automatically by `LoopRunner.checkAutoUnarchiveRecovery()`, called from `RunOnce()` right after `checkAutoArchive()`.
+
+- **Eligibility** (no new boolean): `meta.Archived == true`, `meta.ArchiveReason == session.ArchiveReasonACPFailures`, and a loop config exists (`store.Loop(id).Get()` succeeds). Excludes `ArchiveReasonManual`/`ArchiveReasonInactivity` and non-loop ACP-failure archives.
+- **Retry cadence**: 1h per conversation (`DefaultAutoUnarchiveRetryInterval`), anchored on `Metadata.AutoUnarchiveLastAttemptAt` if set, else `ArchivedAt`. Due when `now.Sub(anchor) >= retryInterval`. Retries indefinitely (no cap) — a session usually gets re-archived by the existing resume-failure path if ACP is still down, restarting the cadence from the new `ArchivedAt`.
+- **Anti-storm stagger**: 10m global minimum gap (`DefaultAutoUnarchiveStaggerInterval`) between attempts, tracked in-memory (`LoopRunner.lastAutoUnarchiveAttempt`, guarded by `r.mu`). Each poll attempts at most the single most-overdue eligible session. **Never `time.Sleep`** in this path — it would stall loop delivery for the whole poll. The in-memory stagger resets on restart (acceptable, since cadence itself is restart-durable via persisted timestamps).
+- **On-by-default**: `LoopRunner.autoUnarchiveEnabled` defaults to `true`; configured via `SetAutoUnarchiveRecovery(enabled, retryInterval, stagger)` (server.go wires the two defaults above; a duration `<= 0` keeps the current value, letting tests override just one).
+- **Callback** (`LoopRunner.onAutoUnarchive`, wired in `server.go`) performs the same steps as manual unarchive: clear `Archived`/`ArchivedAt`/`ArchiveReason`/`AutoUnarchiveLastAttemptAt`, call `SessionManager.ResumeSession()`, broadcast `acp_started`/`acp_start_failed` + `session_archived(false)`, then reuse `handlers.Handlers.RestoreLoopOnUnarchive()` (exported for this purpose) to re-enable the loop.
+- **Cadence persistence**: `attemptAutoUnarchive()` persists `AutoUnarchiveLastAttemptAt = now` via `store.UpdateMetadata` (outside `r.mu`, mirroring `checkAutoArchive`) *before* invoking the callback, so the attempt survives a crash mid-resume. Cleared only when the callback returns `nil`; retained on error so the next poll retries after another full interval.
+- Cleared on any successful unarchive, manual or auto (`session_update.go`'s unarchive branch also resets it, so a user takeover resets the cadence).
+
## ACP Process Crash Recovery
`classifyACPError()` → **Permanent** (command not found, syntax error) = stop + guidance. **Transient** (network, crash) = retry with backoff.
diff --git a/.augment/rules/42-mcpserver-development.md b/.augment/rules/42-mcpserver-development.md
index 0035d37be..4e779f948 100644
--- a/.augment/rules/42-mcpserver-development.md
+++ b/.augment/rules/42-mcpserver-development.md
@@ -96,16 +96,7 @@ if callerMeta.WorkingDir != targetWS.WorkingDir {
## Optional Late-Bound Dependencies
-Some dependencies (e.g. `LoopRunner`) are initialized after the MCP server and wired in via setter methods rather than through `Dependencies`:
-
-```go
-// In internal/web/server.go — after s.loopRunner.Start():
-if s.mcpServer != nil {
- s.mcpServer.SetLoopRunner(s.loopRunner)
-}
-```
-
-The `LoopRunner` interface (defined in `mcpserver/server.go`) is satisfied by `*web.LoopRunner`. Use setter methods (not `Dependencies`) when a dependency must exist before `NewServer()` completes but the dependency itself starts later.
+Some dependencies (e.g. `LoopRunner`) are wired in via setter methods (`s.mcpServer.SetLoopRunner(s.loopRunner)` in `internal/web/server.go`, after `s.loopRunner.Start()`) rather than through `Dependencies`, since they must exist before `NewServer()` completes but start later. The `LoopRunner` interface (in `mcpserver/server.go`) is satisfied by `*web.LoopRunner`.
## Processor Auxiliary Session MCP Access
diff --git a/docs/devel/session-management.md b/docs/devel/session-management.md
index a1528571a..23885b902 100644
--- a/docs/devel/session-management.md
+++ b/docs/devel/session-management.md
@@ -35,6 +35,15 @@ flowchart TB
3. **Completion**: `Recorder.End()` marks session as completed
4. **Playback**: `Player` loads events for review/replay
+### Archive / Auto-Unarchive Recovery Lifecycle
+
+Sessions can be archived manually (`ArchiveReasonManual`), for inactivity (`ArchiveReasonInactivity`), or automatically after repeated ACP process start failures (`ArchiveReasonACPFailures`). The last case is the only one considered transient: a loop conversation archived this way is automatically retried by `LoopRunner.checkAutoUnarchiveRecovery()` (see `internal/web/loop_runner.go`), polled once per minute alongside the other loop housekeeping checks.
+
+- A loop conversation qualifies when it is archived with `ArchiveReasonACPFailures` and still has a loop configuration (`store.Loop(id).Get()` succeeds).
+- Each eligible conversation is retried roughly hourly, anchored on `Metadata.AutoUnarchiveLastAttemptAt` (or `ArchivedAt` if no attempt has been made yet) so the cadence survives a Mitto restart.
+- A 10-minute global stagger ensures at most one conversation is retried per poll, even if several become due simultaneously — the most-overdue one is picked.
+- A retry performs the same steps as a manual unarchive: clear the archive fields, resume the ACP process, broadcast the state change, and re-enable the loop. Failures leave the conversation archived so the schedule retries again after another interval; if the ACP outage persists, the normal resume-failure archiving path will typically re-archive the conversation, restarting the cadence from a fresh `ArchivedAt`.
+
## Immediate Persistence
Events are persisted **immediately** when received from ACP, preserving the sequence numbers assigned at streaming time. This ensures:
diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go
index 22540bee0..d88be5b2e 100644
--- a/internal/mcpserver/server.go
+++ b/internal/mcpserver/server.go
@@ -3591,6 +3591,7 @@ func (s *Server) handleArchiveConversation(ctx context.Context, req *mcp.CallToo
} else {
m.ArchivedAt = time.Time{}
m.ArchiveReason = ""
+ m.AutoUnarchiveLastAttemptAt = time.Time{}
}
})
if err != nil {
diff --git a/internal/session/types.go b/internal/session/types.go
index 53f2acad6..1b2c88393 100644
--- a/internal/session/types.go
+++ b/internal/session/types.go
@@ -289,6 +289,11 @@ type Metadata struct {
// Reset to 0 on successful start. When it reaches ACPStartFailureThreshold,
// the session is auto-archived to prevent infinite retry loops.
ACPStartFailureCount int `json:"acp_start_failure_count,omitempty"`
+ // AutoUnarchiveLastAttemptAt records the last time the auto-unarchive
+ // recovery scheduler attempted to unarchive this loop conversation.
+ // Persisted so the retry cadence survives restarts. Cleared on any
+ // successful (manual or auto) unarchive.
+ AutoUnarchiveLastAttemptAt time.Time `json:"auto_unarchive_last_attempt_at,omitempty"`
}
// ChildOrigin represents how a child conversation was created.
diff --git a/internal/web/handlers/session_update.go b/internal/web/handlers/session_update.go
index 97ab79dcb..a3dc861dc 100644
--- a/internal/web/handlers/session_update.go
+++ b/internal/web/handlers/session_update.go
@@ -93,6 +93,7 @@ func (h *Handlers) HandleUpdateSession(w http.ResponseWriter, r *http.Request, s
// Clear archived timestamp and reason when unarchiving
meta.ArchivedAt = time.Time{}
meta.ArchiveReason = ""
+ meta.AutoUnarchiveLastAttemptAt = time.Time{}
}
}
})
@@ -181,13 +182,13 @@ func (h *Handlers) HandleUpdateSession(w http.ResponseWriter, r *http.Request, s
// Restore/re-surface any loop configuration that was left disabled by
// the archive (mitto-vmp): auto-resume archive-related stops, keep
// other pauses paused, and always re-broadcast the current config.
- h.restoreLoopOnUnarchive(sessionID)
+ h.RestoreLoopOnUnarchive(sessionID)
}
writeJSONOK(w, meta)
}
-// restoreLoopOnUnarchive re-surfaces a session's loop configuration after
+// RestoreLoopOnUnarchive re-surfaces a session's loop configuration after
// unarchive. Loop config (prompt/arguments/trigger/etc.) survives archive in
// loop.json, but MarkStopped(StoppedReasonArchived/ResumeFailures) leaves it
// disabled with no broadcast, so clients never learn it's still there
@@ -198,7 +199,11 @@ func (h *Handlers) HandleUpdateSession(w http.ResponseWriter, r *http.Request, s
// BootstrapOnCompletion for onCompletion loops.
// 3. Leaves other pause reasons (user-paused, max iterations, etc.) alone.
// 4. Always re-broadcasts the current loop state so the UI can re-render it.
-func (h *Handlers) restoreLoopOnUnarchive(sessionID string) {
+//
+// Exported so the web package's auto-unarchive recovery scheduler
+// (LoopRunner.onAutoUnarchive, wired in server.go) can reuse the same
+// restore logic as the manual HTTP unarchive path.
+func (h *Handlers) RestoreLoopOnUnarchive(sessionID string) {
store := h.deps.Store
if store == nil {
return
diff --git a/internal/web/loop_runner.go b/internal/web/loop_runner.go
index eed0b2702..3391e9144 100644
--- a/internal/web/loop_runner.go
+++ b/internal/web/loop_runner.go
@@ -35,6 +35,15 @@ const (
// loopScheduleBackoffCap is the maximum backoff delay for scheduled
// loop delivery failures.
loopScheduleBackoffCap = 15 * time.Minute
+
+ // DefaultAutoUnarchiveRetryInterval is the default per-conversation retry
+ // cadence for auto-unarchiving loop conversations archived due to broken ACP.
+ DefaultAutoUnarchiveRetryInterval = 1 * time.Hour
+
+ // DefaultAutoUnarchiveStaggerInterval is the default global minimum gap
+ // between auto-unarchive attempts, preventing a retry storm when many
+ // sessions become due at once.
+ DefaultAutoUnarchiveStaggerInterval = 10 * time.Minute
)
// loopScheduleBackoff returns the delay to defer the next scheduled run after
@@ -76,6 +85,12 @@ type LoopStartedCallback func(sessionID, sessionName string)
// It should handle broadcasting the archive state change and stopping ACP.
type AutoArchiveCallback func(sessionID string)
+// AutoUnarchiveFunc is called when the loop runner attempts to auto-unarchive
+// a loop conversation previously archived due to broken ACP communication.
+// It should perform the same steps as a manual unarchive (resume ACP, restore
+// the loop, and broadcast the state changes) and return the resume error, if any.
+type AutoUnarchiveFunc func(sessionID string) error
+
// LoopAutoStoppedCallback is called when a loop conversation is auto-stopped after reaching max iterations.
// It should broadcast the updated loop state to all WebSocket clients.
type LoopAutoStoppedCallback func(sessionID string, loop *session.LoopPrompt)
@@ -122,6 +137,19 @@ type LoopRunner struct {
// autoArchiveAfter, when > 0, causes sessions inactive for this long to be archived.
autoArchiveAfter time.Duration
+ // onAutoUnarchive is called when the loop runner attempts to auto-unarchive a loop
+ // conversation archived due to broken ACP communication. Guarded implicitly: set
+ // once via SetOnAutoUnarchive before Start(), read without locking elsewhere.
+ onAutoUnarchive AutoUnarchiveFunc
+
+ // autoUnarchiveEnabled, autoUnarchiveRetryInterval, autoUnarchiveStagger and
+ // lastAutoUnarchiveAttempt configure and track the auto-unarchive recovery
+ // scheduler. Guarded by mu.
+ autoUnarchiveEnabled bool
+ autoUnarchiveRetryInterval time.Duration
+ autoUnarchiveStagger time.Duration
+ lastAutoUnarchiveAttempt time.Time
+
// archiveRetentionPeriod, when non-empty, causes archived sessions older than this
// to be permanently deleted during each poll cycle (not just at startup).
archiveRetentionPeriod string
@@ -214,22 +242,25 @@ func NewLoopRunner(store *session.Store, sm *conversation.SessionManager, logger
}
}
return &LoopRunner{
- store: store,
- sessionManager: sm,
- logger: logger,
- pollInterval: DefaultPollInterval,
- maxLoopIterations: config.DefaultMaxLoopIterations,
- minCompletionDelaySeconds: config.DefaultMinLoopCompletionDelaySeconds,
- consecutiveFailures: make(map[string]int),
- promptResolveFailures: make(map[string]int),
- scheduleBackoffFailures: make(map[string]int),
- completionTimers: make(map[string]*time.Timer),
- tasksEvaluator: evaluator,
- minTasksCooldownSeconds: DefaultMinLoopTasksCooldownSeconds,
- tasksQuiescenceWindow: tasksDefaultQuiescenceWindow,
- tasksRebaseTimers: make(map[string]*time.Timer),
- tasksNoProgressCount: make(map[string]int),
- tasksLastTouchedIDs: make(map[string]map[string]struct{}),
+ store: store,
+ sessionManager: sm,
+ logger: logger,
+ pollInterval: DefaultPollInterval,
+ maxLoopIterations: config.DefaultMaxLoopIterations,
+ minCompletionDelaySeconds: config.DefaultMinLoopCompletionDelaySeconds,
+ consecutiveFailures: make(map[string]int),
+ promptResolveFailures: make(map[string]int),
+ scheduleBackoffFailures: make(map[string]int),
+ completionTimers: make(map[string]*time.Timer),
+ tasksEvaluator: evaluator,
+ minTasksCooldownSeconds: DefaultMinLoopTasksCooldownSeconds,
+ tasksQuiescenceWindow: tasksDefaultQuiescenceWindow,
+ tasksRebaseTimers: make(map[string]*time.Timer),
+ tasksNoProgressCount: make(map[string]int),
+ tasksLastTouchedIDs: make(map[string]map[string]struct{}),
+ autoUnarchiveEnabled: true,
+ autoUnarchiveRetryInterval: DefaultAutoUnarchiveRetryInterval,
+ autoUnarchiveStagger: DefaultAutoUnarchiveStaggerInterval,
}
}
@@ -270,6 +301,27 @@ func (r *LoopRunner) SetOnAutoArchive(callback AutoArchiveCallback) {
r.onAutoArchive = callback
}
+// SetOnAutoUnarchive sets the callback invoked when the loop runner attempts
+// to auto-unarchive a loop conversation archived due to broken ACP communication.
+func (r *LoopRunner) SetOnAutoUnarchive(callback AutoUnarchiveFunc) {
+ r.onAutoUnarchive = callback
+}
+
+// SetAutoUnarchiveRecovery configures the auto-unarchive recovery scheduler.
+// If retryInterval or stagger is <= 0, the current (or default) value is kept,
+// allowing tests to override only what they need.
+func (r *LoopRunner) SetAutoUnarchiveRecovery(enabled bool, retryInterval, stagger time.Duration) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.autoUnarchiveEnabled = enabled
+ if retryInterval > 0 {
+ r.autoUnarchiveRetryInterval = retryInterval
+ }
+ if stagger > 0 {
+ r.autoUnarchiveStagger = stagger
+ }
+}
+
// SetOnLoopAutoStopped sets the callback for when a loop conversation is auto-stopped after reaching max iterations.
func (r *LoopRunner) SetOnLoopAutoStopped(callback LoopAutoStoppedCallback) {
r.onLoopAutoStopped = callback
@@ -861,6 +913,9 @@ func (r *LoopRunner) RunOnce() (delivered, skipped, errored int) {
// Auto-archive inactive sessions
r.checkAutoArchive(sessions, now)
+ // Retry auto-unarchiving loop conversations archived due to broken ACP
+ r.checkAutoUnarchiveRecovery(sessions, now)
+
// Clean up archived sessions past retention
r.checkArchiveCleanup()
@@ -1595,6 +1650,118 @@ func (r *LoopRunner) checkAutoArchive(sessions []session.Metadata, now time.Time
}
}
+// autoUnarchiveEligible reports whether meta qualifies for auto-unarchive recovery
+// and, if so, returns the anchor timestamp its retry cadence is computed from.
+// A session is eligible iff it is archived with ArchiveReasonACPFailures and has a
+// loop configured (loop.json present). The anchor is AutoUnarchiveLastAttemptAt if
+// non-zero, else ArchivedAt.
+func (r *LoopRunner) autoUnarchiveEligible(meta session.Metadata) (time.Time, bool) {
+ if !meta.Archived || meta.ArchiveReason != session.ArchiveReasonACPFailures {
+ return time.Time{}, false
+ }
+
+ _, err := r.store.Loop(meta.SessionID).Get()
+ if err != nil {
+ if err != session.ErrLoopNotFound && r.logger != nil {
+ r.logger.Error("Failed to read loop config during auto-unarchive check",
+ "session_id", meta.SessionID, "error", err)
+ }
+ return time.Time{}, false
+ }
+
+ anchor := meta.ArchivedAt
+ if !meta.AutoUnarchiveLastAttemptAt.IsZero() {
+ anchor = meta.AutoUnarchiveLastAttemptAt
+ }
+ return anchor, true
+}
+
+// checkAutoUnarchiveRecovery retries auto-unarchiving loop conversations archived
+// due to broken ACP communication (session.ArchiveReasonACPFailures), on a slow,
+// staggered, restart-durable schedule. At most one session is attempted per poll.
+func (r *LoopRunner) checkAutoUnarchiveRecovery(sessions []session.Metadata, now time.Time) {
+ r.mu.Lock()
+ enabled := r.autoUnarchiveEnabled
+ retryInterval := r.autoUnarchiveRetryInterval
+ stagger := r.autoUnarchiveStagger
+ lastAttempt := r.lastAutoUnarchiveAttempt
+ r.mu.Unlock()
+
+ if !enabled || r.onAutoUnarchive == nil {
+ return
+ }
+
+ if !lastAttempt.IsZero() && now.Sub(lastAttempt) < stagger {
+ return
+ }
+
+ var mostOverdue *session.Metadata
+ var mostOverdueAnchor time.Time
+ for i := range sessions {
+ anchor, ok := r.autoUnarchiveEligible(sessions[i])
+ if !ok || now.Sub(anchor) < retryInterval {
+ continue
+ }
+ if mostOverdue == nil || anchor.Before(mostOverdueAnchor) {
+ mostOverdue = &sessions[i]
+ mostOverdueAnchor = anchor
+ }
+ }
+
+ if mostOverdue != nil {
+ r.attemptAutoUnarchive(*mostOverdue, now)
+ }
+}
+
+// attemptAutoUnarchive attempts to auto-unarchive a single loop conversation
+// archived due to broken ACP communication. It persists the attempt timestamp
+// before calling the callback (so the cadence survives a crash mid-attempt),
+// and clears it only on success (resetting the cadence for the next failure).
+func (r *LoopRunner) attemptAutoUnarchive(meta session.Metadata, now time.Time) {
+ sessionID := meta.SessionID
+
+ if err := r.store.UpdateMetadata(sessionID, func(m *session.Metadata) {
+ m.AutoUnarchiveLastAttemptAt = now
+ }); err != nil {
+ if r.logger != nil {
+ r.logger.Error("Failed to persist auto-unarchive attempt timestamp",
+ "session_id", sessionID, "error", err)
+ }
+ return
+ }
+
+ r.mu.Lock()
+ r.lastAutoUnarchiveAttempt = now
+ r.mu.Unlock()
+
+ if r.logger != nil {
+ r.logger.Info("Attempting auto-unarchive of loop conversation archived due to broken ACP",
+ "session_id", sessionID, "session_name", meta.Name)
+ }
+
+ err := r.onAutoUnarchive(sessionID)
+ if err != nil {
+ if r.logger != nil {
+ r.logger.Warn("Auto-unarchive attempt failed, will retry later",
+ "session_id", sessionID, "error", err)
+ }
+ return
+ }
+
+ if clearErr := r.store.UpdateMetadata(sessionID, func(m *session.Metadata) {
+ m.AutoUnarchiveLastAttemptAt = time.Time{}
+ }); clearErr != nil {
+ if r.logger != nil {
+ r.logger.Error("Failed to clear auto-unarchive attempt timestamp after success",
+ "session_id", sessionID, "error", clearErr)
+ }
+ }
+
+ if r.logger != nil {
+ r.logger.Info("Auto-unarchived loop conversation successfully", "session_id", sessionID)
+ }
+}
+
// checkArchiveCleanup permanently deletes archived sessions older than the retention period.
func (r *LoopRunner) checkArchiveCleanup() {
r.mu.Lock()
diff --git a/internal/web/loop_runner_test.go b/internal/web/loop_runner_test.go
index 03afa6128..5b4bdb95f 100644
--- a/internal/web/loop_runner_test.go
+++ b/internal/web/loop_runner_test.go
@@ -3303,6 +3303,315 @@ func TestLoopRunner_TasksCooldownSettersGetters(t *testing.T) {
}
}
+// newArchivedLoopSession creates a session archived with the given reason and
+// timestamp, optionally with a loop config, for auto-unarchive recovery tests.
+func newArchivedLoopSession(t *testing.T, store *session.Store, sessionID string, archivedAt time.Time, reason session.ArchiveReason, hasLoop bool) {
+ t.Helper()
+ if err := store.Create(session.Metadata{
+ SessionID: sessionID,
+ ACPServer: "test",
+ WorkingDir: "/tmp",
+ }); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+ if err := store.UpdateMetadata(sessionID, func(m *session.Metadata) {
+ m.Archived = true
+ m.ArchivedAt = archivedAt
+ m.ArchiveReason = reason
+ }); err != nil {
+ t.Fatalf("UpdateMetadata() error = %v", err)
+ }
+ if hasLoop {
+ loopStore := store.Loop(sessionID)
+ if err := loopStore.Set(&session.LoopPrompt{
+ Prompt: "check",
+ Frequency: session.Frequency{Value: 1, Unit: session.FrequencyHours},
+ Enabled: false,
+ }); err != nil {
+ t.Fatalf("Loop Set() error = %v", err)
+ }
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_EligibleAndDuePersistsAttempt(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonACPFailures, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called []string
+ var attemptPersisted bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error {
+ called = append(called, sessionID)
+ if m, err := store.GetMetadata(sessionID); err == nil && !m.AutoUnarchiveLastAttemptAt.IsZero() {
+ attemptPersisted = true
+ }
+ return nil
+ })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if len(called) != 1 || called[0] != "sess-1" {
+ t.Errorf("onAutoUnarchive called = %v, want [sess-1]", called)
+ }
+ if !attemptPersisted {
+ t.Error("AutoUnarchiveLastAttemptAt should be persisted before invoking the callback")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_SuccessClearsAttempt(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonACPFailures, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+ runner.SetOnAutoUnarchive(func(sessionID string) error { return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ meta, err := store.GetMetadata("sess-1")
+ if err != nil {
+ t.Fatalf("GetMetadata() error = %v", err)
+ }
+ if !meta.AutoUnarchiveLastAttemptAt.IsZero() {
+ t.Error("AutoUnarchiveLastAttemptAt should be cleared after a successful auto-unarchive")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_FailureRetainsAttempt(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonACPFailures, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+ runner.SetOnAutoUnarchive(func(sessionID string) error { return errors.New("acp still broken") })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ now := time.Now()
+ runner.checkAutoUnarchiveRecovery(sessions, now)
+
+ meta, err := store.GetMetadata("sess-1")
+ if err != nil {
+ t.Fatalf("GetMetadata() error = %v", err)
+ }
+ if meta.AutoUnarchiveLastAttemptAt.IsZero() {
+ t.Error("AutoUnarchiveLastAttemptAt should be retained after a failed auto-unarchive")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_SkipsManualArchive(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonManual, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = true; return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if called {
+ t.Error("onAutoUnarchive should not be invoked for ArchiveReasonManual")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_SkipsInactivityArchive(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonInactivity, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = true; return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if called {
+ t.Error("onAutoUnarchive should not be invoked for ArchiveReasonInactivity")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_SkipsNonLoopACPFailures(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-2 * time.Hour)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonACPFailures, false)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = true; return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if called {
+ t.Error("onAutoUnarchive should not be invoked for a non-loop ACP-failures archive")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_SkipsWhenNotYetDue(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ archivedAt := time.Now().Add(-10 * time.Minute)
+ newArchivedLoopSession(t, store, "sess-1", archivedAt, session.ArchiveReasonACPFailures, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = true; return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if called {
+ t.Error("onAutoUnarchive should not be invoked before retryInterval has elapsed")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_StaggerLimitsToOnePerPoll(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ // sess-2 is more overdue than sess-1; both are due.
+ newArchivedLoopSession(t, store, "sess-1", time.Now().Add(-2*time.Hour), session.ArchiveReasonACPFailures, true)
+ newArchivedLoopSession(t, store, "sess-2", time.Now().Add(-3*time.Hour), session.ArchiveReasonACPFailures, true)
+
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called []string
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = append(called, sessionID); return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+
+ if len(called) != 1 {
+ t.Fatalf("onAutoUnarchive called %d times, want exactly 1", len(called))
+ }
+ if called[0] != "sess-2" {
+ t.Errorf("onAutoUnarchive called for %q, want the most-overdue session %q", called[0], "sess-2")
+ }
+}
+
+func TestLoopRunner_AutoUnarchive_RestartDurability(t *testing.T) {
+ store, err := session.NewStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+ defer store.Close()
+
+ // Simulate a prior attempt persisted before a restart: ArchivedAt is old,
+ // but AutoUnarchiveLastAttemptAt is the more recent anchor.
+ oldArchivedAt := time.Now().Add(-5 * time.Hour)
+ lastAttempt := time.Now().Add(-30 * time.Minute)
+ newArchivedLoopSession(t, store, "sess-1", oldArchivedAt, session.ArchiveReasonACPFailures, true)
+ if err := store.UpdateMetadata("sess-1", func(m *session.Metadata) {
+ m.AutoUnarchiveLastAttemptAt = lastAttempt
+ }); err != nil {
+ t.Fatalf("UpdateMetadata() error = %v", err)
+ }
+
+ // Fresh LoopRunner instance, as after a process restart (in-memory stagger reset).
+ runner := NewLoopRunner(store, nil, nil)
+ runner.SetAutoUnarchiveRecovery(true, time.Hour, 10*time.Minute)
+
+ var called bool
+ runner.SetOnAutoUnarchive(func(sessionID string) error { called = true; return nil })
+
+ sessions, err := store.List()
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+ // Only 30 minutes have elapsed since AutoUnarchiveLastAttemptAt, which is less
+ // than the 1h retryInterval, so it must NOT be attempted yet even though
+ // ArchivedAt is far in the past.
+ runner.checkAutoUnarchiveRecovery(sessions, time.Now())
+ if called {
+ t.Error("cadence should be anchored on persisted AutoUnarchiveLastAttemptAt, not ArchivedAt")
+ }
+
+ // Advance past retryInterval relative to the persisted anchor.
+ runner.checkAutoUnarchiveRecovery(sessions, lastAttempt.Add(time.Hour+time.Minute))
+ if !called {
+ t.Error("session should become due once retryInterval has elapsed since the persisted attempt timestamp")
+ }
+}
+
func TestTasksBaselineStore_GetSetRoundTrip(t *testing.T) {
dir := t.TempDir()
bs := NewTasksBaselineStore(dir)
diff --git a/internal/web/server.go b/internal/web/server.go
index 3d53fdcb4..6d90f7699 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -948,6 +948,37 @@ func NewServer(config Config) (*Server, error) {
}(),
})
+ // Auto-unarchive recovery: retry unarchiving loop conversations archived due
+ // to broken ACP communication, on a slow, staggered, restart-durable schedule.
+ // Reuses the same restore-loop logic as the manual HTTP unarchive path.
+ s.loopRunner.SetOnAutoUnarchive(func(sessionID string) error {
+ meta, err := store.GetMetadata(sessionID)
+ if err != nil {
+ return err
+ }
+
+ if err := store.UpdateMetadata(sessionID, func(m *session.Metadata) {
+ m.Archived = false
+ m.ArchivedAt = time.Time{}
+ m.ArchiveReason = ""
+ m.AutoUnarchiveLastAttemptAt = time.Time{}
+ }); err != nil {
+ return err
+ }
+
+ _, resumeErr := sessionMgr.ResumeSession(sessionID, meta.Name, meta.WorkingDir)
+ if resumeErr != nil {
+ s.BroadcastACPStartFailed(sessionID, meta.Name, resumeErr, "")
+ } else {
+ s.BroadcastACPStarted(sessionID)
+ }
+ s.BroadcastSessionArchived(sessionID, false)
+ s.apiHandlers.RestoreLoopOnUnarchive(sessionID)
+
+ return resumeErr
+ })
+ s.loopRunner.SetAutoUnarchiveRecovery(true, DefaultAutoUnarchiveRetryInterval, DefaultAutoUnarchiveStaggerInterval)
+
// Configure auto-archive inactive sessions if enabled
if config.MittoConfig != nil && config.MittoConfig.Session != nil {
autoArchivePeriod := config.MittoConfig.Session.GetAutoArchiveInactiveAfter()
From eb01619f46bfe22c5a2c52476a4f986587808602 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 14:48:42 +0200
Subject: [PATCH 009/240] fix(web): keep error toasts visible until manually
dismissed
Error toasts previously auto-dismissed after 10s like warnings, risking
a user missing a critical message. error-style toasts now never
auto-dismiss; they persist until the user closes them via the dismiss
button.
---
.augment/rules/25-web-frontend-components.md | 2 +-
web/static/hooks/useToast.js | 5 +++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/.augment/rules/25-web-frontend-components.md b/.augment/rules/25-web-frontend-components.md
index 88c898215..3cfed906e 100644
--- a/.augment/rules/25-web-frontend-components.md
+++ b/.augment/rules/25-web-frontend-components.md
@@ -119,7 +119,7 @@ const { showToast, dismissToast, toasts } = useToast();
showToast({ message: "Saved", style: "success" }); // auto-dismiss 5s
```
-Durations: info/success=5s, warning/error=10s. Max 5 simultaneous. Render via ` `. Use `error` (red) for actual errors only.
+Durations: info/success=5s, warning=10s. `error` toasts NEVER auto-dismiss — they persist until the user closes them (dismiss button), so critical errors can't be missed. Max 5 simultaneous. Render via ` `. Use `error` (red) for actual errors only.
## useResizeHandle / useSwipeNavigation
diff --git a/web/static/hooks/useToast.js b/web/static/hooks/useToast.js
index 3ec68895f..13d90c9dc 100644
--- a/web/static/hooks/useToast.js
+++ b/web/static/hooks/useToast.js
@@ -67,8 +67,9 @@ export function useToast({ maxToasts = 5 } = {}) {
return next;
});
- // Auto-dismiss unless sticky
- if (!sticky) {
+ // Auto-dismiss unless sticky. Error toasts never auto-dismiss so users
+ // cannot miss critical messages; they stay until manually closed.
+ if (!sticky && style !== "error") {
const ms = duration ?? DURATION_BY_STYLE[style] ?? 5000;
timersRef.current[id] = setTimeout(() => {
delete timersRef.current[id];
From a6c01251e8ec01e14c3abe60e2a62f9a41172bbc Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 14:48:54 +0200
Subject: [PATCH 010/240] docs: note Auggie MCP git-root workspace divergence
'auggie mcp list' resolves to the git toplevel, not the
Mitto workspace's working_dir. A workspace whose working_dir is a git
subdirectory sees servers registered in /.augment/settings.local.json
instead of its own file, so the MCP tab can show servers the running
agent never actually loads. Document the divergence and workarounds
(move to git-root config, register at user scope, or point working_dir
at the git root).
---
.augment/rules/42-mcpserver-development.md | 2 ++
CLAUDE.md | 2 ++
2 files changed, 4 insertions(+)
diff --git a/.augment/rules/42-mcpserver-development.md b/.augment/rules/42-mcpserver-development.md
index 4e779f948..7ddc67d5c 100644
--- a/.augment/rules/42-mcpserver-development.md
+++ b/.augment/rules/42-mcpserver-development.md
@@ -138,3 +138,5 @@ API endpoint: `GET /api/workspace-mcp-tools?acp_server=NAME&dir=PATH` (handler i
| github-copilot | BROKEN (mitto-sys.14) | wrong path: real is `~/.copilot/mcp-config.json` |
| qwen-code | BROKEN (mitto-sys.15) | wrong path: real is `~/.qwen` |
| junie | stub (mitto-sys.10) | always returns `{"servers": []}` |
+
+**Auggie git-root divergence** (not a script bug): `auggie mcp list` resolves `` to the **git toplevel**, not the target `workingDir` — so when `workingDir` is a git subdirectory, `mcp-list.sh` (which reads `/.augment/settings.local.json` literally) can report servers (e.g. `slack`) that auggie itself never loads (it reads `/.augment/settings.local.json` instead). Verify workspace vs. git-root config before trusting the MCP tab for auggie workspaces nested in a larger repo.
diff --git a/CLAUDE.md b/CLAUDE.md
index ef0ecefb3..28cf74dab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,6 +126,8 @@ Two-tier discovery for `enabledWhen`/CEL `tools.*` gating (see `docs/devel/mcp-t
Per-agent `mcp-list.sh` config paths/keys are **not** interchangeable across agents — verify against real docs before writing/trusting one (audit + known-broken scripts: `.augment/rules/42-mcpserver-development.md`).
+**Auggie git-root divergence**: `auggie mcp list` resolves `` to the **git toplevel**, not the Mitto workspace's `working_dir` — so a workspace whose `working_dir` is a git subdirectory sees servers registered in `/.augment/settings.local.json` (not its own `.augment/settings.local.json`). Mitto's `mcp-list.sh` reads `working_dir` literally, so the MCP tab can show servers (e.g. `slack`) the running agent never actually loads. Fix: move servers to the git-root config, register at user scope (`auggie mcp add`, no `--local`), or point `working_dir` at the git root.
+
## Loop Conversations
**onCompletion trigger** (distinct from schedule-based loop):
From 699614e2557ba7d1c0d79f3ab81fe0a502b88d45 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 14:49:03 +0200
Subject: [PATCH 011/240] feat(prompts): per-channel Slack support knowledge
files, need-info fix, and investigate prompt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace the single AGENTS.md 'How to answer customer questions'
section with a per-channel knowledge file
(.mitto/slack-support-.md), so each Slack channel keeps
its own repos/docs/runbooks/escalation guidance instead of sharing
one project-wide note.
- Fix the need-info state machine: once the customer replies to a
need-info bead, it now transitions to awaiting-us instead of staying
stranded in need-info.
- Fix the mitto_ui_textbox 'Result' field name from the non-existent
'full' to 'text' across all support prompts.
- Add 'Support: investigate' — a new prompt that digs for an answer
using the channel knowledge file, records findings on the bead, and
produces a draft reply without ever posting to Slack.
- 'Support: reply to user' now reuses an existing DRAFT comment instead
of regenerating it, persists the draft to the bead before opening the
review dialog, and correctly branches on submit/timeout/abort so a
draft is never lost when the reviewer is away.
---
.../builtin/support-check-status.prompt.yaml | 10 +-
.../support-continue-conversation.prompt.yaml | 18 ++-
.../builtin/support-gather-info.prompt.yaml | 2 +-
.../builtin/support-investigate.prompt.yaml | 145 ++++++++++++++++++
.../builtin/support-reply-to-user.prompt.yaml | 105 +++++++++----
.../builtin/support-watch-channel.prompt.yaml | 84 +++++-----
6 files changed, 284 insertions(+), 80 deletions(-)
create mode 100644 config/prompts/builtin/support-investigate.prompt.yaml
diff --git a/config/prompts/builtin/support-check-status.prompt.yaml b/config/prompts/builtin/support-check-status.prompt.yaml
index 202037de5..320bf71aa 100644
--- a/config/prompts/builtin/support-check-status.prompt.yaml
+++ b/config/prompts/builtin/support-check-status.prompt.yaml
@@ -91,9 +91,15 @@ prompt: |-
- The **last message is from the customer** and it asks something / provides info we must act on →
`state:awaiting-us`.
+ - **`state:need-info` + the customer just replied** (they answered our outstanding clarifying
+ question, or otherwise spoke last) → `state:awaiting-us`. Treat `need-info` exactly like
+ `awaiting-customer` here: once the customer responds, the ball is back in our court. Do **not**
+ leave it stranded in `need-info`.
- The **last message is ours** and we are waiting on the customer → `state:awaiting-customer`.
- - Do **not** override `state:need-info` or `state:drafting` if the situation has not changed (those
- reflect a pending action of ours). Only change the label when the thread clearly moved on.
+ - Do **not** override `state:drafting` if the situation has not changed (it reflects a pending
+ action of ours — a draft awaiting your review). Only change the label when the thread clearly
+ moved on. Likewise keep `state:need-info` **only while the customer has not yet replied**; the
+ moment they do, apply the `need-info → awaiting-us` rule above.
Apply with: `bd update --remove-label state: --add-label state:`, and add a
short `**[STATE · ]** → ` comment noting why.
diff --git a/config/prompts/builtin/support-continue-conversation.prompt.yaml b/config/prompts/builtin/support-continue-conversation.prompt.yaml
index 2483a1cd7..57cc1a431 100644
--- a/config/prompts/builtin/support-continue-conversation.prompt.yaml
+++ b/config/prompts/builtin/support-continue-conversation.prompt.yaml
@@ -34,7 +34,7 @@ prompt: |-
Find recent conversations in the Slack channel **`{{ $channel }}`** where the current user has been
participating, let the user pick one, summarize it, and help craft a reply — either by gathering an
- answer via the project's documented method or by rephrasing user-provided text.
+ answer via the channel knowledge file or by rephrasing user-provided text.
## CRITICAL: User Interaction Rules
@@ -115,18 +115,20 @@ prompt: |-
### Option A — "Gather an answer for me"
- - Use the **project's documented method to gather answers** (see AGENTS.md → "How to answer
- customer questions", or `.augment/rules/` / a docs-search MCP tool / runbook / knowledge base).
- If you do **not** know a reliable method, ASK the user how to gather it (via `mitto_ui_form` /
- `mitto_ui_textbox`) and persist their answer to AGENTS.md under a
- `## How to answer customer questions` section before proceeding.
+ - Use this channel's knowledge file **`.mitto/slack-support-{{ $channel }}.md`** as the
+ authoritative guide for how to gather answers (which repos to search, docs/runbooks/knowledge
+ bases to consult, common patterns, escalation paths, relevant MCP tools). Read it if it exists.
+ If it does **not** exist, create the `.mitto/` directory if missing, ASK the user how to gather
+ answers for this channel (via `mitto_ui_form` / `mitto_ui_textbox`), and write their guidance to
+ `.mitto/slack-support-{{ $channel }}.md` before proceeding. If you learn a new useful pattern,
+ append it to that file.
- Feed the full conversation context (original question, thread history, the specific unanswered
question) and ask for a response that addresses the open point, avoids repeating what was said,
and links docs/tickets if relevant. Proceed to Step 6.
### Option B — "I'll provide the answer"
- - Use `mitto_ui_textbox` (Title "Type your reply", Text "", Result "full", Abort true, Timeout 300).
+ - Use `mitto_ui_textbox` (Title "Type your reply", Text "", Result "text", Abort true, Timeout 300).
- If the user submits, rephrase their answer to be clearer/professional: fix grammar and typos, add
structure if complex, keep the technical accuracy and the user's voice/intent. Proceed to Step 6.
- If the user aborts, acknowledge and end.
@@ -140,7 +142,7 @@ prompt: |-
2. **No follow-up offers** — do not end with "let me know if you need anything else".
3. **Express uncertainty when appropriate** — "I think that…", "Based on my understanding…".
- Use `mitto_ui_textbox` to present the proposed reply for review/editing (Title "📤 Review reply
- before posting to Slack", Result "full", Abort true, Timeout 300).
+ before posting to Slack", Result "text", Abort true, Timeout 300).
- **If the user submits:** the returned text (possibly edited) is the final message. Post it with
the post-reply-to-thread tool using channel `{{ $channel }}` and the thread's parent `thread_ts`.
Confirm success and show the permalink.
diff --git a/config/prompts/builtin/support-gather-info.prompt.yaml b/config/prompts/builtin/support-gather-info.prompt.yaml
index 1ad9466cc..26736cc54 100644
--- a/config/prompts/builtin/support-gather-info.prompt.yaml
+++ b/config/prompts/builtin/support-gather-info.prompt.yaml
@@ -97,7 +97,7 @@ prompt: |-
- Use `mitto_ui_textbox`:
- **Title**: "📤 Review clarifying question before posting to Slack"
- **Text**: the proposed message
- - **Result**: "full"
+ - **Result**: "text"
- **Abort**: true
- **Timeout**: 300
diff --git a/config/prompts/builtin/support-investigate.prompt.yaml b/config/prompts/builtin/support-investigate.prompt.yaml
new file mode 100644
index 000000000..792b67b1d
--- /dev/null
+++ b/config/prompts/builtin/support-investigate.prompt.yaml
@@ -0,0 +1,145 @@
+name: 'Support: investigate'
+description: 'Investigate a tracked support question internally using the channel knowledge file (.mitto/slack-support-.md), record findings on the bead, and produce a draft reply. Never posts to Slack.'
+group: Support
+backgroundColor: '#D1C4E9'
+icon: search
+menus: beadsIssues
+enabledWhen: 'CommandExists("bd") && DirExists(".beads") && Item.Status != "closed" && "support-question" in Item.Labels'
+tags:
+- support
+parameters:
+ - name: IssueID
+ description: 'The beads issue ID to act on (auto-filled from the Beads issue menu)'
+ required: false
+ type: beadsId
+prompt: |-
+ # Support — Investigate
+
+ ## Session Context
+
+ Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls.
+
+ ## Description
+
+ Investigate a tracked support question **on our side** — do the work needed to actually answer it.
+ Use this when we have enough of the customer's context but still need to **find the answer**: follow
+ the channel's knowledge file (`.mitto/slack-support-.md` — docs, runbooks, repos, a
+ Q&A/knowledge MCP tool, commands, escalation paths), correlate with what is on the bead, record what
+ you learn, and — if you reach a confident answer — leave a **draft reply** ready for review.
+
+ > **Applicable state:** any open tracked bead. Typically `state:triaged`, `awaiting-us`, or
+ > `gathering-info`. This prompt sets `state:gathering-info` while it works.
+
+ ## CRITICAL: User Interaction Rules
+
+ > ⚠️ **NEVER use text-based interaction prompts.**
+ >
+ > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection
+ > - ❌ NEVER present numbered options and ask the user to respond with text
+ > - ✅ ALWAYS use `mitto_ui_options` for choices, `mitto_ui_textbox` for review/editing,
+ > `mitto_ui_form` for structured input, and `mitto_ui_notify` for non-blocking notifications
+ >
+ > ⚠️ **This prompt NEVER posts to Slack.** It only investigates and records a draft. Posting is
+ > done — with your approval — by **"Support: reply to user"**.
+
+ ## Slack tools (names vary by MCP server)
+
+ Match Slack MCP tools **by capability, not exact name**. Here you only need to **read a thread's
+ replies** (to make sure you are investigating the latest ask). Read the target channel and parent
+ `thread_ts` from the bead metadata (`slack_channel` / `slack_thread_ts`). No posting tool is needed.
+
+ ## Step 1: Identify the target bead
+
+ {{ $target := "" -}}
+ {{ if .Session.BeadsIssue }}{{ $target = .Session.BeadsIssue }}{{ else if .Args.IssueID }}{{ $target = .Args.IssueID }}{{ end -}}
+ {{ if $target }}
+ - This prompt was launched from bead **`{{ $target }}`** — operate on it directly (skip the picker).
+ {{- else }}
+ - **No linked bead** — show a picker: list open tracked questions
+ `bd list -l support-question --status open,in_progress --all` (read each `state:*` label from
+ `bd show`). Present them with `mitto_ui_options` (first option `{label: "None - Cancel"}`, then
+ one `{label: " [state] — "}` per bead, timeout 300). If the user cancels,
+ acknowledge and stop.
+ {{- end }}
+
+ ## Step 2: Load history + refresh the thread
+
+ - Load the full history: `bd show ` and `bd comments `. The bead is the source of truth.
+ Note the current `state:*` label and the stored `slack_thread_ts` / `slack_channel` / `slack_url`.
+ - **Guard — already `state:drafting`?** If the bead is in `state:drafting`, a draft reply already
+ exists and is waiting for your review — re-investigating would discard that work. Do **not** swap
+ the state automatically. Instead use `mitto_ui_options` (timeout 300):
+ - **Question**: "This bead already has a draft (`state:drafting`). Investigate again anyway?"
+ - **Options**: `[{label: "Open reply-to-user instead"}, {label: "Re-investigate anyway"}, {label: "Cancel"}]`.
+ - On **"Open reply-to-user instead"** or **"Cancel"** (or timeout): `mitto_ui_notify` (info)
+ suggesting **"Support: reply to user"**, and stop without changing state.
+ - Only on **"Re-investigate anyway"** proceed with the rest of this step.
+ - Re-fetch the Slack thread (read-thread-replies tool) using the stored `slack_thread_ts`, and add a
+ markdown comment for **each new** message not yet captured (`[INBOUND]` customer / `[CONTEXT]`
+ others). Do not duplicate existing comments — this keeps the investigation grounded in the latest
+ ask.
+ - Mark that we are actively working it: swap the state label in place (never `bd set-state`):
+ `bd update --remove-label state: --add-label state:gathering-info`, and add a short
+ `**[STATE · ]** → gathering-info` comment.
+
+ ## Step 3: Know the method (the per-channel knowledge file)
+
+ You must investigate using **this channel's documented method**, not by guessing. That method is
+ stored **per channel** in **`.mitto/slack-support-.md`**, where `` is the
+ bead's stored `slack_channel` value (from Step 2). Build the exact path from that value — e.g. if
+ `slack_channel` is `C0XXXXXXX`, the file is `.mitto/slack-support-C0XXXXXXX.md`.
+
+ 1. **Read it.** Check whether the channel's knowledge file exists.
+ - **If it exists** — read it in full. It is the **authoritative guide** for how to gather
+ information and answer questions for this channel (which repos to search, which
+ docs/runbooks/knowledge bases to consult, common patterns, escalation paths, and how to use
+ any relevant MCP tools). Use it in Step 4.
+ - **If it does NOT exist** — do not silently guess. Create the `.mitto/` directory if missing,
+ then use `mitto_ui_form` / `mitto_ui_textbox` to ASK the user exactly how to investigate
+ answers for this channel: which repos, docs, commands, knowledge bases, MCP tools, or people
+ to consult. Do not settle for a vague answer — re-ask for specifics. Write it to
+ `.mitto/slack-support-.md` (create the file) and confirm the write, so future runs
+ find it. If nobody responds (timeout), record a `[CONTEXT]` note that no knowledge file exists
+ yet and stop.
+ 2. **Keep it current.** If you discover a new useful pattern during this investigation (a better
+ search, a docs link that answered it, a recurring issue + resolution, an escalation contact),
+ **append it** to `.mitto/slack-support-.md` so future investigations benefit.
+
+ ## Step 4: Investigate
+
+ - Follow the channel knowledge file to actually find the answer. Pull in whatever it points to: the
+ Q&A / knowledge tool, docs links, repos, related tickets, prior resolved beads on the same topic,
+ and — where safe and useful — **run simple read-only commands locally** (e.g. `curl
+ some.domain.com`) and capture the command **and** its output.
+ - Correlate findings with the question on the bead. Note confidence, caveats, and any documentation
+ links you found (they are valuable in the eventual reply).
+
+ ## Step 5: Record findings on the bead
+
+ - Add a markdown `[CONTEXT]` comment capturing what you investigated and concluded — preserve the
+ layout with `$'...'` (real `\n`) or stdin:
+ ```
+ printf '%s' "$findings_markdown" | bd comment --stdin
+ ```
+ Header: `**[CONTEXT investigation · YYYY-MM-DD HH:MM UTC]**`. Include: what you checked, the key
+ finding(s), any commands + results, documentation links, and a confidence note.
+
+ ## Step 6: Decide the outcome
+
+ - **Confident answer found** → record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment
+ clearly marked **"DRAFT — not yet posted"** (this is the same draft **"Support: reply to user"**
+ reuses). Then set `state:drafting`
+ (`bd update --remove-label state:gathering-info --add-label state:drafting`) and
+ `mitto_ui_notify` (success) that a draft is ready to review with **"Support: reply to user"**.
+ - **Still missing customer detail** → set `state:need-info`
+ (`bd update --remove-label state:gathering-info --add-label state:need-info`), note what is
+ missing, and `mitto_ui_notify` (info) to run **"Support: gather more information"**.
+ - **Inconclusive** → leave `state:gathering-info`, record what is blocking, and `mitto_ui_notify`
+ (info) with the suggested next step.
+
+ ## Notes
+
+ - Load the bead (`bd show` + `bd comments`) **before** investigating — it is the source of truth.
+ - This prompt is **investigate-and-record** — it never posts a reply to the Slack channel.
+ - Keep the bead the single source of truth: log findings as `[CONTEXT]`, the draft as a marked
+ `[OUTBOUND]` DRAFT, and every state change as `[STATE]`.
diff --git a/config/prompts/builtin/support-reply-to-user.prompt.yaml b/config/prompts/builtin/support-reply-to-user.prompt.yaml
index 167993817..417b9bf60 100644
--- a/config/prompts/builtin/support-reply-to-user.prompt.yaml
+++ b/config/prompts/builtin/support-reply-to-user.prompt.yaml
@@ -1,5 +1,5 @@
name: 'Support: reply to user'
-description: 'Draft, review and post our answer to a tracked support question (bead in state:drafting). Posts to Slack only on your approval.'
+description: 'Review and post our answer to a tracked support question (bead in state:drafting). Reuses an existing draft or builds one, saves it on the bead (even on timeout), and posts to Slack only on your approval.'
group: Support
backgroundColor: '#B3E5FC'
icon: chat-bubble
@@ -22,9 +22,9 @@ prompt: |-
## Description
Post our answer to a tracked support question. Use this when the bead is **ready to be answered** —
- we already have enough information (typically after gathering info via the project's documented
- method or an investigation). It shows the proposed reply, lets you edit it, posts it to the Slack
- thread **on your approval**, and records it on the bead.
+ we already have enough information (typically after gathering info via the channel knowledge file or
+ an investigation). It shows the proposed reply, lets you edit it, posts it to the Slack thread **on
+ your approval**, and records it on the bead.
> **Applicable state:** `state:drafting`. If the bead is in a different state, warn and let the
> user decide whether to continue or stop (see Step 2).
@@ -79,54 +79,97 @@ prompt: |-
status"** first: `[{label: "Run Check status first (stop here)"}, {label: "Continue drafting"}]`.
Respect the choice.
- ## Step 4: Build the proposed reply
+ ## Step 4: Reuse or build the proposed reply
+
+ A **proposed reply** lives on the bead as an `[OUTBOUND]`-style comment clearly marked
+ **"DRAFT — not yet posted"** (created here or by **"Support: investigate"** / the watch loop). It
+ is the single source of truth for the draft — never keep it only in memory.
+
+ 1. **Look for an existing draft.** Scan `bd comments ` for the most recent DRAFT `[OUTBOUND]`
+ comment.
+ - **If one exists** → **reuse it as-is.** Do **not** regenerate or rewrite it; the user may have
+ already edited it. This is the text you will show in Step 5.
+ - **If none exists** → build one now from the bead history (investigation / info-gathering notes)
+ plus the refreshed thread, following the formatting guidelines below. Do not repeat what we
+ already said.
+ 2. **Formatting guidelines (only when building a new draft):**
+ 1. **No direct addressing** — do not use the user's name.
+ 2. **No follow-up offers** — do not end with "let me know if you need anything else".
+ 3. **Hedge** — start with "I think that…", "It seems like…"; we are never 100% sure.
+ 4. **Ask if unclear** — if scope/context is missing, ask rather than guess.
+ 5. **Run simple commands** — if we suggest e.g. `curl some.domain.com` and we can run it, run it
+ locally and show the command **and** the result.
+ 6. **Keep it short and conversational** — lead with the single most likely solution or next step;
+ avoid walls of text.
+
+ ## Step 5: Persist the draft to the bead (before review)
+
+ Always make sure the current proposed reply is saved on the bead **before** opening the review
+ dialog, so nothing is lost if the user is away.
+
+ - **If you built a NEW draft in Step 4**, record it now as a DRAFT `[OUTBOUND]` comment (preserve
+ the markdown with `$'...'` or stdin):
+ ```
+ printf '%s' "$draft_markdown" | bd comment --stdin
+ ```
+ Header: `**[OUTBOUND us · DRAFT — not yet posted · YYYY-MM-DD HH:MM UTC]**`, then the reply text.
+ - Ensure the bead is in `state:drafting` (swap in place, never `bd set-state`):
+ `bd update --remove-label state: --add-label state:drafting`.
+ - **If you reused an existing draft**, it is already persisted — do not add a duplicate comment.
+
+ ## Step 6: Review in an editable textbox
- - Draw from the bead history — especially the recorded draft (the `state:drafting` proposal) and
- any investigation / info-gathering notes — plus the refreshed thread. Do not repeat what we
- already said.
- Show a clickable link to the thread so the user can review context (use the stored `slack_url`):
`📤 Replying to [this thread]()`.
- - **Formatting guidelines for the Slack reply:**
- 1. **No direct addressing** — do not use the user's name.
- 2. **No follow-up offers** — do not end with "let me know if you need anything else".
- 3. **Hedge** — start with "I think that…", "It seems like…"; we are never 100% sure.
- 4. **Ask if unclear** — if scope/context is missing, ask rather than guess.
- 5. **Run simple commands** — if we suggest e.g. `curl some.domain.com` and we can run it, run it
- locally and show the command **and** the result.
- 6. **Keep it short and conversational** — lead with the single most likely solution or next step;
- avoid walls of text.
-
- ## Step 5: Review in an editable textbox
-
- Use `mitto_ui_textbox`:
- **Title**: "📤 Review reply before posting to Slack"
- - **Text**: the proposed reply
- - **Result**: "full"
+ - **Text**: the proposed reply (reused or freshly built)
+ - **Result**: "text"
- **Abort**: true
- **Timeout**: 300
+ - The tool returns `{ result, changed, aborted, timed_out }`. Branch on these in Step 7.
- ## Step 6: Post + log (on submit)
+ ## Step 7: Act on the textbox result
- - The returned text (possibly edited) is the final message. Post it with the post-reply-to-thread
+ ### On submit (`aborted` and `timed_out` are both false)
+
+ - The returned `result` (possibly edited) is the final message. Post it with the post-reply-to-thread
tool, using the stored `slack_channel` and the thread's parent `slack_thread_ts`.
- Confirm success and show the permalink (build it from the posted ts: `/archives/
/p`).
- - **Log to the bead** — add a markdown `[OUTBOUND]` comment with the final text and the permalink.
- Preserve the markdown layout with `$'...'` (real `\n`) or stdin:
+ - **Log to the bead** — add a markdown `[OUTBOUND]` comment (this one is the **posted** reply, not a
+ draft) with the final text and the permalink:
```
printf '%s' "$comment_markdown" | bd comment --stdin
```
- Header: `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**`.
- - **Transition state** (same issue, no subtasks — never `bd set-state`):
+ Header: `**[OUTBOUND us · POSTED · YYYY-MM-DD HH:MM UTC]**`.
+ - **Transition state** (same issue, never `bd set-state`):
`bd update --remove-label state:drafting --add-label state:awaiting-customer`.
(If Step 2 continued from a different state, remove that state label instead.)
- ## On abort
+ ### On timeout (`timed_out == true`)
+
+ The user is away — **do NOT post anything to Slack**, and do **not** re-open the dialog (staged
+ draft holding pattern).
+
+ - **Persist the proposed reply so it is never lost:**
+ - If the draft was **newly built** in Step 4, it was already saved in Step 5 — nothing more to do.
+ - If the user **edited** the text before the timeout (`changed == true` and a non-empty `result`
+ is returned), update the stored draft to that edited text: add a fresh DRAFT `[OUTBOUND]` comment
+ (header `**[OUTBOUND us · DRAFT (edited, not posted) · YYYY-MM-DD HH:MM UTC]**`) so the latest
+ version is on the bead for next time.
+ - Leave the bead in `state:drafting`. `mitto_ui_notify` (info): "Review dialog timed out — assuming
+ you're away. Nothing posted; the draft is saved on the bead. Re-run **Support: reply to user** when
+ you're back." Then stop (do not re-invoke the dialog or re-fetch).
+
+ ### On abort (`aborted == true`)
- - Do **NOT** post anything to Slack. Leave the bead in its current state (the draft stays pending).
- - Acknowledge and stop.
+ - Do **NOT** post anything to Slack. Leave the bead in `state:drafting` (the saved draft stays
+ pending). Acknowledge and stop.
## Notes
- Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth.
+ - The proposed reply always lives on the bead as a DRAFT `[OUTBOUND]` comment; reuse it rather than
+ regenerating, and keep it current even when the review dialog times out.
- **NEVER** send a reply in the support channel without explicit review and approval.
diff --git a/config/prompts/builtin/support-watch-channel.prompt.yaml b/config/prompts/builtin/support-watch-channel.prompt.yaml
index 621e178d6..d999a261c 100644
--- a/config/prompts/builtin/support-watch-channel.prompt.yaml
+++ b/config/prompts/builtin/support-watch-channel.prompt.yaml
@@ -58,7 +58,7 @@ prompt: |-
**Interactive run** (first send, or force-triggered ▶️) — a user may be present, so you MAY use the
interactive `mitto_ui_*` tools. This is the moment to run the **Knowledge self-check** and, if
- needed, ask the user how to gather answers and persist it to AGENTS.md.
+ needed, ask the user how to gather answers and persist it to this channel's knowledge file.
{{- end }}
## CRITICAL: user interaction rules
@@ -110,8 +110,7 @@ prompt: |-
| state | meaning |
|-------|---------|
| `triaged` | auto-created during triage, not yet worked (initial) |
- | `engaged` | actively working it |
- | `gathering-info` | gathering information to answer (docs/tools/investigation) |
+ | `gathering-info` | actively working it — investigating to answer (docs/tools/knowledge file) |
| `need-info` | need more details from the customer before we can answer |
| `drafting` | have a draft answer, pending your review |
| `awaiting-customer` | we posted a reply, waiting on the customer |
@@ -119,30 +118,32 @@ prompt: |-
| `resolved` | answered/accepted (also `bd close `) |
| `stale` | auto-closed after 10+ days of inactivity (also `bd close `) |
- ## Knowledge self-check: how to answer customer questions
-
- Before you can help a customer you must know a **reliable way to gather answers** for this
- project's domain. This knowledge lives in your project rules — **AGENTS.md** (or `CLAUDE.md` /
- `.augment/rules/` / equivalent).
-
- 1. **Self-check.** Read the rules/docs you already have and ask yourself honestly: *"Do I know a
- reliable way to gather information to answer questions about this project?"* A reliable method
- could be a documented MCP tool (a Q&A / knowledge / docs-search assistant), a runbook, a docs
- site, a specific set of commands, or a knowledge base. Look for a section such as
- **"## How to answer customer questions"** in AGENTS.md.
- 2. **If you DO know** — use that method to gather what you need, then draft (Step 4).
- 3. **If you do NOT know** — you must find out; do not silently guess:
- - **Interactive run:** ASK the user (via `mitto_ui_form` / `mitto_ui_textbox`) exactly how to
- gather information to answer customer questions reliably — which tools, docs, commands,
- knowledge bases, or people to consult. **Do not be satisfied until you get a concrete, usable
- answer** — if the reply is vague, re-ask for specifics. Once you have it, **persist it to
- AGENTS.md** under a `## How to answer customer questions` section (append, or update in place
- if it already exists), then confirm the write. From then on, future runs will find it in
- step 1.
- - **Silent scheduled run:** nobody is watching, so do **NOT** block. Send a `mitto_ui_notify`
- (warning) explaining that you don't yet know how to gather answers and the user should
- force-run this prompt (▶️) once to teach you. Continue triage + keeping beads in sync, but
- **skip drafting** answers this run.
+ ## Knowledge self-check: the per-channel knowledge file
+
+ Before you can help a customer you must know a **reliable way to gather answers** for this specific
+ channel's domain. That knowledge is stored **per channel** in
+ **`.mitto/slack-support-{{ $channel }}.md`** — a file dedicated to channel `{{ $channel }}`. Each
+ channel maintains its own knowledge base (which repos to search, which docs/runbooks to consult,
+ common patterns, escalation paths, and how to use any relevant MCP tools).
+
+ 1. **Read it.** Check whether `.mitto/slack-support-{{ $channel }}.md` exists.
+ - **If it exists** — read it in full at the start of the run. It is the **authoritative guide**
+ for how to gather information and answer questions for this channel. Use it in Step 4.
+ - **If it does NOT exist** — you do not yet know how to answer reliably for this channel:
+ - **Interactive run:** create the `.mitto/` directory if missing, then ASK the user (via
+ `mitto_ui_form` / `mitto_ui_textbox`) for channel-specific guidance: which repos to search,
+ which docs/runbooks/knowledge bases to consult, common patterns, escalation paths, and how
+ to use any relevant MCP tools. **Do not be satisfied until you get concrete, usable
+ guidance** — if the reply is vague, re-ask for specifics. Write it to
+ `.mitto/slack-support-{{ $channel }}.md` (create the file), then confirm the write. From then
+ on, every run will find it in step 1.
+ - **Silent scheduled run:** nobody is watching, so do **NOT** block. Send a `mitto_ui_notify`
+ (warning) explaining that no knowledge file exists yet for this channel and the user should
+ force-run this prompt (▶️) once to create it. Continue triage + keeping beads in sync, but
+ **skip drafting** answers this run.
+ 2. **Keep it current.** Whenever you learn a new useful pattern while working a question this run
+ (a better search, a docs link that answered it, a recurring issue + resolution, an escalation
+ contact), **append it** to `.mitto/slack-support-{{ $channel }}.md` so future runs benefit.
## Instructions
@@ -183,20 +184,24 @@ prompt: |-
- Add a markdown comment for **each new** thread message not yet captured (`[INBOUND]` customer /
`[CONTEXT]` others / `[OUTBOUND]` any of our own replies). Do not duplicate existing comments.
- Update the `state:*` label to reflect reality: last message from the customer needing action →
- `state:awaiting-us`; last message is ours, waiting on them → `state:awaiting-customer`. Do not
- override `need-info` / `drafting` if our pending action still stands. Add a short `[STATE]`
- comment noting any change.
-
- ### 4. Gather info + draft (only if the Knowledge self-check passed)
-
- - For beads where it is **our turn** (`state:triaged` / `awaiting-us` / `engaged`) and we can
- answer: set `state:gathering-info`, use the project's documented method to gather the answer,
- then record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment (clearly marked
- "DRAFT — not yet posted") and set `state:drafting`.
+ `state:awaiting-us`; last message is ours, waiting on them → `state:awaiting-customer`.
+ - **If the bead is in `need-info` and the customer just replied** (answered our clarifying question,
+ or spoke last) → `state:awaiting-us`. Treat `need-info` like `awaiting-customer` once the customer
+ responds — never leave it stranded in `need-info`.
+ - Do not override `drafting` if our pending action still stands (a draft awaiting review). Add a
+ short `[STATE]` comment noting any change.
+
+ ### 4. Gather info + draft (only if the knowledge file exists)
+
+ - For beads where it is **our turn** (`state:triaged` / `awaiting-us`) and we can
+ answer: set `state:gathering-info`, follow **`.mitto/slack-support-{{ $channel }}.md`** to gather
+ the answer, then record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment (clearly
+ marked "DRAFT — not yet posted") and set `state:drafting`. If you learn a reusable pattern,
+ append it to the knowledge file (see the Knowledge self-check).
- If you cannot answer without more detail from the customer, set `state:need-info` and note what
is missing (the interactive **"Support: gather more information"** prompt will ask them).
- **Never post to Slack here.** Drafts wait for review in **"Support: reply to user"**.
- - If the Knowledge self-check did **not** pass, skip this step (see that section).
+ - If no knowledge file exists for this channel yet, skip this step (see the Knowledge self-check).
### 5. Summary
@@ -208,5 +213,8 @@ prompt: |-
- The bead is the single source of truth — always load it (`bd show` + `bd comments`) before acting.
- Per-bead follow-ups: **"Support: check status"** (refresh a thread onto its bead),
+ **"Support: investigate"** (dig for the answer using the channel knowledge file + record a draft),
**"Support: gather more information"** (ask the customer), **"Support: reply to user"** (post our
answer). All posting requires your explicit approval.
+ - Channel knowledge lives in **`.mitto/slack-support-{{ $channel }}.md`** — read it before drafting
+ and append newly-learned patterns to it.
From 14cfacc950c6dbe170685e3decf3f20dc628cf9d Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 16:20:03 +0200
Subject: [PATCH 012/240] fix(acpproc): recycle confirmed-degraded shared ACP
process even while busy (mitto-1h0)
The mitto-tfb proactive recycle (GC Tier 5) only recycles a saturated shared
ACP process when it is fully idle. During the 2026-07-06 15:53-15:55
recurrence, session/new and set_model timed out at full RPC deadline while
those very RPCs were in flight (ActiveRPCs>0), so Tier 5's idle gate skipped
the process and it stayed wedged, starving user and auxiliary sessions.
Add GC Tier 6: recycles a CONFIRMED-degraded process (saturationLevel >= 2,
i.e. it tripped saturation, served its cooldown, and its post-cooldown probe
also timed out) even while busy, dropping the ActiveRPCs()/IsPrompting idle
gate. Guarded by a mandatory no-streamed-progress check (SessionInfo's new
LastStreamActivityAt) so a legitimately slow-but-progressing prompt is never
killed. Tier 5 is unchanged.
- shared_acp_process.go: non-mutating IsConfirmedDegraded()/SaturationLevel()
getters (saturationMu-guarded, never perturb the probe state machine).
- session_info.go / background_session.go / session_manager.go: plumb
LastStreamActivityAt from BackgroundSession into SessionInfo.
- acp_process_gc.go: Tier 6 block after Tier 5, same recycle action
(MarkGCSuspended + sessionClose + StopProcess).
- acp_process_gc_test.go: recycle-busy-degraded, skip-progressing-degraded,
skip-level1-busy.
- docs/devel/acp.md: document Tier 5 and Tier 6.
---
docs/devel/acp.md | 52 +++++-
internal/acpproc/acp_process_gc.go | 115 +++++++++++++
internal/acpproc/acp_process_gc_test.go | 175 ++++++++++++++++++++
internal/acpproc/shared_acp_process.go | 31 ++++
internal/conversation/background_session.go | 11 ++
internal/conversation/session_info.go | 6 +
internal/conversation/session_manager.go | 1 +
7 files changed, 390 insertions(+), 1 deletion(-)
diff --git a/docs/devel/acp.md b/docs/devel/acp.md
index d09a65c98..3e581885d 100644
--- a/docs/devel/acp.md
+++ b/docs/devel/acp.md
@@ -361,7 +361,7 @@ use a loop GC loop that is self-healing: even if something goes wrong, the next
cycle cleans up. `RunGCOnce()` executes the tiers below in order each cycle.
> The tier numbers reflect the order they were added, not their execution order. The
-> actual run order in `RunGCOnce()` is: **Tier 1 → Tier 2 → Tier 4 → Tier 3**.
+> actual run order in `RunGCOnce()` is: **Tier 1 → Tier 2 → Tier 4 → Tier 5 → Tier 6 → Tier 3**.
### Tier 1 — Idle Session Cleanup
@@ -452,6 +452,56 @@ This reuses the exact idle-safety and anti-thrash machinery already proven in Ti
loop-suspend path. The threshold is configurable per the
[Configuration](#configuration) section below.
+### Tier 5 — Degraded-Idle Process Recycling (mitto-tfb)
+
+Runs after Tier 4 (re-querying sessions). The saturation infrastructure in
+`internal/acpproc/shared_acp_process.go` (`sessionSaturationTimeoutThreshold`,
+`saturatedUntil`, `saturationLevel`) only fails new requests fast once a shared
+process is flagged saturated — it never *heals* the degraded process. Left alone, a
+saturated-but-idle process keeps failing every subsequent `NewSession`/`LoadSession`
+until its cooldown finally elapses.
+
+Tier 5 proactively recycles a process once `SharedACPProcess.IsSaturated()` is true
+**and** the process is fully idle — the exact same hard safety gates as Tier 4
+(`ActiveRPCs() == 0`, no session `IsPrompting`, all queues empty, no loop prompt due
+within 2× the GC interval). When those gates pass, each session is marked
+`MarkGCSuspended`, closed, and the process is stopped via `StopProcess`; the next
+`NewSession` lazily builds a fresh process with zeroed saturation state.
+
+### Tier 6 — Non-Idle Recycle for Confirmed-Degraded Processes (mitto-1h0)
+
+Tier 5 is **idle-gated**: it skips a process with in-flight RPCs or a prompting
+session. That is exactly backwards for a **wedged** process, where a
+timing-out `NewSession`/`SetSessionModel` RPC itself is the in-flight "activity"
+Tier 5 reads as busy — the process can stay wedged for minutes, starving both the
+user session and auxiliary sessions (title-gen, follow-up, MCP-check).
+
+Tier 6 escalates for a **confirmed-degraded** process — one where
+`SharedACPProcess.IsConfirmedDegraded()` is true, i.e. `saturationLevel >=
+confirmedDegradedLevel` (2). Reaching level 2 means the process tripped saturation,
+served its cooldown, ran a single-attempt probe (`inProbe`), and that probe **also**
+timed out — demonstrable proof it is not self-healing.
+
+For a confirmed-degraded process, Tier 6:
+
+- **Drops the `ActiveRPCs() > 0` / `IsPrompting` gate** — those in-flight, timing-out
+ control RPCs are the wedge itself, not legitimate work.
+- **Keeps a mandatory no-streamed-progress guard**: if any session has streamed
+ agent activity (`SessionInfo.LastStreamActivityAt`, mirroring
+ `BackgroundSession.lastStreamActivityAt`) within a short quiet window
+ (`tier6StreamedProgressQuietWindow`, 10s — mirrors the `conversation` package's
+ `agentWorkingHeartbeatQuietThreshold`), the process is skipped: that session is
+ legitimately slow but **progressing**, not wedged. This is the hard constraint from
+ the parent epic (mitto-8ul) — never kill a healthy but slow tool call.
+- Keeps the just-resumed grace skip (`ResumedAt` within the GC interval) and the
+ loop-due-soon skip, matching Tier 5's conservative semantics for those cases.
+
+When a process passes all gates, its sessions are `MarkGCSuspended` + closed and the
+process is stopped exactly as in Tier 5, logged at `Info` as "GC: recycling
+confirmed-degraded busy shared ACP process". A level-1 (first-trip, non-probed)
+saturated busy process is **not** recycled by Tier 6 — only Tier 5's idle path
+governs it until it escalates to level 2.
+
### Tier 3 — Auxiliary Session Cleanup
Cleans up auxiliary sessions (title-gen, follow-ups, prompt improvement) idle longer
diff --git a/internal/acpproc/acp_process_gc.go b/internal/acpproc/acp_process_gc.go
index f593c6854..b6d26e893 100644
--- a/internal/acpproc/acp_process_gc.go
+++ b/internal/acpproc/acp_process_gc.go
@@ -7,6 +7,13 @@ import (
"github.com/inercia/mitto/internal/conversation"
)
+// tier6StreamedProgressQuietWindow is the minimum time since a session's last
+// streamed agent update before Tier 6 considers it eligible for recycle
+// (mitto-1h0). Mirrors conversation.agentWorkingHeartbeatQuietThreshold (10s):
+// that constant is unexported in package conversation, so it is duplicated here
+// rather than imported. Keep the two values in sync if either changes.
+const tier6StreamedProgressQuietWindow = 10 * time.Second
+
// GCConfig configures the garbage collection loop.
type GCConfig struct {
// Interval is how often the GC runs (default: 30s).
@@ -241,6 +248,14 @@ func (m *ACPProcessManager) gcLoop() {
// NewSession lazily builds a fresh, healthy process — instead of the degraded one
// continuing to starve resumes/loop-prompts.
//
+// Tier 6 escalates Tier 5 to recycle a CONFIRMED-degraded process (saturationLevel
+// >= confirmedDegradedLevel) even while it is busy (mitto-1h0): a control-plane
+// wedge shows up as in-flight-but-timing-out RPCs, which Tier 5's idle gate reads
+// as legitimate work and refuses to recycle. Tier 6 drops the ActiveRPCs()/
+// IsPrompting gate for confirmed-degraded processes, but is guarded by a
+// no-streamed-progress check (LastStreamActivityAt) so a legitimately slow but
+// progressing prompt is never killed.
+//
// Tier 3 cleans up auxiliary sessions that have been idle longer than AuxIdleTimeout.
// Cleaned-up sessions are lazily re-created on next use via getOrCreateAuxiliarySession.
func (m *ACPProcessManager) RunGCOnce() {
@@ -736,6 +751,106 @@ gcTier1:
}
}
+ // ----------------------------------------------------------------
+ // Tier 6: recycle CONFIRMED-degraded processes even while busy (mitto-1h0)
+ // Re-query sessions so any closed by earlier tiers are excluded.
+ // ----------------------------------------------------------------
+ {
+ sessionsByWorkspace = m.sessionQuery()
+
+ m.mu.RLock()
+ degradedUUIDs := make([]string, 0, len(m.processes))
+ for uuid := range m.processes {
+ degradedUUIDs = append(degradedUUIDs, uuid)
+ }
+ m.mu.RUnlock()
+
+ for _, workspaceUUID := range degradedUUIDs {
+ p := m.GetProcess(workspaceUUID)
+ if p == nil {
+ continue
+ }
+
+ // Only act on confirmed-degraded processes (saturationLevel >= 2):
+ // they have already tripped saturation, served a cooldown, and their
+ // post-cooldown probe ALSO timed out — demonstrably not self-healing.
+ if !p.IsConfirmedDegraded() {
+ continue
+ }
+
+ sessions := sessionsByWorkspace[workspaceUUID]
+
+ // Just-resumed grace: never recycle a session that was resumed within
+ // the last GC interval (same as Tier 1/5's grace window).
+ skip := false
+ for _, s := range sessions {
+ if !s.ResumedAt.IsZero() && now.Sub(s.ResumedAt) < m.gcConfig.Interval {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping confirmed-degraded recycle (recently resumed)",
+ "workspace_uuid", workspaceUUID,
+ "session_id", s.SessionID,
+ "resumed_ago", now.Sub(s.ResumedAt))
+ }
+ skip = true
+ break
+ }
+ if s.NextLoopAt != nil && s.NextLoopAt.Before(now.Add(2*m.gcConfig.Interval)) {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping confirmed-degraded recycle (loop prompt due soon)",
+ "workspace_uuid", workspaceUUID,
+ "session_id", s.SessionID,
+ "next_loop_at", s.NextLoopAt)
+ }
+ skip = true
+ break
+ }
+ }
+ if skip {
+ continue
+ }
+
+ // No-streamed-progress guard (mandatory anti-regression): if any session
+ // has streamed activity within the quiet window, it is legitimately
+ // progressing — do NOT kill it, regardless of in-flight RPCs or prompting
+ // state. Note: unlike Tier 5, we deliberately do NOT gate on
+ // ActiveRPCs()/IsPrompting here — the in-flight, timing-out control RPCs
+ // (session/new, set_model) ARE the wedge, not real work.
+ progressing := false
+ for _, s := range sessions {
+ if !s.LastStreamActivityAt.IsZero() && now.Sub(s.LastStreamActivityAt) < tier6StreamedProgressQuietWindow {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping confirmed-degraded recycle (session progressing)",
+ "workspace_uuid", workspaceUUID,
+ "session_id", s.SessionID,
+ "stream_idle", now.Sub(s.LastStreamActivityAt))
+ }
+ progressing = true
+ break
+ }
+ }
+ if progressing {
+ continue
+ }
+
+ // Confirmed-degraded, not progressing — recycle even though busy.
+ if m.logger != nil {
+ m.logger.Info("GC: recycling confirmed-degraded busy shared ACP process",
+ "workspace_uuid", workspaceUUID,
+ "saturation_level", p.SaturationLevel(),
+ "active_rpcs", p.ActiveRPCs(),
+ "session_count", len(sessions))
+ }
+ for _, s := range sessions {
+ m.MarkGCSuspended(s.SessionID)
+ m.sessionClose(s.SessionID)
+ }
+ m.StopProcess(workspaceUUID)
+ m.gcMu.Lock()
+ delete(m.lastSessionSeen, workspaceUUID)
+ m.gcMu.Unlock()
+ }
+ }
+
// ----------------------------------------------------------------
// Tier 3: clean up idle auxiliary sessions
// ----------------------------------------------------------------
diff --git a/internal/acpproc/acp_process_gc_test.go b/internal/acpproc/acp_process_gc_test.go
index 7d14177a9..03698a745 100644
--- a/internal/acpproc/acp_process_gc_test.go
+++ b/internal/acpproc/acp_process_gc_test.go
@@ -1558,3 +1558,178 @@ func TestGCHealthTier_RecyclesSaturatedIdleProcess(t *testing.T) {
}
}
}
+
+// driveToConfirmedDegraded pushes proc's saturation state machine to
+// saturationLevel >= confirmedDegradedLevel (2), mirroring the real sequence:
+// trip saturation (threshold consecutive timeouts) -> cooldown elapses (probe
+// opens) -> the probe itself times out (escalates to level 2).
+func driveToConfirmedDegraded(t *testing.T, proc *SharedACPProcess) {
+ t.Helper()
+ for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
+ proc.recordRPCTimeout()
+ }
+ if proc.SaturationLevel() != 1 {
+ t.Fatalf("test setup: expected saturationLevel 1 after initial trip, got %d", proc.SaturationLevel())
+ }
+ // Force the cooldown to have already elapsed so isSaturated() opens the probe.
+ proc.saturationMu.Lock()
+ proc.saturatedUntil = time.Now().Add(-time.Millisecond)
+ proc.saturationMu.Unlock()
+ if proc.isSaturated() {
+ t.Fatalf("test setup: expected isSaturated()=false once cooldown elapses (probe opens)")
+ }
+ proc.saturationMu.Lock()
+ inProbe := proc.inProbe
+ proc.saturationMu.Unlock()
+ if !inProbe {
+ t.Fatalf("test setup: expected inProbe=true after cooldown elapse")
+ }
+ // The probe itself times out -> escalates to level 2 and re-saturates.
+ proc.recordRPCTimeout()
+ if !proc.IsConfirmedDegraded() {
+ t.Fatalf("test setup: expected IsConfirmedDegraded()=true, saturationLevel=%d", proc.SaturationLevel())
+ }
+}
+
+// TestGCTier6_RecyclesBusyConfirmedDegradedProcess verifies that a confirmed-
+// degraded (saturationLevel >= 2) shared ACP process is recycled by Tier 6 even
+// while busy (in-flight RPCs / a prompting session), provided no session shows
+// recent streamed activity (mitto-1h0).
+func TestGCTier6_RecyclesBusyConfirmedDegradedProcess(t *testing.T) {
+ workspaceUUID := "ws-degraded-busy"
+ proc := newTestSharedProcess()
+ driveToConfirmedDegraded(t, proc)
+ proc.activeRPCs.Add(1) // simulate an in-flight (wedged) control RPC
+
+ sessions := map[string][]conversation.SessionInfo{
+ workspaceUUID: {
+ {
+ SessionID: "s1",
+ WorkspaceUUID: workspaceUUID,
+ HasObservers: true,
+ IsPrompting: true,
+ LastStreamActivityAt: time.Now().Add(-time.Hour), // stale
+ },
+ },
+ }
+
+ var mu sync.Mutex
+ closed := make(map[string]bool)
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ closed[id] = true
+ },
+ )
+ m.mu.Lock()
+ m.processes[workspaceUUID] = proc
+ m.mu.Unlock()
+
+ m.RunGCOnce()
+
+ m.mu.RLock()
+ _, exists := m.processes[workspaceUUID]
+ m.mu.RUnlock()
+ if exists {
+ t.Error("confirmed-degraded busy process should have been recycled by Tier 6")
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if !closed["s1"] {
+ t.Error("expected session s1 to be closed during Tier 6 recycle")
+ }
+ if !m.IsGCSuspended("s1") {
+ t.Error("expected session s1 to be marked GC-suspended before close")
+ }
+}
+
+// TestGCTier6_SkipsProgressingDegradedProcess verifies that a confirmed-degraded
+// process is NOT recycled by Tier 6 when a session has streamed activity within
+// the quiet window — it is legitimately slow but progressing (mitto-1h0
+// anti-regression guard).
+func TestGCTier6_SkipsProgressingDegradedProcess(t *testing.T) {
+ workspaceUUID := "ws-degraded-progressing"
+ proc := newTestSharedProcess()
+ driveToConfirmedDegraded(t, proc)
+ proc.activeRPCs.Add(1)
+
+ sessions := map[string][]conversation.SessionInfo{
+ workspaceUUID: {
+ {
+ SessionID: "s1",
+ WorkspaceUUID: workspaceUUID,
+ HasObservers: true,
+ IsPrompting: true,
+ LastStreamActivityAt: time.Now(), // recent -> progressing
+ },
+ },
+ }
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {},
+ )
+ m.mu.Lock()
+ m.processes[workspaceUUID] = proc
+ m.mu.Unlock()
+
+ m.RunGCOnce()
+
+ m.mu.RLock()
+ _, exists := m.processes[workspaceUUID]
+ m.mu.RUnlock()
+ if !exists {
+ t.Error("progressing confirmed-degraded process should NOT have been recycled by Tier 6")
+ }
+}
+
+// TestGCTier6_SkipsLevel1BusyProcess verifies that a level-1 (first-trip)
+// saturated busy process is NOT recycled by Tier 6 — only Tier 5's idle-gated
+// path governs level-1 saturation (mitto-1h0).
+func TestGCTier6_SkipsLevel1BusyProcess(t *testing.T) {
+ workspaceUUID := "ws-level1-busy"
+ proc := newTestSharedProcess()
+ for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
+ proc.recordRPCTimeout()
+ }
+ if proc.SaturationLevel() != 1 {
+ t.Fatalf("test setup: expected saturationLevel 1, got %d", proc.SaturationLevel())
+ }
+ if proc.IsConfirmedDegraded() {
+ t.Fatalf("test setup: level-1 process should not be IsConfirmedDegraded()")
+ }
+ proc.activeRPCs.Add(1)
+
+ sessions := map[string][]conversation.SessionInfo{
+ workspaceUUID: {
+ {
+ SessionID: "s1",
+ WorkspaceUUID: workspaceUUID,
+ HasObservers: true,
+ IsPrompting: true,
+ LastStreamActivityAt: time.Now().Add(-time.Hour),
+ },
+ },
+ }
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {},
+ )
+ m.mu.Lock()
+ m.processes[workspaceUUID] = proc
+ m.mu.Unlock()
+
+ m.RunGCOnce()
+
+ m.mu.RLock()
+ _, exists := m.processes[workspaceUUID]
+ m.mu.RUnlock()
+ if !exists {
+ t.Error("level-1 saturated busy process should NOT have been recycled by Tier 6")
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index f3009fee3..7ed136cab 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -127,6 +127,14 @@ const (
// of the pre-fix flat-cooldown design.
sessionSaturationCooldownMax = 5 * time.Minute
+ // confirmedDegradedLevel is the minimum saturationLevel at which a process is
+ // considered "confirmed degraded" (mitto-1h0): it has tripped saturation, served
+ // its cooldown, run a single-attempt probe, and that probe ALSO timed out — i.e.
+ // it has demonstrably failed to self-heal. Used by IsConfirmedDegraded() to gate
+ // the GC's non-idle recycle tier (Tier 6), which recycles even a busy process
+ // once this bar is met.
+ confirmedDegradedLevel = 2
+
// Note: Runtime restart constants (maxProcessRestarts, processRestartWindow,
// processRestartBaseDelay, processRestartMaxDelay) are now defined in
// acp_error_classification.go as shared constants (conversation.MaxACPRestarts, conversation.ACPRestartWindow,
@@ -873,6 +881,29 @@ func (p *SharedACPProcess) IsSaturated() bool {
return time.Now().Before(p.saturatedUntil)
}
+// IsConfirmedDegraded reports whether the process is currently saturated AND has
+// reached confirmedDegradedLevel (mitto-1h0): it tripped saturation, served its
+// cooldown, ran a single-attempt probe, and that probe also timed out. Like
+// IsSaturated(), this is a NON-mutating read guarded by saturationMu — it never
+// flips inProbe or otherwise perturbs the saturation state machine, so the GC's
+// non-idle recycle tier (Tier 6) can poll it safely.
+func (p *SharedACPProcess) IsConfirmedDegraded() bool {
+ p.saturationMu.Lock()
+ defer p.saturationMu.Unlock()
+ if p.saturatedUntil.IsZero() {
+ return false
+ }
+ return time.Now().Before(p.saturatedUntil) && p.saturationLevel >= confirmedDegradedLevel
+}
+
+// SaturationLevel returns the current saturation escalation level (0 = healthy).
+// Non-mutating; for tests and observability.
+func (p *SharedACPProcess) SaturationLevel() int {
+ p.saturationMu.Lock()
+ defer p.saturationMu.Unlock()
+ return p.saturationLevel
+}
+
// rpcErrorCode extracts the JSON-RPC error code from err when it (or any error it
// wraps) is an *acp.RequestError. The second return reports whether a code was
// found. Used to surface a structured, queryable rpc_code on NewSession failures
diff --git a/internal/conversation/background_session.go b/internal/conversation/background_session.go
index 1aed6e6ef..5738887f2 100644
--- a/internal/conversation/background_session.go
+++ b/internal/conversation/background_session.go
@@ -1395,6 +1395,17 @@ func (bs *BackgroundSession) HasObservers() bool {
return bs.ObserverCount() > 0
}
+// LastStreamActivityAt returns the time of the most recent streamed update
+// received from the agent (see lastStreamActivityAt). Returns zero time if no
+// streamed activity has been observed yet.
+func (bs *BackgroundSession) LastStreamActivityAt() time.Time {
+ nanos := bs.lastStreamActivityAt.Load()
+ if nanos == 0 {
+ return time.Time{}
+ }
+ return time.Unix(0, nanos)
+}
+
// notifyObservers calls a function on all observers.
func (bs *BackgroundSession) notifyObservers(fn func(SessionObserver)) {
bs.observersMu.RLock()
diff --git a/internal/conversation/session_info.go b/internal/conversation/session_info.go
index a23d74257..791206989 100644
--- a/internal/conversation/session_info.go
+++ b/internal/conversation/session_info.go
@@ -34,4 +34,10 @@ type SessionInfo struct {
// work, making it the correct signal for the loop-suspend grace window.
// Zero if the agent has not completed a response since the session was resumed.
LastResponseCompleteAt time.Time
+ // LastStreamActivityAt is when the session last received a streamed update from
+ // the agent (mirrors BackgroundSession.lastStreamActivityAt). Unlike
+ // LastActivityAt, it grows monotonically through a long silent tool call and is
+ // the correct signal to distinguish a wedged process from one making genuine,
+ // slow progress. Zero if no streamed activity has been observed.
+ LastStreamActivityAt time.Time
}
diff --git a/internal/conversation/session_manager.go b/internal/conversation/session_manager.go
index 31947e538..31250f9f0 100644
--- a/internal/conversation/session_manager.go
+++ b/internal/conversation/session_manager.go
@@ -2677,6 +2677,7 @@ func (sm *SessionManager) GetSessionInfoByWorkspace() map[string][]SessionInfo {
LastObserverRemovedAt: bs.LastObserverRemovedAt(),
LastActivityAt: bs.LastActivityAt(),
LastResponseCompleteAt: bs.GetLastResponseCompleteTime(),
+ LastStreamActivityAt: bs.LastStreamActivityAt(),
})
}
return result
From 9faa686147d398f314a6e5a5e4568b5ad76ed949 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 16:20:18 +0200
Subject: [PATCH 013/240] fix(beads): surface schema-version skew as actionable
HTTP 409 instead of bare 500
bd refuses to auto-apply pending schema migrations to a remote-backed
database (only one designated clone may migrate it), so every read against
that store previously failed and surfaced to the frontend as a generic 500
with no indication of what was wrong or how to fix it.
- internal/beads/beads.go: IsSchemaSkew() detects the skew signature in a
command's stderr; SchemaSkewDBPath() best-effort parses the offending
database path out of the same stderr for the remediation message.
- internal/web/handlers/beads.go: writeBeadsError() now distinguishes a
schema skew from a genuine internal error, responding HTTP 409 with a
remediation hint (db path + migrate/bootstrap instructions) instead of a
bare 500.
- internal/web/handlers/helpers.go: new errCodeBeadsSchemaSkew error code.
- web/static/components/BeadsView.js: renders a distinct actionable error
card (schemaSkew state) when the list load fails with that error code,
instead of the plain error text.
- Unit tests for IsSchemaSkew/SchemaSkewDBPath and the handler's 409 path.
---
internal/beads/beads.go | 39 ++++++++++++++++++++++
internal/beads/beads_test.go | 37 +++++++++++++++++++++
internal/web/handlers/beads.go | 31 ++++++++++++++++--
internal/web/handlers/beads_test.go | 43 ++++++++++++++++++++++++
internal/web/handlers/helpers.go | 5 +++
web/static/components/BeadsView.js | 51 +++++++++++++++++++++++++----
6 files changed, 197 insertions(+), 9 deletions(-)
diff --git a/internal/beads/beads.go b/internal/beads/beads.go
index 12011241d..4decffe3a 100644
--- a/internal/beads/beads.go
+++ b/internal/beads/beads.go
@@ -43,6 +43,45 @@ func IsNotFound(err error) bool {
return strings.Contains(strings.ToLower(StderrOf(err)), "no issue found matching")
}
+// IsSchemaSkew reports whether err represents a bd schema-version skew
+// failure: bd deliberately refuses to auto-apply pending migrations to a
+// remote-backed database (only one designated clone may migrate it), so every
+// read against that store fails until reconciled. This is distinct from a
+// transient/startup failure and callers use it to map the failure to an
+// actionable "needs migration" response instead of a bare 500.
+func IsSchemaSkew(err error) bool {
+ if err == nil {
+ return false
+ }
+ stderr := strings.ToLower(StderrOf(err))
+ if strings.Contains(stderr, "schema version mismatch") {
+ return true
+ }
+ return strings.Contains(stderr, "schema migration") && strings.Contains(stderr, "remote-backed database")
+}
+
+// SchemaSkewDBPath best-effort parses the beads database path out of a schema
+// skew error's stderr, e.g. from:
+//
+// failed to open routed store at /Users/alvaro/.beads-planning: schema version mismatch
+//
+// it returns "/Users/alvaro/.beads-planning". Returns "" if the path cannot be
+// parsed.
+func SchemaSkewDBPath(err error) string {
+ stderr := StderrOf(err)
+ const marker = "store at "
+ idx := strings.Index(stderr, marker)
+ if idx < 0 {
+ return ""
+ }
+ rest := stderr[idx+len(marker):]
+ end := strings.Index(rest, ":")
+ if end < 0 {
+ return ""
+ }
+ return strings.TrimSpace(rest[:end])
+}
+
// CreateParams carries optional fields for Client.Create.
type CreateParams struct {
Title string
diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go
index 8fe61f9da..13d966bfc 100644
--- a/internal/beads/beads_test.go
+++ b/internal/beads/beads_test.go
@@ -107,6 +107,43 @@ func TestIsNotFound(t *testing.T) {
}
}
+func TestIsSchemaSkew(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil", nil, false},
+ {"real v49->v53 stderr", &CmdError{Err: errors.New("bd exited with non-zero status"),
+ Stderr: "... refusing to auto-apply 4 pending schema migrations to a remote-backed database (v49 -> v53) ...\n" +
+ "Error: failed to open routed store at /Users/alvaro/.beads-planning: schema version mismatch: database is at v49, binary expects v53 ..."}, true},
+ {"schema version mismatch only", &CmdError{Stderr: "schema version mismatch: database is at v1, binary expects v2"}, true},
+ {"not found", &CmdError{Stderr: `no issue found matching "mitto-cam"`}, false},
+ {"schema migration without remote-backed", &CmdError{Stderr: "pending schema migrations detected"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := IsSchemaSkew(tc.err); got != tc.want {
+ t.Errorf("IsSchemaSkew = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSchemaSkewDBPath(t *testing.T) {
+ realStderr := "... refusing to auto-apply 4 pending schema migrations to a remote-backed database (v49 -> v53) ...\n" +
+ "Error: failed to open routed store at /Users/alvaro/.beads-planning: schema version mismatch: database is at v49, binary expects v53 ..."
+ err := &CmdError{Err: errors.New("bd exited with non-zero status"), Stderr: realStderr}
+ if got, want := SchemaSkewDBPath(err), "/Users/alvaro/.beads-planning"; got != want {
+ t.Errorf("SchemaSkewDBPath = %q, want %q", got, want)
+ }
+
+ noPath := &CmdError{Stderr: "schema version mismatch: database is at v1, binary expects v2"}
+ if got := SchemaSkewDBPath(noPath); got != "" {
+ t.Errorf("SchemaSkewDBPath(no path) = %q, want empty", got)
+ }
+}
+
// ---------------------------------------------------------------------------
// Validators
// ---------------------------------------------------------------------------
diff --git a/internal/web/handlers/beads.go b/internal/web/handlers/beads.go
index 6fd68769c..de9de472d 100644
--- a/internal/web/handlers/beads.go
+++ b/internal/web/handlers/beads.go
@@ -64,9 +64,36 @@ func isValidBeadsIssueRef(s string) bool {
}
// writeBeadsError reports a bd-command failure using the canonical error
-// envelope (HTTP 500), carrying any captured stderr under error.details.stderr.
-// It also logs the failure (nil-guarded) so 500s are never silent in mitto.log.
+// envelope, carrying any captured stderr under error.details.stderr. It also
+// logs the failure (nil-guarded) so failures are never silent in mitto.log.
+//
+// A schema-version skew (bd refusing to auto-migrate a remote-backed database)
+// is distinguished from a genuine internal error: it surfaces as an
+// actionable HTTP 409 "needs migration" response with the DB path and a
+// remediation hint, rather than a bare 500. Every other failure keeps the
+// existing HTTP 500 behavior.
func (h *Handlers) writeBeadsError(w http.ResponseWriter, r *http.Request, err error) {
+ if beads.IsSchemaSkew(err) {
+ dbPath := beads.SchemaSkewDBPath(err)
+ if h.deps.Logger != nil {
+ h.deps.Logger.Warn("beads schema needs migration", "db_path", dbPath, "stderr", beads.StderrOf(err), "path", r.URL.Path)
+ }
+ hint := "This beads database is behind the bd binary's schema and is remote-backed, so bd will not auto-migrate it. Reconcile it once (e.g. `BD_ALLOW_REMOTE_MIGRATE=1 bd migrate && bd dolt push` on the designated migrator clone, or `bd bootstrap` if another clone already migrated), then reload."
+ details := map[string]any{"hint": hint}
+ if dbPath != "" {
+ details["db_path"] = dbPath
+ }
+ if s := beads.StderrOf(err); s != "" {
+ details["stderr"] = s
+ }
+ msg := "The beads database schema needs migration"
+ if dbPath != "" {
+ msg = "The beads database at " + dbPath + " needs migration"
+ }
+ writeJSON(w, http.StatusConflict, errorEnvelope{Error: errorBody{Code: errCodeBeadsSchemaSkew, Message: msg, Details: details}})
+ return
+ }
+
if h.deps.Logger != nil {
h.deps.Logger.Error("beads command failed", "error", err, "stderr", beads.StderrOf(err), "path", r.URL.Path)
}
diff --git a/internal/web/handlers/beads_test.go b/internal/web/handlers/beads_test.go
index f629ff6cd..c58b2150a 100644
--- a/internal/web/handlers/beads_test.go
+++ b/internal/web/handlers/beads_test.go
@@ -52,6 +52,20 @@ func (c *showInternalErrorClient) Show(_ context.Context, _, _ string) ([]byte,
return nil, &beads.CmdError{Err: errors.New("bd exited with non-zero status"), Stderr: "database is locked"}
}
+// schemaSkewClient is a beads.Client whose List mimics bd's refusal to
+// auto-migrate a remote-backed database that is behind the binary's schema.
+// Used to verify that a schema-version skew maps to an actionable HTTP 409
+// "needs migration" response instead of a bare 500.
+type schemaSkewClient struct{ stubBeadsClient }
+
+func (c *schemaSkewClient) List(_ context.Context, _ string) ([]byte, error) {
+ return nil, &beads.CmdError{
+ Err: errors.New("bd exited with non-zero status"),
+ Stderr: "... refusing to auto-apply 4 pending schema migrations to a remote-backed database (v49 -> v53) ...\n" +
+ "Error: failed to open routed store at /Users/test/.beads-planning: schema version mismatch: database is at v49, binary expects v53 ...",
+ }
+}
+
// stubBeadsClient implements beads.Client for unit tests.
// All methods except Create are no-ops that return nil / zero values.
type stubBeadsClient struct {
@@ -272,6 +286,35 @@ func TestHandleBeadsList_BdCommandError_ReturnsServerError(t *testing.T) {
}
}
+func TestHandleBeadsList_SchemaSkew(t *testing.T) {
+ // Deterministic schema-version skew via stub: List returns a schema-skew
+ // error → actionable 409 envelope with the DB path, not a bare 500.
+ s := newBeadsTestServerWithClient(&schemaSkewClient{})
+ req := localhostRequest("/api/issues?working_dir=/test/workspace")
+ w := httptest.NewRecorder()
+ s.handleBeadsList(w, req)
+
+ if w.Code != http.StatusConflict {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusConflict)
+ }
+ var env struct {
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Details map[string]any `json:"details"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&env); err != nil {
+ t.Fatalf("decode error body: %v", err)
+ }
+ if env.Error.Code != "beads_schema_skew" {
+ t.Errorf("error.code = %q, want %q", env.Error.Code, "beads_schema_skew")
+ }
+ if got, want := env.Error.Details["db_path"], "/Users/test/.beads-planning"; got != want {
+ t.Errorf("error.details.db_path = %v, want %q", got, want)
+ }
+}
+
// listTimeoutClient is a beads.Client whose List blocks until ctx is done.
type listTimeoutClient struct{ stubBeadsClient }
diff --git a/internal/web/handlers/helpers.go b/internal/web/handlers/helpers.go
index 1507604b7..1684eb2a6 100644
--- a/internal/web/handlers/helpers.go
+++ b/internal/web/handlers/helpers.go
@@ -68,6 +68,11 @@ const (
errCodeRateLimited = "rate_limited"
errCodeServerError = "server_error"
errCodeUnavailable = "unavailable"
+
+ // errCodeBeadsSchemaSkew identifies a beads database that is behind the bd
+ // binary's schema and is remote-backed, so bd refuses to auto-migrate it.
+ // See writeBeadsError in internal/web/handlers/beads.go.
+ errCodeBeadsSchemaSkew = "beads_schema_skew"
)
// auxBackedRequestTimeout bounds aux/bd-backed handlers BELOW the 30s
diff --git a/web/static/components/BeadsView.js b/web/static/components/BeadsView.js
index b498d69d9..ab47d6d03 100644
--- a/web/static/components/BeadsView.js
+++ b/web/static/components/BeadsView.js
@@ -102,8 +102,10 @@ async function readBeadsResponse(res) {
if (parsed && typeof parsed.error === "object" && parsed.error !== null) {
return {
error: parsed.error.message || `Request failed (HTTP ${res.status})`,
+ code: parsed.error.code,
stderr:
(parsed.error.details && parsed.error.details.stderr) || undefined,
+ details: parsed.error.details || undefined,
};
}
return parsed;
@@ -3147,6 +3149,11 @@ export function BeadsView({
const [issues, setIssues] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ // Set when the list load failed with a "beads_schema_skew" error code: the
+ // beads database is behind the bd binary's schema and is remote-backed, so
+ // bd refuses to auto-migrate it. Drives a distinct actionable error card
+ // (see the render error region below) instead of the plain error text.
+ const [schemaSkew, setSchemaSkew] = useState(null);
const [selectedIssue, setSelectedIssue] = useState(null);
const [isCreating, setIsCreating] = useState(false);
// When the create panel is opened via an epic's "+" button, this holds the
@@ -3331,12 +3338,23 @@ export function BeadsView({
);
const data = await readBeadsResponse(res);
if (!res.ok || data.error) {
+ if (data.code === "beads_schema_skew") {
+ setSchemaSkew({
+ message: data.error,
+ dbPath: (data.details && data.details.db_path) || "",
+ hint: (data.details && data.details.hint) || "",
+ });
+ } else {
+ setSchemaSkew(null);
+ }
setError(data.error || data.message || "Failed to load issues");
setIssues([]);
} else {
+ setSchemaSkew(null);
setIssues(Array.isArray(data) ? data : []);
}
} catch (err) {
+ setSchemaSkew(null);
setError(err.message || "Failed to load issues");
} finally {
setLoading(false);
@@ -5112,13 +5130,32 @@ export function BeadsView({
${
!loading &&
error &&
- html`
-
- ${error}
-
- `
+ (schemaSkew
+ ? html`
+
+
+ Beads schema needs migration
+
+ ${schemaSkew.dbPath &&
+ html`
+ ${schemaSkew.dbPath}
+
`}
+
+ ${schemaSkew.hint}
+
+
+ `
+ : html`
+
+ ${error}
+
+ `)
}
${
!loading &&
From d684122c4c6b8d7f13b55af859d7b921d0b8cf43 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 16:20:33 +0200
Subject: [PATCH 014/240] fix(web): fold all prompt-source mtimes into
workspace-prompts Last-Modified (mitto-tf9)
GET /api/workspace-prompts derived its Last-Modified header (and the
If-Modified-Since 304 short-circuit) solely from the workspace .mittorc
mtime, even though the returned prompt list is merged from several other
sources whose files can change independently: the global prompts dir
(MITTO_DIR/prompts, including builtins deployed by `mitto prompts
update-builtin`), the settings file, additional configured prompts_dirs, and
the workspace's own .mitto/prompts. A prompt change that didn't touch
.mittorc kept getting 304'd until an unrelated .mittorc mtime bump, so the
frontend never saw it without a hard reload.
computeWorkspacePromptsLastModified() now takes the max mtime across every
contributing source, so any of them advancing the header correctly
invalidates the client's cached list.
Adds a regression test (TestWorkspacePrompts_BuiltinDeployAdvancesLastModified)
deploying a builtin prompt without touching .mittorc and asserting the
conditional GET returns 200 with the new prompt.
---
internal/web/handlers/workspace_prompts.go | 70 ++++++++-
.../inprocess/beads_prompts_test.go | 141 ++++++++++++++++++
2 files changed, 209 insertions(+), 2 deletions(-)
diff --git a/internal/web/handlers/workspace_prompts.go b/internal/web/handlers/workspace_prompts.go
index a8660ecf9..e7132c9f1 100644
--- a/internal/web/handlers/workspace_prompts.go
+++ b/internal/web/handlers/workspace_prompts.go
@@ -311,8 +311,11 @@ func (h *Handlers) HandleWorkspacePromptsGET(w http.ResponseWriter, r *http.Requ
acpServerType = acpServerName
}
- // Get the file's last modification time for conditional requests
- lastModified := h.deps.SessionManager.GetWorkspaceRCLastModified(workingDir)
+ // Get the last modification time for conditional requests. This folds in the
+ // mtimes of ALL sources that contribute to the merged prompt list (not just
+ // .mittorc), so a change in any source advances Last-Modified and correctly
+ // invalidates the client's cached list instead of answering 304 (mitto-tf9).
+ lastModified := h.computeWorkspacePromptsLastModified(workingDir)
// Check If-Modified-Since header for conditional request.
// Skip the 304 short-circuit when we just migrated files: the client must
@@ -500,6 +503,69 @@ func (h *Handlers) HandleWorkspacePromptsGET(w http.ResponseWriter, r *http.Requ
writeJSONOK(w, resp)
}
+// computeWorkspacePromptsLastModified returns the most recent modification time
+// across ALL sources that contribute to the merged workspace prompt list. The
+// GET handler previously derived Last-Modified solely from the workspace
+// .mittorc mtime, so any prompt change that did not touch .mittorc (e.g. a
+// builtin deployed by `mitto prompts update-builtin`, or an edit under
+// .mitto/prompts/) was invisible to the frontend: the conditional
+// If-Modified-Since request kept getting 304 until an unrelated .mittorc mtime
+// bump. Folding every source's mtime in fixes that (mitto-tf9).
+func (h *Handlers) computeWorkspacePromptsLastModified(workingDir string) time.Time {
+ var latest time.Time
+ bump := func(t time.Time) {
+ if t.After(latest) {
+ latest = t
+ }
+ }
+
+ // 1. Workspace .mittorc (original input).
+ if h.deps.SessionManager != nil {
+ bump(h.deps.SessionManager.GetWorkspaceRCLastModified(workingDir))
+ }
+
+ // 2. Global prompts dir (MITTO_DIR/prompts), walked recursively so it also
+ // covers builtin/ prompts deployed by `mitto prompts update-builtin` and
+ // any ACP-server-specific *.prompt.yaml files living in the same tree.
+ if promptsDir, err := appdir.PromptsDir(); err == nil {
+ bump(configPkg.GetPromptsDirModTime(promptsDir))
+ }
+
+ // 3. Settings file (config.Prompts are parsed from settings.json).
+ if settingsPath, err := appdir.SettingsPath(); err == nil {
+ if info, statErr := os.Stat(settingsPath); statErr == nil {
+ bump(info.ModTime())
+ }
+ }
+
+ // 4. Additional global prompts_dirs from settings.
+ if h.deps.MittoConfig != nil {
+ for _, d := range h.deps.MittoConfig.PromptsDirs {
+ bump(configPkg.GetPromptsDirModTime(resolvePromptsDir(workingDir, d)))
+ }
+ }
+
+ // 5. Workspace directory prompts: default .mitto/prompts plus any prompts_dirs
+ // configured in the workspace .mittorc.
+ bump(configPkg.GetPromptsDirModTime(appdir.WorkspacePromptsDir(workingDir)))
+ if h.deps.SessionManager != nil {
+ for _, d := range h.deps.SessionManager.GetWorkspacePromptsDirs(workingDir) {
+ bump(configPkg.GetPromptsDirModTime(resolvePromptsDir(workingDir, d)))
+ }
+ }
+
+ return latest
+}
+
+// resolvePromptsDir resolves a possibly-relative prompts directory against
+// workingDir. Absolute paths (and blanks) are returned unchanged.
+func resolvePromptsDir(workingDir, dir string) string {
+ if dir == "" || filepath.IsAbs(dir) {
+ return dir
+ }
+ return filepath.Join(workingDir, dir)
+}
+
// splitItemLabels splits a comma-separated item_labels query param into a
// trimmed, empty-filtered slice. Returns nil for blank input.
func splitItemLabels(s string) []string {
diff --git a/tests/integration/inprocess/beads_prompts_test.go b/tests/integration/inprocess/beads_prompts_test.go
index 66a1aa54a..03dac1297 100644
--- a/tests/integration/inprocess/beads_prompts_test.go
+++ b/tests/integration/inprocess/beads_prompts_test.go
@@ -10,8 +10,11 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
"github.com/inercia/mitto/internal/client"
+ "github.com/inercia/mitto/internal/config"
+ "github.com/inercia/mitto/internal/web"
)
// TestWorkspacePrompts_BeadsDirGatesUseDirParamNotSession is an end-to-end
@@ -154,3 +157,141 @@ func TestWorkspacePrompts_BeadsDirGatesUseDirParamNotSession(t *testing.T) {
t.Errorf("ungated prompt missing for no-beads dir; got %v", nb)
}
}
+
+// TestWorkspacePrompts_BuiltinDeployAdvancesLastModified is a regression test
+// for mitto-tf9: GET /api/workspace-prompts derived its Last-Modified header
+// (and the If-Modified-Since 304 short-circuit) solely from the workspace
+// .mittorc mtime. A prompt deployed to another source — here a builtin under
+// MITTO_DIR/prompts/builtin, exactly as `mitto prompts update-builtin` does —
+// left .mittorc untouched, so the browser's conditional revalidation kept
+// getting 304 and never saw the new prompt until a hard reload or a `.mittorc`
+// touch.
+//
+// Post-fix the endpoint folds every prompt source's mtime into Last-Modified,
+// so deploying a builtin advances it: a stale conditional request (cached
+// before the deploy) returns 200 with the new prompt, while the conditional
+// logic itself still 304s for a client that is genuinely up to date.
+func TestWorkspacePrompts_BuiltinDeployAdvancesLastModified(t *testing.T) {
+ // Wire a global PromptsCache so the endpoint actually serves prompts from
+ // MITTO_DIR/prompts/ (the default test server leaves it nil). The cache
+ // resolves its default dir lazily to appdir.PromptsDir(), which is under the
+ // MITTO_DIR that SetupTestServer has already pointed at the temp dir.
+ ts := SetupTestServer(t, func(c *web.Config) {
+ c.PromptsCache = config.NewPromptsCache()
+ })
+
+ // The configured test workspace lives at /workspace; NOTE we never
+ // touch its .mittorc below — the point is that a non-.mittorc source changes.
+ workspaceDir := filepath.Join(ts.TempDir, "workspace")
+
+ // Deploy an initial builtin prompt so the global prompts dir exists and the
+ // endpoint emits a non-zero Last-Modified baseline to revalidate against.
+ builtinDir := filepath.Join(ts.TempDir, "prompts", "builtin")
+ if err := os.MkdirAll(builtinDir, 0755); err != nil {
+ t.Fatalf("mkdir builtin: %v", err)
+ }
+ alpha := "name: \"Builtin alpha\"\ngroup: \"Support\"\nprompt: \"a\"\n"
+ if err := os.WriteFile(filepath.Join(builtinDir, "alpha.prompt.yaml"), []byte(alpha), 0644); err != nil {
+ t.Fatalf("write alpha builtin: %v", err)
+ }
+
+ promptsURL := ts.HTTPServer.URL + "/mitto/api/workspace-prompts?" +
+ url.Values{"working_dir": {workspaceDir}}.Encode()
+
+ // fetch issues the GET with an optional If-Modified-Since header and returns
+ // the status, the Last-Modified response header, and the prompt names.
+ fetch := func(t *testing.T, ifModifiedSince string) (int, string, []string) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, promptsURL, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ if ifModifiedSince != "" {
+ req.Header.Set("If-Modified-Since", ifModifiedSince)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET workspace-prompts: %v", err)
+ }
+ defer resp.Body.Close()
+ lastMod := resp.Header.Get("Last-Modified")
+ if resp.StatusCode == http.StatusNotModified {
+ return resp.StatusCode, lastMod, nil
+ }
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("unexpected status %d: %s", resp.StatusCode, string(body))
+ }
+ var decoded struct {
+ Prompts []struct{ Name string } `json:"prompts"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ names := make([]string, 0, len(decoded.Prompts))
+ for _, p := range decoded.Prompts {
+ names = append(names, p.Name)
+ }
+ return resp.StatusCode, lastMod, names
+ }
+
+ has := func(names []string, want string) bool {
+ for _, n := range names {
+ if n == want {
+ return true
+ }
+ }
+ return false
+ }
+
+ // Baseline: 200 with a non-zero Last-Modified and the initial builtin present.
+ status, baseline, names := fetch(t, "")
+ if status != http.StatusOK {
+ t.Fatalf("baseline: status %d, want 200", status)
+ }
+ if baseline == "" {
+ t.Fatalf("baseline: missing Last-Modified header (folded mtime was zero)")
+ }
+ if !has(names, "Builtin alpha") {
+ t.Fatalf("baseline: initial builtin prompt not returned; got %v", names)
+ }
+ baseTime, err := time.Parse(http.TimeFormat, baseline)
+ if err != nil {
+ t.Fatalf("parse baseline Last-Modified %q: %v", baseline, err)
+ }
+
+ // Deploy a NEW builtin prompt WITHOUT touching .mittorc. Stamp it strictly
+ // after the baseline so the folded Last-Modified advances deterministically
+ // (HTTP time has second precision, so a +2s bump is unambiguous).
+ betaPath := filepath.Join(builtinDir, "beta.prompt.yaml")
+ beta := "name: \"Builtin beta\"\ngroup: \"Support\"\nprompt: \"b\"\n"
+ if err := os.WriteFile(betaPath, []byte(beta), 0644); err != nil {
+ t.Fatalf("write beta builtin: %v", err)
+ }
+ newer := baseTime.Add(2 * time.Second)
+ if err := os.Chtimes(betaPath, newer, newer); err != nil {
+ t.Fatalf("chtimes beta builtin: %v", err)
+ }
+
+ // The stale conditional request (If-Modified-Since = pre-deploy baseline)
+ // must now return 200 with the newly deployed prompt — the mitto-tf9 fix.
+ status, newLastMod, names := fetch(t, baseline)
+ if status != http.StatusOK {
+ t.Fatalf("post-deploy revalidation: status %d, want 200 (stale-304 bug not fixed)", status)
+ }
+ if !has(names, "Builtin beta") {
+ t.Errorf("post-deploy: newly deployed builtin prompt missing; got %v", names)
+ }
+ if newLastMod == "" {
+ t.Errorf("post-deploy: missing Last-Modified header")
+ } else if nt, perr := time.Parse(http.TimeFormat, newLastMod); perr == nil && !nt.After(baseTime) {
+ t.Errorf("post-deploy: Last-Modified %q did not advance past baseline %q", newLastMod, baseline)
+ }
+
+ // The conditional logic still works with the folded mtime: a client that is
+ // genuinely up to date (If-Modified-Since well past every source) gets 304.
+ future := newer.Add(time.Hour).UTC().Format(http.TimeFormat)
+ if status, _, _ := fetch(t, future); status != http.StatusNotModified {
+ t.Errorf("up-to-date revalidation: status %d, want 304", status)
+ }
+}
From 46b10c48d9afd070e33799ae1ee7563c2043bfbb Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 18:42:46 +0200
Subject: [PATCH 015/240] fix(acpproc): extend session/new budget for cold
MCP-init on Auggie (mitto-8ul.1)
cgw-managed-tools conversations failed to start because Auggie blocks
servicing session/new until its MCP servers finish initializing
(~225s), while SharedACPProcess used a fixed 25s per-attempt / 75s
total budget. The RPC timed out, tripped the saturation circuit
breaker after 3 consecutive failures, and left the conversation stuck
in is_prompting=true with no useful feedback.
- SessionConfig.McpInitTimeout (default 240s, configurable/disableable)
in internal/config/settings.go.
- SharedACPProcess.coldMCPBudget()/beginMCPInitWindow() widen the
session/new and session/load per-attempt and total budgets to
MCPInitTimeout only for the first call on a cold process; once one
session RPC succeeds (mcpInitDone) subsequent calls use the normal
25s budget again.
- Saturation guard: a DeadlineExceeded during the extended cold window
is expected agent-side latency, not evidence of a hung process, and
no longer counts toward the saturation threshold.
- Stderr-signature detection (bgsession_acp_process.go) recognizes when
the agent reports MCP-init progress or an internal MCP-init timeout;
the latter aborts the pending RPC immediately via mcpInitTimeoutCh
instead of waiting out the full budget.
- New mcp_initializing / mcp_init_timed_out WebSocket notifications
(server.go, ws_messages.go) drive frontend toasts
(useBackgroundNotifications.js, useWebSocket.js) so the UI shows
"Starting MCP servers..." instead of an indefinite spinner.
- RecommendedLoadTimeout() lets shared_session_handshaker.go widen its
outer 30s session/load timeout so it doesn't truncate the extended
budget.
- Mock ACP server updated to simulate delayed/timed-out MCP init for
the new integration tests.
Tests: mcp_init_budget_test.go (5 unit tests on budget selection),
tests/integration/inprocess/mcp_init_timeout_test.go
(TestMCPInitTimeout_FailsFastOnStderrSignal,
TestMCPInitDelay_ExtendedBudgetAllowsSuccess). Full integration suite
143/0, gofmt/vet clean.
---
internal/acpproc/acp_process_manager.go | 89 +++++-
internal/acpproc/acp_process_manager_test.go | 4 +-
internal/acpproc/mcp_init_budget_test.go | 119 ++++++++
internal/acpproc/shared_acp_process.go | 265 ++++++++++++++++--
internal/config/config.go | 2 +
internal/config/settings.go | 45 +++
internal/config/settings_test.go | 29 ++
.../conversation/acp_error_classification.go | 9 +
.../acp_error_classification_test.go | 17 ++
.../conversation/background_session_test.go | 1 +
.../conversation/bgsession_acp_process.go | 63 ++++-
.../bgsession_acp_process_test.go | 81 +++++-
internal/conversation/interfaces.go | 7 +
.../conversation/shared_session_handshaker.go | 9 +-
.../shared_session_handshaker_test.go | 1 +
internal/web/config_handlers.go | 8 +
internal/web/server.go | 81 ++++++
internal/web/ws_messages.go | 16 ++
.../inprocess/mcp_init_timeout_test.go | 193 +++++++++++++
tests/mocks/acp-server/handler.go | 23 ++
tests/mocks/acp-server/main.go | 30 ++
tests/mocks/acp-server/types.go | 5 +-
.../hooks/useBackgroundNotifications.js | 54 ++++
web/static/hooks/useWebSocket.js | 24 ++
24 files changed, 1132 insertions(+), 43 deletions(-)
create mode 100644 internal/acpproc/mcp_init_budget_test.go
create mode 100644 tests/integration/inprocess/mcp_init_timeout_test.go
diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go
index 21195eded..b870154ce 100644
--- a/internal/acpproc/acp_process_manager.go
+++ b/internal/acpproc/acp_process_manager.go
@@ -108,6 +108,21 @@ type ACPProcessManager struct {
globalRestartMu sync.Mutex
globalRestartTimes []time.Time
globalCooldownUntil time.Time
+
+ // MCPInitTimeout is the extended per-attempt/total budget passed to every new
+ // SharedACPProcess so cold session/new calls with MCP servers do not hit the
+ // standard 25s deadline before the agent finishes its own MCP-init wait
+ // (mitto-8ul.1). Zero disables the feature. Guarded by mu.
+ mcpInitTimeoutMu sync.RWMutex
+ mcpInitTimeout time.Duration
+
+ // onMCPInitializing, if set, is called (at most once per process) from the
+ // stderr-monitor goroutine when the agent reports it is blocked waiting for
+ // MCP servers to initialize. onMCPInitTimeout is called (at most once per
+ // process) when the agent reports its internal MCP-init wait budget elapsed.
+ // Used by the web layer to broadcast UI notifications (mitto-8ul.1).
+ onMCPInitializing func(workspaceUUID string)
+ onMCPInitTimedOut func(workspaceUUID string)
}
// MarkGCSuspended records that a session was intentionally suspended by the GC's
@@ -249,6 +264,41 @@ func (m *ACPProcessManager) SetOnMemoryRecycled(fn func(workspaceUUID string, rs
m.onMemoryRecycled = fn
}
+// UpdateMCPInitTimeout sets the extended MCP-init budget passed to every new
+// SharedACPProcess. Zero disables the feature. Existing processes are not
+// updated (the process-level MCPInitTimeout is captured at NewSharedACPProcess
+// time). mitto-8ul.1.
+func (m *ACPProcessManager) UpdateMCPInitTimeout(d time.Duration) {
+ m.mcpInitTimeoutMu.Lock()
+ defer m.mcpInitTimeoutMu.Unlock()
+ m.mcpInitTimeout = d
+}
+
+// getMCPInitTimeout returns the current extended MCP-init budget.
+func (m *ACPProcessManager) getMCPInitTimeout() time.Duration {
+ m.mcpInitTimeoutMu.RLock()
+ defer m.mcpInitTimeoutMu.RUnlock()
+ return m.mcpInitTimeout
+}
+
+// SetOnMCPInitializing registers the callback invoked (at most once per process)
+// when the agent reports it is blocked waiting for MCP servers to initialize.
+// Used by the web layer to broadcast an "MCP initializing" UI notification
+// (mitto-8ul.1).
+func (m *ACPProcessManager) SetOnMCPInitializing(fn func(workspaceUUID string)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.onMCPInitializing = fn
+}
+
+// SetOnMCPInitTimedOut registers the callback invoked (at most once per process)
+// when the agent reports its MCP-init wait has timed out (mitto-8ul.1).
+func (m *ACPProcessManager) SetOnMCPInitTimedOut(fn func(workspaceUUID string)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.onMCPInitTimedOut = fn
+}
+
// Ensure ACPProcessManager implements auxiliary.ProcessProvider
var _ auxiliary.ProcessProvider = (*ACPProcessManager)(nil)
@@ -343,18 +393,37 @@ func (m *ACPProcessManager) GetOrCreateProcess(workspace *config.WorkspaceSettin
processLogger = processLogger.With("workspace_uuid", workspace.UUID)
}
+ // Snapshot MCP-init callbacks and timeout while holding m.mu (we release it
+ // below). The callbacks close over workspace.UUID so a stderr signal from the
+ // specific process can be routed to the correct workspace (mitto-8ul.1).
+ mcpInitTimeout := m.getMCPInitTimeout()
+ initCb := m.onMCPInitializing
+ timeoutCb := m.onMCPInitTimedOut
+ wsUUID := workspace.UUID
+ var onMCPInitProgress func()
+ if initCb != nil {
+ onMCPInitProgress = func() { initCb(wsUUID) }
+ }
+ var onMCPInitTimeout func()
+ if timeoutCb != nil {
+ onMCPInitTimeout = func() { timeoutCb(wsUUID) }
+ }
+
createStart := time.Now()
p, err := NewSharedACPProcess(m.ctx, SharedACPProcessConfig{
- WorkspaceUUID: workspace.UUID,
- ACPCommand: acpCommand,
- ACPCwd: acpCwd,
- ACPServer: workspace.ACPServer,
- WorkingDir: workspace.WorkingDir,
- Env: acpEnv,
- Runner: r,
- Logger: processLogger,
- CanRestartGlobal: m.CanRestartGlobally,
- RecordRestart: m.RecordGlobalRestart,
+ WorkspaceUUID: workspace.UUID,
+ ACPCommand: acpCommand,
+ ACPCwd: acpCwd,
+ ACPServer: workspace.ACPServer,
+ WorkingDir: workspace.WorkingDir,
+ Env: acpEnv,
+ Runner: r,
+ Logger: processLogger,
+ CanRestartGlobal: m.CanRestartGlobally,
+ RecordRestart: m.RecordGlobalRestart,
+ MCPInitTimeout: mcpInitTimeout,
+ OnMCPInitProgress: onMCPInitProgress,
+ OnMCPInitTimeout: onMCPInitTimeout,
})
createDuration := time.Since(createStart)
diff --git a/internal/acpproc/acp_process_manager_test.go b/internal/acpproc/acp_process_manager_test.go
index a8ad4b982..a77b51dcd 100644
--- a/internal/acpproc/acp_process_manager_test.go
+++ b/internal/acpproc/acp_process_manager_test.go
@@ -1259,7 +1259,7 @@ func TestShouldFailFastCreateAttempt(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- bail, reason := shouldFailFastCreateAttempt(tc.attempt, tc.saturated, tc.hasDeadline, tc.remaining)
+ bail, reason := shouldFailFastCreateAttempt(tc.attempt, tc.saturated, tc.hasDeadline, tc.remaining, sessionCreateAttemptTimeout)
if bail != tc.wantBail {
t.Errorf("bail=%v, want %v (reason=%q)", bail, tc.wantBail, reason)
}
@@ -1673,7 +1673,7 @@ func TestSessionCreateTotalBudgetBound(t *testing.T) {
// After two full per-attempt timeouts, the remaining budget must be insufficient to
// fund another attempt, so shouldFailFastCreateAttempt bails before attempt 3.
remainingAfterTwo := sessionCreateTotalBudget - 2*sessionCreateAttemptTimeout
- bail, reason := shouldFailFastCreateAttempt(3, false, true, remainingAfterTwo)
+ bail, reason := shouldFailFastCreateAttempt(3, false, true, remainingAfterTwo, sessionCreateAttemptTimeout)
if !bail {
t.Errorf("attempt=3 with remaining=%v must bail (budget exhausted); got bail=false", remainingAfterTwo)
}
diff --git a/internal/acpproc/mcp_init_budget_test.go b/internal/acpproc/mcp_init_budget_test.go
new file mode 100644
index 000000000..19539b49a
--- /dev/null
+++ b/internal/acpproc/mcp_init_budget_test.go
@@ -0,0 +1,119 @@
+package acpproc
+
+// Tests for the MCP-init extended budget policy (mitto-8ul.1). The helper
+// coldMCPBudget() decides whether NewSession/LoadSession should widen its
+// per-attempt and total deadlines to give a cold agent time to finish its
+// internal MCP-server handshake before Mitto times out.
+
+import (
+ "testing"
+ "time"
+)
+
+func TestColdMCPBudget_DisabledByDefault(t *testing.T) {
+ p := &SharedACPProcess{} // MCPInitTimeout unset = 0
+ perAttempt, total, extended := p.coldMCPBudget(true /*hasMCPServers*/)
+ if extended {
+ t.Fatalf("expected extended=false when MCPInitTimeout=0, got extended=true")
+ }
+ if perAttempt != sessionCreateAttemptTimeout {
+ t.Fatalf("perAttempt=%v, want %v", perAttempt, sessionCreateAttemptTimeout)
+ }
+ if total != sessionCreateTotalBudget {
+ t.Fatalf("total=%v, want %v", total, sessionCreateTotalBudget)
+ }
+}
+
+func TestColdMCPBudget_NoMCPServersStillExtends(t *testing.T) {
+ // Under the current design (MCP attached globally, not per session/new),
+ // hasMCPServers is not load-bearing: the extended budget applies to every
+ // cold session/new so long as MCPInitTimeout > 0. mitto-8ul.1.
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+
+ perAttempt, total, extended := p.coldMCPBudget(false /*hasMCPServers*/)
+ if !extended {
+ t.Fatal("expected extended=true even without hasMCPServers on cold start")
+ }
+ if perAttempt != 240*time.Second || total != 240*time.Second {
+ t.Fatalf("budgets = (%v, %v), want (240s, 240s)", perAttempt, total)
+ }
+}
+
+func TestColdMCPBudget_ColdWithMCPServersExtends(t *testing.T) {
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+
+ perAttempt, total, extended := p.coldMCPBudget(true /*hasMCPServers*/)
+ if !extended {
+ t.Fatal("expected extended=true for cold session with MCP servers")
+ }
+ if perAttempt != 240*time.Second {
+ t.Fatalf("perAttempt=%v, want 240s", perAttempt)
+ }
+ if total != 240*time.Second {
+ t.Fatalf("total=%v, want 240s", total)
+ }
+}
+
+func TestColdMCPBudget_WarmProcessRevertsToNormal(t *testing.T) {
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+ // Simulate the process having completed one successful cold-start session RPC.
+ p.mcpInitDone.Store(true)
+
+ perAttempt, total, extended := p.coldMCPBudget(true /*hasMCPServers*/)
+ if extended {
+ t.Fatalf("expected extended=false once mcpInitDone=true, got extended=true")
+ }
+ if perAttempt != sessionCreateAttemptTimeout {
+ t.Fatalf("perAttempt=%v, want %v", perAttempt, sessionCreateAttemptTimeout)
+ }
+ if total != sessionCreateTotalBudget {
+ t.Fatalf("total=%v, want %v", total, sessionCreateTotalBudget)
+ }
+}
+
+func TestRecommendedLoadTimeout(t *testing.T) {
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+
+ // Cold: widen regardless of hasMCPServers hint (Mitto attaches MCP globally).
+ if got := p.RecommendedLoadTimeout(true); got != 240*time.Second {
+ t.Errorf("cold+mcp: got %v, want 240s", got)
+ }
+ if got := p.RecommendedLoadTimeout(false); got != 240*time.Second {
+ t.Errorf("cold no-mcp-hint: got %v, want 240s", got)
+ }
+ // Warm: 0.
+ p.mcpInitDone.Store(true)
+ if got := p.RecommendedLoadTimeout(true); got != 0 {
+ t.Errorf("warm: got %v, want 0", got)
+ }
+ // Disabled: 0.
+ p2 := &SharedACPProcess{}
+ p2.config.MCPInitTimeout = 0
+ if got := p2.RecommendedLoadTimeout(true); got != 0 {
+ t.Errorf("disabled: got %v, want 0", got)
+ }
+}
+
+func TestBeginMCPInitWindow_ResetsPerCall(t *testing.T) {
+ p := &SharedACPProcess{}
+ p.mcpInitTimedOut.Store(true)
+
+ ch := p.beginMCPInitWindow()
+ if ch == nil {
+ t.Fatal("expected non-nil channel from beginMCPInitWindow")
+ }
+ if p.mcpInitTimedOut.Load() {
+ t.Fatal("expected mcpInitTimedOut to be reset by beginMCPInitWindow")
+ }
+
+ // A second call must return a fresh channel (the old one is orphaned so signals
+ // from a previous RPC do not affect the new one).
+ ch2 := p.beginMCPInitWindow()
+ if ch == ch2 {
+ t.Fatal("expected beginMCPInitWindow to return a fresh channel per call")
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index 7ed136cab..34b7efba8 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -197,6 +197,22 @@ type SharedACPProcessConfig struct {
CanRestartGlobal func() bool
// RecordRestart is an optional callback to record a restart in the global tracker.
RecordRestart func()
+ // MCPInitTimeout is the extended per-attempt/total budget granted to the very
+ // first session/new (and session/load) on a cold shared ACP process when the
+ // request carries MCP servers. Zero disables the extended budget and the normal
+ // sessionCreateAttemptTimeout/sessionCreateTotalBudget are used. See
+ // SessionConfig.ParseMcpInitTimeout for the rationale (mitto-8ul.1).
+ MCPInitTimeout time.Duration
+ // OnMCPInitProgress is called at most once, from the stderr-monitor goroutine, the
+ // first time the agent reports it is blocked waiting for MCP servers to initialize.
+ // Used by the web layer to emit an "MCP initializing" UI notification (mitto-8ul.1).
+ // Optional.
+ OnMCPInitProgress func()
+ // OnMCPInitTimeout is called at most once when the agent reports its internal
+ // MCP-init wait has timed out. The pending session/new should then be aborted with
+ // an actionable error rather than waiting for the RPC deadline to elapse
+ // (mitto-8ul.1). Optional.
+ OnMCPInitTimeout func()
}
// Compile-time assertion: *SharedACPProcess must satisfy the conversation.SharedProcess interface.
@@ -274,6 +290,21 @@ type SharedACPProcess struct {
// to invalidate caches (e.g., auxiliary sessions) that reference old session IDs.
onRestart func()
+ // MCP-init lifecycle tracking (mitto-8ul.1). Set from the stderr-monitor goroutine.
+ // mcpInitInProgress flips to 1 when the agent first reports it is waiting for MCP
+ // servers to initialize on this process. Once a session/new (or session/load) call
+ // succeeds we treat the cold-start window as closed and revert to normal budgets.
+ // mcpInitTimedOut flips to 1 when the agent reports its internal MCP-init wait
+ // budget elapsed; the currently-pending NewSession call watches this via
+ // mcpInitTimeoutCh so it can abort promptly with an actionable error rather than
+ // waiting for the RPC deadline. mcpInitTimeoutCh is (re-)created per session/new
+ // attempt so a signal from a previous attempt does not fire spuriously.
+ mcpInitInProgress atomic.Bool
+ mcpInitDone atomic.Bool
+ mcpInitTimedOut atomic.Bool
+ mcpInitMu sync.Mutex
+ mcpInitTimeoutCh chan struct{}
+
// Logger
logger *slog.Logger
}
@@ -438,6 +469,42 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
})
}
+ // MCP-init lifecycle callbacks (mitto-8ul.1). Both are fired at most once per
+ // process lifetime by the stderr monitor. The pending NewSession call watches
+ // mcpInitTimeoutCh so it can abort promptly on a hard timeout signal instead of
+ // waiting for the RPC deadline.
+ onMCPInitProgress := func() {
+ p.mcpInitInProgress.Store(true)
+ if p.logger != nil {
+ p.logger.Info("ACP agent reports MCP servers initializing",
+ "acp_server", p.config.ACPServer)
+ }
+ if cb := p.config.OnMCPInitProgress; cb != nil {
+ cb()
+ }
+ }
+ onMCPInitTimeout := func() {
+ p.mcpInitTimedOut.Store(true)
+ if p.logger != nil {
+ p.logger.Warn("ACP agent reports MCP initialization timed out",
+ "acp_server", p.config.ACPServer)
+ }
+ p.mcpInitMu.Lock()
+ ch := p.mcpInitTimeoutCh
+ p.mcpInitMu.Unlock()
+ if ch != nil {
+ select {
+ case <-ch:
+ // already closed
+ default:
+ close(ch)
+ }
+ }
+ if cb := p.config.OnMCPInitTimeout; cb != nil {
+ cb()
+ }
+ }
+
// Startup watchdog: warn/error if no stderr activity and no Initialize completion
// within the configured windows. Cancelled when doStartProcess returns.
watchdogCtx, watchdogCancel := context.WithCancel(p.ctx)
@@ -483,7 +550,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, -1)
- conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity)
+ conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout)
} else {
cmd = exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -544,7 +611,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
}
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, pid)
- conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity)
+ conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout)
wait = func() error {
return cmd.Wait()
@@ -832,19 +899,80 @@ func (p *SharedACPProcess) recordRPCSuccess() {
// attempts bail if the shared process has become saturated mid-flight, or if the
// caller's remaining deadline can no longer fund a full per-attempt budget.
// Returns a non-empty reason when the attempt should fail fast.
-func shouldFailFastCreateAttempt(attempt int, saturated bool, hasDeadline bool, remaining time.Duration) (bail bool, reason string) {
+func shouldFailFastCreateAttempt(attempt int, saturated bool, hasDeadline bool, remaining time.Duration, perAttemptBudget time.Duration) (bail bool, reason string) {
if attempt <= 1 {
return false, ""
}
if saturated {
return true, "shared ACP process became saturated mid-flight"
}
- if hasDeadline && remaining < sessionCreateAttemptTimeout {
+ if hasDeadline && remaining < perAttemptBudget {
return true, "insufficient remaining budget for another attempt"
}
return false, ""
}
+// coldMCPBudget decides whether the extended MCP-init budget applies to the
+// current NewSession/LoadSession attempt (mitto-8ul.1) and returns the
+// per-attempt and total budget to use.
+//
+// The extended budget applies only when ALL of the following hold:
+// - MCPInitTimeout > 0 (the operator has not disabled it).
+// - The process has not yet observed a successful cold-start session RPC
+// (mcpInitDone is false). Subsequent sessions on the same warm process use
+// the normal budget because MCP servers are already initialized inside the
+// agent.
+//
+// The extended budget does NOT gate on the request carrying MCP servers, because
+// Mitto attaches MCP through a globally-registered server (not per session/new
+// call), and even agents whose only MCP is configured globally block session/new
+// on the same handshake. Applying the widened budget to every cold session/new is
+// safe: it is capped by the actual RPC deadline anyway and reverts to the normal
+// 25 s once one call succeeds. When the extended budget applies both the per-
+// attempt and total budgets are widened to MCPInitTimeout, sized above the
+// agent's own MCP-init wait (e.g. Auggie's 225 s) plus margin.
+//
+// hasMCPServers is retained on the signature for observability / future gating.
+func (p *SharedACPProcess) coldMCPBudget(hasMCPServers bool) (perAttempt time.Duration, total time.Duration, extended bool) {
+ _ = hasMCPServers // reserved for future per-request gating
+ if p.config.MCPInitTimeout <= 0 {
+ return sessionCreateAttemptTimeout, sessionCreateTotalBudget, false
+ }
+ if p.mcpInitDone.Load() {
+ return sessionCreateAttemptTimeout, sessionCreateTotalBudget, false
+ }
+ return p.config.MCPInitTimeout, p.config.MCPInitTimeout, true
+}
+
+// RecommendedLoadTimeout implements conversation.SharedProcess (mitto-8ul.1).
+// For a cold process (mcpInitDone=false), returns MCPInitTimeout so the caller's
+// outer timeout does not truncate the process's own extended budget. Returns 0
+// once the process has served one successful cold-start session RPC. The
+// hasMCPServers hint is retained for future per-request gating; it is not
+// currently load-bearing because Mitto attaches MCP globally.
+func (p *SharedACPProcess) RecommendedLoadTimeout(hasMCPServers bool) time.Duration {
+ _ = hasMCPServers
+ if p.config.MCPInitTimeout <= 0 {
+ return 0
+ }
+ if p.mcpInitDone.Load() {
+ return 0
+ }
+ return p.config.MCPInitTimeout
+}
+
+// beginMCPInitWindow prepares per-RPC MCP-init lifecycle tracking (mitto-8ul.1):
+// it (re-)creates a fresh timeout channel so a signal from a previous RPC does not
+// fire on this one, and clears the mcpInitTimedOut flag if it was set. Returns the
+// channel the caller should select on.
+func (p *SharedACPProcess) beginMCPInitWindow() <-chan struct{} {
+ p.mcpInitMu.Lock()
+ defer p.mcpInitMu.Unlock()
+ p.mcpInitTimedOut.Store(false)
+ p.mcpInitTimeoutCh = make(chan struct{})
+ return p.mcpInitTimeoutCh
+}
+
// isSaturated reports whether the shared process is currently flagged saturated.
// When the cooldown has elapsed it self-clears and sets inProbe=true so the next
// NewSession call is capped to a single probe attempt (mitto-13ck.2). The probe
@@ -967,6 +1095,12 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
cwd = "."
}
+ // Extended MCP-init budget (mitto-8ul.1): a cold session/new on a process with
+ // MCP servers may block up to the agent's own MCP-init wait (Auggie: ~225s).
+ // coldMCPBudget widens both budgets to MCPInitTimeout for that first call only;
+ // subsequent sessions on the same warm process use the normal budgets.
+ perAttemptBudget, totalBudget, extendedBudget := p.coldMCPBudget(len(mcpServers) > 0)
+
// Bounded total wall-clock budget (mitto-8d7): a deadline-less (or very generous)
// caller context would otherwise let the retry loop burn the full
// effectiveMaxAttempts × sessionCreateAttemptTimeout (~75s) on a hung transport —
@@ -974,19 +1108,25 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
// fail-fast in shouldFailFastCreateAttempt never tripped. Derive a budgetCtx that
// caps the whole sequence; we only ever tighten the caller's deadline, never extend
// it. This makes the existing remaining-budget bail active for every caller and
- // guarantees NewSession returns within sessionCreateTotalBudget.
+ // guarantees NewSession returns within totalBudget.
budgetCtx := ctx
- if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > sessionCreateTotalBudget {
+ if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > totalBudget {
var budgetCancel context.CancelFunc
- budgetCtx, budgetCancel = context.WithTimeout(ctx, sessionCreateTotalBudget)
+ budgetCtx, budgetCancel = context.WithTimeout(ctx, totalBudget)
defer budgetCancel()
}
+ // Arm the MCP-init timeout watch so a hard timeout signal from the agent's
+ // stderr can abort the pending RPC promptly (mitto-8ul.1). Only meaningful for
+ // requests that carry MCP servers on a not-yet-warm process; harmless otherwise.
+ mcpTimeoutCh := p.beginMCPInitWindow()
+
// Bounded retry-with-jitter loop (mitto-4no7): mirrors SetSessionModel's policy so
// transient deadline failures on session/new are retried up to effectiveMaxAttempts.
- // Each attempt gets a fresh sessionCreateAttemptTimeout budget, preserving the
- // documented 25s per-attempt create deadline (mitto-63o8) without regression.
- // In probe mode effectiveMaxAttempts=1, limiting the probe to a single attempt.
+ // Each attempt gets a fresh per-attempt budget, preserving the documented 25s
+ // per-attempt create deadline (mitto-63o8) — or the extended MCP-init budget for
+ // cold sessions with MCP servers (mitto-8ul.1) — without regression. In probe mode
+ // effectiveMaxAttempts=1, limiting the probe to a single attempt.
var lastErr error
for attempt := 1; attempt <= effectiveMaxAttempts; attempt++ {
// Honour caller cancellation / total budget before each attempt.
@@ -1006,7 +1146,7 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
hasDeadline = true
remaining = time.Until(dl)
}
- if bail, reason := shouldFailFastCreateAttempt(attempt, p.isSaturated(), hasDeadline, remaining); bail {
+ if bail, reason := shouldFailFastCreateAttempt(attempt, p.isSaturated(), hasDeadline, remaining, perAttemptBudget); bail {
return nil, fmt.Errorf("session/new: %s (after %d attempt(s)); failing fast: %w", reason, attempt-1, context.DeadlineExceeded)
}
}
@@ -1025,23 +1165,48 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
// Fresh per-attempt sub-context so each attempt gets a full create budget,
// capped by the remaining total budget (budgetCtx).
- attemptCtx, attemptCancel := context.WithTimeout(budgetCtx, sessionCreateAttemptTimeout)
+ attemptCtx, attemptCancel := context.WithTimeout(budgetCtx, perAttemptBudget)
ctxRemainingMs := int64(-1)
if dl, ok := budgetCtx.Deadline(); ok {
ctxRemainingMs = time.Until(dl).Milliseconds()
}
+ // Wire MCP-init hard-timeout abort (mitto-8ul.1): if the agent's stderr says
+ // its own MCP-init wait timed out we cancel the attempt context so the RPC
+ // returns immediately with context.Canceled rather than draining the full
+ // per-attempt budget on a request the agent has already given up on.
+ rpcCtx, rpcCancel := attemptCtx, attemptCancel
+ if extendedBudget {
+ var stopWatch context.CancelFunc
+ rpcCtx, stopWatch = context.WithCancel(attemptCtx)
+ done := make(chan struct{})
+ go func() {
+ select {
+ case <-mcpTimeoutCh:
+ stopWatch()
+ case <-done:
+ }
+ }()
+ // Ensure we release the watcher when the attempt completes.
+ rpcCancel = func() {
+ close(done)
+ stopWatch()
+ attemptCancel()
+ }
+ }
+
rpcStart := time.Now()
- sessResp, err := conn.NewSession(attemptCtx, acp.NewSessionRequest{
+ sessResp, err := conn.NewSession(rpcCtx, acp.NewSessionRequest{
Cwd: cwd,
McpServers: mcpServers,
})
rpcDuration := time.Since(rpcStart)
- attemptCancel()
+ rpcCancel()
if err == nil {
p.recordRPCSuccess()
+ p.mcpInitDone.Store(true)
handle := &conversation.SessionHandle{
SessionID: string(sessResp.SessionId),
Process: p,
@@ -1060,13 +1225,34 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
"acp_session_id", handle.SessionID,
"attempt", attempt,
"total_ms", time.Since(totalStart).Milliseconds(),
- "rpc_new_session_ms", rpcDuration.Milliseconds())
+ "rpc_new_session_ms", rpcDuration.Milliseconds(),
+ "extended_mcp_budget", extendedBudget,
+ "per_attempt_budget_ms", perAttemptBudget.Milliseconds())
}
return handle, nil
}
+ // MCP-init hard timeout (mitto-8ul.1): the agent already reported it gave up
+ // on its own MCP-init wait. Surface an actionable, deadline-classified error
+ // so classification promotes it to a permanent (non-retryable) failure and the
+ // UI can render a meaningful message instead of "context deadline exceeded".
+ if p.mcpInitTimedOut.Load() {
+ p.recordRPCTimeout()
+ lastErr = fmt.Errorf("session/new: mcp initialization timed out (agent reported MCP-init wait exhausted): %w", context.DeadlineExceeded)
+ if p.logger != nil {
+ p.logger.Warn("SharedACPProcess.NewSession aborted by MCP-init-timeout signal",
+ "attempt", attempt, "rpc_ms", rpcDuration.Milliseconds())
+ }
+ return nil, lastErr
+ }
+
lastErr = err
- if errors.Is(err, context.DeadlineExceeded) {
+ // A cold-start-with-MCP window uses the extended budget deliberately, so a
+ // deadline exceeded on that call is expected agent-side latency, not evidence
+ // the shared process is hung — do NOT count it toward saturation. Once the
+ // window is closed (first successful RPC → mcpInitDone) the normal accounting
+ // applies again (mitto-8ul.1).
+ if errors.Is(err, context.DeadlineExceeded) && !extendedBudget {
p.recordRPCTimeout()
}
if p.logger != nil {
@@ -1077,6 +1263,7 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
"rpc_ms", rpcDuration.Milliseconds(),
"ctx_remaining_ms", ctxRemainingMs,
"rpc_code", rpcCode,
+ "extended_mcp_budget", extendedBudget,
"error", err)
}
@@ -1147,8 +1334,37 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
}
ctxAlreadyExpired := ctx.Err() != nil
+ // Extended MCP-init budget (mitto-8ul.1): symmetric with NewSession. session/load
+ // on a cold process with MCP servers can also block on the agent's MCP-init wait,
+ // so widen the deadline for that first call only. Wraps ctx with a sub-context so
+ // the caller's own deadline is still honoured (we never extend it).
+ rpcCtx := ctx
+ perAttemptBudget, _, extendedBudget := p.coldMCPBudget(len(mcpServers) > 0)
+ var mcpTimeoutCh <-chan struct{}
+ if extendedBudget {
+ mcpTimeoutCh = p.beginMCPInitWindow()
+ if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > perAttemptBudget {
+ var loadCancel context.CancelFunc
+ rpcCtx, loadCancel = context.WithTimeout(ctx, perAttemptBudget)
+ defer loadCancel()
+ }
+ // Wire hard-timeout abort so the agent's stderr signal cancels the RPC.
+ var abortCancel context.CancelFunc
+ rpcCtx, abortCancel = context.WithCancel(rpcCtx)
+ defer abortCancel()
+ done := make(chan struct{})
+ defer close(done)
+ go func() {
+ select {
+ case <-mcpTimeoutCh:
+ abortCancel()
+ case <-done:
+ }
+ }()
+ }
+
rpcStart := time.Now()
- loadResp, err := conn.LoadSession(ctx, acp.LoadSessionRequest{
+ loadResp, err := conn.LoadSession(rpcCtx, acp.LoadSessionRequest{
SessionId: acp.SessionId(acpSessionID),
Cwd: cwd,
McpServers: mcpServers,
@@ -1156,7 +1372,15 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
rpcDuration := time.Since(rpcStart)
if err != nil {
- if errors.Is(err, context.DeadlineExceeded) {
+ if p.mcpInitTimedOut.Load() {
+ p.recordRPCTimeout()
+ if p.logger != nil {
+ p.logger.Warn("SharedACPProcess.LoadSession aborted by MCP-init-timeout signal",
+ "acp_session_id", acpSessionID, "rpc_ms", rpcDuration.Milliseconds())
+ }
+ return nil, fmt.Errorf("session/load: mcp initialization timed out (agent reported MCP-init wait exhausted): %w", context.DeadlineExceeded)
+ }
+ if errors.Is(err, context.DeadlineExceeded) && !extendedBudget {
p.recordRPCTimeout()
}
if p.logger != nil {
@@ -1165,12 +1389,14 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
"rpc_ms", rpcDuration.Milliseconds(),
"ctx_remaining_ms", ctxRemainingMs,
"ctx_already_expired", ctxAlreadyExpired,
+ "extended_mcp_budget", extendedBudget,
"error", err)
}
return nil, fmt.Errorf("failed to load session: %w", err)
}
p.recordRPCSuccess()
+ p.mcpInitDone.Store(true)
handle := &conversation.SessionHandle{
SessionID: acpSessionID,
Capabilities: *caps,
@@ -1183,7 +1409,8 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
p.logger.Info("Loaded ACP session on shared process",
"acp_session_id", acpSessionID,
"total_ms", time.Since(totalStart).Milliseconds(),
- "rpc_load_session_ms", rpcDuration.Milliseconds())
+ "rpc_load_session_ms", rpcDuration.Milliseconds(),
+ "extended_mcp_budget", extendedBudget)
}
return handle, nil
diff --git a/internal/config/config.go b/internal/config/config.go
index 2ac836382..24ae6f135 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1518,6 +1518,7 @@ type rawConfig struct {
LoopSuspendTimeout string `yaml:"loop_suspend_timeout"`
MemoryRecycleThreshold string `yaml:"memory_recycle_threshold"`
AgentInactivityTimeout string `yaml:"agent_inactivity_timeout"`
+ McpInitTimeout string `yaml:"mcp_init_timeout"`
} `yaml:"session"`
// MCP is the MCP server configuration
MCP *struct {
@@ -1881,6 +1882,7 @@ func Parse(data []byte) (*Config, error) {
LoopSuspendTimeout: raw.Session.LoopSuspendTimeout,
MemoryRecycleThreshold: raw.Session.MemoryRecycleThreshold,
AgentInactivityTimeout: raw.Session.AgentInactivityTimeout,
+ McpInitTimeout: raw.Session.McpInitTimeout,
}
}
diff --git a/internal/config/settings.go b/internal/config/settings.go
index ea4bc3e5f..8b2ec0dda 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -136,6 +136,19 @@ type SessionConfig struct {
// This breaks the GC deadlock where a wedged shared ACP process pins a session
// as stuck forever. Values: "" (default, 10m), "disabled", "5m", "10m", "15m", "30m".
AgentInactivityTimeout string `json:"agent_inactivity_timeout,omitempty"`
+ // McpInitTimeout controls the extended per-attempt/total budget granted to the
+ // very first session/new (and session/load) on a cold shared ACP process when
+ // the request carries MCP servers. Rationale (mitto-8ul.1): agents (Auggie in
+ // particular) block servicing session/new until MCP init completes, and their
+ // internal MCP wait is ~225s — well past Mitto's normal 25s per-attempt budget.
+ // A cold cold-start with MCP servers therefore fails as "context deadline
+ // exceeded" even though the agent would eventually respond. This timeout
+ // widens the budget for that first cold call only; once the process has
+ // completed one successful session/new (or observed all-servers-ready) the
+ // normal 25s budget is used again. Values: "" (default, 240s covering
+ // Auggie's 225s + margin), "disabled" (use the normal budget), "120s"/"2m",
+ // "240s"/"4m", "300s"/"5m".
+ McpInitTimeout string `json:"mcp_init_timeout,omitempty"`
}
// ArchiveRetentionNever is the value for keeping archived conversations forever.
@@ -257,6 +270,38 @@ func (c *SessionConfig) ParseAgentInactivityTimeout() (time.Duration, bool) {
}
}
+// ValidMcpInitTimeouts contains all valid MCP-init timeout values (mitto-8ul.1).
+var ValidMcpInitTimeouts = []string{"", "disabled", "120s", "2m", "240s", "4m", "300s", "5m"}
+
+// GetMcpInitTimeout returns the MCP-init timeout string, or "" if not set.
+func (c *SessionConfig) GetMcpInitTimeout() string {
+ if c == nil {
+ return ""
+ }
+ return c.McpInitTimeout
+}
+
+// ParseMcpInitTimeout converts the MCP-init timeout string to a time.Duration.
+// Returns (duration, true) when the extended cold-start budget is enabled and
+// (0, false) when disabled. Empty string returns the default of 240s, which
+// covers Auggie's internal 225s MCP-init wait + margin (mitto-8ul.1). Unknown
+// values fall back to the default rather than silently disabling the feature.
+func (c *SessionConfig) ParseMcpInitTimeout() (time.Duration, bool) {
+ switch c.GetMcpInitTimeout() {
+ case "disabled":
+ return 0, false
+ case "", "240s", "4m":
+ return 240 * time.Second, true
+ case "120s", "2m":
+ return 120 * time.Second, true
+ case "300s", "5m":
+ return 300 * time.Second, true
+ default:
+ // Unknown value — use default
+ return 240 * time.Second, true
+ }
+}
+
// GetStartupStaggerMs returns the stagger delay in milliseconds between consecutive session
// resumes on startup for sessions sharing the same ACP process.
// Returns DefaultStartupStaggerMs (300 ms) if not configured (0).
diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go
index d01f91269..46bc860ef 100644
--- a/internal/config/settings_test.go
+++ b/internal/config/settings_test.go
@@ -530,6 +530,35 @@ func TestParseAgentInactivityTimeout(t *testing.T) {
}
}
+// TestParseMcpInitTimeout guards the SessionConfig accessor added for mitto-8ul.1.
+// Empty → default 240s (enabled); "disabled" → 0/false; explicit durations parse.
+// Unknown values fall back to the enabled default rather than silently disabling.
+func TestParseMcpInitTimeout(t *testing.T) {
+ tests := []struct {
+ value string
+ wantDur time.Duration
+ wantEnabled bool
+ }{
+ {"", 240 * time.Second, true},
+ {"disabled", 0, false},
+ {"120s", 120 * time.Second, true},
+ {"2m", 120 * time.Second, true},
+ {"240s", 240 * time.Second, true},
+ {"4m", 240 * time.Second, true},
+ {"300s", 300 * time.Second, true},
+ {"5m", 300 * time.Second, true},
+ {"bogus", 240 * time.Second, true},
+ }
+ for _, tc := range tests {
+ c := &SessionConfig{McpInitTimeout: tc.value}
+ gotDur, gotEnabled := c.ParseMcpInitTimeout()
+ if gotDur != tc.wantDur || gotEnabled != tc.wantEnabled {
+ t.Errorf("ParseMcpInitTimeout(%q) = (%s, %t), want (%s, %t)",
+ tc.value, gotDur, gotEnabled, tc.wantDur, tc.wantEnabled)
+ }
+ }
+}
+
func TestContextFlushCommand_RoundTrip(t *testing.T) {
original := &Config{
ACPServers: []ACPServer{
diff --git a/internal/conversation/acp_error_classification.go b/internal/conversation/acp_error_classification.go
index e696a52af..31fac0ae5 100644
--- a/internal/conversation/acp_error_classification.go
+++ b/internal/conversation/acp_error_classification.go
@@ -196,6 +196,15 @@ var permanentErrorPatterns = []errorPattern{
userMessage: "The ACP process pipe was permanently closed",
userGuidance: "Archive and re-open this conversation to get a fresh ACP connection.",
},
+ {
+ // The agent's internal MCP-init wait budget elapsed before every configured MCP
+ // server finished handshake, so the pending session/new was aborted by the
+ // stderr-signal watch (mitto-8ul.1). Retrying with the same MCP configuration
+ // will produce the same failure until the underlying MCP server is fixed.
+ substrings: []string{"mcp initialization timed out"},
+ userMessage: "MCP server initialization timed out",
+ userGuidance: "Check that every configured MCP server is reachable and starts within the agent's MCP-init budget. Fix the failing MCP server or remove it from the workspace configuration.",
+ },
}
// ClassifyACPError examines an error message and stderr output to determine
diff --git a/internal/conversation/acp_error_classification_test.go b/internal/conversation/acp_error_classification_test.go
index e86f84561..0c6b5d927 100644
--- a/internal/conversation/acp_error_classification_test.go
+++ b/internal/conversation/acp_error_classification_test.go
@@ -131,6 +131,23 @@ func TestClassifyACPError(t *testing.T) {
wantRetryable: false,
wantContains: "No ACP command",
},
+ // --- Permanent: MCP init timeout (mitto-8ul.1) ---
+ {
+ name: "MCP initialization timed out in wrapped error",
+ err: fmt.Errorf("session/new: mcp initialization timed out (agent reported MCP-init wait exhausted): context deadline exceeded"),
+ stderr: "",
+ wantClass: ACPErrorPermanent,
+ wantRetryable: false,
+ wantContains: "MCP server initialization timed out",
+ },
+ {
+ name: "MCP initialization timed out in stderr",
+ err: fmt.Errorf("failed to create session"),
+ stderr: "auggie: MCP initialization timed out after 225s",
+ wantClass: ACPErrorPermanent,
+ wantRetryable: false,
+ wantContains: "MCP server initialization timed out",
+ },
// --- Transient: unrecognized errors ---
{
name: "network timeout is transient",
diff --git a/internal/conversation/background_session_test.go b/internal/conversation/background_session_test.go
index f06c5ecba..7388eef36 100644
--- a/internal/conversation/background_session_test.go
+++ b/internal/conversation/background_session_test.go
@@ -5001,6 +5001,7 @@ func (p *alwaysFailSharedProcess) Capabilities() *acp.AgentCapabilities { return
func (p *alwaysFailSharedProcess) Restart() error {
return fmt.Errorf("alwaysFailSharedProcess: cannot restart — no real process")
}
+func (p *alwaysFailSharedProcess) RecommendedLoadTimeout(_ bool) time.Duration { return 0 }
// TestACPInitializeAttemptTimeoutBound is a math test for mitto-13ck.2.
//
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index 473e0675a..d10ffda08 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"os/exec"
+ "regexp"
"strings"
"sync"
"sync/atomic"
@@ -409,15 +410,40 @@ var stderrCrashPatterns = []string{
"Reached heap limit",
}
+// mcpInitProgressPattern detects the "Waiting for N MCP server(s) to initialize"
+// line the agent writes to stderr while it is blocking on MCP handshake. It is
+// intentionally count-agnostic and case-insensitive so it survives minor phrasing
+// variations across agent versions (mitto-8ul.1).
+var mcpInitProgressPattern = regexp.MustCompile(`(?i)waiting for .* mcp server`)
+
+// mcpInitTimeoutPattern detects the agent's "MCP initialization timed out after Ns"
+// line, emitted when its internal MCP wait budget elapses without all servers being
+// ready. Matched tolerantly so we don't couple to the exact suffix (mitto-8ul.1).
+var mcpInitTimeoutPattern = regexp.MustCompile(`(?i)mcp initialization timed out`)
+
// StartStderrMonitor starts a goroutine that reads from stderr and writes to the collector.
// If onCrashDetected is non-nil, it is called (at most once) when crash patterns are
// detected in the stderr output, enabling early process death signaling.
// If onFirstActivity is non-nil, it is called (at most once) the first time any bytes
// are observed on stderr — used by the startup watchdog to detect "live" processes.
-func StartStderrMonitor(stderr runner.ReadCloser, collector *StderrCollector, onCrashDetected func(), onFirstActivity func()) {
+// If onMCPInitProgress is non-nil, it is called (at most once) when the agent reports it
+// is blocked waiting for MCP servers to initialize. If onMCPInitTimeout is non-nil, it
+// is called (at most once) when the agent reports its MCP-init wait has timed out —
+// callers use this to abort the pending session/new promptly with an actionable error
+// (mitto-8ul.1). Neither MCP signal contributes to crash detection.
+func StartStderrMonitor(
+ stderr runner.ReadCloser,
+ collector *StderrCollector,
+ onCrashDetected func(),
+ onFirstActivity func(),
+ onMCPInitProgress func(),
+ onMCPInitTimeout func(),
+) {
go func() {
crashSignaled := false
activitySignaled := false
+ mcpProgressSignaled := false
+ mcpTimeoutSignaled := false
buf := make([]byte, 4096)
for {
n, readErr := stderr.Read(buf)
@@ -429,19 +455,38 @@ func StartStderrMonitor(stderr runner.ReadCloser, collector *StderrCollector, on
onFirstActivity()
}
+ chunkStr := ""
+
// Fix C: Check for crash patterns in stderr output.
// This detects inner CLI subprocess death immediately from SDK
// stderr messages, bypassing the 60s control request timeout.
if !crashSignaled && onCrashDetected != nil {
- chunk := string(buf[:n])
+ chunkStr = string(buf[:n])
for _, pattern := range stderrCrashPatterns {
- if strings.Contains(chunk, pattern) {
+ if strings.Contains(chunkStr, pattern) {
crashSignaled = true
onCrashDetected()
break
}
}
}
+
+ // MCP-init lifecycle signals (mitto-8ul.1): tolerant regex matches
+ // so the exact phrasing/count in the agent's log line is not load-bearing.
+ if (onMCPInitProgress != nil && !mcpProgressSignaled) ||
+ (onMCPInitTimeout != nil && !mcpTimeoutSignaled) {
+ if chunkStr == "" {
+ chunkStr = string(buf[:n])
+ }
+ if !mcpProgressSignaled && onMCPInitProgress != nil && mcpInitProgressPattern.MatchString(chunkStr) {
+ mcpProgressSignaled = true
+ onMCPInitProgress()
+ }
+ if !mcpTimeoutSignaled && onMCPInitTimeout != nil && mcpInitTimeoutPattern.MatchString(chunkStr) {
+ mcpTimeoutSignaled = true
+ onMCPInitTimeout()
+ }
+ }
}
if readErr != nil {
break
@@ -868,8 +913,11 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
signalStartupActivity = StartACPStartupWatchdog(watchdogCtx, bs.logger, acpCommand, "", -1)
- // Monitor stderr in background (with crash detection for Fix C and watchdog wake-up)
- StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity)
+ // Monitor stderr in background (with crash detection for Fix C and watchdog wake-up).
+ // BackgroundSession's own ACP process (non-shared path) does not multiplex sessions,
+ // so the MCP-init callbacks are unused here — the extended-budget policy lives on
+ // SharedACPProcess where MCP servers are actually attached (mitto-8ul.1).
+ StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil)
// Store wait function for cleanup
// We'll call it in Close() method
@@ -920,8 +968,9 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
signalStartupActivity = StartACPStartupWatchdog(watchdogCtx, bs.logger, acpCommand, "", pid)
// Monitor stderr in background (same as runner case, with crash detection for Fix C
- // and watchdog wake-up on first stderr activity)
- StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity)
+ // and watchdog wake-up on first stderr activity). MCP-init callbacks are unused on
+ // the non-shared BackgroundSession path (mitto-8ul.1).
+ StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil)
bs.acpCmd = cmd
diff --git a/internal/conversation/bgsession_acp_process_test.go b/internal/conversation/bgsession_acp_process_test.go
index 8cf3eb602..7804a81c1 100644
--- a/internal/conversation/bgsession_acp_process_test.go
+++ b/internal/conversation/bgsession_acp_process_test.go
@@ -22,7 +22,7 @@ func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
chunk := "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
go func() {
@@ -38,6 +38,83 @@ func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
}
}
+// TestStartStderrMonitor_MCPInitProgress_TriggersCallback is a regression test for
+// mitto-8ul.1: when the agent writes a "Waiting for N MCP server(s) to initialize"
+// line the stderr monitor must invoke onMCPInitProgress at most once so the UI can
+// display an "initializing" hint and the process can widen its RPC budget.
+func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ progressCalls := 0
+ onProgress := func() { progressCalls++ }
+ onTimeout := func() { t.Fatal("MCP timeout should not fire on progress lines") }
+
+ StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout)
+
+ go func() {
+ // Two lines to prove the callback still fires only once.
+ _, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize\n"))
+ _, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize (still)\n"))
+ _ = pw.Close()
+ }()
+
+ // Give the goroutine a moment to consume the stream.
+ time.Sleep(200 * time.Millisecond)
+ if progressCalls != 1 {
+ t.Fatalf("expected onMCPInitProgress to be called exactly once, got %d", progressCalls)
+ }
+}
+
+// TestStartStderrMonitor_MCPInitTimeout_TriggersCallback is a regression test for
+// mitto-8ul.1: the "MCP initialization timed out" signal must invoke onMCPInitTimeout
+// (at most once) so the pending session/new can be aborted promptly.
+func TestStartStderrMonitor_MCPInitTimeout_TriggersCallback(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ timeoutCalls := 0
+ onTimeout := func() { timeoutCalls++ }
+
+ StartStderrMonitor(pr, collector, nil, nil, nil, onTimeout)
+
+ go func() {
+ _, _ = pw.Write([]byte("MCP initialization timed out after 225s\n"))
+ _, _ = pw.Write([]byte("MCP initialization timed out again\n"))
+ _ = pw.Close()
+ }()
+
+ time.Sleep(200 * time.Millisecond)
+ if timeoutCalls != 1 {
+ t.Fatalf("expected onMCPInitTimeout to be called exactly once, got %d", timeoutCalls)
+ }
+}
+
+// TestStartStderrMonitor_MCPPatternsDoNotTriggerCrash guards against future regex
+// churn accidentally overlapping MCP-init phrasing with crash detection patterns.
+func TestStartStderrMonitor_MCPPatternsDoNotTriggerCrash(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() { crashDetected <- struct{}{} }
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
+
+ go func() {
+ _, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize\n"))
+ _, _ = pw.Write([]byte("MCP initialization timed out after 225s\n"))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ t.Fatal("MCP-init lines must not trigger crash detection")
+ case <-time.After(300 * time.Millisecond):
+ // expected: no crash signal
+ }
+}
+
// TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection is a sanity
// check that ordinary stderr output does not falsely trigger crash detection.
func TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection(t *testing.T) {
@@ -52,7 +129,7 @@ func TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection(t *testing
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
go func() {
_, _ = pw.Write([]byte("some normal debug output\n"))
diff --git a/internal/conversation/interfaces.go b/internal/conversation/interfaces.go
index e19d92b24..338f55f65 100644
--- a/internal/conversation/interfaces.go
+++ b/internal/conversation/interfaces.go
@@ -3,6 +3,7 @@ package conversation
import (
"context"
"log/slog"
+ "time"
acp "github.com/coder/acp-go-sdk"
"github.com/inercia/mitto/internal/config"
@@ -42,6 +43,12 @@ type SharedProcess interface {
Capabilities() *acp.AgentCapabilities
// Restart attempts to restart the underlying OS process.
Restart() error
+ // RecommendedLoadTimeout returns the outer wall-clock budget the caller should
+ // apply to a session/load RPC. It widens for cold sessions with MCP servers so
+ // the outer timeout does not truncate the process's own extended MCP-init
+ // budget (mitto-8ul.1). Returns 0 to indicate the caller should use its own
+ // default.
+ RecommendedLoadTimeout(hasMCPServers bool) time.Duration
}
// PromptResolver resolves a prompt name to its full text for a given working directory.
diff --git a/internal/conversation/shared_session_handshaker.go b/internal/conversation/shared_session_handshaker.go
index d4459b063..21e1776e4 100644
--- a/internal/conversation/shared_session_handshaker.go
+++ b/internal/conversation/shared_session_handshaker.go
@@ -273,7 +273,14 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
if handle == nil && supportsLoad {
client := d.hsGetACPClient()
client.SetLoadingSession(true)
- loadCtx, loadCancel := context.WithTimeout(d.hsSessionCtx(), 30*time.Second)
+ // Outer load budget defaults to 30s; SharedACPProcess widens this for
+ // cold sessions with MCP servers so the outer timeout does not truncate
+ // the extended MCP-init budget (mitto-8ul.1).
+ loadTimeout := 30 * time.Second
+ if rec := sharedProcess.RecommendedLoadTimeout(len(mcpServers) > 0); rec > loadTimeout {
+ loadTimeout = rec
+ }
+ loadCtx, loadCancel := context.WithTimeout(d.hsSessionCtx(), loadTimeout)
handle, err = sharedProcess.LoadSession(loadCtx, acpSessionID, workingDir, mcpServers)
loadCancel()
client.SetLoadingSession(false)
diff --git a/internal/conversation/shared_session_handshaker_test.go b/internal/conversation/shared_session_handshaker_test.go
index f39eff12e..ba0cbd131 100644
--- a/internal/conversation/shared_session_handshaker_test.go
+++ b/internal/conversation/shared_session_handshaker_test.go
@@ -68,6 +68,7 @@ func (f *fakeSharedProcess) SetSessionModel(_ context.Context, _ acp.SessionId,
return nil
}
func (f *fakeSharedProcess) Restart() error { return nil }
+func (f *fakeSharedProcess) RecommendedLoadTimeout(_ bool) time.Duration { return 0 }
func (f *fakeSharedProcess) SetPromptFunc(_ func(context.Context, string, string, string) error) {}
func (f *fakeSharedProcess) PromptProcessorAsync(_ context.Context, _, _, _ string) error {
return nil
diff --git a/internal/web/config_handlers.go b/internal/web/config_handlers.go
index 6d18925a4..4d199a690 100644
--- a/internal/web/config_handlers.go
+++ b/internal/web/config_handlers.go
@@ -421,6 +421,14 @@ func (s *Server) applyConfigChanges(req *ConfigSaveRequest, settings *configPkg.
} else {
s.acpProcessManager.UpdateMemoryRecycleThreshold(0)
}
+ // Update the MCP-init extended budget at runtime (mitto-8ul.1). Existing
+ // processes keep their construction-time value; new processes pick up the
+ // updated timeout on next GetOrCreateProcess.
+ if d, enabled := settings.Session.ParseMcpInitTimeout(); enabled {
+ s.acpProcessManager.UpdateMCPInitTimeout(d)
+ } else {
+ s.acpProcessManager.UpdateMCPInitTimeout(0)
+ }
}
// Update the prompt inactivity watchdog timeout at runtime if session config
diff --git a/internal/web/server.go b/internal/web/server.go
index 6d90f7699..c93d54ba2 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -370,6 +370,18 @@ func NewServer(config Config) (*Server, error) {
}
}
+ // Apply the MCP-init extended budget from settings (mitto-8ul.1). Cold session/new
+ // calls with MCP servers use this widened deadline instead of the normal 25s, so
+ // agents like Auggie that block session/new until MCP init completes (up to ~225s)
+ // no longer fail with "context deadline exceeded" on first use.
+ if config.MittoConfig != nil && config.MittoConfig.Session != nil {
+ if d, enabled := config.MittoConfig.Session.ParseMcpInitTimeout(); enabled {
+ acpProcessMgr.UpdateMCPInitTimeout(d)
+ } else {
+ acpProcessMgr.UpdateMCPInitTimeout(0)
+ }
+ }
+
// Start ACP process garbage collector to clean up idle sessions and processes.
// The GC periodically checks for sessions with no observers, no active prompts,
// and no pending work, and stops shared ACP processes that have no active sessions.
@@ -693,6 +705,29 @@ func NewServer(config Config) (*Server, error) {
s.BroadcastMemoryRecycled(workspaceUUID, workspaceName, workingDir, rssBytes, threshold, sessionCount)
})
+ // MCP-init lifecycle notifications (mitto-8ul.1): fired at most once per shared
+ // process. "initializing" is informational; "timed out" indicates the agent gave
+ // up on its own MCP-init wait budget and the pending session/new was aborted.
+ // Resolve a friendly workspace name here (the manager only knows the UUID).
+ acpProcessMgr.SetOnMCPInitializing(func(workspaceUUID string) {
+ workspaceName := ""
+ workingDir := ""
+ if ws := sessionMgr.GetWorkspaceByUUID(workspaceUUID); ws != nil {
+ workspaceName = ws.Name
+ workingDir = ws.WorkingDir
+ }
+ s.BroadcastMCPInitializing(workspaceUUID, workspaceName, workingDir)
+ })
+ acpProcessMgr.SetOnMCPInitTimedOut(func(workspaceUUID string) {
+ workspaceName := ""
+ workingDir := ""
+ if ws := sessionMgr.GetWorkspaceByUUID(workspaceUUID); ws != nil {
+ workspaceName = ws.Name
+ workingDir = ws.WorkingDir
+ }
+ s.BroadcastMCPInitTimedOut(workspaceUUID, workspaceName, workingDir)
+ })
+
// Initialize MCP server.
// This serves both global tools and session-scoped tools.
// The MCP server is always started; only its bind host/port are configurable.
@@ -1625,6 +1660,39 @@ func (s *Server) BroadcastMemoryRecycled(workspaceUUID, workspaceName, workingDi
}
}
+// BroadcastMCPInitializing notifies all connected clients that the agent for a
+// workspace is currently blocked waiting for one or more MCP servers to initialize
+// (mitto-8ul.1). Informational — the pending session/new is still expected to
+// succeed once MCP init completes. Fired at most once per shared process lifetime.
+func (s *Server) BroadcastMCPInitializing(workspaceUUID, workspaceName, workingDir string) {
+ s.eventsManager.Broadcast(WSMsgTypeMCPInitializing, map[string]interface{}{
+ "workspace_uuid": workspaceUUID,
+ "workspace_name": workspaceName,
+ "working_dir": workingDir,
+ })
+ if s.logger != nil {
+ s.logger.Info("Broadcast MCP initializing",
+ "workspace_uuid", workspaceUUID,
+ "clients", s.eventsManager.ClientCount())
+ }
+}
+
+// BroadcastMCPInitTimedOut notifies all connected clients that the agent's MCP-init
+// wait elapsed before every MCP server finished handshake (mitto-8ul.1). The pending
+// session/new has been aborted with an actionable error.
+func (s *Server) BroadcastMCPInitTimedOut(workspaceUUID, workspaceName, workingDir string) {
+ s.eventsManager.Broadcast(WSMsgTypeMCPInitTimedOut, map[string]interface{}{
+ "workspace_uuid": workspaceUUID,
+ "workspace_name": workspaceName,
+ "working_dir": workingDir,
+ })
+ if s.logger != nil {
+ s.logger.Warn("Broadcast MCP init timed out",
+ "workspace_uuid", workspaceUUID,
+ "clients", s.eventsManager.ClientCount())
+ }
+}
+
// BroadcastBeadsCleanupProgress notifies all connected clients about the
// progress of a background bulk closed-issue cleanup.
func (s *Server) BroadcastBeadsCleanupProgress(workingDir string, deleted, total int, done bool, errMsg string) {
@@ -1847,6 +1915,19 @@ func (s *Server) OnPromptsChanged(event configPkg.PromptsChangeEvent) {
"timestamp": event.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
})
+ // Surface any prompt files that failed to load so the user is not left
+ // with a silently-missing prompt (mitto-mqe). Error-style toasts persist
+ // until manually dismissed.
+ if s.config.PromptsCache != nil {
+ for _, pe := range s.config.PromptsCache.LoadErrors() {
+ s.eventsManager.Broadcast(WSMsgTypeNotification, map[string]interface{}{
+ "title": "Prompt failed to load",
+ "message": fmt.Sprintf("%s: %v", pe.Path, pe.Err),
+ "style": "error",
+ })
+ }
+ }
+
if s.logger != nil {
s.logger.Debug("Broadcasted prompts_changed event",
"changed_dirs", event.ChangedDirs,
diff --git a/internal/web/ws_messages.go b/internal/web/ws_messages.go
index edcda4314..1a38031f3 100644
--- a/internal/web/ws_messages.go
+++ b/internal/web/ws_messages.go
@@ -224,6 +224,22 @@ const (
// "rss_bytes": uint64, "threshold_bytes": uint64, "session_count": int }
WSMsgTypeMemoryRecycled = "memory_recycled"
+ // WSMsgTypeMCPInitializing notifies that the agent for a workspace is currently
+ // blocked waiting for one or more MCP servers to initialize (mitto-8ul.1). This is
+ // an informational "session/new may take longer than usual" hint the UI can use to
+ // display a subdued toast — NOT an error. Broadcast at most once per shared process
+ // (per workspace) and only fired when the agent explicitly reports MCP-init progress
+ // via its stderr log. Data: { "workspace_uuid": string, "workspace_name": string,
+ // "working_dir": string }.
+ WSMsgTypeMCPInitializing = "mcp_initializing"
+
+ // WSMsgTypeMCPInitTimedOut notifies that the agent's internal MCP-init wait budget
+ // elapsed before all MCP servers finished handshake (mitto-8ul.1). The pending
+ // session/new (or session/load) call has been aborted with an actionable error. The
+ // UI can use this to display a persistent notification pointing at MCP configuration.
+ // Data: { "workspace_uuid": string, "workspace_name": string, "working_dir": string }.
+ WSMsgTypeMCPInitTimedOut = "mcp_init_timed_out"
+
// WSMsgTypeQueueUpdated notifies that the message queue state changed.
// Sent when messages are added, removed, or the queue is cleared.
// Data: { "queue_length": int, "action": string, "message_id": string }
diff --git a/tests/integration/inprocess/mcp_init_timeout_test.go b/tests/integration/inprocess/mcp_init_timeout_test.go
new file mode 100644
index 000000000..30f0164ac
--- /dev/null
+++ b/tests/integration/inprocess/mcp_init_timeout_test.go
@@ -0,0 +1,193 @@
+//go:build integration
+
+package inprocess
+
+// Integration tests for the MCP-init extended-budget policy (mitto-8ul.1).
+//
+// The mock ACP server accepts two env vars:
+// - MOCK_MCP_INIT_DELAY_MS: delay session/new by this many ms while emitting
+// an MCP-init progress line on stderr. Mitto's stderr monitor uses that
+// progress line to fire the onMCPInitProgress callback and widen its RPC
+// deadline for the cold session/new call.
+// - MOCK_MCP_INIT_TIMEOUT_MS: after this many ms, emit an MCP-init-timeout
+// line on stderr and fail the pending session/new with a JSON-RPC error.
+// Mitto's stderr monitor should abort the pending RPC promptly and surface
+// an actionable permanent error rather than "context deadline exceeded".
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/inercia/mitto/internal/client"
+ "github.com/inercia/mitto/internal/config"
+ "github.com/inercia/mitto/internal/session"
+ "github.com/inercia/mitto/internal/web"
+)
+
+// TestMCPInitTimeout_FailsFastOnStderrSignal verifies that a mock agent whose
+// stderr reports "MCP initialization timed out" causes Mitto to abort the pending
+// session/new with an actionable permanent error, without waiting the full RPC
+// deadline. mitto-8ul.1.
+func TestMCPInitTimeout_FailsFastOnStderrSignal(t *testing.T) {
+ // Mock agent will emit the MCP-init progress line, wait 500ms, then emit
+ // the MCP-init-timeout line and fail session/new.
+ t.Setenv("MOCK_MCP_INIT_TIMEOUT_MS", "500")
+
+ ts := SetupTestServer(t, func(c *web.Config) {
+ if c.MittoConfig == nil {
+ c.MittoConfig = &config.Config{}
+ }
+ // Extended budget of 10s so we would otherwise wait a long time — the test
+ // asserts we bail well before that on the stderr signal.
+ c.MittoConfig.Session = &config.SessionConfig{McpInitTimeout: "10s"}
+ })
+
+ sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "mcp-init-timeout"})
+ if err != nil {
+ t.Fatalf("CreateSession failed: %v", err)
+ }
+ t.Cleanup(func() { _ = ts.Client.DeleteSession(sess.SessionID) })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ var mu sync.Mutex
+ var errors []string
+ promptComplete := false
+
+ ws, err := ts.Client.Connect(ctx, sess.SessionID, client.SessionCallbacks{
+ OnPromptComplete: func(_ int) {
+ mu.Lock()
+ promptComplete = true
+ mu.Unlock()
+ },
+ OnError: func(msg string) {
+ mu.Lock()
+ errors = append(errors, msg)
+ mu.Unlock()
+ },
+ })
+ if err != nil {
+ t.Fatalf("Connect failed: %v", err)
+ }
+ defer ws.Close()
+
+ if err := ws.LoadEvents(50, 0, 0); err != nil {
+ t.Fatalf("LoadEvents failed: %v", err)
+ }
+ time.Sleep(100 * time.Millisecond)
+
+ start := time.Now()
+ if err := ws.SendPrompt("hello mcp-init-timeout"); err != nil {
+ t.Fatalf("SendPrompt failed: %v", err)
+ }
+
+ // The RPC should fail promptly on the stderr signal (well before the 10s
+ // extended budget elapses). Give the server up to 20s to record the failure.
+ waitFor(t, 20*time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return promptComplete || len(errors) > 0
+ }, "prompt complete or error received")
+
+ elapsed := time.Since(start)
+ if elapsed > 8*time.Second {
+ t.Errorf("Expected fail-fast on MCP-init-timeout stderr signal; took %v", elapsed)
+ }
+
+ // is_prompting must be false — no stuck spinner.
+ bs := ts.Server.GetSessionManager().GetSession(sess.SessionID)
+ if bs != nil && bs.IsPrompting() {
+ t.Error("Expected is_prompting=false after MCP-init timeout")
+ }
+
+ // A persisted EventTypeError must exist so the failed turn is reopen-visible.
+ time.Sleep(200 * time.Millisecond)
+ events, err := ts.Store.ReadEvents(sess.SessionID)
+ if err != nil {
+ t.Fatalf("ReadEvents failed: %v", err)
+ }
+ hasError := false
+ for _, e := range events {
+ if e.Type == session.EventTypeError {
+ hasError = true
+ t.Logf("Found persisted error event seq=%d: %+v", e.Seq, e.Data)
+ break
+ }
+ }
+ if !hasError {
+ t.Error("Expected persisted EventTypeError after MCP-init-timeout abort")
+ }
+}
+
+// TestMCPInitDelay_ExtendedBudgetAllowsSuccess verifies that a cold session/new
+// which takes longer than the normal 25s budget (simulated at ~500ms so the test
+// is fast) still succeeds when the MCP-init extended budget is configured
+// generously. mitto-8ul.1.
+func TestMCPInitDelay_ExtendedBudgetAllowsSuccess(t *testing.T) {
+ // Mock agent delays session/new by 500ms while emitting the MCP-init progress line.
+ t.Setenv("MOCK_MCP_INIT_DELAY_MS", "500")
+
+ ts := SetupTestServer(t, func(c *web.Config) {
+ if c.MittoConfig == nil {
+ c.MittoConfig = &config.Config{}
+ }
+ // Any non-empty enabled value; even the default 240s is fine since the mock
+ // only delays 500ms.
+ c.MittoConfig.Session = &config.SessionConfig{McpInitTimeout: "4m"}
+ })
+
+ sess, err := ts.Client.CreateSession(client.CreateSessionRequest{Name: "mcp-init-delay-ok"})
+ if err != nil {
+ t.Fatalf("CreateSession failed: %v", err)
+ }
+ t.Cleanup(func() { _ = ts.Client.DeleteSession(sess.SessionID) })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ var mu sync.Mutex
+ promptComplete := false
+ var errors []string
+
+ ws, err := ts.Client.Connect(ctx, sess.SessionID, client.SessionCallbacks{
+ OnPromptComplete: func(_ int) {
+ mu.Lock()
+ promptComplete = true
+ mu.Unlock()
+ },
+ OnError: func(msg string) {
+ mu.Lock()
+ errors = append(errors, msg)
+ mu.Unlock()
+ },
+ })
+ if err != nil {
+ t.Fatalf("Connect failed: %v", err)
+ }
+ defer ws.Close()
+
+ if err := ws.LoadEvents(50, 0, 0); err != nil {
+ t.Fatalf("LoadEvents failed: %v", err)
+ }
+ time.Sleep(100 * time.Millisecond)
+
+ if err := ws.SendPrompt("hello mcp-init-delay-ok"); err != nil {
+ t.Fatalf("SendPrompt failed: %v", err)
+ }
+
+ waitFor(t, 20*time.Second, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return promptComplete
+ }, "prompt complete despite MCP-init delay")
+
+ mu.Lock()
+ errsCopy := append([]string{}, errors...)
+ mu.Unlock()
+ if len(errsCopy) > 0 {
+ t.Errorf("Expected no errors with extended MCP-init budget, got: %v", errsCopy)
+ }
+}
diff --git a/tests/mocks/acp-server/handler.go b/tests/mocks/acp-server/handler.go
index e8bebb4a5..ee6650f98 100644
--- a/tests/mocks/acp-server/handler.go
+++ b/tests/mocks/acp-server/handler.go
@@ -95,6 +95,29 @@ func (s *MockACPServer) handleNewSession(req JSONRPCRequest) error {
return s.sendError(req.ID, -32603, "agent busy: request timeout", nil)
}
+ // MCP-init simulation (mitto-8ul.1). Fired based on env vars regardless of whether
+ // the caller populated McpServers, because Mitto attaches MCP globally today so
+ // session/new requests carry an empty mcpServers slice. The stderr progress line
+ // is what internal/conversation's StartStderrMonitor watches for to invoke the
+ // onMCPInitProgress callback; the timeout line triggers fail-fast.
+ nServers := len(params.McpServers)
+ if nServers == 0 {
+ nServers = 1 // synthetic count for the stderr progress message
+ }
+ if s.mcpInitTimeoutAfterMs > 0 {
+ fmt.Fprintf(os.Stderr, "Waiting for %d MCP servers to initialize\n", nServers)
+ time.Sleep(time.Duration(s.mcpInitTimeoutAfterMs) * time.Millisecond)
+ fmt.Fprintf(os.Stderr, "MCP initialization timed out after %ds\n", s.mcpInitTimeoutAfterMs/1000)
+ // Return an error so any client that ignored the stderr signal still gets
+ // a deterministic failure. Tests that rely on the stderr abort will have
+ // already cancelled their context before we reach this line.
+ return s.sendError(req.ID, -32603, "mcp initialization timed out", nil)
+ }
+ if s.mcpInitDelayMs > 0 {
+ fmt.Fprintf(os.Stderr, "Waiting for %d MCP servers to initialize\n", nServers)
+ time.Sleep(time.Duration(s.mcpInitDelayMs) * time.Millisecond)
+ }
+
// Use Cwd (new format) or fallback to WorkingDirectory (legacy)
workdir := params.Cwd
if workdir == "" {
diff --git a/tests/mocks/acp-server/main.go b/tests/mocks/acp-server/main.go
index 904a531d5..07ec2d221 100644
--- a/tests/mocks/acp-server/main.go
+++ b/tests/mocks/acp-server/main.go
@@ -98,6 +98,18 @@ type MockACPServer struct {
// single O_APPEND write so concurrent mock processes sharing the file (e.g. an
// auxiliary title-generation session) cannot interleave within a line.
rpcOrderFile string
+
+ // mcpInitDelayMs: sleep before responding to session/new when the request
+ // carries at least one MCP server, and emit the "Waiting for N MCP servers to
+ // initialize" progress line on stderr just before the delay. Simulates an
+ // agent that blocks session/new until MCP init completes (mitto-8ul.1).
+ mcpInitDelayMs int
+
+ // mcpInitTimeoutAfterMs: after this many ms, emit an "MCP initialization
+ // timed out after Ns" line on stderr AND return a JSON-RPC error from the
+ // pending session/new. Used to prove the client aborts promptly rather than
+ // waiting the full RPC deadline (mitto-8ul.1). 0 disables.
+ mcpInitTimeoutAfterMs int
}
// Default modes provided by the mock server
@@ -161,6 +173,24 @@ func NewMockACPServer(scenarioDir string, defaultDelay time.Duration, verbose bo
// MOCK_RPC_ORDER_FILE: append-only log of inbound RPC arrival order.
server.rpcOrderFile = os.Getenv("MOCK_RPC_ORDER_FILE")
+ // MOCK_MCP_INIT_DELAY_MS: delay session/new by this many ms and emit an MCP-init
+ // progress line on stderr. Only applies when the request carries MCP servers.
+ // Simulates Auggie-style agents that block session/new on MCP handshake (mitto-8ul.1).
+ if v := os.Getenv("MOCK_MCP_INIT_DELAY_MS"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ server.mcpInitDelayMs = n
+ }
+ }
+
+ // MOCK_MCP_INIT_TIMEOUT_MS: after this many ms, emit an MCP-init-timeout stderr
+ // line and fail the pending session/new. Used to prove fail-fast on the signal
+ // (mitto-8ul.1).
+ if v := os.Getenv("MOCK_MCP_INIT_TIMEOUT_MS"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ server.mcpInitTimeoutAfterMs = n
+ }
+ }
+
server.loadScenarios()
return server
}
diff --git a/tests/mocks/acp-server/types.go b/tests/mocks/acp-server/types.go
index 4cca2fa87..7ae70bb3a 100644
--- a/tests/mocks/acp-server/types.go
+++ b/tests/mocks/acp-server/types.go
@@ -73,8 +73,9 @@ type SessionListCapabilities struct {
}
type NewSessionParams struct {
- Cwd string `json:"cwd"`
- WorkingDirectory string `json:"workingDirectory"` // Legacy field
+ Cwd string `json:"cwd"`
+ WorkingDirectory string `json:"workingDirectory"` // Legacy field
+ McpServers []McpServer `json:"mcpServers,omitempty"`
}
type NewSessionResult struct {
diff --git a/web/static/hooks/useBackgroundNotifications.js b/web/static/hooks/useBackgroundNotifications.js
index 9e8175c15..00b9e8eb8 100644
--- a/web/static/hooks/useBackgroundNotifications.js
+++ b/web/static/hooks/useBackgroundNotifications.js
@@ -67,6 +67,60 @@ export function useBackgroundNotifications({
};
}, [showToast]);
+ // Listen for MCP-init progress events (mitto-8ul.1): agent is blocked waiting
+ // for MCP servers to initialize on cold start. Informational, low-priority toast.
+ useEffect(() => {
+ const handleMCPInitializing = (event) => {
+ const data = event.detail;
+ if (!data) return;
+ const name =
+ data.workspace_name ||
+ (data.working_dir ? data.working_dir.split("/").pop() : "") ||
+ "a workspace";
+ showToast({
+ style: "info",
+ title: `Starting MCP servers: ${name}`,
+ message:
+ "The agent is initializing its MCP servers. First response may take up to a few minutes.",
+ duration: 8000,
+ });
+ };
+ window.addEventListener("mitto:mcp_initializing", handleMCPInitializing);
+ return () => {
+ window.removeEventListener(
+ "mitto:mcp_initializing",
+ handleMCPInitializing,
+ );
+ };
+ }, [showToast]);
+
+ // Listen for MCP-init timeout events (mitto-8ul.1): the agent gave up on its
+ // MCP-init wait and aborted the pending session/new. Persistent error toast.
+ useEffect(() => {
+ const handleMCPInitTimedOut = (event) => {
+ const data = event.detail;
+ if (!data) return;
+ const name =
+ data.workspace_name ||
+ (data.working_dir ? data.working_dir.split("/").pop() : "") ||
+ "a workspace";
+ showToast({
+ style: "error",
+ title: `MCP initialization timed out: ${name}`,
+ message:
+ "The agent could not start all configured MCP servers. Check that every MCP server is reachable or remove it from the workspace configuration.",
+ duration: 30000,
+ });
+ };
+ window.addEventListener("mitto:mcp_init_timed_out", handleMCPInitTimedOut);
+ return () => {
+ window.removeEventListener(
+ "mitto:mcp_init_timed_out",
+ handleMCPInitTimedOut,
+ );
+ };
+ }, [showToast]);
+
// Listen for ACP start failed events
useEffect(() => {
const handleAcpStartFailed = (event) => {
diff --git a/web/static/hooks/useWebSocket.js b/web/static/hooks/useWebSocket.js
index 6cb1adb08..1ece1e166 100644
--- a/web/static/hooks/useWebSocket.js
+++ b/web/static/hooks/useWebSocket.js
@@ -2989,6 +2989,30 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) {
}
break;
+ case "mcp_initializing":
+ // Server notifies that the agent for a workspace is blocked waiting for
+ // MCP servers to initialize on this cold start (mitto-8ul.1). Informational
+ // only — the pending session/new is still expected to succeed.
+ console.log("MCP initializing:", msg.data);
+ if (msg.data) {
+ window.dispatchEvent(
+ new CustomEvent("mitto:mcp_initializing", { detail: msg.data }),
+ );
+ }
+ break;
+
+ case "mcp_init_timed_out":
+ // Server notifies that the agent's MCP-init wait budget elapsed before all
+ // MCP servers finished handshake, so the pending session/new was aborted
+ // with an actionable error (mitto-8ul.1).
+ console.warn("MCP init timed out:", msg.data);
+ if (msg.data) {
+ window.dispatchEvent(
+ new CustomEvent("mitto:mcp_init_timed_out", { detail: msg.data }),
+ );
+ }
+ break;
+
case "acp_start_failed":
// Server notifies that the ACP server failed to start
console.error("ACP start failed:", msg.data);
From c9782ec4a338a5652dc957085a2499ca77e47d36 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 18:43:33 +0200
Subject: [PATCH 016/240] fix(config): surface prompt file load errors instead
of silent drop (mitto-mqe)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LoadPromptsFromDir silently skipped any .prompt.yaml file that failed
to parse, with only a code comment noting "In production, this would
use a logger" — so a broken prompt file vanished with no diagnostic
signal anywhere.
- LoadPromptsFromDirWithErrors (prompts.go) is the new entry point:
returns both the successfully-loaded prompts and a PromptLoadError
per failing file, and logs each failure at WARN.
LoadPromptsFromDir is kept as a thin wrapper for existing callers.
- PromptsCache.reload() collects load errors across all scanned prompt
directories; PromptsCache.LoadErrors() exposes the most recent set.
- handleGlobalEventsWS (events_ws.go) sends a persistent error-style
toast to newly-connected clients for each outstanding load error,
covering the case where a prompt file was already broken at server
startup (not just edit-time).
Tests: TestLoadPromptsFromDirWithErrors_ReportsBadFileAndKeepsGood,
TestPromptsCache_LoadErrors_ReportsBadFile. gofmt/build/vet/config
tests green.
---
internal/config/prompts.go | 28 +++++++++++++---
internal/config/prompts_cache.go | 15 ++++++++-
internal/config/prompts_cache_test.go | 48 +++++++++++++++++++++++++++
internal/config/prompts_test.go | 46 +++++++++++++++++++++++++
internal/web/events_ws.go | 15 +++++++++
5 files changed, 146 insertions(+), 6 deletions(-)
diff --git a/internal/config/prompts.go b/internal/config/prompts.go
index 25ede2161..e3824c633 100644
--- a/internal/config/prompts.go
+++ b/internal/config/prompts.go
@@ -378,17 +378,33 @@ func LoadPromptFile(promptsDir, relativePath string) (*PromptFile, error) {
return ParsePromptFile(relativePath, data, info.ModTime())
}
+// PromptLoadError describes a single prompt file that failed to load/parse/precompile.
+type PromptLoadError struct {
+ Path string // path (relative to the scanned dir) of the offending file
+ Err error // underlying parse/template error
+}
+
// LoadPromptsFromDir loads all .prompt.yaml files from a directory recursively.
// Disabled prompts (enabled: false) are included so they can suppress same-named
// prompts from lower-priority directories during the merge phase.
// Returns an empty slice if the directory doesn't exist.
func LoadPromptsFromDir(dir string) ([]*PromptFile, error) {
+ prompts, _, err := LoadPromptsFromDirWithErrors(dir)
+ return prompts, err
+}
+
+// LoadPromptsFromDirWithErrors loads all .prompt.yaml files from a directory recursively,
+// returning both the successfully-loaded prompts and per-file errors for files that
+// failed to load/parse/precompile. Failed files are also logged at WARN.
+// Returns an empty slice if the directory doesn't exist.
+func LoadPromptsFromDirWithErrors(dir string) ([]*PromptFile, []PromptLoadError, error) {
// Check if directory exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
- return nil, nil
+ return nil, nil, nil
}
var prompts []*PromptFile
+ var loadErrors []PromptLoadError
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -414,8 +430,10 @@ func LoadPromptsFromDir(dir string) ([]*PromptFile, error) {
// Load and parse the file
prompt, err := LoadPromptFile(dir, relPath)
if err != nil {
- // Log warning but continue with other files
- // In production, this would use a logger
+ loadErrors = append(loadErrors, PromptLoadError{Path: relPath, Err: err})
+ slog.Warn("failed to load prompt file",
+ "path", filepath.Join(dir, relPath),
+ "error", err)
return nil
}
@@ -424,10 +442,10 @@ func LoadPromptsFromDir(dir string) ([]*PromptFile, error) {
})
if err != nil {
- return nil, fmt.Errorf("failed to walk prompts directory %s: %w", dir, err)
+ return nil, loadErrors, fmt.Errorf("failed to walk prompts directory %s: %w", dir, err)
}
- return prompts, nil
+ return prompts, loadErrors, nil
}
// PromptsToWebPrompts converts a slice of PromptFile to WebPrompt.
diff --git a/internal/config/prompts_cache.go b/internal/config/prompts_cache.go
index f69347e69..9f9029fde 100644
--- a/internal/config/prompts_cache.go
+++ b/internal/config/prompts_cache.go
@@ -41,6 +41,9 @@ type PromptsCache struct {
// workspaceRoot is the workspace directory for resolving relative paths
workspaceRoot string
+
+ // loadErrors holds per-file load errors from the most recent reload.
+ loadErrors []PromptLoadError
}
// NewPromptsCache creates a new prompts cache.
@@ -180,13 +183,15 @@ func (c *PromptsCache) reload() ([]*PromptFile, error) {
// Later directories override earlier ones
promptsByName := make(map[string]*PromptFile)
newModTimes := make(map[string]time.Time)
+ var newLoadErrors []PromptLoadError
for _, dir := range dirs {
modTime := GetPromptsDirModTime(dir)
newModTimes[dir] = modTime
// Skip non-existent directories silently
- dirPrompts, err := LoadPromptsFromDir(dir)
+ dirPrompts, dirLoadErrors, err := LoadPromptsFromDirWithErrors(dir)
+ newLoadErrors = append(newLoadErrors, dirLoadErrors...)
if err != nil {
// Log warning but continue with other directories
continue
@@ -212,10 +217,18 @@ func (c *PromptsCache) reload() ([]*PromptFile, error) {
c.webPrompts = PromptsToWebPrompts(prompts)
c.loadedAt = time.Now()
c.dirModTimes = newModTimes
+ c.loadErrors = newLoadErrors
return prompts, nil
}
+// LoadErrors returns the per-file load errors from the most recent reload.
+func (c *PromptsCache) LoadErrors() []PromptLoadError {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.loadErrors
+}
+
// GetWebPrompts returns the cached prompts as WebPrompt slice.
// This is the format used by the API.
func (c *PromptsCache) GetWebPrompts() ([]WebPrompt, error) {
diff --git a/internal/config/prompts_cache_test.go b/internal/config/prompts_cache_test.go
index fdcf0211b..5c1c5ed52 100644
--- a/internal/config/prompts_cache_test.go
+++ b/internal/config/prompts_cache_test.go
@@ -539,3 +539,51 @@ func TestPromptsCache_GetDirectories(t *testing.T) {
t.Errorf("dirs[3] = %q, want %q", dirs[3], "/absolute/path")
}
}
+
+func TestPromptsCache_LoadErrors_ReportsBadFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv(appdir.MittoDirEnv, tmpDir)
+ appdir.ResetCache()
+ t.Cleanup(appdir.ResetCache)
+
+ promptsDir := filepath.Join(tmpDir, appdir.PromptsDirName)
+ if err := os.MkdirAll(promptsDir, 0755); err != nil {
+ t.Fatalf("Failed to create prompts dir: %v", err)
+ }
+
+ // One valid, one malformed.
+ goodPrompt := `name: "Good"
+prompt: |
+ ok.
+`
+ if err := os.WriteFile(filepath.Join(promptsDir, "good.prompt.yaml"), []byte(goodPrompt), 0644); err != nil {
+ t.Fatalf("Failed to write good.prompt.yaml: %v", err)
+ }
+ badPrompt := `name: [unclosed
+prompt: |
+ broken.
+`
+ if err := os.WriteFile(filepath.Join(promptsDir, "bad.prompt.yaml"), []byte(badPrompt), 0644); err != nil {
+ t.Fatalf("Failed to write bad.prompt.yaml: %v", err)
+ }
+
+ cache := NewPromptsCache()
+ prompts, err := cache.Get()
+ if err != nil {
+ t.Fatalf("Get() failed: %v", err)
+ }
+ if len(prompts) != 1 {
+ t.Errorf("len(prompts) = %d, want 1", len(prompts))
+ }
+
+ loadErrors := cache.LoadErrors()
+ if len(loadErrors) != 1 {
+ t.Fatalf("len(LoadErrors()) = %d, want 1 (%+v)", len(loadErrors), loadErrors)
+ }
+ if loadErrors[0].Path != "bad.prompt.yaml" {
+ t.Errorf("LoadErrors()[0].Path = %q, want %q", loadErrors[0].Path, "bad.prompt.yaml")
+ }
+ if loadErrors[0].Err == nil {
+ t.Error("LoadErrors()[0].Err = nil, want non-nil")
+ }
+}
diff --git a/internal/config/prompts_test.go b/internal/config/prompts_test.go
index b9c75a157..988641272 100644
--- a/internal/config/prompts_test.go
+++ b/internal/config/prompts_test.go
@@ -525,6 +525,52 @@ func TestLoadPromptsFromDir_NonExistent(t *testing.T) {
}
}
+func TestLoadPromptsFromDirWithErrors_ReportsBadFileAndKeepsGood(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Valid prompt file.
+ goodPrompt := `name: "Good Prompt"
+prompt: |
+ Some content.
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "good.prompt.yaml"), []byte(goodPrompt), 0644); err != nil {
+ t.Fatalf("Failed to write good.prompt.yaml: %v", err)
+ }
+
+ // Invalid prompt file: malformed YAML (unclosed flow sequence).
+ badPrompt := `name: [unclosed
+prompt: |
+ Should fail to parse.
+`
+ if err := os.WriteFile(filepath.Join(tmpDir, "bad.prompt.yaml"), []byte(badPrompt), 0644); err != nil {
+ t.Fatalf("Failed to write bad.prompt.yaml: %v", err)
+ }
+
+ prompts, loadErrors, err := LoadPromptsFromDirWithErrors(tmpDir)
+ if err != nil {
+ t.Fatalf("LoadPromptsFromDirWithErrors failed: %v", err)
+ }
+
+ // Good prompt must still be returned.
+ if len(prompts) != 1 {
+ t.Fatalf("len(prompts) = %d, want 1", len(prompts))
+ }
+ if prompts[0].Name != "Good Prompt" {
+ t.Errorf("prompts[0].Name = %q, want %q", prompts[0].Name, "Good Prompt")
+ }
+
+ // Exactly one load error for the bad file.
+ if len(loadErrors) != 1 {
+ t.Fatalf("len(loadErrors) = %d, want 1 (%+v)", len(loadErrors), loadErrors)
+ }
+ if loadErrors[0].Path != "bad.prompt.yaml" {
+ t.Errorf("loadErrors[0].Path = %q, want %q", loadErrors[0].Path, "bad.prompt.yaml")
+ }
+ if loadErrors[0].Err == nil {
+ t.Error("loadErrors[0].Err = nil, want non-nil")
+ }
+}
+
func TestPromptsToWebPrompts(t *testing.T) {
prompts := []*PromptFile{
{Name: "One", Content: "Content 1"},
diff --git a/internal/web/events_ws.go b/internal/web/events_ws.go
index 99a31a061..2f338daf1 100644
--- a/internal/web/events_ws.go
+++ b/internal/web/events_ws.go
@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"sync"
@@ -110,6 +111,20 @@ func (s *Server) handleGlobalEventsWS(w http.ResponseWriter, r *http.Request) {
client.wsConn.SendMessage(WSMsgTypeConnected, map[string]string{
"acp_server": s.config.ACPServer,
})
+
+ // Surface any prompt files that failed to load so the user is not left
+ // with a silently-missing prompt (mitto-mqe). Error-style toasts persist
+ // until manually dismissed.
+ if s.config.PromptsCache != nil {
+ _, _ = s.config.PromptsCache.Get() // ensure cache loaded so LoadErrors() is populated
+ for _, pe := range s.config.PromptsCache.LoadErrors() {
+ client.wsConn.SendMessage(WSMsgTypeNotification, map[string]interface{}{
+ "title": "Prompt failed to load",
+ "message": fmt.Sprintf("%s: %v", pe.Path, pe.Err),
+ "style": "error",
+ })
+ }
+ }
}
func (c *GlobalEventsClient) readPump() {
From 197368440299d453c90f0c48d004ab81c04918e0 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 20:42:59 +0200
Subject: [PATCH 017/240] fix(acpproc): rate/rolling-window saturation trigger
for intermittent+budget-gated storms (mitto-5eq)
---
docs/devel/acp.md | 43 +++++
internal/acpproc/acp_process_manager_test.go | 15 +-
internal/acpproc/saturation_rate_test.go | 168 ++++++++++++++++
internal/acpproc/shared_acp_process.go | 193 ++++++++++++++++++-
4 files changed, 413 insertions(+), 6 deletions(-)
create mode 100644 internal/acpproc/saturation_rate_test.go
diff --git a/docs/devel/acp.md b/docs/devel/acp.md
index 3e581885d..4001c36d8 100644
--- a/docs/devel/acp.md
+++ b/docs/devel/acp.md
@@ -468,6 +468,49 @@ within 2× the GC interval). When those gates pass, each session is marked
`MarkGCSuspended`, closed, and the process is stopped via `StopProcess`; the next
`NewSession` lazily builds a fresh process with zeroed saturation state.
+#### Saturation triggers feeding Tier 5 / Tier 6
+
+Two independent triggers can set `saturatedUntil` / `saturationLevel`; both are
+picked up by `IsSaturated()` and `IsConfirmedDegraded()` identically, so no changes
+in `acp_process_gc.go` are required beyond reading those getters.
+
+1. **Consecutive-timeout fast path** (mitto-13ck.2, unchanged): after
+ `sessionSaturationTimeoutThreshold` (3) *back-to-back* full-deadline
+ `NewSession`/`LoadSession` RPCs the process is flagged. This catches the
+ fully-wedged case where every RPC runs to its deadline.
+
+2. **Rate / rolling-window trigger** (mitto-5eq): a bucketed sliding window
+ (`saturationWindowDuration` = 5 min, `saturationWindowBucketCount` = 10 → 30 s
+ buckets) counts full-deadline timeouts, `shouldFailFastCreateAttempt`
+ budget-exhaustion bails, and successful control-plane RPCs. When the window
+ holds at least `saturationWindowMinSamples` (8) samples AND
+ `(timeouts + bails) / total ≥ saturationWindowFailRatio` (0.5), the process is
+ promoted to saturated via the same `saturationLevel++` /
+ `saturatedUntil = now + cooldown` path as the consecutive trigger.
+
+ This closes two gaps the consecutive-only design left open:
+
+ - **Interspersed success reset**: a degraded process that still serves *some*
+ traffic never accumulates 3 timeouts in a row — every interspersed success
+ zeroes `consecutiveRPCTimeouts`. The rolling window is NOT wiped by a
+ success; a success only adds a sample so the ratio drops naturally as
+ health returns. Concrete effect: an incident with 38 context-deadlines,
+ ~2000 interspersed successful ACP events, and ~10 min of aux-session
+ starvation produced zero Tier 5/6 recycles before the rate trigger.
+ - **Budget-exhaustion bails don't count**: the dominant real failure mode is
+ `session/new: insufficient remaining budget ... failing fast` via
+ `shouldFailFastCreateAttempt`. Those bails intentionally skip
+ `recordRPCTimeout` (nothing was actually attempted), so they never
+ contributed to saturation. The rate trigger's new `recordRPCBudgetBail`
+ records them into the window only — it does NOT touch
+ `consecutiveRPCTimeouts`, preserving the consecutive fast path unchanged.
+
+ Both triggers share `saturationMu` — no second lock is introduced. The window
+ is bounded (fixed-size ring buffer, no unbounded growth) and cost is O(1) per
+ record + O(bucketCount) per evaluate. Cold-start MCP-init timeouts and bails
+ are excluded from the window on the same rationale as the consecutive path
+ (`extendedBudget == true` → skipped).
+
### Tier 6 — Non-Idle Recycle for Confirmed-Degraded Processes (mitto-1h0)
Tier 5 is **idle-gated**: it skips a process with in-flight RPCs or a prompting
diff --git a/internal/acpproc/acp_process_manager_test.go b/internal/acpproc/acp_process_manager_test.go
index a77b51dcd..de5c94355 100644
--- a/internal/acpproc/acp_process_manager_test.go
+++ b/internal/acpproc/acp_process_manager_test.go
@@ -1449,7 +1449,7 @@ func TestSaturationStateMachine_EscalatingCooldown(t *testing.T) {
t.Errorf("after level-3 trip: cooldown ≈ %v, want ≈ %v", cd3, wantCD3)
}
- // A successful RPC resets level to 0 and clears all state.
+ // A successful RPC resets level to 0 and clears all consecutive-path state.
p.recordRPCSuccess()
p.saturationMu.Lock()
lvlReset := p.saturationLevel
@@ -1470,6 +1470,19 @@ func TestSaturationStateMachine_EscalatingCooldown(t *testing.T) {
t.Error("after recordRPCSuccess: saturatedUntil must be zero")
}
+ // Isolate the consecutive-path re-trip check from the rate/rolling-window
+ // trigger (mitto-5eq): recordRPCSuccess intentionally does NOT wipe the
+ // rolling window (that would reintroduce the interspersed-success reset bug),
+ // so the residual timeouts from earlier in this test could otherwise let the
+ // rate trigger fire before the consecutive threshold on the next 3 timeouts,
+ // promoting saturationLevel past 1. This test is specifically exercising the
+ // consecutive path in isolation, so clear the window here. Coverage for the
+ // interaction ("success clears state but window survives") lives in
+ // TestSaturationRate_SuccessDoesNotWipeWindow.
+ p.saturationMu.Lock()
+ p.saturationBuckets = nil
+ p.saturationMu.Unlock()
+
// After reset, next trip should again use level 1 (base cooldown).
for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
p.recordRPCTimeout()
diff --git a/internal/acpproc/saturation_rate_test.go b/internal/acpproc/saturation_rate_test.go
new file mode 100644
index 000000000..ec168d85e
--- /dev/null
+++ b/internal/acpproc/saturation_rate_test.go
@@ -0,0 +1,168 @@
+package acpproc
+
+import (
+ "testing"
+)
+
+// TestSaturationRate_IntermittentStormTripsRate verifies the mitto-5eq acceptance
+// criterion (a): an intermittently-degraded process — full-deadline timeouts
+// interleaved with successes and budget-exhaustion bails, none of them 3-in-a-row —
+// is flagged saturated by the new rate/rolling-window trigger even though the
+// consecutive-timeout fast path alone would miss it.
+func TestSaturationRate_IntermittentStormTripsRate(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ // Interleave the events so consecutiveRPCTimeouts never reaches
+ // sessionSaturationTimeoutThreshold (3): each timeout/bail run is broken up
+ // by a success that zeroes the consecutive counter. This is the exact real
+ // pattern from the 2026-07-06 incident (2065 interspersed successes reset
+ // the counter every time). Sequence: T,S,B,T,S,B,T,S,T,B,T,S,B — 6 timeouts,
+ // 4 bails, 4 successes → 10/14 ≈ 71% failure ratio over 14 samples, which is
+ // ≥ saturationWindowFailRatio (0.5) with total ≥ saturationWindowMinSamples (8).
+ sequence := []string{"T", "S", "B", "T", "S", "B", "T", "S", "T", "B", "T", "S", "B", "T"}
+ for _, ev := range sequence {
+ switch ev {
+ case "T":
+ proc.recordRPCTimeout()
+ case "B":
+ proc.recordRPCBudgetBail()
+ case "S":
+ // A success intentionally clears saturatedUntil/consecutive counter
+ // (preserved semantics) but the rolling-window sample survives.
+ proc.recordRPCSuccess()
+ }
+ }
+
+ if !proc.IsSaturated() {
+ t.Fatalf("expected IsSaturated()=true after intermittent storm; level=%d", proc.SaturationLevel())
+ }
+ // The consecutive counter must NEVER have crossed threshold on this
+ // sequence — a success comes after every 1-2 failures. If the rate trigger
+ // weren't in place this test would fail.
+ proc.saturationMu.Lock()
+ consec := proc.consecutiveRPCTimeouts
+ proc.saturationMu.Unlock()
+ if consec >= sessionSaturationTimeoutThreshold {
+ t.Fatalf("test setup invariant: consecutive counter reached %d, threshold %d — sequence needs more interleaved successes", consec, sessionSaturationTimeoutThreshold)
+ }
+}
+
+// TestSaturationRate_SteadyHealthyDoesNotTrip verifies acceptance criterion:
+// a mostly-healthy process (occasional single timeout below the fail-ratio
+// threshold) is NOT flagged. Guards against a false-positive on light traffic.
+func TestSaturationRate_SteadyHealthyDoesNotTrip(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ // 30 successes with 1 timeout sprinkled in → 1/31 ≈ 3.2% failure ratio,
+ // well below saturationWindowFailRatio (0.5).
+ for i := 0; i < 15; i++ {
+ proc.recordRPCSuccess()
+ }
+ proc.recordRPCTimeout()
+ for i := 0; i < 15; i++ {
+ proc.recordRPCSuccess()
+ }
+
+ if proc.IsSaturated() {
+ t.Fatalf("steady-healthy process should NOT be saturated; level=%d", proc.SaturationLevel())
+ }
+ if proc.SaturationLevel() != 0 {
+ t.Fatalf("expected saturationLevel=0, got %d", proc.SaturationLevel())
+ }
+}
+
+// TestSaturationRate_MinSamplesGuard verifies the min-sample guard prevents a
+// 1/1 (or otherwise tiny) failure ratio from tripping the rate trigger. This is
+// the primary false-positive-on-light-traffic protection.
+func TestSaturationRate_MinSamplesGuard(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ // Only 1 timeout + 1 bail = 2 samples, well below saturationWindowMinSamples.
+ // Ratio 100% but sample size too small → must not trip.
+ proc.recordRPCTimeout()
+ proc.recordRPCBudgetBail()
+
+ if proc.IsSaturated() {
+ t.Fatalf("rate trigger fired below min-sample threshold; level=%d", proc.SaturationLevel())
+ }
+}
+
+// TestSaturationRate_BudgetBailOnlyStormTrips verifies acceptance criterion (c):
+// a storm made ENTIRELY of budget-exhaustion bails (the dominant real failure mode
+// observed in the incident — 17 SetSessionModel failures via
+// shouldFailFastCreateAttempt) trips the rate trigger. Before mitto-5eq those bails
+// intentionally skipped recordRPCTimeout, so this scenario produced ZERO
+// saturation signal and no GC recycle.
+func TestSaturationRate_BudgetBailOnlyStormTrips(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ for i := 0; i < saturationWindowMinSamples; i++ {
+ proc.recordRPCBudgetBail()
+ }
+
+ if !proc.IsSaturated() {
+ t.Fatalf("expected IsSaturated()=true after %d budget bails; level=%d", saturationWindowMinSamples, proc.SaturationLevel())
+ }
+ // The consecutive counter must be untouched — budget bails feed only the
+ // rate signal, never the consecutive fast path (see recordRPCBudgetBail
+ // doc comment for rationale).
+ proc.saturationMu.Lock()
+ consec := proc.consecutiveRPCTimeouts
+ proc.saturationMu.Unlock()
+ if consec != 0 {
+ t.Fatalf("recordRPCBudgetBail must not touch consecutiveRPCTimeouts; got %d", consec)
+ }
+}
+
+// TestSaturationRate_ConsecutiveFastPathStillTrips verifies acceptance criterion
+// (d): the pre-existing consecutive-timeout fast path is unaffected. Three
+// back-to-back full timeouts (below the rate trigger's min-sample size) must still
+// trip saturation via the classic path — this is the fully-wedged-process case
+// which must NOT regress.
+func TestSaturationRate_ConsecutiveFastPathStillTrips(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ for i := 0; i < sessionSaturationTimeoutThreshold; i++ {
+ proc.recordRPCTimeout()
+ }
+
+ if !proc.IsSaturated() {
+ t.Fatalf("consecutive fast path did not trip after %d timeouts; level=%d", sessionSaturationTimeoutThreshold, proc.SaturationLevel())
+ }
+ if proc.SaturationLevel() != 1 {
+ t.Fatalf("expected saturationLevel=1 on first consecutive trip, got %d", proc.SaturationLevel())
+ }
+}
+
+// TestSaturationRate_SuccessDoesNotWipeWindow verifies the design decision
+// documented on recordRPCSuccess: a success adds a sample to the rolling window
+// but does NOT clear the window itself. If it did, an intermittent-storm process
+// could always be "reset" by a single lucky success and we'd reintroduce the exact
+// reset-on-success bug that made the consecutive-timeout trigger inert. Concretely:
+// after a rate-trip is cleared by one success, the very next timeout must re-trip
+// via the still-populated window rather than start from scratch.
+func TestSaturationRate_SuccessDoesNotWipeWindow(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ // Drive to a rate-based trip with lots of bails and a couple of successes.
+ for i := 0; i < saturationWindowMinSamples; i++ {
+ proc.recordRPCBudgetBail()
+ }
+ if !proc.IsSaturated() {
+ t.Fatalf("test setup: rate trigger did not fire after %d bails", saturationWindowMinSamples)
+ }
+
+ // A single success clears the fast-path state (existing preserved semantics)…
+ proc.recordRPCSuccess()
+ if proc.IsSaturated() {
+ t.Fatalf("recordRPCSuccess did not clear saturatedUntil (fast-path reset semantics broken)")
+ }
+
+ // …but the next single timeout must immediately re-trip via the still-live
+ // rolling window (bails still counted, ratio still ≥ threshold, samples
+ // still above minimum). This is the whole point of not wiping the window.
+ proc.recordRPCTimeout()
+ if !proc.IsSaturated() {
+ t.Fatalf("expected re-trip on next timeout because rolling window was not wiped; level=%d", proc.SaturationLevel())
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index 34b7efba8..d6fd0b5f0 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -135,6 +135,48 @@ const (
// once this bar is met.
confirmedDegradedLevel = 2
+ // Rate/rolling-window saturation trigger (mitto-5eq). The consecutive-timeout
+ // path above only trips after N *back-to-back* full RPC deadlines; any interspersed
+ // success zeroes the counter and budget-exhaustion bails in shouldFailFastCreateAttempt
+ // intentionally skip recordRPCTimeout — so a shared process that fails intermittently
+ // (e.g. 30-50% of session/new + set_model RPCs deadline over 5-10 minutes, but ~2000
+ // unrelated ACP events keep succeeding in between) never accumulates enough consecutive
+ // timeouts to trip, and the GC's Tier 5/6 recycle tiers stay inert. Observed 2026-07-06
+ // 16:34–16:42: 38 context-deadlines, 9 NewSession + 17 SetSessionModel failures, ~10 min
+ // aux-session starvation → ZERO recycles.
+ //
+ // The rate trigger complements (does not replace) the consecutive fast path by counting
+ // full-deadline timeouts AND budget-exhaustion bails against successes in a bounded
+ // sliding window. Bookkeeping is bucketed (fixed-size ring) so cost is O(1) per record
+ // and O(bucketCount) per evaluate, with no unbounded growth. All state is guarded by
+ // the existing saturationMu — no second lock is introduced.
+ //
+ // saturationWindowDuration is the sliding-window length. 5 min is chosen to match the
+ // upper end of sessionSaturationCooldownMax and the observed incident timescale: a
+ // process that fails ≥50% of its control-plane RPCs for 5 minutes is not going to
+ // self-heal in another 5 minutes, but we still recycle no more aggressively than the
+ // existing max cooldown.
+ saturationWindowDuration = 5 * time.Minute
+ // saturationWindowBucketCount is the number of ring-buffer buckets covering
+ // saturationWindowDuration. 10 → 30-second buckets, granular enough that aging is
+ // smooth on the same order as sessionSaturationCooldownBase (30s) but small enough that
+ // aggregation stays trivially cheap. Must be > 0 and divide the window duration
+ // cleanly for bucket alignment to be exact.
+ saturationWindowBucketCount = 10
+ // saturationWindowMinSamples is the minimum total sample count (timeouts + bails +
+ // successes) required inside the window before the rate trigger can fire. This is the
+ // primary false-positive guard: a healthy process that happens to see 1 timeout in an
+ // otherwise-empty window (1/1 = 100%) must NOT trip. Set to 8 so a single burst has to
+ // clearly dominate ordinary traffic before the trigger arms.
+ saturationWindowMinSamples = 8
+ // saturationWindowFailRatio is the (timeouts + bails) / (timeouts + bails + successes)
+ // threshold at which the rate trigger fires. 0.5 (50%) is well outside any plausible
+ // steady-state healthy baseline (real deployments show <5% timeouts) yet captures the
+ // intermittent-degradation regime the incident exhibited (~40-60% failed control-plane
+ // RPCs interleaved with successes). Paired with saturationWindowMinSamples this
+ // preserves the "no false positives on light traffic" acceptance criterion.
+ saturationWindowFailRatio = 0.5
+
// Note: Runtime restart constants (maxProcessRestarts, processRestartWindow,
// processRestartBaseDelay, processRestartMaxDelay) are now defined in
// acp_error_classification.go as shared constants (conversation.MaxACPRestarts, conversation.ACPRestartWindow,
@@ -280,6 +322,16 @@ type SharedACPProcess struct {
saturatedUntil time.Time
saturationLevel int
inProbe bool
+ // saturationBuckets is the fixed-size ring buffer backing the rate/rolling-window
+ // saturation trigger (mitto-5eq). Each bucket covers saturationWindowDuration /
+ // saturationWindowBucketCount and records timeouts, budget-exhaustion bails, and
+ // successful control-plane RPCs falling in its time slot. Buckets age out purely by
+ // timestamp — a success does NOT wipe the window; it only adds a success sample so
+ // the failure ratio drops naturally. This deliberately avoids the "one success
+ // resets everything" bug that made the consecutive-timeout trigger inert for
+ // intermittently-degraded processes. Guarded by saturationMu; lazily allocated on
+ // first record.
+ saturationBuckets []saturationBucket
// Restart tracking
restartMu sync.Mutex
@@ -858,35 +910,156 @@ func saturationCooldownForLevel(level int) time.Duration {
return d
}
+// saturationBucket is one time-slot of the rate/rolling-window saturation
+// counter (mitto-5eq). Each bucket covers saturationWindowDuration /
+// saturationWindowBucketCount and records how many timeouts, budget-exhaustion
+// bails, and successful control-plane RPCs happened during that slot. The `start`
+// timestamp is aligned to the bucket duration so ring-buffer slot reuse can be
+// detected (a new event whose aligned slot no longer matches `start` means this
+// bucket has aged out and must be zeroed before being incremented).
+type saturationBucket struct {
+ start time.Time
+ timeouts int
+ bails int
+ successes int
+}
+
+// saturationBucketDuration returns the per-bucket time slot length. Kept as a
+// function (not a const) so the arithmetic is centralised and both writers and
+// readers agree on the divisor even if the constants ever change.
+func saturationBucketDuration() time.Duration {
+ return saturationWindowDuration / saturationWindowBucketCount
+}
+
+// saturationCurrentBucketLocked returns a pointer to the ring-buffer slot for the
+// current wall-clock time, zeroing it first if it belonged to an older window
+// (implicit prune). saturationMu MUST be held by the caller.
+func (p *SharedACPProcess) saturationCurrentBucketLocked(now time.Time) *saturationBucket {
+ if p.saturationBuckets == nil {
+ p.saturationBuckets = make([]saturationBucket, saturationWindowBucketCount)
+ }
+ bucketDur := saturationBucketDuration()
+ slot := now.Truncate(bucketDur)
+ // Map the aligned slot to a ring index. Both bucketDur and UnixNano are >0 here,
+ // so the modulo is well-defined and stable across all sample times.
+ idx := int(slot.UnixNano()/int64(bucketDur)) % saturationWindowBucketCount
+ if idx < 0 {
+ idx += saturationWindowBucketCount
+ }
+ if !p.saturationBuckets[idx].start.Equal(slot) {
+ p.saturationBuckets[idx] = saturationBucket{start: slot}
+ }
+ return &p.saturationBuckets[idx]
+}
+
+// saturationWindowStatsLocked aggregates all live buckets (those whose start is
+// within the current window ending at `now`) into totals. Buckets whose start is
+// older than now-saturationWindowDuration are treated as expired and skipped.
+// saturationMu MUST be held by the caller.
+func (p *SharedACPProcess) saturationWindowStatsLocked(now time.Time) (timeouts, bails, successes int) {
+ cutoff := now.Add(-saturationWindowDuration)
+ for i := range p.saturationBuckets {
+ b := p.saturationBuckets[i]
+ if b.start.IsZero() || b.start.Before(cutoff) {
+ continue
+ }
+ timeouts += b.timeouts
+ bails += b.bails
+ successes += b.successes
+ }
+ return
+}
+
+// evaluateSaturationRateTriggerLocked checks whether the current rolling window
+// meets the rate/min-sample threshold and, if so, promotes the process into the
+// SAME saturation state that the consecutive-timeout path uses (bumping
+// saturationLevel and arming saturatedUntil). This is a no-op when the process is
+// already saturated (saturatedUntil in the future) to avoid re-arming the cooldown
+// on every subsequent failure — the consecutive-path probe/escalation logic then
+// takes over as normal once the cooldown elapses. saturationMu MUST be held.
+func (p *SharedACPProcess) evaluateSaturationRateTriggerLocked(now time.Time) {
+ if !p.saturatedUntil.IsZero() && now.Before(p.saturatedUntil) {
+ return
+ }
+ timeouts, bails, successes := p.saturationWindowStatsLocked(now)
+ total := timeouts + bails + successes
+ if total < saturationWindowMinSamples {
+ return
+ }
+ fails := timeouts + bails
+ if float64(fails)/float64(total) < saturationWindowFailRatio {
+ return
+ }
+ // Trip via the shared saturation state so IsSaturated()/IsConfirmedDegraded()
+ // (and therefore GC Tier 5/6) pick it up unchanged. We deliberately do NOT
+ // touch consecutiveRPCTimeouts here — the two triggers stay independent so
+ // the consecutive fast path for the fully-wedged case is unaffected.
+ p.saturationLevel++
+ p.saturatedUntil = now.Add(saturationCooldownForLevel(p.saturationLevel))
+}
+
// recordRPCTimeout records a NewSession/LoadSession RPC timeout (mitto-13ck.2).
// In normal mode the consecutive counter increments toward the threshold; once the
// threshold is reached, saturationLevel is incremented and a fresh cooldown is set.
// In probe mode (inProbe=true) a single timeout immediately escalates the level and
// re-saturates, because the probe has already confirmed the process is still hung.
+// The timeout is ALSO recorded into the rate/rolling-window trigger (mitto-5eq)
+// which can promote the process to saturated independently — see
+// evaluateSaturationRateTriggerLocked for the rate-based fallback path.
func (p *SharedACPProcess) recordRPCTimeout() {
p.saturationMu.Lock()
defer p.saturationMu.Unlock()
+ now := time.Now()
+ p.saturationCurrentBucketLocked(now).timeouts++
if p.inProbe {
// Probe timed out: immediately escalate and re-saturate.
p.inProbe = false
p.saturationLevel++
p.consecutiveRPCTimeouts = 0
- p.saturatedUntil = time.Now().Add(saturationCooldownForLevel(p.saturationLevel))
+ p.saturatedUntil = now.Add(saturationCooldownForLevel(p.saturationLevel))
return
}
p.consecutiveRPCTimeouts++
if p.consecutiveRPCTimeouts >= sessionSaturationTimeoutThreshold {
p.saturationLevel++
- p.saturatedUntil = time.Now().Add(saturationCooldownForLevel(p.saturationLevel))
+ p.saturatedUntil = now.Add(saturationCooldownForLevel(p.saturationLevel))
+ return
}
+ // Consecutive threshold not reached — the rate/rolling-window trigger may still
+ // fire for the intermittent-storm case (mitto-5eq).
+ p.evaluateSaturationRateTriggerLocked(now)
}
-// recordRPCSuccess clears all saturation tracking after a successful NewSession/
-// LoadSession RPC (mitto-13ck.2). Resets the saturation level so the next event
-// starts again from the base cooldown (30s).
+// recordRPCBudgetBail records a mid-flight budget-exhaustion bail from
+// shouldFailFastCreateAttempt (mitto-5eq). These bails are NOT full RPC deadlines
+// (nothing was actually attempted) so they intentionally do NOT feed the
+// consecutive-timeout fast path (that path is reserved for the fully-wedged case
+// where the RPC itself runs to deadline). They DO feed the rate/rolling-window
+// signal — this is the "budget-exhaustion bails don't count" gap the rate trigger
+// was designed to close.
+func (p *SharedACPProcess) recordRPCBudgetBail() {
+ p.saturationMu.Lock()
+ defer p.saturationMu.Unlock()
+ now := time.Now()
+ p.saturationCurrentBucketLocked(now).bails++
+ p.evaluateSaturationRateTriggerLocked(now)
+}
+
+// recordRPCSuccess clears the consecutive-timeout saturation tracking after a
+// successful NewSession/LoadSession RPC (mitto-13ck.2). Resets the saturation
+// level so the next event starts again from the base cooldown (30s).
+//
+// A success is ALSO recorded as a sample in the rolling-window trigger
+// (mitto-5eq), but the window itself is NOT wiped: entries age out purely by
+// timestamp. If we cleared the window here we would reintroduce the exact
+// interspersed-success reset bug that made the consecutive-timeout trigger inert
+// for intermittently-degraded processes. Keeping window history intact means a
+// single fluke success right after a rate-trip clears the fast-path state but the
+// NEXT timeout/bail can immediately re-trip via the still-populated window.
func (p *SharedACPProcess) recordRPCSuccess() {
p.saturationMu.Lock()
defer p.saturationMu.Unlock()
+ p.saturationCurrentBucketLocked(time.Now()).successes++
p.consecutiveRPCTimeouts = 0
p.saturatedUntil = time.Time{}
p.saturationLevel = 0
@@ -1147,6 +1320,16 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
remaining = time.Until(dl)
}
if bail, reason := shouldFailFastCreateAttempt(attempt, p.isSaturated(), hasDeadline, remaining, perAttemptBudget); bail {
+ // Feed the budget-exhaustion bail into the rate/rolling-window trigger
+ // (mitto-5eq). This is the "bails don't count" gap: nothing was actually
+ // attempted so the consecutive fast path stays untouched, but a stream of
+ // bails on the same process IS evidence of intermittent degradation and
+ // should promote saturation via the rate signal. Skip during a cold-start
+ // MCP-init window (extendedBudget) since that latency isn't saturation
+ // evidence, mirroring recordRPCTimeout's gating.
+ if !extendedBudget {
+ p.recordRPCBudgetBail()
+ }
return nil, fmt.Errorf("session/new: %s (after %d attempt(s)); failing fast: %w", reason, attempt-1, context.DeadlineExceeded)
}
}
From a9ccbc94f541617be8d7cea43a31699d36f58923 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 21:22:24 +0200
Subject: [PATCH 018/240] feat(agents): config-driven per-agent stderr
crash/ignore patterns in metadata.yaml (mitto-k6h)
---
.augment/rules/03-cli-acp.md | 1 +
Makefile | 9 +-
config/agents/builtin/augment/metadata.yaml | 17 ++
.../agents/builtin/claude-code/metadata.yaml | 14 ++
config/agents/builtin/gemini/metadata.yaml | 10 +
docs/devel/acp.md | 47 ++++
internal/acpproc/acp_process_manager.go | 15 ++
internal/acpproc/shared_acp_process.go | 15 +-
internal/agents/stderr_patterns_test.go | 85 ++++++++
internal/agents/types.go | 25 +++
internal/conversation/background_session.go | 8 +
.../conversation/bgsession_acp_process.go | 140 +++++++++++-
.../bgsession_acp_process_test.go | 10 +-
internal/conversation/session_manager.go | 31 +++
internal/conversation/stderr_patterns_test.go | 204 ++++++++++++++++++
internal/web/server.go | 38 ++++
internal/web/stderr_patterns_cache.go | 48 +++++
17 files changed, 706 insertions(+), 11 deletions(-)
create mode 100644 internal/agents/stderr_patterns_test.go
create mode 100644 internal/conversation/stderr_patterns_test.go
create mode 100644 internal/web/stderr_patterns_cache.go
diff --git a/.augment/rules/03-cli-acp.md b/.augment/rules/03-cli-acp.md
index ae0f21dd5..0e33f8e3a 100644
--- a/.augment/rules/03-cli-acp.md
+++ b/.augment/rules/03-cli-acp.md
@@ -95,3 +95,4 @@ Located in `config/agents/builtin//` (shipped) or `MITTO_DIR/agents/custo
**MCP scopes**: `user` (global), `project` (per-repo), `local` (uncommitted).
**Agent defaults** (seeded at discovery): Pre-fill ACP server settings. Request-wins: user values take precedence.
**Commands**: `mcp-list.sh`, `mcp-install.sh`, `mcp-remove.sh` (scope must match metadata).
+**stderrPatterns** (mitto-k6h): Optional per-agent regex patterns applied by the ACP stderr monitor. `crash` (union with hardcoded baseline → `onCrashDetected` bypasses SDK 60s timeout), `ignore` (suppress from debug log; buffer capture unaffected), `degraded` (plumbed only, behaviour deferred). Compiled once at process start; invalid regex is skipped with a warn (never fatal). CI guard: `make check-stderr-patterns`. Compile happens in `internal/web` (only layer with both `agents.Manager` and ACP-server mapping); `*conversation.CompiledStderrPatterns` is injected into `SharedACPProcessConfig`/`BackgroundSessionConfig` via a per-server-name resolver. See [docs/devel/acp.md § Stderr Pattern Detection](../../docs/devel/acp.md).
diff --git a/Makefile b/Makefile
index d5eb68410..b915d6dfe 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build install test test-go test-js check-model-tags test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean
+.PHONY: build install test test-go test-js check-model-tags check-stderr-patterns test-integration test-integration-go test-integration-cli test-integration-api test-integration-client test-ui test-ui-headed test-ui-debug test-ui-report test-all test-ci test-setup test-clean clean run fmt fmt-check fmt-docs fmt-docs-check lint lint-go lint-frontend deps-go deps-js deps tailwind vendor-codemirror build-mac-app clean-mac-app test-webviewlog build-mock-acp ci install-hooks homebrew-generate homebrew-test homebrew-test-style homebrew-test-install homebrew-test-cask homebrew-tap-setup homebrew-clean smoke-build smoke-test-cli smoke-test smoke-clean
# Binary name
BINARY_NAME=mitto
@@ -51,6 +51,13 @@ check-model-tags:
@echo "Validating builtin prompt model tags..."
$(GOTEST) -run 'TestBuiltinPrompts_ModelTagsAreCanonical|TestDefaultModelProfiles_MatchesEmbeddedYAML|TestCanonicalModelTags|TestEffectiveModelProfiles_MergeAndPrecedence' ./internal/config/
+# Validate builtin agent stderr patterns compile as valid Go regexes (mitto-k6h).
+# Fails if any pattern in config/agents/builtin/*/metadata.yaml stderrPatterns
+# (crash / ignore / degraded) fails regexp.Compile.
+check-stderr-patterns:
+ @echo "Validating builtin agent stderr patterns..."
+ $(GOTEST) -run 'TestBuiltinAgents_StderrPatternsCompile' ./internal/agents/
+
# =============================================================================
# Integration & UI Tests
# =============================================================================
diff --git a/config/agents/builtin/augment/metadata.yaml b/config/agents/builtin/augment/metadata.yaml
index c69d258c1..2eecf20c0 100644
--- a/config/agents/builtin/augment/metadata.yaml
+++ b/config/agents/builtin/augment/metadata.yaml
@@ -12,3 +12,20 @@ install:
args: ["--workspace-root=$MITTO_WORKING_DIR", "--acp"]
mcp:
scopes: ["user", "project", "local"]
+# Per-agent stderr patterns (mitto-k6h). Unioned with the hardcoded baseline in
+# internal/conversation. Regexes are compiled once at process start; a bad regex
+# is skipped with a warn log (never fatal).
+stderrPatterns:
+ # Node/V8 fatal-heap messages already covered by baseline; listed here as a
+ # documented example that per-agent extension is allowed.
+ crash:
+ - "FATAL ERROR: .* Allocation failed"
+ ignore:
+ # Suppress node's MaxListenersExceededWarning noise (plugin-config-changed
+ # listeners emitted by auggie's plugin subsystem during hot init).
+ - "(?i)MaxListenersExceededWarning"
+ - "(?i)Use `node --trace-warnings"
+ - "(?i)method not found"
+ degraded:
+ # Plumbed for future use (mitto-k6h defers behavioural wiring).
+ - "(?i)rate limit"
diff --git a/config/agents/builtin/claude-code/metadata.yaml b/config/agents/builtin/claude-code/metadata.yaml
index 3f3546960..9a2976ca6 100644
--- a/config/agents/builtin/claude-code/metadata.yaml
+++ b/config/agents/builtin/claude-code/metadata.yaml
@@ -12,3 +12,17 @@ mcp:
scopes: ["user", "project", "local"]
defaults:
contextFlushCommand: "/clear"
+# Per-agent stderr patterns (mitto-k6h). Unioned with the hardcoded baseline.
+stderrPatterns:
+ crash:
+ # Rust-layer panics from claude-code-agent-sdk that don't already match
+ # baseline substrings.
+ - "^thread '.*' panicked at"
+ ignore:
+ - "(?i)method not found"
+ # OpenTelemetry startup warnings are informational.
+ - "(?i)OTEL_"
+ degraded:
+ # Anthropic quota / rate-limit chatter (plumbed only, mitto-k6h).
+ - "(?i)rate limit"
+ - "(?i)429 Too Many Requests"
diff --git a/config/agents/builtin/gemini/metadata.yaml b/config/agents/builtin/gemini/metadata.yaml
index fcaca680b..a53f6a1e6 100644
--- a/config/agents/builtin/gemini/metadata.yaml
+++ b/config/agents/builtin/gemini/metadata.yaml
@@ -12,3 +12,13 @@ install:
args: ["--acp"]
mcp:
scopes: ["user", "project"]
+# Per-agent stderr patterns (mitto-k6h). Unioned with the hardcoded baseline.
+stderrPatterns:
+ ignore:
+ - "(?i)method not found"
+ # Node deprecation notices from @google/gemini-cli.
+ - "(?i)DeprecationWarning"
+ degraded:
+ # Google API quota chatter (plumbed only, mitto-k6h).
+ - "(?i)RESOURCE_EXHAUSTED"
+ - "(?i)quota exceeded"
diff --git a/docs/devel/acp.md b/docs/devel/acp.md
index 4001c36d8..f45a9504d 100644
--- a/docs/devel/acp.md
+++ b/docs/devel/acp.md
@@ -256,6 +256,53 @@ stateDiagram-v2
correct `BackgroundSession` → observers (WebSocket clients)
5. **Close** — Session unregisters from `MultiplexClient`, decrements reference count
+### Stderr Pattern Detection (mitto-k6h)
+
+Mitto watches each ACP subprocess's stderr with `StartStderrMonitor`
+(`internal/conversation/bgsession_acp_process.go`) to detect crashes and
+lifecycle signals sub-second, bypassing the SDK's 60s control-request timeout.
+Detection is split into a **hardcoded baseline** (universal, SDK-layer strings)
+and a **per-agent** extension declared in each agent's `metadata.yaml`.
+
+**Schema** (`config/agents/builtin//metadata.yaml`):
+
+```yaml
+stderrPatterns:
+ crash: ["FATAL ERROR: .* Allocation failed"] # OR'd with hardcoded baseline
+ ignore: ["(?i)DeprecationWarning"] # suppress from debug log
+ degraded: ["(?i)rate limit"] # plumbed, behaviour deferred
+```
+
+**Action classes**:
+
+- **`crash`** — matches trigger `onCrashDetected()` → close `processDone` →
+ immediate GC recycle. The list is **unioned** with `stderrCrashPatterns` (the
+ hardcoded baseline: `stream ended unexpectedly`, `broken pipe`,
+ `JavaScript heap out of memory`, ...). Either source firing counts.
+- **`ignore`** — matches suppress the `agent stderr` debug-level log line for
+ that write. The captured stderr buffer (used for error reporting) is
+ unaffected. Complements the existing `$/cancel_request` suppression.
+- **`degraded`** — compiled and plumbed end-to-end (`CompiledStderrPatterns.Degraded`)
+ but **not consumed** in this increment. The follow-up wires these matches
+ into the shared-process saturation signal so agent-specific "warning"
+ patterns (rate limits, quota chatter) can proactively trip Tier 5/6 recycles.
+
+**Layering** (why compile lives in `internal/conversation`, not `internal/agents`):
+
+- `internal/acpproc` MUST NOT import `internal/agents` (would create a cycle
+ through `internal/conversation`).
+- `internal/conversation` MUST NOT import `internal/agents` (same reason).
+- `internal/web` is the only layer that has both `agents.Manager` and knows
+ which ACP server maps to which agent. It compiles the metadata once and
+ injects a `*conversation.CompiledStderrPatterns` into `SharedACPProcessConfig`
+ and `BackgroundSessionConfig` via a per-server-name resolver.
+
+**Compile semantics**: `regexp.Compile` runs once at process-start. Invalid
+regexes are **skipped with a warn log** (never fatal) so a single typo in one
+agent's metadata cannot block startup. `make check-stderr-patterns`
+(`TestBuiltinAgents_StderrPatternsCompile` in `internal/agents/`) catches
+those typos at CI time instead of runtime.
+
## Content Blocks
The ACP SDK uses a discriminated union (pointer fields) for content blocks:
diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go
index b870154ce..781872f99 100644
--- a/internal/acpproc/acp_process_manager.go
+++ b/internal/acpproc/acp_process_manager.go
@@ -46,6 +46,13 @@ type ACPProcessManager struct {
// AuxiliaryModelTag is ignored.
ModelProfilesByTagResolver func(tag string) []config.ModelProfile
+ // StderrPatternsResolver returns the compiled per-agent stderr regex patterns
+ // for a given ACP server name (mitto-k6h). The web layer wires this to resolve
+ // the ACP server → agent metadata → StderrPatterns → CompileStderrPatterns.
+ // May be nil (all processes then use only the hardcoded baseline). Nil result
+ // from the resolver is also valid (agent has no per-agent patterns).
+ StderrPatternsResolver func(acpServer string) *conversation.CompiledStderrPatterns
+
// Auxiliary session tracking
auxMu sync.Mutex
auxSessions map[auxSessionKey]*auxiliarySessionState
@@ -409,6 +416,13 @@ func (m *ACPProcessManager) GetOrCreateProcess(workspace *config.WorkspaceSettin
onMCPInitTimeout = func() { timeoutCb(wsUUID) }
}
+ // Resolve per-agent stderr patterns for this ACP server (mitto-k6h). Nil is
+ // a safe no-op — the process falls back to the hardcoded baseline.
+ var stderrPatterns *conversation.CompiledStderrPatterns
+ if m.StderrPatternsResolver != nil {
+ stderrPatterns = m.StderrPatternsResolver(workspace.ACPServer)
+ }
+
createStart := time.Now()
p, err := NewSharedACPProcess(m.ctx, SharedACPProcessConfig{
WorkspaceUUID: workspace.UUID,
@@ -424,6 +438,7 @@ func (m *ACPProcessManager) GetOrCreateProcess(workspace *config.WorkspaceSettin
MCPInitTimeout: mcpInitTimeout,
OnMCPInitProgress: onMCPInitProgress,
OnMCPInitTimeout: onMCPInitTimeout,
+ StderrPatterns: stderrPatterns,
})
createDuration := time.Since(createStart)
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index d6fd0b5f0..d949b13a7 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -255,6 +255,12 @@ type SharedACPProcessConfig struct {
// an actionable error rather than waiting for the RPC deadline to elapse
// (mitto-8ul.1). Optional.
OnMCPInitTimeout func()
+ // StderrPatterns holds per-agent compiled stderr regex patterns (mitto-k6h).
+ // Nil means only the hardcoded baseline crash patterns apply. Compiled once
+ // by the caller from the agent's metadata.yaml. Kept as a pointer to
+ // CompiledStderrPatterns (defined in internal/conversation) so acpproc does
+ // NOT depend on internal/agents.
+ StderrPatterns *conversation.CompiledStderrPatterns
}
// Compile-time assertion: *SharedACPProcess must satisfy the conversation.SharedProcess interface.
@@ -505,6 +511,11 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
var cmd *exec.Cmd
stderrCollector := conversation.NewStderrCollector(8192, p.logger)
+ // Install per-agent ignore patterns (mitto-k6h) so matching writes are
+ // suppressed from the debug-level stderr log. Nil is a safe no-op.
+ if p.config.StderrPatterns != nil {
+ stderrCollector.SetIgnorePatterns(p.config.StderrPatterns.Ignore)
+ }
// Pre-create process death detection channel so the stderr crash detector
// (Fix C) can signal it immediately when crash patterns are detected.
@@ -602,7 +613,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, -1)
- conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout)
+ conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, p.config.StderrPatterns)
} else {
cmd = exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -663,7 +674,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
}
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, pid)
- conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout)
+ conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, p.config.StderrPatterns)
wait = func() error {
return cmd.Wait()
diff --git a/internal/agents/stderr_patterns_test.go b/internal/agents/stderr_patterns_test.go
new file mode 100644
index 000000000..150a81514
--- /dev/null
+++ b/internal/agents/stderr_patterns_test.go
@@ -0,0 +1,85 @@
+package agents
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+// TestBuiltinAgents_StderrPatternsCompile is the validator behind
+// `make check-stderr-patterns` (mitto-k6h). It walks config/agents/builtin/*
+// on disk, parses each metadata.yaml, and asserts that every regex in
+// stderrPatterns.crash / .ignore / .degraded compiles cleanly with
+// regexp.Compile. A single bad regex fails the test — this catches typos in
+// YAML at CI time before they show up as skip-with-warn at runtime.
+func TestBuiltinAgents_StderrPatternsCompile(t *testing.T) {
+ builtinDir := builtinAgentsDirForTest(t)
+
+ entries, err := os.ReadDir(builtinDir)
+ if err != nil {
+ t.Fatalf("cannot read %s: %v", builtinDir, err)
+ }
+
+ checked := 0
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ metaPath := filepath.Join(builtinDir, entry.Name(), "metadata.yaml")
+ data, err := os.ReadFile(metaPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ t.Fatalf("cannot read %s: %v", metaPath, err)
+ }
+
+ var meta AgentMetadata
+ if err := yaml.Unmarshal(data, &meta); err != nil {
+ t.Fatalf("cannot parse %s: %v", metaPath, err)
+ }
+
+ if meta.StderrPatterns == nil {
+ continue
+ }
+
+ checkClass(t, entry.Name(), "crash", meta.StderrPatterns.Crash)
+ checkClass(t, entry.Name(), "ignore", meta.StderrPatterns.Ignore)
+ checkClass(t, entry.Name(), "degraded", meta.StderrPatterns.Degraded)
+ checked++
+ }
+
+ if checked == 0 {
+ t.Fatalf("no builtin agent metadata.yaml with stderrPatterns found under %s", builtinDir)
+ }
+}
+
+// checkClass fails the test if any pattern in the list is not a valid regex.
+func checkClass(t *testing.T, agentDir, class string, patterns []string) {
+ t.Helper()
+ for i, p := range patterns {
+ if _, err := regexp.Compile(p); err != nil {
+ t.Errorf("%s: stderrPatterns.%s[%d] = %q does not compile: %v",
+ agentDir, class, i, p, err)
+ }
+ }
+}
+
+// builtinAgentsDirForTest returns the absolute path to config/agents/builtin
+// relative to the test's source file location. This works regardless of the
+// current working directory (the test can be run via `go test ./internal/agents/...`
+// or from the package directory).
+func builtinAgentsDirForTest(t *testing.T) string {
+ t.Helper()
+ _, thisFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ // thisFile: /internal/agents/stderr_patterns_test.go
+ repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
+ return filepath.Join(repoRoot, "config", "agents", "builtin")
+}
diff --git a/internal/agents/types.go b/internal/agents/types.go
index 2f7691140..a059124d4 100644
--- a/internal/agents/types.go
+++ b/internal/agents/types.go
@@ -98,6 +98,27 @@ type AgentDefaults struct {
ContextFlushCommand string `yaml:"contextFlushCommand,omitempty" json:"contextFlushCommand,omitempty"`
}
+// StderrPatterns holds per-agent regex patterns applied by the stderr monitor.
+// Patterns are pure YAML strings (compiled once by internal/conversation). Kept
+// as a schema-only struct here to preserve the layering constraint that
+// internal/acpproc must not import internal/agents (mitto-k6h).
+//
+// Action classes:
+// - Crash: match → treat as inner-CLI crash, close the process-done channel
+// immediately (bypasses the SDK 60s control-request timeout).
+// - Ignore: match → suppress the agent's debug-level stderr log for that
+// write. Useful to silence known-benign noise (e.g., agent-specific
+// lifecycle chatter) without contaminating crash detection.
+// - Degraded: match → intended to feed the shared-process saturation signal
+// as a "degraded but not crashed" input. Plumbed end-to-end in this
+// increment but NOT yet consumed (mitto-k6h defers the behavioural wiring
+// to a follow-up).
+type StderrPatterns struct {
+ Crash []string `yaml:"crash,omitempty" json:"crash,omitempty"`
+ Ignore []string `yaml:"ignore,omitempty" json:"ignore,omitempty"`
+ Degraded []string `yaml:"degraded,omitempty" json:"degraded,omitempty"`
+}
+
// AgentMetadata holds the parsed content of a metadata.yaml file.
type AgentMetadata struct {
Name string `yaml:"name" json:"name"`
@@ -113,6 +134,10 @@ type AgentMetadata struct {
// agent-discovery time. When absent (nil) the ACP server is created with no
// pre-filled defaults, preserving existing behaviour.
Defaults *AgentDefaults `yaml:"defaults,omitempty" json:"defaults,omitempty"`
+ // StderrPatterns holds optional per-agent stderr regex patterns applied by
+ // the ACP process's stderr monitor (mitto-k6h). Absent (nil) means only the
+ // hardcoded baseline patterns apply.
+ StderrPatterns *StderrPatterns `yaml:"stderrPatterns,omitempty" json:"stderr_patterns,omitempty"`
}
// AgentDefinition represents a fully resolved agent definition with its
diff --git a/internal/conversation/background_session.go b/internal/conversation/background_session.go
index 5738887f2..e2d91113d 100644
--- a/internal/conversation/background_session.go
+++ b/internal/conversation/background_session.go
@@ -204,6 +204,7 @@ type BackgroundSession struct {
acpCommand string // Command used to start ACP process (for restart)
acpCwd string // Working directory for ACP process (for restart)
serverEnv map[string]string // Server-specific env vars from settings.json (for restart)
+ stderrPatterns *CompiledStderrPatterns // Per-agent stderr regex patterns (mitto-k6h); nil = baseline only
acpServerConstraints map[string]*config.ACPServerConstraint // Auto-selection constraints from the ACP server config
mittoConfig *config.Config // Full Mitto config; used for model-tag resolution (config.ResolveModelTags)
contextFlushCommand string // Agent-native context-flush command (e.g. "/clear"); empty = disabled
@@ -433,6 +434,11 @@ type BackgroundSessionConfig struct {
// SharedProcess is the shared ACP process for this workspace (nil = legacy per-session process).
SharedProcess SharedProcess
+ // StderrPatterns holds per-agent compiled stderr patterns (crash / ignore /
+ // degraded classes; mitto-k6h). Nil means only the hardcoded baseline
+ // applies. Compiled once by the web layer from agent metadata.yaml.
+ StderrPatterns *CompiledStderrPatterns
+
// PruneConfig is the pruning configuration for the session recorder.
// When set, the recorder automatically prunes old events after each recording
// to keep the session within the configured limits (max messages, max size).
@@ -590,6 +596,7 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro
acpCommand: cfg.ACPCommand, // Store for restart
acpCwd: cfg.ACPCwd, // Store for restart
serverEnv: cfg.Env, // Store for restart
+ stderrPatterns: cfg.StderrPatterns, // Per-agent stderr regex patterns (mitto-k6h)
globalMcpServer: cfg.GlobalMCPServer, // Global MCP server for session registration
auxiliaryManager: cfg.AuxiliaryManager, // Workspace-scoped auxiliary manager
availableACPServers: cfg.AvailableACPServers, // Pre-computed workspace server list
@@ -808,6 +815,7 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
acpCommand: config.ACPCommand, // Store for restart
acpCwd: config.ACPCwd, // Store for restart
serverEnv: config.Env, // Store for restart
+ stderrPatterns: config.StderrPatterns, // Per-agent stderr regex patterns (mitto-k6h)
globalMcpServer: config.GlobalMCPServer, // Global MCP server for session registration
auxiliaryManager: config.AuxiliaryManager, // Workspace-scoped auxiliary manager
availableACPServers: config.AvailableACPServers, // Pre-computed workspace server list
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index d10ffda08..716bb648b 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -331,6 +331,10 @@ type StderrCollector struct {
maxSize int
logger *slog.Logger
isClosed bool
+ // ignorePatterns, if non-nil, causes matching writes to be suppressed from
+ // the debug-level "agent stderr" log line. Crash detection is unaffected —
+ // crash matching happens in StartStderrMonitor, not here (mitto-k6h).
+ ignorePatterns []*regexp.Regexp
}
// NewStderrCollector creates a new stderr collector with the given max buffer size.
@@ -342,6 +346,15 @@ func NewStderrCollector(maxSize int, logger *slog.Logger) *StderrCollector {
}
}
+// SetIgnorePatterns replaces the collector's debug-log suppression patterns
+// (mitto-k6h). Safe to call before the monitor goroutine is started. Passing
+// nil clears the patterns.
+func (c *StderrCollector) SetIgnorePatterns(patterns []*regexp.Regexp) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.ignorePatterns = patterns
+}
+
// Write implements io.Writer to collect stderr output.
func (c *StderrCollector) Write(p []byte) (n int, err error) {
c.mu.Lock()
@@ -356,9 +369,13 @@ func (c *StderrCollector) Write(p []byte) (n int, err error) {
// don't support; their "Method not found" rejection written to stderr is expected
// and can be safely ignored. The SDK-level error log for this is already suppressed
// in logging.go; this suppresses the agent-side stderr counterpart.
+ //
+ // Per-agent ignore patterns (mitto-k6h) additionally suppress the debug log for
+ // any write matching one of the compiled regexes. Buffer capture is unaffected —
+ // error diagnostics still see the full tail.
if c.logger != nil && len(p) > 0 {
output := string(p)
- if !strings.Contains(output, "$/cancel_request") {
+ if !strings.Contains(output, "$/cancel_request") && !matchAnyRegex(c.ignorePatterns, output) {
c.logger.Debug("agent stderr", "output", output)
}
}
@@ -393,6 +410,9 @@ func (c *StderrCollector) Close() {
//
// Fix C: These patterns come from the claude-code-agent-sdk Rust layer which logs
// to stderr when the CLI subprocess dies unexpectedly.
+//
+// These are the hardcoded baseline. Per-agent metadata.yaml `stderrPatterns.crash`
+// entries are unioned with this list at process-start time (mitto-k6h).
var stderrCrashPatterns = []string{
"stream ended unexpectedly",
"EOF received from CLI stdout",
@@ -410,6 +430,91 @@ var stderrCrashPatterns = []string{
"Reached heap limit",
}
+// StderrPatternsSpec is the pure-data (schema) form of per-agent stderr patterns.
+// It intentionally mirrors internal/agents.StderrPatterns as plain string slices
+// so the conversation package can compile them without importing internal/agents
+// (internal/acpproc must not depend on internal/agents; mitto-k6h).
+type StderrPatternsSpec struct {
+ Crash []string
+ Ignore []string
+ Degraded []string
+}
+
+// CompiledStderrPatterns holds regex patterns compiled once from a
+// StderrPatternsSpec. All three fields are separately populated so callers can
+// wire each action class independently (mitto-k6h).
+//
+// Action-class semantics:
+// - Crash: OR'd with the hardcoded stderrCrashPatterns baseline; a match
+// triggers onCrashDetected (SDK-timeout bypass).
+// - Ignore: applied by StderrCollector.Write to suppress the debug-level
+// "agent stderr" log for matching writes. Buffer capture is unaffected.
+// - Degraded: plumbed end-to-end for schema completeness but NOT yet consumed
+// by the saturation signal (mitto-k6h defers the behavioural wiring to a
+// follow-up). Kept non-nil-empty-safe so tests can assert plumbing today.
+type CompiledStderrPatterns struct {
+ Crash []*regexp.Regexp
+ Ignore []*regexp.Regexp
+ Degraded []*regexp.Regexp
+}
+
+// CompileStderrPatterns compiles the plain-string patterns in spec into a
+// CompiledStderrPatterns. Invalid regexes are SKIPPED (logged as warnings via
+// logger when non-nil) rather than causing a fatal error — a single malformed
+// per-agent pattern must not prevent process start. Returns nil when spec is
+// empty (no patterns of any class) so hot paths can cheaply short-circuit
+// (mitto-k6h).
+func CompileStderrPatterns(spec StderrPatternsSpec, logger *slog.Logger) *CompiledStderrPatterns {
+ if len(spec.Crash) == 0 && len(spec.Ignore) == 0 && len(spec.Degraded) == 0 {
+ return nil
+ }
+ out := &CompiledStderrPatterns{}
+ out.Crash = compileRegexList(spec.Crash, "crash", logger)
+ out.Ignore = compileRegexList(spec.Ignore, "ignore", logger)
+ out.Degraded = compileRegexList(spec.Degraded, "degraded", logger)
+ return out
+}
+
+// compileRegexList compiles each pattern; invalid ones are skipped with a warn
+// log (never fatal). Empty input returns nil.
+func compileRegexList(patterns []string, class string, logger *slog.Logger) []*regexp.Regexp {
+ if len(patterns) == 0 {
+ return nil
+ }
+ compiled := make([]*regexp.Regexp, 0, len(patterns))
+ for _, raw := range patterns {
+ if raw == "" {
+ continue
+ }
+ re, err := regexp.Compile(raw)
+ if err != nil {
+ if logger != nil {
+ logger.Warn("skipping invalid stderr pattern",
+ "class", class,
+ "pattern", raw,
+ "error", err)
+ }
+ continue
+ }
+ compiled = append(compiled, re)
+ }
+ if len(compiled) == 0 {
+ return nil
+ }
+ return compiled
+}
+
+// matchAnyRegex returns true if any regex in patterns matches s. Nil/empty
+// slice always returns false.
+func matchAnyRegex(patterns []*regexp.Regexp, s string) bool {
+ for _, re := range patterns {
+ if re != nil && re.MatchString(s) {
+ return true
+ }
+ }
+ return false
+}
+
// mcpInitProgressPattern detects the "Waiting for N MCP server(s) to initialize"
// line the agent writes to stderr while it is blocking on MCP handshake. It is
// intentionally count-agnostic and case-insensitive so it survives minor phrasing
@@ -431,6 +536,15 @@ var mcpInitTimeoutPattern = regexp.MustCompile(`(?i)mcp initialization timed out
// is called (at most once) when the agent reports its MCP-init wait has timed out —
// callers use this to abort the pending session/new promptly with an actionable error
// (mitto-8ul.1). Neither MCP signal contributes to crash detection.
+//
+// perAgent, when non-nil, contributes per-agent regex patterns on top of the
+// hardcoded baseline (mitto-k6h):
+// - Crash regexes are OR'd with stderrCrashPatterns when matching for onCrashDetected.
+// - Ignore regexes are already applied by the collector (installed by the caller
+// via StderrCollector.SetIgnorePatterns before this monitor is started).
+// - Degraded regexes are scanned but their behavioural wiring is deferred; this
+// increment intentionally does not consume them (mitto-k6h). Kept in signature
+// so the follow-up can add the saturation-signal call without changing callers.
func StartStderrMonitor(
stderr runner.ReadCloser,
collector *StderrCollector,
@@ -438,6 +552,7 @@ func StartStderrMonitor(
onFirstActivity func(),
onMCPInitProgress func(),
onMCPInitTimeout func(),
+ perAgent *CompiledStderrPatterns,
) {
go func() {
crashSignaled := false
@@ -460,6 +575,9 @@ func StartStderrMonitor(
// Fix C: Check for crash patterns in stderr output.
// This detects inner CLI subprocess death immediately from SDK
// stderr messages, bypassing the 60s control request timeout.
+ //
+ // Per-agent crash regexes (mitto-k6h) are OR'd with the baseline —
+ // either source firing counts as a crash.
if !crashSignaled && onCrashDetected != nil {
chunkStr = string(buf[:n])
for _, pattern := range stderrCrashPatterns {
@@ -469,8 +587,19 @@ func StartStderrMonitor(
break
}
}
+ if !crashSignaled && perAgent != nil && matchAnyRegex(perAgent.Crash, chunkStr) {
+ crashSignaled = true
+ onCrashDetected()
+ }
}
+ // Degraded regexes: scanned for future use (mitto-k6h). This block
+ // exists so tests can assert that a Degraded pattern is compiled and
+ // reachable end-to-end, but it INTENTIONALLY does not fire any
+ // callback in this increment. Behavioural wiring (feeding the
+ // saturation signal on the shared process) is deferred to a follow-up.
+ _ = perAgent // keep referenced so linters don't flag the deferred path
+
// MCP-init lifecycle signals (mitto-8ul.1): tolerant regex matches
// so the exact phrasing/count in the agent's log line is not load-bearing.
if (onMCPInitProgress != nil && !mcpProgressSignaled) ||
@@ -862,6 +991,11 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
// Create stderr collector to capture output for error reporting
// Keep last 8KB of stderr output
StderrCollector := NewStderrCollector(8192, bs.logger)
+ // Install per-agent ignore patterns (mitto-k6h) so matching writes are
+ // suppressed from the debug-level stderr log. Nil is a safe no-op.
+ if bs.stderrPatterns != nil {
+ StderrCollector.SetIgnorePatterns(bs.stderrPatterns.Ignore)
+ }
// Pre-create the process death detection channel so the stderr monitor
// (started below) can signal crash detection immediately.
@@ -917,7 +1051,7 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
// BackgroundSession's own ACP process (non-shared path) does not multiplex sessions,
// so the MCP-init callbacks are unused here — the extended-budget policy lives on
// SharedACPProcess where MCP servers are actually attached (mitto-8ul.1).
- StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil)
+ StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, bs.stderrPatterns)
// Store wait function for cleanup
// We'll call it in Close() method
@@ -970,7 +1104,7 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
// Monitor stderr in background (same as runner case, with crash detection for Fix C
// and watchdog wake-up on first stderr activity). MCP-init callbacks are unused on
// the non-shared BackgroundSession path (mitto-8ul.1).
- StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil)
+ StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, bs.stderrPatterns)
bs.acpCmd = cmd
diff --git a/internal/conversation/bgsession_acp_process_test.go b/internal/conversation/bgsession_acp_process_test.go
index 7804a81c1..605590f1a 100644
--- a/internal/conversation/bgsession_acp_process_test.go
+++ b/internal/conversation/bgsession_acp_process_test.go
@@ -22,7 +22,7 @@ func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
chunk := "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
go func() {
@@ -50,7 +50,7 @@ func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
onProgress := func() { progressCalls++ }
onTimeout := func() { t.Fatal("MCP timeout should not fire on progress lines") }
- StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout)
+ StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout, nil)
go func() {
// Two lines to prove the callback still fires only once.
@@ -76,7 +76,7 @@ func TestStartStderrMonitor_MCPInitTimeout_TriggersCallback(t *testing.T) {
timeoutCalls := 0
onTimeout := func() { timeoutCalls++ }
- StartStderrMonitor(pr, collector, nil, nil, nil, onTimeout)
+ StartStderrMonitor(pr, collector, nil, nil, nil, onTimeout, nil)
go func() {
_, _ = pw.Write([]byte("MCP initialization timed out after 225s\n"))
@@ -99,7 +99,7 @@ func TestStartStderrMonitor_MCPPatternsDoNotTriggerCrash(t *testing.T) {
crashDetected := make(chan struct{}, 1)
onCrashDetected := func() { crashDetected <- struct{}{} }
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
go func() {
_, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize\n"))
@@ -129,7 +129,7 @@ func TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection(t *testing
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
go func() {
_, _ = pw.Write([]byte("some normal debug output\n"))
diff --git a/internal/conversation/session_manager.go b/internal/conversation/session_manager.go
index 31250f9f0..b6db1eb03 100644
--- a/internal/conversation/session_manager.go
+++ b/internal/conversation/session_manager.go
@@ -164,6 +164,13 @@ type SessionManager struct {
// Passed to BackgroundSession via BackgroundSessionConfig on creation/resume.
promptParametersResolver func(name, workingDir string) []config.PromptParameter
+ // stderrPatternsResolver returns per-agent compiled stderr regex patterns for
+ // a given ACP server name (mitto-k6h). Passed to BackgroundSession via
+ // BackgroundSessionConfig on creation/resume so legacy per-session ACP
+ // processes see the same per-agent patterns as shared processes. Nil means
+ // only the hardcoded baseline applies.
+ stderrPatternsResolver func(acpServer string) *CompiledStderrPatterns
+
// onConversationIdle is invoked when a session's agent stops and the session is
// idle. Wired to the loop runner to drive event-driven on-completion firing.
onConversationIdle func(sessionID string)
@@ -703,6 +710,28 @@ func (sm *SessionManager) SetPreferredModelsResolver(resolver func(name, working
sm.preferredModelsResolver = resolver
}
+// SetStderrPatternsResolver sets the function used to resolve per-agent compiled
+// stderr regex patterns for a given ACP server name (mitto-k6h). The resolver is
+// passed to every new and resumed BackgroundSession via BackgroundSessionConfig.
+func (sm *SessionManager) SetStderrPatternsResolver(resolver func(acpServer string) *CompiledStderrPatterns) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.stderrPatternsResolver = resolver
+}
+
+// resolveStderrPatterns looks up compiled per-agent stderr regex patterns for
+// the given ACP server name (mitto-k6h). Returns nil if no resolver is set or
+// the resolver returned nil (baseline patterns only).
+func (sm *SessionManager) resolveStderrPatterns(acpServer string) *CompiledStderrPatterns {
+ sm.mu.RLock()
+ r := sm.stderrPatternsResolver
+ sm.mu.RUnlock()
+ if r == nil {
+ return nil
+ }
+ return r(acpServer)
+}
+
// SetPromptParametersResolver sets the function used to resolve a prompt name to its declared parameter list.
// The resolver is passed to every new and resumed BackgroundSession via BackgroundSessionConfig.
func (sm *SessionManager) SetPromptParametersResolver(resolver func(name, workingDir string) []config.PromptParameter) {
@@ -1395,6 +1424,7 @@ func (sm *SessionManager) CreateSessionWithWorkspace(ctx context.Context, name,
PromptResolver: sm.promptResolver, // Named prompt resolver (resolves prompt name → text)
PreferredModelsResolver: sm.preferredModelsResolver, // Named prompt resolver (resolves prompt name → preferredModels)
PromptParametersResolver: sm.promptParametersResolver, // Named prompt resolver (resolves prompt name → parameters)
+ StderrPatterns: sm.resolveStderrPatterns(acpServer),
OnTurnIdle: func(sessionID string) {
sm.mu.RLock()
cb := sm.onConversationIdle
@@ -2035,6 +2065,7 @@ func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, wo
PromptResolver: sm.promptResolver, // Named prompt resolver (resolves prompt name → text)
PreferredModelsResolver: sm.preferredModelsResolver, // Named prompt resolver (resolves prompt name → preferredModels)
PromptParametersResolver: sm.promptParametersResolver, // Named prompt resolver (resolves prompt name → parameters)
+ StderrPatterns: sm.resolveStderrPatterns(acpServer),
OnTurnIdle: func(sessionID string) {
sm.mu.RLock()
cb := sm.onConversationIdle
diff --git a/internal/conversation/stderr_patterns_test.go b/internal/conversation/stderr_patterns_test.go
new file mode 100644
index 000000000..b2987b62b
--- /dev/null
+++ b/internal/conversation/stderr_patterns_test.go
@@ -0,0 +1,204 @@
+package conversation
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestCompileStderrPatterns_EmptySpecReturnsNil verifies the compile helper
+// returns nil for an empty spec so hot paths can cheaply short-circuit
+// (mitto-k6h).
+func TestCompileStderrPatterns_EmptySpecReturnsNil(t *testing.T) {
+ if got := CompileStderrPatterns(StderrPatternsSpec{}, nil); got != nil {
+ t.Fatalf("expected nil for empty spec, got %+v", got)
+ }
+}
+
+// TestCompileStderrPatterns_InvalidPatternSkippedWithWarn verifies that an
+// invalid regex is dropped (never fatal) and a warn line is emitted.
+func TestCompileStderrPatterns_InvalidPatternSkippedWithWarn(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ spec := StderrPatternsSpec{
+ Crash: []string{
+ `valid pattern`,
+ `(broken`, // unclosed group
+ `another valid`,
+ },
+ }
+ got := CompileStderrPatterns(spec, logger)
+ if got == nil {
+ t.Fatal("expected non-nil compiled patterns")
+ }
+ if len(got.Crash) != 2 {
+ t.Fatalf("expected 2 valid crash patterns, got %d", len(got.Crash))
+ }
+ if !strings.Contains(buf.String(), "skipping invalid stderr pattern") {
+ t.Fatalf("expected warn log for invalid pattern, got: %s", buf.String())
+ }
+ if !strings.Contains(buf.String(), "class=crash") {
+ t.Fatalf("expected warn log to include class=crash, got: %s", buf.String())
+ }
+}
+
+// TestCompileStderrPatterns_AllClassesCompiled verifies each action class is
+// separately populated so downstream wiring can pick them up independently.
+func TestCompileStderrPatterns_AllClassesCompiled(t *testing.T) {
+ spec := StderrPatternsSpec{
+ Crash: []string{`crash-pat`},
+ Ignore: []string{`ignore-pat`},
+ Degraded: []string{`degraded-pat`},
+ }
+ got := CompileStderrPatterns(spec, nil)
+ if got == nil {
+ t.Fatal("expected non-nil compiled patterns")
+ }
+ if len(got.Crash) != 1 || len(got.Ignore) != 1 || len(got.Degraded) != 1 {
+ t.Fatalf("expected 1 pattern per class, got crash=%d ignore=%d degraded=%d",
+ len(got.Crash), len(got.Ignore), len(got.Degraded))
+ }
+ // Sanity-check the Degraded regex is compiled and matches — even though its
+ // behavioural wiring is deferred, callers can inspect it end-to-end.
+ if !got.Degraded[0].MatchString("agent hit degraded-pat threshold") {
+ t.Fatal("degraded regex compiled but does not match expected input")
+ }
+}
+
+// TestStartStderrMonitor_PerAgentCrashPatternTriggersCallback verifies a
+// per-agent Crash regex fires onCrashDetected even when the chunk does NOT
+// match the hardcoded baseline (mitto-k6h).
+func TestStartStderrMonitor_PerAgentCrashPatternTriggersCallback(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() {
+ select {
+ case crashDetected <- struct{}{}:
+ default:
+ }
+ }
+
+ perAgent := CompileStderrPatterns(StderrPatternsSpec{
+ Crash: []string{`(?i)per-agent-only fatal`},
+ }, nil)
+ if perAgent == nil {
+ t.Fatal("expected non-nil compiled patterns")
+ }
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+
+ go func() {
+ _, _ = pw.Write([]byte("Per-Agent-Only Fatal encountered\n"))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ // expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected onCrashDetected for per-agent crash pattern")
+ }
+}
+
+// TestStartStderrMonitor_BaselineStillFiresWithPerAgent verifies that when
+// per-agent patterns are supplied, the hardcoded baseline is NOT replaced.
+func TestStartStderrMonitor_BaselineStillFiresWithPerAgent(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() {
+ select {
+ case crashDetected <- struct{}{}:
+ default:
+ }
+ }
+
+ // Per-agent patterns that WILL NOT match — the baseline "broken pipe"
+ // must still fire the callback.
+ perAgent := CompileStderrPatterns(StderrPatternsSpec{
+ Crash: []string{`per-agent-only-never-matches-baseline-input`},
+ }, nil)
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+
+ go func() {
+ _, _ = pw.Write([]byte("error: broken pipe on write\n"))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ // expected: baseline fired
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected baseline crash pattern to still fire alongside per-agent patterns")
+ }
+}
+
+// TestStderrCollector_IgnorePatternsSuppressDebugLog verifies that ignore
+// regexes suppress the debug-level "agent stderr" log but do NOT affect the
+// captured buffer (still available for error reporting).
+func TestStderrCollector_IgnorePatternsSuppressDebugLog(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ collector := NewStderrCollector(8192, logger)
+ perAgent := CompileStderrPatterns(StderrPatternsSpec{
+ Ignore: []string{`(?i)deprecationwarning`},
+ }, nil)
+ collector.SetIgnorePatterns(perAgent.Ignore)
+
+ // Suppressed line.
+ _, _ = collector.Write([]byte("(node:1234) DeprecationWarning: something\n"))
+ // Non-suppressed line.
+ _, _ = collector.Write([]byte("some other output\n"))
+
+ logs := buf.String()
+ if strings.Contains(logs, "DeprecationWarning") {
+ t.Errorf("expected DeprecationWarning to be suppressed from debug log; got: %s", logs)
+ }
+ if !strings.Contains(logs, "some other output") {
+ t.Errorf("expected non-matching write to still be logged; got: %s", logs)
+ }
+ // Buffer capture is unaffected — both writes present.
+ captured := collector.GetOutput()
+ if !strings.Contains(captured, "DeprecationWarning") || !strings.Contains(captured, "some other output") {
+ t.Errorf("expected buffer to still capture both writes; got: %q", captured)
+ }
+}
+
+// TestStartStderrMonitor_DegradedPatternDoesNotFireInIncrement documents the
+// deferred behaviour: per-agent Degraded patterns are compiled and plumbed but
+// intentionally not consumed in this increment (mitto-k6h). Reaching this test
+// after the follow-up increment lands is a signal to update it.
+func TestStartStderrMonitor_DegradedPatternDoesNotFireInIncrement(t *testing.T) {
+ pr, pw := io.Pipe()
+ collector := NewStderrCollector(8192, nil)
+
+ crashDetected := make(chan struct{}, 1)
+ onCrashDetected := func() { crashDetected <- struct{}{} }
+
+ perAgent := CompileStderrPatterns(StderrPatternsSpec{
+ Degraded: []string{`(?i)rate limit`},
+ }, nil)
+
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+
+ go func() {
+ _, _ = pw.Write([]byte("hit rate limit, backing off\n"))
+ _ = pw.Close()
+ }()
+
+ select {
+ case <-crashDetected:
+ t.Fatal("Degraded patterns must not trigger crash detection in this increment")
+ case <-time.After(300 * time.Millisecond):
+ // expected: no fire, patterns plumbed but deferred
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index c93d54ba2..7a7c96159 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -610,6 +610,44 @@ func NewServer(config Config) (*Server, error) {
}
return out.Servers, nil
}
+
+ // Wire per-agent stderr patterns resolver (mitto-k6h). Given an ACP server
+ // name it resolves the ACP server → agent metadata → StderrPatterns and
+ // compiles them once. Results are cached per ACP server name so
+ // GetOrCreateProcess does not re-parse metadata.yaml on every call. The
+ // hardcoded stderrCrashPatterns baseline in internal/conversation still
+ // applies unconditionally — this only adds per-agent extensions.
+ stderrCache := newStderrPatternsCache()
+ compileFor := func(acpServer string) *conversation.CompiledStderrPatterns {
+ if acpServer == "" {
+ return nil
+ }
+ if cached, ok := stderrCache.get(acpServer); ok {
+ return cached
+ }
+ acpType := ""
+ if config.MittoConfig != nil {
+ acpType = config.MittoConfig.GetServerType(acpServer)
+ }
+ if acpType == "" {
+ acpType = acpServer
+ }
+ agent, gerr := agentMgr.GetAgentByACPId(acpType)
+ if gerr != nil || agent == nil || agent.Metadata.StderrPatterns == nil {
+ stderrCache.put(acpServer, nil)
+ return nil
+ }
+ spec := conversation.StderrPatternsSpec{
+ Crash: agent.Metadata.StderrPatterns.Crash,
+ Ignore: agent.Metadata.StderrPatterns.Ignore,
+ Degraded: agent.Metadata.StderrPatterns.Degraded,
+ }
+ compiled := conversation.CompileStderrPatterns(spec, logger)
+ stderrCache.put(acpServer, compiled)
+ return compiled
+ }
+ acpProcessMgr.StderrPatternsResolver = compileFor
+ sessionMgr.SetStderrPatternsResolver(compileFor)
} else {
logger.Warn("stdio MCP discovery disabled: cannot resolve agents dir", "error", aerr)
}
diff --git a/internal/web/stderr_patterns_cache.go b/internal/web/stderr_patterns_cache.go
new file mode 100644
index 000000000..f8ee3313f
--- /dev/null
+++ b/internal/web/stderr_patterns_cache.go
@@ -0,0 +1,48 @@
+package web
+
+import (
+ "sync"
+
+ "github.com/inercia/mitto/internal/conversation"
+)
+
+// stderrPatternsCache is a simple concurrent cache keyed by ACP server name that
+// memoizes the compiled per-agent stderr regex patterns for that server (mitto-k6h).
+// The cache stores nil values explicitly (via the ok bool from get) so a
+// negative lookup — "this server has no per-agent patterns" — is not re-resolved
+// against agent metadata on every GetOrCreateProcess call. Entries are compiled
+// once at first request and reused thereafter.
+//
+// Invalidation is intentionally NOT provided: agent metadata changes require a
+// server restart to pick up new stderr patterns, matching the existing
+// discovery-time lifecycle for AgentDefaults.
+type stderrPatternsCache struct {
+ mu sync.RWMutex
+ entries map[string]*conversation.CompiledStderrPatterns
+ // present tracks negative lookups so callers can distinguish "not cached"
+ // from "cached-as-nil" (the latter is a valid, terminal result).
+ present map[string]bool
+}
+
+func newStderrPatternsCache() *stderrPatternsCache {
+ return &stderrPatternsCache{
+ entries: make(map[string]*conversation.CompiledStderrPatterns),
+ present: make(map[string]bool),
+ }
+}
+
+func (c *stderrPatternsCache) get(key string) (*conversation.CompiledStderrPatterns, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if !c.present[key] {
+ return nil, false
+ }
+ return c.entries[key], true
+}
+
+func (c *stderrPatternsCache) put(key string, val *conversation.CompiledStderrPatterns) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.entries[key] = val
+ c.present[key] = true
+}
From dc3f1e3dace7aa71fe0a9e1506116c84cbfcd1f5 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Mon, 6 Jul 2026 21:45:00 +0200
Subject: [PATCH 019/240] feat(acpproc): wire degraded stderr pattern into
rolling-window saturation signal (mitto-k6h)
---
internal/acpproc/saturation_rate_test.go | 26 ++++++++++
internal/acpproc/shared_acp_process.go | 32 +++++++++++-
.../conversation/bgsession_acp_process.go | 50 +++++++++++++------
.../bgsession_acp_process_test.go | 10 ++--
internal/conversation/stderr_patterns_test.go | 48 +++++++++++++-----
5 files changed, 133 insertions(+), 33 deletions(-)
diff --git a/internal/acpproc/saturation_rate_test.go b/internal/acpproc/saturation_rate_test.go
index ec168d85e..5c9abc00d 100644
--- a/internal/acpproc/saturation_rate_test.go
+++ b/internal/acpproc/saturation_rate_test.go
@@ -166,3 +166,29 @@ func TestSaturationRate_SuccessDoesNotWipeWindow(t *testing.T) {
t.Fatalf("expected re-trip on next timeout because rolling window was not wiped; level=%d", proc.SaturationLevel())
}
}
+
+// TestRecordDegradedStderr_ContributesToSaturation verifies the mitto-k6h
+// increment-2 wiring: recordDegradedStderr() calls contribute fail-side samples
+// to the same rolling window used by recordRPCTimeout / recordRPCBudgetBail, so
+// enough per-agent Degraded stderr matches can trip IsSaturated() on their own
+// — no RPC deadline required. Also proves recordDegradedStderr does NOT touch
+// the consecutive fast path (stderr degradation is not a wedged-RPC signal).
+func TestRecordDegradedStderr_ContributesToSaturation(t *testing.T) {
+ proc := newTestSharedProcess()
+
+ for i := 0; i < saturationWindowMinSamples; i++ {
+ proc.recordDegradedStderr()
+ }
+
+ if !proc.IsSaturated() {
+ t.Fatalf("expected IsSaturated()=true after %d degraded-stderr samples; level=%d", saturationWindowMinSamples, proc.SaturationLevel())
+ }
+ // Consecutive-timeout fast path must be untouched — stderr degradation is
+ // a rolling-window contributor only, matching recordRPCBudgetBail semantics.
+ proc.saturationMu.Lock()
+ consec := proc.consecutiveRPCTimeouts
+ proc.saturationMu.Unlock()
+ if consec != 0 {
+ t.Fatalf("recordDegradedStderr must not touch consecutiveRPCTimeouts; got %d", consec)
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index d949b13a7..edb2e1a9b 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -532,6 +532,19 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
})
}
+ // onDegraded is invoked on each stderr chunk matching a per-agent Degraded
+ // regex (mitto-k6h). It feeds a fail-side sample into the mitto-5eq rolling
+ // window so stderr-observed degradation can promote the process to saturated
+ // and let GC Tier 5/6 recycle it. Unlike onCrashDetected, this is NOT latched
+ // — recurring degraded output keeps contributing samples.
+ onDegraded := func() {
+ if p.logger != nil {
+ p.logger.Warn("ACP subprocess degraded via stderr pattern (feeding saturation)",
+ "acp_server", p.config.ACPServer)
+ }
+ p.recordDegradedStderr()
+ }
+
// MCP-init lifecycle callbacks (mitto-8ul.1). Both are fired at most once per
// process lifetime by the stderr monitor. The pending NewSession call watches
// mcpInitTimeoutCh so it can abort promptly on a hard timeout signal instead of
@@ -613,7 +626,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, -1)
- conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, p.config.StderrPatterns)
+ conversation.StartStderrMonitor(stderr, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, onDegraded, p.config.StderrPatterns)
} else {
cmd = exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -674,7 +687,7 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
}
signalStartupActivity = conversation.StartACPStartupWatchdog(watchdogCtx, p.logger, acpCommand, p.config.ACPServer, pid)
- conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, p.config.StderrPatterns)
+ conversation.StartStderrMonitor(stderrPipe, stderrCollector, onCrashDetected, signalStartupActivity, onMCPInitProgress, onMCPInitTimeout, onDegraded, p.config.StderrPatterns)
wait = func() error {
return cmd.Wait()
@@ -1056,6 +1069,21 @@ func (p *SharedACPProcess) recordRPCBudgetBail() {
p.evaluateSaturationRateTriggerLocked(now)
}
+// recordDegradedStderr records a per-agent "degraded" stderr pattern match
+// (mitto-k6h) as a fail-side sample in the mitto-5eq rolling window. A degraded
+// stderr line is real degradation evidence but is NOT an RPC deadline, so it does
+// NOT touch the consecutive-timeout fast path (reserved for the fully-wedged case).
+// It feeds the SAME rolling-window rate trigger as recordRPCBudgetBail, so frequent
+// degraded output — alone or combined with real RPC timeouts/bails — can promote the
+// process to saturated and let GC Tier 5/6 recycle it.
+func (p *SharedACPProcess) recordDegradedStderr() {
+ p.saturationMu.Lock()
+ defer p.saturationMu.Unlock()
+ now := time.Now()
+ p.saturationCurrentBucketLocked(now).timeouts++
+ p.evaluateSaturationRateTriggerLocked(now)
+}
+
// recordRPCSuccess clears the consecutive-timeout saturation tracking after a
// successful NewSession/LoadSession RPC (mitto-13ck.2). Resets the saturation
// level so the next event starts again from the base cooldown (30s).
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index 716bb648b..50de7f4e5 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -449,9 +449,12 @@ type StderrPatternsSpec struct {
// triggers onCrashDetected (SDK-timeout bypass).
// - Ignore: applied by StderrCollector.Write to suppress the debug-level
// "agent stderr" log for matching writes. Buffer capture is unaffected.
-// - Degraded: plumbed end-to-end for schema completeness but NOT yet consumed
-// by the saturation signal (mitto-k6h defers the behavioural wiring to a
-// follow-up). Kept non-nil-empty-safe so tests can assert plumbing today.
+// - Degraded: fires onDegraded when a stderr chunk matches. onDegraded on the
+// shared process feeds a fail-side sample into the mitto-5eq rolling-window
+// saturation counter — frequent degraded output (alone or combined with real
+// RPC timeouts/bails) can promote the process to saturated and let GC
+// Tier 5/6 recycle it. NOT latched: a degraded line can recur and each
+// matching chunk fires once (mitto-k6h).
type CompiledStderrPatterns struct {
Crash []*regexp.Regexp
Ignore []*regexp.Regexp
@@ -537,14 +540,20 @@ var mcpInitTimeoutPattern = regexp.MustCompile(`(?i)mcp initialization timed out
// callers use this to abort the pending session/new promptly with an actionable error
// (mitto-8ul.1). Neither MCP signal contributes to crash detection.
//
+// If onDegraded is non-nil, it is called every time a stderr chunk matches a
+// per-agent Degraded regex (mitto-k6h). Unlike onCrashDetected, onDegraded is
+// NOT latched — a degraded line can recur and each matching chunk fires once.
+// Callers on the shared process feed this into the mitto-5eq rolling-window
+// saturation counter so stderr-observed degradation contributes to Tier 5/6
+// recycle decisions alongside real RPC timeouts/bails.
+//
// perAgent, when non-nil, contributes per-agent regex patterns on top of the
// hardcoded baseline (mitto-k6h):
// - Crash regexes are OR'd with stderrCrashPatterns when matching for onCrashDetected.
// - Ignore regexes are already applied by the collector (installed by the caller
// via StderrCollector.SetIgnorePatterns before this monitor is started).
-// - Degraded regexes are scanned but their behavioural wiring is deferred; this
-// increment intentionally does not consume them (mitto-k6h). Kept in signature
-// so the follow-up can add the saturation-signal call without changing callers.
+// - Degraded regexes fire onDegraded (see above) — they feed the shared-process
+// saturation signal, not crash detection.
func StartStderrMonitor(
stderr runner.ReadCloser,
collector *StderrCollector,
@@ -552,6 +561,7 @@ func StartStderrMonitor(
onFirstActivity func(),
onMCPInitProgress func(),
onMCPInitTimeout func(),
+ onDegraded func(),
perAgent *CompiledStderrPatterns,
) {
go func() {
@@ -593,12 +603,18 @@ func StartStderrMonitor(
}
}
- // Degraded regexes: scanned for future use (mitto-k6h). This block
- // exists so tests can assert that a Degraded pattern is compiled and
- // reachable end-to-end, but it INTENTIONALLY does not fire any
- // callback in this increment. Behavioural wiring (feeding the
- // saturation signal on the shared process) is deferred to a follow-up.
- _ = perAgent // keep referenced so linters don't flag the deferred path
+ // Degraded regexes: fire onDegraded on each matching chunk (mitto-k6h).
+ // Unlike crash detection, there is NO one-shot latch — a degraded
+ // line can recur and each match should contribute another sample
+ // to the shared-process rolling-window saturation counter.
+ if onDegraded != nil && perAgent != nil && len(perAgent.Degraded) > 0 {
+ if chunkStr == "" {
+ chunkStr = string(buf[:n])
+ }
+ if matchAnyRegex(perAgent.Degraded, chunkStr) {
+ onDegraded()
+ }
+ }
// MCP-init lifecycle signals (mitto-8ul.1): tolerant regex matches
// so the exact phrasing/count in the agent's log line is not load-bearing.
@@ -1051,7 +1067,11 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
// BackgroundSession's own ACP process (non-shared path) does not multiplex sessions,
// so the MCP-init callbacks are unused here — the extended-budget policy lives on
// SharedACPProcess where MCP servers are actually attached (mitto-8ul.1).
- StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, bs.stderrPatterns)
+ // Per-session (non-shared) path: no saturation counter on this side,
+ // so onDegraded is nil — degraded stderr chunks are captured in the buffer
+ // but do not feed a rolling-window signal (there is no shared process to
+ // promote/recycle here).
+ StartStderrMonitor(stderr, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, nil, bs.stderrPatterns)
// Store wait function for cleanup
// We'll call it in Close() method
@@ -1104,7 +1124,9 @@ func (bs *BackgroundSession) doStartACPProcess(acpCommand, acpCwd, workingDir, a
// Monitor stderr in background (same as runner case, with crash detection for Fix C
// and watchdog wake-up on first stderr activity). MCP-init callbacks are unused on
// the non-shared BackgroundSession path (mitto-8ul.1).
- StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, bs.stderrPatterns)
+ // See rationale at the sibling StartStderrMonitor callsite in the shared
+ // process fallback above: onDegraded is nil on the non-shared path.
+ StartStderrMonitor(stderrPipe, StderrCollector, onCrashDetected, signalStartupActivity, nil, nil, nil, bs.stderrPatterns)
bs.acpCmd = cmd
diff --git a/internal/conversation/bgsession_acp_process_test.go b/internal/conversation/bgsession_acp_process_test.go
index 605590f1a..a632e6219 100644
--- a/internal/conversation/bgsession_acp_process_test.go
+++ b/internal/conversation/bgsession_acp_process_test.go
@@ -22,7 +22,7 @@ func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil, nil)
chunk := "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
go func() {
@@ -50,7 +50,7 @@ func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
onProgress := func() { progressCalls++ }
onTimeout := func() { t.Fatal("MCP timeout should not fire on progress lines") }
- StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout, nil)
+ StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout, nil, nil)
go func() {
// Two lines to prove the callback still fires only once.
@@ -76,7 +76,7 @@ func TestStartStderrMonitor_MCPInitTimeout_TriggersCallback(t *testing.T) {
timeoutCalls := 0
onTimeout := func() { timeoutCalls++ }
- StartStderrMonitor(pr, collector, nil, nil, nil, onTimeout, nil)
+ StartStderrMonitor(pr, collector, nil, nil, nil, onTimeout, nil, nil)
go func() {
_, _ = pw.Write([]byte("MCP initialization timed out after 225s\n"))
@@ -99,7 +99,7 @@ func TestStartStderrMonitor_MCPPatternsDoNotTriggerCrash(t *testing.T) {
crashDetected := make(chan struct{}, 1)
onCrashDetected := func() { crashDetected <- struct{}{} }
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil, nil)
go func() {
_, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize\n"))
@@ -129,7 +129,7 @@ func TestStartStderrMonitor_NormalOutput_DoesNotTriggerCrashDetection(t *testing
}
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil, nil)
go func() {
_, _ = pw.Write([]byte("some normal debug output\n"))
diff --git a/internal/conversation/stderr_patterns_test.go b/internal/conversation/stderr_patterns_test.go
index b2987b62b..801d5cdca 100644
--- a/internal/conversation/stderr_patterns_test.go
+++ b/internal/conversation/stderr_patterns_test.go
@@ -91,7 +91,7 @@ func TestStartStderrMonitor_PerAgentCrashPatternTriggersCallback(t *testing.T) {
t.Fatal("expected non-nil compiled patterns")
}
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil, perAgent)
go func() {
_, _ = pw.Write([]byte("Per-Agent-Only Fatal encountered\n"))
@@ -126,7 +126,7 @@ func TestStartStderrMonitor_BaselineStillFiresWithPerAgent(t *testing.T) {
Crash: []string{`per-agent-only-never-matches-baseline-input`},
}, nil)
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, nil, perAgent)
go func() {
_, _ = pw.Write([]byte("error: broken pipe on write\n"))
@@ -173,32 +173,56 @@ func TestStderrCollector_IgnorePatternsSuppressDebugLog(t *testing.T) {
}
}
-// TestStartStderrMonitor_DegradedPatternDoesNotFireInIncrement documents the
-// deferred behaviour: per-agent Degraded patterns are compiled and plumbed but
-// intentionally not consumed in this increment (mitto-k6h). Reaching this test
-// after the follow-up increment lands is a signal to update it.
-func TestStartStderrMonitor_DegradedPatternDoesNotFireInIncrement(t *testing.T) {
+// TestStartStderrMonitor_DegradedPatternFiresOnDegradedCallback verifies the
+// mitto-k6h increment-2 wiring: a per-agent Degraded regex fires onDegraded
+// (feeding the shared-process saturation signal) and does NOT trigger the
+// crash callback. Degraded is a saturation contributor, not a crash source.
+func TestStartStderrMonitor_DegradedPatternFiresOnDegradedCallback(t *testing.T) {
pr, pw := io.Pipe()
collector := NewStderrCollector(8192, nil)
crashDetected := make(chan struct{}, 1)
- onCrashDetected := func() { crashDetected <- struct{}{} }
+ onCrashDetected := func() {
+ select {
+ case crashDetected <- struct{}{}:
+ default:
+ }
+ }
+
+ degradedDetected := make(chan struct{}, 1)
+ onDegraded := func() {
+ select {
+ case degradedDetected <- struct{}{}:
+ default:
+ }
+ }
perAgent := CompileStderrPatterns(StderrPatternsSpec{
Degraded: []string{`(?i)rate limit`},
}, nil)
+ if perAgent == nil {
+ t.Fatal("expected non-nil compiled patterns")
+ }
- StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, perAgent)
+ StartStderrMonitor(pr, collector, onCrashDetected, nil, nil, nil, onDegraded, perAgent)
go func() {
_, _ = pw.Write([]byte("hit rate limit, backing off\n"))
_ = pw.Close()
}()
+ select {
+ case <-degradedDetected:
+ // expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected onDegraded to be invoked for per-agent Degraded pattern")
+ }
+
+ // Crash must NOT fire for a Degraded-only match.
select {
case <-crashDetected:
- t.Fatal("Degraded patterns must not trigger crash detection in this increment")
- case <-time.After(300 * time.Millisecond):
- // expected: no fire, patterns plumbed but deferred
+ t.Fatal("Degraded patterns must not trigger crash detection")
+ case <-time.After(100 * time.Millisecond):
+ // expected: crash callback stays quiet
}
}
From 067bbb33291e21c32812289bd4ab279740178efa Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 08:51:52 +0200
Subject: [PATCH 020/240] =?UTF-8?q?feat(acpproc):=20adaptive=20ACP/MCP=20p?=
=?UTF-8?q?re-warming=20=E2=80=94=20warm,=20probe=20health,=20pin=20only?=
=?UTF-8?q?=20slow=20workspaces=20(mitto-mw0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace eager pinning of all workspaces with a self-selecting model: warm a
keepalive session, probe its health (session/new latency + MCP readiness), and
pin only workspaces that are slow or broken. Healthy workspaces cost nothing —
GC reaps them normally.
Config:
- Add PrewarmConfig at Config.Prewarm with thresholds session_new_fast=10s,
mcp_ready=10s, healthy_probes_to_unpin=3, max_pin_duration=30m,
max_pinned_workspaces=5 (yaml + nil-safe parse accessors).
Pin state:
- Add Pinned/PinReason/PinExpiry to SessionInfo.
- Pin state machine on ACPProcessManager: PinWorkspace, UnpinWorkspace,
IsPinned, RecordPrewarmProbeResult, ExpirePinsAndAlert, FirePrewarmPinAlert.
Probe + controller:
- probePrewarmHealth times the PurposeKeepAlive session/new and reads
MCPInitDone/MCPInitTimedOut to compute a health verdict with reason codes
(session_new_failed, mcp_timeout, mcp_not_ready, slow_session_new).
- Hysteresis: unpin only after N consecutive healthy probes.
- MaxPinDuration cap folded into EnsureMCPBackoffRetry via PrewarmPinReevaluator
(no extra timer).
GC:
- Tier-1 (recycle) and Tier-2 (shutdown) pin exemptions, expiry-aware so the
max-pin-duration cap self-heals a stuck pin.
Observability:
- SetOnPrewarmPinAlert -> BroadcastPrewarmPinAlert (prewarm_pin_alert WS message)
fired at-most-once per pin for MCP-timeout pins and on cap expiry.
Tests: 8 prewarm_pin tests + 3 GC pin tests + 2 config tests, all green.
Follow-up: mitto-yns (frontend toast handler for prewarm_pin_alert).
---
config/config.default.yaml | 11 +
internal/acpproc/acp_process_gc.go | 26 ++
internal/acpproc/acp_process_gc_test.go | 66 +++++
internal/acpproc/acp_process_manager.go | 348 +++++++++++++++++++++++-
internal/acpproc/prewarm_pin_test.go | 263 ++++++++++++++++++
internal/acpproc/shared_acp_process.go | 15 +
internal/auxiliary/workspace_manager.go | 32 +++
internal/config/config.go | 21 ++
internal/config/settings.go | 147 ++++++++++
internal/config/settings_test.go | 114 ++++++++
internal/conversation/session_info.go | 11 +
internal/web/server.go | 51 ++++
internal/web/ws_messages.go | 8 +
13 files changed, 1111 insertions(+), 2 deletions(-)
create mode 100644 internal/acpproc/prewarm_pin_test.go
diff --git a/config/config.default.yaml b/config/config.default.yaml
index 4dd35c556..8e986198f 100644
--- a/config/config.default.yaml
+++ b/config/config.default.yaml
@@ -298,6 +298,17 @@ conversations:
# external_images:
# enabled: false # Allow external HTTPS images (default: false)
+# Adaptive ACP/MCP pre-warming thresholds (mitto-mw0)
+# Pre-warming warms a workspace, probes its health (session/new latency + MCP
+# readiness), and pins a warm keepalive session only for slow/broken workspaces.
+# Healthy workspaces are left alone (GC reaps them).
+# prewarm:
+# session_new_fast: "10s" # T_fast: session/new latency at/under which a workspace is "fast"
+# mcp_ready: "10s" # T_mcp: max time for all configured MCP servers to be reachable
+# healthy_probes_to_unpin: 3 # Hysteresis: consecutive healthy probes required before unpinning
+# max_pin_duration: "30m" # Cap on how long a pinned session is held (use "disabled" for no cap)
+# max_pinned_workspaces: 5 # Blast-radius cap on simultaneously-pinned workspaces
+
# Permission handling configuration
# Controls how permission requests from agents are handled.
# Permission requests occur when an agent wants to perform sensitive operations
diff --git a/internal/acpproc/acp_process_gc.go b/internal/acpproc/acp_process_gc.go
index b6d26e893..3bb59ceaf 100644
--- a/internal/acpproc/acp_process_gc.go
+++ b/internal/acpproc/acp_process_gc.go
@@ -294,6 +294,19 @@ gcTier1:
}
continue
}
+ // Skip pinned keepalive sessions: the adaptive pre-warming path pins a
+ // warm session for slow/broken workspaces so GC does not reap it before
+ // the first real prompt. An expired PinExpiry (non-nil and in the past)
+ // falls through so the max-pin-duration cap self-heals a stuck pin.
+ if s.Pinned && (s.PinExpiry == nil || now.Before(*s.PinExpiry)) {
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping session (pinned)",
+ "session_id", s.SessionID,
+ "workspace_uuid", workspaceUUID,
+ "pin_reason", s.PinReason)
+ }
+ continue
+ }
// Determine if this is a loop session eligible for suspension.
// A loop session qualifies when:
@@ -464,6 +477,19 @@ gcTier1:
continue
}
+ // Adaptive pre-warming pin (mitto-mw0): a pinned workspace holds a warm
+ // PurposeKeepAlive auxiliary session but may have no BackgroundSessions.
+ // Skip Tier 2 shutdown so the process stays warm for the first real prompt.
+ // An expired pin falls through so the max-pin-duration cap self-heals.
+ if m.IsPinned(workspaceUUID) {
+ m.lastSessionSeen[workspaceUUID] = now
+ if m.logger != nil {
+ m.logger.Debug("GC: skipping process shutdown (pinned by prewarm)",
+ "workspace_uuid", workspaceUUID)
+ }
+ continue
+ }
+
// No active sessions for this workspace.
last, seen := m.lastSessionSeen[workspaceUUID]
if !seen {
diff --git a/internal/acpproc/acp_process_gc_test.go b/internal/acpproc/acp_process_gc_test.go
index 03698a745..900092875 100644
--- a/internal/acpproc/acp_process_gc_test.go
+++ b/internal/acpproc/acp_process_gc_test.go
@@ -154,6 +154,72 @@ func TestGCTier1_ClosesSessionWithDistantLoop(t *testing.T) {
}
}
+// TestGCTier1_SkipsPinnedSession verifies that a session marked Pinned=true is
+// exempt from Tier 1 GC as long as PinExpiry is nil or still in the future.
+func TestGCTier1_SkipsPinnedSession(t *testing.T) {
+ future := time.Now().Add(10 * time.Minute)
+
+ sessions := map[string][]conversation.SessionInfo{
+ "ws-1": {
+ {SessionID: "pinned-no-expiry", WorkspaceUUID: "ws-1", Pinned: true, PinReason: "slow session/new"},
+ {SessionID: "pinned-future-expiry", WorkspaceUUID: "ws-1", Pinned: true, PinReason: "mcp-init timeout", PinExpiry: &future},
+ },
+ }
+
+ var mu sync.Mutex
+ closed := make(map[string]bool)
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ closed[id] = true
+ },
+ )
+
+ m.RunGCOnce()
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(closed) > 0 {
+ t.Errorf("pinned sessions should not be closed by Tier 1; got %v", closed)
+ }
+}
+
+// TestGCTier1_ClosesPinnedSessionWithExpiredExpiry verifies that a session with
+// Pinned=true but PinExpiry in the past is eligible for GC — the pin is
+// treated as expired so the max-pin-duration cap can release a stuck pin.
+func TestGCTier1_ClosesPinnedSessionWithExpiredExpiry(t *testing.T) {
+ past := time.Now().Add(-1 * time.Minute)
+
+ sessions := map[string][]conversation.SessionInfo{
+ "ws-1": {
+ {SessionID: "pinned-expired", WorkspaceUUID: "ws-1", Pinned: true, PinReason: "slow session/new", PinExpiry: &past},
+ },
+ }
+
+ var mu sync.Mutex
+ closed := make(map[string]bool)
+
+ m := newTestGCManager(
+ func() map[string][]conversation.SessionInfo { return sessions },
+ func(id string) {
+ mu.Lock()
+ defer mu.Unlock()
+ closed[id] = true
+ },
+ )
+
+ m.RunGCOnce()
+
+ mu.Lock()
+ defer mu.Unlock()
+ if !closed["pinned-expired"] {
+ t.Error("pinned session with expired PinExpiry should fall through and be closed by Tier 1")
+ }
+}
+
// TestGCTier2_GracePeriod verifies the two-step grace period logic:
// - First RunGCOnce records the "sessionless" timestamp and keeps the process.
// - After the grace period elapses the process is stopped on the next cycle.
diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go
index 781872f99..da4ce5336 100644
--- a/internal/acpproc/acp_process_manager.go
+++ b/internal/acpproc/acp_process_manager.go
@@ -34,6 +34,12 @@ type ACPProcessManager struct {
// Used to look up AuxiliaryModelSelection for new auxiliary sessions.
WorkspaceConfigProvider func(workspaceUUID string) *config.WorkspaceSettings
+ // PrewarmConfigProvider returns the effective adaptive pre-warming
+ // thresholds (mitto-mw0). May be nil, in which case the built-in defaults
+ // from config.PrewarmConfig helpers are used. The web layer wires this to
+ // the global Config.Prewarm.
+ PrewarmConfigProvider func() *config.PrewarmConfig
+
// ModelProfileResolver resolves a named Model profile (Config.Models) by name.
// Used to look up AuxiliaryModelProfile for new auxiliary sessions (mitto-hke).
// May be nil, in which case AuxiliaryModelProfile is ignored and
@@ -130,6 +136,25 @@ type ACPProcessManager struct {
// Used by the web layer to broadcast UI notifications (mitto-8ul.1).
onMCPInitializing func(workspaceUUID string)
onMCPInitTimedOut func(workspaceUUID string)
+
+ // Adaptive pre-warming pin state (mitto-mw0). One entry per pinned
+ // workspace. Guarded by pinMu. A pinned workspace exempts its
+ // PurposeKeepAlive auxiliary session from AuxIdleTimeout and its Tier 2
+ // sessionless process shutdown, keeping the agent warm for the first real
+ // prompt on a slow/broken workspace.
+ pinMu sync.Mutex
+ pinState map[string]*pinInfo
+ onPrewarmPinAlert func(workspaceUUID, reason string, expired bool)
+}
+
+// pinInfo tracks a workspace's pin metadata for the adaptive pre-warming
+// controller (mitto-mw0).
+type pinInfo struct {
+ Reason string
+ PinnedAt time.Time
+ Expiry *time.Time // nil = no cap; otherwise, when the pin auto-expires
+ Healthy int // consecutive healthy probes observed while pinned (hysteresis)
+ Alerted bool // whether an alert has been fired for this pin
}
// MarkGCSuspended records that a session was intentionally suspended by the GC's
@@ -253,6 +278,7 @@ func NewACPProcessManager(ctx context.Context, logger *slog.Logger) *ACPProcessM
processes: make(map[string]*SharedACPProcess),
auxSessions: make(map[auxSessionKey]*auxiliarySessionState),
auxCreateMu: make(map[auxSessionKey]*sync.Mutex),
+ pinState: make(map[string]*pinInfo),
ctx: ctx,
logger: logger,
}
@@ -306,6 +332,203 @@ func (m *ACPProcessManager) SetOnMCPInitTimedOut(fn func(workspaceUUID string))
m.onMCPInitTimedOut = fn
}
+// SetOnPrewarmPinAlert registers the callback invoked by the adaptive
+// pre-warming controller (mitto-mw0) when a workspace is pinned due to a
+// slow/broken MCP init, or when a stuck pin expires because its
+// MaxPinDuration cap elapsed. expired=true distinguishes the two cases so
+// the web layer can pick the right UI toast copy.
+func (m *ACPProcessManager) SetOnPrewarmPinAlert(fn func(workspaceUUID, reason string, expired bool)) {
+ m.pinMu.Lock()
+ defer m.pinMu.Unlock()
+ m.onPrewarmPinAlert = fn
+}
+
+// PinWorkspace marks a workspace as pinned by the adaptive pre-warming
+// controller (mitto-mw0). While pinned, the workspace's PurposeKeepAlive
+// auxiliary session is exempt from AuxIdleTimeout and Tier 2 process
+// shutdown. reason describes why (e.g. "slow_session_new", "mcp_timeout").
+// maxDuration=0 disables the auto-expiry cap. maxPinned=0 disables the
+// blast-radius cap. Returns true iff the pin was applied; false when the
+// cap was reached (workspace is not pinned in that case).
+//
+// If the workspace was already pinned, the reason/expiry are refreshed and
+// the healthy-probe hysteresis counter is reset (any bad signal restarts
+// the count). This is intentional — an unhealthy probe should immediately
+// undo hysteresis progress.
+func (m *ACPProcessManager) PinWorkspace(workspaceUUID, reason string, maxDuration time.Duration, maxPinned int) bool {
+ m.pinMu.Lock()
+ defer m.pinMu.Unlock()
+
+ if _, ok := m.pinState[workspaceUUID]; !ok {
+ if maxPinned > 0 && len(m.pinState) >= maxPinned {
+ if m.logger != nil {
+ m.logger.Warn("prewarm: pin refused (max_pinned cap reached)",
+ "workspace_uuid", workspaceUUID,
+ "reason", reason,
+ "pinned_count", len(m.pinState),
+ "max_pinned", maxPinned)
+ }
+ return false
+ }
+ }
+
+ now := time.Now()
+ var expiry *time.Time
+ if maxDuration > 0 {
+ e := now.Add(maxDuration)
+ expiry = &e
+ }
+ m.pinState[workspaceUUID] = &pinInfo{
+ Reason: reason,
+ PinnedAt: now,
+ Expiry: expiry,
+ Healthy: 0,
+ }
+ if m.logger != nil {
+ m.logger.Info("prewarm: pinned workspace",
+ "workspace_uuid", workspaceUUID,
+ "reason", reason,
+ "expiry", expiry,
+ "pinned_count", len(m.pinState))
+ }
+ return true
+}
+
+// UnpinWorkspace clears a workspace's pin. No-op if the workspace is not
+// pinned. The PurposeKeepAlive auxiliary session is left in place; the next
+// AuxIdleTimeout sweep will reap it.
+func (m *ACPProcessManager) UnpinWorkspace(workspaceUUID string) {
+ m.pinMu.Lock()
+ defer m.pinMu.Unlock()
+ if _, ok := m.pinState[workspaceUUID]; !ok {
+ return
+ }
+ delete(m.pinState, workspaceUUID)
+ if m.logger != nil {
+ m.logger.Info("prewarm: unpinned workspace",
+ "workspace_uuid", workspaceUUID,
+ "pinned_count", len(m.pinState))
+ }
+}
+
+// IsPinned returns true if the workspace is currently pinned by the
+// adaptive pre-warming controller (mitto-mw0) and its pin has not yet
+// expired.
+func (m *ACPProcessManager) IsPinned(workspaceUUID string) bool {
+ m.pinMu.Lock()
+ defer m.pinMu.Unlock()
+ pi, ok := m.pinState[workspaceUUID]
+ if !ok {
+ return false
+ }
+ if pi.Expiry != nil && !time.Now().Before(*pi.Expiry) {
+ return false
+ }
+ return true
+}
+
+// PinnedCount returns the number of currently pinned workspaces (including
+// pins whose Expiry has passed but that have not yet been reaped by the
+// controller's re-evaluation round).
+func (m *ACPProcessManager) PinnedCount() int {
+ m.pinMu.Lock()
+ defer m.pinMu.Unlock()
+ return len(m.pinState)
+}
+
+// RecordPrewarmProbeResult feeds the outcome of a health probe into the
+// pin controller's hysteresis state machine (mitto-mw0). healthy=true
+// increments the consecutive-healthy counter for the workspace; when the
+// counter reaches probesToUnpin the workspace is unpinned and the method
+// returns true. healthy=false resets the counter to zero (any bad signal
+// undoes hysteresis progress). Returns false when the workspace is not
+// currently pinned.
+func (m *ACPProcessManager) RecordPrewarmProbeResult(workspaceUUID string, healthy bool, probesToUnpin int) (unpinned bool) {
+ m.pinMu.Lock()
+ pi, ok := m.pinState[workspaceUUID]
+ if !ok {
+ m.pinMu.Unlock()
+ return false
+ }
+ if !healthy {
+ pi.Healthy = 0
+ m.pinMu.Unlock()
+ return false
+ }
+ pi.Healthy++
+ if probesToUnpin > 0 && pi.Healthy >= probesToUnpin {
+ delete(m.pinState, workspaceUUID)
+ count := len(m.pinState)
+ m.pinMu.Unlock()
+ if m.logger != nil {
+ m.logger.Info("prewarm: unpinned workspace (hysteresis satisfied)",
+ "workspace_uuid", workspaceUUID,
+ "probes_to_unpin", probesToUnpin,
+ "pinned_count", count)
+ }
+ return true
+ }
+ m.pinMu.Unlock()
+ return false
+}
+
+// ExpirePinsAndAlert scans pinState for pins whose MaxPinDuration cap has
+// elapsed, removes them, and fires onPrewarmPinAlert(expired=true) for
+// each. Called by the pre-warming controller's re-evaluation round to
+// self-heal stuck pins (mitto-mw0). Returns the workspaces that were
+// expired.
+func (m *ACPProcessManager) ExpirePinsAndAlert() []string {
+ now := time.Now()
+ m.pinMu.Lock()
+ var expired []string
+ var alerts []struct {
+ uuid, reason string
+ }
+ for uuid, pi := range m.pinState {
+ if pi.Expiry == nil || now.Before(*pi.Expiry) {
+ continue
+ }
+ expired = append(expired, uuid)
+ alerts = append(alerts, struct{ uuid, reason string }{uuid, pi.Reason})
+ delete(m.pinState, uuid)
+ }
+ cb := m.onPrewarmPinAlert
+ m.pinMu.Unlock()
+
+ for _, a := range alerts {
+ if m.logger != nil {
+ m.logger.Warn("prewarm: pin expired (max_pin_duration cap)",
+ "workspace_uuid", a.uuid,
+ "reason", a.reason)
+ }
+ if cb != nil {
+ cb(a.uuid, a.reason, true)
+ }
+ }
+ return expired
+}
+
+// FirePrewarmPinAlert invokes the registered onPrewarmPinAlert callback for
+// a pin caused by an MCP-related failure (expired=false). At-most-once per
+// pin — subsequent calls for the same pin are no-ops. No-op when the
+// workspace is not pinned or no callback is registered (mitto-mw0).
+func (m *ACPProcessManager) FirePrewarmPinAlert(workspaceUUID string) {
+ m.pinMu.Lock()
+ pi, ok := m.pinState[workspaceUUID]
+ if !ok || pi.Alerted {
+ m.pinMu.Unlock()
+ return
+ }
+ pi.Alerted = true
+ reason := pi.Reason
+ cb := m.onPrewarmPinAlert
+ m.pinMu.Unlock()
+
+ if cb != nil {
+ cb(workspaceUUID, reason, false)
+ }
+}
+
// Ensure ACPProcessManager implements auxiliary.ProcessProvider
var _ auxiliary.ProcessProvider = (*ACPProcessManager)(nil)
@@ -1162,6 +1385,11 @@ func acquireAuxLock(ctx context.Context, auxState *auxiliarySessionState) error
// CleanupStaleAuxiliarySessions removes auxiliary sessions that haven't been used recently.
// This helps recover from stuck sessions and free up resources.
// maxIdleTime specifies how long a session can be idle before being cleaned up.
+//
+// Pinned PurposeKeepAlive sessions (mitto-mw0) are exempt while the
+// workspace's pin has not expired: keeping the aux session hot is the whole
+// point of the pin. An expired pin (Expiry non-nil and in the past) falls
+// through so the max-pin-duration cap self-heals a stuck keepalive.
func (m *ACPProcessManager) CleanupStaleAuxiliarySessions(maxIdleTime time.Duration) int {
m.auxMu.Lock()
defer m.auxMu.Unlock()
@@ -1171,9 +1399,13 @@ func (m *ACPProcessManager) CleanupStaleAuxiliarySessions(maxIdleTime time.Durat
// Find stale sessions
for key, state := range m.auxSessions {
- if now.Sub(state.lastUsed) > maxIdleTime {
- staleKeys = append(staleKeys, key)
+ if now.Sub(state.lastUsed) <= maxIdleTime {
+ continue
+ }
+ if key.purpose == auxiliary.PurposeKeepAlive && m.IsPinned(key.workspaceUUID) {
+ continue
}
+ staleKeys = append(staleKeys, key)
}
// Remove stale sessions
@@ -1224,6 +1456,11 @@ func (m *ACPProcessManager) EnsurePrewarmed(workspaceUUID string, logger *slog.L
// later callers (MCP tool fetch, title generation, follow-up analysis) can find an existing
// aux session immediately without waiting for session creation.
//
+// The adaptive pre-warming controller (mitto-mw0) additionally creates a
+// PurposeKeepAlive aux session, measures its NewSession latency, checks
+// the shared process's MCP-init signals, and pins the workspace when the
+// verdict is unhealthy.
+//
// Run in a goroutine after releasing the ACPProcessManager lock.
func (m *ACPProcessManager) prewarmAuxiliarySessions(workspaceUUID string, logger *slog.Logger) {
purposes := []string{
@@ -1263,4 +1500,111 @@ func (m *ACPProcessManager) prewarmAuxiliarySessions(workspaceUUID string, logge
}(purpose)
}
wg.Wait()
+
+ // Adaptive pre-warming health probe (mitto-mw0). Runs after the initial
+ // aux prewarm so the shared process has had a chance to complete MCP init
+ // and the mcp{Init,Timed}Out signals are up to date. The keepalive session
+ // creation is what actually measures session/new latency for pin decisions.
+ m.probePrewarmHealth(workspaceUUID, logger)
+}
+
+// probePrewarmHealth runs the adaptive pre-warming health probe for a
+// workspace (mitto-mw0): it creates a PurposeKeepAlive aux session,
+// measures NewSession latency, checks the shared process's MCP-init
+// signals, and pins the workspace when the verdict is unhealthy. Called at
+// the tail of prewarmAuxiliarySessions and periodically by the pin
+// controller (see ReevaluatePrewarmPin).
+func (m *ACPProcessManager) probePrewarmHealth(workspaceUUID string, logger *slog.Logger) {
+ pc := m.effectivePrewarmConfig()
+ tFast, _ := pc.ParseSessionNewFast()
+ tMcp, _ := pc.ParseMcpReady()
+ maxDur, _ := pc.ParseMaxPinDuration()
+ maxPinned := pc.GetMaxPinnedWorkspaces()
+
+ // Create the keepalive session and time the NewSession round-trip.
+ // Use a budget slightly greater than the MCP-ready threshold so a broken
+ // MCP does not artificially trip session/new latency alone.
+ probeBudget := tMcp + 20*time.Second
+ if probeBudget < 30*time.Second {
+ probeBudget = 30 * time.Second
+ }
+ ctx, cancel := context.WithTimeout(m.ctx, probeBudget)
+ defer cancel()
+
+ start := time.Now()
+ _, err := m.getOrCreateAuxiliarySession(ctx, workspaceUUID, auxiliary.PurposeKeepAlive)
+ sessionNewLatency := time.Since(start)
+
+ // Sample MCP-init signals on the shared process (may be nil if the
+ // process was torn down between prewarm start and now — treat as
+ // unhealthy so the retry loop re-evaluates).
+ var mcpTimedOut, mcpDone bool
+ if p := m.GetProcess(workspaceUUID); p != nil {
+ mcpTimedOut = p.MCPInitTimedOut()
+ mcpDone = p.MCPInitDone()
+ }
+
+ // Verdict: healthy iff session/new completed under T_fast AND MCP init
+ // did not time out AND MCP init has finished (i.e. the agent is not
+ // still blocked on MCP). A failed session/new is always unhealthy.
+ healthy := err == nil && sessionNewLatency <= tFast && !mcpTimedOut && mcpDone
+
+ reason := ""
+ switch {
+ case err != nil:
+ reason = "session_new_failed"
+ case mcpTimedOut:
+ reason = "mcp_timeout"
+ case !mcpDone:
+ reason = "mcp_not_ready"
+ case sessionNewLatency > tFast:
+ reason = "slow_session_new"
+ }
+
+ if logger != nil {
+ logger.Info("prewarm: health probe",
+ "workspace_uuid", workspaceUUID,
+ "session_new_ms", sessionNewLatency.Milliseconds(),
+ "session_new_fast_ms", tFast.Milliseconds(),
+ "mcp_ready_ms", tMcp.Milliseconds(),
+ "mcp_init_done", mcpDone,
+ "mcp_timed_out", mcpTimedOut,
+ "err", err,
+ "healthy", healthy,
+ "reason", reason)
+ }
+
+ if healthy {
+ // Feed the healthy probe into hysteresis (unpins after N consecutive
+ // healthy probes). No-op if not pinned.
+ m.RecordPrewarmProbeResult(workspaceUUID, true, pc.GetHealthyProbesToUnpin())
+ return
+ }
+
+ // Unhealthy → pin the workspace (or refresh the pin if already pinned).
+ if m.PinWorkspace(workspaceUUID, reason, maxDur, maxPinned) && reason == "mcp_timeout" {
+ // Fire the alert only for MCP-timeout pins on the initial pin event
+ // (FirePrewarmPinAlert is at-most-once per pin).
+ m.FirePrewarmPinAlert(workspaceUUID)
+ }
+}
+
+// effectivePrewarmConfig returns the caller-supplied PrewarmConfig or nil.
+// The config.PrewarmConfig accessors themselves handle nil safely (returning
+// defaults), so callers can pass the result directly to the helper methods.
+func (m *ACPProcessManager) effectivePrewarmConfig() *config.PrewarmConfig {
+ if m.PrewarmConfigProvider == nil {
+ return nil
+ }
+ return m.PrewarmConfigProvider()
+}
+
+// ReevaluatePrewarmPin runs the health probe for a currently-pinned
+// workspace and applies the pin/unpin decision (mitto-mw0). It is intended
+// to be called from the MCP backoff retry loop (EnsureMCPBackoffRetry) so
+// pin/unpin decisions ride the same schedule as MCP reachability probes.
+// Expired pins are self-healed via ExpirePinsAndAlert before the probe.
+func (m *ACPProcessManager) ReevaluatePrewarmPin(workspaceUUID string, logger *slog.Logger) {
+ m.ExpirePinsAndAlert()
+ m.probePrewarmHealth(workspaceUUID, logger)
}
diff --git a/internal/acpproc/prewarm_pin_test.go b/internal/acpproc/prewarm_pin_test.go
new file mode 100644
index 000000000..69c4df573
--- /dev/null
+++ b/internal/acpproc/prewarm_pin_test.go
@@ -0,0 +1,263 @@
+package acpproc
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+)
+
+// TestPrewarmPin_BasicPinUnpin verifies that PinWorkspace/UnpinWorkspace
+// set and clear the pin, and IsPinned/PinnedCount reflect the state.
+func TestPrewarmPin_BasicPinUnpin(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ if m.IsPinned("ws-1") {
+ t.Fatal("workspace unexpectedly reported pinned before PinWorkspace")
+ }
+ if got := m.PinnedCount(); got != 0 {
+ t.Fatalf("PinnedCount before pin: got %d, want 0", got)
+ }
+
+ if ok := m.PinWorkspace("ws-1", "slow_session_new", 0, 0); !ok {
+ t.Fatal("PinWorkspace returned false (expected true)")
+ }
+ if !m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=false after PinWorkspace")
+ }
+ if got := m.PinnedCount(); got != 1 {
+ t.Fatalf("PinnedCount after pin: got %d, want 1", got)
+ }
+
+ m.UnpinWorkspace("ws-1")
+ if m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=true after UnpinWorkspace")
+ }
+ if got := m.PinnedCount(); got != 0 {
+ t.Fatalf("PinnedCount after unpin: got %d, want 0", got)
+ }
+}
+
+// TestPrewarmPin_MaxPinnedCap verifies that the blast-radius cap refuses
+// new pins once the limit is reached.
+func TestPrewarmPin_MaxPinnedCap(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ if ok := m.PinWorkspace("ws-a", "r", 0, 2); !ok {
+ t.Fatal("first pin refused (expected accepted)")
+ }
+ if ok := m.PinWorkspace("ws-b", "r", 0, 2); !ok {
+ t.Fatal("second pin refused (expected accepted)")
+ }
+ if ok := m.PinWorkspace("ws-c", "r", 0, 2); ok {
+ t.Fatal("third pin accepted (expected refused by cap)")
+ }
+ if got := m.PinnedCount(); got != 2 {
+ t.Fatalf("PinnedCount: got %d, want 2", got)
+ }
+ // Re-pinning an already-pinned workspace is not gated by the cap.
+ if ok := m.PinWorkspace("ws-a", "r2", 0, 2); !ok {
+ t.Fatal("re-pin of already-pinned workspace refused (expected accepted)")
+ }
+}
+
+// TestPrewarmPin_Hysteresis verifies that RecordPrewarmProbeResult requires
+// N consecutive healthy probes to unpin, and that any unhealthy probe
+// resets the counter.
+func TestPrewarmPin_Hysteresis(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ if !m.PinWorkspace("ws-1", "slow", 0, 0) {
+ t.Fatal("initial pin refused")
+ }
+
+ // Two healthy probes → still pinned (N=3).
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", true, 3); unpinned {
+ t.Fatal("unpinned after 1 healthy probe (expected still pinned)")
+ }
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", true, 3); unpinned {
+ t.Fatal("unpinned after 2 healthy probes (expected still pinned)")
+ }
+ if !m.IsPinned("ws-1") {
+ t.Fatal("not pinned after 2 healthy probes")
+ }
+
+ // One unhealthy probe → counter resets.
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", false, 3); unpinned {
+ t.Fatal("unpinned by unhealthy probe (expected still pinned)")
+ }
+ if !m.IsPinned("ws-1") {
+ t.Fatal("not pinned after unhealthy probe (expected still pinned)")
+ }
+
+ // Two more healthy probes → still pinned (counter was reset).
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", true, 3); unpinned {
+ t.Fatal("unpinned after only 1 fresh healthy probe")
+ }
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", true, 3); unpinned {
+ t.Fatal("unpinned after only 2 fresh healthy probes")
+ }
+
+ // Third healthy probe → unpin.
+ if unpinned := m.RecordPrewarmProbeResult("ws-1", true, 3); !unpinned {
+ t.Fatal("still pinned after 3 consecutive healthy probes")
+ }
+ if m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=true after hysteresis unpin")
+ }
+}
+
+// TestPrewarmPin_ExpiryCap verifies that pins with MaxPinDuration set are
+// auto-expired by ExpirePinsAndAlert once the cap elapses, and that the
+// alert callback fires with expired=true.
+func TestPrewarmPin_ExpiryCap(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ var mu sync.Mutex
+ type alert struct {
+ uuid, reason string
+ expired bool
+ }
+ var alerts []alert
+ m.SetOnPrewarmPinAlert(func(uuid, reason string, expired bool) {
+ mu.Lock()
+ defer mu.Unlock()
+ alerts = append(alerts, alert{uuid, reason, expired})
+ })
+
+ // Pin with a very short cap so we can wait it out synchronously.
+ if !m.PinWorkspace("ws-1", "mcp_timeout", 20*time.Millisecond, 0) {
+ t.Fatal("pin refused")
+ }
+ // Before expiry: IsPinned=true, ExpirePinsAndAlert returns nothing.
+ if !m.IsPinned("ws-1") {
+ t.Fatal("not pinned immediately after PinWorkspace")
+ }
+ if got := m.ExpirePinsAndAlert(); len(got) != 0 {
+ t.Fatalf("ExpirePinsAndAlert before cap: got %v, want []", got)
+ }
+
+ time.Sleep(40 * time.Millisecond)
+
+ expired := m.ExpirePinsAndAlert()
+ if len(expired) != 1 || expired[0] != "ws-1" {
+ t.Fatalf("ExpirePinsAndAlert after cap: got %v, want [ws-1]", expired)
+ }
+ if m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=true after expiry")
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(alerts) != 1 {
+ t.Fatalf("alert count: got %d, want 1", len(alerts))
+ }
+ if alerts[0].uuid != "ws-1" || alerts[0].reason != "mcp_timeout" || !alerts[0].expired {
+ t.Fatalf("alert payload: got %+v, want {ws-1, mcp_timeout, expired=true}", alerts[0])
+ }
+}
+
+// TestPrewarmPin_FireAlertIsIdempotent verifies that FirePrewarmPinAlert
+// only fires once per pin.
+func TestPrewarmPin_FireAlertIsIdempotent(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ var mu sync.Mutex
+ fired := 0
+ m.SetOnPrewarmPinAlert(func(uuid, reason string, expired bool) {
+ mu.Lock()
+ defer mu.Unlock()
+ if expired {
+ t.Errorf("expected expired=false, got true")
+ }
+ fired++
+ })
+
+ if !m.PinWorkspace("ws-1", "mcp_timeout", 0, 0) {
+ t.Fatal("pin refused")
+ }
+ m.FirePrewarmPinAlert("ws-1")
+ m.FirePrewarmPinAlert("ws-1")
+ m.FirePrewarmPinAlert("ws-1")
+
+ mu.Lock()
+ defer mu.Unlock()
+ if fired != 1 {
+ t.Fatalf("alert fired %d times, want 1 (at-most-once per pin)", fired)
+ }
+}
+
+// TestPrewarmPin_FireAlertNoOpWhenNotPinned verifies FirePrewarmPinAlert
+// is a safe no-op for unpinned workspaces.
+func TestPrewarmPin_FireAlertNoOpWhenNotPinned(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ fired := false
+ m.SetOnPrewarmPinAlert(func(uuid, reason string, expired bool) {
+ fired = true
+ })
+
+ m.FirePrewarmPinAlert("nonexistent")
+ if fired {
+ t.Fatal("alert fired for unpinned workspace")
+ }
+}
+
+// TestPrewarmPin_ExpiredPinIsNotIsPinned verifies that a pin whose Expiry
+// has passed reports IsPinned=false even before ExpirePinsAndAlert runs.
+func TestPrewarmPin_ExpiredPinIsNotIsPinned(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ if !m.PinWorkspace("ws-1", "r", 10*time.Millisecond, 0) {
+ t.Fatal("pin refused")
+ }
+ if !m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=false before expiry")
+ }
+ time.Sleep(25 * time.Millisecond)
+ if m.IsPinned("ws-1") {
+ t.Fatal("IsPinned=true after expiry (before ExpirePinsAndAlert)")
+ }
+}
+
+// TestPrewarmPin_CleanupStaleAuxSkipsPinnedKeepalive verifies that
+// CleanupStaleAuxiliarySessions leaves a pinned workspace's keepalive
+// session in place, while still reaping non-keepalive stale sessions.
+func TestPrewarmPin_CleanupStaleAuxSkipsPinnedKeepalive(t *testing.T) {
+ m := NewACPProcessManager(context.Background(), nil)
+ defer m.Close()
+
+ // Seed the aux session map directly (bypassing NewSession, which needs a
+ // real process) — this test exercises only the cleanup skip logic.
+ past := time.Now().Add(-1 * time.Hour)
+ m.auxMu.Lock()
+ m.auxSessions[auxSessionKey{"ws-pinned", "keepalive"}] = &auxiliarySessionState{lastUsed: past}
+ m.auxSessions[auxSessionKey{"ws-pinned", "title-gen"}] = &auxiliarySessionState{lastUsed: past}
+ m.auxSessions[auxSessionKey{"ws-unpinned", "keepalive"}] = &auxiliarySessionState{lastUsed: past}
+ m.auxMu.Unlock()
+
+ if !m.PinWorkspace("ws-pinned", "r", 0, 0) {
+ t.Fatal("pin refused")
+ }
+
+ m.CleanupStaleAuxiliarySessions(1 * time.Minute)
+
+ m.auxMu.Lock()
+ defer m.auxMu.Unlock()
+ if _, ok := m.auxSessions[auxSessionKey{"ws-pinned", "keepalive"}]; !ok {
+ t.Fatal("pinned workspace's keepalive was reaped (expected exempt)")
+ }
+ if _, ok := m.auxSessions[auxSessionKey{"ws-pinned", "title-gen"}]; ok {
+ t.Fatal("pinned workspace's non-keepalive session was NOT reaped (expected reaped)")
+ }
+ if _, ok := m.auxSessions[auxSessionKey{"ws-unpinned", "keepalive"}]; ok {
+ t.Fatal("unpinned workspace's keepalive was NOT reaped (expected reaped)")
+ }
+}
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index edb2e1a9b..72eabdb98 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -1173,6 +1173,21 @@ func (p *SharedACPProcess) RecommendedLoadTimeout(hasMCPServers bool) time.Durat
return p.config.MCPInitTimeout
}
+// MCPInitDone reports whether the shared process's MCP-init window has
+// closed (the agent's first successful RPC observed). Used by the adaptive
+// pre-warming controller (mitto-mw0) to compute the health verdict.
+func (p *SharedACPProcess) MCPInitDone() bool {
+ return p.mcpInitDone.Load()
+}
+
+// MCPInitTimedOut reports whether the shared process's stderr monitor has
+// seen the agent report its internal MCP-init wait budget elapsed (a hard
+// "MCP is broken" signal). Used by the adaptive pre-warming controller
+// (mitto-mw0) to compute the health verdict.
+func (p *SharedACPProcess) MCPInitTimedOut() bool {
+ return p.mcpInitTimedOut.Load()
+}
+
// beginMCPInitWindow prepares per-RPC MCP-init lifecycle tracking (mitto-8ul.1):
// it (re-)creates a fresh timeout channel so a signal from a previous RPC does not
// fire on this one, and clears the mcpInitTimedOut flag if it was set. Returns the
diff --git a/internal/auxiliary/workspace_manager.go b/internal/auxiliary/workspace_manager.go
index 99550fea1..ef644a250 100644
--- a/internal/auxiliary/workspace_manager.go
+++ b/internal/auxiliary/workspace_manager.go
@@ -29,6 +29,13 @@ const (
PurposeMCPCheck = "mcp-check"
PurposeMCPTools = "mcp-tools"
+ // PurposeKeepAlive is a warm keepalive auxiliary session held by the
+ // adaptive pre-warming controller (mitto-mw0) for slow/broken workspaces.
+ // It carries no traffic; its sole job is to hold MCP-connection warmth so
+ // the first real prompt hits an already-warm agent. Exempt from
+ // AuxIdleTimeout while the workspace is pinned.
+ PurposeKeepAlive = "keepalive"
+
// PurposeProcessorPrefix is the prefix for processor-scoped auxiliary sessions.
// Each prompt-mode processor gets its own session: "processor:".
PurposeProcessorPrefix = "processor:"
@@ -115,6 +122,15 @@ type WorkspaceAuxiliaryManager struct {
// mcpToolsTTL bounds reuse of a persisted snapshot; defaults to
// defaultMCPToolsTTL. Overridable in tests for speed.
mcpToolsTTL time.Duration
+
+ // PrewarmPinReevaluator, when set, is called once per EnsureMCPBackoffRetry
+ // round (after each MCP probe) so the adaptive pre-warming controller
+ // (mitto-mw0) can re-run its health probe and apply hysteresis/expiry.
+ // The web layer wires this to (*acpproc.ACPProcessManager).ReevaluatePrewarmPin
+ // so pin/unpin decisions ride the same schedule as MCP reachability probes
+ // without introducing an import cycle (auxiliary → acpproc is forbidden).
+ // nil (tests/CLI) disables re-evaluation.
+ PrewarmPinReevaluator func(workspaceUUID string)
}
// NewWorkspaceAuxiliaryManager creates a new workspace-scoped auxiliary manager.
@@ -730,6 +746,12 @@ func (m *WorkspaceAuxiliaryManager) EnsureMCPBackoffRetry(ctx context.Context, w
probe := func(pctx context.Context) mcpdiscovery.ServerToolsResult {
results, err := m.StdioToolsDiscoverer(pctx, workspaceUUID)
if err != nil {
+ // Re-evaluate the adaptive pre-warming pin even on discovery
+ // error — an unhealthy probe here should keep the pin held
+ // (or refresh the hysteresis reset).
+ if m.PrewarmPinReevaluator != nil {
+ m.PrewarmPinReevaluator(workspaceUUID)
+ }
return mcpdiscovery.ServerToolsResult{Server: workspaceUUID, Reachable: false, Err: err}
}
@@ -755,6 +777,16 @@ func (m *WorkspaceAuxiliaryManager) EnsureMCPBackoffRetry(ctx context.Context, w
onUpdate(merged)
}
+ // Adaptive pre-warming re-evaluation (mitto-mw0): once per probe
+ // round, ask the pin controller to re-run its health verdict.
+ // Piggybacking on the MCP backoff loop keeps the pin/unpin cadence
+ // aligned with actual MCP reachability changes and needs no extra
+ // timer. Errors and short-circuit exits are covered by the pre-
+ // return re-evaluation above and the final ExpirePinsAndAlert.
+ if m.PrewarmPinReevaluator != nil {
+ m.PrewarmPinReevaluator(workspaceUUID)
+ }
+
// Stop only when every configured server responded (none unreachable).
return mcpdiscovery.ServerToolsResult{Server: workspaceUUID, Reachable: unreachable == 0}
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 24ae6f135..b2bb2effc 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1317,6 +1317,8 @@ type Config struct {
UI UIConfig
// Session contains session storage limits configuration (not exposed in Settings dialog)
Session *SessionConfig
+ // Prewarm contains adaptive ACP/MCP pre-warming thresholds (mitto-mw0)
+ Prewarm *PrewarmConfig
// Conversations contains global conversation processing configuration
Conversations *ConversationsConfig
// Permissions contains global permission handling configuration
@@ -1520,6 +1522,14 @@ type rawConfig struct {
AgentInactivityTimeout string `yaml:"agent_inactivity_timeout"`
McpInitTimeout string `yaml:"mcp_init_timeout"`
} `yaml:"session"`
+ // Prewarm is the adaptive pre-warming thresholds (mitto-mw0)
+ Prewarm *struct {
+ SessionNewFast string `yaml:"session_new_fast"`
+ McpReady string `yaml:"mcp_ready"`
+ HealthyProbesToUnpin int `yaml:"healthy_probes_to_unpin"`
+ MaxPinDuration string `yaml:"max_pin_duration"`
+ MaxPinnedWorkspaces int `yaml:"max_pinned_workspaces"`
+ } `yaml:"prewarm"`
// MCP is the MCP server configuration
MCP *struct {
Host string `yaml:"host"`
@@ -1886,6 +1896,17 @@ func Parse(data []byte) (*Config, error) {
}
}
+ // Parse prewarm config (mitto-mw0)
+ if raw.Prewarm != nil {
+ cfg.Prewarm = &PrewarmConfig{
+ SessionNewFast: raw.Prewarm.SessionNewFast,
+ McpReady: raw.Prewarm.McpReady,
+ HealthyProbesToUnpin: raw.Prewarm.HealthyProbesToUnpin,
+ MaxPinDuration: raw.Prewarm.MaxPinDuration,
+ MaxPinnedWorkspaces: raw.Prewarm.MaxPinnedWorkspaces,
+ }
+ }
+
// Parse MCP config
if raw.MCP != nil {
cfg.MCP = &MCPConfig{
diff --git a/internal/config/settings.go b/internal/config/settings.go
index 8b2ec0dda..a958f31e0 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -63,6 +63,8 @@ type Settings struct {
UI UIConfig `json:"ui,omitempty"`
// Session contains session storage limits configuration
Session *SessionConfig `json:"session,omitempty"`
+ // Prewarm contains adaptive ACP/MCP pre-warming thresholds (mitto-mw0)
+ Prewarm *PrewarmConfig `json:"prewarm,omitempty"`
// Conversations contains global conversation processing configuration
Conversations *ConversationsConfig `json:"conversations,omitempty"`
// Permissions contains global permission handling configuration
@@ -329,6 +331,149 @@ func (c *SessionConfig) GetStartupLoopDelay() time.Duration {
return time.Duration(c.StartupLoopDelaySeconds) * time.Second
}
+// PrewarmConfig represents adaptive ACP/MCP pre-warming thresholds (mitto-mw0).
+// Pre-warming warms a workspace, probes its health (session/new latency + MCP
+// readiness), and pins a warm keepalive session only for slow/broken workspaces.
+type PrewarmConfig struct {
+ // SessionNewFast is T_fast: session/new latency at/under which a workspace is
+ // considered "fast" and does NOT need pinning. Aligned with the startup
+ // watchdog WARN threshold at 10s.
+ // Values: "" (default, 10s), "5s", "10s", "20s", "30s".
+ SessionNewFast string `json:"session_new_fast,omitempty"`
+ // McpReady is T_mcp: max time for all configured MCP servers to be reachable
+ // before the workspace is flagged as slow.
+ // Values: "" (default, 10s), "5s", "10s", "20s", "30s".
+ McpReady string `json:"mcp_ready,omitempty"`
+ // HealthyProbesToUnpin is the hysteresis N: consecutive healthy probes
+ // required before unpinning a pinned workspace. Default: 3.
+ HealthyProbesToUnpin int `json:"healthy_probes_to_unpin,omitempty"`
+ // MaxPinDuration caps how long a pinned keepalive session is held before
+ // giving up + alerting. "disabled" means no cap.
+ // Values: "" (default, 30m), "disabled", "5m", "15m", "30m", "1h", "2h".
+ MaxPinDuration string `json:"max_pin_duration,omitempty"`
+ // MaxPinnedWorkspaces is the blast-radius cap on simultaneously-pinned
+ // workspaces. Default: 5.
+ MaxPinnedWorkspaces int `json:"max_pinned_workspaces,omitempty"`
+}
+
+// Prewarm defaults (mitto-mw0).
+const (
+ DefaultPrewarmSessionNewFast = 10 * time.Second
+ DefaultPrewarmMcpReady = 10 * time.Second
+ DefaultPrewarmHealthyProbesToUnpin = 3
+ DefaultPrewarmMaxPinDuration = 30 * time.Minute
+ DefaultPrewarmMaxPinnedWorkspaces = 5
+)
+
+// ValidSessionNewFast lists accepted values for PrewarmConfig.SessionNewFast.
+var ValidSessionNewFast = []string{"", "5s", "10s", "20s", "30s"}
+
+// GetSessionNewFast returns the SessionNewFast string, or "" if not set.
+func (c *PrewarmConfig) GetSessionNewFast() string {
+ if c == nil {
+ return ""
+ }
+ return c.SessionNewFast
+}
+
+// ParseSessionNewFast converts the SessionNewFast string to a time.Duration.
+// Returns (duration, true) — this threshold is always enabled. Empty/unknown
+// values fall back to the 10s default.
+func (c *PrewarmConfig) ParseSessionNewFast() (time.Duration, bool) {
+ switch c.GetSessionNewFast() {
+ case "", "10s":
+ return DefaultPrewarmSessionNewFast, true
+ case "5s":
+ return 5 * time.Second, true
+ case "20s":
+ return 20 * time.Second, true
+ case "30s":
+ return 30 * time.Second, true
+ default:
+ return DefaultPrewarmSessionNewFast, true
+ }
+}
+
+// ValidMcpReady lists accepted values for PrewarmConfig.McpReady.
+var ValidMcpReady = []string{"", "5s", "10s", "20s", "30s"}
+
+// GetMcpReady returns the McpReady string, or "" if not set.
+func (c *PrewarmConfig) GetMcpReady() string {
+ if c == nil {
+ return ""
+ }
+ return c.McpReady
+}
+
+// ParseMcpReady converts the McpReady string to a time.Duration.
+// Returns (duration, true) — this threshold is always enabled. Empty/unknown
+// values fall back to the 10s default.
+func (c *PrewarmConfig) ParseMcpReady() (time.Duration, bool) {
+ switch c.GetMcpReady() {
+ case "", "10s":
+ return DefaultPrewarmMcpReady, true
+ case "5s":
+ return 5 * time.Second, true
+ case "20s":
+ return 20 * time.Second, true
+ case "30s":
+ return 30 * time.Second, true
+ default:
+ return DefaultPrewarmMcpReady, true
+ }
+}
+
+// GetHealthyProbesToUnpin returns the hysteresis count, or the default (3)
+// when unset or non-positive.
+func (c *PrewarmConfig) GetHealthyProbesToUnpin() int {
+ if c == nil || c.HealthyProbesToUnpin <= 0 {
+ return DefaultPrewarmHealthyProbesToUnpin
+ }
+ return c.HealthyProbesToUnpin
+}
+
+// ValidMaxPinDurations lists accepted values for PrewarmConfig.MaxPinDuration.
+var ValidMaxPinDurations = []string{"", "disabled", "5m", "15m", "30m", "1h", "2h"}
+
+// GetMaxPinDuration returns the MaxPinDuration string, or "" if not set.
+func (c *PrewarmConfig) GetMaxPinDuration() string {
+ if c == nil {
+ return ""
+ }
+ return c.MaxPinDuration
+}
+
+// ParseMaxPinDuration converts the MaxPinDuration string to a time.Duration.
+// Returns (duration, true) when a cap applies, or (0, false) when "disabled"
+// (no cap). Empty/unknown values fall back to the 30m default.
+func (c *PrewarmConfig) ParseMaxPinDuration() (time.Duration, bool) {
+ switch c.GetMaxPinDuration() {
+ case "disabled":
+ return 0, false
+ case "", "30m":
+ return DefaultPrewarmMaxPinDuration, true
+ case "5m":
+ return 5 * time.Minute, true
+ case "15m":
+ return 15 * time.Minute, true
+ case "1h":
+ return time.Hour, true
+ case "2h":
+ return 2 * time.Hour, true
+ default:
+ return DefaultPrewarmMaxPinDuration, true
+ }
+}
+
+// GetMaxPinnedWorkspaces returns the blast-radius cap, or the default (5)
+// when unset or non-positive.
+func (c *PrewarmConfig) GetMaxPinnedWorkspaces() int {
+ if c == nil || c.MaxPinnedWorkspaces <= 0 {
+ return DefaultPrewarmMaxPinnedWorkspaces
+ }
+ return c.MaxPinnedWorkspaces
+}
+
// ScannerDefenseConfig holds configuration for the scanner defense system.
type ScannerDefenseConfig struct {
// Enabled controls whether scanner defense is active.
@@ -399,6 +544,7 @@ func (s *Settings) ToConfig() *Config {
Web: s.Web,
UI: s.UI,
Session: s.Session,
+ Prewarm: s.Prewarm,
Conversations: s.Conversations,
Permissions: s.Permissions,
RestrictedRunners: s.RestrictedRunners,
@@ -421,6 +567,7 @@ func ConfigToSettings(cfg *Config) *Settings {
Web: cfg.Web,
UI: cfg.UI,
Session: cfg.Session,
+ Prewarm: cfg.Prewarm,
Conversations: cfg.Conversations,
Permissions: cfg.Permissions,
RestrictedRunners: cfg.RestrictedRunners,
diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go
index 46bc860ef..8edecc075 100644
--- a/internal/config/settings_test.go
+++ b/internal/config/settings_test.go
@@ -559,6 +559,120 @@ func TestParseMcpInitTimeout(t *testing.T) {
}
}
+// TestPrewarmConfig_Defaults guards the PrewarmConfig accessor/parse helpers
+// added for mitto-mw0. Empty struct and nil receiver must both return the
+// documented defaults; unknown string values fall back to the defaults; the
+// "disabled" MaxPinDuration returns (0, false).
+func TestPrewarmConfig_Defaults(t *testing.T) {
+ // Nil receiver → all defaults.
+ var nilCfg *PrewarmConfig
+ if d, ok := nilCfg.ParseSessionNewFast(); d != 10*time.Second || !ok {
+ t.Errorf("nil ParseSessionNewFast() = (%s, %t), want (10s, true)", d, ok)
+ }
+ if d, ok := nilCfg.ParseMcpReady(); d != 10*time.Second || !ok {
+ t.Errorf("nil ParseMcpReady() = (%s, %t), want (10s, true)", d, ok)
+ }
+ if d, ok := nilCfg.ParseMaxPinDuration(); d != 30*time.Minute || !ok {
+ t.Errorf("nil ParseMaxPinDuration() = (%s, %t), want (30m, true)", d, ok)
+ }
+ if n := nilCfg.GetHealthyProbesToUnpin(); n != 3 {
+ t.Errorf("nil GetHealthyProbesToUnpin() = %d, want 3", n)
+ }
+ if n := nilCfg.GetMaxPinnedWorkspaces(); n != 5 {
+ t.Errorf("nil GetMaxPinnedWorkspaces() = %d, want 5", n)
+ }
+
+ // Empty struct → same defaults as nil.
+ empty := &PrewarmConfig{}
+ if d, _ := empty.ParseSessionNewFast(); d != 10*time.Second {
+ t.Errorf("empty ParseSessionNewFast() = %s, want 10s", d)
+ }
+ if d, _ := empty.ParseMcpReady(); d != 10*time.Second {
+ t.Errorf("empty ParseMcpReady() = %s, want 10s", d)
+ }
+ if d, _ := empty.ParseMaxPinDuration(); d != 30*time.Minute {
+ t.Errorf("empty ParseMaxPinDuration() = %s, want 30m", d)
+ }
+ if n := empty.GetHealthyProbesToUnpin(); n != 3 {
+ t.Errorf("empty GetHealthyProbesToUnpin() = %d, want 3", n)
+ }
+ if n := empty.GetMaxPinnedWorkspaces(); n != 5 {
+ t.Errorf("empty GetMaxPinnedWorkspaces() = %d, want 5", n)
+ }
+
+ // Explicit values parse; unknown values fall back to default.
+ cases := []struct {
+ name string
+ cfg PrewarmConfig
+ snf time.Duration
+ mcp time.Duration
+ pin time.Duration
+ pinOk bool
+ }{
+ {"explicit", PrewarmConfig{SessionNewFast: "5s", McpReady: "20s", MaxPinDuration: "1h"}, 5 * time.Second, 20 * time.Second, time.Hour, true},
+ {"disabled_pin", PrewarmConfig{MaxPinDuration: "disabled"}, 10 * time.Second, 10 * time.Second, 0, false},
+ {"unknown", PrewarmConfig{SessionNewFast: "bogus", McpReady: "bogus", MaxPinDuration: "bogus"}, 10 * time.Second, 10 * time.Second, 30 * time.Minute, true},
+ }
+ for _, tc := range cases {
+ if d, _ := tc.cfg.ParseSessionNewFast(); d != tc.snf {
+ t.Errorf("[%s] ParseSessionNewFast() = %s, want %s", tc.name, d, tc.snf)
+ }
+ if d, _ := tc.cfg.ParseMcpReady(); d != tc.mcp {
+ t.Errorf("[%s] ParseMcpReady() = %s, want %s", tc.name, d, tc.mcp)
+ }
+ d, ok := tc.cfg.ParseMaxPinDuration()
+ if d != tc.pin || ok != tc.pinOk {
+ t.Errorf("[%s] ParseMaxPinDuration() = (%s, %t), want (%s, %t)", tc.name, d, ok, tc.pin, tc.pinOk)
+ }
+ }
+}
+
+// TestPrewarmConfig_LoadFromSettings verifies the Prewarm section is wired
+// through settings.json load and reaches Config.Prewarm intact (mitto-mw0).
+func TestPrewarmConfig_LoadFromSettings(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv(appdir.MittoDirEnv, tmpDir)
+ appdir.ResetCache()
+ t.Cleanup(appdir.ResetCache)
+
+ settingsPath := filepath.Join(tmpDir, appdir.SettingsFileName)
+ customSettings := `{
+ "acp_servers": [{"name": "test", "command": "cmd"}],
+ "prewarm": {
+ "session_new_fast": "5s",
+ "mcp_ready": "20s",
+ "healthy_probes_to_unpin": 4,
+ "max_pin_duration": "1h",
+ "max_pinned_workspaces": 7
+ }
+ }`
+ if err := os.WriteFile(settingsPath, []byte(customSettings), 0644); err != nil {
+ t.Fatalf("failed to create test settings.json: %v", err)
+ }
+ cfg, err := LoadSettings()
+ if err != nil {
+ t.Fatalf("LoadSettings() failed: %v", err)
+ }
+ if cfg.Prewarm == nil {
+ t.Fatal("Prewarm config should not be nil")
+ }
+ if d, _ := cfg.Prewarm.ParseSessionNewFast(); d != 5*time.Second {
+ t.Errorf("SessionNewFast = %s, want 5s", d)
+ }
+ if d, _ := cfg.Prewarm.ParseMcpReady(); d != 20*time.Second {
+ t.Errorf("McpReady = %s, want 20s", d)
+ }
+ if n := cfg.Prewarm.GetHealthyProbesToUnpin(); n != 4 {
+ t.Errorf("HealthyProbesToUnpin = %d, want 4", n)
+ }
+ if d, ok := cfg.Prewarm.ParseMaxPinDuration(); d != time.Hour || !ok {
+ t.Errorf("MaxPinDuration = (%s, %t), want (1h, true)", d, ok)
+ }
+ if n := cfg.Prewarm.GetMaxPinnedWorkspaces(); n != 7 {
+ t.Errorf("MaxPinnedWorkspaces = %d, want 7", n)
+ }
+}
+
func TestContextFlushCommand_RoundTrip(t *testing.T) {
original := &Config{
ACPServers: []ACPServer{
diff --git a/internal/conversation/session_info.go b/internal/conversation/session_info.go
index 791206989..f324b497b 100644
--- a/internal/conversation/session_info.go
+++ b/internal/conversation/session_info.go
@@ -40,4 +40,15 @@ type SessionInfo struct {
// the correct signal to distinguish a wedged process from one making genuine,
// slow progress. Zero if no streamed activity has been observed.
LastStreamActivityAt time.Time
+ // Pinned marks this session as a pinned keepalive that must survive GC. Used
+ // by the adaptive pre-warming path to keep a warm session alive for slow or
+ // broken workspaces so the first real prompt does not pay the cold-start cost.
+ Pinned bool
+ // PinReason is a human-readable reason for the pin (e.g. "slow session/new",
+ // "mcp-init timeout"). Empty when Pinned is false.
+ PinReason string
+ // PinExpiry optionally caps how long the pin is honoured. When non-nil and in
+ // the past, the pin is EXPIRED and must NOT be honoured — the session then
+ // falls through to the normal GC checks. nil means no expiry.
+ PinExpiry *time.Time
}
diff --git a/internal/web/server.go b/internal/web/server.go
index 7a7c96159..307c193cb 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -611,6 +611,15 @@ func NewServer(config Config) (*Server, error) {
return out.Servers, nil
}
+ // Adaptive pre-warming pin re-evaluation (mitto-mw0): each MCP backoff
+ // round asks the process manager to re-run its health verdict for the
+ // workspace so pin/unpin decisions ride the same schedule as MCP
+ // reachability probes (no separate timer required). The bare wrapper
+ // keeps the auxiliary package free of the acpproc dependency.
+ auxiliaryManager.PrewarmPinReevaluator = func(workspaceUUID string) {
+ acpProcessMgr.ReevaluatePrewarmPin(workspaceUUID, logger)
+ }
+
// Wire per-agent stderr patterns resolver (mitto-k6h). Given an ACP server
// name it resolves the ACP server → agent metadata → StderrPatterns and
// compiles them once. Results are cached per ACP server name so
@@ -766,6 +775,26 @@ func NewServer(config Config) (*Server, error) {
s.BroadcastMCPInitTimedOut(workspaceUUID, workspaceName, workingDir)
})
+ // Adaptive pre-warming (mitto-mw0): expose the global PrewarmConfig to the
+ // process manager and surface pin alerts as UI toasts. The pin controller
+ // itself runs inside prewarmAuxiliarySessions and ReevaluatePrewarmPin;
+ // this only wires the callback and the config accessor.
+ acpProcessMgr.PrewarmConfigProvider = func() *configPkg.PrewarmConfig {
+ if config.MittoConfig == nil {
+ return nil
+ }
+ return config.MittoConfig.Prewarm
+ }
+ acpProcessMgr.SetOnPrewarmPinAlert(func(workspaceUUID, reason string, expired bool) {
+ workspaceName := ""
+ workingDir := ""
+ if ws := sessionMgr.GetWorkspaceByUUID(workspaceUUID); ws != nil {
+ workspaceName = ws.Name
+ workingDir = ws.WorkingDir
+ }
+ s.BroadcastPrewarmPinAlert(workspaceUUID, workspaceName, workingDir, reason, expired)
+ })
+
// Initialize MCP server.
// This serves both global tools and session-scoped tools.
// The MCP server is always started; only its bind host/port are configurable.
@@ -1731,6 +1760,28 @@ func (s *Server) BroadcastMCPInitTimedOut(workspaceUUID, workspaceName, workingD
}
}
+// BroadcastPrewarmPinAlert notifies all connected clients that the adaptive
+// pre-warming controller pinned a workspace due to a slow/broken MCP init,
+// or that a stuck pin was force-expired because its max_pin_duration cap
+// elapsed (mitto-mw0). expired=true distinguishes the two cases so the UI
+// can pick the right toast copy.
+func (s *Server) BroadcastPrewarmPinAlert(workspaceUUID, workspaceName, workingDir, reason string, expired bool) {
+ s.eventsManager.Broadcast(WSMsgTypePrewarmPinAlert, map[string]interface{}{
+ "workspace_uuid": workspaceUUID,
+ "workspace_name": workspaceName,
+ "working_dir": workingDir,
+ "reason": reason,
+ "expired": expired,
+ })
+ if s.logger != nil {
+ s.logger.Warn("Broadcast prewarm pin alert",
+ "workspace_uuid", workspaceUUID,
+ "reason", reason,
+ "expired", expired,
+ "clients", s.eventsManager.ClientCount())
+ }
+}
+
// BroadcastBeadsCleanupProgress notifies all connected clients about the
// progress of a background bulk closed-issue cleanup.
func (s *Server) BroadcastBeadsCleanupProgress(workingDir string, deleted, total int, done bool, errMsg string) {
diff --git a/internal/web/ws_messages.go b/internal/web/ws_messages.go
index 1a38031f3..e0b65644e 100644
--- a/internal/web/ws_messages.go
+++ b/internal/web/ws_messages.go
@@ -240,6 +240,14 @@ const (
// Data: { "workspace_uuid": string, "workspace_name": string, "working_dir": string }.
WSMsgTypeMCPInitTimedOut = "mcp_init_timed_out"
+ // WSMsgTypePrewarmPinAlert notifies that the adaptive pre-warming controller
+ // (mitto-mw0) pinned a workspace due to a slow/broken MCP init, or that a
+ // stuck pin was force-expired because its max_pin_duration cap elapsed. The
+ // UI can surface this as a warning toast pointing at MCP configuration.
+ // Data: { "workspace_uuid": string, "workspace_name": string, "working_dir": string,
+ // "reason": string, "expired": bool }.
+ WSMsgTypePrewarmPinAlert = "prewarm_pin_alert"
+
// WSMsgTypeQueueUpdated notifies that the message queue state changed.
// Sent when messages are added, removed, or the queue is cleared.
// Data: { "queue_length": int, "action": string, "message_id": string }
From bcbc0d05cb1345eb141188986f0c5a29eec6c594 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 12:43:10 +0200
Subject: [PATCH 021/240] fix(acpproc): re-arm MCP-init progress on every
handshake episode (mitto-29q)
coldMCPBudget's mcpInitDone latch only granted the extended MCPInitTimeout
budget for the FIRST successful session/new on a shared process. Agents
like Auggie re-run the full MCP initialize handshake on EVERY session/new,
so every subsequent conversation or unarchive on the same warm process fell
back to the normal 25s RPC deadline and failed with context deadline
exceeded while the agent was still waiting on MCP servers.
Edge-detect the false->true transition of mcpInitInProgress in
onMCPInitProgress (CompareAndSwap) so it fires once per handshake episode
instead of once per process lifetime, and let coldMCPBudget /
RecommendedLoadTimeout re-grant the extended budget whenever
mcpInitInProgress is true, even after mcpInitDone has latched.
Adds regression coverage for budget re-grant after a prior success
(TestColdMCPBudget_ReinitAfterFirstSuccessReExtends) and for reverting to
the normal budget once idle (TestColdMCPBudget_WarmIdleNotInProgressRevertsToNormal).
---
internal/acpproc/mcp_init_budget_test.go | 175 ++++++++++++++++++
internal/acpproc/shared_acp_process.go | 140 ++++++++++----
.../conversation/bgsession_acp_process.go | 25 ++-
.../bgsession_acp_process_test.go | 14 +-
4 files changed, 303 insertions(+), 51 deletions(-)
diff --git a/internal/acpproc/mcp_init_budget_test.go b/internal/acpproc/mcp_init_budget_test.go
index 19539b49a..cfc1ba942 100644
--- a/internal/acpproc/mcp_init_budget_test.go
+++ b/internal/acpproc/mcp_init_budget_test.go
@@ -6,6 +6,9 @@ package acpproc
// internal MCP-server handshake before Mitto times out.
import (
+ "context"
+ "sync"
+ "sync/atomic"
"testing"
"time"
)
@@ -74,6 +77,38 @@ func TestColdMCPBudget_WarmProcessRevertsToNormal(t *testing.T) {
}
}
+func TestColdMCPBudget_ReinitAfterFirstSuccessReExtends(t *testing.T) {
+ // mitto-29q: an agent that re-runs the MCP handshake on a later session/new
+ // (mcpInitInProgress=true) must get the extended budget re-granted even though
+ // a prior session already succeeded (mcpInitDone=true).
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+ p.mcpInitDone.Store(true)
+ p.mcpInitInProgress.Store(true)
+
+ perAttempt, total, extended := p.coldMCPBudget(true /*hasMCPServers*/)
+ if !extended {
+ t.Fatal("expected extended=true when mcpInitInProgress even though mcpInitDone=true")
+ }
+ if perAttempt != 240*time.Second || total != 240*time.Second {
+ t.Fatalf("budgets = (%v, %v), want (240s, 240s)", perAttempt, total)
+ }
+}
+
+func TestColdMCPBudget_WarmIdleNotInProgressRevertsToNormal(t *testing.T) {
+ // After a success closes the window (mcpInitInProgress=false) and no new
+ // handshake is running, revert to the normal budget (mitto-29q).
+ p := &SharedACPProcess{}
+ p.config.MCPInitTimeout = 240 * time.Second
+ p.mcpInitDone.Store(true)
+ p.mcpInitInProgress.Store(false)
+
+ _, _, extended := p.coldMCPBudget(true)
+ if extended {
+ t.Fatal("expected extended=false when warm and no handshake in progress")
+ }
+}
+
func TestRecommendedLoadTimeout(t *testing.T) {
p := &SharedACPProcess{}
p.config.MCPInitTimeout = 240 * time.Second
@@ -90,6 +125,12 @@ func TestRecommendedLoadTimeout(t *testing.T) {
if got := p.RecommendedLoadTimeout(true); got != 0 {
t.Errorf("warm: got %v, want 0", got)
}
+ // Warm but a new handshake is in progress: re-widen (mitto-29q).
+ p.mcpInitInProgress.Store(true)
+ if got := p.RecommendedLoadTimeout(true); got != 240*time.Second {
+ t.Errorf("warm+reinit: got %v, want 240s", got)
+ }
+ p.mcpInitInProgress.Store(false)
// Disabled: 0.
p2 := &SharedACPProcess{}
p2.config.MCPInitTimeout = 0
@@ -98,6 +139,140 @@ func TestRecommendedLoadTimeout(t *testing.T) {
}
}
+// --- Cold-start admission gate (mitto-8tb) ---
+
+// newTestProcessWithGate returns a SharedACPProcess with just enough state to
+// exercise acquireColdStartGate independently of a real ACP subprocess.
+func newTestProcessWithGate() *SharedACPProcess {
+ return &SharedACPProcess{coldStartGate: make(chan struct{}, 1)}
+}
+
+func TestColdStartGate_AcquireReleaseAllowsSerialCallers(t *testing.T) {
+ p := newTestProcessWithGate()
+
+ release1, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire failed: %v", err)
+ }
+ release1()
+
+ release2, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Fatalf("second acquire after release failed: %v", err)
+ }
+ release2()
+}
+
+func TestColdStartGate_BlocksSecondCallerUntilFirstReleases(t *testing.T) {
+ p := newTestProcessWithGate()
+
+ release1, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire failed: %v", err)
+ }
+
+ // Second acquire must not complete until release1 fires.
+ var acquired atomic.Bool
+ done := make(chan struct{})
+ go func() {
+ release2, err := p.acquireColdStartGate(context.Background())
+ if err == nil {
+ acquired.Store(true)
+ release2()
+ }
+ close(done)
+ }()
+
+ time.Sleep(20 * time.Millisecond)
+ if acquired.Load() {
+ t.Fatal("second acquire completed before first release — gate not serializing")
+ }
+
+ release1()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("second acquire never completed after release")
+ }
+ if !acquired.Load() {
+ t.Fatal("second acquire did not report success")
+ }
+}
+
+func TestColdStartGate_HonorsCtxCancellationWhileWaiting(t *testing.T) {
+ p := newTestProcessWithGate()
+
+ // Hold the gate to force the next acquire to wait.
+ release1, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire failed: %v", err)
+ }
+ defer release1()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ release2, err := p.acquireColdStartGate(ctx)
+ if err == nil {
+ release2()
+ t.Fatal("expected context error while gate held by another caller")
+ }
+ if time.Since(start) > 500*time.Millisecond {
+ t.Fatalf("acquire took too long to honour ctx cancellation: %v", time.Since(start))
+ }
+}
+
+func TestColdStartGate_NilGateIsNoOp(t *testing.T) {
+ // A zero-value SharedACPProcess (no gate) must be safe to call — the RPC
+ // paths should just proceed as before.
+ p := &SharedACPProcess{}
+ release, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Fatalf("nil-gate acquire returned error: %v", err)
+ }
+ if release == nil {
+ t.Fatal("nil-gate acquire returned nil release func")
+ }
+ release() // must not panic
+}
+
+func TestColdStartGate_SerializesUnderConcurrency(t *testing.T) {
+ // N goroutines racing the gate must observe strictly serialized entry —
+ // only one holder at a time — with all eventually completing.
+ p := newTestProcessWithGate()
+
+ var inFlight atomic.Int32
+ var maxInFlight atomic.Int32
+ var wg sync.WaitGroup
+ const N = 8
+ wg.Add(N)
+ for i := 0; i < N; i++ {
+ go func() {
+ defer wg.Done()
+ release, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Errorf("acquire failed: %v", err)
+ return
+ }
+ now := inFlight.Add(1)
+ for {
+ prev := maxInFlight.Load()
+ if now <= prev || maxInFlight.CompareAndSwap(prev, now) {
+ break
+ }
+ }
+ time.Sleep(5 * time.Millisecond)
+ inFlight.Add(-1)
+ release()
+ }()
+ }
+ wg.Wait()
+ if got := maxInFlight.Load(); got != 1 {
+ t.Fatalf("gate did not serialize callers: max in-flight = %d, want 1", got)
+ }
+}
+
func TestBeginMCPInitWindow_ResetsPerCall(t *testing.T) {
p := &SharedACPProcess{}
p.mcpInitTimedOut.Store(true)
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index 72eabdb98..b4e79f5cf 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -245,9 +245,12 @@ type SharedACPProcessConfig struct {
// sessionCreateAttemptTimeout/sessionCreateTotalBudget are used. See
// SessionConfig.ParseMcpInitTimeout for the rationale (mitto-8ul.1).
MCPInitTimeout time.Duration
- // OnMCPInitProgress is called at most once, from the stderr-monitor goroutine, the
- // first time the agent reports it is blocked waiting for MCP servers to initialize.
+ // OnMCPInitProgress is called once per MCP-init handshake episode (re-armed
+ // after each successful session RPC), from the stderr-monitor goroutine, when
+ // the agent reports it is blocked waiting for MCP servers to initialize.
// Used by the web layer to emit an "MCP initializing" UI notification (mitto-8ul.1).
+ // Agents like Auggie re-run the MCP handshake on every session/new, so this
+ // callback fires again on subsequent per-session re-handshakes (mitto-29q).
// Optional.
OnMCPInitProgress func()
// OnMCPInitTimeout is called at most once when the agent reports its internal
@@ -348,21 +351,35 @@ type SharedACPProcess struct {
// to invalidate caches (e.g., auxiliary sessions) that reference old session IDs.
onRestart func()
- // MCP-init lifecycle tracking (mitto-8ul.1). Set from the stderr-monitor goroutine.
- // mcpInitInProgress flips to 1 when the agent first reports it is waiting for MCP
- // servers to initialize on this process. Once a session/new (or session/load) call
- // succeeds we treat the cold-start window as closed and revert to normal budgets.
- // mcpInitTimedOut flips to 1 when the agent reports its internal MCP-init wait
- // budget elapsed; the currently-pending NewSession call watches this via
- // mcpInitTimeoutCh so it can abort promptly with an actionable error rather than
- // waiting for the RPC deadline. mcpInitTimeoutCh is (re-)created per session/new
- // attempt so a signal from a previous attempt does not fire spuriously.
+ // MCP-init lifecycle tracking (mitto-8ul.1, mitto-29q). Set from the stderr-monitor
+ // goroutine. mcpInitInProgress flips to true (edge-detected) when the agent reports
+ // it is waiting for MCP servers to initialize, and is cleared to false on each
+ // successful session/new or session/load RPC. Agents like Auggie re-run the MCP
+ // handshake on every session/new, so this field re-arms per handshake episode and
+ // is used by coldMCPBudget / RecommendedLoadTimeout to re-grant the extended budget
+ // for per-session re-handshakes even after mcpInitDone has latched (mitto-29q).
+ // mcpInitDone latches once the first session RPC succeeds; on its own it would
+ // starve every subsequent session on agents that re-handshake, hence the additional
+ // mcpInitInProgress gate. mcpInitTimedOut flips to true when the agent reports its
+ // internal MCP-init wait budget elapsed; the currently-pending NewSession call
+ // watches this via mcpInitTimeoutCh so it can abort promptly with an actionable
+ // error rather than waiting for the RPC deadline. mcpInitTimeoutCh is (re-)created
+ // per session/new attempt so a signal from a previous attempt does not fire
+ // spuriously.
mcpInitInProgress atomic.Bool
mcpInitDone atomic.Bool
mcpInitTimedOut atomic.Bool
mcpInitMu sync.Mutex
mcpInitTimeoutCh chan struct{}
+ // coldStartGate serializes concurrent NewSession/LoadSession callers on a
+ // still-cold process (mitto-8tb). N conversations racing their deferred
+ // session/new against a fresh shared process would each make the agent re-run
+ // its MCP handshake in parallel, multiplying MCP child processes (36-78 obs.)
+ // and self-inflicting saturation. The gate is a capacity-1 semaphore held only
+ // on the cold path (extendedBudget=true); warm calls bypass it entirely.
+ coldStartGate chan struct{}
+
// Logger
logger *slog.Logger
}
@@ -373,12 +390,13 @@ func NewSharedACPProcess(ctx context.Context, config SharedACPProcessConfig) (*S
processCtx, processCancel := context.WithCancel(ctx)
p := &SharedACPProcess{
- config: config,
- client: NewMultiplexClient(),
- ctx: processCtx,
- ctxCancel: processCancel,
- logger: config.Logger,
- setModelSem: make(chan struct{}, 1),
+ config: config,
+ client: NewMultiplexClient(),
+ ctx: processCtx,
+ ctxCancel: processCancel,
+ logger: config.Logger,
+ setModelSem: make(chan struct{}, 1),
+ coldStartGate: make(chan struct{}, 1),
}
if err := p.startProcess(); err != nil {
@@ -545,12 +563,20 @@ func (p *SharedACPProcess) doStartProcess() (string, error) {
p.recordDegradedStderr()
}
- // MCP-init lifecycle callbacks (mitto-8ul.1). Both are fired at most once per
- // process lifetime by the stderr monitor. The pending NewSession call watches
- // mcpInitTimeoutCh so it can abort promptly on a hard timeout signal instead of
- // waiting for the RPC deadline.
+ // MCP-init lifecycle callbacks (mitto-8ul.1). The progress callback re-arms per
+ // handshake episode (mitto-29q); the timeout callback stays effectively one-shot
+ // per process because it closes mcpInitTimeoutCh. The pending NewSession call
+ // watches mcpInitTimeoutCh so it can abort promptly on a hard timeout signal
+ // instead of waiting for the RPC deadline.
onMCPInitProgress := func() {
- p.mcpInitInProgress.Store(true)
+ // Edge-detected (mitto-29q): only act on the false->true transition so a
+ // handshake that logs "Waiting for MCP" repeatedly does not spam the log or
+ // the mcp_initializing broadcast. The window is re-armed after each success
+ // clears mcpInitInProgress, so a subsequent per-session re-handshake fires
+ // this again.
+ if !p.mcpInitInProgress.CompareAndSwap(false, true) {
+ return
+ }
if p.logger != nil {
p.logger.Info("ACP agent reports MCP servers initializing",
"acp_server", p.config.ACPServer)
@@ -1128,21 +1154,25 @@ func shouldFailFastCreateAttempt(attempt int, saturated bool, hasDeadline bool,
// current NewSession/LoadSession attempt (mitto-8ul.1) and returns the
// per-attempt and total budget to use.
//
-// The extended budget applies only when ALL of the following hold:
+// The extended budget applies when ALL of the following hold:
// - MCPInitTimeout > 0 (the operator has not disabled it).
-// - The process has not yet observed a successful cold-start session RPC
-// (mcpInitDone is false). Subsequent sessions on the same warm process use
-// the normal budget because MCP servers are already initialized inside the
-// agent.
+// - Either the process has not yet observed a successful cold-start session RPC
+// (mcpInitDone is false), OR an MCP-init handshake is currently in progress
+// (mcpInitInProgress is true). The extended budget is ALSO re-granted whenever
+// mcpInitInProgress is true — i.e. the agent is (re-)running an MCP handshake —
+// because agents like Auggie re-handshake MCP on every session/new, so a
+// one-shot mcpInitDone latch would starve every session after the first
+// (mitto-29q).
//
// The extended budget does NOT gate on the request carrying MCP servers, because
// Mitto attaches MCP through a globally-registered server (not per session/new
// call), and even agents whose only MCP is configured globally block session/new
// on the same handshake. Applying the widened budget to every cold session/new is
// safe: it is capped by the actual RPC deadline anyway and reverts to the normal
-// 25 s once one call succeeds. When the extended budget applies both the per-
-// attempt and total budgets are widened to MCPInitTimeout, sized above the
-// agent's own MCP-init wait (e.g. Auggie's 225 s) plus margin.
+// 25 s once one call succeeds AND no new handshake is running. When the extended
+// budget applies both the per-attempt and total budgets are widened to
+// MCPInitTimeout, sized above the agent's own MCP-init wait (e.g. Auggie's 225 s)
+// plus margin.
//
// hasMCPServers is retained on the signature for observability / future gating.
func (p *SharedACPProcess) coldMCPBudget(hasMCPServers bool) (perAttempt time.Duration, total time.Duration, extended bool) {
@@ -1150,24 +1180,42 @@ func (p *SharedACPProcess) coldMCPBudget(hasMCPServers bool) (perAttempt time.Du
if p.config.MCPInitTimeout <= 0 {
return sessionCreateAttemptTimeout, sessionCreateTotalBudget, false
}
- if p.mcpInitDone.Load() {
+ if p.mcpInitDone.Load() && !p.mcpInitInProgress.Load() {
return sessionCreateAttemptTimeout, sessionCreateTotalBudget, false
}
return p.config.MCPInitTimeout, p.config.MCPInitTimeout, true
}
+// acquireColdStartGate blocks until the capacity-1 cold-start gate is acquired
+// or ctx is done (mitto-8tb). Returns a release func (nil on error). Only cold
+// callers (extendedBudget=true) invoke this; warm calls bypass it.
+func (p *SharedACPProcess) acquireColdStartGate(ctx context.Context) (release func(), err error) {
+ if p.coldStartGate == nil {
+ return func() {}, nil
+ }
+ select {
+ case p.coldStartGate <- struct{}{}:
+ return func() { <-p.coldStartGate }, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
// RecommendedLoadTimeout implements conversation.SharedProcess (mitto-8ul.1).
// For a cold process (mcpInitDone=false), returns MCPInitTimeout so the caller's
// outer timeout does not truncate the process's own extended budget. Returns 0
-// once the process has served one successful cold-start session RPC. The
-// hasMCPServers hint is retained for future per-request gating; it is not
-// currently load-bearing because Mitto attaches MCP globally.
+// once the process is warm (mcpInitDone=true) AND no handshake is currently
+// running (mcpInitInProgress=false). Re-widens to MCPInitTimeout while
+// mcpInitInProgress is true so per-session re-handshakes on agents that re-run
+// the MCP init on every session/new (e.g. Auggie) still get the extended budget
+// (mitto-29q). The hasMCPServers hint is retained for future per-request gating;
+// it is not currently load-bearing because Mitto attaches MCP globally.
func (p *SharedACPProcess) RecommendedLoadTimeout(hasMCPServers bool) time.Duration {
_ = hasMCPServers
if p.config.MCPInitTimeout <= 0 {
return 0
}
- if p.mcpInitDone.Load() {
+ if p.mcpInitDone.Load() && !p.mcpInitInProgress.Load() {
return 0
}
return p.config.MCPInitTimeout
@@ -1343,6 +1391,19 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
defer budgetCancel()
}
+ // Cold-start admission gate (mitto-8tb): serialize concurrent cold session/new
+ // callers so the agent's MCP handshake fires once and warms the process before
+ // the remaining sessions proceed, instead of N conversations stampeding the
+ // handshake in parallel. Warm callers (extendedBudget=false) bypass the gate.
+ // Honours budgetCtx so a wedged holder can't block a caller past its deadline.
+ if extendedBudget {
+ release, err := p.acquireColdStartGate(budgetCtx)
+ if err != nil {
+ return nil, fmt.Errorf("session/new: context cancelled while waiting for cold-start gate: %w", err)
+ }
+ defer release()
+ }
+
// Arm the MCP-init timeout watch so a hard timeout signal from the agent's
// stderr can abort the pending RPC promptly (mitto-8ul.1). Only meaningful for
// requests that carry MCP servers on a not-yet-warm process; harmless otherwise.
@@ -1444,6 +1505,7 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
if err == nil {
p.recordRPCSuccess()
p.mcpInitDone.Store(true)
+ p.mcpInitInProgress.Store(false) // close the MCP-init window (mitto-29q)
handle := &conversation.SessionHandle{
SessionID: string(sessResp.SessionId),
Process: p,
@@ -1579,6 +1641,13 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
perAttemptBudget, _, extendedBudget := p.coldMCPBudget(len(mcpServers) > 0)
var mcpTimeoutCh <-chan struct{}
if extendedBudget {
+ // Cold-start admission gate (mitto-8tb): serialize concurrent cold callers.
+ // Honours the caller ctx so a wedged holder can't block past its deadline.
+ release, gateErr := p.acquireColdStartGate(ctx)
+ if gateErr != nil {
+ return nil, fmt.Errorf("session/load: context cancelled while waiting for cold-start gate: %w", gateErr)
+ }
+ defer release()
mcpTimeoutCh = p.beginMCPInitWindow()
if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > perAttemptBudget {
var loadCancel context.CancelFunc
@@ -1634,6 +1703,7 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
p.recordRPCSuccess()
p.mcpInitDone.Store(true)
+ p.mcpInitInProgress.Store(false) // close the MCP-init window (mitto-29q)
handle := &conversation.SessionHandle{
SessionID: acpSessionID,
Capabilities: *caps,
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index 50de7f4e5..98fd6caec 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -534,11 +534,12 @@ var mcpInitTimeoutPattern = regexp.MustCompile(`(?i)mcp initialization timed out
// detected in the stderr output, enabling early process death signaling.
// If onFirstActivity is non-nil, it is called (at most once) the first time any bytes
// are observed on stderr — used by the startup watchdog to detect "live" processes.
-// If onMCPInitProgress is non-nil, it is called (at most once) when the agent reports it
-// is blocked waiting for MCP servers to initialize. If onMCPInitTimeout is non-nil, it
-// is called (at most once) when the agent reports its MCP-init wait has timed out —
-// callers use this to abort the pending session/new promptly with an actionable error
-// (mitto-8ul.1). Neither MCP signal contributes to crash detection.
+// If onMCPInitProgress is non-nil, it is called on each chunk reporting the agent is
+// waiting for MCP servers (re-fires per handshake episode; callers must dedup if
+// needed — mitto-29q). If onMCPInitTimeout is non-nil, it is called (at most once)
+// when the agent reports its MCP-init wait has timed out — callers use this to abort
+// the pending session/new promptly with an actionable error (mitto-8ul.1). Neither
+// MCP signal contributes to crash detection.
//
// If onDegraded is non-nil, it is called every time a stderr chunk matches a
// per-agent Degraded regex (mitto-k6h). Unlike onCrashDetected, onDegraded is
@@ -567,7 +568,6 @@ func StartStderrMonitor(
go func() {
crashSignaled := false
activitySignaled := false
- mcpProgressSignaled := false
mcpTimeoutSignaled := false
buf := make([]byte, 4096)
for {
@@ -618,13 +618,18 @@ func StartStderrMonitor(
// MCP-init lifecycle signals (mitto-8ul.1): tolerant regex matches
// so the exact phrasing/count in the agent's log line is not load-bearing.
- if (onMCPInitProgress != nil && !mcpProgressSignaled) ||
- (onMCPInitTimeout != nil && !mcpTimeoutSignaled) {
+ //
+ // MCP-init progress fires on EVERY matching chunk (mitto-29q): agents
+ // like Auggie re-run the MCP handshake on every session/new, so a
+ // one-shot latch would only widen the budget for the first-ever
+ // handshake. Duplicate logs/broadcasts are suppressed by the
+ // CompareAndSwap edge-detection inside the onMCPInitProgress callback.
+ // The hard-timeout signal remains one-shot.
+ if onMCPInitProgress != nil || (onMCPInitTimeout != nil && !mcpTimeoutSignaled) {
if chunkStr == "" {
chunkStr = string(buf[:n])
}
- if !mcpProgressSignaled && onMCPInitProgress != nil && mcpInitProgressPattern.MatchString(chunkStr) {
- mcpProgressSignaled = true
+ if onMCPInitProgress != nil && mcpInitProgressPattern.MatchString(chunkStr) {
onMCPInitProgress()
}
if !mcpTimeoutSignaled && onMCPInitTimeout != nil && mcpInitTimeoutPattern.MatchString(chunkStr) {
diff --git a/internal/conversation/bgsession_acp_process_test.go b/internal/conversation/bgsession_acp_process_test.go
index a632e6219..0792e4233 100644
--- a/internal/conversation/bgsession_acp_process_test.go
+++ b/internal/conversation/bgsession_acp_process_test.go
@@ -39,9 +39,11 @@ func TestStartStderrMonitor_HeapOOM_TriggersCrashDetection(t *testing.T) {
}
// TestStartStderrMonitor_MCPInitProgress_TriggersCallback is a regression test for
-// mitto-8ul.1: when the agent writes a "Waiting for N MCP server(s) to initialize"
-// line the stderr monitor must invoke onMCPInitProgress at most once so the UI can
-// display an "initializing" hint and the process can widen its RPC budget.
+// mitto-8ul.1 / mitto-29q: when the agent writes a "Waiting for N MCP server(s) to
+// initialize" line the stderr monitor must invoke onMCPInitProgress on each matching
+// chunk. Dedup is now the callback's responsibility (edge-detected CompareAndSwap on
+// mcpInitInProgress) so a per-session re-handshake after the first success re-fires
+// the callback and re-grants the extended MCP-init budget.
func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
pr, pw := io.Pipe()
collector := NewStderrCollector(8192, nil)
@@ -53,7 +55,7 @@ func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
StartStderrMonitor(pr, collector, nil, nil, onProgress, onTimeout, nil, nil)
go func() {
- // Two lines to prove the callback still fires only once.
+ // Two matching lines: the monitor must invoke the callback for each.
_, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize\n"))
_, _ = pw.Write([]byte("Waiting for 3 MCP servers to initialize (still)\n"))
_ = pw.Close()
@@ -61,8 +63,8 @@ func TestStartStderrMonitor_MCPInitProgress_TriggersCallback(t *testing.T) {
// Give the goroutine a moment to consume the stream.
time.Sleep(200 * time.Millisecond)
- if progressCalls != 1 {
- t.Fatalf("expected onMCPInitProgress to be called exactly once, got %d", progressCalls)
+ if progressCalls < 1 {
+ t.Fatalf("expected onMCPInitProgress to be called at least once, got %d", progressCalls)
}
}
From 7d250deb139a66c1c66407c5d49e3225ca06a09e Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 12:43:22 +0200
Subject: [PATCH 022/240] fix(conversation): add watchdog to deferred
session/new handshake (mitto-f51)
PromptWithMeta's deferred session/new handshake ran with the session's base
context (no deadline) while isPrompting was already latched true. On a
cold-start MCP handshake this could sit for up to ~240s per retry attempt
with no watchdog armed and no streamed activity, so a wedged handshake left
the conversation stuck in is_prompting forever with no recovery short of
archive/unarchive or a manual ForceReset.
Add pdRecommendedHandshakeDeadline() to read the shared process's own
RecommendedLoadTimeout hint, and bound each completeDeferredHandshake
attempt in a goroutine watched by runHandshakeWithWatchdog against that
deadline plus a margin (falling back to handshakeWatchdogFallback when the
process reports no recommendation). A watchdog trip surfaces a friendly
"still starting up" message, resets prompting state, and does not retry
(the orphaned goroutine still holds the shared process, so retrying would
just re-hang).
Adds regression coverage for the watchdog firing on a wedged handshake and
for the fallback deadline being used when the dependency reports zero.
---
internal/conversation/bgsession_prompt.go | 14 ++
internal/conversation/prompt_dispatcher.go | 79 ++++++++++-
.../conversation/prompt_dispatcher_test.go | 125 +++++++++++++++++-
3 files changed, 211 insertions(+), 7 deletions(-)
diff --git a/internal/conversation/bgsession_prompt.go b/internal/conversation/bgsession_prompt.go
index 0025be242..4a33c7c1a 100644
--- a/internal/conversation/bgsession_prompt.go
+++ b/internal/conversation/bgsession_prompt.go
@@ -802,6 +802,20 @@ func (bs *BackgroundSession) pdCompleteDeferredHandshake() error {
return bs.completeDeferredHandshake()
}
+// pdRecommendedHandshakeDeadline reads the extended-budget hint from the shared
+// process so completeHandshakeOrAbort can bound a hung deferred session/new
+// against it (mitto-f51). Returns 0 when no shared process is configured or the
+// process signals no widening is needed.
+func (bs *BackgroundSession) pdRecommendedHandshakeDeadline() time.Duration {
+ if bs.sharedProcess == nil {
+ return 0
+ }
+ bs.pendingSharedMu.Lock()
+ hasMCP := len(bs.pendingSharedMcpServers) > 0
+ bs.pendingSharedMu.Unlock()
+ return bs.sharedProcess.RecommendedLoadTimeout(hasMCP)
+}
+
func (bs *BackgroundSession) pdHasRecorder() bool { return bs.recorder != nil }
func (bs *BackgroundSession) pdGetNextSeq() int64 { return bs.getNextSeq() }
diff --git a/internal/conversation/prompt_dispatcher.go b/internal/conversation/prompt_dispatcher.go
index 8f42f815a..43c8fcf36 100644
--- a/internal/conversation/prompt_dispatcher.go
+++ b/internal/conversation/prompt_dispatcher.go
@@ -7,6 +7,7 @@ package conversation
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"strings"
@@ -78,6 +79,12 @@ type promptDeps interface {
// Handshake
pdHasSharedProcess() bool
pdCompleteDeferredHandshake() error
+ // pdRecommendedHandshakeDeadline returns the outer wall-clock budget the
+ // deferred session/new handshake should be bounded by (mitto-f51). Derived
+ // from the shared process's own RecommendedLoadTimeout so we do not truncate
+ // a legitimate cold handshake. Returns 0 to indicate the caller should apply
+ // its own default.
+ pdRecommendedHandshakeDeadline() time.Duration
// Error event recording (for handshake failure)
pdHasRecorder() bool
@@ -608,22 +615,55 @@ func (p promptDispatcher) applyProcessorsAndBuildBlocks(
return finalBlocks
}
+// handshakeWatchdogFallback is the outer wall-clock bound applied to each deferred
+// handshake attempt when the shared process reports no recommended timeout
+// (mitto-f51). Sized above the normal cold-start budget so a warm-path attempt is
+// never truncated, and above the normal per-attempt create budget (~25s) so the
+// abort branch only fires on a genuinely wedged session/new. Var (not const) so
+// tests can shrink it without waiting the full production duration.
+var handshakeWatchdogFallback = 90 * time.Second
+
+// handshakeWatchdogMargin is added on top of RecommendedLoadTimeout so the outer
+// wait outlasts the SharedACPProcess's own internal retry loop and never
+// prematurely aborts a handshake the process is still legitimately working on.
+// Var (not const) so tests can shrink it.
+var handshakeWatchdogMargin = 30 * time.Second
+
// completeHandshakeOrAbort handles the deferred session/new handshake for shared-process
// sessions at the top of the PromptWithMeta goroutine. Returns true to continue, false to
// abort (caller must return from the goroutine). When no shared process is configured it
// is always a no-op that returns true.
+//
+// Each handshake attempt is bounded by a deadline (mitto-f51) so a hung
+// session/new takes the abort branch instead of latching isPrompting silently
+// forever. The deadline is derived from the shared process's own recommended
+// load timeout (extended for cold MCP handshakes) plus a margin; if the process
+// signals no recommendation, handshakeWatchdogFallback is used.
func (p promptDispatcher) completeHandshakeOrAbort(d promptDeps) bool {
if !d.pdHasSharedProcess() {
return true
}
+ watchdog := d.pdRecommendedHandshakeDeadline()
+ if watchdog > 0 {
+ watchdog += handshakeWatchdogMargin
+ } else {
+ watchdog = handshakeWatchdogFallback
+ }
+
const maxHandshakeAttempts = 3
var handshakeErr error
for attempt := 1; attempt <= maxHandshakeAttempts; attempt++ {
- handshakeErr = d.pdCompleteDeferredHandshake()
+ handshakeErr = runHandshakeWithWatchdog(d, watchdog)
if handshakeErr == nil {
break
}
+ // A watchdog trip means the previous attempt's goroutine is still running
+ // against the shared process; retrying here would just spawn another that
+ // re-blocks the same way. Bail out and let the user re-send (mitto-f51).
+ if errors.Is(handshakeErr, errHandshakeWatchdogFired) {
+ break
+ }
errStr := strings.ToLower(handshakeErr.Error())
transient := strings.Contains(errStr, "deadline") ||
strings.Contains(errStr, "timeout") ||
@@ -649,7 +689,12 @@ func (p promptDispatcher) completeHandshakeOrAbort(d promptDeps) bool {
"session_id", d.pdSessionID(),
"error", handshakeErr)
}
- friendlyMsg := "Could not start the agent session: " + formatACPError(handshakeErr) + " Please resend your message."
+ var friendlyMsg string
+ if errors.Is(handshakeErr, errHandshakeWatchdogFired) {
+ friendlyMsg = "The agent is still starting up — please resend your message."
+ } else {
+ friendlyMsg = "Could not start the agent session: " + formatACPError(handshakeErr) + " Please resend your message."
+ }
if d.pdHasRecorder() {
seq := d.pdGetNextSeq()
if recErr := d.pdRecordErrorEvent(seq, friendlyMsg); recErr != nil {
@@ -665,6 +710,36 @@ func (p promptDispatcher) completeHandshakeOrAbort(d promptDeps) bool {
return false
}
+// errHandshakeWatchdogFired signals that runHandshakeWithWatchdog aborted a
+// pdCompleteDeferredHandshake attempt because the outer deadline expired
+// (mitto-f51). The orphaned goroutine may keep running; the abort branch in
+// completeHandshakeOrAbort still clears prompting state so the user is unwedged.
+var errHandshakeWatchdogFired = errors.New("deferred session/new timed out (handshake watchdog fired)")
+
+// runHandshakeWithWatchdog invokes pdCompleteDeferredHandshake in a goroutine
+// and waits for it up to deadline. On expiry it returns errHandshakeWatchdogFired
+// while the goroutine keeps running (its own RPC budget bounds it eventually).
+func runHandshakeWithWatchdog(d promptDeps, deadline time.Duration) error {
+ if deadline <= 0 {
+ return d.pdCompleteDeferredHandshake()
+ }
+ resultCh := make(chan error, 1)
+ go func() { resultCh <- d.pdCompleteDeferredHandshake() }()
+ timer := time.NewTimer(deadline)
+ defer timer.Stop()
+ select {
+ case err := <-resultCh:
+ return err
+ case <-timer.C:
+ if l := d.pdLogger(); l != nil {
+ l.Warn("Deferred session/new watchdog fired; aborting handshake attempt",
+ "session_id", d.pdSessionID(),
+ "deadline", deadline)
+ }
+ return errHandshakeWatchdogFired
+ }
+}
+
// createFreshContextSession prepares a fresh context for a FreshContext loop run.
//
// When a contextFlushCommand is configured for the ACP server, it performs an
diff --git a/internal/conversation/prompt_dispatcher_test.go b/internal/conversation/prompt_dispatcher_test.go
index 9af5fe4bf..365bc67a9 100644
--- a/internal/conversation/prompt_dispatcher_test.go
+++ b/internal/conversation/prompt_dispatcher_test.go
@@ -62,9 +62,15 @@ type fakePromptDeps struct {
historyPrefix string // prefix injected by pdBuildPromptWithHistory
// === New in 2.5-c ===
- hasSharedProcess bool
- handshakeErr error
- handshakeCalls int
+ hasSharedProcess bool
+ handshakeErr error
+ handshakeCalls int
+ // handshakeBlock, when non-nil, is closed by the test to release a blocked
+ // pdCompleteDeferredHandshake. Used to simulate a wedged handshake for the
+ // completeHandshakeOrAbort watchdog test (mitto-f51).
+ handshakeBlock chan struct{}
+ // handshakeDeadline is returned by pdRecommendedHandshakeDeadline (mitto-f51).
+ handshakeDeadline time.Duration
hasRecorder bool
recordedErrorEvents []string
nextSeq int64
@@ -217,9 +223,17 @@ func (f *fakePromptDeps) pdWorkspaceProcessorArgOverrides() map[string]map[strin
func (f *fakePromptDeps) pdHasSharedProcess() bool { return f.hasSharedProcess }
func (f *fakePromptDeps) pdCompleteDeferredHandshake() error {
f.mu.Lock()
- defer f.mu.Unlock()
f.handshakeCalls++
- return f.handshakeErr
+ block := f.handshakeBlock
+ err := f.handshakeErr
+ f.mu.Unlock()
+ if block != nil {
+ <-block
+ }
+ return err
+}
+func (f *fakePromptDeps) pdRecommendedHandshakeDeadline() time.Duration {
+ return f.handshakeDeadline
}
func (f *fakePromptDeps) pdHasRecorder() bool { return f.hasRecorder }
func (f *fakePromptDeps) pdGetNextSeq() int64 {
@@ -1214,6 +1228,107 @@ func (t *transientFakePromptDeps) pdCompleteDeferredHandshake() error {
return nil
}
+// TestPromptDispatcher_CompleteHandshakeOrAbort_WatchdogFiresOnWedgedHandshake
+// verifies mitto-f51: a pdCompleteDeferredHandshake that hangs past the derived
+// deadline takes the abort branch (friendly "still starting up" message, prompting
+// state reset, streaming state notified) instead of blocking forever.
+func TestPromptDispatcher_CompleteHandshakeOrAbort_WatchdogFiresOnWedgedHandshake(t *testing.T) {
+ // Shrink the margin so the test isn't blocked for 30s. Restore after.
+ origMargin := handshakeWatchdogMargin
+ handshakeWatchdogMargin = 10 * time.Millisecond
+ defer func() { handshakeWatchdogMargin = origMargin }()
+
+ p := promptDispatcher{}
+ d := newFakePromptDeps()
+ d.hasSharedProcess = true
+ d.hasRecorder = true
+ // Force pdCompleteDeferredHandshake to block indefinitely.
+ d.handshakeBlock = make(chan struct{})
+ defer close(d.handshakeBlock) // release the orphaned goroutine at test end
+ // Tight deadline so the test is fast (base + shrunken margin ~= 20ms).
+ d.handshakeDeadline = 10 * time.Millisecond
+
+ done := make(chan bool, 1)
+ go func() { done <- p.completeHandshakeOrAbort(d) }()
+
+ select {
+ case ok := <-done:
+ if ok {
+ t.Fatal("expected completeHandshakeOrAbort to return false when watchdog fires")
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("completeHandshakeOrAbort did not return within 5s despite watchdog")
+ }
+
+ // Watchdog trips must not spawn retries — the orphaned goroutine still holds
+ // the shared process and another attempt would just re-hang (mitto-f51).
+ d.mu.Lock()
+ calls := d.handshakeCalls
+ d.mu.Unlock()
+ if calls != 1 {
+ t.Fatalf("expected exactly 1 handshake call (no retry on watchdog trip), got %d", calls)
+ }
+
+ // The friendly "still starting up" message must be surfaced to observers.
+ if len(d.notifiedErrors) != 1 {
+ t.Fatalf("expected 1 observer error notification, got %d", len(d.notifiedErrors))
+ }
+ if !strings.Contains(d.notifiedErrors[0], "still starting up") {
+ t.Fatalf("expected 'still starting up' message, got %q", d.notifiedErrors[0])
+ }
+ // And a recorded error event (recorder is present in this test).
+ if len(d.recordedErrorEvents) != 1 {
+ t.Fatalf("expected 1 recorded error event, got %d", len(d.recordedErrorEvents))
+ }
+ // Prompting state must be reset so the user can re-send.
+ if d.promptingResetCalls != 1 {
+ t.Fatalf("expected 1 prompting reset, got %d", d.promptingResetCalls)
+ }
+ // Streaming state must be flipped to false.
+ if len(d.streamingChanges) != 1 || d.streamingChanges[0] {
+ t.Fatalf("expected streaming=false notification, got %v", d.streamingChanges)
+ }
+}
+
+// TestPromptDispatcher_CompleteHandshakeOrAbort_FallbackDeadlineUsedWhenDepsReportsZero
+// verifies runHandshakeWithWatchdog uses handshakeWatchdogFallback when
+// pdRecommendedHandshakeDeadline returns 0 — the watchdog is always armed so a
+// hung handshake is always recoverable (mitto-f51).
+func TestPromptDispatcher_CompleteHandshakeOrAbort_FallbackDeadlineUsedWhenDepsReportsZero(t *testing.T) {
+ p := promptDispatcher{}
+ d := newFakePromptDeps()
+ d.hasSharedProcess = true
+ d.handshakeDeadline = 0 // deps has no recommendation
+ // Immediate success: the dispatcher must still complete quickly even with
+ // the (long) fallback armed.
+ d.handshakeErr = nil
+
+ start := time.Now()
+ ok := p.completeHandshakeOrAbort(d)
+ if !ok {
+ t.Fatalf("expected true on immediate success, got false; errors=%v", d.notifiedErrors)
+ }
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Fatalf("dispatcher took too long (%v) on immediate success — watchdog blocking?", elapsed)
+ }
+}
+
+// TestRunHandshakeWithWatchdog_ZeroDeadlineFallsThrough verifies that a
+// non-positive deadline bypasses the watchdog goroutine entirely — used as a
+// safety valve for tests / callers that explicitly opt out.
+func TestRunHandshakeWithWatchdog_ZeroDeadlineFallsThrough(t *testing.T) {
+ d := newFakePromptDeps()
+ d.handshakeErr = errors.New("boom")
+
+ err := runHandshakeWithWatchdog(d, 0)
+ if err == nil || err.Error() != "boom" {
+ t.Fatalf("expected 'boom' error passed through, got %v", err)
+ }
+ if d.handshakeCalls != 1 {
+ t.Fatalf("expected exactly 1 handshake call, got %d", d.handshakeCalls)
+ }
+}
+
// --- createFreshContextSession tests ---
func TestPromptDispatcher_CreateFreshContextSession_FreshContextFalse_ReturnsEmpty(t *testing.T) {
From fd3a451cfb5c6663512324718fdf7ddfb0c2fac1 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 12:43:31 +0200
Subject: [PATCH 023/240] feat(web): toast for prewarm-pin-alert WebSocket
message (mitto-yns)
mitto-mw0's adaptive ACP/MCP pre-warming broadcasts a prewarm_pin_alert
WebSocket message when a workspace is pinned due to a slow MCP-init, or
when the max-pin-duration cap force-releases a still-broken pin, but there
was no frontend consumer so the warning never reached the user.
useWebSocket.js's handleGlobalEvent dispatch re-emits the message as a
mitto:prewarm_pin_alert CustomEvent; useBackgroundNotifications.js listens
for it and shows a warning toast naming the workspace (workspace_name,
falling back to the basename of working_dir), with expired-aware copy
distinguishing "pin released after max-pin-duration cap" from "workspace
pinned due to slow MCP", and appending the reason when present.
---
.../hooks/useBackgroundNotifications.js | 35 +++++++++++++++++++
web/static/hooks/useWebSocket.js | 9 +++++
2 files changed, 44 insertions(+)
diff --git a/web/static/hooks/useBackgroundNotifications.js b/web/static/hooks/useBackgroundNotifications.js
index 00b9e8eb8..7ce2c4e36 100644
--- a/web/static/hooks/useBackgroundNotifications.js
+++ b/web/static/hooks/useBackgroundNotifications.js
@@ -121,6 +121,41 @@ export function useBackgroundNotifications({
};
}, [showToast]);
+ // Listen for prewarm pin alert events (mitto-mw0): the adaptive pre-warming
+ // controller pinned a workspace due to slow/broken MCP init, OR force-expired
+ // a stuck pin after its max-pin-duration cap elapsed. Warning toast.
+ useEffect(() => {
+ const handlePrewarmPinAlert = (event) => {
+ const data = event.detail;
+ if (!data) return;
+ const name =
+ data.workspace_name ||
+ (data.working_dir ? data.working_dir.split("/").pop() : "") ||
+ "a workspace";
+ const reasonSuffix = data.reason ? ` ${data.reason}` : "";
+ if (data.expired) {
+ showToast({
+ style: "warning",
+ title: `MCP pin released: ${name}`,
+ message: `The warm pin was released after the max-pin-duration cap because the MCP servers stayed slow or unavailable — check the workspace's MCP configuration.${reasonSuffix}`,
+ });
+ } else {
+ showToast({
+ style: "warning",
+ title: `Slow MCP workspace pinned: ${name}`,
+ message: `This workspace's MCP servers were slow to start, so a warm session was pinned to speed up the first prompt.${reasonSuffix}`,
+ });
+ }
+ };
+ window.addEventListener("mitto:prewarm_pin_alert", handlePrewarmPinAlert);
+ return () => {
+ window.removeEventListener(
+ "mitto:prewarm_pin_alert",
+ handlePrewarmPinAlert,
+ );
+ };
+ }, [showToast]);
+
// Listen for ACP start failed events
useEffect(() => {
const handleAcpStartFailed = (event) => {
diff --git a/web/static/hooks/useWebSocket.js b/web/static/hooks/useWebSocket.js
index 1ece1e166..38692dd68 100644
--- a/web/static/hooks/useWebSocket.js
+++ b/web/static/hooks/useWebSocket.js
@@ -4632,6 +4632,15 @@ export function useWebSocket({ onActiveSessionRemovedRef } = {}) {
}),
);
break;
+
+ case "prewarm_pin_alert":
+ console.warn("Prewarm pin alert:", msg.data);
+ if (msg.data) {
+ window.dispatchEvent(
+ new CustomEvent("mitto:prewarm_pin_alert", { detail: msg.data }),
+ );
+ }
+ break;
}
}, []);
From eea071db2b9a935e1f6685504ce3937e64cc1a44 Mon Sep 17 00:00:00 2001
From: mitto-agent
Date: Tue, 7 Jul 2026 13:41:59 +0200
Subject: [PATCH 024/240] fix(web): bound concurrent interactive session
resumes on cold start (mitto-54k.1)
On cold start, ~N sessions in a workspace reconnect their WebSockets
nearly simultaneously; each spawned its own unbounded goroutine calling
sessionManager.ResumeSession, saturating the Mitto process and starving
the agent's inbound HTTP initialize/tools/list on :5757/mcp (timeouts up
to 560s blocking the first prompt).
Introduce a small counting semaphore (resumeSemaphore) on *Server that
bounds concurrent interactive ResumeSession calls to a configurable
limit (default 3). Acquire INSIDE the spawned resume goroutine so the
WebSocket handler is never blocked; the frontend still receives
'connected' immediately with is_running=false. Release after
ResumeSession returns (success or failure), preserving all existing
post-resume side effects (negative-cache invalidation,
tryAttachToSession, BroadcastACPStarted, TriggerFollowUpSuggestions).
The user-focused ensure_resumed foreground path (session_ws.go:~1994)
intentionally BYPASSES this bound so the session the user is actively
looking at resumes first, even when the cold-start fan-out has
saturated the interactive pool.
Config knob 'startup_resume_concurrency' mirrors the existing
'startup_stagger_ms' pattern (config.SessionConfig field +
Default/Get accessor, threaded through internal/config/config.go
raw->typed). Wired in server.go next to the loop-runner stagger.
Tests: TestResumeSemaphore_{BoundsConcurrency,ForegroundBypass,
ClampsNonPositiveCapacity,NilReceiverIsNoop} in
internal/web/resume_semaphore_test.go +
TestSessionConfig_GetStartupResumeConcurrency in
internal/config/settings_test.go.
---
internal/config/config.go | 2 +
internal/config/settings.go | 30 ++++++
internal/config/settings_test.go | 28 ++++++
internal/web/resume_semaphore.go | 53 ++++++++++
internal/web/resume_semaphore_test.go | 137 ++++++++++++++++++++++++++
internal/web/server.go | 16 +++
internal/web/session_ws.go | 10 ++
7 files changed, 276 insertions(+)
create mode 100644 internal/web/resume_semaphore.go
create mode 100644 internal/web/resume_semaphore_test.go
diff --git a/internal/config/config.go b/internal/config/config.go
index b2bb2effc..a7cc8293d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1517,6 +1517,7 @@ type rawConfig struct {
AutoArchiveInactiveAfter string `yaml:"auto_archive_inactive_after"`
StartupStaggerMs int `yaml:"startup_stagger_ms"`
StartupLoopDelaySeconds int `yaml:"startup_loop_delay_seconds"`
+ StartupResumeConcurrency int `yaml:"startup_resume_concurrency"`
LoopSuspendTimeout string `yaml:"loop_suspend_timeout"`
MemoryRecycleThreshold string `yaml:"memory_recycle_threshold"`
AgentInactivityTimeout string `yaml:"agent_inactivity_timeout"`
@@ -1889,6 +1890,7 @@ func Parse(data []byte) (*Config, error) {
AutoArchiveInactiveAfter: raw.Session.AutoArchiveInactiveAfter,
StartupStaggerMs: raw.Session.StartupStaggerMs,
StartupLoopDelaySeconds: raw.Session.StartupLoopDelaySeconds,
+ StartupResumeConcurrency: raw.Session.StartupResumeConcurrency,
LoopSuspendTimeout: raw.Session.LoopSuspendTimeout,
MemoryRecycleThreshold: raw.Session.MemoryRecycleThreshold,
AgentInactivityTimeout: raw.Session.AgentInactivityTimeout,
diff --git a/internal/config/settings.go b/internal/config/settings.go
index a958f31e0..67ad05a0a 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -90,6 +90,13 @@ const DefaultStartupStaggerMs = 300
// resume first via WebSocket connections.
const DefaultStartupLoopDelay = 15 * time.Second
+// DefaultStartupResumeConcurrency is the default maximum number of concurrent
+// interactive ResumeSession calls issued from the cold-start WebSocket fan-out.
+// Bounding this prevents the Mitto process from saturating itself (which can
+// starve the agent's inbound MCP handshake on :5757/mcp) when many sessions
+// reconnect at once (mitto-54k.1).
+const DefaultStartupResumeConcurrency = 3
+
// SessionConfig represents session storage configuration.
type SessionConfig struct {
// MaxMessagesPerSession is the maximum number of messages to retain per conversation.
@@ -118,6 +125,15 @@ type SessionConfig struct {
// first via WebSocket connections, preventing thundering herd on ACP.
// Default: 15 seconds. Set to 0 to disable (not recommended).
StartupLoopDelaySeconds int `json:"startup_loop_delay_seconds,omitempty"`
+ // StartupResumeConcurrency caps the number of concurrent interactive
+ // ResumeSession calls issued from the cold-start WebSocket fan-out. When many
+ // browsers reconnect at once (or a single browser holds many session tabs),
+ // unbounded fan-out saturates the Mitto process and starves the agent's
+ // inbound MCP handshake on :5757/mcp (see mitto-54k). The user-focused
+ // ensure_resumed path is NOT throttled.
+ // Default: 0 (use DefaultStartupResumeConcurrency = 3). Values <1 are clamped
+ // to 1 (a semaphore of size 0 would deadlock every resume).
+ StartupResumeConcurrency int `json:"startup_resume_concurrency,omitempty"`
// LoopSuspendTimeout controls when idle loop conversations have their ACP
// connection suspended to save memory. When a loop conversation's next prompt
// is farther away than this timeout, its ACP session is closed even if the user has
@@ -331,6 +347,20 @@ func (c *SessionConfig) GetStartupLoopDelay() time.Duration {
return time.Duration(c.StartupLoopDelaySeconds) * time.Second
}
+// GetStartupResumeConcurrency returns the maximum number of concurrent
+// interactive ResumeSession calls issued from the cold-start WebSocket fan-out.
+// Returns DefaultStartupResumeConcurrency (3) if not configured (0). Values <1
+// are clamped to 1 — a bound of 0 would deadlock every resume.
+func (c *SessionConfig) GetStartupResumeConcurrency() int {
+ if c == nil || c.StartupResumeConcurrency == 0 {
+ return DefaultStartupResumeConcurrency
+ }
+ if c.StartupResumeConcurrency < 1 {
+ return 1
+ }
+ return c.StartupResumeConcurrency
+}
+
// PrewarmConfig represents adaptive ACP/MCP pre-warming thresholds (mitto-mw0).
// Pre-warming warms a workspace, probes its health (session/new latency + MCP
// readiness), and pins a warm keepalive session only for slow/broken workspaces.
diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go
index 8edecc075..b7e831c8b 100644
--- a/internal/config/settings_test.go
+++ b/internal/config/settings_test.go
@@ -1018,3 +1018,31 @@ func TestLoadSettings_NewKeyedSettingsUntouched(t *testing.T) {
t.Errorf("settings.json was rewritten even though it already used loop_* keys:\nbefore:\n%s\nafter:\n%s", before, after)
}
}
+
+// TestSessionConfig_GetStartupResumeConcurrency covers the mitto-54k.1 config
+// accessor: nil/zero → default, negative → clamped to 1, positive → passthrough.
+func TestSessionConfig_GetStartupResumeConcurrency(t *testing.T) {
+ // nil receiver → default
+ var nilCfg *SessionConfig
+ if got := nilCfg.GetStartupResumeConcurrency(); got != DefaultStartupResumeConcurrency {
+ t.Errorf("nil.GetStartupResumeConcurrency() = %d, want %d", got, DefaultStartupResumeConcurrency)
+ }
+
+ // zero value → default
+ cfg := &SessionConfig{}
+ if got := cfg.GetStartupResumeConcurrency(); got != DefaultStartupResumeConcurrency {
+ t.Errorf("zero.GetStartupResumeConcurrency() = %d, want %d", got, DefaultStartupResumeConcurrency)
+ }
+
+ // negative → clamped to 1 (a size-0 semaphore would deadlock)
+ cfg.StartupResumeConcurrency = -5
+ if got := cfg.GetStartupResumeConcurrency(); got != 1 {
+ t.Errorf("negative.GetStartupResumeConcurrency() = %d, want 1", got)
+ }
+
+ // explicit positive → passthrough
+ cfg.StartupResumeConcurrency = 8
+ if got := cfg.GetStartupResumeConcurrency(); got != 8 {
+ t.Errorf("positive.GetStartupResumeConcurrency() = %d, want 8", got)
+ }
+}
diff --git a/internal/web/resume_semaphore.go b/internal/web/resume_semaphore.go
new file mode 100644
index 000000000..66c9ff70b
--- /dev/null
+++ b/internal/web/resume_semaphore.go
@@ -0,0 +1,53 @@
+package web
+
+// resumeSemaphore is a small counting semaphore used to bound the number of
+// concurrent interactive ResumeSession calls issued from the cold-start
+// WebSocket fan-out (mitto-54k.1).
+//
+// On cold start, ~N sessions in a workspace reconnect their WebSockets nearly
+// simultaneously. Without a bound, each spawns its own goroutine that calls
+// ResumeSession, saturating the Mitto process and starving the agent's inbound
+// HTTP `initialize`/`tools/list` to Mitto's own :5757/mcp MCP endpoint.
+//
+// The user-focused `ensure_resumed` path (foreground) MUST NOT acquire this
+// semaphore — the session the user is actively looking at should resume first.
+type resumeSemaphore struct {
+ ch chan struct{}
+}
+
+// newResumeSemaphore returns a semaphore with the given capacity. A capacity
+// <1 is clamped to 1 — a size-0 semaphore would deadlock every acquire.
+func newResumeSemaphore(capacity int) *resumeSemaphore {
+ if capacity < 1 {
+ capacity = 1
+ }
+ return &resumeSemaphore{ch: make(chan struct{}, capacity)}
+}
+
+// Acquire blocks until a slot is available. A nil receiver is a no-op so
+// callers can guard optional wiring with a simple nil check upstream and still
+// call Acquire unconditionally in tests.
+func (s *resumeSemaphore) Acquire() {
+ if s == nil {
+ return
+ }
+ s.ch <- struct{}{}
+}
+
+// Release frees a previously-acquired slot. Callers must pair each successful
+// Acquire with exactly one Release (typically via defer).
+func (s *resumeSemaphore) Release() {
+ if s == nil {
+ return
+ }
+ <-s.ch
+}
+
+// Capacity reports the configured maximum concurrency. Returns 0 for a nil
+// receiver.
+func (s *resumeSemaphore) Capacity() int {
+ if s == nil {
+ return 0
+ }
+ return cap(s.ch)
+}
diff --git a/internal/web/resume_semaphore_test.go b/internal/web/resume_semaphore_test.go
new file mode 100644
index 000000000..09c2077f4
--- /dev/null
+++ b/internal/web/resume_semaphore_test.go
@@ -0,0 +1,137 @@
+package web
+
+import (
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestResumeSemaphore_BoundsConcurrency saturates the semaphore with many
+// concurrent "interactive resumes" and asserts that no more than `capacity`
+// ever run simultaneously — the core mitto-54k.1 acceptance criterion for the
+// cold-start fan-out at internal/web/session_ws.go:~378.
+func TestResumeSemaphore_BoundsConcurrency(t *testing.T) {
+ const capacity = 3
+ const goroutines = 28 // roughly the observed cold-start fan-out size
+
+ sem := newResumeSemaphore(capacity)
+ if got := sem.Capacity(); got != capacity {
+ t.Fatalf("Capacity() = %d, want %d", got, capacity)
+ }
+
+ var (
+ inFlight int32
+ maxObserved int32
+ completed int32
+ wg sync.WaitGroup
+ )
+
+ wg.Add(goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func() {
+ defer wg.Done()
+ sem.Acquire()
+ defer sem.Release()
+ now := atomic.AddInt32(&inFlight, 1)
+ // Track high-water mark via CAS loop.
+ for {
+ prev := atomic.LoadInt32(&maxObserved)
+ if now <= prev || atomic.CompareAndSwapInt32(&maxObserved, prev, now) {
+ break
+ }
+ }
+ // Simulate ResumeSession work: long enough that natural scheduling
+ // pressure would exceed the bound if the semaphore were absent.
+ time.Sleep(20 * time.Millisecond)
+ atomic.AddInt32(&inFlight, -1)
+ atomic.AddInt32(&completed, 1)
+ }()
+ }
+
+ wg.Wait()
+
+ if got := atomic.LoadInt32(&completed); got != goroutines {
+ t.Fatalf("completed = %d, want %d", got, goroutines)
+ }
+ if got := atomic.LoadInt32(&maxObserved); got > int32(capacity) {
+ t.Fatalf("max observed concurrency = %d, want <= %d", got, capacity)
+ }
+ if got := atomic.LoadInt32(&maxObserved); got < 1 {
+ t.Fatalf("max observed concurrency = %d, want >= 1 (test never ran?)", got)
+ }
+}
+
+// TestResumeSemaphore_ForegroundBypass asserts that a caller which does NOT
+// acquire the semaphore (the ensure_resumed / user-focused path at
+// internal/web/session_ws.go:~1994) makes progress even while every slot is
+// held by long-running cold-start interactive resumes. This encodes the
+// mitto-54k.1 requirement that ensure_resumed must NOT be throttled.
+func TestResumeSemaphore_ForegroundBypass(t *testing.T) {
+ const capacity = 3
+ sem := newResumeSemaphore(capacity)
+
+ // Saturate all slots with goroutines that hold them until released.
+ release := make(chan struct{})
+ held := make(chan struct{}, capacity)
+ for i := 0; i < capacity; i++ {
+ go func() {
+ sem.Acquire()
+ held <- struct{}{}
+ <-release
+ sem.Release()
+ }()
+ }
+ // Wait for all slots to actually be acquired before running the foreground
+ // caller — otherwise we'd race the saturation and get a false pass.
+ for i := 0; i < capacity; i++ {
+ select {
+ case <-held:
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for interactive slots to fill")
+ }
+ }
+
+ // Foreground caller does NOT acquire the semaphore. It must complete
+ // promptly even though every interactive slot is currently held.
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ // Simulated ensure_resumed work — no Acquire/Release.
+ time.Sleep(5 * time.Millisecond)
+ }()
+
+ select {
+ case <-done:
+ // success: foreground path bypassed the semaphore
+ case <-time.After(1 * time.Second):
+ t.Fatal("ensure_resumed-style foreground caller was blocked by the interactive semaphore")
+ }
+
+ // Cleanup — release the held slots.
+ close(release)
+}
+
+// TestResumeSemaphore_ClampsNonPositiveCapacity ensures that a mis-configured
+// non-positive capacity is clamped to 1 instead of producing a size-0
+// semaphore (which would deadlock every acquire).
+func TestResumeSemaphore_ClampsNonPositiveCapacity(t *testing.T) {
+ for _, in := range []int{0, -1, -100} {
+ sem := newResumeSemaphore(in)
+ if got := sem.Capacity(); got != 1 {
+ t.Errorf("newResumeSemaphore(%d).Capacity() = %d, want 1 (clamped)", in, got)
+ }
+ }
+}
+
+// TestResumeSemaphore_NilReceiverIsNoop guards the nil-safe Acquire/Release
+// contract relied on by the session_ws.go call site.
+func TestResumeSemaphore_NilReceiverIsNoop(t *testing.T) {
+ var sem *resumeSemaphore
+ // Neither call should panic or block.
+ sem.Acquire()
+ sem.Release()
+ if got := sem.Capacity(); got != 0 {
+ t.Errorf("nil.Capacity() = %d, want 0", got)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index 307c193cb..9a1e409fb 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -211,6 +211,12 @@ type Server struct {
// Caches session IDs known to not exist, preventing repeated filesystem lookups.
negativeSessionCache *NegativeSessionCache
+ // interactiveResumeSem bounds concurrent interactive ResumeSession calls
+ // issued from the cold-start WebSocket fan-out (mitto-54k.1). The
+ // user-focused ensure_resumed path bypasses this bound so the session the
+ // user is actively looking at resumes first.
+ interactiveResumeSem *resumeSemaphore
+
// beads is the injectable Client for bd operations.
// When nil, beadsClient() falls back to beads.NewClient() (real bd binary).
beads beads.Client
@@ -720,6 +726,16 @@ func NewServer(config Config) (*Server, error) {
mcpAvailable: true,
}
+ // Bound concurrent interactive ResumeSession calls issued from the
+ // cold-start WebSocket fan-out (mitto-54k.1). Config knob mirrors the
+ // existing startup_stagger_ms pattern; the user-focused ensure_resumed
+ // path bypasses this bound (see session_ws.go ensureResumed).
+ resumeConcurrency := configPkg.DefaultStartupResumeConcurrency
+ if config.MittoConfig != nil && config.MittoConfig.Session != nil {
+ resumeConcurrency = config.MittoConfig.Session.GetStartupResumeConcurrency()
+ }
+ s.interactiveResumeSem = newResumeSemaphore(resumeConcurrency)
+
// Wrap the beads client so every bd invocation this process makes brackets
// itself with the BeadsWatcher self-suppression window. Even read-only bd
// reads (list/show) rewrite the embedded Dolt noms journal/manifest and
diff --git a/internal/web/session_ws.go b/internal/web/session_ws.go
index c17307418..232a9b1ba 100644
--- a/internal/web/session_ws.go
+++ b/internal/web/session_ws.go
@@ -376,6 +376,12 @@ func (s *Server) handleSessionWS(w http.ResponseWriter, r *http.Request) {
}
sessionName := meta.Name
go func() {
+ // Bound cold-start fan-out (mitto-54k.1): acquire INSIDE the
+ // goroutine so the WebSocket handler is never blocked, and
+ // release after ResumeSession returns (success or failure).
+ // A nil semaphore is a no-op (Acquire/Release both return).
+ s.interactiveResumeSem.Acquire()
+ defer s.interactiveResumeSem.Release()
resumedBS, err := s.sessionManager.ResumeSession(sessionID, sessionName, cwd)
if err != nil {
if clientLogger != nil {
@@ -1992,6 +1998,10 @@ func (c *SessionWSClient) handleEnsureResumed() {
}
sessionName := meta.Name
go func() {
+ // User-focused foreground resume: intentionally BYPASSES
+ // interactiveResumeSem (mitto-54k.1) so the session the user is
+ // actively looking at resumes first, even when the cold-start
+ // fan-out has already saturated the interactive-resume bound.
resumedBS, err := c.server.sessionManager.ResumeSession(c.sessionID, sessionName, cwd)
if err != nil {
if c.logger != nil {
From 9cbf025c6714fc6b2421b7ce13640215619420d6 Mon Sep 17 00:00:00 2001
From: mitto-agent
Date: Tue, 7 Jul 2026 14:15:29 +0200
Subject: [PATCH 025/240] test(mcpserver): guard warm-up fast-path against
cold-start starvation (mitto-54k.2)
Fix B for mitto-54k (Cold-start MCP starvation): independent safety net
alongside Fix A's resume-storm bound (mitto-54k.1).
Audit of internal/mcpserver/server.go confirms Mitto's own inbound /mcp
initialize + tools/list are served entirely by the go-sdk's
mcp.NewStreamableHTTPHandler from the statically-registered tool table
(built at NewServer time via mcp.AddTool). The handshake path never
acquires Mitto's internal locks (s.mu, s.sessionsMu) nor calls any
blocking helper (WaitForPendingRequest, correlation waits, store
lookups); no receiving/sending middlewares are registered.
Add TestFastPath_InboundInitAndToolsListStayBoundedUnderLoad as a
regression guard: starts a real MCP server, applies 32-way concurrent
initialize+tools/list round-trips (each a fresh MCP client session over
StreamableClientTransport), and asserts every round-trip completes
under a 2 s budget with a stable, non-empty tool set. Observed max
round-trip on this machine: ~35ms under load with race detector.
Document the warm-up fast-path in docs/devel/mcp-tool-discovery.md,
cross-referencing Fix A as the source-side fix.
- internal/mcpserver/server_fastpath_test.go (new)
- docs/devel/mcp-tool-discovery.md
---
docs/devel/mcp-tool-discovery.md | 23 +++
internal/mcpserver/server_fastpath_test.go | 178 +++++++++++++++++++++
2 files changed, 201 insertions(+)
create mode 100644 internal/mcpserver/server_fastpath_test.go
diff --git a/docs/devel/mcp-tool-discovery.md b/docs/devel/mcp-tool-discovery.md
index c1330f7a6..9658ad09b 100644
--- a/docs/devel/mcp-tool-discovery.md
+++ b/docs/devel/mcp-tool-discovery.md
@@ -39,6 +39,29 @@ asks the agent's own LLM to introspect itself:
**second**, per-pattern LLM query to the same auxiliary session — also
non-deterministic.
+## Warm-up fast-path: Mitto's own inbound `/mcp` (mitto-54k.2)
+
+Mitto's own MCP endpoint (`internal/mcpserver/server.go` `startSSE` →
+`mcp.NewStreamableHTTPHandler`) exposes `initialize` and `tools/list` on a
+**lock-free, no-per-Mitto-session-state** path: the SDK's streamable HTTP
+handler dispatches these two methods entirely from the `*mcp.Server`'s
+statically-registered tool table (built once at `NewServer` time via
+`registerGlobalTools` + `registerSessionScopedTools`, both `mcp.AddTool` calls
+against a pre-built list). Neither `s.mu` nor `s.sessionsMu` nor any blocking
+Mitto helper (`WaitForPendingRequest`, correlation waits, store lookups) is
+touched during handshake; there are also no receiving/sending middlewares
+registered on the server.
+
+Consequence: the cold-start resume storm cannot starve an agent's inbound
+`initialize`/`tools/list` to Mitto — the two work on independent goroutines and
+share no locks. The primary fix is **Fix A (mitto-54k.1)**, which caps the
+resume storm at the source via a bounded `resumeSemaphore` in the web layer;
+this fast-path property is the independent safety net. It is guarded by the
+regression test `TestFastPath_InboundInitAndToolsListStayBoundedUnderLoad`
+(`internal/mcpserver/server_fastpath_test.go`), which applies 32-way concurrent
+initialize+tools/list round-trips and asserts each completes well under a 2 s
+budget with a stable, non-empty tool set.
+
## Findings
### Q1 — Can we get a real tool list deterministically?
diff --git a/internal/mcpserver/server_fastpath_test.go b/internal/mcpserver/server_fastpath_test.go
new file mode 100644
index 000000000..dfeb8bc93
--- /dev/null
+++ b/internal/mcpserver/server_fastpath_test.go
@@ -0,0 +1,178 @@
+package mcpserver
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ mcp "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/inercia/mitto/internal/session"
+)
+
+// TestFastPath_InboundInitAndToolsListStayBoundedUnderLoad is the regression
+// guard for mitto-54k.2. Mitto's own inbound /mcp `initialize` + `tools/list`
+// are served entirely by the SDK's `mcp.NewStreamableHTTPHandler` — the
+// factory (server.go ~340) is a pure passthrough returning `s.mcpServer`, and
+// Mitto's own tools are registered statically at startup via `mcp.AddTool`.
+// Neither `s.mu` nor `s.sessionsMu` nor any blocking helper
+// (`WaitForPendingRequest`, correlation waits) is touched on that path.
+//
+// This test proves that property empirically: while the server is under
+// concurrent MCP-client load (many parallel initialize+tools/list round-trips
+// mimicking a resume-storm's pressure on goroutines and Go's scheduler), each
+// individual initialize+tools/list round-trip still completes well under a
+// generous bound, and the returned tool set is non-empty and stable across
+// concurrent callers.
+func TestFastPath_InboundInitAndToolsListStayBoundedUnderLoad(t *testing.T) {
+ // Standard test-server construction, mirrors TestServerStartStop.
+ tmpDir := t.TempDir()
+ store, err := session.NewStore(tmpDir)
+ if err != nil {
+ t.Fatalf("Failed to create store: %v", err)
+ }
+ defer store.Close()
+
+ srv, err := NewServer(
+ Config{Port: 0}, // random port
+ Dependencies{Store: store},
+ )
+ if err != nil {
+ t.Fatalf("NewServer failed: %v", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ if err := srv.Start(ctx); err != nil {
+ t.Fatalf("Start failed: %v", err)
+ }
+ t.Cleanup(func() { _ = srv.Stop() })
+
+ port := srv.Port()
+ if port == 0 {
+ t.Fatal("Port not assigned after Start()")
+ }
+ endpoint := fmt.Sprintf("http://127.0.0.1:%d/mcp", port)
+
+ // Warm-up: one baseline initialize+tools/list to capture the expected
+ // tool set (consistency check target) and confirm the endpoint is up.
+ baseline := doInitAndListTools(t, ctx, endpoint, 5*time.Second)
+ if len(baseline) == 0 {
+ t.Fatalf("baseline tools/list returned no tools; expected the statically-registered Mitto tools")
+ }
+ baselineSet := make(map[string]struct{}, len(baseline))
+ for _, name := range baseline {
+ baselineSet[name] = struct{}{}
+ }
+
+ // Concurrent load: N goroutines each perform their own initialize +
+ // tools/list round-trip. This exercises the SDK handler under real
+ // concurrent HTTP contention (each round-trip is a fresh MCP session,
+ // so the server spins up per-session state, sends "initialized"
+ // notifications, etc.). If any Mitto lock were held across the SDK
+ // dispatch on this path, we'd see the tail latency balloon.
+ const (
+ concurrency = 32
+ perCallBudget = 2 * time.Second
+ )
+
+ var (
+ wg sync.WaitGroup
+ maxDuration atomic.Int64 // nanoseconds
+ failures atomic.Int32
+ )
+
+ wg.Add(concurrency)
+ for i := 0; i < concurrency; i++ {
+ go func(i int) {
+ defer wg.Done()
+
+ callCtx, callCancel := context.WithTimeout(ctx, perCallBudget)
+ defer callCancel()
+
+ start := time.Now()
+ tools := doInitAndListTools(t, callCtx, endpoint, perCallBudget)
+ elapsed := time.Since(start)
+
+ // Track high-water mark.
+ for {
+ prev := maxDuration.Load()
+ if int64(elapsed) <= prev || maxDuration.CompareAndSwap(prev, int64(elapsed)) {
+ break
+ }
+ }
+
+ if len(tools) == 0 {
+ failures.Add(1)
+ t.Errorf("worker %d: tools/list returned no tools under load", i)
+ return
+ }
+ // Consistency: every returned tool must be in the baseline set,
+ // and every baseline tool must be present here. Mitto's own /mcp
+ // server registers a static tool set; concurrent load must not
+ // return a partial/warm-up view.
+ seen := make(map[string]struct{}, len(tools))
+ for _, name := range tools {
+ seen[name] = struct{}{}
+ if _, ok := baselineSet[name]; !ok {
+ failures.Add(1)
+ t.Errorf("worker %d: tool %q returned under load but absent from baseline set", i, name)
+ }
+ }
+ for name := range baselineSet {
+ if _, ok := seen[name]; !ok {
+ failures.Add(1)
+ t.Errorf("worker %d: baseline tool %q missing from under-load response", i, name)
+ }
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ if got := failures.Load(); got > 0 {
+ t.Fatalf("%d workers reported failures under load", got)
+ }
+
+ maxObserved := time.Duration(maxDuration.Load())
+ if maxObserved >= perCallBudget {
+ t.Fatalf("max observed initialize+tools/list latency = %v, want < %v (per-call budget)", maxObserved, perCallBudget)
+ }
+ t.Logf("initialize+tools/list under %d-way concurrency: max round-trip = %v (budget %v), tools = %d",
+ concurrency, maxObserved, perCallBudget, len(baseline))
+}
+
+// doInitAndListTools opens a fresh MCP client session to endpoint via the
+// Streamable HTTP transport, calls tools/list, and returns the tool names.
+// Any failure (build/connect/list/timeout) fails the calling test via t.Fatalf.
+// The SDK's client.Connect performs the JSON-RPC `initialize` handshake, so
+// this covers both methods on the fast-path.
+func doInitAndListTools(t *testing.T, ctx context.Context, endpoint string, timeout time.Duration) []string {
+ t.Helper()
+
+ callCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ transport := &mcp.StreamableClientTransport{Endpoint: endpoint}
+ client := mcp.NewClient(&mcp.Implementation{Name: "mitto-fastpath-test", Version: "1.0.0"}, nil)
+
+ sess, err := client.Connect(callCtx, transport, nil)
+ if err != nil {
+ t.Fatalf("mcp client Connect (initialize) failed: %v", err)
+ }
+ defer sess.Close()
+
+ res, err := sess.ListTools(callCtx, &mcp.ListToolsParams{})
+ if err != nil {
+ t.Fatalf("mcp client ListTools failed: %v", err)
+ }
+
+ names := make([]string, 0, len(res.Tools))
+ for _, tool := range res.Tools {
+ names = append(names, tool.Name)
+ }
+ return names
+}
From 1bb48dd4dea3d8cc7f0c4141f6abe3284caf1f91 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 14:57:37 +0200
Subject: [PATCH 026/240] docs: mark cold-start inbound /mcp starvation gap as
fixed (mitto-54k)
The project-memory note in .augment/rules/42-mcpserver-development.md and
CLAUDE.md was written while the gap was open. Update it now that both fixes
landed on this branch:
- mitto-54k.1 (eea071db): bounds the interactive resume storm at the source
via a per-Server resume semaphore.
- mitto-54k.2 (9cbf025c): confirmed the inbound /mcp initialize+tools/list
handshake is already lock-free (SDK static tool table), backed by a
regression test.
---
.augment/rules/42-mcpserver-development.md | 4 ++++
CLAUDE.md | 1 +
2 files changed, 5 insertions(+)
diff --git a/.augment/rules/42-mcpserver-development.md b/.augment/rules/42-mcpserver-development.md
index 7ddc67d5c..bfce379a2 100644
--- a/.augment/rules/42-mcpserver-development.md
+++ b/.augment/rules/42-mcpserver-development.md
@@ -26,6 +26,10 @@ Single global MCP server at `http://127.0.0.1:5757/mcp`. Two tool classes:
- **Global tools** (no session): `mitto_conversation_list`, `mitto_get_config`, `mitto_get_runtime_info`
- **Session-scoped tools** (require `self_id`): UI prompts, conversation control, history, prompt management (`mitto_prompt_list/get/update`), loop control (`mitto_conversation_set_loop`, `mitto_conversation_run_loop_now`)
+## Cold-Start Inbound `/mcp` Starvation (fixed: mitto-54k)
+
+Was confirmed across multiple cold starts: an agent's (e.g. Auggie) *inbound* HTTP `initialize`/`tools/list` to Mitto's own `/mcp` endpoint (`127.0.0.1:5757/mcp`, same process serving the UI) could be starved during the session resume storm (many sessions resuming at once on native-app cold start). Symptom: agent logs `⏳ mitto (timed out)` for 170–560s while all *external* MCP servers (github/jira/slack/etc.) succeed — only `mitto` shares the saturated process. The cold-start gate (`mitto-8tb`) only serializes Mitto's *outbound* `session/new`/`session/load`; it does not throttle or prioritize this inbound `/mcp` handshake. Fixed by two independent changes: **mitto-54k.1** bounds the interactive resume storm at the source (a per-`Server` semaphore caps concurrent interactive `ResumeSession` calls, configurable via `startup_resume_concurrency`; `ensure_resumed`/foreground bypasses it — `internal/web/resume_semaphore.go`); **mitto-54k.2** is the independent durable safety net — an audit confirmed Mitto's own inbound `initialize`/`tools/list` are already served lock-free by the go-sdk's static tool table (no `s.mu`/`s.sessionsMu`, no blocking helper on that path), backed by a regression test (`internal/mcpserver/server_fastpath_test.go`) asserting bounded latency under concurrent load.
+
## Adding New Tools
Handler signature (3-arg form — SDK unmarshals input automatically):
diff --git a/CLAUDE.md b/CLAUDE.md
index 28cf74dab..463dcbd8f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,7 @@ go test -v -tags integration ./tests/integration/inprocess/
- **daisyUI drawer GPU bug**: `.drawer-side` + fixed-position overlay compete for pointer events → blank artifacts. Fix: See `web/static/styles.css` for verified pattern. Do NOT use `translateZ(0)`.
- **Zombie WebSocket recovery**: When phone sleeps or app backgrounded, WS may enter "zombie" state (appearing open but dead). On visibility change or app activate, force-close and reconnect. This is expected behavior — not a bug. See `.augment/rules/23-web-frontend-mobile.md` for resilience patterns.
- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk — session gaps, restarts, or a **concurrent loop conversation** (e.g. a PR-babysitting/cleanup loop sharing the same repo) stashing/resetting the working directory mid-task can silently drop them. Re-check with `git status`/`git diff`/re-view before relying on earlier work; if files vanish unexpectedly, check `git stash list` first — work is often auto-stashed, not lost.
+- **Cold-start MCP self-saturation (fixed: mitto-54k)**: Auggie's *inbound* HTTP `initialize`/`tools/list` to Mitto's own `/mcp` endpoint (same process/port) could be starved by the cold-start session resume storm — symptom: `⏳ mitto (timed out)` while all external MCP servers succeed. The cold-start gate (mitto-8tb) only serializes Mitto's *outbound* `session/new`/`session/load`, not this inbound path. Fixed by mitto-54k.1 (bounds the interactive resume storm at the source) + mitto-54k.2 (confirmed the inbound handshake is already lock-free, backed by a regression guard). See `.augment/rules/42-mcpserver-development.md`.
## New Agent Capability Checklist
From 9a4c969c746519bac361dba2b8ffc88bc0982d01 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 14:57:48 +0200
Subject: [PATCH 027/240] fix(prompts): persist Slack support drafts to the
bead before review
Harden the existing Support prompt suite so a draft reply is never lost to a
review-dialog timeout/abort or left only in the chat transcript:
- Save the proposed message to the bead as a DRAFT [OUTBOUND] comment BEFORE
opening the mitto_ui_textbox review dialog (support-gather-info,
support-investigate).
- Explicit on-submit / on-timeout / on-abort branching for the review result;
never post to Slack unless the user actually submits.
- 'Never dump the draft as chat text, never ask a free-text approve/hold/post
question' hand-off rules once state:drafting is set.
- support-watch-channel: allow a short blocking mitto_ui_form/textbox ask ONLY
for the per-channel knowledge-file gate, even during a silent scheduled run,
since the run cannot proceed at all without that guidance; falls back to a
non-blocking notify + skip-drafting-this-run on timeout.
- support-continue-conversation: updated description to reflect the
review-before-post flow.
---
.../support-continue-conversation.prompt.yaml | 45 ++++++++++---
.../builtin/support-gather-info.prompt.yaml | 52 +++++++++++----
.../builtin/support-investigate.prompt.yaml | 31 +++++++--
.../builtin/support-watch-channel.prompt.yaml | 64 +++++++++++--------
4 files changed, 142 insertions(+), 50 deletions(-)
diff --git a/config/prompts/builtin/support-continue-conversation.prompt.yaml b/config/prompts/builtin/support-continue-conversation.prompt.yaml
index 57cc1a431..0be7beacf 100644
--- a/config/prompts/builtin/support-continue-conversation.prompt.yaml
+++ b/config/prompts/builtin/support-continue-conversation.prompt.yaml
@@ -1,5 +1,5 @@
name: 'Support: continue conversation'
-description: 'Find a Slack channel conversation you have been participating in, summarize it, and help craft a reply. Posts to Slack only on your approval.'
+description: 'Find a Slack channel conversation you have been participating in, summarize it, and help craft a reply. Proposes the reply for review, posts to Slack only on your approval, and records it on a tracked bead.'
group: Support
backgroundColor: '#C8E6C9'
icon: chat-bubble
@@ -141,14 +141,43 @@ prompt: |-
1. **No direct addressing** — do not address the user by name.
2. **No follow-up offers** — do not end with "let me know if you need anything else".
3. **Express uncertainty when appropriate** — "I think that…", "Based on my understanding…".
- - Use `mitto_ui_textbox` to present the proposed reply for review/editing (Title "📤 Review reply
- before posting to Slack", Result "text", Abort true, Timeout 300).
- - **If the user submits:** the returned text (possibly edited) is the final message. Post it with
- the post-reply-to-thread tool using channel `{{ $channel }}` and the thread's parent `thread_ts`.
- Confirm success and show the permalink.
- - **If the user aborts:** acknowledge and end. Do **NOT** post anything to Slack.
+ - **ALWAYS** propose the reply for review first — never post without it. Use `mitto_ui_textbox` to
+ present the proposed reply for review/editing (Title "📤 Review reply before posting to Slack",
+ Result "text", Abort true, Timeout 300). The tool returns `{ result, changed, aborted, timed_out }`.
+ - **If the user submits** (`aborted` and `timed_out` both false): the returned `result` (possibly
+ edited) is the final message. Post it with the post-reply-to-thread tool using channel
+ `{{ $channel }}` and the thread's parent `thread_ts`. Confirm success and show the permalink. Then
+ record it on a bead in Step 7.
+ - **If the user aborts or the dialog times out:** acknowledge and end. Do **NOT** post anything to
+ Slack. (On timeout, `mitto_ui_notify` that you assumed they were away and nothing was posted.)
+
+ ## Step 7: Save the reply on a bead (always, after posting)
+
+ Every posted reply must be recorded on a tracked bead so the conversation is never lost and future
+ support prompts can pick it up. Run `bd` from the workspace root (if `bd list` reports no database,
+ run `bd init` first).
+
+ 1. **Deduplicate by thread.** Look up an existing bead for this thread:
+ `bd list --metadata-field slack_thread_ts= --all`. If one exists, **reuse** it —
+ never create a second bead for the same thread.
+ 2. **If none exists, create one now** (`$'...'` so `\n` become real newlines):
+ ```
+ bd create "" -t task -p P2 -l support,support-question \
+ -d $'# Question\n\n\n\n# User\n\n\n\n# Links\n\n[Slack]({{ $ws }}/archives/{{ $channel }}/p)\n' \
+ --metadata '{"slack_thread_ts":"","slack_channel":"{{ $channel }}","slack_url":"{{ $ws }}/archives/{{ $channel }}/p"}'
+ ```
+ Also add an `[INBOUND]` comment with the original customer question for history.
+ 3. **Log the posted reply** — add a markdown `[OUTBOUND]` comment with the final text and the
+ permalink (preserve markdown with `$'...'` or `printf '%s' "$c" | bd comment --stdin`):
+ Header `**[OUTBOUND us · POSTED · YYYY-MM-DD HH:MM UTC]**`.
+ 4. **Set state** (single `state:*` label — never `bd set-state`): swap in place to
+ `state:awaiting-customer` (`bd update --remove-label state: --add-label state:awaiting-customer`;
+ for a brand-new bead just `--add-label state:awaiting-customer`).
## Notes
- - **Slack message approval**: NEVER post to Slack without explicit user approval.
+ - **Slack message approval**: NEVER post to Slack without explicit user review and approval via the
+ editable `mitto_ui_textbox`.
+ - **Every posted reply is saved on a bead** (create-or-reuse by `slack_thread_ts`), so the thread is
+ tracked exactly like threads found by **"Support: watch channel"**.
- Thread timestamps: convert `1234567890.123456` → `p1234567890123456` for URLs.
diff --git a/config/prompts/builtin/support-gather-info.prompt.yaml b/config/prompts/builtin/support-gather-info.prompt.yaml
index 26736cc54..a10ea61ed 100644
--- a/config/prompts/builtin/support-gather-info.prompt.yaml
+++ b/config/prompts/builtin/support-gather-info.prompt.yaml
@@ -92,40 +92,70 @@ prompt: |-
- Show a clickable link to the thread for context (use the stored `slack_url`):
`📤 Asking in [this thread]()`.
- ## Step 5: Review in an editable textbox
+ ## Step 5: Persist the draft to the bead (before review)
- - Use `mitto_ui_textbox`:
+ Always save the proposed clarifying message on the bead **before** opening the review dialog, so
+ nothing is lost if the user is away. Record it as a DRAFT `[OUTBOUND]` comment (preserve the markdown
+ with `$'...'` or stdin):
+ ```
+ printf '%s' "$draft_markdown" | bd comment --stdin
+ ```
+ Header: `**[OUTBOUND us · DRAFT (request for info) — not yet posted · YYYY-MM-DD HH:MM UTC]**`, then
+ the message text. Leave the bead in `state:need-info` for now.
+
+ ## Step 6: Review in an editable textbox
+
+ - **ALWAYS** propose the message for review first — never post without it. Use `mitto_ui_textbox`:
- **Title**: "📤 Review clarifying question before posting to Slack"
- **Text**: the proposed message
- **Result**: "text"
- **Abort**: true
- **Timeout**: 300
+ - The tool returns `{ result, changed, aborted, timed_out }`. Branch on these in Step 7. **Do NOT
+ post to Slack unless the user submits (Step 7 "On submit").**
+
+ ## Step 7: Act on the textbox result
- ## Step 6: Post + log (on submit)
+ ### On submit (`aborted` and `timed_out` are both false)
- - The returned text (possibly edited) is the final message. Post it with the post-reply-to-thread
+ - The returned `result` (possibly edited) is the final message. Post it with the post-reply-to-thread
tool, using the stored `slack_channel` and the thread's parent `slack_thread_ts`.
- Confirm success and show the permalink (build it from the posted ts: `/archives/
/p`).
- - **Log to the bead** — add a markdown `[OUTBOUND]` comment with the final text and the permalink.
- Preserve the markdown layout with `$'...'` (real `\n`) or stdin:
+ - **Log to the bead** — add a markdown `[OUTBOUND]` comment (this one is the **posted** message, not
+ a draft) with the final text and the permalink. Preserve the markdown layout with `$'...'` (real
+ `\n`) or stdin:
```
printf '%s' "$comment_markdown" | bd comment --stdin
```
- Header: `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**` — and note in the body that this is a request
- for more information.
+ Header: `**[OUTBOUND us · POSTED (request for info) · YYYY-MM-DD HH:MM UTC]**` — note in the body
+ that this is a request for more information.
- **Transition state** (same issue, no subtasks — never `bd set-state`):
`bd update --remove-label state:need-info --add-label state:awaiting-customer`.
(If Step 2 continued from a different state, remove that state label instead.)
- ## On abort
+ ### On timeout (`timed_out == true`)
+
+ The user is away — **do NOT post anything to Slack**, and do **not** re-open the dialog.
+
+ - If the user **edited** the text before the timeout (`changed == true` and a non-empty `result` is
+ returned), update the stored draft to that edited text: add a fresh DRAFT `[OUTBOUND]` comment
+ (header `**[OUTBOUND us · DRAFT (request for info, edited, not posted) · YYYY-MM-DD HH:MM UTC]**`)
+ so the latest version is on the bead. If it was not edited, the Step 5 draft already covers it.
+ - Leave the bead in `state:need-info`. `mitto_ui_notify` (info): "Review dialog timed out — assuming
+ you're away. Nothing posted; the draft is saved on the bead. Re-run **Support: gather more
+ information** when you're back." Then stop.
+
+ ### On abort (`aborted == true`)
- - Do **NOT** post anything to Slack. Leave the bead in `state:need-info`.
- - Acknowledge and stop.
+ - Do **NOT** post anything to Slack. Leave the bead in `state:need-info` (the saved draft stays
+ pending). Acknowledge and stop.
## Notes
- Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth.
+ - The proposed message is **always** saved on the bead as a DRAFT `[OUTBOUND]` comment before review
+ and re-saved (as POSTED) after approval, so it is never lost even if the review dialog times out.
- When the customer replies, the thread moves to `state:awaiting-us`; use **"Support: check
status"** to pull the reply onto the bead, then **"Support: reply to user"** once we can answer.
- **NEVER** post in the support channel without explicit review and approval.
diff --git a/config/prompts/builtin/support-investigate.prompt.yaml b/config/prompts/builtin/support-investigate.prompt.yaml
index 792b67b1d..48bfd6ea8 100644
--- a/config/prompts/builtin/support-investigate.prompt.yaml
+++ b/config/prompts/builtin/support-investigate.prompt.yaml
@@ -129,17 +129,36 @@ prompt: |-
- **Confident answer found** → record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment
clearly marked **"DRAFT — not yet posted"** (this is the same draft **"Support: reply to user"**
reuses). Then set `state:drafting`
- (`bd update --remove-label state:gathering-info --add-label state:drafting`) and
- `mitto_ui_notify` (success) that a draft is ready to review with **"Support: reply to user"**.
+ (`bd update --remove-label state:gathering-info --add-label state:drafting`) and hand off per
+ Step 7.
- **Still missing customer detail** → set `state:need-info`
(`bd update --remove-label state:gathering-info --add-label state:need-info`), note what is
- missing, and `mitto_ui_notify` (info) to run **"Support: gather more information"**.
+ missing, and `mitto_ui_notify` (info) to run **"Support: gather more information"**. Then stop.
- **Inconclusive** → leave `state:gathering-info`, record what is blocking, and `mitto_ui_notify`
- (info) with the suggested next step.
+ (info) with the suggested next step. Then stop.
+
+ ## Step 7: Hand off the draft (do NOT dump it as chat text, do NOT ask for approval here)
+
+ Once a draft is saved and the bead is `state:drafting`, your job is done. **Posting — with an
+ editable review and explicit approval — is the job of "Support: reply to user", not this prompt.**
+
+ - ❌ **NEVER** paste the full draft into the chat as plain text.
+ - ❌ **NEVER** ask a free-text "approve / hold / post?" question. This prompt does not post, so it
+ must not offer to post.
+ - ✅ **Persist first** — the draft is already saved as an `[OUTBOUND]` DRAFT comment on the bead
+ (Step 6); that bead comment is the single source of truth for the reply. Do not rely on the chat
+ transcript to carry the draft.
+ - ✅ Send a short **`mitto_ui_notify`** (success): a draft is ready and **saved on the bead**, review
+ and post it with **"Support: reply to user"**. Include the bead ID. Do not include the full draft
+ body in the notification — just confirm it is saved and where to review it.
+ - ✅ End with a brief one-line chat summary (state is now `drafting`; draft saved on the bead;
+ next step is **"Support: reply to user"**). Do not reproduce the draft.
## Notes
- Load the bead (`bd show` + `bd comments`) **before** investigating — it is the source of truth.
- - This prompt is **investigate-and-record** — it never posts a reply to the Slack channel.
+ - This prompt is **investigate-and-record** — it never posts a reply to the Slack channel, and it
+ never presents the draft for approval. It saves the draft on the bead and hands off.
- Keep the bead the single source of truth: log findings as `[CONTEXT]`, the draft as a marked
- `[OUTBOUND]` DRAFT, and every state change as `[STATE]`.
+ `[OUTBOUND]` DRAFT, and every state change as `[STATE]`. The chat transcript is **not** a store —
+ the draft must live on the bead, never only in chat.
diff --git a/config/prompts/builtin/support-watch-channel.prompt.yaml b/config/prompts/builtin/support-watch-channel.prompt.yaml
index d999a261c..393a9089c 100644
--- a/config/prompts/builtin/support-watch-channel.prompt.yaml
+++ b/config/prompts/builtin/support-watch-channel.prompt.yaml
@@ -50,15 +50,20 @@ prompt: |-
{{- if and .Session.IsLoop (not .Session.IsLoopForced) }}
**Silent scheduled run** — nobody is watching.
- - Use **only** `mitto_ui_notify`. Do **NOT** call `mitto_ui_options` / `mitto_ui_form` /
- `mitto_ui_textbox` — they would block with no one to answer.
- - Do triage + reconcile beads normally. If you do **not** yet know how to gather answers (see the
- **Knowledge self-check** below), notify and **skip drafting** this run.
+ - Use **only** `mitto_ui_notify` for status and summaries. Do **NOT** call `mitto_ui_options` /
+ `mitto_ui_form` / `mitto_ui_textbox` for anything **except the knowledge-file gate below** —
+ they would otherwise block with no one to answer.
+ - **Exception — the knowledge-file gate.** If `.mitto/slack-support-{{ $channel }}.md` is missing
+ you cannot answer this channel, so you MAY use a blocking `mitto_ui_form` / `mitto_ui_textbox`
+ **with a short `timeout_seconds`** to ask for the instructions and save the file. If nobody
+ answers before the timeout, fall back to a `mitto_ui_notify` warning, then continue triage +
+ reconcile but **skip drafting** this run — the next iteration asks again.
+ - Otherwise do triage + reconcile beads normally.
{{- else }}
- **Interactive run** (first send, or force-triggered ▶️) — a user may be present, so you MAY use the
- interactive `mitto_ui_*` tools. This is the moment to run the **Knowledge self-check** and, if
- needed, ask the user how to gather answers and persist it to this channel's knowledge file.
+ **Interactive run** (first send, or force-triggered ▶️) — a user is present, so use the interactive
+ `mitto_ui_*` tools freely. Run the **knowledge-file gate first**: if the file is missing, ask the
+ user how to gather answers and persist it before doing anything else.
{{- end }}
## CRITICAL: user interaction rules
@@ -118,7 +123,7 @@ prompt: |-
| `resolved` | answered/accepted (also `bd close `) |
| `stale` | auto-closed after 10+ days of inactivity (also `bd close `) |
- ## Knowledge self-check: the per-channel knowledge file
+ ## Knowledge-file gate: check this FIRST, every iteration
Before you can help a customer you must know a **reliable way to gather answers** for this specific
channel's domain. That knowledge is stored **per channel** in
@@ -126,27 +131,36 @@ prompt: |-
channel maintains its own knowledge base (which repos to search, which docs/runbooks to consult,
common patterns, escalation paths, and how to use any relevant MCP tools).
- 1. **Read it.** Check whether `.mitto/slack-support-{{ $channel }}.md` exists.
- - **If it exists** — read it in full at the start of the run. It is the **authoritative guide**
- for how to gather information and answer questions for this channel. Use it in Step 4.
- - **If it does NOT exist** — you do not yet know how to answer reliably for this channel:
- - **Interactive run:** create the `.mitto/` directory if missing, then ASK the user (via
- `mitto_ui_form` / `mitto_ui_textbox`) for channel-specific guidance: which repos to search,
- which docs/runbooks/knowledge bases to consult, common patterns, escalation paths, and how
- to use any relevant MCP tools. **Do not be satisfied until you get concrete, usable
- guidance** — if the reply is vague, re-ask for specifics. Write it to
- `.mitto/slack-support-{{ $channel }}.md` (create the file), then confirm the write. From then
- on, every run will find it in step 1.
- - **Silent scheduled run:** nobody is watching, so do **NOT** block. Send a `mitto_ui_notify`
- (warning) explaining that no knowledge file exists yet for this channel and the user should
- force-run this prompt (▶️) once to create it. Continue triage + keeping beads in sync, but
- **skip drafting** answers this run.
+ **This gate runs first, before every numbered step below — on *every* iteration, not just the
+ first.** A channel that never had a knowledge file keeps getting asked until one exists.
+
+ 1. **Check + create.** At the **start of every iteration**, check whether
+ `.mitto/slack-support-{{ $channel }}.md` exists.
+ - **If it exists** — read it in full now. It is the **authoritative guide** for how to gather
+ information and answer questions for this channel. Use it in Step 4.
+ - **If it does NOT exist** — you do not yet know how to answer reliably, so **ask for the
+ instructions and save them before proceeding** (this applies on every run, silent or
+ interactive):
+ - Create the `.mitto/` directory if missing, then ASK the user (via `mitto_ui_form` /
+ `mitto_ui_textbox`) for channel-specific guidance: which repos to search, which
+ docs/runbooks/knowledge bases to consult, common patterns, escalation paths, and how to use
+ any relevant MCP tools. **Do not be satisfied until you get concrete, usable guidance** — if
+ the reply is vague, re-ask for specifics. Write it to `.mitto/slack-support-{{ $channel }}.md`
+ (create the file), then confirm the write. From then on, every run finds it above.
+ - **On a silent scheduled run**, use a **short `timeout_seconds`** on the ask so an unattended
+ tick does not hang. If nobody answers before it times out, send a `mitto_ui_notify` (warning)
+ that no knowledge file exists yet, then continue triage + keeping beads in sync but **skip
+ drafting** this run. The next iteration asks again.
2. **Keep it current.** Whenever you learn a new useful pattern while working a question this run
(a better search, a docs link that answered it, a recurring issue + resolution, an escalation
contact), **append it** to `.mitto/slack-support-{{ $channel }}.md` so future runs benefit.
## Instructions
+ > **Run the knowledge-file gate above FIRST**, at the start of every iteration — before Step 0 and
+ > everything else. If `.mitto/slack-support-{{ $channel }}.md` is missing, ask for the instructions
+ > and save the file before triaging.
+
### 0. Ensure beads ready + housekeeping
- Verify the database (`bd list`; if none, `bd init`).
@@ -197,11 +211,11 @@ prompt: |-
answer: set `state:gathering-info`, follow **`.mitto/slack-support-{{ $channel }}.md`** to gather
the answer, then record a **proposed reply** as an `[OUTBOUND]`-style *draft* comment (clearly
marked "DRAFT — not yet posted") and set `state:drafting`. If you learn a reusable pattern,
- append it to the knowledge file (see the Knowledge self-check).
+ append it to the knowledge file (see the knowledge-file gate).
- If you cannot answer without more detail from the customer, set `state:need-info` and note what
is missing (the interactive **"Support: gather more information"** prompt will ask them).
- **Never post to Slack here.** Drafts wait for review in **"Support: reply to user"**.
- - If no knowledge file exists for this channel yet, skip this step (see the Knowledge self-check).
+ - If no knowledge file exists for this channel yet, skip this step (see the knowledge-file gate).
### 5. Summary
From 27522128cdf5750e468c5859549e835f88c0e27f Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 14:57:49 +0200
Subject: [PATCH 028/240] feat(prompts): add "Support: housekeeping" sweep
prompt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New prompt that periodically sweeps all tracked support-question beads:
refreshes each from its linked Slack thread, closes or flags stale/irrelevant
tickets (with user confirmation), and re-evaluates priority/status/next-steps
for everything that stays open. Read-only on Slack — never posts a reply.
---
.../builtin/support-housekeeping.prompt.yaml | 155 ++++++++++++++++++
1 file changed, 155 insertions(+)
create mode 100644 config/prompts/builtin/support-housekeeping.prompt.yaml
diff --git a/config/prompts/builtin/support-housekeeping.prompt.yaml b/config/prompts/builtin/support-housekeeping.prompt.yaml
new file mode 100644
index 000000000..483dd11d0
--- /dev/null
+++ b/config/prompts/builtin/support-housekeeping.prompt.yaml
@@ -0,0 +1,155 @@
+name: 'Support: housekeeping'
+description: 'Sweep all tracked support tickets: refresh each from its Slack thread, close (or flag) stale/irrelevant ones, then re-evaluate priority/status/next-steps for the rest. Reads Slack; never posts a reply.'
+group: Support
+backgroundColor: '#D7CCC8'
+icon: broom
+menus: beadsList
+enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads")'
+tags:
+- support
+prompt: |-
+ # Support — Housekeeping
+
+ ## Session Context
+
+ Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls.
+
+ ## Description
+
+ Periodic maintenance sweep over **all** tracked support tickets (beads labelled
+ `support-question`). For each ticket you (1) refresh it from its linked Slack thread and update
+ state/notes, (2) close or flag tickets that are stale / no longer relevant, and (3) re-evaluate the
+ priority, status, and next steps of everything that stays open. The bead is always the source of
+ truth — record what you do as comments and state-label changes.
+
+ > **Scope:** the whole `support-question` backlog (list-level). This prompt operates on many beads,
+ > not a single one.
+
+ ## CRITICAL: User Interaction Rules
+
+ > ⚠️ **NEVER use text-based interaction prompts.**
+ >
+ > - ❌ NEVER ask the user to type a number, command, or keyword to make a selection
+ > - ❌ NEVER present numbered options and ask the user to respond with text
+ > - ✅ ALWAYS use `mitto_ui_options` for choices, `mitto_ui_textbox` for review/editing,
+ > `mitto_ui_form` for structured input, and `mitto_ui_notify` for non-blocking notifications
+ >
+ > ⚠️ **This prompt NEVER posts to Slack.** It only reads threads and updates beads. Posting a reply
+ > is done — with your approval — by **"Support: reply to user"**.
+
+ ## Slack tools (names vary by MCP server)
+
+ Match Slack MCP tools **by capability, not exact name**. You only need to **read a thread's
+ replies** (e.g. `slack_get_thread_replies*`, `conversations_replies_slack`). Read each ticket's
+ channel + parent `thread_ts` from its bead metadata (`slack_channel` / `slack_thread_ts`). No
+ posting tool is needed.
+
+ ## Step 0: Gate — is there anything to do?
+
+ - List the open tracked support tickets:
+ `bd list -l support-question --status open,in_progress --all`.
+ - **If the list is empty** (zero open support tickets), there is nothing to sweep. Send a
+ `mitto_ui_notify` (info): "No open support tickets — housekeeping has nothing to do." and **stop
+ immediately**. Do not proceed to the next steps.
+ - Otherwise, collect the ticket IDs and continue. Announce how many you will process.
+
+ ## Step 1: Confirm the plan
+
+ - Summarise the backlog you are about to sweep (count + a one-line list of ` [state] — `).
+ - Use `mitto_ui_options` (timeout 300):
+ - **Question**: "Sweep these support tickets (refresh from Slack, close stale, re-evaluate)?"
+ - **Options**: `[{label: "Yes, run housekeeping"}, {label: "Cancel"}]`.
+ - On **"Cancel"** (or timeout): acknowledge and stop without changing anything.
+
+ ## Step 2: Refresh each ticket from Slack
+
+ For **each** ticket in the backlog, load `bd show ` + `bd comments ` (bead = source of
+ truth), then reconcile it with its Slack thread:
+
+ - Re-fetch the thread (read-thread-replies tool) using the stored `slack_thread_ts` /
+ `slack_channel`. Build the chronological message list (author + UTC time) and identify messages
+ **not yet captured** on the bead.
+ - Add one markdown comment per **new** message (never duplicate existing ones; preserve layout with
+ `printf '%s' "$c" | bd comment --stdin`):
+ - Customer messages → `**[INBOUND @user · YYYY-MM-DD HH:MM UTC]**`
+ - Team / others → `**[CONTEXT @user · YYYY-MM-DD HH:MM UTC]**`
+ - Any of our own replies not yet logged → `**[OUTBOUND us · YYYY-MM-DD HH:MM UTC]**`
+ - Note any reactions on our answers (positive = helpful, negative = not helpful).
+ - Update the `state:*` label **in place** to reflect whose turn it is (same issue — never
+ `bd set-state`), following the standard rules:
+ - Customer spoke last / owes us nothing but we must act → `state:awaiting-us`.
+ - `state:need-info` **and the customer just replied** → `state:awaiting-us` (do not strand it in
+ `need-info`).
+ - We spoke last and are waiting on them → `state:awaiting-customer`.
+ - Do **not** override `state:drafting` (a draft is pending your review) unless the thread clearly
+ moved on.
+ Apply with `bd update --remove-label state: --add-label state:` and add a short
+ `**[STATE · ]** → ` comment explaining why.
+ - If the thread is unreachable (channel/thread gone, no metadata), record a `[CONTEXT]` note that it
+ could not be refreshed and carry it into Step 3 as a stale candidate.
+
+ ## Step 3: Identify stale / no-longer-relevant tickets
+
+ A ticket is a **stale candidate** when, based on the thread activity and bead content, it no longer
+ needs our attention. Signals (any of):
+
+ - The thread was **resolved** (positive reaction on our answer, a "thanks / that worked / got it",
+ or it concluded with no open question).
+ - **No activity for a long time** and the ball was on the customer (`state:awaiting-customer` /
+ `state:need-info` with a stale last message) — effectively abandoned.
+ - The thread is **gone / deleted / unreachable**, or the underlying question is obsolete
+ (superseded, duplicate of another bead, or overtaken by events).
+
+ Build the list of stale candidates with a one-line reason each. Present them with `mitto_ui_options`
+ (timeout 300):
+ - **Question**: "These tickets look stale/irrelevant. Close them now, or just flag for review?"
+ - **Options**: `[{label: "Close all listed"}, {label: "Flag only (no close)"}, {label: "Let me pick"}, {label: "Skip"}]`.
+ - **"Close all listed"** → for each: swap to `state:resolved` (or add `state:obsolete` if the
+ reason is obsolete/duplicate) and `bd close -r "housekeeping: "`.
+ - **"Flag only"** → for each, add label `needs-review` and a `[STATE]` comment with the reason;
+ do NOT close.
+ - **"Let me pick"** → present the candidates as a second `mitto_ui_options` (or `mitto_ui_form`
+ with a checkbox per candidate) and act only on the chosen ones (close them; leave the rest).
+ - **"Skip"** (or timeout) → change nothing in this step.
+ - Never close a ticket in `state:drafting` (a reply is pending your approval) without explicit
+ confirmation — surface it separately rather than auto-closing.
+
+ ## Step 4: Re-evaluate the remaining open tickets
+
+ For every ticket still open after Step 3, reassess and update:
+
+ - **Priority** — bump or lower `P0..P3` based on impact/urgency evident from the thread (an
+ escalation, a P1 incident reference, many affected users → raise; a nice-to-have or single-user
+ question → lower). Apply with `bd update --priority ` and note the change.
+ - **Status / state** — make sure the `state:*` label matches reality after Step 2 (whose turn,
+ drafting pending, needs info, etc.).
+ - **Next step** — add a short `**[NEXT · ]**` comment naming the concrete next action and the
+ prompt that performs it (**"Support: investigate"** to find an answer, **"Support: gather more
+ information"** to ask the customer, **"Support: reply to user"** to post a ready draft).
+
+ ## Step 5: Summarise (in the conversation)
+
+ Present a concise report **in this conversation** (not to Slack):
+
+ ```markdown
+ ## Support housekeeping —
+
+ **Swept:** tickets
+ **Refreshed from Slack:** (with new messages: )
+ **Closed / flagged stale:** — reason>
+ **Re-prioritised / restated:** — change>
+
+ ### Needs your attention
+ - —
+ ```
+
+ Finish with a `mitto_ui_notify` (success) summarising counts (swept / closed / needs-attention).
+
+ ## Notes
+
+ - The bead is the single source of truth: log every new thread message, state change, priority
+ change, and next step as comments — never keep them only in chat.
+ - **Read-only on Slack** — this prompt never posts a reply. Hand off posting to
+ **"Support: reply to user"**.
+ - Process tickets one at a time and keep going on per-ticket errors (record a `[CONTEXT]` note and
+ move on) so one bad thread does not abort the whole sweep.
From 5cb0041ca248eae84ed5229ef48352a4b6d5fc21 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 7 Jul 2026 20:50:25 +0200
Subject: [PATCH 029/240] fix(conversation,acpproc): don't block first
interactive prompt on cold set_model + harden warm-once barrier (mitto-54k.5,
mitto-54k.3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The warm-once barrier (mitto-54k.3) fixed the session/new MCP-init wedge, but
the cold-start bottleneck moved one RPC downstream to session/set_model: the
interactive prompt goroutine called applyModelPreference synchronously before
the Prompt RPC, so a slow set_model on a cold agent (~85s observed in cgw,
Auggie Opus) blocked the first "Say hello" until "Agent slow during prompt".
Fix E (mitto-54k.5): make the interactive model switch best-effort/async.
applyModelPreference now runs SetSessionModel in a background goroutine bounded
by modelSwitchAsyncBudget (90s, session-ctx), and waits only modelSwitchSyncGrace
(3s) for it to land. A warm switch applies to THIS turn; a cold/slow switch is
deferred to the background (applying to the NEXT turn) and the prompt dispatches
immediately on the current model. Override-pill/baseline semantics preserved via
finalizeOverride (happens-before via close(done)); setModelSem serialisation and
the mitto-29q re-arm are unchanged (switch still goes through pdSetActiveModelOnly).
Barrier hardening (mitto-54k.3): shared_acp_process.go NewSession/LoadSession now
do a post-acquire warmth re-check — a caller that finds mcpInitDone=true after
waiting on the gate releases it immediately and recomputes warm budgets instead
of holding it through its RPC. Entry uses the raw cold predicate so mitto-29q
warm per-session re-handshakes still bypass the barrier.
Tests: prompt_dispatcher_test.go asserts the cold path logs "Deferring model
switch to background" and dispatches without blocking; mcp_init_budget_test.go
adds TestWarmOnceBarrier_OnlyOneHolderThroughWarmup. go vet + unit tests green
for both packages.
---
internal/acpproc/mcp_init_budget_test.go | 68 ++++++++++++++
internal/acpproc/shared_acp_process.go | 74 ++++++++++++---
internal/conversation/prompt_dispatcher.go | 82 +++++++++++++----
.../conversation/prompt_dispatcher_test.go | 91 ++++++++++++++++++-
4 files changed, 281 insertions(+), 34 deletions(-)
diff --git a/internal/acpproc/mcp_init_budget_test.go b/internal/acpproc/mcp_init_budget_test.go
index cfc1ba942..452219208 100644
--- a/internal/acpproc/mcp_init_budget_test.go
+++ b/internal/acpproc/mcp_init_budget_test.go
@@ -273,6 +273,74 @@ func TestColdStartGate_SerializesUnderConcurrency(t *testing.T) {
}
}
+// TestWarmOnceBarrier_OnlyOneHolderThroughWarmup models the mitto-54k.3
+// warm-once barrier logic used inside NewSession/LoadSession: given N
+// concurrent COLD callers racing the barrier, only ONE holds the gate
+// through the warm-up window and the rest proceed fast once mcpInitDone
+// latches. The real ACP subprocess is not exercised here — we replay the
+// entry condition (MCPInitTimeout>0 && !mcpInitDone) + acquire +
+// post-acquire re-check exactly as the production code does.
+func TestWarmOnceBarrier_OnlyOneHolderThroughWarmup(t *testing.T) {
+ p := newTestProcessWithGate()
+ p.config.MCPInitTimeout = 240 * time.Second // cold-barrier entry enabled
+
+ const N = 8
+ var holders atomic.Int32 // callers that were still cold on acquire (kept the gate)
+ var bypassers atomic.Int32 // callers that found the process warm after acquire
+ var skipped atomic.Int32 // callers that saw warm before ever taking the gate
+
+ var wg sync.WaitGroup
+ wg.Add(N)
+ for i := 0; i < N; i++ {
+ go func() {
+ defer wg.Done()
+
+ // (a) barrier ENTRY condition (raw cold predicate).
+ if !(p.config.MCPInitTimeout > 0 && !p.mcpInitDone.Load()) {
+ skipped.Add(1)
+ return
+ }
+
+ // (b) acquire the gate.
+ release, err := p.acquireColdStartGate(context.Background())
+ if err != nil {
+ t.Errorf("acquire failed: %v", err)
+ return
+ }
+
+ // (c) post-acquire warmth re-check.
+ if p.mcpInitDone.Load() {
+ // Barrier holder warmed the process while we waited: release
+ // IMMEDIATELY and proceed as a warm caller.
+ release()
+ bypassers.Add(1)
+ return
+ }
+
+ // Holder path: hold the gate through the warm-up, then latch
+ // mcpInitDone BEFORE the deferred release fires (mirroring the
+ // production ordering where mcpInitDone.Store(true) at line ~1507
+ // / ~1732 happens before `defer release()` runs).
+ holders.Add(1)
+ time.Sleep(30 * time.Millisecond) // simulate the MCP-init handshake
+ p.mcpInitDone.Store(true)
+ release()
+ }()
+ }
+ wg.Wait()
+
+ if got := holders.Load(); got != 1 {
+ t.Fatalf("expected exactly 1 barrier holder, got %d (bypassers=%d, skipped=%d)",
+ got, bypassers.Load(), skipped.Load())
+ }
+ if got := holders.Load() + bypassers.Load() + skipped.Load(); got != N {
+ t.Fatalf("caller accounting mismatch: %d != %d", got, N)
+ }
+ if !p.mcpInitDone.Load() {
+ t.Fatal("expected mcpInitDone to be latched after the holder warmed the process")
+ }
+}
+
func TestBeginMCPInitWindow_ResetsPerCall(t *testing.T) {
p := &SharedACPProcess{}
p.mcpInitTimedOut.Store(true)
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index b4e79f5cf..a0f67c889 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -1391,17 +1391,44 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
defer budgetCancel()
}
- // Cold-start admission gate (mitto-8tb): serialize concurrent cold session/new
- // callers so the agent's MCP handshake fires once and warms the process before
- // the remaining sessions proceed, instead of N conversations stampeding the
- // handshake in parallel. Warm callers (extendedBudget=false) bypass the gate.
- // Honours budgetCtx so a wedged holder can't block a caller past its deadline.
- if extendedBudget {
+ // Warm-once barrier (mitto-54k.3): at a genuinely COLD shared ACP process the
+ // first session/new triggers the agent's full MCP handshake (Auggie re-handshakes
+ // ALL its MCP servers on EVERY session/new — mitto-29q — so N stampeding cold
+ // ops = N full handshakes competing on the agent's single event loop). We admit
+ // ONE cold caller through the capacity-1 gate as the barrier holder, let it run
+ // the RPC (which latches mcpInitDone on success — see line ~1507 below), and
+ // keep the gate held via `defer release()` until it warms the process. Every
+ // other cold caller that arrives while the holder is warming waits on the gate,
+ // then on acquire finds mcpInitDone=true, releases the gate IMMEDIATELY and
+ // re-computes its own budgets via coldMCPBudget — which now returns warm
+ // (extendedBudget=false, normal 25s budget). The barrier ENTRY condition uses
+ // the raw cold predicate (MCPInitTimeout>0 && !mcpInitDone) rather than
+ // extendedBudget, so mitto-29q warm per-session re-handshakes
+ // (mcpInitDone=true, mcpInitInProgress=true → extendedBudget=true) do NOT
+ // serialize on the barrier — they still bypass it and keep their extended
+ // budget from the UNCHANGED coldMCPBudget. On holder RPC FAILURE the deferred
+ // release still fires so the next queued caller becomes the new (still-cold)
+ // holder — no caller stranded. budgetCtx bounds the wait so a wedged holder
+ // can't block past MCPInitTimeout.
+ if p.config.MCPInitTimeout > 0 && !p.mcpInitDone.Load() {
release, err := p.acquireColdStartGate(budgetCtx)
if err != nil {
return nil, fmt.Errorf("session/new: context cancelled while waiting for cold-start gate: %w", err)
}
- defer release()
+ // Post-acquire warmth re-check: if the barrier holder warmed the process
+ // while we waited, release the gate immediately (do NOT hold it through
+ // our RPC) and recompute budgets so we run as a warm caller (normal 25s
+ // per-attempt budget, extendedBudget=false). Only a caller still cold on
+ // acquire keeps the gate via `defer release()` and holds it through its
+ // RPC — mcpInitDone latches on RPC success BEFORE deferred release runs,
+ // so the gate is inherently held "until warm."
+ if p.mcpInitDone.Load() {
+ release()
+ perAttemptBudget, totalBudget, extendedBudget = p.coldMCPBudget(len(mcpServers) > 0)
+ _ = totalBudget // budgetCtx already derived above; wider ceiling is acceptable
+ } else {
+ defer release()
+ }
}
// Arm the MCP-init timeout watch so a hard timeout signal from the agent's
@@ -1639,15 +1666,38 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
// the caller's own deadline is still honoured (we never extend it).
rpcCtx := ctx
perAttemptBudget, _, extendedBudget := p.coldMCPBudget(len(mcpServers) > 0)
- var mcpTimeoutCh <-chan struct{}
- if extendedBudget {
- // Cold-start admission gate (mitto-8tb): serialize concurrent cold callers.
- // Honours the caller ctx so a wedged holder can't block past its deadline.
+
+ // Warm-once barrier (mitto-54k.3): symmetric with NewSession. Admit ONE cold
+ // caller as the barrier holder, hold the gate via `defer release()` through
+ // its RPC (mcpInitDone latches on success at line ~1732 BEFORE the deferred
+ // release runs, so the gate is inherently held "until warm"). Other cold
+ // callers that arrive while the holder is warming wait on the gate, then on
+ // acquire find mcpInitDone=true, release the gate IMMEDIATELY and recompute
+ // their own budgets via coldMCPBudget — which now returns warm (normal 25s
+ // per-attempt budget, extendedBudget=false). The barrier ENTRY condition uses
+ // the raw cold predicate (MCPInitTimeout>0 && !mcpInitDone) rather than
+ // extendedBudget, so mitto-29q warm per-session re-handshakes
+ // (mcpInitDone=true, mcpInitInProgress=true → extendedBudget=true) do NOT
+ // serialize on the barrier — they still bypass it and keep their extended
+ // budget from the UNCHANGED coldMCPBudget. On holder RPC FAILURE the deferred
+ // release still fires so the next queued caller becomes the new (still-cold)
+ // holder — no caller stranded. ctx bounds the wait so a wedged holder can't
+ // block past MCPInitTimeout.
+ if p.config.MCPInitTimeout > 0 && !p.mcpInitDone.Load() {
release, gateErr := p.acquireColdStartGate(ctx)
if gateErr != nil {
return nil, fmt.Errorf("session/load: context cancelled while waiting for cold-start gate: %w", gateErr)
}
- defer release()
+ if p.mcpInitDone.Load() {
+ release()
+ perAttemptBudget, _, extendedBudget = p.coldMCPBudget(len(mcpServers) > 0)
+ } else {
+ defer release()
+ }
+ }
+
+ var mcpTimeoutCh <-chan struct{}
+ if extendedBudget {
mcpTimeoutCh = p.beginMCPInitWindow()
if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > perAttemptBudget {
var loadCancel context.CancelFunc
diff --git a/internal/conversation/prompt_dispatcher.go b/internal/conversation/prompt_dispatcher.go
index 43c8fcf36..1f25905f8 100644
--- a/internal/conversation/prompt_dispatcher.go
+++ b/internal/conversation/prompt_dispatcher.go
@@ -805,6 +805,21 @@ func (p promptDispatcher) createFreshContextSession(d promptDeps, meta PromptMet
return ""
}
+// modelSwitchSyncGrace bounds how long applyModelPreference blocks the interactive
+// prompt waiting for a set_model RPC to land. A warm switch completes well within
+// this window so the preferred model applies to THIS turn; a cold/slow switch
+// exceeds it, so the prompt is dispatched on the current model immediately and the
+// switch completes in the background (applying to the NEXT turn). Prevents the
+// cold-start wedge where a synchronous ~41s set_model blocked the first prompt
+// (mitto-54k.5). A var so tests can shrink it.
+var modelSwitchSyncGrace = 3 * time.Second
+
+// modelSwitchAsyncBudget is the total wall-clock budget for the background set_model
+// RPC, bounded by the session context. Mirrors the aux-session async budget so a
+// cold agent gets its full retry schedule off the critical path (mitto-54k.5).
+// A var so tests can shrink it.
+var modelSwitchAsyncBudget = 90 * time.Second
+
// applyModelPreference ensures the correct model is active before sending the prompt.
// Implements set-if-different (lazy): only issues a SetSessionModel RPC when the
// desired model differs from the current active model. No-op when agentModels is nil.
@@ -839,16 +854,16 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) {
isOverride := desired != "" && desired != baseline
switching := desired != "" && desired != currentModel
- switchFailed := false
- if switching {
- setCtx, setCancel := context.WithTimeout(d.pdSessionCtx(), 15*time.Second)
- if setErr := d.pdSetActiveModelOnly(setCtx, desired); setErr != nil {
- switchFailed = true
- if l := d.pdLogger(); l != nil {
- l.Warn("Failed to apply model preference", "model", desired, "error", setErr)
- }
+
+ finalizeOverride := func(switchFailed bool) {
+ if isOverride && !switchFailed {
+ d.pdRecordSessionChange(
+ ConfigOptionCategoryModelOverride,
+ ModelDisplayName(models, desired),
+ ModelDisplayName(models, baseline),
+ )
}
- setCancel()
+ d.pdWriteOverrideActive(isOverride)
}
if l := d.pdLogger(); l != nil {
@@ -873,18 +888,47 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) {
"decision", decision)
}
- // Emit a timeline pill when this prompt runs on a model different from the
- // conversation baseline, so the transient override is visible to the user.
- // Skipped when the switch RPC failed (the model did not actually change).
- if isOverride && !switchFailed {
- d.pdRecordSessionChange(
- ConfigOptionCategoryModelOverride,
- ModelDisplayName(models, desired),
- ModelDisplayName(models, baseline),
- )
+ if !switching {
+ finalizeOverride(false)
+ return
}
- d.pdWriteOverrideActive(isOverride)
+ // A model switch is required. Do NOT block the interactive prompt on a slow
+ // set_model (mitto-54k.5): run the switch in the background bounded by the
+ // session context, but wait up to modelSwitchSyncGrace for it to land so a warm
+ // switch still applies to THIS turn. On a cold/slow agent the grace elapses, the
+ // prompt is dispatched on the current model, and the switch completes in the
+ // background (applying to the NEXT turn). setModelSem serialisation and the
+ // mitto-29q re-arm are preserved because the switch still goes through
+ // pdSetActiveModelOnly -> SetSessionModel.
+ done := make(chan struct{})
+ go func() {
+ setCtx, setCancel := context.WithTimeout(d.pdSessionCtx(), modelSwitchAsyncBudget)
+ defer setCancel()
+ setErr := d.pdSetActiveModelOnly(setCtx, desired)
+ if setErr != nil {
+ if l := d.pdLogger(); l != nil {
+ l.Warn("Failed to apply model preference", "model", desired, "error", setErr)
+ }
+ }
+ // finalize BEFORE signalling done so the warm path observes the pill and
+ // override flag as soon as the select returns (happens-before via close(done)).
+ finalizeOverride(setErr != nil)
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // Switch landed within the grace window (warm/fast): applies to this turn.
+ case <-time.After(modelSwitchSyncGrace):
+ // Cold/slow: dispatch the prompt now; the switch completes in the background.
+ if l := d.pdLogger(); l != nil {
+ l.Info("Deferring model switch to background; dispatching prompt on current model",
+ "session_id", d.pdSessionID(),
+ "desired_model", desired,
+ "current_model", currentModel)
+ }
+ }
}
// accumulateTokenUsage stores and accumulates token usage from a prompt response.
diff --git a/internal/conversation/prompt_dispatcher_test.go b/internal/conversation/prompt_dispatcher_test.go
index 365bc67a9..a09ebf855 100644
--- a/internal/conversation/prompt_dispatcher_test.go
+++ b/internal/conversation/prompt_dispatcher_test.go
@@ -88,6 +88,7 @@ type fakePromptDeps struct {
overrideActive bool
setActiveModelCalls []string
setActiveModelErr error
+ setActiveModelGate chan struct{} // if non-nil, block in pdSetActiveModelOnly until closed (simulates a slow/cold set_model)
recordedSessionChanges []session.SessionChangeData
// === New in 2.5-d ===
@@ -279,11 +280,20 @@ func (f *fakePromptDeps) pdWriteOverrideActive(active bool) {
defer f.mu.Unlock()
f.overrideActive = active
}
-func (f *fakePromptDeps) pdSetActiveModelOnly(_ context.Context, modelID string) error {
+func (f *fakePromptDeps) pdSetActiveModelOnly(ctx context.Context, modelID string) error {
f.mu.Lock()
- defer f.mu.Unlock()
f.setActiveModelCalls = append(f.setActiveModelCalls, modelID)
- return f.setActiveModelErr
+ gate := f.setActiveModelGate
+ err := f.setActiveModelErr
+ f.mu.Unlock()
+ if gate != nil {
+ select {
+ case <-gate:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ return err
}
func (f *fakePromptDeps) pdRecordSessionChange(kind, value, previousValue string) {
f.mu.Lock()
@@ -1617,6 +1627,81 @@ func TestPromptDispatcher_ApplyModelPreference_SwitchFails_NoPill(t *testing.T)
}
}
+func TestPromptDispatcher_ApplyModelPreference_ColdSlowSwitch_DoesNotBlockPrompt(t *testing.T) {
+ // Shrink the synchronous grace so the test is fast.
+ origGrace := modelSwitchSyncGrace
+ modelSwitchSyncGrace = 30 * time.Millisecond
+ defer func() { modelSwitchSyncGrace = origGrace }()
+
+ p := promptDispatcher{}
+ d := newFakePromptDeps()
+ d.agentModels = &acp.UnstableSessionModelState{
+ CurrentModelId: "m-1",
+ AvailableModels: []acp.UnstableModelInfo{
+ {ModelId: "m-1", Name: "Model 1"},
+ {ModelId: "m-2", Name: "Model 2"},
+ },
+ }
+ d.baselineModel = "m-1"
+ d.modelProfiles = []config.ModelProfile{
+ {Name: "Pref2", Criteria: &config.ACPServerConstraint{MatchMode: "contains", Pattern: "Model 2"}},
+ }
+ gate := make(chan struct{})
+ d.setActiveModelGate = gate
+ var buf bytes.Buffer
+ d.logger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ start := time.Now()
+ p.applyModelPreference(d, PromptMeta{PreferredModels: []config.PromptPreferredModel{{ModelName: "Pref2"}}})
+ elapsed := time.Since(start)
+
+ // The interactive prompt must NOT block on the slow set_model.
+ if elapsed > 500*time.Millisecond {
+ t.Fatalf("applyModelPreference blocked on slow set_model (%s); expected to return near the grace window", elapsed)
+ }
+ if !strings.Contains(buf.String(), "Deferring model switch to background") {
+ t.Fatalf("expected deferral log, got: %s", buf.String())
+ }
+
+ // The background switch was attempted (poll to avoid scheduling flakiness).
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ d.mu.Lock()
+ calls := len(d.setActiveModelCalls)
+ d.mu.Unlock()
+ if calls == 1 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("expected the background switch to be attempted once")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+
+ // The override pill/flag are NOT applied yet (switch still in flight -> next turn).
+ d.mu.Lock()
+ pills := len(d.recordedSessionChanges)
+ d.mu.Unlock()
+ if pills != 0 {
+ t.Fatalf("expected no override pill until the deferred switch lands, got %d", pills)
+ }
+
+ // Release the switch; it should now complete and apply the override.
+ close(gate)
+ deadline = time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ d.mu.Lock()
+ pills = len(d.recordedSessionChanges)
+ override := d.overrideActive
+ d.mu.Unlock()
+ if pills == 1 && override {
+ return // success: switch landed, override applied for the next turn
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("deferred model switch did not apply the override after the switch completed")
+}
+
// --- accumulateTokenUsage tests ---
func TestPromptDispatcher_AccumulateTokenUsage_UsagePresent_SetsAndAccumulates(t *testing.T) {
From ca51477d7ee2509cf6b2cb61703d673bdcb8d259 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 08:47:10 +0200
Subject: [PATCH 030/240] fix(web): isolate MITTO_DIR in
TestHandleWorkspacePrompts_FileDeleted (mitto-nnu)
The test built Server{} with zero-valued config, so
computeWorkspacePromptsLastModified (broadened by mitto-tf9 to fold in ALL
prompt-source mtimes) still consulted appdir.PromptsDir() and
appdir.SettingsPath(), which resolve to the developer's real Mitto data dir.
Those files survive the .mittorc deletion, so Last-Modified stayed non-zero and
the assertion "no Last-Modified after deletion" failed.
mitto-tf9's fold-all-sources behavior is correct; this was a test-isolation
gap. Isolate MITTO_DIR to an empty temp dir using the codebase's established
pattern (t.Setenv + appdir.ResetCache + t.Cleanup) so all folded sources return
zero after deletion. Test-only change; no production code affected.
---
internal/web/session_api_test.go | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/internal/web/session_api_test.go b/internal/web/session_api_test.go
index e722747a8..ed795e082 100644
--- a/internal/web/session_api_test.go
+++ b/internal/web/session_api_test.go
@@ -13,6 +13,7 @@ import (
"testing"
"time"
+ "github.com/inercia/mitto/internal/appdir"
"github.com/inercia/mitto/internal/config"
"github.com/inercia/mitto/internal/conversation"
"github.com/inercia/mitto/internal/session"
@@ -322,6 +323,14 @@ func TestHandleWorkspacePrompts_ConditionalRequest(t *testing.T) {
}
func TestHandleWorkspacePrompts_FileDeleted(t *testing.T) {
+ // Isolate MITTO_DIR to an empty temp dir so the Last-Modified computation
+ // (which folds in ALL prompt-source mtimes since mitto-tf9) does not pick up
+ // the developer's real global prompts dir / settings.json. Without this, those
+ // real files survive the .mittorc deletion and keep Last-Modified non-zero.
+ t.Setenv(appdir.MittoDirEnv, t.TempDir())
+ appdir.ResetCache()
+ t.Cleanup(appdir.ResetCache)
+
// Create a temp directory with a .mittorc file
tmpDir := t.TempDir()
rcPath := tmpDir + "/.mittorc"
From 59438267c69cb3007c4eb3eacef255658c456ec9 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 09:16:54 +0200
Subject: [PATCH 031/240] fix(beads): recover valid JSON from non-zero bd exit;
retry reads on transient lock (mitto-xl0)
bd can exit non-zero while still emitting the intended JSON payload (e.g. the created-issue JSON printed to stderr with a non-zero exit right after a restart), which Mitto logged as a hard 'beads command failed'.
- execRunner.Run returns raw untruncated stdout+stderr on error; log-truncation moved to CmdError construction.
- runJSONOnce recovers valid, non-error JSON from stdout or stderr on a non-zero exit (recoverableJSON + isJSONErrorObject guard against treating {error:...} as success).
- runJSONRead retries once on a transient dolt-lock (isTransientLock), scoped to read ops only; Create stays recovery-only so a non-idempotent write is never retried/duplicated.
- Tests cover recovery from stdout/stderr, error-object-not-recovered, read-retries-once, and create-not-retried.
---
internal/beads/beads_test.go | 105 +++++++++++++++++++++++++++++++++++
internal/beads/cli.go | 102 ++++++++++++++++++++++++++++++----
2 files changed, 196 insertions(+), 11 deletions(-)
diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go
index 13d966bfc..87ca4e6de 100644
--- a/internal/beads/beads_test.go
+++ b/internal/beads/beads_test.go
@@ -622,6 +622,111 @@ func TestClient_RunnerError_WrappedAsCmdError(t *testing.T) {
}
}
+// ---------------------------------------------------------------------------
+// runJSON recovery + read retry (mitto-xl0)
+// ---------------------------------------------------------------------------
+
+// TestRunJSON_RecoversJSONFromStderr covers the bug that motivated mitto-xl0: bd
+// can exit non-zero while still emitting the intended JSON payload (observed on
+// Create: the created-issue JSON printed to stderr right after a dolt restart).
+// Create must treat that response as a success rather than logging a hard
+// "beads command failed".
+func TestRunJSON_RecoversJSONFromStderr(t *testing.T) {
+ r := &recordingRunner{responses: []runnerResp{
+ {
+ stderr: `[{"id":"mitto-54k.5"}]`,
+ err: errors.New("bd exited with non-zero status"),
+ },
+ }}
+ c := newClient(r)
+ out, err := c.Create(context.Background(), initializedDir(t), CreateParams{Title: "T"})
+ if err != nil {
+ t.Fatalf("Create() error = %v, want nil (JSON on stderr should be recovered)", err)
+ }
+ if !strings.Contains(string(out), "mitto-54k.5") {
+ t.Errorf("recovered bytes = %q, want to contain %q", out, "mitto-54k.5")
+ }
+}
+
+// TestRunJSON_RecoversJSONFromStdout covers the symmetric case where the JSON
+// payload landed on stdout but bd still exited non-zero (e.g. a stderr advisory
+// during dolt warm-up).
+func TestRunJSON_RecoversJSONFromStdout(t *testing.T) {
+ r := &recordingRunner{responses: []runnerResp{
+ {
+ stdout: []byte(`{"id":"mitto-1"}`),
+ stderr: "warning: dolt sync advisory",
+ err: errors.New("bd exited with non-zero status"),
+ },
+ }}
+ c := newClient(r)
+ out, err := c.Create(context.Background(), initializedDir(t), CreateParams{Title: "T"})
+ if err != nil {
+ t.Fatalf("Create() error = %v, want nil (JSON on stdout should be recovered)", err)
+ }
+ if !strings.Contains(string(out), "mitto-1") {
+ t.Errorf("recovered bytes = %q, want to contain %q", out, "mitto-1")
+ }
+}
+
+// TestRunJSON_ErrorObjectNotRecovered ensures that a bd machine-readable error
+// JSON payload (top-level "error" key) is NOT treated as a success, even
+// though it happens to be valid JSON.
+func TestRunJSON_ErrorObjectNotRecovered(t *testing.T) {
+ r := &recordingRunner{responses: []runnerResp{
+ {
+ stdout: []byte(`{"error":"boom"}`),
+ err: errors.New("bd exited with non-zero status"),
+ },
+ }}
+ c := newClient(r)
+ _, err := c.Create(context.Background(), initializedDir(t), CreateParams{Title: "T"})
+ if err == nil {
+ t.Fatal("expected error, got nil (JSON error object must not be recovered)")
+ }
+ var ce *CmdError
+ if !errors.As(err, &ce) {
+ t.Fatalf("error type = %T, want *CmdError", err)
+ }
+}
+
+// TestRunJSONRead_RetriesOnceOnTransientLock verifies that read-only commands
+// retry once when the first invocation fails with a transient dolt lock error.
+func TestRunJSONRead_RetriesOnceOnTransientLock(t *testing.T) {
+ r := &recordingRunner{responses: []runnerResp{
+ {stderr: "another dolt process is using the database", err: errors.New("bd exited with non-zero status")},
+ {stdout: []byte("[]")},
+ }}
+ c := newClient(r)
+ out, err := c.List(context.Background(), initializedDir(t))
+ if err != nil {
+ t.Fatalf("List() error after retry = %v, want nil", err)
+ }
+ if string(out) != "[]" {
+ t.Errorf("List() = %q, want %q", out, "[]")
+ }
+ if len(r.calls) != 2 {
+ t.Errorf("runner call count = %d, want 2 (initial + one retry)", len(r.calls))
+ }
+}
+
+// TestCreate_NotRetriedOnTransientLock verifies that Create — which is
+// non-idempotent — is NOT retried even on a transient lock error, to avoid
+// duplicating a write if the first attempt actually committed.
+func TestCreate_NotRetriedOnTransientLock(t *testing.T) {
+ r := &recordingRunner{responses: []runnerResp{
+ {stderr: "another dolt process is using the database", err: errors.New("bd exited with non-zero status")},
+ }}
+ c := newClient(r)
+ _, err := c.Create(context.Background(), initializedDir(t), CreateParams{Title: "T"})
+ if err == nil {
+ t.Fatal("expected error (Create must not retry)")
+ }
+ if len(r.calls) != 1 {
+ t.Errorf("runner call count = %d, want 1 (Create must not retry)", len(r.calls))
+ }
+}
+
// ---------------------------------------------------------------------------
// EnsureInitialized
// ---------------------------------------------------------------------------
diff --git a/internal/beads/cli.go b/internal/beads/cli.go
index 27d754395..f009c4caf 100644
--- a/internal/beads/cli.go
+++ b/internal/beads/cli.go
@@ -51,7 +51,7 @@ func (r execRunner) Run(ctx context.Context, dir string, args ...string) ([]byte
} else if errors.As(err, &exitErr) {
msg = "bd exited with non-zero status"
}
- return nil, diagnosticOutput(stderr.String(), stdout.String()), errors.New(msg)
+ return stdout.Bytes(), stderr.String(), errors.New(msg)
}
return stdout.Bytes(), "", nil
@@ -77,6 +77,56 @@ func diagnosticOutput(stderr, stdout string) string {
return diag
}
+// recoverableJSON returns the first candidate that is valid JSON and is not a
+// bd machine-readable error object, or nil if none qualifies. Candidates are
+// checked in order (stdout preferred, then stderr).
+func recoverableJSON(candidates ...[]byte) []byte {
+ for _, cand := range candidates {
+ trimmed := bytes.TrimSpace(cand)
+ if len(trimmed) == 0 || !json.Valid(trimmed) {
+ continue
+ }
+ if isJSONErrorObject(trimmed) {
+ continue
+ }
+ return trimmed
+ }
+ return nil
+}
+
+// isJSONErrorObject reports whether b is a JSON object carrying a top-level
+// "error" key, i.e. a bd machine-readable failure rather than a success
+// payload. A JSON array (list output) or an object without an "error" key is
+// treated as a success payload.
+func isJSONErrorObject(b []byte) bool {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(b, &obj); err != nil {
+ return false
+ }
+ for k := range obj {
+ if strings.EqualFold(k, "error") {
+ return true
+ }
+ }
+ return false
+}
+
+// isTransientLock reports whether err is a transient dolt/database lock or
+// contention failure that is safe to retry for a read-only command.
+func isTransientLock(err error) bool {
+ s := strings.ToLower(StderrOf(err))
+ if s == "" {
+ return false
+ }
+ if strings.Contains(s, "another dolt process") ||
+ strings.Contains(s, "database is locked") ||
+ strings.Contains(s, "database table is locked") ||
+ strings.Contains(s, "resource temporarily unavailable") {
+ return true
+ }
+ return strings.Contains(s, "lock") && (strings.Contains(s, "could not acquire") || strings.Contains(s, "failed to acquire"))
+}
+
// envWithActor returns a copy of the current process environment with any
// existing BEADS_ACTOR entry removed and a single BEADS_ACTOR=actor appended, so
// the bd subprocess is stamped with the given actor regardless of what the
@@ -105,16 +155,27 @@ func (c *cliClient) runRaw(ctx context.Context, timeout time.Duration, dir strin
out, stderr, err := c.runner.Run(ctx, dir, args...)
if err != nil {
- return nil, &CmdError{Err: err, Stderr: stderr}
+ return nil, &CmdError{Err: err, Stderr: diagnosticOutput(stderr, string(out))}
}
return out, nil
}
-// runJSON executes bd with the default timeout and validates that the output is valid JSON.
-func (c *cliClient) runJSON(ctx context.Context, dir string, args ...string) ([]byte, error) {
- out, err := c.runRaw(ctx, defaultTimeout, dir, args...)
+// runJSONOnce executes bd once (defaultTimeout) and validates JSON output. On a
+// non-zero exit it applies JSON recovery: bd can exit non-zero while still
+// emitting the intended JSON payload (observed: created-issue JSON printed to
+// stderr with a non-zero exit right after a restart). If the raw stdout or
+// stderr already contains valid, non-error JSON, the call is treated as a
+// success rather than a hard failure.
+func (c *cliClient) runJSONOnce(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
+ defer cancel()
+
+ out, stderr, err := c.runner.Run(ctx, dir, args...)
if err != nil {
- return nil, err
+ if j := recoverableJSON(out, []byte(stderr)); j != nil {
+ return j, nil
+ }
+ return nil, &CmdError{Err: err, Stderr: diagnosticOutput(stderr, string(out))}
}
if !json.Valid(out) {
return nil, &CmdError{Err: errors.New("bd returned invalid JSON")}
@@ -122,6 +183,25 @@ func (c *cliClient) runJSON(ctx context.Context, dir string, args ...string) ([]
return out, nil
}
+// runJSON runs a JSON bd command with recovery but NO retry. Some callers
+// (Create) are non-idempotent, so a blind retry could duplicate a write; the
+// recovery in runJSONOnce already handles the observed non-zero-but-valid-JSON
+// case safely.
+func (c *cliClient) runJSON(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ return c.runJSONOnce(ctx, dir, args...)
+}
+
+// runJSONRead is like runJSON but retries ONCE on a transient dolt-lock
+// failure. It is safe only for read-only commands (no risk of a duplicate
+// write).
+func (c *cliClient) runJSONRead(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ out, err := c.runJSONOnce(ctx, dir, args...)
+ if err != nil && isTransientLock(err) {
+ out, err = c.runJSONOnce(ctx, dir, args...)
+ }
+ return out, err
+}
+
func (c *cliClient) List(ctx context.Context, dir string) ([]byte, error) {
// An uninitialized folder has no issues yet. Return an empty list rather
// than letting bd fail, so simply opening the Tasks view does not surface an
@@ -129,7 +209,7 @@ func (c *cliClient) List(ctx context.Context, dir string) ([]byte, error) {
if !isInitialized(dir) {
return []byte("[]"), nil
}
- return c.runJSON(ctx, dir, "list", "--json", "--all", "-n", "0")
+ return c.runJSONRead(ctx, dir, "list", "--json", "--all", "-n", "0")
}
func (c *cliClient) Status(ctx context.Context, dir string) ([]byte, error) {
@@ -139,11 +219,11 @@ func (c *cliClient) Status(ctx context.Context, dir string) ([]byte, error) {
if !isInitialized(dir) {
return []byte(`{"summary":{}}`), nil
}
- return c.runJSON(ctx, dir, "status", "--json", "--no-activity")
+ return c.runJSONRead(ctx, dir, "status", "--json", "--no-activity")
}
func (c *cliClient) Show(ctx context.Context, dir, id string) ([]byte, error) {
- return c.runJSON(ctx, dir, "show", id, "--json", "--include-comments")
+ return c.runJSONRead(ctx, dir, "show", id, "--json", "--include-comments")
}
func (c *cliClient) Create(ctx context.Context, dir string, p CreateParams) ([]byte, error) {
@@ -203,7 +283,7 @@ func cleanupTimeout(n int) time.Duration {
}
func (c *cliClient) ListClosedIDs(ctx context.Context, dir string) ([]string, error) {
- out, err := c.runJSON(ctx, dir, "list", "--json", "--status", "closed", "-n", "0")
+ out, err := c.runJSONRead(ctx, dir, "list", "--json", "--status", "closed", "-n", "0")
if err != nil {
return nil, err
}
@@ -312,5 +392,5 @@ func (c *cliClient) ListAllLabels(ctx context.Context, dir string) ([]byte, erro
if !isInitialized(dir) {
return []byte("[]"), nil
}
- return c.runJSON(ctx, dir, "label", "list-all", "--json")
+ return c.runJSONRead(ctx, dir, "label", "list-all", "--json")
}
From 949ab93581fe8c7aab5fde889e27bd45c9a8340b Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 09:26:12 +0200
Subject: [PATCH 032/240] feat(coldstart): unified phase tracer +
host-contention sampler + semaphore/RPC timing (mitto-3mv, mitto-54k.4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add cold-start diagnostics to pinpoint where cold session/new and prompt
dispatch spend time on a saturated agent event loop.
mitto-3mv — new leaf package internal/coldstart:
- Trace: per-cold-start phase tracer (cold_start_id, elapsed_ms, phase_ms)
with context propagation (WithTrace/FromContext) so RPCs in acpproc can be
correlated back to the originating conversation trace.
- Contention sampler: goroutine count, CPU count, and best-effort OS load
average (unix.SysctlRaw on Darwin, /proc/loadavg on Linux); injected
providers for concurrent_prompting / live_acp_processes.
- Ring buffer of recent traces for post-hoc inspection.
Integration:
- conversation: trace lifecycle, MCP-init episode duration, slow-agent WARN
contention snapshot, resume-semaphore observability; thread WithTrace
through the handshaker (hsColdTraceCtx) so acpproc RPCs receive cold_start_id.
- acpproc: log wait_ms/permits_in_use/capacity for coldStartGate and
setModelSem; add cold_start_id correlation to session/new + session/load RPC
timing logs.
- mcpserver: mitto_coldstart_recent debug tool exposing the ring buffer.
mitto-54k.4 (closed, awaiting commit) — defer non-foreground LoadSession
until the cold shared ACP process is warm:
- SharedProcess.MCPInitDone()/WaitForMCPInit() interface methods; background
WS resumes wait for the foreground handshake to warm the agent before
competing for the event loop (warm-gate before resumeSemaphore).
Verified: build, vet, and unit tests green for coldstart, conversation,
acpproc, web, and mcpserver.
---
.augment/rules/42-mcpserver-development.md | 4 +-
go.mod | 2 +-
internal/acpproc/acp_process_manager.go | 11 +-
internal/acpproc/shared_acp_process.go | 74 +++++-
internal/acpproc/wait_for_mcp_init_test.go | 64 +++++
internal/coldstart/coldstart.go | 241 ++++++++++++++++++
internal/coldstart/coldstart_test.go | 189 ++++++++++++++
internal/coldstart/contention.go | 83 ++++++
internal/coldstart/contention_darwin.go | 57 +++++
internal/coldstart/contention_linux.go | 26 ++
internal/coldstart/contention_other.go | 8 +
internal/conversation/background_session.go | 60 +++++
.../conversation/background_session_test.go | 2 +
.../conversation/bgsession_acp_process.go | 14 +-
internal/conversation/bgsession_coldstart.go | 120 +++++++++
internal/conversation/bgsession_prompt.go | 3 +
.../conversation/bgsession_shared_session.go | 13 +
internal/conversation/interfaces.go | 11 +-
internal/conversation/prompt_dispatcher.go | 15 ++
.../conversation/prompt_dispatcher_test.go | 3 +
internal/conversation/session_manager.go | 73 +++++-
.../conversation/shared_session_handshaker.go | 85 +++++-
.../shared_session_handshaker_test.go | 11 +
internal/mcpserver/server.go | 17 ++
internal/mcpserver/types.go | 15 ++
internal/web/resume_semaphore.go | 10 +
internal/web/session_ws.go | 14 +-
27 files changed, 1203 insertions(+), 22 deletions(-)
create mode 100644 internal/acpproc/wait_for_mcp_init_test.go
create mode 100644 internal/coldstart/coldstart.go
create mode 100644 internal/coldstart/coldstart_test.go
create mode 100644 internal/coldstart/contention.go
create mode 100644 internal/coldstart/contention_darwin.go
create mode 100644 internal/coldstart/contention_linux.go
create mode 100644 internal/coldstart/contention_other.go
create mode 100644 internal/conversation/bgsession_coldstart.go
diff --git a/.augment/rules/42-mcpserver-development.md b/.augment/rules/42-mcpserver-development.md
index bfce379a2..c9c073e09 100644
--- a/.augment/rules/42-mcpserver-development.md
+++ b/.augment/rules/42-mcpserver-development.md
@@ -26,9 +26,9 @@ Single global MCP server at `http://127.0.0.1:5757/mcp`. Two tool classes:
- **Global tools** (no session): `mitto_conversation_list`, `mitto_get_config`, `mitto_get_runtime_info`
- **Session-scoped tools** (require `self_id`): UI prompts, conversation control, history, prompt management (`mitto_prompt_list/get/update`), loop control (`mitto_conversation_set_loop`, `mitto_conversation_run_loop_now`)
-## Cold-Start Inbound `/mcp` Starvation (fixed: mitto-54k)
+## Cold-Start MCP Wedge (mitto-54k) — Corrected Diagnosis
-Was confirmed across multiple cold starts: an agent's (e.g. Auggie) *inbound* HTTP `initialize`/`tools/list` to Mitto's own `/mcp` endpoint (`127.0.0.1:5757/mcp`, same process serving the UI) could be starved during the session resume storm (many sessions resuming at once on native-app cold start). Symptom: agent logs `⏳ mitto (timed out)` for 170–560s while all *external* MCP servers (github/jira/slack/etc.) succeed — only `mitto` shares the saturated process. The cold-start gate (`mitto-8tb`) only serializes Mitto's *outbound* `session/new`/`session/load`; it does not throttle or prioritize this inbound `/mcp` handshake. Fixed by two independent changes: **mitto-54k.1** bounds the interactive resume storm at the source (a per-`Server` semaphore caps concurrent interactive `ResumeSession` calls, configurable via `startup_resume_concurrency`; `ensure_resumed`/foreground bypasses it — `internal/web/resume_semaphore.go`); **mitto-54k.2** is the independent durable safety net — an audit confirmed Mitto's own inbound `initialize`/`tools/list` are already served lock-free by the go-sdk's static tool table (no `s.mu`/`s.sessionsMu`, no blocking helper on that path), backed by a regression test (`internal/mcpserver/server_fastpath_test.go`) asserting bounded latency under concurrent load.
+The original theory (Auggie's *inbound* HTTP `initialize`/`tools/list` into Mitto's own `/mcp` endpoint starved during the session-resume storm) was **falsified** by direct probing: live `initialize` returns in ~1.5ms; a 12-way concurrent `initialize`+`tools/list` load test completes in 13ms wall, 0 errors. Mitto's inbound `/mcp` path is fast and lock-free (regression-guarded by `internal/mcpserver/server_fastpath_test.go`, owned by mitto-54k.2, repurposed from "fix" to "guard"). **Real root cause** (mitto-29q): Auggie re-handshakes **all** its configured MCP servers on every `session/new`; `stdio` servers spawn cheap parallel child processes, but the single `http`/`sse` server (`mitto`) initializes **inline on the agent's main event loop** — so workspaces with more stdio MCP servers in the agent's own config starve the inline `mitto` handshake more (severity scales with stdio server count in the *agent's* MCP config, not Mitto's own session count — e.g. a 6-server workspace with 2 Mitto sessions wedges, a 1-server workspace with 10 sessions never does). Fixed by **mitto-54k.3** (warm-once barrier in `internal/acpproc/shared_acp_process.go`: admits one cold `session/new` through, waits for `mcpInitDone`, then releases queued cold callers as warm — DONE) + **mitto-54k.4** (defer background `LoadSession` until the process is warm — OPEN P2). **Post-fix caveat**: wedges still recur intermittently and are not always MCP-init-bound — a baseline run can show MCP init completing in ~2s while the prompt still wedges on cold `set_model`/first-token latency. **Diagnostic gotcha**: before running a server-removal (or any single-variable) timing experiment, check for concurrent auggie/ACP processes (`ps`) and other active/loop conversations in the same or sibling workspaces — CPU contention from unrelated concurrent agents confounds the measurement and can masquerade as MCP-server-count effects.
## Adding New Tools
diff --git a/go.mod b/go.mod
index de2af7395..5e6813b00 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,7 @@ require (
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/mermaid v0.6.0
+ golang.org/x/sys v0.41.0
golang.org/x/time v0.14.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -61,7 +62,6 @@ require (
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/protobuf v1.36.10 // indirect
diff --git a/internal/acpproc/acp_process_manager.go b/internal/acpproc/acp_process_manager.go
index da4ce5336..cc10c0d2f 100644
--- a/internal/acpproc/acp_process_manager.go
+++ b/internal/acpproc/acp_process_manager.go
@@ -13,6 +13,7 @@ import (
"github.com/coder/acp-go-sdk"
"github.com/inercia/mitto/internal/auxiliary"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/config"
"github.com/inercia/mitto/internal/conversation"
"github.com/inercia/mitto/internal/runner"
@@ -274,7 +275,7 @@ func diffEnvKeys(a, b map[string]string) (added, removed, changed []string) {
// It does NOT perform orphan cleanup — call CleanupOrphanedProcesses() explicitly
// at server startup if orphan cleanup is desired.
func NewACPProcessManager(ctx context.Context, logger *slog.Logger) *ACPProcessManager {
- return &ACPProcessManager{
+ m := &ACPProcessManager{
processes: make(map[string]*SharedACPProcess),
auxSessions: make(map[auxSessionKey]*auxiliarySessionState),
auxCreateMu: make(map[auxSessionKey]*sync.Mutex),
@@ -282,6 +283,14 @@ func NewACPProcessManager(ctx context.Context, logger *slog.Logger) *ACPProcessM
ctx: ctx,
logger: logger,
}
+ // Diagnostic: expose the live shared-ACP-process count to the coldstart
+ // sampler (mitto-3mv). Latest manager wins; benign in tests.
+ coldstart.SetLiveACPCounter(func() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.processes)
+ })
+ return m
}
// CleanupOrphanedProcesses kills any ACP processes left over from a previous Mitto
diff --git a/internal/acpproc/shared_acp_process.go b/internal/acpproc/shared_acp_process.go
index a0f67c889..446f26bc2 100644
--- a/internal/acpproc/shared_acp_process.go
+++ b/internal/acpproc/shared_acp_process.go
@@ -16,6 +16,7 @@ import (
"github.com/coder/acp-go-sdk"
mittoAcp "github.com/inercia/mitto/internal/acp"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/conversation"
"github.com/inercia/mitto/internal/logging"
"github.com/inercia/mitto/internal/runner"
@@ -372,6 +373,12 @@ type SharedACPProcess struct {
mcpInitMu sync.Mutex
mcpInitTimeoutCh chan struct{}
+ // mcpInitDoneCh is closed exactly once (via mcpInitDoneOnce) when mcpInitDone
+ // first latches, so WaitForMCPInit can block until the process is warm without
+ // polling (mitto-54k.4).
+ mcpInitDoneOnce sync.Once
+ mcpInitDoneCh chan struct{}
+
// coldStartGate serializes concurrent NewSession/LoadSession callers on a
// still-cold process (mitto-8tb). N conversations racing their deferred
// session/new against a fresh shared process would each make the agent re-run
@@ -397,6 +404,7 @@ func NewSharedACPProcess(ctx context.Context, config SharedACPProcessConfig) (*S
logger: config.Logger,
setModelSem: make(chan struct{}, 1),
coldStartGate: make(chan struct{}, 1),
+ mcpInitDoneCh: make(chan struct{}),
}
if err := p.startProcess(); err != nil {
@@ -1193,8 +1201,16 @@ func (p *SharedACPProcess) acquireColdStartGate(ctx context.Context) (release fu
if p.coldStartGate == nil {
return func() {}, nil
}
+ gateWaitStart := time.Now()
select {
case p.coldStartGate <- struct{}{}:
+ if p.logger != nil {
+ p.logger.Debug("cold_start_gate acquired",
+ "wait_ms", time.Since(gateWaitStart).Milliseconds(),
+ "permits_in_use", len(p.coldStartGate),
+ "capacity", cap(p.coldStartGate),
+ "cold_start_id", coldstart.FromContext(ctx).ID())
+ }
return func() { <-p.coldStartGate }, nil
case <-ctx.Done():
return nil, ctx.Err()
@@ -1221,6 +1237,17 @@ func (p *SharedACPProcess) RecommendedLoadTimeout(hasMCPServers bool) time.Durat
return p.config.MCPInitTimeout
}
+// markMCPInitDone latches mcpInitDone and closes mcpInitDoneCh exactly once so
+// waiters in WaitForMCPInit unblock. Safe to call on every successful session RPC.
+func (p *SharedACPProcess) markMCPInitDone() {
+ p.mcpInitDone.Store(true)
+ p.mcpInitDoneOnce.Do(func() {
+ if p.mcpInitDoneCh != nil {
+ close(p.mcpInitDoneCh)
+ }
+ })
+}
+
// MCPInitDone reports whether the shared process's MCP-init window has
// closed (the agent's first successful RPC observed). Used by the adaptive
// pre-warming controller (mitto-mw0) to compute the health verdict.
@@ -1228,6 +1255,33 @@ func (p *SharedACPProcess) MCPInitDone() bool {
return p.mcpInitDone.Load()
}
+// WaitForMCPInit blocks until the shared process's MCP-init window closes
+// (mcpInitDone latched via a successful session RPC), ctx is done, or the
+// process exits. Returns true only if the process became warm. Used by the
+// background resume path (mitto-54k.4) to defer non-foreground LoadSession
+// until the foreground session's handshake warms the agent, without stranding
+// background sessions (the caller bounds ctx).
+func (p *SharedACPProcess) WaitForMCPInit(ctx context.Context) bool {
+ if p.mcpInitDone.Load() {
+ return true
+ }
+ if p.mcpInitDoneCh == nil {
+ return p.mcpInitDone.Load()
+ }
+ processDone := p.processDone
+ if processDone == nil {
+ processDone = make(chan struct{}) // never fires; process-exit not observable
+ }
+ select {
+ case <-p.mcpInitDoneCh:
+ return true
+ case <-ctx.Done():
+ return false
+ case <-processDone:
+ return false
+ }
+}
+
// MCPInitTimedOut reports whether the shared process's stderr monitor has
// seen the agent report its internal MCP-init wait budget elapsed (a hard
// "MCP is broken" signal). Used by the adaptive pre-warming controller
@@ -1531,7 +1585,7 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
if err == nil {
p.recordRPCSuccess()
- p.mcpInitDone.Store(true)
+ p.markMCPInitDone()
p.mcpInitInProgress.Store(false) // close the MCP-init window (mitto-29q)
handle := &conversation.SessionHandle{
SessionID: string(sessResp.SessionId),
@@ -1553,7 +1607,8 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
"total_ms", time.Since(totalStart).Milliseconds(),
"rpc_new_session_ms", rpcDuration.Milliseconds(),
"extended_mcp_budget", extendedBudget,
- "per_attempt_budget_ms", perAttemptBudget.Milliseconds())
+ "per_attempt_budget_ms", perAttemptBudget.Milliseconds(),
+ "cold_start_id", coldstart.FromContext(rpcCtx).ID())
}
return handle, nil
}
@@ -1590,6 +1645,7 @@ func (p *SharedACPProcess) NewSession(ctx context.Context, cwd string, mcpServer
"ctx_remaining_ms", ctxRemainingMs,
"rpc_code", rpcCode,
"extended_mcp_budget", extendedBudget,
+ "cold_start_id", coldstart.FromContext(rpcCtx).ID(),
"error", err)
}
@@ -1746,13 +1802,14 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
"ctx_remaining_ms", ctxRemainingMs,
"ctx_already_expired", ctxAlreadyExpired,
"extended_mcp_budget", extendedBudget,
+ "cold_start_id", coldstart.FromContext(rpcCtx).ID(),
"error", err)
}
return nil, fmt.Errorf("failed to load session: %w", err)
}
p.recordRPCSuccess()
- p.mcpInitDone.Store(true)
+ p.markMCPInitDone()
p.mcpInitInProgress.Store(false) // close the MCP-init window (mitto-29q)
handle := &conversation.SessionHandle{
SessionID: acpSessionID,
@@ -1767,7 +1824,8 @@ func (p *SharedACPProcess) LoadSession(ctx context.Context, acpSessionID, cwd st
"acp_session_id", acpSessionID,
"total_ms", time.Since(totalStart).Milliseconds(),
"rpc_load_session_ms", rpcDuration.Milliseconds(),
- "extended_mcp_budget", extendedBudget)
+ "extended_mcp_budget", extendedBudget,
+ "cold_start_id", coldstart.FromContext(rpcCtx).ID())
}
return handle, nil
@@ -1965,8 +2023,16 @@ func (p *SharedACPProcess) SetSessionModel(ctx context.Context, sessionID acp.Se
// Acquire the per-process serialisation semaphore, respecting caller ctx.
// This ensures only one set_model RPC is in-flight at a time — concurrent
// callers queue here instead of racing the serially-served agent subprocess.
+ setModelWaitStart := time.Now()
select {
case p.setModelSem <- struct{}{}:
+ if p.logger != nil {
+ p.logger.Debug("set_model_sem acquired",
+ "wait_ms", time.Since(setModelWaitStart).Milliseconds(),
+ "permits_in_use", len(p.setModelSem),
+ "capacity", cap(p.setModelSem),
+ "cold_start_id", coldstart.FromContext(ctx).ID())
+ }
defer func() { <-p.setModelSem }()
case <-ctx.Done():
return fmt.Errorf("set_model: cancelled while waiting for serialization slot: %w", ctx.Err())
diff --git a/internal/acpproc/wait_for_mcp_init_test.go b/internal/acpproc/wait_for_mcp_init_test.go
new file mode 100644
index 000000000..f63559b89
--- /dev/null
+++ b/internal/acpproc/wait_for_mcp_init_test.go
@@ -0,0 +1,64 @@
+package acpproc
+
+// Tests for WaitForMCPInit (mitto-54k.4). The helper blocks background resume
+// callers until the shared process's MCP-init window closes so foreground
+// session/new wins the agent's event loop first.
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestWaitForMCPInit_ReturnsTrueWhenAlreadyWarm(t *testing.T) {
+ p := &SharedACPProcess{mcpInitDoneCh: make(chan struct{})}
+ p.markMCPInitDone()
+
+ if !p.WaitForMCPInit(context.Background()) {
+ t.Fatal("expected WaitForMCPInit=true when mcpInitDone already latched")
+ }
+}
+
+func TestWaitForMCPInit_UnblocksOnLatch(t *testing.T) {
+ p := &SharedACPProcess{mcpInitDoneCh: make(chan struct{})}
+
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ p.markMCPInitDone()
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ if !p.WaitForMCPInit(ctx) {
+ t.Fatal("expected WaitForMCPInit=true after markMCPInitDone latches")
+ }
+ if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
+ t.Fatalf("WaitForMCPInit unblocked too slowly: %v", elapsed)
+ }
+}
+
+func TestWaitForMCPInit_ReturnsFalseOnCtxCancel(t *testing.T) {
+ p := &SharedACPProcess{mcpInitDoneCh: make(chan struct{})}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if p.WaitForMCPInit(ctx) {
+ t.Fatal("expected WaitForMCPInit=false when ctx cancels before latch")
+ }
+}
+
+func TestWaitForMCPInit_ReturnsFalseOnProcessDone(t *testing.T) {
+ done := make(chan struct{})
+ close(done)
+ p := &SharedACPProcess{
+ mcpInitDoneCh: make(chan struct{}),
+ processDone: done,
+ }
+
+ if p.WaitForMCPInit(context.Background()) {
+ t.Fatal("expected WaitForMCPInit=false when process is already done")
+ }
+}
diff --git a/internal/coldstart/coldstart.go b/internal/coldstart/coldstart.go
new file mode 100644
index 000000000..e789478e4
--- /dev/null
+++ b/internal/coldstart/coldstart.go
@@ -0,0 +1,241 @@
+// Package coldstart provides diagnostic instrumentation for cold-start flows.
+//
+// It is a leaf package: it depends only on the Go standard library and
+// golang.org/x/sys/unix. It intentionally imports no other internal/*
+// package so it can be consumed anywhere in the tree without creating
+// import cycles.
+//
+// A Trace represents one cold-start's correlated timeline. Callers record
+// phase boundaries via Phase and finalize with Summary. All Trace methods
+// are nil-safe: a nil *Trace behaves as a no-op, so callers never need
+// guards. Completed traces are retained in a small ring buffer accessible
+// via RecentSummaries for debugging endpoints.
+package coldstart
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+)
+
+// ringCapacity is the maximum number of completed Summary entries kept
+// in memory. Newest entries evict oldest.
+const ringCapacity = 64
+
+// PhaseRecord is one recorded phase boundary.
+type PhaseRecord struct {
+ Name string `json:"name"`
+ ElapsedMs int64 `json:"elapsed_ms"`
+ PhaseMs int64 `json:"phase_ms"`
+ At time.Time `json:"at"`
+}
+
+// Summary is a completed trace snapshot kept in the ring buffer.
+type Summary struct {
+ ID string `json:"cold_start_id"`
+ SessionID string `json:"session_id"`
+ WorkspaceUUID string `json:"workspace_uuid"`
+ Outcome string `json:"outcome"`
+ TotalMs int64 `json:"total_ms"`
+ Phases []PhaseRecord `json:"phases"`
+ At time.Time `json:"at"`
+}
+
+// Trace is one cold-start's correlated timeline.
+type Trace struct {
+ id string
+ sessionID string
+ workspaceUUID string
+ logger *slog.Logger
+
+ mu sync.Mutex
+ begin time.Time
+ lastPhase time.Time
+ phases []PhaseRecord
+ done bool
+}
+
+// New creates a Trace with a fresh short random cold_start_id.
+// logger may be nil.
+func New(logger *slog.Logger, sessionID, workspaceUUID string) *Trace {
+ return &Trace{
+ id: newID(),
+ sessionID: sessionID,
+ workspaceUUID: workspaceUUID,
+ logger: logger,
+ }
+}
+
+// newID returns a short random id (~12 hex chars from 6 random bytes),
+// falling back to a time-based id if the RNG fails.
+func newID() string {
+ var b [6]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return fmt.Sprintf("t%x", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// ID returns the trace id, or "" if the trace is nil.
+func (t *Trace) ID() string {
+ if t == nil {
+ return ""
+ }
+ return t.id
+}
+
+// Phase records a phase boundary and logs "cold_start_phase" at INFO.
+// The first Phase() call is the trace's begin: it initializes timing
+// and attaches a ContentionSnapshot to the log line. Nil-safe.
+func (t *Trace) Phase(name string, kv ...any) {
+ if t == nil {
+ return
+ }
+ now := time.Now()
+
+ t.mu.Lock()
+ first := t.begin.IsZero()
+ if first {
+ t.begin = now
+ t.lastPhase = now
+ }
+ elapsed := now.Sub(t.begin)
+ phase := now.Sub(t.lastPhase)
+ t.lastPhase = now
+ t.phases = append(t.phases, PhaseRecord{
+ Name: name,
+ ElapsedMs: elapsed.Milliseconds(),
+ PhaseMs: phase.Milliseconds(),
+ At: now,
+ })
+ t.mu.Unlock()
+
+ if t.logger == nil {
+ return
+ }
+ attrs := make([]any, 0, 12+len(kv))
+ attrs = append(attrs,
+ "cold_start_id", t.id,
+ "session_id", t.sessionID,
+ "workspace_uuid", t.workspaceUUID,
+ "phase", name,
+ "elapsed_ms", elapsed.Milliseconds(),
+ "phase_ms", phase.Milliseconds(),
+ )
+ if first {
+ attrs = append(attrs, Contention().LogAttrs()...)
+ }
+ attrs = append(attrs, kv...)
+ t.logger.Info("cold_start_phase", attrs...)
+}
+
+// Summary finalizes the trace, logs "cold_start_summary" at INFO, and
+// stores the resulting Summary in the ring buffer. Idempotent and
+// nil-safe.
+func (t *Trace) Summary(outcome string, kv ...any) {
+ if t == nil {
+ return
+ }
+ t.mu.Lock()
+ if t.done {
+ t.mu.Unlock()
+ return
+ }
+ t.done = true
+ begin := t.begin
+ phasesCopy := make([]PhaseRecord, len(t.phases))
+ copy(phasesCopy, t.phases)
+ t.mu.Unlock()
+
+ now := time.Now()
+ var totalMs int64
+ if !begin.IsZero() {
+ totalMs = now.Sub(begin).Milliseconds()
+ }
+
+ sum := Summary{
+ ID: t.id,
+ SessionID: t.sessionID,
+ WorkspaceUUID: t.workspaceUUID,
+ Outcome: outcome,
+ TotalMs: totalMs,
+ Phases: phasesCopy,
+ At: now,
+ }
+ ringAppend(sum)
+
+ if t.logger != nil {
+ attrs := make([]any, 0, 10+len(kv))
+ attrs = append(attrs,
+ "cold_start_id", t.id,
+ "session_id", t.sessionID,
+ "workspace_uuid", t.workspaceUUID,
+ "outcome", outcome,
+ "total_ms", totalMs,
+ "phases", phasesCopy,
+ )
+ attrs = append(attrs, kv...)
+ t.logger.Info("cold_start_summary", attrs...)
+ }
+}
+
+type traceCtxKey struct{}
+
+// WithTrace returns a derived context carrying t.
+func WithTrace(ctx context.Context, t *Trace) context.Context {
+ return context.WithValue(ctx, traceCtxKey{}, t)
+}
+
+// FromContext returns the Trace attached to ctx, or nil if none.
+func FromContext(ctx context.Context) *Trace {
+ if ctx == nil {
+ return nil
+ }
+ t, _ := ctx.Value(traceCtxKey{}).(*Trace)
+ return t
+}
+
+// --- ring buffer ---------------------------------------------------------
+
+var (
+ ringMu sync.Mutex
+ ringBuf [ringCapacity]Summary
+ ringLen int
+ ringNext int // index of next write slot
+)
+
+func ringAppend(s Summary) {
+ ringMu.Lock()
+ ringBuf[ringNext] = s
+ ringNext = (ringNext + 1) % ringCapacity
+ if ringLen < ringCapacity {
+ ringLen++
+ }
+ ringMu.Unlock()
+}
+
+// RecentSummaries returns up to k most-recent completed summaries,
+// newest first. k<=0 returns all held summaries.
+func RecentSummaries(k int) []Summary {
+ ringMu.Lock()
+ defer ringMu.Unlock()
+ n := ringLen
+ if k > 0 && k < n {
+ n = k
+ }
+ out := make([]Summary, 0, n)
+ // Walk backwards from most-recent write.
+ idx := ringNext - 1
+ for i := 0; i < n; i++ {
+ if idx < 0 {
+ idx += ringCapacity
+ }
+ out = append(out, ringBuf[idx])
+ idx--
+ }
+ return out
+}
diff --git a/internal/coldstart/coldstart_test.go b/internal/coldstart/coldstart_test.go
new file mode 100644
index 000000000..79244b877
--- /dev/null
+++ b/internal/coldstart/coldstart_test.go
@@ -0,0 +1,189 @@
+package coldstart
+
+import (
+ "bytes"
+ "context"
+ "log/slog"
+ "math"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func newTestLogger() (*slog.Logger, *bytes.Buffer) {
+ var buf bytes.Buffer
+ h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
+ return slog.New(h), &buf
+}
+
+func TestPhaseMonotonicAndPhaseMs(t *testing.T) {
+ logger, buf := newTestLogger()
+ tr := New(logger, "sess-1", "ws-1")
+
+ tr.Phase("begin")
+ time.Sleep(20 * time.Millisecond)
+ tr.Phase("second")
+
+ // Inspect recorded phases via Summary.
+ tr.Summary("ok")
+ sums := RecentSummaries(1)
+ if len(sums) != 1 {
+ t.Fatalf("expected 1 summary, got %d", len(sums))
+ }
+ ph := sums[0].Phases
+ if len(ph) != 2 {
+ t.Fatalf("expected 2 phases, got %d", len(ph))
+ }
+ if ph[0].ElapsedMs != 0 || ph[0].PhaseMs != 0 {
+ t.Errorf("first phase should have zero elapsed/phase, got %+v", ph[0])
+ }
+ if ph[1].ElapsedMs < ph[0].ElapsedMs {
+ t.Errorf("elapsed not monotonic: %d < %d", ph[1].ElapsedMs, ph[0].ElapsedMs)
+ }
+ if ph[1].PhaseMs <= 0 {
+ t.Errorf("second phase_ms should be > 0, got %d", ph[1].PhaseMs)
+ }
+ if !strings.Contains(buf.String(), "cold_start_phase") {
+ t.Errorf("expected cold_start_phase log line, got %q", buf.String())
+ }
+ if !strings.Contains(buf.String(), "cold_start_summary") {
+ t.Errorf("expected cold_start_summary log line, got %q", buf.String())
+ }
+}
+
+func TestNilTraceIsSafe(t *testing.T) {
+ var tr *Trace
+ if got := tr.ID(); got != "" {
+ t.Errorf("nil ID want empty, got %q", got)
+ }
+ tr.Phase("x") // must not panic
+ tr.Summary("y") // must not panic
+}
+
+func TestSummaryIdempotent(t *testing.T) {
+ tr := New(nil, "s", "w")
+ tr.Phase("a")
+ before := len(RecentSummaries(0))
+ tr.Summary("ok")
+ tr.Summary("ok") // second call should be a no-op
+ after := len(RecentSummaries(0))
+ if after-before != 1 {
+ t.Errorf("expected exactly one summary appended, got delta %d", after-before)
+ }
+}
+
+func TestRingBufferCapAndOrder(t *testing.T) {
+ // Fill well beyond capacity.
+ for i := 0; i < ringCapacity+10; i++ {
+ tr := New(nil, "s", "w")
+ tr.Phase("begin")
+ tr.Summary("ok")
+ }
+ all := RecentSummaries(0)
+ if len(all) != ringCapacity {
+ t.Errorf("expected len %d, got %d", ringCapacity, len(all))
+ }
+ // Newest-first: At timestamps should be non-increasing.
+ for i := 1; i < len(all); i++ {
+ if all[i].At.After(all[i-1].At) {
+ t.Errorf("summaries not newest-first at %d: %v after %v", i, all[i].At, all[i-1].At)
+ }
+ }
+ // k limit honored.
+ small := RecentSummaries(3)
+ if len(small) != 3 {
+ t.Errorf("expected 3, got %d", len(small))
+ }
+}
+
+func TestContentionDefaults(t *testing.T) {
+ // Ensure clean provider state before assertions.
+ SetPromptingCounter(nil)
+ SetLiveACPCounter(nil)
+
+ c := Contention()
+ if c.NumGoroutine <= 0 {
+ t.Errorf("NumGoroutine should be > 0, got %d", c.NumGoroutine)
+ }
+ if c.NumCPU <= 0 {
+ t.Errorf("NumCPU should be > 0, got %d", c.NumCPU)
+ }
+ if c.ConcurrentPrompting != -1 {
+ t.Errorf("expected ConcurrentPrompting=-1 without provider, got %d", c.ConcurrentPrompting)
+ }
+ if c.LiveACPProcesses != -1 {
+ t.Errorf("expected LiveACPProcesses=-1 without provider, got %d", c.LiveACPProcesses)
+ }
+
+ SetPromptingCounter(func() int { return 7 })
+ SetLiveACPCounter(func() int { return 3 })
+ t.Cleanup(func() {
+ SetPromptingCounter(nil)
+ SetLiveACPCounter(nil)
+ })
+ c2 := Contention()
+ if c2.ConcurrentPrompting != 7 {
+ t.Errorf("expected ConcurrentPrompting=7, got %d", c2.ConcurrentPrompting)
+ }
+ if c2.LiveACPProcesses != 3 {
+ t.Errorf("expected LiveACPProcesses=3, got %d", c2.LiveACPProcesses)
+ }
+}
+
+func TestContentionLogAttrsOmissions(t *testing.T) {
+ SetPromptingCounter(nil)
+ SetLiveACPCounter(nil)
+ c := Contention()
+ attrs := c.LogAttrs()
+ // Convert to a set of keys for lookup.
+ keys := map[string]bool{}
+ for i := 0; i < len(attrs); i += 2 {
+ if k, ok := attrs[i].(string); ok {
+ keys[k] = true
+ }
+ }
+ if !keys["num_goroutine"] || !keys["num_cpu"] {
+ t.Errorf("num_goroutine/num_cpu should always be present, got %v", keys)
+ }
+ if keys["concurrent_prompting"] || keys["live_acp_processes"] {
+ t.Errorf("expected prompting/acp omitted when -1, got %v", keys)
+ }
+}
+
+func TestContentionLoadPlausible(t *testing.T) {
+ c := Contention()
+ if math.IsNaN(c.Load1) || math.IsInf(c.Load1, 0) {
+ t.Errorf("Load1 must be finite, got %v", c.Load1)
+ }
+ if c.Load1 < 0 {
+ t.Errorf("Load1 must be non-negative, got %v", c.Load1)
+ }
+ if runtime.GOOS == "darwin" {
+ if !c.LoadAvailable {
+ t.Errorf("expected LoadAvailable=true on darwin")
+ }
+ }
+}
+
+func TestContextRoundTrip(t *testing.T) {
+ tr := New(nil, "s", "w")
+ ctx := WithTrace(context.Background(), tr)
+ if got := FromContext(ctx); got != tr {
+ t.Errorf("FromContext returned %v, want %v", got, tr)
+ }
+ if got := FromContext(context.Background()); got != nil {
+ t.Errorf("bare context should return nil, got %v", got)
+ }
+ //nolint:staticcheck // intentional nil ctx to exercise guard
+ if got := FromContext(nil); got != nil {
+ t.Errorf("nil ctx should return nil, got %v", got)
+ }
+}
+
+func TestNewGeneratesID(t *testing.T) {
+ tr := New(nil, "s", "w")
+ if tr.ID() == "" {
+ t.Errorf("expected non-empty id")
+ }
+}
diff --git a/internal/coldstart/contention.go b/internal/coldstart/contention.go
new file mode 100644
index 000000000..7094ed6aa
--- /dev/null
+++ b/internal/coldstart/contention.go
@@ -0,0 +1,83 @@
+package coldstart
+
+import (
+ "runtime"
+ "sync"
+)
+
+// ContentionSnapshot is a cheap host-load sample.
+type ContentionSnapshot struct {
+ NumGoroutine int `json:"num_goroutine"`
+ NumCPU int `json:"num_cpu"`
+ ConcurrentPrompting int `json:"concurrent_prompting"`
+ LiveACPProcesses int `json:"live_acp_processes"`
+ Load1 float64 `json:"load1"`
+ LoadAvailable bool `json:"load_available"`
+}
+
+var (
+ providerMu sync.RWMutex
+ promptingFn func() int
+ liveACPFn func() int
+)
+
+// SetPromptingCounter registers a provider for ConcurrentPrompting.
+// Intended to be called once at startup. Thread-safe.
+func SetPromptingCounter(fn func() int) {
+ providerMu.Lock()
+ promptingFn = fn
+ providerMu.Unlock()
+}
+
+// SetLiveACPCounter registers a provider for LiveACPProcesses.
+// Intended to be called once at startup. Thread-safe.
+func SetLiveACPCounter(fn func() int) {
+ providerMu.Lock()
+ liveACPFn = fn
+ providerMu.Unlock()
+}
+
+// Contention samples current host load. Cheap to call.
+func Contention() ContentionSnapshot {
+ s := ContentionSnapshot{
+ NumGoroutine: runtime.NumGoroutine(),
+ NumCPU: runtime.NumCPU(),
+ ConcurrentPrompting: -1,
+ LiveACPProcesses: -1,
+ }
+ providerMu.RLock()
+ pf := promptingFn
+ lf := liveACPFn
+ providerMu.RUnlock()
+ if pf != nil {
+ s.ConcurrentPrompting = pf()
+ }
+ if lf != nil {
+ s.LiveACPProcesses = lf()
+ }
+ if l, ok := readLoad1(); ok {
+ s.Load1 = l
+ s.LoadAvailable = true
+ }
+ return s
+}
+
+// LogAttrs returns a flat []any of key/value pairs suitable to splat
+// into slog. Omits load1 when unavailable and prompting/acp when -1.
+func (c ContentionSnapshot) LogAttrs() []any {
+ attrs := make([]any, 0, 12)
+ attrs = append(attrs,
+ "num_goroutine", c.NumGoroutine,
+ "num_cpu", c.NumCPU,
+ )
+ if c.ConcurrentPrompting >= 0 {
+ attrs = append(attrs, "concurrent_prompting", c.ConcurrentPrompting)
+ }
+ if c.LiveACPProcesses >= 0 {
+ attrs = append(attrs, "live_acp_processes", c.LiveACPProcesses)
+ }
+ if c.LoadAvailable {
+ attrs = append(attrs, "load1", c.Load1)
+ }
+ return attrs
+}
diff --git a/internal/coldstart/contention_darwin.go b/internal/coldstart/contention_darwin.go
new file mode 100644
index 000000000..580ff0153
--- /dev/null
+++ b/internal/coldstart/contention_darwin.go
@@ -0,0 +1,57 @@
+//go:build darwin
+
+package coldstart
+
+import (
+ "encoding/binary"
+ "unsafe"
+
+ "golang.org/x/sys/unix"
+)
+
+// hostByteOrder returns the platform's native byte order.
+func hostByteOrder() binary.ByteOrder {
+ var i uint16 = 1
+ b := (*[2]byte)(unsafe.Pointer(&i))
+ if b[0] == 1 {
+ return binary.LittleEndian
+ }
+ return binary.BigEndian
+}
+
+// readLoad1 returns the 1-minute load average from vm.loadavg.
+//
+// The darwin kernel exposes:
+//
+// struct loadavg {
+// fixpt_t ldavg[3]; // uint32
+// long fscale; // 4 or 8 bytes; 8 bytes on arm64/amd64
+// };
+//
+// Alignment inserts padding between ldavg and fscale on 64-bit builds,
+// so the buffer may be 20 or 24 bytes. We only need ldavg[0] and fscale.
+func readLoad1() (float64, bool) {
+ buf, err := unix.SysctlRaw("vm.loadavg")
+ if err != nil || len(buf) < 16 {
+ return 0, false
+ }
+ bo := hostByteOrder()
+ ld0 := bo.Uint32(buf[0:4])
+
+ var fscale uint64
+ switch {
+ case len(buf) >= 24:
+ // 64-bit long, 4 bytes of alignment padding after ldavg[3].
+ fscale = bo.Uint64(buf[16:24])
+ case len(buf) >= 20:
+ // 32-bit long or tightly-packed 64-bit long at offset 12.
+ // Prefer the aligned 32-bit interpretation.
+ fscale = uint64(bo.Uint32(buf[16:20]))
+ default:
+ return 0, false
+ }
+ if fscale == 0 {
+ return 0, false
+ }
+ return float64(ld0) / float64(fscale), true
+}
diff --git a/internal/coldstart/contention_linux.go b/internal/coldstart/contention_linux.go
new file mode 100644
index 000000000..439e52348
--- /dev/null
+++ b/internal/coldstart/contention_linux.go
@@ -0,0 +1,26 @@
+//go:build linux
+
+package coldstart
+
+import (
+ "os"
+ "strconv"
+ "strings"
+)
+
+// readLoad1 parses the 1-minute load average from /proc/loadavg.
+func readLoad1() (float64, bool) {
+ data, err := os.ReadFile("/proc/loadavg")
+ if err != nil {
+ return 0, false
+ }
+ fields := strings.Fields(string(data))
+ if len(fields) == 0 {
+ return 0, false
+ }
+ v, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return 0, false
+ }
+ return v, true
+}
diff --git a/internal/coldstart/contention_other.go b/internal/coldstart/contention_other.go
new file mode 100644
index 000000000..b0143ee2f
--- /dev/null
+++ b/internal/coldstart/contention_other.go
@@ -0,0 +1,8 @@
+//go:build !darwin && !linux
+
+package coldstart
+
+// readLoad1 is not implemented on this platform.
+func readLoad1() (float64, bool) {
+ return 0, false
+}
diff --git a/internal/conversation/background_session.go b/internal/conversation/background_session.go
index e2d91113d..ee04942c2 100644
--- a/internal/conversation/background_session.go
+++ b/internal/conversation/background_session.go
@@ -13,6 +13,7 @@ import (
"github.com/coder/acp-go-sdk"
"github.com/inercia/mitto/internal/auxiliary"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/config"
"github.com/inercia/mitto/internal/logging"
"github.com/inercia/mitto/internal/mcpserver"
@@ -340,6 +341,17 @@ type BackgroundSession struct {
// callbacks so the flush turn never reaches the recorder, observers, or the transcript.
streamingSuppressedMu sync.Mutex
streamingSuppressed bool
+
+ // coldTrace correlates the first activation of this session (process start,
+ // deferred handshake, first prompt's first-token) into one timeline. It is
+ // created lazily by beginColdTrace on the first activation boundary and
+ // finalized once by finishColdTrace. All *coldstart.Trace methods are
+ // nil-safe, so callers may emit phases without guards. mitto-3mv (WI-2).
+ coldTraceOnce sync.Once
+ coldTrace *coldstart.Trace
+ coldTraceFirst atomic.Bool // guards the first-token phase emission
+ coldTraceMcpAt atomic.Int64 // Unix nanos when the current MCP-init episode started (0 = none)
+ coldTraceDone atomic.Bool // guards finishColdTrace one-shot semantics
}
// activeUIPrompt holds the state for a pending UI prompt from an MCP tool.
@@ -469,6 +481,13 @@ type BackgroundSessionConfig struct {
// Pass r.Context() from HTTP handlers so that the 30s request-timeout middleware
// can cancel the RPC and free the goroutine if the agent is busy.
CreationCtx context.Context
+
+ // ColdStartSemWait, when non-zero, is the wall-clock duration the caller
+ // spent blocked on the resume/creation semaphore before invoking this
+ // factory. Recorded on the session's cold-start Trace as the "sem_acquired"
+ // phase's wait attribute so the queueing contribution is visible in the
+ // unified per-session timeline. mitto-3mv (WI-2).
+ ColdStartSemWait time.Duration
}
// NewBackgroundSession creates a new background session.
@@ -733,6 +752,15 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro
"runner_restricted", isRestricted)
}
+ // Cold-start diagnostics (mitto-3mv): begin the correlated trace as early as
+ // possible so all subsequent phases (session_new, mcp_init, first_token, ready)
+ // share one timeline. sem_acquired records the queueing wait spent before
+ // this factory was invoked.
+ bs.beginColdTrace("sem_acquired",
+ "sem_wait_ms", cfg.ColdStartSemWait.Milliseconds(),
+ "is_resume", false,
+ "acp_server", cfg.ACPServer)
+
// Use shared process if available, otherwise start a new per-session process.
// For shared sessions, defer the session/new RPC to the first prompt so that
// creating a conversation never blocks on a busy agent process.
@@ -742,6 +770,7 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro
if bs.recorder != nil {
bs.recorder.End(session.SessionEndData{Reason: "failed_to_start"})
}
+ bs.finishColdTrace("shared_prepare_failed", "error", err.Error())
return nil, err
}
} else {
@@ -751,6 +780,7 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro
if bs.recorder != nil {
bs.recorder.End(session.SessionEndData{Reason: "failed_to_start"})
}
+ bs.finishColdTrace("acp_start_failed", "error", err.Error())
return nil, err
}
}
@@ -764,6 +794,17 @@ func NewBackgroundSession(cfg BackgroundSessionConfig) (*BackgroundSession, erro
}
}
+ // Cold-start diagnostics (mitto-3mv): when session/new is NOT deferred
+ // (direct-connection sessions), the agent is ready to accept prompts as
+ // soon as the ACP handshake completes here. For shared/deferred sessions
+ // (bs.pendingShared == true), the trace stays open until
+ // completeDeferredHandshake finalizes it. finishColdTrace is one-shot,
+ // so a second finalize from the deferred path (if ever taken) is a no-op.
+ if !bs.pendingShared {
+ bs.coldPhase("ready", "resume_method", bs.resumeMethod, "acp_id", bs.acpID)
+ bs.finishColdTrace("ready", "resume_method", bs.resumeMethod, "acp_id", bs.acpID)
+ }
+
return bs, nil
}
@@ -919,6 +960,15 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
"runner_restricted", isRestricted)
}
+ // Cold-start diagnostics (mitto-3mv): begin the correlated trace as early as
+ // possible so all subsequent phases (session_new/load, mcp_init, first_token,
+ // ready) are on a single timeline. sem_acquired records the queueing wait
+ // spent before this factory was invoked.
+ bs.beginColdTrace("sem_acquired",
+ "sem_wait_ms", config.ColdStartSemWait.Milliseconds(),
+ "is_resume", true,
+ "acp_server", config.ACPServer)
+
// Use shared process if available, otherwise start a new per-session process.
if config.SharedProcess != nil {
if err := bs.resumeSharedACPSession(config.SharedProcess, config.WorkingDir, config.ACPSessionID); err != nil {
@@ -950,6 +1000,7 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
if bs.recorder != nil {
bs.recorder.Suspend()
}
+ bs.finishColdTrace("shared_restart_failed", "error", restartErr.Error())
return nil, fmt.Errorf("ACP process restart failed on resume: %w", restartErr)
}
@@ -959,6 +1010,7 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
if bs.recorder != nil {
bs.recorder.Suspend()
}
+ bs.finishColdTrace("shared_resume_retry_failed", "error", err.Error())
return nil, err
}
} else {
@@ -966,6 +1018,7 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
if bs.recorder != nil {
bs.recorder.Suspend()
}
+ bs.finishColdTrace("shared_resume_failed", "error", err.Error())
return nil, err
}
}
@@ -976,6 +1029,7 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
if bs.recorder != nil {
bs.recorder.Suspend()
}
+ bs.finishColdTrace("acp_start_failed", "error", err.Error())
return nil, err
}
}
@@ -990,6 +1044,12 @@ func ResumeBackgroundSession(config BackgroundSessionConfig) (*BackgroundSession
}
}
+ // Cold-start diagnostics (mitto-3mv): resume paths always complete their
+ // session_new/load/resume synchronously (no deferred handshake), so the
+ // agent is ready as soon as this function returns.
+ bs.coldPhase("ready", "resume_method", bs.resumeMethod, "acp_id", bs.acpID)
+ bs.finishColdTrace("ready", "resume_method", bs.resumeMethod, "acp_id", bs.acpID)
+
return bs, nil
}
diff --git a/internal/conversation/background_session_test.go b/internal/conversation/background_session_test.go
index 7388eef36..17b9c30bf 100644
--- a/internal/conversation/background_session_test.go
+++ b/internal/conversation/background_session_test.go
@@ -5002,6 +5002,8 @@ func (p *alwaysFailSharedProcess) Restart() error {
return fmt.Errorf("alwaysFailSharedProcess: cannot restart — no real process")
}
func (p *alwaysFailSharedProcess) RecommendedLoadTimeout(_ bool) time.Duration { return 0 }
+func (p *alwaysFailSharedProcess) MCPInitDone() bool { return true }
+func (p *alwaysFailSharedProcess) WaitForMCPInit(_ context.Context) bool { return true }
// TestACPInitializeAttemptTimeoutBound is a math test for mitto-13ck.2.
//
diff --git a/internal/conversation/bgsession_acp_process.go b/internal/conversation/bgsession_acp_process.go
index 98fd6caec..40d623cfd 100644
--- a/internal/conversation/bgsession_acp_process.go
+++ b/internal/conversation/bgsession_acp_process.go
@@ -17,6 +17,7 @@ import (
"github.com/coder/acp-go-sdk"
mittoAcp "github.com/inercia/mitto/internal/acp"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/logging"
"github.com/inercia/mitto/internal/runner"
"github.com/inercia/mitto/internal/session"
@@ -738,6 +739,9 @@ func (bs *BackgroundSession) signalAgentActivity() {
now := time.Now().UnixNano()
bs.lastAgentActivityAt.Store(now)
bs.lastStreamActivityAt.Store(now)
+ // Cold-start diagnostics (mitto-3mv): mark the first token of the first
+ // prompt after activation. One-shot; nil-safe when no trace is active.
+ bs.coldPhaseFirstToken()
}
// trackToolCallStatus records a tool call's status transition so the prompt
@@ -881,10 +885,16 @@ func (bs *BackgroundSession) startPromptInactivityWatchdog(ctx context.Context,
if warnDelay > 0 && !warned && idle >= warnDelay {
warned = true
if bs.logger != nil {
- bs.logger.Warn("Agent slow during prompt — no streamed activity observed",
+ // Cold-start diagnostics (mitto-3mv): attach a host-contention
+ // snapshot so slowness can be correlated with concurrent load
+ // (num_goroutine, load1, concurrent_prompting, live_acp_processes).
+ attrs := []any{
"session_id", bs.persistedID,
"idle", idle.Round(time.Second).String(),
- "warn_delay", warnDelay.String())
+ "warn_delay", warnDelay.String(),
+ }
+ attrs = append(attrs, coldstart.Contention().LogAttrs()...)
+ bs.logger.Warn("Agent slow during prompt — no streamed activity observed", attrs...)
}
}
}
diff --git a/internal/conversation/bgsession_coldstart.go b/internal/conversation/bgsession_coldstart.go
new file mode 100644
index 000000000..8dd25f21a
--- /dev/null
+++ b/internal/conversation/bgsession_coldstart.go
@@ -0,0 +1,120 @@
+package conversation
+
+// Cold-start diagnostic helpers for BackgroundSession (mitto-3mv WI-2).
+//
+// A cold-start Trace is created once per session on the FIRST activation
+// (session creation OR resume) and finalized once when the agent is ready
+// to receive prompts (or the activation fails). All emit helpers are nil-safe
+// via coldstart.Trace's own nil-safety, so callers may skip nil checks.
+
+import (
+ "context"
+ "time"
+
+ "github.com/inercia/mitto/internal/coldstart"
+)
+
+// beginColdTrace lazily creates the per-session cold-start Trace and emits
+// the initial phase. Safe to call multiple times: only the first call
+// installs the Trace and emits the phase.
+func (bs *BackgroundSession) beginColdTrace(phase string, kv ...any) {
+ if bs == nil {
+ return
+ }
+ bs.coldTraceOnce.Do(func() {
+ bs.coldTrace = coldstart.New(bs.logger, bs.persistedID, bs.workspaceUUID)
+ })
+ bs.coldTrace.Phase(phase, kv...)
+}
+
+// coldPhase emits a phase on the session's cold-start Trace when one is
+// already active. Emits nothing when the trace hasn't been begun or has
+// been finalized. Nil-safe.
+func (bs *BackgroundSession) coldPhase(phase string, kv ...any) {
+ if bs == nil {
+ return
+ }
+ if bs.coldTraceDone.Load() {
+ return
+ }
+ bs.coldTrace.Phase(phase, kv...)
+}
+
+// coldPhaseFirstToken emits the "first_token" phase exactly once per trace.
+// Wired to the agent-activity signal so the first streaming byte on any
+// prompt in the trace's lifetime marks the ready-to-stream boundary.
+func (bs *BackgroundSession) coldPhaseFirstToken() {
+ if bs == nil {
+ return
+ }
+ if bs.coldTrace == nil || bs.coldTraceDone.Load() {
+ return
+ }
+ if !bs.coldTraceFirst.CompareAndSwap(false, true) {
+ return
+ }
+ bs.coldTrace.Phase("first_token")
+}
+
+// markMcpInitStart records the wall time of the current MCP-init episode
+// so the closing markMcpInitEnd can report elapsed time. Only records when
+// no episode is already active — safe under concurrent callers and stderr
+// vs. handshaker races.
+func (bs *BackgroundSession) markMcpInitStart() {
+ if bs == nil {
+ return
+ }
+ bs.coldTraceMcpAt.CompareAndSwap(0, time.Now().UnixNano())
+}
+
+// markMcpInitEnd emits an "mcp_init" phase with the episode's duration when
+// an MCP-init episode is active. Called from the activation success path so
+// the elapsed time from the boundary to first agent readiness is visible in
+// the timeline. Resets the episode marker so a future re-init (e.g. per
+// session/new on Auggie) records a fresh episode. Nil-safe / no-op when no
+// episode is active.
+func (bs *BackgroundSession) markMcpInitEnd() {
+ if bs == nil {
+ return
+ }
+ startNanos := bs.coldTraceMcpAt.Swap(0)
+ if startNanos == 0 {
+ return
+ }
+ elapsed := time.Since(time.Unix(0, startNanos))
+ bs.coldPhase("mcp_init", "episode_ms", elapsed.Milliseconds())
+}
+
+// finishColdTrace finalizes the trace once. outcome is a short label like
+// "ready", "handshake_failed", or "session_creation_failed". Extra kv pairs
+// are appended to the summary log. Safe to call multiple times; only the
+// first invocation writes to the ring buffer.
+func (bs *BackgroundSession) finishColdTrace(outcome string, kv ...any) {
+ if bs == nil {
+ return
+ }
+ if !bs.coldTraceDone.CompareAndSwap(false, true) {
+ return
+ }
+ bs.coldTrace.Summary(outcome, kv...)
+}
+
+// coldTraceID returns the cold-start ID for correlation across log lines, or
+// "" if no trace is active. Nil-safe.
+func (bs *BackgroundSession) coldTraceID() string {
+ if bs == nil {
+ return ""
+ }
+ return bs.coldTrace.ID()
+}
+
+// coldTraceCtx wraps base with the session's active cold-start Trace so the
+// acpproc RPC layer (WI-3) can recover the cold_start_id via
+// coldstart.FromContext. Returns base unchanged when no trace is active, so it
+// is safe to call unconditionally at every RPC context site.
+func (bs *BackgroundSession) coldTraceCtx(base context.Context) context.Context {
+ if bs == nil || bs.coldTrace == nil {
+ return base
+ }
+ return coldstart.WithTrace(base, bs.coldTrace)
+}
diff --git a/internal/conversation/bgsession_prompt.go b/internal/conversation/bgsession_prompt.go
index 4a33c7c1a..28f721f9d 100644
--- a/internal/conversation/bgsession_prompt.go
+++ b/internal/conversation/bgsession_prompt.go
@@ -1088,6 +1088,9 @@ func (bs *BackgroundSession) pdFlushContextInPlace(ctx context.Context) error {
return bs.flushContextInPlace(ctx)
}
+// Cold-start diagnostics (mitto-3mv WI-2). Delegates to the nil-safe helper.
+func (bs *BackgroundSession) pdColdPhase(name string, kv ...any) { bs.coldPhase(name, kv...) }
+
// peekLoopContinuation reports whether the current dispatch is an uninterrupted
// continuation (a scheduled loop run directly following another one) WITHOUT mutating
// the marker. The marker is advanced separately at the dispatch point of no return so that
diff --git a/internal/conversation/bgsession_shared_session.go b/internal/conversation/bgsession_shared_session.go
index 08aef544a..bcd56caff 100644
--- a/internal/conversation/bgsession_shared_session.go
+++ b/internal/conversation/bgsession_shared_session.go
@@ -179,6 +179,19 @@ func (bs *BackgroundSession) hsNotifyObservers(fn func(SessionObserver)) {
bs.notifyObservers(fn)
}
+// Cold-start diagnostic delegators (mitto-3mv WI-2). Delegate through the
+// nil-safe helpers on BackgroundSession so the handshake collaborator never
+// needs a nil check on bs.coldTrace.
+func (bs *BackgroundSession) hsColdPhase(name string, kv ...any) { bs.coldPhase(name, kv...) }
+func (bs *BackgroundSession) hsMarkMcpInitStart() { bs.markMcpInitStart() }
+func (bs *BackgroundSession) hsMarkMcpInitEnd() { bs.markMcpInitEnd() }
+func (bs *BackgroundSession) hsFinishColdTrace(outcome string, kv ...any) {
+ bs.finishColdTrace(outcome, kv...)
+}
+func (bs *BackgroundSession) hsColdTraceCtx(base context.Context) context.Context {
+ return bs.coldTraceCtx(base)
+}
+
// logSessionModes logs the session modes/config options at DEBUG level.
// This helps with debugging which modes are available from the ACP server.
func (bs *BackgroundSession) logSessionModes(modes *acp.SessionModeState) {
diff --git a/internal/conversation/interfaces.go b/internal/conversation/interfaces.go
index 338f55f65..2754ce5dc 100644
--- a/internal/conversation/interfaces.go
+++ b/internal/conversation/interfaces.go
@@ -14,7 +14,7 @@ import (
// BackgroundSession uses this interface (rather than *SharedACPProcess directly)
// so that the domain layer does not depend on the web infrastructure package.
//
-// The 13 methods below correspond exactly to the exported methods of
+// The 15 methods below correspond exactly to the exported methods of
// *internal/web.SharedACPProcess that BackgroundSession calls.
type SharedProcess interface {
// NewSession creates a new ACP session on this process.
@@ -49,6 +49,15 @@ type SharedProcess interface {
// budget (mitto-8ul.1). Returns 0 to indicate the caller should use its own
// default.
RecommendedLoadTimeout(hasMCPServers bool) time.Duration
+ // MCPInitDone reports whether the shared process's MCP-init window has closed
+ // (the agent's first successful session RPC observed). Used to gate background
+ // resume deferral (mitto-54k.4).
+ MCPInitDone() bool
+ // WaitForMCPInit blocks until the process's MCP-init window closes, ctx is done,
+ // or the process exits; returns true only if the process became warm. Used to
+ // defer non-foreground resume until the foreground handshake warms the agent
+ // (mitto-54k.4).
+ WaitForMCPInit(ctx context.Context) bool
}
// PromptResolver resolves a prompt name to its full text for a given working directory.
diff --git a/internal/conversation/prompt_dispatcher.go b/internal/conversation/prompt_dispatcher.go
index 1f25905f8..8578b808d 100644
--- a/internal/conversation/prompt_dispatcher.go
+++ b/internal/conversation/prompt_dispatcher.go
@@ -172,6 +172,10 @@ type promptDeps interface {
// pdFlushContextInPlace sends the flush command synchronously on the existing ACP session
// with streaming suppressed so the flush turn stays out of the transcript.
pdFlushContextInPlace(ctx context.Context) error
+
+ // Cold-start diagnostics (mitto-3mv WI-2). Nil-safe — no-op when the
+ // session's cold-start trace has not been begun or has been finalized.
+ pdColdPhase(name string, kv ...any)
}
// promptDispatcher is a stateless collaborator holding safe synchronous chunks of
@@ -901,6 +905,7 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) {
// background (applying to the NEXT turn). setModelSem serialisation and the
// mitto-29q re-arm are preserved because the switch still goes through
// pdSetActiveModelOnly -> SetSessionModel.
+ switchStart := time.Now()
done := make(chan struct{})
go func() {
setCtx, setCancel := context.WithTimeout(d.pdSessionCtx(), modelSwitchAsyncBudget)
@@ -920,6 +925,11 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) {
select {
case <-done:
// Switch landed within the grace window (warm/fast): applies to this turn.
+ d.pdColdPhase("model_switch",
+ "desired", desired,
+ "from", currentModel,
+ "landed", "warm",
+ "switch_ms", time.Since(switchStart).Milliseconds())
case <-time.After(modelSwitchSyncGrace):
// Cold/slow: dispatch the prompt now; the switch completes in the background.
if l := d.pdLogger(); l != nil {
@@ -928,6 +938,11 @@ func (p promptDispatcher) applyModelPreference(d promptDeps, meta PromptMeta) {
"desired_model", desired,
"current_model", currentModel)
}
+ d.pdColdPhase("model_switch",
+ "desired", desired,
+ "from", currentModel,
+ "landed", "deferred",
+ "grace_ms", modelSwitchSyncGrace.Milliseconds())
}
}
diff --git a/internal/conversation/prompt_dispatcher_test.go b/internal/conversation/prompt_dispatcher_test.go
index a09ebf855..3f0cd0693 100644
--- a/internal/conversation/prompt_dispatcher_test.go
+++ b/internal/conversation/prompt_dispatcher_test.go
@@ -472,6 +472,9 @@ func (f *fakePromptDeps) pdFlushContextInPlace(_ context.Context) error {
return f.flushContextInPlaceErr
}
+// mitto-3mv WI-2: cold-start trace stub — no-op in tests.
+func (f *fakePromptDeps) pdColdPhase(_ string, _ ...any) {}
+
type pdRecorderObserver struct{ deps *fakePromptDeps }
func (r *pdRecorderObserver) OnError(msg string) {
diff --git a/internal/conversation/session_manager.go b/internal/conversation/session_manager.go
index b6db1eb03..c3d8005d7 100644
--- a/internal/conversation/session_manager.go
+++ b/internal/conversation/session_manager.go
@@ -13,6 +13,7 @@ import (
"github.com/inercia/mitto/internal/appdir"
"github.com/inercia/mitto/internal/auxiliary"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/config"
"github.com/inercia/mitto/internal/mcpserver"
"github.com/inercia/mitto/internal/processors"
@@ -197,7 +198,7 @@ func NewSessionManager(acpCommand, acpServer string, autoApprove bool, logger *s
reg := newWorkspaceRegistry(logger, false, nil)
reg.defaultWorkspace = defaultWS
- return &SessionManager{
+ sm := &SessionManager{
sessions: make(map[string]*BackgroundSession),
pendingResumes: make(map[string]*pendingResumeResult),
logger: logger,
@@ -210,6 +211,13 @@ func NewSessionManager(acpCommand, acpServer string, autoApprove bool, logger *s
mcpToolsFetchedWorkspaces: make(map[string]bool),
resumeSemaphore: make(chan struct{}, maxConcurrentSessionResumes),
}
+ // Cold-start diagnostics (mitto-3mv): expose concurrent prompting count as
+ // the "concurrent_prompting" contention counter. Overwrites any previously
+ // registered provider — last constructor wins, matching typical single-manager
+ // deployments; tests that construct multiple managers still see a plausible
+ // (albeit non-deterministic) counter.
+ coldstart.SetPromptingCounter(sm.ConcurrentPromptingCount)
+ return sm
}
// SessionManagerOptions contains options for creating a SessionManager.
@@ -244,7 +252,7 @@ func NewSessionManagerWithOptions(opts SessionManagerOptions) *SessionManager {
}
}
- return &SessionManager{
+ sm := &SessionManager{
sessions: make(map[string]*BackgroundSession),
pendingResumes: make(map[string]*pendingResumeResult),
logger: opts.Logger,
@@ -258,6 +266,9 @@ func NewSessionManagerWithOptions(opts SessionManagerOptions) *SessionManager {
mcpToolsFetchedWorkspaces: make(map[string]bool),
resumeSemaphore: make(chan struct{}, maxConcurrentSessionResumes),
}
+ // Cold-start diagnostics (mitto-3mv): see NewSessionManager for rationale.
+ coldstart.SetPromptingCounter(sm.ConcurrentPromptingCount)
+ return sm
}
// SetGlobalConversations sets the global conversation processing configuration.
@@ -993,6 +1004,15 @@ func (sm *SessionManager) IsStreaming(sessionID string) bool {
return sm.streaming[sessionID]
}
+// ConcurrentPromptingCount returns the number of sessions currently prompting
+// (their agents are actively streaming). Used by cold-start diagnostics
+// (mitto-3mv) to attribute host contention to concurrent prompt load.
+func (sm *SessionManager) ConcurrentPromptingCount() int {
+ sm.streamingMu.RLock()
+ defer sm.streamingMu.RUnlock()
+ return len(sm.streaming)
+}
+
// childArchiveTimeout is the timeout for gracefully closing child sessions when a parent is archived.
const childArchiveTimeout = 30 * time.Second
@@ -1646,7 +1666,16 @@ func (sm *SessionManager) GetOrCreateSession(sessionID, workingDir string) (*Bac
// on the server side as well. Otherwise, we create a new ACP connection and continue
// using the same persisted session ID for recording.
func (sm *SessionManager) ResumeSession(sessionID, sessionName, workingDir string) (*BackgroundSession, error) {
- return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, nil)
+ return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, nil, true)
+}
+
+// ResumeSessionBackground resumes a persisted session like ResumeSession but marks
+// the resume as non-foreground (background). On a COLD shared ACP process the
+// LoadSession is deferred until the process warms (mcpInitDone), so the user's
+// foreground session/new wins the agent's event loop first (mitto-54k.4). Used by
+// the WebSocket cold-start resume fan-out.
+func (sm *SessionManager) ResumeSessionBackground(sessionID, sessionName, workingDir string) (*BackgroundSession, error) {
+ return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, nil, false)
}
// ResumeSessionWithModelConstraint resumes an existing persisted session like ResumeSession,
@@ -1654,7 +1683,7 @@ func (sm *SessionManager) ResumeSession(sessionID, sessionName, workingDir strin
// auto-selection constraint (mitto-9x8). Used by auto-children to apply a per-child initial
// model profile. Pass nil to preserve the default ACP-server-derived model selection.
func (sm *SessionManager) ResumeSessionWithModelConstraint(sessionID, sessionName, workingDir string, modelConstraint *config.ACPServerConstraint) (*BackgroundSession, error) {
- return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, modelConstraint)
+ return sm.resumeSessionWithConstraint(sessionID, sessionName, workingDir, modelConstraint, true)
}
// resumeSessionWithConstraint resumes an existing persisted session by creating a new ACP
@@ -1662,7 +1691,7 @@ func (sm *SessionManager) ResumeSessionWithModelConstraint(sessionID, sessionNam
// loading and we have a stored ACP session ID, we attempt to resume the ACP session
// on the server side as well. Otherwise, we create a new ACP connection and continue
// using the same persisted session ID for recording.
-func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, workingDir string, modelConstraint *config.ACPServerConstraint) (*BackgroundSession, error) {
+func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, workingDir string, modelConstraint *config.ACPServerConstraint, foreground bool) (*BackgroundSession, error) {
// Clear GC-suspended flag — any explicit resume (ensure_resumed, loop runner,
// queue processing) should allow the session to run. This must happen before the
// "already running" check to avoid stale flags.
@@ -2010,10 +2039,41 @@ func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, wo
"session_id", sessionID,
"max_concurrent", maxConcurrentSessionResumes)
}
+ // Fix D (mitto-54k.4): on a COLD shared ACP process, defer a BACKGROUND
+ // (non-foreground) resume's LoadSession until the process's MCP handshake has
+ // completed, so the user's foreground session/new wins the agent's single event
+ // loop first. Do this BEFORE acquiring resumeSemaphore so a waiting background
+ // resume never blocks a foreground one. Bounded by the process's own cold-load
+ // budget so background sessions are never stranded if the process never warms;
+ // getSharedProcess is idempotent so the later call reuses the same instance.
+ if !foreground {
+ if warmGate := sm.getSharedProcess(foundWs, acpCommand, acpCwd, acpEnv, r); warmGate != nil && !warmGate.MCPInitDone() {
+ if waitBudget := warmGate.RecommendedLoadTimeout(true); waitBudget > 0 {
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), waitBudget)
+ warmed := warmGate.WaitForMCPInit(waitCtx)
+ waitCancel()
+ if sm.logger != nil {
+ sm.logger.Debug("Deferred background resume until shared process warm (mitto-54k.4)",
+ "session_id", sessionID,
+ "warmed", warmed,
+ "wait_budget", waitBudget)
+ }
+ }
+ }
+ }
+ // Cold-start diagnostics (mitto-3mv): time the resume-semaphore wait so the
+ // contribution of concurrency-bound queueing is visible in logs alongside the
+ // per-session cold-start trace begun inside ResumeBackgroundSession.
+ semWaitStart := time.Now()
sm.resumeSemaphore <- struct{}{}
+ semWait := time.Since(semWaitStart)
if sm.logger != nil {
sm.logger.Debug("Acquired session-resume semaphore, starting ACP",
- "session_id", sessionID)
+ "session_id", sessionID,
+ "sem_wait_ms", semWait.Milliseconds(),
+ "sem_permits", maxConcurrentSessionResumes,
+ "sem_in_use", len(sm.resumeSemaphore),
+ "foreground", foreground)
}
// Resolve shared ACP process for this workspace (if shared mode is enabled).
@@ -2037,6 +2097,7 @@ func (sm *SessionManager) resumeSessionWithConstraint(sessionID, sessionName, wo
// provides the safety net so the goroutine doesn't block indefinitely if the ACP
// agent is busy.
PersistedID: sessionID,
+ ColdStartSemWait: semWait, // mitto-3mv: attribute queueing wait to trace
ACPCommand: acpCommand,
ACPCwd: acpCwd,
Env: acpEnv,
diff --git a/internal/conversation/shared_session_handshaker.go b/internal/conversation/shared_session_handshaker.go
index 21e1776e4..f79d405b7 100644
--- a/internal/conversation/shared_session_handshaker.go
+++ b/internal/conversation/shared_session_handshaker.go
@@ -80,6 +80,20 @@ type handshakeDeps interface {
// Observer fan-out
hsNotifyObservers(fn func(SessionObserver))
+
+ // Cold-start diagnostics (mitto-3mv WI-2) — nil-safe by construction.
+ // hsColdPhase records a phase on the session's cold-start Trace (no-op when
+ // the trace is not active). hsFinishColdTrace finalizes the trace one-shot.
+ // hsMarkMcpInitStart records the boundary of an MCP-init episode; the paired
+ // hsMarkMcpInitEnd emits an "mcp_init" phase with the elapsed duration.
+ // hsColdTraceCtx wraps base with the session's active cold-start Trace so the
+ // acpproc RPC layer can correlate its NewSession/LoadSession/ResumeSession logs
+ // via cold_start_id (WI-3). Returns base unchanged when no trace is active.
+ hsColdPhase(name string, kv ...any)
+ hsMarkMcpInitStart()
+ hsMarkMcpInitEnd()
+ hsFinishColdTrace(outcome string, kv ...any)
+ hsColdTraceCtx(base context.Context) context.Context
}
// sharedSessionHandshaker is a stateless collaborator owning the lazy/deferred shared-
@@ -95,7 +109,9 @@ func (c sharedSessionHandshaker) creationRPCCtx(d handshakeDeps) (context.Contex
if base == nil {
base = d.hsSessionCtx()
}
- return context.WithCancel(base)
+ // Cold-start diagnostics (mitto-3mv): carry the active trace so the acpproc
+ // RPC layer can correlate its logs via cold_start_id (WI-3).
+ return context.WithCancel(d.hsColdTraceCtx(base))
}
// buildWebClientConfig delegates to the deps seam (builds from BackgroundSession fields).
@@ -143,10 +159,29 @@ func (c sharedSessionHandshaker) ensureSharedACPSession(d handshakeDeps) error {
return nil
}
- handle, err := d.hsGetSharedProcess().NewSession(d.hsSessionCtx(), d.hsGetPendingSharedWorkingDir(), d.hsGetPendingSharedMcpServers())
+ // Cold-start diagnostics (mitto-3mv): if the shared process's MCP-init
+ // window is still open, mark the boundary so the closing MCP-init phase
+ // (emitted from completeDeferredHandshake) has a duration to report.
+ if sp := d.hsGetSharedProcess(); sp != nil && !sp.MCPInitDone() {
+ d.hsColdPhase("mcp_init_wait_begin",
+ "has_mcp_servers", len(d.hsGetPendingSharedMcpServers()) > 0,
+ "deferred", true)
+ d.hsMarkMcpInitStart()
+ }
+
+ newStart := time.Now()
+ handle, err := d.hsGetSharedProcess().NewSession(d.hsColdTraceCtx(d.hsSessionCtx()), d.hsGetPendingSharedWorkingDir(), d.hsGetPendingSharedMcpServers())
if err != nil {
+ d.hsColdPhase("session_new_failed",
+ "rpc_ms", time.Since(newStart).Milliseconds(),
+ "deferred", true,
+ "error", err.Error())
return fmt.Errorf("failed to create session on shared process: %w", err)
}
+ d.hsColdPhase("session_new",
+ "rpc_ms", time.Since(newStart).Milliseconds(),
+ "deferred", true,
+ "acp_session_id", handle.SessionID)
client := d.hsGetACPClient()
d.hsGetSharedProcess().RegisterSession(acp.SessionId(handle.SessionID), &SessionCallbacks{
@@ -208,12 +243,20 @@ func (c sharedSessionHandshaker) completeDeferredHandshake(d handshakeDeps) erro
}
if err := c.ensureSharedACPSession(d); err != nil {
+ d.hsFinishColdTrace("deferred_handshake_failed", "error", err.Error())
return err
}
d.hsPersistACPSessionID()
c.applyPendingSharedModes(d)
d.hsNotifyObservers(func(o SessionObserver) { o.OnACPStarted() })
+
+ // Cold-start diagnostics (mitto-3mv): the deferred handshake path leaves
+ // the trace open across NewBackgroundSession's return; close the MCP-init
+ // episode (if any) and finalize the trace here.
+ d.hsMarkMcpInitEnd()
+ d.hsColdPhase("ready", "acp_id", d.hsGetACPID(), "deferred", true)
+ d.hsFinishColdTrace("ready", "acp_id", d.hsGetACPID(), "deferred", true)
return nil
}
@@ -242,6 +285,15 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
mcpServers := d.hsStartMcpServer(caps)
d.hsSetACPClient(NewWebClient(c.buildWebClientConfig(d)))
+ // Cold-start diagnostics (mitto-3mv): if the shared process's MCP-init
+ // window is still open, this handshake will be gated on it. Mark the
+ // boundary now so the closing d.hsMarkMcpInitEnd() at the end of this
+ // function can emit an "mcp_init" phase with the elapsed duration.
+ if !sharedProcess.MCPInitDone() {
+ d.hsColdPhase("mcp_init_wait_begin", "has_mcp_servers", len(mcpServers) > 0)
+ d.hsMarkMcpInitStart()
+ }
+
var handle *SessionHandle
var err error
@@ -250,7 +302,8 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
supportsLoad := caps.LoadSession
if supportsResume {
- resumeCtx, resumeCancel := context.WithTimeout(d.hsSessionCtx(), 10*time.Second)
+ resumeCtx, resumeCancel := context.WithTimeout(d.hsColdTraceCtx(d.hsSessionCtx()), 10*time.Second)
+ resumeStart := time.Now()
handle, err = sharedProcess.ResumeSession(resumeCtx, acpSessionID, workingDir, mcpServers)
resumeCancel()
if err != nil {
@@ -261,12 +314,18 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
if l := d.hsLogger(); l != nil {
l.Info("Resume failed, will try Load or New", logFields...)
}
+ d.hsColdPhase("session_resume_failed",
+ "rpc_ms", time.Since(resumeStart).Milliseconds(),
+ "error", err.Error())
} else {
d.hsSetResumeMethod("resume")
if l := d.hsLogger(); l != nil {
l.Info("Successfully resumed session using UNSTABLE resume API",
"acp_session_id", acpSessionID, "resume_method", "resume")
}
+ d.hsColdPhase("session_resume",
+ "rpc_ms", time.Since(resumeStart).Milliseconds(),
+ "acp_session_id", acpSessionID)
}
}
@@ -280,7 +339,8 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
if rec := sharedProcess.RecommendedLoadTimeout(len(mcpServers) > 0); rec > loadTimeout {
loadTimeout = rec
}
- loadCtx, loadCancel := context.WithTimeout(d.hsSessionCtx(), loadTimeout)
+ loadCtx, loadCancel := context.WithTimeout(d.hsColdTraceCtx(d.hsSessionCtx()), loadTimeout)
+ loadStart := time.Now()
handle, err = sharedProcess.LoadSession(loadCtx, acpSessionID, workingDir, mcpServers)
loadCancel()
client.SetLoadingSession(false)
@@ -292,12 +352,18 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
if l := d.hsLogger(); l != nil {
l.Info("Load failed, creating new session", logFields...)
}
+ d.hsColdPhase("session_load_failed",
+ "rpc_ms", time.Since(loadStart).Milliseconds(),
+ "error", err.Error())
} else {
d.hsSetResumeMethod("load")
if l := d.hsLogger(); l != nil {
l.Info("Successfully loaded session (with history replay)",
"acp_session_id", acpSessionID, "resume_method", "load")
}
+ d.hsColdPhase("session_load",
+ "rpc_ms", time.Since(loadStart).Milliseconds(),
+ "acp_session_id", acpSessionID)
}
}
}
@@ -305,6 +371,7 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
if handle == nil {
d.hsSetResumeMethod("new")
rpcCtx, rpcCancel := c.creationRPCCtx(d)
+ newStart := time.Now()
handle, err = sharedProcess.NewSession(rpcCtx, workingDir, mcpServers)
rpcCancel()
if err != nil {
@@ -312,9 +379,19 @@ func (c sharedSessionHandshaker) resumeSharedACPSession(d handshakeDeps, sharedP
d.hsGetACPClient().Close()
d.hsSetACPClient(nil)
d.hsSetSharedProcess(nil)
+ d.hsColdPhase("session_new_failed",
+ "rpc_ms", time.Since(newStart).Milliseconds(),
+ "error", err.Error())
return fmt.Errorf("failed to create session on shared process: %w", err)
}
+ d.hsColdPhase("session_new",
+ "rpc_ms", time.Since(newStart).Milliseconds(),
+ "acp_session_id", handle.SessionID)
}
+ // Cold-start diagnostics (mitto-3mv): if the agent reported "waiting for MCP
+ // server..." during this handshake episode, close the episode now — session
+ // establishment is done. Nil-safe when no episode was recorded.
+ d.hsMarkMcpInitEnd()
d.hsNilCreationCtx()
client := d.hsGetACPClient()
diff --git a/internal/conversation/shared_session_handshaker_test.go b/internal/conversation/shared_session_handshaker_test.go
index ba0cbd131..b64dbd967 100644
--- a/internal/conversation/shared_session_handshaker_test.go
+++ b/internal/conversation/shared_session_handshaker_test.go
@@ -69,6 +69,8 @@ func (f *fakeSharedProcess) SetSessionModel(_ context.Context, _ acp.SessionId,
}
func (f *fakeSharedProcess) Restart() error { return nil }
func (f *fakeSharedProcess) RecommendedLoadTimeout(_ bool) time.Duration { return 0 }
+func (f *fakeSharedProcess) MCPInitDone() bool { return true }
+func (f *fakeSharedProcess) WaitForMCPInit(_ context.Context) bool { return true }
func (f *fakeSharedProcess) SetPromptFunc(_ func(context.Context, string, string, string) error) {}
func (f *fakeSharedProcess) PromptProcessorAsync(_ context.Context, _, _, _ string) error {
return nil
@@ -207,6 +209,15 @@ func (f *fakeHandshakeDeps) hsNotifyObservers(fn func(SessionObserver)) {
fn(&handshakeRecorderObserver{deps: f})
}
+// mitto-3mv WI-2: cold-start trace stubs — no-op in tests.
+func (f *fakeHandshakeDeps) hsColdPhase(_ string, _ ...any) {}
+func (f *fakeHandshakeDeps) hsMarkMcpInitStart() {}
+func (f *fakeHandshakeDeps) hsMarkMcpInitEnd() {}
+func (f *fakeHandshakeDeps) hsFinishColdTrace(_ string, _ ...any) {}
+func (f *fakeHandshakeDeps) hsColdTraceCtx(base context.Context) context.Context {
+ return base
+}
+
// fakeSeqProvider satisfies SeqProvider for WebClientConfig.
type fakeSeqProvider struct{}
diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go
index d88be5b2e..2d495bed7 100644
--- a/internal/mcpserver/server.go
+++ b/internal/mcpserver/server.go
@@ -19,6 +19,7 @@ import (
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/config"
"github.com/inercia/mitto/internal/logging"
"github.com/inercia/mitto/internal/session"
@@ -1038,6 +1039,12 @@ func (s *Server) registerGlobalTools(mcpSrv *mcp.Server, deps Dependencies) {
Description: "Get runtime information including OS, architecture, log file paths, data directories, and process info",
}, s.createGetRuntimeInfoHandler())
+ // mitto_coldstart_recent tool - always available
+ mcp.AddTool(mcpSrv, &mcp.Tool{
+ Name: "mitto_coldstart_recent",
+ Description: "Return the most recent cold-start diagnostic summaries (phase timeline + durations) captured by the cold-start tracer (mitto-3mv). Useful for post-hoc analysis of cold-start latency without grepping logs.",
+ }, s.createColdStartRecentHandler())
+
// mitto_workspace_list tool - always available
mcp.AddTool(mcpSrv, &mcp.Tool{
Name: "mitto_workspace_list",
@@ -1896,6 +1903,16 @@ func (s *Server) createGetRuntimeInfoHandler() mcp.ToolHandlerFor[struct{}, Runt
}
}
+// createColdStartRecentHandler creates the handler for the mitto_coldstart_recent tool.
+// It returns the most recent cold-start summaries captured by the cold-start
+// tracer (internal/coldstart), newest first. A Limit of 0 (or omitted) returns
+// all summaries currently held in the ring buffer.
+func (s *Server) createColdStartRecentHandler() mcp.ToolHandlerFor[ColdStartRecentInput, ColdStartRecent] {
+ return func(ctx context.Context, req *mcp.CallToolRequest, input ColdStartRecentInput) (*mcp.CallToolResult, ColdStartRecent, error) {
+ return nil, ColdStartRecent{ColdStarts: coldstart.RecentSummaries(input.Limit)}, nil
+ }
+}
+
// =============================================================================
// Session-Scoped Tool Handlers
// These tools require a session_id parameter and operate on specific conversations.
diff --git a/internal/mcpserver/types.go b/internal/mcpserver/types.go
index d7ca90d65..53d656d7c 100644
--- a/internal/mcpserver/types.go
+++ b/internal/mcpserver/types.go
@@ -9,9 +9,24 @@ import (
"time"
"github.com/inercia/mitto/internal/appdir"
+ "github.com/inercia/mitto/internal/coldstart"
"github.com/inercia/mitto/internal/config"
)
+// ColdStartRecentInput is the input for the mitto_coldstart_recent tool.
+type ColdStartRecentInput struct {
+ // Limit is the maximum number of recent cold-start summaries to return.
+ // 0 or omitted returns all summaries currently held (up to the ring capacity).
+ Limit int `json:"limit,omitempty" jsonschema:"max number of recent cold starts to return; 0 or omitted = all (up to the ring capacity)"`
+}
+
+// ColdStartRecent is the output for the mitto_coldstart_recent tool.
+// It wraps the ring-buffer snapshot returned by coldstart.RecentSummaries,
+// newest first.
+type ColdStartRecent struct {
+ ColdStarts []coldstart.Summary `json:"cold_starts"`
+}
+
// ListConversationsInput contains optional filter criteria for mitto_conversation_list.
// All fields are optional — when omitted, no filtering is applied for that field.
type ListConversationsInput struct {
diff --git a/internal/web/resume_semaphore.go b/internal/web/resume_semaphore.go
index 66c9ff70b..5b52d5a16 100644
--- a/internal/web/resume_semaphore.go
+++ b/internal/web/resume_semaphore.go
@@ -51,3 +51,13 @@ func (s *resumeSemaphore) Capacity() int {
}
return cap(s.ch)
}
+
+// Len reports the number of currently-held permits (in-use slots).
+// Returns 0 for a nil receiver. Used by cold-start diagnostics (mitto-3mv)
+// to surface saturation alongside per-session semaphore wait times.
+func (s *resumeSemaphore) Len() int {
+ if s == nil {
+ return 0
+ }
+ return len(s.ch)
+}
diff --git a/internal/web/session_ws.go b/internal/web/session_ws.go
index 232a9b1ba..fcdc73367 100644
--- a/internal/web/session_ws.go
+++ b/internal/web/session_ws.go
@@ -380,9 +380,21 @@ func (s *Server) handleSessionWS(w http.ResponseWriter, r *http.Request) {
// goroutine so the WebSocket handler is never blocked, and
// release after ResumeSession returns (success or failure).
// A nil semaphore is a no-op (Acquire/Release both return).
+ // On a cold shared process this background resume additionally
+ // defers its LoadSession until the process warms so the user's
+ // foreground session/new wins the first handshake (mitto-54k.4).
+ // Cold-start diagnostics (mitto-3mv): log the semaphore wait so
+ // the fan-out queueing contribution is visible in server logs.
+ acqStart := time.Now()
s.interactiveResumeSem.Acquire()
+ if clientLogger != nil {
+ clientLogger.Debug("Acquired interactive resume semaphore",
+ "sem_wait_ms", time.Since(acqStart).Milliseconds(),
+ "sem_capacity", s.interactiveResumeSem.Capacity(),
+ "sem_in_use", s.interactiveResumeSem.Len())
+ }
defer s.interactiveResumeSem.Release()
- resumedBS, err := s.sessionManager.ResumeSession(sessionID, sessionName, cwd)
+ resumedBS, err := s.sessionManager.ResumeSessionBackground(sessionID, sessionName, cwd)
if err != nil {
if clientLogger != nil {
clientLogger.Error("Failed to resume session (async)", "error", err)
From a88d5a53266a7d73b545d9fa5c1c11cbfc5861a2 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 09:35:49 +0200
Subject: [PATCH 033/240] docs: refresh cold-start MCP wedge notes in CLAUDE.md
Replace the superseded "fixed: mitto-54k" narrative with the
falsified-theory + real-cause + post-fix-caveat findings: the
original "Mitto's inbound /mcp is starved" theory was falsified;
the real driver is Auggie re-handshaking all configured MCP servers
on every session/new, with severity scaling by stdio server count.
Also notes that post-fix wedges can stem from cold set_model/model
latency or external CPU contention from concurrent agent processes.
---
CLAUDE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 463dcbd8f..0660957aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,7 +65,7 @@ go test -v -tags integration ./tests/integration/inprocess/
- **daisyUI drawer GPU bug**: `.drawer-side` + fixed-position overlay compete for pointer events → blank artifacts. Fix: See `web/static/styles.css` for verified pattern. Do NOT use `translateZ(0)`.
- **Zombie WebSocket recovery**: When phone sleeps or app backgrounded, WS may enter "zombie" state (appearing open but dead). On visibility change or app activate, force-close and reconnect. This is expected behavior — not a bug. See `.augment/rules/23-web-frontend-mobile.md` for resilience patterns.
- **Verify prior edits actually persisted**: Don't trust that a previous turn's file edits are still on disk — session gaps, restarts, or a **concurrent loop conversation** (e.g. a PR-babysitting/cleanup loop sharing the same repo) stashing/resetting the working directory mid-task can silently drop them. Re-check with `git status`/`git diff`/re-view before relying on earlier work; if files vanish unexpectedly, check `git stash list` first — work is often auto-stashed, not lost.
-- **Cold-start MCP self-saturation (fixed: mitto-54k)**: Auggie's *inbound* HTTP `initialize`/`tools/list` to Mitto's own `/mcp` endpoint (same process/port) could be starved by the cold-start session resume storm — symptom: `⏳ mitto (timed out)` while all external MCP servers succeed. The cold-start gate (mitto-8tb) only serializes Mitto's *outbound* `session/new`/`session/load`, not this inbound path. Fixed by mitto-54k.1 (bounds the interactive resume storm at the source) + mitto-54k.2 (confirmed the inbound handshake is already lock-free, backed by a regression guard). See `.augment/rules/42-mcpserver-development.md`.
+- **Cold-start MCP wedge (mitto-54k) — agent-side, not Mitto-side**: symptom `⏳ mitto (timed out)` for minutes while external MCP servers succeed. The original "Mitto's inbound `/mcp` is starved" theory was **falsified** (probed: <2ms, lock-free). Real cause: Auggie re-handshakes ALL its configured MCP servers on every `session/new` (mitto-29q); `stdio` servers spawn cheap parallel children but the single `http`/`sse` server (`mitto`) inits inline on the agent's main loop, so **severity scales with the workspace agent-config's stdio server count**, not Mitto's session count. Fixed by mitto-54k.3 (warm-once barrier) + mitto-54k.4 (defer background LoadSession, open). See `.augment/rules/42-mcpserver-development.md`. **Post-fix caveat**: wedges still appear intermittently and aren't always MCP-init-bound — the bottleneck can instead be downstream (cold `set_model` + first-token latency on a cold model) or external CPU contention from **other concurrent auggie/ACP processes** (e.g. sibling loop conversations sharing the same workspace). Before timing experiments (e.g. removing MCP servers one-by-one), check `ps` for concurrent agent processes and other active/loop conversations — uncontrolled concurrent load confounds single-variable measurements.
## New Agent Capability Checklist
From 11d63e94c5b7ec45b5016531a3a55db7acd6ffb3 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 09:36:16 +0200
Subject: [PATCH 034/240] feat(config): add BeadsCount/HasBeads CEL + template
functions
Add a shared, fail-open beads-query helper (beadsCount/hasBeads in
templatefuncs.go) that runs `bd list -l --status
--all --json` in the workspace folder, bounded by a 5s timeout and
memoised for 5s per (folder, labels, statuses) key.
Expose it identically on both surfaces:
- CEL: BeadsCount(labels, statuses) / HasBeads(labels, statuses)
macros, rewritten to inject Workspace.Folder like the existing Git*
macros.
- Go templates: BeadsCount / HasBeads funcs in the FuncMap.
Fail-open on any error (missing bd, non-zero exit, unparseable JSON,
timeout) returns a positive sentinel so HasBeads-gated prompts are
never wrongly hidden; a legitimate empty result ([]) returns 0/false.
Add unit tests for the new helpers and document them in
docs/devel/prompt-templates.md and .augment/rules/07-prompts.md.
---
.augment/rules/07-prompts.md | 4 +-
docs/devel/prompt-templates.md | 2 +
internal/config/cel_evaluator.go | 56 ++++++++
internal/config/templatefuncs.go | 113 ++++++++++++++-
internal/config/templatefuncs_test.go | 190 ++++++++++++++++++++++++++
5 files changed, 363 insertions(+), 2 deletions(-)
diff --git a/.augment/rules/07-prompts.md b/.augment/rules/07-prompts.md
index 8b1618bc8..3caaca06a 100644
--- a/.augment/rules/07-prompts.md
+++ b/.augment/rules/07-prompts.md
@@ -163,7 +163,9 @@ Updates replicate the 5-layer REST API merge. Name slugification via `config.Slu
## enabledWhen Filtering & Preferred Models
-Server-side via `filterPromptsByEnabled()` / `buildPromptEnabledContext()`. Use `enabledWhen` (CEL) exclusively. Full CEL context: see `05-msghooks.md`. Useful functions: `FileExists(".git/config")`, `CommandExists("gh")`, `Tools.HasPattern("github_*")`.
+Server-side via `filterPromptsByEnabled()` / `buildPromptEnabledContext()`. Use `enabledWhen` (CEL) exclusively. Full CEL context: see `05-msghooks.md`. Useful functions: `FileExists(".git/config")`, `CommandExists("gh")`, `Tools.HasPattern("github_*")`, `BeadsCount("label", "open,in_progress")` / `HasBeads("label", "open,in_progress")`.
+
+**Beads gating (`BeadsCount` / `HasBeads`)**: query the workspace's `bd` (beads) DB from CEL AND Go templates. Both accept two comma-separated string args — `labels` (ALL match) and `statuses` (ANY match) — and run `bd list -l --status --all --json` in `Workspace.Folder` (5s timeout, 5s in-memory cache). **Fail-open**: missing `bd`, non-zero exit, unparseable JSON, or timeout returns a positive sentinel (count=1 / true) so gated prompts are never wrongly hidden; a legitimate `[]` returns 0/false. Always short-circuit with cheap gates first so `bd` isn't exec'd when there's no DB: `CommandExists("bd") && DirExists(".beads") && HasBeads("support-question", "open,in_progress")`. Shared pure-Go helper (`beadsCount` in `internal/config/templatefuncs.go`) is the single source of truth for both surfaces — the CEL macros (`beadsCountMacro`, `hasBeadsMacro`) auto-inject `Workspace.Folder`.
**Per-conversation user data (`UserData`)**: exposed as a `map[string]string` in both the template context (`{{ UserData "NAME" }}` / `{{ index .UserData "NAME" }}`) and CEL (`UserData["NAME"]` / `"NAME" in UserData`), built from the same conversation attributes that back `Session.UserDataJSON`. Wired exactly like `Args` (struct field + `cel.Variable` + `buildActivation` normalization + template func), but populated at **both** menu time (`buildPromptEnabledContext`) and send time (`buildProcessorInput`) — the parity invariant — so menu gating and body rendering agree. Use it for set-if-unset, else-do-Y flows; the opaque `UserDataJSON` blob cannot drive a per-field conditional.
diff --git a/docs/devel/prompt-templates.md b/docs/devel/prompt-templates.md
index 13600bf54..bdd0b572b 100644
--- a/docs/devel/prompt-templates.md
+++ b/docs/devel/prompt-templates.md
@@ -197,6 +197,8 @@ The shared pure-Go helpers are: `statResolved`, the glob-match logic, `matchesSe
| `GitDirModified` | `GitDirModified(path ...string) bool` | Directory (default: whole workspace) has any pending changes, including untracked files. |
| `GitFileTracked` | `GitFileTracked(path string) bool` | `path` is tracked by git (present in the index). |
| `GitFileDeleted` | `GitFileDeleted(path string) bool` | Tracked file at `path` has been deleted (staged or unstaged deletion). |
+| `BeadsCount` | `BeadsCount(labels, statuses string) int` | Count of beads matching ALL comma-separated `labels` AND ANY of the comma-separated `statuses`, via `bd list -l --status --all --json` in `Workspace.Folder`. Bounded 5s subprocess, short-TTL in-memory cache (5s), **fail-open**: returns positive sentinel on any error (missing `bd`, non-zero exit, unparseable JSON, timeout) so `HasBeads`-gated prompts are never wrongly hidden. Legitimate empty result (`[]`) returns `0`. Cheap gates (`CommandExists("bd") && DirExists(".beads")`) should short-circuit **before** this to avoid exec when there is no beads DB. |
+| `HasBeads` | `HasBeads(labels, statuses string) bool` | `BeadsCount(labels, statuses) > 0`. Same fail-open + cache semantics. |
| `Model` | `Model(tag string) bool` | Current model carries capability `tag` (case-insensitive), resolved from `models:` profiles. `false` when the model is unknown or no profile matches. |
**No `html` escaping.** Use `text/template` (not `html/template`). Prompt bodies are
diff --git a/internal/config/cel_evaluator.go b/internal/config/cel_evaluator.go
index 113b857f6..96c6e1c54 100644
--- a/internal/config/cel_evaluator.go
+++ b/internal/config/cel_evaluator.go
@@ -250,6 +250,20 @@ func NewCELEvaluator() (*CELEvaluator, error) {
cel.BinaryBinding(mittoGitFileDeleted),
),
),
+ cel.Function("__mitto_beadsCount",
+ cel.Overload("__mitto_beadsCount_string_string_string",
+ []*cel.Type{cel.StringType, cel.StringType, cel.StringType},
+ cel.IntType,
+ cel.FunctionBinding(mittoBeadsCount),
+ ),
+ ),
+ cel.Function("__mitto_hasBeads",
+ cel.Overload("__mitto_hasBeads_string_string_string",
+ []*cel.Type{cel.StringType, cel.StringType, cel.StringType},
+ cel.BoolType,
+ cel.FunctionBinding(mittoHasBeads),
+ ),
+ ),
// Macros rewrite user-facing convenience calls into the internal
// context-free functions above, injecting activation-sourced arguments.
@@ -270,6 +284,8 @@ func NewCELEvaluator() (*CELEvaluator, error) {
cel.GlobalMacro("GitDirModified", 1, gitDirModifiedMacro1),
cel.GlobalMacro("GitFileTracked", 1, gitFileTrackedMacro),
cel.GlobalMacro("GitFileDeleted", 1, gitFileDeletedMacro),
+ cel.GlobalMacro("BeadsCount", 2, beadsCountMacro),
+ cel.GlobalMacro("HasBeads", 2, hasBeadsMacro),
),
)
if err != nil {
@@ -578,6 +594,18 @@ func gitFileDeletedMacro(eh cel.MacroExprFactory, _ celast.Expr, args []celast.E
return eh.NewCall("__mitto_gitFileDeleted", eh.NewIdent("Workspace.Folder"), args[0]), nil
}
+// beadsCountMacro rewrites BeadsCount(labels, statuses) ->
+// __mitto_beadsCount(Workspace.Folder, labels, statuses).
+func beadsCountMacro(eh cel.MacroExprFactory, _ celast.Expr, args []celast.Expr) (celast.Expr, *celcommon.Error) {
+ return eh.NewCall("__mitto_beadsCount", eh.NewIdent("Workspace.Folder"), args[0], args[1]), nil
+}
+
+// hasBeadsMacro rewrites HasBeads(labels, statuses) ->
+// __mitto_hasBeads(Workspace.Folder, labels, statuses).
+func hasBeadsMacro(eh cel.MacroExprFactory, _ celast.Expr, args []celast.Expr) (celast.Expr, *celcommon.Error) {
+ return eh.NewCall("__mitto_hasBeads", eh.NewIdent("Workspace.Folder"), args[0], args[1]), nil
+}
+
// valToString returns the Go string for a CEL string value, or "" otherwise.
func valToString(v ref.Val) string {
if s, ok := v.(types.String); ok {
@@ -810,6 +838,34 @@ func mittoGitFileDeleted(folderVal, pathVal ref.Val) ref.Val {
return types.Bool(gitFileDeleted(valToString(folderVal), valToString(pathVal)))
}
+// mittoBeadsCount returns the count of beads matching ALL comma-separated
+// labels (args[1]) AND ANY of the comma-separated statuses (args[2]) in the
+// workspace folder (args[0]). Fail-open: returns a positive sentinel on any
+// error so gates using HasBeads never wrongly hide a prompt. Delegates to
+// beadsCount (templatefuncs.go) — single source of truth shared with the
+// template FuncMap.
+func mittoBeadsCount(args ...ref.Val) ref.Val {
+ if len(args) != 3 {
+ return types.Int(beadsCountFailOpen)
+ }
+ folder := valToString(args[0])
+ labels := valToString(args[1])
+ statuses := valToString(args[2])
+ return types.Int(beadsCount(folder, labels, statuses))
+}
+
+// mittoHasBeads reports whether beadsCount(folder, labels, statuses) > 0.
+// Same fail-open + cache semantics as beadsCount. Delegates to hasBeads.
+func mittoHasBeads(args ...ref.Val) ref.Val {
+ if len(args) != 3 {
+ return types.Bool(true) // fail-open on arg-count mismatch
+ }
+ folder := valToString(args[0])
+ labels := valToString(args[1])
+ statuses := valToString(args[2])
+ return types.Bool(hasBeads(folder, labels, statuses))
+}
+
// extractStringArgs extracts string values from CEL function arguments.
// Handles both individual string args and list(string) args.
func extractStringArgs(args []ref.Val) []string {
diff --git a/internal/config/templatefuncs.go b/internal/config/templatefuncs.go
index 94a00eb7d..fda30c334 100644
--- a/internal/config/templatefuncs.go
+++ b/internal/config/templatefuncs.go
@@ -2,10 +2,12 @@ package config
import (
"context"
+ "encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
+ "sync"
"text/template"
"time"
)
@@ -14,6 +16,35 @@ import (
// before it is killed, so template/CEL evaluation never hangs on a stalled repo.
const gitCmdTimeout = 5 * time.Second
+// bdCmdTimeout bounds how long a bd (beads) subprocess invocation is allowed to
+// run before it is killed. Mirrors gitCmdTimeout for the git helpers.
+const bdCmdTimeout = 5 * time.Second
+
+// beadsCacheTTL bounds how long a BeadsCount/HasBeads result is memoised per
+// (folder, labels, statuses) tuple, to avoid re-exec on rapid menu re-opens.
+// Kept short so a beads mutation is reflected within one TTL window.
+const beadsCacheTTL = 5 * time.Second
+
+// beadsCountFailOpen is the sentinel returned by beadsCount on ANY error (bd
+// missing, timeout, non-zero exit, unparseable JSON). It is a positive value
+// so HasBeads(...) returns true and the prompt is NEVER wrongly hidden —
+// consistent with the CEL fail-open policy. A legitimate empty result from bd
+// (`[]`) is NOT an error and returns 0.
+const beadsCountFailOpen = 1
+
+// beadsCache memoises beadsCount results for beadsCacheTTL keyed by
+// folder\x00labels\x00statuses. Simple sync.Mutex-guarded map with a
+// timestamped entry per key.
+var (
+ beadsCacheMu sync.Mutex
+ beadsCache = map[string]beadsCacheEntry{}
+)
+
+type beadsCacheEntry struct {
+ count int
+ at time.Time
+}
+
// =============================================================================
// Pure-Go condition helpers — single source of truth shared by CEL bindings
// (cel_evaluator.go) and the template FuncMap (BuildTemplateFuncMap below).
@@ -261,6 +292,77 @@ func gitFileDeleted(folder, path string) bool {
return false
}
+// runBd runs `bd ` with the working directory set to folder (when
+// non-empty), bounded by bdCmdTimeout. Returns the raw stdout bytes and true
+// when bd exits 0. Returns (nil, false) when bd is unavailable, exits non-zero,
+// or the command times out. Mirrors runGit.
+func runBd(folder string, args ...string) ([]byte, bool) {
+ if !commandExists("bd") {
+ return nil, false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), bdCmdTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "bd", args...)
+ if folder != "" {
+ cmd.Dir = folder
+ }
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, false
+ }
+ return out, true
+}
+
+// beadsCount counts beads matching ALL comma-separated labels AND ANY of the
+// comma-separated statuses, running `bd list -l --status
+// --all --json` in folder and parsing the resulting JSON array length.
+//
+// Fail-open: on ANY error (bd missing, not a beads repo, timeout, non-zero
+// exit, unparseable JSON) returns beadsCountFailOpen (a positive sentinel) so
+// HasBeads(...) returns true and callers gating a prompt never wrongly hide
+// it — consistent with the CEL fail-open policy. A legitimate empty result
+// (`[]`, exit 0) is NOT an error and returns 0.
+//
+// Results are memoised for beadsCacheTTL per (folder, labels, statuses) tuple
+// to bound exec frequency on rapid menu re-opens. Consumers relying on
+// short-circuit ordering (e.g. `CommandExists("bd") && DirExists(".beads") &&
+// HasBeads(...)`) still get zero exec cost when the cheap gates fail.
+func beadsCount(folder, labels, statuses string) int {
+ key := folder + "\x00" + labels + "\x00" + statuses
+ beadsCacheMu.Lock()
+ if e, ok := beadsCache[key]; ok && time.Since(e.at) < beadsCacheTTL {
+ beadsCacheMu.Unlock()
+ return e.count
+ }
+ beadsCacheMu.Unlock()
+
+ out, ok := runBd(folder, "list", "-l", labels, "--status", statuses, "--all", "--json")
+ if !ok {
+ return beadsCountFailOpen
+ }
+ trimmed := strings.TrimSpace(string(out))
+ if trimmed == "" {
+ // Empty stdout is unexpected (bd emits at least `[]`); treat as error.
+ return beadsCountFailOpen
+ }
+ var arr []json.RawMessage
+ if err := json.Unmarshal([]byte(trimmed), &arr); err != nil {
+ return beadsCountFailOpen
+ }
+ count := len(arr)
+
+ beadsCacheMu.Lock()
+ beadsCache[key] = beadsCacheEntry{count: count, at: time.Now()}
+ beadsCacheMu.Unlock()
+ return count
+}
+
+// hasBeads reports whether beadsCount(folder, labels, statuses) > 0. Convenience
+// wrapper sharing the same fail-open + cache semantics as beadsCount.
+func hasBeads(folder, labels, statuses string) bool {
+ return beadsCount(folder, labels, statuses) > 0
+}
+
// =============================================================================
// Exported formatting helpers (single source of truth for legacy @mitto: output)
// =============================================================================
@@ -337,6 +439,9 @@ func FormatChildren(children []ChildInfo) string {
// changes, including untracked files.
// - GitFileTracked(path) — true iff path is tracked by git (present in the index).
// - GitFileDeleted(path) — true iff the tracked file has been deleted (staged or unstaged).
+// - BeadsCount(labels, statuses) — count of beads matching ALL comma-separated labels
+// AND ANY of the comma-separated statuses. Fail-open (positive sentinel) on error.
+// - HasBeads(labels, statuses) — BeadsCount(...) > 0. Same fail-open semantics.
// - hasPattern(pattern) — true iff any MCP tool name matches pattern (fail-open).
// - Model(tag) — true iff the current model carries the capability tag (case-insensitive).
// - cond(expr) / when(expr) — compile+evaluate a CEL expression via GetCELEvaluator()
@@ -414,7 +519,13 @@ func BuildTemplateFuncMap(ctx *PromptEnabledContext) template.FuncMap {
},
"GitFileTracked": func(path string) bool { return gitFileTracked(folder, path) },
"GitFileDeleted": func(path string) bool { return gitFileDeleted(folder, path) },
- "HasPattern": func(pattern string) bool { return hasPattern(toolServers, pattern) },
+ // BeadsCount / HasBeads — see beadsCount/hasBeads (templatefuncs.go) for
+ // fail-open + short-TTL cache semantics. Cheap gates (CommandExists("bd"),
+ // DirExists(".beads")) must come BEFORE these via && short-circuit so bd
+ // only runs when the workspace actually has a beads database.
+ "BeadsCount": func(labels, statuses string) int { return beadsCount(folder, labels, statuses) },
+ "HasBeads": func(labels, statuses string) bool { return hasBeads(folder, labels, statuses) },
+ "HasPattern": func(pattern string) bool { return hasPattern(toolServers, pattern) },
// Model(tag) — true iff the session's current model carries the capability tag
// (case-insensitive), resolved from the models: profiles. False for an unknown model.
"Model": func(tag string) bool { return hasModelTag(modelTags, tag) },
diff --git a/internal/config/templatefuncs_test.go b/internal/config/templatefuncs_test.go
index 17da41e83..4afc7ea4e 100644
--- a/internal/config/templatefuncs_test.go
+++ b/internal/config/templatefuncs_test.go
@@ -682,6 +682,7 @@ func TestBuildTemplateFuncMap_AllKeysPresent(t *testing.T) {
"Arg", "Default", "UserData",
"FileExists", "DirExists", "CommandExists", "HasPattern", "Model",
"GitFileModified", "GitDirModified", "GitFileTracked", "GitFileDeleted",
+ "BeadsCount", "HasBeads",
"Trim", "Lower", "Upper", "Contains", "HasPrefix", "HasSuffix", "Join",
}
for _, key := range expected {
@@ -1154,3 +1155,192 @@ func TestPrecompileTemplateConds_ParseError(t *testing.T) {
t.Fatal("expected parse error, got nil")
}
}
+
+// installFakeBd writes a fake `bd` shell script to a fresh temp dir, prepends
+// that dir to PATH for the duration of the test, and clears the beadsCache so
+// results from other tests don't leak. The script's stdout comes from `stdout`
+// and its exit code from `exitCode` (0 = success). Returns the temp dir.
+func installFakeBd(t *testing.T, stdout string, exitCode int) string {
+ t.Helper()
+ dir := t.TempDir()
+ script := fmt.Sprintf("#!/bin/sh\ncat <<'MITTO_BD_EOF'\n%s\nMITTO_BD_EOF\nexit %d\n", stdout, exitCode)
+ bdPath := filepath.Join(dir, "bd")
+ if err := os.WriteFile(bdPath, []byte(script), 0755); err != nil {
+ t.Fatal(err)
+ }
+ oldPath := os.Getenv("PATH")
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+oldPath)
+ // Clear the cache so a previous test's result doesn't shadow this one.
+ beadsCacheMu.Lock()
+ beadsCache = map[string]beadsCacheEntry{}
+ beadsCacheMu.Unlock()
+ return dir
+}
+
+// TestBeadsCount_EmptyResult verifies that a legitimate empty result (bd exit
+// 0, `[]`) returns 0 — NOT the fail-open sentinel.
+func TestBeadsCount_EmptyResult(t *testing.T) {
+ installFakeBd(t, "[]", 0)
+ tmp := t.TempDir()
+
+ got := beadsCount(tmp, "support-question", "open,in_progress")
+ if got != 0 {
+ t.Errorf("beadsCount empty = %d, want 0", got)
+ }
+ if hasBeads(tmp, "support-question", "open,in_progress") {
+ t.Errorf("hasBeads empty = true, want false")
+ }
+}
+
+// TestBeadsCount_JSONParse verifies that a well-formed array is counted correctly.
+func TestBeadsCount_JSONParse(t *testing.T) {
+ installFakeBd(t, `[{"id":"mitto-1"},{"id":"mitto-2"},{"id":"mitto-3"}]`, 0)
+ tmp := t.TempDir()
+
+ got := beadsCount(tmp, "support-question", "open,in_progress")
+ if got != 3 {
+ t.Errorf("beadsCount = %d, want 3", got)
+ }
+ if !hasBeads(tmp, "support-question", "open,in_progress") {
+ t.Errorf("hasBeads = false, want true")
+ }
+}
+
+// TestBeadsCount_FailOpenOnNonZeroExit verifies that a non-zero exit code
+// (e.g. not a beads repo) returns the positive sentinel so HasBeads is truthy.
+func TestBeadsCount_FailOpenOnNonZeroExit(t *testing.T) {
+ installFakeBd(t, "error: not a beads repo", 1)
+ tmp := t.TempDir()
+
+ got := beadsCount(tmp, "support-question", "open,in_progress")
+ if got != beadsCountFailOpen {
+ t.Errorf("beadsCount fail-open = %d, want %d", got, beadsCountFailOpen)
+ }
+ if !hasBeads(tmp, "support-question", "open,in_progress") {
+ t.Errorf("hasBeads fail-open = false, want true")
+ }
+}
+
+// TestBeadsCount_FailOpenOnBadJSON verifies that unparseable stdout returns
+// the positive sentinel.
+func TestBeadsCount_FailOpenOnBadJSON(t *testing.T) {
+ installFakeBd(t, "not json at all {{{", 0)
+ tmp := t.TempDir()
+
+ got := beadsCount(tmp, "support-question", "open,in_progress")
+ if got != beadsCountFailOpen {
+ t.Errorf("beadsCount fail-open on bad json = %d, want %d", got, beadsCountFailOpen)
+ }
+}
+
+// TestBeadsCount_FailOpenWhenMissing verifies that bd absent from PATH returns
+// the positive sentinel (fail-open).
+func TestBeadsCount_FailOpenWhenMissing(t *testing.T) {
+ // Force an isolated PATH with no bd.
+ emptyDir := t.TempDir()
+ t.Setenv("PATH", emptyDir)
+ beadsCacheMu.Lock()
+ beadsCache = map[string]beadsCacheEntry{}
+ beadsCacheMu.Unlock()
+
+ got := beadsCount(emptyDir, "support-question", "open,in_progress")
+ if got != beadsCountFailOpen {
+ t.Errorf("beadsCount missing bd = %d, want %d", got, beadsCountFailOpen)
+ }
+ if !hasBeads(emptyDir, "support-question", "open,in_progress") {
+ t.Errorf("hasBeads missing bd = false, want true (fail-open)")
+ }
+}
+
+// TestBeadsCount_Cache verifies that repeated calls within beadsCacheTTL hit
+// the cache and don't re-exec bd. We swap the fake bd's script mid-test: the
+// second call must still return the first (cached) value.
+func TestBeadsCount_Cache(t *testing.T) {
+ dir := installFakeBd(t, `[{"id":"a"},{"id":"b"}]`, 0)
+ tmp := t.TempDir()
+
+ first := beadsCount(tmp, "support-question", "open,in_progress")
+ if first != 2 {
+ t.Fatalf("first beadsCount = %d, want 2", first)
+ }
+ // Overwrite the fake bd to return a different count; the cache must mask this.
+ bdPath := filepath.Join(dir, "bd")
+ newScript := "#!/bin/sh\necho '[{\"id\":\"a\"},{\"id\":\"b\"},{\"id\":\"c\"},{\"id\":\"d\"}]'\n"
+ if err := os.WriteFile(bdPath, []byte(newScript), 0755); err != nil {
+ t.Fatal(err)
+ }
+ second := beadsCount(tmp, "support-question", "open,in_progress")
+ if second != first {
+ t.Errorf("second beadsCount = %d, want cached %d", second, first)
+ }
+}
+
+// TestBeadsCount_CELParity verifies that HasBeads and BeadsCount evaluated
+// through CEL produce the same result as the pure-Go helpers — mirrors the
+// git-func parity tests (mitto-d01 pattern).
+func TestBeadsCount_CELParity(t *testing.T) {
+ installFakeBd(t, `[{"id":"mitto-1"},{"id":"mitto-2"}]`, 0)
+ tmp := t.TempDir()
+
+ e := newTestEvaluator(t)
+ ctx := &PromptEnabledContext{Workspace: WorkspaceContext{Folder: tmp}}
+
+ // BeadsCount(...) -> int; evaluate raw via cel.Program to compare Int values.
+ ce, err := e.Compile(`BeadsCount("support-question", "open,in_progress")`)
+ if err != nil {
+ t.Fatalf("compile BeadsCount: %v", err)
+ }
+ out, _, err := ce.prog.Eval(buildActivation(ctx))
+ if err != nil {
+ t.Fatalf("eval BeadsCount: %v", err)
+ }
+ i, ok := out.Value().(int64)
+ if !ok {
+ t.Fatalf("BeadsCount result type = %T, want int64", out.Value())
+ }
+ goCount := beadsCount(tmp, "support-question", "open,in_progress")
+ if int64(goCount) != i {
+ t.Errorf("CEL BeadsCount = %d, go beadsCount = %d", i, goCount)
+ }
+
+ // HasBeads(...) -> bool through evalCEL.
+ got := evalCEL(t, e, `HasBeads("support-question", "open,in_progress")`, ctx)
+ if got != hasBeads(tmp, "support-question", "open,in_progress") {
+ t.Errorf("CEL HasBeads = %v, go hasBeads mismatch", got)
+ }
+ if !got {
+ t.Errorf("CEL HasBeads = false, want true (2 beads returned)")
+ }
+
+ // Combined expression: mirrors the real support-housekeeping gate.
+ combined := evalCEL(t, e, `CommandExists("bd") && HasBeads("support-question", "open,in_progress")`, ctx)
+ if !combined {
+ t.Errorf("combined gate = false, want true")
+ }
+}
+
+// TestBeadsCount_TemplateFuncRender verifies BeadsCount/HasBeads render through
+// RenderPromptTemplate (mirrors TestBuildTemplateFuncMap_GitFuncsRenderSmoke).
+func TestBeadsCount_TemplateFuncRender(t *testing.T) {
+ installFakeBd(t, `[{"id":"mitto-1"}]`, 0)
+ tmp := t.TempDir()
+
+ ctx := &PromptEnabledContext{Workspace: WorkspaceContext{Folder: tmp}}
+ fm := BuildTemplateFuncMap(ctx)
+
+ got, err := RenderPromptTemplate("test", `{{ if HasBeads "support-question" "open,in_progress" }}yes{{ else }}no{{ end }}`, ctx, fm)
+ if err != nil {
+ t.Fatalf("render HasBeads: %v", err)
+ }
+ if got != "yes" {
+ t.Errorf("HasBeads render = %q, want %q", got, "yes")
+ }
+
+ got, err = RenderPromptTemplate("test", `count={{ BeadsCount "support-question" "open,in_progress" }}`, ctx, fm)
+ if err != nil {
+ t.Fatalf("render BeadsCount: %v", err)
+ }
+ if got != "count=1" {
+ t.Errorf("BeadsCount render = %q, want %q", got, "count=1")
+ }
+}
From 6bfd3c94fb4270aab0564200690cfeb67a564e59 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 8 Jul 2026 09:36:28 +0200
Subject: [PATCH 035/240] feat(prompts): gate support-housekeeping on HasBeads
+ add priority step
Update the "Support: *" workflow prompts as a cohesive set:
- Add a "Set priority by intervention urgency" step (P0-P3 via
bd update --priority) after the state-reconciliation step, so
priority reflects how urgently a human must act rather than
drifting stale.
- Gate support-housekeeping's enabledWhen with the new HasBeads(
"support-question", "open,in_progress") so the sweep prompt only
shows up when there is actually a tracked backlog, short-circuited
after the existing CommandExists/DirExists checks.
- Add "Available ACP servers" / "Existing child conversations"
context sections so investigation spawns pick a sensible server tag
and avoid re-spawning duplicate children.
Affects support-check-status, support-continue-conversation,
support-gather-info, support-housekeeping, support-investigate,
support-reply-to-user, and support-watch-channel prompts.
---
.../builtin/support-check-status.prompt.yaml | 14 ++
.../support-continue-conversation.prompt.yaml | 15 ++
.../builtin/support-gather-info.prompt.yaml | 17 +-
.../builtin/support-housekeeping.prompt.yaml | 193 +++++++++++++++---
.../builtin/support-investigate.prompt.yaml | 15 ++
.../builtin/support-reply-to-user.prompt.yaml | 17 +-
.../builtin/support-watch-channel.prompt.yaml | 158 ++++++++++++--
7 files changed, 385 insertions(+), 44 deletions(-)
diff --git a/config/prompts/builtin/support-check-status.prompt.yaml b/config/prompts/builtin/support-check-status.prompt.yaml
index 320bf71aa..46e9d4531 100644
--- a/config/prompts/builtin/support-check-status.prompt.yaml
+++ b/config/prompts/builtin/support-check-status.prompt.yaml
@@ -138,6 +138,20 @@ prompt: |-
**"Support: reply to user"** (if we can answer) or **"Support: gather more information"** (if we
still need details), then stop.
+ ## Set priority by intervention urgency (final step)
+
+ After updating the state (Step 5) — and unless you closed the bead in Step 7 — set its priority to
+ match how urgently *you* (the human) must act. In general:
+
+ - **P1 (HIGH)** — a draft (reply or clarifying question) is pending **your** approval
+ (`state:drafting`); bump to **P0** only if it answers an escalation / outage / broad-impact thread.
+ - **P2 (MEDIUM)** — in motion, not blocked on you: `triaged` / `gathering-info` / `awaiting-us`, or
+ `awaiting-customer` / `need-info` with the customer silent **< 3 days**.
+ - **P3 (LOW)** — `awaiting-customer` / `need-info` with **no customer response for 3+ days** (dormant).
+
+ Apply with `bd update --priority ` (n is `0`–`3`, 0 = highest) and add a short
+ `**[PRIORITY · ]** P → P ()` comment when it changes.
+
## Notes
- This prompt is **read-and-record** on Slack — it never posts a reply to the channel.
diff --git a/config/prompts/builtin/support-continue-conversation.prompt.yaml b/config/prompts/builtin/support-continue-conversation.prompt.yaml
index 0be7beacf..94d1922f2 100644
--- a/config/prompts/builtin/support-continue-conversation.prompt.yaml
+++ b/config/prompts/builtin/support-continue-conversation.prompt.yaml
@@ -174,6 +174,21 @@ prompt: |-
`state:awaiting-customer` (`bd update --remove-label state: --add-label state:awaiting-customer`;
for a brand-new bead just `--add-label state:awaiting-customer`).
+ ## Set priority by intervention urgency (final step)
+
+ After Step 7 the tracked bead sits in `state:awaiting-customer` (we just posted), so set it to
+ **P2** — dropping to **P3** on a later sweep if the customer stays silent 3+ days. In general the
+ priority reflects how urgently *you* (the human) must act:
+
+ - **P1 (HIGH)** — a draft (reply or clarifying question) is pending **your** approval
+ (`state:drafting`); bump to **P0** only if it answers an escalation / outage / broad-impact thread.
+ - **P2 (MEDIUM)** — in motion, not blocked on you: `triaged` / `gathering-info` / `awaiting-us`, or
+ `awaiting-customer` / `need-info` with the customer silent **< 3 days**.
+ - **P3 (LOW)** — `awaiting-customer` / `need-info` with **no customer response for 3+ days** (dormant).
+
+ Apply with `bd update --priority ` (n is `0`–`3`, 0 = highest) and add a short
+ `**[PRIORITY · ]** P → P ()` comment when it changes.
+
## Notes
- **Slack message approval**: NEVER post to Slack without explicit user review and approval via the
diff --git a/config/prompts/builtin/support-gather-info.prompt.yaml b/config/prompts/builtin/support-gather-info.prompt.yaml
index a10ea61ed..655853380 100644
--- a/config/prompts/builtin/support-gather-info.prompt.yaml
+++ b/config/prompts/builtin/support-gather-info.prompt.yaml
@@ -56,7 +56,7 @@ prompt: |-
- **No linked bead** — show a picker: list candidates and keep only those in `state:need-info`:
`bd list -l support-question --status open,in_progress --all` (read each `state:*` label from
`bd show`). Present them with `mitto_ui_options` (first option `{label: "None - Cancel"}`, then
- one `{label: " — "}` per bead, timeout 300).
+ one `{label: " [state] — "}` per bead, timeout 300).
- If there are no `state:need-info` beads, `mitto_ui_notify` (info) that nothing needs clarification
and stop. If the user cancels, acknowledge and stop.
{{- end }}
@@ -151,6 +151,21 @@ prompt: |-
- Do **NOT** post anything to Slack. Leave the bead in `state:need-info` (the saved draft stays
pending). Acknowledge and stop.
+ ## Set priority by intervention urgency (final step)
+
+ Set the bead's priority to match how urgently *you* (the human) must act: while the clarifying draft
+ is pending **your** approval it is **P1**; once posted (`state:awaiting-customer`) it is **P2**,
+ dropping to **P3** if the customer then stays silent 3+ days. In general:
+
+ - **P1 (HIGH)** — a draft (reply or clarifying question) is pending **your** approval
+ (`state:drafting`); bump to **P0** only if it answers an escalation / outage / broad-impact thread.
+ - **P2 (MEDIUM)** — in motion, not blocked on you: `triaged` / `gathering-info` / `awaiting-us`, or
+ `awaiting-customer` / `need-info` with the customer silent **< 3 days**.
+ - **P3 (LOW)** — `awaiting-customer` / `need-info` with **no customer response for 3+ days** (dormant).
+
+ Apply with `bd update --priority ` (n is `0`–`3`, 0 = highest) and add a short
+ `**[PRIORITY · ]** P → P ()` comment when it changes.
+
## Notes
- Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth.
diff --git a/config/prompts/builtin/support-housekeeping.prompt.yaml b/config/prompts/builtin/support-housekeeping.prompt.yaml
index 483dd11d0..4aace7b0c 100644
--- a/config/prompts/builtin/support-housekeeping.prompt.yaml
+++ b/config/prompts/builtin/support-housekeeping.prompt.yaml
@@ -4,7 +4,7 @@ group: Support
backgroundColor: '#D7CCC8'
icon: broom
menus: beadsList
-enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads")'
+enabledWhen: '!Session.IsChild && CommandExists("bd") && DirExists(".beads") && HasBeads("support-question", "open,in_progress")'
tags:
- support
prompt: |-
@@ -14,6 +14,15 @@ prompt: |-
Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls.
+ Available ACP servers:
+ {{ .ACP.AvailableText }}
+
+ Existing child conversations (spawned by previous runs):
+ {{ .Children.MCPText }}
+
+ When spawning investigation conversations, prefer a `"coding"` or `"fast"` tagged server. **Never**
+ configure spawned conversations as loops — each is a one-off investigation of a single bead.
+
## Description
Periodic maintenance sweep over **all** tracked support tickets (beads labelled
@@ -44,16 +53,37 @@ prompt: |-
channel + parent `thread_ts` from its bead metadata (`slack_channel` / `slack_thread_ts`). No
posting tool is needed.
- ## Step 0: Gate — is there anything to do?
+ ## State lifecycle (single `state:*` label)
+
+ Every tracked bead carries exactly one `state:*` label. Change it **in place** (same issue — never
+ `bd set-state`, which spawns child event beads): read the current label from `bd show `, then
+ `bd update --remove-label state: --add-label state:`. Whenever you change a
+ bead's `state:*` label, **recompute its priority** per the rubric in Step 5.
+
+ | state | meaning |
+ |-------|---------|
+ | `triaged` | auto-created during triage, not yet worked (initial) |
+ | `gathering-info` | actively working it — investigating to answer (docs/tools/knowledge file) |
+ | `need-info` | need more details from the customer before we can answer |
+ | `drafting` | have a draft answer, pending your review |
+ | `awaiting-customer` | we posted a reply, waiting on the customer |
+ | `awaiting-us` | customer responded, our turn to act |
+ | `resolved` | answered/accepted (also `bd close `) |
+ | `stale` | auto-closed after 10+ days of inactivity (also `bd close `) |
+
+ ## Step 1: List the backlog
+
+ > The CEL `enabledWhen` gate on this prompt (`HasBeads("support-question", "open,in_progress")`)
+ > already ensures there is at least one open ticket before the menu entry appears, so no separate
+ > "is there anything to do?" guard is needed here. As a defensive no-op only: if `bd list` below
+ > comes back empty (e.g. a race with a just-closed ticket), send a `mitto_ui_notify` (info) that
+ > there is nothing to sweep and stop.
- List the open tracked support tickets:
`bd list -l support-question --status open,in_progress --all`.
- - **If the list is empty** (zero open support tickets), there is nothing to sweep. Send a
- `mitto_ui_notify` (info): "No open support tickets — housekeeping has nothing to do." and **stop
- immediately**. Do not proceed to the next steps.
- - Otherwise, collect the ticket IDs and continue. Announce how many you will process.
+ - Collect the ticket IDs and continue. Announce how many you will process.
- ## Step 1: Confirm the plan
+ ## Step 2: Confirm the plan
- Summarise the backlog you are about to sweep (count + a one-line list of ` [state] — `).
- Use `mitto_ui_options` (timeout 300):
@@ -61,7 +91,7 @@ prompt: |-
- **Options**: `[{label: "Yes, run housekeeping"}, {label: "Cancel"}]`.
- On **"Cancel"** (or timeout): acknowledge and stop without changing anything.
- ## Step 2: Refresh each ticket from Slack
+ ## Step 3: Refresh each ticket from Slack
For **each** ticket in the backlog, load `bd show ` + `bd comments ` (bead = source of
truth), then reconcile it with its Slack thread:
@@ -86,26 +116,34 @@ prompt: |-
Apply with `bd update --remove-label state: --add-label state:` and add a short
`**[STATE · ]** → ` comment explaining why.
- If the thread is unreachable (channel/thread gone, no metadata), record a `[CONTEXT]` note that it
- could not be refreshed and carry it into Step 3 as a stale candidate.
+ could not be refreshed and carry it into Step 4 as a stale candidate.
- ## Step 3: Identify stale / no-longer-relevant tickets
+ ## Step 4: Identify stale / no-longer-relevant tickets
A ticket is a **stale candidate** when, based on the thread activity and bead content, it no longer
needs our attention. Signals (any of):
+ - **Aged out — 10+ days of inactivity.** The primary rule (same as **"Support: watch channel"**):
+ find tracked issues with no activity for **10+ days**
+ (`bd list -l support-question --status open,in_progress --updated-before --all`, e.g.
+ cutoff `date -u -v-10d +%Y-%m-%d`). Verify each is truly stale (no recent comments, no open
+ blockers, no unanswered customer message). These close to `state:stale`.
- The thread was **resolved** (positive reaction on our answer, a "thanks / that worked / got it",
- or it concluded with no open question).
+ or it concluded with no open question). These close to `state:resolved`.
- **No activity for a long time** and the ball was on the customer (`state:awaiting-customer` /
- `state:need-info` with a stale last message) — effectively abandoned.
+ `state:need-info` with a stale last message) — effectively abandoned. These close to `state:stale`.
- The thread is **gone / deleted / unreachable**, or the underlying question is obsolete
- (superseded, duplicate of another bead, or overtaken by events).
+ (superseded, duplicate of another bead, or overtaken by events). These close to `state:stale`.
- Build the list of stale candidates with a one-line reason each. Present them with `mitto_ui_options`
- (timeout 300):
+ Build the list of stale candidates with a one-line reason **and its target close state** each.
+ Present them with `mitto_ui_options` (timeout 300):
- **Question**: "These tickets look stale/irrelevant. Close them now, or just flag for review?"
- **Options**: `[{label: "Close all listed"}, {label: "Flag only (no close)"}, {label: "Let me pick"}, {label: "Skip"}]`.
- - **"Close all listed"** → for each: swap to `state:resolved` (or add `state:obsolete` if the
- reason is obsolete/duplicate) and `bd close -r "housekeeping: "`.
+ - **"Close all listed"** → for each, swap its `state:*` label to the target close state
+ (`state:resolved` for a genuinely-answered thread, otherwise `state:stale` for aged-out /
+ abandoned / obsolete / gone) with `bd update --remove-label state: --add-label
+ state:`, then `bd close -r "housekeeping: "` (use
+ `"auto-closed: no activity for 10+ days"` for the aged-out rule).
- **"Flag only"** → for each, add label `needs-review` and a `[STATE]` comment with the reason;
do NOT close.
- **"Let me pick"** → present the candidates as a second `mitto_ui_options` (or `mitto_ui_form`
@@ -114,20 +152,90 @@ prompt: |-
- Never close a ticket in `state:drafting` (a reply is pending your approval) without explicit
confirmation — surface it separately rather than auto-closing.
- ## Step 4: Re-evaluate the remaining open tickets
+ ## Step 5: Re-evaluate the remaining open tickets
- For every ticket still open after Step 3, reassess and update:
+ For every ticket still open after Step 4, reassess and update:
- - **Priority** — bump or lower `P0..P3` based on impact/urgency evident from the thread (an
- escalation, a P1 incident reference, many affected users → raise; a nice-to-have or single-user
- question → lower). Apply with `bd update --priority ` and note the change.
- - **Status / state** — make sure the `state:*` label matches reality after Step 2 (whose turn,
+ - **Status / state** — make sure the `state:*` label matches reality after Step 3 (whose turn,
drafting pending, needs info, etc.).
+ - **Priority = urgency of *your* intervention.** Priority does **not** reflect customer-impact
+ severity — it reflects **how urgently _you_ (the human operator) must step in**. Recompute it
+ whenever a bead's `state:*` changes and apply it with `bd update --priority ` (n is
+ `0`–`3`, 0 = highest), adding a short `**[PRIORITY · ]** P → P ()`
+ comment:
+
+ | Priority | Intervention urgency | When |
+ |----------|----------------------|------|
+ | **P1** | HIGH — actionable, waiting on **your** approval | A draft (reply **or** clarifying question) is ready and pending your OK to post — typically `state:drafting`. Bump to **P0** only if that draft answers an escalation / outage / broad-impact thread (you are the bottleneck on an urgent fix). |
+ | **P2** | MEDIUM — in motion, not blocked on you | `state:triaged`, `state:gathering-info`, `state:awaiting-us` (customer replied, our turn), or `awaiting-customer` / `need-info` where the customer last spoke **less than 3 days ago**. |
+ | **P3** | LOW — dormant, nothing for you to do | `state:awaiting-customer` or `state:need-info` with **no customer response for 3+ days** — drifting toward the 10-day auto-close. |
- **Next step** — add a short `**[NEXT · ]**` comment naming the concrete next action and the
prompt that performs it (**"Support: investigate"** to find an answer, **"Support: gather more
information"** to ask the customer, **"Support: reply to user"** to post a ready draft).
- ## Step 5: Summarise (in the conversation)
+ ## Step 6: Proactively kick off investigations (in parallel)
+
+ Rather than leaving "needs investigation" as a note for later, **start the work now** by delegating
+ each investigable ticket to its own child conversation running the canonical **"Support: investigate"**
+ prompt. The children run **in parallel**, each digging for the answer via the channel knowledge file
+ and leaving a draft (`state:drafting`) on its bead — turning the sweep from purely bookkeeping into
+ real progress.
+
+ {{- if .Permissions.CanStartConversation }}
+ - **First, reap finished investigation children from prior sweeps.** This sweep does not wait on the
+ children it spawns, so they finish between runs. Before spawning anything new, scan
+ `{{ .Children.MCPText }}`: a child is **done** once its bead has left the in-flight states — the
+ bead is now `drafting` / `need-info` (the two terminal outcomes of "Support: investigate"), or has
+ otherwise moved off `triaged` / `gathering-info` / `awaiting-us`. For each such child that is
+ **idle** (not currently prompting, no queued prompts), **archive** it to free the max-children cap
+ and keep dedup clean:
+ ```
+ mitto_conversation_archive(self_id: "{{ .Session.ID }}", conversation_id: "")
+ ```
+ **Archive, never delete** — the investigation transcript stays inspectable for auditing. Never
+ archive a child that is still prompting or whose bead is still `gathering-info`; leave it running.
+ {{- end }}
+
+ - **Which tickets to investigate.** After Step 5, a ticket is **investigable** when it is **our turn
+ and not already in flight**: its `state:*` is `triaged` or `awaiting-us` (not `drafting` /
+ `need-info` / `awaiting-customer` / `gathering-info` / closed), it has a usable `slack_channel` +
+ `slack_thread_ts`, and no open child is already working it. Build this candidate list with a
+ one-line reason each.
+ {{- if .Permissions.CanStartConversation }}
+ - **Confirm before spawning** (this is an interactive sweep — do not spawn unattended). If the
+ candidate list is non-empty, present it with `mitto_ui_options` (timeout 300):
+ - **Question**: "Start investigations for these tickets now (one parallel child each)?"
+ - **Options**: `[{label: "Investigate all listed"}, {label: "Let me pick"}, {label: "Skip — just leave [NEXT] notes"}]`.
+ - **"Let me pick"** → present a second `mitto_ui_options` / `mitto_ui_form` (a checkbox per
+ candidate) and spawn only for the chosen ones.
+ - **"Skip"** (or timeout) → spawn nothing; the `[NEXT]` notes from Step 5 already record the
+ pending investigation.
+ - **Spawn one child per chosen ticket (parallel).** Cap at **3 spawns per run** (highest priority /
+ oldest first). Before spawning, **dedup against `{{ .Children.MCPText }}`**: skip any ticket whose
+ ID already appears in an existing child's title. For each chosen ticket `` spawn (do **not**
+ pre-set the state here — the child's own Step 2 owns the `state:gathering-info` transition, so
+ setting it now would produce a redundant no-op `gathering-info → gathering-info` swap in the
+ child):
+ ```
+ mitto_conversation_new(
+ self_id: "{{ .Session.ID }}",
+ title: "Investigate : ",
+ beads_issue: "",
+ prompt_name: "Support: investigate",
+ arguments: { "IssueID": "" },
+ acp_server: )
+ ```
+ The child runs "Support: investigate" end-to-end on that bead (gather → record `[CONTEXT]` →
+ save an `[OUTBOUND]` DRAFT → `state:drafting`). **Do not wait** for the children — they report
+ on their own beads and via their own notifications. Note which tickets you spawned for the
+ summary.
+ {{- else }}
+ - **Child spawning is unavailable** (the *Can start conversation* permission is off). Do not spawn;
+ just make sure every investigable ticket carries a clear `**[NEXT]**` note from Step 5 pointing at
+ **"Support: investigate"**, and surface them in the summary as needing investigation.
+ {{- end }}
+
+ ## Step 7: Summarise (in the conversation)
Present a concise report **in this conversation** (not to Slack):
@@ -138,12 +246,36 @@ prompt: |-
**Refreshed from Slack:** (with new messages: )
**Closed / flagged stale:** — reason>
**Re-prioritised / restated:** — change>
+ **Investigations started:** — child spawned> (or "none")
### Needs your attention
- —
```
- Finish with a `mitto_ui_notify` (success) summarising counts (swept / closed / needs-attention).
+ - **Always end with a "what's next" table** listing every ticket that is still open after the sweep,
+ one row per bead: `| | | |`. The **what's next** cell is a short human
+ phrase derived from the bead's current `state:*` label (use this exact mapping so it reads the same
+ across runs and matches **"Support: watch channel"**):
+
+ | state | what's next |
+ |-------|-------------|
+ | `triaged` | Needs investigation |
+ | `gathering-info` | Investigation in progress |
+ | `need-info` | Needs more info from user |
+ | `drafting` | Ready to reply to user |
+ | `awaiting-customer` | Waiting on customer reply |
+ | `awaiting-us` | Our turn — needs a response |
+
+ Render it as:
+ ```markdown
+ | Bead | State | What's next |
+ |------|-------|-------------|
+ | | drafting | Ready to reply to user |
+ | | need-info | Needs more info from user |
+ ```
+
+ Finish with a `mitto_ui_notify` (success) summarising counts (swept / closed / investigations
+ started / needs-attention).
## Notes
@@ -153,3 +285,14 @@ prompt: |-
**"Support: reply to user"**.
- Process tickets one at a time and keep going on per-ticket errors (record a `[CONTEXT]` note and
move on) so one bad thread does not abort the whole sweep.
+ - **Proactive investigation** — Step 6 turns the sweep into real progress by delegating investigable
+ tickets (`state:triaged` / `awaiting-us`) to parallel one-off children running
+ **"Support: investigate"**, after your confirmation, capped at **3 per run** and deduped against
+ existing children. Spawned children are **never** loops and this sweep does not block on them —
+ each records its own draft on its bead. When the *Can start conversation* permission is off, Step 6
+ only leaves `[NEXT]` notes instead.
+ - **Child cleanup** — because this sweep does not wait on its children, they finish between runs.
+ Step 6 therefore **reaps finished investigation children from prior sweeps first**: once a child's
+ bead has reached a terminal outcome (`state:drafting` / `need-info`) and the child is idle, it is
+ **archived** (never deleted, so the transcript stays inspectable) to free the max-children cap and
+ keep the dedup list clean before new children are spawned.
diff --git a/config/prompts/builtin/support-investigate.prompt.yaml b/config/prompts/builtin/support-investigate.prompt.yaml
index 48bfd6ea8..b02703c88 100644
--- a/config/prompts/builtin/support-investigate.prompt.yaml
+++ b/config/prompts/builtin/support-investigate.prompt.yaml
@@ -154,6 +154,21 @@ prompt: |-
- ✅ End with a brief one-line chat summary (state is now `drafting`; draft saved on the bead;
next step is **"Support: reply to user"**). Do not reproduce the draft.
+ ## Set priority by intervention urgency (final step)
+
+ Whatever outcome you reached in Step 6, finish by setting the bead's priority to match how urgently
+ *you* (the human) must act — a saved draft makes it **P1**, while still-investigating
+ (`gathering-info`) or awaiting a customer detail (`need-info`) makes it **P2**. In general:
+
+ - **P1 (HIGH)** — a draft (reply or clarifying question) is pending **your** approval
+ (`state:drafting`); bump to **P0** only if it answers an escalation / outage / broad-impact thread.
+ - **P2 (MEDIUM)** — in motion, not blocked on you: `triaged` / `gathering-info` / `awaiting-us`, or
+ `awaiting-customer` / `need-info` with the customer silent **< 3 days**.
+ - **P3 (LOW)** — `awaiting-customer` / `need-info` with **no customer response for 3+ days** (dormant).
+
+ Apply with `bd update --priority ` (n is `0`–`3`, 0 = highest) and add a short
+ `**[PRIORITY · ]** P → P ()` comment when it changes.
+
## Notes
- Load the bead (`bd show` + `bd comments`) **before** investigating — it is the source of truth.
diff --git a/config/prompts/builtin/support-reply-to-user.prompt.yaml b/config/prompts/builtin/support-reply-to-user.prompt.yaml
index 417b9bf60..c39002e76 100644
--- a/config/prompts/builtin/support-reply-to-user.prompt.yaml
+++ b/config/prompts/builtin/support-reply-to-user.prompt.yaml
@@ -56,7 +56,7 @@ prompt: |-
- **No linked bead** — show a picker: list candidates and keep only those in `state:drafting`:
`bd list -l support-question --status open,in_progress --all` (read each `state:*` label from
`bd show`). Present them with `mitto_ui_options` (first option `{label: "None - Cancel"}`, then
- one `{label: " — "}` per bead, timeout 300).
+ one `{label: " [state] — "}` per bead, timeout 300).
- If there are no `state:drafting` beads, `mitto_ui_notify` (info) that nothing is ready to answer
and stop. If the user cancels, acknowledge and stop.
{{- end }}
@@ -167,6 +167,21 @@ prompt: |-
- Do **NOT** post anything to Slack. Leave the bead in `state:drafting` (the saved draft stays
pending). Acknowledge and stop.
+ ## Set priority by intervention urgency (final step)
+
+ Set the bead's priority to match how urgently *you* (the human) must act: while the draft is pending
+ **your** approval (`state:drafting`, including after a timeout or abort) it stays **P1**; once posted
+ (`state:awaiting-customer`) it becomes **P2**. In general:
+
+ - **P1 (HIGH)** — a draft (reply or clarifying question) is pending **your** approval
+ (`state:drafting`); bump to **P0** only if it answers an escalation / outage / broad-impact thread.
+ - **P2 (MEDIUM)** — in motion, not blocked on you: `triaged` / `gathering-info` / `awaiting-us`, or
+ `awaiting-customer` / `need-info` with the customer silent **< 3 days**.
+ - **P3 (LOW)** — `awaiting-customer` / `need-info` with **no customer response for 3+ days** (dormant).
+
+ Apply with `bd update --priority ` (n is `0`–`3`, 0 = highest) and add a short
+ `**[PRIORITY · ]** P → P ()` comment when it changes.
+
## Notes
- Load the bead (`bd show` + `bd comments`) **before** drafting — it is the source of truth.
diff --git a/config/prompts/builtin/support-watch-channel.prompt.yaml b/config/prompts/builtin/support-watch-channel.prompt.yaml
index 393a9089c..a97d1fa08 100644
--- a/config/prompts/builtin/support-watch-channel.prompt.yaml
+++ b/config/prompts/builtin/support-watch-channel.prompt.yaml
@@ -36,6 +36,15 @@ prompt: |-
Your session ID is `{{ .Session.ID }}` — use it as `self_id` for all `mitto_*` MCP tool calls.
+ Available ACP servers:
+ {{ .ACP.AvailableText }}
+
+ Existing child conversations (spawned by previous runs):
+ {{ .Children.MCPText }}
+
+ When spawning investigation conversations, prefer a `"coding"` or `"fast"` tagged server. **Never**
+ configure spawned conversations as loops — each is a one-off investigation of a single bead.
+
## What this does
Listen to the Slack channel **`{{ $channel }}`**. For every **new open customer question**
@@ -89,15 +98,17 @@ prompt: |-
create a second bead for the same thread.
- **Permalink.** Build it from the thread ts by removing the dot and prefixing `p`:
`{{ $ws }}/archives/{{ $channel }}/p` (e.g. `1770802214.391359` → `p1770802214391359`).
- - **Create** with the `support, support-question` labels, a triage priority (`-p`), the fixed
- markdown description via `-d`, and metadata. Use `$'...'` so `\n` become real newlines:
+ - **Create** with the `support, support-question` labels, a priority (`-p`) set by **intervention
+ urgency** (see **Priority = urgency of your intervention** below — a freshly-triaged bead is
+ `state:triaged` → **P2**), the fixed markdown description via `-d`, and metadata. Use `$'...'` so
+ `\n` become real newlines:
```
- bd create "" -t task -p -l support,support-question \
+ bd create "" -t task -p P2 -l support,support-question \
-d $'# Question\n\n\n\n# User\n\n\n\n# Links\n\n[Slack]({{ $ws }}/archives/{{ $channel }}/p)\n' \
--metadata '{"slack_thread_ts":"","slack_channel":"{{ $channel }}","slack_url":""}'
```
- - **Triage priority (`-p`):** `P0` outage/broad impact · `P1` urgent/blocking a customer ·
- `P2` standard question (default) · `P3` minor/how-to.
+ - **Priority (`-p`):** set by **intervention urgency**, not customer impact — see **Priority =
+ urgency of your intervention** below. A brand-new triaged bead is `state:triaged` → **P2**.
- **Comments = full history.** Log every relevant message as a markdown comment (preserve layout
with `$'...'` or `printf '%s' "$c" | bd comment --stdin`). Header then blank line then body:
```
@@ -123,6 +134,21 @@ prompt: |-
| `resolved` | answered/accepted (also `bd close `) |
| `stale` | auto-closed after 10+ days of inactivity (also `bd close `) |
+ Whenever you change a bead's `state:*` label, **recompute its priority** per the rubric below.
+
+ ## Priority = urgency of *your* intervention
+
+ Priority does **not** reflect customer-impact severity — it reflects **how urgently _you_ (the human
+ operator) must step in**. Recompute it whenever a bead's `state:*` changes and apply it with
+ `bd update --priority ` (n is `0`–`3`, 0 = highest), adding a short
+ `**[PRIORITY · ]** P → P ()` comment:
+
+ | Priority | Intervention urgency | When |
+ |----------|----------------------|------|
+ | **P1** | HIGH — actionable, waiting on **your** approval | A draft (reply **or** clarifying question) is ready and pending your OK to post — typically `state:drafting`. Bump to **P0** only if that draft answers an escalation / outage / broad-impact thread (you are the bottleneck on an urgent fix). |
+ | **P2** | MEDIUM — in motion, not blocked on you | `state:triaged`, `state:gathering-info`, `state:awaiting-us` (customer replied, our turn), or `awaiting-customer` / `need-info` where the customer last spoke **less than 3 days ago**. |
+ | **P3** | LOW — dormant, nothing for you to do | `state:awaiting-customer` or `state:need-info` with **no customer response for 3+ days** — drifting toward the 10-day auto-close. |
+
## Knowledge-file gate: check this FIRST, every iteration
Before you can help a customer you must know a **reliable way to gather answers** for this specific
@@ -204,24 +230,111 @@ prompt: |-
responds — never leave it stranded in `need-info`.
- Do not override `drafting` if our pending action still stands (a draft awaiting review). Add a
short `[STATE]` comment noting any change.
+ - **After any state change, recompute priority** (see **Priority = urgency of your intervention**):
+ `drafting` → P1; `awaiting-us` / fresh `awaiting-customer` / `need-info` → P2; `awaiting-customer`
+ or `need-info` with the customer silent **3+ days** → P3.
+ {{- if .Permissions.CanStartConversation }}
+ - **Reap finished investigation children.** A child spawned by Step 4 is **done** once its bead has
+ left the in-flight states — i.e. the bead is now `drafting` / `need-info` (the two terminal
+ outcomes of "Support: investigate"), or has otherwise moved off `triaged` / `gathering-info` /
+ `awaiting-us`. For each such bead, find the matching child in `{{ .Children.MCPText }}` (its ID
+ appears in the child's title / `beads_issue`); if that child is **idle** (not currently prompting,
+ no queued prompts), **archive** it to free the max-children cap and keep dedup clean for the next
+ iteration:
+ ```
+ mitto_conversation_archive(self_id: "{{ .Session.ID }}", conversation_id: "")
+ ```
+ **Archive, never delete** — the investigation transcript stays inspectable for auditing. Never
+ archive a child that is still prompting or whose bead is still `gathering-info` (it is mid-flight);
+ leave it running and reap it on a later iteration.
+ {{- end }}
- ### 4. Gather info + draft (only if the knowledge file exists)
+ ### 4. Gather info + draft — proactively, in parallel children (only if the knowledge file exists)
+
+ Do **not** investigate every bead inline this run — that serialises the work and can blow the
+ run's time budget. Instead, **delegate each investigable bead to its own child conversation** so
+ they run **in parallel**, each executing the canonical **"Support: investigate"** prompt (which
+ follows the same knowledge file, records findings, and leaves a draft → `state:drafting`).
+
+ - **Which beads to investigate.** A bead is **investigable** this run when it is **our turn and not
+ already in flight**: its `state:*` is `triaged` or `awaiting-us` (not `drafting` / `need-info` /
+ `awaiting-customer` / `resolved` / `stale`), it has a usable `slack_channel` + `slack_thread_ts`,
+ and there is no open child already working it (see dedup below). Skip anything already
+ `state:gathering-info` or `state:drafting` — it is in progress or waiting on you.
+ {{- if .Permissions.CanStartConversation }}
+
+ - **Spawn one child per investigable bead (parallel).** Cap at **3 spawns per run** (highest
+ priority / oldest first). Before spawning, **dedup against `{{ .Children.MCPText }}`**: skip any
+ bead whose ID already appears in an existing child's title — never spawn a second investigation
+ for the same bead. For each selected bead `` spawn (do **not** pre-set the state here — the
+ child's own Step 2 owns the `state:gathering-info` transition, so setting it now would produce a
+ redundant no-op `gathering-info → gathering-info` swap in the child):
+ ```
+ mitto_conversation_new(
+ self_id: "{{ .Session.ID }}",
+ title: "Investigate : ",
+ beads_issue: "",
+ prompt_name: "Support: investigate",
+ arguments: { "IssueID": "