Files
multica/server/internal/daemon/task_output_observer.go
Multica Eveandmultica-agent 79d801f45c MUL-8188: fail fast on Kiro panic and stop resuming undrivable sessions (#9458)
* fix(kiro): fail fast when the runtime panics mid-turn (MUL-8188)

Kiro can panic inside its agent loop while the ACP process stays alive, so
session/prompt never answered and the task hung until the 2h idle watchdog
or a manual cancel. Watch stderr live for a panic header, abort the prompt,
and surface the panic text. A resumed session that panics before its first
message signals ResumeRejected so the daemon retries once on a fresh session.

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

* fix(resume): stop re-resuming sessions the runtime cannot drive (MUL-8188)

- taskfailure.UnresumableHistory matches "invalid conversation history",
  so the daemon's fresh-session retry, classifyPoisonedError and
  ResumeUnsafeFailure all treat Kiro's panic as resume-unsafe; the
  GetLastTaskSession / GetLastChatTaskSession text guards mirror it for
  rows written by older daemons.
- A resumed run cancelled after >=5 minutes with zero agent output now
  retires its session: the daemon sends retired_session_id on cancel-ack
  and the server records it on the cancelled row (status CAS, never
  overwrites) and clears a chat pointer still naming it.

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

* fix(resume): address review on retired sessions (MUL-8188)

- Manual retry no longer revives a retired session: the exact-source rerun
  claim path now checks IsTaskSessionRetired (fails closed), still reuses
  the source workdir, and sets PriorSessionResumeUnavailable.
- Silent-resume retirement measures awakeclock time, so host suspend
  (Windows monotonic clock counts through sleep) cannot retire a healthy
  session; adds a suspend regression test.
- Handler tests use dbfx.Issue/Task and testutil.Call per AGENTS.md.

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

---------

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

130 lines
4.1 KiB
Go

package daemon
import (
"context"
"sync"
"time"
"github.com/multica-ai/multica/server/pkg/awakeclock"
)
// silentResumeRetireThreshold is how long a RESUMED run must have gone without
// a single agent message before a cancellation retires its session.
//
// A healthy resumed turn produces its first thinking/text/tool event within
// seconds; minutes of total silence after a resume means the runtime loaded
// the transcript and then could not drive it — the MUL-8188 shape, where Kiro
// panicked in its agent loop and left session/prompt unanswered. Users cancel
// such runs by hand, and before this the cancelled row kept the session
// resumable, so every later trigger re-entered the same hang.
//
// The threshold keeps an ordinary early cancel ("wrong issue", "let me edit
// the prompt") from discarding a healthy conversation: those land well before
// it. The cost of a false positive is bounded — the next run starts a fresh
// session in the same workdir — while a false negative is an issue that hangs
// on every trigger.
const silentResumeRetireThreshold = 5 * time.Minute
// taskOutputObserver records, for the most recent backend execution of a task,
// when it started and whether it produced any agent output. Unlike
// taskPhaseRecorder (diagnostic-only by contract) it exists to drive the
// silent-resume retirement decision.
//
// Silence is measured on awakeclock, not wall time: a laptop that suspends
// right after a resume and is woken and cancelled later must not have its slept
// time counted as the session failing to answer — on Windows Go's monotonic
// clock keeps counting through sleep, which would permanently retire a healthy
// conversation. Same reasoning as the idle watchdog (MUL-7851 / #9201).
type taskOutputObserver struct {
// clock returns awake-time readings; nil means awakeclock.Now. Tests
// inject a fake to simulate host suspend.
clock func() time.Duration
mu sync.Mutex
started time.Duration
begun bool
observed bool
}
func (o *taskOutputObserver) now() time.Duration {
if o.clock != nil {
return o.clock()
}
return awakeclock.Now()
}
// begin marks the start of a backend execution. Each execution (including a
// fresh-session retry) resets the observation so the decision always concerns
// the run whose result is being classified.
func (o *taskOutputObserver) begin() {
if o == nil {
return
}
now := o.now()
o.mu.Lock()
o.started = now
o.begun = true
o.observed = false
o.mu.Unlock()
}
func (o *taskOutputObserver) markOutput() {
if o == nil {
return
}
o.mu.Lock()
o.observed = true
o.mu.Unlock()
}
// silentFor reports how much AWAKE time the current execution has run
// without output. ok is false when the execution never started or did
// produce output.
func (o *taskOutputObserver) silentFor() (time.Duration, bool) {
if o == nil {
return 0, false
}
now := o.now()
o.mu.Lock()
defer o.mu.Unlock()
if !o.begun || o.observed {
return 0, false
}
silent := now - o.started
if silent < 0 {
silent = 0
}
return silent, true
}
type taskOutputObserverContextKey struct{}
func withTaskOutputObserver(ctx context.Context, o *taskOutputObserver) context.Context {
return context.WithValue(ctx, taskOutputObserverContextKey{}, o)
}
func taskOutputObserverFromContext(ctx context.Context) *taskOutputObserver {
o, _ := ctx.Value(taskOutputObserverContextKey{}).(*taskOutputObserver)
return o
}
// shouldRetireSilentResume reports whether a run's resumed session should be
// retired because the run was cancelled after resuming it and never produced
// any output for at least silentResumeRetireThreshold.
//
// Only cancellation statuses qualify: a failed run already carries an error
// the resume guards classify, and a timeout/idle_watchdog has its own
// failure_reason. priorSessionID == "" means the run started cold, so there
// is no resumed session to blame.
func shouldRetireSilentResume(status, priorSessionID string, silent time.Duration, wasSilent bool) bool {
if priorSessionID == "" || !wasSilent {
return false
}
switch status {
case "cancelled", "aborted":
default:
return false
}
return silent >= silentResumeRetireThreshold
}