Skip to content

feat(runtime): observe background Bash through runtime refs - #547

Merged
Astro-Han merged 11 commits into
apache:mainfrom
M4n5ter:shell-run-p0
Jul 7, 2026
Merged

feat(runtime): observe background Bash through runtime refs#547
Astro-Han merged 11 commits into
apache:mainfrom
M4n5ter:shell-run-p0

Conversation

@M4n5ter

@M4n5ter M4n5ter commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

Refs #486.

This PR keeps long-running non-interactive Bash work in the existing ShellRun lifecycle, but narrows the agent-facing surface to the runtime-resource shape discussed in review:

  • Bash remains the single start path. Commands that finish inside yield_time_ms return a normal terminal result.
  • Commands that outlive yield_time_ms continue as background tasks and return a runtime ref such as maka://runtime/background-tasks/<id>.
  • Read(ref) observes a background task and returns status plus bounded stdout/stderr tails together.
  • StopBackgroundTask({ ref }) stops a background task by runtime ref.
  • ShellStatus, ShellWait, and ShellCancel are not part of the tool surface.

Design

Layer Change
core Keeps ShellRunRecord as the durable internal model and uses a shell_run tool result with a required ref.
storage Stores ShellRuns under session-scoped file-backed records, with per-run writes serialized by the store.
runtime Owns the ShellRun process lifecycle, runtime ref parsing, Read(ref) rendering, and generic stop-by-ref for shell background tasks.
SessionManager / RuntimeKernel Recovers stale running records as orphaned and injects bounded background-task metadata into the next turn tail.
desktop Wires background-capable Bash into the main runtime while keeping child-agent local-read tools file-only.
CLI/TUI Wires the same ShellRun manager into the TUI runtime and cleans up live tasks when the CLI runtime context closes.

Behavior

  • Bash remains permission-gated.
  • The default Bash timeout is 120_000ms when no explicit timeout_ms is supplied.
  • Aborting before the initial yield_time_ms window returns cancels the process as foreground work instead of leaving it behind.
  • Read(ref) treats runtime refs as whole resources; file pagination options do not slice task metadata away from stdout/stderr tails.
  • Completed, failed, timed-out, cancelled, and orphaned background tasks are observations, not failed tool invocations by themselves.
  • Runtime refs are not UI navigation links; the UI maka:// router continues to reject maka://runtime/*.

Scope Boundaries

Not included in this PR:

  • PTY or interactive stdin.
  • Recovering OS process handles after app restart; durable running records without live handles become orphaned.
  • Full-output artifacts beyond bounded stdout/stderr tails.
  • A headless ShellRun lifecycle; headless keeps isolated foreground Bash.
  • A subagent or automation background-task control surface.

Verification

Validated with runtime, CLI/TUI, headless, UI, and desktop URI-focused coverage relevant to this change.

@M4n5ter
M4n5ter marked this pull request as ready for review July 5, 2026 10:38
M4n5ter added 2 commits July 5, 2026 19:03
# Conflicts:
#	apps/desktop/src/main/main.ts
#	packages/cli/src/cli.ts
# Conflicts:
#	packages/runtime/src/ai-sdk-backend.ts

@Astro-Han Astro-Han 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.

Strong work on this. I ran the full merge-check against current main (typecheck, build, core/storage/runtime 878, desktop 2060) and it's all green. The implementation follows the #486 RFC invariants closely: runtime-owned lifecycle, bounded and redaction-safe output, conservative orphaned recovery, desktop and headless sharing one model. The yield-based auto-background plus the turn-tail summary is a nice touch. Short commands stay foreground transparently; long ones become observable without the model having to decide up front. I thumbs-up'd both #486 and #544. The direction is right and this is a solid realization of it.

The thing I want to settle before this merges is the tool surface. #486 Question 2 left the agent-facing surface open on purpose, and this PR picks the heaviest of the three options it listed: Bash plus three always-on control tools (ShellStatus, ShellWait, ShellCancel).

Tool schemas are a recurring tax, not a one-time cost. Every one of these sits in the system prompt for the whole session. Maka's pitch is economy, and we already built the load_tools / deferred-tools seam for exactly this reason: to keep the always-loaded surface small. Adding three shell-control tools to the default set cuts against that. We can't defer them either: if the model had to load_tools before it could check on a backgrounded run, the flow breaks (Bash backgrounds a run, then the model has to load tools before it can look at it).

Claude Code hit the same problem and solved it with one Bash tool and one boolean parameter, run_in_background. The rest reuses things that already exist: background output is a file, read with the existing Read tool; completion is a passive notification ("you'll be notified when it finishes, don't poll"); cancel goes through a shared TaskStop tool that the agent and task system already use. Net new shell-specific schema: roughly zero. No ShellStatus, because Read covers it. No ShellWait, because passive notification covers it, and saves the model from burning turns polling.

My suggestion for maka: keep this PR's good parts (yield-based auto-background, turn-tail summary that already nudges the model about running ShellRuns each turn) and drop the three control tools. Read covers status. The existing event stream plus the turn-tail summary cover "what's still running" and "when did it finish" without a dedicated wait tool. Cancel still needs a tool, but it can be one shared "stop background task" tool that future agent/cron/eval tasks will also want, instead of a shell-specific ShellCancel. That's three schemas down to one, or two if you'd rather keep a dedicated read-only observe tool. If we keep the current surface, several of the smaller issues below get worse; if we shrink it, most of them disappear.

Separate from the surface, one fix I'd want before this lands regardless. The old Bash tool defaulted to 120000ms. The new background path only installs a timeout timer when timeout_ms is passed explicitly, so a Bash call with no timeout_ms runs unbounded in the background until cancel or session end. One line to restore the default: timeout_ms ?? 120_000. Real regression from the old safety bound, cheap to fix.

Smaller things, as follow-ups. Several are moot if the surface shrinks:

  • Abort during the initial yield window backgrounds the run instead of cancelling it. Old behavior killed the process. Worth a deliberate call. Probably kill during the yield window, keep after it backgrounds.
  • ShellStatus lists only the first 8 actionable runs. Runs 9 through 64 have no discoverable id, and there's no limit/offset. Goes away if Status becomes Read.
  • ShellWait doesn't pass the abort signal, so a long wait blocks up to 300s even if the user hits stop. Goes away if Wait becomes passive notification.
  • Scope is broad: 39 files, including UI stories, a desktop fixture, and headless wiring. The headless share is minimal and probably has to ship with this; the UI and fixture bits might split out.
  • PR body says 28 tests, the suite actually has 32. Minor.

Happy to pair on the surface reshape. The direction is right; this is about how much schema we pay for it.

@M4n5ter

M4n5ter commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

@Astro-Han I agree with the main concern here: the runtime lifecycle model is useful, but the always-loaded agent-facing surface is probably too heavy. ShellStatus, ShellWait, and ShellCancel make every session carry shell-specific schema even when no background shell is used. That cuts against Maka's tool economy.

After re-reading the implementation and your comment, I think the PR should be reshaped around a smaller surface:

  • Keep the yield-based auto-background behavior in Bash. yield_time_ms still seems useful because it lets the model wait briefly for likely-short commands or hand control back quickly for known long-running commands, but I am open to changing that if you prefer a simpler contract.
  • Restore the old Bash default timeout for the background path: no explicit timeout_ms should still mean 120_000ms.
  • Treat abort during the initial yield window as foreground cancellation. If the run has not been returned to the model as a background resource yet, stopping the turn should stop the process rather than silently leaving it running.
  • Drop ShellStatus, ShellWait, and ShellCancel from this PR.

For observation, I am considering reusing Read for runtime-owned resources instead of adding shell-specific control tools or writing output files into the workspace cwd, if that fits Maka's existing Read abstraction. The candidate shape is something like:

  • maka://runtime/background-tasks/<id> for a single background task snapshot
  • possibly maka://runtime/background-tasks for the actionable list, if you think a list document is needed after removing ShellStatus

The Bash result for a backgrounded run would stay compact: status/cwd/command/ref, without stdout/stderr tail. The benefit here is context economy and explicit observation: long command output enters model context only when the model asks to read it.

Internally I would keep ShellRun as the shell-specific runtime/process fact. The change is mostly that ShellRun stops being the model-facing concept; the model sees a runtime resource ref instead.

For stop/cancel, I do not plan to introduce a new shared stop tool in this PR. Given #545/#558 are also actively shaping the broader automation/background-task model, it seems better not to preempt that shared contract here. I would rather leave cancellation out of this PR than ship another shell-specific tool that we already know should become shared.

Scope-wise, I would keep this to desktop and CLI/TUI where ShellRunProcessManager is already wired. Headless isolated Bash, subagents, and automation runs would stay out of scope for this PR.

Does this direction match what you had in mind? The two parts I would especially like your take on are the Read resource-ref shape and whether deferring the shared stop contract is the right boundary for this PR.

M4n5ter added 3 commits July 6, 2026 21:17
# Conflicts:
#	apps/desktop/src/main/main.ts
#	packages/cli/src/__tests__/runtime-bootstrap.test.ts
#	packages/cli/src/runtime-bootstrap.ts
#	packages/headless/src/__tests__/tools.test.ts
#	packages/runtime/src/__tests__/builtin-tools.test.ts
#	packages/runtime/src/builtin-tools.ts
#	packages/runtime/src/index.ts
#	packages/runtime/src/runtime-kernel.ts
#	packages/runtime/src/tool-runtime.ts
@Astro-Han

Astro-Han commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@M4n5ter This matches what I had in mind. Drop the three control tools, keep yield-based backgrounding, observe through Read. On your two questions:

Q1 — Read resource-ref. Agree, and it's better than the disk-file idea we floated. I checked: resolveExistingInsideCwd is strictly cwd-contained, and the shell-run record sits under the storage root, outside cwd. So "output → real file → Read" forces a choice we don't want: widen Read's containment, or write the log into cwd (workspace pollution, Edit/git-leak risk). The virtual ref avoids both. It also keeps one source of truth: live BashTailBuffer while running, store record after exit, redaction + truncation once at read time. Data stays where it is today; maka://runtime/background-tasks/<id> is just a handle Read resolves.

Two things to pin down. One Read must return status + the bounded tail together; if stdout/stderr need a second hop we've rebuilt ShellStatus. And maka://runtime/ is a new Read backend owned by the runtime, separate from the UI-nav maka:// sections; add a line in maka-uri.ts so the UI router doesn't try to route it.

Skip the list ref for now. The turn-tail summary already surfaces actionable runs with refs each turn. yield_time_ms: keep, it's cheap and useful.

Q2 — shared stop. I'd rather not defer this. The fallbacks don't hold: a timeout only fires at the deadline, so a run the model wants to stop now (wrong command, runaway loop, tail -f) still blocks; shutdown / terminateAll / session-close are session-level, i.e. killing the app to stop one command. For a feature built around backgrounding, "no stop except wait or close" is the unsafe part, and I don't think cleanliness is worth that gap.

But your concern is fair. Compromise: ship the stop tool now with the generic shape, scoped to shell runs. StopBackgroundTask taking the same ref Read uses. It's mostly a reshape of the existing ShellCancel / cancel(), which already does SIGTERM→SIGKILL grace and returns a snapshot. Schema is { ref }, so when #545/#558 tasks land they plug in with no schema change. v1 of the generic stop, not a throwaway.

With yield-window-abort-kills, every phase has a stop path. And #545/#558 are scheduling (timers injecting turns); the overlap with a live process is mostly "cancel by id," which is stable, so committing to the shape now is lower-risk than it looks.

Net surface: Bash + StopBackgroundTask. Rest of the plan (120s default, abort-during-yield kills, scope to desktop + CLI/TUI) is good. Minor: body says 28 tests, suite has 32.

@M4n5ter M4n5ter changed the title feat(runtime): add observable ShellRuns for long Bash commands feat(runtime): observe background Bash through runtime refs Jul 6, 2026
@M4n5ter

M4n5ter commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

@Astro-Han I reshaped the PR around your suggested surface.

The current version removes ShellStatus, ShellWait, and ShellCancel. Bash now returns a runtime background-task ref when it yields; Read(ref) is the observation path and returns status plus bounded stdout/stderr tails together; StopBackgroundTask({ ref }) is the generic stop path for this slice. I also kept maka://runtime/* refs out of UI navigation and made child-agent local-read tools file-only so subagents do not inherit the parent background-task resource backend.

I added coverage for a finalization edge case in this shape: a just-finished process should be observed as completed, not orphaned, even if the durable terminal write is still settling.

Headless, subagent background control, PTY, and stdin remain out of scope here.

@M4n5ter

M4n5ter commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

@Astro-Han I want to explicitly call out one bounded-discovery tradeoff in the current Read(ref) shape.

The current turn tail lists only a bounded subset of actionable background tasks. Each Bash call that yields still returns its own maka://runtime/background-tasks/<id> ref immediately, so the normal path is fine. The edge case is when a session accumulates more actionable tasks than the turn-tail cap, and a task ref has fallen out of the model-visible context. In that case, tasks outside the visible turn-tail subset are not self-discoverable by the model: the turn tail reports that more tasks exist, but it does not include every overflow ref, and this PR intentionally does not add a list resource or bring back a status tool.

The most likely cases are:

  • many background commands are started in one session;
  • many terminal-but-unobserved tasks accumulate and keep occupying the visible subset;
  • an older running task or an older unobserved terminal task has no ref left in the current model context.

I think this is acceptable for the initial implementation. It is a bounded discoverability tradeoff rather than a data-loss or process-control issue: the initial Bash result returns the ref, known refs remain readable through Read(ref), the turn tail still exposes the most relevant actionable tasks plus an overflow count, and the common case should be one or a few background tasks. Keeping this bounded also avoids reintroducing the heavier always-on status surface we are trying to remove.

If real usage shows that overflow discovery matters, the lowest-regret follow-up seems to be adding a bounded list resource under the same runtime namespace, without changing the existing single-task Read(ref) and StopBackgroundTask({ ref }) contract.

@Astro-Han Astro-Han 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.

LGTM. Verified the reshape against what we agreed: 120s default restored, abort-during-yield kills with a regression test, Read(ref) returns status + tail in one call, StopBackgroundTask is the generic stop path, and the just-finished finalization race has coverage with a delayed store. maka://runtime/* stays out of UI nav and subagent Read fails closed without the controller.

On the bounded-discovery tradeoff: agree it's acceptable for the initial cut. No data loss, no runaway (stop + default timeout + ref returned inline), and the list-resource follow-up doesn't change the Read/Stop contract.

@Astro-Han
Astro-Han merged commit aa941f1 into apache:main Jul 7, 2026
@M4n5ter
M4n5ter deleted the shell-run-p0 branch July 13, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants