refactor(handlers): use GetOrEnsureExecution for workspace ops (#786)
* refactor(handlers): use GetOrEnsureExecution for workspace ops Per CLAUDE.md, workspace-oriented handlers should use GetOrEnsureExecution so they survive backend restarts. Two git ops in this file (wsGitCommits, wsCumulativeDiff) already followed this pattern; the shared helper and the shell.subscribe handler did not. Changes: - git_handlers.getAgentCtlClient: now takes ctx and uses GetOrEnsureExecution. Propagates to all 14 git ops routing through the helper (pull, push, rebase, merge, abort, commit, stage, unstage, discard, createPR, revertCommit, renameBranch, reset, commitDiff). - shell_handlers.wsShellSubscribe: matches wsShellStatus in the same file. Left alone (intentional): - shell_handlers.wsShellInput: the 5s workspace-stream wait is too short to absorb a cold-start of agentctl, so we still require an existing execution. - passthrough_handlers: stdin/resize require a live agent process per the rule. - git_handlers post-success/failure callbacks: just look up TaskID; ensuring would be wasteful. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(handlers): cover wsShellSubscribe no-instance path Mirrors TestWsShellInput_NoInstanceFound. Closes a coverage gap flagged in PR review — the handler was switched to GetOrEnsureExecution but had no direct tests for the lookup-failure path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -247,7 +247,7 @@ func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message,
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message,
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
return nil, fmt.Errorf("base_branch is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, fmt.Errorf("base_branch is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (h *GitHandlers) wsAbort(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, fmt.Errorf("operation must be 'merge' or 'rebase'")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -411,7 +411,7 @@ func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws.
|
||||
return nil, fmt.Errorf("new_name is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -445,7 +445,7 @@ func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, fmt.Errorf("invalid reset mode: %s (must be soft, mixed, or hard)", req.Mode)
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -469,7 +469,7 @@ func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -493,7 +493,7 @@ func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Messa
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -521,7 +521,7 @@ func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Messa
|
||||
return nil, fmt.Errorf("paths are required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -548,7 +548,7 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess
|
||||
return nil, fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -594,7 +594,7 @@ func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws.
|
||||
return nil, fmt.Errorf("commit_sha is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -622,7 +622,7 @@ func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Me
|
||||
return nil, fmt.Errorf("commit_sha is required")
|
||||
}
|
||||
|
||||
client, err := h.getAgentCtlClient(req.SessionID)
|
||||
client, err := h.getAgentCtlClient(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
if isSessionNotReadyError(err) {
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
@@ -652,11 +652,13 @@ func isSessionNotReadyError(err error) bool {
|
||||
strings.Contains(msg, "agent client not available for session")
|
||||
}
|
||||
|
||||
// getAgentCtlClient gets the agentctl client for a session
|
||||
func (h *GitHandlers) getAgentCtlClient(sessionID string) (*client.Client, error) {
|
||||
execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no agent running for session %s", sessionID)
|
||||
// getAgentCtlClient gets the agentctl client for a session.
|
||||
// Uses GetOrEnsureExecution so git operations survive backend restarts —
|
||||
// they're workspace-oriented and don't require a running agent process.
|
||||
func (h *GitHandlers) getAgentCtlClient(ctx context.Context, sessionID string) (*client.Client, error) {
|
||||
execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no agent running for session %s: %w", sessionID, err)
|
||||
}
|
||||
|
||||
c := execution.GetAgentCtlClient()
|
||||
|
||||
@@ -130,10 +130,10 @@ func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) (
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
// Verify the agent execution exists for this session
|
||||
execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no agent running for session %s", req.SessionID)
|
||||
// Get or create execution on-demand (survives backend restart)
|
||||
execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no agent running for session %s: %w", req.SessionID, err)
|
||||
}
|
||||
|
||||
// Get buffered output to include in response
|
||||
@@ -164,7 +164,10 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws.
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
// Get the agent execution for this session
|
||||
// Get the agent execution for this session.
|
||||
// Use GetExecutionBySessionID (not GetOrEnsureExecution): writing input
|
||||
// requires the workspace stream to already be live, and the 5s wait below
|
||||
// is too short to absorb a cold-start of agentctl.
|
||||
execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no agent running for session %s", req.SessionID)
|
||||
|
||||
@@ -242,6 +242,23 @@ func TestWsShellStatus_NoClientAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsShellSubscribe_NoInstanceFound(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
mgr := newTestManager()
|
||||
handlers := NewShellHandlers(mgr, nil, log)
|
||||
|
||||
msg, _ := ws.NewRequest("test-1", ws.ActionShellSubscribe, ShellSubscribeRequest{SessionID: "session-1"})
|
||||
|
||||
_, err := handlers.wsShellSubscribe(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent session")
|
||||
}
|
||||
expectedPrefix := "no agent running for session session-1"
|
||||
if !strings.HasPrefix(err.Error(), expectedPrefix) {
|
||||
t.Errorf("expected prefix '%s', got: %v", expectedPrefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWsShellInput_NoInstanceFound(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
mgr := newTestManager()
|
||||
|
||||
Reference in New Issue
Block a user