Skip to content

fix(lifecycle): use PTY-based renderer for lifecycle terminal output#1425

Merged
arnestrickmann merged 5 commits intogeneralaction:mainfrom
ckafrouni:emdash/run-terminal-colors-6qk
Mar 12, 2026
Merged

fix(lifecycle): use PTY-based renderer for lifecycle terminal output#1425
arnestrickmann merged 5 commits intogeneralaction:mainfrom
ckafrouni:emdash/run-terminal-colors-6qk

Conversation

@ckafrouni
Copy link
Contributor

@ckafrouni ckafrouni commented Mar 11, 2026

Fixes #1304

Summary

  • Added LifecycleTerminalView, a separate read-only xterm instance. Blocks input, it just renders the lifecycle output with ANSI colors.
  • Replace child_process.spawn() with node-pty for lifecycle scripts (setup/run/teardown) so output includes proper ANSI colors and formatting
  • Add LifecycleTerminalView component that renders lifecycle output in a read-only xterm.js terminal instead of a plain <pre> tag
  • Respect SIGTERM→SIGKILL escalation in stopRun

Test plan

  • Verify lifecycle scripts (setup/run/teardown) produce colored output in the terminal panel
  • Verify stopRun kills a stubborn process after 8s via SIGKILL escalation
  • Confirm font family/size settings apply to lifecycle terminal view
  • Run pnpm exec vitest run src/test/main/TaskLifecycleService.test.ts — all 9 tests pass

The migration to PTY-based lifecycle execution lost the SIGTERM→SIGKILL
escalation when stopping a run process. After 8 seconds, the fallback
kill now correctly sends SIGKILL instead of a redundant SIGTERM.
@vercel
Copy link

vercel bot commented Mar 11, 2026

@ckafrouni is attempting to deploy a commit to the General Action Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR replaces child_process.spawn() with node-pty for all lifecycle scripts (setup/run/teardown) so their output includes proper ANSI colour codes, and introduces a new LifecycleTerminalView read-only xterm.js component to render that output in the terminal panel. A startLifecycleSpawnFallback path is retained for environments where node-pty is unavailable or EMDASH_DISABLE_PTY=1 is set. On the service side, ChildProcess references are replaced with the LifecyclePtyHandle abstraction, clearTask/shutdown properly drain the new maps, and stopRun gains SIGTERM→SIGKILL escalation after 8 s.

Key changes:

  • LifecyclePtyHandle interface unifies PTY and fallback spawn under a common API; startLifecyclePty in ptyManager.ts implements both paths.
  • TaskLifecycleService maps (runPtys, finitePtys) replace the old ChildProcess maps. The stale-entry guard for startRunInternal is simplified — correct because entries are only removed on actual exit.
  • waitForPtyExit is correctly constructed before stopRun is called, avoiding a race where the exit event could fire before the callback is registered.
  • LifecycleTerminalView is a self-contained xterm.js component that listens to global font-change events, uses an incremental startsWith append optimisation, and cleans up properly on unmount.
  • The main concern is in ptyManager.ts: spawning the shell with -ilc (login + interactive) will cause the user's shell startup files to be sourced on every lifecycle script invocation, injecting any output-producing lines from ~/.bashrc, oh-my-zsh, conda init, nvm, etc. into the lifecycle terminal panel — a visible behaviour change from the old non-interactive shell.

Confidence Score: 3/5

  • Safe to merge for the architectural change, but the -ilc shell flags may cause user-visible noise in lifecycle output on systems with verbose shell configs.
  • The core PTY abstraction, handle lifecycle, deduplication logic, and SIGKILL escalation are all correctly implemented and well-tested. The primary concern is the behavioural change introduced by the -il shell flags in startLifecyclePty, which will inject login/interactive shell startup output (nvm, conda, oh-my-zsh greetings, etc.) into every lifecycle terminal — something the old child_process.spawn path never did. This could confuse users and is not immediately obvious from the PR description.
  • src/main/services/ptyManager.ts — specifically the shell invocation flags at line 1708

Important Files Changed

Filename Overview
src/main/services/ptyManager.ts Adds startLifecyclePty and startLifecycleSpawnFallback. The PTY path uses '-ilc' shell flags causing login/interactive startup noise in lifecycle output; fallback path correctly omits these flags. Lifecycle PTYs are registered in the shared ptys map with kind:'local', making them indistinguishable from interactive terminals to existing consumers.
src/main/services/TaskLifecycleService.ts Cleanly migrates from ChildProcess to LifecyclePtyHandle throughout. The new run-guard (existence in runPtys only) is correct because entries are removed on exit. The waitForPtyExit helper correctly registers onExit before stopRun to avoid race conditions, and the SIGTERM→SIGKILL escalation is properly gated on identity equality.
src/renderer/components/LifecycleTerminalView.tsx New read-only xterm.js terminal component for lifecycle output. Terminal init effect uses empty deps (intentional) but captures stale pre-async font values causing a brief font reflow on mount. Incremental append optimization via startsWith is correct. ResizeObserver and event listeners are properly cleaned up.
src/renderer/components/TaskTerminalPanel.tsx Replaces <pre> tag with LifecycleTerminalView; correctly keys on task id + phase to force terminal recreation on selection change. The lifecycleLogContent useMemo is a straightforward extraction of existing logic.
src/test/main/TaskLifecycleService.test.ts Tests are cleanly migrated from child_process mocks to LifecyclePtyHandle mocks. The mock factory provides emitData/emitExit/emitError helpers for fine-grained event control. All 9 tests correctly reflect the new PTY-based implementation.

Sequence Diagram

sequenceDiagram
    participant TLS as TaskLifecycleService
    participant PM as ptyManager
    participant PTY as node-pty / spawn fallback
    participant UI as LifecycleTerminalView

    Note over TLS,UI: startRun / runFinite (setup|teardown)

    TLS->>PM: startLifecyclePty({ id, command, cwd, env })
    alt node-pty available
        PM->>PTY: pty.spawn(shell, ['-ilc', command], opts)
        PM-->>PM: ptys.set(id, { proc, kind:'local' })
    else EMDASH_DISABLE_PTY=1 or require fails
        PM->>PTY: spawn(command, { shell:true, detached:true })
    end
    PM-->>TLS: LifecyclePtyHandle { pid, onData, onExit, onError, kill }

    TLS->>TLS: runPtys.set(taskId, handle)
    PTY-->>TLS: onData(line) → emitLifecycleEvent('line')
    TLS-->>UI: IPC line event → content string grows

    UI->>UI: useEffect([content]): startsWith check
    alt content appends previous
        UI->>UI: terminal.write(appended)
    else content reset
        UI->>UI: terminal.reset() + terminal.write(content)
    end

    Note over TLS,PTY: stopRun (SIGTERM → SIGKILL escalation)

    TLS->>TLS: stopIntents.add(taskId)
    TLS->>PM: handle.kill()  [SIGTERM]
    TLS->>TLS: setTimeout(8s) → handle.kill('SIGKILL')
    PTY-->>TLS: onExit(code) → runPtys.delete(taskId)

    Note over TLS,PTY: runTeardown waits for run to exit

    TLS->>TLS: waitForPtyExit(handle, isTracked, 10s)
    TLS->>TLS: stopRun(taskId)
    PTY-->>TLS: onExit fires → finish() resolves
    TLS->>TLS: runFinite('teardown', …)
Loading

Last reviewed commit: 5b771c9

maybeProc.on?.('error', emitError);
} catch {}

ptys.set(id, { id, proc, cwd, kind: 'local', cols: 120, rows: 32 });
Copy link
Contributor

Choose a reason for hiding this comment

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

Lifecycle PTYs added to the shared ptys map without a cleanup guard

ptys.set(id, ...) registers this lifecycle PTY in the same global map used for interactive terminal PTYs. The kill() closure and the onExit handler both call ptys.delete(id), so the entry is normally removed.

However, if a caller constructs the LifecyclePtyHandle but never calls any of the registered callbacks (e.g., startLifecyclePty is called but the returned handle is immediately discarded via clearTask calling ptys.delete from the kill() closure), the ptys map is still eventually cleaned. This is fine.

The subtle concern is that lifecycle PTY entries (lifecycle-run-*, lifecycle-setup-*, etc.) are now visible to exported helpers like getPty(id), getPtyKind(id), and hasPty(id). If any consumer iterates or checks for these IDs in a way that assumes all entries are interactive terminals, it may behave unexpectedly. Adding a comment or a distinct kind value (e.g., kind: 'lifecycle') would make the distinction explicit and enable guarding in those helpers.

@ckafrouni
Copy link
Contributor Author

ckafrouni commented Mar 11, 2026

One more thing:

Lifecycle PTYs registered are now registered global ptys map, before they were just subprocesses, so not registered.

The id format is "lifecycle-run-{taskId}", different than the agent pty.

Should i just not have them registered in the global pty list, or add a "lifecycle" kind to the pty map?

When EMDASH_DISABLE_PTY=1 is set or node-pty fails to load, lifecycle
scripts now gracefully degrade to a child_process.spawn-based
implementation instead of hard-failing. Also fixes the onExit signal
type to match node-pty's actual typings.
@ckafrouni
Copy link
Contributor Author

ckafrouni commented Mar 11, 2026

One more thing:

Lifecycle PTYs registered are now registered global ptys map, before they were just subprocesses, so not registered.

The id format is "lifecycle-run-{taskId}", different than the agent pty.

Should i just not have them registered in the global pty list, or add a "lifecycle" kind to the pty map?

The ptys map entries already have a kind field typed as 'local' | 'ssh'.
Right now lifecycle PTYs set to kind: 'local', so similar to all the other terminals you guys have, the agent and the ones in the sidebar.

I'm not sure how you guys already differentiate between the main agent terminals, and the ones in the sidebar, I'm guessing it is purely id based ?

  • Agent (main): {providerId}-main-{taskId}
  • Agent (chat): {providerId}-chat-{conversationId}

What I have now is

  • Lifecycle: lifecycle-{phase}-{taskId} — e.g. lifecycle-run-nav-sidebar-claude-565

I don't know if you guys wan't to differentiate them even more, by adding another property or kind just for lifecycles.

@ckafrouni ckafrouni marked this pull request as draft March 11, 2026 20:01
@ckafrouni ckafrouni marked this pull request as ready for review March 11, 2026 20:12
@arnestrickmann
Copy link
Contributor

Thanks, @ckafrouni!

Just resolved a merge conflict and formatted the code.

@arnestrickmann arnestrickmann merged commit 5c51961 into generalaction:main Mar 12, 2026
2 of 3 checks passed
@ckafrouni
Copy link
Contributor Author

Heyy bud, thank you. Sorry you had to take care of the merge conflict for me. But glad it worked out !

@ckafrouni ckafrouni deleted the emdash/run-terminal-colors-6qk branch March 12, 2026 20:35
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.

Bug: Lifecycle scripts use child_process.spawn instead of PTY, breaking interactive tools

2 participants