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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -2
  3. data/README.md +150 -18
  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 +3 -1
  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 +39 -3
  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 +47 -95
  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 +15 -1
  23. data/lib/letsdo/config.rb +89 -8
  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 +16 -5
  47. metadata +27 -6
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/pi_runner/events.rb +0 -75
  50. data/lib/letsdo/pi_runner/process.rb +0 -68
  51. data/lib/letsdo/pi_runner.rb +0 -101
data/docs/usage.md ADDED
@@ -0,0 +1,296 @@
1
+ # letsdo — usage guide
2
+
3
+ This guide walks through running letsdo end to end: prerequisites, install,
4
+ the first run, what a session looks like (plain line-stream and TUI), the
5
+ orchestrator loop semantics, and how to run several agents at once.
6
+
7
+ - [Prerequisites](#prerequisites)
8
+ - [Install](#install)
9
+ - [Project layout](#project-layout)
10
+ - [First run](#first-run)
11
+ - [A session in plain mode](#a-session-in-plain-mode)
12
+ - [The interactive TUI](#the-interactive-tui)
13
+ - [How the loop behaves](#how-the-loop-behaves)
14
+ - [Running several agents](#running-several-agents)
15
+ - [Environment self-check (doctor)](#environment-self-check-doctor)
16
+ - [Exit codes](#exit-codes)
17
+ - [Next steps](#next-steps)
18
+
19
+ ## Prerequisites
20
+
21
+ - **Ruby >= 3.0**.
22
+ - The **pi agent CLI** on `PATH` — the AI backend that actually runs each
23
+ agent (`pi --mode json`). Override the command with `LETSDO_PI_COMMAND`
24
+ (see the [configuration reference](config.md)).
25
+ - The **Backlog.md CLI** (`backlog`) on `PATH` — the task provider reads the
26
+ runnable open tasks assigned to an agent via
27
+ `backlog task list --exclude-status Done --ready --sort priority` (the
28
+ assignee is matched on the returned tasks; the CLI line stays bare). Override
29
+ with `LETSDO_BACKLOG_COMMAND`.
30
+
31
+ Tests and the gem build use only Ruby's bundled default gems (Minitest,
32
+ Rake) — no `bundle install` needed.
33
+
34
+ ## Install
35
+
36
+ Build and install the gem from the repository:
37
+
38
+ ```sh
39
+ git clone git@github.com:sergio-fry/letsdo.git
40
+ cd letsdo
41
+ gem build letsdo.gemspec
42
+ gem install letsdo-0.3.0.gem
43
+ ```
44
+
45
+ > **Ruby 4.0.x note.** Some Ruby 4.0.x builds ship default gems out of sync —
46
+ > rdoc 8.0.0 declares `rbs >= 4.0.0` while rbs 3.x is bundled — so the
47
+ > post-install RDoc hook can raise `Gem::ConflictError` even though the gem
48
+ > files are already installed. Fix the environment by installing a matching
49
+ > rbs first (`gem install rbs -v '>= 4.0.0'`); if that is not possible,
50
+ > install the gem without documentation to skip the hook
51
+ > (`gem install letsdo-0.3.0.gem --no-document`).
52
+
53
+ Or run it straight from the checkout without installing:
54
+
55
+ ```sh
56
+ cd letsdo
57
+ ./bin/letsdo --version
58
+ ```
59
+
60
+ ## Project layout
61
+
62
+ letsdo works in a Backlog.md project root — a folder that holds your
63
+ `backlog/` tasks and your `agents/` prompts:
64
+
65
+ ```
66
+ your-backlog-project/
67
+ ├── backlog/ # the Backlog.md tasks (the single source of truth)
68
+ ├── agents/ # one prompt file per agent: agents/<name>.md
69
+ └── ... # anything else — docs, code, etc.
70
+ ```
71
+
72
+ The project root defaults to the current working directory and can be set
73
+ explicitly with `LETSDO_ROOT`.
74
+
75
+ ## First run
76
+
77
+ An agent is just a prompt file. Create one, then run the agent:
78
+
79
+ ```sh
80
+ cd your-backlog-project
81
+
82
+ # Option 1: scaffold a starter prompt, then customize it
83
+ letsdo developer --init # writes agents/developer.md, never runs the agent
84
+
85
+ # Option 2: write agents/developer.md by hand
86
+ # (see the prompt-authoring guide for what a good prompt contains)
87
+
88
+ # Run the agent: it works through all open tasks assigned to developer
89
+ letsdo developer
90
+ ```
91
+
92
+ No prompt file yet? The agent still runs on the built-in default prompt and
93
+ letsdo tells you once how to create your own:
94
+
95
+ ```
96
+ $ letsdo newcomer
97
+ letsdo: no prompt for newcomer at /home/user/backlog-project/agents/newcomer.md
98
+ letsdo: using the built-in default prompt (create a prompt file with 'letsdo newcomer --init')
99
+ ```
100
+
101
+ `--init` never overwrites an existing prompt and never runs the agent: it
102
+ fails with exit 1 when `agents/<name>.md` already exists or the name is
103
+ unsafe (contains `/` or `\`, or is `.`/`..` — nothing is ever written
104
+ outside `agents/`). Both argument orders work: `letsdo <name> --init` and
105
+ `letsdo --init <name>`.
106
+
107
+ ## A session in plain mode
108
+
109
+ When stdout is **not** a terminal (pipes, CI, `TERM=dumb`), letsdo prints a
110
+ plain line stream:
111
+
112
+ - **stdout** — only the agent's answer text, as it is generated.
113
+ - **stderr** — service and tool lines with a shared `HH:MM:SS` prefix:
114
+
115
+ ```
116
+ HH:MM:SS ⚙ tool_name: arguments tool call (bash → the command, read/write/edit → the path)
117
+ HH:MM:SS ✓ tool_name: done (3s) tool completion, success
118
+ HH:MM:SS ✖ tool_name: error (3s) tool completion, error
119
+ ```
120
+
121
+ Tool result bodies are never printed — the call line and its one-line
122
+ completion are the whole tool story, so the stream stays readable.
123
+
124
+ - Loop service messages on stderr, e.g.:
125
+
126
+ ```
127
+ letsdo: developer has 3 open task(s)
128
+ letsdo: running developer for TASK-42
129
+ letsdo: no open tasks for developer, retrying in 10s
130
+ letsdo: backlog unavailable, retrying in 10s - run `letsdo doctor` to diagnose
131
+ letsdo: stopped
132
+ ```
133
+
134
+ When the backlog stays unreadable, only the first message of a run
135
+ carries the `letsdo doctor` hint; later retries repeat the short line.
136
+
137
+ Stop the loop with `Ctrl+C` (`SIGINT`; `SIGTERM` and `SIGHUP` — terminal
138
+ closed — work too) — a running pi child is terminated and the process
139
+ exits with code 0. With `LETSDO_DEBUG=1` the loop additionally traces
140
+ `[letsdo] loop: ...` decisions to stderr.
141
+
142
+ ### Pause and quit in plain mode
143
+
144
+ When stdin is still a terminal (for example `letsdo developer > run.log`,
145
+ where stdout is redirected but the keyboard is live), plain mode reads the
146
+ same control keys as the TUI:
147
+
148
+ | Key | Action |
149
+ | --- | --- |
150
+ | `p` | pause/resume: mid-run the running pi child is suspended at the kernel level (SIGSTOP); a second `p` resumes it (SIGCONT). Between runs the next task is held until resume. A no-op when nothing is running |
151
+ | `q` | stop — identical to `Ctrl+C`: the pi child is terminated, the process exits with code 0 |
152
+
153
+ The control reader writes nothing, so the plain stream stays
154
+ byte-identical (no escape codes, no echoed input). When stdin is **not** a
155
+ terminal (a pipe, `</dev/null`, CI), the reader is never started and the
156
+ loop is stopped only by `SIGINT`/`SIGTERM`/`SIGHUP`.
157
+
158
+ ## The interactive TUI
159
+
160
+ When stdout **and** stdin are terminals and `TERM` is not `dumb`, `letsdo
161
+ <name>` starts a full-screen interface instead of the line stream:
162
+
163
+ ```
164
+ letsdo · developer (@developer) session 00:12:34
165
+ done 3 · left 2 · task TASK-42 · 00:03:21
166
+ ├──────────────────────────────────────────────────────────┤
167
+ …scrollable combined log: agent text, tool lines and loop
168
+ service messages in arrival order, newest at the bottom…
169
+ ↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit (p resume while paused)
170
+ ```
171
+
172
+ - **Header** — agent identity (`letsdo · <name> (@handle)`) and the session
173
+ timer (monotonic, ticks every second).
174
+ - **State line** — tasks done in this session, tasks left (from the latest
175
+ backlog query), and either the running task with its elapsed time, a
176
+ waiting reason (`no open tasks (retry in 10s)` / `backlog unavailable
177
+ (retry in 10s)`), or `PAUSED` while the agent is suspended.
178
+ - **Stream** — one combined log of everything the agent produces: answer
179
+ text deltas, tool lines and loop messages, exactly as OutputStreamer
180
+ emits them. The view follows the newest line automatically (tail -f).
181
+ While paused the frame freezes but the log keeps buffering, so nothing
182
+ is lost.
183
+ - **Footer** — the key map; the `p` hint says `p pause` while the agent
184
+ runs and `p resume` while it is suspended.
185
+
186
+ Keys:
187
+
188
+ | Key | Action |
189
+ | --- | --- |
190
+ | `↑` / `↓` | scroll one line (scrolling up leaves auto-follow) |
191
+ | `PgUp` / `PgDn` | scroll one page |
192
+ | `Home` / `End` | jump to top / back to the newest line (auto-follow) |
193
+ | `p` | pause/resume the agent: mid-run the running pi is suspended at the kernel level (SIGSTOP — model generation and tool executions freeze, the frame shows `PAUSED`, the log keeps buffering); between runs the next task is held until resume. A second `p` resumes (SIGCONT). The footer flips between `p pause` and `p resume` |
194
+ | `r` | immediate backlog re-query (updates the "left" counter) |
195
+ | `q` | quit — identical to a stop signal: the pi child is terminated, the terminal is restored, exit code 0 |
196
+ | `Ctrl+C` | same as `q` inside the TUI |
197
+
198
+ Resizes (`SIGWINCH`) repaint the frame without corruption. Every exit path —
199
+ quit, signal, agent loop end — restores the terminal (alternate screen
200
+ left, cursor back). Inside tmux the window is labeled with the agent name
201
+ for the whole session, so side-by-side agent panes stay distinguishable;
202
+ the previous label (and automatic-rename) returns on exit. The TUI only
203
+ renders when it is safe to do so; in any
204
+ other context the output is byte-identical to the plain line stream, which
205
+ is what keeps CI and pipes deterministic.
206
+
207
+ ## How the loop behaves
208
+
209
+ `letsdo <name>` runs an orchestrator loop:
210
+
211
+ 1. **Query** — fetch all open tasks assigned to `<name>` via the backlog
212
+ CLI (the assignee comes from `AGENT_ASSIGNEE_HANDLE`, default `<name>`
213
+ — the canonical bare name; `@<name>` is prose notation only).
214
+ 2. **Run** — take the next task and run the agent on it. **One run = one
215
+ task**; the agent must not pick up more than one task per run.
216
+ 3. **Repeat** — when a run finishes, query again.
217
+ 4. **Wait** — when no tasks are open (or the backlog is unreadable), wait
218
+ the retry interval (10 s by default, see `LETSDO_WAIT_SECONDS`) and
219
+ query again. An unreadable backlog pauses instead of crashing.
220
+ 5. **Stop** — `Ctrl+C` / `SIGTERM` / `SIGHUP` (or `q` in the TUI, and on a
221
+ terminal stdin in plain mode) stops the
222
+ loop immediately: the running pi child is terminated (even when it was
223
+ paused — SIGCONT comes before SIGTERM), the terminal is restored, exit
224
+ code 0.
225
+
226
+ The waiting is interruptible — a stop signal unwinds the loop right away
227
+ instead of waiting out the retry interval. A non-zero agent exit code is
228
+ reported but does not stop the loop.
229
+
230
+ Pause (`p` in the TUI or in plain mode on a terminal stdin) between runs
231
+ sets a gate the loop polls before starting the next run: while paused, no
232
+ new task is started even when the backlog has open ones; resume lets the
233
+ queued task run.
234
+
235
+ ## Running several agents
236
+
237
+ Agents run as separate processes, each with its own loop and its own
238
+ assignee:
239
+
240
+ ```sh
241
+ letsdo developer & # works on developer tasks
242
+ letsdo analyst & # works on analyst tasks
243
+ ```
244
+
245
+ They coordinate through the shared backlog — nothing else in common. Any
246
+ number of agents can run simultaneously; the backlog folder is the single
247
+ source of truth.
248
+
249
+ When the agents run in tmux panes, a TUI session labels its window with the
250
+ agent name (`letsdo developer` → window `developer`) and restores the
251
+ previous label on exit. tmux's automatic-rename is disabled for the session
252
+ and restored afterwards, so the label stays put instead of being overwritten
253
+ with the process name (`ruby`). Outside tmux nothing is written and the tmux
254
+ binary is never invoked.
255
+
256
+ ## Environment self-check (doctor)
257
+
258
+ `letsdo doctor` checks whether the environment can actually run the loop
259
+ and prints one line per check with a status tag and an actionable hint:
260
+
261
+ ```
262
+ $ letsdo doctor
263
+ [ OK ] ruby 4.0.2 (>= 3.3)
264
+ [ OK ] pi command found: pi
265
+ [ OK ] backlog command found: backlog
266
+ [ OK ] project root /home/user/backlog-project has backlog/tasks/
267
+ [ OK ] AGENTS.md present at /home/user/backlog-project/AGENTS.md
268
+ [ OK ] agents/ present with 1 prompt(s)
269
+ [INFO] stdout is not a TTY - plain mode
270
+ ```
271
+
272
+ Checks: Ruby version (`>= 3.3`), the `pi` command (`LETSDO_PI_COMMAND`),
273
+ the `backlog` command (`LETSDO_BACKLOG_COMMAND`), `backlog/tasks/` under
274
+ `LETSDO_ROOT`, `AGENTS.md`, a non-empty `agents/`, and whether stdout is a
275
+ TTY (TUI vs plain mode). A missing command or project layout is a `[FAIL]`;
276
+ a missing `AGENTS.md` or `agents/` is a `[WARN]`; the mode line is
277
+ `[INFO]`. Every FAIL and WARN names the fix.
278
+
279
+ It exits `0` when no check FAILs (warnings do not fail the run) and `1`
280
+ otherwise. `doctor` is a reserved agent name: it always runs the self-check
281
+ and never launches an agent.
282
+
283
+ ## Exit codes
284
+
285
+ | Code | Meaning |
286
+ | --- | --- |
287
+ | `0` | `--version` / `--help`; successful loop run and stop (incl. TUI `q`); `--init` created the prompt; `doctor` found no FAIL |
288
+ | `1` | no arguments; unknown option; `--init` on an existing/unsafe name; `doctor` found at least one FAIL |
289
+ | `2` | the AI backend command is missing (clean message, no backtrace) |
290
+
291
+ ## Next steps
292
+
293
+ - [Prompt-authoring guide](prompts.md) — what to put into `agents/<name>.md`.
294
+ - [Configuration reference](config.md) — every environment variable, its
295
+ default and where it is read.
296
+ - The README — why letsdo exists and what it does.
data/letsdo.gemspec CHANGED
@@ -40,8 +40,10 @@ Gem::Specification.new do |spec|
40
40
  # The AI backend is the external `pi` CLI (default, overridable via
41
41
  # LETSDO_PI_COMMAND) — a runtime *requirement*, not a rubygem, so it is
42
42
  # documented in the README rather than declared as a dependency.
43
+ # User-facing guides live in docs/ and are linked from the README, so
44
+ # they must ship inside the gem for installed copies to be self-contained.
43
45
  spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE",
44
- "CHANGELOG.md", "letsdo.gemspec"]
46
+ "CHANGELOG.md", "docs/**/*.md", "letsdo.gemspec"]
45
47
  spec.bindir = "bin"
46
48
  spec.executables = ["letsdo"]
47
49
  spec.require_paths = ["lib"]
data/lib/letsdo/agent.rb CHANGED
@@ -2,40 +2,53 @@
2
2
 
3
3
  module Letsdo
4
4
  # A single agent run: reads the prompt from agents/<name>.md by agent name
5
- # (falling back to the built-in default prompt when the file is missing)
6
- # and runs pi with that prompt. Returns the pi exit code. There are no
7
- # unknown agents — every name runs, with the file prompt when present and
8
- # with Letsdo::DefaultPrompt::TEXT otherwise.
5
+ # (falling back to the built-in default prompt when the file is missing),
6
+ # prepends the agent's identity (name + backlog assignee handle, TASK-85)
7
+ # and delegates to the injected backend_factory. Returns the backend exit
8
+ # code. There are no unknown agents -- every name runs, with the file
9
+ # prompt when present and with Letsdo::DefaultPrompt::TEXT otherwise.
9
10
  #
10
11
  # This is the logic of one bin/agent run: a prompt store + an output
11
- # streamer + a pi runner. The orchestrator (a loop while tasks exist)
12
- # lives in Letsdo::Loop.
12
+ # streamer + a backend factory. The orchestrator (a loop while tasks
13
+ # exist) lives in Letsdo::Loop.
13
14
  class Agent
14
15
  # @param name [String] agent name (agents/<name>.md)
15
16
  # @param root [String] project root (agents/ lives there)
16
- # @param flags [Array<String>] extra pi flags
17
+ # @param backend_factory [Proc] callable(prompt:, streamer:, model:) -> backend
18
+ # The factory creates a fresh backend for each run. The prompt
19
+ # is read from PromptStore before the call; model comes from the
20
+ # agent's front-matter config (nil when absent). All flag/
21
+ # command/env wiring is the factory's job -- Agent keeps no pi
22
+ # vocabulary.
17
23
  # @param streamer [OutputStreamer] where to print output (by default
18
24
  # the real stdout/stderr)
19
- # @param command [String] the pi command (overridable for tests)
20
- def initialize(name:, root:, flags: [], streamer: nil, command: PiRunner::COMMAND)
25
+ # @param handle [String, nil] the agent's backlog assignee
26
+ # (Config#assignee_handle). Defaults to the bare <name>
27
+ # (TASK-96); the launcher passes the resolved assignee so the
28
+ # injected identity always matches what the backlog tasks are
29
+ # assigned to.
30
+ def initialize(name:, root:, backend_factory:, streamer: nil, handle: nil)
21
31
  @name = name
22
32
  @root = root
23
- @flags = flags
33
+ @backend_factory = backend_factory
24
34
  @streamer = streamer || OutputStreamer.new
25
- @command = command
35
+ @handle = handle || name.to_s
26
36
  end
27
37
 
28
- # The runner of the last/current run — lets the orchestrator terminate
29
- # a running pi when the loop is stopped.
30
- attr_reader :runner
38
+ # The backend of the last/current run -- lets the orchestrator
39
+ # terminate a running backend when the loop is stopped.
40
+ attr_reader :backend
31
41
 
32
42
  # Runs the agent once.
33
43
  #
34
- # @return [Integer] pi exit code
44
+ # @return [Integer] backend exit code
35
45
  def run
36
46
  prompt = prompt_store.read(@name) || Letsdo::DefaultPrompt::TEXT
37
- @runner = PiRunner.new(prompt: prompt, flags: @flags, streamer: @streamer, command: @command)
38
- @runner.run
47
+ prompt = Letsdo::AgentIdentity.inject(prompt, name: @name, handle: @handle)
48
+ model = prompt_store.config(@name)[:model]
49
+ @backend = @backend_factory.call(prompt: prompt, streamer: @streamer,
50
+ model: model)
51
+ @backend.run
39
52
  end
40
53
 
41
54
  private
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # The identity preamble letsdo injects into every agent's system prompt on
5
+ # launch (TASK-85). The launcher already knows the agent name and its
6
+ # backlog assignee; the prompt template may not. Injecting the two facts
7
+ # unconditionally keeps every agent aware of who it is and which tasks
8
+ # are its own — independent of the template content, for the built-in
9
+ # default prompt and for every custom agents/<name>.md alike.
10
+ #
11
+ # The assignee is Config#assignee_handle — the bare name (TASK-96); the
12
+ # '@' prefix is prose notation only, so the identity an agent reads both
13
+ # presents and instructs the bare tracker value.
14
+ module AgentIdentity
15
+ # The identity block prepended to a prompt. It ends with a blank line so
16
+ # the original prompt keeps its own heading structure.
17
+ #
18
+ # @param name [String] agent name (also the backlog assignee name)
19
+ # @param handle [String] backlog assignee (Config#assignee_handle:
20
+ # the bare name)
21
+ # @return [String]
22
+ def self.preamble(name:, handle:)
23
+ <<~TEXT
24
+ # Your identity
25
+
26
+ You are the agent `#{name}`. Your backlog assignee is `#{handle}`:
27
+ the tasks assigned to `#{handle}` are yours to work. Identify
28
+ yourself as this agent and store `#{handle}` — the bare name, the
29
+ '@' prefix ('@#{handle}') is prose notation only — as the assignee
30
+ in every tracked artifact you write.
31
+
32
+ TEXT
33
+ end
34
+
35
+ # Prepends the identity block to a prompt, leaving the prompt itself
36
+ # untouched.
37
+ #
38
+ # @param prompt [String] the agent's system prompt (file or default)
39
+ # @param name [String] agent name
40
+ # @param handle [String] backlog assignee (bare name)
41
+ # @return [String] prompt with the identity block at the very top
42
+ def self.inject(prompt, name:, handle:)
43
+ "#{preamble(name: name, handle: handle)}#{prompt}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Assignee mismatch reporting for AgentLoop (TASK-96).
5
+ #
6
+ # The provider matches the configured assignee after normalization
7
+ # (leading '@', whitespace, case), so legacy-stored values keep the loop
8
+ # running — but every exact-match consumer (backlog --assignee, the
9
+ # agent's own queries) misses them. This concern surfaces that deviation
10
+ # as a once-per-run stderr hint instead of letting it pass silently.
11
+ module AgentLoopAssigneeHints
12
+ private
13
+
14
+ # Warns once per run about assignees that matched only after
15
+ # normalization. Providers without #assignee_variants (plain lambdas
16
+ # in tests) are silently skipped.
17
+ def report_assignee_variants
18
+ variants = provider_assignee_variants
19
+ return if @variants_hinted || variants.empty?
20
+
21
+ @variants_hinted = true
22
+ quoted = variants.map { |value| "'#{value}'" }.join(', ')
23
+ @stderr.puts("letsdo: warning: tasks store assignee(s) #{quoted} instead of the handle " \
24
+ "'#{@handle}' - matched after normalization; store '#{@handle}' on the tasks " \
25
+ '(or set AGENT_ASSIGNEE_HANDLE) so exact-match filters find them')
26
+ end
27
+
28
+ def provider_assignee_variants
29
+ return [] unless @task_provider.respond_to?(:assignee_variants)
30
+
31
+ @task_provider.assignee_variants.to_a
32
+ end
33
+ end
34
+ end
@@ -2,31 +2,59 @@
2
2
 
3
3
  module Letsdo
4
4
  # Provider reporting and per-task run wrapping for AgentLoop.
5
+ #
6
+ # Retry coordination happens here: the provider batch is reconciled
7
+ # against the previous attempt (TASK-68), tasks in retry cooldown (or
8
+ # given up) are filtered out of the batch, and each run records its
9
+ # outcome for the next reconciliation.
5
10
  module AgentLoopTasks
6
11
  private
7
12
 
8
13
  def wrapped_provider
9
14
  lambda do
10
15
  tasks = @task_provider.call
11
- @metrics&.provider_result(tasks&.length)
12
- report_provider(tasks)
13
- tasks
16
+ return provider_unavailable if tasks.nil?
17
+
18
+ report_assignee_variants
19
+ reconcile_attempts(tasks)
20
+ filtered = reject_cooled_down(tasks)
21
+ @metrics&.provider_result(filtered.length)
22
+ report_provider(filtered, raw: tasks.length)
23
+ filtered
14
24
  end
15
25
  end
16
26
 
17
- def report_provider(tasks)
18
- if tasks.nil?
19
- debug('provider: backlog unavailable')
20
- @stderr.puts("letsdo: backlog unavailable, retrying in #{@wait_seconds}s")
21
- elsif tasks.empty?
27
+ def report_provider(tasks, raw: nil)
28
+ if tasks.empty?
29
+ report_empty(raw)
30
+ return
31
+ end
32
+
33
+ debug("provider: #{tasks.length} open task(s)")
34
+ @stderr.puts("letsdo: #{@name} has #{tasks.length} open task(s)")
35
+ return unless raw && raw > tasks.length
36
+
37
+ @stderr.puts("letsdo: #{raw - tasks.length} open task(s) in retry backoff")
38
+ end
39
+
40
+ def report_empty(raw)
41
+ if raw&.positive?
42
+ debug("provider: #{raw} open task(s) in retry backoff")
43
+ @stderr.puts("letsdo: no runnable tasks for #{@name} (retry backoff), " \
44
+ "retrying in #{@wait_seconds}s")
45
+ else
22
46
  debug('provider: no open tasks')
23
47
  @stderr.puts("letsdo: no open tasks for #{@name}, retrying in #{@wait_seconds}s")
24
- else
25
- debug("provider: #{tasks.length} open task(s)")
26
- @stderr.puts("letsdo: #{@name} has #{tasks.length} open task(s)")
27
48
  end
28
49
  end
29
50
 
51
+ def provider_unavailable
52
+ debug('provider: backlog unavailable')
53
+ @stderr.puts(unavailable_message)
54
+ @metrics&.provider_result(nil)
55
+ nil
56
+ end
57
+
30
58
  def wrapped_run
31
59
  lambda do |task|
32
60
  wait_while_paused
@@ -42,21 +70,68 @@ module Letsdo
42
70
  code = @run_one.call(task)
43
71
  debug("agent run exit #{code}")
44
72
  @stderr.puts("letsdo: #{@name} exited with code #{code}") if code != 0
73
+ @last_attempted[task_key(task)] = code
45
74
  ensure
46
- @metrics&.run_finished
75
+ @metrics&.run_finished(code)
47
76
  end
48
77
 
49
78
  def task_label(task)
50
- id = task.respond_to?(:[]) ? task['id'] : nil
79
+ id = task.respond_to?(:id) ? task.id : nil
51
80
  return id.to_s unless id.nil? || id.to_s.empty?
52
81
 
53
82
  task.to_s
54
83
  end
55
84
 
85
+ def task_key(task)
86
+ task_label(task)
87
+ end
88
+
56
89
  def wait_while_paused
57
90
  return unless @pause_gate
58
91
 
59
92
  @sleeper.call(AgentLoop::PAUSE_POLL_SECONDS) while @pause_gate.paused?
60
93
  end
94
+
95
+ # Compare tasks from the previous run batch with the fresh provider
96
+ # result: tasks still open are failures, tasks gone are successes.
97
+ # Tasks already in retry cooldown (or given up) were skipped and are
98
+ # NOT re-recorded as failed.
99
+ def reconcile_attempts(tasks)
100
+ return unless @last_attempted
101
+
102
+ fresh = tasks.to_h { |t| [task_key(t), true] }
103
+ @last_attempted.each_key { |key| reconcile_key(key, fresh) }
104
+ @last_attempted.clear
105
+ end
106
+
107
+ def reconcile_key(key, fresh)
108
+ if fresh.key?(key)
109
+ @retry_policy.record_failure(key)
110
+ log_give_up(key) if @retry_policy.failures(key) >= @retry_policy.max_retries
111
+ else
112
+ @retry_policy.record_success(key)
113
+ end
114
+ end
115
+
116
+ # Filters tasks that must not be attempted this batch (retry cooldown
117
+ # or give-up). A filtered task is also dropped from the attempted-set so
118
+ # the next reconciliation does not re-record it as failed.
119
+ def reject_cooled_down(tasks)
120
+ return tasks if tasks.nil? || tasks.empty?
121
+
122
+ tasks.reject do |task|
123
+ key = task_key(task)
124
+ cooled = @retry_policy.cooldown?(key) || @retry_policy.gave_up?(key)
125
+ @last_attempted.delete(key) if cooled
126
+ cooled
127
+ end
128
+ end
129
+
130
+ def log_give_up(task_key)
131
+ message = "letsdo: giving up on #{task_key} after " \
132
+ "#{@retry_policy.failures(task_key)} failed runs - " \
133
+ 'task stays open, next session will retry it'
134
+ @stderr.puts(message)
135
+ end
61
136
  end
62
137
  end