Skip to content

Interactive autopilot stops after 600s while a background subagent is still running (regression in 1.0.102) #5223

Open

Description

@bsiegel

Describe the bug

Since 1.0.102, interactive autopilot stops whenever a background subagent runs for more than 600 seconds. The subagent is still working normally when this happens. The CLI shows:

Autopilot stopped after 600s waiting for background tasks. Set COPILOT_TASK_WAIT_TIMEOUT_SECONDS to change the limit.

A common pattern is for the main agent to start a background agent (for example, to implement part of a feature) and then end its turn to wait for the result. Any subagent that needs more than 10 minutes now ends the autopilot run, and I have to continue it manually.

This worked in 1.0.101 and 1.0.100. The 1.0.102 changelog doesn't mention any change to this behavior, so it looks unintended.

Affected version

1.0.102

Steps to reproduce the behavior

  1. Start Copilot CLI interactively and turn on autopilot (/autopilot or shift+tab).
  2. Give it a task where the main agent starts a background subagent (task with mode: "background") that takes more than 10 minutes.
  3. Let the main agent end its turn to wait for the subagent.
  4. After 600 seconds, autopilot stops with the message above, even though the subagent is still running.

Expected behavior

Interactive autopilot keeps waiting for in-flight background agents and continues when they finish, as it did in 1.0.101. If a cap is needed, a running subagent that is making progress shouldn't count as stuck, or the cap should be opt-in for interactive sessions.

Additional context

  • In 1.0.101, COPILOT_TASK_WAIT_TIMEOUT_SECONDS / autopilot_background_timeout appear to apply only to prompt mode (-p). In 1.0.102 the native runtime adds new autopilot continuation logic, and the stop message and autopilot_background_timeout warning first appear there.
  • Workaround: export COPILOT_TASK_WAIT_TIMEOUT_SECONDS=3600 (or higher) before launching.
  • The limit can't be turned off. COPILOT_TASK_WAIT_TIMEOUT_SECONDS=0 gives a 0 ms wait, not an unlimited one. Negative or non-numeric values fall back to 600 s.
  • Related but different: Autopilot background-task timeout exits active parent after subagent completes #4628 (prompt mode on 1.0.80, closed).

Activity

  1. added theissue type on Oct 21, 2026
  2. added
    area:agentsSub-agents, fleet, autopilot, plan mode, background agents, and custom agents
    and removed on Oct 22, 2026
  3. havra commented on Oct 23, 2026

    @havra

    Verified short-timeout reproduction and longer-timeout control

    Additional reproduction for the interactive-autopilot background-timeout report.

    Environment: Windows; pinned v1.0.103 native runtime. The test uses the installed SDK's supported session API with the session explicitly set to autopilot. This exercises the runtime behavior without terminal UI automation.

    Steps to reproduce

    1. Have the Windows CLI distribution for v1.0.103 installed in its normal per-user package cache, existing Copilot authentication, and Node.js available.

    2. Save the self-contained JavaScript module below locally. The test uses the configured models and consumes Copilot usage. It does not require additional npm dependencies.

    3. In PowerShell, enter the full path of that saved module, then run the short-timeout case:

      $repro = Read-Host 'Full path to the saved JavaScript module'
      node $repro --version 1.0.103 --timeout 2 --expect timeout --deadline-seconds 120
    4. The module starts an isolated SDK runtime process with a child-process-only COPILOT_TASK_WAIT_TIMEOUT_SECONDS=2. It verifies the actual runtime version and session mode, then asks the parent to dispatch exactly one tool-free background worker that outputs integers from 1 through 2000. The parent ends its turn to wait.

    5. Observe a native warning and session.autopilot_stopped with reason background_timeout while that worker is still generating output. The worker completes afterward. Successful reproduction exits zero only when the required background-dispatch, worker-completion, permission, and timeout-order assertions pass.

    6. Run the longer-timeout control:

      node $repro --version 1.0.103 --timeout 120 --expect complete --deadline-seconds 150

      This must complete the worker and reach native session.autopilot_stopped with reason task_complete, without a background-timeout warning. Calling the completion tool alone does not count as a passing control.

    The worker performs no filesystem operations, shell commands, network tools, or permission requests. No approval-all, trust bypass, instruction replacement, or persistent configuration change is used. Each run records a sanitized relative event timeline beside the saved module and shuts down its own runtime. The worker duration is model-dependent; the assertions reject a case that finishes before the short timeout instead of treating it as a successful reproduction.

    Fresh measured results

    Both cases passed all four assertions. Both reported actual runtime version matching the requested version, autopilot session mode, zero unexpected permission requests, and successful cleanup.

    Short-timeout case, relative seconds from process start:

    6.484   task dispatched with mode=background
    7.248   subagent started
    12.076  session.warning warningType=autopilot_background_timeout
    12.077  session.autopilot_stopped reason=background_timeout
    75.107  subagent completed
    

    Warning text:

    Autopilot stopped after 2s waiting for background tasks. Set COPILOT_TASK_WAIT_TIMEOUT_SECONDS to change the limit.
    

    The worker was still active when the timeout warning fired and completed approximately 63 seconds after autopilot stopped. Streaming output was observed; generated text is intentionally not included.

    Longer-timeout control:

    6.484   task dispatched with mode=background
    7.114   subagent started
    75.014  subagent completed
    77.268  parent invoked task_complete
    80.035  session.autopilot_stopped reason=task_complete
    

    There was no background-timeout warning in the control.

    Scope and limitations

    • This is a fresh native-runtime autopilot reproduction, not replayed historical logs and not automated TUI rendering verification.
    • It time-compresses the reported default timeout using the supported environment variable. The unchanged 600-second default was independently checked; this test does not claim a new ten-minute controlled run.
    • An older-runtime A/B comparison was not established. These measurements do not identify the introducing commit or prove a previous release's behavior.
    • Full transcripts, generated worker text, personal paths, session identifiers, internal links, and credentials are not included.

    Please use this to investigate progress-aware waiting or a separately configurable interactive-autopilot background-wait policy. Raising the timeout avoids the stop in the control but does not address the unexpectedly interruptible default behavior described in the original issue.

    Self-contained Windows reproduction module
    import fs from 'node:fs';
    import path from 'node:path';
    import { fileURLToPath, pathToFileURL } from 'node:url';
    import { setTimeout as sleep } from 'node:timers/promises';
    
    const args = process.argv.slice(2);
    function option(name, fallback) {
      const index = args.indexOf(name);
      return index < 0 ? fallback : args[index + 1];
    }
    const version = option('--version', '1.0.103');
    const sdkVersion = option('--sdk-version', version);
    const waitSeconds = Number(option('--timeout', '2'));
    const deadlineSeconds = Number(option('--deadline-seconds', '120'));
    const expected = option('--expect', waitSeconds <= 2 ? 'timeout' : 'complete');
    if (!['timeout', 'complete'].includes(expected)) throw new Error('Expected outcome must be timeout or complete');
    if (![waitSeconds, deadlineSeconds].every(value => Number.isFinite(value) && value > 0)) {
      throw new Error('Timeout and deadline must be positive finite seconds');
    }
    if (!/^\d+\.\d+\.\d+(?:-\d+)?$/.test(version)) throw new Error('Invalid version');
    if (!/^\d+\.\d+\.\d+(?:-\d+)?$/.test(sdkVersion)) throw new Error('Invalid SDK version');
    const directory = path.dirname(fileURLToPath(import.meta.url));
    const sdk = path.join(process.env.LOCALAPPDATA, 'copilot', 'pkg', 'win32-x64', sdkVersion, 'copilot-sdk', 'index.js');
    const runtime = option('--runtime', path.join(process.env.LOCALAPPDATA, 'copilot', 'pkg', 'win32-x64', version,
      'prebuilds', 'win32-x64', 'copilot-runtime.exe'));
    const { CopilotClient, RuntimeConnection } = await import(pathToFileURL(sdk).href);
    const resultPath = path.join(directory, `repro-${version}-timeout-${waitSeconds}.json`);
    const workspace = path.join(directory, 'repro-workspace');
    fs.mkdirSync(workspace, { recursive: true });
    const started = performance.now();
    const report = {
      version, sdkVersion, timeoutSeconds: waitSeconds, expected,
      interface: 'Supported SDK, native runtime, agentMode=autopilot; not TUI automation',
      outcome: 'inconclusive', events: [], assertions: [], permissionRequests: 0,
    };
    function record(type, data = {}) {
      const entry = { seconds: Number(((performance.now() - started) / 1000).toFixed(3)), type, ...data };
      report.events.push(entry);
      console.log(JSON.stringify(entry));
      return entry;
    }
    async function bounded(promise, milliseconds, label) {
      let timer;
      try {
        return await Promise.race([
          promise,
          new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds); }),
        ]);
      } finally {
        clearTimeout(timer);
      }
    }
    let session;
    let workerStarted;
    let workerCompleted;
    let warning;
    let backgroundDispatched = false;
    let rootCompleted = false;
    let workerCalls = 0;
    let workerChunks = 0;
    let parentTurnEnded;
    let pendingError;
    const client = new CopilotClient({
      connection: RuntimeConnection.forStdio({
        path: runtime,
        env: { ...process.env, COPILOT_TASK_WAIT_TIMEOUT_SECONDS: String(waitSeconds) },
      }),
      workingDirectory: workspace,
    });
    try {
      await bounded(client.start(), 35000, 'Runtime startup');
      const status = await bounded(client.getStatus(), 10000, 'Runtime status');
      const auth = await bounded(client.getAuthStatus(), 10000, 'Authentication status');
      report.runtimeVersion = status.version;
      report.authenticated = auth.isAuthenticated;
      record('runtime_ready', { authenticated: auth.isAuthenticated });
      if (!auth.isAuthenticated) throw new Error('Existing Copilot authentication is unavailable; no login attempted');
      if (status.version !== version) throw new Error('Actual runtime version does not match the requested version');
      if (args.includes('--probe')) {
        report.outcome = 'probe_ready';
      } else {
        session = await bounded(client.createSession({
          workingDirectory: workspace,
          availableTools: ['task', 'task_complete'],
          streaming: true,
          customAgents: [{
            name: 'counting-worker',
            description: 'Tool-free reproduction worker that produces a long response',
            tools: [],
            prompt: 'Without tools, output every integer from 1 through 2000, one per line. Do not summarize or skip numbers. End with WORKER_DONE. Do not access files, run commands, or call tools.',
          }],
        }), 30000, 'Session creation');
        session.on(event => {
          const data = event.data || {};
          if (event.type === 'assistant.message_delta' && event.agentId) workerChunks++;
          if (event.type === 'subagent.started') {
            workerCalls++;
            workerStarted = { seconds: (performance.now() - started) / 1000 };
          }
          if (event.type === 'subagent.completed') {
            workerCompleted = { seconds: (performance.now() - started) / 1000 };
          }
          if (event.type === 'assistant.turn_end' && !event.agentId && backgroundDispatched) {
            parentTurnEnded = { seconds: (performance.now() - started) / 1000 };
          }
          if (event.type === 'tool.execution_start') {
            const tool = data.toolName;
            if (tool === 'task' && data.arguments?.mode === 'background') backgroundDispatched = true;
            record(event.type, { tool, subagent: Boolean(event.agentId), background: data.arguments?.mode === 'background' });
          } else if (event.type === 'session.warning') {
            if (data.warningType === 'autopilot_background_timeout') {
              warning = record(event.type, {
                warningType: data.warningType, message: data.message,
                workerChunksObserved: workerChunks,
                secondsSinceParentTurnEnd: parentTurnEnded
                  ? Number(((performance.now() - started) / 1000 - parentTurnEnded.seconds).toFixed(3)) : null,
              });
            }
          } else if (event.type === 'permission.requested') {
            report.permissionRequests++;
            pendingError = new Error('Unexpected permission request: no permission was approved');
            record('permission_requested', { kind: data.permissionRequest?.kind });
          } else if (event.type === 'session.error') {
            pendingError = new Error(data.message || 'Runtime session error');
            record('session_error', { message: data.message });
          } else if (event.type === 'session.autopilot_stopped') {
            if (!event.agentId && data.reason === 'task_complete') rootCompleted = Boolean(workerCompleted);
            record(event.type, { subagent: Boolean(event.agentId), reason: data.reason });
          } else if (['assistant.turn_start', 'assistant.turn_end', 'subagent.started', 'subagent.completed',
            'session.idle'].includes(event.type)) {
            record(event.type, { subagent: Boolean(event.agentId), ...(data.reason ? { reason: data.reason } : {}) });
          }
        });
        await bounded(session.rpc.mode.set({ mode: 'autopilot' }), 10000, 'Setting interaction mode');
        report.sessionMode = await bounded(session.rpc.mode.get(), 10000, 'Checking interaction mode');
        if (report.sessionMode !== 'autopilot') throw new Error('Session is not in autopilot mode');
        await bounded(session.send({
          agentMode: 'autopilot',
          prompt: 'This is a bounded background-wait reproduction, not repository work. Start exactly one task with agent_type "counting-worker", mode "background", and prompt "Without tools, output every integer from 1 through 2000, one per line, then WORKER_DONE. Do not summarize or skip numbers." Immediately after receiving its handle, end your turn with WAITING_FOR_WORKER. Do not poll, read agents, or call any other tool while waiting. When the worker finishes, call task_complete with summary WORKER_DONE. Do not finish the task before the worker returns.',
        }), 30000, 'Initial send');
        while (performance.now() - started < deadlineSeconds * 1000) {
          if (pendingError) throw pendingError;
          if (workerCompleted && (warning || rootCompleted)) break;
          await sleep(100);
        }
        report.assertions = [
          { name: 'A task was dispatched in background mode', passed: backgroundDispatched },
          { name: 'Exactly one tool-free background worker completed', passed: workerCalls === 1 && Boolean(workerCompleted) },
          { name: 'No unexpected permissions were approved or pending', passed: report.permissionRequests === 0 },
        ];
        report.workerChunks = workerChunks;
        if (expected === 'timeout') {
          report.assertions.push({
            name: 'Timeout warning occurred while the worker was still running',
            passed: Boolean(workerStarted && warning && workerCompleted &&
              workerStarted.seconds < warning.seconds && warning.seconds < workerCompleted.seconds),
          });
          report.outcome = report.assertions.every(item => item.passed) ? 'reproduced' : 'inconclusive';
        } else {
          report.assertions.push({
            name: 'Parent completed after the worker without a background-timeout warning',
            passed: Boolean(rootCompleted && workerCompleted && !warning),
          });
          report.outcome = report.assertions.every(item => item.passed) ? 'control_passed' : 'inconclusive';
        }
      }
    } catch (error) {
      report.outcome = 'blocked';
      report.error = `${error.name}: ${error.message}`;
      console.error(report.error);
    } finally {
      try {
        if (session) await bounded(session.abort(), 5000, 'Session cleanup');
        const errors = await bounded(client.stop(), 5000, 'Runtime cleanup');
        if (errors.length) throw new Error(errors.map(error => error.message).join('; '));
        report.cleanupSucceeded = true;
      } catch (error) {
        report.cleanupSucceeded = false;
        report.cleanupError = error.message;
        await bounded(client.forceStop(), 5000, 'Forced runtime cleanup');
      }
      report.elapsedSeconds = Number(((performance.now() - started) / 1000).toFixed(3));
      fs.writeFileSync(resultPath, JSON.stringify(report, null, 2));
      console.log(JSON.stringify({ outcome: report.outcome, assertions: report.assertions, cleanupSucceeded: report.cleanupSucceeded }));
      process.exitCode = ['probe_ready', 'reproduced', 'control_passed'].includes(report.outcome) && report.cleanupSucceeded ? 0 : 1;
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:agentsSub-agents, fleet, autopilot, plan mode, background agents, and custom agents

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions