PR #3467
Sections
Review

feat(executors): keep worktree and local agents running across a backend restart

main ← feature/agents-survive-backe-tcn 233 files +8420 −1180 PR #3467 ↗

Worktree and local agents now survive a backend restart: the backend adopts the detached agentctl control server, re-tracks live instances, and resumes sessions instead of killing them.

Why this change

A backend restart kills every worktree and local agent. The control server dies with its parent, and the new backend has no way to find or resume the old sessions.

What it does

Architecture, end to end

On restart the new backend adopts the surviving control server, then re-tracks its live instances. Only worktree and local executors use this path.

flowchart LR
  BackendOld[Old backend] -- graceful stop --> Launcher[Launcher]
  Launcher -- keeps alive --> Agentctl[agentctl control server]
  Agentctl -- hosts --> InstA[Instance A]
  Agentctl -- hosts --> InstB[Instance B]
  BackendNew[New backend] --> Adopt{Adopt?}
  Adopt -- identity + proof + rotate --> Agentctl
  Adopt --> Guard[RecoveryGuard]
  Guard --> Inventory[(executors_running)]
  Agentctl -- ListInstances --> BackendNew
  BackendNew -- rebuild --> ExecStore[(ExecutionStore)]
  Agentctl -- unowned reaper --> Shutdown[Self-shutdown if unowned]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Backend launch
agentctl process
Adoption
Recovery
Launcher keeps agentctl alive when survival is onapps/backend/internal/agent/runtime/agentctl/launcher/launcher.go ↗
func (l *Launcher) buildAndStartProcess(nonce string) error
Click for details →

The launcher skips the parent-liveness pipe and Pdeathsig when survival is enabled, and Stop becomes a no-op so a graceful restart does not kill the server.

Skip kill paths when survival is enabled
l.cmd.SysProcAttr = buildSysProcAttr(l.startupConfig.AgentSurvivalEnabled)

var pipeWrite *os.File
if l.startupConfig.AgentSurvivalEnabled {
    clearInheritedLivenessPipeEnv(l.cmd)
} else {
    pipeWrite, err = setupLivenessPipe(l.cmd)
    if err != nil {
        return err
    }
}
Stop is a no-op under survival
func (l *Launcher) Stop(ctx context.Context) error {
    if l.startupConfig.AgentSurvivalEnabled {
        l.logger.Info("agent-survival capability engaged; leaving agentctl running instead of stopping it")
        return nil
    }
    // ... close pipe, SIGTERM, wait ...
}
agentctl survives SIGPIPE and detached loggingapps/backend/cmd/agentctl/main.go ↗
func applySIGPIPEDisposition(cfg *config.Config, ignore func(...os.Signal))
Click for details →

agentctl ignores SIGPIPE and switches to a bounded diagnostic log file when survival is active, so a closed parent pipe does not kill it.

Ignore SIGPIPE only when survival is on
func applySIGPIPEDisposition(cfg *config.Config, ignore func(...os.Signal)) {
    if !cfg.AgentSurvivalEnabled {
        return
    }
    ignore(syscall.SIGPIPE)
}
Diagnostic log gate
func resolveRunLoggingConfig(cfg *config.Config) logger.LoggingConfig {
    if cfg.AgentSurvivalEnabled && cfg.DiagnosticLogPath != "" {
        return diagnosticLoggingConfig(cfg.LogLevel, cfg.LogFormat, cfg.DiagnosticLogPath)
    }
    return logger.LoggingConfig{Level: cfg.LogLevel, Format: cfg.LogFormat, OutputPath: "stdout"}
}
Wait for ownership shutdown too
waitForShutdown(log, parentDied, controlServer.ShutdownRequested(), func(ctx context.Context) {
    // ... shutdown instances, janitor, http server ...
})

func waitForShutdown(log *logger.Logger, parentDied <-chan struct{}, ownershipShutdown <-chan struct{}, cleanup func(ctx context.Context)) {
    select {
    case sig := <-sigCh:
        log.Info("received signal", zap.String("signal", sig.String()))
    case <-parentDied:
        log.Warn("parent process died, initiating shutdown")
    case <-ownershipShutdown:
        log.Warn("ownership-shutdown operation invoked, initiating shutdown")
    }
}
func AttemptAdoptControlServer(ctx context.Context, store AdoptionRecordStore, secretStore secrets.SecretStore, newClient AdoptionControlClientFactory, homeDir string, requiredCapabilities []string, recoveryReadTimeout time.Duration, recoveryReadRetries int, log *logger.Logger) AdoptionOutcome
Click for details →

The new backend reads the recorded endpoint, checks identity and credential proof, rotates the credential, and persists the new record before it re-tracks any session.

Adoption gates in order
record, err := store.GetControlServerRecord(ctx)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
client, err := newClient(record.Endpoint)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
contactedAt := time.Now()
identity, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.GetIdentity)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
if !identityMatchesRecordedServer(identity, record) || len(identity.Capabilities) == 0 {
    return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt}
}
credential, err := revealControlServerCredential(ctx, secretStore, record.CredentialSecretID)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialUnavailable, ContactedAt: contactedAt}
}
if !controlServerHoldsCredential(ctx, client, credential, homeDir, recoveryReadTimeout, recoveryReadRetries) {
    return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt}
}
client.SetAuthToken(credential)
rotated, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.RotateCredential)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonAuthenticationFailed, ContactedAt: contactedAt}
}
Persist rotated credential then confirm
secretID, err := storeControlServerCredential(ctx, a.secretStore, a.record.CredentialSecretID, a.rotated.Credential)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt}
}
updated := &models.ControlServerRecord{
    Endpoint: a.record.Endpoint, ServerIdentity: a.identity.ServerIdentity,
    CredentialSecretID: secretID, Capabilities: a.identity.Capabilities,
    DiagnosticLogPath: recordedDiagnosticLogPath(ctx, a.client, a.recoveryReadTimeout, a.recoveryReadRetries),
}
if err := a.store.UpsertControlServerRecord(ctx, updated); err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt}
}
// Confirm only after both durable writes succeed
withAdoptionRetry(ctx, a.recoveryReadTimeout, a.recoveryReadRetries, func(c context.Context) (struct{}, error) {
    return struct{}{}, a.client.ConfirmCredentialRotation(c, a.rotated.RotationID)
})
func TakeStartupRecoveryGuards(ctx context.Context, runningWriter ExecutorRunningWriter, passthroughLookup PassthroughLookup, log *logger.Logger) *RecoveryGuard
Click for details →

The guard is taken before any control-server contact and blocks concurrent launches for sessions that may still be re-tracked.

Guard taken at startup step 3
func TakeStartupRecoveryGuards(ctx context.Context, runningWriter ExecutorRunningWriter, passthroughLookup PassthroughLookup, log *logger.Logger) *RecoveryGuard {
    guard := NewRecoveryGuard()
    lister, ok := runningWriter.(executorRunningLister)
    if !ok {
        return guard
    }
    records, err := lister.ListExecutorsRunningLiveStandalone(ctx)
    if err != nil {
        log.Warn("failed to read live standalone recovery-inventory records", zap.Error(err))
        return guard
    }
    for _, sessionID := range SessionsToGuard(ctx, sessionIDsFromExecutorRunning(records), passthroughLookup) {
        guard.AcquireOrObserve(sessionID)
    }
    return guard
}
Standalone re-tracking with duplicate handling
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)
    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
}
Control server ownership and credential rotationapps/backend/internal/agentctl/server/api/control_server.go ↗
func NewControlServer(cfg *config.Config, instMgr *instance.Manager, log *logger.Logger) *ControlServer
Click for details →

The control server exposes identity, proof, and ownership routes with three-tier auth and an unowned-period reaper that self-terminates when no backend claims it.

Three-tier auth and routes
var adoptionOnlyPaths = map[string]bool{
    "/api/v1/ownership/rotate":   true,
    "/api/v1/ownership/shutdown": true,
}

cs.router.Use(controlCredentialAuth(cs.credentials, adoptionOnlyPaths, "/health", "/auth/handshake", "/identity", "/ownership/prove"))
cs.router.GET("/identity", m.handleIdentity)
cs.router.POST("/ownership/prove", m.handleOwnershipProve)
api.POST("/ownership/claim", m.handleOwnershipClaim)
api.POST("/ownership/rotate", m.handleCredentialRotate)
api.POST("/ownership/confirm", m.handleCredentialConfirm)
api.POST("/ownership/shutdown", m.handleOwnershipShutdown)
Unowned reaper gated by survival flag
func startUnownedReaperIfEnabled(cfg *config.Config, reaper unownedReaper) (stop func()) {
    if cfg.AgentSurvivalEnabled {
        reaper.StartUnownedReaper()
    }
    return reaper.StopUnownedReaper
}
Read the changes as a list

Launcher keeps agentctl alive when survival is on

apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go

The launcher skips the parent-liveness pipe and Pdeathsig when survival is enabled, and Stop becomes a no-op so a graceful restart does not kill the server.

Skip kill paths when survival is enabled
l.cmd.SysProcAttr = buildSysProcAttr(l.startupConfig.AgentSurvivalEnabled)

var pipeWrite *os.File
if l.startupConfig.AgentSurvivalEnabled {
    clearInheritedLivenessPipeEnv(l.cmd)
} else {
    pipeWrite, err = setupLivenessPipe(l.cmd)
    if err != nil {
        return err
    }
}
Stop is a no-op under survival
func (l *Launcher) Stop(ctx context.Context) error {
    if l.startupConfig.AgentSurvivalEnabled {
        l.logger.Info("agent-survival capability engaged; leaving agentctl running instead of stopping it")
        return nil
    }
    // ... close pipe, SIGTERM, wait ...
}

agentctl survives SIGPIPE and detached logging

apps/backend/cmd/agentctl/main.go

agentctl ignores SIGPIPE and switches to a bounded diagnostic log file when survival is active, so a closed parent pipe does not kill it.

Ignore SIGPIPE only when survival is on
func applySIGPIPEDisposition(cfg *config.Config, ignore func(...os.Signal)) {
    if !cfg.AgentSurvivalEnabled {
        return
    }
    ignore(syscall.SIGPIPE)
}
Diagnostic log gate
func resolveRunLoggingConfig(cfg *config.Config) logger.LoggingConfig {
    if cfg.AgentSurvivalEnabled && cfg.DiagnosticLogPath != "" {
        return diagnosticLoggingConfig(cfg.LogLevel, cfg.LogFormat, cfg.DiagnosticLogPath)
    }
    return logger.LoggingConfig{Level: cfg.LogLevel, Format: cfg.LogFormat, OutputPath: "stdout"}
}
Wait for ownership shutdown too
waitForShutdown(log, parentDied, controlServer.ShutdownRequested(), func(ctx context.Context) {
    // ... shutdown instances, janitor, http server ...
})

func waitForShutdown(log *logger.Logger, parentDied <-chan struct{}, ownershipShutdown <-chan struct{}, cleanup func(ctx context.Context)) {
    select {
    case sig := <-sigCh:
        log.Info("received signal", zap.String("signal", sig.String()))
    case <-parentDied:
        log.Warn("parent process died, initiating shutdown")
    case <-ownershipShutdown:
        log.Warn("ownership-shutdown operation invoked, initiating shutdown")
    }
}

Adopt the detached control server on startup

apps/backend/internal/agent/runtime/lifecycle/control_server_adoption.go

The new backend reads the recorded endpoint, checks identity and credential proof, rotates the credential, and persists the new record before it re-tracks any session.

Adoption gates in order
record, err := store.GetControlServerRecord(ctx)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
client, err := newClient(record.Endpoint)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
contactedAt := time.Now()
identity, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.GetIdentity)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonNoServer}
}
if !identityMatchesRecordedServer(identity, record) || len(identity.Capabilities) == 0 {
    return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt}
}
credential, err := revealControlServerCredential(ctx, secretStore, record.CredentialSecretID)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialUnavailable, ContactedAt: contactedAt}
}
if !controlServerHoldsCredential(ctx, client, credential, homeDir, recoveryReadTimeout, recoveryReadRetries) {
    return AdoptionOutcome{Reason: AdoptionReasonIdentityMismatch, ContactedAt: contactedAt}
}
client.SetAuthToken(credential)
rotated, err := withAdoptionRetry(ctx, recoveryReadTimeout, recoveryReadRetries, client.RotateCredential)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonAuthenticationFailed, ContactedAt: contactedAt}
}
Persist rotated credential then confirm
secretID, err := storeControlServerCredential(ctx, a.secretStore, a.record.CredentialSecretID, a.rotated.Credential)
if err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt}
}
updated := &models.ControlServerRecord{
    Endpoint: a.record.Endpoint, ServerIdentity: a.identity.ServerIdentity,
    CredentialSecretID: secretID, Capabilities: a.identity.Capabilities,
    DiagnosticLogPath: recordedDiagnosticLogPath(ctx, a.client, a.recoveryReadTimeout, a.recoveryReadRetries),
}
if err := a.store.UpsertControlServerRecord(ctx, updated); err != nil {
    return AdoptionOutcome{Reason: AdoptionReasonCredentialRotationFailed, ContactedAt: a.contactedAt}
}
// Confirm only after both durable writes succeed
withAdoptionRetry(ctx, a.recoveryReadTimeout, a.recoveryReadRetries, func(c context.Context) (struct{}, error) {
    return struct{}{}, a.client.ConfirmCredentialRotation(c, a.rotated.RotationID)
})

Recovery guard and instance re-tracking

apps/backend/internal/agent/runtime/lifecycle/recovery_guard.go

The guard is taken before any control-server contact and blocks concurrent launches for sessions that may still be re-tracked.

Guard taken at startup step 3
func TakeStartupRecoveryGuards(ctx context.Context, runningWriter ExecutorRunningWriter, passthroughLookup PassthroughLookup, log *logger.Logger) *RecoveryGuard {
    guard := NewRecoveryGuard()
    lister, ok := runningWriter.(executorRunningLister)
    if !ok {
        return guard
    }
    records, err := lister.ListExecutorsRunningLiveStandalone(ctx)
    if err != nil {
        log.Warn("failed to read live standalone recovery-inventory records", zap.Error(err))
        return guard
    }
    for _, sessionID := range SessionsToGuard(ctx, sessionIDsFromExecutorRunning(records), passthroughLookup) {
        guard.AcquireOrObserve(sessionID)
    }
    return guard
}
Standalone re-tracking with duplicate handling
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)
    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
}

Control server ownership and credential rotation

apps/backend/internal/agentctl/server/api/control_server.go

The control server exposes identity, proof, and ownership routes with three-tier auth and an unowned-period reaper that self-terminates when no backend claims it.

Three-tier auth and routes
var adoptionOnlyPaths = map[string]bool{
    "/api/v1/ownership/rotate":   true,
    "/api/v1/ownership/shutdown": true,
}

cs.router.Use(controlCredentialAuth(cs.credentials, adoptionOnlyPaths, "/health", "/auth/handshake", "/identity", "/ownership/prove"))
cs.router.GET("/identity", m.handleIdentity)
cs.router.POST("/ownership/prove", m.handleOwnershipProve)
api.POST("/ownership/claim", m.handleOwnershipClaim)
api.POST("/ownership/rotate", m.handleCredentialRotate)
api.POST("/ownership/confirm", m.handleCredentialConfirm)
api.POST("/ownership/shutdown", m.handleOwnershipShutdown)
Unowned reaper gated by survival flag
func startUnownedReaperIfEnabled(cfg *config.Config, reaper unownedReaper) (stop func()) {
    if cfg.AgentSurvivalEnabled {
        reaper.StartUnownedReaper()
    }
    return reaper.StopUnownedReaper
}

Data and storage

One durable control-server record per installation plus per-session recovery rows drive the whole flow.

FieldTypeNotes
ControlServerRecord.Endpointstringhost:port of the surviving server; may differ after port fallback
ControlServerRecord.ServerIdentitystringopaque per-launch nonce; proves the live process is the recorded one
ControlServerRecord.CredentialSecretIDstringreference to the rotating credential in the secret store
ControlServerRecord.Capabilities[]stringadvertised set, must contain agent-survival.v1
ExecutorRunning.SessionIDstringrecovery inventory key; guarded before adoption
ExecutorRunning.LocalPIDinthost PID of the control server for local liveness checks
ExecutorRunning.ResumeTokenstringpreserved across repair; never blanked by a blind upsert

Risk

7 / 10 High
1 low5 medium10 high

Why this score

  • Touches process lifecycle, credential rotation, and startup ordering across backend and agentctl.
  • A wrong adoption decision could adopt a foreign server or leave a detached server running forever.
  • Recovery guard and deadline logic is subtle; a bug could block launches or create duplicate agents.
  • 233 files changed with new persistence, API, and launcher code; rollback needs a backend restart.
  • Extensive new tests and bounded retries reduce risk, but the surface is wide.

Trade-offs and review notes

Where to look first

  1. Verify adoption gate order in control_server_adoption.go: identity, proof, rotate, capability check, then persist.
  2. Check launcher kill-path removal in launcher.go and sysprocattr_linux.go: Pdeathsig and pipe must be skipped only when survival is on.
  3. Review agentctl SIGPIPE and diagnostic log gating in sigpipe.go and diagnostic_log.go.
  4. Confirm recovery guard lifecycle in recovery_guard.go and manager.go: taken before adoption, released after re-tracking or at deadline.
  5. Inspect credential rotation and three-tier auth in control_server.go and credential_rotation.go.