Skip to content

feat: working fwatcher integration in Runfile#43

Merged
nxtcoder17 merged 1 commit intomasterfrom
feat/watcher-re-integration
Apr 24, 2026
Merged

feat: working fwatcher integration in Runfile#43
nxtcoder17 merged 1 commit intomasterfrom
feat/watcher-re-integration

Conversation

@nxtcoder17
Copy link
Copy Markdown
Owner

@nxtcoder17 nxtcoder17 commented Apr 24, 2026

Summary by Sourcery

Improve task watching, command execution robustness, and cycle detection in Runfile tasks.

New Features:

  • Add file-watcher-driven task re-execution with debouncing to rerun pipelines on file changes.
  • Provide a test-watch example task demonstrating watch-based execution in the example Runfile.

Bug Fixes:

  • Fix prefixed writer handling of partial lines at EOF so rendered output is written to the underlying writer.
  • Prevent hanging interactive shell commands by respecting context cancellation and force-killing the process group when cancelled.
  • Ensure non-interactive shell commands terminate promptly on context cancellation by sending SIGKILL to the process group.
  • Correct command output box sizing and indentation when using unicode prefixes by basing layout on display width rather than byte length.
  • Detect and fail fast on circular or self-referential run-task dependencies, returning a descriptive error with the cycle path.

Enhancements:

  • Refine watch-mode pipeline management to safely restart the current pipeline on file change events with debounced triggers and proper synchronization.
  • Improve command rendering helpers to use display width calculations and a reusable prefix width helper for consistent layout.
  • Add explicit cycle detection helper for run-task resolution to surface clearer errors and avoid recursive loops.

Tests:

  • Extend task resolution tests to cover circular and self-referential task dependencies and assert that the reported cycle path is included in error messages.

Chores:

  • Tidy imports and minor configuration formatting in the CLI entrypoint and Nix configuration.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 24, 2026

Reviewer's Guide

Implements a working file-watcher-driven re-run pipeline for Runfile tasks, improves command output layout with Unicode-aware width calculations, adds circular task dependency detection (with tests), tightens shell command cancellation semantics, and fixes a couple of smaller configuration/IO issues and example Runfile entries.

Sequence diagram for watcher-driven task rerun pipeline

sequenceDiagram
    actor User
    participant CLI
    participant Resolver
    participant Watcher
    participant CurrentPipeline as Pipeline

    User->>CLI: run <task> with watch enabled
    CLI->>Resolver: RunTask(ctx, name)
    Resolver->>Resolver: createSteps(task)
    Resolver->>CurrentPipeline: NewPipeline(logger, steps)

    alt watch not enabled
        Resolver->>CurrentPipeline: Start(ctx)
        CurrentPipeline-->>Resolver: completion or error
        Resolver-->>CLI: return
    else watch enabled
        Resolver->>Watcher: NewWatcher(ctx, WatcherArgs)
        Resolver->>Watcher: Watch(ctx)
        activate Watcher
        Resolver->>Resolver: run()
        note over Resolver,CurrentPipeline: run() stops existing pipeline (if any), creates new pipeline and starts it in a goroutine
        Resolver->>CurrentPipeline: Start(ctx) (async)

        loop file change events
            Watcher-->>Resolver: event from GetEvents()
            Resolver->>Resolver: reset debounce timer (300ms)
            alt debounce expires
                Resolver->>Resolver: increment reload counter
                Resolver->>Resolver: run() (stop old pipeline, start new)
            end
        end

        alt context cancelled
            CLI-->>Resolver: ctx.Done()
            Resolver->>CurrentPipeline: Stop()
            Resolver->>Watcher: Close()
            deactivate Watcher
            Resolver-->>CLI: return nil
        end
    end
Loading

Class diagram for task resolution, watching, and pipeline execution

classDiagram
    class Resolver {
        +RunTask(ctx context.Context, name string) error
        +createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error)
        +appendCycle(taskTrail []string, next string) []string
    }

    class ResolvedTask {
        +Name string
        +Watch *WatchConfig
    }

    class WatchConfig {
        +Enabled bool
        +Dirs []string
        +IgnoreDirs []string
        +Debounce int
    }

    class Watcher {
        +Watch(ctx context.Context)
        +GetEvents() <-chan Event
        +Close() error
    }

    class Event {
        +Name string
        +Op string
    }

    class Pipeline {
        +Start(ctx context.Context) error
        +Stop()
    }

    class Executor {
        +NewPipeline(logger *slog.Logger, steps []Step) *Pipeline
        +NewShellCommand(handler func(ctx context.Context) *exec.Cmd) *command
        +NewInteractiveShellCommand(handler func(ctx context.Context) *exec.Cmd) *command
    }

    class command {
        +Run(ctx context.Context) error
    }

    class ShellCommand {
        +NewShellCommand(handler func(ctx context.Context) *exec.Cmd) *command
    }

    class InteractiveShellCommand {
        +NewInteractiveShellCommand(handler func(ctx context.Context) *exec.Cmd) *command
    }

    class Writer {
        +PrefixedWriter
        +Write(p []byte) (int, error)
        +GetStyledPrefix(prefix string) string
    }

    class LayoutHelpers {
        +printCommand(w *writer.LogWriter, prefix string, lang string, cmd string)
        +longestLineWidth(str string) int
        +padString(str string, withPrefix string) string
        +prefixDisplayWidth(prefix string) int
    }

    Resolver --> ResolvedTask
    ResolvedTask --> WatchConfig
    Resolver --> Watcher : uses
    Resolver --> Pipeline : creates
    Executor --> Pipeline : NewPipeline
    ShellCommand --> command : returns
    InteractiveShellCommand --> command : returns
    Executor --> ShellCommand
    Executor --> InteractiveShellCommand
    Writer <.. LayoutHelpers : uses
Loading

File-Level Changes

Change Details Files
Add a restartable, debounced watch-driven execution loop around the task pipeline so that watched tasks re-run on filesystem changes and stop cleanly on context cancellation.
  • Rename the initial pipeline variable to currentPipeline and wrap its lifecycle in a mutex-protected helper function run that stops any existing pipeline and starts a new one with the same steps.
  • Start the watcher with watch.Watch(ctx) in a goroutine and defer watch.Close() to ensure resources are cleaned up.
  • Replace the previous WaitGroup-based orchestration with a select loop that listens to context cancellation, watcher events, and a debounce timer.
  • Introduce a 300ms debounce timer that is reset on each filesystem event and only triggers a re-run when the timer fires without new events, logging a reload counter and calling run().
  • Ensure that on context cancellation the current pipeline is stopped under lock before returning, and exit cleanly when the watcher event channel is closed.
pkg/runfile/resolver/task.go
Make command rendering and prefix padding width-aware using lipgloss so Unicode prefixes do not misalign boxes.
  • Replace longestLineLen with longestLineWidth, which splits on newlines and uses lipgloss.Width to compute the maximum display width across lines.
  • Use a new helper prefixDisplayWidth to compute the rendered width of the prefix (e.g. "[X] ") and feed that into the longest line calculation instead of a hard-coded +2 offset.
  • Adjust padString to compute a reusable indent string using prefixDisplayWidth and apply it for continuation lines, rather than relying on len(prefix)+2 character spacing.
  • Add prefixDisplayWidth helper that returns 0 for an empty prefix and otherwise returns the lipgloss.Width of the formatted prefix-with-brackets string.
pkg/runfile/resolver/task.go
Detect circular task dependencies (including self-references) when expanding run targets, and surface the cycle path in the error, with tests covering these scenarios.
  • In createSteps, before resolving a run target command, call appendCycle with the current taskTrail and the next task name; if a non-nil cycle is returned, return an error "Circular Task Dependency" with a KV that includes the cycle path joined by " -> ".
  • Implement appendCycle, which scans the existing task trail for the next task name and, when found, builds and returns a slice representing the cycle (trail from the first occurrence through the end, plus the next task).
  • Add tests where tasks a and b reference each other to form a cycle and expect createSteps to error, asserting that the error string contains the cycle path "a -> b -> a".
  • Add a test where a task runs itself (a -> a) and assert that the error is non-nil and that the cycle string "a -> a" appears in the error message.
pkg/runfile/resolver/task.go
pkg/runfile/resolver/task_test.go
Improve cancellation and process cleanup for shell commands, especially for interactive PTY-based commands, to respect context cancellation reliably.
  • In NewInteractiveShellCommand, change IO copying so both stdin and stdout are handled via goroutines, then wait on cmd.Wait() in a dedicated goroutine whose result is fed into a done channel.
  • Wrap cmd.Wait() with a select that either returns when the command completes or, on ctx.Done, sends SIGKILL to the process (if present), waits for cmd.Wait() to finish, and then returns ctx.Err().
  • In NewShellCommand, simplify the cancellation path: on ctx.Done, immediately send SIGKILL to the negative pid (process group), wait for done to drain, and return ctx.Err(), removing the previous TERM-then-KILL with 2s grace period.
  • Remove the now-unused time import from shell-command.go.
pkg/executor/shell-command.go
Adjust example Runfile and minor wiring to showcase watch mode and fix small bugs in writer and main wiring.
  • Enable the example cook run step in the laundry task by uncommenting "- run: cook" so it actually chains into the cook task.
  • Add a new example task test-watch that enables file watching over the current directory and runs a simple echo command, demonstrating the new watch integration.
  • Fix PrefixedWriter.Write to write the rendered (prefixed) line directly to the underlying writer when encountering io.EOF instead of buffering it, ensuring partial lines are output correctly.
  • Normalize import ordering in cmd/run/main.go so term is grouped consistently with other imports.
  • Add a cosmetic blank line in nixy.yml between the package list and onShellEnter for readability.
examples/Runfile.yml
pkg/writer/writer.go
cmd/run/main.go
nixy.yml

Possibly linked issues

  • #unknown: RunTask watcher logic now restarts the full pipeline on file changes, addressing watch-only-last-command behavior

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The change in PrefixedWriter.Write where io.EOF now triggers a direct write to the underlying writer instead of buffering the partial line changes semantics for lines without a trailing newline; this likely breaks the intent of prefixing only complete lines and preserving partial lines across writes, so it would be good to re-evaluate and add a test that covers incremental writes without \n.
  • In RunTask, the debounce loop resets the timer on every file event but ignores ev.Name in the reload log; if the intent is to help users see what triggered the reload, consider including the file path in the [RELOADING] log message as before.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The change in `PrefixedWriter.Write` where `io.EOF` now triggers a direct write to the underlying writer instead of buffering the partial line changes semantics for lines without a trailing newline; this likely breaks the intent of prefixing only complete lines and preserving partial lines across writes, so it would be good to re-evaluate and add a test that covers incremental writes without `\n`.
- In `RunTask`, the debounce loop resets the timer on every file event but ignores `ev.Name` in the reload log; if the intent is to help users see what triggered the reload, consider including the file path in the `[RELOADING]` log message as before.

## Individual Comments

### Comment 1
<location path="pkg/runfile/resolver/task.go" line_range="165" />
<code_context>
+	dequeueDuration := 300 * time.Millisecond
</code_context>
<issue_to_address>
**issue (bug_risk):** Timer debouncing logic can misbehave due to Reset being called on an active timer without Stop/drain

`time.Timer`’s contract requires `Reset` to be called only on a stopped or fully-drained timer. Resetting it while it may still be active can cause races and extra or delayed firings, especially under bursty events.

Consider refactoring to a pattern that:
- Lazily creates the timer.
- Calls `Stop()` and drains `timer.C` (if needed) before each `Reset`.
- Uses a separate `timerC` channel variable in the `select` to avoid reading from an uninitialized or already-fired timer.

This will make the debounce behavior deterministic and avoid subtle timing bugs.
</issue_to_address>

### Comment 2
<location path="pkg/writer/writer.go" line_range="27-28" />
<code_context>
 		line, err := pw.buf.ReadBytes('\n')
 		if errors.Is(err, io.EOF) {
 			pw.buf.Reset()
-			pw.buf.Write(pw.render(line))
+			pw.w.Write(pw.render(line))
 			break
 		}
</code_context>
<issue_to_address>
**issue (bug_risk):** Change in handling of partial lines on Write likely alters prefixing behavior and can cause duplicated prefixes

Previously, on `io.EOF` the code kept the *rendered* (prefixed) partial line in `pw.buf`, so the same logical line was only rendered/prefixed once when completed. Now the partial line is written directly to `pw.w` and the buffer is cleared, so a line split across writes will be prefixed multiple times, e.g.

```text
[PREFIX] hell
[PREFIX] o world
```

If you want to keep a single prefix per logical line while supporting streaming, consider buffering raw bytes until a newline is seen and only rendering/writing once the line is complete, or explicitly accept and document the new behavior of re-prefixed partial lines.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread pkg/runfile/resolver/task.go
Comment thread pkg/writer/writer.go
Comment on lines 27 to -28
pw.buf.Reset()
pw.buf.Write(pw.render(line))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Change in handling of partial lines on Write likely alters prefixing behavior and can cause duplicated prefixes

Previously, on io.EOF the code kept the rendered (prefixed) partial line in pw.buf, so the same logical line was only rendered/prefixed once when completed. Now the partial line is written directly to pw.w and the buffer is cleared, so a line split across writes will be prefixed multiple times, e.g.

[PREFIX] hell
[PREFIX] o world

If you want to keep a single prefix per logical line while supporting streaming, consider buffering raw bytes until a newline is seen and only rendering/writing once the line is complete, or explicitly accept and document the new behavior of re-prefixed partial lines.

@nxtcoder17 nxtcoder17 merged commit a04571e into master Apr 24, 2026
1 check passed
@nxtcoder17 nxtcoder17 deleted the feat/watcher-re-integration branch April 25, 2026 05:58
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.

1 participant