mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
MUL-2794 fix(agent): stop Cursor sessions on terminal result (#3165)
Treats Cursor's stream-json terminal `result` event as the protocol completion boundary so a lingering Cursor worker process can no longer hold the daemon task open after the agent has produced its final result. - Tighten `cmd.WaitDelay` to 500ms (set before `Start()`) - Set `resultSeen` and `cancel()` on terminal `result` - Preserve completed/failed status across the cancellation via two `!resultSeen` guards in the post-loop status decision - Add unix fake-CLI coverage for success and `is_error` terminal results
This commit is contained in:
@@ -39,7 +39,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
cmd := exec.CommandContext(runCtx, argv0, cmdArgs...)
|
||||
hideAgentWindow(cmd)
|
||||
b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs)
|
||||
cmd.WaitDelay = 20 * time.Second
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
resultSeen := false
|
||||
// stepUsage accumulates per-step token counts from "step_finish" events.
|
||||
// resultUsage holds authoritative session totals from "result" events.
|
||||
// If the result event includes usage, we use resultUsage exclusively;
|
||||
@@ -140,6 +141,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
})
|
||||
|
||||
case "result":
|
||||
resultSeen = true
|
||||
if evt.IsError || evt.Subtype == "error" {
|
||||
finalStatus = "failed"
|
||||
finalError = cursorErrorText(&evt)
|
||||
@@ -151,6 +153,10 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
if evt.Usage != nil {
|
||||
hasResultUsage = true
|
||||
}
|
||||
// Current Cursor Agent versions can emit the terminal result
|
||||
// event but keep a worker process alive. Treat result as the
|
||||
// protocol boundary so the daemon can report completion.
|
||||
cancel()
|
||||
|
||||
case "error":
|
||||
errMsg := cursorErrorText(&evt)
|
||||
@@ -198,10 +204,10 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
finalStatus = "timeout"
|
||||
finalError = fmt.Sprintf("cursor-agent timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
} else if runCtx.Err() == context.Canceled && !resultSeen {
|
||||
finalStatus = "aborted"
|
||||
finalError = "execution cancelled"
|
||||
} else if exitErr != nil && finalStatus == "completed" {
|
||||
} else if exitErr != nil && finalStatus == "completed" && !resultSeen {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("cursor-agent exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
77
server/pkg/agent/cursor_execute_unix_test.go
Normal file
77
server/pkg/agent/cursor_execute_unix_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build unix
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCursorExecuteStopsAfterTerminalResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := `#!/bin/sh
|
||||
printf '%s\n' '{"type":"system","subtype":"init","session_id":"sess-terminal"}'
|
||||
printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"sess-terminal"}'
|
||||
sleep 10
|
||||
`
|
||||
result := executeFakeCursor(t, script)
|
||||
|
||||
if result.Status != "completed" {
|
||||
t.Fatalf("status = %q, want completed; error=%q", result.Status, result.Error)
|
||||
}
|
||||
if result.Output != "done" {
|
||||
t.Fatalf("output = %q, want done", result.Output)
|
||||
}
|
||||
if result.SessionID != "sess-terminal" {
|
||||
t.Fatalf("session id = %q, want sess-terminal", result.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorExecuteStopsAfterTerminalErrorResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script := `#!/bin/sh
|
||||
printf '%s\n' '{"type":"system","subtype":"init","session_id":"sess-terminal-error"}'
|
||||
printf '%s\n' '{"type":"result","subtype":"error","is_error":true,"result":"failed hard","session_id":"sess-terminal-error"}'
|
||||
sleep 10
|
||||
`
|
||||
result := executeFakeCursor(t, script)
|
||||
|
||||
if result.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed; error=%q", result.Status, result.Error)
|
||||
}
|
||||
if result.Error != "failed hard" {
|
||||
t.Fatalf("error = %q, want failed hard", result.Error)
|
||||
}
|
||||
if result.Output != "failed hard" {
|
||||
t.Fatalf("output = %q, want failed hard", result.Output)
|
||||
}
|
||||
if result.SessionID != "sess-terminal-error" {
|
||||
t.Fatalf("session id = %q, want sess-terminal-error", result.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func executeFakeCursor(t *testing.T, script string) Result {
|
||||
t.Helper()
|
||||
|
||||
fakePath := filepath.Join(t.TempDir(), "cursor-agent")
|
||||
writeTestExecutable(t, fakePath, []byte(script))
|
||||
|
||||
backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
||||
if err != nil {
|
||||
t.Fatalf("New(cursor): %v", err)
|
||||
}
|
||||
session, err := backend.Execute(t.Context(), "hello", ExecOptions{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
result := <-session.Result
|
||||
if result.Status == "timeout" {
|
||||
t.Fatalf("cursor backend timed out instead of stopping after terminal result; error=%q", result.Error)
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user