Skip to content

feat(core): move blocking work to the background when steering - #60367

Open
kitlangton wants to merge 1 commit into
devfrom
t901-steerbg
Open

kitlangton wants to merge 1 commit into
devfrom
t901-steerbg

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

Sending a steer message while an agent is waiting on a shell command or subagent now moves that work to the background on the server across every client.
The agent reads the new message on the next step while the command or subagent keeps running and posts its output when finished. Tools that cannot run in the background and prompts queued for the next turn still wait as before.

💡 Why: Previously only the web and desktop app moved blocking work on steer, using a second client request after prompt admission that missed TUI and SDK callers, missed tools that started while tool-call JSON was still streaming, and appended a duplicate synthetic notice beside the tool result.

🆕 Now you can: Steer a busy session from TUI, web, desktop, iOS, or the SDK (or promote a queued prompt to a steer) and have its blocking shells and subagents move to the background right away, with one completion notice when the background work finishes.

flowchart LR
  Steer["Steer admitted or promoted"] --> Inbox["Session inbox (user + steer)"]
  Inbox --> Move["jobs.backgroundAll(sessionID)"]
  Inbox --> Guard["job.block entry check & registry lock"]
  Move & Guard --> Step["Tool returns background status -> next step reads steer"]
  Move & Guard --> Done["Job finishes later -> single completion notice"]
  style Move fill:#dcfce7,stroke:#16a34a,color:#14532d
  style Guard fill:#dcfce7,stroke:#16a34a,color:#14532d
Loading
- client: prompt(steer) -> POST /session/:id/background -> synthetic("User requested...")
+ server: prompt(steer) | steerInbox -> if hasUserSteer: jobs.backgroundAll(sessionID)
+ job.block: if hasUserSteer: backgroundAll(sessionID); decide foreground vs background under lock

📊 Proof

Case Before After
Steer from TUI or SDK while a shell or subagent runs ❌ Waits for the tool to finish ✅ Moves to background; next step reads the steer
Tool starts blocking just after a steer was admitted ❌ Blocks the steered step ✅ Moves to background on block entry
Job finishes right after background ownership commits ❌ Could return inline and leave a recovery marker ✅ Lock keeps background ownership; one notice, marker cleared
Manual or steer backgrounding ❌ Appends duplicate "User requested..." synthetic message ✅ Tool result carries the background notice once
Non-backgroundable tool or delivery: "queue" prompt ✅ Runs to completion / waits for next turn ✅ Unchanged

New unit and integration tests in packages/core/test/session-steer-background.test.ts and packages/core/test/job.test.ts fail without the change; 94 core tests and 34 app composer tests pass.

👀 Read: packages/core/src/job.ts inside block: foreground versus background ownership is decided under the state.jobs lock after raceFirst wakes so a job that settles right after isBackgrounded commits still takes the background observer path and clears its recovery marker. 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>
    .job { animation: job 4.2s ease-in-out infinite }
    .msg { animation: msg 4.2s ease-out infinite }
    .dot { animation: dot 1.4s ease-in-out infinite }
    @keyframes job {
      0%, 24% { transform: translate(0, 0) scale(1); opacity: .9 }
      46%, 86% { transform: translate(88px, -16px) scale(.86); opacity: .55 }
      100% { transform: translate(0, 0) scale(1); opacity: .9 }
    }
    @keyframes msg {
      0%, 22% { transform: translateY(10px); opacity: 0 }
      42%, 86% { transform: translateY(0); opacity: 1 }
      100% { transform: translateY(10px); opacity: 0 }
    }
    @keyframes dot { 0%, 100% { opacity: .35 } 50% { opacity: .95 } }
    @media (prefers-reduced-motion: reduce) {
      .job { animation: none; transform: translate(88px, -16px) scale(.86); opacity: .6 }
      .msg { animation: none; transform: none; opacity: 1 }
      .dot { animation: none; opacity: .8 }
    }
  </style>
  <rect x="12" y="10" width="216" height="52" rx="8" stroke="currentColor" stroke-opacity=".2"/>
  <g class="job">
    <rect x="22" y="24" width="96" height="22" rx="5" fill="currentColor" fill-opacity=".08" stroke="currentColor" stroke-opacity=".35"/>
    <circle class="dot" cx="33" cy="35" r="3" fill="currentColor"/>
    <rect x="42" y="33" width="64" height="4" rx="2" fill="currentColor" fill-opacity=".65"/>
  </g>
  <g class="msg">
    <rect x="22" y="34" width="128" height="20" rx="5" fill="currentColor" fill-opacity=".14" stroke="currentColor" stroke-opacity=".5"/>
    <rect x="32" y="42" width="88" height="4" rx="2" fill="currentColor" fill-opacity=".85"/>
  </g>
</svg>

🔍 Background-jobs split; independently reviewed and both findings fixed · no user reports · not measured

Checks run
  • bun run script/test.ts test/job.test.ts test/session-steer-background.test.ts test/session-owned.test.ts test/session-prompt.test.ts (packages/core): 94 pass, 0 fail
  • bun run script/test.ts test/session-steer-background.test.ts --rerun-each 10 --randomize (packages/core): 60 pass, 0 fail
  • bun run script/test.ts test/job.test.ts --rerun-each 10 --randomize (packages/core): 170 pass, 0 fail
  • bun run typecheck && bun run typecheck:tsc (packages/core): pass
  • bun test --conditions=solid --preload ../ui/test/solid-jsx.ts --preload ./happydom.ts src/composer/submit.test.ts src/session/composer/queue.test.ts (packages/app): 34 pass, 0 fail
  • bun run typecheck && bun run typecheck:tsc (packages/app): pass
  • bun run lint:changed (root): 0 problems

@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 server-side move looks right. The new core integration tests fail on the base and pass with this change; core and app tests and tsc pass. I couldn't get a useful end-to-end steer check in the TUI with the simulated model, so I'm marking this not verified.

Public API: no schema, route or SDK changes. Two behaviour changes for SDK/HTTP callers:

  • POST /session/:id/background no longer appends the "User requested that active blocking work be moved to the background…" synthetic message.
  • A delivery: "steer" prompt, or an inbox update to steer, now backgrounds the session's blocking shells and subagents on the server.

Both are backwards compatible for callers. Clients that called session.background after a steer can drop that call; calling it again does nothing harmful.

Concerns:

  • Job depends on session code (inline): the generic job registry now imports Database and SessionInbox and queries the database on every block.
  • Lost guidance for the model (inline): without the synthetic message, a backgrounded shell's result no longer tells the model not to wait or poll.
  • Test size: session-steer-background.test.ts is 610 lines of live tests with real shell processes, file-polling loops and 10s timeouts. Please cut it down to the core cases: steer moves a running shell, a shell that starts after a steer is admitted is moved too, and a non-backgroundable tool is unchanged. The job.test.ts cases already cover the lock race more cheaply.

Comment thread packages/core/src/job.ts Outdated
)
return yield* Effect.gen(function* () {
// A steer that landed while tool-call JSON was streaming or the job was starting yields on arrival here.
if (Option.isSome(database) && (yield* SessionInbox.hasUserSteer(database.value.db, input.sessionID)))

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.

This makes the generic job registry depend on Database and SessionInbox, and run an inbox query on every block call. It also uses Effect.serviceOption, so the check silently does nothing in any layer without a database. Admission and promotion in session/session.ts already call backgroundAll, so this entry check only covers the race where a job starts blocking after the steer was admitted. Could the session or tool side own that check instead, e.g. a predicate passed to block or a check in the shell/subagent plugins before they block? That would keep Job free of session and storage dependencies.

].join("\n"),
})
.pipe(Effect.catchTag("Session.SyntheticConflictError", Effect.die))
yield* jobs.backgroundAll({ sessionID })

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.

Removing the synthetic message also removes the guidance the model got after a manual (ctrl+b) or steer-driven move: "still unfinished… Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification". The subagent's background result carries equivalent text. The shell's backgroundResult only says Command moved to the background … Output is streaming to: <file>, which invites polling the file. Consider adding the "you will be notified, do not poll" line to the shell's backgrounded result so the guidance isn't lost.

@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 new commit is a rebase. The steer logic is unchanged and the core steer and job tests plus tsc still pass. The Database fallback in Job is gone because Job now uses the base's database service, but job.ts still imports SessionInbox and queries the inbox on every block. The shell's backgrounded result still has no "you will be notified, do not poll" guidance. Both earlier threads stay open.

The public API is unchanged since my last review: POST /session/:id/background no longer appends a synthetic message, and steer prompts now move blocking work to the background on the server. Both are backwards compatible.

The earlier note on session-steer-background.test.ts still applies: 610 lines of live tests with real shell processes and file polling.

@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 new commit rebases cleanly and the steer logic is unchanged. Core and app tests and tsc pass. The lost "do not poll" guidance for backgrounded shells is now covered: the base appends the shell's background instruction to running results, and the steer test now checks that text reaches the next turn. The Job → SessionInbox dependency (earlier thread) is unchanged. The public API is unchanged since my last review.

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

Development

Successfully merging this pull request may close these issues.

1 participant