letsdo 0.3.0 → 0.6.1

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +232 -0
  3. data/README.md +156 -23
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +63 -0
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +46 -4
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +102 -0
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +17 -56
  23. data/lib/letsdo/config.rb +152 -0
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +17 -5
  47. metadata +39 -13
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/cli/launch.rb +0 -87
  50. data/lib/letsdo/pi_runner/events.rb +0 -75
  51. data/lib/letsdo/pi_runner/process.rb +0 -68
  52. data/lib/letsdo/pi_runner.rb +0 -101
data/lib/letsdo.rb CHANGED
@@ -6,32 +6,44 @@
6
6
  # Letsdo::Errors - error hierarchy (Letsdo::Error and others)
7
7
  # Letsdo::PromptStore - access to agents/*.md prompts
8
8
  # Letsdo::DefaultPrompt - the built-in default prompt (fallback run, --init template)
9
+ # Letsdo::AgentIdentity - the identity block injected into every agent prompt
9
10
  # Letsdo::OutputStreamer - where and how agent text and service lines are printed
10
- # Letsdo::PiRunner - running pi --mode json and parsing the event stream
11
+ # Letsdo::Backends::Pi - running pi --mode json and parsing the event stream
11
12
  # Letsdo::Agent - a single agent run: prompt from agents/ or default + pi
12
13
  # Letsdo::Capture - interruption-safe child stdout/stderr capture
13
14
  # Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
14
15
  # Letsdo::Loop - generic orchestrator: tasks → runs → waiting
16
+ # Letsdo::RetryPolicy - per-task retry/backoff/give-up policy (used at AgentLoop level)
15
17
  # Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
16
- # Letsdo::Control - agent-control primitives (PauseGate)
18
+ # Letsdo::Control - agent-control primitives (PauseGate, Reader)
19
+ # Letsdo::Doctor - `letsdo doctor` environment self-check
17
20
  # Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
18
- # Renderer, Terminal, Input, Session)
21
+ # Renderer, Terminal, WindowTitle, Input, Session)
19
22
  # Letsdo::CLI - command-line arguments, usage, exit code
20
23
  #
21
24
  # Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
22
25
 
23
26
  require_relative 'letsdo/version'
24
27
  require_relative 'letsdo/errors'
28
+ require_relative 'letsdo/duration'
29
+ require_relative 'letsdo/config'
25
30
  require_relative 'letsdo/prompt_store'
26
31
  require_relative 'letsdo/default_prompt'
32
+ require_relative 'letsdo/agent_identity'
27
33
  require_relative 'letsdo/output_streamer'
28
- require_relative 'letsdo/pi_runner'
34
+ require_relative 'letsdo/backends/backend'
35
+ require_relative 'letsdo/backends/pi'
29
36
  require_relative 'letsdo/agent'
30
37
  require_relative 'letsdo/capture'
31
- require_relative 'letsdo/backlog_tasks'
38
+ require_relative 'letsdo/providers/backlog'
32
39
  require_relative 'letsdo/loop'
40
+ require_relative 'letsdo/retry_policy'
33
41
  require_relative 'letsdo/watcher'
34
42
  require_relative 'letsdo/agent_loop'
43
+ require_relative 'letsdo/session_recorder'
44
+ require_relative 'letsdo/task_time_writeback'
45
+ require_relative 'letsdo/metrics/fanout'
35
46
  require_relative 'letsdo/control'
47
+ require_relative 'letsdo/doctor'
36
48
  require_relative 'letsdo/tui'
37
49
  require_relative 'letsdo/cli'
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.3.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergei O. Udalov
@@ -79,10 +79,11 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: 1.77.0
82
- description: 'letsdo — a local agent worker for Backlog.md/markdown tasks. OOP structure:
83
- Letsdo::PromptStore (agents/), Letsdo::OutputStreamer and Letsdo::PiRunner (pi --mode
84
- json), Letsdo::Agent (one run), Letsdo::Loop (orchestrator loop), Letsdo::CLI. Minitest
85
- tests. Will later be split into a separate repository.'
82
+ description: letsdo turns a plain markdown backlog into a team of autonomous local
83
+ agents. Each agent is just a prompt file in agents/; run `letsdo <name>` and it
84
+ works through every open task assigned to that agent, one task per run, and stops
85
+ cleanly on Ctrl+C. No framework, no hosted platform — the backlog folder is the
86
+ single source of truth and task data never leaves your machine.
86
87
  email:
87
88
  - udalov.x@mail.ru
88
89
  executables:
@@ -90,28 +91,50 @@ executables:
90
91
  extensions: []
91
92
  extra_rdoc_files: []
92
93
  files:
94
+ - CHANGELOG.md
93
95
  - LICENSE
94
96
  - README.md
95
97
  - bin/letsdo
98
+ - docs/config.md
99
+ - docs/prompts.md
100
+ - docs/task-selection.md
101
+ - docs/usage.md
102
+ - letsdo.gemspec
96
103
  - lib/letsdo.rb
97
104
  - lib/letsdo/agent.rb
105
+ - lib/letsdo/agent_identity.rb
98
106
  - lib/letsdo/agent_loop.rb
107
+ - lib/letsdo/agent_loop/assignee_hints.rb
99
108
  - lib/letsdo/agent_loop/tasks.rb
100
- - lib/letsdo/backlog_tasks.rb
109
+ - lib/letsdo/backends/backend.rb
110
+ - lib/letsdo/backends/pi.rb
111
+ - lib/letsdo/backends/pi/events.rb
101
112
  - lib/letsdo/capture.rb
102
113
  - lib/letsdo/cli.rb
114
+ - lib/letsdo/cli/builder.rb
115
+ - lib/letsdo/cli/builder_assembly.rb
116
+ - lib/letsdo/cli/builder_metrics.rb
117
+ - lib/letsdo/cli/doctor.rb
103
118
  - lib/letsdo/cli/init.rb
104
- - lib/letsdo/cli/launch.rb
119
+ - lib/letsdo/config.rb
105
120
  - lib/letsdo/control.rb
121
+ - lib/letsdo/control/reader.rb
106
122
  - lib/letsdo/default_prompt.rb
123
+ - lib/letsdo/doctor.rb
124
+ - lib/letsdo/doctor/checks.rb
125
+ - lib/letsdo/duration.rb
107
126
  - lib/letsdo/errors.rb
108
127
  - lib/letsdo/loop.rb
128
+ - lib/letsdo/metrics/fanout.rb
109
129
  - lib/letsdo/output_streamer.rb
110
130
  - lib/letsdo/output_streamer/arg_summary.rb
111
- - lib/letsdo/pi_runner.rb
112
- - lib/letsdo/pi_runner/events.rb
113
- - lib/letsdo/pi_runner/process.rb
114
131
  - lib/letsdo/prompt_store.rb
132
+ - lib/letsdo/providers/backlog.rb
133
+ - lib/letsdo/providers/task.rb
134
+ - lib/letsdo/retry_policy.rb
135
+ - lib/letsdo/session_recorder.rb
136
+ - lib/letsdo/session_recorder/jsonl_writer.rb
137
+ - lib/letsdo/task_time_writeback.rb
115
138
  - lib/letsdo/tui.rb
116
139
  - lib/letsdo/tui/input.rb
117
140
  - lib/letsdo/tui/log_buffer.rb
@@ -119,17 +142,20 @@ files:
119
142
  - lib/letsdo/tui/renderer.rb
120
143
  - lib/letsdo/tui/session.rb
121
144
  - lib/letsdo/tui/session/keys.rb
145
+ - lib/letsdo/tui/session/terminal.rb
122
146
  - lib/letsdo/tui/session/view.rb
123
147
  - lib/letsdo/tui/terminal.rb
148
+ - lib/letsdo/tui/window_title.rb
124
149
  - lib/letsdo/version.rb
125
150
  - lib/letsdo/watcher.rb
126
151
  homepage: https://github.com/sergio-fry/letsdo
127
152
  licenses:
128
153
  - MIT
129
154
  metadata:
130
- homepage_uri: https://github.com/sergio-fry/letsdo
131
155
  source_code_uri: https://github.com/sergio-fry/letsdo
156
+ bug_tracker_uri: https://github.com/sergio-fry/letsdo/issues
132
157
  changelog_uri: https://github.com/sergio-fry/letsdo/blob/main/CHANGELOG.md
158
+ documentation_uri: https://github.com/sergio-fry/letsdo/blob/main/README.md
133
159
  rubygems_mfa_required: 'true'
134
160
  allowed_push_host: https://rubygems.org
135
161
  rdoc_options: []
@@ -139,14 +165,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
165
  requirements:
140
166
  - - ">="
141
167
  - !ruby/object:Gem::Version
142
- version: '3.0'
168
+ version: '3.3'
143
169
  required_rubygems_version: !ruby/object:Gem::Requirement
144
170
  requirements:
145
171
  - - ">="
146
172
  - !ruby/object:Gem::Version
147
173
  version: '0'
148
174
  requirements: []
149
- rubygems_version: 4.0.6
175
+ rubygems_version: 4.0.21
150
176
  specification_version: 4
151
177
  summary: A local agent worker for Backlog.md/markdown tasks
152
178
  test_files: []
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'shellwords'
5
-
6
- module Letsdo
7
- # Task provider for Letsdo::Loop backed by the real backlog CLI:
8
- #
9
- # backlog task list --assignee <handle> --exclude-status Done --json
10
- #
11
- # Returns the list of open tasks assigned to the handle (an Array, empty
12
- # when there are none), or nil when the backlog state is unreadable — the
13
- # CLI is not on PATH, failed, or its output is not the expected JSON. The
14
- # loop treats nil as "pause and retry, do not run the agent".
15
- #
16
- # The command runs in the project root (cwd), where the backlog CLI finds
17
- # the backlog/ folder — the same context as a single agent run.
18
- class BacklogTasks
19
- # @param handle [String] assignee handle to filter by (e.g. "@developer")
20
- # @param command [String] backlog CLI command (overridable for tests)
21
- # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
22
- # @param env [Hash, nil] environment for the CLI child (nil = inherit
23
- # the process environment; injected in tests to control the
24
- # fake backlog scenarios)
25
- def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
26
- @handle = handle
27
- @command = command
28
- @cwd = cwd
29
- @env = env
30
- end
31
-
32
- # Reads the open tasks once.
33
- #
34
- # @return [Array<Hash>, nil] open tasks; nil when the backlog is unreadable
35
- def call
36
- args = @env ? [@env, *command_line] : command_line
37
- out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
38
- return nil unless status.success?
39
-
40
- tasks = JSON.parse(out)['tasks']
41
- tasks.is_a?(Array) ? tasks : nil
42
- rescue Errno::ENOENT, JSON::ParserError, TypeError
43
- nil
44
- end
45
-
46
- private
47
-
48
- # [command..., task, list, --assignee <handle>, --exclude-status Done, --json]
49
- def command_line
50
- [
51
- *Shellwords.split(@command),
52
- 'task', 'list',
53
- '--assignee', @handle,
54
- '--exclude-status', 'Done',
55
- '--json'
56
- ]
57
- end
58
- end
59
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Builds the agent, provider, and orchestrator loop for CLI run paths.
5
- module CLILaunch
6
- private
7
-
8
- def run_agent_plain(name)
9
- streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
10
- agent_loop(name, streamer, stderr: @stderr).run
11
- end
12
-
13
- def run_agent_tui(name)
14
- ctx = tui_context(name)
15
- session = Tui::Session.new(**ctx[:session])
16
- session.run { ctx[:loop].run }
17
- end
18
-
19
- def tui_context(name)
20
- parts = tui_parts(name)
21
- {
22
- loop: tui_loop(name, parts),
23
- session: tui_session_args(name, **parts)
24
- }
25
- end
26
-
27
- def tui_parts(name)
28
- clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
29
- handle = assignee_handle(name)
30
- log = Tui::LogBuffer.new
31
- {
32
- clock: clock, handle: handle, log: log,
33
- metrics: tui_metrics(name, handle, clock, log),
34
- agent: agent_for(name, OutputStreamer.new(log: log)),
35
- provider: provider_for(handle),
36
- pause_gate: Control::PauseGate.new
37
- }
38
- end
39
-
40
- def tui_loop(name, parts)
41
- agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
42
- handle: parts[:handle], stderr: parts[:log],
43
- metrics: parts[:metrics], pause_gate: parts[:pause_gate])
44
- end
45
-
46
- def tui_metrics(name, handle, clock, log)
47
- Tui::Metrics.new(name: name, handle: handle, clock: clock,
48
- on_run_start: ->(_label) { log.divider })
49
- end
50
-
51
- def tui_session_args(name, **parts)
52
- {
53
- name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:metrics],
54
- terminal: Tui::Terminal.new(stream: @stdout),
55
- input: Tui::Input.new(stdin: @stdin),
56
- refresh: -> { parts[:provider].call },
57
- wait_seconds: wait_seconds, clock: parts[:clock],
58
- pause_gate: parts[:pause_gate], runner: -> { parts[:agent].runner }
59
- }
60
- end
61
-
62
- def agent_for(name, streamer)
63
- Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
64
- command: pi_command)
65
- end
66
-
67
- def provider_for(handle)
68
- BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
69
- env: ENV.to_h.merge(@env))
70
- end
71
-
72
- def agent_loop(name, streamer, **opts)
73
- handle = opts[:handle] || assignee_handle(name)
74
- agent = opts[:agent] || agent_for(name, streamer)
75
- provider = opts[:provider] || provider_for(handle)
76
- AgentLoop.new(name: name, handle: handle, agent: agent,
77
- task_provider: -> { provider.call },
78
- wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
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)
85
- end
86
- end
87
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Pi JSON event-stream handlers used by PiRunner.
5
- module PiRunnerEvents
6
- private
7
-
8
- def read_pi_stream(out_r)
9
- out_r.each_line { |line| handle_line(line) }
10
- debug('stream: EOF')
11
- end
12
-
13
- def handle_line(line)
14
- line = line.strip
15
- return if line.empty?
16
-
17
- event = parse_event(line)
18
- dispatch_event(event) if event
19
- end
20
-
21
- def dispatch_event(event)
22
- case event['type']
23
- when 'message_update' then handle_message_update(event)
24
- when 'tool_execution_start' then handle_tool_start(event)
25
- when 'tool_execution_end' then handle_tool_execution_end(event)
26
- when 'agent_end' then flush_pending_tools
27
- end
28
- end
29
-
30
- def handle_tool_start(event)
31
- @pending_tools.delete(event['toolCallId'])
32
- @streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
33
- end
34
-
35
- def handle_message_update(event)
36
- payload = event['assistantMessageEvent']
37
- return unless payload
38
-
39
- handle_text_delta(payload) || remember_toolcall(event, payload)
40
- end
41
-
42
- def handle_text_delta(payload)
43
- return unless payload['type'] == 'text_delta'
44
-
45
- delta = payload['delta']
46
- @streamer.text_delta(delta) if delta && !delta.empty?
47
- true
48
- end
49
-
50
- def remember_toolcall(event, payload)
51
- return unless payload['type'] == 'toolcall_start'
52
-
53
- id = event['id'] || payload['id']
54
- name = event['toolName'] || payload['toolName'] || 'tool'
55
- @pending_tools[id] = name unless id.nil?
56
- end
57
-
58
- def handle_tool_execution_end(event)
59
- name = event['toolName'] || 'tool'
60
- error = event['isError'] == true
61
- @streamer.tool_result(name, error: error)
62
- end
63
-
64
- def flush_pending_tools
65
- @pending_tools.each_value { |name| @streamer.tool_start(name) }
66
- @pending_tools.clear
67
- end
68
-
69
- def parse_event(line)
70
- JSON.parse(line)
71
- rescue JSON::ParserError
72
- nil
73
- end
74
- end
75
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Letsdo
4
- # Process-group control for a running pi child.
5
- module PiRunnerProcess
6
- def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
7
- pid = @pid
8
- return true unless pid
9
-
10
- send_signal('CONT', pid)
11
- send_signal(signal, pid)
12
- wait_for_exit(pid, grace, tick)
13
- end
14
-
15
- private
16
-
17
- def wait_for_exit(pid, grace, tick)
18
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
19
- loop do
20
- status = wait_status(pid, nonblock: true)
21
- return status if status
22
- break unless alive?(pid)
23
- return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
24
-
25
- sleep(tick)
26
- end
27
- true
28
- end
29
-
30
- def overdue_deadline?(deadline)
31
- Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
- end
33
-
34
- def kill_and_reap(pid, tick)
35
- send_signal('KILL', pid)
36
- sleep(tick)
37
- wait_status(pid, nonblock: true) || true
38
- end
39
-
40
- def wait_status(pid, nonblock: false)
41
- _, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
42
- @reaped_status = status if status
43
- status
44
- rescue Errno::ECHILD
45
- @reaped_status
46
- end
47
-
48
- def send_signal(signal, pid)
49
- Process.kill(signal, -pid)
50
- rescue Errno::ESRCH, Errno::EPERM
51
- nil
52
- end
53
-
54
- def alive?(pid)
55
- Process.kill(0, pid)
56
- true
57
- rescue Errno::ESRCH, Errno::EPERM
58
- false
59
- end
60
-
61
- def exit_code(status)
62
- return 1 if status.nil?
63
- return status.exitstatus if status.exitstatus
64
-
65
- status.termsig ? 128 + status.termsig : 1
66
- end
67
- end
68
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
-
5
- module Letsdo
6
- # Runs pi in --mode json and hands events to the output streamer.
7
- class PiRunner
8
- COMMAND = 'pi'
9
- MODE = 'json'
10
- MESSAGE_UPDATE = 'message_update'
11
- TOOL_EXECUTION_START = 'tool_execution_start'
12
- TOOL_EXECUTION_END = 'tool_execution_end'
13
- AGENT_END = 'agent_end'
14
- end
15
- end
16
-
17
- require_relative 'pi_runner/events'
18
- require_relative 'pi_runner/process'
19
-
20
- module Letsdo
21
- # Pi process spawn, stream drain, and pause/resume signals.
22
- class PiRunner
23
- include PiRunnerEvents
24
- include PiRunnerProcess
25
-
26
- def initialize(prompt:, streamer:, **opts)
27
- @prompt = prompt
28
- @streamer = streamer
29
- @flags = opts.fetch(:flags, [])
30
- @command = opts.fetch(:command, COMMAND)
31
- @pending_tools = {}
32
- @reaped_status = nil
33
- @debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
34
- end
35
-
36
- def run
37
- out_r = spawn_pi
38
- drain_stream(out_r)
39
- finish_run
40
- ensure
41
- @pid = nil
42
- @reaped_status = nil
43
- end
44
-
45
- def debug(message)
46
- warn("[letsdo] pi: #{message}") if @debug
47
- end
48
-
49
- def terminate_now
50
- pid = @pid
51
- send_signal('TERM', pid) if pid
52
- end
53
-
54
- def pause
55
- pid = @pid
56
- send_signal('STOP', pid) if pid
57
- end
58
-
59
- def resume
60
- pid = @pid
61
- send_signal('CONT', pid) if pid
62
- end
63
-
64
- private
65
-
66
- def spawn_pi
67
- cmd = [@command, '--mode', MODE, *@flags, @prompt]
68
- out_r, out_w = IO.pipe
69
- @pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
70
- out_w.close
71
- debug("spawned pid=#{@pid} (own group)")
72
- out_r
73
- end
74
-
75
- def drain_stream(out_r)
76
- read_pi_stream(out_r)
77
- rescue Letsdo::Stopped
78
- debug('stopped by signal, terminating pi')
79
- terminate
80
- wait_status(@pid)
81
- @streamer.finish
82
- raise
83
- ensure
84
- close_pipe(out_r)
85
- flush_pending_tools
86
- end
87
-
88
- def close_pipe(out_r)
89
- out_r.close
90
- rescue IOError
91
- nil
92
- end
93
-
94
- def finish_run
95
- status = wait_status(@pid)
96
- @streamer.finish
97
- debug("exit status=#{status.inspect} code=#{exit_code(status)}")
98
- exit_code(status)
99
- end
100
- end
101
- end