Skip to content

feat(core): move long-running tools to the background after a wait - #60375

Open
kitlangton wants to merge 4 commits into
devfrom
t900-autobg
Open

kitlangton wants to merge 4 commits into
devfrom
t900-autobg

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Long-running shell commands, Code Mode scripts, and subagents now move to the background on their own after 30 seconds instead of blocking the turn or getting killed at 2 minutes.
Calls that finish within 30 seconds still return inline in a single step as before, and sessions stay busy while their background jobs run. Stacked on #60367 (t901-steerbg).

💡 Why: Previously a slow build, server, Code Mode script, or subagent blocked the whole turn unless the model guessed background: true up front, shell killed foreground commands at 2 minutes, plugin reloads dropped completion fibers, and sessions flipped to idle while background jobs were still running.

🆕 Now you can: Pass yield_after in milliseconds on shell, execute, and subagent (0 backgrounds right away; defaults to 30000), inspect or control jobs with tools.opencode.job_list, job_output, job_wait, and job_stop, and query running family jobs at GET /api/experimental/session/:sessionID/jobs.

🟰 Behavior change (30 s default yield): Yes — shell, execute, and subagent calls that run past 30 s now yield status: "running" with a jobID and the output captured so far instead of holding the step open. This is safe because fast calls (< 30 s) are unchanged, nothing is killed on yield (shell clears only its implicit 2-minute foreground timeout while keeping any explicit timeout armed), completion notices automatically resume the session from Job's global scope, subagents wait for their own background jobs before reporting back, and agents can pass a larger yield_after or call job_wait when they want to wait longer.

flowchart LR
  Call["shell / execute / subagent"] --> Start["Job.start (global scope)"]
  Start --> Block["Job.block (yield_after = 30s + #60367 steer guard)"]
  Block -->|"settles first"| Inline["Return inline output"]
  Block -->|"yield_after / steer / Ctrl+B"| Bg["Return jobID + output so far"]
  Bg --> Controls["job_list / job_output / job_wait / job_stop"]
  Bg --> Settle["Global Job.settle -> inbox notice + bounded history"]
  style Block fill:#dcfce7,stroke:#16a34a,color:#14532d
  style Controls fill:#dcfce7,stroke:#16a34a,color:#14532d
  style Settle fill:#dcfce7,stroke:#16a34a,color:#14532d
Loading
- shell / subagent: foreground blocks until exit (or 2m SIGTERM); plugin-scoped notify fiber; execute cannot background
+ shell / execute / subagent: Job.block({ yield_after: 30_000 }) -> returns { jobID, output } while job continues
+ Job.settle (global scope): delivers notice once under lock, keeps 25-entry history, cancels descendant jobs on stop/remove
+ session.active + /api/experimental/session/:id/jobs + job.updated: keeps session busy while jobs run

📊 Proof

Case Before After
Command, Code Mode script, or subagent runs past yield_after ❌ Blocks the turn (or shell SIGTERMs at 2m) ✅ Yields jobID + live output so far; Implicit 2m shell timeout cleared, explicit timeout preserved
Completion races yield_after at the boundary ❌ Could return inline and also send a background notice ✅ Registry lock decides ownership once; single delivery
Plugin/location reloads while a background job runs ❌ Plugin-scoped watcher interrupted; notice dropped ✅ Delivered from Job's global scope (onSettle)
Agent inspects, waits on, or stops a job ❌ No job tools; completed background output discarded ✅ job_list, job_output, job_wait, job_stop + 25-entry completed history; stopping a subagent or deleting a session cancels descendant jobs
Session status while background jobs run ❌ Goes idle when the foreground step ends ✅ Stays busy across /api/session/active, job.updated, and /api/experimental/session/:sessionID/jobs

New unit and integration tests in packages/core/test/tool-auto-background.test.ts, packages/core/test/job.test.ts, packages/server/test/session-jobs.test.ts, and packages/client/test/solid-data.test.ts fail without the change; 673 core, server, client, schema, protocol, and codemode tests pass.

👀 Read: packages/core/src/job.ts in block, completeBackground, and cancelSession: block races done, backgrounded, and waitYield then resolves ownership under the state.jobs lock (matching #60367), completeBackground clears the durable notice marker while keeping finished jobs in the 25-entry history, and cancelSession + SubagentJob tear down descendant jobs before session removal or subagent stop. The rest is routine.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <style>
    .bar { animation: bar 4.4s ease-in-out infinite }
    .pill { animation: pill 4.4s ease-in-out infinite }
    .spin { animation: spin 1.2s ease-in-out infinite }
    @keyframes bar {
      0% { width: 18px; opacity: .85 }
      42% { width: 112px; opacity: .85 }
      55%, 100% { width: 112px; opacity: .28 }
    }
    @keyframes pill {
      0%, 40% { transform: translateY(8px); opacity: 0 }
      54%, 88% { transform: translateY(0); opacity: 1 }
      100% { transform: translateY(8px); opacity: 0 }
    }
    @keyframes spin { 0%, 100% { opacity: .35 } 50% { opacity: .95 } }
    @media (prefers-reduced-motion: reduce) {
      .bar { animation: none; width: 112px; opacity: .35 }
      .pill { animation: none; transform: none; opacity: 1 }
      .spin { animation: none; opacity: .85 }
    }
  </style>
  <rect x="12" y="10" width="216" height="52" rx="8" stroke="currentColor" stroke-opacity=".2"/>
  <rect x="24" y="22" width="112" height="8" rx="4" fill="currentColor" fill-opacity=".1"/>
  <rect class="bar" x="24" y="22" width="112" height="8" rx="4" fill="currentColor" fill-opacity=".55"/>
  <line x1="136" y1="18" x2="136" y2="34" stroke="currentColor" stroke-opacity=".45" stroke-dasharray="2 2"/>
  <g class="pill">
    <rect x="24" y="38" width="192" height="16" rx="5" fill="currentColor" fill-opacity=".1" stroke="currentColor" stroke-opacity=".35"/>
    <circle class="spin" cx="34" cy="46" r="2.8" fill="currentColor"/>
    <rect x="42" y="44" width="74" height="4" rx="2" fill="currentColor" fill-opacity=".75"/>
    <rect x="156" y="44" width="48" height="4" rx="2" fill="currentColor" fill-opacity=".4"/>
  </g>
</svg>

🔍 Stacked on #60367 · independently reviewed and all 5 findings fixed with regression tests · no user reports · not measured

Checks run
  • bun run script/test.ts test/job.test.ts test/tool-auto-background.test.ts --rerun-each 10 --randomize (packages/core): 260 pass, 0 fail
  • bun run script/test.ts test/session-steer-background.test.ts test/session-owned.test.ts test/tool-subagent.test.ts test/tool-execute.test.ts test/tool-image-generate.test.ts test/location-layer.test.ts test/session-execution.test.ts test/session-runner.test.ts test/schedule.test.ts (packages/core): 398 pass, 0 fail
  • bun test test/session-jobs.test.ts (packages/server): 1 pass, 0 fail
  • bun test test/solid-data.test.ts test/effect.test.ts (packages/client): 45 pass, 0 fail
  • bun test test/contract-hygiene.test.ts test/event-manifest.test.ts (packages/schema): 22 pass, 0 fail
  • bun test test/experimental-identifiers.test.ts && bun run check:generated (packages/protocol): pass
  • bun test test/codemode.test.ts (packages/codemode): 54 pass, 0 fail
  • bun run typecheck && bun run typecheck:tsc (schema, codemode, core, plugin, protocol, server, client, sdk) & bun run typecheck (tui, app): 0 errors

Added by the opencode bot (AI-generated).

Fixes #50278
Fixes #48826
Fixes #56729

Related: #54221, #51810, #36423

Public API: adds yield_after to shell/execute/subagent, job_list/job_output/job_wait/job_stop tools, GET /api/experimental/session/:sessionID/jobs, job.updated event and Session.jobs; session active now includes job-only sessions (additive; default 30s auto-yield changes tool behaviour)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-yield works as described. With the simulated model, a 45 s shell command blocked the turn for the full 45 s on the base. With this PR the call returned at 30 s with jobID and Output so far, and the completion notice started the session again by itself at 45 s. Core job, shell, execute, subagent and steer tests pass, as do the server session-jobs and client solid-data tests, and core typecheck:tsc is clean.

Public API (additive, but the default behaviour changes):

  • shell, execute and subagent take a new optional yield_after input (defaults to 30000; 0 backgrounds right away). Their outputs gain jobID, and execute output gains status: "running".
  • New Code Mode tools: tools.opencode.job_list, job_output, job_wait and job_stop.
  • New endpoint GET /api/experimental/session/:sessionID/jobs, exposed as experimental.session.jobs in the client and SDK.
  • New job.updated event.
  • New Session.jobs in the plugin API.
  • /api/session/active now also lists sessions whose only activity is a running job.
  • Session.background no longer adds the "moved to the background" message (from #60367).

Existing callers keep working. What changes is that tool calls longer than 30 s now return running instead of blocking, and the implicit 2-minute shell timeout no longer applies once a command yields.

Concerns:

  1. A subagent that starts a server never reports back to its parent (inline comment).
  2. Because sessions now count as busy while any job runs, and a yielded command keeps no implicit timeout, a session with a dev server or watch-mode command left running will look busy indefinitely (esc interrupt, 1 shell in the footer). Please confirm that's intended, and that Esc or job_stop is an obvious way to clear it.

Fixes #50278
Fixes #48826
Fixes #56729
Related: #54221, #51810, #36423

Before

After

yield* sessions.resume(recovery.childSessionID)
while (true) {
yield* sessions.resume(recovery.childSessionID)
yield* jobs.awaitSessionBackground(recovery.childSessionID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subagent now only reports back once every background job it owns has settled. With the 30 s auto-yield, any server the subagent starts, for example uvicorn or bun dev in the foreground or with background: true, becomes one of those jobs. The subagent then never completes, and its parent stays busy waiting for a notice that never comes. Before, the foreground server was killed at 2 minutes, or the subagent finished with the job still running. Could long-lived jobs be excluded from this wait, given a time limit, or handed over to the parent when the subagent's turn ends?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant