794 lines
21 KiB
Markdown
794 lines
21 KiB
Markdown
# Session Resilience Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Eliminate silent session context loss in multi-workspace mode by normalizing paths, handling resume failures gracefully, surfacing context consumption to users, and adding diagnostic logging.
|
|
|
|
**Architecture:** Four independent changes layered bottom-up: path normalization (prevents mismatches), diagnostic logging (makes failures visible), resume fallback (auto-recovers from broken resumes), context indicator (gives users agency over compaction).
|
|
|
|
**Tech Stack:** Go, Claude Code CLI (stream-json protocol), slog structured logging
|
|
|
|
**Design doc:** `docs/plans/2026-03-13-session-resilience-design.md`
|
|
|
|
---
|
|
|
|
### Task 1: Add `normalizeWorkspacePath` helper
|
|
|
|
**Files:**
|
|
- Modify: `core/workspace_state.go`
|
|
- Create: `core/workspace_state_test.go` (add test cases)
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `core/workspace_state_test.go`:
|
|
|
|
```go
|
|
func TestNormalizeWorkspacePath(t *testing.T) {
|
|
// Create a real temp directory for symlink tests
|
|
tmp := t.TempDir()
|
|
realDir := filepath.Join(tmp, "real-project")
|
|
if err := os.Mkdir(realDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
symlink := filepath.Join(tmp, "link-project")
|
|
if err := os.Symlink(realDir, symlink); err != nil {
|
|
t.Skip("symlinks not supported")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"trailing slash", realDir + "/", realDir},
|
|
{"double slash", filepath.Join(tmp, "real-project") + "//", realDir},
|
|
{"dot segment", filepath.Join(tmp, ".", "real-project"), realDir},
|
|
{"dotdot segment", filepath.Join(tmp, "real-project", "subdir", ".."), realDir},
|
|
{"symlink resolved", symlink, realDir},
|
|
{"nonexistent uses Clean only", "/nonexistent/path/./foo/../bar", "/nonexistent/path/bar"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeWorkspacePath(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
|
|
Expected: FAIL — `normalizeWorkspacePath` undefined
|
|
|
|
**Step 3: Write minimal implementation**
|
|
|
|
Add to `core/workspace_state.go`:
|
|
|
|
```go
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// normalizeWorkspacePath cleans and resolves a workspace path to prevent
|
|
// mismatches caused by trailing slashes, symlinks, or relative segments.
|
|
// If the path cannot be resolved (e.g. doesn't exist yet), falls back to
|
|
// filepath.Clean only.
|
|
func normalizeWorkspacePath(path string) string {
|
|
cleaned := filepath.Clean(path)
|
|
resolved, err := filepath.EvalSymlinks(cleaned)
|
|
if err != nil {
|
|
// Path doesn't exist yet — best effort
|
|
return cleaned
|
|
}
|
|
if resolved != path {
|
|
slog.Debug("workspace path normalized", "original", path, "normalized", resolved)
|
|
}
|
|
return resolved
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add core/workspace_state.go core/workspace_state_test.go
|
|
git commit -m "feat: add normalizeWorkspacePath helper for consistent pool keys"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Apply path normalization at entry points
|
|
|
|
**Files:**
|
|
- Modify: `core/workspace_state.go` — `GetOrCreate`
|
|
- Modify: `core/engine.go:5656,5681` — `resolveWorkspace` return values
|
|
- Modify: `core/engine.go:993` — `getOrCreateWorkspaceAgent`
|
|
|
|
**Step 1: Write failing tests**
|
|
|
|
Add to `core/workspace_state_test.go`:
|
|
|
|
```go
|
|
func TestWorkspacePoolNormalizesKeys(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
realDir := filepath.Join(tmp, "project")
|
|
if err := os.Mkdir(realDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pool := newWorkspacePool(15 * time.Minute)
|
|
|
|
// Access with trailing slash
|
|
ws1 := pool.GetOrCreate(realDir + "/")
|
|
// Access without trailing slash
|
|
ws2 := pool.GetOrCreate(realDir)
|
|
|
|
if ws1 != ws2 {
|
|
t.Error("trailing slash created a different workspace state")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./core/ -run TestWorkspacePoolNormalizesKeys -v`
|
|
Expected: FAIL — two different states returned
|
|
|
|
**Step 3: Normalize in `GetOrCreate` and `Get`**
|
|
|
|
In `core/workspace_state.go`, modify `GetOrCreate`:
|
|
|
|
```go
|
|
func (p *workspacePool) GetOrCreate(workspace string) *workspaceState {
|
|
workspace = normalizeWorkspacePath(workspace)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if s, ok := p.states[workspace]; ok {
|
|
return s
|
|
}
|
|
s := newWorkspaceState(workspace)
|
|
p.states[workspace] = s
|
|
return s
|
|
}
|
|
```
|
|
|
|
Modify `Get` similarly:
|
|
|
|
```go
|
|
func (p *workspacePool) Get(workspace string) *workspaceState {
|
|
workspace = normalizeWorkspacePath(workspace)
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.states[workspace]
|
|
}
|
|
```
|
|
|
|
**Step 4: Normalize in `resolveWorkspace` returns**
|
|
|
|
In `core/engine.go`, at lines 5656 and 5681 where workspace paths are returned, wrap with normalization:
|
|
|
|
At line 5656:
|
|
```go
|
|
return normalizeWorkspacePath(b.Workspace), b.ChannelName, nil
|
|
```
|
|
|
|
At line 5681:
|
|
```go
|
|
normalized := normalizeWorkspacePath(candidate)
|
|
e.workspaceBindings.Bind(projectKey, channelID, channelName, normalized)
|
|
slog.Info("workspace auto-bound by convention",
|
|
"channel", channelName, "workspace", normalized)
|
|
return normalized, channelName, nil
|
|
```
|
|
|
|
**Step 5: Run tests**
|
|
|
|
Run: `go test ./core/ -run TestWorkspacePool -v`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add core/workspace_state.go core/workspace_state_test.go core/engine.go
|
|
git commit -m "feat: normalize workspace paths at pool and resolution entry points"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add token usage fields to Event and parse in session
|
|
|
|
**Files:**
|
|
- Modify: `core/message.go:87-98` — add `InputTokens`, `OutputTokens` to `Event`
|
|
- Modify: `agent/claudecode/session.go:268-282` — parse usage from result JSON
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Add to a new file or existing test for claudecode session parsing. Since `handleResult` is on the unexported `claudeSession`, test via the event channel:
|
|
|
|
Create `agent/claudecode/session_test.go` test (or add to existing):
|
|
|
|
```go
|
|
func TestHandleResultParsesUsage(t *testing.T) {
|
|
// Simulate a result event JSON with usage data
|
|
raw := map[string]any{
|
|
"type": "result",
|
|
"result": "test response",
|
|
"session_id": "sess-123",
|
|
"usage": map[string]any{
|
|
"input_tokens": float64(50000),
|
|
"output_tokens": float64(1500),
|
|
},
|
|
}
|
|
|
|
cs := &claudeSession{
|
|
events: make(chan core.Event, 1),
|
|
ctx: context.Background(),
|
|
done: make(chan struct{}),
|
|
}
|
|
cs.alive.Store(true)
|
|
|
|
cs.handleResult(raw)
|
|
|
|
evt := <-cs.events
|
|
if evt.InputTokens != 50000 {
|
|
t.Errorf("InputTokens = %d, want 50000", evt.InputTokens)
|
|
}
|
|
if evt.OutputTokens != 1500 {
|
|
t.Errorf("OutputTokens = %d, want 1500", evt.OutputTokens)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
|
|
Expected: FAIL — `InputTokens` field doesn't exist on Event
|
|
|
|
**Step 3: Add fields to Event**
|
|
|
|
In `core/message.go`, add to the `Event` struct:
|
|
|
|
```go
|
|
InputTokens int // populated for EventResult — total input tokens this turn
|
|
OutputTokens int // populated for EventResult — output tokens this turn
|
|
```
|
|
|
|
**Step 4: Parse usage in handleResult**
|
|
|
|
In `agent/claudecode/session.go`, modify `handleResult`:
|
|
|
|
```go
|
|
func (cs *claudeSession) handleResult(raw map[string]any) {
|
|
var content string
|
|
if result, ok := raw["result"].(string); ok {
|
|
content = result
|
|
}
|
|
if sid, ok := raw["session_id"].(string); ok && sid != "" {
|
|
cs.sessionID.Store(sid)
|
|
}
|
|
|
|
var inputTokens, outputTokens int
|
|
if usage, ok := raw["usage"].(map[string]any); ok {
|
|
if v, ok := usage["input_tokens"].(float64); ok {
|
|
inputTokens = int(v)
|
|
}
|
|
if v, ok := usage["output_tokens"].(float64); ok {
|
|
outputTokens = int(v)
|
|
}
|
|
}
|
|
|
|
evt := core.Event{
|
|
Type: core.EventResult,
|
|
Content: content,
|
|
SessionID: cs.CurrentSessionID(),
|
|
Done: true,
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
}
|
|
select {
|
|
case cs.events <- evt:
|
|
case <-cs.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add core/message.go agent/claudecode/session.go agent/claudecode/session_test.go
|
|
git commit -m "feat: parse token usage from Claude Code result events"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Track context percentage on interactiveState and append to messages
|
|
|
|
**Files:**
|
|
- Modify: `core/engine.go:192-202` — add `inputTokens` field to `interactiveState`
|
|
- Modify: `core/engine.go:1326-1352` — track tokens on EventResult
|
|
- Modify: `core/engine.go:1354+` — append `[ctx: XX%]` to relayed messages
|
|
|
|
**Step 1: Add field to interactiveState**
|
|
|
|
In `core/engine.go`, add to the `interactiveState` struct:
|
|
|
|
```go
|
|
type interactiveState struct {
|
|
// ... existing fields ...
|
|
inputTokens int // last known input_tokens from result event (context size proxy)
|
|
}
|
|
```
|
|
|
|
**Step 2: Update EventResult handler to track tokens and append indicator**
|
|
|
|
In `processInteractiveEvents`, in the `case EventResult:` block (around line 1326), after the existing session ID handling:
|
|
|
|
```go
|
|
case EventResult:
|
|
if event.SessionID != "" {
|
|
session.mu.Lock()
|
|
session.AgentSessionID = event.SessionID
|
|
session.mu.Unlock()
|
|
}
|
|
|
|
// Track context consumption
|
|
if event.InputTokens > 0 {
|
|
state.mu.Lock()
|
|
state.inputTokens = event.InputTokens
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
fullResponse := event.Content
|
|
if fullResponse == "" && len(textParts) > 0 {
|
|
fullResponse = strings.Join(textParts, "")
|
|
}
|
|
if fullResponse == "" {
|
|
fullResponse = e.i18n.T(MsgEmptyResponse)
|
|
}
|
|
|
|
// Append context indicator
|
|
if event.InputTokens > 0 {
|
|
pct := event.InputTokens * 100 / 200_000
|
|
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", pct)
|
|
}
|
|
|
|
// ... rest of existing EventResult handling
|
|
```
|
|
|
|
**Step 3: Also append indicator to intermediate messages (tool use, thinking)**
|
|
|
|
For every visible message sent during a turn, we need the indicator. The simplest approach: store the last known percentage on the state, and have a helper:
|
|
|
|
```go
|
|
func contextIndicator(inputTokens int) string {
|
|
if inputTokens <= 0 {
|
|
return ""
|
|
}
|
|
pct := inputTokens * 100 / 200_000
|
|
return fmt.Sprintf("\n[ctx: %d%%]", pct)
|
|
}
|
|
```
|
|
|
|
Append `contextIndicator(state.inputTokens)` to every `e.send()` call in the event loop for EventThinking, EventToolUse, and EventResult. Read `state.inputTokens` under the state mutex that's already being acquired.
|
|
|
|
**Step 4: Run existing tests**
|
|
|
|
Run: `go test ./core/ -v -count=1`
|
|
Expected: PASS (no test changes needed — this is additive to message content)
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add core/engine.go
|
|
git commit -m "feat: append context consumption indicator [ctx: XX%] to relayed messages"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add system prompt instruction for Claude self-reporting
|
|
|
|
**Files:**
|
|
- Modify: `core/interfaces.go:36-81` — append context self-report instruction to `AgentSystemPrompt()`
|
|
|
|
**Step 1: Add instruction to system prompt**
|
|
|
|
At the end of the `AgentSystemPrompt()` return string, before the closing backtick, add:
|
|
|
|
```go
|
|
## Context awareness
|
|
At the end of every message you send, append your estimate of your context window consumption as: [ctx: ~XX%]
|
|
This helps the user decide when to run /compact. Be honest — if you're unsure, estimate conservatively.
|
|
```
|
|
|
|
**Step 2: Run existing tests**
|
|
|
|
Run: `go test ./core/ -v -count=1`
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add core/interfaces.go
|
|
git commit -m "feat: instruct agent to self-report context usage for comparison logging"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add dual-track context logging
|
|
|
|
**Files:**
|
|
- Modify: `core/engine.go` — EventResult handler, add structured log with both values
|
|
|
|
**Step 1: Parse self-reported percentage from response**
|
|
|
|
Add helper in `core/engine.go`:
|
|
|
|
```go
|
|
import "regexp"
|
|
|
|
var ctxSelfReportRe = regexp.MustCompile(`\[ctx:\s*~?(\d+)%\]`)
|
|
|
|
// parseSelfReportedCtx extracts the self-reported context percentage from a response.
|
|
// Returns -1 if not found.
|
|
func parseSelfReportedCtx(response string) int {
|
|
m := ctxSelfReportRe.FindStringSubmatch(response)
|
|
if m == nil {
|
|
return -1
|
|
}
|
|
v, _ := strconv.Atoi(m[1])
|
|
return v
|
|
}
|
|
```
|
|
|
|
**Step 2: Write test for parser**
|
|
|
|
```go
|
|
func TestParseSelfReportedCtx(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want int
|
|
}{
|
|
{"some response\n[ctx: ~45%]", 45},
|
|
{"response [ctx: 80%]", 80},
|
|
{"no indicator", -1},
|
|
{"[ctx: ~100%] mid-text", 100},
|
|
}
|
|
for _, tt := range tests {
|
|
got := parseSelfReportedCtx(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("parseSelfReportedCtx(%q) = %d, want %d", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails, implement, verify pass**
|
|
|
|
Run: `go test ./core/ -run TestParseSelfReportedCtx -v`
|
|
|
|
**Step 4: Add structured logging in EventResult handler**
|
|
|
|
After computing the SDK percentage but before appending the indicator, add:
|
|
|
|
```go
|
|
if event.InputTokens > 0 {
|
|
sdkPct := event.InputTokens * 100 / 200_000
|
|
selfPct := parseSelfReportedCtx(fullResponse)
|
|
slog.Info("context_usage",
|
|
"session_key", sessionKey,
|
|
"sdk_pct", sdkPct,
|
|
"self_reported_pct", selfPct,
|
|
"input_tokens", event.InputTokens,
|
|
"output_tokens", event.OutputTokens,
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 5: Strip Claude's self-reported indicator before appending the real one**
|
|
|
|
So we don't show duplicate indicators, strip the self-reported one from the response before appending the SDK-based one:
|
|
|
|
```go
|
|
// Strip self-reported indicator (we replace it with the accurate SDK one)
|
|
fullResponse = ctxSelfReportRe.ReplaceAllString(fullResponse, "")
|
|
fullResponse = strings.TrimRight(fullResponse, "\n ")
|
|
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", sdkPct)
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add core/engine.go core/engine_test.go
|
|
git commit -m "feat: dual-track context usage logging (SDK vs self-reported)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Add diagnostic logging to session lifecycle
|
|
|
|
**Files:**
|
|
- Modify: `core/engine.go:1097-1119` — spawn/resume logging
|
|
- Modify: `core/engine.go:259-283` — reap logging
|
|
- Modify: `agent/claudecode/session.go:42-117` — spawn logging with JSONL path/size
|
|
- Modify: `agent/claudecode/claudecode.go:195-224` — log cwd at StartSession
|
|
|
|
**Step 1: Enhanced spawn logging in `getOrCreateInteractiveStateWith`**
|
|
|
|
Replace the existing log at line 1118 with:
|
|
|
|
```go
|
|
slog.Info("session spawned",
|
|
"session_key", sessionKey,
|
|
"agent_session", session.AgentSessionID,
|
|
"is_resume", session.AgentSessionID != "",
|
|
"elapsed", startElapsed,
|
|
)
|
|
```
|
|
|
|
**Step 2: Add JSONL file size logging in `claudecode.StartSession`**
|
|
|
|
In `agent/claudecode/claudecode.go`, in `StartSession`, before calling `newClaudeSession`, add:
|
|
|
|
```go
|
|
if sessionID != "" {
|
|
// Log session file details for diagnostics
|
|
homeDir, _ := os.UserHomeDir()
|
|
absWorkDir, _ := filepath.Abs(a.workDir)
|
|
if homeDir != "" {
|
|
projectDir := findProjectDir(homeDir, absWorkDir)
|
|
sessionFile := filepath.Join(projectDir, sessionID+".jsonl")
|
|
if info, err := os.Stat(sessionFile); err == nil {
|
|
slog.Info("session resume attempt",
|
|
"session_id", sessionID,
|
|
"jsonl_path", sessionFile,
|
|
"jsonl_size_bytes", info.Size(),
|
|
"work_dir", absWorkDir,
|
|
)
|
|
} else {
|
|
slog.Warn("session file not found for resume",
|
|
"session_id", sessionID,
|
|
"expected_path", sessionFile,
|
|
"work_dir", absWorkDir,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Enhanced reap logging in `runIdleReaper`**
|
|
|
|
In `core/engine.go`, in the reap loop (around line 271), add idle duration:
|
|
|
|
```go
|
|
reaped := e.workspacePool.ReapIdle()
|
|
for _, ws := range reaped {
|
|
e.interactiveMu.Lock()
|
|
for key, state := range e.interactiveStates {
|
|
if state.workspaceDir == ws {
|
|
state.mu.Lock()
|
|
tokenCount := state.inputTokens
|
|
state.mu.Unlock()
|
|
slog.Info("session idle-reaped",
|
|
"session_key", key,
|
|
"workspace", ws,
|
|
"last_ctx_pct", tokenCount*100/200_000,
|
|
"input_tokens", tokenCount,
|
|
)
|
|
if state.agentSession != nil {
|
|
state.agentSession.Close()
|
|
}
|
|
delete(e.interactiveStates, key)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Log stderr on session process failure**
|
|
|
|
In `agent/claudecode/session.go`, the `readLoop` already logs stderr on failure (line 125). Enhance it:
|
|
|
|
```go
|
|
slog.Error("claudeSession: process failed",
|
|
"error", err,
|
|
"stderr", stderrMsg,
|
|
"work_dir", cs.workDir,
|
|
"session_id", cs.CurrentSessionID(),
|
|
)
|
|
```
|
|
|
|
**Step 5: Run all tests**
|
|
|
|
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add core/engine.go agent/claudecode/session.go agent/claudecode/claudecode.go
|
|
git commit -m "feat: add diagnostic logging for session spawn, resume, reap, and failure"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Resume failure fallback with user notification
|
|
|
|
**Files:**
|
|
- Modify: `core/engine.go:1097-1105` — retry logic in `getOrCreateInteractiveStateWith`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Add to `core/engine_test.go`:
|
|
|
|
```go
|
|
func TestResumeFailureFallsBackToFreshSession(t *testing.T) {
|
|
callCount := 0
|
|
agent := &stubAgent{
|
|
startSessionFunc: func(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
|
callCount++
|
|
if sessionID != "" {
|
|
// Simulate resume failure
|
|
return nil, fmt.Errorf("Prompt is too long")
|
|
}
|
|
// Fresh session succeeds
|
|
return &stubAgentSession{alive: true}, nil
|
|
},
|
|
}
|
|
|
|
e := newTestEngine(t)
|
|
e.agent = agent
|
|
|
|
session := e.sessions.GetOrCreateActive("test:chan:user")
|
|
session.AgentSessionID = "old-session-id"
|
|
|
|
p := &stubPlatform{}
|
|
state := e.getOrCreateInteractiveState("test:chan:user", p, nil, session)
|
|
|
|
if state.agentSession == nil {
|
|
t.Fatal("expected agentSession to be non-nil after fallback")
|
|
}
|
|
if callCount != 2 {
|
|
t.Errorf("expected 2 StartSession calls (resume + fresh), got %d", callCount)
|
|
}
|
|
if session.AgentSessionID != "" {
|
|
t.Errorf("expected AgentSessionID cleared, got %q", session.AgentSessionID)
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: This test may need adjustment to match the actual test helpers in the codebase. Check `core/engine_test.go` for the existing `stubAgent` and `newTestEngine` patterns and adapt accordingly.
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
|
|
Expected: FAIL — current code doesn't retry
|
|
|
|
**Step 3: Implement retry logic**
|
|
|
|
Replace the error handling block in `getOrCreateInteractiveStateWith` (lines 1100-1104):
|
|
|
|
```go
|
|
startAt := time.Now()
|
|
agentSession, err := agent.StartSession(e.ctx, session.AgentSessionID)
|
|
startElapsed := time.Since(startAt)
|
|
if err != nil {
|
|
if session.AgentSessionID != "" {
|
|
// Resume failed — log diagnostics and retry with fresh session
|
|
slog.Error("session resume failed, falling back to fresh session",
|
|
"session_key", sessionKey,
|
|
"failed_session_id", session.AgentSessionID,
|
|
"error", err,
|
|
"elapsed", startElapsed,
|
|
)
|
|
|
|
// Clear the stale session ID
|
|
session.mu.Lock()
|
|
session.AgentSessionID = ""
|
|
session.mu.Unlock()
|
|
|
|
// Notify user
|
|
if p != nil {
|
|
go func() {
|
|
_ = p.Send(context.Background(), replyCtx,
|
|
"⚠️ Session context was too large to resume — starting fresh. Project context is preserved in CLAUDE.md.")
|
|
}()
|
|
}
|
|
|
|
// Retry with fresh session
|
|
freshStart := time.Now()
|
|
agentSession, err = agent.StartSession(e.ctx, "")
|
|
freshElapsed := time.Since(freshStart)
|
|
if err != nil {
|
|
slog.Error("fresh session also failed",
|
|
"session_key", sessionKey,
|
|
"error", err,
|
|
"elapsed", freshElapsed,
|
|
)
|
|
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
|
|
e.interactiveStates[sessionKey] = state
|
|
return state
|
|
}
|
|
slog.Info("fresh session started after resume failure",
|
|
"session_key", sessionKey,
|
|
"elapsed", freshElapsed,
|
|
)
|
|
} else {
|
|
slog.Error("failed to start interactive session",
|
|
"session_key", sessionKey,
|
|
"error", err,
|
|
"elapsed", startElapsed,
|
|
)
|
|
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
|
|
e.interactiveStates[sessionKey] = state
|
|
return state
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Run full test suite**
|
|
|
|
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add core/engine.go core/engine_test.go
|
|
git commit -m "feat: auto-recover from resume failure with fresh session and user notification"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Final integration verification
|
|
|
|
**Files:** None — verification only
|
|
|
|
**Step 1: Run full test suite**
|
|
|
|
Run: `go test ./... -count=1`
|
|
Expected: PASS
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `go build ./...`
|
|
Expected: no errors
|
|
|
|
**Step 3: Review all changes**
|
|
|
|
Run: `git log --oneline main..HEAD`
|
|
|
|
Verify the commit sequence matches the plan:
|
|
1. `normalizeWorkspacePath` helper
|
|
2. Apply normalization at entry points
|
|
3. Token usage fields + parsing
|
|
4. Context indicator on messages
|
|
5. System prompt self-report instruction
|
|
6. Dual-track logging
|
|
7. Diagnostic lifecycle logging
|
|
8. Resume failure fallback
|
|
|
|
**Step 4: Final commit (if any fixups needed)**
|
|
|
|
```bash
|
|
git add -A && git commit -m "fix: address issues found during integration verification"
|
|
```
|