e75c8b37c8
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
789 lines
35 KiB
Go
789 lines
35 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
"github.com/kandev/kandev/internal/agentctl/tracing"
|
|
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
const (
|
|
containerStateCreated = "created"
|
|
containerStateExited = "exited"
|
|
containerStateRunning = "running"
|
|
)
|
|
|
|
// Start starts the lifecycle manager background tasks
|
|
func (m *Manager) Start(ctx context.Context) error {
|
|
if m.executorRegistry == nil {
|
|
m.logger.Warn("no runtime registry configured")
|
|
return nil
|
|
}
|
|
|
|
runtimeNames := m.executorRegistry.List()
|
|
m.logger.Info("starting lifecycle manager", zap.Int("runtimes", len(runtimeNames)))
|
|
|
|
// Check health of all registered runtimes
|
|
healthResults := m.executorRegistry.HealthCheckAll(ctx)
|
|
for name, err := range healthResults {
|
|
if err != nil {
|
|
m.logger.Warn("runtime health check failed",
|
|
zap.String("runtime", string(name)),
|
|
zap.Error(err))
|
|
} else {
|
|
m.logger.Info("runtime is healthy", zap.String("runtime", string(name)))
|
|
}
|
|
}
|
|
|
|
// AC-EXECUTORS-SURVIVAL-003.1: start this pass's re-tracked-session set
|
|
// empty so a stale entry from an earlier Start() call (tests, or a
|
|
// hypothetical re-Start) never leaks into this pass's outcome.
|
|
m.resetRetrackedSessions()
|
|
|
|
// Read the live standalone recovery-inventory records (startup step 3,
|
|
// AC-EXECUTORS-SURVIVAL-002.8) before recovery contacts any control
|
|
// server, and hand them to every runtime's RecoverInstances unchanged.
|
|
records, listErr := m.ListLiveStandaloneExecutorsRunning(ctx)
|
|
if listErr != nil {
|
|
// A failed read leaves the record set unknown, which is not the same
|
|
// thing as empty. AC-EXECUTORS-SURVIVAL-002.6 stops a live instance
|
|
// because it is known to have no record; passing an unknown set on as
|
|
// an empty one would make every live instance look record-less and
|
|
// send all of them down that orphan-stop path, so a transient database
|
|
// error during startup would kill every agent that just survived the
|
|
// restart. Recover nothing instead, on the same terms
|
|
// AC-EXECUTORS-SURVIVAL-002.12 sets for the mirror failure -- the
|
|
// adopted server that cannot be enumerated: report no recovered
|
|
// instances, stop no instance, and leave every record to the existing
|
|
// stale-execution repair path.
|
|
m.logger.Error("skipping recovery: live standalone recovery-inventory records could not be read, so no live instance can be correlated to a session",
|
|
zap.Error(listErr))
|
|
records = nil
|
|
}
|
|
|
|
// Take a recovery guard for every named session except a confirmed
|
|
// passthrough one, BEFORE any control server is contacted, so a
|
|
// concurrent launch request cannot race re-tracking
|
|
// (AC-EXECUTORS-SURVIVAL-002.8, AC-EXECUTORS-SURVIVAL-005.3). No lookup
|
|
// configured is treated the same as a failed read: every session is
|
|
// guarded rather than excluded.
|
|
passthroughLookup := m.passthroughLookup
|
|
if passthroughLookup == nil {
|
|
passthroughLookup = func(context.Context, string) (bool, bool) { return false, false }
|
|
}
|
|
guardedSessions := SessionsToGuard(ctx, sessionIDsFromExecutorRunning(records), passthroughLookup)
|
|
for _, sessionID := range guardedSessions {
|
|
m.recoveryGuard.AcquireOrObserve(sessionID)
|
|
}
|
|
|
|
// AC-EXECUTORS-SURVIVAL-005.3: the same classification also decides what
|
|
// re-tracking may reach. A confirmed-passthrough session owns a real
|
|
// agentctl instance that does outlive this backend, so without this the
|
|
// enumeration would correlate it to its record and re-track a session
|
|
// whose PTY agent died with the backend -- reporting it as having
|
|
// survived, which the AC forbids. Only standalone records reach here
|
|
// (ListLiveStandaloneExecutorsRunning), and StandaloneExecutor is the
|
|
// only backend that reads them, so this narrows nothing else.
|
|
records = recoverableRecords(records, guardedSessions)
|
|
|
|
// AC-EXECUTORS-SURVIVAL-003.7: bound this pass's
|
|
// adoption+enumeration+reconstruction work with a single deadline, clocked
|
|
// from this launch's first control-server contact (or, absent one, from
|
|
// right now). recoveryCtx now covers not just RecoverAll (the producer)
|
|
// but every per-instance reconstruction step in the consumer loop below,
|
|
// per design 02's "Startup" steps 4-7 sharing one deadline. A backend
|
|
// implementation that honors ctx.Deadline() (see
|
|
// StandaloneExecutor.RecoverInstances) may still let work already in
|
|
// flight at the deadline finish in the background rather than aborting
|
|
// it -- cancelling recoveryCtx once this pass is over only stops the
|
|
// deadline timer itself from leaking, it never reaches that background
|
|
// work, which deliberately runs against context.Background() instead.
|
|
recoveryCtx, cancelRecovery := context.WithDeadline(ctx, m.recoveryDeadlineDeadline())
|
|
var recovered []*ExecutorInstance
|
|
if listErr == nil {
|
|
var err error
|
|
recovered, err = m.executorRegistry.RecoverAll(recoveryCtx, records)
|
|
if err != nil {
|
|
m.logger.Warn("failed to recover executions from some runtimes", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
var stopWG sync.WaitGroup
|
|
if len(recovered) > 0 {
|
|
for _, ri := range recovered {
|
|
if recoveryCtx.Err() != nil {
|
|
// AC-EXECUTORS-SURVIVAL-003.7: the deadline elapsed before this
|
|
// record could be reconstructed -- treat it as not re-tracked
|
|
// and stop it (against context.Background(), matching every
|
|
// other recovery-time stop: an elapsed deadline must not abort
|
|
// a stop already dispatched).
|
|
m.logger.Warn("recovery deadline elapsed before this record could be reconstructed; treating as not re-tracked",
|
|
zap.String("instance_id", ri.InstanceID),
|
|
zap.String("session_id", ri.SessionID))
|
|
m.dispatchUnreconstructableStop(&stopWG, ri)
|
|
continue
|
|
}
|
|
execution := &AgentExecution{
|
|
ID: ri.InstanceID,
|
|
TaskID: ri.TaskID,
|
|
SessionID: ri.SessionID,
|
|
// AC-EXECUTORS-SURVIVAL-002.14: agent profile identity's
|
|
// declared source is the recovery-inventory record's
|
|
// execution-profile column, carried onto ri by
|
|
// buildRecoveredInstances -- never the adopted instance.
|
|
AgentProfileID: ri.AgentProfileID,
|
|
ContainerID: ri.ContainerID,
|
|
ContainerIP: ri.ContainerIP,
|
|
WorkspacePath: ri.WorkspacePath,
|
|
RuntimeName: ri.RuntimeName,
|
|
Status: v1.AgentStatusRunning,
|
|
StartedAt: time.Now(),
|
|
metadata: ri.Metadata,
|
|
agentctl: ri.Client,
|
|
standaloneInstanceID: ri.StandaloneInstanceID,
|
|
standalonePort: ri.StandalonePort,
|
|
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
|
// AC-EXECUTORS-SURVIVAL-002.14: run identity is re-derived from
|
|
// the runtime environment, which is itself read back from the
|
|
// adopted instance rather than the database (both deliberately
|
|
// memory-only, per design 03's "runtime environment" /
|
|
// "run identity" rows).
|
|
RunID: ri.Env["KANDEV_RUN_ID"],
|
|
// AC-EXECUTORS-SURVIVAL-002.14: workspace source roots are read
|
|
// back from the adopted instance, never pushed -- agentctl owns
|
|
// this allowlist and a rebind may have changed it since launch.
|
|
WorkspaceSourceRoots: ri.WorkspaceSourceRoots,
|
|
// AC-EXECUTORS-SURVIVAL-002.14: provider session identity comes
|
|
// from the adopted instance, which holds the live provider
|
|
// session -- never from a durable/database value, which could be
|
|
// stale relative to what the instance actually resumed.
|
|
ACPSessionID: ri.ProviderSessionID,
|
|
}
|
|
// AC-EXECUTORS-SURVIVAL-002.14's declared source for task
|
|
// identity is the recovery-inventory record alone, never the
|
|
// adopted instance (buildRecoveredInstances no longer falls
|
|
// back to the instance's self-reported task ID). An empty
|
|
// value here means the record itself is authoritatively
|
|
// missing it: refuse to re-track, per AC-EXECUTORS-SURVIVAL-002.4,
|
|
// rather than trusting the instance's own claim about which
|
|
// task it belongs to.
|
|
if execution.TaskID == "" {
|
|
m.logger.Error("refusing to re-track recovered execution: task identity was not present in the recovery-inventory record",
|
|
zap.String("instance_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID))
|
|
m.dispatchUnreconstructableStop(&stopWG, ri)
|
|
continue
|
|
}
|
|
// A recovered instance is resuming a live provider session (its
|
|
// ACPSessionID above comes straight from the adopted instance), so
|
|
// ACP session setup is already complete -- unlike a fresh launch,
|
|
// there is no InitializeAndPrompt call on this path to mark it.
|
|
// Leaving this false blocks SetSessionMode/SetSessionModel/
|
|
// SetSessionConfigOption ("ACP session is not ready") on every
|
|
// re-tracked execution until its next full relaunch.
|
|
execution.setSessionInitialized(true)
|
|
execution.setRuntimeEnvironment(ri.Env)
|
|
if err := m.hydrateRecoveredTaskEnvironmentID(recoveryCtx, execution); err != nil {
|
|
m.logger.Error("refusing to re-track recovered execution: task environment identity could not be reconstructed",
|
|
zap.String("instance_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.Error(err))
|
|
m.dispatchUnreconstructableStop(&stopWG, ri)
|
|
continue
|
|
}
|
|
// AC-EXECUTORS-SURVIVAL-002.14: Office profile identity is a new key
|
|
// in the same persisted metadata this record already carries, not a
|
|
// new source -- an empty value here is a legitimate non-Office
|
|
// launch, not a missing reconstruction.
|
|
execution.OfficeAgentProfileID = getMetadataString(ri.Metadata, MetadataKeyOfficeAgentProfileID)
|
|
// AC-EXECUTORS-SURVIVAL-002.14: agent identity, agent and
|
|
// continuation commands and their arguments, and the history
|
|
// setting are re-derived from the restored agent profile and the
|
|
// agent-type registry, never read from the instance. When the
|
|
// re-derivation cannot answer (AC-EXECUTORS-SURVIVAL-002.4: the
|
|
// declared source answered and the value is not present -- an
|
|
// unresolvable profile or an agent type no longer in the
|
|
// registry), this instance is authoritatively missing a required
|
|
// value: refuse to re-track it and leave it to the stop path
|
|
// AC-EXECUTORS-SURVIVAL-002.6 defines, rather than tracking a
|
|
// session the backend cannot safely operate.
|
|
if err := m.reDeriveRecoveredAgentIdentity(recoveryCtx, execution); err != nil {
|
|
m.logger.Error("refusing to re-track recovered execution: agent identity could not be reconstructed",
|
|
zap.String("instance_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.String("agent_profile_id", execution.AgentProfileID),
|
|
zap.Error(err))
|
|
m.dispatchUnreconstructableStop(&stopWG, ri)
|
|
continue
|
|
}
|
|
// AC-EXECUTORS-SURVIVAL-004.2/004.5: retrieve this instance's
|
|
// retained turn status before this session's state is published.
|
|
// A read failure (retries exhausted) must not publish the session
|
|
// as running -- it is authoritatively unknown, not running -- so
|
|
// it takes the same not-re-tracked stop path as an
|
|
// unreconstructable agent identity above.
|
|
turnOutcome, turnOutcomeResult := m.retrieveRecoveredTurnOutcome(recoveryCtx, ri)
|
|
if turnOutcomeResult == recoveredTurnOutcomeReadFailed {
|
|
m.logger.Error("refusing to re-track recovered execution: turn status could not be retrieved",
|
|
zap.String("instance_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID))
|
|
m.dispatchUnreconstructableStop(&stopWG, ri)
|
|
continue
|
|
}
|
|
// Create trace span for the recovered session
|
|
_, recoverySpan := tracing.TraceSessionRecovered(
|
|
context.Background(), execution.TaskID, execution.SessionID, execution.ID,
|
|
)
|
|
execution.SetSessionSpan(recoverySpan)
|
|
if ri.Client != nil {
|
|
ri.Client.SetTraceContext(execution.SessionTraceContext())
|
|
}
|
|
|
|
// Create short-lived init span so recovery-phase operations are visible
|
|
_, initSpan := tracing.TraceSessionInit(
|
|
execution.SessionTraceContext(), execution.TaskID, execution.SessionID, execution.ID,
|
|
)
|
|
|
|
if err := m.executionStore.Add(execution); err != nil {
|
|
// Should not happen at startup — duplicate sessions in the recovery
|
|
// list signal a DB consistency issue, not a normal race. Log loudly
|
|
// and skip; the first one to land wins.
|
|
m.logger.Error("skipping duplicate execution during recovery",
|
|
zap.String("execution_id", execution.ID),
|
|
zap.String("session_id", execution.SessionID),
|
|
zap.Error(err))
|
|
if ri.Client != nil {
|
|
ri.Client.Close()
|
|
}
|
|
execution.EndSessionSpan()
|
|
initSpan.End()
|
|
continue
|
|
}
|
|
m.setRuntimeInterest(execution.SessionID, true)
|
|
// AC-EXECUTORS-SURVIVAL-003.1: this execution is durably in the
|
|
// store as of the Add above, so its session is re-tracked from
|
|
// this point on regardless of which branch below applies the
|
|
// retained turn outcome or publishes running.
|
|
m.markSessionRetracked(execution.SessionID)
|
|
|
|
// Reconcile the persistence row to match the recovered in-memory ID.
|
|
// If executors_running.agent_execution_id had drifted (e.g. from a
|
|
// prior bug or manual edit), the recovered runtime instance is the
|
|
// truth — overwrite the row to match. No-op if already in sync.
|
|
m.persistExecutorRunning(recoveryCtx, execution)
|
|
|
|
// Re-seed the base-branch map before reconnecting. A surviving
|
|
// agentctl instance never passes through waitForAgentctlReady, so
|
|
// without this its trackers keep whatever map they had — or none,
|
|
// if it was created by a path that could not supply one — and
|
|
// their diff stats stay pinned to an integration-branch fallback.
|
|
if client, releaseClient := execution.AcquireAgentCtlClient(); client != nil {
|
|
m.pushTaskBaseBranches(recoveryCtx, execution.TaskID, execution.ID, client)
|
|
m.pushTaskComparisonTargets(recoveryCtx, execution.TaskID, execution.ID, client)
|
|
releaseClient()
|
|
}
|
|
|
|
// AC-EXECUTORS-SURVIVAL-004.2: apply the retained outcome, or
|
|
// publish running when nothing was retained, before streams
|
|
// reconnect below -- reconnecting is what would eventually
|
|
// deliver the same terminal event live (AC-EXECUTORS-SURVIVAL-
|
|
// 004.3/004.4), so this ordering makes the recovery-time
|
|
// application the one that wins in the common case.
|
|
if turnOutcomeResult == recoveredTurnOutcomeApplied {
|
|
m.applyRecoveredTurnOutcome(recoveryCtx, execution, ri, turnOutcome)
|
|
} else {
|
|
m.publishRecoveredExecutionRunning(recoveryCtx, execution)
|
|
}
|
|
|
|
// AC-EXECUTORS-SURVIVAL-003.7/002.8: release this session's guard
|
|
// now that its outcome has actually been published, rather than
|
|
// waiting for every session in this pass to finish (design 02
|
|
// step 7's "releasing each session's guard as its outcome is
|
|
// published"). ReleaseAllExceptRetained below still covers any
|
|
// session a later layer marked retained-unstoppable or
|
|
// stop-in-flight, and any session this loop never reached.
|
|
m.recoveryGuard.Release(execution.SessionID)
|
|
|
|
// Reconnect to workspace streams (shell, git, file changes) in background
|
|
// This is needed so shell.input, git status, etc. work after backend restart
|
|
go m.streamManager.ReconnectAll(execution)
|
|
|
|
initSpan.End()
|
|
}
|
|
m.logger.Info("recovered executions", zap.Int("count", len(recovered)))
|
|
}
|
|
cancelRecovery()
|
|
|
|
// AC-EXECUTORS-SURVIVAL-002.11: every dispatchUnreconstructableStop call
|
|
// above ran on its own goroutine so one record's bounded-retry stop
|
|
// latency never starves another record's reconstruction of this pass's
|
|
// shared deadline budget. Wait for all of them to resolve (each one
|
|
// settles its own session's guard via RetainAsUnstoppable or Release
|
|
// before returning) so Start remains synchronous overall and
|
|
// ReleaseAllExceptRetained below never races a still-resolving stop.
|
|
stopWG.Wait()
|
|
|
|
// Recovery is synchronous above: by this point every guarded session's
|
|
// outcome (re-tracked or not) is already decided, so every guard still
|
|
// held for this pass is released now. Most were already released
|
|
// individually inside the loop as each session's outcome was published;
|
|
// this covers a session whose record was skipped by the deadline check,
|
|
// a duplicate-execution anomaly, or anything a later layer marks
|
|
// retained-unstoppable or stop-in-flight (AC-EXECUTORS-SURVIVAL-002.16,
|
|
// AC-EXECUTORS-SURVIVAL-003.7), which ReleaseAllExceptRetained leaves held.
|
|
m.recoveryGuard.ReleaseAllExceptRetained()
|
|
|
|
// AC-EXECUTORS-SURVIVAL-003.6: this pass's recovery work (adoption,
|
|
// enumeration, and every recovered instance's reconstruction above) is
|
|
// synchronous and now complete, so a caller outside a reconciliation
|
|
// pass may safely enumerate live standalone instances from this point on.
|
|
m.recoveryComplete.Store(true)
|
|
|
|
// Start remote status polling loop for runtimes exposing remote status.
|
|
m.wg.Add(1)
|
|
go m.remoteStatusLoop(ctx)
|
|
m.logger.Info("remote status loop started")
|
|
// Set up callbacks for passthrough mode (using standalone runtime)
|
|
if standaloneRT, err := m.executorRegistry.GetBackend(executor.NameStandalone); err == nil {
|
|
if interactiveRunner := standaloneRT.GetInteractiveRunner(); interactiveRunner != nil {
|
|
// Turn complete callback
|
|
interactiveRunner.SetTurnCompleteCallback(func(sessionID, processID string) {
|
|
m.handlePassthroughTurnComplete(sessionID, processID)
|
|
})
|
|
|
|
// Output callback for standalone passthrough (no WorkspaceTracker)
|
|
interactiveRunner.SetOutputCallback(func(output *agentctltypes.ProcessOutput) {
|
|
m.handlePassthroughOutput(output)
|
|
})
|
|
|
|
// Status callback for standalone passthrough (no WorkspaceTracker)
|
|
interactiveRunner.SetStatusCallback(func(status *agentctltypes.ProcessStatusUpdate) {
|
|
m.handlePassthroughStatus(status)
|
|
})
|
|
|
|
m.logger.Info("passthrough callbacks configured")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hydrateRecoveredTaskEnvironmentID fills in a recovered execution's
|
|
// task-environment identity (AC-EXECUTORS-SURVIVAL-002.14): the declared
|
|
// source is the durable store, via the session's own task-environment
|
|
// reference, not the adopted instance -- ExecutorInstance carries no such
|
|
// field at all. Task-environment identity is one of AC-EXECUTORS-SURVIVAL-002.3's
|
|
// required reconstructed values, so unlike an earlier version of this method
|
|
// (which logged and ignored both failure shapes below), neither is silently
|
|
// swallowed: a lookup failure is reported to the caller as an
|
|
// AC-EXECUTORS-SURVIVAL-002.13 failed read, and a durable store that answers
|
|
// successfully with no value at all is the AC-EXECUTORS-SURVIVAL-002.4
|
|
// authoritatively-absent case. A nil provider is a capability this
|
|
// deployment never wired at all -- every production backend wires one via
|
|
// SetWorkspaceInfoProvider, only bare-bones test doubles don't -- so it is
|
|
// treated like any other absent optional recovery capability: skip, not
|
|
// refuse.
|
|
func (m *Manager) hydrateRecoveredTaskEnvironmentID(ctx context.Context, execution *AgentExecution) error {
|
|
if m.workspaceInfoProvider == nil {
|
|
return nil
|
|
}
|
|
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, execution.TaskID, execution.SessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("task environment identity read failed: %w", err)
|
|
}
|
|
if info == nil || info.TaskEnvironmentID == "" {
|
|
return errors.New("task environment identity: durable store answered with no value")
|
|
}
|
|
execution.TaskEnvironmentID = info.TaskEnvironmentID
|
|
return nil
|
|
}
|
|
|
|
// reDeriveRecoveredAgentIdentity implements AC-EXECUTORS-SURVIVAL-002.14's
|
|
// last reconstruction-table row: agent identity, agent and continuation
|
|
// commands and their arguments, and the history setting are re-derived from
|
|
// the restored agent profile and the agent-type registry -- the same
|
|
// computation an ordinary launch performs, never read from the adopted
|
|
// instance, so a compromised or stale instance cannot tell the backend which
|
|
// command to run for the session's next turn.
|
|
//
|
|
// Re-derivation reflects the profile and registry as they stand at recovery,
|
|
// not as they stood before the restart (AC-EXECUTORS-SURVIVAL-002.3): a
|
|
// profile edited or an agent type removed during the outage takes effect
|
|
// exactly as it would on the session's next ordinary turn.
|
|
//
|
|
// getAgentConfigForExecution and buildFreshAgentCommand are the same
|
|
// primitives an in-session context reset uses (manager_interaction.go's
|
|
// prepareAgentRestart), reused as-is: both already consume nothing but
|
|
// execution fields already restored by this point (AgentProfileID,
|
|
// RuntimeName, metadata), with no launch-request-only dependency.
|
|
func (m *Manager) reDeriveRecoveredAgentIdentity(ctx context.Context, execution *AgentExecution) error {
|
|
agentConfig, err := m.getAgentConfigForExecution(execution)
|
|
if err != nil {
|
|
return fmt.Errorf("agent identity: %w", err)
|
|
}
|
|
execution.AgentID = agentConfig.ID()
|
|
if rt := agentConfig.Runtime(); rt != nil {
|
|
execution.historyEnabled = rt.SessionConfig.HistoryContextInjection ||
|
|
rt.SessionConfig.NewSessionOnWorkspaceRebind
|
|
}
|
|
commands, err := m.buildFreshAgentCommand(ctx, execution, agentConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("agent and continuation commands: %w", err)
|
|
}
|
|
execution.AgentCommand = commands.initial
|
|
execution.ContinueCommand = commands.continue_
|
|
execution.AgentArgs = commands.args
|
|
execution.ContinueArgs = commands.continueArgs
|
|
return nil
|
|
}
|
|
|
|
// recoveryStopper is the optional capability satisfied by *StandaloneExecutor
|
|
// that stops a recovered instance within the bounded per-attempt timeout and
|
|
// retry count AC-EXECUTORS-SURVIVAL-002.15 requires for every recovery-time
|
|
// stop -- the same budget stopWithRetry already applies to the
|
|
// AC-EXECUTORS-SURVIVAL-002.6/002.10 loser/orphan stop paths in
|
|
// StandaloneExecutor.RecoverInstances. A backend that doesn't implement it
|
|
// falls back to a single StopInstance call: AC-EXECUTORS-SURVIVAL-002.15's
|
|
// retry contract covers the WORKTREE and LOCAL executor types only, both
|
|
// standalone-runtime.
|
|
type recoveryStopper interface {
|
|
stopWithRetry(ctx context.Context, instanceID string) error
|
|
}
|
|
|
|
// dispatchUnreconstructableStop starts stopUnreconstructableRecoveredInstance
|
|
// on its own goroutine and registers it on wg, so one record's bounded-retry
|
|
// stop latency cannot block the caller's loop from reconstructing every
|
|
// other record within the same AC-EXECUTORS-SURVIVAL-003.7 shared deadline
|
|
// (AC-EXECUTORS-SURVIVAL-002.11: "no such instance's outcome shall depend on
|
|
// another's"). MarkStopInFlight runs synchronously first, before the loop
|
|
// can reach ReleaseAllExceptRetained, so this session's guard survives the
|
|
// bulk release until stopUnreconstructableRecoveredInstance resolves it
|
|
// (Release on success, RetainAsUnstoppable on failure).
|
|
func (m *Manager) dispatchUnreconstructableStop(wg *sync.WaitGroup, ri *ExecutorInstance) {
|
|
m.recoveryGuard.MarkStopInFlight(ri.SessionID)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
m.stopUnreconstructableRecoveredInstance(context.Background(), ri)
|
|
}()
|
|
}
|
|
|
|
// stopUnreconstructableRecoveredInstance implements the
|
|
// AC-EXECUTORS-SURVIVAL-002.4 refusal path's "leave the instance to the stop
|
|
// path defined by AC-EXECUTORS-SURVIVAL-002.6" for an instance whose agent
|
|
// identity could not be re-derived: it was never added to the execution
|
|
// store, so RemoveExecution's teardown does not apply -- stop the live
|
|
// instance directly through its owning runtime backend. Run against
|
|
// context.Background(), matching every sibling AC-002.6/002.10 recovery stop
|
|
// (executor_standalone.go's dispatchRecoveryStops): an elapsed recovery
|
|
// deadline (AC-EXECUTORS-SURVIVAL-003.7) must not abort a stop already in
|
|
// flight. Every return path resolves this session's recovery guard
|
|
// (AC-EXECUTORS-SURVIVAL-002.16): a live instance this call could not stop
|
|
// (no backend for its runtime, or the stop itself failed) retains the guard
|
|
// for the rest of this backend's lifetime rather than letting it fall
|
|
// through to a bulk release that would let a fresh launch race the still-live
|
|
// instance; the instance is left to the existing stale-execution repair path
|
|
// like any other recovery-time stop failure that isn't part of the joint
|
|
// winner/loser accounting.
|
|
func (m *Manager) stopUnreconstructableRecoveredInstance(ctx context.Context, ri *ExecutorInstance) {
|
|
backend, err := m.executorRegistry.GetBackend(ri.RuntimeName)
|
|
if err != nil {
|
|
m.logger.Warn("cannot stop unreconstructable recovered instance: no backend for its runtime",
|
|
zap.String("instance_id", ri.InstanceID),
|
|
zap.String("session_id", ri.SessionID),
|
|
zap.String("runtime", string(ri.RuntimeName)),
|
|
zap.Error(err))
|
|
m.recoveryGuard.RetainAsUnstoppable(ri.SessionID)
|
|
return
|
|
}
|
|
if stopper, ok := backend.(recoveryStopper); ok {
|
|
if err := stopper.stopWithRetry(context.Background(), ri.StandaloneInstanceID); err != nil {
|
|
m.logger.Warn("failed to stop unreconstructable recovered instance after exhausting retries",
|
|
zap.String("instance_id", ri.InstanceID),
|
|
zap.String("session_id", ri.SessionID),
|
|
zap.Error(err))
|
|
m.recoveryGuard.RetainAsUnstoppable(ri.SessionID)
|
|
return
|
|
}
|
|
m.recoveryGuard.Release(ri.SessionID)
|
|
return
|
|
}
|
|
if err := backend.StopInstance(ctx, ri, true); err != nil {
|
|
m.logger.Warn("failed to stop unreconstructable recovered instance",
|
|
zap.String("instance_id", ri.InstanceID),
|
|
zap.String("session_id", ri.SessionID),
|
|
zap.Error(err))
|
|
m.recoveryGuard.RetainAsUnstoppable(ri.SessionID)
|
|
return
|
|
}
|
|
m.recoveryGuard.Release(ri.SessionID)
|
|
}
|
|
|
|
// defaultRecoveryDeadline is the AC-EXECUTORS-SURVIVAL-003.7 fallback used
|
|
// when no recovery deadline was configured (recoveryDeadline left zero).
|
|
const defaultRecoveryDeadline = 30 * time.Second
|
|
|
|
// recoveryDeadlineDeadline resolves the wall-clock instant this startup
|
|
// pass's recovery work must stop admitting new work by, per
|
|
// AC-EXECUTORS-SURVIVAL-003.7.
|
|
func (m *Manager) recoveryDeadlineDeadline() time.Time {
|
|
start := m.recoveryDeadlineStart
|
|
if start.IsZero() {
|
|
start = time.Now()
|
|
}
|
|
period := m.recoveryDeadline
|
|
if period <= 0 {
|
|
period = defaultRecoveryDeadline
|
|
}
|
|
return start.Add(period)
|
|
}
|
|
|
|
// sessionIDsFromExecutorRunning extracts the distinct, non-empty session
|
|
// identities named by a set of recovery-inventory records.
|
|
func sessionIDsFromExecutorRunning(records []*models.ExecutorRunning) []string {
|
|
sessionIDs := make([]string, 0, len(records))
|
|
for _, rec := range records {
|
|
if rec != nil && rec.SessionID != "" {
|
|
sessionIDs = append(sessionIDs, rec.SessionID)
|
|
}
|
|
}
|
|
return sessionIDs
|
|
}
|
|
|
|
// GetRecoveredExecutions returns a snapshot of all currently tracked executions
|
|
// This can be used by the orchestrator to sync with the database
|
|
func (m *Manager) GetRecoveredExecutions() []RecoveredExecution {
|
|
executions := m.executionStore.List()
|
|
result := make([]RecoveredExecution, 0, len(executions))
|
|
for _, exec := range executions {
|
|
result = append(result, RecoveredExecution{
|
|
ExecutionID: exec.ID,
|
|
TaskID: exec.TaskID,
|
|
SessionID: exec.SessionID,
|
|
ContainerID: exec.ContainerID,
|
|
AgentProfileID: exec.officeProfileID(),
|
|
ExecutionProfileID: exec.AgentProfileID,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// IsShuttingDown reports whether graceful shutdown has begun. Set by
|
|
// StopAllAgents before it starts tearing down executions so concurrent
|
|
// handlers (e.g. passthrough exit auto-restart, agentctl HTTP calls) can
|
|
// skip or downgrade work that would otherwise race the teardown.
|
|
func (m *Manager) IsShuttingDown() bool {
|
|
return m.shuttingDown.Load()
|
|
}
|
|
|
|
// closeStopCh closes the manager shutdown channel at most once.
|
|
func (m *Manager) closeStopCh() {
|
|
m.stopOnce.Do(func() { close(m.stopCh) })
|
|
}
|
|
|
|
// Stop stops the lifecycle manager and releases resources held by executors.
|
|
func (m *Manager) Stop() error {
|
|
m.logger.Info("stopping lifecycle manager")
|
|
|
|
m.closeStopCh()
|
|
if m.streamManager != nil {
|
|
m.streamManager.Wait()
|
|
}
|
|
m.wg.Wait()
|
|
|
|
// Close executor backends that hold resources (e.g., Docker SDK client).
|
|
if m.executorRegistry != nil {
|
|
m.executorRegistry.CloseAll()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopReasonBackendShutdown identifies the graceful backend-shutdown path.
|
|
// Executors may preserve resumable remote state for this reason only; user
|
|
// stops and rollback cleanup deliberately retain their destructive semantics.
|
|
const StopReasonBackendShutdown = "backend shutdown"
|
|
|
|
// StopAllAgents attempts a graceful shutdown of all active agents concurrently.
|
|
func (m *Manager) StopAllAgents(ctx context.Context) error {
|
|
m.shuttingDown.Store(true)
|
|
|
|
executions := m.executionStore.List()
|
|
if len(executions) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, len(executions))
|
|
|
|
for _, exec := range executions {
|
|
wg.Add(1)
|
|
go func(e *AgentExecution) {
|
|
defer wg.Done()
|
|
if err := m.StopAgentWithReason(ctx, e.ID, StopReasonBackendShutdown, false); err != nil {
|
|
errCh <- err
|
|
m.logger.Warn("failed to stop agent during shutdown",
|
|
zap.String("execution_id", e.ID),
|
|
zap.Error(err))
|
|
}
|
|
}(exec)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errCh)
|
|
|
|
var errs []error
|
|
for err := range errCh {
|
|
errs = append(errs, err)
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
const stopReasonStaleExecutionCleanup = "stale execution cleanup"
|
|
|
|
// CleanupStaleExecutionBySessionID cleans up a stale execution: stops the runtime
|
|
// instance, closes the client connection, and removes it from tracking.
|
|
//
|
|
// A "stale" execution is one where the agent process has stopped externally (crashed, killed,
|
|
// or terminated outside of our control) but the execution is still tracked in memory.
|
|
//
|
|
// When to use this:
|
|
// - After detecting the agentctl HTTP server is unreachable
|
|
// - When the agent container no longer exists (Docker runtime)
|
|
// - After server restart when recovering persisted state
|
|
// - When IsAgentRunningForSession returns false but execution exists
|
|
//
|
|
// This method performs cleanup:
|
|
// 1. Stops the runtime instance (workspace tracker, shell, etc.) via the executor backend
|
|
// 2. Closes the agentctl HTTP client connection
|
|
// 3. Removes the execution from the in-memory tracking store
|
|
//
|
|
// What this does NOT do:
|
|
// - Clean up worktrees or containers (caller's responsibility)
|
|
// - Update database session state (caller's responsibility)
|
|
//
|
|
// Safe to call even if the process is already stopped — StopInstance is idempotent.
|
|
//
|
|
// Returns nil if no execution exists for the session (idempotent).
|
|
func (m *Manager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists {
|
|
return nil // No execution to clean up
|
|
}
|
|
return m.cleanupStaleExecution(ctx, execution)
|
|
}
|
|
|
|
// CleanupStaleExecutionBySessionIDIfCurrent cleans up only the execution that
|
|
// the caller observed. The operation shares the session-keyed singleflight
|
|
// used by launch paths, so a successor launch either wins before cleanup or
|
|
// runs after the stale execution has been removed. The persisted timestamp
|
|
// check closes the remaining same-ID workspace-promotion window.
|
|
func (m *Manager) CleanupStaleExecutionBySessionIDIfCurrent(
|
|
ctx context.Context,
|
|
sessionID, expectedExecutionID string,
|
|
expectedUpdatedAt time.Time,
|
|
) error {
|
|
if sessionID == "" || expectedExecutionID == "" {
|
|
return nil
|
|
}
|
|
_, err := m.doCoalescedExecution(ctx, sessionID, func(sharedCtx context.Context) (interface{}, error) {
|
|
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
|
if !exists || execution == nil || execution.ID != expectedExecutionID {
|
|
return nil, nil
|
|
}
|
|
if reader, ok := m.runningWriter.(executorRunningReader); ok && !expectedUpdatedAt.IsZero() {
|
|
current, readErr := reader.GetExecutorRunningBySessionID(sharedCtx, sessionID)
|
|
if readErr != nil || current == nil || current.AgentExecutionID != expectedExecutionID ||
|
|
!current.UpdatedAt.Equal(expectedUpdatedAt) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
return nil, m.cleanupStaleExecution(sharedCtx, execution)
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) cleanupStaleExecution(ctx context.Context, execution *AgentExecution) error {
|
|
if execution == nil {
|
|
return nil
|
|
}
|
|
execution.remoteInstanceLifecycleMu.Lock()
|
|
defer execution.remoteInstanceLifecycleMu.Unlock()
|
|
execution.agentctlLifecycleMu.Lock()
|
|
sessionID := execution.SessionID
|
|
|
|
m.logger.Info("cleaning up stale agent execution",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("execution_id", execution.ID))
|
|
|
|
// Stop the runtime instance (workspace tracker, shell, etc.) to prevent leaked
|
|
// goroutines. Without this, the old agentctl instance keeps running when a new
|
|
// execution is created for the same session, causing git polling on deleted worktrees.
|
|
// This is idempotent — returns success if the instance is already gone.
|
|
if err := m.stopAgentViaBackend(ctx, execution.ID, execution, stopReasonStaleExecutionCleanup, false, false); err != nil {
|
|
execution.agentctlLifecycleMu.Unlock()
|
|
return fmt.Errorf("stop stale runtime %s: %w", execution.ID, err)
|
|
}
|
|
|
|
// End session trace span only after the runtime has stopped. A failed stop
|
|
// remains retryable and should keep the execution's lifecycle intact.
|
|
execution.EndSessionSpan()
|
|
|
|
// Close agentctl connection if it exists
|
|
if client := execution.currentAgentCtlClient(); client != nil { // protected by agentctlLifecycleMu
|
|
client.Close()
|
|
}
|
|
execution.detachAgentctlClient()
|
|
execution.agentctlLifecycleMu.Unlock()
|
|
|
|
// Remove from execution store
|
|
m.RemoveExecution(execution.ID)
|
|
|
|
// Delete the persistence row in lockstep with store removal so we never
|
|
// leave a phantom executors_running row pointing at a non-existent
|
|
// execution. Best-effort; "not found" is expected and silently swallowed.
|
|
m.deleteExecutorRunning(ctx, sessionID, execution.ID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveExecution removes an execution from tracking.
|
|
//
|
|
// ⚠️ WARNING: This is a potentially dangerous operation that should only be called when:
|
|
// 1. The agent process has been fully stopped (via StopAgent)
|
|
// 2. All cleanup operations have completed (worktree cleanup, container removal)
|
|
// 3. The execution is in a terminal state (Completed, Failed, or Cancelled)
|
|
//
|
|
// This method:
|
|
// - Removes the execution from the in-memory store
|
|
// - Makes the sessionID available for new executions
|
|
// - Does NOT stop the agent process (call StopAgent first)
|
|
// - Does NOT close the agentctl client (use the execution lifecycle lock first)
|
|
// - Does NOT clean up resources (worktrees, containers, etc.)
|
|
//
|
|
// After calling this, the executionID and sessionID can no longer be used to query
|
|
// or control the execution. Any references to this execution will become invalid.
|
|
//
|
|
// Typical usage: Called by cleanup loops or after successful StopAgent completion.
|
|
// For stale/dead executions, use CleanupStaleExecutionBySessionID instead.
|
|
func (m *Manager) RemoveExecution(executionID string) {
|
|
m.releaseActivity(executionActivityKey(executionID))
|
|
if execution, ok := m.executionStore.Get(executionID); ok {
|
|
m.closeStreamCoalescer(execution)
|
|
m.cleanupPassthroughMCPConfig(execution)
|
|
m.setRuntimeInterest(execution.SessionID, false)
|
|
}
|
|
m.executionStore.Remove(executionID)
|
|
m.logger.Debug("removed execution from tracking",
|
|
zap.String("execution_id", executionID))
|
|
}
|