初始化仓库
This commit is contained in:
@@ -0,0 +1,793 @@
|
||||
# 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"
|
||||
```
|
||||
Reference in New Issue
Block a user