Files
2026-09-16 09:46:13 +01:00

659 lines
26 KiB
Go

package lifecycle
import (
"context"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/subproc"
"github.com/kandev/kandev/internal/task/models"
)
// defaultRecoveryReadTimeout and defaultRecoveryReadRetries are the
// AC-EXECUTORS-SURVIVAL-002.13 defaults (two seconds, two retries), used
// whenever SetRecoveryRetryConfig has not installed a configured value.
const (
defaultRecoveryReadTimeout = 2 * time.Second
defaultRecoveryReadRetries = 2
)
// UnstoppableSessionRecorder is the recovery-guard sink StandaloneExecutor
// drives for a session's not-re-tracked outcome during recovery: retains a
// session's guard for the rest of this backend's lifetime when
// AC-EXECUTORS-SURVIVAL-002.16 applies (at least one live instance could not
// be stopped despite AC-EXECUTORS-SURVIVAL-002.15's bounded retry), marks a
// session whose stop is still resolving when the AC-EXECUTORS-SURVIVAL-003.7
// recovery deadline elapses mid-retry, and releases that guard once the
// resolution is known. Satisfied structurally by *lifecycle.RecoveryGuard
// (see Manager.RecoveryGuard).
type UnstoppableSessionRecorder interface {
RetainAsUnstoppable(sessionID string)
// MarkStopInFlight records that sessionID's guard must survive
// ReleaseAllExceptRetained until its still-resolving stop is known,
// rather than being released at the deadline bound.
MarkStopInFlight(sessionID string)
// Release drops sessionID's guard once its resolution is known. A no-op
// for a session RetainAsUnstoppable has already claimed.
Release(sessionID string)
}
// StandaloneExecutor implements Runtime for standalone agentctl execution.
// In this mode, a single agentctl control server manages multiple agent instances.
type StandaloneExecutor struct {
ctl *agentctl.ControlClient
host string
port int
authToken string // per-launch auth token from launcher
logger *logger.Logger
interactiveRunner *process.InteractiveRunner
recoveryReadTimeout time.Duration
recoveryReadRetries int
unstoppableRecorder UnstoppableSessionRecorder
}
// NewStandaloneExecutor creates a new standalone runtime.
func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneExecutor {
return &StandaloneExecutor{
ctl: ctl,
host: host,
port: port,
logger: log.WithFields(zap.String("runtime", "standalone")),
// -1 is the "unset" sentinel: zero is itself a valid configured
// retry count (AC-EXECUTORS-SURVIVAL-002.13 allows zero retries), so
// the zero value of an unset int field can't be used to mean unset.
recoveryReadRetries: -1,
}
}
// SetAuthToken sets the per-launch auth token for authenticating instance clients.
func (r *StandaloneExecutor) SetAuthToken(token string) {
r.authToken = token
}
// SetRecoveryRetryConfig installs the bounded per-attempt timeout and retry
// count AC-EXECUTORS-SURVIVAL-002.13/002.15 require for recovery reads and
// stops. A zero timeout or negative retry count falls back to the two-second/
// two-retry default the AC itself specifies.
func (r *StandaloneExecutor) SetRecoveryRetryConfig(timeout time.Duration, retries int) {
r.recoveryReadTimeout = timeout
r.recoveryReadRetries = retries
}
// SetUnstoppableSessionRecorder installs the AC-EXECUTORS-SURVIVAL-002.16 sink.
// Unset means an unstoppable instance is only logged, never retained --
// acceptable for callers that haven't wired a recovery guard.
func (r *StandaloneExecutor) SetUnstoppableSessionRecorder(recorder UnstoppableSessionRecorder) {
r.unstoppableRecorder = recorder
}
// stopWithRetry stops instanceID within the bounded per-attempt timeout and
// retry count of AC-EXECUTORS-SURVIVAL-002.13/002.15, with no delay between
// attempts. DeleteInstance itself already treats an already-absent instance
// (404) as success.
func (r *StandaloneExecutor) stopWithRetry(ctx context.Context, instanceID string) error {
timeout := r.recoveryReadTimeout
if timeout <= 0 {
timeout = defaultRecoveryReadTimeout
}
retries := r.recoveryReadRetries
if retries < 0 {
retries = defaultRecoveryReadRetries
}
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
lastErr = r.ctl.DeleteInstance(attemptCtx, instanceID)
cancel()
if lastErr == nil {
return nil
}
}
return lastErr
}
// listInstancesWithRetry enumerates the adopted control server's live
// instances within the same bounded per-attempt timeout and retry count as
// stopWithRetry (AC-EXECUTORS-SURVIVAL-002.13): this enumeration is the sole
// read source for two AC-EXECUTORS-SURVIVAL-002.3 reconstruction-table
// values -- workspace source roots and provider session identity -- both
// declared to come from the adopted instance, so a transient failure here
// must be retried before an instance's reconstruction is given up on.
func (r *StandaloneExecutor) listInstancesWithRetry(ctx context.Context) ([]*agentctl.InstanceInfo, error) {
timeout := r.recoveryReadTimeout
if timeout <= 0 {
timeout = defaultRecoveryReadTimeout
}
retries := r.recoveryReadRetries
if retries < 0 {
retries = defaultRecoveryReadRetries
}
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
instances, err := r.ctl.ListInstances(attemptCtx)
cancel()
if err == nil {
return instances, nil
}
lastErr = err
}
return nil, lastErr
}
func (r *StandaloneExecutor) Name() executor.Name {
return executor.NameStandalone
}
func (r *StandaloneExecutor) HealthCheck(ctx context.Context) error {
return r.ctl.Health(ctx)
}
// SubprocessAdmission returns the admission snapshot from the host agentctl
// control server for backend diagnostics.
func (r *StandaloneExecutor) SubprocessAdmission(ctx context.Context) (subproc.Snapshot, error) {
return r.ctl.SubprocessAdmission(ctx)
}
func (r *StandaloneExecutor) waitForReady(ctx context.Context) error {
if err := r.ctl.Health(ctx); err == nil {
return nil
}
waitCtx := ctx
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
}
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-waitCtx.Done():
return fmt.Errorf("agentctl not ready: %w", waitCtx.Err())
case <-ticker.C:
if err := r.ctl.Health(waitCtx); err == nil {
return nil
}
}
}
}
func buildStandaloneCreateInstanceRequest(
req *ExecutorCreateRequest,
env map[string]string,
agentType string,
disableAskQuestion, assumeMcpSse, assumeMcpHttp, requiresProcessKill bool,
stripEnv []string,
) *agentctl.CreateInstanceRequest {
return &agentctl.CreateInstanceRequest{
ID: req.InstanceID,
WorkspacePath: req.WorkspacePath,
AgentCommand: "", // Agent command set via Configure endpoint
Protocol: req.Protocol,
AgentType: agentType,
Env: env,
AutoApprovePermissions: autoApprovePermissionsOverride(
req.AutoApprovePermissions,
req.AutoApprovePermissionsOverride,
),
AutoStart: false,
McpServers: req.McpServers,
SessionID: req.SessionID,
TaskID: req.TaskID,
DisableAskQuestion: disableAskQuestion,
AssumeMcpSse: assumeMcpSse,
AssumeMcpHttp: assumeMcpHttp,
McpMode: req.McpMode,
McpProviders: req.McpProviders,
McpProfile: req.McpProfile,
NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromReq(req),
RequiresProcessKill: requiresProcessKill,
StripEnv: stripEnv,
ProviderGatewayAuth: req.ProviderGatewayAuth,
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
RemoteContributions: req.RemoteContributions,
ContributionDestinations: req.ContributionDestinations,
ComparisonTargets: req.ComparisonTargets,
WorkspaceSourceRoots: req.WorkspaceSourceRoots,
}
}
func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
if err := r.waitForReady(ctx); err != nil {
return nil, err
}
// Build environment variables
env := req.Env
if env == nil {
env = make(map[string]string)
}
env["KANDEV_TASK_ID"] = req.TaskID
env["KANDEV_SESSION_ID"] = req.SessionID
// Create instance via control API
// Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client.
agentType := ""
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
}
disableAskQuestion := !agents.SupportsInteractiveMCPTools(req.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
var stripEnv []string
if req.AgentConfig != nil {
if rt := req.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
requiresProcessKill = rt.RequiresProcessKill
stripEnv = rt.StripEnv
}
}
createReq := buildStandaloneCreateInstanceRequest(
req, env, agentType, disableAskQuestion, assumeMcpSse, assumeMcpHttp, requiresProcessKill, stripEnv,
)
r.logger.Info("CreateInstance: sending request to agentctl",
zap.String("instance_id", req.InstanceID),
zap.String("req_protocol", req.Protocol),
zap.String("createReq_protocol", createReq.Protocol))
resp, err := r.ctl.CreateInstance(ctx, createReq)
if err != nil {
return nil, fmt.Errorf("failed to create standalone instance: %w", err)
}
// Create agentctl client pointing to the instance port
client := agentctl.NewClient(r.host, resp.Port, r.logger,
agentctl.WithExecutionID(req.InstanceID),
agentctl.WithSessionID(req.SessionID),
agentctl.WithAuthToken(r.authToken))
// Extract runtime-specific values from metadata
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch)
// Build metadata
metadata := make(map[string]interface{})
metadata["standalone_port"] = resp.Port
if worktreeID != "" {
metadata["worktree_id"] = worktreeID
metadata["worktree_path"] = req.WorkspacePath
metadata["worktree_branch"] = worktreeBranch
}
r.logger.Debug("standalone instance created",
zap.String("instance_id", req.InstanceID),
zap.Int("port", resp.Port),
zap.String("workspace", req.WorkspacePath))
return &ExecutorInstance{
InstanceID: req.InstanceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: r.Name(),
Client: client,
StandaloneInstanceID: resp.ID,
StandalonePort: resp.Port,
WorkspacePath: req.WorkspacePath,
Metadata: metadata,
}, nil
}
func (r *StandaloneExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error {
if instance.StandaloneInstanceID == "" {
return nil // No standalone instance to stop
}
if err := r.ctl.DeleteInstance(ctx, instance.StandaloneInstanceID); err != nil {
return fmt.Errorf("failed to stop standalone instance: %w", err)
}
return nil
}
// RecoverInstances enumerates the adopted control server's live instances and
// correlates them to the live standalone recovery-inventory records read at
// startup step 3 (AC-EXECUTORS-SURVIVAL-002.1/002.8), applying the
// AC-EXECUTORS-SURVIVAL-002.10 duplicate tiebreak via CorrelateRecoveryInstances.
// Every losing duplicate, ambiguous-session instance, and record-less orphan
// (AC-EXECUTORS-SURVIVAL-002.6) is stopped within the bounded retry budget of
// AC-EXECUTORS-SURVIVAL-002.13/002.15 (SetRecoveryRetryConfig); every winner
// is returned for the caller to re-track. When a losing duplicate's stop
// exhausts its retries, that session's winner is also stopped and dropped
// from the result rather than re-tracked (AC-EXECUTORS-SURVIVAL-002.15); if
// the winner's own stop then also fails, the session is reported to the
// installed UnstoppableSessionRecorder (AC-EXECUTORS-SURVIVAL-002.16).
//
// Every stop runs in its own goroutine against context.Background(), never
// ctx: if ctx carries a deadline (AC-EXECUTORS-SURVIVAL-003.7) and it elapses
// while stops are still outstanding, this stops WAITING for them -- treating
// every session whose losing duplicate hasn't yet resolved as not re-tracked
// right now -- but never cancels work already dispatched. Outstanding stops,
// and any winner-stop or unstoppable report they trigger, keep running and
// recording their outcome in the background after this call returns.
//
// Enumeration itself is retried within the AC-EXECUTORS-SURVIVAL-002.13
// bounded budget (listInstancesWithRetry) before being treated as failed --
// workspace source roots and provider session identity are read back from
// this same response, so a transient enumeration failure must not skip the
// retry that AC applies to every other adopted-instance read. When the
// adopted server still cannot be enumerated after retries are exhausted,
// this reports nothing recovered, stops nothing, and leaves every record to
// the existing stale-execution repair path (AC-EXECUTORS-SURVIVAL-002.12)
// rather than treating an enumeration failure as though every instance were
// an orphan.
func (r *StandaloneExecutor) RecoverInstances(ctx context.Context, records []*models.ExecutorRunning) ([]*ExecutorInstance, error) {
instances, err := r.listInstancesWithRetry(ctx)
if err != nil {
r.logger.Warn("failed to enumerate standalone instances for recovery; leaving every record to the existing repair path",
zap.Error(err))
return nil, nil
}
correlation := CorrelateRecoveryInstances(records, instances)
// winnersBySession is a stable snapshot of every session's recovered
// winner, independent of correlation.Winners (which this call mutates
// and returns): AC-EXECUTORS-SURVIVAL-003.7 requires an in-flight loser
// stop, once dispatched, to still be able to find and stop its session's
// winner even after that session has already been dropped from the
// returned map at the deadline.
winnersBySession := make(map[string]*agentctl.InstanceInfo, len(correlation.Winners))
for sessionID, winner := range correlation.Winners {
winnersBySession[sessionID] = winner
}
pending := pendingLoserCounts(correlation.ToStop, correlation.Winners)
results := r.dispatchRecoveryStops(correlation.ToStop)
tracker := &jointFailureTracker{exec: r, winners: winnersBySession}
r.collectRecoveryStops(ctx, results, correlation.Winners, pending, tracker)
return r.buildRecoveredInstances(correlation.Winners, indexRecordsBySession(records)), nil
}
// recoveryStopOutcome is one instance's bounded stop attempt result.
type recoveryStopOutcome struct {
inst *agentctl.InstanceInfo
err error
}
// dispatchRecoveryStops starts one goroutine per not-re-tracked instance,
// each stopping it within the bounded retry budget against
// context.Background() -- never the caller's ctx, so an elapsed recovery
// deadline can never abort a stop already in flight
// (AC-EXECUTORS-SURVIVAL-003.7). The returned channel is closed once every
// goroutine has reported.
func (r *StandaloneExecutor) dispatchRecoveryStops(toStop []*agentctl.InstanceInfo) <-chan recoveryStopOutcome {
results := make(chan recoveryStopOutcome, len(toStop))
var wg sync.WaitGroup
for _, inst := range toStop {
wg.Add(1)
go func(inst *agentctl.InstanceInfo) {
defer wg.Done()
results <- recoveryStopOutcome{inst: inst, err: r.stopWithRetry(context.Background(), inst.ID)}
}(inst)
}
go func() {
wg.Wait()
close(results)
}()
return results
}
// pendingLoserCounts counts, per session with a winner, how many of its
// losing duplicates have not yet reported a stop outcome.
func pendingLoserCounts(toStop []*agentctl.InstanceInfo, winners map[string]*agentctl.InstanceInfo) map[string]int {
pending := make(map[string]int, len(winners))
for _, inst := range toStop {
if _, hasWinner := winners[inst.SessionID]; hasWinner {
pending[inst.SessionID]++
}
}
return pending
}
// collectRecoveryStops waits for dispatchRecoveryStops's results, applying
// each as it arrives, until either every result has landed or ctx's deadline
// (if any) elapses first. On deadline elapse it synchronously drops every
// session with an outstanding loser stop from winners -- AC-EXECUTORS-SURVIVAL-003.7's
// "treated as not-re-tracked" -- then keeps draining the remaining results in
// the background so their outcomes (including a later AC-EXECUTORS-SURVIVAL-002.16
// report) still get recorded.
//
// winners is only ever mutated from this call's own goroutine, and only
// before this function returns: the caller iterates it immediately after
// (buildRecoveredInstances), and the background drain goroutine started at
// the deadline branch never touches it, to avoid a concurrent map
// read/write with that iteration.
func (r *StandaloneExecutor) collectRecoveryStops(
ctx context.Context,
results <-chan recoveryStopOutcome,
winners map[string]*agentctl.InstanceInfo,
pending map[string]int,
tracker *jointFailureTracker,
) {
deadlineCh, stopTimer := recoveryDeadlineChannel(ctx)
defer stopTimer()
for {
select {
case res, ok := <-results:
if !ok {
return
}
r.handleRecoveryStopResult(res, pending, tracker)
if res.err != nil {
delete(winners, res.inst.SessionID)
}
case <-deadlineCh:
for sessionID, n := range pending {
if n > 0 {
delete(winners, sessionID)
// AC-EXECUTORS-SURVIVAL-003.7: this session's stop is still
// retrying past the deadline -- its guard must survive
// ReleaseAllExceptRetained until drainRecoveryStopsAfterDeadline
// below learns the resolution and releases (or retains) it.
if r.unstoppableRecorder != nil {
r.unstoppableRecorder.MarkStopInFlight(sessionID)
}
}
}
go r.drainRecoveryStopsAfterDeadline(results, pending, tracker)
return
}
}
}
// drainRecoveryStopsAfterDeadline finishes applying every recovery stop
// outcome still outstanding once collectRecoveryStops has already returned
// past the AC-EXECUTORS-SURVIVAL-003.7 deadline. Each affected session's
// MarkStopInFlight guard is released once every one of its pending stops has
// resolved: either directly here (every loser stopped, nothing left to
// jointly stop) or by stopWinnerAfterLoserFailure's own terminal Release/
// RetainAsUnstoppable call -- so the "retain until that stop resolves"
// exception never outlives the actual resolution. Release is safe to call a
// second time here even after stopWinnerAfterLoserFailure already resolved
// the guard: it no-ops for an already-released or already-retained session.
func (r *StandaloneExecutor) drainRecoveryStopsAfterDeadline(
results <-chan recoveryStopOutcome,
pending map[string]int,
tracker *jointFailureTracker,
) {
for res := range results {
r.handleRecoveryStopResult(res, pending, tracker)
if r.unstoppableRecorder != nil && pending[res.inst.SessionID] == 0 {
r.unstoppableRecorder.Release(res.inst.SessionID)
}
}
}
// recoveryDeadlineChannel returns a channel that fires at ctx's deadline, or
// a nil channel (which never fires) when ctx carries none.
func recoveryDeadlineChannel(ctx context.Context) (<-chan time.Time, func()) {
deadline, ok := ctx.Deadline()
if !ok {
return nil, func() {}
}
timer := time.NewTimer(time.Until(deadline))
return timer.C, func() { timer.Stop() }
}
// handleRecoveryStopResult applies one stop outcome: on success it only
// clears the session's pending-loser count; on failure it also logs and, for
// a losing duplicate whose session has a winner, triggers the joint failure
// handling (AC-EXECUTORS-SURVIVAL-002.15).
func (r *StandaloneExecutor) handleRecoveryStopResult(res recoveryStopOutcome, pending map[string]int, tracker *jointFailureTracker) {
sessionID := res.inst.SessionID
if _, tracked := pending[sessionID]; tracked {
pending[sessionID]--
}
if res.err == nil {
return
}
r.logger.Warn("failed to stop a not-re-tracked standalone instance during recovery after exhausting retries",
zap.String("instance_id", res.inst.ID), zap.String("session_id", sessionID), zap.Error(res.err))
tracker.onLoserStopFailed(sessionID)
}
// jointFailureTracker ensures AC-EXECUTORS-SURVIVAL-002.15/002.16's joint
// winner-stop-and-possible-unstoppable-report handling runs at most once per
// session, whether triggered synchronously (before the recovery deadline) or
// from the background drain after it (AC-EXECUTORS-SURVIVAL-003.7).
type jointFailureTracker struct {
exec *StandaloneExecutor
winners map[string]*agentctl.InstanceInfo
handled sync.Map // sessionID -> *sync.Once
}
func (t *jointFailureTracker) onLoserStopFailed(sessionID string) {
winner, ok := t.winners[sessionID]
if !ok {
return // orphan, or a session with no winner: nothing to jointly affect
}
once, _ := t.handled.LoadOrStore(sessionID, &sync.Once{})
once.(*sync.Once).Do(func() {
// The loser has already proved that this session cannot be made
// single-owner. Retain the guard before stopping the winner so a
// successful winner stop cannot release the protection for a still-live
// loser.
if t.exec.unstoppableRecorder != nil {
t.exec.unstoppableRecorder.RetainAsUnstoppable(sessionID)
}
t.exec.stopWinnerAfterLoserFailure(sessionID, winner)
})
}
// stopWinnerAfterLoserFailure stops a session's winning instance on the same
// bounded retry terms after one of its losing duplicates could not be
// stopped. The caller retains the session guard before entering this method,
// and that retention survives either winner-stop result (AC-EXECUTORS-
// SURVIVAL-002.16). Always runs to completion once started, even past the
// recovery deadline (AC-EXECUTORS-SURVIVAL-003.7).
func (r *StandaloneExecutor) stopWinnerAfterLoserFailure(sessionID string, winner *agentctl.InstanceInfo) {
if err := r.stopWithRetry(context.Background(), winner.ID); err != nil {
r.logger.Warn("failed to stop the winning instance after its losing duplicate could not be stopped; retaining session as unstoppable",
zap.String("instance_id", winner.ID), zap.String("session_id", sessionID), zap.Error(err))
return
}
r.logger.Warn("stopped the winning instance because its losing duplicate could not be stopped; session left not re-tracked",
zap.String("instance_id", winner.ID), zap.String("session_id", sessionID))
}
// indexRecordsBySession maps every named recovery-inventory record by
// session ID for use while building recovered executions.
func indexRecordsBySession(records []*models.ExecutorRunning) map[string]*models.ExecutorRunning {
recordBySession := make(map[string]*models.ExecutorRunning, len(records))
for _, rec := range records {
if rec != nil && rec.SessionID != "" {
recordBySession[rec.SessionID] = rec
}
}
return recordBySession
}
// buildRecoveredInstances converts every still-winning correlated instance
// into an ExecutorInstance for the caller to re-track.
func (r *StandaloneExecutor) buildRecoveredInstances(
winners map[string]*agentctl.InstanceInfo,
recordBySession map[string]*models.ExecutorRunning,
) []*ExecutorInstance {
recovered := make([]*ExecutorInstance, 0, len(winners))
for sessionID, inst := range winners {
record := recordBySession[sessionID]
client := agentctl.NewClient(r.host, inst.Port, r.logger,
agentctl.WithExecutionID(inst.ID),
agentctl.WithSessionID(sessionID),
agentctl.WithAuthToken(r.authToken))
// AC-EXECUTORS-SURVIVAL-002.14: task identity and workspace path's
// declared source is the recovery-inventory record, never the
// adopted instance -- an instance must not be able to influence what
// the backend believes either belongs to. A record with no TaskID
// (or, defensively, no record at all -- correlation should never
// produce a winner without one) is left empty here rather than
// falling back to the instance's self-reported value; the caller
// routes an empty TaskID to the AC-EXECUTORS-SURVIVAL-002.4 refusal
// path instead of tracking a session it cannot safely operate.
var taskID, workspacePath string
var metadata map[string]interface{}
var agentProfileID string
if record != nil {
taskID = record.TaskID
workspacePath = record.WorktreePath
metadata = record.Metadata
// AC-EXECUTORS-SURVIVAL-002.14: agent profile identity's declared
// source is the recovery-inventory record's execution-profile
// column -- never the adopted instance.
agentProfileID = record.ExecutionProfileID
}
recovered = append(recovered, &ExecutorInstance{
InstanceID: inst.ID,
TaskID: taskID,
SessionID: sessionID,
AgentProfileID: agentProfileID,
RuntimeName: r.Name(),
Client: client,
StandaloneInstanceID: inst.ID,
StandalonePort: inst.Port,
WorkspacePath: workspacePath,
Metadata: metadata,
Env: inst.Env,
WorkspaceSourceRoots: inst.WorkspaceSourceRoots,
ProviderSessionID: inst.ProviderSessionID,
})
}
return recovered
}
// SetInteractiveRunner sets the interactive runner for passthrough mode.
func (r *StandaloneExecutor) SetInteractiveRunner(runner *process.InteractiveRunner) {
r.interactiveRunner = runner
}
// GetInteractiveRunner returns the interactive runner for passthrough mode.
func (r *StandaloneExecutor) GetInteractiveRunner() *process.InteractiveRunner {
return r.interactiveRunner
}
func (r *StandaloneExecutor) RequiresCloneURL() bool { return false }
func (r *StandaloneExecutor) ShouldApplyPreferredShell() bool { return true }
func (r *StandaloneExecutor) IsAlwaysResumable() bool { return false }