feat: add git worktree isolation for per-session plan/heartbeat execution
Each session (user plan, heartbeat) gets an independent git worktree, preventing concurrent sessions from conflicting on the same working tree. Key changes: - pkg/git/worktree.go: Git worktree CRUD with safe disposal (auto-commit before removing, branch retained if it has unique commits) - pkg/tools/workspace_ctx.go: Context-based filesystem redirect that transparently routes file/edit/shell tools to the session's worktree while keeping memory/ paths on the original workspace - Per-session worktree state on AgentInstance with activate/deactivate - SessionTracker extended with project coordination (purpose, branch) and GetPeerPurposes for lightweight cross-session awareness - System prompt injection of peer session info to avoid conflicts - Loop integration: worktree activation on /plan start, lazy creation for heartbeat on first write-tool, safe disposal on plan completion with merge instructions, cleanup after heartbeat - Shell guard: block git checkout/switch to prevent branch escapes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
615a1fabc5
commit
53c4398487
14 changed files with 845 additions and 36 deletions
|
|
@ -381,12 +381,15 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
||||||
result := make([]miniapp.SessionInfo, len(entries))
|
result := make([]miniapp.SessionInfo, len(entries))
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
result[i] = miniapp.SessionInfo{
|
result[i] = miniapp.SessionInfo{
|
||||||
SessionKey: e.SessionKey,
|
SessionKey: e.SessionKey,
|
||||||
Channel: e.Channel,
|
Channel: e.Channel,
|
||||||
ChatID: e.ChatID,
|
ChatID: e.ChatID,
|
||||||
TouchDir: e.TouchDir,
|
TouchDir: e.TouchDir,
|
||||||
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
|
ProjectPath: e.ProjectPath,
|
||||||
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
|
Purpose: e.Purpose,
|
||||||
|
Branch: e.Branch,
|
||||||
|
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
|
||||||
|
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type ContextBuilder struct {
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||||
|
peerNote string // set per-call from loop.go for peer session awareness
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
|
@ -48,6 +49,11 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||||
cb.tools = registry
|
cb.tools = registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPeerNote sets the peer session awareness note for the current call.
|
||||||
|
func (cb *ContextBuilder) SetPeerNote(note string) {
|
||||||
|
cb.peerNote = note
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
|
|
@ -169,6 +175,11 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Peer session coordination
|
||||||
|
if cb.peerNote != "" {
|
||||||
|
parts = append(parts, "## Active Sessions\n\n"+cb.peerNote)
|
||||||
|
}
|
||||||
|
|
||||||
// Memory context
|
// Memory context
|
||||||
memoryContext := cb.memory.GetMemoryContext()
|
memoryContext := cb.memory.GetMemoryContext()
|
||||||
if memoryContext != "" {
|
if memoryContext != "" {
|
||||||
|
|
@ -449,6 +460,11 @@ func (cb *ContextBuilder) GetPlanWorkDir() string {
|
||||||
return cb.memory.GetPlanWorkDir()
|
return cb.memory.GetPlanWorkDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPlanTaskName returns the task description from the plan metadata, or "".
|
||||||
|
func (cb *ContextBuilder) GetPlanTaskName() string {
|
||||||
|
return cb.memory.GetPlanTaskName()
|
||||||
|
}
|
||||||
|
|
||||||
// GetSkillsInfo returns information about loaded skills.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/git"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
|
@ -39,6 +42,10 @@ type AgentInstance struct {
|
||||||
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
|
||||||
interviewStaleCount int
|
interviewStaleCount int
|
||||||
interviewMemoryLen int
|
interviewMemoryLen int
|
||||||
|
|
||||||
|
// Per-session worktree isolation
|
||||||
|
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
|
||||||
|
worktreeMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -123,6 +130,12 @@ func NewAgentInstance(
|
||||||
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Startup cleanup: prune orphaned worktrees
|
||||||
|
worktreesDir := filepath.Join(workspace, ".picoclaw", "worktrees")
|
||||||
|
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
|
||||||
|
git.PruneOrphaned(repoRoot, worktreesDir)
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
|
|
@ -192,6 +205,88 @@ func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDe
|
||||||
return defaults.PlanModelFallbacks
|
return defaults.PlanModelFallbacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivateWorktree creates a worktree for a session.
|
||||||
|
// Path: <workspace>/.picoclaw/worktrees/<branch-basename>/
|
||||||
|
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName string) (*git.WorktreeInfo, error) {
|
||||||
|
repoRoot := git.FindRepoRoot(ai.Workspace)
|
||||||
|
if repoRoot == "" {
|
||||||
|
return nil, fmt.Errorf("workspace is not a git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
branchName := git.SanitizeBranchName(taskName)
|
||||||
|
baseName := git.BranchBaseName(branchName)
|
||||||
|
wtPath := filepath.Join(ai.Workspace, ".picoclaw", "worktrees", baseName)
|
||||||
|
|
||||||
|
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ai.worktreeMu.Lock()
|
||||||
|
if ai.worktrees == nil {
|
||||||
|
ai.worktrees = make(map[string]*git.WorktreeInfo)
|
||||||
|
}
|
||||||
|
ai.worktrees[sessionKey] = wt
|
||||||
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
|
return wt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateWorktree safe-disposes the session's worktree.
|
||||||
|
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
|
||||||
|
ai.worktreeMu.Lock()
|
||||||
|
wt, ok := ai.worktrees[sessionKey]
|
||||||
|
if ok {
|
||||||
|
delete(ai.worktrees, sessionKey)
|
||||||
|
}
|
||||||
|
ai.worktreeMu.Unlock()
|
||||||
|
|
||||||
|
if !ok || wt == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot := git.FindRepoRoot(ai.Workspace)
|
||||||
|
if repoRoot == "" {
|
||||||
|
return nil, fmt.Errorf("workspace is not a git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even on discard, SafeDispose auto-commits first for safety
|
||||||
|
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
|
||||||
|
_ = git.AutoCommit(wt.Path, commitMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := git.SafeDispose(repoRoot, wt)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorktree returns the session's active worktree, or nil.
|
||||||
|
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
|
||||||
|
ai.worktreeMu.RLock()
|
||||||
|
defer ai.worktreeMu.RUnlock()
|
||||||
|
return ai.worktrees[sessionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInWorktree returns true if the session has an active worktree.
|
||||||
|
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
|
||||||
|
return ai.GetWorktree(sessionKey) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveWorkspace returns worktree path for session, or original Workspace.
|
||||||
|
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
|
||||||
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
|
return wt.Path
|
||||||
|
}
|
||||||
|
return ai.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
|
||||||
|
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
|
||||||
|
if wt := ai.GetWorktree(sessionKey); wt != nil {
|
||||||
|
return wt.Branch
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,19 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activate worktree for the session's plan execution
|
||||||
|
if agent := al.registry.GetDefaultAgent(); agent != nil {
|
||||||
|
taskName := agent.ContextBuilder.Memory().GetPlanTaskName()
|
||||||
|
if taskName == "" {
|
||||||
|
taskName = "plan-execution"
|
||||||
|
}
|
||||||
|
if wt, err := agent.ActivateWorktree(msg.SessionKey, taskName); err != nil {
|
||||||
|
logger.WarnCF("agent", "Worktree activation skipped", map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("agent", "Worktree activated", map[string]any{"branch": wt.Branch})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
syntheticMeta := map[string]string{"echoed": "1"}
|
syntheticMeta := map[string]string{"echoed": "1"}
|
||||||
for k, v := range msg.Metadata {
|
for k, v := range msg.Metadata {
|
||||||
if k != "source" {
|
if k != "source" {
|
||||||
|
|
@ -768,6 +781,24 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
// 1. Update tool contexts
|
// 1. Update tool contexts
|
||||||
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
||||||
|
|
||||||
|
// 1b. Inject peer session awareness into system prompt
|
||||||
|
projectPath := agent.ContextBuilder.GetPlanWorkDir()
|
||||||
|
if projectPath == "" {
|
||||||
|
projectPath = agent.Workspace
|
||||||
|
}
|
||||||
|
peers := al.sessions.GetPeerPurposes(opts.SessionKey, projectPath)
|
||||||
|
if len(peers) > 0 {
|
||||||
|
var peerNote strings.Builder
|
||||||
|
peerNote.WriteString("Other sessions working on this project:\n")
|
||||||
|
for _, p := range peers {
|
||||||
|
peerNote.WriteString(fmt.Sprintf("- %s: %s (branch: %s)\n", p.SessionKey, p.Purpose, p.Branch))
|
||||||
|
}
|
||||||
|
peerNote.WriteString("\nAvoid conflicting changes with these sessions.")
|
||||||
|
agent.ContextBuilder.SetPeerNote(peerNote.String())
|
||||||
|
} else {
|
||||||
|
agent.ContextBuilder.SetPeerNote("")
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Build messages (skip history for heartbeat)
|
// 2. Build messages (skip history for heartbeat)
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
|
|
@ -889,11 +920,21 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
_ = agent.ContextBuilder.SetCurrentPhase(total)
|
_ = agent.ContextBuilder.SetCurrentPhase(total)
|
||||||
if preStatus != "completed" {
|
if preStatus != "completed" {
|
||||||
_ = agent.ContextBuilder.SetPlanStatus("completed")
|
_ = agent.ContextBuilder.SetPlanStatus("completed")
|
||||||
|
|
||||||
|
// Deactivate worktree on plan completion
|
||||||
|
commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName()
|
||||||
|
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
|
||||||
|
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
|
msg := "\u2705 Plan completed!"
|
||||||
|
if wtResult != nil && wtResult.CommitsAhead > 0 {
|
||||||
|
msg += fmt.Sprintf("\nBranch `%s` retained (%d commits). To merge: `git merge %s`",
|
||||||
|
wtResult.Branch, wtResult.CommitsAhead, wtResult.Branch)
|
||||||
|
}
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: "\u2705 Plan completed!",
|
Content: msg,
|
||||||
SkipPlaceholder: true,
|
SkipPlaceholder: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -962,6 +1003,20 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
"final_length": len(finalContent),
|
"final_length": len(finalContent),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 10. Heartbeat worktree cleanup: auto-commit and dispose after background task
|
||||||
|
if opts.Background && agent.IsInWorktree(opts.SessionKey) {
|
||||||
|
commitMsg := "heartbeat: auto-save"
|
||||||
|
wtResult, _ := agent.DeactivateWorktree(opts.SessionKey, commitMsg, false)
|
||||||
|
if wtResult != nil && wtResult.CommitsAhead > 0 && !constants.IsInternalChannel(opts.Channel) {
|
||||||
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Content: fmt.Sprintf("Heartbeat made code changes on branch `%s` (%d commits).",
|
||||||
|
wtResult.Branch, wtResult.CommitsAhead),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return finalContent, nil
|
return finalContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1950,7 +2005,15 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if detectedDir != "" {
|
if detectedDir != "" {
|
||||||
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir)
|
meta := &TouchMeta{
|
||||||
|
ProjectPath: agent.ContextBuilder.GetPlanWorkDir(),
|
||||||
|
Purpose: utils.Truncate(opts.UserMessage, 80),
|
||||||
|
Branch: agent.GetWorktreeBranch(opts.SessionKey),
|
||||||
|
}
|
||||||
|
if meta.ProjectPath == "" {
|
||||||
|
meta.ProjectPath = agent.Workspace
|
||||||
|
}
|
||||||
|
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1998,6 +2061,14 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Heartbeat lazy worktree: create worktree on first write-tool call
|
||||||
|
if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) {
|
||||||
|
taskName := "heartbeat-" + time.Now().Format("20060102")
|
||||||
|
if wt, err := agent.ActivateWorktree(opts.SessionKey, taskName); err == nil {
|
||||||
|
logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncTool
|
// Create async callback for tools that implement AsyncTool
|
||||||
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
|
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
|
||||||
// Instead, they notify the agent via PublishInbound, and the agent decides
|
// Instead, they notify the agent via PublishInbound, and the agent decides
|
||||||
|
|
@ -2015,7 +2086,11 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
toolStart := time.Now()
|
toolStart := time.Now()
|
||||||
toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
toolCtx := ctx
|
||||||
|
if wt := agent.GetWorktree(opts.SessionKey); wt != nil {
|
||||||
|
toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path)
|
||||||
|
}
|
||||||
|
toolResult := agent.Tools.ExecuteWithContext(toolCtx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
||||||
toolDuration := time.Since(toolStart)
|
toolDuration := time.Since(toolStart)
|
||||||
|
|
||||||
// Update tool log entry with result
|
// Update tool log entry with result
|
||||||
|
|
@ -2619,7 +2694,7 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
return al.handleSkillsCommand(), true
|
return al.handleSkillsCommand(), true
|
||||||
|
|
||||||
case "/plan":
|
case "/plan":
|
||||||
resp, handled := al.handlePlanCommand(args)
|
resp, handled := al.handlePlanCommand(args, msg.SessionKey)
|
||||||
if handled {
|
if handled {
|
||||||
al.notifyStateChange()
|
al.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
@ -2739,7 +2814,7 @@ func (al *AgentLoop) handleSkillsCommand() string {
|
||||||
// Returns (response, handled). For "/plan <task>" (new plan), it returns
|
// Returns (response, handled). For "/plan <task>" (new plan), it returns
|
||||||
// ("", false) so the message falls through to the LLM queue, where
|
// ("", false) so the message falls through to the LLM queue, where
|
||||||
// expandPlanCommand writes the seed and rewrites the content.
|
// expandPlanCommand writes the seed and rewrites the content.
|
||||||
func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
|
func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) {
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
if agent == nil {
|
if agent == nil {
|
||||||
return "No agent configured.", true
|
return "No agent configured.", true
|
||||||
|
|
@ -2756,6 +2831,10 @@ func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
|
||||||
if agent.ContextBuilder.ReadMemory() == "" {
|
if agent.ContextBuilder.ReadMemory() == "" {
|
||||||
return "No active plan to clear.", true
|
return "No active plan to clear.", true
|
||||||
}
|
}
|
||||||
|
// Deactivate worktree on plan clear
|
||||||
|
if sessionKey != "" {
|
||||||
|
agent.DeactivateWorktree(sessionKey, "", true)
|
||||||
|
}
|
||||||
if err := agent.ContextBuilder.ClearMemory(); err != nil {
|
if err := agent.ContextBuilder.ClearMemory(); err != nil {
|
||||||
return fmt.Sprintf("Error clearing plan: %v", err), true
|
return fmt.Sprintf("Error clearing plan: %v", err), true
|
||||||
}
|
}
|
||||||
|
|
@ -2945,6 +3024,15 @@ func isReadOnlyCommand(cmd string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWriteTool returns true if the tool can modify files.
|
||||||
|
func isWriteTool(name string) bool {
|
||||||
|
switch tools.NormalizeToolName(name) {
|
||||||
|
case "writefile", "editfile", "appendfile", "exec":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// expandPlanCommand detects "/plan <task>" (new plan start) and:
|
// expandPlanCommand detects "/plan <task>" (new plan start) and:
|
||||||
// - writes the interview seed to MEMORY.md
|
// - writes the interview seed to MEMORY.md
|
||||||
// - rewrites the message content for the LLM
|
// - rewrites the message content for the LLM
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,19 @@ func (ms *MemoryStore) GetPlanWorkDir() string {
|
||||||
return strings.TrimSpace(m[1])
|
return strings.TrimSpace(m[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reTaskLine extracts the task name from "> Task: <description>".
|
||||||
|
var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`)
|
||||||
|
|
||||||
|
// GetPlanTaskName returns the task description from the plan metadata, or "".
|
||||||
|
func (ms *MemoryStore) GetPlanTaskName() string {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
m := reTaskLine.FindStringSubmatch(content)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
|
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
|
||||||
const interviewSeedTemplate = `# Active Plan
|
const interviewSeedTemplate = `# Active Plan
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,28 @@ import (
|
||||||
|
|
||||||
// SessionEntry represents an active or recently-active session.
|
// SessionEntry represents an active or recently-active session.
|
||||||
type SessionEntry struct {
|
type SessionEntry struct {
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
TouchDir string `json:"touch_dir"`
|
TouchDir string `json:"touch_dir"`
|
||||||
LastSeenAt time.Time `json:"last_seen_at"`
|
ProjectPath string `json:"project_path,omitempty"` // canonical project path
|
||||||
|
Purpose string `json:"purpose,omitempty"` // 1-line task description
|
||||||
|
Branch string `json:"branch,omitempty"` // git branch name
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchMeta carries optional metadata for Touch calls.
|
||||||
|
type TouchMeta struct {
|
||||||
|
ProjectPath string // canonical project path (always original workspace-relative)
|
||||||
|
Purpose string // 1-line task description
|
||||||
|
Branch string // git branch name
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerInfo is the minimal info shared between sessions on the same project.
|
||||||
|
type PeerInfo struct {
|
||||||
|
SessionKey string
|
||||||
|
Purpose string
|
||||||
|
Branch string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionTracker tracks per-session tool-call activity.
|
// SessionTracker tracks per-session tool-call activity.
|
||||||
|
|
@ -32,7 +49,8 @@ const sessionActivityTimeout = 15 * time.Minute
|
||||||
// Touch records a tool-call activity for a session.
|
// Touch records a tool-call activity for a session.
|
||||||
// dir is the workspace-relative directory the tool call targeted.
|
// dir is the workspace-relative directory the tool call targeted.
|
||||||
// If dir is empty, only LastSeenAt is updated.
|
// If dir is empty, only LastSeenAt is updated.
|
||||||
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) {
|
// meta is optional and carries project coordination metadata.
|
||||||
|
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string, meta *TouchMeta) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
val, loaded := st.entries.Load(sessionKey)
|
val, loaded := st.entries.Load(sessionKey)
|
||||||
if loaded {
|
if loaded {
|
||||||
|
|
@ -47,15 +65,32 @@ func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) {
|
||||||
if chatID != "" {
|
if chatID != "" {
|
||||||
entry.ChatID = chatID
|
entry.ChatID = chatID
|
||||||
}
|
}
|
||||||
|
if meta != nil {
|
||||||
|
if meta.ProjectPath != "" {
|
||||||
|
entry.ProjectPath = meta.ProjectPath
|
||||||
|
}
|
||||||
|
if meta.Purpose != "" {
|
||||||
|
entry.Purpose = meta.Purpose
|
||||||
|
}
|
||||||
|
if meta.Branch != "" {
|
||||||
|
entry.Branch = meta.Branch
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
st.entries.Store(sessionKey, &SessionEntry{
|
entry := &SessionEntry{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
TouchDir: dir,
|
TouchDir: dir,
|
||||||
LastSeenAt: now,
|
LastSeenAt: now,
|
||||||
})
|
}
|
||||||
|
if meta != nil {
|
||||||
|
entry.ProjectPath = meta.ProjectPath
|
||||||
|
entry.Purpose = meta.Purpose
|
||||||
|
entry.Branch = meta.Branch
|
||||||
|
}
|
||||||
|
st.entries.Store(sessionKey, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsActiveInDir returns true if any session (excluding those matching excludeKey)
|
// IsActiveInDir returns true if any session (excluding those matching excludeKey)
|
||||||
|
|
@ -96,3 +131,28 @@ func (st *SessionTracker) ListActive() []SessionEntry {
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPeerPurposes returns purposes of other active sessions targeting the same project.
|
||||||
|
// Used for lightweight coordination without context pollution.
|
||||||
|
func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo {
|
||||||
|
if projectPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||||
|
var result []PeerInfo
|
||||||
|
st.entries.Range(func(key, val any) bool {
|
||||||
|
if key.(string) == sessionKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
if entry.LastSeenAt.After(cutoff) && entry.ProjectPath == projectPath {
|
||||||
|
result = append(result, PeerInfo{
|
||||||
|
SessionKey: entry.SessionKey,
|
||||||
|
Purpose: entry.Purpose,
|
||||||
|
Branch: entry.Branch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ func TestTouch(t *testing.T) {
|
||||||
st := NewSessionTracker()
|
st := NewSessionTracker()
|
||||||
|
|
||||||
// Basic touch creates entry
|
// Basic touch creates entry
|
||||||
st.Touch("sess1", "telegram", "123", "projects/myapp")
|
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
|
||||||
entries := st.ListActive()
|
entries := st.ListActive()
|
||||||
if len(entries) != 1 {
|
if len(entries) != 1 {
|
||||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
|
@ -25,7 +25,7 @@ func TestTouch(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch again with new dir overwrites TouchDir
|
// Touch again with new dir overwrites TouchDir
|
||||||
st.Touch("sess1", "", "", "projects/other")
|
st.Touch("sess1", "", "", "projects/other", nil)
|
||||||
entries = st.ListActive()
|
entries = st.ListActive()
|
||||||
if len(entries) != 1 {
|
if len(entries) != 1 {
|
||||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
|
@ -39,7 +39,7 @@ func TestTouch(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch with empty dir does not overwrite TouchDir
|
// Touch with empty dir does not overwrite TouchDir
|
||||||
st.Touch("sess1", "", "", "")
|
st.Touch("sess1", "", "", "", nil)
|
||||||
entries = st.ListActive()
|
entries = st.ListActive()
|
||||||
if entries[0].TouchDir != "projects/other" {
|
if entries[0].TouchDir != "projects/other" {
|
||||||
t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir)
|
t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir)
|
||||||
|
|
@ -50,7 +50,7 @@ func TestIsActiveInDir(t *testing.T) {
|
||||||
st := NewSessionTracker()
|
st := NewSessionTracker()
|
||||||
|
|
||||||
// Setup: sess1 touches "projects/myapp"
|
// Setup: sess1 touches "projects/myapp"
|
||||||
st.Touch("sess1", "telegram", "123", "projects/myapp")
|
st.Touch("sess1", "telegram", "123", "projects/myapp", nil)
|
||||||
|
|
||||||
// Same dir, excluding sess1 → false
|
// Same dir, excluding sess1 → false
|
||||||
if st.IsActiveInDir("projects/myapp", "sess1") {
|
if st.IsActiveInDir("projects/myapp", "sess1") {
|
||||||
|
|
@ -91,9 +91,9 @@ func TestListActive(t *testing.T) {
|
||||||
st := NewSessionTracker()
|
st := NewSessionTracker()
|
||||||
|
|
||||||
// Add two sessions
|
// Add two sessions
|
||||||
st.Touch("sess1", "telegram", "123", "projects/a")
|
st.Touch("sess1", "telegram", "123", "projects/a", nil)
|
||||||
time.Sleep(5 * time.Millisecond) // ensure different timestamps
|
time.Sleep(5 * time.Millisecond) // ensure different timestamps
|
||||||
st.Touch("sess2", "discord", "456", "projects/b")
|
st.Touch("sess2", "discord", "456", "projects/b", nil)
|
||||||
|
|
||||||
entries := st.ListActive()
|
entries := st.ListActive()
|
||||||
if len(entries) != 2 {
|
if len(entries) != 2 {
|
||||||
|
|
|
||||||
241
pkg/git/worktree.go
Normal file
241
pkg/git/worktree.go
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorktreeInfo describes an active git worktree.
|
||||||
|
type WorktreeInfo struct {
|
||||||
|
Path string // absolute worktree dir
|
||||||
|
Branch string // e.g. "plan/setup-monitoring"
|
||||||
|
BaseBranch string // branch forked from
|
||||||
|
RepoRoot string // main repo root
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisposeResult describes what happened when a worktree was disposed.
|
||||||
|
type DisposeResult struct {
|
||||||
|
Branch string
|
||||||
|
AutoCommitted bool // true if uncommitted changes were saved
|
||||||
|
BranchDeleted bool // true if branch had no unique commits
|
||||||
|
CommitsAhead int // unique commits on branch (0 = safe to delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRepoRoot returns the git repository root for dir, or "" if not a git repo.
|
||||||
|
func FindRepoRoot(dir string) string {
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentBranch returns the current branch name, or "" on error.
|
||||||
|
func CurrentBranch(dir string) string {
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
var unsafeBranchRe = regexp.MustCompile(`[^a-z0-9-]`)
|
||||||
|
|
||||||
|
// SanitizeBranchName creates a safe branch name from a task description.
|
||||||
|
// Returns "plan/<safe-40-chars>".
|
||||||
|
func SanitizeBranchName(task string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(task))
|
||||||
|
s = unsafeBranchRe.ReplaceAllString(s, "-")
|
||||||
|
|
||||||
|
// Collapse consecutive hyphens
|
||||||
|
for strings.Contains(s, "--") {
|
||||||
|
s = strings.ReplaceAll(s, "--", "-")
|
||||||
|
}
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
|
||||||
|
if s == "" {
|
||||||
|
s = "worktree"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to 40 chars
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > 40 {
|
||||||
|
runes = runes[:40]
|
||||||
|
}
|
||||||
|
s = strings.TrimRight(string(runes), "-")
|
||||||
|
|
||||||
|
return "plan/" + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorktree creates a new git worktree at worktreePath with branchName.
|
||||||
|
// If the branch already exists, it reuses it.
|
||||||
|
func CreateWorktree(repoDir, worktreePath, branchName string) (*WorktreeInfo, error) {
|
||||||
|
baseBranch := CurrentBranch(repoDir)
|
||||||
|
if baseBranch == "" {
|
||||||
|
baseBranch = "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create worktree parent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if branch already exists
|
||||||
|
checkCmd := exec.Command("git", "rev-parse", "--verify", branchName)
|
||||||
|
checkCmd.Dir = repoDir
|
||||||
|
branchExists := checkCmd.Run() == nil
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if branchExists {
|
||||||
|
// Reuse existing branch
|
||||||
|
cmd = exec.Command("git", "worktree", "add", worktreePath, branchName)
|
||||||
|
} else {
|
||||||
|
// Create new branch
|
||||||
|
cmd = exec.Command("git", "worktree", "add", "-b", branchName, worktreePath)
|
||||||
|
}
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WorktreeInfo{
|
||||||
|
Path: worktreePath,
|
||||||
|
Branch: branchName,
|
||||||
|
BaseBranch: baseBranch,
|
||||||
|
RepoRoot: repoDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUncommittedChanges returns true if the working tree has staged or unstaged changes.
|
||||||
|
func HasUncommittedChanges(dir string) bool {
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(strings.TrimSpace(string(out))) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoCommit stages all changes and commits with the given message.
|
||||||
|
func AutoCommit(worktreePath, message string) error {
|
||||||
|
addCmd := exec.Command("git", "add", "-A")
|
||||||
|
addCmd.Dir = worktreePath
|
||||||
|
if out, err := addCmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("git add: %s: %w", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commitCmd := exec.Command("git", "commit", "-m", message, "--allow-empty-message")
|
||||||
|
commitCmd.Dir = worktreePath
|
||||||
|
if out, err := commitCmd.CombinedOutput(); err != nil {
|
||||||
|
// "nothing to commit" is not a real error
|
||||||
|
if strings.Contains(string(out), "nothing to commit") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("git commit: %s: %w", strings.TrimSpace(string(out)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitsAhead returns the number of commits on branch that are not on base.
|
||||||
|
func CommitsAhead(repoDir, base, branch string) int {
|
||||||
|
cmd := exec.Command("git", "rev-list", "--count", base+".."+branch)
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeDispose auto-commits uncommitted changes, removes the worktree directory,
|
||||||
|
// and deletes the branch ONLY if it has no unique commits.
|
||||||
|
func SafeDispose(repoDir string, wt *WorktreeInfo) DisposeResult {
|
||||||
|
result := DisposeResult{Branch: wt.Branch}
|
||||||
|
|
||||||
|
// 1. Auto-commit if there are uncommitted changes
|
||||||
|
if HasUncommittedChanges(wt.Path) {
|
||||||
|
msg := fmt.Sprintf("auto: save from %s", wt.Branch)
|
||||||
|
if err := AutoCommit(wt.Path, msg); err == nil {
|
||||||
|
result.AutoCommitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Count unique commits
|
||||||
|
result.CommitsAhead = CommitsAhead(repoDir, wt.BaseBranch, wt.Branch)
|
||||||
|
|
||||||
|
// 3. Remove worktree
|
||||||
|
removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.Path)
|
||||||
|
removeCmd.Dir = repoDir
|
||||||
|
removeCmd.Run() // best-effort
|
||||||
|
|
||||||
|
// 4. Delete branch if no unique commits
|
||||||
|
if result.CommitsAhead == 0 {
|
||||||
|
delCmd := exec.Command("git", "branch", "-D", wt.Branch)
|
||||||
|
delCmd.Dir = repoDir
|
||||||
|
if delCmd.Run() == nil {
|
||||||
|
result.BranchDeleted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback cleanup
|
||||||
|
os.RemoveAll(wt.Path)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneOrphaned runs git worktree prune and removes dirs in worktreesDir
|
||||||
|
// that aren't valid git worktrees.
|
||||||
|
func PruneOrphaned(repoDir, worktreesDir string) {
|
||||||
|
pruneCmd := exec.Command("git", "worktree", "prune")
|
||||||
|
pruneCmd.Dir = repoDir
|
||||||
|
pruneCmd.Run() // best-effort
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(worktreesDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wtPath := filepath.Join(worktreesDir, entry.Name())
|
||||||
|
// Check if it's still a valid git worktree
|
||||||
|
checkCmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||||
|
checkCmd.Dir = wtPath
|
||||||
|
if err := checkCmd.Run(); err != nil {
|
||||||
|
// Not a valid git worktree — remove
|
||||||
|
os.RemoveAll(wtPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchBaseName extracts the last segment of a branch name.
|
||||||
|
// "plan/add-auth" → "plan-add-auth"
|
||||||
|
func BranchBaseName(branch string) string {
|
||||||
|
s := strings.ReplaceAll(branch, "/", "-")
|
||||||
|
// Remove leading/trailing hyphens
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
// Remove non-printable chars
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsPrint(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "worktree"
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
218
pkg/git/worktree_test.go
Normal file
218
pkg/git/worktree_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeBranchName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Add auth module", "plan/add-auth-module"},
|
||||||
|
{"", "plan/worktree"},
|
||||||
|
{" spaces ", "plan/spaces"},
|
||||||
|
{"UPPER-case_Mix", "plan/upper-case-mix"},
|
||||||
|
{"a/b/c", "plan/a-b-c"},
|
||||||
|
{"very long task name that exceeds the forty character limit for safety", "plan/very-long-task-name-that-exceeds-the-for"},
|
||||||
|
{"---leading-trailing---", "plan/leading-trailing"},
|
||||||
|
{"special!@#$%chars", "plan/special-chars"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := SanitizeBranchName(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("SanitizeBranchName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchBaseName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"plan/add-auth", "plan-add-auth"},
|
||||||
|
{"heartbeat/20260224", "heartbeat-20260224"},
|
||||||
|
{"main", "main"},
|
||||||
|
{"", "worktree"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := BranchBaseName(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("BranchBaseName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initTestRepo creates a temporary git repo with an initial commit.
|
||||||
|
func initTestRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git init: %s: %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial commit
|
||||||
|
f := filepath.Join(dir, "README.md")
|
||||||
|
os.WriteFile(f, []byte("# Test\n"), 0o644)
|
||||||
|
add := exec.Command("git", "add", "-A")
|
||||||
|
add.Dir = dir
|
||||||
|
add.Run()
|
||||||
|
commit := exec.Command("git", "commit", "-m", "initial")
|
||||||
|
commit.Dir = dir
|
||||||
|
commit.Run()
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRepoRoot(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
root := FindRepoRoot(dir)
|
||||||
|
if root == "" {
|
||||||
|
t.Fatal("FindRepoRoot returned empty for valid repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-repo should return ""
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if got := FindRepoRoot(tmpDir); got != "" {
|
||||||
|
t.Errorf("FindRepoRoot(non-repo) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentBranch(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
branch := CurrentBranch(dir)
|
||||||
|
// Should be "main" or "master" depending on git config
|
||||||
|
if branch == "" {
|
||||||
|
t.Fatal("CurrentBranch returned empty for valid repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWorktreeAndDispose(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-wt")
|
||||||
|
|
||||||
|
wt, err := CreateWorktree(dir, wtPath, "plan/test-feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wt.Path != wtPath {
|
||||||
|
t.Errorf("Path = %q, want %q", wt.Path, wtPath)
|
||||||
|
}
|
||||||
|
if wt.Branch != "plan/test-feature" {
|
||||||
|
t.Errorf("Branch = %q, want %q", wt.Branch, "plan/test-feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify worktree exists
|
||||||
|
if _, err := os.Stat(wtPath); os.IsNotExist(err) {
|
||||||
|
t.Fatal("worktree dir was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeDispose with no changes — should delete branch
|
||||||
|
result := SafeDispose(dir, wt)
|
||||||
|
if result.AutoCommitted {
|
||||||
|
t.Error("AutoCommitted should be false with no changes")
|
||||||
|
}
|
||||||
|
if result.CommitsAhead != 0 {
|
||||||
|
t.Errorf("CommitsAhead = %d, want 0", result.CommitsAhead)
|
||||||
|
}
|
||||||
|
if !result.BranchDeleted {
|
||||||
|
t.Error("BranchDeleted should be true when no unique commits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWorktreeWithChangesAndDispose(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-changes")
|
||||||
|
|
||||||
|
wt, err := CreateWorktree(dir, wtPath, "plan/with-changes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a change in the worktree
|
||||||
|
os.WriteFile(filepath.Join(wtPath, "new-file.txt"), []byte("hello"), 0o644)
|
||||||
|
|
||||||
|
if !HasUncommittedChanges(wtPath) {
|
||||||
|
t.Fatal("HasUncommittedChanges should be true after adding file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeDispose should auto-commit
|
||||||
|
result := SafeDispose(dir, wt)
|
||||||
|
if !result.AutoCommitted {
|
||||||
|
t.Error("AutoCommitted should be true")
|
||||||
|
}
|
||||||
|
if result.CommitsAhead != 1 {
|
||||||
|
t.Errorf("CommitsAhead = %d, want 1", result.CommitsAhead)
|
||||||
|
}
|
||||||
|
if result.BranchDeleted {
|
||||||
|
t.Error("BranchDeleted should be false when branch has commits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasUncommittedChanges(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
|
||||||
|
if HasUncommittedChanges(dir) {
|
||||||
|
t.Fatal("clean repo should have no uncommitted changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0o644)
|
||||||
|
if !HasUncommittedChanges(dir) {
|
||||||
|
t.Fatal("should detect uncommitted changes after adding file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitsAhead(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
base := CurrentBranch(dir)
|
||||||
|
|
||||||
|
// Create a branch with a commit
|
||||||
|
exec.Command("git", "checkout", "-b", "test-ahead").Run()
|
||||||
|
branchCmd := exec.Command("git", "checkout", "-b", "test-ahead")
|
||||||
|
branchCmd.Dir = dir
|
||||||
|
branchCmd.Run()
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("data"), 0o644)
|
||||||
|
AutoCommit(dir, "extra commit")
|
||||||
|
|
||||||
|
n := CommitsAhead(dir, base, "test-ahead")
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("CommitsAhead = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneOrphaned(t *testing.T) {
|
||||||
|
dir := initTestRepo(t)
|
||||||
|
|
||||||
|
// Use a separate temp dir for worktrees (outside the repo) to avoid
|
||||||
|
// git rev-parse finding the parent repo's .git.
|
||||||
|
worktreesDir := filepath.Join(t.TempDir(), "worktrees")
|
||||||
|
os.MkdirAll(worktreesDir, 0o755)
|
||||||
|
|
||||||
|
// Create a fake dir that's not a worktree
|
||||||
|
orphanDir := filepath.Join(worktreesDir, "orphan")
|
||||||
|
os.MkdirAll(orphanDir, 0o755)
|
||||||
|
|
||||||
|
PruneOrphaned(dir, worktreesDir)
|
||||||
|
|
||||||
|
if _, err := os.Stat(orphanDir); !os.IsNotExist(err) {
|
||||||
|
t.Error("orphaned dir should have been removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,12 +59,15 @@ type PlanInfo struct {
|
||||||
|
|
||||||
// SessionInfo represents an active session entry for the API response.
|
// SessionInfo represents an active session entry for the API response.
|
||||||
type SessionInfo struct {
|
type SessionInfo struct {
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
TouchDir string `json:"touch_dir"`
|
TouchDir string `json:"touch_dir"`
|
||||||
LastSeenAt string `json:"last_seen_at"`
|
ProjectPath string `json:"project_path,omitempty"`
|
||||||
AgeSec int `json:"age_sec"`
|
Purpose string `json:"purpose,omitempty"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
|
AgeSec int `json:"age_sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitRepoSummary represents a lightweight repo entry for the list view.
|
// GitRepoSummary represents a lightweight repo entry for the list view.
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("new_text is required")
|
return ErrorResult("new_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||||
|
|
@ -126,7 +126,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
|
||||||
return ErrorResult("content is required")
|
return ErrorResult("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := appendFile(t.fs, path, content); err != nil {
|
if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("path is required")
|
return ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := t.fs.ReadFile(path)
|
content, err := resolveFS(ctx, t.fs, path).ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +180,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
return ErrorResult("content is required")
|
return ErrorResult("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
|
if err := resolveFS(ctx, t.fs, path).WriteFile(path, []byte(content)); err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +228,7 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
path = "."
|
path = "."
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := t.fs.ReadDir(path)
|
entries, err := resolveFS(ctx, t.fs, path).ReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(err.Error())
|
return ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,8 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`\bdocker\s+exec\b`),
|
regexp.MustCompile(`\bdocker\s+exec\b`),
|
||||||
regexp.MustCompile(`\bgit\s+push\b`),
|
regexp.MustCompile(`\bgit\s+push\b`),
|
||||||
regexp.MustCompile(`\bgit\s+force\b`),
|
regexp.MustCompile(`\bgit\s+force\b`),
|
||||||
|
regexp.MustCompile(`\bgit\s+checkout\b`),
|
||||||
|
regexp.MustCompile(`\bgit\s+switch\b`),
|
||||||
regexp.MustCompile(`\bssh\b.*@`),
|
regexp.MustCompile(`\bssh\b.*@`),
|
||||||
regexp.MustCompile(`\beval\b`),
|
regexp.MustCompile(`\beval\b`),
|
||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
|
|
@ -277,6 +279,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
|
if override := WorkspaceOverrideFromCtx(ctx); override != "" {
|
||||||
|
cwd = override
|
||||||
|
}
|
||||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
||||||
if t.restrictToWorkspace && t.workingDir != "" {
|
if t.restrictToWorkspace && t.workingDir != "" {
|
||||||
resolvedWD, err := validatePath(wd, t.workingDir, true)
|
resolvedWD, err := validatePath(wd, t.workingDir, true)
|
||||||
|
|
|
||||||
67
pkg/tools/workspace_ctx.go
Normal file
67
pkg/tools/workspace_ctx.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workspaceOverrideKey struct{}
|
||||||
|
|
||||||
|
// WithWorkspaceOverride returns a context carrying a workspace override path.
|
||||||
|
// Tools will resolve file operations against this path instead of the original workspace.
|
||||||
|
func WithWorkspaceOverride(ctx context.Context, workspace string) context.Context {
|
||||||
|
return context.WithValue(ctx, workspaceOverrideKey{}, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceOverrideFromCtx extracts the workspace override from context, or "".
|
||||||
|
func WorkspaceOverrideFromCtx(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(workspaceOverrideKey{}).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFS returns a fileSystem applying workspace override from context.
|
||||||
|
// Paths under "memory/" are excluded (always use original workspace).
|
||||||
|
// For sandboxFs: creates a temporary instance with the override workspace.
|
||||||
|
// For hostFs (unrestricted): returns as-is.
|
||||||
|
func resolveFS(ctx context.Context, fs fileSystem, path string) fileSystem {
|
||||||
|
override := WorkspaceOverrideFromCtx(ctx)
|
||||||
|
if override == "" {
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory/ paths always use original workspace
|
||||||
|
if isMemoryPath(path) {
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only sandboxFs supports workspace override
|
||||||
|
if sfs, ok := fs.(*sandboxFs); ok {
|
||||||
|
if sfs.workspace == override {
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
return &sandboxFs{workspace: override}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMemoryPath returns true for paths under the memory/ directory.
|
||||||
|
// Matches: "memory/MEMORY.md", "memory", "/workspace/memory/notes.md"
|
||||||
|
func isMemoryPath(path string) bool {
|
||||||
|
p := filepath.ToSlash(filepath.Clean(path))
|
||||||
|
|
||||||
|
// Relative path starting with memory/
|
||||||
|
if strings.HasPrefix(p, "memory/") || p == "memory" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path containing /memory/ or ending with /memory
|
||||||
|
if strings.Contains(p, "/memory/") || strings.HasSuffix(p, "/memory") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue