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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +232 -0
- data/README.md +156 -23
- data/bin/letsdo +8 -1
- data/docs/config.md +176 -0
- data/docs/prompts.md +210 -0
- data/docs/task-selection.md +244 -0
- data/docs/usage.md +296 -0
- data/letsdo.gemspec +63 -0
- data/lib/letsdo/agent.rb +30 -17
- data/lib/letsdo/agent_identity.rb +46 -0
- data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
- data/lib/letsdo/agent_loop/tasks.rb +88 -13
- data/lib/letsdo/agent_loop.rb +46 -4
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +102 -0
- data/lib/letsdo/cli/builder_assembly.rb +154 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +17 -56
- data/lib/letsdo/config.rb +152 -0
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +144 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +180 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +10 -3
- data/lib/letsdo/tui/renderer.rb +9 -1
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +17 -5
- metadata +39 -13
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/cli/launch.rb +0 -87
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- data/lib/letsdo/pi_runner.rb +0 -101
data/lib/letsdo/agent_loop.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'agent_loop/tasks'
|
|
4
|
+
require_relative 'agent_loop/assignee_hints'
|
|
4
5
|
|
|
5
6
|
module Letsdo
|
|
6
7
|
# The orchestrator loop wired to the real environment for `letsdo <name>`.
|
|
7
8
|
class AgentLoop
|
|
8
9
|
include AgentLoopTasks
|
|
10
|
+
include AgentLoopAssigneeHints
|
|
9
11
|
|
|
10
12
|
STOP = :letsdo_stop
|
|
11
13
|
PAUSE_POLL_SECONDS = 0.05
|
|
@@ -18,8 +20,7 @@ module Letsdo
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def run
|
|
21
|
-
|
|
22
|
-
install_signal_handlers
|
|
23
|
+
prepare_run
|
|
23
24
|
debug("loop start (agent=#{@name}, handle=#{@handle}, wait=#{@wait_seconds}s)")
|
|
24
25
|
run_until_stopped
|
|
25
26
|
debug('loop stopped')
|
|
@@ -30,6 +31,15 @@ module Letsdo
|
|
|
30
31
|
restore_signal_handlers
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# The `letsdo doctor` hint rides on the first unavailable backlog call
|
|
35
|
+
# of a run only: later nils repeat the same diagnosis, and a wall of
|
|
36
|
+
# hints would just add noise (TASK-93).
|
|
37
|
+
def unavailable_message
|
|
38
|
+
hint = @unavailable_hinted ? '' : ' - run `letsdo doctor` to diagnose'
|
|
39
|
+
@unavailable_hinted = true
|
|
40
|
+
"letsdo: backlog unavailable, retrying in #{@wait_seconds}s#{hint}"
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
def close_watcher
|
|
34
44
|
@watcher&.close
|
|
35
45
|
end
|
|
@@ -38,8 +48,14 @@ module Letsdo
|
|
|
38
48
|
warn("[letsdo] loop: #{message}") if @debug
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
# Stop the running backend immediately. The signal handler is
|
|
52
|
+
# invoked from the main thread (Ruby 4.0) or from a dedicated signal
|
|
53
|
+
# thread (3.x fallback via AGENT_SIGNAL_THREAD=1). Thread.raise
|
|
54
|
+
# works reliably on CRuby 4.0: M:N fibers make threads interruptible
|
|
55
|
+
# everywhere, so a trap handler may safely raise Letsdo::Stopped on
|
|
56
|
+
# the main thread to unwind the current run.
|
|
41
57
|
def on_signal(_signum)
|
|
42
|
-
@agent&.
|
|
58
|
+
@agent&.backend&.terminate_now
|
|
43
59
|
raise Letsdo::Stopped
|
|
44
60
|
end
|
|
45
61
|
|
|
@@ -50,13 +66,33 @@ module Letsdo
|
|
|
50
66
|
@run_one = opts[:run_one] || ->(_task) { @agent.run }
|
|
51
67
|
@wait_seconds = opts.fetch(:wait_seconds, 10.0)
|
|
52
68
|
@stderr = opts.fetch(:stderr, $stderr)
|
|
69
|
+
@retry_policy = build_retry_policy(opts)
|
|
70
|
+
@last_attempted = {}
|
|
53
71
|
assign_control_opts(opts)
|
|
54
72
|
end
|
|
55
73
|
|
|
74
|
+
def prepare_run
|
|
75
|
+
@loop = build_loop
|
|
76
|
+
install_signal_handlers
|
|
77
|
+
@unavailable_hinted = false
|
|
78
|
+
@variants_hinted = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_retry_policy(opts)
|
|
82
|
+
return opts[:retry_policy] if opts[:retry_policy]
|
|
83
|
+
|
|
84
|
+
Letsdo::RetryPolicy.new(
|
|
85
|
+
base: opts.fetch(:retry_base, @wait_seconds),
|
|
86
|
+
cap: opts.fetch(:retry_cap, Letsdo::RetryPolicy::DEFAULT_CAP),
|
|
87
|
+
max_retries: opts.fetch(:max_retries, Letsdo::RetryPolicy::DEFAULT_MAX_RETRIES),
|
|
88
|
+
clock: opts[:clock]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
56
92
|
def assign_control_opts(opts)
|
|
57
93
|
@metrics = opts[:metrics]
|
|
58
94
|
@pause_gate = opts[:pause_gate]
|
|
59
|
-
@debug = opts
|
|
95
|
+
@debug = resolve_debug(opts)
|
|
60
96
|
@watcher = opts[:watcher] || (Watcher.new(path: opts[:watch_path]) if opts[:watch_path])
|
|
61
97
|
# Precedence is deliberate: an explicitly injected sleeper always wins
|
|
62
98
|
# over the watcher idle path — tests inject a stop/control sleeper that
|
|
@@ -64,6 +100,12 @@ module Letsdo
|
|
|
64
100
|
@sleeper = opts[:sleeper] || watcher_sleeper || ->(seconds) { sleep(seconds) }
|
|
65
101
|
end
|
|
66
102
|
|
|
103
|
+
def resolve_debug(opts)
|
|
104
|
+
return opts[:debug] unless opts[:debug].nil?
|
|
105
|
+
|
|
106
|
+
(opts[:config] || Config.new).debug?
|
|
107
|
+
end
|
|
108
|
+
|
|
67
109
|
def watcher_sleeper
|
|
68
110
|
@watcher && ->(seconds) { @watcher.wait(seconds) }
|
|
69
111
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../config'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
module Backends
|
|
7
|
+
# The documented backend protocol plus shared process-lifecycle helpers.
|
|
8
|
+
#
|
|
9
|
+
# Concrete adapters (Pi) add subprocess management and streaming.
|
|
10
|
+
# In-process fakes (test helpers) override terminate_now and skip
|
|
11
|
+
# spawning entirely.
|
|
12
|
+
#
|
|
13
|
+
# Protocol:
|
|
14
|
+
# run → Integer child exit code
|
|
15
|
+
# terminate_now trap-safe, no waits/IO
|
|
16
|
+
# terminate(signal:, grace:, tick:) → no-op when already finished
|
|
17
|
+
# pause / resume SIGSTOP/SIGCONT to the process group
|
|
18
|
+
# debug(message) '[letsdo] pi:' when debug is on
|
|
19
|
+
#
|
|
20
|
+
# Normalized events emitted at the injected streamer:
|
|
21
|
+
# text_delta(delta) tool_start(name, args: nil)
|
|
22
|
+
# tool_result(name, text) finish
|
|
23
|
+
# finish is called exactly once per run.
|
|
24
|
+
class Backend
|
|
25
|
+
# @param prompt [String] the stripped prompt text (no front-matter).
|
|
26
|
+
# @param streamer [Letsdo::OutputStreamer] normalized event sink.
|
|
27
|
+
# @param debug [Boolean, nil] trace override; nil → check env.
|
|
28
|
+
# @param config [Letsdo::Config, nil] debug source (LETSDO_DEBUG);
|
|
29
|
+
# nil → Config.new for env-only lookups.
|
|
30
|
+
def initialize(prompt:, streamer:, debug: nil, config: nil)
|
|
31
|
+
@prompt = prompt
|
|
32
|
+
@streamer = streamer
|
|
33
|
+
@debug = debug.nil? ? (config || Config.new).debug? : debug
|
|
34
|
+
@pid = nil
|
|
35
|
+
@reaped_status = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run the backend. Subclasses must override; the base returns nil.
|
|
39
|
+
def run
|
|
40
|
+
raise NotImplementedError,
|
|
41
|
+
"#{self.class.name} must implement #run"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ── signal helpers ────────────────────────────────────────────
|
|
45
|
+
# All are trap-safe (no waits, no IO) and swallow ESRCH/EPERM.
|
|
46
|
+
|
|
47
|
+
def terminate_now
|
|
48
|
+
pid = @pid
|
|
49
|
+
send_signal('TERM', pid) if pid
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pause
|
|
53
|
+
pid = @pid
|
|
54
|
+
send_signal('CONT', pid) if pid
|
|
55
|
+
send_signal('SIGSTOP', pid) if pid
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resume
|
|
59
|
+
pid = @pid
|
|
60
|
+
send_signal('SIGCONT', pid) if pid
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Graceful stop, then SIGKILL after *grace* seconds.
|
|
64
|
+
# Returns true when the process already finished.
|
|
65
|
+
def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
|
|
66
|
+
pid = @pid
|
|
67
|
+
return true unless pid
|
|
68
|
+
|
|
69
|
+
send_signal('CONT', pid)
|
|
70
|
+
send_signal(signal, pid)
|
|
71
|
+
wait_for_exit(pid, grace, tick)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# ── tracing ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def debug(message)
|
|
77
|
+
warn("[letsdo] pi: #{message}") if @debug
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# ── process-lifecycle helpers (shared with concrete adapters) ──
|
|
83
|
+
|
|
84
|
+
def wait_status(pid, nonblock: false)
|
|
85
|
+
_, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
|
|
86
|
+
@reaped_status = status if status
|
|
87
|
+
status
|
|
88
|
+
rescue Errno::ECHILD
|
|
89
|
+
@reaped_status
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wait_for_exit(pid, grace, tick)
|
|
93
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
|
|
94
|
+
loop do
|
|
95
|
+
status = wait_status(pid, nonblock: true)
|
|
96
|
+
return status if status
|
|
97
|
+
break unless alive?(pid)
|
|
98
|
+
return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
|
|
99
|
+
|
|
100
|
+
sleep(tick)
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def overdue_deadline?(deadline)
|
|
106
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def kill_and_reap(pid, tick)
|
|
110
|
+
send_signal('KILL', pid)
|
|
111
|
+
sleep(tick)
|
|
112
|
+
wait_status(pid, nonblock: true) || true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def send_signal(signal, pid)
|
|
116
|
+
Process.kill(signal, -pid)
|
|
117
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def alive?(pid)
|
|
122
|
+
Process.kill(0, pid)
|
|
123
|
+
true
|
|
124
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 0/N from a clean exit; 128+signal for a killed run; 1 when unknown.
|
|
129
|
+
def exit_code(status)
|
|
130
|
+
return 1 if status.nil?
|
|
131
|
+
return status.exitstatus if status.exitstatus
|
|
132
|
+
|
|
133
|
+
status.termsig ? 128 + status.termsig : 1
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Backends
|
|
5
|
+
# Pi JSON event-stream handlers used by Letsdo::Backends::Pi.
|
|
6
|
+
module PiEvents
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def read_pi_stream(out_r)
|
|
10
|
+
out_r.each_line { |line| handle_line(line) }
|
|
11
|
+
debug('stream: EOF')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handle_line(line)
|
|
15
|
+
line = line.strip
|
|
16
|
+
return if line.empty?
|
|
17
|
+
|
|
18
|
+
event = parse_event(line)
|
|
19
|
+
dispatch_event(event) if event
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def dispatch_event(event)
|
|
23
|
+
case event['type']
|
|
24
|
+
when 'message_update' then handle_message_update(event)
|
|
25
|
+
when 'tool_execution_start' then handle_tool_start(event)
|
|
26
|
+
when 'tool_execution_end' then handle_tool_execution_end(event)
|
|
27
|
+
when 'agent_end' then flush_pending_tools
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_tool_start(event)
|
|
32
|
+
@pending_tools.delete(event['toolCallId'])
|
|
33
|
+
@streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def handle_message_update(event)
|
|
37
|
+
payload = event['assistantMessageEvent']
|
|
38
|
+
return unless payload
|
|
39
|
+
|
|
40
|
+
handle_text_delta(payload) || remember_toolcall(event, payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle_text_delta(payload)
|
|
44
|
+
return unless payload['type'] == 'text_delta'
|
|
45
|
+
|
|
46
|
+
delta = payload['delta']
|
|
47
|
+
@streamer.text_delta(delta) if delta && !delta.empty?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def remember_toolcall(event, payload)
|
|
52
|
+
return unless payload['type'] == 'toolcall_start'
|
|
53
|
+
|
|
54
|
+
id = event['id'] || payload['id']
|
|
55
|
+
name = event['toolName'] || payload['toolName'] || 'tool'
|
|
56
|
+
@pending_tools[id] = name unless id.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_tool_execution_end(event)
|
|
60
|
+
name = event['toolName'] || 'tool'
|
|
61
|
+
error = event['isError'] == true
|
|
62
|
+
@streamer.tool_result(name, error: error)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def flush_pending_tools
|
|
66
|
+
@pending_tools.each_value { |name| @streamer.tool_start(name) }
|
|
67
|
+
@pending_tools.clear
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_event(line)
|
|
71
|
+
JSON.parse(line)
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'pi/events'
|
|
5
|
+
|
|
6
|
+
module Letsdo
|
|
7
|
+
module Backends
|
|
8
|
+
# Runs pi --mode json and hands normalized events to the streamer.
|
|
9
|
+
#
|
|
10
|
+
# Pi-specific knowledge lives here (spawn command, JSON event schema,
|
|
11
|
+
# exit-code mapping, process-group termination) -- the business layer
|
|
12
|
+
# sees only the backend protocol.
|
|
13
|
+
class Pi < Backend
|
|
14
|
+
COMMAND = 'pi'
|
|
15
|
+
MODE = 'json'
|
|
16
|
+
|
|
17
|
+
def initialize(prompt:, streamer:, **options)
|
|
18
|
+
flags = options.fetch(:flags, [])
|
|
19
|
+
command = options.fetch(:command, COMMAND)
|
|
20
|
+
model = options[:model]
|
|
21
|
+
super(prompt: prompt, streamer: streamer, debug: options[:debug],
|
|
22
|
+
config: options[:config])
|
|
23
|
+
@command = command
|
|
24
|
+
@flags = flags.dup
|
|
25
|
+
@flags.unshift('--model', model) if model && !@flags.include?('--model')
|
|
26
|
+
@pending_tools = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
out_r = spawn_pi
|
|
31
|
+
drain_stream(out_r)
|
|
32
|
+
finish_run
|
|
33
|
+
ensure
|
|
34
|
+
@pid = nil
|
|
35
|
+
@reaped_status = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# spawn / drain / finish
|
|
41
|
+
|
|
42
|
+
def spawn_pi
|
|
43
|
+
cmd = [@command, '--mode', MODE, *@flags, @prompt]
|
|
44
|
+
out_r, out_w = IO.pipe
|
|
45
|
+
spawn_process(cmd, out_r, out_w)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def spawn_process(cmd, out_r, out_w)
|
|
49
|
+
@pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
|
|
50
|
+
out_w.close
|
|
51
|
+
debug("spawned pid=#{@pid} (own group)")
|
|
52
|
+
out_r
|
|
53
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
54
|
+
out_w.close
|
|
55
|
+
out_r.close
|
|
56
|
+
raise Letsdo::BackendUnavailableError, "cannot start AI backend '#{@command}': #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def drain_stream(out_r)
|
|
60
|
+
read_pi_stream(out_r)
|
|
61
|
+
rescue Letsdo::Stopped
|
|
62
|
+
debug('stopped by signal, terminating pi')
|
|
63
|
+
terminate
|
|
64
|
+
wait_status(@pid)
|
|
65
|
+
@streamer.finish
|
|
66
|
+
raise
|
|
67
|
+
ensure
|
|
68
|
+
close_pipe(out_r)
|
|
69
|
+
flush_pending_tools
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def close_pipe(out_r)
|
|
73
|
+
out_r.close
|
|
74
|
+
rescue IOError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def finish_run
|
|
79
|
+
status = wait_status(@pid)
|
|
80
|
+
@streamer.finish
|
|
81
|
+
debug("exit status=#{status.inspect} code=#{exit_code(status)}")
|
|
82
|
+
exit_code(status)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
include PiEvents
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'builder_assembly'
|
|
4
|
+
require_relative 'builder_metrics'
|
|
5
|
+
|
|
6
|
+
module Letsdo
|
|
7
|
+
class CLI
|
|
8
|
+
# TUI component assembly used by Letsdo::CLI::Builder. Kept in its own
|
|
9
|
+
# module so Builder stays within the class-length limit; mirrors the
|
|
10
|
+
# CLILaunch/CLIInit split pattern.
|
|
11
|
+
module BuilderTui
|
|
12
|
+
def run_tui(name, recorder)
|
|
13
|
+
ctx = tui_context(name, recorder)
|
|
14
|
+
session = Tui::Session.new(**ctx[:session])
|
|
15
|
+
session.run { ctx[:loop].run }
|
|
16
|
+
ensure
|
|
17
|
+
finish_session(name, recorder)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tui_context(name, recorder)
|
|
21
|
+
parts = tui_parts(name, recorder)
|
|
22
|
+
{
|
|
23
|
+
loop: tui_loop(name, parts),
|
|
24
|
+
session: tui_session_args(name, **parts)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tui_parts(name, recorder)
|
|
29
|
+
clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
30
|
+
handle = assignee_handle(name)
|
|
31
|
+
log = Tui::LogBuffer.new
|
|
32
|
+
tui_parts_hash(name, recorder, clock, handle, log)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Assembles the parts hash. The header facade (Tui::Metrics — the only
|
|
36
|
+
# object answering #snapshot, which the renderer needs) and the Fanout
|
|
37
|
+
# that forwards loop events to both recorder and header share the same
|
|
38
|
+
# header instance (TASK-92).
|
|
39
|
+
def tui_parts_hash(name, recorder, clock, handle, log)
|
|
40
|
+
header = tui_metrics(name, handle, clock, log)
|
|
41
|
+
provider = provider_for(handle)
|
|
42
|
+
{
|
|
43
|
+
clock: clock, handle: handle, log: log, header: header,
|
|
44
|
+
metrics: Letsdo::Metrics::Fanout.new(recorder, header),
|
|
45
|
+
agent: agent_for(name, OutputStreamer.new(log: log)),
|
|
46
|
+
provider: provider,
|
|
47
|
+
pause_gate: Control::PauseGate.new,
|
|
48
|
+
refresh: tui_refresh(provider, recorder)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The manual 'r' refresh (TASK-42) queries the provider immediately.
|
|
53
|
+
# Its return value feeds the header; the same count is handed to the
|
|
54
|
+
# session recorder so the stop summary reports a fresh 'left' value
|
|
55
|
+
# (TASK-69).
|
|
56
|
+
def tui_refresh(provider, recorder)
|
|
57
|
+
lambda do
|
|
58
|
+
tasks = provider.call
|
|
59
|
+
count = tasks&.length
|
|
60
|
+
recorder.provider_result(count)
|
|
61
|
+
count
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tui_loop(name, parts)
|
|
66
|
+
agent_loop(name, nil, agent: parts[:agent], provider: parts[:provider],
|
|
67
|
+
handle: parts[:handle], stderr: parts[:log],
|
|
68
|
+
metrics: parts[:metrics], pause_gate: parts[:pause_gate])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tui_metrics(name, handle, clock, log)
|
|
72
|
+
Tui::Metrics.new(name: name, handle: handle, clock: clock,
|
|
73
|
+
on_run_start: ->(_label) { log.divider })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tui_session_args(name, **parts)
|
|
77
|
+
{
|
|
78
|
+
name: name, handle: parts[:handle], log: parts[:log], metrics: parts[:header],
|
|
79
|
+
terminal: Tui::Terminal.new(stream: @stdout),
|
|
80
|
+
input: Tui::Input.new(stdin: @stdin),
|
|
81
|
+
title: Tui::WindowTitle.new(stream: @stdout, env: @env),
|
|
82
|
+
refresh: parts[:refresh],
|
|
83
|
+
wait_seconds: wait_seconds, clock: parts[:clock],
|
|
84
|
+
pause_gate: parts[:pause_gate], runner: -> { parts[:agent].backend }
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Owns all component assembly for running an agent (TASK-54): builds the
|
|
90
|
+
# streamer, agent, task provider, orchestrator loop and the whole TUI,
|
|
91
|
+
# and decides plain vs TUI mode via stdout/stdin TTY + TERM. Parsing stays
|
|
92
|
+
# in Letsdo::CLI (TASK-54); this class is the wiring half — independently
|
|
93
|
+
# testable and free to grow with provider/backend selection (TASK-51/52)
|
|
94
|
+
# without bloating the parser. The wiring lives in BuilderAssembly so the
|
|
95
|
+
# class stays within the class-length limit.
|
|
96
|
+
class Builder
|
|
97
|
+
include BuilderTui
|
|
98
|
+
include BuilderAssembly
|
|
99
|
+
include BuilderMetrics
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
class CLI
|
|
5
|
+
# Provider and backend registries used by Letsdo::CLI::Builder: name ->
|
|
6
|
+
# factory maps, injectable via the constructor for tests. Kept separate so
|
|
7
|
+
# the assembly module stays within the module-length limit.
|
|
8
|
+
module BuilderRegistries
|
|
9
|
+
# Provider registry: maps LETSDO_PROVIDER names to factories that build
|
|
10
|
+
# a provider from handle, command, cwd, and env. Inject a custom
|
|
11
|
+
# registry via the constructor for tests.
|
|
12
|
+
PROVIDERS = {
|
|
13
|
+
'backlog' => lambda do |handle:, command:, cwd:, env:|
|
|
14
|
+
Providers::Backlog.new(handle: handle, command: command, cwd: cwd, env: env)
|
|
15
|
+
end
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Backend registry: maps LETSDO_BACKEND names to factory builders that
|
|
19
|
+
# take the Letsdo::Config and return a backend_factory (callable with
|
|
20
|
+
# prompt:, streamer:, model:). The pi entry captures the pi command and
|
|
21
|
+
# flags from Config (LETSDO_PI_COMMAND / LETSDO_PI_FLAGS with the
|
|
22
|
+
# AGENT_PI_FLAGS fallback), keeping pi vocabulary out of the business
|
|
23
|
+
# layer. Inject a custom registry via the constructor for tests.
|
|
24
|
+
BACKENDS = {
|
|
25
|
+
'pi' => lambda do |config:|
|
|
26
|
+
command = config.pi_command
|
|
27
|
+
flags = config.pi_flags
|
|
28
|
+
lambda { |prompt:, streamer:, model: nil|
|
|
29
|
+
Letsdo::Backends::Pi.new(prompt: prompt, streamer: streamer,
|
|
30
|
+
flags: flags, command: command,
|
|
31
|
+
model: model, config: config)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Registry-driven component wiring for Letsdo::CLI::Builder: the run
|
|
38
|
+
# entry point, agent/provider/loop assembly and the plain-mode run path.
|
|
39
|
+
# Extracted into its own module so Builder stays within the class-length
|
|
40
|
+
# limit (same reason as the BuilderTui split).
|
|
41
|
+
module BuilderAssembly
|
|
42
|
+
include BuilderRegistries
|
|
43
|
+
|
|
44
|
+
def initialize(env:, stdout:, stderr:, **rest)
|
|
45
|
+
@env = env
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@stderr = stderr
|
|
48
|
+
@stdin = rest[:stdin] || $stdin
|
|
49
|
+
@sleeper = rest[:sleeper]
|
|
50
|
+
@config = Config.new(env: env)
|
|
51
|
+
@root = @config.root
|
|
52
|
+
@provider_registry = rest[:provider_registry] || PROVIDERS
|
|
53
|
+
@backend_registry = rest[:backend_registry] || BACKENDS
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs <name> in plain or TUI mode; returns the process exit code.
|
|
57
|
+
def run(name)
|
|
58
|
+
return 1 unless resolve_provider!
|
|
59
|
+
return 1 unless resolve_backend!
|
|
60
|
+
|
|
61
|
+
store = PromptStore.new(root: @root)
|
|
62
|
+
announce_default_prompt(name, store) if store.read(name).nil?
|
|
63
|
+
recorder = build_recorder(name)
|
|
64
|
+
tui? ? run_tui(name, recorder) : run_plain(name, recorder)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def agent_for(name, streamer)
|
|
70
|
+
Agent.new(name: name, root: @root, handle: assignee_handle(name),
|
|
71
|
+
backend_factory: @selected_backend_factory,
|
|
72
|
+
streamer: streamer)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Resolves LETSDO_BACKEND through the backend registry once per run,
|
|
76
|
+
# so both wiring paths (plain and TUI) build the Agent's
|
|
77
|
+
# backend_factory through the same registry entry. An unknown backend
|
|
78
|
+
# name fails fast instead of silently falling back.
|
|
79
|
+
def resolve_backend!
|
|
80
|
+
builder = @backend_registry[@config.backend]
|
|
81
|
+
if builder
|
|
82
|
+
@selected_backend_factory = builder.call(config: @config)
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@stderr.puts("letsdo: unknown AI backend: #{@config.backend}")
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_provider!
|
|
91
|
+
@selected_provider_factory = @provider_registry[@config.provider]
|
|
92
|
+
return true if @selected_provider_factory
|
|
93
|
+
|
|
94
|
+
@stderr.puts("letsdo: unknown task provider: #{@config.provider}")
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def provider_for(handle)
|
|
99
|
+
@selected_provider_factory.call(
|
|
100
|
+
handle: handle,
|
|
101
|
+
command: backlog_command,
|
|
102
|
+
cwd: @root,
|
|
103
|
+
env: ENV.to_h.merge(@env)
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def agent_loop(name, streamer, **opts)
|
|
108
|
+
handle = opts[:handle] || assignee_handle(name)
|
|
109
|
+
agent = opts[:agent] || agent_for(name, streamer)
|
|
110
|
+
provider = opts[:provider] || provider_for(handle)
|
|
111
|
+
# The provider object itself (not a wrapping lambda) reaches the loop
|
|
112
|
+
# so the once-per-run assignee mismatch hint can read its
|
|
113
|
+
# #assignee_variants (TASK-96).
|
|
114
|
+
AgentLoop.new(name: name, handle: handle, agent: agent,
|
|
115
|
+
task_provider: provider,
|
|
116
|
+
wait_seconds: wait_seconds, sleeper: @sleeper, stderr: opts[:stderr],
|
|
117
|
+
metrics: opts[:metrics], pause_gate: opts[:pause_gate],
|
|
118
|
+
watcher: backlog_watcher, **retry_options)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retry_options
|
|
122
|
+
{ retry_base: @config.retry_base, retry_cap: @config.retry_cap, max_retries: @config.max_retries }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def backlog_watcher
|
|
126
|
+
Watcher.new(path: File.join(@root, 'backlog'), poll_seconds: wait_seconds)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# One-time fallback notification (before the first loop message): names
|
|
130
|
+
# the exact path checked and the placement hint. The agent still starts
|
|
131
|
+
# — Letsdo::Agent falls back to the built-in default prompt itself.
|
|
132
|
+
def announce_default_prompt(name, store)
|
|
133
|
+
@stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
|
|
134
|
+
@stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def tui?
|
|
138
|
+
@stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def assignee_handle(name)
|
|
142
|
+
@config.assignee_handle(name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def backlog_command
|
|
146
|
+
@config.backlog_command
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def wait_seconds
|
|
150
|
+
@config.wait_seconds
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|