letsdo 0.4.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 +92 -2
- data/README.md +150 -18
- 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 +3 -1
- 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 +39 -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 +154 -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 +89 -8
- 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 +16 -5
- metadata +27 -6
- 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
|
@@ -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
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
class CLI
|
|
5
|
+
# Session recorder and metrics-file wiring for Letsdo::CLI::Builder.
|
|
6
|
+
# Kept in its own module so BuilderAssembly stays within the module-length
|
|
7
|
+
# limit.
|
|
8
|
+
module BuilderMetrics
|
|
9
|
+
# Returns the IO for the LETSDO_METRICS_FILE, or nil if unset or
|
|
10
|
+
# unwritable. Emits a warning to stderr on invalid path.
|
|
11
|
+
def metrics_io
|
|
12
|
+
path = @env['LETSDO_METRICS_FILE']
|
|
13
|
+
return nil if path.nil? || path.empty?
|
|
14
|
+
|
|
15
|
+
File.open(path, 'a', encoding: 'UTF-8')
|
|
16
|
+
rescue SystemCallError, IOError => e
|
|
17
|
+
@stderr.puts("letsdo: cannot open metrics file #{path}: #{e.message}")
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_recorder(name)
|
|
24
|
+
Letsdo::SessionRecorder.new(name: name, handle: assignee_handle(name),
|
|
25
|
+
metrics_io: metrics_io)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run_plain(name, recorder)
|
|
29
|
+
streamer = OutputStreamer.new(stdout: @stdout, stderr: @stderr)
|
|
30
|
+
agent = agent_for(name, streamer)
|
|
31
|
+
pause_gate = Control::PauseGate.new
|
|
32
|
+
reader = plain_control_reader(agent, pause_gate)
|
|
33
|
+
agent_loop(name, streamer, agent: agent, stderr: @stderr, metrics: recorder,
|
|
34
|
+
pause_gate: pause_gate).run
|
|
35
|
+
ensure
|
|
36
|
+
reader&.stop
|
|
37
|
+
finish_session(name, recorder)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Plain-mode control (TASK-75): with a terminal stdin the reader maps
|
|
41
|
+
# p/q to the same actions as the TUI keys — the shared PauseGate plus
|
|
42
|
+
# the current backend (nil between runs is a no-op). Reader#start is a
|
|
43
|
+
# no-op when stdin is not a terminal (pipes, CI), so stopping stays
|
|
44
|
+
# signal-only there. The reader writes nothing, so the plain stream
|
|
45
|
+
# stays byte-identical.
|
|
46
|
+
def plain_control_reader(agent, pause_gate)
|
|
47
|
+
Control::Reader.new(input: @stdin, pause_gate: pause_gate,
|
|
48
|
+
runner: -> { agent.backend }).tap(&:start)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One shared stop path for plain and TUI mode (TASK-69/TASK-70): close
|
|
52
|
+
# the recorder, optionally write per-task elapsed back into the task
|
|
53
|
+
# records, then print the summary. Write-back failures are counted and
|
|
54
|
+
# reported, never raised.
|
|
55
|
+
def finish_session(name, recorder)
|
|
56
|
+
recorder.session_stop
|
|
57
|
+
not_written = task_time_comments(name, recorder)
|
|
58
|
+
@stderr.puts(recorder.summary_line)
|
|
59
|
+
@stderr.puts(comment_failure_line(not_written)) if not_written.positive?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Opt-in (LETSDO_TASK_TIME_COMMENT=1): re-query the provider once at
|
|
63
|
+
# stop and comment the elapsed time on runs whose task is gone. Off by
|
|
64
|
+
# default — no subprocess, no task file mutation.
|
|
65
|
+
def task_time_comments(name, recorder)
|
|
66
|
+
return 0 unless @config.task_time_comment?
|
|
67
|
+
|
|
68
|
+
writeback = TaskTimeWriteback.new(command: backlog_command, cwd: @root,
|
|
69
|
+
env: ENV.to_h.merge(@env), stderr: @stderr)
|
|
70
|
+
writeback.call(recorder.summary.runs, final_open_tasks(name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def final_open_tasks(name)
|
|
74
|
+
provider_for(assignee_handle(name)).call
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def comment_failure_line(count)
|
|
78
|
+
"letsdo: #{count} #{count == 1 ? 'comment' : 'comments'} not written"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
class CLI
|
|
5
|
+
# `letsdo doctor` dispatch (TASK-71): builds the environment self-check
|
|
6
|
+
# from the same Letsdo::Config the rest of the CLI uses and returns its
|
|
7
|
+
# exit code. `doctor` is a reserved name -- it never launches an agent.
|
|
8
|
+
module CLIDoctor
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def doctor_command
|
|
12
|
+
Doctor.new(config: Config.new(env: @env), stdout: @stdout, stdin: @stdin).run
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/letsdo/cli.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'cli/init'
|
|
4
|
+
require_relative 'cli/doctor'
|
|
4
5
|
require_relative 'cli/builder'
|
|
5
6
|
|
|
6
7
|
module Letsdo
|
|
@@ -10,6 +11,8 @@ module Letsdo
|
|
|
10
11
|
# letsdo — usage and agent list, exit code 1;
|
|
11
12
|
# letsdo --version — version, exit code 0;
|
|
12
13
|
# letsdo --help — usage, exit code 0;
|
|
14
|
+
# letsdo doctor — environment self-check, exit 0 unless a
|
|
15
|
+
# check FAILs (then 1); `doctor` is reserved;
|
|
13
16
|
# letsdo <name> — run the <name> agent in the orchestrator
|
|
14
17
|
# loop until SIGINT/SIGTERM;
|
|
15
18
|
# letsdo <name> (no prompt) — same, but on the built-in default prompt;
|
|
@@ -21,6 +24,7 @@ module Letsdo
|
|
|
21
24
|
# letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
|
|
22
25
|
class CLI
|
|
23
26
|
include CLIInit
|
|
27
|
+
include CLIDoctor
|
|
24
28
|
|
|
25
29
|
USAGE = 'Usage: letsdo <agent_name>'
|
|
26
30
|
AGENTS_HEADER = 'Available agents:'
|
|
@@ -34,6 +38,8 @@ module Letsdo
|
|
|
34
38
|
def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
|
|
35
39
|
@stdout = stdout
|
|
36
40
|
@stderr = stderr
|
|
41
|
+
@env = env
|
|
42
|
+
@stdin = stdin
|
|
37
43
|
@root = Config.new(env: env).root
|
|
38
44
|
@builder = Builder.new(env: env, stdout: stdout, stderr: stderr,
|
|
39
45
|
stdin: stdin, sleeper: sleeper)
|
|
@@ -44,14 +50,22 @@ module Letsdo
|
|
|
44
50
|
return print_version if version_flag?(arg)
|
|
45
51
|
return print_help if help_flag?(arg)
|
|
46
52
|
return usage_error if arg.nil?
|
|
53
|
+
return doctor_command if arg == 'doctor'
|
|
47
54
|
return init_command(argv) if argv.include?('--init')
|
|
48
55
|
return unknown_option(arg) if arg.start_with?('-')
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
run_agent(arg)
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
private
|
|
54
61
|
|
|
62
|
+
def run_agent(arg)
|
|
63
|
+
@builder.run(arg)
|
|
64
|
+
rescue Letsdo::BackendUnavailableError => e
|
|
65
|
+
@stderr.puts("letsdo: #{e.message}")
|
|
66
|
+
2
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
def version_flag?(arg)
|
|
56
70
|
['--version', '-v'].include?(arg)
|
|
57
71
|
end
|
data/lib/letsdo/config.rb
CHANGED
|
@@ -4,8 +4,8 @@ require 'shellwords'
|
|
|
4
4
|
|
|
5
5
|
module Letsdo
|
|
6
6
|
# The single place where every LETSDO_*/AGENT_* environment variable is
|
|
7
|
-
# read, with exactly the defaults and precedence the CLI,
|
|
8
|
-
# AgentLoop used to apply inline. Giving env policy one home makes it
|
|
7
|
+
# read, with exactly the defaults and precedence the CLI, the Pi backend
|
|
8
|
+
# and AgentLoop used to apply inline. Giving env policy one home makes it
|
|
9
9
|
# testable in one place and gives future knobs (provider/backend
|
|
10
10
|
# selection) a single spot to add a variable.
|
|
11
11
|
#
|
|
@@ -14,6 +14,10 @@ module Letsdo
|
|
|
14
14
|
DEFAULT_WAIT_SECONDS = 10.0
|
|
15
15
|
DEFAULT_PI_COMMAND = 'pi'
|
|
16
16
|
DEFAULT_BACKLOG_COMMAND = 'backlog'
|
|
17
|
+
DEFAULT_PROVIDER = 'backlog'
|
|
18
|
+
DEFAULT_BACKEND = 'pi'
|
|
19
|
+
DEFAULT_MAX_RETRIES = 3
|
|
20
|
+
DEFAULT_RETRY_CAP = 300.0
|
|
17
21
|
|
|
18
22
|
def initialize(env: ENV)
|
|
19
23
|
@env = env
|
|
@@ -34,11 +38,22 @@ module Letsdo
|
|
|
34
38
|
Shellwords.split(value)
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
# The agent's backlog assignee
|
|
38
|
-
#
|
|
41
|
+
# The agent's backlog assignee name. Default: the bare agent name —
|
|
42
|
+
# the tracker stores bare names ('developer'); the '@' prefix is
|
|
43
|
+
# prompt-only notation (TASK-96). AGENT_ASSIGNEE_HANDLE overrides for
|
|
44
|
+
# legacy setups (used verbatim; a @-prefixed value still matches after
|
|
45
|
+
# normalization in the provider, but `letsdo doctor` warns about it);
|
|
46
|
+
# a blank override also falls back to the derived bare name.
|
|
39
47
|
def assignee_handle(name)
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
assignee_handle_override || name.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Raw AGENT_ASSIGNEE_HANDLE (nil when unset or blank) — `letsdo
|
|
52
|
+
# doctor` uses it to warn about a legacy @-prefixed override instead
|
|
53
|
+
# of it silently missing every bare-named task.
|
|
54
|
+
def assignee_handle_override
|
|
55
|
+
value = @env['AGENT_ASSIGNEE_HANDLE']
|
|
56
|
+
value && !value.strip.empty? ? value : nil
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
# Retry interval when no tasks are open. LETSDO_WAIT_SECONDS wins, then
|
|
@@ -53,7 +68,7 @@ module Letsdo
|
|
|
53
68
|
DEFAULT_WAIT_SECONDS
|
|
54
69
|
end
|
|
55
70
|
|
|
56
|
-
# The pi command used to run agents (same literal as
|
|
71
|
+
# The pi command used to run agents (same literal as Backends::Pi::COMMAND).
|
|
57
72
|
def pi_command
|
|
58
73
|
@env.fetch('LETSDO_PI_COMMAND', DEFAULT_PI_COMMAND)
|
|
59
74
|
end
|
|
@@ -63,9 +78,75 @@ module Letsdo
|
|
|
63
78
|
@env.fetch('LETSDO_BACKLOG_COMMAND', DEFAULT_BACKLOG_COMMAND)
|
|
64
79
|
end
|
|
65
80
|
|
|
66
|
-
#
|
|
81
|
+
# Executable search path, used by `letsdo doctor` to resolve the
|
|
82
|
+
# configured command names. It lives here so doctor code never reads
|
|
83
|
+
# ENV directly (TASK-71).
|
|
84
|
+
def path
|
|
85
|
+
@env.fetch('PATH', '')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Environment for CLI child processes spawned outside the builder
|
|
89
|
+
# (the doctor's handle-less provider): the process environment
|
|
90
|
+
# overlaid with the injected env, so shebang PATH resolution and test
|
|
91
|
+
# scenario variables both keep working (TASK-96).
|
|
92
|
+
def child_env
|
|
93
|
+
ENV.to_h.merge(@env)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The task provider name. Reads LETSDO_PROVIDER with default 'backlog'.
|
|
97
|
+
# An empty value falls back to 'backlog'.
|
|
98
|
+
def provider
|
|
99
|
+
@env['LETSDO_PROVIDER'].to_s.strip.empty? ? DEFAULT_PROVIDER : @env['LETSDO_PROVIDER'].to_s.strip
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The AI backend name. Reads LETSDO_BACKEND with default 'pi'.
|
|
103
|
+
# An empty value falls back to 'pi'.
|
|
104
|
+
def backend
|
|
105
|
+
@env['LETSDO_BACKEND'].to_s.strip.empty? ? DEFAULT_BACKEND : @env['LETSDO_BACKEND'].to_s.strip
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether [letsdo] traces are enabled in the Pi backend and AgentLoop.
|
|
67
109
|
def debug?
|
|
68
110
|
@env['LETSDO_DEBUG'] == '1'
|
|
69
111
|
end
|
|
112
|
+
|
|
113
|
+
# Whether per-task elapsed is written back into the task record as a
|
|
114
|
+
# backlog comment at session stop (TASK-70). Opt-in: only the literal
|
|
115
|
+
# '1' enables it, so no task file is ever modified by default.
|
|
116
|
+
def task_time_comment?
|
|
117
|
+
@env['LETSDO_TASK_TIME_COMMENT'] == '1'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Give-up after N consecutive failed runs of the same task in a session
|
|
121
|
+
# (default 3). Any invalid value falls back to the default.
|
|
122
|
+
def max_retries
|
|
123
|
+
value = @env['LETSDO_MAX_RETRIES'].to_s.strip
|
|
124
|
+
return DEFAULT_MAX_RETRIES if value.empty?
|
|
125
|
+
|
|
126
|
+
Integer(value)
|
|
127
|
+
rescue ArgumentError, TypeError
|
|
128
|
+
DEFAULT_MAX_RETRIES
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Base backoff seconds for the first retry; doubles per failure.
|
|
132
|
+
# Default = LETSDO_WAIT_SECONDS (the loop poll interval). An invalid
|
|
133
|
+
# value falls back to that default.
|
|
134
|
+
def retry_base
|
|
135
|
+
value = @env['LETSDO_RETRY_BASE'].to_s.strip
|
|
136
|
+
value.empty? ? wait_seconds : Float(value)
|
|
137
|
+
rescue ArgumentError, TypeError
|
|
138
|
+
wait_seconds
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Maximum backoff seconds (default 300). An invalid value falls back
|
|
142
|
+
# to the default.
|
|
143
|
+
def retry_cap
|
|
144
|
+
value = @env['LETSDO_RETRY_CAP'].to_s.strip
|
|
145
|
+
return DEFAULT_RETRY_CAP if value.empty?
|
|
146
|
+
|
|
147
|
+
Float(value)
|
|
148
|
+
rescue ArgumentError, TypeError
|
|
149
|
+
DEFAULT_RETRY_CAP
|
|
150
|
+
end
|
|
70
151
|
end
|
|
71
152
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Control
|
|
5
|
+
# The plain-mode control reader (TASK-94): when letsdo runs in plain
|
|
6
|
+
# line-stream mode (stdout not a TTY) but stdin IS a terminal, a
|
|
7
|
+
# background thread reads Enter-terminated commands and maps them to
|
|
8
|
+
# the same actions as the TUI keys (TASK-67/74 control model):
|
|
9
|
+
#
|
|
10
|
+
# p - toggle pause: on -> Control::PauseGate#pause plus the running
|
|
11
|
+
# backend's SIGSTOP (a no-op when nothing runs), off -> resume;
|
|
12
|
+
# q - clean stop: Thread.main.raise(Letsdo::Stopped), the exact
|
|
13
|
+
# teardown path of SIGINT/SIGTERM and the TUI 'q'.
|
|
14
|
+
#
|
|
15
|
+
# The reader only starts for a terminal stdin: with a pipe or
|
|
16
|
+
# /dev/null it never reads a single byte, so a user's piped stdin is
|
|
17
|
+
# left untouched and stopping stays signal-only. It writes nothing
|
|
18
|
+
# (no echo, no escape codes, no partial output) and its thread exits
|
|
19
|
+
# quietly on EOF or a read error.
|
|
20
|
+
class Reader
|
|
21
|
+
# @param input [IO] the stdin stream to read lines from
|
|
22
|
+
# @param pause_gate [Letsdo::Control::PauseGate, nil] the between-runs
|
|
23
|
+
# gate toggled by 'p'
|
|
24
|
+
# @param runner [#call, nil] callable returning the current backend
|
|
25
|
+
# (answers pause/resume) or nil; mirrors the TUI wiring, so a
|
|
26
|
+
# nil between runs is simply a no-op
|
|
27
|
+
# @param on_stop [#call, nil] stop action; defaults to raising
|
|
28
|
+
# Letsdo::Stopped into the main thread (injected in tests)
|
|
29
|
+
# @param tty [Boolean, nil] overrides the input.tty? check (tests only)
|
|
30
|
+
def initialize(input:, pause_gate: nil, runner: nil, on_stop: nil, tty: nil)
|
|
31
|
+
@input = input
|
|
32
|
+
@pause_gate = pause_gate
|
|
33
|
+
@runner = runner
|
|
34
|
+
@on_stop = on_stop
|
|
35
|
+
@tty = tty
|
|
36
|
+
@paused = false
|
|
37
|
+
@thread = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether the reader may run: stdin must be a terminal that can be
|
|
41
|
+
# read line by line, otherwise a pipe, /dev/null or CI stdin would be
|
|
42
|
+
# read from (and stolen from the user's pipe).
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def available?
|
|
46
|
+
@tty.nil? ? terminal_input? : @tty
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Starts the background reader thread. A non-terminal stdin starts
|
|
50
|
+
# nothing at all.
|
|
51
|
+
#
|
|
52
|
+
# @return [Thread, nil] the reader thread, or nil when not a terminal
|
|
53
|
+
def start
|
|
54
|
+
return nil unless available?
|
|
55
|
+
return @thread if @thread
|
|
56
|
+
|
|
57
|
+
@thread = build_thread
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Waits for the reader thread so tests and shutdown can join it.
|
|
61
|
+
#
|
|
62
|
+
# @param timeout [Numeric, nil] seconds to wait, nil waits forever
|
|
63
|
+
# @return [Thread, nil]
|
|
64
|
+
def join(timeout = nil)
|
|
65
|
+
@thread&.join(timeout)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ends the reader: kills the background thread when it is still
|
|
69
|
+
# blocked on input, so no reader outlives the run that started it.
|
|
70
|
+
# Safe to call when it was never started (non-TTY stdin).
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def stop
|
|
74
|
+
thread = @thread
|
|
75
|
+
return unless thread
|
|
76
|
+
|
|
77
|
+
thread.kill
|
|
78
|
+
thread.join(1)
|
|
79
|
+
@thread = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def terminal_input?
|
|
85
|
+
@input.respond_to?(:tty?) && @input.tty? && @input.respond_to?(:gets)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_thread
|
|
89
|
+
Thread.new { read_loop }.tap { |thread| thread.report_on_exception = false }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Reads Enter-terminated commands until EOF or a read error; never
|
|
93
|
+
# writes and never lets an error escape the thread.
|
|
94
|
+
def read_loop
|
|
95
|
+
while (line = @input.gets)
|
|
96
|
+
handle_command(line)
|
|
97
|
+
end
|
|
98
|
+
rescue IOError, SystemCallError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_command(line)
|
|
103
|
+
case line.strip
|
|
104
|
+
when 'p' then toggle_pause
|
|
105
|
+
when 'q' then request_stop
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def toggle_pause
|
|
110
|
+
@paused = !@paused
|
|
111
|
+
action = @paused ? :pause : :resume
|
|
112
|
+
invoke(@pause_gate, action)
|
|
113
|
+
invoke(current_runner, action)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def current_runner
|
|
117
|
+
@runner.respond_to?(:call) ? @runner.call : @runner
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def invoke(target, action)
|
|
121
|
+
return unless target.respond_to?(action)
|
|
122
|
+
|
|
123
|
+
target.public_send(action)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def request_stop
|
|
127
|
+
(@on_stop || -> { Thread.main.raise(Letsdo::Stopped) }).call
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/letsdo/control.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'control/reader'
|
|
4
|
+
|
|
3
5
|
module Letsdo
|
|
4
|
-
# Agent-control primitives shared by the TUI
|
|
5
|
-
# (TASK-67 control model): pausing between runs
|
|
6
|
+
# Agent-control primitives shared by the TUI, the plain-mode reader and
|
|
7
|
+
# the orchestrator loop (TASK-67 control model): pausing between runs
|
|
8
|
+
# (PauseGate) and the stdin control reader (Reader).
|
|
6
9
|
module Control
|
|
7
10
|
# A thread-safe pause flag polled by Letsdo::AgentLoop between runs.
|
|
8
11
|
#
|
|
9
|
-
# Mid-run suspension is handled by Letsdo::
|
|
12
|
+
# Mid-run suspension is handled by Letsdo::Backends::Pi#pause (SIGSTOP to
|
|
10
13
|
# the pi group, kernel-level freeze). Between runs there is no pi to
|
|
11
14
|
# stop, so the pause lives in this gate: while #paused? is true the
|
|
12
15
|
# loop must not start a new run, and it waits until #resume.
|