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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'doctor/checks'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
# `letsdo doctor`: one line per environment check with a status tag and an
|
|
7
|
+
# actionable hint for every FAIL/WARN (TASK-71). Exits 0 when nothing
|
|
8
|
+
# FAILs and 1 otherwise, so it can gate scripts.
|
|
9
|
+
#
|
|
10
|
+
# Every input comes from Letsdo::Config (env policy) plus the injected
|
|
11
|
+
# streams, so the command is fully testable without touching process ENV.
|
|
12
|
+
class Doctor
|
|
13
|
+
include DoctorChecks
|
|
14
|
+
|
|
15
|
+
def initialize(config:, stdout:, stdin: $stdin)
|
|
16
|
+
@config = config
|
|
17
|
+
@stdout = stdout
|
|
18
|
+
@stdin = stdin
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Prints the report and returns the process exit code: 1 when at least
|
|
22
|
+
# one check FAILed, 0 otherwise.
|
|
23
|
+
def run
|
|
24
|
+
results = checks
|
|
25
|
+
results.each { |check| print_result(check) }
|
|
26
|
+
results.any? { |check| check[:status] == 'FAIL' } ? 1 : 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def print_result(check)
|
|
32
|
+
line = "[#{tag(check[:status])}] #{check[:message]}"
|
|
33
|
+
line += " - #{check[:hint]}" if check[:hint]
|
|
34
|
+
@stdout.puts(line)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# All tags render six characters wide, so the columns line up.
|
|
38
|
+
def tag(status)
|
|
39
|
+
status == 'OK' ? ' OK ' : status
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Shared rendering of a duration in seconds as "4m 12s" / "12s" / "0s".
|
|
5
|
+
#
|
|
6
|
+
# The stop summary (TASK-69) and the opt-in per-task elapsed write-back
|
|
7
|
+
# (TASK-70) both use this one formatter, so the number a session reports
|
|
8
|
+
# and the number written into the task record can never drift apart.
|
|
9
|
+
module Duration
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param seconds [Numeric] duration in seconds (negative values clamp to 0)
|
|
13
|
+
# @return [String] human-readable duration, e.g. "4m 12s"
|
|
14
|
+
def format(seconds)
|
|
15
|
+
total = [seconds.to_f, 0.0].max.round
|
|
16
|
+
minutes, secs = total.divmod(60)
|
|
17
|
+
return '0s' if minutes.zero? && secs.zero?
|
|
18
|
+
return "#{secs}s" if minutes.zero?
|
|
19
|
+
|
|
20
|
+
"#{minutes}m #{secs}s"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/letsdo/errors.rb
CHANGED
|
@@ -9,4 +9,11 @@ module Letsdo
|
|
|
9
9
|
# cleanly. Not a StandardError — nothing rescues it accidentally.
|
|
10
10
|
class Stopped < StandardError
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
# Raised when the AI backend process cannot be started (missing or
|
|
14
|
+
# non-executable binary). This is a configuration error, not a loop
|
|
15
|
+
# problem: the CLI catches it, prints a clear message and exits 2 instead
|
|
16
|
+
# of dying with a Ruby backtrace.
|
|
17
|
+
class BackendUnavailableError < Error
|
|
18
|
+
end
|
|
12
19
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Metrics
|
|
5
|
+
# A small fan-out facade that forwards the loop-driver events to a
|
|
6
|
+
# list of observers. Used in TUI mode so the session recorder and
|
|
7
|
+
# the header metrics facade both receive the same callbacks from
|
|
8
|
+
# Letsdo::AgentLoop.
|
|
9
|
+
#
|
|
10
|
+
# Each observer must respond to provider_result, run_started and
|
|
11
|
+
# run_finished (the same interface Tui::Metrics and SessionRecorder
|
|
12
|
+
# implement). The exit code is forwarded to every observer so the
|
|
13
|
+
# session recorder can classify outcomes; Tui::Metrics ignores it.
|
|
14
|
+
class Fanout
|
|
15
|
+
# @param observers [Array<Object>] each must implement the three
|
|
16
|
+
# callback methods (provider_result, run_started, run_finished)
|
|
17
|
+
def initialize(*observers)
|
|
18
|
+
@observers = observers
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Forwards the provider open-task count to every observer.
|
|
22
|
+
#
|
|
23
|
+
# @param count [Integer, nil] number of open tasks; nil = the
|
|
24
|
+
# backlog state is unreadable
|
|
25
|
+
# @return [void]
|
|
26
|
+
def provider_result(count)
|
|
27
|
+
@observers.each { |observer| observer.provider_result(count) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Forwards a run-started event (task label) to every observer.
|
|
31
|
+
#
|
|
32
|
+
# @param task [String] task label
|
|
33
|
+
# @return [void]
|
|
34
|
+
def run_started(task)
|
|
35
|
+
@observers.each { |observer| observer.run_started(task) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Forwards a run-finished event (exit code) to every observer.
|
|
39
|
+
#
|
|
40
|
+
# @param exit_code [Integer, nil] the run exit code
|
|
41
|
+
# @return [void]
|
|
42
|
+
def run_finished(exit_code = nil)
|
|
43
|
+
@observers.each { |observer| observer.run_finished(exit_code) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/letsdo/prompt_store.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
4
5
|
|
|
5
6
|
module Letsdo
|
|
6
7
|
# Access to agent prompts: the agents/<name>.md directory in the project
|
|
@@ -40,7 +41,22 @@ module Letsdo
|
|
|
40
41
|
path = agent_path(name)
|
|
41
42
|
return nil unless File.file?(path)
|
|
42
43
|
|
|
43
|
-
File.read(path)
|
|
44
|
+
content = File.read(path)
|
|
45
|
+
self.class.strip_front_matter(content)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parsed launch configuration for an agent.
|
|
49
|
+
#
|
|
50
|
+
# Returns a hash with symbolised keys. An empty hash when the file
|
|
51
|
+
# is missing or has no YAML front matter.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String] agent name
|
|
54
|
+
# @return [Hash{Symbol => Object}]
|
|
55
|
+
def config(name)
|
|
56
|
+
path = agent_path(name)
|
|
57
|
+
return {} unless File.file?(path)
|
|
58
|
+
|
|
59
|
+
self.class.parse_front_matter(File.read(path))
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
# Absolute path of an agent's prompt file, whether or not it exists.
|
|
@@ -69,6 +85,37 @@ module Letsdo
|
|
|
69
85
|
true
|
|
70
86
|
end
|
|
71
87
|
|
|
88
|
+
# Strips an optional YAML front matter block delimited by `---` at
|
|
89
|
+
# the very start of a prompt file. Returns the remaining content.
|
|
90
|
+
#
|
|
91
|
+
# @param content [String] full file content
|
|
92
|
+
# @return [String] content without front matter
|
|
93
|
+
def self.strip_front_matter(content)
|
|
94
|
+
content.sub(/\A---\n.+?\n---\n?/m, '')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
FRONT_MATTER = /\A---\n?\n(.+?)\n?\n---\n?\n/m
|
|
98
|
+
|
|
99
|
+
# Parses an optional YAML front matter block delimited by `---` at
|
|
100
|
+
# the very start of a prompt file. Returns the keys symbolised;
|
|
101
|
+
# returns an empty hash when there is no front matter or it is
|
|
102
|
+
# invalid.
|
|
103
|
+
#
|
|
104
|
+
# @param content [String] full file content
|
|
105
|
+
# @return [Hash{Symbol => Object}]
|
|
106
|
+
def self.parse_front_matter(content)
|
|
107
|
+
match = content.match(FRONT_MATTER)
|
|
108
|
+
return {} unless match
|
|
109
|
+
|
|
110
|
+
symbolize_keys(YAML.safe_load(match[1], permitted_classes: []))
|
|
111
|
+
rescue StandardError
|
|
112
|
+
{}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.symbolize_keys(parsed)
|
|
116
|
+
parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : {}
|
|
117
|
+
end
|
|
118
|
+
|
|
72
119
|
private
|
|
73
120
|
|
|
74
121
|
def agents_dir
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require_relative 'task'
|
|
6
|
+
|
|
7
|
+
module Letsdo
|
|
8
|
+
module Providers
|
|
9
|
+
# Task provider for Letsdo::Loop backed by the real backlog CLI:
|
|
10
|
+
#
|
|
11
|
+
# backlog task list --exclude-status Done --ready --sort priority --json
|
|
12
|
+
#
|
|
13
|
+
# Returns the runnable tasks assigned to the handle in the
|
|
14
|
+
# authoritative run order (an Array of Letsdo::Providers::Task), or nil
|
|
15
|
+
# when the backlog state is unreadable — the CLI is not on PATH, failed,
|
|
16
|
+
# or its output is not the expected JSON. The loop treats nil as "pause
|
|
17
|
+
# and retry, do not run the agent".
|
|
18
|
+
#
|
|
19
|
+
# The assignee filter is applied in Ruby, not via the CLI's --assignee:
|
|
20
|
+
# the CLI matches the assignee by exact string, so a task stored as
|
|
21
|
+
# '@developer' (legacy notation) would never reach a '--assignee
|
|
22
|
+
# developer' query and the loop would silently starve (TASK-96). The
|
|
23
|
+
# handle match here tolerates the leading '@', surrounding whitespace
|
|
24
|
+
# and case, so both notations keep working; the literal values that
|
|
25
|
+
# matched only after normalization are exposed by #assignee_variants
|
|
26
|
+
# for the doctor WARN and the once-per-run loop hint.
|
|
27
|
+
#
|
|
28
|
+
# The command runs in the project root (cwd), where the backlog CLI finds
|
|
29
|
+
# the backlog/ folder — the same context as a single agent run.
|
|
30
|
+
class Backlog
|
|
31
|
+
# Keys of the normalized Letsdo::Providers::Task shape. The backlog CLI
|
|
32
|
+
# emits more (reporter, parentTaskId, createdAt, updatedAt); the adapter
|
|
33
|
+
# projects onto these and ignores the rest so a growing CLI schema
|
|
34
|
+
# cannot crash the loop. Beyond the identity fields, the shape keeps
|
|
35
|
+
# what deterministic selection needs: ordinal (the stable tie-break) and
|
|
36
|
+
# type/labels/milestone (optional capability routing).
|
|
37
|
+
TASK_FIELDS = %w[id title status priority assignees ordinal type labels milestone].freeze
|
|
38
|
+
|
|
39
|
+
# Sort ranks for the deterministic batch order. Known priorities are
|
|
40
|
+
# compared case-insensitively; anything else (nil or a new label) ranks
|
|
41
|
+
# last instead of crashing the comparator.
|
|
42
|
+
PRIORITY_RANKS = { 'high' => 0, 'medium' => 1, 'low' => 2 }.freeze
|
|
43
|
+
UNKNOWN_RANK = PRIORITY_RANKS.size
|
|
44
|
+
|
|
45
|
+
# @param handle [String, nil] assignee name to filter by (canonical:
|
|
46
|
+
# 'developer'; a legacy '@developer' still matches the same
|
|
47
|
+
# tasks); nil disables the filter (doctor inspects all open
|
|
48
|
+
# tasks)
|
|
49
|
+
# @param command [String] backlog CLI command (overridable for tests)
|
|
50
|
+
# @param cwd [String, nil] project root for the CLI; nil = inherit cwd
|
|
51
|
+
# @param env [Hash, nil] environment for the CLI child (nil = inherit
|
|
52
|
+
# the process environment; injected in tests to control the
|
|
53
|
+
# fake backlog scenarios)
|
|
54
|
+
def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
|
|
55
|
+
@handle = handle
|
|
56
|
+
@command = command
|
|
57
|
+
@cwd = cwd
|
|
58
|
+
@env = env
|
|
59
|
+
@assignee_variants = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Literal assignee values from the last successful batch that matched
|
|
63
|
+
# the handle only after normalization (e.g. '@developer' for the
|
|
64
|
+
# handle 'developer') — the mismatch signature the doctor check and
|
|
65
|
+
# the loop hint surface. Empty when the handle matched exactly, no
|
|
66
|
+
# handle is configured, or the last call failed.
|
|
67
|
+
def assignee_variants
|
|
68
|
+
@assignee_variants.dup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Reads the runnable open tasks once, in deterministic run order.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Task>, nil] runnable open tasks; nil when the backlog is
|
|
74
|
+
# unreadable; empty array when there are no open tasks
|
|
75
|
+
def call
|
|
76
|
+
@assignee_variants = []
|
|
77
|
+
args = @env ? [@env, *command_line] : command_line
|
|
78
|
+
out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
|
|
79
|
+
return nil unless status.success?
|
|
80
|
+
|
|
81
|
+
tasks = JSON.parse(out)['tasks']
|
|
82
|
+
return nil unless tasks.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
sort(select_by_assignee(tasks.map { |raw| normalize(raw) }))
|
|
85
|
+
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Deterministic run order: In Progress first (resume before starting),
|
|
92
|
+
# then priority High > Medium > Low, then ordinal ascending, then id
|
|
93
|
+
# ascending. The CLI's --sort priority does not put In Progress first
|
|
94
|
+
# and cannot express the full tie-break, so the adapter sorts the batch
|
|
95
|
+
# itself. The original index is the final tie-break, so equal keys keep
|
|
96
|
+
# the provider's (deterministic) order.
|
|
97
|
+
def sort(tasks)
|
|
98
|
+
tasks.each_with_index.sort_by do |task, index|
|
|
99
|
+
[in_progress_rank(task), priority_rank(task.priority),
|
|
100
|
+
ordinal_rank(task.ordinal), id_rank(task.id), index]
|
|
101
|
+
end.map(&:first)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Assignee filtering happens here, not in the CLI's --assignee: the
|
|
105
|
+
# CLI matches by exact string (TASK-96). The stored value may be the
|
|
106
|
+
# bare canonical name or the legacy '@'-prefixed one — both match.
|
|
107
|
+
# A nil handle (doctor) keeps every task and never records variants.
|
|
108
|
+
def select_by_assignee(tasks)
|
|
109
|
+
return tasks unless @handle
|
|
110
|
+
|
|
111
|
+
selected = tasks.select { |task| match_assignees(task) }
|
|
112
|
+
@assignee_variants = @assignee_variants.uniq.sort
|
|
113
|
+
selected
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def match_assignees(task)
|
|
117
|
+
task.assignees.any? do |value|
|
|
118
|
+
next false if normalize_assignee(value) != normalized_handle
|
|
119
|
+
|
|
120
|
+
@assignee_variants << value unless value == @handle
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalized_handle
|
|
126
|
+
normalize_assignee(@handle)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# '@Dev', ' dev ' and 'dev' are the same handle; only the leading '@'
|
|
130
|
+
# is stripped, so '@dev team' stays distinct from 'devteam'.
|
|
131
|
+
def normalize_assignee(value)
|
|
132
|
+
value.to_s.strip.sub(/\A@/, '').downcase
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def in_progress_rank(task)
|
|
136
|
+
task.status.to_s.casecmp('In Progress').zero? ? 0 : 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def priority_rank(priority)
|
|
140
|
+
PRIORITY_RANKS[priority.to_s.downcase] || UNKNOWN_RANK
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Unknown/nil ordinals sort last (their id still orders them).
|
|
144
|
+
def ordinal_rank(ordinal)
|
|
145
|
+
value = Integer(ordinal, exception: false)
|
|
146
|
+
value.nil? ? [1, 0] : [0, value]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# An absent id (malformed task) sorts after identified ones.
|
|
150
|
+
def id_rank(id)
|
|
151
|
+
[id.to_s.empty? ? 1 : 0, id.to_s]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Projects one raw CLI task onto the normalized shape. Unknown keys are
|
|
155
|
+
# ignored (the CLI schema grows over time); a non-Hash entry means the
|
|
156
|
+
# payload is not the expected schema and raises TypeError, which #call
|
|
157
|
+
# turns into nil.
|
|
158
|
+
def normalize(raw)
|
|
159
|
+
raise TypeError, "task is not an object: #{raw.class}" unless raw.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
Task.new(**TASK_FIELDS.to_h { |field| [field.to_sym, raw[field]] })
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# [command..., task, list, --exclude-status Done, --ready,
|
|
165
|
+
# --sort priority, --json] — no --assignee: the filter runs in Ruby
|
|
166
|
+
# (select_by_assignee), so exact-match deviations in the stored
|
|
167
|
+
# assignees cannot starve the loop (TASK-96).
|
|
168
|
+
def command_line
|
|
169
|
+
[
|
|
170
|
+
*Shellwords.split(@command),
|
|
171
|
+
'task', 'list',
|
|
172
|
+
'--exclude-status', 'Done',
|
|
173
|
+
'--ready',
|
|
174
|
+
'--sort', 'priority',
|
|
175
|
+
'--json'
|
|
176
|
+
]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Providers
|
|
5
|
+
# Immutable value object representing a normalized task.
|
|
6
|
+
#
|
|
7
|
+
# Adapters map tracker-specific JSON onto this shape. Business logic
|
|
8
|
+
# consumes only this interface, not the raw tracker schema.
|
|
9
|
+
#
|
|
10
|
+
# The constructor is strict on purpose: adapters project the tracker
|
|
11
|
+
# payload onto these keywords instead of forwarding it, so extra tracker
|
|
12
|
+
# fields never reach here and a wrong key is caught as a programming
|
|
13
|
+
# error instead of silently ignored.
|
|
14
|
+
#
|
|
15
|
+
# Besides the identity/routing fields, the shape carries the fields a
|
|
16
|
+
# deterministic selector needs: +ordinal+ (the stable tie-break), and
|
|
17
|
+
# +type+/+labels+/+milestone+ for optional capability routing.
|
|
18
|
+
class Task
|
|
19
|
+
attr_reader :id, :title, :status, :priority, :assignees,
|
|
20
|
+
:ordinal, :type, :labels, :milestone
|
|
21
|
+
|
|
22
|
+
# rubocop:disable Metrics/ParameterLists -- the keywords are the
|
|
23
|
+
# normalized shape's explicit contract; adapters project tracker
|
|
24
|
+
# payloads onto exactly these and the strict list rejects a typo.
|
|
25
|
+
def initialize(id:, title: nil, status: nil, priority: nil, assignees: [],
|
|
26
|
+
ordinal: nil, type: nil, labels: [], milestone: nil)
|
|
27
|
+
@id = id
|
|
28
|
+
@title = title
|
|
29
|
+
@status = status
|
|
30
|
+
@priority = priority
|
|
31
|
+
@assignees = Array(assignees)
|
|
32
|
+
@ordinal = ordinal
|
|
33
|
+
@type = type
|
|
34
|
+
@labels = Array(labels)
|
|
35
|
+
@milestone = milestone
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
# rubocop:enable Metrics/ParameterLists
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
identifier = id.to_s
|
|
42
|
+
return identifier unless identifier.empty?
|
|
43
|
+
|
|
44
|
+
title.to_s
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Pure policy object for per-task retry, exponential backoff and give-up.
|
|
5
|
+
#
|
|
6
|
+
# Letsdo::Loop stays generic and untouched; this object is wired at the
|
|
7
|
+
# AgentLoop level. The loop filters attempt batches through #cooldown? and
|
|
8
|
+
# records outcomes with #record_failure / #record_success.
|
|
9
|
+
#
|
|
10
|
+
# State is per-task (keyed by task id) and in-memory only: a fresh letsdo
|
|
11
|
+
# session starts with zero failures, so a broken task yields instead of
|
|
12
|
+
# hammering, and a temporarily-failing task is retried next session.
|
|
13
|
+
#
|
|
14
|
+
# The clock is injectable (monotonic, as in Tui::Metrics) for
|
|
15
|
+
# deterministic tests.
|
|
16
|
+
class RetryPolicy
|
|
17
|
+
DEFAULT_MAX_RETRIES = 3
|
|
18
|
+
DEFAULT_CAP = 300.0
|
|
19
|
+
DEFAULT_BASE = 10.0
|
|
20
|
+
|
|
21
|
+
# Tunables exposed for the reconciliation logic (give-up checks).
|
|
22
|
+
attr_reader :max_retries
|
|
23
|
+
|
|
24
|
+
# @param base [Float] base backoff seconds; default wait_seconds (10.0)
|
|
25
|
+
# @param cap [Float] maximum backoff seconds (default 300)
|
|
26
|
+
# @param max_retries [Integer] give-up after N consecutive failures
|
|
27
|
+
# @param clock [Proc] callable → monotonic epoch seconds
|
|
28
|
+
def initialize(base: nil, cap: nil, max_retries: nil, clock: nil)
|
|
29
|
+
@base = base || DEFAULT_BASE
|
|
30
|
+
@cap = cap || DEFAULT_CAP
|
|
31
|
+
@max_retries = max_retries || DEFAULT_MAX_RETRIES
|
|
32
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
33
|
+
@state = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Consecutive failures recorded for task_key (0 when none).
|
|
37
|
+
def failures(task_key)
|
|
38
|
+
info = @state[task_key]
|
|
39
|
+
info ? info[:failures] : 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Records a failed run: bumps the failure count and (re)arms the cooldown
|
|
43
|
+
# deadline for exponential backoff.
|
|
44
|
+
def record_failure(task_key)
|
|
45
|
+
info = (@state[task_key] ||= { failures: 0, cool_until: 0 })
|
|
46
|
+
info[:failures] += 1
|
|
47
|
+
info[:cool_until] = cooldown_deadline(info[:failures])
|
|
48
|
+
info
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Records a successful run: clears the per-task retry state.
|
|
52
|
+
def record_success(task_key)
|
|
53
|
+
@state.delete(task_key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Clears all per-task retry state (fresh session).
|
|
57
|
+
def reset
|
|
58
|
+
@state.clear
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether the task is cooling down and must be excluded from attempt
|
|
62
|
+
# batches. Tasks skipped by cooldown are NOT re-recorded as failed.
|
|
63
|
+
def cooldown?(task_key, now = nil)
|
|
64
|
+
info = @state[task_key]
|
|
65
|
+
return false unless info
|
|
66
|
+
|
|
67
|
+
cool_until(info) > current_time(now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whether the task has hit the give-up limit: it must not be attempted
|
|
71
|
+
# again for the rest of the session.
|
|
72
|
+
def gave_up?(task_key)
|
|
73
|
+
failures(task_key) >= @max_retries
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Earliest cooldown deadline across all tasks (for a bounded wait); nil
|
|
77
|
+
# when nothing is cooling down.
|
|
78
|
+
def earliest_cooldown(now = nil)
|
|
79
|
+
current = current_time(now)
|
|
80
|
+
deadlines = @state.values.map { |info| info[:cool_until] }.select { |d| d > current }
|
|
81
|
+
deadlines.empty? ? nil : deadlines.min
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def current_time(now)
|
|
87
|
+
now.nil? ? @clock.call : now
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cooldown_deadline(failure_count)
|
|
91
|
+
@clock.call + [@base * (2**(failure_count - 1)), @cap].min
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cool_until(info)
|
|
95
|
+
info[:cool_until]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Letsdo
|
|
7
|
+
class SessionRecorder
|
|
8
|
+
# Writes the optional JSON Lines metrics stream for a session recorder:
|
|
9
|
+
# a session_start line on construction, one run_finished line per closed
|
|
10
|
+
# run and a session_stop line at the end. With a nil IO the writer is a
|
|
11
|
+
# no-op, so callers need no nil checks.
|
|
12
|
+
class JsonlWriter
|
|
13
|
+
# @param io [IO, nil] append-only target; nil disables the writer
|
|
14
|
+
# @param name [String] agent name
|
|
15
|
+
# @param handle [String] assignee handle
|
|
16
|
+
# @param wall_clock [Proc] -> ISO8601 UTC timestamp string
|
|
17
|
+
def initialize(io, name:, handle:, wall_clock:)
|
|
18
|
+
@io = io
|
|
19
|
+
@name = name
|
|
20
|
+
@handle = handle
|
|
21
|
+
@wall_clock = wall_clock
|
|
22
|
+
session_start if enabled?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether an output target is configured
|
|
26
|
+
def enabled?
|
|
27
|
+
!@io.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Writes the opening session_start line (no-op when disabled).
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
def session_start
|
|
34
|
+
write(session_start_payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param run [SessionRecorder::Run] a closed run record
|
|
38
|
+
# @return [void]
|
|
39
|
+
def run_finished(run)
|
|
40
|
+
write(
|
|
41
|
+
'event' => 'run_finished', 'task' => run.task_id, 'exit' => run.exit_code,
|
|
42
|
+
'outcome' => run.outcome.to_s, 'elapsed_s' => run.elapsed_s,
|
|
43
|
+
'ts' => @wall_clock.call
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param summary [SessionRecorder::Summary] the final aggregate
|
|
48
|
+
# @return [void]
|
|
49
|
+
def session_stop(summary)
|
|
50
|
+
write(
|
|
51
|
+
'event' => 'session_stop', 'agent' => @name, 'handle' => @handle,
|
|
52
|
+
'ts' => @wall_clock.call, 'done' => summary.done, 'failed' => summary.failed,
|
|
53
|
+
'interrupted' => summary.interrupted, 'left' => summary.left,
|
|
54
|
+
'session_s' => summary.session_s, 'runs_s' => summary.active_s
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def session_start_payload
|
|
61
|
+
{ 'event' => 'session_start', 'agent' => @name, 'handle' => @handle,
|
|
62
|
+
'ts' => @wall_clock.call }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write(payload)
|
|
66
|
+
return unless enabled?
|
|
67
|
+
|
|
68
|
+
@io.puts(JSON.generate(payload))
|
|
69
|
+
@io.flush if @io.respond_to?(:flush)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|