letsdo 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9576e7f61d65dbf1379b3e4fcce4831f3162432b9039dc2153f7952201420091
4
- data.tar.gz: 679c98fd2628270bb62cf2f35c6a9d13653dd75a3ebe48aa83abc77c3aeccd97
3
+ metadata.gz: f15aee491b4d0954c628e52566908e6b7ea35428c347a79822f5781198ea4be9
4
+ data.tar.gz: 71b47328ea44b224cb250e4bb8d0e58c052ddd054cd950fa1b058c85933f6a91
5
5
  SHA512:
6
- metadata.gz: 8a6744c21cd6681ac8fde456977962f8a1c8a79cfd8e8157091b0d3ba42848d29f982dd622880e7b7c03f82ec065d014d56da1b61a2f9cd954f3693fc2218f21
7
- data.tar.gz: 2925952a1d86f40fc54d1e20e57c83cec89e0f07579c2a0336e2cc370b94c462516c9686c981aedc4b456120ec42fcd51859386685be342c8999ca5bcc3d53d7
6
+ metadata.gz: eab4462fe5a7bec0981c9b2a803a02063d1714cc049f51f5add4053f80c302b917491465dda6d90aa66a59fb668b33a5d2166ea95589d317e1958a3756564f1c
7
+ data.tar.gz: 5f1b243e5bce00874965683d68bebf22c2c9cec992e6f8ab1e825db3a0796781a47bf51be934b5c8c5409c7ad1cf85a83d8a872c807b8d4e5a6023c888f8ca73
data/README.md CHANGED
@@ -99,9 +99,16 @@ The gem is built from the repository:
99
99
  git clone git@github.com:sergio-fry/letsdo.git
100
100
  cd letsdo
101
101
  gem build letsdo.gemspec
102
- gem install letsdo-0.2.0.gem
102
+ gem install letsdo-0.3.0.gem
103
103
  ```
104
104
 
105
+ > **Ruby 4.0.x note.** Some Ruby 4.0.x builds ship default gems out of sync —
106
+ > rdoc 8.0.0 declares `rbs >= 4.0.0` while rbs 3.x is bundled — so the
107
+ > post-install RDoc hook can raise `Gem::ConflictError` even though the gem
108
+ > files are already installed. Install without documentation to skip the
109
+ > hook (`gem install letsdo-0.3.0.gem --no-document`), or install a matching
110
+ > rbs first (`gem install rbs -v '>= 4.0.0'`).
111
+
105
112
  or run it straight from the checkout without installing:
106
113
 
107
114
  ```sh
@@ -26,9 +26,14 @@ module Letsdo
26
26
  @stderr.puts('letsdo: stopped')
27
27
  0
28
28
  ensure
29
+ close_watcher
29
30
  restore_signal_handlers
30
31
  end
31
32
 
33
+ def close_watcher
34
+ @watcher&.close
35
+ end
36
+
32
37
  def debug(message)
33
38
  warn("[letsdo] loop: #{message}") if @debug
34
39
  end
@@ -52,7 +57,15 @@ module Letsdo
52
57
  @metrics = opts[:metrics]
53
58
  @pause_gate = opts[:pause_gate]
54
59
  @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
55
- @sleeper = opts[:sleeper] || ->(seconds) { sleep(seconds) }
60
+ @watcher = opts[:watcher] || (Watcher.new(path: opts[:watch_path]) if opts[:watch_path])
61
+ # Precedence is deliberate: an explicitly injected sleeper always wins
62
+ # over the watcher idle path — tests inject a stop/control sleeper that
63
+ # must never be bypassed by a configured watcher (TASK-84).
64
+ @sleeper = opts[:sleeper] || watcher_sleeper || ->(seconds) { sleep(seconds) }
65
+ end
66
+
67
+ def watcher_sleeper
68
+ @watcher && ->(seconds) { @watcher.wait(seconds) }
56
69
  end
57
70
 
58
71
  def run_until_stopped
@@ -33,6 +33,7 @@ module Letsdo
33
33
  @err_w.close
34
34
  readers = [quiet_reader(@out_r), quiet_reader(@err_r)]
35
35
  status = wait_and_reap(@pid)
36
+ @reaped = true
36
37
  [readers[0].value, readers[1].value, status]
37
38
  ensure
38
39
  cleanup
@@ -41,7 +42,7 @@ module Letsdo
41
42
  private
42
43
 
43
44
  def spawn_child
44
- opts = { out: @out_w, err: @err_w }
45
+ opts = { out: @out_w, err: @err_w, pgroup: true }
45
46
  opts[:chdir] = @chdir if @chdir
46
47
  Process.spawn(*@args, **opts)
47
48
  end
@@ -62,12 +63,26 @@ module Letsdo
62
63
  status
63
64
  end
64
65
 
65
- # Reaps pid if the normal wait never ran (the wait was interrupted by a
66
- # stop); a no-op when the child was already reaped.
67
- def reattach_reaper(pid)
66
+ # Kills the child's process group and reaps it when the normal wait was
67
+ # interrupted (a stop raised into the main thread). Killing the whole
68
+ # group also terminates any grandchildren that inherited the child's
69
+ # stdout/stderr pipes, so none of them is left holding the pipes open
70
+ # (TASK-84). A no-op when the child was already reaped normally.
71
+ def terminate_and_reap(pid)
68
72
  return unless pid
69
73
 
70
- Process.detach(pid)
74
+ kill_group(pid)
75
+ reap(pid)
76
+ end
77
+
78
+ def kill_group(pid)
79
+ Process.kill('TERM', -pid)
80
+ rescue Errno::ESRCH, Errno::EPERM
81
+ nil
82
+ end
83
+
84
+ def reap(pid)
85
+ Process.wait2(pid)
71
86
  rescue Errno::ECHILD
72
87
  nil
73
88
  end
@@ -77,7 +92,7 @@ module Letsdo
77
92
  close_quietly(@err_w)
78
93
  close_quietly(@out_r)
79
94
  close_quietly(@err_r)
80
- reattach_reaper(@pid)
95
+ terminate_and_reap(@pid) unless @reaped
81
96
  end
82
97
 
83
98
  def close_quietly(io)
@@ -76,7 +76,12 @@ module Letsdo
76
76
  AgentLoop.new(name: name, handle: handle, agent: agent,
77
77
  task_provider: -> { provider.call },
78
78
  wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
79
- metrics: opts[:metrics], pause_gate: opts[:pause_gate])
79
+ metrics: opts[:metrics], pause_gate: opts[:pause_gate],
80
+ watcher: backlog_watcher)
81
+ end
82
+
83
+ def backlog_watcher
84
+ Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
80
85
  end
81
86
  end
82
87
  end
@@ -37,10 +37,14 @@ module Letsdo
37
37
  end
38
38
 
39
39
  def finish
40
- return unless @last_char && @last_char != "\n"
41
-
42
- text_sink.write("\n")
43
- text_sink.flush unless @log
40
+ if @last_char && @last_char != "\n"
41
+ text_sink.write("\n")
42
+ text_sink.flush unless @log
43
+ end
44
+ # Reset so the next run's finish is a no-op unless new text arrived —
45
+ # otherwise a tool-only run after a text run writes a spurious blank
46
+ # line (TASK-77).
47
+ @last_char = nil
44
48
  end
45
49
 
46
50
  private
@@ -7,11 +7,9 @@ module Letsdo
7
7
  private
8
8
 
9
9
  def input_loop
10
- with_raw_input do
11
- repaint
12
- @last_repaint = @clock.call
13
- loop { break if @stop || !poll_once }
14
- end
10
+ repaint
11
+ @last_repaint = @clock.call
12
+ loop { break if @stop || !poll_once }
15
13
  rescue StandardError
16
14
  nil
17
15
  end
@@ -25,8 +25,7 @@ module Letsdo
25
25
  def run(&work)
26
26
  install_winch_handler
27
27
  @terminal.enter
28
- start_input_thread
29
- work.call
28
+ run_work(&work)
30
29
  rescue Letsdo::Stopped
31
30
  0
32
31
  ensure
@@ -35,6 +34,13 @@ module Letsdo
35
34
  restore_winch_handler
36
35
  end
37
36
 
37
+ def run_work(&work)
38
+ with_raw_input do
39
+ start_input_thread
40
+ work.call
41
+ end
42
+ end
43
+
38
44
  private
39
45
 
40
46
  def assign_identity(opts)
@@ -68,6 +74,10 @@ module Letsdo
68
74
  @input_thread = nil
69
75
  end
70
76
 
77
+ # Enters raw mode on the keyboard for the duration of the block. Held
78
+ # by the main thread (not the killable input thread) so the terminal
79
+ # state is restored on every quit path — a killed input thread would
80
+ # leave the shared tty in raw mode and break the calling shell (TASK-83).
71
81
  def with_raw_input(&block)
72
82
  if @input.stdin.tty? && @input.stdin.respond_to?(:raw)
73
83
  @input.stdin.raw(&block)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tty-cursor'
4
-
5
3
  module Letsdo
6
4
  module Tui
7
5
  # Thin wrapper over the terminal: alternate screen, cursor handling and
@@ -36,19 +34,28 @@ module Letsdo
36
34
 
37
35
  # Enters the alternate screen and hides the cursor.
38
36
  def enter
39
- write(ENTER_ALT_SCREEN + TTY::Cursor.hide)
37
+ write(ENTER_ALT_SCREEN + cursor.hide)
40
38
  end
41
39
 
42
40
  # Shows the cursor and leaves the alternate screen.
43
41
  def leave
44
- write(TTY::Cursor.show + LEAVE_ALT_SCREEN)
42
+ write(cursor.show + LEAVE_ALT_SCREEN)
45
43
  end
46
44
 
47
45
  # Repaints the whole frame: cursor to the top left, then the frame.
48
46
  #
47
+ # Row separators are written as CRLF (\r\n), never bare LF. The TUI
48
+ # input thread holds stdin in io-console raw mode for the whole
49
+ # session, and raw mode clears OPOST on the shared tty — without it a
50
+ # bare \n no longer implies a carriage return, so every row after the
51
+ # first would start at the previous row's end column and the frame
52
+ # would wrap/scroll into the "blank activity pane" scramble (TASK-82).
53
+ # Explicit CRLF renders correctly both in raw mode and on terminals
54
+ # whose ONLCR already expands \n (a doubled CR is harmless).
55
+ #
49
56
  # @param frame [String] the rendered screen (see Letsdo::Tui::Renderer)
50
57
  def render(frame)
51
- write(HOME + frame)
58
+ write(HOME + frame.gsub("\n", "\r\n"))
52
59
  end
53
60
 
54
61
  # The current terminal size.
@@ -61,6 +68,14 @@ module Letsdo
61
68
 
62
69
  private
63
70
 
71
+ # Loaded lazily (like tty-screen/tty-reader) so requiring letsdo on a
72
+ # clean Ruby loads no tty-* gems — only the interactive TTY path needs
73
+ # them (TASK-78).
74
+ def cursor
75
+ require 'tty-cursor' unless defined?(TTY::Cursor)
76
+ TTY::Cursor
77
+ end
78
+
64
79
  def write(text)
65
80
  @stream.write(text)
66
81
  @stream.flush
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letsdo
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle/import'
4
+
5
+ module Letsdo
6
+ # OS file-change watcher for the backlog folder.
7
+ #
8
+ # On Linux it subscribes to inotify (via Fiddle, no external gem) and wakes
9
+ # as soon as a backlog entry changes; everywhere else it degrades to the
10
+ # same periodic polling a plain sleeper would do. Either way the wait is
11
+ # interruptible — a signal trap raising Letsdo::Stopped breaks the
12
+ # underlying IO.select — and an internal self-pipe lets #wake interrupt a
13
+ # blocked #wait from another thread.
14
+ class Watcher
15
+ # inotify event masks we subscribe to (Linux <sys/inotify.h>).
16
+ IN_ACCESS = 0x00000001
17
+ IN_MODIFY = 0x00000002
18
+ IN_ATTRIB = 0x00000004
19
+ IN_CLOSE_WRITE = 0x00000008
20
+ IN_CLOSE_NOWRITE = 0x00000010
21
+ IN_OPEN = 0x00000020
22
+ IN_MOVED_FROM = 0x00000040
23
+ IN_MOVED_TO = 0x00000080
24
+ IN_CREATE = 0x00000100
25
+ IN_DELETE = 0x00000200
26
+ IN_DELETE_SELF = 0x00000400
27
+ IN_MOVE_SELF = 0x00000800
28
+
29
+ RELEVANT_MASK = IN_CREATE | IN_MODIFY | IN_CLOSE_WRITE | IN_DELETE |
30
+ IN_MOVED_FROM | IN_MOVED_TO | IN_DELETE_SELF | IN_MOVE_SELF
31
+
32
+ EXCLUDED_DIR = '.locks'
33
+
34
+ # @param path [String] the watched folder (its direct entries)
35
+ # @param poll_seconds [Float] fallback poll interval / select timeout
36
+ def initialize(path:, poll_seconds: 10.0)
37
+ @poll_seconds = poll_seconds
38
+ @wake_r, @wake_w = IO.pipe
39
+ @backend = build_backend(path)
40
+ end
41
+
42
+ # Blocks until a relevant backlog change, an explicit #wake, or the poll
43
+ # timeout. Irrelevant events (e.g. .locks churn) keep the wait going for
44
+ # the remainder of the timeout.
45
+ #
46
+ # @return [Symbol] :change, :wake, or :timeout
47
+ def wait(timeout = @poll_seconds)
48
+ deadline = monotonic + timeout
49
+ loop do
50
+ result = wait_once(deadline)
51
+ return result if result
52
+ end
53
+ end
54
+
55
+ # Interrupts a blocked #wait from another thread.
56
+ def wake
57
+ @wake_w.write_nonblock('.')
58
+ rescue IO::WaitWritable, Errno::EPIPE
59
+ nil
60
+ end
61
+
62
+ def close
63
+ @backend&.close
64
+ close_io(@wake_w)
65
+ close_io(@wake_r)
66
+ end
67
+
68
+ private
69
+
70
+ def wait_once(deadline)
71
+ remaining = deadline - monotonic
72
+ return :timeout if remaining <= 0
73
+
74
+ ready = IO.select(read_fds, nil, nil, remaining)
75
+ return :timeout if ready.nil?
76
+ return :wake if wake_ready?(ready)
77
+
78
+ @backend.relevant_event? ? :change : nil
79
+ end
80
+
81
+ def read_fds
82
+ fds = [@wake_r]
83
+ fds << @backend.io if @backend
84
+ fds
85
+ end
86
+
87
+ def wake_ready?(ready)
88
+ return false unless ready.first.include?(@wake_r)
89
+
90
+ drain_wake
91
+ true
92
+ end
93
+
94
+ def drain_wake
95
+ @wake_r.read_nonblock(4096)
96
+ rescue IO::WaitReadable, EOFError
97
+ nil
98
+ end
99
+
100
+ def build_backend(path)
101
+ InotifyBackend.new(path, RELEVANT_MASK)
102
+ rescue InotifyBackend::Unavailable, Fiddle::DLError
103
+ nil
104
+ end
105
+
106
+ def monotonic
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
109
+
110
+ def close_io(io)
111
+ io.close unless io.nil? || io.closed?
112
+ rescue IOError
113
+ nil
114
+ end
115
+
116
+ # Linux inotify backend. Construction raises InotifyBackend::Unavailable
117
+ # when inotify is missing or the watch cannot be set up, so the watcher
118
+ # falls back to polling through its self-pipe timeout.
119
+ class InotifyBackend
120
+ # libc inotify bindings via Fiddle (no external gem).
121
+ module Lib
122
+ extend Fiddle::Importer
123
+ dlload Fiddle.dlopen(nil)
124
+ extern 'int inotify_init1(int)'
125
+ extern 'int inotify_add_watch(int, const char*, uint32_t)'
126
+ end
127
+
128
+ # Signals that inotify is unavailable and the watcher should fall back.
129
+ class Unavailable < StandardError; end
130
+
131
+ IN_CLOEXEC = 0x00080000
132
+ EVENT_HEADER_SIZE = 16
133
+
134
+ attr_reader :io
135
+
136
+ def initialize(path, mask)
137
+ fd = Lib.inotify_init1(IN_CLOEXEC)
138
+ guard(fd)
139
+ @io = IO.new(fd, 'r', autoclose: true)
140
+ guard(Lib.inotify_add_watch(fd, path, mask))
141
+ rescue Unavailable, Fiddle::DLError
142
+ @io&.close
143
+ raise Unavailable
144
+ end
145
+
146
+ # Drains pending events and reports whether any relevant (non-.locks)
147
+ # entry changed.
148
+ def relevant_event?
149
+ buffer = @io.read_nonblock(8192)
150
+ parse_relevant(buffer)
151
+ rescue IO::WaitReadable, EOFError
152
+ false
153
+ end
154
+
155
+ def close
156
+ @io&.close
157
+ rescue IOError
158
+ nil
159
+ end
160
+
161
+ private
162
+
163
+ def guard(value)
164
+ raise Unavailable if value.negative?
165
+ end
166
+
167
+ def parse_relevant(buffer)
168
+ offset = 0
169
+ relevant = false
170
+ while offset + EVENT_HEADER_SIZE <= buffer.bytesize
171
+ _wd, mask, _cookie, len = buffer.byteslice(offset, EVENT_HEADER_SIZE).unpack('L4')
172
+ name = buffer.byteslice(offset + EVENT_HEADER_SIZE, len).to_s.sub(/\0.*/m, '')
173
+ relevant ||= relevant_mask?(mask) && !excluded?(name)
174
+ offset += EVENT_HEADER_SIZE + len
175
+ end
176
+ relevant
177
+ end
178
+
179
+ def relevant_mask?(mask)
180
+ (mask & Watcher::RELEVANT_MASK) != 0
181
+ end
182
+
183
+ def excluded?(name)
184
+ name == Watcher::EXCLUDED_DIR || name.start_with?("#{Watcher::EXCLUDED_DIR}/")
185
+ end
186
+ end
187
+ end
188
+ end
data/lib/letsdo.rb CHANGED
@@ -30,6 +30,7 @@ require_relative 'letsdo/agent'
30
30
  require_relative 'letsdo/capture'
31
31
  require_relative 'letsdo/backlog_tasks'
32
32
  require_relative 'letsdo/loop'
33
+ require_relative 'letsdo/watcher'
33
34
  require_relative 'letsdo/agent_loop'
34
35
  require_relative 'letsdo/control'
35
36
  require_relative 'letsdo/tui'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letsdo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei O. Udalov
@@ -122,6 +122,7 @@ files:
122
122
  - lib/letsdo/tui/session/view.rb
123
123
  - lib/letsdo/tui/terminal.rb
124
124
  - lib/letsdo/version.rb
125
+ - lib/letsdo/watcher.rb
125
126
  homepage: https://github.com/sergio-fry/letsdo
126
127
  licenses:
127
128
  - MIT