Skip to content

fix: stream agent output in resume --headless via a shared event handler - #400

Open
seniorquico wants to merge 1 commit into
subsy:mainfrom
seniorquico:fix-headless-resume-logging
Open

fix: stream agent output in resume --headless via a shared event handler#400
seniorquico wants to merge 1 commit into
subsy:mainfrom
seniorquico:fix-headless-resume-logging

Conversation

@seniorquico

@seniorquico seniorquico commented Jul 26, 2026

Copy link
Copy Markdown

Fixed #399. Wide open to feedback if this approach is incorrect, and I'm happy to keep iterating on a solution!

resume --headless produced two plain console.log lines per iteration and
nothing in between. Iterations that take many minutes therefore emitted zero
output for the whole iteration, which is indistinguishable from a hang.

The cause is duplication, not configuration. run --headless and
resume --headless each had their own engine event subscription, and resume's was
a subset:

Event run --headless resume --headless (before)
agent:output streamed not handled
engine:warning logged not handled
iteration:retrying logged not handled
iteration:skipped logged not handled
task:selected logged not handled
task:activated / task:completed tracked in session state not handled
output format [timestamp] [level] [component] message ad-hoc console.log

The engine emits agent:output (src/engine/index.ts:1182) regardless; resume
just never subscribed to it.

Change

Extract run's headless event handler into src/commands/headless-events.ts
(createHeadlessEventHandler) and use it from both commands. Resume now gets the
full structured log stream, including agent output streamed as the iteration runs.

The handler owns the session state it maintains and exposes getState() /
setState() so shutdown paths can still mark a session interrupted.
Command-specific concerns stay in their commands: notifications, remote server,
and engineStartTime/lastError tracking remain in run as a second listener;
lock release remains in resume.

  • run --headless output is unchanged — the switch moved verbatim, and the shared
    listener registers first, so log-then-notify ordering is preserved.
  • resume --headless now also maintains activeTaskIds in session.json the way
    run does, which makes stale-task recovery consistent between the two.
  • Resume's iteration lines change format (--- Iteration 1: title ---
    [INFO] [progress] Iteration 1/20: Working on <id> - <title>). That's the point
    of the change, but it is a user-visible output change for anyone grepping resume
    stdout.

Notes

Resume inherits run's verbosity: for agents that emit structured JSONL, every
non-empty line is logged under the agent component. That is existing run
behavior, now consistent across both commands, but it is more output than resume
used to produce.

Summary by CodeRabbit

  • New Features

    • Added structured logging for headless run and resume sessions, including progress, task activity, agent output, warnings, and lifecycle events.
    • Improved session-state tracking and persistence during interruptions, pauses, resumes, retries, and completions.
    • Headless resume now provides output consistent with headless run.
  • Documentation

    • Documented the structured output format for resume --headless, including streamed agent output.

@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

@seniorquico is attempting to deploy a commit to the plgeek Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Headless run and resume execution now share structured engine-event logging and persisted session-state tracking through a new event handler. Resume wiring, interruption handling, tests, and CLI documentation are updated accordingly.

Changes

Headless event flow

Layer / File(s) Summary
Shared handler and event-state coverage
src/commands/headless-events.ts, tests/commands/headless-events.test.ts
Defines the shared handler contract, processes engine events, persists state changes, and tests logging, lifecycle events, agent output, iteration outcomes, and task tracking.
Run command integration
src/commands/run.tsx
Routes headless run events through the shared handler while retaining notification handling and interruption state transitions.
Resume command integration and output documentation
src/commands/resume.tsx, website/content/docs/cli/resume.mdx
Routes headless resume events through the shared handler, updates configuration and shutdown wiring, and documents structured resume output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: subsy

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main fix: streaming agent output in resume --headless through a shared handler.
Linked Issues check ✅ Passed The changes address #399 by sharing headless event handling, restoring streamed output, warnings, retries, skips, and task-state tracking.
Out of Scope Changes check ✅ Passed The new tests and docs support the headless-output fix and no clearly unrelated code changes are introduced.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


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.

Warning

⚠️ This pull request shows signs of AI-generated slop (defensive_cruft). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/resume.tsx (1)

498-506: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Resume now tracks activeTaskIds but never resets them on interrupt.

run --headless resets in_progress tasks to open before persisting interrupted (see src/commands/run.tsx gracefulShutdown). Here the handler populates activeTaskIds on task:activated, but handleSignal persists them as-is, so tasks stay in_progress in the tracker until the next resume's stale-session recovery clears them. Mirroring the run behavior removes that window and matches the shared-behavior goal of the PR.

🛠️ Align with the run path
   const handleSignal = async (): Promise<void> => {
     logger.info('system', 'Interrupted, stopping...');
+    const activeTasks = getActiveTasks(headlessEvents.getState());
+    if (activeTasks.length > 0) {
+      logger.info('system', `Resetting ${activeTasks.length} in_progress task(s) to open...`);
+      const resetCount = await engine.resetTasksToOpen(activeTasks);
+      if (resetCount > 0) {
+        headlessEvents.setState(clearActiveTasks(headlessEvents.getState()));
+      }
+    }
     // Save interrupted state
     headlessEvents.setState({ ...headlessEvents.getState(), status: 'interrupted' });
     await savePersistedSession(headlessEvents.getState());
🤖 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 `@src/commands/resume.tsx` around lines 498 - 506, Update resume's handleSignal
to reset every task referenced by activeTaskIds from in_progress to open before
persisting the interrupted session, mirroring run.tsx's gracefulShutdown
behavior. Ensure the reset occurs before savePersistedSession and preserves the
interrupted status and cleanup sequence.
🧹 Nitpick comments (3)
src/commands/run.tsx (1)

3259-3270: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shutdown paths are now byte-identical; extract one helper.

gracefulShutdown and handleSigterm repeat the same reset-active-tasks + persist-interrupted sequence verbatim. A single finalizeShutdown() used by both removes the drift risk.

♻️ Suggested extraction
+  const resetAndMarkInterrupted = async (): Promise<void> => {
+    const activeTasks = getActiveTasks(headlessEvents.getState());
+    if (activeTasks.length > 0) {
+      logger.info('system', `Resetting ${activeTasks.length} in_progress task(s) to open...`);
+      const resetCount = await engine.resetTasksToOpen(activeTasks);
+      if (resetCount > 0) {
+        headlessEvents.setState(clearActiveTasks(headlessEvents.getState()));
+      }
+    }
+    headlessEvents.setState({ ...headlessEvents.getState(), status: 'interrupted' });
+    await savePersistedSession(headlessEvents.getState());
+  };

Also applies to: 3302-3312

🤖 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 `@src/commands/run.tsx` around lines 3259 - 3270, Extract the duplicated
active-task reset and interrupted-session persistence sequence from
gracefulShutdown and handleSigterm into a shared finalizeShutdown() helper. Have
both shutdown paths call this helper, preserving the existing resetTasksToOpen,
clearActiveTasks, status update, and savePersistedSession behavior.
src/commands/headless-events.ts (1)

60-64: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Serialize the fire-and-forget persistence writes.

persist() starts an unawaited savePersistedSession(currentState) on every event. Bursty events (task:activatediteration:completedtask:completed) can have their writes interleave, so a later write may land before an earlier one and leave .ralph-tui/session.json holding a stale snapshot. Also, silently discarding every save failure means a persistently unwritable session file is invisible.

A simple promise chain keeps ordering and lets you surface failures once.

♻️ Serialize writes and log failures
+  let persistChain: Promise<void> = Promise.resolve();
+
   const persist = (): void => {
-    savePersistedSession(currentState).catch(() => {
-      // Silently continue on save errors
-    });
+    const snapshot = currentState;
+    persistChain = persistChain
+      .then(() => savePersistedSession(snapshot))
+      .catch((error: unknown) => {
+        logger.warn(
+          'session',
+          `Failed to persist session state: ${error instanceof Error ? error.message : String(error)}`
+        );
+      });
   };
🤖 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 `@src/commands/headless-events.ts` around lines 60 - 64, Update persist() in
the headless event handling flow to serialize savePersistedSession(currentState)
calls through a shared promise chain, ensuring each write starts only after the
previous one settles. Preserve fire-and-forget behavior for callers, but surface
persistence failures through the existing logging mechanism instead of silently
discarding them.
tests/commands/headless-events.test.ts (1)

104-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer satisfies EngineEvent over as EngineEvent in the event literals.

The as casts disable structural checking, so a rename in the engine event union (e.g. previousError, action, tasks) would leave these tests compiling and asserting stale behavior. satisfies EngineEvent keeps the literals validated against the union while preserving inference.

♻️ Example
-      } as EngineEvent);
+      } satisfies EngineEvent);

Also applies to: 170-196, 290-296

🤖 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 `@tests/commands/headless-events.test.ts` around lines 104 - 109, Replace the
`as EngineEvent` assertions on the event literals passed to
`handler.handleEvent` with `satisfies EngineEvent`, including the occurrences
around the referenced additional ranges. Keep each literal’s inferred types and
existing test behavior unchanged while enabling structural validation against
the `EngineEvent` union.
🤖 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.

Outside diff comments:
In `@src/commands/resume.tsx`:
- Around line 498-506: Update resume's handleSignal to reset every task
referenced by activeTaskIds from in_progress to open before persisting the
interrupted session, mirroring run.tsx's gracefulShutdown behavior. Ensure the
reset occurs before savePersistedSession and preserves the interrupted status
and cleanup sequence.

---

Nitpick comments:
In `@src/commands/headless-events.ts`:
- Around line 60-64: Update persist() in the headless event handling flow to
serialize savePersistedSession(currentState) calls through a shared promise
chain, ensuring each write starts only after the previous one settles. Preserve
fire-and-forget behavior for callers, but surface persistence failures through
the existing logging mechanism instead of silently discarding them.

In `@src/commands/run.tsx`:
- Around line 3259-3270: Extract the duplicated active-task reset and
interrupted-session persistence sequence from gracefulShutdown and handleSigterm
into a shared finalizeShutdown() helper. Have both shutdown paths call this
helper, preserving the existing resetTasksToOpen, clearActiveTasks, status
update, and savePersistedSession behavior.

In `@tests/commands/headless-events.test.ts`:
- Around line 104-109: Replace the `as EngineEvent` assertions on the event
literals passed to `handler.handleEvent` with `satisfies EngineEvent`, including
the occurrences around the referenced additional ranges. Keep each literal’s
inferred types and existing test behavior unchanged while enabling structural
validation against the `EngineEvent` union.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01f9b02d-63f7-40f5-a5f6-9f35ec788272

📥 Commits

Reviewing files that changed from the base of the PR and between 8191b80 and f3bd889.

📒 Files selected for processing (5)
  • src/commands/headless-events.ts
  • src/commands/resume.tsx
  • src/commands/run.tsx
  • tests/commands/headless-events.test.ts
  • website/content/docs/cli/resume.mdx

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.

resume --headless drops agent output

1 participant