agent-orchestrator/backend/internal/lifecycle/decide_bridge.go

228 lines
9.1 KiB
Go

package lifecycle
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/domain/decide"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// defaultRecentActivityWindow is how fresh the last activity signal must be for
// the probe decider to treat the agent as "recently active" (which keeps an
// ambiguous dead-runtime probe in detecting instead of concluding death).
const defaultRecentActivityWindow = 60 * time.Second
// ---- fact translation: ports DTOs -> pure decide inputs ----
// runtimeFactsToProbeInput maps a raw RuntimeFacts (plus the prior detecting
// memory and last-known activity read back from canonical) into the probe
// decider's input. KillRequested is always false here: the inferred-death path
// never carries an explicit kill — that arrives via OnKillRequested.
func runtimeFactsToProbeInput(f ports.RuntimeFacts, cur domain.CanonicalSessionLifecycle, window time.Duration) decide.ProbeInput {
rt, rtFailed := runtimeProbeToState(f.RuntimeState)
proc, procFailed := processProbeToLiveness(f.ProcessState)
now := nowOr(f.ObservedAt)
return decide.ProbeInput{
Runtime: rt,
RuntimeFailed: rtFailed,
Process: proc,
ProcessFailed: procFailed,
RecentActivity: hasRecentActivity(cur.Activity, now, window),
Prior: cur.Detecting,
Now: now,
}
}
func runtimeProbeToState(p ports.RuntimeProbe) (domain.RuntimeState, bool) {
switch p {
case ports.RuntimeProbeAlive:
return domain.RuntimeAlive, false
case ports.RuntimeProbeDead:
return domain.RuntimeExited, false
case ports.RuntimeProbeFailed:
return domain.RuntimeProbeFailed, true
default: // indeterminate / unset: ambiguous, never a death conclusion
return domain.RuntimeUnknown, false
}
}
func processProbeToLiveness(p ports.ProcessProbe) (decide.ProcessLiveness, bool) {
switch p {
case ports.ProcessProbeAlive:
return decide.ProcessAlive, false
case ports.ProcessProbeDead:
return decide.ProcessDead, false
case ports.ProcessProbeFailed:
return decide.ProcessIndeterminate, true
default: // indeterminate / unset
return decide.ProcessIndeterminate, false
}
}
// runtimeSubstateFromFacts derives the runtime sub-state to persist. Liveness
// always owns this axis, so it is written on every runtime observation
// regardless of what the session axis does.
func runtimeSubstateFromFacts(f ports.RuntimeFacts) domain.RuntimeSubstate {
switch f.RuntimeState {
case ports.RuntimeProbeAlive:
return domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}
case ports.RuntimeProbeDead:
return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonTmuxMissing}
case ports.RuntimeProbeFailed:
return domain.RuntimeSubstate{State: domain.RuntimeProbeFailed, Reason: domain.RuntimeReasonProbeError}
case ports.RuntimeProbeIndeterminate:
// Probe ran but couldn't tell — distinct from a probe error, so no
// probe_error reason; the ambiguity is carried by RuntimeUnknown alone.
return domain.RuntimeSubstate{State: domain.RuntimeUnknown}
default: // unset
return domain.RuntimeSubstate{State: domain.RuntimeUnknown}
}
}
// hasRecentActivity answers the probe decider's "was the agent heard from
// recently?" question. Sticky states (waiting_input/blocked) count as recent
// because they mean a live-but-paused agent; an explicit exited signal never
// counts; otherwise we age the last-activity timestamp against the window.
func hasRecentActivity(a domain.ActivitySubstate, now time.Time, window time.Duration) bool {
if a.State == domain.ActivityExited {
return false
}
if a.State.IsSticky() {
return true
}
if a.LastActivityAt.IsZero() {
return false
}
return now.Sub(a.LastActivityAt) <= window
}
// openPRInput maps SCM facts onto the open-PR ladder. IdleBeyond is always false
// in split A — the idle-duration signal is owned by the escalation engine
// (split B); the synchronous LCM has no clock of its own here.
func openPRInput(f ports.SCMFacts) decide.OpenPRInput {
return decide.OpenPRInput{
CIFailing: f.CISummary == ports.CIFailing,
ChangesRequested: f.ReviewDecision == ports.ReviewChangesRequested,
Approved: f.ReviewDecision == ports.ReviewApproved,
Mergeable: f.Mergeability.Mergeable,
ReviewPending: f.ReviewDecision == ports.ReviewPending,
Number: f.PRNumber,
URL: f.PRURL,
}
}
// ---- activity -> session axis mapping (activity owns working/idle/waiting) ----
// activityToSession maps an activity classification onto the session sub-state.
// exited returns ok=false: an exit signal must NOT write a terminal session
// state — only the probe pipeline (via detecting) may conclude inferred death.
func activityToSession(a domain.ActivityState) (domain.SessionState, domain.SessionReason, bool) {
switch a {
case domain.ActivityActive:
return domain.SessionWorking, domain.ReasonTaskInProgress, true
case domain.ActivityReady:
// ready = the agent finished a unit and is waiting for more work.
return domain.SessionIdle, domain.ReasonResearchComplete, true
case domain.ActivityIdle:
// plain inactivity carries no completion claim, so no specific reason
// (research_complete here would read misleadingly in diagnostics).
return domain.SessionIdle, "", true
case domain.ActivityWaitingInput:
return domain.SessionNeedsInput, domain.ReasonAwaitingUserInput, true
case domain.ActivityBlocked:
return domain.SessionStuck, domain.ReasonAwaitingUserInput, true
default: // exited / unset
return "", "", false
}
}
// ---- composition predicates: who may write the session axis ----
// isTerminal reports a final session state that must not be resurrected by an
// observation (only an explicit Restore reopens a terminal session).
func isTerminal(s domain.SessionState) bool {
return s == domain.SessionDone || s == domain.SessionTerminated
}
// isLivenessOwned reports whether the current session sub-state was set by the
// liveness/death axis (the probe pipeline) and may therefore be recovered by a
// later healthy probe. detecting is always liveness-owned; a stuck/terminated
// state is liveness-owned only when its reason came from a death inference.
func isLivenessOwned(s domain.SessionSubstate) bool {
if s.State == domain.SessionDetecting {
return true
}
switch s.Reason {
case domain.ReasonRuntimeLost, domain.ReasonAgentProcessExited, domain.ReasonProbeFailure:
return true
}
return false
}
// shouldWriteSessionRuntime is the #1 composition rule for ApplyRuntimeObservation.
// A death-axis verdict (detecting/stuck/terminal) always writes — it overrides
// activity because a (maybe) dead agent can't be working/waiting. A healthy
// "working" verdict only writes when it is recovering a liveness-owned state
// (e.g. detecting -> working); it must NOT clobber an activity-owned
// needs_input/blocked/idle the activity axis is responsible for.
func shouldWriteSessionRuntime(d decide.LifecycleDecision, cur domain.CanonicalSessionLifecycle) bool {
if isTerminal(cur.Session.State) {
// A terminal session is only reopened by an explicit Restore — never by
// an observation. Even a death-axis verdict (e.g. detecting) must not
// resurrect it; the runtime axis is still patched separately.
return false
}
if d.SessionState == domain.SessionWorking {
return isLivenessOwned(cur.Session)
}
return true
}
// shouldWriteSessionActivity is the mirror rule for ApplyActivitySignal: the
// activity axis owns working/idle/waiting. A valid activity signal is direct
// proof of life, so it is allowed to RESOLVE a detecting session (pull it out of
// the liveness quarantine) — but it must not resurrect a terminal session, and
// it leaves a liveness-escalated stuck state to the probe pipeline (stuck is a
// deliberate human-facing escalation, not a transient quarantine).
func shouldWriteSessionActivity(cur domain.CanonicalSessionLifecycle) bool {
if isTerminal(cur.Session.State) {
return false
}
if cur.Session.State == domain.SessionDetecting {
return true
}
return !isLivenessOwned(cur.Session)
}
// ---- explicit-kill mapping (SM's terminal-write authority) ----
func killSession(k ports.LifecycleKillReason) domain.SessionSubstate {
switch k {
case ports.KillManual:
return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonManuallyKilled}
case ports.KillCleanup:
return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonAutoCleanup}
default: // error
return domain.SessionSubstate{State: domain.SessionTerminated, Reason: domain.ReasonErrorInProcess}
}
}
func killRuntime(k ports.LifecycleKillReason) domain.RuntimeSubstate {
switch k {
case ports.KillManual:
return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonManualKillRequested}
case ports.KillCleanup:
return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonAutoCleanup}
default: // error
return domain.RuntimeSubstate{State: domain.RuntimeExited, Reason: domain.RuntimeReasonProbeError}
}
}
func nowOr(t time.Time) time.Time {
if t.IsZero() {
return time.Now()
}
return t
}