Skip to content

refactor(opencode): add Effect-native process wrapper - #1397

Merged
Astro-Han merged 4 commits into
devfrom
codex/i936-process-effect-wrapper
Jun 19, 2026
Merged

refactor(opencode): add Effect-native process wrapper#1397
Astro-Han merged 4 commits into
devfrom
codex/i936-process-effect-wrapper

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

Move packages/opencode/src/util/process.ts to an Effect-native implementation while keeping the legacy async compatibility facade.

Closed process boundaries:

  • Process.runEffect, textEffect, linesEffect, stopEffect, descendantsEffect, and terminateTreeEffect now own execution and cleanup.
  • The async run/text/lines/stop/descendants/terminateTree facade now delegates through the Process service runtime.
  • Effect graph callers in session/prompt.ts, pty/index.ts, and tool/shell.ts now yield the Effect-native process APIs directly.

Retained process boundaries:

  • Process.spawn still returns the Node child facade with exited: Promise<number> because CLI pager/auth flows, long-lived LSP launch, Windows cmd script spawning, stream ownership, and process cleanup still depend on that shape.
  • CLI-only, test harness, long-lived LSP shutdown, and async module compatibility callers keep using the legacy facade.

Why

Related to #936.

The remaining process utility primitive was still a Promise-first wrapper. This PR moves the reusable process execution and cleanup paths behind Effect APIs without forcing every compatibility caller through the Effect graph in one review.

Related Issue

Related to #936

Human Review Status

Pending

Review Focus

Please focus on whether the Effect API owns the real implementation, whether the retained spawn facade is still the right compatibility boundary, and whether abort/timeout/process-tree cleanup behavior is preserved.

Risk Notes

Process and shell cleanup are platform-sensitive. The PR preserves the existing cross-spawn child facade, Windows cmd script behavior, stdout/stderr buffering, nothrow, missing-command handling, abort cleanup, timeout cleanup, and process-tree termination tests.

Skipped checklist items:

  • Visible UI/copy manual check: no visible UI or copy changed.

How To Verify

bun test test/util/process.test.ts test/session/prompt.test.ts test/pty test/tool/shell.test.ts: 104 passed
bun test test/server/node-runtime-bun-boundary.test.ts: 2 passed
GOMAXPROCS=2 bun run typecheck: passed
git diff --check: passed

Screenshots or Recordings

Not applicable; no visible UI changes.

Checklist

  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

  • Refactor

    • Improved process execution reliability with enhanced shell command handling
    • Strengthened PTY session termination logic
    • Updated documentation with migration guidance
  • Tests

    • Expanded test coverage for process execution paths

@Astro-Han Astro-Han added P2 Medium priority upstream Tracked upstream or vendor behavior harness Model harness, prompts, tool descriptions, and session mechanics task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work tech-debt Supplemental cleanup, maintainability, architecture, test, or quality debt context labels Jun 19, 2026
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Astro-Han, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 51 minutes and 36 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 050c8a7d-609f-4a83-aaf0-c8d360611411

📥 Commits

Reviewing files that changed from the base of the PR and between 65f9aee and 5560053.

📒 Files selected for processing (1)
  • packages/opencode/src/util/process.ts
📝 Walkthrough

Walkthrough

util/process.ts is refactored into an Effect-based service: Process.Interface, Process.Service, and a ManagedRuntime are introduced; all core operations (spawnEffect, runEffect, stopEffect, descendantsEffect, terminateTreeEffect, textEffect, linesEffect) are reimplemented as Effects; existing Promise-based exports delegate via runtime.runPromise. Call sites in PTY teardown, ShellTool abort/timeout, and prompt shell expansion are updated to use the new *Effect APIs directly.

Changes

Process Effect Migration

Layer / File(s) Summary
Process.Service contracts and core types
packages/opencode/src/util/process.ts
Adds Process.Interface describing effect-based operations, Process.Service context class, Process.TerminateTreeInput type, and the effect package imports (Context, Effect, Layer, ManagedRuntime).
spawnEffect, runEffect, and stopEffect implementation
packages/opencode/src/util/process.ts
Replaces the old spawn/run body with spawnNode delegating through spawnEffect/runEffect; runEffect validates stdout/stderr, buffers output via effectful promises, applies nothrow semantics, and propagates RunFailedError; stopEffect begins the effectful stop path.
descendantsEffect and terminateTreeEffect implementation
packages/opencode/src/util/process.ts
Implements descendantsEffect (pgrep-based enumeration with error fallback), terminateTreeEffect (Windows taskkill, SIGTERM/SIGKILL signaling, grace-period race via Effect.promise), textEffect/linesEffect, layer/defaultLayer/runtime, and Promise-export delegation via runtime.runPromise.
Call-site updates: PTY, ShellTool, prompt expansion
packages/opencode/src/pty/index.ts, packages/opencode/src/tool/shell.ts, packages/opencode/src/session/prompt.ts
PTY terminate, ShellTool.run abort/timeout branches, and expandCommandTemplate shell-snippet evaluation replaced from Effect.promise(() => Process.terminateTree(...)) / Promise.all wrappers to direct yield* Process.terminateTreeEffect(...) and Effect.all over Process.textEffect(...) with Effect.orDie.
Tests and migration docs
packages/opencode/test/util/process.test.ts, packages/opencode/specs/effect-migration.md
New test asserts stdout/stderr capture via Effect.runPromise(Process.runEffect(...)). Migration spec updated to document Process.Service ownership, Process.spawn retention rationale, and converted call sites.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Astro-Han/pawwork#423: Changes the same util/process.ts process-cleanup primitives and updates PTY teardown to use shared terminateTree* handling in pty/index.ts.
  • Astro-Han/pawwork#1391: Refactors lsp/server.ts to use Effect-native child-process helpers, directly consuming the new Process.runEffect/terminateTreeEffect APIs introduced in this PR.

Poem

🐇 Hop, hop, no more Promise.all in sight,
Effects cascade through the pipeline bright.
terminateTreeEffect — a branch is tamed,
runEffect fires, each process named.
The bunny wires layer, runtime, and more,
Effect-native shells knock at the door! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding an Effect-native process wrapper to replace Promise-first implementations while maintaining compatibility.
Description check ✅ Passed The PR description fully complies with the template, including Summary, Why, Related Issue, Human Review Status, Review Focus, Risk Notes, How To Verify, and completed checklist with all required items addressed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/i936-process-effect-wrapper

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

Comment thread packages/opencode/src/util/process.ts Fixed
Comment thread packages/opencode/src/util/process.ts Fixed

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (5)
packages/opencode/test/util/process.test.ts (1)

22-27: Use the Effect test harness instead of Effect.runPromise(...) in the test body.

This test exercises an Effect-native workflow (Process.runEffect) with a spawned child process, so it should run via testEffect(...) with it.live(...) rather than raw test(...) + Effect.runPromise(...). Import testEffect from test/lib/effect.ts, define const it = testEffect(Layer.allSucceed(...) /* or minimal layer */) near the top of the file, then replace the test with it.live("captures stdout and stderr through the Effect path", () => Effect.gen(function* () { ... })).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/util/process.test.ts` around lines 22 - 27, The test
for Process.runEffect should use the Effect test harness instead of raw test()
with Effect.runPromise(). Import testEffect from test/lib/effect.ts and create
an it constant at the top of the file using testEffect(Layer.allSucceed(...)).
Replace the test() function declaration with it.live("captures stdout and stderr
through the Effect path", ...) and convert the test body to use Effect.gen()
syntax instead of await Effect.runPromise(), yielding the Process.runEffect call
and asserting on the result within the generator function.

Sources: Coding guidelines, Learnings

packages/opencode/src/util/process.ts (1)

356-357: 💤 Low value

Consider using makeRuntime from src/effect/run-service.ts.

The coding guidelines recommend using makeRuntime which provides a shared memoMap for layer deduplication. The current implementation uses ManagedRuntime.make directly. This may be intentional if Process is a foundational module that needs to avoid circular dependencies, but if not, consider aligning with the standard runtime pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/util/process.ts` around lines 356 - 357, The runtime is
being created using ManagedRuntime.make(defaultLayer) directly instead of the
recommended makeRuntime function from src/effect/run-service.ts, which provides
a shared memoMap for layer deduplication. Unless this Process module is a
foundational module that needs to avoid circular dependencies, replace the
ManagedRuntime.make(defaultLayer) call with makeRuntime() from
src/effect/run-service.ts, ensuring the runtime variable and runPromise function
continue to work as expected.

Source: Coding guidelines

packages/opencode/src/pty/index.ts (1)

172-176: ⚡ Quick win

Use Effect.fn operator arguments instead of outer .pipe() here.

Line 172-Line 176 can pass Effect.orDie directly to Process.terminateTreeEffect(...) to match the repo’s Effect composition convention.

♻️ Proposed refactor
-        yield* Process.terminateTreeEffect({
+        yield* Process.terminateTreeEffect({
           pid: session.process.pid,
           signalRoot: (signal) => session.process.kill(signal),
           waitForExit: exited,
-        }).pipe(Effect.orDie)
+        }, Effect.orDie)

As per coding guidelines, “Use Effect.fn("Domain.method") ... these accept pipeable operators as extra arguments to avoid unnecessary outer .pipe() wrappers”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/pty/index.ts` around lines 172 - 176, The code is using
an outer `.pipe(Effect.orDie)` wrapper after the `Process.terminateTreeEffect()`
call, but according to the repo's Effect composition convention, this operator
should be passed directly as an argument to `Process.terminateTreeEffect()`
instead. Refactor the call to `Process.terminateTreeEffect()` to accept
`Effect.orDie` as a direct argument within the function call, eliminating the
need for the outer `.pipe()` wrapper while maintaining the same functionality.

Source: Coding guidelines

packages/opencode/src/tool/shell.ts (1)

610-618: ⚡ Quick win

Apply the same Effect.fn call style in both abort/timeout branches.

Line 610-Line 618 should pass Effect.orDie as an argument to Process.terminateTreeEffect(...) instead of chaining .pipe(...) for consistency with the repo Effect style.

♻️ Proposed refactor
-            yield* Process.terminateTreeEffect({ pid: handle.pid, waitForExit: Effect.runPromise(handle.exitCode) }).pipe(
-              Effect.orDie,
-            )
+            yield* Process.terminateTreeEffect(
+              { pid: handle.pid, waitForExit: Effect.runPromise(handle.exitCode) },
+              Effect.orDie,
+            )
...
-            yield* Process.terminateTreeEffect({ pid: handle.pid, waitForExit: Effect.runPromise(handle.exitCode) }).pipe(
-              Effect.orDie,
-            )
+            yield* Process.terminateTreeEffect(
+              { pid: handle.pid, waitForExit: Effect.runPromise(handle.exitCode) },
+              Effect.orDie,
+            )

As per coding guidelines, “Use Effect.fn("Domain.method") ... these accept pipeable operators as extra arguments to avoid unnecessary outer .pipe() wrappers”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/shell.ts` around lines 610 - 618, Both the abort
branch (around line 610) and timeout branch (around line 615) currently use
Process.terminateTreeEffect() with a chained .pipe(Effect.orDie) call. Refactor
both instances to pass Effect.orDie directly as an argument to the
Process.terminateTreeEffect() function call instead of chaining it with .pipe(),
removing the outer .pipe() wrapper entirely. This applies the consistent Effect
style used throughout the repository where pipeable operators are passed as
extra arguments to effect functions.

Source: Coding guidelines

packages/opencode/src/session/prompt.ts (1)

1498-1505: ⚡ Quick win

Inline operators into Process.textEffect(...) instead of piping the result.

Line 1498-Line 1505 should use Effect.fn’s operator-argument form to avoid the extra .pipe(...) wrapper.

♻️ Proposed refactor
-            Process.textEffect([shellCmd], { shell: sh, nothrow: true }).pipe(
-              Effect.map((result) => result.text),
-              Effect.orDie,
-            ),
+            Process.textEffect(
+              [shellCmd],
+              { shell: sh, nothrow: true },
+              Effect.map((result) => result.text),
+              Effect.orDie,
+            ),

As per coding guidelines, “Use Effect.fn("Domain.method") ... these accept pipeable operators as extra arguments to avoid unnecessary outer .pipe() wrappers”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/session/prompt.ts` around lines 1498 - 1505, The code
in the shellMatches.map callback is chaining Effect operators using `.pipe()`
after calling Process.textEffect. Refactor this to pass the operators directly
as arguments to Process.textEffect instead of piping them. Remove the
`.pipe(Effect.map((result) => result.text), Effect.orDie,)` chain and instead
provide these operators as additional arguments to the Process.textEffect call
to follow the coding guideline of using Effect.fn operator-argument form to
avoid unnecessary outer pipe wrappers.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/opencode/src/pty/index.ts`:
- Around line 172-176: The code is using an outer `.pipe(Effect.orDie)` wrapper
after the `Process.terminateTreeEffect()` call, but according to the repo's
Effect composition convention, this operator should be passed directly as an
argument to `Process.terminateTreeEffect()` instead. Refactor the call to
`Process.terminateTreeEffect()` to accept `Effect.orDie` as a direct argument
within the function call, eliminating the need for the outer `.pipe()` wrapper
while maintaining the same functionality.

In `@packages/opencode/src/session/prompt.ts`:
- Around line 1498-1505: The code in the shellMatches.map callback is chaining
Effect operators using `.pipe()` after calling Process.textEffect. Refactor this
to pass the operators directly as arguments to Process.textEffect instead of
piping them. Remove the `.pipe(Effect.map((result) => result.text),
Effect.orDie,)` chain and instead provide these operators as additional
arguments to the Process.textEffect call to follow the coding guideline of using
Effect.fn operator-argument form to avoid unnecessary outer pipe wrappers.

In `@packages/opencode/src/tool/shell.ts`:
- Around line 610-618: Both the abort branch (around line 610) and timeout
branch (around line 615) currently use Process.terminateTreeEffect() with a
chained .pipe(Effect.orDie) call. Refactor both instances to pass Effect.orDie
directly as an argument to the Process.terminateTreeEffect() function call
instead of chaining it with .pipe(), removing the outer .pipe() wrapper
entirely. This applies the consistent Effect style used throughout the
repository where pipeable operators are passed as extra arguments to effect
functions.

In `@packages/opencode/src/util/process.ts`:
- Around line 356-357: The runtime is being created using
ManagedRuntime.make(defaultLayer) directly instead of the recommended
makeRuntime function from src/effect/run-service.ts, which provides a shared
memoMap for layer deduplication. Unless this Process module is a foundational
module that needs to avoid circular dependencies, replace the
ManagedRuntime.make(defaultLayer) call with makeRuntime() from
src/effect/run-service.ts, ensuring the runtime variable and runPromise function
continue to work as expected.

In `@packages/opencode/test/util/process.test.ts`:
- Around line 22-27: The test for Process.runEffect should use the Effect test
harness instead of raw test() with Effect.runPromise(). Import testEffect from
test/lib/effect.ts and create an it constant at the top of the file using
testEffect(Layer.allSucceed(...)). Replace the test() function declaration with
it.live("captures stdout and stderr through the Effect path", ...) and convert
the test body to use Effect.gen() syntax instead of await Effect.runPromise(),
yielding the Process.runEffect call and asserting on the result within the
generator function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e473b1ca-c27a-4b1a-8d5f-eb470b342956

📥 Commits

Reviewing files that changed from the base of the PR and between 8f15bed and 65f9aee.

📒 Files selected for processing (6)
  • packages/opencode/specs/effect-migration.md
  • packages/opencode/src/pty/index.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/src/util/process.ts
  • packages/opencode/test/util/process.test.ts

@Astro-Han
Astro-Han merged commit 7837d82 into dev Jun 19, 2026
41 checks passed
@Astro-Han
Astro-Han deleted the codex/i936-process-effect-wrapper branch June 19, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work tech-debt Supplemental cleanup, maintainability, architecture, test, or quality debt context upstream Tracked upstream or vendor behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants