Files
multica/server/pkg/agent/cursor_background.go
Multica Eveandmultica-agent b115ec80f1 MUL-7851: fix(daemon): don't count host sleep as idle-watchdog silence (#9201)
* fix(daemon): don't count host sleep as agent silence in idle watchdog (#9024)

The idle watchdog stamped activity in wall-clock unix nanos, so a laptop
that slept past the idle window woke up with every in-flight run already
over budget and force-stopped them all on the first tick.

Detect host suspend from the wall-clock gap between watchdog ticks and
credit that time back to lastActivityAt before each check, clamped to now.
Works the same on macOS/Linux (monotonic clock pauses in sleep) and
Windows (monotonic clock keeps counting), and covers the tool-in-flight
budget too since both read the same stamp.

MUL-7851

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): measure idle watchdog on an awake-only clock (#9024)

Review on #9201 showed the tick-gap heuristic still charges sleep to the
agent: a sleep shorter than one tick is never credited, and when the
overdue tick fires on wake (Windows) only gap−tick is credited.

Replace it with pkg/awakeclock, a clock that only advances while the host
is awake: Go's monotonic clock on Linux/macOS (already stops in suspend),
minus host sleep on Windows (GetTickCount64 − QueryUnbiasedInterruptTime).
lastActivityAt, the drain loop stamp, and Cursor's native tool activity
(Session.ToolActivity now returns an awakeclock reading) all share it.

Regression tests cover both review boundaries plus a long sleep, each with
a wall-clock control that proves the scenario reproduces the bug.

MUL-7851

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-12 17:19:58 +08:00

251 lines
8.5 KiB
Go

package agent
import (
"context"
"log/slog"
"math"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/multica-ai/multica/server/pkg/awakeclock"
)
type cursorBackgroundTool struct {
call cursorToolCall
process *cursorBackgroundProcess
}
// The daemon owns the only budget timer. This tracker owns process cleanup and
// the matching tool result, so expiration cannot race a second run-cancel timer.
type cursorBackgroundTools struct {
ctx context.Context
cmd *exec.Cmd
messages chan<- Message
logger *slog.Logger
mu sync.Mutex
tools []cursorBackgroundTool
closed bool
stop chan struct{}
done chan struct{}
once sync.Once
inFlight atomic.Int32
lastToolActivity atomic.Int64
// closeBudget overrides cursorCloseBudget for this tracker. Zero — the value
// every production construction path leaves it at — means the constant.
closeBudget time.Duration
// terminal is set the moment Cursor's authoritative result is read, before
// Close() takes mu. Cleanup can block on that lock behind an in-progress
// Interrupt and can legitimately fail, so it cannot be the signal that the
// outcome is decided; this flag is, and it is deliberately lock-free.
terminal atomic.Bool
}
// ObserveTerminal records that the backend has read its authoritative terminal
// result. Call it before any cleanup that may block or fail.
func (b *cursorBackgroundTools) ObserveTerminal() { b.terminal.Store(true) }
// TerminalObserved reports whether the run's outcome is already decided.
func (b *cursorBackgroundTools) TerminalObserved() bool { return b.terminal.Load() }
func newCursorBackgroundTools(ctx context.Context, cmd *exec.Cmd, messages chan<- Message, logger *slog.Logger) *cursorBackgroundTools {
b := &cursorBackgroundTools{ctx: ctx, cmd: cmd, messages: messages, logger: logger, stop: make(chan struct{}), done: make(chan struct{})}
// "No tool activity yet" must sort before every awakeclock reading. Those
// readings can be negative, so zero is not a safe sentinel.
b.lastToolActivity.Store(math.MinInt64)
go func() {
defer close(b.done)
// This only observes natural process exit; it never measures inactivity.
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.Reap()
case <-b.stop:
return
case <-ctx.Done():
return
}
}
}()
return b
}
// Activity reports in-flight tools and the awakeclock reading of the last
// tool transition. It uses the same clock as the daemon's idle watchdog.
func (b *cursorBackgroundTools) Activity() (int32, time.Duration) {
return b.inFlight.Load(), time.Duration(b.lastToolActivity.Load())
}
func (b *cursorBackgroundTools) Send(msg Message) {
if msg.Type == MessageToolUse || msg.Type == MessageToolResult {
now := int64(awakeclock.Now())
for {
previous := b.lastToolActivity.Load()
if now <= previous || b.lastToolActivity.CompareAndSwap(previous, now) {
break
}
}
}
if msg.Type == MessageToolUse {
b.inFlight.Add(1)
}
// Publish completion before switching to the idle budget. Otherwise the
// watchdog can see zero tools with the old timestamp and an empty queue.
trySend(b.messages, msg)
if msg.Type == MessageToolResult {
for {
count := b.inFlight.Load()
if count <= 0 || b.inFlight.CompareAndSwap(count, count-1) {
break
}
}
}
}
func (b *cursorBackgroundTools) SendResult(call cursorToolCall) {
b.Send(Message{Type: MessageToolResult, Tool: call.Name, CallID: call.CallID, Output: call.Result})
}
func (b *cursorBackgroundTools) Add(call cursorToolCall) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
for _, tool := range b.tools {
if call.CallID != "" && tool.call.CallID == call.CallID {
return
}
}
p, err := captureCursorBackgroundProcess(b.cmd, call.PID)
if err != nil {
// No work was claimed. Preserve Cursor's launch completion so the idle
// watchdog remains available even when the tool watchdog is disabled.
// This grants no cleanup recovery window and never signals the PID.
b.logger.Warn("cannot own Cursor background shell; returning launch result for idle watchdog fallback", "error", err)
b.SendResult(call)
return
}
b.tools = append(b.tools, cursorBackgroundTool{call: call, process: p})
}
// cursorCloseBudget bounds the WHOLE of Close(), not each process in it.
// Per-process termination is already bounded, but the number of background
// shells one run may launch is not, so "bounded per process" is not a bound on
// finalization. It has to be one, because Close() runs after the terminal
// result has been observed — the point at which the daemon's watchdog has
// deliberately stopped supervising this run, and MULTICA_AGENT_TIMEOUT is 0 by
// default. Work still unconfirmed when the budget runs out takes the existing
// unconfirmed-at-close path: its result is preserved and its cleanup is logged
// as unconfirmed, never reported as successful.
const cursorCloseBudget = 10 * time.Second
func (b *cursorBackgroundTools) Reap() {
b.mu.Lock()
defer b.mu.Unlock()
if !b.closed {
b.finish(false, time.Now().Add(b.budget()))
}
}
// budget resolves the per-pass cleanup bound. Every pass that holds mu is
// bounded by it, not just the closing one: Close() has to wait for this lock,
// so an unbounded pass held by anyone else is an unbounded Close(), which is
// the same defect one level down.
func (b *cursorBackgroundTools) budget() time.Duration {
if b.closeBudget > 0 {
return b.closeBudget
}
return cursorCloseBudget
}
// Interrupt is called synchronously by the daemon's tool watchdog. Each tool
// can grant a recovery window only once: it is removed with its tool result.
func (b *cursorBackgroundTools) Interrupt() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed || b.ctx.Err() != nil {
return false
}
return b.finish(true, time.Now().Add(b.budget()))
}
// finish is called with mu held. A failed ownership check or cleanup never
// decrements the tool count; only confirmed process exit releases its result.
//
// A non-zero deadline caps the whole pass rather than each process: once it has
// passed, the remaining tools are left untouched and unconfirmed instead of
// each adding its own termination wait.
func (b *cursorBackgroundTools) finish(interrupt bool, deadline time.Time) bool {
completed := false
remaining := b.tools[:0]
for _, tool := range b.tools {
if tool.process == nil {
remaining = append(remaining, tool)
continue
}
if !deadline.IsZero() && !time.Now().Before(deadline) {
remaining = append(remaining, tool)
continue
}
alive, err := tool.process.Alive()
if err == nil && alive && interrupt {
err = tool.process.Terminate()
if err == nil {
alive, err = tool.process.Alive()
}
}
if err != nil || alive {
if err != nil && interrupt {
b.logger.Warn("could not stop Cursor background shell", "error", err)
}
remaining = append(remaining, tool)
continue
}
b.SendResult(tool.call)
tool.process.Close()
completed = true
}
b.tools = remaining
return completed
}
func (b *cursorBackgroundTools) Close() {
b.once.Do(func() {
// Taken before the lock on purpose: waiting for a concurrent pass is
// part of what Close() has to bound, not something outside its budget.
// Every other pass is bounded by the same value, so the wait is too.
deadline := time.Now().Add(b.budget())
b.mu.Lock()
b.closed = true
// If waiting for the lock already consumed the budget, the closing pass
// still gets a full one of its own: the bound this guards is "Close()
// returns", and giving it zero time would just push every tool onto the
// unconfirmed path without trying. Two budgets is the honest ceiling,
// and terminalResultHandoffBudget is derived from exactly that.
if !time.Now().Before(deadline) {
deadline = time.Now().Add(b.budget())
}
b.finish(true, deadline)
if len(b.tools) > 0 {
// Retry a transient lookup/termination failure before releasing the
// final claim. Persistent errors remain explicitly unconfirmed.
b.finish(true, deadline)
}
for _, tool := range b.tools {
// The stream is closing, so preserve even unverifiable launch
// payloads without pretending cleanup succeeded in native accounting.
b.logger.Warn("Cursor background cleanup unconfirmed at close", "call_id", tool.call.CallID)
trySend(b.messages, Message{Type: MessageToolResult, Tool: tool.call.Name, CallID: tool.call.CallID, Output: tool.call.Result})
tool.process.Close()
}
b.tools = nil
b.mu.Unlock()
close(b.stop)
<-b.done
})
}