letsdo 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +39 -2
- data/README.md +141 -12
- data/bin/letsdo +5 -0
- data/docs/config.md +171 -0
- data/docs/prompts.md +209 -0
- data/docs/task-selection.md +239 -0
- data/docs/usage.md +295 -0
- data/letsdo.gemspec +3 -1
- data/lib/letsdo/agent.rb +29 -17
- data/lib/letsdo/agent_identity.rb +42 -0
- data/lib/letsdo/agent_loop/tasks.rb +91 -13
- data/lib/letsdo/agent_loop.rb +36 -3
- 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 +47 -95
- data/lib/letsdo/cli/builder_assembly.rb +151 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +15 -1
- data/lib/letsdo/config.rb +66 -4
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +98 -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 +124 -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 +7 -2
- 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 +16 -5
- metadata +25 -5
- data/lib/letsdo/backlog_tasks.rb +0 -59
- 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
|
@@ -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
|
data/lib/letsdo/pi_runner.rb
DELETED
|
@@ -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? ? (opts[:config] || Config.new).debug? : 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
|